@onekeyfe/hd-transport 1.2.0-alpha.3 → 1.2.0-alpha.31

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 (65) hide show
  1. package/README.md +1 -1
  2. package/__tests__/messages.test.js +142 -0
  3. package/__tests__/protocol-v2-ble-frame-writer.test.js +119 -0
  4. package/__tests__/protocol-v2-link-manager.test.js +389 -0
  5. package/__tests__/protocol-v2-usb-transport-base.test.js +330 -0
  6. package/__tests__/protocol-v2.test.js +555 -119
  7. package/dist/constants.d.ts +6 -3
  8. package/dist/constants.d.ts.map +1 -1
  9. package/dist/index.d.ts +981 -278
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +759 -247
  12. package/dist/protocols/index.d.ts +14 -5
  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/constants.d.ts +1 -0
  17. package/dist/protocols/v2/constants.d.ts.map +1 -1
  18. package/dist/protocols/v2/decode.d.ts +13 -2
  19. package/dist/protocols/v2/decode.d.ts.map +1 -1
  20. package/dist/protocols/v2/encode.d.ts +2 -3
  21. package/dist/protocols/v2/encode.d.ts.map +1 -1
  22. package/dist/protocols/v2/errors.d.ts +8 -0
  23. package/dist/protocols/v2/errors.d.ts.map +1 -0
  24. package/dist/protocols/v2/frame-assembler.d.ts.map +1 -1
  25. package/dist/protocols/v2/index.d.ts +2 -1
  26. package/dist/protocols/v2/index.d.ts.map +1 -1
  27. package/dist/protocols/v2/link-manager.d.ts +38 -0
  28. package/dist/protocols/v2/link-manager.d.ts.map +1 -0
  29. package/dist/protocols/v2/sequence-cursor.d.ts +5 -0
  30. package/dist/protocols/v2/sequence-cursor.d.ts.map +1 -0
  31. package/dist/protocols/v2/session.d.ts +19 -5
  32. package/dist/protocols/v2/session.d.ts.map +1 -1
  33. package/dist/protocols/v2/usb-transport-base.d.ts +32 -0
  34. package/dist/protocols/v2/usb-transport-base.d.ts.map +1 -0
  35. package/dist/serialization/protobuf/decode.d.ts.map +1 -1
  36. package/dist/types/messages.d.ts +523 -158
  37. package/dist/types/messages.d.ts.map +1 -1
  38. package/dist/types/transport.d.ts +13 -3
  39. package/dist/types/transport.d.ts.map +1 -1
  40. package/messages-protocol-v2.json +796 -620
  41. package/package.json +3 -3
  42. package/scripts/protobuf-build.sh +96 -310
  43. package/scripts/protobuf-patches/index.js +1 -0
  44. package/scripts/protobuf-types.js +19 -1
  45. package/src/constants.ts +29 -15
  46. package/src/index.ts +11 -1
  47. package/src/protocols/index.ts +39 -40
  48. package/src/protocols/v2/ble-frame-writer.ts +77 -0
  49. package/src/protocols/v2/constants.ts +1 -0
  50. package/src/protocols/v2/crc8.ts +1 -1
  51. package/src/protocols/v2/decode.ts +71 -37
  52. package/src/protocols/v2/encode.ts +2 -28
  53. package/src/protocols/v2/errors.ts +25 -0
  54. package/src/protocols/v2/frame-assembler.ts +6 -4
  55. package/src/protocols/v2/index.ts +2 -1
  56. package/src/protocols/v2/link-manager.ts +186 -0
  57. package/src/protocols/v2/sequence-cursor.ts +10 -0
  58. package/src/protocols/v2/session.ts +157 -201
  59. package/src/protocols/v2/usb-transport-base.ts +213 -0
  60. package/src/serialization/protobuf/decode.ts +4 -1
  61. package/src/types/messages.ts +623 -181
  62. package/src/types/transport.ts +13 -3
  63. package/dist/protocols/v2/debug.d.ts +0 -13
  64. package/dist/protocols/v2/debug.d.ts.map +0 -1
  65. package/src/protocols/v2/debug.ts +0 -26
@@ -1,9 +1,18 @@
1
1
  const { ProtocolV2 } = require('../src/protocols');
2
2
  const { parseConfigure } = require('../src/serialization/protobuf/messages');
3
- const sessionModule = require('../src/protocols/v2/session');
4
-
5
- const { ProtocolV2FrameAssembler, ProtocolV2Session, probeProtocolV2 } = sessionModule;
3
+ const {
4
+ ProtocolV2FrameAssembler,
5
+ ProtocolV2LinkError,
6
+ ProtocolV2SequenceCursor,
7
+ ProtocolV2Session,
8
+ hexToBytes,
9
+ probeProtocolV2,
10
+ } = require('../src/protocols/v2/session');
6
11
  const protocolV2 = require('../src/protocols/v2');
12
+ const {
13
+ PROTOCOL_V2_DEFAULT_RESPONSE_TIMEOUT_MS,
14
+ PROTOCOL_V2_FRAME_MAX_BYTES,
15
+ } = require('../src/constants');
7
16
 
