@onekeyfe/hd-transport 1.2.0-alpha.12 → 1.2.0-alpha.121

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 (59) 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 +566 -69
  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 +696 -221
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +662 -130
  12. package/dist/protocols/index.d.ts +24 -1
  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 +11 -0
  17. package/dist/protocols/v2/decode.d.ts.map +1 -1
  18. package/dist/protocols/v2/errors.d.ts +8 -0
  19. package/dist/protocols/v2/errors.d.ts.map +1 -0
  20. package/dist/protocols/v2/frame-assembler.d.ts.map +1 -1
  21. package/dist/protocols/v2/index.d.ts +2 -0
  22. package/dist/protocols/v2/index.d.ts.map +1 -1
  23. package/dist/protocols/v2/link-manager.d.ts +4 -0
  24. package/dist/protocols/v2/link-manager.d.ts.map +1 -1
  25. package/dist/protocols/v2/session.d.ts +11 -4
  26. package/dist/protocols/v2/session.d.ts.map +1 -1
  27. package/dist/protocols/v2/usb-transport-base.d.ts +1 -0
  28. package/dist/protocols/v2/usb-transport-base.d.ts.map +1 -1
  29. package/dist/serialization/protobuf/decode.d.ts.map +1 -1
  30. package/dist/serialization/protobuf/encode.d.ts.map +1 -1
  31. package/dist/types/messages.d.ts +345 -158
  32. package/dist/types/messages.d.ts.map +1 -1
  33. package/dist/types/transport.d.ts +21 -3
  34. package/dist/types/transport.d.ts.map +1 -1
  35. package/dist/utils/transportLog.d.ts +9 -0
  36. package/dist/utils/transportLog.d.ts.map +1 -0
  37. package/messages-protocol-v2.json +1057 -577
  38. package/package.json +3 -3
  39. package/scripts/protobuf-build.sh +85 -217
  40. package/scripts/protobuf-patches/index.js +1 -0
  41. package/scripts/protobuf-types.js +19 -1
  42. package/src/constants.ts +30 -16
  43. package/src/index.ts +5 -1
  44. package/src/protocols/index.ts +127 -4
  45. package/src/protocols/v2/ble-frame-writer.ts +77 -0
  46. package/src/protocols/v2/crc8.ts +1 -1
  47. package/src/protocols/v2/decode.ts +49 -12
  48. package/src/protocols/v2/encode.ts +1 -1
  49. package/src/protocols/v2/errors.ts +25 -0
  50. package/src/protocols/v2/frame-assembler.ts +6 -4
  51. package/src/protocols/v2/index.ts +2 -0
  52. package/src/protocols/v2/link-manager.ts +54 -5
  53. package/src/protocols/v2/session.ts +166 -59
  54. package/src/protocols/v2/usb-transport-base.ts +29 -10
  55. package/src/serialization/protobuf/decode.ts +4 -1
  56. package/src/serialization/protobuf/encode.ts +5 -1
  57. package/src/types/messages.ts +421 -201
  58. package/src/types/transport.ts +30 -3
  59. package/src/utils/transportLog.ts +102 -0
@@ -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: {
@@ -107,7 +137,7 @@ const protocolV2Messages = parseConfigure({
107
137
  },
108
138
  },
109
139
  },
