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

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.
@@ -3,7 +3,6 @@ import transportPackage, {
3
3
  PROTOCOL_V2_CHANNEL_BLE_UART,
4
4
  ProtocolV2,
5
5
  TRANSPORT_EVENT,
6
- bytesToHex,
7
6
  } from '@onekeyfe/hd-transport';
8
7
  import { HardwareErrorCode, createDeferred } from '@onekeyfe/hd-shared';
9
8
 
@@ -33,6 +32,7 @@ jest.mock('react-native-ble-plx', () => ({
33
32
  CharacteristicNotFound: 404,
34
33
  },
35
34
  BleManager: jest.fn(),
35
+ ConnectionPriority: { Balanced: 0, High: 1, LowPower: 2 },
36
36
  ScanMode: { LowLatency: 2 },
37
37
  }));
38
38
 
@@ -46,6 +46,11 @@ jest.mock('../subscribeBleOn', () => ({
46
46
  subscribeBleOn: jest.fn(() => Promise.resolve()),
47
47
  }));
48
48
 
49
+ const setPlatformOS = (os: 'ios' | 'android') => {
50
+ const reactNative: { Platform: { OS: string } } = jest.requireMock('react-native');
51
+ reactNative.Platform.OS = os;
52
+ };
53
+
49
54
  const { parseConfigure } = transportPackage;
50
55
 
