@onekeyfe/hd-transport 1.2.0-alpha.11 → 1.2.0-alpha.110

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.
Files changed (64) hide show
  1. package/__tests__/messages.test.js +165 -13
  2. package/__tests__/protocol-v2-ble-frame-writer.test.js +119 -0
  3. package/__tests__/protocol-v2-link-manager.test.js +120 -9
  4. package/__tests__/protocol-v2-usb-transport-base.test.js +36 -2
  5. package/__tests__/protocol-v2.test.js +553 -63
  6. package/__tests__/transport-log.test.js +79 -0
  7. package/dist/constants.d.ts +3 -1
  8. package/dist/constants.d.ts.map +1 -1
  9. package/dist/index.d.ts +687 -255
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +661 -296
  12. package/dist/protocols/index.d.ts +25 -6
  13. package/dist/protocols/index.d.ts.map +1 -1
  14. package/dist/protocols/v2/ble-frame-writer.d.ts +15 -0
  15. package/dist/protocols/v2/ble-frame-writer.d.ts.map +1 -0
  16. package/dist/protocols/v2/decode.d.ts +12 -2
  17. package/dist/protocols/v2/decode.d.ts.map +1 -1
  18. package/dist/protocols/v2/encode.d.ts +2 -3
  19. package/dist/protocols/v2/encode.d.ts.map +1 -1
  20. package/dist/protocols/v2/errors.d.ts +8 -0
  21. package/dist/protocols/v2/errors.d.ts.map +1 -0
  22. package/dist/protocols/v2/frame-assembler.d.ts.map +1 -1
  23. package/dist/protocols/v2/index.d.ts +2 -1
  24. package/dist/protocols/v2/index.d.ts.map +1 -1
  25. package/dist/protocols/v2/link-manager.d.ts +4 -0
  26. package/dist/protocols/v2/link-manager.d.ts.map +1 -1
  27. package/dist/protocols/v2/session.d.ts +10 -4
  28. package/dist/protocols/v2/session.d.ts.map +1 -1
  29. package/dist/protocols/v2/usb-transport-base.d.ts +1 -0
  30. package/dist/protocols/v2/usb-transport-base.d.ts.map +1 -1
  31. package/dist/serialization/protobuf/decode.d.ts.map +1 -1
  32. package/dist/serialization/protobuf/encode.d.ts.map +1 -1
  33. package/dist/types/messages.d.ts +336 -155
  34. package/dist/types/messages.d.ts.map +1 -1
  35. package/dist/types/transport.d.ts +20 -3
  36. package/dist/types/transport.d.ts.map +1 -1
  37. package/dist/utils/transportLog.d.ts +9 -0
  38. package/dist/utils/transportLog.d.ts.map +1 -0
  39. package/messages-protocol-v2.json +1030 -567
  40. package/package.json +3 -3
  41. package/scripts/protobuf-build.sh +85 -217
  42. package/scripts/protobuf-patches/index.js +1 -0
  43. package/scripts/protobuf-types.js +19 -1
  44. package/src/constants.ts +30 -16
  45. package/src/index.ts +5 -1
  46. package/src/protocols/index.ts +128 -40
  47. package/src/protocols/v2/ble-frame-writer.ts +77 -0
  48. package/src/protocols/v2/crc8.ts +1 -1
  49. package/src/protocols/v2/decode.ts +50 -35
  50. package/src/protocols/v2/encode.ts +2 -28
  51. package/src/protocols/v2/errors.ts +25 -0
  52. package/src/protocols/v2/frame-assembler.ts +6 -4
  53. package/src/protocols/v2/index.ts +2 -1
  54. package/src/protocols/v2/link-manager.ts +54 -5
  55. package/src/protocols/v2/session.ts +151 -200
  56. package/src/protocols/v2/usb-transport-base.ts +29 -10
  57. package/src/serialization/protobuf/decode.ts +4 -1
  58. package/src/serialization/protobuf/encode.ts +5 -1
  59. package/src/types/messages.ts +409 -197
  60. package/src/types/transport.ts +28 -3
  61. package/src/utils/transportLog.ts +102 -0
  62. package/dist/protocols/v2/debug.d.ts +0 -13
  63. package/dist/protocols/v2/debug.d.ts.map +0 -1
  64. package/src/protocols/v2/debug.ts +0 -26
@@ -2,12 +2,26 @@ const { ProtocolV2 } = require('../src/protocols');
2
2
  const { parseConfigure } = require('../src/serialization/protobuf/messages');
3
3
  const {
4
4
  ProtocolV2FrameAssembler,
5
+ ProtocolV2LinkError,
5
6
  ProtocolV2SequenceCursor,
6
7
  ProtocolV2Session,
7
8
  hexToBytes,
9
+ isProtocolV2HighThroughputCall,
8
10
  probeProtocolV2,
9
11
  } = require('../src/protocols/v2/session');
10
12
  const protocolV2 = require('../src/protocols/v2');
