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

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
@@ -28,6 +28,7 @@ import {
28
28
  HardwareErrorCode,
29
29
  createDeferred,
30
30
  isOnekeyBluetoothDevice,
31
+ isPro2FindMyAdvertisementName,
31
32
  } from '@onekeyfe/hd-shared';
32
33
 
33
34
  import { getConnectedDeviceIds, onDeviceBondState, pairDevice } from './BleManager';
@@ -60,6 +61,7 @@ const FIRMWARE_UPLOAD_WRITE_BURST_SIZE = Platform.OS === 'ios' ? 4 : 5;
60
61
  const FIRMWARE_UPLOAD_WRITE_PAUSE_MS = Platform.OS === 'ios' ? 8 : 10;
61
62
  const FIRMWARE_UPLOAD_WRITE_FLUSH_DELAY_MS = Platform.OS === 'ios' ? 24 : 30;
62
63
  const FIRMWARE_UPLOAD_WRITE_MAX_RETRIES = 8;
64
+ const IOS_PROTOCOL_V2_CONTROL_WRITE_DELAY_MS = 5;
63
65
  const ANDROID_FIRMWARE_UPLOAD_PACKET_LENGTH = 192;
64
66
  const FIRMWARE_UPLOAD_WRITE_PACKET_CAPACITY =
65
67
  Platform.OS === 'ios' ? IOS_PACKET_LENGTH : ANDROID_FIRMWARE_UPLOAD_PACKET_LENGTH;
@@ -71,6 +73,33 @@ type ResolvedBleCharacteristics = {
71
73
  notifyCharacteristic: Characteristic;
72
74
  };
73
75
 
76
+ const isAsciiWhitespace = (code: number) =>
77
+ code === 0x09 ||
78
+ code === 0x0a ||
79
+ code === 0x0b ||
80
+ code === 0x0c ||
81
+ code === 0x0d ||
82
+ code === 0x20;
83
+
84
+ const hasGattCongestedStatus = (text: string) => {
85
+ let searchFrom = 0;
86
+ while (searchFrom < text.length) {
87
+ const statusIndex = text.indexOf('status', searchFrom);
88
+ if (statusIndex < 0) return false;
89
+
90
+ let cursor = statusIndex + 'status'.length;
91
+ while (cursor < text.length && isAsciiWhitespace(text.charCodeAt(cursor))) cursor += 1;
92
+ if (text[cursor] === ':' || text[cursor] === '=') {
93
+ cursor += 1;
94
+ while (cursor < text.length && isAsciiWhitespace(text.charCodeAt(cursor))) cursor += 1;
95
+ }
96
+ if (text.startsWith(String(ANDROID_GATT_CONGESTED_STATUS), cursor)) return true;
97
+
98
+ searchFrom = statusIndex + 'status'.length;
99
+ }
100
+ return false;
101
+ };
102
+
74
103
  const delay = (ms: number) =>
