@onekeyfe/hd-transport 1.2.0-alpha.13 → 1.2.0-alpha.131

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 +144 -4
  2. package/__tests__/protocol-v2-ble-frame-writer.test.js +119 -0
  3. package/__tests__/protocol-v2-link-manager.test.js +176 -9
  4. package/__tests__/protocol-v2-usb-transport-base.test.js +36 -2
  5. package/__tests__/protocol-v2.test.js +540 -108
  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 +645 -217
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +679 -153
  12. package/dist/protocols/index.d.ts +17 -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 +16 -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 +5 -0
  24. package/dist/protocols/v2/link-manager.d.ts.map +1 -1
  25. package/dist/protocols/v2/session.d.ts +14 -4
  26. package/dist/protocols/v2/session.d.ts.map +1 -1
  27. package/dist/protocols/v2/usb-transport-base.d.ts +2 -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 +301 -156
  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 +523 -449
  38. package/package.json +2 -2
  39. package/scripts/protobuf-build.sh +112 -32
  40. package/scripts/protobuf-patches/index.js +1 -0
  41. package/scripts/protobuf-types.js +10 -1
  42. package/src/constants.ts +30 -16
  43. package/src/index.ts +5 -1
  44. package/src/protocols/index.ts +114 -3
  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 +54 -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 +65 -5
  53. package/src/protocols/v2/session.ts +224 -80
  54. package/src/protocols/v2/usb-transport-base.ts +42 -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 +375 -198
  58. package/src/types/transport.ts +30 -3
  59. package/src/utils/transportLog.ts +102 -0
@@ -2,12 +2,27 @@ 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,
11
+ isProtocolV2LinkDisabledFailure,
9
12
  } = require('../src/protocols/v2/session');
10
13
  const protocolV2 = require('../src/protocols/v2');
14
+ const {
15
+ PROTOCOL_V2_BLE_FIRMWARE_FILE_CHUNK_SIZE,
16
+ PROTOCOL_V2_BLE_FRAME_MAX_BYTES,
17
+ PROTOCOL_V2_DEFAULT_RESPONSE_TIMEOUT_MS,
18
+ PROTOCOL_V2_FRAME_MAX_BYTES,
19
+ } = require('../src/constants');
20
+
21
+ test('keeps sensitive acknowledgements separate from high-throughput calls', () => {
22
+ expect(isProtocolV2HighThroughputCall('PassphraseAck')).toBe(false);
23
+ expect(isProtocolV2HighThroughputCall('PinMatrixAck')).toBe(false);
24
+ expect(isProtocolV2HighThroughputCall('FilesystemFileWrite')).toBe(true);
25
+ });
11
26
 