8
17
  const protocolV1Messages = parseConfigure({
9
18
  nested: {
@@ -55,21 +64,22 @@ const protocolV1Messages = parseConfigure({
55
64
 
56
65
  const protocolV2Messages = parseConfigure({
57
66
  nested: {
58
- GetProtoVersion: {
67
+ ProtocolInfoRequest: {
59
68
  fields: {},
60
69
  },
61
- ProtoVersion: {
70
+ ProtocolInfo: {
62
71
  fields: {
63
- major_version: {
72
+ version: {
64
73
  type: 'uint32',
65
74
  id: 1,
66
75
  },
67
- minor_version: {
76
+ supported_messages: {
68
77
  type: 'uint32',
69
78
  id: 2,
79
+ rule: 'repeated',
70
80
  },
71
- patch_version: {
72
- type: 'uint32',
81
+ protobuf_definition: {
82
+ type: 'string',
73
83
  id: 3,
74
84
  },
75
85
  },
@@ -90,17 +100,80 @@ const protocolV2Messages = parseConfigure({
90
100
  },
91
101
  },
92
102
  },
93
- DevFirmwareUpdate: {
94
- fields: {},
103
+ Failure: {
104
+ fields: {
105
+ code: {
106
+ type: 'uint32',
107
+ id: 1,
108
+ },
109
+ subcode: {
110
+ type: 'uint32',
111
+ id: 2,
112
+ },
113
+ message: {
114
+ type: 'string',
115
+ id: 3,
116
+ },
117
+ },
95
118
  },
96
- DevFirmwareInstallProgress: {
119
+ DeviceFirmwareTarget: {
97
120
  fields: {
98
121
  target_id: {
99
122
  type: 'uint32',
100
123
  id: 1,
101
124
  },
102
- progress: {
125
+ path: {
126
+ type: 'string',
127
+ id: 2,
128
+ },
129
+ },
130
+ },
131
+ DeviceFirmwareUpdateRequest: {
132
+ fields: {
133
+ targets: {
134
+ type: 'DeviceFirmwareTarget',
135
+ id: 1,
136
+ rule: 'repeated',
137
+ },
138
+ },
139
+ },
140
+ DeviceFirmwareUpdateRecord: {
141
+ fields: {
142
+ target_id: {
103
143
  type: 'uint32',
144
+ id: 1,
145
+ },
146
+ status: {
147
+ type: 'uint32',
148
+ id: 10,
149
+ },
150
+ payload_version: {
151
+ type: 'uint32',
152
+ id: 20,
153
+ },
154
+ path: {
155
+ type: 'string',
156
+ id: 30,
157
+ },
158
+ },
159
+ },
160
+ DeviceFirmwareUpdateStatus: {
161
+ fields: {
162
+ records: {
163
+ type: 'DeviceFirmwareUpdateRecord',
164
+ id: 1,
165
+ rule: 'repeated',
166
+ },
167
+ },
168
+ },
169
+ DeviceSession: {
170
+ fields: {
171
+ session_id: {
172
+ type: 'bytes',
173
+ id: 1,
174
+ },
175
+ btc_test_address: {
176
+ type: 'string',
104
177
  id: 2,
105
178
  },
106
179
  },
@@ -130,13 +203,15 @@ const protocolV2Messages = parseConfigure({
130
203
  },
131
204
  MessageType: {
132
205
  values: {
133
- MessageType_GetProtoVersion: 60200,
134
- MessageType_ProtoVersion: 60201,
206
+ MessageType_ProtocolInfoRequest: 60200,
207
+ MessageType_ProtocolInfo: 60201,
135
208
  MessageType_Ping: 60206,
136
209
  MessageType_Success: 60207,
210
+ MessageType_Failure: 60208,
137
211
  MessageType_FileWrite: 60805,
138
- MessageType_DevFirmwareUpdate: 61000,
139
- MessageType_DevFirmwareInstallProgress: 61001,
212
+ MessageType_DeviceFirmwareUpdateRequest: 61000,
213
+ MessageType_DeviceFirmwareUpdateStatus: 61002,
214
+ MessageType_DeviceSession: 60607,
140
215
  MessageType_PartialNested: 62000,
141
216
  },
142
217
  },
@@ -150,6 +225,7 @@ const schemas = {
150
225
 
151
226
  const rewriteSeq = (frame, seq) => {
152
227
  const copy = new Uint8Array(frame);
228
+ copy[4] = 1;
153
229
  copy[6] = seq;
154
230
  copy[copy.length - 1] = protocolV2.crc8(copy, copy.length - 1);
155
231
  return copy;
@@ -157,10 +233,10 @@ const rewriteSeq = (frame, seq) => {
157
233
 
158
234
  describe('Protocol V2 framing and session', () => {
159
235
  test('encodes and decodes Protocol V2 protobuf frames', () => {
160
- const frame = ProtocolV2.encodeFrame(schemas, 'ProtoVersion', {
161
- major_version: 1,
162
- minor_version: 2,
163
- patch_version: 3,
236
+ const frame = ProtocolV2.encodeFrame(schemas, 'ProtocolInfo', {
237
+ version: 1,
238
+ supported_messages: [60200, 60201],
239
+ protobuf_definition: 'proto',
164
240
  });
165
241
 
166
242
  const parsed = protocolV2.decodeFrame(frame);
@@ -168,15 +244,15 @@ describe('Protocol V2 framing and session', () => {
168
244
 
169
245
  const decoded = ProtocolV2.decodeFrame(schemas, frame);
170
246
  expect(decoded).toEqual({
171
- type: 'ProtoVersion',
172
- messageName: 'ProtoVersion',
247
+ type: 'ProtocolInfo',
248
+ messageName: 'ProtocolInfo',
173
249
  messageTypeId: 60201,
174
250
  pbPayload: parsed.pbPayload,
175
251
  seq: parsed.seq,
176
252
  message: {
177
- major_version: 1,
178
- minor_version: 2,
179
- patch_version: 3,
253
+ version: 1,
254
+ supported_messages: [60200, 60201],
255
+ protobuf_definition: 'proto',
180
256
  },
181
257
  });
182
258
  });
@@ -201,6 +277,34 @@ describe('Protocol V2 framing and session', () => {
201
277
  expect(decoded.message).toEqual({ message: 'ok' });
202
278
  });
203
279
 
280
+ test('round-trips a DeviceSession response with a 32-byte session id', () => {
281
+ const sessionId = '01'.repeat(32);
282
+ const frame = ProtocolV2.encodeFrame(schemas, 'DeviceSession', {
283
+ session_id: sessionId,
284
+ btc_test_address: 'tb1qwallet',
285
+ });
286
+
287
+ const decoded = ProtocolV2.decodeFrame(schemas, frame);
288
+
289
+ expect(decoded.type).toBe('DeviceSession');
290
+ expect(decoded.message).toEqual({
291
+ session_id: sessionId,
292
+ btc_test_address: 'tb1qwallet',
293
+ });
294
+ });
295
+
296
+ test('reports malformed DeviceSession protobuf as a schema-compatible transport error', () => {
297
+ const malformedPayload = new Uint8Array(32);
298
+ malformedPayload[0] = 0x0a;
299
+ malformedPayload[1] = 101;
300
+ const frame = protocolV2.encodeProtobufFrame(60607, malformedPayload);
301
+
302
+ expect(() => ProtocolV2.decodeFrame(schemas, frame)).toThrow(
303
+ 'Protocol V2 protobuf decode failed for "DeviceSession" (60607, 32-byte payload); ' +
304
+ 'the payload is malformed or incompatible with the active SDK schema.'
305
+ );
306
+ });
307
+
204
308
  test('decodes missing optional nested messages as null', () => {
205
309
  const frame = ProtocolV2.encodeFrame(schemas, 'PartialNested', {
206
310
  label: 'only label',
@@ -214,10 +318,9 @@ describe('Protocol V2 framing and session', () => {
214
318
  });
215
319
 
216
320
  test('reassembles split Protocol V2 frames and rejects oversized frames', () => {
217
- const frame = ProtocolV2.encodeFrame(schemas, 'ProtoVersion', {
218
- major_version: 1,
219
- minor_version: 0,
220
- patch_version: 0,
321
+ const frame = ProtocolV2.encodeFrame(schemas, 'ProtocolInfo', {
322
+ version: 1,
323
+ supported_messages: [],
221
324
  });
222
325
  const assembler = new ProtocolV2FrameAssembler();
223
326
 
@@ -228,16 +331,35 @@ describe('Protocol V2 framing and session', () => {
228
331
  expect(() => assembler.push(oversized)).toThrow('Protocol V2 frame too large');
229
332
  });
230
333
 
334
+ test('enforces a transport-specific receive frame boundary', () => {
335
+ const assembler = new ProtocolV2FrameAssembler(2048);
336
+ const oversizedHeader = new Uint8Array([0x5a, 0x01, 0x08]);
337
+
338
+ expect(() => assembler.push(oversizedHeader)).toThrow('Protocol V2 frame too large: 2049');
339
+ });
340
+
341
+ test('enforces the firmware 4200-byte Protocol V2 frame boundary', () => {
342
+ const boundaryFrame = ProtocolV2.encodeFrame(schemas, 'Ping', {
343
+ message: 'x'.repeat(4187),
344
+ });
345
+
346
+ expect(PROTOCOL_V2_FRAME_MAX_BYTES).toBe(4200);
347
+ expect(boundaryFrame).toHaveLength(PROTOCOL_V2_FRAME_MAX_BYTES);
348
+ expect(() =>
349
+ ProtocolV2.encodeFrame(schemas, 'Ping', {
350
+ message: 'x'.repeat(4188),
351
+ })
352
+ ).toThrow('Protocol V2 frame too large: 4201 > 4200');
353
+ });
354
+
231
355
  test('keeps bytes after the first complete frame for the next read', () => {
232
- const first = ProtocolV2.encodeFrame(schemas, 'ProtoVersion', {
233
- major_version: 1,
234
- minor_version: 0,
235
- patch_version: 0,
356
+ const first = ProtocolV2.encodeFrame(schemas, 'ProtocolInfo', {
357
+ version: 1,
358
+ supported_messages: [],
236
359
  });
237
- const second = ProtocolV2.encodeFrame(schemas, 'ProtoVersion', {
238
- major_version: 2,
239
- minor_version: 0,
240
- patch_version: 0,
360
+ const second = ProtocolV2.encodeFrame(schemas, 'ProtocolInfo', {
361
+ version: 2,
362
+ supported_messages: [],
241
363
  });
242
364
  const assembler = new ProtocolV2FrameAssembler();
243
365
  const combined = new Uint8Array(first.length + second.length);
@@ -250,10 +372,9 @@ describe('Protocol V2 framing and session', () => {
250
372
 
251
373
  test('session writes one encoded frame and decodes the response frame', async () => {
252
374
  const written = [];
253
- const response = ProtocolV2.encodeFrame(schemas, 'ProtoVersion', {
254
- major_version: 2,
255
- minor_version: 0,
256
- patch_version: 1,
375
+ const response = ProtocolV2.encodeFrame(schemas, 'ProtocolInfo', {
376
+ version: 2,
377
+ supported_messages: [60206],
257
378
  });
258
379
  const session = new ProtocolV2Session({
259
380
  schemas,
@@ -266,51 +387,251 @@ describe('Protocol V2 framing and session', () => {
266
387
  Promise.resolve(rewriteSeq(response, protocolV2.decodeFrame(written[0]).seq)),
267
388
  });
268
389
 
269
- const result = await session.call('GetProtoVersion', {});
390
+ const result = await session.call('ProtocolInfoRequest', {});
270
391
 
271
392
  expect(written).toHaveLength(1);
272
393
  expect(written[0][4]).toBe(1);
273
394
  expect(written[0][5]).toBe(0);
274
395
  expect(protocolV2.decodeFrame(written[0]).messageTypeId).toBe(60200);
275
396
  expect(result).toEqual({
276
- type: 'ProtoVersion',
397
+ type: 'ProtocolInfo',
398
+ message: {
399
+ version: 2,
400
+ supported_messages: [60206],
401
+ protobuf_definition: null,
402
+ },
403
+ });
404
+ });
405
+
406
+ test('session does not log Protocol V2 TX and RX frames', async () => {
407
+ const written = [];
408
+ const logger = { debug: jest.fn() };
409
+ const response = ProtocolV2.encodeFrame(schemas, 'ProtocolInfo', {
410
+ version: 2,
411
+ supported_messages: [60206],
412
+ protobuf_definition: 'sensitive-schema',
413
+ });
414
+ const session = new ProtocolV2Session({
415
+ schemas,
416
+ router: 1,
417
+ logger,
418
+ logPrefix: 'ProtocolV2 Test',
419
+ writeFrame: frame => {
420
+ written.push(frame);
421
+ return Promise.resolve();
422
+ },
423
+ readFrame: () =>
424
+ Promise.resolve(rewriteSeq(response, protocolV2.decodeFrame(written[0]).seq)),
425
+ });
426
+
427
+ await session.call('ProtocolInfoRequest', {});
428
+
429
+ expect(logger.debug).not.toHaveBeenCalled();
430
+ });
431
+
432
+ test('session does not log RX frame metadata when protobuf payload decoding fails', async () => {
433
+ const logger = { debug: jest.fn() };
434
+ const response = protocolV2.encodeProtobufFrame(
435
+ 60208,
436
+ hexToBytes('0801121648616e646c6572206e6f742072656769737465726564')
437
+ );
438
+ const session = new ProtocolV2Session({
439
+ schemas,
440
+ router: 1,
441
+ logger,
442
+ logPrefix: 'ProtocolV2 Test',
443
+ writeFrame: () => Promise.resolve(),
444
+ readFrame: () => Promise.resolve(rewriteSeq(response, 1)),
445
+ });
446
+
447
+ await expect(session.call('Ping', { message: 'hello' })).rejects.toThrow();
448
+
449
+ expect(logger.debug).not.toHaveBeenCalled();
450
+ });
451
+
452
+ test('session skips Proto Link ACK frames before decoding the protobuf response', async () => {
453
+ const ack = new Uint8Array(8);
454
+ ack[0] = 0x5a;
455
+ ack[1] = 8;
456
+ ack[2] = 0;
457
+ ack[4] = 1;
458
+ ack[5] = 1;
459
+ ack[6] = 1;
460
+ ack[3] = protocolV2.crc8(ack, 3);
461
+ ack[7] = protocolV2.crc8(ack, 7);
462
+
463
+ const response = ProtocolV2.encodeFrame(schemas, 'Success', {
464
+ message: 'ok',
465
+ });
466
+ const readFrame = jest
467
+ .fn()
468
+ .mockResolvedValueOnce(ack)
469
+ .mockResolvedValueOnce(rewriteSeq(response, 1));
470
+ const session = new ProtocolV2Session({
471
+ schemas,
472
+ router: 1,
473
+ writeFrame: () => Promise.resolve(),
474
+ readFrame,
475
+ });
476
+
477
+ await expect(session.call('Ping', { message: 'hello' })).resolves.toEqual({
478
+ type: 'Success',
277
479
  message: {
278
- major_version: 2,
279
- minor_version: 0,
280
- patch_version: 1,
480
+ message: 'ok',
281
481
  },
282
482
  });
483
+ expect(readFrame).toHaveBeenCalledTimes(2);
283
484
  });
284
485
 
285
- test('session starts response timeout after the frame is written', async () => {
486
+ test('session accepts a terminal response before an ACK', async () => {
286
487
  const response = ProtocolV2.encodeFrame(schemas, 'Success', {
287
488
  message: 'ok',
288
489
  });
289
490
  const session = new ProtocolV2Session({
290
491
  schemas,
291
492
  router: 1,
292
- writeFrame: () =>
293
- new Promise(resolve => {
294
- setTimeout(resolve, 30);
295
- }),
296
- readFrame: () => Promise.resolve(response),
493
+ writeFrame: () => Promise.resolve(),
494
+ readFrame: () => Promise.resolve(rewriteSeq(response, 1)),
297
495
  });
298
496
 
299
497
  await expect(
300
- session.call('Ping', { message: 'hello' }, { timeoutMs: 10, expectedTypes: ['Success'] })
498
+ session.call('Ping', { message: 'hello' }, { expectedTypes: ['Success'] })
499
+ ).resolves.toEqual({
500
+ type: 'Success',
501
+ message: {
502
+ message: 'ok',
503
+ },
504
+ });
505
+ });
506
+
507
+ test('session uses the unified response timeout before the first ACK', async () => {
508
+ const response = ProtocolV2.encodeFrame(schemas, 'Success', {
509
+ message: 'ok',
510
+ });
511
+ const readFrame = jest.fn(() => Promise.resolve(rewriteSeq(response, 1)));
512
+ const session = new ProtocolV2Session({
513
+ schemas,
514
+ router: 1,
515
+ writeFrame: () => Promise.resolve(),
516
+ readFrame,
517
+ });
518
+
519
+ await expect(
520
+ session.call(
521
+ 'Ping',
522
+ { message: 'hello' },
523
+ {
524
+ timeoutMs: 12_345,
525
+ expectedTypes: ['Success'],
526
+ }
527
+ )
301
528
  ).resolves.toEqual({
302
529
  type: 'Success',
303
530
  message: {
304
531
  message: 'ok',
305
532
  },
306
533
  });
534
+ expect(readFrame).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 12_345 }));
535
+ });
536
+
537
+ test('session rejects an ACK that belongs to another request sequence', async () => {
538
+ const ack = new Uint8Array(8);
539
+ ack[0] = 0x5a;
540
+ ack[1] = 8;
541
+ ack[4] = 1;
542
+ ack[5] = 1;
543
+ ack[6] = 2;
544
+ ack[3] = protocolV2.crc8(ack, 3);
545
+ ack[7] = protocolV2.crc8(ack, 7);
546
+ const session = new ProtocolV2Session({
547
+ schemas,
548
+ router: 1,
549
+ writeFrame: () => Promise.resolve(),
550
+ readFrame: () => Promise.resolve(ack),
551
+ });
552
+
553
+ await expect(session.call('Ping', { message: 'hello' })).rejects.toEqual(
554
+ expect.objectContaining({
555
+ name: 'ProtocolV2LinkError',
556
+ code: 'ack-sequence',
557
+ message: 'Protocol V2 ACK sequence mismatch: expected 1, got 2',
558
+ })
559
+ );
560
+ });
561
+
562
+ test('session rejects a response from another routing channel', async () => {
563
+ const response = ProtocolV2.encodeFrame(schemas, 'Success', { message: 'wrong route' });
564
+ const session = new ProtocolV2Session({
565
+ schemas,
566
+ router: 1,
567
+ writeFrame: () => Promise.resolve(),
568
+ readFrame: () => Promise.resolve(response),
569
+ });
570
+
571
+ await expect(session.call('Ping', { message: 'hello' })).rejects.toBeInstanceOf(
572
+ ProtocolV2LinkError
573
+ );
307
574
  });
308
575
 
309
- test('session accepts response frames with a device-owned seq', async () => {
310
- const response = ProtocolV2.encodeFrame(schemas, 'ProtoVersion', {
311
- major_version: 2,
312
- minor_version: 0,
313
- patch_version: 1,
576
+ test('session rejects BLE frames above its configured frame limit before writing', async () => {
577
+ const writeFrame = jest.fn().mockResolvedValue(undefined);
578
+ const response = ProtocolV2.encodeFrame(schemas, 'Success', {
579
+ message: 'ok',
580
+ });
581
+ const session = new ProtocolV2Session({
582
+ schemas,
583
+ router: 1,
584
+ maxFrameBytes: 2048,
585
+ writeFrame,
586
+ readFrame: () => Promise.resolve(rewriteSeq(response, 1)),
587
+ });
588
+
589
+ await expect(session.call('Ping', { message: 'x'.repeat(2048) })).rejects.toThrow(
590
+ 'Protocol V2 frame too large for transport: 2061 > 2048'
591
+ );
592
+ expect(writeFrame).not.toHaveBeenCalled();
593
+ });
594
+
595
+ test('session watchdog covers a write that never settles', async () => {
596
+ const readFrame = jest.fn();
597
+ let writeSignal;
598
+ const session = new ProtocolV2Session({
599
+ schemas,
600
+ router: 1,
601
+ writeFrame: (_frame, context) => {
602
+ writeSignal = context.signal;
603
+ return new Promise(() => {});
604
+ },
605
+ readFrame,
606
+ });
607
+
608
+ await expect(
609
+ session.call('Ping', { message: 'hello' }, { timeoutMs: 10, expectedTypes: ['Success'] })
610
+ ).rejects.toThrow('Protocol V2 response timeout after 10ms for Ping');
611
+ expect(writeSignal.aborted).toBe(true);
612
+ expect(readFrame).not.toHaveBeenCalled();
613
+ });
614
+
615
+ test('session watchdog covers prepareCall before writing', async () => {
616
+ const writeFrame = jest.fn();
617
+ const session = new ProtocolV2Session({
618
+ schemas,
619
+ router: 1,
620
+ prepareCall: () => new Promise(() => {}),
621
+ writeFrame,
622
+ readFrame: jest.fn(),
623
+ });
624
+
625
+ await expect(session.call('Ping', { message: 'hello' }, { timeoutMs: 10 })).rejects.toThrow(
626
+ 'Protocol V2 response timeout after 10ms for Ping'
627
+ );
628
+ expect(writeFrame).not.toHaveBeenCalled();
629
+ });
630
+
631
+ test('session logs a device-owned response seq without rejecting the frame', async () => {
632
+ const response = ProtocolV2.encodeFrame(schemas, 'ProtocolInfo', {
633
+ version: 2,
634
+ supported_messages: [],
314
635
  });
315
636
  const logger = {
316
637
  debug: jest.fn(),
@@ -323,18 +644,53 @@ describe('Protocol V2 framing and session', () => {
323
644
  logger,
324
645
  });
325
646
 
326
- await expect(session.call('GetProtoVersion', {})).resolves.toEqual({
327
- type: 'ProtoVersion',
647
+ await expect(session.call('ProtocolInfoRequest', {})).resolves.toEqual({
648
+ type: 'ProtocolInfo',
328
649
  message: {
329
- major_version: 2,
330
- minor_version: 0,
331
- patch_version: 1,
650
+ version: 2,
651
+ supported_messages: [],
652
+ protobuf_definition: null,
332
653
  },
333
654
  });
334
- expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('seq differs'));
655
+ expect(logger.debug).not.toHaveBeenCalled();
656
+ });
657
+
658
+ test('session accepts gaps in the firmware-global response sequence', async () => {
659
+ const response = ProtocolV2.encodeFrame(schemas, 'Success', { message: 'ok' });
660
+ const readFrame = jest
661
+ .fn()
662
+ .mockResolvedValueOnce(rewriteSeq(response, 1))
663
+ .mockResolvedValueOnce(rewriteSeq(response, 4));
664
+ const session = new ProtocolV2Session({
665
+ schemas,
666
+ router: 1,
667
+ writeFrame: () => Promise.resolve(),
668
+ readFrame,
669
+ });
670
+
671
+ await session.call('Ping', { message: 'first' });
672
+ await expect(session.call('Ping', { message: 'second' })).resolves.toMatchObject({
673
+ type: 'Success',
674
+ });
675
+ });
676
+
677
+ test('session rejects a duplicate device response sequence', async () => {
678
+ const response = ProtocolV2.encodeFrame(schemas, 'Success', { message: 'ok' });
679
+ const readFrame = jest.fn(() => Promise.resolve(rewriteSeq(response, 7)));
680
+ const session = new ProtocolV2Session({
681
+ schemas,
682
+ router: 1,
683
+ writeFrame: () => Promise.resolve(),
684
+ readFrame,
685
+ });
686
+
687
+ await session.call('Ping', { message: 'first' });
688
+ await expect(session.call('Ping', { message: 'second' })).rejects.toThrow(
689
+ 'Protocol V2 duplicate response sequence: 7'
690
+ );
335
691
  });
336
692
 
337
- test('session logs decoded transmit and receive payloads', async () => {
693
+ test('session does not log transmit or receive payload details', async () => {
338
694
  const response = ProtocolV2.encodeFrame(schemas, 'Success', {
339
695
  message: 'accepted',
340
696
  });
@@ -345,7 +701,7 @@ describe('Protocol V2 framing and session', () => {
345
701
  schemas,
346
702
  router: 1,
347
703
  writeFrame: () => Promise.resolve(),
348
- readFrame: () => Promise.resolve(response),
704
+ readFrame: () => Promise.resolve(rewriteSeq(response, 1)),
349
705
  logger,
350
706
  logPrefix: 'ProtocolV2 Test',
351
707
  });
@@ -357,30 +713,7 @@ describe('Protocol V2 framing and session', () => {
357
713
  },
358
714
  });
359
715
 
360
- expect(logger.debug).toHaveBeenCalledWith('[ProtocolV2 Test] TX payload name=Ping', {
361
- message: 'hello',
362
- });
363
- expect(logger.debug).toHaveBeenCalledWith(
364
- '[ProtocolV2 Test] encode raw frame',
365
- expect.objectContaining({
366
- context: 'tx:Ping',
367
- messageTypeId: 60206,
368
- router: 1,
369
- })
370
- );
371
- expect(logger.debug).toHaveBeenCalledWith(
372
- '[ProtocolV2 Test] decode raw frame',
373
- expect.objectContaining({
374
- context: 'rx:Ping',
375
- messageTypeId: 60207,
376
- })
377
- );
378
- expect(logger.debug).toHaveBeenCalledWith(
379
- '[ProtocolV2 Test] RX payload type=Success messageTypeId=60207',
380
- {
381
- message: 'accepted',
382
- }
383
- );
716
+ expect(logger.debug).not.toHaveBeenCalled();
384
717
  });
385
718
 
386
719
  test('session suppresses debug logs for file transfer calls', async () => {
@@ -394,7 +727,7 @@ describe('Protocol V2 framing and session', () => {
394
727
  schemas,
395
728
  router: 1,
396
729
  writeFrame: () => Promise.resolve(),
397
- readFrame: () => Promise.resolve(response),
730
+ readFrame: () => Promise.resolve(rewriteSeq(response, 1)),
398
731
  logger,
399
732
  });
400
733
 
@@ -412,15 +745,17 @@ describe('Protocol V2 framing and session', () => {
412
745
  const stale = ProtocolV2.encodeFrame(schemas, 'Success', {
413
746
  message: 'stale response',
414
747
  });
415
- const response = ProtocolV2.encodeFrame(schemas, 'ProtoVersion', {
416
- major_version: 2,
417
- minor_version: 0,
418
- patch_version: 1,
748
+ const response = ProtocolV2.encodeFrame(schemas, 'ProtocolInfo', {
749
+ version: 2,
750
+ supported_messages: [],
419
751
  });
420
752
  const logger = {
421
753
  debug: jest.fn(),
422
754
  };
423
- const readFrame = jest.fn().mockResolvedValueOnce(stale).mockResolvedValueOnce(response);
755
+ const readFrame = jest
756
+ .fn()
757
+ .mockResolvedValueOnce(rewriteSeq(stale, 1))
758
+ .mockResolvedValueOnce(rewriteSeq(response, 2));
424
759
  const session = new ProtocolV2Session({
425
760
  schemas,
426
761
  router: 1,
@@ -430,13 +765,13 @@ describe('Protocol V2 framing and session', () => {
430
765
  });
431
766
 
432
767
  await expect(
433
- session.call('GetProtoVersion', {}, { expectedTypes: ['ProtoVersion'] })
768
+ session.call('ProtocolInfoRequest', {}, { expectedTypes: ['ProtocolInfo'] })
434
769
  ).resolves.toEqual({
435
- type: 'ProtoVersion',
770
+ type: 'ProtocolInfo',
436
771
  message: {
437
- major_version: 2,
438
- minor_version: 0,
439
- patch_version: 1,
772
+ version: 2,
773
+ supported_messages: [],
774
+ protobuf_definition: null,
440
775
  },
441
776
  });
442
777
 
@@ -446,9 +781,8 @@ describe('Protocol V2 framing and session', () => {
446
781
 
447
782
  test('session consumes intermediate response frames before returning the final response', async () => {
448
783
  const written = [];
449
- const progress = ProtocolV2.encodeFrame(schemas, 'DevFirmwareInstallProgress', {
450
- target_id: 0,
451
- progress: 42,
784
+ const progress = ProtocolV2.encodeFrame(schemas, 'DeviceFirmwareUpdateStatus', {
785
+ records: [{ target_id: 4, status: 1 }],
452
786
  });
453
787
  const success = ProtocolV2.encodeFrame(schemas, 'Success', {
454
788
  message: 'ok',
@@ -458,7 +792,9 @@ describe('Protocol V2 framing and session', () => {
458
792
  const [writtenFrame] = written;
459
793
  const { seq } = protocolV2.decodeFrame(writtenFrame);
460
794
  return Promise.resolve(
461
- readFrame.mock.calls.length === 1 ? rewriteSeq(progress, seq) : rewriteSeq(success, seq)
795
+ readFrame.mock.calls.length === 1
796
+ ? rewriteSeq(progress, seq)
797
+ : rewriteSeq(success, protocolV2.nextProtoSeq(seq))
462
798
  );
463
799
  });
464
800
  const session = new ProtocolV2Session({
@@ -472,20 +808,19 @@ describe('Protocol V2 framing and session', () => {
472
808
  });
473
809
 
474
810
  const result = await session.call(
475
- 'DevFirmwareUpdate',
476
- {},
811
+ 'DeviceFirmwareUpdateRequest',
812
+ { targets: [{ target_id: 4, path: 'vol1:firmware.bin' }] },
477
813
  {
478
- intermediateTypes: ['DevFirmwareInstallProgress'],
814
+ intermediateTypes: ['DeviceFirmwareUpdateStatus'],
479
815
  onIntermediateResponse,
480
816
  }
481
817
  );
482
818
 
483
819
  expect(readFrame).toHaveBeenCalledTimes(2);
484
820
  expect(onIntermediateResponse).toHaveBeenCalledWith({
485
- type: 'DevFirmwareInstallProgress',
821
+ type: 'DeviceFirmwareUpdateStatus',
486
822
  message: {
487
- target_id: 0,
488
- progress: 42,
823
+ records: [{ target_id: 4, status: 1, payload_version: null, path: null }],
489
824
  },
490
825
  });
491
826
  expect(result).toEqual({
@@ -575,7 +910,7 @@ describe('Protocol V2 framing and session', () => {
575
910
  });
576
911
 
577
912
  await expect(
578
- session.call('GetProtoVersion', {}, { timeoutMs: 10, expectedTypes: ['ProtoVersion'] })
913
+ session.call('ProtocolInfoRequest', {}, { timeoutMs: 10, expectedTypes: ['ProtocolInfo'] })
579
914
  ).rejects.toThrow('Protocol V2 response timeout');
580
915
 
581
916
  // Without cancellation the loop would skip this unexpected Success frame
@@ -636,7 +971,7 @@ describe('Protocol V2 framing and session', () => {
636
971
  }
637
972
  return Promise.resolve();
638
973
  },
639
- readFrame: () => Promise.resolve(response),
974
+ readFrame: () => Promise.resolve(rewriteSeq(response, 1)),
640
975
  });
641
976
 
642
977
  await expect(session.call('Ping', { message: '1' })).rejects.toThrow('transport write failed');
@@ -674,6 +1009,109 @@ describe('Protocol V2 framing and session', () => {
674
1009
  expect(written.map(frame => frame[6])).toEqual([1, 2, 1]);
675
1010
  });
676
1011
 
1012
+ test('session reuses an injected sequence cursor across recreated sessions', async () => {
1013
+ const written = [];
1014
+ const cursor = new ProtocolV2SequenceCursor();
1015
+ const makeSession = () =>
1016
+ new ProtocolV2Session({
1017
+ schemas,
1018
+ router: 1,
1019
+ sequenceCursor: cursor,
1020
+ writeFrame: frame => {
1021
+ written.push(frame);
1022
+ return Promise.resolve();
1023
+ },
1024
+ readFrame: () => {
1025
+ const [frame] = written.slice(-1);
1026
+ const { seq } = protocolV2.decodeFrame(frame);
1027
+ return Promise.resolve(
1028
+ rewriteSeq(ProtocolV2.encodeFrame(schemas, 'Success', { message: 'ok' }), seq)
1029
+ );
1030
+ },
1031
+ });
1032
+
1033
+ await makeSession().call('Ping', { message: '1' });
1034
+ await makeSession().call('Ping', { message: '2' });
1035
+
1036
+ expect(written.map(frame => frame[6])).toEqual([1, 2]);
1037
+ });
1038
+
1039
+ test('session passes per-call context to frame IO callbacks', async () => {
1040
+ const writeContexts = [];
1041
+ const readContexts = [];
1042
+ const response = ProtocolV2.encodeFrame(schemas, 'Success', { message: 'ok' });
1043
+ let responseSeq = 0;
1044
+ const session = new ProtocolV2Session({
1045
+ schemas,
1046
+ router: 1,
1047
+ generation: 7,
1048
+ writeFrame: (_frame, context) => {
1049
+ writeContexts.push(context);
1050
+ return Promise.resolve();
1051
+ },
1052
+ readFrame: context => {
1053
+ readContexts.push(context);
1054
+ responseSeq = protocolV2.nextProtoSeq(responseSeq);
1055
+ return Promise.resolve(rewriteSeq(response, responseSeq));
1056
+ },
1057
+ });
1058
+
1059
+ await session.call('Ping', { message: 'ping' }, { timeoutMs: 123 });
1060
+ await session.call('FileWrite', {}, { timeoutMs: 456 });
1061
+ await session.call('Ping', { message: 'default-timeout' });
1062
+
1063
+ const normalizeContext = ({ signal, ...context }) => ({
1064
+ ...context,
1065
+ signalAborted: signal.aborted,
1066
+ });
1067
+ expect(writeContexts.map(normalizeContext)).toEqual([
1068
+ {
1069
+ messageName: 'Ping',
1070
+ timeoutMs: 123,
1071
+ highVolume: false,
1072
+ generation: 7,
1073
+ signalAborted: false,
1074
+ },
1075
+ {
1076
+ messageName: 'FileWrite',
1077
+ timeoutMs: 456,
1078
+ highVolume: true,
1079
+ generation: 7,
1080
+ signalAborted: false,
1081
+ },
1082
+ {
1083
+ messageName: 'Ping',
1084
+ timeoutMs: PROTOCOL_V2_DEFAULT_RESPONSE_TIMEOUT_MS,
1085
+ highVolume: false,
1086
+ generation: 7,
1087
+ signalAborted: false,
1088
+ },
1089
+ ]);
1090
+ expect(readContexts.map(normalizeContext)).toEqual([
1091
+ {
1092
+ messageName: 'Ping',
1093
+ timeoutMs: 123,
1094
+ highVolume: false,
1095
+ generation: 7,
1096
+ signalAborted: false,
1097
+ },
1098
+ {
1099
+ messageName: 'FileWrite',
1100
+ timeoutMs: 456,
1101
+ highVolume: true,
1102
+ generation: 7,
1103
+ signalAborted: false,
1104
+ },
1105
+ {
1106
+ messageName: 'Ping',
1107
+ timeoutMs: PROTOCOL_V2_DEFAULT_RESPONSE_TIMEOUT_MS,
1108
+ highVolume: false,
1109
+ generation: 7,
1110
+ signalAborted: false,
1111
+ },
1112
+ ]);
1113
+ });
1114
+
677
1115
  test('assembler throws and resets on frames with an impossible length field', () => {
678
1116
  const assembler = new ProtocolV2FrameAssembler();
679
1117
  // expectedLen = 0 < 8-byte minimum: without the guard this poisons the
@@ -700,10 +1138,9 @@ describe('Protocol V2 framing and session', () => {
700
1138
  });
701
1139
 
702
1140
  test('assembler drain returns every buffered complete frame', () => {
703
- const first = ProtocolV2.encodeFrame(schemas, 'ProtoVersion', {
704
- major_version: 1,
705
- minor_version: 0,
706
- patch_version: 0,
1141
+ const first = ProtocolV2.encodeFrame(schemas, 'ProtocolInfo', {
1142
+ version: 1,
1143
+ supported_messages: [],
707
1144
  });
708
1145
  const second = ProtocolV2.encodeFrame(schemas, 'Success', { message: 'ok' });
709
1146
  const third = ProtocolV2.encodeFrame(schemas, 'Success', { message: 'last' });
@@ -754,7 +1191,6 @@ describe('Protocol V2 framing and session', () => {
754
1191
  });
755
1192
 
756
1193
  test('hexToBytes converts valid hex and rejects malformed input', () => {
757
- const { hexToBytes } = sessionModule;
758
1194
  expect(hexToBytes('5a0102')).toEqual(new Uint8Array([0x5a, 0x01, 0x02]));
759
1195
  expect(hexToBytes('')).toEqual(new Uint8Array(0));
760
1196
  expect(() => hexToBytes('abc')).toThrow('Invalid hex string: odd length');