75
104
  new Promise<void>(resolve => {
76
105
  setTimeout(resolve, ms);
@@ -99,29 +128,13 @@ export const getFirmwareUploadWriteRetryType = (
99
128
  const text = [bleWriteError.reason, bleWriteError.message, bleWriteError.name]
100
129
  .filter(value => typeof value === 'string')
101
130
  .join(' ');
102
- return /GATT_CONGESTED|status\s*[:=]?\s*143/.test(text) ? 'congested' : null;
131
+ return text.includes('GATT_CONGESTED') || hasGattCongestedStatus(text) ? 'congested' : null;
103
132
  };
104
133
 
105
134
  const resolveFirmwareUploadRetryDelay = (attempt: number, baseDelayMs = 200, maxDelayMs = 1200) =>
106
135
  Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
107
136
  const PROTOCOL_PROBE_TIMEOUT_MS = 1000;
108
137
  const PROTOCOL_V2_PROBE_TIMEOUT_MS = 10_000;
109
- /**
110
- * Per-packet write budget. iOS only resolves writeWithoutResponse once CoreBluetooth
111
- * reports the peripheral ready again; a peripheral wedged by its own firmware reboot
112
- * stops reporting ready while staying connected, so the write promise never settles.
113
- * Response timeouts cannot cover that — they are armed after the writes complete —
114
- * and an unbounded write leaves the whole transport unusable until the process dies.
115
- * A healthy packet completes in milliseconds, so this only fires on a dead link.
116
- */
117
- export const BLE_WRITE_PACKET_TIMEOUT_MS = 10_000;
118
- const WEDGED_WRITE_MESSAGE = 'BLE write timeout after';
119
- const isWedgedWriteError = (error: unknown): boolean =>
120
- (error as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleWriteCharacteristicError &&
121
- typeof (error as { message?: unknown })?.message === 'string' &&
122
- (error as { message: string }).message.startsWith(WEDGED_WRITE_MESSAGE);
123
- /** Consecutive wedged writes on one device before the BLE manager itself is recreated. */
124
- export const BLE_WRITE_TIMEOUT_MANAGER_RESET_THRESHOLD = 2;
125
138
  const DEVICE_SCAN_TIMEOUT_MS = 3000;
126
139
  const IOS_NOTIFY_READY_DELAY_MS = 150;
127
140
  const ANDROID_NOTIFY_READY_DELAY_MS = 300;
@@ -178,52 +191,12 @@ function getDeviceDisplayName(device?: Device | null) {
178
191
 
179
192
  const ANDROID_REQUEST_MTU = 256;
180
193
 
181
- const BLE_NATIVE_CONNECT_TIMEOUT_MS = 3000;
182
-
183
194
  const connectOptions: Record<string, unknown> = {
184
195
  requestMTU: ANDROID_REQUEST_MTU,
185
- timeout: BLE_NATIVE_CONNECT_TIMEOUT_MS,
196
+ timeout: 3000,
186
197
  refreshGatt: 'OnConnected',
187
198
  };
188
199
 
189
- /** Fallback connect options: drops requestMTU (the thing being worked around) but keeps the native budget. */
190
- const fallbackConnectOptions: Record<string, unknown> = {
191
- timeout: BLE_NATIVE_CONNECT_TIMEOUT_MS,
192
- };
193
-
194
- /**
195
- * JS backstop for connect. The native adapter applies its own 3s budget, but it
196
- * schedules that timeout on its serial queue, so a busy queue (e.g. right after a
197
- * firmware install tears the link down) can leave the promise unsettled — observed
198
- * blocking a reconnect for 61s until the app-level timeout. Healthy connects finish
199
- * inside the native budget, so this only fires when the native timeout did not.
200
- */
201
- export const BLE_CONNECT_TIMEOUT_MS = BLE_NATIVE_CONNECT_TIMEOUT_MS * 2 + 2000;
202
- /**
203
- * Service discovery and characteristic resolution run after connect() succeeds, but
204
- * CoreBluetooth schedules them on the same serial queue. If that queue is wedged by a
205
- * device reboot, these calls can remain pending forever unless they have their own
206
- * budget.
207
- */
208
- export const BLE_GATT_SETUP_TIMEOUT_MS = 10_000;
209
- /**
210
- * How many times a known device may fail its own protocol before we probe the others
211
- * again. Reconnect polling during a device reboot repeats this every few seconds, and
212
- * probing Protocol V2 costs a 10s Ping timeout, so paying it on every attempt for a
213
- * device we just spoke V1 to dominates the wait. A firmware update can legitimately
214
- * change a device's protocol, so the shortcut has to expire rather than stick.
215
- */
216
- export const PROTOCOL_REPROBE_FALLBACK_ATTEMPTS = 3;
217
- /** BLE setup timeouts since the last successful setup before the manager is recreated. */
218
- export const BLE_CONNECT_TIMEOUT_MANAGER_RESET_THRESHOLD = 2;
219
- const CONNECT_TIMEOUT_MESSAGE = 'BLE connect timeout after';
220
- const isConnectTimeoutError = (error: unknown): boolean =>
221
- (error as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleConnectedError &&
222
- typeof (error as { message?: unknown })?.message === 'string' &&
223
- (error as { message: string }).message.startsWith(CONNECT_TIMEOUT_MESSAGE);
224
- const isNativeOperationTimeoutError = (error: unknown): boolean =>
225
- (error as { errorCode?: unknown })?.errorCode === BleErrorCode.OperationTimedOut;
226
-
227
200
  export type IOneKeyDevice = OneKeyDeviceInfoBase & Device;
228
201
 
229
202
  const tryToGetConfiguration = (device: Device) => {
@@ -312,28 +285,8 @@ export default class ReactNativeBleTransport {
312
285
  /** Per-device protocol type detected by active wire-level probe after connect. */
313
286
  private deviceProtocol: Map<string, ProtocolType> = new Map();
314
287
 
315
- /**
316
- * Protocol a probe is currently trying, before the device has confirmed it. Calls
317
- * must route with it, but acquire() must not treat it as a detected protocol: a
318
- * probe that never answers would otherwise leave the reuse fast path handing out a
319
- * transport that was never validated.
320
- */
321
- private probingProtocols: Map<string, ProtocolType> = new Map();
322
-
323
- /** Consecutive write timeouts per device; reset by any write that completes. */
324
- private writeTimeoutCounts: Map<string, number> = new Map();
325
-
326
- /** BLE setup timeouts per device since the last complete characteristic resolution. */
327
- private connectionSetupTimeoutCounts: Map<string, number> = new Map();
328
-
329
288
  private deviceProtocolHints: Map<string, ProtocolType> = new Map();
330
289
 
331
- /** Protocol this device actually answered on, kept across reconnects of one session. */
332
- private sessionProtocols: Map<string, ProtocolType> = new Map();
333
-
334
- /** Consecutive detections that failed while trusting sessionProtocols. */
335
- private protocolReprobeFailures: Map<string, number> = new Map();
336
-
337
290
  private protocolV2Assemblers: Map<string, ProtocolV2FrameAssembler> = new Map();
338
291
 
339
292
  private protocolV2FrameQueues: Map<string, Uint8Array[]> = new Map();
@@ -551,21 +504,22 @@ export default class ReactNativeBleTransport {
551
504
  const isConnected = await device.isConnected().catch(() => false);
552
505
  if (!isConnected) {
553
506
  try {
554
- device = await this.connectWithTimeout(uuid, () => device.connect(connectOptions));
507
+ device = await device.connect(connectOptions);
555
508
  } catch (e) {
556
509
  if (
557
510
  e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
558
511
  e.errorCode === BleErrorCode.OperationCancelled
559
512
  ) {
560
- device = await this.connectWithTimeout(uuid, () => device.connect());
513
+ device = await device.connect();
561
514
  } else if (e.errorCode !== BleErrorCode.DeviceAlreadyConnected) {
562
515
  throw e;
563
516
  }
564
517
  }
565
518
  }
566
519
 
567
- const { writeCharacteristic, notifyCharacteristic } =
568
- await this.resolveCharacteristicsWithTimeout(uuid, device);
520
+ const { writeCharacteristic, notifyCharacteristic } = await this.resolveCharacteristics(
521
+ device
522
+ );
569
523
 
570
524
  transport.device = device;
571
525
  transport.writeCharacteristic = writeCharacteristic;
@@ -655,12 +609,21 @@ export default class ReactNativeBleTransport {
655
609
  }
656
610
 
657
611
  const displayName = getDeviceDisplayName(device);
658
- const isOneKey = isOnekeyBluetoothDevice({
659
- id: device?.id,
660
- name: device?.name,
661
- localName: device?.localName,
662
- serviceUuids: device?.serviceUUIDs,
663
- });
612
+ // iOS may report a service-only advertisement before the named scan response.
613
+ // Do not cache that incomplete advertisement as an unknown device.
614
+ const isUnnamedIOSPeripheral = Platform.OS === 'ios' && !displayName?.trim();
615
+ const isFindMyPeripheral =
616
+ isPro2FindMyAdvertisementName(device?.name) ||
617
+ isPro2FindMyAdvertisementName(device?.localName);
618
+ const isOneKey =
619
+ !isUnnamedIOSPeripheral &&
620
+ !isFindMyPeripheral &&
621
+ isOnekeyBluetoothDevice({
622
+ id: device?.id,
623
+ name: device?.name,
624
+ localName: device?.localName,
625
+ serviceUuids: device?.serviceUUIDs,
626
+ });
664
627
  if (isOneKey) {
665
628
  addDevice(device as unknown as Device);
666
629
  } else if (displayName && /\bpro\s*2\b/i.test(displayName)) {
@@ -681,7 +644,12 @@ export default class ReactNativeBleTransport {
681
644
  'localName' in device && typeof device.localName === 'string'
682
645
  ? device.localName
683
646
  : null;
647
+ const isFindMyPeripheral =
648
+ isPro2FindMyAdvertisementName(device.name) ||
649
+ isPro2FindMyAdvertisementName(localName);
650
+
684
651
  if (
652
+ !isFindMyPeripheral &&
685
653
  isOnekeyBluetoothDevice({
686
654
  id: device.id,
687
655
  name: device.name,
@@ -730,7 +698,7 @@ export default class ReactNativeBleTransport {
730
698
  characteristics?: ResolvedBleCharacteristics
731
699
  ) {
732
700
  const { writeCharacteristic, notifyCharacteristic } =
733
- characteristics ?? (await this.resolveCharacteristicsWithTimeout(uuid, device));
701
+ characteristics ?? (await this.resolveCharacteristics(device));
734
702
  const transport = new BleTransport(device, writeCharacteristic, notifyCharacteristic);
735
703
  if (Platform.OS === 'android') {
736
704
  transport.mtuSize = typeof device.mtu === 'number' ? device.mtu : transport.mtuSize;
@@ -834,22 +802,15 @@ export default class ReactNativeBleTransport {
834
802
  if (!device) {
835
803
  Log?.debug('try to connect to device: ', uuid);
836
804
  try {
837
- device = await this.connectWithTimeout(uuid, () =>
838
- blePlxManager.connectToDevice(uuid, connectOptions)
839
- );
805
+ device = await blePlxManager.connectToDevice(uuid, connectOptions);
840
806
  } catch (e) {
841
807
  Log?.debug('try to connect to device has error: ', e);
842
- if (isConnectTimeoutError(e)) {
843
- throw e;
844
- }
845
808
  if (
846
809
  e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
847
810
  e.errorCode === BleErrorCode.OperationCancelled
848
811
  ) {
849
812
  Log?.debug('first try to reconnect without params');
850
- device = await this.connectWithTimeout(uuid, () =>
851
- blePlxManager.connectToDevice(uuid, fallbackConnectOptions)
852
- );
813
+ device = await blePlxManager.connectToDevice(uuid);
853
814
  } else if (e.errorCode === BleErrorCode.DeviceAlreadyConnected) {
854
815
  Log?.debug('device already connected');
855
816
  throw ERRORS.TypedError(HardwareErrorCode.BleAlreadyConnected);
@@ -865,36 +826,26 @@ export default class ReactNativeBleTransport {
865
826
 
866
827
  if (!(await device.isConnected())) {
867
828
  Log?.debug('not connected, try to connect to device: ', uuid);
868
- const disconnectedDevice = device;
869
829
 
870
830
  try {
871
- device = await this.connectWithTimeout(uuid, () =>
872
- disconnectedDevice.connect(connectOptions)
873
- );
831
+ device = await device.connect(connectOptions);
874
832
  } catch (e) {
875
833
  Log?.debug('not connected, try to connect to device has error: ', e);
876
- if (isConnectTimeoutError(e)) {
877
- throw e;
878
- }
879
834
  if (
880
835
  e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
881
836
  e.errorCode === BleErrorCode.OperationCancelled
882
837
  ) {
883
838
  Log?.debug('second try to reconnect without params');
884
839
  try {
885
- device = await this.connectWithTimeout(uuid, () =>
886
- disconnectedDevice.connect(fallbackConnectOptions)
887
- );
840
+ device = await device.connect();
888
841
  } catch (e) {
889
842
  Log?.debug('last try to reconnect error: ', e);
890
843
  // last try to reconnect device if this issue exists
891
844
  // https://github.com/dotintent/react-native-ble-plx/issues/426
892
845
  if (e.errorCode === BleErrorCode.OperationCancelled) {
893
846
  Log?.debug('last try to reconnect');
894
- await disconnectedDevice.cancelConnection();
895
- device = await this.connectWithTimeout(uuid, () =>
896
- disconnectedDevice.connect(fallbackConnectOptions)
897
- );
847
+ await device.cancelConnection();
848
+ device = await device.connect();
898
849
  }
899
850
  }
900
851
  } else {
@@ -905,8 +856,9 @@ export default class ReactNativeBleTransport {
905
856
 
906
857
  device = await requestAndroidMtu(device);
907
858
  const acquiredDevice = device;
908
- const { writeCharacteristic, notifyCharacteristic } =
909
- await this.resolveCharacteristicsWithTimeout(uuid, acquiredDevice);
859
+ const { writeCharacteristic, notifyCharacteristic } = await this.resolveCharacteristics(
860
+ acquiredDevice
861
+ );
910
862
 
911
863
  const protocolHint = expectedProtocol
912
864
  ? undefined
@@ -970,7 +922,7 @@ export default class ReactNativeBleTransport {
970
922
  Log?.debug('monitor error ignored for stale transport: ', uuid, notifyTransactionId);
971
923
  return;
972
924
  }
973
- if (this.getActiveProtocol(uuid) === 'V2') {
925
+ if (this.deviceProtocol.get(uuid) === 'V2') {
974
926
  let errorCode:
975
927
  | typeof HardwareErrorCode.BleDeviceBondError
976
928
  | typeof HardwareErrorCode.BleCharacteristicNotifyError
@@ -1040,7 +992,7 @@ export default class ReactNativeBleTransport {
1040
992
 
1041
993
  try {
1042
994
  const data = Buffer.from(c.value as string, 'base64');
1043
- const protocol = this.getActiveProtocol(uuid);
995
+ const protocol = this.deviceProtocol.get(uuid);
1044
996
  if (!protocol) {
1045
997
  Log?.debug('monitor data ignored before protocol detection: ', uuid);
1046
998
  return;
@@ -1074,7 +1026,7 @@ export default class ReactNativeBleTransport {
1074
1026
  } catch (error) {
1075
1027
  Log?.debug('monitor data error: ', error);
1076
1028
  const notifyError = ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
1077
- if (this.getActiveProtocol(uuid) === 'V2') {
1029
+ if (this.deviceProtocol.get(uuid) === 'V2') {
1078
1030
  this.rejectProtocolV2Frames(uuid, notifyError);
1079
1031
  } else if (this.runPromiseDeviceId === uuid) {
1080
1032
  this.runPromise?.reject(notifyError);
@@ -1138,7 +1090,6 @@ export default class ReactNativeBleTransport {
1138
1090
  }
1139
1091
 
1140
1092
  this.deviceProtocol.delete(uuid);
1141
- this.probingProtocols.delete(uuid);
1142
1093
  // Preserve a name-derived hint across disconnects so reconnect can probe V2 first.
1143
1094
  this.protocolV2Assemblers.get(uuid)?.reset();
1144
1095
  this.protocolV2Assemblers.delete(uuid);
@@ -1205,25 +1156,8 @@ export default class ReactNativeBleTransport {
1205
1156
  const transport = this.getCachedTransport(uuid);
1206
1157
  const runPromise = createDeferred<string>();
1207
1158
  runPromise.promise.catch(() => undefined);
1208
- const supersededRunPromise = this.runPromise;
1209
- if (supersededRunPromise) {
1210
- // Only forceRun calls (Initialize/Cancel) reach here with a pending call. Settle
1211
- // the superseded deferred now so its response race resolves and its finally block
1212
- // clears its timeout timer; an orphaned timer would otherwise fire much later and
1213
- // tear down the shared connection while another call is using it.
1214
- supersededRunPromise.reject(ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise));
1215
- }
1216
1159
  this.runPromise = runPromise;
1217
1160
  this.runPromiseDeviceId = uuid;
1218
- // A superseded call's late write failure must not clear the successor's ownership;
1219
- // only the call that still owns the slot may release it.
1220
- const releaseOwnershipIfCurrent = () => {
1221
- if (this.runPromise === runPromise) {
1222
- this.runPromise = null;
1223
- this.runPromiseDeviceId = null;
1224
- }
1225
- };
1226
- const isCurrentOwner = () => this.runPromise === runPromise;
1227
1161
  const messages = this._messages;
1228
1162
  const buffers = ProtocolV1.encodeTransportPackets(messages, name, data);
1229
1163
  let timeout: ReturnType<typeof setTimeout> | undefined;
@@ -1249,9 +1183,6 @@ export default class ReactNativeBleTransport {
1249
1183
  chunk = ByteBuffer.allocate(packetCapacity);
1250
1184
  } catch (e) {
1251
1185
  onError(e);
1252
- if (isWedgedWriteError(e)) {
1253
- throw e;
1254
- }
1255
1186
  throw ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
1256
1187
  }
1257
1188
  }
@@ -1283,9 +1214,6 @@ export default class ReactNativeBleTransport {
1283
1214
  }
1284
1215
  } catch (e) {
1285
1216
  onError(e);
1286
- if (isWedgedWriteError(e)) {
1287
- throw e;
1288
- }
1289
1217
  throw ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
1290
1218
  }
1291
1219
  }
@@ -1299,15 +1227,9 @@ export default class ReactNativeBleTransport {
1299
1227
  if (name === 'EmmcFileWrite') {
1300
1228
  await writeChunkedData(
1301
1229
  buffers,
1302
- data =>
1303
- this.writeBlePacket(
1304
- uuid,
1305
- data,
1306
- payload => transport.writeWithRetry(payload),
1307
- isCurrentOwner
1308
- ),
1230
+ data => transport.writeWithRetry(data),
1309
1231
  e => {
1310
- releaseOwnershipIfCurrent();
1232
+ this.runPromise = null;
1311
1233
  Log?.error('writeCharacteristic write error: ', e);
1312
1234
  }
1313
1235
  );
@@ -1328,12 +1250,7 @@ export default class ReactNativeBleTransport {
1328
1250
  // eslint-disable-next-line no-constant-condition
1329
1251
  while (true) {
1330
1252
  try {
1331
- await this.writeBlePacket(
1332
- uuid,
1333
- data,
1334
- payload => transport.writeCharacteristic.writeWithoutResponse(payload),
1335
- isCurrentOwner
1336
- );
1253
+ await transport.writeWithRetry(data);
1337
1254
  return;
1338
1255
  } catch (error) {
1339
1256
  const retryType = getFirmwareUploadWriteRetryType(error);
@@ -1352,7 +1269,7 @@ export default class ReactNativeBleTransport {
1352
1269
  }
1353
1270
  },
1354
1271
  e => {
1355
- releaseOwnershipIfCurrent();
1272
+ this.runPromise = null;
1356
1273
  Log?.error('writeCharacteristic write error: ', e);
1357
1274
  }
1358
1275
  );
@@ -1361,18 +1278,16 @@ export default class ReactNativeBleTransport {
1361
1278
  const outData = o.toString('base64');
1362
1279
  // Upload resources on low-end phones may OOM
1363
1280
  try {
1364
- await this.writeBlePacket(
1365
- uuid,
1366
- outData,
1367
- payload => transport.writeCharacteristic.writeWithoutResponse(payload),
1368
- isCurrentOwner
1369
- );
1281
+ const shouldUseWriteWithResponse =
1282
+ Platform.OS === 'ios' && transport.writeCharacteristic.isWritableWithResponse;
1283
+ if (shouldUseWriteWithResponse) {
1284
+ await transport.writeCharacteristic.writeWithResponse(outData);
1285
+ } else {
1286
+ await transport.writeCharacteristic.writeWithoutResponse(outData);
1287
+ }
1370
1288
  } catch (e) {
1371
1289
  Log?.debug('writeCharacteristic write error: ', e);
1372
- releaseOwnershipIfCurrent();
1373
- if (isWedgedWriteError(e)) {
1374
- throw e;
1375
- }
1290
+ this.runPromise = null;
1376
1291
  if (e.errorCode === BleErrorCode.DeviceDisconnected) {
1377
1292
  throw ERRORS.TypedError(HardwareErrorCode.BleDeviceNotBonded);
1378
1293
  } else if (e.errorCode === BleErrorCode.OperationStartFailed) {
@@ -1415,13 +1330,8 @@ export default class ReactNativeBleTransport {
1415
1330
  }
1416
1331
  const isProbeTimeout =
1417
1332
  name === 'GetFeatures' && options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS;
1418
- // A call that has been superseded (forceRun) or cleaned up no longer owns the
1419
- // transport; its late timeout must not tear down the connection the current
1420
- // call is actively using.
1421
- const isStaleCall = this.runPromise !== runPromise;
1422
1333
  if (
1423
1334
  !isProbeTimeout &&
1424
- !isStaleCall &&
1425
1335
  (e as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleTimeoutError
1426
1336
  ) {
1427
1337
  await this.disconnect(uuid);
@@ -1501,10 +1411,7 @@ export default class ReactNativeBleTransport {
1501
1411
  delete transportCache[session];
1502
1412
  }
1503
1413
  this.deviceProtocol.delete(session);
1504
- this.probingProtocols.delete(session);
1505
1414
  this.deviceProtocolHints.delete(session);
1506
- this.sessionProtocols.delete(session);
1507
- this.protocolReprobeFailures.delete(session);
1508
1415
  this.protocolV2Assemblers.delete(session);
1509
1416
  this.resetProtocolV2Frames(session);
1510
1417
 
@@ -1530,113 +1437,6 @@ export default class ReactNativeBleTransport {
1530
1437
  this.runPromiseDeviceId = null;
1531
1438
  }
1532
1439
 
1533
- /** Run a native connect under the JS backstop budget. */
1534
- private async connectWithTimeout<T>(uuid: string, connect: () => Promise<T>): Promise<T> {
1535
- let timer: ReturnType<typeof setTimeout> | undefined;
1536
- let timedOut = false;
1537
- const pending = connect();
1538
- // The abandoned attempt keeps running; swallow its late outcome so it cannot
1539
- // surface as an unhandled rejection after we have already given up on it.
1540
- pending.catch(() => undefined);
1541
- try {
1542
- const result = await Promise.race([
1543
- pending,
1544
- new Promise<never>((_, reject) => {
1545
- timer = setTimeout(() => {
1546
- timedOut = true;
1547
- reject(
1548
- ERRORS.TypedError(
1549
- HardwareErrorCode.BleConnectedError,
1550
- `BLE connect timeout after ${BLE_CONNECT_TIMEOUT_MS}ms for ${uuid}`
1551
- )
1552
- );
1553
- }, BLE_CONNECT_TIMEOUT_MS);
1554
- }),
1555
- ]);
1556
- return result;
1557
- } catch (error) {
1558
- if (timedOut || isNativeOperationTimeoutError(error)) {
1559
- this.abandonStalledConnection(uuid, timedOut ? 'connect-backstop' : 'connect-native');
1560
- }
1561
- throw error;
1562
- } finally {
1563
- if (timer) clearTimeout(timer);
1564
- }
1565
- }
1566
-
1567
- /** Resolve the complete GATT shape under a budget so acquire() always settles. */
1568
- private async resolveCharacteristicsWithTimeout(
1569
- uuid: string,
1570
- device: Device
1571
- ): Promise<ResolvedBleCharacteristics> {
1572
- let timer: ReturnType<typeof setTimeout> | undefined;
1573
- let timedOut = false;
1574
- const pending = this.resolveCharacteristics(device);
1575
- pending.catch(() => undefined);
1576
- try {
1577
- const result = await Promise.race([
1578
- pending,
1579
- new Promise<never>((_, reject) => {
1580
- timer = setTimeout(() => {
1581
- timedOut = true;
1582
- reject(
1583
- ERRORS.TypedError(
1584
- HardwareErrorCode.BleConnectedError,
1585
- `BLE GATT setup timeout after ${BLE_GATT_SETUP_TIMEOUT_MS}ms for ${uuid}`
1586
- )
1587
- );
1588
- }, BLE_GATT_SETUP_TIMEOUT_MS);
1589
- }),
1590
- ]);
1591
- this.connectionSetupTimeoutCounts.delete(uuid);
1592
- return result;
1593
- } catch (error) {
1594
- if (timedOut || isNativeOperationTimeoutError(error)) {
1595
- this.abandonStalledConnection(uuid, timedOut ? 'gatt-backstop' : 'gatt-native');
1596
- }
1597
- throw error;
1598
- } finally {
1599
- if (timer) clearTimeout(timer);
1600
- }
1601
- }
1602
-
1603
- /**
1604
- * Give up on a BLE setup operation the native layer did not settle. The abandoned
1605
- * operation still owns native connection/GATT state that can poison the next attempt,
1606
- * so it is cleared here without awaiting the same queue that stopped responding.
1607
- */
1608
- private abandonStalledConnection(
1609
- uuid: string,
1610
- stage: 'connect-backstop' | 'connect-native' | 'gatt-backstop' | 'gatt-native'
1611
- ) {
1612
- const timeouts = (this.connectionSetupTimeoutCounts.get(uuid) ?? 0) + 1;
1613
- this.connectionSetupTimeoutCounts.set(uuid, timeouts);
1614
- Log?.error('[ReactNativeBleTransport] BLE setup timed out:', uuid, {
1615
- stage,
1616
- setupTimeoutsSinceSuccess: timeouts,
1617
- });
1618
-
1619
- this.blePlxManager?.cancelDeviceConnection(uuid).catch(() => {
1620
- // Rejects with "Operation was cancelled" while merely connecting — expected.
1621
- });
1622
- const stalled = transportCache[uuid];
1623
- if (stalled) {
1624
- delete transportCache[uuid];
1625
- }
1626
- this.deviceProtocol.delete(uuid);
1627
- this.probingProtocols.delete(uuid);
1628
- this.protocolV2Assemblers.delete(uuid);
1629
- this.resetProtocolV2Frames(uuid);
1630
-
1631
- if (timeouts >= BLE_CONNECT_TIMEOUT_MANAGER_RESET_THRESHOLD) {
1632
- // BleManager.destroy() force-rejects every promise the native queue abandoned —
1633
- // the only JS-reachable way to settle them — and drops all cached peripherals.
1634
- Log?.error('[ReactNativeBleTransport] BLE setup wedged repeatedly, resetting BLE manager');
1635
- this.resetPlxManager();
1636
- this.connectionSetupTimeoutCounts.delete(uuid);
1637
- }
1638
- }
1639
-
1640
1440
  private getCachedTransport(uuid: string) {
1641
1441
  const transport = transportCache[uuid];
1642
1442
  if (!transport) {
@@ -1645,107 +1445,6 @@ export default class ReactNativeBleTransport {
1645
1445
  return transport;
1646
1446
  }
1647
1447
 
1648
- /**
1649
- * Write one packet under a bounded budget. A write that never settles means the
1650
- * peripheral is wedged even though the GATT link still reports connected, so the
1651
- * link is torn down: releasing JS state alone would leave the poisoned peripheral
1652
- * cached and every later call would hang on it again.
1653
- */
1654
- private async writeBlePacket(
1655
- uuid: string,
1656
- data: string,
1657
- write: (payload: string) => Promise<unknown>,
1658
- isCurrentOwner?: () => boolean
1659
- ) {
1660
- let timer: ReturnType<typeof setTimeout> | undefined;
1661
- let timedOut = false;
1662
- try {
1663
- await Promise.race([
1664
- write(data),
1665
- new Promise<never>((_, reject) => {
1666
- timer = setTimeout(() => {
1667
- timedOut = true;
1668
- reject(
1669
- ERRORS.TypedError(
1670
- HardwareErrorCode.BleWriteCharacteristicError,
1671
- `BLE write timeout after ${BLE_WRITE_PACKET_TIMEOUT_MS}ms`
1672
- )
1673
- );
1674
- }, BLE_WRITE_PACKET_TIMEOUT_MS);
1675
- }),
1676
- ]);
1677
- this.writeTimeoutCounts.delete(uuid);
1678
- } catch (error) {
1679
- if (timedOut) {
1680
- // A superseded call's late write must not tear down the link the current
1681
- // call is using; only the owner of the transport may declare it dead.
1682
- if (isCurrentOwner && !isCurrentOwner()) {
1683
- Log?.debug('[ReactNativeBleTransport] stale BLE write timed out, link kept:', uuid);
1684
- } else {
1685
- this.tearDownWedgedLink(uuid);
1686
- }
1687
- }
1688
- throw error;
1689
- } finally {
1690
- if (timer) clearTimeout(timer);
1691
- }
1692
- }
1693
-
1694
- /**
1695
- * Drop a link whose writes stopped completing. The JS state is purged synchronously
1696
- * so the next acquire() cannot reuse the dead transport, while the native teardown is
1697
- * intentionally NOT awaited: it talks to the very layer that just stopped settling
1698
- * promises, so awaiting it could hang exactly like the write it is recovering from.
1699
- */
1700
- private tearDownWedgedLink(uuid: string) {
1701
- const timeouts = (this.writeTimeoutCounts.get(uuid) ?? 0) + 1;
1702
- this.writeTimeoutCounts.set(uuid, timeouts);
1703
- Log?.error('[ReactNativeBleTransport] BLE write timed out, tearing down link:', uuid, {
1704
- consecutiveWriteTimeouts: timeouts,
1705
- });
1706
-
1707
- const wedged = transportCache[uuid];
1708
- this.disconnect(uuid).catch(error => {
1709
- Log?.debug('[ReactNativeBleTransport] wedged link teardown failed (ignored):', error);
1710
- });
1711
- if (wedged && transportCache[uuid] === wedged) {
1712
- delete transportCache[uuid];
1713
- }
1714
- this.deviceProtocol.delete(uuid);
1715
- this.probingProtocols.delete(uuid);
1716
- this.protocolV2Assemblers.delete(uuid);
1717
- this.resetProtocolV2Frames(uuid);
1718
-
1719
- if (timeouts >= BLE_WRITE_TIMEOUT_MANAGER_RESET_THRESHOLD) {
1720
- // Reconnecting reuses the same native peripheral object. When it stays wedged
1721
- // across attempts the poison lives in the BLE manager itself, and only a fresh
1722
- // manager drops every cached peripheral — the JS equivalent of restarting the app.
1723
- Log?.error('[ReactNativeBleTransport] BLE writes wedged repeatedly, resetting BLE manager');
1724
- this.resetPlxManager();
1725
- this.writeTimeoutCounts.delete(uuid);
1726
- }
1727
- }
1728
-
1729
- private resetPlxManager() {
1730
- const manager = this.blePlxManager;
1731
- this.blePlxManager = undefined;
1732
- // Every cached transport belongs to the destroyed manager's peripherals.
1733
- Object.keys(transportCache).forEach(key => {
1734
- delete transportCache[key];
1735
- });
1736
- this.deviceProtocol.clear();
1737
- this.probingProtocols.clear();
1738
- this.sessionProtocols.clear();
1739
- this.protocolReprobeFailures.clear();
1740
- this.monitorTokens.clear();
1741
- this.protocolV2Assemblers.clear();
1742
- try {
1743
- manager?.destroy();
1744
- } catch (error) {
1745
- Log?.debug('[ReactNativeBleTransport] BLE manager destroy failed (ignored):', error);
1746
- }
1747
- }
1748
-
1749
1448
  private createProtocolMismatchError(expected: ProtocolType) {
1750
1449
  return ERRORS.TypedError(
1751
1450
  HardwareErrorCode.RuntimeError,
@@ -1761,29 +1460,30 @@ export default class ReactNativeBleTransport {
1761
1460
  }
1762
1461
 
1763
1462
  private clearProbeProtocol(uuid: string, protocol: ProtocolType) {
1764
- if (this.probingProtocols.get(uuid) === protocol) {
1765
- this.probingProtocols.delete(uuid);
1766
- }
1767
1463
  if (this.deviceProtocol.get(uuid) === protocol) {
1768
1464
  this.deviceProtocol.delete(uuid);
1769
1465
  }
1770
1466
  }
1771
1467
 
1772
- /** Protocol to route a call with: confirmed if known, otherwise the one being probed. */
1773
- private getActiveProtocol(uuid: string): ProtocolType | undefined {
1774
- return this.deviceProtocol.get(uuid) ?? this.probingProtocols.get(uuid);
1775
- }
1776
-
1777
1468
  private async detectProtocol(
1778
1469
  uuid: string,
1779
1470
  expectedProtocol?: ProtocolType,
1780
1471
  protocolHint?: ProtocolType,
1781
1472
  rebuildTransport?: () => Promise<void>
1782
1473
  ): Promise<ProtocolType> {
1474
+ if (Platform.OS === 'ios' && expectedProtocol) {
1475
+ this.deviceProtocol.set(uuid, expectedProtocol);
1476
+ Log?.debug('[ReactNativeBleTransport] protocol selected', {
1477
+ deviceId: uuid,
1478
+ protocol: expectedProtocol,
1479
+ source: 'expected',
1480
+ });
1481
+ return expectedProtocol;
1482
+ }
1483
+
1783
1484
  if (expectedProtocol === 'V1') {
1784
1485
  if (await this.probeProtocolV1(uuid)) {
1785
1486
  this.deviceProtocol.set(uuid, 'V1');
1786
- this.sessionProtocols.set(uuid, 'V1');
1787
1487
  Log?.debug('[ReactNativeBleTransport] protocol detected', {
1788
1488
  deviceId: uuid,
1789
1489
  protocol: 'V1',
@@ -1797,7 +1497,6 @@ export default class ReactNativeBleTransport {
1797
1497
  if (expectedProtocol === 'V2') {
1798
1498
  if (await this.probeProtocolV2(uuid)) {
1799
1499
  this.deviceProtocol.set(uuid, 'V2');
1800
- this.sessionProtocols.set(uuid, 'V2');
1801
1500
  Log?.debug('[ReactNativeBleTransport] protocol detected', {
1802
1501
  deviceId: uuid,
1803
1502
  protocol: 'V2',
@@ -1810,18 +1509,8 @@ export default class ReactNativeBleTransport {
1810
1509
 
1811
1510
  // Protocol must be actively probed after connection. Name, PID, and descriptors only
1812
1511
  // influence probe order; a V2 hint probes V2 first and falls back to V1.
1813
- const sessionProtocol = this.sessionProtocols.get(uuid);
1814
- const reprobeFailures = this.protocolReprobeFailures.get(uuid) ?? 0;
1815
- const fullProbeOrder: ProtocolType[] =
1512
+ const probeOrder: ProtocolType[] =
1816
1513
  protocolHint === 'V2' || this.deviceProtocol.get(uuid) === 'V2' ? ['V2', 'V1'] : ['V1', 'V2'];
1817
- // A device that already answered on a protocol in this session keeps answering on
1818
- // it; while it is rebooting nothing answers at all, so probing the other protocol
1819
- // only adds its timeout to every poll.
1820
- const trustSessionProtocol =
1821
- sessionProtocol !== undefined &&
1822
- !protocolHint &&
1823
- reprobeFailures < PROTOCOL_REPROBE_FALLBACK_ATTEMPTS;
1824
- const probeOrder: ProtocolType[] = trustSessionProtocol ? [sessionProtocol] : fullProbeOrder;
1825
1514
 
1826
1515
  for (let i = 0; i < probeOrder.length; i += 1) {
1827
1516
  const protocol = probeOrder[i];
@@ -1839,8 +1528,6 @@ export default class ReactNativeBleTransport {
1839
1528
  protocol === 'V1' ? await this.probeProtocolV1(uuid) : await this.probeProtocolV2(uuid);
1840
1529
  if (detected) {
1841
1530
  this.deviceProtocol.set(uuid, protocol);
1842
- this.sessionProtocols.set(uuid, protocol);
1843
- this.protocolReprobeFailures.delete(uuid);
1844
1531
  Log?.debug('[ReactNativeBleTransport] protocol detected', {
1845
1532
  deviceId: uuid,
1846
1533
  protocol,
@@ -1850,16 +1537,7 @@ export default class ReactNativeBleTransport {
1850
1537
  }
1851
1538
  }
1852
1539
 
1853
- if (trustSessionProtocol) {
1854
- // Still silent on its own protocol: count it, and let the streak expire the
1855
- // shortcut so a device that genuinely switched protocols is found again.
1856
- this.protocolReprobeFailures.set(uuid, reprobeFailures + 1);
1857
- } else {
1858
- this.protocolReprobeFailures.delete(uuid);
1859
- }
1860
-
1861
1540
  this.deviceProtocol.delete(uuid);
1862
- this.probingProtocols.delete(uuid);
1863
1541
  throw this.createProtocolDetectionError();
1864
1542
  }
1865
1543
 
@@ -1921,20 +1599,14 @@ export default class ReactNativeBleTransport {
1921
1599
  }
1922
1600
 
1923
1601
  try {
1924
- this.probingProtocols.set(uuid, 'V1');
1602
+ this.deviceProtocol.set(uuid, 'V1');
1925
1603
  // GetFeatures identifies Protocol V1 without resetting an existing wallet
1926
1604
  // session before Core has a chance to restore a hidden wallet.
1927
1605
  await this.callProtocolV1(uuid, 'GetFeatures', {}, { timeoutMs: PROTOCOL_PROBE_TIMEOUT_MS });
1928
- this.probingProtocols.delete(uuid);
1929
1606
  return true;
1930
1607
  } catch (error) {
1931
1608
  this.clearProbeProtocol(uuid, 'V1');
1932
1609
  Log?.debug('[ReactNativeBleTransport] Protocol V1 GetFeatures probe failed:', error);
1933
- // A wedged write already dropped the link, so probing another protocol on it
1934
- // would only fail against a torn-down transport: surface the real cause.
1935
- if (isWedgedWriteError(error)) {
1936
- throw error;
1937
- }
1938
1610
  return false;
1939
1611
  }
1940
1612
  }
@@ -1944,7 +1616,7 @@ export default class ReactNativeBleTransport {
1944
1616
  return false;
1945
1617
  }
1946
1618
 
1947
- this.probingProtocols.set(uuid, 'V2');
1619
+ this.deviceProtocol.set(uuid, 'V2');
1948
1620
  this.protocolV2Assemblers.get(uuid)?.reset();
1949
1621
  const detected = await probeProtocolV2Helper({
1950
1622
  call: (name: string, data: Record<string, unknown>, options?: TransportCallOptions) =>
@@ -1959,8 +1631,6 @@ export default class ReactNativeBleTransport {
1959
1631
  });
1960
1632
  if (!detected) {
1961
1633
  this.clearProbeProtocol(uuid, 'V2');
1962
- } else {
1963
- this.probingProtocols.delete(uuid);
1964
1634
  }
1965
1635
  return detected;
1966
1636
  }
@@ -2042,12 +1712,14 @@ export default class ReactNativeBleTransport {
2042
1712
  }
2043
1713
 
2044
1714
  private async writeProtocolV2Packet(
2045
- uuid: string,
2046
1715
  transport: BleTransport,
2047
1716
  base64: string,
2048
1717
  context: ProtocolV2CallContext,
2049
1718
  assertCurrentGeneration: () => void
2050
1719
  ) {
1720
+ const shouldUseWriteWithResponse =
1721
+ transport.writeCharacteristic.isWritableWithResponse &&
1722
+ (context.writeWithResponse === true || (Platform.OS === 'ios' && !context.highVolume));
2051
1723
  let attempt = 0;
2052
1724
  for (;;) {
2053
1725
  assertCurrentGeneration();
@@ -2055,21 +1727,11 @@ export default class ReactNativeBleTransport {
2055
1727
  throw new Error(`Protocol V2 BLE write aborted for ${context.messageName}`);
2056
1728
  }
2057
1729
  try {
2058
- await this.writeBlePacket(
2059
- uuid,
2060
- base64,
2061
- payload => transport.writeCharacteristic.writeWithoutResponse(payload),
2062
- // Same rule as Protocol V1: a write from a superseded generation must not
2063
- // tear down the link that the current generation is using.
2064
- () => {
2065
- try {
2066
- assertCurrentGeneration();
2067
- return !context.signal.aborted;
2068
- } catch {
2069
- return false;
2070
- }
2071
- }
2072
- );
1730
+ if (shouldUseWriteWithResponse) {
1731
+ await transport.writeCharacteristic.writeWithResponse(base64);
1732
+ } else {
1733
+ await transport.writeCharacteristic.writeWithoutResponse(base64);
1734
+ }
2073
1735
  assertCurrentGeneration();
2074
1736
  return;
2075
1737
  } catch (error) {
@@ -2092,7 +1754,6 @@ export default class ReactNativeBleTransport {
2092
1754
  }
2093
1755
 
2094
1756
  private async writeProtocolV2Frame(
2095
- uuid: string,
2096
1757
  transport: BleTransport,
2097
1758
  frame: Uint8Array,
2098
1759
  context: ProtocolV2CallContext,
@@ -2105,19 +1766,25 @@ export default class ReactNativeBleTransport {
2105
1766
  androidPacketLength: tuning.androidPacketLength,
2106
1767
  mtu: Platform.OS === 'android' ? transport.mtuSize : undefined,
2107
1768
  });
1769
+ // Match Desktop BLE pacing so Pro2 firmware can finish the previous response
1770
+ // before the next single-packet control command is written.
1771
+ const initialDelayMs =
1772
+ Platform.OS === 'ios' && !context.highVolume && frame.length <= packetCapacity
1773
+ ? IOS_PROTOCOL_V2_CONTROL_WRITE_DELAY_MS
1774
+ : 0;
2108
1775
  await writeProtocolV2BleFrame({
2109
1776
  frame,
2110
1777
  packetCapacity,
2111
1778
  assertActive: assertCurrentGeneration,
2112
1779
  signal: context.signal,
2113
1780
  abortMessage: `Protocol V2 BLE write aborted for ${context.messageName}`,
1781
+ initialDelayMs,
2114
1782
  burstSize: FIRMWARE_UPLOAD_WRITE_BURST_SIZE,
2115
1783
  burstPauseMs: FIRMWARE_UPLOAD_WRITE_PAUSE_MS,
2116
1784
  flushDelayMs: FIRMWARE_UPLOAD_WRITE_FLUSH_DELAY_MS,
2117
1785
  wait: delay,
2118
1786
  writePacket: packet =>
2119
1787
  this.writeProtocolV2Packet(
2120
- uuid,
2121
1788
  transport,
2122
1789
  Buffer.from(packet).toString('base64'),
2123
1790
  context,
@@ -2143,7 +1810,7 @@ export default class ReactNativeBleTransport {
2143
1810
  const tuning = getProtocolV2BleTuning();
2144
1811
  Log?.debug('[ReactNativeBleTransport] Protocol V2 high-volume write configured', {
2145
1812
  name,
2146
- writeMode: 'withoutResponse',
1813
+ writeMode: options?.writeWithResponse ? 'withResponse' : 'withoutResponse',
2147
1814
  packetCapacity: Platform.OS === 'ios' ? tuning.iosPacketLength : tuning.androidPacketLength,
2148
1815
  });
2149
1816
  }
@@ -2182,13 +1849,7 @@ export default class ReactNativeBleTransport {
2182
1849
  writeFrame: async (frame: Uint8Array, context: ProtocolV2CallContext) => {
2183
1850
  assertCurrentGeneration();
2184
1851
  const currentTransport = this.getCachedTransport(uuid);
2185
- await this.writeProtocolV2Frame(
2186
- uuid,
2187
- currentTransport,
2188
- frame,
2189
- context,
2190
- assertCurrentGeneration
2191
- );
1852
+ await this.writeProtocolV2Frame(currentTransport, frame, context, assertCurrentGeneration);
2192
1853
  },
2193
1854
  readFrame: async () => {
2194
1855
  assertCurrentGeneration();
@@ -2213,6 +1874,6 @@ export default class ReactNativeBleTransport {
2213
1874
  }
2214
1875
 
2215
1876
  getProtocolType(path: string): ProtocolType | undefined {
2216
- return this.getActiveProtocol(path);
1877
+ return this.deviceProtocol.get(path);
2217
1878
  }
2218
1879
  }