12
27
  const protocolV1Messages = parseConfigure({
13
28
  nested: {
@@ -68,14 +83,14 @@ const protocolV2Messages = parseConfigure({
68
83
  type: 'uint32',
69
84
  id: 1,
70
85
  },
71
- supported_messages: {
72
- type: 'uint32',
86
+ build_fingerprint: {
87
+ type: 'string',
73
88
  id: 2,
74
- rule: 'repeated',
75
89
  },
76
- protobuf_definition: {
77
- type: 'string',
90
+ supported_messages: {
91
+ type: 'uint32',
78
92
  id: 3,
93
+ rule: 'repeated',
79
94
  },
80
95
  },
81
96
  },
@@ -123,7 +138,7 @@ const protocolV2Messages = parseConfigure({
123
138
  },
124
139
  },
125
140
  },
126
- DeviceFirmwareUpdateRequest: {
141
+ DeviceFirmwareUpdateStage: {
127
142
  fields: {
128
143
  targets: {
129
144
  type: 'DeviceFirmwareTarget',
@@ -132,6 +147,9 @@ const protocolV2Messages = parseConfigure({
132
147
  },
133
148
  },
134
149
  },
150
+ DeviceFirmwareUpdateRequest: {
151
+ fields: {},
152
+ },
135
153
  DeviceFirmwareUpdateRecord: {
136
154
  fields: {
137
155
  target_id: {
@@ -161,6 +179,18 @@ const protocolV2Messages = parseConfigure({
161
179
  },
162
180
  },
163
181
  },
182
+ DeviceSession: {
183
+ fields: {
184
+ session_id: {
185
+ type: 'bytes',
186
+ id: 1,
187
+ },
188
+ btc_test_address: {
189
+ type: 'string',
190
+ id: 2,
191
+ },
192
+ },
193
+ },
164
194
  FileWrite: {
165
195
  fields: {},
166
196
  },
@@ -192,8 +222,10 @@ const protocolV2Messages = parseConfigure({
192
222
  MessageType_Success: 60207,
193
223
  MessageType_Failure: 60208,
194
224
  MessageType_FileWrite: 60805,
195
- MessageType_DeviceFirmwareUpdateRequest: 61000,
225
+ MessageType_DeviceFirmwareUpdateStage: 61000,
226
+ MessageType_DeviceFirmwareUpdateRequest: 61001,
196
227
  MessageType_DeviceFirmwareUpdateStatus: 61002,
228
+ MessageType_DeviceSession: 61201,
197
229
  MessageType_PartialNested: 62000,
198
230
  },
199
231
  },
@@ -204,9 +236,37 @@ const schemas = {
204
236
  protocolV1: protocolV1Messages,
205
237
  protocolV2: protocolV2Messages,
206
238
  };
239
+ const productionProtocolV2Messages = parseConfigure(require('../messages-protocol-v2.json'));
240
+
241
+ const legacyProtocolInfoMessage = parseConfigure({
242
+ nested: {
243
+ ProtocolInfo: {
244
+ fields: {
245
+ version: {
246
+ type: 'uint32',
247
+ id: 1,
248
+ rule: 'required',
249
+ },
250
+ supported_messages: {
251
+ type: 'uint32',
252
+ id: 2,
253
+ rule: 'repeated',
254
+ options: {
255
+ packed: false,
256
+ },
257
+ },
258
+ protobuf_definition: {
259
+ type: 'string',
260
+ id: 3,
261
+ },
262
+ },
263
+ },
264
+ },
265
+ }).lookupType('ProtocolInfo');
207
266
 
208
267
  const rewriteSeq = (frame, seq) => {
209
268
  const copy = new Uint8Array(frame);
269
+ copy[4] = 1;
210
270
  copy[6] = seq;
211
271
  copy[copy.length - 1] = protocolV2.crc8(copy, copy.length - 1);
212
272
  return copy;
@@ -216,8 +276,8 @@ describe('Protocol V2 framing and session', () => {
216
276
  test('encodes and decodes Protocol V2 protobuf frames', () => {
217
277
  const frame = ProtocolV2.encodeFrame(schemas, 'ProtocolInfo', {
218
278
  version: 1,
279
+ build_fingerprint: 'application__1.0.0__abc__DEV__DEBUG',
219
280
  supported_messages: [60200, 60201],
220
- protobuf_definition: 'proto',
221
281
  });
222
282
 
223
283
  const parsed = protocolV2.decodeFrame(frame);
@@ -232,12 +292,70 @@ describe('Protocol V2 framing and session', () => {
232
292
  seq: parsed.seq,
233
293
  message: {
234
294
  version: 1,
295
+ build_fingerprint: 'application__1.0.0__abc__DEV__DEBUG',
235
296
  supported_messages: [60200, 60201],
236
- protobuf_definition: 'proto',
237
297
  },
238
298
  });
239
299
  });
240
300
 
301
+ test('decodes a two-byte legacy ProtocolInfo at the generic frame boundary', () => {
302
+ const frame = protocolV2.encodeProtobufFrame(60201, new Uint8Array([0x08, 0x01]));
303
+ const productionSchemas = {
304
+ protocolV1: protocolV1Messages,
305
+ protocolV2: productionProtocolV2Messages,
306
+ };
307
+
308
+ expect(ProtocolV2.decodeFrame(productionSchemas, frame)).toMatchObject({
309
+ type: 'ProtocolInfo',
310
+ message: {
311
+ version: 1,
312
+ build_fingerprint: '',
313
+ supported_messages: [],
314
+ protobuf_definition: null,
315
+ },
316
+ });
317
+ });
318
+
319
+ test('does not reinterpret a malformed current ProtocolInfo as the legacy layout', () => {
320
+ const frame = protocolV2.encodeProtobufFrame(60201, new Uint8Array([0x08, 0x01, 0x18, 0x01]));
321
+
322
+ expect(() =>
323
+ ProtocolV2.decodeFrame(
324
+ {
325
+ protocolV1: protocolV1Messages,
326
+ protocolV2: productionProtocolV2Messages,
327
+ },
328
+ frame
329
+ )
330
+ ).toThrow('Protocol V2 protobuf decode failed for "ProtocolInfo"');
331
+ });
332
+
333
+ test('preserves legacy ProtocolInfo capabilities when using the old field numbers', () => {
334
+ const payload = legacyProtocolInfoMessage
335
+ .encode({
336
+ version: 1,
337
+ supported_messages: [60602],
338
+ protobuf_definition: 'legacy',
339
+ })
340
+ .finish();
341
+ const frame = protocolV2.encodeProtobufFrame(60201, payload);
342
+
343
+ const decoded = ProtocolV2.decodeFrame(
344
+ {
345
+ protocolV1: protocolV1Messages,
346
+ protocolV2: productionProtocolV2Messages,
347
+ },
348
+ frame
349
+ );
350
+
351
+ expect(decoded.message).toEqual({
352
+ version: 1,
353
+ build_fingerprint: '',
354
+ supported_messages: [60602],
355
+ protobuf_definition: null,
356
+ });
357
+ });
358
+
241
359
  test('does not encode V1-only messages into Protocol V2 frames', () => {
242
360
  expect(() => ProtocolV2.encodeFrame(schemas, 'Ping', { message: 'ok' })).not.toThrow();
243
361
  expect(() => ProtocolV2.encodeFrame(schemas, 'OnekeyGetFeatures', {})).toThrow(
@@ -258,6 +376,34 @@ describe('Protocol V2 framing and session', () => {
258
376
  expect(decoded.message).toEqual({ message: 'ok' });
259
377
  });
260
378
 
379
+ test('round-trips a DeviceSession response with a 32-byte session id', () => {
380
+ const sessionId = '01'.repeat(32);
381
+ const frame = ProtocolV2.encodeFrame(schemas, 'DeviceSession', {
382
+ session_id: sessionId,
383
+ btc_test_address: 'tb1qwallet',
384
+ });
385
+
386
+ const decoded = ProtocolV2.decodeFrame(schemas, frame);
387
+
388
+ expect(decoded.type).toBe('DeviceSession');
389
+ expect(decoded.message).toEqual({
390
+ session_id: sessionId,
391
+ btc_test_address: 'tb1qwallet',
392
+ });
393
+ });
394
+
395
+ test('reports malformed DeviceSession protobuf as a schema-compatible transport error', () => {
396
+ const malformedPayload = new Uint8Array(32);
397
+ malformedPayload[0] = 0x0a;
398
+ malformedPayload[1] = 101;
399
+ const frame = protocolV2.encodeProtobufFrame(61201, malformedPayload);
400
+
401
+ expect(() => ProtocolV2.decodeFrame(schemas, frame)).toThrow(
402
+ 'Protocol V2 protobuf decode failed for "DeviceSession" (61201, 32-byte payload); ' +
403
+ 'the payload is malformed or incompatible with the active SDK schema.'
404
+ );
405
+ });
406
+
261
407
  test('decodes missing optional nested messages as null', () => {
262
408
  const frame = ProtocolV2.encodeFrame(schemas, 'PartialNested', {
263
409
  label: 'only label',
@@ -284,6 +430,60 @@ describe('Protocol V2 framing and session', () => {
284
430
  expect(() => assembler.push(oversized)).toThrow('Protocol V2 frame too large');
285
431
  });
286
432
 
433
+ test('enforces a transport-specific receive frame boundary', () => {
434
+ const assembler = new ProtocolV2FrameAssembler(2048);
435
+ const oversizedHeader = new Uint8Array([0x5a, 0x01, 0x08]);
436
+
437
+ expect(() => assembler.push(oversizedHeader)).toThrow('Protocol V2 frame too large: 2049');
438
+ });
439
+
440
+ test('enforces the firmware 4200-byte Protocol V2 frame boundary', () => {
441
+ const boundaryFrame = ProtocolV2.encodeFrame(schemas, 'Ping', {
442
+ message: 'x'.repeat(4187),
443
+ });
444
+
445
+ expect(PROTOCOL_V2_FRAME_MAX_BYTES).toBe(4200);
446
+ expect(boundaryFrame).toHaveLength(PROTOCOL_V2_FRAME_MAX_BYTES);
447
+ expect(() =>
448
+ ProtocolV2.encodeFrame(schemas, 'Ping', {
449
+ message: 'x'.repeat(4188),
450
+ })
451
+ ).toThrow('Protocol V2 frame too large: 4201 > 4200');
452
+ });
453
+
454
+ test('keeps optimized BLE firmware chunks inside the transport frame boundary', () => {
455
+ const productionSchemas = {
456
+ protocolV1: protocolV1Messages,
457
+ protocolV2: productionProtocolV2Messages,
458
+ };
459
+ const stagingPaths = [
460
+ 'vol0:/bootloader.bin',
461
+ 'vol0:/application_p1.bin',
462
+ 'vol0:/application_p2.bin',
463
+ 'vol0:/coprocessor.bin',
464
+ 'vol0:/se01.bin',
465
+ 'vol0:/se02.bin',
466
+ 'vol0:/se03.bin',
467
+ 'vol0:/se04.bin',
468
+ ];
469
+
470
+ for (const path of stagingPaths) {
471
+ const frame = ProtocolV2.encodeFrame(productionSchemas, 'FilesystemFileWrite', {
472
+ file: {
473
+ path,
474
+ offset: 0xffffffff,
475
+ total_size: 0xffffffff,
476
+ data: new Uint8Array(PROTOCOL_V2_BLE_FIRMWARE_FILE_CHUNK_SIZE),
477
+ },
478
+ overwrite: true,
479
+ append: false,
480
+ ui_percentage: 100,
481
+ });
482
+
483
+ expect(frame.length).toBeLessThanOrEqual(PROTOCOL_V2_BLE_FRAME_MAX_BYTES);
484
+ }
485
+ });
486
+
287
487
  test('keeps bytes after the first complete frame for the next read', () => {
288
488
  const first = ProtocolV2.encodeFrame(schemas, 'ProtocolInfo', {
289
489
  version: 1,
@@ -329,19 +529,51 @@ describe('Protocol V2 framing and session', () => {
329
529
  type: 'ProtocolInfo',
330
530
  message: {
331
531
  version: 2,
532
+ build_fingerprint: null,
332
533
  supported_messages: [60206],
534
+ },
535
+ });
536
+ });
537
+
538
+ test('session decodes legacy ProtocolInfo through the generic frame boundary', async () => {
539
+ const productionSchemas = {
540
+ protocolV1: protocolV1Messages,
541
+ protocolV2: productionProtocolV2Messages,
542
+ };
543
+ const response = protocolV2.encodeProtobufFrame(60201, new Uint8Array([0x08, 0x01]));
544
+ const session = new ProtocolV2Session({
545
+ schemas: productionSchemas,
546
+ router: 1,
547
+ writeFrame: () => Promise.resolve(),
548
+ readFrame: () => Promise.resolve(rewriteSeq(response, 1)),
549
+ });
550
+
551
+ await expect(
552
+ session.call(
553
+ 'ProtocolInfoRequest',
554
+ { eventless_wallet_session: true },
555
+ {
556
+ expectedTypes: ['ProtocolInfo'],
557
+ }
558
+ )
559
+ ).resolves.toEqual({
560
+ type: 'ProtocolInfo',
561
+ message: {
562
+ version: 1,
563
+ build_fingerprint: '',
564
+ supported_messages: [],
333
565
  protobuf_definition: null,
334
566
  },
335
567
  });
336
568
  });
337
569
 
338
- test('session logs Protocol V2 TX and RX frame metadata without payloads', async () => {
570
+ test('session does not log Protocol V2 TX and RX frames', async () => {
339
571
  const written = [];
340
572
  const logger = { debug: jest.fn() };
341
573
  const response = ProtocolV2.encodeFrame(schemas, 'ProtocolInfo', {
342
574
  version: 2,
575
+ build_fingerprint: 'application__1.0.0__sensitive__DEV__DEBUG',
343
576
  supported_messages: [60206],
344
- protobuf_definition: 'sensitive-schema',
345
577
  });
346
578
  const session = new ProtocolV2Session({
347
579
  schemas,
@@ -358,27 +590,10 @@ describe('Protocol V2 framing and session', () => {
358
590
 
359
591
  await session.call('ProtocolInfoRequest', {});
360
592
 
361
- const tx = protocolV2.decodeFrame(written[0]);
362
- const rx = protocolV2.decodeFrame(rewriteSeq(response, tx.seq));
363
- expect(logger.debug).toHaveBeenCalledWith('[ProtocolV2 Test] TX', {
364
- method: 'ProtocolInfoRequest',
365
- type: 'ProtocolInfoRequest',
366
- typeId: tx.messageTypeId,
367
- seq: tx.seq,
368
- bytes: written[0].length,
369
- });
370
- expect(logger.debug).toHaveBeenCalledWith('[ProtocolV2 Test] RX', {
371
- method: 'ProtocolInfoRequest',
372
- type: 'ProtocolInfo',
373
- typeId: rx.messageTypeId,
374
- seq: rx.seq,
375
- bytes: response.length,
376
- });
377
- expect(JSON.stringify(logger.debug.mock.calls)).not.toContain('sensitive-schema');
378
- expect(JSON.stringify(logger.debug.mock.calls)).not.toContain('supported_messages');
593
+ expect(logger.debug).not.toHaveBeenCalled();
379
594
  });
380
595
 
381
- test('session logs RX frame metadata before protobuf payload decoding fails', async () => {
596
+ test('session does not log RX frame metadata when protobuf payload decoding fails', async () => {
382
597
  const logger = { debug: jest.fn() };
383
598
  const response = protocolV2.encodeProtobufFrame(
384
599
  60208,
@@ -390,19 +605,12 @@ describe('Protocol V2 framing and session', () => {
390
605
  logger,
391
606
  logPrefix: 'ProtocolV2 Test',
392
607
  writeFrame: () => Promise.resolve(),
393
- readFrame: () => Promise.resolve(response),
608
+ readFrame: () => Promise.resolve(rewriteSeq(response, 1)),
394
609
  });
395
610
 
396
611
  await expect(session.call('Ping', { message: 'hello' })).rejects.toThrow();
397
612
 
398
- const rx = protocolV2.decodeFrame(response);
399
- expect(logger.debug).toHaveBeenCalledWith('[ProtocolV2 Test] RX', {
400
- method: 'Ping',
401
- type: 'Failure',
402
- typeId: rx.messageTypeId,
403
- seq: rx.seq,
404
- bytes: response.length,
405
- });
613
+ expect(logger.debug).not.toHaveBeenCalled();
406
614
  });
407
615
 
408
616
  test('session skips Proto Link ACK frames before decoding the protobuf response', async () => {
@@ -419,7 +627,10 @@ describe('Protocol V2 framing and session', () => {
419
627
  const response = ProtocolV2.encodeFrame(schemas, 'Success', {
420
628
  message: 'ok',
421
629
  });
422
- const readFrame = jest.fn().mockResolvedValueOnce(ack).mockResolvedValueOnce(response);
630
+ const readFrame = jest
631
+ .fn()
632
+ .mockResolvedValueOnce(ack)
633
+ .mockResolvedValueOnce(rewriteSeq(response, 1));
423
634
  const session = new ProtocolV2Session({
424
635
  schemas,
425
636
  router: 1,
@@ -436,6 +647,96 @@ describe('Protocol V2 framing and session', () => {
436
647
  expect(readFrame).toHaveBeenCalledTimes(2);
437
648
  });
438
649
 
650
+ test('session accepts a terminal response before an ACK', async () => {
651
+ const response = ProtocolV2.encodeFrame(schemas, 'Success', {
652
+ message: 'ok',
653
+ });
654
+ const session = new ProtocolV2Session({
655
+ schemas,
656
+ router: 1,
657
+ writeFrame: () => Promise.resolve(),
658
+ readFrame: () => Promise.resolve(rewriteSeq(response, 1)),
659
+ });
660
+
661
+ await expect(
662
+ session.call('Ping', { message: 'hello' }, { expectedTypes: ['Success'] })
663
+ ).resolves.toEqual({
664
+ type: 'Success',
665
+ message: {
666
+ message: 'ok',
667
+ },
668
+ });
669
+ });
670
+
671
+ test('session uses the unified response timeout before the first ACK', async () => {
672
+ const response = ProtocolV2.encodeFrame(schemas, 'Success', {
673
+ message: 'ok',
674
+ });
675
+ const readFrame = jest.fn(() => Promise.resolve(rewriteSeq(response, 1)));
676
+ const session = new ProtocolV2Session({
677
+ schemas,
678
+ router: 1,
679
+ writeFrame: () => Promise.resolve(),
680
+ readFrame,
681
+ });
682
+
683
+ await expect(
684
+ session.call(
685
+ 'Ping',
686
+ { message: 'hello' },
687
+ {
688
+ timeoutMs: 12_345,
689
+ expectedTypes: ['Success'],
690
+ }
691
+ )
692
+ ).resolves.toEqual({
693
+ type: 'Success',
694
+ message: {
695
+ message: 'ok',
696
+ },
697
+ });
698
+ expect(readFrame).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 12_345 }));
699
+ });
700
+
701
+ test('session rejects an ACK that belongs to another request sequence', async () => {
702
+ const ack = new Uint8Array(8);
703
+ ack[0] = 0x5a;
704
+ ack[1] = 8;
705
+ ack[4] = 1;
706
+ ack[5] = 1;
707
+ ack[6] = 2;
708
+ ack[3] = protocolV2.crc8(ack, 3);
709
+ ack[7] = protocolV2.crc8(ack, 7);
710
+ const session = new ProtocolV2Session({
711
+ schemas,
712
+ router: 1,
713
+ writeFrame: () => Promise.resolve(),
714
+ readFrame: () => Promise.resolve(ack),
715
+ });
716
+
717
+ await expect(session.call('Ping', { message: 'hello' })).rejects.toEqual(
718
+ expect.objectContaining({
719
+ name: 'ProtocolV2LinkError',
720
+ code: 'ack-sequence',
721
+ message: 'Protocol V2 ACK sequence mismatch: expected 1, got 2',
722
+ })
723
+ );
724
+ });
725
+
726
+ test('session rejects a response from another routing channel', async () => {
727
+ const response = ProtocolV2.encodeFrame(schemas, 'Success', { message: 'wrong route' });
728
+ const session = new ProtocolV2Session({
729
+ schemas,
730
+ router: 1,
731
+ writeFrame: () => Promise.resolve(),
732
+ readFrame: () => Promise.resolve(response),
733
+ });
734
+
735
+ await expect(session.call('Ping', { message: 'hello' })).rejects.toBeInstanceOf(
736
+ ProtocolV2LinkError
737
+ );
738
+ });
739
+
439
740
  test('session rejects BLE frames above its configured frame limit before writing', async () => {
440
741
  const writeFrame = jest.fn().mockResolvedValue(undefined);
441
742
  const response = ProtocolV2.encodeFrame(schemas, 'Success', {
@@ -446,7 +747,7 @@ describe('Protocol V2 framing and session', () => {
446
747
  router: 1,
447
748
  maxFrameBytes: 2048,
448
749
  writeFrame,
449
- readFrame: () => Promise.resolve(response),
750
+ readFrame: () => Promise.resolve(rewriteSeq(response, 1)),
450
751
  });
451
752
 
452
753
  await expect(session.call('Ping', { message: 'x'.repeat(2048) })).rejects.toThrow(
@@ -455,28 +756,40 @@ describe('Protocol V2 framing and session', () => {
455
756
  expect(writeFrame).not.toHaveBeenCalled();
456
757
  });
457
758
 
458
- test('session starts response timeout after the frame is written', async () => {
459
- const response = ProtocolV2.encodeFrame(schemas, 'Success', {
460
- message: 'ok',
461
- });
759
+ test('session watchdog covers a write that never settles', async () => {
760
+ const readFrame = jest.fn();
761
+ let writeSignal;
462
762
  const session = new ProtocolV2Session({
463
763
  schemas,
464
764
  router: 1,
465
- writeFrame: () =>
466
- new Promise(resolve => {
467
- setTimeout(resolve, 30);
468
- }),
469
- readFrame: () => Promise.resolve(response),
765
+ writeFrame: (_frame, context) => {
766
+ writeSignal = context.signal;
767
+ return new Promise(() => {});
768
+ },
769
+ readFrame,
470
770
  });
471
771
 
472
772
  await expect(
473
773
  session.call('Ping', { message: 'hello' }, { timeoutMs: 10, expectedTypes: ['Success'] })
474
- ).resolves.toEqual({
475
- type: 'Success',
476
- message: {
477
- message: 'ok',
478
- },
774
+ ).rejects.toThrow('Protocol V2 response timeout after 10ms for Ping');
775
+ expect(writeSignal.aborted).toBe(true);
776
+ expect(readFrame).not.toHaveBeenCalled();
777
+ });
778
+
779
+ test('session watchdog covers prepareCall before writing', async () => {
780
+ const writeFrame = jest.fn();
781
+ const session = new ProtocolV2Session({
782
+ schemas,
783
+ router: 1,
784
+ prepareCall: () => new Promise(() => {}),
785
+ writeFrame,
786
+ readFrame: jest.fn(),
479
787
  });
788
+
789
+ await expect(session.call('Ping', { message: 'hello' }, { timeoutMs: 10 })).rejects.toThrow(
790
+ 'Protocol V2 response timeout after 10ms for Ping'
791
+ );
792
+ expect(writeFrame).not.toHaveBeenCalled();
480
793
  });
481
794
 
482
795
  test('session logs a device-owned response seq without rejecting the frame', async () => {
@@ -499,17 +812,46 @@ describe('Protocol V2 framing and session', () => {
499
812
  type: 'ProtocolInfo',
500
813
  message: {
501
814
  version: 2,
815
+ build_fingerprint: null,
502
816
  supported_messages: [],
503
- protobuf_definition: null,
504
817
  },
505
818
  });
506
- expect(logger.debug).toHaveBeenCalledWith('[ProtocolV2] RX', {
507
- method: 'ProtocolInfoRequest',
508
- type: 'ProtocolInfo',
509
- typeId: 60201,
510
- seq: 200,
511
- bytes: response.length,
819
+ expect(logger.debug).not.toHaveBeenCalled();
820
+ });
821
+
822
+ test('session accepts gaps in the firmware-global response sequence', async () => {
823
+ const response = ProtocolV2.encodeFrame(schemas, 'Success', { message: 'ok' });
824
+ const readFrame = jest
825
+ .fn()
826
+ .mockResolvedValueOnce(rewriteSeq(response, 1))
827
+ .mockResolvedValueOnce(rewriteSeq(response, 4));
828
+ const session = new ProtocolV2Session({
829
+ schemas,
830
+ router: 1,
831
+ writeFrame: () => Promise.resolve(),
832
+ readFrame,
512
833
  });
834
+
835
+ await session.call('Ping', { message: 'first' });
836
+ await expect(session.call('Ping', { message: 'second' })).resolves.toMatchObject({
837
+ type: 'Success',
838
+ });
839
+ });
840
+
841
+ test('session rejects a duplicate device response sequence', async () => {
842
+ const response = ProtocolV2.encodeFrame(schemas, 'Success', { message: 'ok' });
843
+ const readFrame = jest.fn(() => Promise.resolve(rewriteSeq(response, 7)));
844
+ const session = new ProtocolV2Session({
845
+ schemas,
846
+ router: 1,
847
+ writeFrame: () => Promise.resolve(),
848
+ readFrame,
849
+ });
850
+
851
+ await session.call('Ping', { message: 'first' });
852
+ await expect(session.call('Ping', { message: 'second' })).rejects.toThrow(
853
+ 'Protocol V2 duplicate response sequence: 7'
854
+ );
513
855
  });
514
856
 
515
857
  test('session does not log transmit or receive payload details', async () => {
@@ -523,7 +865,7 @@ describe('Protocol V2 framing and session', () => {
523
865
  schemas,
524
866
  router: 1,
525
867
  writeFrame: () => Promise.resolve(),
526
- readFrame: () => Promise.resolve(response),
868
+ readFrame: () => Promise.resolve(rewriteSeq(response, 1)),
527
869
  logger,
528
870
  logPrefix: 'ProtocolV2 Test',
529
871
  });
@@ -535,10 +877,7 @@ describe('Protocol V2 framing and session', () => {
535
877
  },
536
878
  });
537
879
 
538
- expect(logger.debug).toHaveBeenCalledTimes(2);
539
- const logs = JSON.stringify(logger.debug.mock.calls);
540
- expect(logs).not.toContain('hello');
541
- expect(logs).not.toContain('accepted');
880
+ expect(logger.debug).not.toHaveBeenCalled();
542
881
  });
543
882
 
544
883
  test('session suppresses debug logs for file transfer calls', async () => {
@@ -552,7 +891,7 @@ describe('Protocol V2 framing and session', () => {
552
891
  schemas,
553
892
  router: 1,
554
893
  writeFrame: () => Promise.resolve(),
555
- readFrame: () => Promise.resolve(response),
894
+ readFrame: () => Promise.resolve(rewriteSeq(response, 1)),
556
895
  logger,
557
896
  });
558
897
 
@@ -566,6 +905,34 @@ describe('Protocol V2 framing and session', () => {
566
905
  expect(logger.debug).not.toHaveBeenCalled();
567
906
  });
568
907
 
908
+ test('session reports when the complete request frame has been written', async () => {
909
+ const response = ProtocolV2.encodeFrame(schemas, 'Success', {
910
+ message: 'ok',
911
+ });
912
+ const events = [];
913
+ const onWriteCompleted = jest.fn(metrics => events.push(['written', metrics]));
914
+ const session = new ProtocolV2Session({
915
+ schemas,
916
+ router: 1,
917
+ writeFrame: frame => {
918
+ events.push(['write', frame.byteLength]);
919
+ return Promise.resolve();
920
+ },
921
+ readFrame: () => {
922
+ events.push(['read']);
923
+ return Promise.resolve(rewriteSeq(response, 1));
924
+ },
925
+ });
926
+
927
+ await session.call('Ping', { message: 'metrics' }, { onWriteCompleted });
928
+
929
+ expect(events.map(event => event[0])).toEqual(['write', 'written', 'read']);
930
+ expect(onWriteCompleted).toHaveBeenCalledWith({
931
+ elapsedMs: expect.any(Number),
932
+ frameBytes: expect.any(Number),
933
+ });
934
+ });
935
+
569
936
  test('session skips unrelated terminal frames when expected response types are provided', async () => {
570
937
  const stale = ProtocolV2.encodeFrame(schemas, 'Success', {
571
938
  message: 'stale response',
@@ -577,7 +944,10 @@ describe('Protocol V2 framing and session', () => {
577
944
  const logger = {
578
945
  debug: jest.fn(),
579
946
  };
580
- const readFrame = jest.fn().mockResolvedValueOnce(stale).mockResolvedValueOnce(response);
947
+ const readFrame = jest
948
+ .fn()
949
+ .mockResolvedValueOnce(rewriteSeq(stale, 1))
950
+ .mockResolvedValueOnce(rewriteSeq(response, 2));
581
951
  const session = new ProtocolV2Session({
582
952
  schemas,
583
953
  router: 1,
@@ -592,8 +962,8 @@ describe('Protocol V2 framing and session', () => {
592
962
  type: 'ProtocolInfo',
593
963
  message: {
594
964
  version: 2,
965
+ build_fingerprint: null,
595
966
  supported_messages: [],
596
- protobuf_definition: null,
597
967
  },
598
968
  });
599
969
 
@@ -601,22 +971,10 @@ describe('Protocol V2 framing and session', () => {
601
971
  expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('skip unexpected response'));
602
972
  });
603
973
 
604
- test('session consumes intermediate response frames before returning the final response', async () => {
974
+ test('session can return after a request frame is written', async () => {
605
975
  const written = [];
606
- const progress = ProtocolV2.encodeFrame(schemas, 'DeviceFirmwareUpdateStatus', {
607
- records: [{ target_id: 4, status: 1 }],
608
- });
609
- const success = ProtocolV2.encodeFrame(schemas, 'Success', {
610
- message: 'ok',
611
- });
612
- const onIntermediateResponse = jest.fn();
613
- const readFrame = jest.fn(() => {
614
- const [writtenFrame] = written;
615
- const { seq } = protocolV2.decodeFrame(writtenFrame);
616
- return Promise.resolve(
617
- readFrame.mock.calls.length === 1 ? rewriteSeq(progress, seq) : rewriteSeq(success, seq)
618
- );
619
- });
976
+ const readFrame = jest.fn();
977
+ const onWriteCompleted = jest.fn();
620
978
  const session = new ProtocolV2Session({
621
979
  schemas,
622
980
  router: 1,
@@ -629,26 +987,20 @@ describe('Protocol V2 framing and session', () => {
629
987
 
630
988
  const result = await session.call(
631
989
  'DeviceFirmwareUpdateRequest',
632
- { targets: [{ target_id: 4, path: 'vol1:firmware.bin' }] },
990
+ {},
633
991
  {
634
- intermediateTypes: ['DeviceFirmwareUpdateStatus'],
635
- onIntermediateResponse,
992
+ returnAfterWrite: true,
993
+ onWriteCompleted,
636
994
  }
637
995
  );
638
996
 
639
- expect(readFrame).toHaveBeenCalledTimes(2);
640
- expect(onIntermediateResponse).toHaveBeenCalledWith({
641
- type: 'DeviceFirmwareUpdateStatus',
642
- message: {
643
- records: [{ target_id: 4, status: 1, payload_version: null, path: null }],
644
- },
645
- });
646
- expect(result).toEqual({
647
- type: 'Success',
648
- message: {
649
- message: 'ok',
650
- },
651
- });
997
+ expect(written).toHaveLength(1);
998
+ expect(ProtocolV2.decodeFrame(schemas, written[0]).messageName).toBe(
999
+ 'DeviceFirmwareUpdateRequest'
1000
+ );
1001
+ expect(readFrame).not.toHaveBeenCalled();
1002
+ expect(onWriteCompleted).toHaveBeenCalledTimes(1);
1003
+ expect(result).toEqual({ type: 'WriteCompleted', message: {} });
652
1004
  });
653
1005
 
654
1006
  test('probeProtocolV2 accepts Success as a normal V2 probe response', async () => {
@@ -667,6 +1019,36 @@ describe('Protocol V2 framing and session', () => {
667
1019
  ).resolves.toBe(false);
668
1020
  });
669
1021
 
1022
+ test('recognizes USB-priority firmware failures independently of surrounding whitespace', () => {
1023
+ expect(isProtocolV2LinkDisabledFailure('Failure_ProcessError', ' link disabled ')).toBe(true);
1024
+ expect(isProtocolV2LinkDisabledFailure(5, 'Link Disabled')).toBe(true);
1025
+ expect(isProtocolV2LinkDisabledFailure('Failure_ProcessError', 'busy')).toBe(false);
1026
+ });
1027
+
1028
+ test.each(['Failure_ProcessError', 5])(
1029
+ 'probeProtocolV2 surfaces link disabled without resetting the link for code %s',
1030
+ async code => {
1031
+ const onProbeFailed = jest.fn();
1032
+
1033
+ await expect(
1034
+ probeProtocolV2({
1035
+ call: () =>
1036
+ Promise.resolve({
1037
+ type: 'Failure',
1038
+ message: { code, message: ' link disabled ' },
1039
+ }),
1040
+ timeoutMs: 1,
1041
+ onProbeFailed,
1042
+ })
1043
+ ).rejects.toMatchObject({
1044
+ name: 'ProtocolV2LinkDisabledError',
1045
+ failureCode: code,
1046
+ firmwareMessage: ' link disabled ',
1047
+ });
1048
+ expect(onProbeFailed).not.toHaveBeenCalled();
1049
+ }
1050
+ );
1051
+
670
1052
  test('decodeFrame rejects frames that are too short', () => {
671
1053
  expect(() => protocolV2.decodeFrame(new Uint8Array([0x5a, 0x08, 0x00]))).toThrow(
672
1054
  'Protocol V2 frame too short'
@@ -791,7 +1173,7 @@ describe('Protocol V2 framing and session', () => {
791
1173
  }
792
1174
  return Promise.resolve();
793
1175
  },
794
- readFrame: () => Promise.resolve(response),
1176
+ readFrame: () => Promise.resolve(rewriteSeq(response, 1)),
795
1177
  });
796
1178
 
797
1179
  await expect(session.call('Ping', { message: '1' })).rejects.toThrow('transport write failed');
@@ -860,6 +1242,7 @@ describe('Protocol V2 framing and session', () => {
860
1242
  const writeContexts = [];
861
1243
  const readContexts = [];
862
1244
  const response = ProtocolV2.encodeFrame(schemas, 'Success', { message: 'ok' });
1245
+ let responseSeq = 0;
863
1246
  const session = new ProtocolV2Session({
864
1247
  schemas,
865
1248
  router: 1,
@@ -870,18 +1253,67 @@ describe('Protocol V2 framing and session', () => {
870
1253
  },
871
1254
  readFrame: context => {
872
1255
  readContexts.push(context);
873
- return Promise.resolve(response);
1256
+ responseSeq = protocolV2.nextProtoSeq(responseSeq);
1257
+ return Promise.resolve(rewriteSeq(response, responseSeq));
874
1258
  },
875
1259
  });
876
1260
 
877
1261
  await session.call('Ping', { message: 'ping' }, { timeoutMs: 123 });
878
- await session.call('FileWrite', {}, { timeoutMs: 456 });
1262
+ await session.call('FileWrite', {}, { timeoutMs: 456, writeWithResponse: true });
1263
+ await session.call('Ping', { message: 'default-timeout' });
879
1264
 
880
- expect(writeContexts).toEqual([
881
- { messageName: 'Ping', timeoutMs: 123, highVolume: false, generation: 7 },
882
- { messageName: 'FileWrite', timeoutMs: 456, highVolume: true, generation: 7 },
1265
+ const normalizeContext = ({ signal, ...context }) => ({
1266
+ ...context,
1267
+ signalAborted: signal.aborted,
1268
+ });
1269
+ expect(writeContexts.map(normalizeContext)).toEqual([
1270
+ {
1271
+ messageName: 'Ping',
1272
+ timeoutMs: 123,
1273
+ highThroughput: false,
1274
+ generation: 7,
1275
+ signalAborted: false,
1276
+ },
1277
+ {
1278
+ messageName: 'FileWrite',
1279
+ timeoutMs: 456,
1280
+ highThroughput: true,
1281
+ writeWithResponse: true,
1282
+ generation: 7,
1283
+ signalAborted: false,
1284
+ },
1285
+ {
1286
+ messageName: 'Ping',
1287
+ timeoutMs: PROTOCOL_V2_DEFAULT_RESPONSE_TIMEOUT_MS,
1288
+ highThroughput: false,
1289
+ generation: 7,
1290
+ signalAborted: false,
1291
+ },
1292
+ ]);
1293
+ expect(readContexts.map(normalizeContext)).toEqual([
1294
+ {
1295
+ messageName: 'Ping',
1296
+ timeoutMs: 123,
1297
+ highThroughput: false,
1298
+ generation: 7,
1299
+ signalAborted: false,
1300
+ },
1301
+ {
1302
+ messageName: 'FileWrite',
1303
+ timeoutMs: 456,
1304
+ highThroughput: true,
1305
+ writeWithResponse: true,
1306
+ generation: 7,
1307
+ signalAborted: false,
1308
+ },
1309
+ {
1310
+ messageName: 'Ping',
1311
+ timeoutMs: PROTOCOL_V2_DEFAULT_RESPONSE_TIMEOUT_MS,
1312
+ highThroughput: false,
1313
+ generation: 7,
1314
+ signalAborted: false,
1315
+ },
883
1316
  ]);
884
- expect(readContexts).toEqual(writeContexts);
885
1317
  });
886
1318
 
887
1319
  test('assembler throws and resets on frames with an impossible length field', () => {