@onekeyfe/hd-transport 1.2.2-alpha.100 → 1.2.2-alpha.101

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.
package/README.md CHANGED
@@ -20,7 +20,7 @@ In order to be able to use new features of onekey-firmware you need to update pr
20
20
  1. `yarn update-submodules` to update firmware submodule
21
21
  1. `yarn update-protobuf` to generate new `./messages.json`, `./messages-protocol-v2.json` and `./src/types/messages.ts`
22
22
 
23
- The same task can be run from the repository root with `yarn update-protobuf`. The Protocol V2 schema requires the `firmware-pro2` submodule checked out on branch `dev`.
23
+ The same task can be run from the repository root with `yarn update-protobuf`. The Protocol V2 schema requires the `firmware-pro2` submodule checked out on branch `main`.
24
24
 
25
25
  ## Docs
26
26
 
@@ -88,6 +88,31 @@ describe('messages', () => {
88
88
  });
89
89
  });
90
90
 
91
+ test('Protocol V2 firmware update progress matches firmware-pro2 main', () => {
92
+ expect(v2Messages.nested.MessageType.values).toMatchObject({
93
+ MessageType_DeviceFindMyTokenState: 60450,
94
+ MessageType_DeviceFindMyTokenUpdate: 60451,
95
+ MessageType_DeviceFindMyTokenStateGet: 60452,
96
+ });
97
+ expect(v2Messages.nested.DeviceFirmwareUpdatePhase.values).toEqual({
98
+ FW_MGMT_UPDATER_PHASE_PREPARE: 0,
99
+ FW_MGMT_UPDATER_PHASE_INSTALL: 1,
100
+ FW_MGMT_UPDATER_PHASE_VERIFY: 2,
101
+ });
102
+ expect(v2Messages.nested.DeviceFirmwareUpdateRequest.fields.reboot_after_update).toMatchObject({
103
+ id: 1,
104
+ type: 'bool',
105
+ });
106
+ expect(v2Messages.nested.DeviceFirmwareUpdateRecord.fields).toMatchObject({
107
+ progress_percent: { id: 11, type: 'uint32' },
108
+ phase_info: { id: 12, type: 'DeviceFirmwareUpdatePhaseInfo' },
109
+ });
110
+ expect(v2Messages.nested.DeviceFirmwareUpdateRecordFields.fields).toMatchObject({
111
+ progress_percent: { id: 11, type: 'bool' },
112
+ phase_info: { id: 12, type: 'bool' },
113
+ });
114
+ });
115
+
91
116
  test('Protocol V2 conflicting enums keep their own wire values', () => {
92
117
  expect(generatedTypes.ProtocolV2FailureType).toMatchObject({
93
118
  Failure_DataError: 4,
@@ -125,11 +150,6 @@ describe('messages', () => {
125
150
  expect(v2Messages.nested.DeviceSessionGet.fields).toEqual({
126
151
  session_id: { id: 1, type: 'bytes' },
127
152
  btc_test_address: { id: 2, type: 'string' },
128
- seed_domains: {
129
- id: 3,
130
- rule: 'repeated',
131
- type: 'DeviceSessionSeedDomain',
132
- },
133
153
  });
134
154
  expect(v2Messages.nested.DeviceSessionSeedDomain.values).toEqual({
135
155
  SeedDomain_Standard: 1,
@@ -151,9 +171,14 @@ describe('messages', () => {
151
171
  expect(v2Messages.nested).not.toHaveProperty('DeviceWalletSelect');
152
172
  expect(v2Messages.nested).not.toHaveProperty('DeviceWalletType');
153
173
  expect(v2Messages.nested).not.toHaveProperty('DeviceHiddenWalletSelect');
154
- expect(v2Messages.nested.DeviceSession.fields).toMatchObject({
174
+ expect(v2Messages.nested.DeviceSession.fields).toEqual({
155
175
  session_id: { id: 1, type: 'bytes' },
156
176
  btc_test_address: { id: 2, type: 'string' },
177
+ seed_domains: {
178
+ rule: 'repeated',
179
+ type: 'DeviceSessionSeedDomain',
180
+ id: 3,
181
+ },
157
182
  });
158
183
  expect(v2Messages.nested.DeviceSessionAskPin.fields.type).toMatchObject({
159
184
  id: 1,
@@ -170,6 +195,11 @@ describe('messages', () => {
170
195
  type: 'bool',
171
196
  id: 2,
172
197
  },
198
+ seed_domains: {
199
+ rule: 'repeated',
200
+ type: 'DeviceSessionSeedDomain',
201
+ id: 3,
202
+ },
173
203
  },
174
204
  });
175
205
  expect(v2Messages.nested.DeviceSessionAskPin_FailureSubCodes.values).toEqual({
@@ -187,38 +217,77 @@ describe('messages', () => {
187
217
  const messages = parseConfigure(v2Messages);
188
218
  const { Message } = createMessageFromName(messages, 'DeviceSessionAskPassphrase');
189
219
 
190
- const standardWallet = encode(Message, { passphrase: '', on_device: false });
220
+ const standardWallet = encode(Message, {
221
+ passphrase: '',
222
+ on_device: false,
223
+ seed_domains: [],
224
+ });
191
225
  const onHost = Message.encode(
192
- Message.create({ passphrase: 'host hidden wallet', on_device: false })
226
+ Message.create({
227
+ passphrase: 'host hidden wallet',
228
+ on_device: false,
229
+ seed_domains: [
230
+ generatedTypes.DeviceSessionSeedDomain.SeedDomain_Standard,
231
+ generatedTypes.DeviceSessionSeedDomain.SeedDomain_Cardano,
232
+ ],
233
+ })
234
+ ).finish();
235
+ const onDevice = Message.encode(
236
+ Message.create({
237
+ on_device: true,
238
+ seed_domains: [generatedTypes.DeviceSessionSeedDomain.SeedDomain_Standard],
239
+ })
193
240
  ).finish();
194
- const onDevice = Message.encode(Message.create({ on_device: true })).finish();
195
241
 
196
242
  expect(standardWallet.toString('hex')).toBe('0a001000');
197
243
  expect(Buffer.from(onHost).toString('hex')).toBe(
198
- '0a12686f73742068696464656e2077616c6c65741000'
244
+ '0a12686f73742068696464656e2077616c6c657410001a020102'
199
245
  );
200
- expect(Buffer.from(onDevice).toString('hex')).toBe('1001');
246
+ expect(Buffer.from(onDevice).toString('hex')).toBe('10011a0101');
201
247
  expect(Message.decode(onHost)).toMatchObject({
202
248
  passphrase: 'host hidden wallet',
203
249
  on_device: false,
250
+ seed_domains: [
251
+ generatedTypes.DeviceSessionSeedDomain.SeedDomain_Standard,
252
+ generatedTypes.DeviceSessionSeedDomain.SeedDomain_Cardano,
253
+ ],
254
+ });
255
+ expect(Message.decode(onDevice)).toMatchObject({
256
+ on_device: true,
257
+ seed_domains: [generatedTypes.DeviceSessionSeedDomain.SeedDomain_Standard],
204
258
  });
205
- expect(Message.decode(onDevice)).toMatchObject({ on_device: true });
206
259
  });
207
260
 
208
- test('Protocol V2 wallet recovery carries the expected wallet and seed domains on wire', () => {
261
+ test('Protocol V2 wallet recovery carries the expected wallet on wire', () => {
209
262
  const messages = parseConfigure(v2Messages);
210
263
  const { Message } = createMessageFromName(messages, 'DeviceSessionGet');
211
264
  const payload = encode(Message, {
212
265
  btc_test_address: 'tb1qwallet',
213
- seed_domains: [
214
- generatedTypes.DeviceSessionSeedDomain.SeedDomain_Standard,
215
- generatedTypes.DeviceSessionSeedDomain.SeedDomain_Cardano,
216
- ],
217
266
  });
218
267
 
219
- expect(payload.toString('hex')).toBe('120a7462317177616c6c65741a020102');
268
+ expect(payload.toString('hex')).toBe('120a7462317177616c6c6574');
220
269
  expect(Message.decode(payload.toBuffer())).toMatchObject({
221
270
  btc_test_address: 'tb1qwallet',
271
+ });
272
+ expect(Message.decode(payload.toBuffer())).not.toHaveProperty('seed_domains');
273
+ });
274
+
275
+ test('Protocol V2 DeviceSession reports generated seed domains on wire', () => {
276
+ const messages = parseConfigure(v2Messages);
277
+ const { Message } = createMessageFromName(messages, 'DeviceSession');
278
+ const encoded = Message.encode(
279
+ Message.create({
280
+ btc_test_address: 'tb1qwallet',
281
+ seed_domains: [
282
+ generatedTypes.DeviceSessionSeedDomain.SeedDomain_Standard,
283
+ generatedTypes.DeviceSessionSeedDomain.SeedDomain_Cardano,
284
+ ],
285
+ })
286
+ ).finish();
287
+
288
+ expect(Buffer.from(encoded).toString('hex')).toBe('120a7462317177616c6c65741a020102');
289
+ expect(Message.decode(encoded)).toMatchObject({
290
+ btc_test_address: 'tb1qwallet',
222
291
  seed_domains: [
223
292
  generatedTypes.DeviceSessionSeedDomain.SeedDomain_Standard,
224
293
  generatedTypes.DeviceSessionSeedDomain.SeedDomain_Cardano,
@@ -317,6 +317,76 @@ describe('ProtocolV2LinkManager', () => {
317
317
  ]);
318
318
  });
319
319
 
320
+ test('times out a queued call without sending it after the active call settles', async () => {
321
+ let releaseActiveRead;
322
+ let markActiveReadStarted;
323
+ const activeReadStarted = new Promise(resolve => {
324
+ markActiveReadStarted = resolve;
325
+ });
326
+ const activeReadBlocked = new Promise(resolve => {
327
+ releaseActiveRead = resolve;
328
+ });
329
+ const sentSeqs = [];
330
+ const success = ProtocolV2.encodeFrame(
331
+ schemas,
332
+ 'Success',
333
+ { message: 'ok' },
334
+ { router: 1, packetSrc: 0, seq: 1 }
335
+ );
336
+ let requestSeq = 0;
337
+ let readCount = 0;
338
+ const adapter = {
339
+ router: 1,
340
+ generation: 1,
341
+ prepareCall: jest.fn(),
342
+ writeFrame: jest.fn(frame => {
343
+ [, , , , , , requestSeq] = frame;
344
+ sentSeqs.push(requestSeq);
345
+ return Promise.resolve();
346
+ }),
347
+ readFrame: jest.fn(async () => {
348
+ readCount += 1;
349
+ if (readCount === 1) {
350
+ markActiveReadStarted();
351
+ await activeReadBlocked;
352
+ }
353
+ return rewriteSeq(success, requestSeq);
354
+ }),
355
+ reset: jest.fn(),
356
+ createTimeoutError: (name, timeoutMs) =>
357
+ new Error(`response timeout after ${timeoutMs}ms for ${name}`),
358
+ };
359
+ const manager = new ProtocolV2LinkManager({
360
+ getSchemas: () => schemas,
361
+ classifyError: () => 'recoverable',
362
+ });
363
+ const createAdapter = jest.fn(() => adapter);
364
+
365
+ const activeCall = manager.call('device-a', createAdapter, 'Ping', { message: 'active' });
366
+ await activeReadStarted;
367
+ const queuedCall = manager.call(
368
+ 'device-a',
369
+ createAdapter,
370
+ 'Ping',
371
+ { message: 'queued' },
372
+ { timeoutMs: 20 }
373
+ );
374
+
375
+ await expect(queuedCall).rejects.toThrow('response timeout after 20ms for Ping');
376
+ expect(sentSeqs).toEqual([1]);
377
+
378
+ releaseActiveRead();
379
+ await activeCall;
380
+ await expect(
381
+ manager.call('device-a', createAdapter, 'Ping', { message: 'after-timeout' })
382
+ ).resolves.toEqual({
383
+ type: 'Success',
384
+ message: { message: 'ok' },
385
+ });
386
+
387
+ expect(sentSeqs).toEqual([1, 2]);
388
+ });
389
+
320
390
  test('writes flow control while the active call is waiting for its response', async () => {
321
391
  const sentSeqs = [];
322
392
  let releaseRead;
@@ -217,7 +217,7 @@ describe('ProtocolV2UsbTransportBase', () => {
217
217
  expect(transport.nativeResets).toEqual([['device-a', 'USB reconnected']]);
218
218
  });
219
219
 
220
- test('passes each queued call its own timeout context', async () => {
220
+ test('passes each queued call its remaining timeout context', async () => {
221
221
  const transport = new FakeUsbTransport();
222
222
  await transport.rotate('device-a');
223
223
 
@@ -226,12 +226,14 @@ describe('ProtocolV2UsbTransportBase', () => {
226
226
  transport.callDevice('device-a', 'second', 222),
227
227
  ]);
228
228
 
229
- expect(transport.readContexts).toEqual([
229
+ expect(transport.readContexts.slice(0, 2)).toEqual([
230
230
  ['device-a', 111],
231
231
  ['device-a', 111],
232
- ['device-a', 222],
233
- ['device-a', 222],
234
232
  ]);
233
+ const secondCallTimeouts = transport.readContexts.slice(2).map(([, timeoutMs]) => timeoutMs);
234
+ expect(secondCallTimeouts[0]).toBeGreaterThan(0);
235
+ expect(secondCallTimeouts[0]).toBeLessThanOrEqual(222);
236
+ expect(secondCallTimeouts[1]).toBe(secondCallTimeouts[0]);
235
237
  });
236
238
 
237
239
  test('keeps a coalesced response buffered for the next call', async () => {
@@ -5,6 +5,7 @@ const {
5
5
  ProtocolV2LinkError,
6
6
  ProtocolV2SequenceCursor,
7
7
  ProtocolV2Session,
8
+ detectProtocolV2LinkDisabledError,
8
9
  hexToBytes,
9
10
  isProtocolV2HighThroughputCall,
10
11
  probeProtocolV2,
@@ -12,6 +13,7 @@ const {
12
13
  } = require('../src/protocols/v2/session');
13
14
  const protocolV2 = require('../src/protocols/v2');
14
15
  const {
16
+ PROTOCOL_V2_BLE_FILE_CHUNK_SIZE,
15
17
  PROTOCOL_V2_BLE_FIRMWARE_FILE_CHUNK_SIZE,
16
18
  PROTOCOL_V2_BLE_FRAME_MAX_BYTES,
17
19
  PROTOCOL_V2_DEFAULT_RESPONSE_TIMEOUT_MS,
@@ -298,6 +300,29 @@ describe('Protocol V2 framing and session', () => {
298
300
  });
299
301
  });
300
302
 
303
+ test('round-trips Solana v1 off-chain required signers with the production schema', () => {
304
+ const productionSchemas = {
305
+ protocolV1: protocolV1Messages,
306
+ protocolV2: productionProtocolV2Messages,
307
+ };
308
+ const requiredSigners = ['11'.repeat(32), '22'.repeat(32)];
309
+ const frame = ProtocolV2.encodeFrame(productionSchemas, 'SolanaSignOffChainMessage', {
310
+ address_n: [0x8000002c, 0x800001f5, 0x80000000, 0x80000000],
311
+ message: '01020304',
312
+ message_version: 1,
313
+ required_signers: requiredSigners,
314
+ });
315
+
316
+ expect(ProtocolV2.decodeFrame(productionSchemas, frame)).toMatchObject({
317
+ type: 'SolanaSignOffChainMessage',
318
+ message: {
319
+ message: '01020304',
320
+ message_version: 'MESSAGE_VERSION_1',
321
+ required_signers: requiredSigners,
322
+ },
323
+ });
324
+ });
325
+
301
326
  test('decodes a two-byte legacy ProtocolInfo at the generic frame boundary', () => {
302
327
  const frame = protocolV2.encodeProtobufFrame(60201, new Uint8Array([0x08, 0x01]));
303
328
  const productionSchemas = {
@@ -451,12 +476,13 @@ describe('Protocol V2 framing and session', () => {
451
476
  ).toThrow('Protocol V2 frame too large: 4201 > 4200');
452
477
  });
453
478
 
454
- test('keeps optimized BLE firmware chunks inside the transport frame boundary', () => {
479
+ test('keeps optimized BLE fixed-path chunks inside the transport frame boundary', () => {
455
480
  const productionSchemas = {
456
481
  protocolV1: protocolV1Messages,
457
482
  protocolV2: productionProtocolV2Messages,
458
483
  };
459
- const stagingPaths = [
484
+ const fixedPaths = [
485
+ 'vol1:/wallpapers/wallpaper.okpkg',
460
486
  'vol0:/bootloader.bin',
461
487
  'vol0:/application_p1.bin',
462
488
  'vol0:/application_p2.bin',
@@ -467,7 +493,7 @@ describe('Protocol V2 framing and session', () => {
467
493
  'vol0:/se04.bin',
468
494
  ];
469
495
 
470
- for (const path of stagingPaths) {
496
+ for (const path of fixedPaths) {
471
497
  const frame = ProtocolV2.encodeFrame(productionSchemas, 'FilesystemFileWrite', {
472
498
  file: {
473
499
  path,
@@ -484,6 +510,35 @@ describe('Protocol V2 framing and session', () => {
484
510
  }
485
511
  });
486
512
 
513
+ test('keeps the generic BLE chunk safe for the longest valid filesystem path', () => {
514
+ const productionSchemas = {
515
+ protocolV1: protocolV1Messages,
516
+ protocolV2: productionProtocolV2Messages,
517
+ };
518
+ const longestValidPath = `vol0:/${'a'.repeat(121)}`;
519
+ const encodeFileWrite = dataLength =>
520
+ ProtocolV2.encodeFrame(productionSchemas, 'FilesystemFileWrite', {
521
+ file: {
522
+ path: longestValidPath,
523
+ offset: 0xffffffff,
524
+ total_size: 0xffffffff,
525
+ data: new Uint8Array(dataLength),
526
+ },
527
+ overwrite: true,
528
+ append: true,
529
+ ui_percentage: 100,
530
+ });
531
+
532
+ expect(Buffer.byteLength(longestValidPath, 'utf8')).toBe(127);
533
+ expect(encodeFileWrite(PROTOCOL_V2_BLE_FILE_CHUNK_SIZE).length).toBeLessThanOrEqual(
534
+ PROTOCOL_V2_BLE_FRAME_MAX_BYTES
535
+ );
536
+ expect(encodeFileWrite(PROTOCOL_V2_BLE_FIRMWARE_FILE_CHUNK_SIZE).length).toBeGreaterThan(
537
+ PROTOCOL_V2_BLE_FRAME_MAX_BYTES
538
+ );
539
+ expect(encodeFileWrite(1885)).toHaveLength(PROTOCOL_V2_BLE_FRAME_MAX_BYTES);
540
+ });
541
+
487
542
  test('keeps bytes after the first complete frame for the next read', () => {
488
543
  const first = ProtocolV2.encodeFrame(schemas, 'ProtocolInfo', {
489
544
  version: 1,
@@ -1003,6 +1058,126 @@ describe('Protocol V2 framing and session', () => {
1003
1058
  expect(result).toEqual({ type: 'WriteCompleted', message: {} });
1004
1059
  });
1005
1060
 
1061
+ test('session consumes a delayed write-only response without completing the next call', async () => {
1062
+ const requestSuccess = ProtocolV2.encodeFrame(schemas, 'Success', {
1063
+ message: 'install accepted',
1064
+ });
1065
+ const statusResponse = ProtocolV2.encodeFrame(schemas, 'DeviceFirmwareUpdateStatus', {
1066
+ records: [{ target_id: 6, status: 1, path: 'vol0:/coprocessor.bin' }],
1067
+ });
1068
+ const prepareCall = jest.fn();
1069
+ const onResponseAfterWrite = jest.fn();
1070
+ const readFrame = jest
1071
+ .fn()
1072
+ .mockResolvedValueOnce(rewriteSeq(requestSuccess, 1))
1073
+ .mockResolvedValueOnce(rewriteSeq(statusResponse, 2));
1074
+ const session = new ProtocolV2Session({
1075
+ schemas,
1076
+ router: 1,
1077
+ prepareCall,
1078
+ writeFrame: () => Promise.resolve(),
1079
+ readFrame,
1080
+ });
1081
+
1082
+ await expect(
1083
+ session.call(
1084
+ 'DeviceFirmwareUpdateRequest',
1085
+ {},
1086
+ {
1087
+ returnAfterWrite: true,
1088
+ expectedTypes: ['Success'],
1089
+ onResponseAfterWrite,
1090
+ }
1091
+ )
1092
+ ).resolves.toEqual({ type: 'WriteCompleted', message: {} });
1093
+
1094
+ await expect(
1095
+ session.call(
1096
+ 'Ping',
1097
+ { message: 'status-poll' },
1098
+ {
1099
+ expectedTypes: ['DeviceFirmwareUpdateStatus'],
1100
+ }
1101
+ )
1102
+ ).resolves.toMatchObject({
1103
+ type: 'DeviceFirmwareUpdateStatus',
1104
+ message: {
1105
+ records: [{ target_id: 6, status: 1, path: 'vol0:/coprocessor.bin' }],
1106
+ },
1107
+ });
1108
+
1109
+ expect(prepareCall).toHaveBeenCalledTimes(1);
1110
+ expect(onResponseAfterWrite).toHaveBeenCalledWith({
1111
+ type: 'Success',
1112
+ message: { message: 'install accepted' },
1113
+ });
1114
+ expect(readFrame).toHaveBeenCalledTimes(2);
1115
+ });
1116
+
1117
+ test('session preserves a delayed write-only Failure for the next caller', async () => {
1118
+ const requestFailure = ProtocolV2.encodeFrame(schemas, 'Failure', {
1119
+ code: 4,
1120
+ message: 'install cancelled',
1121
+ });
1122
+ const prepareCall = jest.fn();
1123
+ const onResponseAfterWrite = jest.fn();
1124
+ const readFrame = jest.fn().mockResolvedValueOnce(rewriteSeq(requestFailure, 1));
1125
+ const session = new ProtocolV2Session({
1126
+ schemas,
1127
+ router: 1,
1128
+ prepareCall,
1129
+ writeFrame: () => Promise.resolve(),
1130
+ readFrame,
1131
+ });
1132
+
1133
+ await expect(
1134
+ session.call(
1135
+ 'DeviceFirmwareUpdateRequest',
1136
+ {},
1137
+ {
1138
+ returnAfterWrite: true,
1139
+ expectedTypes: ['Success'],
1140
+ onResponseAfterWrite,
1141
+ }
1142
+ )
1143
+ ).resolves.toEqual({ type: 'WriteCompleted', message: {} });
1144
+
1145
+ await expect(
1146
+ session.call(
1147
+ 'Ping',
1148
+ { message: 'status-poll' },
1149
+ {
1150
+ expectedTypes: ['DeviceFirmwareUpdateStatus'],
1151
+ }
1152
+ )
1153
+ ).resolves.toMatchObject({
1154
+ type: 'Failure',
1155
+ message: {
1156
+ code: 4,
1157
+ message: 'install cancelled',
1158
+ },
1159
+ });
1160
+
1161
+ expect(prepareCall).toHaveBeenCalledTimes(1);
1162
+ expect(onResponseAfterWrite).not.toHaveBeenCalled();
1163
+ expect(readFrame).toHaveBeenCalledTimes(1);
1164
+ });
1165
+
1166
+ test('probeProtocolV2 rethrows caller-selected fatal errors without treating them as a miss', async () => {
1167
+ const onProbeFailed = jest.fn();
1168
+ const staleBond = Object.assign(new Error('Bluetooth pairing failed'), { errorCode: 715 });
1169
+
1170
+ await expect(
1171
+ probeProtocolV2({
1172
+ call: () => Promise.reject(staleBond),
1173
+ timeoutMs: 1,
1174
+ onProbeFailed,
1175
+ shouldRethrow: error => error?.errorCode === 715,
1176
+ })
1177
+ ).rejects.toBe(staleBond);
1178
+ expect(onProbeFailed).not.toHaveBeenCalled();
1179
+ });
1180
+
1006
1181
  test('probeProtocolV2 accepts Success as a normal V2 probe response', async () => {
1007
1182
  await expect(
1008
1183
  probeProtocolV2({
@@ -1025,6 +1200,36 @@ describe('Protocol V2 framing and session', () => {
1025
1200
  expect(isProtocolV2LinkDisabledFailure('Failure_ProcessError', 'busy')).toBe(false);
1026
1201
  });
1027
1202
 
1203
+ test('detects a split Protocol V2 link-disabled frame in the shared transport layer', () => {
1204
+ const assembler = new ProtocolV2FrameAssembler();
1205
+ const frame = ProtocolV2.encodeFrame(
1206
+ schemas,
1207
+ 'Failure',
1208
+ { code: 5, message: 'link disabled' },
1209
+ { router: 2 }
1210
+ );
1211
+ const splitAt = 4;
1212
+
1213
+ expect(
1214
+ detectProtocolV2LinkDisabledError({
1215
+ schemas,
1216
+ assembler,
1217
+ bytes: frame.subarray(0, splitAt),
1218
+ })
1219
+ ).toBeUndefined();
1220
+ expect(
1221
+ detectProtocolV2LinkDisabledError({
1222
+ schemas,
1223
+ assembler,
1224
+ bytes: frame.subarray(splitAt),
1225
+ })
1226
+ ).toMatchObject({
1227
+ name: 'ProtocolV2LinkDisabledError',
1228
+ failureCode: 5,
1229
+ firmwareMessage: 'link disabled',
1230
+ });
1231
+ });
1232
+
1028
1233
  test.each(['Failure_ProcessError', 5])(
1029
1234
  'probeProtocolV2 surfaces link disabled without resetting the link for code %s',
1030
1235
  async code => {