110
- DeviceFirmwareUpdateRequest: {
140
+ DeviceFirmwareUpdateStage: {
111
141
  fields: {
112
142
  targets: {
113
143
  type: 'DeviceFirmwareTarget',
@@ -116,6 +146,9 @@ const protocolV2Messages = parseConfigure({
116
146
  },
117
147
  },
118
148
  },
149
+ DeviceFirmwareUpdateRequest: {
150
+ fields: {},
151
+ },
119
152
  DeviceFirmwareUpdateRecord: {
120
153
  fields: {
121
154
  target_id: {
@@ -145,6 +178,18 @@ const protocolV2Messages = parseConfigure({
145
178
  },
146
179
  },
147
180
  },
181
+ DeviceSession: {
182
+ fields: {
183
+ session_id: {
184
+ type: 'bytes',
185
+ id: 1,
186
+ },
187
+ btc_test_address: {
188
+ type: 'string',
189
+ id: 2,
190
+ },
191
+ },
192
+ },
148
193
  FileWrite: {
149
194
  fields: {},
150
195
  },
@@ -174,9 +219,12 @@ const protocolV2Messages = parseConfigure({
174
219
  MessageType_ProtocolInfo: 60201,
175
220
  MessageType_Ping: 60206,
176
221
  MessageType_Success: 60207,
222
+ MessageType_Failure: 60208,
177
223
  MessageType_FileWrite: 60805,
178
- MessageType_DeviceFirmwareUpdateRequest: 61000,
224
+ MessageType_DeviceFirmwareUpdateStage: 61000,
225
+ MessageType_DeviceFirmwareUpdateRequest: 61001,
179
226
  MessageType_DeviceFirmwareUpdateStatus: 61002,
227
+ MessageType_DeviceSession: 61201,
180
228
  MessageType_PartialNested: 62000,
181
229
  },
182
230
  },
@@ -187,9 +235,37 @@ const schemas = {
187
235
  protocolV1: protocolV1Messages,
188
236
  protocolV2: protocolV2Messages,
189
237
  };
238
+ const productionProtocolV2Messages = parseConfigure(require('../messages-protocol-v2.json'));
239
+
240
+ const legacyProtocolInfoMessage = parseConfigure({
241
+ nested: {
242
+ ProtocolInfo: {
243
+ fields: {
244
+ version: {
245
+ type: 'uint32',
246
+ id: 1,
247
+ rule: 'required',
248
+ },
249
+ supported_messages: {
250
+ type: 'uint32',
251
+ id: 2,
252
+ rule: 'repeated',
253
+ options: {
254
+ packed: false,
255
+ },
256
+ },
257
+ protobuf_definition: {
258
+ type: 'string',
259
+ id: 3,
260
+ },
261
+ },
262
+ },
263
+ },
264
+ }).lookupType('ProtocolInfo');
190
265
 
191
266
  const rewriteSeq = (frame, seq) => {
192
267
  const copy = new Uint8Array(frame);
268
+ copy[4] = 1;
193
269
  copy[6] = seq;
194
270
  copy[copy.length - 1] = protocolV2.crc8(copy, copy.length - 1);
195
271
  return copy;
@@ -199,8 +275,8 @@ describe('Protocol V2 framing and session', () => {
199
275
  test('encodes and decodes Protocol V2 protobuf frames', () => {
200
276
  const frame = ProtocolV2.encodeFrame(schemas, 'ProtocolInfo', {
201
277
  version: 1,
278
+ build_fingerprint: 'application__1.0.0__abc__DEV__DEBUG',
202
279
  supported_messages: [60200, 60201],
203
- protobuf_definition: 'proto',
204
280
  });
205
281
 
206
282
  const parsed = protocolV2.decodeFrame(frame);
@@ -215,12 +291,70 @@ describe('Protocol V2 framing and session', () => {
215
291
  seq: parsed.seq,
216
292
  message: {
217
293
  version: 1,
294
+ build_fingerprint: 'application__1.0.0__abc__DEV__DEBUG',
218
295
  supported_messages: [60200, 60201],
219
- protobuf_definition: 'proto',
220
296
  },
221
297
  });
222
298
  });
223
299
 
300
+ test('decodes a two-byte legacy ProtocolInfo at the generic frame boundary', () => {
301
+ const frame = protocolV2.encodeProtobufFrame(60201, new Uint8Array([0x08, 0x01]));
302
+ const productionSchemas = {
303
+ protocolV1: protocolV1Messages,
304
+ protocolV2: productionProtocolV2Messages,
305
+ };
306
+
307
+ expect(ProtocolV2.decodeFrame(productionSchemas, frame)).toMatchObject({
308
+ type: 'ProtocolInfo',
309
+ message: {
310
+ version: 1,
311
+ build_fingerprint: '',
312
+ supported_messages: [],
313
+ protobuf_definition: null,
314
+ },
315
+ });
316
+ });
317
+
318
+ test('does not reinterpret a malformed current ProtocolInfo as the legacy layout', () => {
319
+ const frame = protocolV2.encodeProtobufFrame(60201, new Uint8Array([0x08, 0x01, 0x18, 0x01]));
320
+
321
+ expect(() =>
322
+ ProtocolV2.decodeFrame(
323
+ {
324
+ protocolV1: protocolV1Messages,
325
+ protocolV2: productionProtocolV2Messages,
326
+ },
327
+ frame
328
+ )
329
+ ).toThrow('Protocol V2 protobuf decode failed for "ProtocolInfo"');
330
+ });
331
+
332
+ test('preserves legacy ProtocolInfo capabilities when using the old field numbers', () => {
333
+ const payload = legacyProtocolInfoMessage
334
+ .encode({
335
+ version: 1,
336
+ supported_messages: [60602],
337
+ protobuf_definition: 'legacy',
338
+ })
339
+ .finish();
340
+ const frame = protocolV2.encodeProtobufFrame(60201, payload);
341
+
342
+ const decoded = ProtocolV2.decodeFrame(
343
+ {
344
+ protocolV1: protocolV1Messages,
345
+ protocolV2: productionProtocolV2Messages,
346
+ },
347
+ frame
348
+ );
349
+
350
+ expect(decoded.message).toEqual({
351
+ version: 1,
352
+ build_fingerprint: '',
353
+ supported_messages: [60602],
354
+ protobuf_definition: null,
355
+ });
356
+ });
357
+
224
358
  test('does not encode V1-only messages into Protocol V2 frames', () => {
225
359
  expect(() => ProtocolV2.encodeFrame(schemas, 'Ping', { message: 'ok' })).not.toThrow();
226
360
  expect(() => ProtocolV2.encodeFrame(schemas, 'OnekeyGetFeatures', {})).toThrow(
@@ -241,6 +375,34 @@ describe('Protocol V2 framing and session', () => {
241
375
  expect(decoded.message).toEqual({ message: 'ok' });
242
376
  });
243
377
 
378
+ test('round-trips a DeviceSession response with a 32-byte session id', () => {
379
+ const sessionId = '01'.repeat(32);
380
+ const frame = ProtocolV2.encodeFrame(schemas, 'DeviceSession', {
381
+ session_id: sessionId,
382
+ btc_test_address: 'tb1qwallet',
383
+ });
384
+
385
+ const decoded = ProtocolV2.decodeFrame(schemas, frame);
386
+
387
+ expect(decoded.type).toBe('DeviceSession');
388
+ expect(decoded.message).toEqual({
389
+ session_id: sessionId,
390
+ btc_test_address: 'tb1qwallet',
391
+ });
392
+ });
393
+
394
+ test('reports malformed DeviceSession protobuf as a schema-compatible transport error', () => {
395
+ const malformedPayload = new Uint8Array(32);
396
+ malformedPayload[0] = 0x0a;
397
+ malformedPayload[1] = 101;
398
+ const frame = protocolV2.encodeProtobufFrame(61201, malformedPayload);
399
+
400
+ expect(() => ProtocolV2.decodeFrame(schemas, frame)).toThrow(
401
+ 'Protocol V2 protobuf decode failed for "DeviceSession" (61201, 32-byte payload); ' +
402
+ 'the payload is malformed or incompatible with the active SDK schema.'
403
+ );
404
+ });
405
+
244
406
  test('decodes missing optional nested messages as null', () => {
245
407
  const frame = ProtocolV2.encodeFrame(schemas, 'PartialNested', {
246
408
  label: 'only label',
@@ -267,6 +429,60 @@ describe('Protocol V2 framing and session', () => {
267
429
  expect(() => assembler.push(oversized)).toThrow('Protocol V2 frame too large');
268
430
  });
269
431
 
432
+ test('enforces a transport-specific receive frame boundary', () => {
433
+ const assembler = new ProtocolV2FrameAssembler(2048);
434
+ const oversizedHeader = new Uint8Array([0x5a, 0x01, 0x08]);
435
+
436
+ expect(() => assembler.push(oversizedHeader)).toThrow('Protocol V2 frame too large: 2049');
437
+ });
438
+
439
+ test('enforces the firmware 4200-byte Protocol V2 frame boundary', () => {
440
+ const boundaryFrame = ProtocolV2.encodeFrame(schemas, 'Ping', {
441
+ message: 'x'.repeat(4187),
442
+ });
443
+
444
+ expect(PROTOCOL_V2_FRAME_MAX_BYTES).toBe(4200);
445
+ expect(boundaryFrame).toHaveLength(PROTOCOL_V2_FRAME_MAX_BYTES);
446
+ expect(() =>
447
+ ProtocolV2.encodeFrame(schemas, 'Ping', {
448
+ message: 'x'.repeat(4188),
449
+ })
450
+ ).toThrow('Protocol V2 frame too large: 4201 > 4200');
451
+ });
452
+
453
+ test('keeps optimized BLE firmware chunks inside the transport frame boundary', () => {
454
+ const productionSchemas = {
455
+ protocolV1: protocolV1Messages,
456
+ protocolV2: productionProtocolV2Messages,
457
+ };
458
+ const stagingPaths = [
459
+ 'vol0:/bootloader.bin',
460
+ 'vol0:/application_p1.bin',
461
+ 'vol0:/application_p2.bin',
462
+ 'vol0:/coprocessor.bin',
463
+ 'vol0:/se01.bin',
464
+ 'vol0:/se02.bin',
465
+ 'vol0:/se03.bin',
466
+ 'vol0:/se04.bin',
467
+ ];
468
+
469
+ for (const path of stagingPaths) {
470
+ const frame = ProtocolV2.encodeFrame(productionSchemas, 'FilesystemFileWrite', {
471
+ file: {
472
+ path,
473
+ offset: 0xffffffff,
474
+ total_size: 0xffffffff,
475
+ data: new Uint8Array(PROTOCOL_V2_BLE_FIRMWARE_FILE_CHUNK_SIZE),
476
+ },
477
+ overwrite: true,
478
+ append: false,
479
+ ui_percentage: 100,
480
+ });
481
+
482
+ expect(frame.length).toBeLessThanOrEqual(PROTOCOL_V2_BLE_FRAME_MAX_BYTES);
483
+ }
484
+ });
485
+
270
486
  test('keeps bytes after the first complete frame for the next read', () => {
271
487
  const first = ProtocolV2.encodeFrame(schemas, 'ProtocolInfo', {
272
488
  version: 1,
@@ -312,12 +528,90 @@ describe('Protocol V2 framing and session', () => {
312
528
  type: 'ProtocolInfo',
313
529
  message: {
314
530
  version: 2,
531
+ build_fingerprint: null,
315
532
  supported_messages: [60206],
533
+ },
534
+ });
535
+ });
536
+
537
+ test('session decodes legacy ProtocolInfo through the generic frame boundary', async () => {
538
+ const productionSchemas = {
539
+ protocolV1: protocolV1Messages,
540
+ protocolV2: productionProtocolV2Messages,
541
+ };
542
+ const response = protocolV2.encodeProtobufFrame(60201, new Uint8Array([0x08, 0x01]));
543
+ const session = new ProtocolV2Session({
544
+ schemas: productionSchemas,
545
+ router: 1,
546
+ writeFrame: () => Promise.resolve(),
547
+ readFrame: () => Promise.resolve(rewriteSeq(response, 1)),
548
+ });
549
+
550
+ await expect(
551
+ session.call(
552
+ 'ProtocolInfoRequest',
553
+ { eventless_wallet_session: true },
554
+ {
555
+ expectedTypes: ['ProtocolInfo'],
556
+ }
557
+ )
558
+ ).resolves.toEqual({
559
+ type: 'ProtocolInfo',
560
+ message: {
561
+ version: 1,
562
+ build_fingerprint: '',
563
+ supported_messages: [],
316
564
  protobuf_definition: null,
317
565
  },
318
566
  });
319
567
  });
320
568
 
569
+ test('session does not log Protocol V2 TX and RX frames', async () => {
570
+ const written = [];
571
+ const logger = { debug: jest.fn() };
572
+ const response = ProtocolV2.encodeFrame(schemas, 'ProtocolInfo', {
573
+ version: 2,
574
+ build_fingerprint: 'application__1.0.0__sensitive__DEV__DEBUG',
575
+ supported_messages: [60206],
576
+ });
577
+ const session = new ProtocolV2Session({
578
+ schemas,
579
+ router: 1,
580
+ logger,
581
+ logPrefix: 'ProtocolV2 Test',
582
+ writeFrame: frame => {
583
+ written.push(frame);
584
+ return Promise.resolve();
585
+ },
586
+ readFrame: () =>
587
+ Promise.resolve(rewriteSeq(response, protocolV2.decodeFrame(written[0]).seq)),
588
+ });
589
+
590
+ await session.call('ProtocolInfoRequest', {});
591
+
592
+ expect(logger.debug).not.toHaveBeenCalled();
593
+ });
594
+
595
+ test('session does not log RX frame metadata when protobuf payload decoding fails', async () => {
596
+ const logger = { debug: jest.fn() };
597
+ const response = protocolV2.encodeProtobufFrame(
598
+ 60208,
599
+ hexToBytes('0801121648616e646c6572206e6f742072656769737465726564')
600
+ );
601
+ const session = new ProtocolV2Session({
602
+ schemas,
603
+ router: 1,
604
+ logger,
605
+ logPrefix: 'ProtocolV2 Test',
606
+ writeFrame: () => Promise.resolve(),
607
+ readFrame: () => Promise.resolve(rewriteSeq(response, 1)),
608
+ });
609
+
610
+ await expect(session.call('Ping', { message: 'hello' })).rejects.toThrow();
611
+
612
+ expect(logger.debug).not.toHaveBeenCalled();
613
+ });
614
+
321
615
  test('session skips Proto Link ACK frames before decoding the protobuf response', async () => {
322
616
  const ack = new Uint8Array(8);
323
617
  ack[0] = 0x5a;
@@ -332,7 +626,10 @@ describe('Protocol V2 framing and session', () => {
332
626
  const response = ProtocolV2.encodeFrame(schemas, 'Success', {
333
627
  message: 'ok',
334
628
  });
335
- const readFrame = jest.fn().mockResolvedValueOnce(ack).mockResolvedValueOnce(response);
629
+ const readFrame = jest
630
+ .fn()
631
+ .mockResolvedValueOnce(ack)
632
+ .mockResolvedValueOnce(rewriteSeq(response, 1));
336
633
  const session = new ProtocolV2Session({
337
634
  schemas,
338
635
  router: 1,
@@ -349,6 +646,96 @@ describe('Protocol V2 framing and session', () => {
349
646
  expect(readFrame).toHaveBeenCalledTimes(2);
350
647
  });
351
648
 
649
+ test('session accepts a terminal response before an ACK', async () => {
650
+ const response = ProtocolV2.encodeFrame(schemas, 'Success', {
651
+ message: 'ok',
652
+ });
653
+ const session = new ProtocolV2Session({
654
+ schemas,
655
+ router: 1,
656
+ writeFrame: () => Promise.resolve(),
657
+ readFrame: () => Promise.resolve(rewriteSeq(response, 1)),
658
+ });
659
+
660
+ await expect(
661
+ session.call('Ping', { message: 'hello' }, { expectedTypes: ['Success'] })
662
+ ).resolves.toEqual({
663
+ type: 'Success',
664
+ message: {
665
+ message: 'ok',
666
+ },
667
+ });
668
+ });
669
+
670
+ test('session uses the unified response timeout before the first ACK', async () => {
671
+ const response = ProtocolV2.encodeFrame(schemas, 'Success', {
672
+ message: 'ok',
673
+ });
674
+ const readFrame = jest.fn(() => Promise.resolve(rewriteSeq(response, 1)));
675
+ const session = new ProtocolV2Session({
676
+ schemas,
677
+ router: 1,
678
+ writeFrame: () => Promise.resolve(),
679
+ readFrame,
680
+ });
681
+
682
+ await expect(
683
+ session.call(
684
+ 'Ping',
685
+ { message: 'hello' },
686
+ {
687
+ timeoutMs: 12_345,
688
+ expectedTypes: ['Success'],
689
+ }
690
+ )
691
+ ).resolves.toEqual({
692
+ type: 'Success',
693
+ message: {
694
+ message: 'ok',
695
+ },
696
+ });
697
+ expect(readFrame).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 12_345 }));
698
+ });
699
+
700
+ test('session rejects an ACK that belongs to another request sequence', async () => {
701
+ const ack = new Uint8Array(8);
702
+ ack[0] = 0x5a;
703
+ ack[1] = 8;
704
+ ack[4] = 1;
705
+ ack[5] = 1;
706
+ ack[6] = 2;
707
+ ack[3] = protocolV2.crc8(ack, 3);
708
+ ack[7] = protocolV2.crc8(ack, 7);
709
+ const session = new ProtocolV2Session({
710
+ schemas,
711
+ router: 1,
712
+ writeFrame: () => Promise.resolve(),
713
+ readFrame: () => Promise.resolve(ack),
714
+ });
715
+
716
+ await expect(session.call('Ping', { message: 'hello' })).rejects.toEqual(
717
+ expect.objectContaining({
718
+ name: 'ProtocolV2LinkError',
719
+ code: 'ack-sequence',
720
+ message: 'Protocol V2 ACK sequence mismatch: expected 1, got 2',
721
+ })
722
+ );
723
+ });
724
+
725
+ test('session rejects a response from another routing channel', async () => {
726
+ const response = ProtocolV2.encodeFrame(schemas, 'Success', { message: 'wrong route' });
727
+ const session = new ProtocolV2Session({
728
+ schemas,
729
+ router: 1,
730
+ writeFrame: () => Promise.resolve(),
731
+ readFrame: () => Promise.resolve(response),
732
+ });
733
+
734
+ await expect(session.call('Ping', { message: 'hello' })).rejects.toBeInstanceOf(
735
+ ProtocolV2LinkError
736
+ );
737
+ });
738
+
352
739
  test('session rejects BLE frames above its configured frame limit before writing', async () => {
353
740
  const writeFrame = jest.fn().mockResolvedValue(undefined);
354
741
  const response = ProtocolV2.encodeFrame(schemas, 'Success', {
@@ -359,7 +746,7 @@ describe('Protocol V2 framing and session', () => {
359
746
  router: 1,
360
747
  maxFrameBytes: 2048,
361
748
  writeFrame,
362
- readFrame: () => Promise.resolve(response),
749
+ readFrame: () => Promise.resolve(rewriteSeq(response, 1)),
363
750
  });
364
751
 
365
752
  await expect(session.call('Ping', { message: 'x'.repeat(2048) })).rejects.toThrow(
@@ -368,31 +755,43 @@ describe('Protocol V2 framing and session', () => {
368
755
  expect(writeFrame).not.toHaveBeenCalled();
369
756
  });
370
757
 
371
- test('session starts response timeout after the frame is written', async () => {
372
- const response = ProtocolV2.encodeFrame(schemas, 'Success', {
373
- message: 'ok',
374
- });
758
+ test('session watchdog covers a write that never settles', async () => {
759
+ const readFrame = jest.fn();
760
+ let writeSignal;
375
761
  const session = new ProtocolV2Session({
376
762
  schemas,
377
763
  router: 1,
378
- writeFrame: () =>
379
- new Promise(resolve => {
380
- setTimeout(resolve, 30);
381
- }),
382
- readFrame: () => Promise.resolve(response),
764
+ writeFrame: (_frame, context) => {
765
+ writeSignal = context.signal;
766
+ return new Promise(() => {});
767
+ },
768
+ readFrame,
383
769
  });
384
770
 
385
771
  await expect(
386
772
  session.call('Ping', { message: 'hello' }, { timeoutMs: 10, expectedTypes: ['Success'] })
387
- ).resolves.toEqual({
388
- type: 'Success',
389
- message: {
390
- message: 'ok',
391
- },
773
+ ).rejects.toThrow('Protocol V2 response timeout after 10ms for Ping');
774
+ expect(writeSignal.aborted).toBe(true);
775
+ expect(readFrame).not.toHaveBeenCalled();
776
+ });
777
+
778
+ test('session watchdog covers prepareCall before writing', async () => {
779
+ const writeFrame = jest.fn();
780
+ const session = new ProtocolV2Session({
781
+ schemas,
782
+ router: 1,
783
+ prepareCall: () => new Promise(() => {}),
784
+ writeFrame,
785
+ readFrame: jest.fn(),
392
786
  });
787
+
788
+ await expect(session.call('Ping', { message: 'hello' }, { timeoutMs: 10 })).rejects.toThrow(
789
+ 'Protocol V2 response timeout after 10ms for Ping'
790
+ );
791
+ expect(writeFrame).not.toHaveBeenCalled();
393
792
  });
394
793
 
395
- test('session accepts response frames with a device-owned seq without logging frame details', async () => {
794
+ test('session logs a device-owned response seq without rejecting the frame', async () => {
396
795
  const response = ProtocolV2.encodeFrame(schemas, 'ProtocolInfo', {
397
796
  version: 2,
398
797
  supported_messages: [],
@@ -412,13 +811,48 @@ describe('Protocol V2 framing and session', () => {
412
811
  type: 'ProtocolInfo',
413
812
  message: {
414
813
  version: 2,
814
+ build_fingerprint: null,
415
815
  supported_messages: [],
416
- protobuf_definition: null,
417
816
  },
418
817
  });
419
818
  expect(logger.debug).not.toHaveBeenCalled();
420
819
  });
421
820
 
821
+ test('session accepts gaps in the firmware-global response sequence', async () => {
822
+ const response = ProtocolV2.encodeFrame(schemas, 'Success', { message: 'ok' });
823
+ const readFrame = jest
824
+ .fn()
825
+ .mockResolvedValueOnce(rewriteSeq(response, 1))
826
+ .mockResolvedValueOnce(rewriteSeq(response, 4));
827
+ const session = new ProtocolV2Session({
828
+ schemas,
829
+ router: 1,
830
+ writeFrame: () => Promise.resolve(),
831
+ readFrame,
832
+ });
833
+
834
+ await session.call('Ping', { message: 'first' });
835
+ await expect(session.call('Ping', { message: 'second' })).resolves.toMatchObject({
836
+ type: 'Success',
837
+ });
838
+ });
839
+
840
+ test('session rejects a duplicate device response sequence', async () => {
841
+ const response = ProtocolV2.encodeFrame(schemas, 'Success', { message: 'ok' });
842
+ const readFrame = jest.fn(() => Promise.resolve(rewriteSeq(response, 7)));
843
+ const session = new ProtocolV2Session({
844
+ schemas,
845
+ router: 1,
846
+ writeFrame: () => Promise.resolve(),
847
+ readFrame,
848
+ });
849
+
850
+ await session.call('Ping', { message: 'first' });
851
+ await expect(session.call('Ping', { message: 'second' })).rejects.toThrow(
852
+ 'Protocol V2 duplicate response sequence: 7'
853
+ );
854
+ });
855
+
422
856
  test('session does not log transmit or receive payload details', async () => {
423
857
  const response = ProtocolV2.encodeFrame(schemas, 'Success', {
424
858
  message: 'accepted',
@@ -430,7 +864,7 @@ describe('Protocol V2 framing and session', () => {
430
864
  schemas,
431
865
  router: 1,
432
866
  writeFrame: () => Promise.resolve(),
433
- readFrame: () => Promise.resolve(response),
867
+ readFrame: () => Promise.resolve(rewriteSeq(response, 1)),
434
868
  logger,
435
869
  logPrefix: 'ProtocolV2 Test',
436
870
  });
@@ -456,7 +890,7 @@ describe('Protocol V2 framing and session', () => {
456
890
  schemas,
457
891
  router: 1,
458
892
  writeFrame: () => Promise.resolve(),
459
- readFrame: () => Promise.resolve(response),
893
+ readFrame: () => Promise.resolve(rewriteSeq(response, 1)),
460
894
  logger,
461
895
  });
462
896
 
@@ -470,6 +904,34 @@ describe('Protocol V2 framing and session', () => {
470
904
  expect(logger.debug).not.toHaveBeenCalled();
471
905
  });
472
906
 
907
+ test('session reports when the complete request frame has been written', async () => {
908
+ const response = ProtocolV2.encodeFrame(schemas, 'Success', {
909
+ message: 'ok',
910
+ });
911
+ const events = [];
912
+ const onWriteCompleted = jest.fn(metrics => events.push(['written', metrics]));
913
+ const session = new ProtocolV2Session({
914
+ schemas,
915
+ router: 1,
916
+ writeFrame: frame => {
917
+ events.push(['write', frame.byteLength]);
918
+ return Promise.resolve();
919
+ },
920
+ readFrame: () => {
921
+ events.push(['read']);
922
+ return Promise.resolve(rewriteSeq(response, 1));
923
+ },
924
+ });
925
+
926
+ await session.call('Ping', { message: 'metrics' }, { onWriteCompleted });
927
+
928
+ expect(events.map(event => event[0])).toEqual(['write', 'written', 'read']);
929
+ expect(onWriteCompleted).toHaveBeenCalledWith({
930
+ elapsedMs: expect.any(Number),
931
+ frameBytes: expect.any(Number),
932
+ });
933
+ });
934
+
473
935
  test('session skips unrelated terminal frames when expected response types are provided', async () => {
474
936
  const stale = ProtocolV2.encodeFrame(schemas, 'Success', {
475
937
  message: 'stale response',
@@ -481,7 +943,10 @@ describe('Protocol V2 framing and session', () => {
481
943
  const logger = {
482
944
  debug: jest.fn(),
483
945
  };
484
- const readFrame = jest.fn().mockResolvedValueOnce(stale).mockResolvedValueOnce(response);
946
+ const readFrame = jest
947
+ .fn()
948
+ .mockResolvedValueOnce(rewriteSeq(stale, 1))
949
+ .mockResolvedValueOnce(rewriteSeq(response, 2));
485
950
  const session = new ProtocolV2Session({
486
951
  schemas,
487
952
  router: 1,
@@ -496,8 +961,8 @@ describe('Protocol V2 framing and session', () => {
496
961
  type: 'ProtocolInfo',
497
962
  message: {
498
963
  version: 2,
964
+ build_fingerprint: null,
499
965
  supported_messages: [],
500
- protobuf_definition: null,
501
966
  },
502
967
  });
503
968
 
@@ -505,22 +970,10 @@ describe('Protocol V2 framing and session', () => {
505
970
  expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('skip unexpected response'));
506
971
  });
507
972
 
508
- test('session consumes intermediate response frames before returning the final response', async () => {
973
+ test('session can return after a request frame is written', async () => {
509
974
  const written = [];
510
- const progress = ProtocolV2.encodeFrame(schemas, 'DeviceFirmwareUpdateStatus', {
511
- records: [{ target_id: 4, status: 1 }],
512
- });
513
- const success = ProtocolV2.encodeFrame(schemas, 'Success', {
514
- message: 'ok',
515
- });
516
- const onIntermediateResponse = jest.fn();
517
- const readFrame = jest.fn(() => {
518
- const [writtenFrame] = written;
519
- const { seq } = protocolV2.decodeFrame(writtenFrame);
520
- return Promise.resolve(
521
- readFrame.mock.calls.length === 1 ? rewriteSeq(progress, seq) : rewriteSeq(success, seq)
522
- );
523
- });
975
+ const readFrame = jest.fn();
976
+ const onWriteCompleted = jest.fn();
524
977
  const session = new ProtocolV2Session({
525
978
  schemas,
526
979
  router: 1,
@@ -533,26 +986,20 @@ describe('Protocol V2 framing and session', () => {
533
986
 
534
987
  const result = await session.call(
535
988
  'DeviceFirmwareUpdateRequest',
536
- { targets: [{ target_id: 4, path: 'vol1:firmware.bin' }] },
989
+ {},
537
990
  {
538
- intermediateTypes: ['DeviceFirmwareUpdateStatus'],
539
- onIntermediateResponse,
991
+ returnAfterWrite: true,
992
+ onWriteCompleted,
540
993
  }
541
994
  );
542
995
 
543
- expect(readFrame).toHaveBeenCalledTimes(2);
544
- expect(onIntermediateResponse).toHaveBeenCalledWith({
545
- type: 'DeviceFirmwareUpdateStatus',
546
- message: {
547
- records: [{ target_id: 4, status: 1, payload_version: null, path: null }],
548
- },
549
- });
550
- expect(result).toEqual({
551
- type: 'Success',
552
- message: {
553
- message: 'ok',
554
- },
555
- });
996
+ expect(written).toHaveLength(1);
997
+ expect(ProtocolV2.decodeFrame(schemas, written[0]).messageName).toBe(
998
+ 'DeviceFirmwareUpdateRequest'
999
+ );
1000
+ expect(readFrame).not.toHaveBeenCalled();
1001
+ expect(onWriteCompleted).toHaveBeenCalledTimes(1);
1002
+ expect(result).toEqual({ type: 'WriteCompleted', message: {} });
556
1003
  });
557
1004
 
558
1005
  test('probeProtocolV2 accepts Success as a normal V2 probe response', async () => {
@@ -695,7 +1142,7 @@ describe('Protocol V2 framing and session', () => {
695
1142
  }
696
1143
  return Promise.resolve();
697
1144
  },
698
- readFrame: () => Promise.resolve(response),
1145
+ readFrame: () => Promise.resolve(rewriteSeq(response, 1)),
699
1146
  });
700
1147
 
701
1148
  await expect(session.call('Ping', { message: '1' })).rejects.toThrow('transport write failed');
@@ -764,6 +1211,7 @@ describe('Protocol V2 framing and session', () => {
764
1211
  const writeContexts = [];
765
1212
  const readContexts = [];
766
1213
  const response = ProtocolV2.encodeFrame(schemas, 'Success', { message: 'ok' });
1214
+ let responseSeq = 0;
767
1215
  const session = new ProtocolV2Session({
768
1216
  schemas,
769
1217
  router: 1,
@@ -774,18 +1222,67 @@ describe('Protocol V2 framing and session', () => {
774
1222
  },
775
1223
  readFrame: context => {
776
1224
  readContexts.push(context);
777
- return Promise.resolve(response);
1225
+ responseSeq = protocolV2.nextProtoSeq(responseSeq);
1226
+ return Promise.resolve(rewriteSeq(response, responseSeq));
778
1227
  },
779
1228
  });
780
1229
 
781
1230
  await session.call('Ping', { message: 'ping' }, { timeoutMs: 123 });
782
- await session.call('FileWrite', {}, { timeoutMs: 456 });
1231
+ await session.call('FileWrite', {}, { timeoutMs: 456, writeWithResponse: true });
1232
+ await session.call('Ping', { message: 'default-timeout' });
783
1233
 
784
- expect(writeContexts).toEqual([
785
- { messageName: 'Ping', timeoutMs: 123, highVolume: false, generation: 7 },
786
- { messageName: 'FileWrite', timeoutMs: 456, highVolume: true, generation: 7 },
1234
+ const normalizeContext = ({ signal, ...context }) => ({
1235
+ ...context,
1236
+ signalAborted: signal.aborted,
1237
+ });
1238
+ expect(writeContexts.map(normalizeContext)).toEqual([
1239
+ {
1240
+ messageName: 'Ping',
1241
+ timeoutMs: 123,
1242
+ highThroughput: false,
1243
+ generation: 7,
1244
+ signalAborted: false,
1245
+ },
1246
+ {
1247
+ messageName: 'FileWrite',
1248
+ timeoutMs: 456,
1249
+ highThroughput: true,
1250
+ writeWithResponse: true,
1251
+ generation: 7,
1252
+ signalAborted: false,
1253
+ },
1254
+ {
1255
+ messageName: 'Ping',
1256
+ timeoutMs: PROTOCOL_V2_DEFAULT_RESPONSE_TIMEOUT_MS,
1257
+ highThroughput: false,
1258
+ generation: 7,
1259
+ signalAborted: false,
1260
+ },
1261
+ ]);
1262
+ expect(readContexts.map(normalizeContext)).toEqual([
1263
+ {
1264
+ messageName: 'Ping',
1265
+ timeoutMs: 123,
1266
+ highThroughput: false,
1267
+ generation: 7,
1268
+ signalAborted: false,
1269
+ },
1270
+ {
1271
+ messageName: 'FileWrite',
1272
+ timeoutMs: 456,
1273
+ highThroughput: true,
1274
+ writeWithResponse: true,
1275
+ generation: 7,
1276
+ signalAborted: false,
1277
+ },
1278
+ {
1279
+ messageName: 'Ping',
1280
+ timeoutMs: PROTOCOL_V2_DEFAULT_RESPONSE_TIMEOUT_MS,
1281
+ highThroughput: false,
1282
+ generation: 7,
1283
+ signalAborted: false,
1284
+ },
787
1285
  ]);
788
- expect(readContexts).toEqual(writeContexts);
789
1286
  });
790
1287
 
791
1288
  test('assembler throws and resets on frames with an impossible length field', () => {