51
56
  const protocolV1Schema = {
@@ -69,11 +74,13 @@ const protocolV1Schema = {
69
74
 
70
75
  const protocolV2Schema = {
71
76
  nested: {
77
+ ProtocolInfoRequest: { fields: {} },
72
78
  Ping: {
73
79
  fields: {
74
80
  message: { type: 'string', id: 1 },
75
81
  },
76
82
  },
83
+ DeviceInfoGet: { fields: {} },
77
84
  FileWrite: { fields: {} },
78
85
  Success: {
79
86
  fields: {
@@ -82,8 +89,10 @@ const protocolV2Schema = {
82
89
  },
83
90
  MessageType: {
84
91
  values: {
92
+ MessageType_ProtocolInfoRequest: 60200,
85
93
  MessageType_Ping: 60206,
86
94
  MessageType_Success: 60207,
95
+ MessageType_DeviceInfoGet: 60600,
87
96
  MessageType_FileWrite: 60805,
88
97
  },
89
98
  },
@@ -95,7 +104,13 @@ const schemas = {
95
104
  protocolV2: parseConfigure(protocolV2Schema),
96
105
  };
97
106
 
98
- const createHarness = () => {
107
+ const createHarness = ({
108
+ deviceName = 'OneKey Pro 2',
109
+ isWritableWithResponse = true,
110
+ }: {
111
+ deviceName?: string;
112
+ isWritableWithResponse?: boolean;
113
+ } = {}) => {
99
114
  const uuid = 'rn-pro2-id';
100
115
  const sentSeqs: number[] = [];
101
116
  let responseSeq = 0;
@@ -134,15 +149,16 @@ const createHarness = () => {
134
149
  const writeCharacteristic = {
135
150
  uuid: '0002',
136
151
  deviceID: uuid,
137
- isWritableWithResponse: true,
152
+ isWritableWithResponse,
138
153
  isWritableWithoutResponse: true,
139
154
  writeWithResponse: jest.fn(handleWrite),
140
155
  writeWithoutResponse: jest.fn(handleWrite),
141
156
  };
142
157
  const device = {
143
158
  id: uuid,
144
- name: 'OneKey Pro 2',
145
- localName: 'OneKey Pro 2',
159
+ name: deviceName,
160
+ localName: deviceName,
161
+ mtu: 247,
146
162
  serviceUUIDs: ['00000001-0000-1000-8000-00805f9b34fb'],
147
163
  isConnected: jest.fn(() => Promise.resolve(true)),
148
164
  cancelConnection: jest.fn(() => Promise.resolve()),
@@ -150,7 +166,9 @@ const createHarness = () => {
150
166
  disconnectCallback = callback;
151
167
  return { remove: jest.fn() };
152
168
  }),
153
- };
169
+ } as any;
170
+ device.requestMTU = jest.fn(() => Promise.resolve(device));
171
+ device.requestConnectionPriority = jest.fn(() => Promise.resolve(device));
154
172
  const bleManager = {
155
173
  devices: jest.fn(() => Promise.resolve([device])),
156
174
  connectedDevices: jest.fn(() => Promise.resolve([])),
@@ -158,18 +176,21 @@ const createHarness = () => {
158
176
  };
159
177
  const transport = new ReactNativeBleTransport({ scanTimeout: 1 });
160
178
  const emitter = new EventEmitter();
179
+ const logger = { debug: jest.fn(), error: jest.fn() };
161
180
  transport.blePlxManager = bleManager;
162
181
  transport.resolveCharacteristics = jest.fn(() =>
163
182
  Promise.resolve({ writeCharacteristic, notifyCharacteristic })
164
183
  );
165
- transport.init({ debug: jest.fn(), error: jest.fn() }, emitter);
184
+ transport.init(logger, emitter);
166
185
  transport.configure(protocolV1Schema);
167
186
  transport.configureProtocolV2(protocolV2Schema);
168
187
 
169
188
  return {
170
189
  transport,
171
190
  emitter,
191
+ logger,
172
192
  uuid,
193
+ device,
173
194
  sentSeqs,
174
195
  writeCharacteristic,
175
196
  setShouldRespond(value: boolean) {
@@ -184,7 +205,13 @@ const createHarness = () => {
184
205
  };
185
206
  };
186
207
 
187
- const createV1Harness = () => {
208
+ const createV1Harness = ({
209
+ respondOnWriteCount = 1,
210
+ isWritableWithResponse = true,
211
+ }: {
212
+ respondOnWriteCount?: number | number[];
213
+ isWritableWithResponse?: boolean;
214
+ } = {}) => {
188
215
  const uuid = 'rn-classic-id';
189
216
  const notifySubscriptionRemovers: jest.Mock[] = [];
190
217
  const disconnectSubscriptionRemovers: jest.Mock[] = [];
@@ -203,25 +230,31 @@ const createV1Harness = () => {
203
230
  }),
204
231
  };
205
232
  let writeCount = 0;
233
+ const responseWriteCounts = new Set(
234
+ Array.isArray(respondOnWriteCount) ? respondOnWriteCount : [respondOnWriteCount]
235
+ );
236
+ const handleWrite = () => {
237
+ writeCount += 1;
238
+ if (responseWriteCounts.has(writeCount)) {
239
+ notifyCallback?.(null, {
240
+ value: Buffer.from('3f23230002000000040a026f6b', 'hex').toString('base64'),
241
+ });
242
+ }
243
+ return Promise.resolve();
244
+ };
206
245
  const writeCharacteristic = {
207
246
  uuid: '0002',
208
247
  deviceID: uuid,
209
- isWritableWithResponse: true,
248
+ isWritableWithResponse,
210
249
  isWritableWithoutResponse: true,
211
- writeWithoutResponse: jest.fn(() => {
212
- writeCount += 1;
213
- if (writeCount === 1) {
214
- notifyCallback?.(null, {
215
- value: Buffer.from('3f23230002000000040a026f6b', 'hex').toString('base64'),
216
- });
217
- }
218
- return Promise.resolve();
219
- }),
250
+ writeWithResponse: jest.fn(handleWrite),
251
+ writeWithoutResponse: jest.fn(handleWrite),
220
252
  };
221
253
  const device = {
222
254
  id: uuid,
223
255
  name: 'OneKey Classic',
224
256
  localName: 'OneKey Classic',
257
+ mtu: 247,
225
258
  serviceUUIDs: ['00000001-0000-1000-8000-00805f9b34fb'],
226
259
  isConnected: jest.fn(() => Promise.resolve(true)),
227
260
  cancelConnection: jest.fn(() => Promise.resolve()),
@@ -230,7 +263,8 @@ const createV1Harness = () => {
230
263
  disconnectSubscriptionRemovers.push(remove);
231
264
  return { remove };
232
265
  }),
233
- };
266
+ } as any;
267
+ device.requestMTU = jest.fn(() => Promise.resolve(device));
234
268
  const transport = new ReactNativeBleTransport({ scanTimeout: 1 });
235
269
  const bleManager = {
236
270
  devices: jest.fn(() => Promise.resolve([device])),
@@ -249,6 +283,7 @@ const createV1Harness = () => {
249
283
  uuid,
250
284
  device,
251
285
  bleManager,
286
+ writeCharacteristic,
252
287
  notifySubscriptionRemovers,
253
288
  disconnectSubscriptionRemovers,
254
289
  };
@@ -264,6 +299,19 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
264
299
  ).toBeNull();
265
300
  });
266
301
 
302
+ test.each(['status 143', 'status:143', 'status = 143', 'GATT_CONGESTED'])(
303
+ 'classifies %s as transient GATT congestion',
304
+ message => {
305
+ expect(getFirmwareUploadWriteRetryType({ message })).toBe('congested');
306
+ }
307
+ );
308
+
309
+ test('handles long uncontrolled status messages without a backtracking regular expression', () => {
310
+ const message = `status${' '.repeat(100_000)}142`;
311
+
312
+ expect(getFirmwareUploadWriteRetryType({ message })).toBeNull();
313
+ });
314
+
267
315
  test('keeps another device reader when releasing a device with an active V1 call', async () => {
268
316
  const transport = new ReactNativeBleTransport({ scanTimeout: 1 }) as any;
269
317
  const activeV1Call = createDeferred<string>();
@@ -302,12 +350,153 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
302
350
  expect(new ReactNativeBleTransport({}).scanTimeout).toBe(3000);
303
351
  });
304
352
 
353
+ test('uses withResponse for consecutive iOS Protocol V1 control commands without releasing', async () => {
354
+ const { transport, uuid, writeCharacteristic } = createV1Harness({
355
+ respondOnWriteCount: [1, 2],
356
+ });
357
+
358
+ await expect(transport.acquire({ uuid, expectedProtocol: 'V1' })).resolves.toEqual({
359
+ uuid,
360
+ protocolType: 'V1',
361
+ });
362
+ const releaseNative = jest.spyOn(transport as any, 'releaseNative');
363
+ expect(writeCharacteristic.writeWithResponse).not.toHaveBeenCalled();
364
+ expect(writeCharacteristic.writeWithoutResponse).not.toHaveBeenCalled();
365
+
366
+ await expect(transport.call(uuid, 'Initialize', {}, { timeoutMs: 50 })).resolves.toBeDefined();
367
+ await expect(transport.call(uuid, 'GetFeatures', {}, { timeoutMs: 50 })).resolves.toBeDefined();
368
+
369
+ expect(writeCharacteristic.writeWithResponse).toHaveBeenCalledTimes(2);
370
+ expect(writeCharacteristic.writeWithoutResponse).not.toHaveBeenCalled();
371
+ expect(releaseNative).not.toHaveBeenCalled();
372
+ await transport.release(uuid, true);
373
+ });
374
+
375
+ test('falls back to withoutResponse for an iOS Protocol V1 control command when required', async () => {
376
+ const { transport, uuid, writeCharacteristic } = createV1Harness({
377
+ isWritableWithResponse: false,
378
+ });
379
+
380
+ await transport.acquire({ uuid, expectedProtocol: 'V1' });
381
+ await expect(transport.call(uuid, 'Initialize', {}, { timeoutMs: 50 })).resolves.toBeDefined();
382
+
383
+ expect(writeCharacteristic.writeWithResponse).not.toHaveBeenCalled();
384
+ expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(1);
385
+ await transport.release(uuid, true);
386
+ });
387
+
388
+ test('does not resend a failed iOS Protocol V1 control write without response', async () => {
389
+ const { transport, uuid, writeCharacteristic } = createV1Harness();
390
+ const writeError = new Error('write with response failed');
391
+
392
+ await transport.acquire({ uuid, expectedProtocol: 'V1' });
393
+ writeCharacteristic.writeWithResponse.mockRejectedValueOnce(writeError);
394
+
395
+ await expect(transport.call(uuid, 'Initialize', {}, { timeoutMs: 50 })).rejects.toMatchObject({
396
+ errorCode: HardwareErrorCode.BleWriteCharacteristicError,
397
+ });
398
+ expect(writeCharacteristic.writeWithResponse).toHaveBeenCalledTimes(1);
399
+ expect(writeCharacteristic.writeWithoutResponse).not.toHaveBeenCalled();
400
+ await transport.release(uuid, true);
401
+ });
402
+
403
+ test('actively probes Protocol V2 on iOS when only a name-derived hint is available', async () => {
404
+ const { transport, uuid, device, sentSeqs, writeCharacteristic } = createHarness({
405
+ deviceName: 'Pro2 6E9E',
406
+ });
407
+
408
+ await expect(transport.acquire({ uuid })).resolves.toEqual({
409
+ uuid,
410
+ protocolType: 'V2',
411
+ });
412
+ expect(device.requestMTU).toHaveBeenCalledWith(247);
413
+ expect(writeCharacteristic.writeWithResponse).toHaveBeenCalledTimes(1);
414
+
415
+ await expect(
416
+ transport.call(uuid, 'Ping', { message: 'first-core-command' })
417
+ ).resolves.toBeDefined();
418
+ expect(sentSeqs).toEqual([1, 2]);
419
+ await transport.release(uuid, true);
420
+ });
421
+
422
+ test('falls back to the other active probe on iOS when protocol metadata is absent', async () => {
423
+ const { transport, uuid } = createHarness({ deviceName: 'OneKey' });
424
+ const probeProtocolV1 = jest
425
+ .spyOn(transport as any, 'probeProtocolV1')
426
+ .mockResolvedValue(false);
427
+ const probeProtocolV2 = jest.spyOn(transport as any, 'probeProtocolV2').mockResolvedValue(true);
428
+
429
+ await expect(transport.acquire({ uuid })).resolves.toEqual({
430
+ uuid,
431
+ protocolType: 'V2',
432
+ });
433
+
434
+ expect(probeProtocolV1).toHaveBeenCalledTimes(1);
435
+ expect(probeProtocolV2).toHaveBeenCalledTimes(1);
436
+ expect(probeProtocolV1.mock.invocationCallOrder[0]).toBeLessThan(
437
+ probeProtocolV2.mock.invocationCallOrder[0]
438
+ );
439
+ await transport.release(uuid, true);
440
+ });
441
+
442
+ test('continues with the current MTU when the connected snapshot refresh fails', async () => {
443
+ const { transport, uuid, device } = createHarness();
444
+ const mtuError = new Error('MTU refresh failed');
445
+ device.requestMTU.mockRejectedValueOnce(mtuError);
446
+
447
+ await expect(transport.acquire({ uuid })).resolves.toEqual({
448
+ uuid,
449
+ protocolType: 'V2',
450
+ });
451
+ expect(device.requestMTU).toHaveBeenCalledTimes(1);
452
+ expect((transport as any).getCachedTransport(uuid).mtuSize).toBe(247);
453
+ await transport.release(uuid, true);
454
+ });
455
+
456
+ test('refreshes a transient bootloader MTU after notifications are ready', async () => {
457
+ const { transport, uuid, device } = createHarness();
458
+ device.mtu = 23;
459
+ device.requestMTU
460
+ .mockResolvedValueOnce(device)
461
+ .mockResolvedValueOnce(device)
462
+ .mockImplementationOnce(() => {
463
+ device.mtu = 247;
464
+ return Promise.resolve(device);
465
+ });
466
+
467
+ await expect(transport.acquire({ uuid })).resolves.toEqual({
468
+ uuid,
469
+ protocolType: 'V2',
470
+ });
471
+ expect(device.requestMTU).toHaveBeenCalledTimes(3);
472
+ expect((transport as any).getCachedTransport(uuid).mtuSize).toBe(247);
473
+ await transport.release(uuid, true);
474
+ });
475
+
476
+ test('continues with a low bootloader MTU when the bounded retry fails', async () => {
477
+ const { transport, uuid, device } = createHarness();
478
+ device.mtu = 23;
479
+ device.requestMTU
480
+ .mockResolvedValueOnce(device)
481
+ .mockResolvedValueOnce(device)
482
+ .mockRejectedValueOnce(new Error('bootloader MTU retry failed'));
483
+
484
+ await expect(transport.acquire({ uuid })).resolves.toEqual({
485
+ uuid,
486
+ protocolType: 'V2',
487
+ });
488
+ expect(device.requestMTU).toHaveBeenCalledTimes(3);
489
+ expect((transport as any).getCachedTransport(uuid).mtuSize).toBe(23);
490
+ await transport.release(uuid, true);
491
+ });
492
+
305
493
  test('reconnects before falling back to Protocol V1 after a fatal V2 probe failure', async () => {
494
+ setPlatformOS('android');
306
495
  const { transport, uuid, device, notifySubscriptionRemovers, disconnectSubscriptionRemovers } =
307
496
  createV1Harness();
308
497
  const probeProtocolV2 = jest
309
498
  .spyOn(transport as any, 'probeProtocolV2')
310
- .mockImplementationOnce(async () => {
499
+ .mockImplementation(async () => {
311
500
  await (transport as any).releaseNative(uuid, true);
312
501
  return false;
313
502
  });
@@ -333,8 +522,9 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
333
522
  });
334
523
 
335
524
  test('cleans the rebuilt transport when Protocol V1 fallback also fails', async () => {
525
+ setPlatformOS('android');
336
526
  const { transport, uuid, device, bleManager, notifySubscriptionRemovers } = createV1Harness();
337
- jest.spyOn(transport as any, 'probeProtocolV2').mockImplementationOnce(async () => {
527
+ jest.spyOn(transport as any, 'probeProtocolV2').mockImplementation(async () => {
338
528
  await (transport as any).releaseNative(uuid, true);
339
529
  return false;
340
530
  });
@@ -353,7 +543,9 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
353
543
  });
354
544
 
355
545
  test('disconnects and invalidates a Protocol V1 link after a response timeout', async () => {
356
- const { transport, uuid, device } = createV1Harness();
546
+ const { transport, uuid, device } = createV1Harness({
547
+ respondOnWriteCount: Number.POSITIVE_INFINITY,
548
+ });
357
549
 
358
550
  await transport.acquire({ uuid, expectedProtocol: 'V1' });
359
551
  await expect(transport.call(uuid, 'Initialize', {}, { timeoutMs: 5 })).rejects.toMatchObject({
@@ -365,29 +557,31 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
365
557
  });
366
558
 
367
559
  afterEach(() => {
560
+ setPlatformOS('ios');
368
561
  resetProtocolV2BleTuning();
369
562
  });
370
563
 
371
- test('keeps the Protocol V2 sequence across probe and the next call', async () => {
564
+ test('uses the first Protocol V2 sequence for the first Core call when protocol is known', async () => {
372
565
  const { transport, uuid, sentSeqs } = createHarness();
373
566
 
374
- await transport.acquire({ uuid });
375
- await transport.call(uuid, 'Ping', { message: 'after-probe' });
567
+ await transport.acquire({ uuid, expectedProtocol: 'V2' });
568
+ await transport.call(uuid, 'Ping', { message: 'first-core-command' });
376
569
 
377
- expect(sentSeqs).toEqual([1, 2]);
378
- expect(bytesToHex(new Uint8Array([sentSeqs[0], sentSeqs[1]]))).toBe('0102');
570
+ expect(sentSeqs).toEqual([1]);
379
571
  await transport.release(uuid, true);
380
572
  });
381
573
 
382
574
  test('rejects the active Protocol V2 reader when the current monitor errors', async () => {
383
575
  const harness = createHarness();
384
576
  const { transport, uuid, sentSeqs } = harness;
385
- await transport.acquire({ uuid });
577
+ await transport.acquire({ uuid, expectedProtocol: 'V2' });
386
578
  harness.setShouldRespond(false);
387
579
 
388
580
  const call = transport.call(uuid, 'Ping', { message: 'wait-for-monitor' }, { timeoutMs: 50 });
389
- while (sentSeqs.length < 2) {
390
- await Promise.resolve();
581
+ while (sentSeqs.length < 1) {
582
+ await new Promise(resolve => {
583
+ setTimeout(resolve, 0);
584
+ });
391
585
  }
392
586
  await new Promise(resolve => {
393
587
  setTimeout(resolve, 0);
@@ -402,40 +596,162 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
402
596
  test('retains the sequence cursor when a new monitor generation is acquired', async () => {
403
597
  const { transport, uuid, sentSeqs } = createHarness();
404
598
 
405
- await transport.acquire({ uuid });
599
+ await transport.acquire({ uuid, expectedProtocol: 'V2' });
600
+ await transport.call(uuid, 'Ping', { message: 'first-generation' });
406
601
  await transport.release(uuid, true);
407
- await transport.acquire({ uuid });
602
+ await transport.acquire({ uuid, expectedProtocol: 'V2' });
603
+ await transport.call(uuid, 'Ping', { message: 'second-generation' });
408
604
 
409
605
  expect(sentSeqs).toEqual([1, 2]);
410
606
  await transport.release(uuid, true);
411
607
  });
412
608
 
413
- test('uses withoutResponse for normal and high-volume calls', async () => {
609
+ test('uses withResponse for consecutive iOS Protocol V2 control calls without releasing', async () => {
414
610
  const { transport, uuid, writeCharacteristic } = createHarness();
415
611
 
416
- await transport.acquire({ uuid });
417
- expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(1);
612
+ await transport.acquire({ uuid, expectedProtocol: 'V2' });
613
+ const releaseNative = jest.spyOn(transport as any, 'releaseNative');
614
+ expect(writeCharacteristic.writeWithoutResponse).not.toHaveBeenCalled();
418
615
  expect(writeCharacteristic.writeWithResponse).not.toHaveBeenCalled();
419
616
 
420
- await transport.call(uuid, 'Ping', { message: 'normal' });
617
+ await transport.call(uuid, 'DeviceInfoGet', {});
618
+ await transport.call(uuid, 'ProtocolInfoRequest', {});
619
+
620
+ expect(writeCharacteristic.writeWithResponse).toHaveBeenCalledTimes(2);
621
+ expect(writeCharacteristic.writeWithoutResponse).not.toHaveBeenCalled();
622
+ expect(releaseNative).not.toHaveBeenCalled();
623
+
624
+ await transport.release(uuid, true);
625
+ });
626
+
627
+ test('keeps iOS Protocol V2 high-volume calls on withoutResponse', async () => {
628
+ const { transport, uuid, logger, writeCharacteristic } = createHarness();
629
+
630
+ await transport.acquire({ uuid, expectedProtocol: 'V2' });
631
+
632
+ await transport.call(uuid, 'FileWrite', {});
633
+ await transport.call(uuid, 'FileWrite', {});
421
634
  expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(2);
422
635
  expect(writeCharacteristic.writeWithResponse).not.toHaveBeenCalled();
636
+ expect(
637
+ logger.debug.mock.calls.filter(
638
+ ([message]) =>
639
+ message === '[ReactNativeBleTransport] Protocol V2 high-volume write configured'
640
+ )
641
+ ).toHaveLength(1);
642
+ await transport.release(uuid, true);
643
+ });
644
+
645
+ test('uses Android 517 MTU and high connection priority during Protocol V2 high-volume calls', async () => {
646
+ setPlatformOS('android');
647
+ const { transport, uuid, device } = createHarness();
423
648
 
649
+ await transport.acquire({ uuid, expectedProtocol: 'V2' });
650
+ expect(device.requestMTU).toHaveBeenCalledWith(517);
651
+
652
+ await transport.call(uuid, 'FileWrite', {});
424
653
  await transport.call(uuid, 'FileWrite', {});
425
- expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(3);
654
+
655
+ expect(device.requestConnectionPriority).toHaveBeenCalledTimes(1);
656
+ expect(device.requestConnectionPriority).toHaveBeenCalledWith(1);
657
+
658
+ await transport.release(uuid, true);
659
+ expect(device.requestConnectionPriority).toHaveBeenLastCalledWith(0);
660
+ });
661
+
662
+ test('uses withResponse for an iOS Protocol V2 firmware file write when requested', async () => {
663
+ const { transport, uuid, writeCharacteristic } = createHarness();
664
+
665
+ await transport.acquire({ uuid, expectedProtocol: 'V2' });
666
+
667
+ await transport.call(uuid, 'FileWrite', {}, { writeWithResponse: true });
668
+ expect(writeCharacteristic.writeWithResponse).toHaveBeenCalledTimes(1);
669
+ expect(writeCharacteristic.writeWithoutResponse).not.toHaveBeenCalled();
670
+ await transport.release(uuid, true);
671
+ });
672
+
673
+ test('falls back to withoutResponse for an iOS Protocol V2 control call when required', async () => {
674
+ const { transport, uuid, writeCharacteristic } = createHarness({
675
+ isWritableWithResponse: false,
676
+ });
677
+
678
+ await transport.acquire({ uuid, expectedProtocol: 'V2' });
679
+ await transport.call(uuid, 'ProtocolInfoRequest', {});
680
+
426
681
  expect(writeCharacteristic.writeWithResponse).not.toHaveBeenCalled();
682
+ expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(1);
427
683
  await transport.release(uuid, true);
428
684
  });
429
685
 
686
+ test('does not resend a failed iOS Protocol V2 control write without response', async () => {
687
+ const transport = new ReactNativeBleTransport({ scanTimeout: 1 }) as any;
688
+ const writeError = new Error('write with response failed');
689
+ const writeWithResponse = jest.fn().mockRejectedValue(writeError);
690
+ const writeWithoutResponse = jest.fn().mockResolvedValue(undefined);
691
+ const context = {
692
+ messageName: 'ProtocolInfoRequest',
693
+ timeoutMs: 1000,
694
+ highVolume: false,
695
+ generation: 1,
696
+ signal: new AbortController().signal,
697
+ };
698
+
699
+ await expect(
700
+ transport.writeProtocolV2Packet(
701
+ {
702
+ writeCharacteristic: {
703
+ isWritableWithResponse: true,
704
+ writeWithResponse,
705
+ writeWithoutResponse,
706
+ },
707
+ },
708
+ Buffer.from('control').toString('base64'),
709
+ context,
710
+ jest.fn()
711
+ )
712
+ ).rejects.toBe(writeError);
713
+ expect(writeWithResponse).toHaveBeenCalledTimes(1);
714
+ expect(writeWithoutResponse).not.toHaveBeenCalled();
715
+ });
716
+
717
+ test('does not pace a one-packet Protocol V2 control write on iOS', async () => {
718
+ const transport = new ReactNativeBleTransport({ scanTimeout: 1 }) as any;
719
+ const writeWithoutResponse = jest.fn().mockResolvedValue(undefined);
720
+ const bleTransport = {
721
+ mtuSize: 23,
722
+ writeCharacteristic: { writeWithoutResponse },
723
+ };
724
+ const context = {
725
+ messageName: 'ProtocolInfoRequest',
726
+ timeoutMs: 1000,
727
+ highVolume: false,
728
+ generation: 1,
729
+ signal: new AbortController().signal,
730
+ };
731
+ configureProtocolV2BleTuning({ iosPacketLength: 20 });
732
+ const setTimeoutSpy = jest.spyOn(global, 'setTimeout');
733
+
734
+ try {
735
+ await transport.writeProtocolV2Frame(bleTransport, new Uint8Array(10), context, jest.fn());
736
+
737
+ expect(setTimeoutSpy).not.toHaveBeenCalled();
738
+ expect(writeWithoutResponse).toHaveBeenCalledTimes(1);
739
+ } finally {
740
+ setTimeoutSpy.mockRestore();
741
+ }
742
+ });
743
+
430
744
  test('rejects an active Protocol V2 reader when disconnect resets the link', async () => {
431
745
  const harness = createHarness();
432
746
  const { transport, uuid, sentSeqs } = harness;
433
- await transport.acquire({ uuid });
747
+ await transport.acquire({ uuid, expectedProtocol: 'V2' });
434
748
  harness.setShouldRespond(false);
435
749
 
436
750
  const call = transport.call(uuid, 'Ping', { message: 'disconnect' }, { timeoutMs: 50 });
437
- while (sentSeqs.length < 2) {
438
- await Promise.resolve();
751
+ while (sentSeqs.length < 1) {
752
+ await new Promise(resolve => {
753
+ setTimeout(resolve, 0);
754
+ });
439
755
  }
440
756
 
441
757
  const rejection = expect(call).rejects.toThrow('React Native BLE transport disconnected');
@@ -448,7 +764,7 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
448
764
  const disconnectListener = jest.fn();
449
765
  harness.emitter.on(TRANSPORT_EVENT.DEVICE_DISCONNECT, disconnectListener);
450
766
 
451
- await harness.transport.acquire({ uuid: harness.uuid });
767
+ await harness.transport.acquire({ uuid: harness.uuid, expectedProtocol: 'V2' });
452
768
  harness.emitDisconnect();
453
769
  await harness.transport.disconnect(harness.uuid);
454
770
 
@@ -482,7 +798,6 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
482
798
  configureProtocolV2BleTuning({ iosPacketLength: 20 });
483
799
 
484
800
  await transport.writeProtocolV2Frame(
485
- 'device-uuid',
486
801
  bleTransport,
487
802
  new Uint8Array(30),
488
803
  context,
@@ -514,14 +829,34 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
514
829
  configureProtocolV2BleTuning({ iosPacketLength: 20 });
515
830
 
516
831
  await expect(
517
- transport.writeProtocolV2Frame(
518
- 'device-uuid',
519
- bleTransport,
520
- new Uint8Array(30),
521
- context,
522
- jest.fn()
523
- )
832
+ transport.writeProtocolV2Frame(bleTransport, new Uint8Array(30), context, jest.fn())
524
833
  ).rejects.toMatchObject({ errorCode: 205 });
525
834
  expect(writeWithoutResponse).toHaveBeenCalledTimes(1);
526
835
  });
836
+
837
+ test('does not apply fixed burst or flush pauses to high-volume Protocol V2 writes', async () => {
838
+ const transport = new ReactNativeBleTransport({ scanTimeout: 1 }) as any;
839
+ const writeWithoutResponse = jest.fn().mockResolvedValue(undefined);
840
+ const bleTransport = {
841
+ mtuSize: 247,
842
+ writeCharacteristic: { writeWithoutResponse },
843
+ };
844
+ const context = {
845
+ messageName: 'FileWrite',
846
+ timeoutMs: 1000,
847
+ highVolume: true,
848
+ generation: 1,
849
+ signal: new AbortController().signal,
850
+ };
851
+ const setTimeoutSpy = jest.spyOn(global, 'setTimeout');
852
+
853
+ try {
854
+ await transport.writeProtocolV2Frame(bleTransport, new Uint8Array(600), context, jest.fn());
855
+
856
+ expect(writeWithoutResponse).toHaveBeenCalledTimes(3);
857
+ expect(setTimeoutSpy).not.toHaveBeenCalled();
858
+ } finally {
859
+ setTimeoutSpy.mockRestore();
860
+ }
861
+ });
527
862
  });