13
+ const {
14
+ PROTOCOL_V2_BLE_FIRMWARE_FILE_CHUNK_SIZE,
15
+ PROTOCOL_V2_BLE_FRAME_MAX_BYTES,
16
+ PROTOCOL_V2_DEFAULT_RESPONSE_TIMEOUT_MS,
17
+ PROTOCOL_V2_FRAME_MAX_BYTES,
18
+ } = require('../src/constants');
19
+
20
+ test('keeps sensitive acknowledgements separate from high-throughput calls', () => {
21
+ expect(isProtocolV2HighThroughputCall('PassphraseAck')).toBe(false);
22
+ expect(isProtocolV2HighThroughputCall('PinMatrixAck')).toBe(false);
23
+ expect(isProtocolV2HighThroughputCall('FilesystemFileWrite')).toBe(true);
24
+ });
11
25
 
12
26
  const protocolV1Messages = parseConfigure({
13
27
  nested: {
@@ -68,14 +82,14 @@ const protocolV2Messages = parseConfigure({
68
82
  type: 'uint32',
69
83
  id: 1,
70
84
  },
71
- supported_messages: {
72
- type: 'uint32',
85
+ build_fingerprint: {
86
+ type: 'string',
73
87
  id: 2,
74
- rule: 'repeated',
75
88
  },
76
- protobuf_definition: {
77
- type: 'string',
89
+ supported_messages: {
90
+ type: 'uint32',
78
91
  id: 3,
92
+ rule: 'repeated',
79
93
  },
80
94
  },
81
95
  },
@@ -95,6 +109,22 @@ const protocolV2Messages = parseConfigure({
95
109
  },
96
110
  },
97
111
  },
112
+ Failure: {
113
+ fields: {
114
+ code: {
115
+ type: 'uint32',
116
+ id: 1,
117
+ },
118
+ subcode: {
119
+ type: 'uint32',
120
+ id: 2,
121
+ },
122
+ message: {
123
+ type: 'string',
124
+ id: 3,
125
+ },
126
+ },
127
+ },
98
128
  DeviceFirmwareTarget: {
99
129
  fields: {
100
130
  target_id: {
@@ -145,6 +175,18 @@ const protocolV2Messages = parseConfigure({
145
175
  },
146
176
  },
147
177
  },
178
+ DeviceSession: {
179
+ fields: {
180
+ session_id: {
181
+ type: 'bytes',
182
+ id: 1,
183
+ },
184
+ btc_test_address: {
185
+ type: 'string',
186
+ id: 2,
187
+ },
188
+ },
189
+ },
148
190
  FileWrite: {
149
191
  fields: {},
150
192
  },
@@ -174,9 +216,11 @@ const protocolV2Messages = parseConfigure({
174
216
  MessageType_ProtocolInfo: 60201,
175
217
  MessageType_Ping: 60206,
176
218
  MessageType_Success: 60207,
219
+ MessageType_Failure: 60208,
177
220
  MessageType_FileWrite: 60805,
178
221
  MessageType_DeviceFirmwareUpdateRequest: 61000,
179
222
  MessageType_DeviceFirmwareUpdateStatus: 61002,
223
+ MessageType_DeviceSession: 61201,
180
224
  MessageType_PartialNested: 62000,
181
225
  },
182
226
  },
@@ -187,9 +231,37 @@ const schemas = {
187
231
  protocolV1: protocolV1Messages,
188
232
  protocolV2: protocolV2Messages,
189
233
  };
234
+ const productionProtocolV2Messages = parseConfigure(require('../messages-protocol-v2.json'));
235
+
236
+ const legacyProtocolInfoMessage = parseConfigure({
237
+ nested: {
238
+ ProtocolInfo: {
239
+ fields: {
240
+ version: {
241
+ type: 'uint32',
242
+ id: 1,
243
+ rule: 'required',
244
+ },
245
+ supported_messages: {
246
+ type: 'uint32',
247
+ id: 2,
248
+ rule: 'repeated',
249
+ options: {
250
+ packed: false,
251
+ },
252
+ },
253
+ protobuf_definition: {
254
+ type: 'string',
255
+ id: 3,
256
+ },
257
+ },
258
+ },
259
+ },
260
+ }).lookupType('ProtocolInfo');
190
261
 
191
262
  const rewriteSeq = (frame, seq) => {
192
263
  const copy = new Uint8Array(frame);
264
+ copy[4] = 1;
193
265
  copy[6] = seq;
194
266
  copy[copy.length - 1] = protocolV2.crc8(copy, copy.length - 1);
195
267
  return copy;
@@ -199,8 +271,8 @@ describe('Protocol V2 framing and session', () => {
199
271
  test('encodes and decodes Protocol V2 protobuf frames', () => {
200
272
  const frame = ProtocolV2.encodeFrame(schemas, 'ProtocolInfo', {
201
273
  version: 1,
274
+ build_fingerprint: 'application__1.0.0__abc__DEV__DEBUG',
202
275
  supported_messages: [60200, 60201],
203
- protobuf_definition: 'proto',
204
276
  });
205
277
 
206
278
  const parsed = protocolV2.decodeFrame(frame);
@@ -215,12 +287,70 @@ describe('Protocol V2 framing and session', () => {
215
287
  seq: parsed.seq,
216
288
  message: {
217
289
  version: 1,
290
+ build_fingerprint: 'application__1.0.0__abc__DEV__DEBUG',
218
291
  supported_messages: [60200, 60201],
219
- protobuf_definition: 'proto',
220
292
  },
221
293
  });
222
294
  });
223
295
 
296
+ test('decodes a two-byte legacy ProtocolInfo at the generic frame boundary', () => {
297
+ const frame = protocolV2.encodeProtobufFrame(60201, new Uint8Array([0x08, 0x01]));
298
+ const productionSchemas = {
299
+ protocolV1: protocolV1Messages,
300
+ protocolV2: productionProtocolV2Messages,
301
+ };
302
+
303
+ expect(ProtocolV2.decodeFrame(productionSchemas, frame)).toMatchObject({
304
+ type: 'ProtocolInfo',
305
+ message: {
306
+ version: 1,
307
+ build_fingerprint: '',
308
+ supported_messages: [],
309
+ protobuf_definition: null,
310
+ },
311
+ });
312
+ });
313
+
314
+ test('does not reinterpret a malformed current ProtocolInfo as the legacy layout', () => {
315
+ const frame = protocolV2.encodeProtobufFrame(60201, new Uint8Array([0x08, 0x01, 0x18, 0x01]));
316
+
317
+ expect(() =>
318
+ ProtocolV2.decodeFrame(
319
+ {
320
+ protocolV1: protocolV1Messages,
321
+ protocolV2: productionProtocolV2Messages,
322
+ },
323
+ frame
324
+ )
325
+ ).toThrow('Protocol V2 protobuf decode failed for "ProtocolInfo"');
326
+ });
327
+
328
+ test('preserves legacy ProtocolInfo capabilities when using the old field numbers', () => {
329
+ const payload = legacyProtocolInfoMessage
330
+ .encode({
331
+ version: 1,
332
+ supported_messages: [60602],
333
+ protobuf_definition: 'legacy',
334
+ })
335
+ .finish();
336
+ const frame = protocolV2.encodeProtobufFrame(60201, payload);
337
+
338
+ const decoded = ProtocolV2.decodeFrame(
339
+ {
340
+ protocolV1: protocolV1Messages,
341
+ protocolV2: productionProtocolV2Messages,
342
+ },
343
+ frame
344
+ );
345
+
346
+ expect(decoded.message).toEqual({
347
+ version: 1,
348
+ build_fingerprint: '',
349
+ supported_messages: [60602],
350
+ protobuf_definition: null,
351
+ });
352
+ });
353
+
224
354
  test('does not encode V1-only messages into Protocol V2 frames', () => {
225
355
  expect(() => ProtocolV2.encodeFrame(schemas, 'Ping', { message: 'ok' })).not.toThrow();
226
356
  expect(() => ProtocolV2.encodeFrame(schemas, 'OnekeyGetFeatures', {})).toThrow(
@@ -241,6 +371,34 @@ describe('Protocol V2 framing and session', () => {
241
371
  expect(decoded.message).toEqual({ message: 'ok' });
242
372
  });
243
373
 
374
+ test('round-trips a DeviceSession response with a 32-byte session id', () => {
375
+ const sessionId = '01'.repeat(32);
376
+ const frame = ProtocolV2.encodeFrame(schemas, 'DeviceSession', {
377
+ session_id: sessionId,
378
+ btc_test_address: 'tb1qwallet',
379
+ });
380
+
381
+ const decoded = ProtocolV2.decodeFrame(schemas, frame);
382
+
383
+ expect(decoded.type).toBe('DeviceSession');
384
+ expect(decoded.message).toEqual({
385
+ session_id: sessionId,
386
+ btc_test_address: 'tb1qwallet',
387
+ });
388
+ });
389
+
390
+ test('reports malformed DeviceSession protobuf as a schema-compatible transport error', () => {
391
+ const malformedPayload = new Uint8Array(32);
392
+ malformedPayload[0] = 0x0a;
393
+ malformedPayload[1] = 101;
394
+ const frame = protocolV2.encodeProtobufFrame(61201, malformedPayload);
395
+
396
+ expect(() => ProtocolV2.decodeFrame(schemas, frame)).toThrow(
397
+ 'Protocol V2 protobuf decode failed for "DeviceSession" (61201, 32-byte payload); ' +
398
+ 'the payload is malformed or incompatible with the active SDK schema.'
399
+ );
400
+ });
401
+
244
402
  test('decodes missing optional nested messages as null', () => {
245
403
  const frame = ProtocolV2.encodeFrame(schemas, 'PartialNested', {
246
404
  label: 'only label',
@@ -267,6 +425,60 @@ describe('Protocol V2 framing and session', () => {
267
425
  expect(() => assembler.push(oversized)).toThrow('Protocol V2 frame too large');
268
426
  });
269
427
 
428
+ test('enforces a transport-specific receive frame boundary', () => {
429
+ const assembler = new ProtocolV2FrameAssembler(2048);
430
+ const oversizedHeader = new Uint8Array([0x5a, 0x01, 0x08]);
431
+
432
+ expect(() => assembler.push(oversizedHeader)).toThrow('Protocol V2 frame too large: 2049');
433
+ });
434
+
435
+ test('enforces the firmware 4200-byte Protocol V2 frame boundary', () => {
436
+ const boundaryFrame = ProtocolV2.encodeFrame(schemas, 'Ping', {
437
+ message: 'x'.repeat(4187),
438
+ });
439
+
440
+ expect(PROTOCOL_V2_FRAME_MAX_BYTES).toBe(4200);
441
+ expect(boundaryFrame).toHaveLength(PROTOCOL_V2_FRAME_MAX_BYTES);
442
+ expect(() =>
443
+ ProtocolV2.encodeFrame(schemas, 'Ping', {
444
+ message: 'x'.repeat(4188),
445
+ })
446
+ ).toThrow('Protocol V2 frame too large: 4201 > 4200');
447
+ });
448
+
449
+ test('keeps optimized BLE firmware chunks inside the transport frame boundary', () => {
450
+ const productionSchemas = {
451
+ protocolV1: protocolV1Messages,
452
+ protocolV2: productionProtocolV2Messages,
453
+ };
454
+ const stagingPaths = [
455
+ 'vol0:/bootloader.bin',
456
+ 'vol0:/application_p1.bin',
457
+ 'vol0:/application_p2.bin',
458
+ 'vol0:/coprocessor.bin',
459
+ 'vol0:/se01.bin',
460
+ 'vol0:/se02.bin',
461
+ 'vol0:/se03.bin',
462
+ 'vol0:/se04.bin',
463
+ ];
464
+
465
+ for (const path of stagingPaths) {
466
+ const frame = ProtocolV2.encodeFrame(productionSchemas, 'FilesystemFileWrite', {
467
+ file: {
468
+ path,
469
+ offset: 0xffffffff,
470
+ total_size: 0xffffffff,
471
+ data: new Uint8Array(PROTOCOL_V2_BLE_FIRMWARE_FILE_CHUNK_SIZE),
472
+ },
473
+ overwrite: true,
474
+ append: false,
475
+ ui_percentage: 100,
476
+ });
477
+
478
+ expect(frame.length).toBeLessThanOrEqual(PROTOCOL_V2_BLE_FRAME_MAX_BYTES);
479
+ }
480
+ });
481
+
270
482
  test('keeps bytes after the first complete frame for the next read', () => {
271
483
  const first = ProtocolV2.encodeFrame(schemas, 'ProtocolInfo', {
272
484
  version: 1,
@@ -312,12 +524,90 @@ describe('Protocol V2 framing and session', () => {
312
524
  type: 'ProtocolInfo',
313
525
  message: {
314
526
  version: 2,
527
+ build_fingerprint: null,
315
528
  supported_messages: [60206],
529
+ },
530
+ });
531
+ });
532
+
533
+ test('session decodes legacy ProtocolInfo through the generic frame boundary', async () => {
534
+ const productionSchemas = {
535
+ protocolV1: protocolV1Messages,
536
+ protocolV2: productionProtocolV2Messages,
537
+ };
538
+ const response = protocolV2.encodeProtobufFrame(60201, new Uint8Array([0x08, 0x01]));
539
+ const session = new ProtocolV2Session({
540
+ schemas: productionSchemas,
541
+ router: 1,
542
+ writeFrame: () => Promise.resolve(),
543
+ readFrame: () => Promise.resolve(rewriteSeq(response, 1)),
544
+ });
545
+
546
+ await expect(
547
+ session.call(
548
+ 'ProtocolInfoRequest',
549
+ { eventless_wallet_session: true },
550
+ {
551
+ expectedTypes: ['ProtocolInfo'],
552
+ }
553
+ )
554
+ ).resolves.toEqual({
555
+ type: 'ProtocolInfo',
556
+ message: {
557
+ version: 1,
558
+ build_fingerprint: '',
559
+ supported_messages: [],
316
560
  protobuf_definition: null,
317
561
  },
318
562
  });
319
563
  });
320
564
 
565
+ test('session does not log Protocol V2 TX and RX frames', async () => {
566
+ const written = [];
567
+ const logger = { debug: jest.fn() };
568
+ const response = ProtocolV2.encodeFrame(schemas, 'ProtocolInfo', {
569
+ version: 2,
570
+ build_fingerprint: 'application__1.0.0__sensitive__DEV__DEBUG',
571
+ supported_messages: [60206],
572
+ });
573
+ const session = new ProtocolV2Session({
574
+ schemas,
575
+ router: 1,
576
+ logger,
577
+ logPrefix: 'ProtocolV2 Test',
578
+ writeFrame: frame => {
579
+ written.push(frame);
580
+ return Promise.resolve();
581
+ },
582
+ readFrame: () =>
583
+ Promise.resolve(rewriteSeq(response, protocolV2.decodeFrame(written[0]).seq)),
584
+ });
585
+
586
+ await session.call('ProtocolInfoRequest', {});
587
+
588
+ expect(logger.debug).not.toHaveBeenCalled();
589
+ });
590
+
591
+ test('session does not log RX frame metadata when protobuf payload decoding fails', async () => {
592
+ const logger = { debug: jest.fn() };
593
+ const response = protocolV2.encodeProtobufFrame(
594
+ 60208,
595
+ hexToBytes('0801121648616e646c6572206e6f742072656769737465726564')
596
+ );
597
+ const session = new ProtocolV2Session({
598
+ schemas,
599
+ router: 1,
600
+ logger,
601
+ logPrefix: 'ProtocolV2 Test',
602
+ writeFrame: () => Promise.resolve(),
603
+ readFrame: () => Promise.resolve(rewriteSeq(response, 1)),
604
+ });
605
+
606
+ await expect(session.call('Ping', { message: 'hello' })).rejects.toThrow();
607
+
608
+ expect(logger.debug).not.toHaveBeenCalled();
609
+ });
610
+
321
611
  test('session skips Proto Link ACK frames before decoding the protobuf response', async () => {
322
612
  const ack = new Uint8Array(8);
323
613
  ack[0] = 0x5a;
@@ -332,7 +622,10 @@ describe('Protocol V2 framing and session', () => {
332
622
  const response = ProtocolV2.encodeFrame(schemas, 'Success', {
333
623
  message: 'ok',
334
624
  });
335
- const readFrame = jest.fn().mockResolvedValueOnce(ack).mockResolvedValueOnce(response);
625
+ const readFrame = jest
626
+ .fn()
627
+ .mockResolvedValueOnce(ack)
628
+ .mockResolvedValueOnce(rewriteSeq(response, 1));
336
629
  const session = new ProtocolV2Session({
337
630
  schemas,
338
631
  router: 1,
@@ -349,6 +642,96 @@ describe('Protocol V2 framing and session', () => {
349
642
  expect(readFrame).toHaveBeenCalledTimes(2);
350
643
  });
351
644
 
645
+ test('session accepts a terminal response before an ACK', async () => {
646
+ const response = ProtocolV2.encodeFrame(schemas, 'Success', {
647
+ message: 'ok',
648
+ });
649
+ const session = new ProtocolV2Session({
650
+ schemas,
651
+ router: 1,
652
+ writeFrame: () => Promise.resolve(),
653
+ readFrame: () => Promise.resolve(rewriteSeq(response, 1)),
654
+ });
655
+
656
+ await expect(
657
+ session.call('Ping', { message: 'hello' }, { expectedTypes: ['Success'] })
658
+ ).resolves.toEqual({
659
+ type: 'Success',
660
+ message: {
661
+ message: 'ok',
662
+ },
663
+ });
664
+ });
665
+
666
+ test('session uses the unified response timeout before the first ACK', async () => {
667
+ const response = ProtocolV2.encodeFrame(schemas, 'Success', {
668
+ message: 'ok',
669
+ });
670
+ const readFrame = jest.fn(() => Promise.resolve(rewriteSeq(response, 1)));
671
+ const session = new ProtocolV2Session({
672
+ schemas,
673
+ router: 1,
674
+ writeFrame: () => Promise.resolve(),
675
+ readFrame,
676
+ });
677
+
678
+ await expect(
679
+ session.call(
680
+ 'Ping',
681
+ { message: 'hello' },
682
+ {
683
+ timeoutMs: 12_345,
684
+ expectedTypes: ['Success'],
685
+ }
686
+ )
687
+ ).resolves.toEqual({
688
+ type: 'Success',
689
+ message: {
690
+ message: 'ok',
691
+ },
692
+ });
693
+ expect(readFrame).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 12_345 }));
694
+ });
695
+
696
+ test('session rejects an ACK that belongs to another request sequence', async () => {
697
+ const ack = new Uint8Array(8);
698
+ ack[0] = 0x5a;
699
+ ack[1] = 8;
700
+ ack[4] = 1;
701
+ ack[5] = 1;
702
+ ack[6] = 2;
703
+ ack[3] = protocolV2.crc8(ack, 3);
704
+ ack[7] = protocolV2.crc8(ack, 7);
705
+ const session = new ProtocolV2Session({
706
+ schemas,
707
+ router: 1,
708
+ writeFrame: () => Promise.resolve(),
709
+ readFrame: () => Promise.resolve(ack),
710
+ });
711
+
712
+ await expect(session.call('Ping', { message: 'hello' })).rejects.toEqual(
713
+ expect.objectContaining({
714
+ name: 'ProtocolV2LinkError',
715
+ code: 'ack-sequence',
716
+ message: 'Protocol V2 ACK sequence mismatch: expected 1, got 2',
717
+ })
718
+ );
719
+ });
720
+
721
+ test('session rejects a response from another routing channel', async () => {
722
+ const response = ProtocolV2.encodeFrame(schemas, 'Success', { message: 'wrong route' });
723
+ const session = new ProtocolV2Session({
724
+ schemas,
725
+ router: 1,
726
+ writeFrame: () => Promise.resolve(),
727
+ readFrame: () => Promise.resolve(response),
728
+ });
729
+
730
+ await expect(session.call('Ping', { message: 'hello' })).rejects.toBeInstanceOf(
731
+ ProtocolV2LinkError
732
+ );
733
+ });
734
+
352
735
  test('session rejects BLE frames above its configured frame limit before writing', async () => {
353
736
  const writeFrame = jest.fn().mockResolvedValue(undefined);
354
737
  const response = ProtocolV2.encodeFrame(schemas, 'Success', {
@@ -359,7 +742,7 @@ describe('Protocol V2 framing and session', () => {
359
742
  router: 1,
360
743
  maxFrameBytes: 2048,
361
744
  writeFrame,
362
- readFrame: () => Promise.resolve(response),
745
+ readFrame: () => Promise.resolve(rewriteSeq(response, 1)),
363
746
  });
364
747
 
365
748
  await expect(session.call('Ping', { message: 'x'.repeat(2048) })).rejects.toThrow(
@@ -368,31 +751,43 @@ describe('Protocol V2 framing and session', () => {
368
751
  expect(writeFrame).not.toHaveBeenCalled();
369
752
  });
370
753
 
371
- test('session starts response timeout after the frame is written', async () => {
372
- const response = ProtocolV2.encodeFrame(schemas, 'Success', {
373
- message: 'ok',
374
- });
754
+ test('session watchdog covers a write that never settles', async () => {
755
+ const readFrame = jest.fn();
756
+ let writeSignal;
375
757
  const session = new ProtocolV2Session({
376
758
  schemas,
377
759
  router: 1,
378
- writeFrame: () =>
379
- new Promise(resolve => {
380
- setTimeout(resolve, 30);
381
- }),
382
- readFrame: () => Promise.resolve(response),
760
+ writeFrame: (_frame, context) => {
761
+ writeSignal = context.signal;
762
+ return new Promise(() => {});
763
+ },
764
+ readFrame,
383
765
  });
384
766
 
385
767
  await expect(
386
768
  session.call('Ping', { message: 'hello' }, { timeoutMs: 10, expectedTypes: ['Success'] })
387
- ).resolves.toEqual({
388
- type: 'Success',
389
- message: {
390
- message: 'ok',
391
- },
769
+ ).rejects.toThrow('Protocol V2 response timeout after 10ms for Ping');
770
+ expect(writeSignal.aborted).toBe(true);
771
+ expect(readFrame).not.toHaveBeenCalled();
772
+ });
773
+
774
+ test('session watchdog covers prepareCall before writing', async () => {
775
+ const writeFrame = jest.fn();
776
+ const session = new ProtocolV2Session({
777
+ schemas,
778
+ router: 1,
779
+ prepareCall: () => new Promise(() => {}),
780
+ writeFrame,
781
+ readFrame: jest.fn(),
392
782
  });
783
+
784
+ await expect(session.call('Ping', { message: 'hello' }, { timeoutMs: 10 })).rejects.toThrow(
785
+ 'Protocol V2 response timeout after 10ms for Ping'
786
+ );
787
+ expect(writeFrame).not.toHaveBeenCalled();
393
788
  });
394
789
 
395
- test('session accepts response frames with a device-owned seq', async () => {
790
+ test('session logs a device-owned response seq without rejecting the frame', async () => {
396
791
  const response = ProtocolV2.encodeFrame(schemas, 'ProtocolInfo', {
397
792
  version: 2,
398
793
  supported_messages: [],
@@ -412,14 +807,49 @@ describe('Protocol V2 framing and session', () => {
412
807
  type: 'ProtocolInfo',
413
808
  message: {
414
809
  version: 2,
810
+ build_fingerprint: null,
415
811
  supported_messages: [],
416
- protobuf_definition: null,
417
812
  },
418
813
  });
419
- expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('seq differs'));
814
+ expect(logger.debug).not.toHaveBeenCalled();
815
+ });
816
+
817
+ test('session accepts gaps in the firmware-global response sequence', async () => {
818
+ const response = ProtocolV2.encodeFrame(schemas, 'Success', { message: 'ok' });
819
+ const readFrame = jest
820
+ .fn()
821
+ .mockResolvedValueOnce(rewriteSeq(response, 1))
822
+ .mockResolvedValueOnce(rewriteSeq(response, 4));
823
+ const session = new ProtocolV2Session({
824
+ schemas,
825
+ router: 1,
826
+ writeFrame: () => Promise.resolve(),
827
+ readFrame,
828
+ });
829
+
830
+ await session.call('Ping', { message: 'first' });
831
+ await expect(session.call('Ping', { message: 'second' })).resolves.toMatchObject({
832
+ type: 'Success',
833
+ });
420
834
  });
421
835
 
422
- test('session logs decoded transmit and receive payloads', async () => {
836
+ test('session rejects a duplicate device response sequence', async () => {
837
+ const response = ProtocolV2.encodeFrame(schemas, 'Success', { message: 'ok' });
838
+ const readFrame = jest.fn(() => Promise.resolve(rewriteSeq(response, 7)));
839
+ const session = new ProtocolV2Session({
840
+ schemas,
841
+ router: 1,
842
+ writeFrame: () => Promise.resolve(),
843
+ readFrame,
844
+ });
845
+
846
+ await session.call('Ping', { message: 'first' });
847
+ await expect(session.call('Ping', { message: 'second' })).rejects.toThrow(
848
+ 'Protocol V2 duplicate response sequence: 7'
849
+ );
850
+ });
851
+
852
+ test('session does not log transmit or receive payload details', async () => {
423
853
  const response = ProtocolV2.encodeFrame(schemas, 'Success', {
424
854
  message: 'accepted',
425
855
  });
@@ -430,7 +860,7 @@ describe('Protocol V2 framing and session', () => {
430
860
  schemas,
431
861
  router: 1,
432
862
  writeFrame: () => Promise.resolve(),
433
- readFrame: () => Promise.resolve(response),
863
+ readFrame: () => Promise.resolve(rewriteSeq(response, 1)),
434
864
  logger,
435
865
  logPrefix: 'ProtocolV2 Test',
436
866
  });
@@ -442,30 +872,7 @@ describe('Protocol V2 framing and session', () => {
442
872
  },
443
873
  });
444
874
 
445
- expect(logger.debug).toHaveBeenCalledWith('[ProtocolV2 Test] TX payload name=Ping', {
446
- message: 'hello',
447
- });
448
- expect(logger.debug).toHaveBeenCalledWith(
449
- '[ProtocolV2 Test] encode raw frame',
450
- expect.objectContaining({
451
- context: 'tx:Ping',
452
- messageTypeId: 60206,
453
- router: 1,
454
- })
455
- );
456
- expect(logger.debug).toHaveBeenCalledWith(
457
- '[ProtocolV2 Test] decode raw frame',
458
- expect.objectContaining({
459
- context: 'rx:Ping',
460
- messageTypeId: 60207,
461
- })
462
- );
463
- expect(logger.debug).toHaveBeenCalledWith(
464
- '[ProtocolV2 Test] RX payload type=Success messageTypeId=60207',
465
- {
466
- message: 'accepted',
467
- }
468
- );
875
+ expect(logger.debug).not.toHaveBeenCalled();
469
876
  });
470
877
 
471
878
  test('session suppresses debug logs for file transfer calls', async () => {
@@ -479,7 +886,7 @@ describe('Protocol V2 framing and session', () => {
479
886
  schemas,
480
887
  router: 1,
481
888
  writeFrame: () => Promise.resolve(),
482
- readFrame: () => Promise.resolve(response),
889
+ readFrame: () => Promise.resolve(rewriteSeq(response, 1)),
483
890
  logger,
484
891
  });
485
892
 
@@ -493,6 +900,34 @@ describe('Protocol V2 framing and session', () => {
493
900
  expect(logger.debug).not.toHaveBeenCalled();
494
901
  });
495
902
 
903
+ test('session reports when the complete request frame has been written', async () => {
904
+ const response = ProtocolV2.encodeFrame(schemas, 'Success', {
905
+ message: 'ok',
906
+ });
907
+ const events = [];
908
+ const onWriteCompleted = jest.fn(metrics => events.push(['written', metrics]));
909
+ const session = new ProtocolV2Session({
910
+ schemas,
911
+ router: 1,
912
+ writeFrame: frame => {
913
+ events.push(['write', frame.byteLength]);
914
+ return Promise.resolve();
915
+ },
916
+ readFrame: () => {
917
+ events.push(['read']);
918
+ return Promise.resolve(rewriteSeq(response, 1));
919
+ },
920
+ });
921
+
922
+ await session.call('Ping', { message: 'metrics' }, { onWriteCompleted });
923
+
924
+ expect(events.map(event => event[0])).toEqual(['write', 'written', 'read']);
925
+ expect(onWriteCompleted).toHaveBeenCalledWith({
926
+ elapsedMs: expect.any(Number),
927
+ frameBytes: expect.any(Number),
928
+ });
929
+ });
930
+
496
931
  test('session skips unrelated terminal frames when expected response types are provided', async () => {
497
932
  const stale = ProtocolV2.encodeFrame(schemas, 'Success', {
498
933
  message: 'stale response',
@@ -504,7 +939,10 @@ describe('Protocol V2 framing and session', () => {
504
939
  const logger = {
505
940
  debug: jest.fn(),
506
941
  };
507
- const readFrame = jest.fn().mockResolvedValueOnce(stale).mockResolvedValueOnce(response);
942
+ const readFrame = jest
943
+ .fn()
944
+ .mockResolvedValueOnce(rewriteSeq(stale, 1))
945
+ .mockResolvedValueOnce(rewriteSeq(response, 2));
508
946
  const session = new ProtocolV2Session({
509
947
  schemas,
510
948
  router: 1,
@@ -519,8 +957,8 @@ describe('Protocol V2 framing and session', () => {
519
957
  type: 'ProtocolInfo',
520
958
  message: {
521
959
  version: 2,
960
+ build_fingerprint: null,
522
961
  supported_messages: [],
523
- protobuf_definition: null,
524
962
  },
525
963
  });
526
964
 
@@ -541,7 +979,9 @@ describe('Protocol V2 framing and session', () => {
541
979
  const [writtenFrame] = written;
542
980
  const { seq } = protocolV2.decodeFrame(writtenFrame);
543
981
  return Promise.resolve(
544
- readFrame.mock.calls.length === 1 ? rewriteSeq(progress, seq) : rewriteSeq(success, seq)
982
+ readFrame.mock.calls.length === 1
983
+ ? rewriteSeq(progress, seq)
984
+ : rewriteSeq(success, protocolV2.nextProtoSeq(seq))
545
985
  );
546
986
  });
547
987
  const session = new ProtocolV2Session({
@@ -718,7 +1158,7 @@ describe('Protocol V2 framing and session', () => {
718
1158
  }
719
1159
  return Promise.resolve();
720
1160
  },
721
- readFrame: () => Promise.resolve(response),
1161
+ readFrame: () => Promise.resolve(rewriteSeq(response, 1)),
722
1162
  });
723
1163
 
724
1164
  await expect(session.call('Ping', { message: '1' })).rejects.toThrow('transport write failed');
@@ -787,6 +1227,7 @@ describe('Protocol V2 framing and session', () => {
787
1227
  const writeContexts = [];
788
1228
  const readContexts = [];
789
1229
  const response = ProtocolV2.encodeFrame(schemas, 'Success', { message: 'ok' });
1230
+ let responseSeq = 0;
790
1231
  const session = new ProtocolV2Session({
791
1232
  schemas,
792
1233
  router: 1,
@@ -797,18 +1238,67 @@ describe('Protocol V2 framing and session', () => {
797
1238
  },
798
1239
  readFrame: context => {
799
1240
  readContexts.push(context);
800
- return Promise.resolve(response);
1241
+ responseSeq = protocolV2.nextProtoSeq(responseSeq);
1242
+ return Promise.resolve(rewriteSeq(response, responseSeq));
801
1243
  },
802
1244
  });
803
1245
 
804
1246
  await session.call('Ping', { message: 'ping' }, { timeoutMs: 123 });
805
- await session.call('FileWrite', {}, { timeoutMs: 456 });
1247
+ await session.call('FileWrite', {}, { timeoutMs: 456, writeWithResponse: true });
1248
+ await session.call('Ping', { message: 'default-timeout' });
806
1249
 
807
- expect(writeContexts).toEqual([
808
- { messageName: 'Ping', timeoutMs: 123, highVolume: false, generation: 7 },
809
- { messageName: 'FileWrite', timeoutMs: 456, highVolume: true, generation: 7 },
1250
+ const normalizeContext = ({ signal, ...context }) => ({
1251
+ ...context,
1252
+ signalAborted: signal.aborted,
1253
+ });
1254
+ expect(writeContexts.map(normalizeContext)).toEqual([
1255
+ {
1256
+ messageName: 'Ping',
1257
+ timeoutMs: 123,
1258
+ highThroughput: false,
1259
+ generation: 7,
1260
+ signalAborted: false,
1261
+ },
1262
+ {
1263
+ messageName: 'FileWrite',
1264
+ timeoutMs: 456,
1265
+ highThroughput: true,
1266
+ writeWithResponse: true,
1267
+ generation: 7,
1268
+ signalAborted: false,
1269
+ },
1270
+ {
1271
+ messageName: 'Ping',
1272
+ timeoutMs: PROTOCOL_V2_DEFAULT_RESPONSE_TIMEOUT_MS,
1273
+ highThroughput: false,
1274
+ generation: 7,
1275
+ signalAborted: false,
1276
+ },
1277
+ ]);
1278
+ expect(readContexts.map(normalizeContext)).toEqual([
1279
+ {
1280
+ messageName: 'Ping',
1281
+ timeoutMs: 123,
1282
+ highThroughput: false,
1283
+ generation: 7,
1284
+ signalAborted: false,
1285
+ },
1286
+ {
1287
+ messageName: 'FileWrite',
1288
+ timeoutMs: 456,
1289
+ highThroughput: true,
1290
+ writeWithResponse: true,
1291
+ generation: 7,
1292
+ signalAborted: false,
1293
+ },
1294
+ {
1295
+ messageName: 'Ping',
1296
+ timeoutMs: PROTOCOL_V2_DEFAULT_RESPONSE_TIMEOUT_MS,
1297
+ highThroughput: false,
1298
+ generation: 7,
1299
+ signalAborted: false,
1300
+ },
810
1301
  ]);
811
- expect(readContexts).toEqual(writeContexts);
812
1302
  });
813
1303
 
814
1304
  test('assembler throws and resets on frames with an impossible length field', () => {