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

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;
@@ -106,22 +108,6 @@ const resolveFirmwareUploadRetryDelay = (attempt: number, baseDelayMs = 200, max
106
108
  Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
107
109
  const PROTOCOL_PROBE_TIMEOUT_MS = 1000;
108
110
  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
111
  const DEVICE_SCAN_TIMEOUT_MS = 3000;
126
112
  const IOS_NOTIFY_READY_DELAY_MS = 150;
127
113
  const ANDROID_NOTIFY_READY_DELAY_MS = 300;
@@ -272,17 +258,6 @@ export default class ReactNativeBleTransport {
272
258
  /** Per-device protocol type detected by active wire-level probe after connect. */
273
259
  private deviceProtocol: Map<string, ProtocolType> = new Map();
274
260
 
275
- /**
276
- * Protocol a probe is currently trying, before the device has confirmed it. Calls
277
- * must route with it, but acquire() must not treat it as a detected protocol: a
278
- * probe that never answers would otherwise leave the reuse fast path handing out a
279
- * transport that was never validated.
280
- */
281
- private probingProtocols: Map<string, ProtocolType> = new Map();
282
-
283
- /** Consecutive write timeouts per device; reset by any write that completes. */
284
- private writeTimeoutCounts: Map<string, number> = new Map();
285
-
286
261
  private deviceProtocolHints: Map<string, ProtocolType> = new Map();
287
262
 
288
263
  private protocolV2Assemblers: Map<string, ProtocolV2FrameAssembler> = new Map();
@@ -607,12 +582,21 @@ export default class ReactNativeBleTransport {
607
582
  }
608
583
 
609
584
  const displayName = getDeviceDisplayName(device);
610
- const isOneKey = isOnekeyBluetoothDevice({
611
- id: device?.id,
612
- name: device?.name,
613
- localName: device?.localName,
614
- serviceUuids: device?.serviceUUIDs,
615
- });
585
+ // iOS may report a service-only advertisement before the named scan response.
586
+ // Do not cache that incomplete advertisement as an unknown device.
587
+ const isUnnamedIOSPeripheral = Platform.OS === 'ios' && !displayName?.trim();
588
+ const isFindMyPeripheral =
589
+ isPro2FindMyAdvertisementName(device?.name) ||
590
+ isPro2FindMyAdvertisementName(device?.localName);
591
+ const isOneKey =
592
+ !isUnnamedIOSPeripheral &&
593
+ !isFindMyPeripheral &&
594
+ isOnekeyBluetoothDevice({
595
+ id: device?.id,
596
+ name: device?.name,
597
+ localName: device?.localName,
598
+ serviceUuids: device?.serviceUUIDs,
599
+ });
616
600
  if (isOneKey) {
617
601
  addDevice(device as unknown as Device);
618
602
  } else if (displayName && /\bpro\s*2\b/i.test(displayName)) {
@@ -633,7 +617,12 @@ export default class ReactNativeBleTransport {
633
617
  'localName' in device && typeof device.localName === 'string'
634
618
  ? device.localName
635
619
  : null;
620
+ const isFindMyPeripheral =
621
+ isPro2FindMyAdvertisementName(device.name) ||
622
+ isPro2FindMyAdvertisementName(localName);
623
+
636
624
  if (
625
+ !isFindMyPeripheral &&
637
626
  isOnekeyBluetoothDevice({
638
627
  id: device.id,
639
628
  name: device.name,
@@ -906,7 +895,7 @@ export default class ReactNativeBleTransport {
906
895
  Log?.debug('monitor error ignored for stale transport: ', uuid, notifyTransactionId);
907
896
  return;
908
897
  }
909
- if (this.getActiveProtocol(uuid) === 'V2') {
898
+ if (this.deviceProtocol.get(uuid) === 'V2') {
910
899
  let errorCode:
911
900
  | typeof HardwareErrorCode.BleDeviceBondError
912
901
  | typeof HardwareErrorCode.BleCharacteristicNotifyError
@@ -976,7 +965,7 @@ export default class ReactNativeBleTransport {
976
965
 
977
966
  try {
978
967
  const data = Buffer.from(c.value as string, 'base64');
979
- const protocol = this.getActiveProtocol(uuid);
968
+ const protocol = this.deviceProtocol.get(uuid);
980
969
  if (!protocol) {
981
970
  Log?.debug('monitor data ignored before protocol detection: ', uuid);
982
971
  return;
@@ -1010,7 +999,7 @@ export default class ReactNativeBleTransport {
1010
999
  } catch (error) {
1011
1000
  Log?.debug('monitor data error: ', error);
1012
1001
  const notifyError = ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
1013
- if (this.getActiveProtocol(uuid) === 'V2') {
1002
+ if (this.deviceProtocol.get(uuid) === 'V2') {
1014
1003
  this.rejectProtocolV2Frames(uuid, notifyError);
1015
1004
  } else if (this.runPromiseDeviceId === uuid) {
1016
1005
  this.runPromise?.reject(notifyError);
@@ -1074,7 +1063,6 @@ export default class ReactNativeBleTransport {
1074
1063
  }
1075
1064
 
1076
1065
  this.deviceProtocol.delete(uuid);
1077
- this.probingProtocols.delete(uuid);
1078
1066
  // Preserve a name-derived hint across disconnects so reconnect can probe V2 first.
1079
1067
  this.protocolV2Assemblers.get(uuid)?.reset();
1080
1068
  this.protocolV2Assemblers.delete(uuid);
@@ -1141,25 +1129,8 @@ export default class ReactNativeBleTransport {
1141
1129
  const transport = this.getCachedTransport(uuid);
1142
1130
  const runPromise = createDeferred<string>();
1143
1131
  runPromise.promise.catch(() => undefined);
1144
- const supersededRunPromise = this.runPromise;
1145
- if (supersededRunPromise) {
1146
- // Only forceRun calls (Initialize/Cancel) reach here with a pending call. Settle
1147
- // the superseded deferred now so its response race resolves and its finally block
1148
- // clears its timeout timer; an orphaned timer would otherwise fire much later and
1149
- // tear down the shared connection while another call is using it.
1150
- supersededRunPromise.reject(ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise));
1151
- }
1152
1132
  this.runPromise = runPromise;
1153
1133
  this.runPromiseDeviceId = uuid;
1154
- // A superseded call's late write failure must not clear the successor's ownership;
1155
- // only the call that still owns the slot may release it.
1156
- const releaseOwnershipIfCurrent = () => {
1157
- if (this.runPromise === runPromise) {
1158
- this.runPromise = null;
1159
- this.runPromiseDeviceId = null;
1160
- }
1161
- };
1162
- const isCurrentOwner = () => this.runPromise === runPromise;
1163
1134
  const messages = this._messages;
1164
1135
  const buffers = ProtocolV1.encodeTransportPackets(messages, name, data);
1165
1136
  let timeout: ReturnType<typeof setTimeout> | undefined;
@@ -1185,9 +1156,6 @@ export default class ReactNativeBleTransport {
1185
1156
  chunk = ByteBuffer.allocate(packetCapacity);
1186
1157
  } catch (e) {
1187
1158
  onError(e);
1188
- if (isWedgedWriteError(e)) {
1189
- throw e;
1190
- }
1191
1159
  throw ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
1192
1160
  }
1193
1161
  }
@@ -1219,9 +1187,6 @@ export default class ReactNativeBleTransport {
1219
1187
  }
1220
1188
  } catch (e) {
1221
1189
  onError(e);
1222
- if (isWedgedWriteError(e)) {
1223
- throw e;
1224
- }
1225
1190
  throw ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
1226
1191
  }
1227
1192
  }
@@ -1235,15 +1200,9 @@ export default class ReactNativeBleTransport {
1235
1200
  if (name === 'EmmcFileWrite') {
1236
1201
  await writeChunkedData(
1237
1202
  buffers,
1238
- data =>
1239
- this.writeBlePacket(
1240
- uuid,
1241
- data,
1242
- payload => transport.writeWithRetry(payload),
1243
- isCurrentOwner
1244
- ),
1203
+ data => transport.writeWithRetry(data),
1245
1204
  e => {
1246
- releaseOwnershipIfCurrent();
1205
+ this.runPromise = null;
1247
1206
  Log?.error('writeCharacteristic write error: ', e);
1248
1207
  }
1249
1208
  );
@@ -1264,12 +1223,7 @@ export default class ReactNativeBleTransport {
1264
1223
  // eslint-disable-next-line no-constant-condition
1265
1224
  while (true) {
1266
1225
  try {
1267
- await this.writeBlePacket(
1268
- uuid,
1269
- data,
1270
- payload => transport.writeCharacteristic.writeWithoutResponse(payload),
1271
- isCurrentOwner
1272
- );
1226
+ await transport.writeCharacteristic.writeWithoutResponse(data);
1273
1227
  return;
1274
1228
  } catch (error) {
1275
1229
  const retryType = getFirmwareUploadWriteRetryType(error);
@@ -1288,7 +1242,7 @@ export default class ReactNativeBleTransport {
1288
1242
  }
1289
1243
  },
1290
1244
  e => {
1291
- releaseOwnershipIfCurrent();
1245
+ this.runPromise = null;
1292
1246
  Log?.error('writeCharacteristic write error: ', e);
1293
1247
  }
1294
1248
  );
@@ -1297,18 +1251,16 @@ export default class ReactNativeBleTransport {
1297
1251
  const outData = o.toString('base64');
1298
1252
  // Upload resources on low-end phones may OOM
1299
1253
  try {
1300
- await this.writeBlePacket(
1301
- uuid,
1302
- outData,
1303
- payload => transport.writeCharacteristic.writeWithoutResponse(payload),
1304
- isCurrentOwner
1305
- );
1254
+ const shouldUseWriteWithResponse =
1255
+ Platform.OS === 'ios' && transport.writeCharacteristic.isWritableWithResponse;
1256
+ if (shouldUseWriteWithResponse) {
1257
+ await transport.writeCharacteristic.writeWithResponse(outData);
1258
+ } else {
1259
+ await transport.writeCharacteristic.writeWithoutResponse(outData);
1260
+ }
1306
1261
  } catch (e) {
1307
1262
  Log?.debug('writeCharacteristic write error: ', e);
1308
- releaseOwnershipIfCurrent();
1309
- if (isWedgedWriteError(e)) {
1310
- throw e;
1311
- }
1263
+ this.runPromise = null;
1312
1264
  if (e.errorCode === BleErrorCode.DeviceDisconnected) {
1313
1265
  throw ERRORS.TypedError(HardwareErrorCode.BleDeviceNotBonded);
1314
1266
  } else if (e.errorCode === BleErrorCode.OperationStartFailed) {
@@ -1351,13 +1303,8 @@ export default class ReactNativeBleTransport {
1351
1303
  }
1352
1304
  const isProbeTimeout =
1353
1305
  name === 'GetFeatures' && options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS;
1354
- // A call that has been superseded (forceRun) or cleaned up no longer owns the
1355
- // transport; its late timeout must not tear down the connection the current
1356
- // call is actively using.
1357
- const isStaleCall = this.runPromise !== runPromise;
1358
1306
  if (
1359
1307
  !isProbeTimeout &&
1360
- !isStaleCall &&
1361
1308
  (e as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleTimeoutError
1362
1309
  ) {
1363
1310
  await this.disconnect(uuid);
@@ -1437,7 +1384,6 @@ export default class ReactNativeBleTransport {
1437
1384
  delete transportCache[session];
1438
1385
  }
1439
1386
  this.deviceProtocol.delete(session);
1440
- this.probingProtocols.delete(session);
1441
1387
  this.deviceProtocolHints.delete(session);
1442
1388
  this.protocolV2Assemblers.delete(session);
1443
1389
  this.resetProtocolV2Frames(session);
@@ -1472,105 +1418,6 @@ export default class ReactNativeBleTransport {
1472
1418
  return transport;
1473
1419
  }
1474
1420
 
1475
- /**
1476
- * Write one packet under a bounded budget. A write that never settles means the
1477
- * peripheral is wedged even though the GATT link still reports connected, so the
1478
- * link is torn down: releasing JS state alone would leave the poisoned peripheral
1479
- * cached and every later call would hang on it again.
1480
- */
1481
- private async writeBlePacket(
1482
- uuid: string,
1483
- data: string,
1484
- write: (payload: string) => Promise<unknown>,
1485
- isCurrentOwner?: () => boolean
1486
- ) {
1487
- let timer: ReturnType<typeof setTimeout> | undefined;
1488
- let timedOut = false;
1489
- try {
1490
- await Promise.race([
1491
- write(data),
1492
- new Promise<never>((_, reject) => {
1493
- timer = setTimeout(() => {
1494
- timedOut = true;
1495
- reject(
1496
- ERRORS.TypedError(
1497
- HardwareErrorCode.BleWriteCharacteristicError,
1498
- `BLE write timeout after ${BLE_WRITE_PACKET_TIMEOUT_MS}ms`
1499
- )
1500
- );
1501
- }, BLE_WRITE_PACKET_TIMEOUT_MS);
1502
- }),
1503
- ]);
1504
- this.writeTimeoutCounts.delete(uuid);
1505
- } catch (error) {
1506
- if (timedOut) {
1507
- // A superseded call's late write must not tear down the link the current
1508
- // call is using; only the owner of the transport may declare it dead.
1509
- if (isCurrentOwner && !isCurrentOwner()) {
1510
- Log?.debug('[ReactNativeBleTransport] stale BLE write timed out, link kept:', uuid);
1511
- } else {
1512
- this.tearDownWedgedLink(uuid);
1513
- }
1514
- }
1515
- throw error;
1516
- } finally {
1517
- if (timer) clearTimeout(timer);
1518
- }
1519
- }
1520
-
1521
- /**
1522
- * Drop a link whose writes stopped completing. The JS state is purged synchronously
1523
- * so the next acquire() cannot reuse the dead transport, while the native teardown is
1524
- * intentionally NOT awaited: it talks to the very layer that just stopped settling
1525
- * promises, so awaiting it could hang exactly like the write it is recovering from.
1526
- */
1527
- private tearDownWedgedLink(uuid: string) {
1528
- const timeouts = (this.writeTimeoutCounts.get(uuid) ?? 0) + 1;
1529
- this.writeTimeoutCounts.set(uuid, timeouts);
1530
- Log?.error('[ReactNativeBleTransport] BLE write timed out, tearing down link:', uuid, {
1531
- consecutiveWriteTimeouts: timeouts,
1532
- });
1533
-
1534
- const wedged = transportCache[uuid];
1535
- this.disconnect(uuid).catch(error => {
1536
- Log?.debug('[ReactNativeBleTransport] wedged link teardown failed (ignored):', error);
1537
- });
1538
- if (wedged && transportCache[uuid] === wedged) {
1539
- delete transportCache[uuid];
1540
- }
1541
- this.deviceProtocol.delete(uuid);
1542
- this.probingProtocols.delete(uuid);
1543
- this.protocolV2Assemblers.delete(uuid);
1544
- this.resetProtocolV2Frames(uuid);
1545
-
1546
- if (timeouts >= BLE_WRITE_TIMEOUT_MANAGER_RESET_THRESHOLD) {
1547
- // Reconnecting reuses the same native peripheral object. When it stays wedged
1548
- // across attempts the poison lives in the BLE manager itself, and only a fresh
1549
- // manager drops every cached peripheral — the JS equivalent of restarting the app.
1550
- Log?.error('[ReactNativeBleTransport] BLE writes wedged repeatedly, resetting BLE manager');
1551
- this.resetPlxManager();
1552
- this.writeTimeoutCounts.delete(uuid);
1553
- }
1554
- }
1555
-
1556
- private resetPlxManager() {
1557
- const manager = this.blePlxManager;
1558
- this.blePlxManager = undefined;
1559
- // Every cached transport belongs to the destroyed manager's peripherals.
1560
- Object.keys(transportCache).forEach(key => {
1561
- delete transportCache[key];
1562
- });
1563
- this.deviceProtocol.clear();
1564
- this.probingProtocols.clear();
1565
- this.monitorTokens.clear();
1566
- this.protocolV2Assemblers.clear();
1567
- try {
1568
- manager?.destroy();
1569
- } catch (error) {
1570
- Log?.debug('[ReactNativeBleTransport] BLE manager destroy failed (ignored):', error);
1571
- }
1572
- }
1573
-
1574
1421
  private createProtocolMismatchError(expected: ProtocolType) {
1575
1422
  return ERRORS.TypedError(
1576
1423
  HardwareErrorCode.RuntimeError,
@@ -1586,25 +1433,34 @@ export default class ReactNativeBleTransport {
1586
1433
  }
1587
1434
 
1588
1435
  private clearProbeProtocol(uuid: string, protocol: ProtocolType) {
1589
- if (this.probingProtocols.get(uuid) === protocol) {
1590
- this.probingProtocols.delete(uuid);
1591
- }
1592
1436
  if (this.deviceProtocol.get(uuid) === protocol) {
1593
1437
  this.deviceProtocol.delete(uuid);
1594
1438
  }
1595
1439
  }
1596
1440
 
1597
- /** Protocol to route a call with: confirmed if known, otherwise the one being probed. */
1598
- private getActiveProtocol(uuid: string): ProtocolType | undefined {
1599
- return this.deviceProtocol.get(uuid) ?? this.probingProtocols.get(uuid);
1600
- }
1601
-
1602
1441
  private async detectProtocol(
1603
1442
  uuid: string,
1604
1443
  expectedProtocol?: ProtocolType,
1605
1444
  protocolHint?: ProtocolType,
1606
1445
  rebuildTransport?: () => Promise<void>
1607
1446
  ): Promise<ProtocolType> {
1447
+ if (Platform.OS === 'ios') {
1448
+ const protocol = expectedProtocol ?? protocolHint ?? 'V1';
1449
+ let source = 'ios-legacy-default';
1450
+ if (expectedProtocol) {
1451
+ source = 'expected';
1452
+ } else if (protocolHint) {
1453
+ source = 'hint';
1454
+ }
1455
+ this.deviceProtocol.set(uuid, protocol);
1456
+ Log?.debug('[ReactNativeBleTransport] protocol selected', {
1457
+ deviceId: uuid,
1458
+ protocol,
1459
+ source,
1460
+ });
1461
+ return protocol;
1462
+ }
1463
+
1608
1464
  if (expectedProtocol === 'V1') {
1609
1465
  if (await this.probeProtocolV1(uuid)) {
1610
1466
  this.deviceProtocol.set(uuid, 'V1');
@@ -1662,7 +1518,6 @@ export default class ReactNativeBleTransport {
1662
1518
  }
1663
1519
 
1664
1520
  this.deviceProtocol.delete(uuid);
1665
- this.probingProtocols.delete(uuid);
1666
1521
  throw this.createProtocolDetectionError();
1667
1522
  }
1668
1523
 
@@ -1724,20 +1579,14 @@ export default class ReactNativeBleTransport {
1724
1579
  }
1725
1580
 
1726
1581
  try {
1727
- this.probingProtocols.set(uuid, 'V1');
1582
+ this.deviceProtocol.set(uuid, 'V1');
1728
1583
  // GetFeatures identifies Protocol V1 without resetting an existing wallet
1729
1584
  // session before Core has a chance to restore a hidden wallet.
1730
1585
  await this.callProtocolV1(uuid, 'GetFeatures', {}, { timeoutMs: PROTOCOL_PROBE_TIMEOUT_MS });
1731
- this.probingProtocols.delete(uuid);
1732
1586
  return true;
1733
1587
  } catch (error) {
1734
1588
  this.clearProbeProtocol(uuid, 'V1');
1735
1589
  Log?.debug('[ReactNativeBleTransport] Protocol V1 GetFeatures probe failed:', error);
1736
- // A wedged write already dropped the link, so probing another protocol on it
1737
- // would only fail against a torn-down transport: surface the real cause.
1738
- if (isWedgedWriteError(error)) {
1739
- throw error;
1740
- }
1741
1590
  return false;
1742
1591
  }
1743
1592
  }
@@ -1747,7 +1596,7 @@ export default class ReactNativeBleTransport {
1747
1596
  return false;
1748
1597
  }
1749
1598
 
1750
- this.probingProtocols.set(uuid, 'V2');
1599
+ this.deviceProtocol.set(uuid, 'V2');
1751
1600
  this.protocolV2Assemblers.get(uuid)?.reset();
1752
1601
  const detected = await probeProtocolV2Helper({
1753
1602
  call: (name: string, data: Record<string, unknown>, options?: TransportCallOptions) =>
@@ -1762,8 +1611,6 @@ export default class ReactNativeBleTransport {
1762
1611
  });
1763
1612
  if (!detected) {
1764
1613
  this.clearProbeProtocol(uuid, 'V2');
1765
- } else {
1766
- this.probingProtocols.delete(uuid);
1767
1614
  }
1768
1615
  return detected;
1769
1616
  }
@@ -1845,12 +1692,15 @@ export default class ReactNativeBleTransport {
1845
1692
  }
1846
1693
 
1847
1694
  private async writeProtocolV2Packet(
1848
- uuid: string,
1849
1695
  transport: BleTransport,
1850
1696
  base64: string,
1851
1697
  context: ProtocolV2CallContext,
1852
1698
  assertCurrentGeneration: () => void
1853
1699
  ) {
1700
+ const shouldUseWriteWithResponse =
1701
+ Platform.OS === 'ios' &&
1702
+ !context.highVolume &&
1703
+ transport.writeCharacteristic.isWritableWithResponse;
1854
1704
  let attempt = 0;
1855
1705
  for (;;) {
1856
1706
  assertCurrentGeneration();
@@ -1858,21 +1708,11 @@ export default class ReactNativeBleTransport {
1858
1708
  throw new Error(`Protocol V2 BLE write aborted for ${context.messageName}`);
1859
1709
  }
1860
1710
  try {
1861
- await this.writeBlePacket(
1862
- uuid,
1863
- base64,
1864
- payload => transport.writeCharacteristic.writeWithoutResponse(payload),
1865
- // Same rule as Protocol V1: a write from a superseded generation must not
1866
- // tear down the link that the current generation is using.
1867
- () => {
1868
- try {
1869
- assertCurrentGeneration();
1870
- return !context.signal.aborted;
1871
- } catch {
1872
- return false;
1873
- }
1874
- }
1875
- );
1711
+ if (shouldUseWriteWithResponse) {
1712
+ await transport.writeCharacteristic.writeWithResponse(base64);
1713
+ } else {
1714
+ await transport.writeCharacteristic.writeWithoutResponse(base64);
1715
+ }
1876
1716
  assertCurrentGeneration();
1877
1717
  return;
1878
1718
  } catch (error) {
@@ -1895,7 +1735,6 @@ export default class ReactNativeBleTransport {
1895
1735
  }
1896
1736
 
1897
1737
  private async writeProtocolV2Frame(
1898
- uuid: string,
1899
1738
  transport: BleTransport,
1900
1739
  frame: Uint8Array,
1901
1740
  context: ProtocolV2CallContext,
@@ -1908,19 +1747,25 @@ export default class ReactNativeBleTransport {
1908
1747
  androidPacketLength: tuning.androidPacketLength,
1909
1748
  mtu: Platform.OS === 'android' ? transport.mtuSize : undefined,
1910
1749
  });
1750
+ // Match Desktop BLE pacing so Pro2 firmware can finish the previous response
1751
+ // before the next single-packet control command is written.
1752
+ const initialDelayMs =
1753
+ Platform.OS === 'ios' && !context.highVolume && frame.length <= packetCapacity
1754
+ ? IOS_PROTOCOL_V2_CONTROL_WRITE_DELAY_MS
1755
+ : 0;
1911
1756
  await writeProtocolV2BleFrame({
1912
1757
  frame,
1913
1758
  packetCapacity,
1914
1759
  assertActive: assertCurrentGeneration,
1915
1760
  signal: context.signal,
1916
1761
  abortMessage: `Protocol V2 BLE write aborted for ${context.messageName}`,
1762
+ initialDelayMs,
1917
1763
  burstSize: FIRMWARE_UPLOAD_WRITE_BURST_SIZE,
1918
1764
  burstPauseMs: FIRMWARE_UPLOAD_WRITE_PAUSE_MS,
1919
1765
  flushDelayMs: FIRMWARE_UPLOAD_WRITE_FLUSH_DELAY_MS,
1920
1766
  wait: delay,
1921
1767
  writePacket: packet =>
1922
1768
  this.writeProtocolV2Packet(
1923
- uuid,
1924
1769
  transport,
1925
1770
  Buffer.from(packet).toString('base64'),
1926
1771
  context,
@@ -1985,13 +1830,7 @@ export default class ReactNativeBleTransport {
1985
1830
  writeFrame: async (frame: Uint8Array, context: ProtocolV2CallContext) => {
1986
1831
  assertCurrentGeneration();
1987
1832
  const currentTransport = this.getCachedTransport(uuid);
1988
- await this.writeProtocolV2Frame(
1989
- uuid,
1990
- currentTransport,
1991
- frame,
1992
- context,
1993
- assertCurrentGeneration
1994
- );
1833
+ await this.writeProtocolV2Frame(currentTransport, frame, context, assertCurrentGeneration);
1995
1834
  },
1996
1835
  readFrame: async () => {
1997
1836
  assertCurrentGeneration();
@@ -2016,6 +1855,6 @@ export default class ReactNativeBleTransport {
2016
1855
  }
2017
1856
 
2018
1857
  getProtocolType(path: string): ProtocolType | undefined {
2019
- return this.getActiveProtocol(path);
1858
+ return this.deviceProtocol.get(path);
2020
1859
  }
2021
1860
  }