@onekeyfe/hd-core 1.2.0-alpha.107 → 1.2.0-alpha.108

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 (57) hide show
  1. package/__tests__/DeviceCommands.test.ts +140 -26
  2. package/__tests__/base64Data.test.ts +70 -0
  3. package/__tests__/device-lifecycle-events.test.ts +113 -2
  4. package/__tests__/device-pool-state.test.ts +25 -0
  5. package/__tests__/deviceSettings.test.ts +0 -1
  6. package/__tests__/deviceUploadNft.test.ts +33 -4
  7. package/__tests__/firmware-update/firmware-update-v4-install-poll.test.ts +178 -0
  8. package/__tests__/get-device-state.test.ts +52 -13
  9. package/__tests__/logBlockEvent.test.ts +13 -1
  10. package/__tests__/protocol-v2.test.ts +762 -124
  11. package/__tests__/refresh-device-state.test.ts +3 -1
  12. package/__tests__/resourceBase64Boundary.test.ts +48 -0
  13. package/__tests__/ton-sign-message.test.ts +84 -0
  14. package/dist/api/FirmwareUpdateV4.d.ts +10 -3
  15. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  16. package/dist/api/UploadPortfolio.d.ts +1 -1
  17. package/dist/api/UploadPortfolio.d.ts.map +1 -1
  18. package/dist/api/helpers/base64Data.d.ts +16 -0
  19. package/dist/api/helpers/base64Data.d.ts.map +1 -0
  20. package/dist/api/protocol-v2/DeviceUploadNft.d.ts +2 -3
  21. package/dist/api/protocol-v2/DeviceUploadNft.d.ts.map +1 -1
  22. package/dist/api/protocol-v2/DeviceUploadWallpaper.d.ts +2 -3
  23. package/dist/api/protocol-v2/DeviceUploadWallpaper.d.ts.map +1 -1
  24. package/dist/api/ton/TonSignMessage.d.ts.map +1 -1
  25. package/dist/core/index.d.ts.map +1 -1
  26. package/dist/device/Device.d.ts +7 -2
  27. package/dist/device/Device.d.ts.map +1 -1
  28. package/dist/device/DeviceCommands.d.ts.map +1 -1
  29. package/dist/device/DevicePool.d.ts.map +1 -1
  30. package/dist/events/logBlockEvent.d.ts.map +1 -1
  31. package/dist/index.d.ts +11 -14
  32. package/dist/index.js +455 -200
  33. package/dist/protocols/protocol-v2/features.d.ts +9 -1
  34. package/dist/protocols/protocol-v2/features.d.ts.map +1 -1
  35. package/dist/protocols/protocol-v2/index.d.ts +2 -2
  36. package/dist/protocols/protocol-v2/index.d.ts.map +1 -1
  37. package/dist/types/api/protocolV2.d.ts +1 -1
  38. package/dist/types/api/protocolV2.d.ts.map +1 -1
  39. package/dist/utils/pro2Nft.d.ts +7 -0
  40. package/dist/utils/pro2Nft.d.ts.map +1 -1
  41. package/package.json +6 -4
  42. package/src/api/FirmwareUpdateV4.ts +326 -174
  43. package/src/api/UploadPortfolio.ts +9 -2
  44. package/src/api/helpers/base64Data.ts +85 -0
  45. package/src/api/protocol-v2/DeviceUploadNft.ts +56 -8
  46. package/src/api/protocol-v2/DeviceUploadWallpaper.ts +37 -18
  47. package/src/api/ton/TonSignMessage.ts +5 -3
  48. package/src/core/index.ts +6 -2
  49. package/src/device/Device.ts +53 -31
  50. package/src/device/DeviceCommands.ts +4 -14
  51. package/src/device/DevicePool.ts +6 -2
  52. package/src/events/logBlockEvent.ts +14 -1
  53. package/src/protocols/protocol-v2/features.ts +19 -2
  54. package/src/protocols/protocol-v2/index.ts +2 -0
  55. package/src/types/api/protocolV2.ts +1 -1
  56. package/src/utils/deviceSettings.ts +1 -1
  57. package/src/utils/pro2Nft.ts +31 -8
@@ -1,7 +1,9 @@
1
- import { EFirmwareType, ERRORS, HardwareErrorCode } from '@onekeyfe/hd-shared';
1
+ import { Buffer } from 'buffer';
2
+ import { EDeviceType, EFirmwareType, ERRORS, HardwareErrorCode } from '@onekeyfe/hd-shared';
2
3
  import { sha256 } from '@noble/hashes/sha256';
3
4
  import { bytesToHex } from '@noble/hashes/utils';
4
5
  import JSZip from 'jszip';
6
+ import { encode as encodeJpeg } from 'jpeg-js';
5
7
  import {
6
8
  DeviceRebootType,
7
9
  DeviceSessionPinType,
@@ -123,6 +125,18 @@ jest.mock('../src/data/config', () => ({
123
125
  DEFAULT_DOMAIN: 'https://jssdk.onekey.so/1.0.0/',
124
126
  }));
125
127
 
128
+ const jpegBase64Cache = new Map<string, string>();
129
+
130
+ const createJpegBase64 = (width: number, height: number) => {
131
+ const key = `${width}x${height}`;
132
+ const cached = jpegBase64Cache.get(key);
133
+ if (cached) return cached;
134
+ const rgba = new Uint8Array(width * height * 4).fill(0xff);
135
+ const value = encodeJpeg({ width, height, data: rgba }, 80).data.toString('base64');
136
+ jpegBase64Cache.set(key, value);
137
+ return value;
138
+ };
139
+
126
140
  const createProtocolV2OkppBinary = ({
127
141
  version = [1, 2, 3],
128
142
  payloadHashByte = 0x11,
@@ -160,8 +174,18 @@ const createWalletSessionTypedCall = (
160
174
  });
161
175
 
162
176
  describe('DeviceUploadWallpaper', () => {
177
+ const wallpaperProtocolInfo = {
178
+ version: 2,
179
+ supported_messages: [60412, 60805, 60809],
180
+ };
181
+
182
+ const stubWallpaperDevice = <T extends Record<string, any>>(device: T) =>
183
+ stubDevice({
184
+ ...device,
185
+ ensureProtocolV2RuntimeContext: jest.fn().mockResolvedValue(wallpaperProtocolInfo),
186
+ });
187
+
163
188
  test('encodes, uploads and applies a Pro2 wallpaper', async () => {
164
- const rgba = new Uint8Array(604 * 1024 * 4).fill(255);
165
189
  const typedCall = jest.fn().mockImplementation((request, _response, params) => {
166
190
  if (request === 'FilesystemDirMake') return { message: { message: 'directory ready' } };
167
191
  if (request === 'FilesystemFileWrite') {
@@ -175,9 +199,12 @@ describe('DeviceUploadWallpaper', () => {
175
199
  });
176
200
  const method = new DeviceUploadWallpaper({
177
201
  id: 1,
178
- payload: { method: 'deviceUploadWallpaper', width: 604, height: 1024, rgba },
202
+ payload: {
203
+ method: 'deviceUploadWallpaper',
204
+ jpegBase64: createJpegBase64(604, 1024),
205
+ },
179
206
  });
180
- (method as any).device = stubDevice({ commands: { typedCall } });
207
+ (method as any).device = stubWallpaperDevice({ commands: { typedCall } });
181
208
  method.postMessage = jest.fn();
182
209
 
183
210
  method.init();
@@ -225,12 +252,10 @@ describe('DeviceUploadWallpaper', () => {
225
252
  id: 1,
226
253
  payload: {
227
254
  method: 'deviceUploadWallpaper',
228
- width: 604,
229
- height: 1024,
230
- rgba: new Uint8Array(604 * 1024 * 4),
255
+ jpegBase64: createJpegBase64(604, 1024),
231
256
  },
232
257
  });
233
- (method as any).device = stubDevice({ commands: { typedCall } });
258
+ (method as any).device = stubWallpaperDevice({ commands: { typedCall } });
234
259
  method.init();
235
260
 
236
261
  await expect(method.run()).rejects.toThrow('write failed');
@@ -242,15 +267,37 @@ describe('DeviceUploadWallpaper', () => {
242
267
  id: 1,
243
268
  payload: {
244
269
  method: 'deviceUploadWallpaper',
245
- width: 604,
246
- height: 1024,
247
- rgba: new Uint8Array(604 * 1024 * 4),
270
+ jpegBase64: createJpegBase64(604, 1024),
248
271
  fileName: '../wallpaper.bin',
249
272
  },
250
273
  });
251
274
 
252
275
  expect(() => method.init()).toThrow('fileName');
253
276
  });
277
+
278
+ test('rejects unsupported firmware before creating or writing files', async () => {
279
+ const typedCall = jest.fn();
280
+ const method = new DeviceUploadWallpaper({
281
+ id: 1,
282
+ payload: {
283
+ method: 'deviceUploadWallpaper',
284
+ jpegBase64: createJpegBase64(604, 1024),
285
+ },
286
+ });
287
+ (method as any).device = stubDevice({
288
+ commands: { typedCall },
289
+ ensureProtocolV2RuntimeContext: jest.fn().mockResolvedValue({
290
+ version: 2,
291
+ supported_messages: [60805, 60809],
292
+ }),
293
+ });
294
+ method.init();
295
+
296
+ await expect(method.run()).rejects.toMatchObject({
297
+ errorCode: HardwareErrorCode.DeviceNotSupportMethod,
298
+ });
299
+ expect(typedCall).not.toHaveBeenCalled();
300
+ });
254
301
  });
255
302
 
256
303
  describe('UploadPortfolio', () => {
@@ -266,7 +313,6 @@ describe('UploadPortfolio', () => {
266
313
  });
267
314
 
268
315
  test('writes and applies the portfolio while the device is locked without unlocking', async () => {
269
- const packageBytes = new Uint8Array([1, 2, 3]);
270
316
  const typedCall = jest
271
317
  .fn()
272
318
  .mockResolvedValueOnce({ message: { processed_byte: 3 } })
@@ -282,7 +328,7 @@ describe('UploadPortfolio', () => {
282
328
  id: 1,
283
329
  payload: {
284
330
  method: 'uploadPortfolio',
285
- packageBytes,
331
+ packageBase64: 'AQID',
286
332
  },
287
333
  });
288
334
  (method as any).device = device;
@@ -314,7 +360,7 @@ describe('UploadPortfolio', () => {
314
360
  id: 1,
315
361
  payload: {
316
362
  method: 'uploadPortfolio',
317
- packageBytes,
363
+ packageBase64: 'AQID',
318
364
  },
319
365
  });
320
366
  (method as any).device = stubPortfolioDevice({ commands: { typedCall } });
@@ -362,7 +408,7 @@ describe('UploadPortfolio', () => {
362
408
  id: 1,
363
409
  payload: {
364
410
  method: 'uploadPortfolio',
365
- packageBytes: new Uint8Array([1]),
411
+ packageBase64: 'AQ==',
366
412
  },
367
413
  });
368
414
  (method as any).device = stubPortfolioDevice({ commands: { typedCall } });
@@ -384,7 +430,7 @@ describe('UploadPortfolio', () => {
384
430
  id: 1,
385
431
  payload: {
386
432
  method: 'uploadPortfolio',
387
- packageBytes,
433
+ packageBase64: Buffer.from(packageBytes).toString('base64'),
388
434
  },
389
435
  });
390
436
  method.abortSignal = abortController.signal;
@@ -405,7 +451,7 @@ describe('UploadPortfolio', () => {
405
451
  id: 1,
406
452
  payload: {
407
453
  method: 'uploadPortfolio',
408
- packageBytes: new Uint8Array([1]),
454
+ packageBase64: 'AQ==',
409
455
  },
410
456
  });
411
457
  (method as any).device = stubDevice({
@@ -423,6 +469,18 @@ describe('UploadPortfolio', () => {
423
469
  });
424
470
  expect(typedCall).not.toHaveBeenCalled();
425
471
  });
472
+
473
+ test('rejects non-canonical Base64 before device communication', () => {
474
+ const method = new UploadPortfolio({
475
+ id: 1,
476
+ payload: {
477
+ method: 'uploadPortfolio',
478
+ packageBase64: 'data:application/octet-stream;base64,AQID',
479
+ },
480
+ });
481
+
482
+ expect(() => method.init()).toThrow('canonical Base64');
483
+ });
426
484
  });
427
485
 
428
486
  const descriptor = {
@@ -849,6 +907,34 @@ describe('Protocol V2 feature adapter', () => {
849
907
  ).toBeUndefined();
850
908
  });
851
909
 
910
+ test('infers legacy Protocol V2 loader mode from firmware image metadata', () => {
911
+ const legacyProtocolInfo = {
912
+ version: 1,
913
+ build_fingerprint: '',
914
+ supported_messages: [],
915
+ protobuf_definition: null,
916
+ };
917
+
918
+ expect(
919
+ getProtocolV2RuntimeMode(legacyProtocolInfo, {
920
+ fw: { bootloader: { version: '0.2.0' } },
921
+ })
922
+ ).toBe('bootloader');
923
+ expect(
924
+ getProtocolV2RuntimeMode(legacyProtocolInfo, {
925
+ fw: { romloader: { version: '1.0.0' } },
926
+ })
927
+ ).toBe('romloader');
928
+ expect(
929
+ getProtocolV2RuntimeMode(legacyProtocolInfo, {
930
+ fw: {
931
+ application: { version: '1.2.3' },
932
+ bootloader: { version: '0.2.0' },
933
+ },
934
+ })
935
+ ).toBeUndefined();
936
+ });
937
+
852
938
  test('keeps the Protocol V2 main wallet on the default empty-passphrase context', async () => {
853
939
  const features = normalizeProtocolV2Features(descriptor as any);
854
940
  features.firmwareVersion = '1.2.3';
@@ -2697,19 +2783,35 @@ describe('Protocol V2 feature adapter', () => {
2697
2783
  });
2698
2784
  });
2699
2785
 
2700
- test('syncs Protocol V2 cached features without cached profile', () => {
2786
+ test('syncs Protocol V2 cached features and renegotiates the adopted command channel', async () => {
2787
+ const typedCall = jest.fn().mockResolvedValue({ message: protocolV2ApplicationInfo });
2701
2788
  const cached = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
2702
2789
  (cached as any).features = {
2703
2790
  ...normalizeProtocolV2Features({ ...descriptor, protocolType: 'V2' } as any),
2704
2791
  deviceId: null,
2705
2792
  serialNo: 'CACHED-SERIAL',
2706
2793
  };
2794
+ (cached as any).commands = { typedCall };
2707
2795
 
2708
2796
  const current = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
2797
+ current.updateState(
2798
+ {
2799
+ raw: { protocolV2ProtocolInfo: protocolV2BootloaderInfo },
2800
+ status: { mode: 'bootloader' },
2801
+ },
2802
+ 'initialize'
2803
+ );
2709
2804
  current.updateFromCache(cached);
2710
2805
 
2806
+ await expect(current.ensureProtocolV2RuntimeContext()).resolves.toEqual(
2807
+ protocolV2ApplicationInfo
2808
+ );
2809
+
2711
2810
  expect(current.getCurrentDeviceId()).toBeUndefined();
2712
2811
  expect(current.getCurrentSerialNo()).toBe('CACHED-SERIAL');
2812
+ expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
2813
+ eventless_wallet_session: true,
2814
+ });
2713
2815
  expect(current.toMessageObject()).toMatchObject({
2714
2816
  serialNo: 'CACHED-SERIAL',
2715
2817
  uuid: 'CACHED-SERIAL',
@@ -3194,6 +3296,159 @@ describe('Protocol V2 feature adapter', () => {
3194
3296
  expect(typedCall).toHaveBeenCalledTimes(1);
3195
3297
  });
3196
3298
 
3299
+ test('initializes a legacy Protocol V2 bootloader without compatibility options', async () => {
3300
+ const device = Device.fromDescriptor({
3301
+ path: 'usb-path',
3302
+ protocolType: 'V2',
3303
+ } as any);
3304
+ const typedCall = jest
3305
+ .fn()
3306
+ .mockResolvedValueOnce({
3307
+ type: 'DeviceInfo',
3308
+ message: {
3309
+ hw: { Device_type: DeviceType.PRO2, serial_no: 'PR2SERIAL' },
3310
+ fw: { bootloader: { version: '0.2.0' } },
3311
+ },
3312
+ })
3313
+ .mockResolvedValueOnce({
3314
+ type: 'ProtocolInfo',
3315
+ message: {
3316
+ version: 1,
3317
+ build_fingerprint: '',
3318
+ supported_messages: [],
3319
+ protobuf_definition: null,
3320
+ },
3321
+ });
3322
+ (device as any).commands = { typedCall };
3323
+
3324
+ await device.initialize();
3325
+
3326
+ expect(typedCall).toHaveBeenCalledTimes(2);
3327
+ expect(typedCall).not.toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
3328
+ expect(device.features).toMatchObject({
3329
+ mode: 'bootloader',
3330
+ bootloaderMode: true,
3331
+ initialized: null,
3332
+ });
3333
+ });
3334
+
3335
+ test('uses DeviceStatusGet to identify App mode for legacy ProtocolInfo', async () => {
3336
+ const device = Device.fromDescriptor({
3337
+ path: 'usb-path',
3338
+ protocolType: 'V2',
3339
+ } as any);
3340
+ const typedCall = jest
3341
+ .fn()
3342
+ .mockResolvedValueOnce({
3343
+ type: 'ProtocolInfo',
3344
+ message: {
3345
+ version: 1,
3346
+ build_fingerprint: '',
3347
+ supported_messages: [],
3348
+ protobuf_definition: null,
3349
+ },
3350
+ })
3351
+ .mockResolvedValueOnce({
3352
+ type: 'DeviceStatus',
3353
+ message: { init_states: true, unlocked: true },
3354
+ });
3355
+ (device as any).commands = { typedCall };
3356
+
3357
+ await device.probeProtocolV2RuntimeState(
3358
+ {
3359
+ hw: { Device_type: DeviceType.PRO2, serial_no: 'PR2SERIAL' },
3360
+ fw: { application: { version: '1.2.3' } },
3361
+ },
3362
+ 5000
3363
+ );
3364
+
3365
+ expect(typedCall).toHaveBeenNthCalledWith(
3366
+ 1,
3367
+ 'ProtocolInfoRequest',
3368
+ 'ProtocolInfo',
3369
+ { eventless_wallet_session: true },
3370
+ { timeoutMs: 5000 }
3371
+ );
3372
+ expect(typedCall).toHaveBeenNthCalledWith(
3373
+ 2,
3374
+ 'DeviceStatusGet',
3375
+ 'DeviceStatus',
3376
+ {},
3377
+ { timeoutMs: 5000 }
3378
+ );
3379
+ expect(device.features).toMatchObject({
3380
+ mode: 'normal',
3381
+ bootloaderMode: false,
3382
+ initialized: true,
3383
+ unlocked: true,
3384
+ });
3385
+ });
3386
+
3387
+ test('maps legacy bootloader metadata without calling DeviceStatusGet', async () => {
3388
+ const device = Device.fromDescriptor({
3389
+ path: 'usb-path',
3390
+ protocolType: 'V2',
3391
+ } as any);
3392
+ const typedCall = jest.fn().mockResolvedValueOnce({
3393
+ type: 'ProtocolInfo',
3394
+ message: {
3395
+ version: 1,
3396
+ build_fingerprint: '',
3397
+ supported_messages: [],
3398
+ protobuf_definition: null,
3399
+ },
3400
+ });
3401
+ (device as any).commands = { typedCall };
3402
+
3403
+ await device.probeProtocolV2RuntimeState({
3404
+ hw: { Device_type: DeviceType.NEO, serial_no: 'NEOSERIAL' },
3405
+ fw: { bootloader: { version: '0.2.0' } },
3406
+ });
3407
+
3408
+ expect(typedCall).toHaveBeenNthCalledWith(1, 'ProtocolInfoRequest', 'ProtocolInfo', {
3409
+ eventless_wallet_session: true,
3410
+ });
3411
+ expect(typedCall).toHaveBeenCalledTimes(1);
3412
+ expect(typedCall).not.toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
3413
+ expect(device.features).toMatchObject({
3414
+ deviceType: 'neo',
3415
+ mode: 'bootloader',
3416
+ bootloaderMode: true,
3417
+ initialized: null,
3418
+ });
3419
+ });
3420
+
3421
+ test('maps legacy romloader metadata without calling DeviceStatusGet', async () => {
3422
+ const device = Device.fromDescriptor({
3423
+ path: 'usb-path',
3424
+ protocolType: 'V2',
3425
+ } as any);
3426
+ const typedCall = jest.fn().mockResolvedValueOnce({
3427
+ type: 'ProtocolInfo',
3428
+ message: {
3429
+ version: 1,
3430
+ build_fingerprint: '',
3431
+ supported_messages: [],
3432
+ protobuf_definition: null,
3433
+ },
3434
+ });
3435
+ (device as any).commands = { typedCall };
3436
+
3437
+ await device.probeProtocolV2RuntimeState({
3438
+ hw: { Device_type: DeviceType.PRO2, serial_no: 'PR2SERIAL' },
3439
+ fw: { romloader: { version: '1.0.0' } },
3440
+ });
3441
+
3442
+ expect(typedCall).toHaveBeenCalledTimes(1);
3443
+ expect(typedCall).not.toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
3444
+ expect(device.features).toMatchObject({
3445
+ deviceType: 'pro2',
3446
+ mode: 'romloader',
3447
+ bootloaderMode: true,
3448
+ initialized: null,
3449
+ });
3450
+ });
3451
+
3197
3452
  test('does not reinterpret a state update error as runtime detection failure', async () => {
3198
3453
  const device = Device.fromDescriptor({
3199
3454
  path: 'usb-path',
@@ -4098,7 +4353,7 @@ describe('Protocol V2 firmware update targets', () => {
4098
4353
  ).toEqual([3, 4]);
4099
4354
  });
4100
4355
 
4101
- test('requires the external host to prepare manifest resources before bootloader entry', async () => {
4356
+ test('keeps external-only resource updates on the prepared host path', async () => {
4102
4357
  const method = new FirmwareUpdateV4({
4103
4358
  id: 1,
4104
4359
  payload: {
@@ -4108,6 +4363,7 @@ describe('Protocol V2 firmware update targets', () => {
4108
4363
  },
4109
4364
  });
4110
4365
  method.init();
4366
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue('external-only' as never);
4111
4367
 
4112
4368
  (method as any).device = stubDevice({
4113
4369
  originalDescriptor: { id: 'usb-id', path: 'app-path', protocolType: 'V2' },
@@ -4135,48 +4391,174 @@ describe('Protocol V2 firmware update targets', () => {
4135
4391
  expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
4136
4392
  });
4137
4393
 
4138
- test('reboots Protocol V2 normal-mode device to bootloader before transfer', async () => {
4394
+ test('downloads Protocol V2 resource archives in SDK-managed mode', async () => {
4395
+ const resourceArchiveBinary = new Uint8Array([1, 2, 3, 4]).buffer;
4396
+ const applicationP1Binary = new Uint8Array([5, 6, 7, 8]).buffer;
4397
+ const applicationP1InstallItem = {
4398
+ fileName: 'application_p1.bin',
4399
+ binary: applicationP1Binary,
4400
+ targetId: 4,
4401
+ kind: 'firmware',
4402
+ };
4403
+ const resourceArchiveSha256 = bytesToHex(sha256(new Uint8Array(resourceArchiveBinary)));
4139
4404
  const method = new FirmwareUpdateV4({
4140
4405
  id: 1,
4141
4406
  payload: {
4142
4407
  method: 'firmwareUpdateV4',
4408
+ platform: 'ext',
4409
+ targetsToUpdate: ['app_v1', 'resource'],
4143
4410
  },
4144
4411
  });
4412
+ method.init();
4413
+
4145
4414
  (method as any).device = stubDevice({
4146
- originalDescriptor: { id: 'usb-id', path: 'usb-path', protocolType: 'V2' },
4147
- features: { bootloader_mode: false, capabilities: [] },
4415
+ originalDescriptor: { id: 'usb-id', path: 'app-path', protocolType: 'V2' },
4416
+ features: {
4417
+ deviceType: 'pro2',
4418
+ firmwareVersion: '1.0.0',
4419
+ mode: 'normal',
4420
+ bootloaderMode: false,
4421
+ capabilities: [],
4422
+ },
4423
+ getCurrentDeviceType: () => 'pro2',
4148
4424
  isBootloader: () => false,
4149
- probeProtocolV2RuntimeState: jest.fn().mockResolvedValue({
4150
- mode: 'bootloader',
4151
- bootloaderMode: true,
4152
- }),
4425
+ isRomloader: () => false,
4153
4426
  });
4154
- (method as any).protocolV2Reboot = jest.fn().mockResolvedValue({
4155
- message: 'Device rebooted successfully',
4427
+ (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
4428
+ (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue({
4429
+ bootloaderBinary: null,
4430
+ fwBinaryMap: [
4431
+ {
4432
+ fileName: applicationP1InstallItem.fileName,
4433
+ binary: applicationP1Binary,
4434
+ targetId: applicationP1InstallItem.targetId,
4435
+ },
4436
+ ],
4437
+ installItems: [applicationP1InstallItem],
4156
4438
  });
4157
- (method as any).checkDeviceToBootloader = jest.fn();
4158
- const typedCall = jest.fn().mockImplementation((requestType: string) => {
4159
- if (requestType === 'DeviceInfoGet') {
4160
- return Promise.resolve({
4161
- type: 'DeviceInfo',
4162
- message: protocolV2BootloaderDeviceInfo,
4163
- });
4164
- }
4165
- if (requestType === 'ProtocolInfoRequest') {
4166
- return Promise.resolve({
4167
- type: 'ProtocolInfo',
4168
- message: protocolV2BootloaderInfo,
4169
- });
4170
- }
4171
- return Promise.reject(new Error(`unexpected call ${requestType}`));
4439
+ const release = jest.fn();
4440
+ (method as any).prepareProtocolV2LocalMemoryHost = jest.fn().mockResolvedValue({ release });
4441
+ (method as any).runProtocolV2PreparedArtifacts = jest
4442
+ .fn()
4443
+ .mockResolvedValue('sdk-managed-resource-result');
4444
+ method.postTipMessage = jest.fn();
4445
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue('sdk-managed' as never);
4446
+ jest.spyOn(DataManager, 'getProtocolV2ResourceSource').mockReturnValue({
4447
+ archiveUrl: 'https://example.com/pro2-resource.zip',
4448
+ archiveSize: resourceArchiveBinary.byteLength,
4449
+ archiveSha256: resourceArchiveSha256,
4172
4450
  });
4173
- const reconnectProtocolV2Device = jest.fn().mockImplementation(() => {
4174
- (method as any).device.isBootloader = () => true;
4175
- return Promise.resolve();
4451
+ const getSysResourceBinarySpy = jest
4452
+ .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
4453
+ .mockResolvedValue({ binary: resourceArchiveBinary });
4454
+
4455
+ await expect(method.run()).resolves.toBe('sdk-managed-resource-result');
4456
+
4457
+ expect(forceReloadDataSpy).toHaveBeenCalledWith({
4458
+ requireResources: true,
4459
+ resourceDeviceType: EDeviceType.Pro2,
4176
4460
  });
4177
- (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
4178
- (method as any).device.getCommands = () => ({ typedCall });
4179
- (method as any).protocolV2ExpectedSerialNumber = 'PR9999999999';
4461
+ expect(getSysResourceBinarySpy).toHaveBeenCalledWith('https://example.com/pro2-resource.zip');
4462
+ expect((method as any).params.resourceArchiveBinary).toBe(resourceArchiveBinary);
4463
+ expect((method as any).prepareProtocolV2LocalMemoryHost).toHaveBeenCalledWith({
4464
+ features: expect.objectContaining({ deviceType: 'pro2' }),
4465
+ firmwareType: EFirmwareType.Universal,
4466
+ availableInstallItems: [applicationP1InstallItem],
4467
+ });
4468
+ expect((method as any).runProtocolV2PreparedArtifacts).toHaveBeenCalledTimes(1);
4469
+ expect(release).toHaveBeenCalledTimes(1);
4470
+ });
4471
+
4472
+ test('preserves SDK-managed resource installation errors after preparation', async () => {
4473
+ const resourceArchiveBinary = new Uint8Array([1, 2, 3, 4]).buffer;
4474
+ const installError = ERRORS.TypedError(
4475
+ HardwareErrorCode.FirmwareError,
4476
+ 'device rejected the prepared resource installation'
4477
+ );
4478
+ const method = new FirmwareUpdateV4({
4479
+ id: 1,
4480
+ payload: {
4481
+ method: 'firmwareUpdateV4',
4482
+ platform: 'ext',
4483
+ targetsToUpdate: ['resource'],
4484
+ },
4485
+ });
4486
+ method.init();
4487
+
4488
+ (method as any).device = stubDevice({
4489
+ originalDescriptor: { id: 'usb-id', path: 'app-path', protocolType: 'V2' },
4490
+ features: {
4491
+ deviceType: 'pro2',
4492
+ firmwareVersion: '1.0.0',
4493
+ mode: 'normal',
4494
+ bootloaderMode: false,
4495
+ capabilities: [],
4496
+ },
4497
+ getCurrentDeviceType: () => 'pro2',
4498
+ isBootloader: () => false,
4499
+ isRomloader: () => false,
4500
+ });
4501
+ (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
4502
+ (method as any).downloadRemoteProtocolV2ResourceArchive = jest
4503
+ .fn()
4504
+ .mockResolvedValue(resourceArchiveBinary);
4505
+ const release = jest.fn();
4506
+ (method as any).prepareProtocolV2LocalMemoryHost = jest.fn().mockResolvedValue({ release });
4507
+ (method as any).runProtocolV2PreparedArtifacts = jest.fn().mockRejectedValue(installError);
4508
+ method.postTipMessage = jest.fn();
4509
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue('sdk-managed' as never);
4510
+
4511
+ await expect(method.run()).rejects.toBe(installError);
4512
+
4513
+ expect(forceReloadDataSpy).toHaveBeenCalledWith({
4514
+ requireResources: true,
4515
+ resourceDeviceType: EDeviceType.Pro2,
4516
+ });
4517
+ expect(release).toHaveBeenCalledTimes(1);
4518
+ });
4519
+
4520
+ test('reboots Protocol V2 normal-mode device to bootloader before transfer', async () => {
4521
+ const method = new FirmwareUpdateV4({
4522
+ id: 1,
4523
+ payload: {
4524
+ method: 'firmwareUpdateV4',
4525
+ },
4526
+ });
4527
+ (method as any).device = stubDevice({
4528
+ originalDescriptor: { id: 'usb-id', path: 'usb-path', protocolType: 'V2' },
4529
+ features: { bootloader_mode: false, capabilities: [] },
4530
+ isBootloader: () => false,
4531
+ probeProtocolV2RuntimeState: jest.fn().mockResolvedValue({
4532
+ mode: 'bootloader',
4533
+ bootloaderMode: true,
4534
+ }),
4535
+ });
4536
+ (method as any).protocolV2Reboot = jest.fn().mockResolvedValue({
4537
+ message: 'Device rebooted successfully',
4538
+ });
4539
+ (method as any).checkDeviceToBootloader = jest.fn();
4540
+ const typedCall = jest.fn().mockImplementation((requestType: string) => {
4541
+ if (requestType === 'DeviceInfoGet') {
4542
+ return Promise.resolve({
4543
+ type: 'DeviceInfo',
4544
+ message: protocolV2BootloaderDeviceInfo,
4545
+ });
4546
+ }
4547
+ if (requestType === 'ProtocolInfoRequest') {
4548
+ return Promise.resolve({
4549
+ type: 'ProtocolInfo',
4550
+ message: protocolV2BootloaderInfo,
4551
+ });
4552
+ }
4553
+ return Promise.reject(new Error(`unexpected call ${requestType}`));
4554
+ });
4555
+ const reconnectProtocolV2Device = jest.fn().mockImplementation(() => {
4556
+ (method as any).device.isBootloader = () => true;
4557
+ return Promise.resolve();
4558
+ });
4559
+ (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
4560
+ (method as any).device.getCommands = () => ({ typedCall });
4561
+ (method as any).protocolV2ExpectedSerialNumber = 'PR9999999999';
4180
4562
  (method as any).protocolV2ExpectedPath = 'usb-path';
4181
4563
  method.postTipMessage = jest.fn();
4182
4564
 
@@ -4249,6 +4631,43 @@ describe('Protocol V2 firmware update targets', () => {
4249
4631
  expect(method.postTipMessage).toHaveBeenCalledWith('GoToBootloaderSuccess');
4250
4632
  });
4251
4633
 
4634
+ test('keeps an ambiguous legacy runtime on the direct loader update path', async () => {
4635
+ const method = new FirmwareUpdateV4({
4636
+ id: 1,
4637
+ payload: {
4638
+ method: 'firmwareUpdateV4',
4639
+ },
4640
+ });
4641
+ (method as any).device = stubDevice({
4642
+ originalDescriptor: { id: 'usb-id', path: 'legacy-path', protocolType: 'V2' },
4643
+ state: {
4644
+ raw: {
4645
+ protocolV2ProtocolInfo: {
4646
+ version: 1,
4647
+ build_fingerprint: '',
4648
+ supported_messages: [],
4649
+ protobuf_definition: null,
4650
+ },
4651
+ },
4652
+ },
4653
+ features: {
4654
+ deviceType: 'pro2',
4655
+ mode: 'bootloader',
4656
+ bootloaderMode: true,
4657
+ capabilities: [],
4658
+ },
4659
+ isBootloader: () => true,
4660
+ isRomloader: () => false,
4661
+ });
4662
+ (method as any).protocolV2Reboot = jest.fn();
4663
+ method.postTipMessage = jest.fn();
4664
+
4665
+ await expect((method as any).enterProtocolV2BootloaderMode()).resolves.toBe(false);
4666
+
4667
+ expect((method as any).protocolV2LegacyDirectUpdate).toBe(true);
4668
+ expect((method as any).protocolV2Reboot).not.toHaveBeenCalled();
4669
+ });
4670
+
4252
4671
  test('keeps Protocol V2 romloader active before firmware transfer', async () => {
4253
4672
  const method = new FirmwareUpdateV4({
4254
4673
  id: 1,
@@ -4881,6 +5300,87 @@ describe('Protocol V2 firmware update targets', () => {
4881
5300
  expect(method.postProgressMessage).not.toHaveBeenCalled();
4882
5301
  });
4883
5302
 
5303
+ test.each([
5304
+ 'Failure_InvalidMessage,Handler not registered',
5305
+ 'Failure_InvalidMessage,Unsupported message',
5306
+ ])('reboots a legacy App when the direct update endpoint is unavailable: %s', async message => {
5307
+ const method = new FirmwareUpdateV4({
5308
+ id: 1,
5309
+ payload: {
5310
+ method: 'firmwareUpdateV4',
5311
+ },
5312
+ });
5313
+ const targets = [{ target_id: 4, path: 'vol0:/application_p1.bin' }];
5314
+ const appTypedCall = jest.fn().mockRejectedValueOnce(new Error(message));
5315
+ const bootloaderTypedCall = jest
5316
+ .fn()
5317
+ .mockResolvedValueOnce({ type: 'Success', message: { message: '' } });
5318
+ let activeCommands = { typedCall: appTypedCall };
5319
+
5320
+ (method as any).device = stubDevice({
5321
+ getCommands: () => activeCommands,
5322
+ });
5323
+ (method as any).protocolV2LegacyDirectUpdate = true;
5324
+ (method as any).rebootProtocolV2ToBootloader = jest.fn().mockImplementation(() => {
5325
+ activeCommands = { typedCall: bootloaderTypedCall };
5326
+ return Promise.resolve(true);
5327
+ });
5328
+ method.postTipMessage = jest.fn();
5329
+ method.postProgressMessage = jest.fn();
5330
+
5331
+ await expect((method as any).protocolV2StartFirmwareUpdate({ targets })).resolves.toEqual({
5332
+ type: 'Success',
5333
+ message: { message: '' },
5334
+ });
5335
+
5336
+ expect(appTypedCall).toHaveBeenCalledTimes(1);
5337
+ expect(appTypedCall).toHaveBeenCalledWith(
5338
+ 'DeviceFirmwareUpdateRequest',
5339
+ 'Success',
5340
+ { targets },
5341
+ { timeoutMs: 180000 }
5342
+ );
5343
+ expect(bootloaderTypedCall).toHaveBeenCalledTimes(1);
5344
+ expect(bootloaderTypedCall).toHaveBeenCalledWith(
5345
+ 'DeviceFirmwareUpdateRequest',
5346
+ 'Success',
5347
+ { targets },
5348
+ { timeoutMs: 180000 }
5349
+ );
5350
+ expect((method as any).rebootProtocolV2ToBootloader).toHaveBeenCalledTimes(1);
5351
+ expect(method.postTipMessage).toHaveBeenCalledWith('FirmwareUpdating');
5352
+ expect(method.postProgressMessage).toHaveBeenCalledWith(0, 'installingFirmware');
5353
+ });
5354
+
5355
+ test('does not replay a legacy direct update after a link failure', async () => {
5356
+ const method = new FirmwareUpdateV4({
5357
+ id: 1,
5358
+ payload: {
5359
+ method: 'firmwareUpdateV4',
5360
+ },
5361
+ });
5362
+ const typedCall = jest.fn().mockRejectedValue(new Error('LIBUSB_TRANSFER_TIMED_OUT'));
5363
+
5364
+ (method as any).device = stubDevice({
5365
+ getCommands: () => ({ typedCall }),
5366
+ });
5367
+ (method as any).protocolV2LegacyDirectUpdate = true;
5368
+ (method as any).rebootProtocolV2ToBootloader = jest.fn();
5369
+ method.postTipMessage = jest.fn();
5370
+ method.postProgressMessage = jest.fn();
5371
+
5372
+ await expect(
5373
+ (method as any).protocolV2StartFirmwareUpdate({
5374
+ targets: [{ target_id: 4, path: 'vol0:/application_p1.bin' }],
5375
+ })
5376
+ ).rejects.toThrow('LIBUSB_TRANSFER_TIMED_OUT');
5377
+
5378
+ expect(typedCall).toHaveBeenCalledTimes(1);
5379
+ expect((method as any).rebootProtocolV2ToBootloader).not.toHaveBeenCalled();
5380
+ expect(method.postTipMessage).not.toHaveBeenCalled();
5381
+ expect(method.postProgressMessage).not.toHaveBeenCalled();
5382
+ });
5383
+
4884
5384
  test('polls only firmware status while Protocol V2 bootloader is installing', async () => {
4885
5385
  const method = new FirmwareUpdateV4({
4886
5386
  id: 1,
@@ -4943,6 +5443,7 @@ describe('Protocol V2 firmware update targets', () => {
4943
5443
  const deviceInfo = { hw: { serial_no: 'PRO2-PHYSICAL-1' } };
4944
5444
  (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(deviceInfo);
4945
5445
  (method as any).probeProtocolV2NormalMode = jest.fn().mockResolvedValue(true);
5446
+ (method as any).protocolV2InstallAckReceived = true;
4946
5447
  method.postProgressMessage = jest.fn();
4947
5448
 
4948
5449
  await (method as any).waitForProtocolV2FirmwareUpdateComplete([
@@ -4981,6 +5482,7 @@ describe('Protocol V2 firmware update targets', () => {
4981
5482
  (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
4982
5483
  (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(deviceInfo);
4983
5484
  (method as any).probeProtocolV2NormalMode = jest.fn().mockResolvedValue(true);
5485
+ (method as any).protocolV2InstallAckReceived = true;
4984
5486
  method.postProgressMessage = jest.fn();
4985
5487
 
4986
5488
  await expect(
@@ -4994,6 +5496,78 @@ describe('Protocol V2 firmware update targets', () => {
4994
5496
  expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
4995
5497
  });
4996
5498
 
5499
+ test('rejects normal mode without install ACK, target status, or a version change', async () => {
5500
+ const method = new FirmwareUpdateV4({
5501
+ id: 1,
5502
+ payload: {
5503
+ method: 'firmwareUpdateV4',
5504
+ },
5505
+ });
5506
+ const typedCall = jest
5507
+ .fn()
5508
+ .mockRejectedValue(new Error('Failure: Handler not registered for this message'));
5509
+ const deviceInfo = { hw: { serial_no: 'PRO2-PHYSICAL-1' } };
5510
+ let now = 0;
5511
+ jest.spyOn(Date, 'now').mockImplementation(() => now);
5512
+ jest.spyOn(global, 'setTimeout').mockImplementation(((callback: () => void) => {
5513
+ now += 31 * 1000;
5514
+ callback();
5515
+ return 0 as any;
5516
+ }) as typeof setTimeout);
5517
+
5518
+ (method as any).device = stubDevice({
5519
+ getCommands: () => ({ typedCall }),
5520
+ });
5521
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
5522
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(deviceInfo);
5523
+ (method as any).probeProtocolV2NormalMode = jest.fn().mockResolvedValue(true);
5524
+ method.postProgressMessage = jest.fn();
5525
+
5526
+ await expect(
5527
+ (method as any).waitForProtocolV2FirmwareUpdateComplete([
5528
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
5529
+ ])
5530
+ ).rejects.toMatchObject({ errorCode: HardwareErrorCode.FirmwareError });
5531
+
5532
+ expect(method.postProgressMessage).not.toHaveBeenCalledWith(100, 'installingFirmware');
5533
+ });
5534
+
5535
+ test('accepts ACK-less normal mode after the requested target version changes', async () => {
5536
+ const method = new FirmwareUpdateV4({
5537
+ id: 1,
5538
+ payload: {
5539
+ method: 'firmwareUpdateV4',
5540
+ },
5541
+ });
5542
+ const typedCall = jest
5543
+ .fn()
5544
+ .mockRejectedValue(new Error('Failure: Handler not registered for this message'));
5545
+ const deviceInfo = { hw: { serial_no: 'PRO2-PHYSICAL-1' } };
5546
+ const probeProtocolV2RuntimeState = jest.fn().mockResolvedValue({
5547
+ mode: 'normal',
5548
+ bootloaderMode: false,
5549
+ firmwareVersion: '2.0.0',
5550
+ });
5551
+
5552
+ (method as any).device = stubDevice({
5553
+ getCommands: () => ({ typedCall }),
5554
+ probeProtocolV2RuntimeState,
5555
+ });
5556
+ (method as any).protocolV2InstallBaselineVersions = new Map([[4, '1.0.0']]);
5557
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
5558
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(deviceInfo);
5559
+ method.postProgressMessage = jest.fn();
5560
+
5561
+ await expect(
5562
+ (method as any).waitForProtocolV2FirmwareUpdateComplete([
5563
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
5564
+ ])
5565
+ ).resolves.toBeUndefined();
5566
+
5567
+ expect(probeProtocolV2RuntimeState).toHaveBeenCalledWith(deviceInfo, 5000);
5568
+ expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
5569
+ });
5570
+
4997
5571
  test.each([
4998
5572
  [{ mode: 'normal', bootloaderMode: false }, true],
4999
5573
  [{ mode: 'bootloader', bootloaderMode: true }, false],
@@ -5555,7 +6129,7 @@ describe('Protocol V2 firmware update targets', () => {
5555
6129
  );
5556
6130
  });
5557
6131
 
5558
- test('passes bootloader, coprocessor, SE and app files to DeviceFirmwareUpdate targets', async () => {
6132
+ test('preserves component-first target order for the firmware-owned loader handoff', async () => {
5559
6133
  const method = new FirmwareUpdateV4({
5560
6134
  id: 1,
5561
6135
  payload: {
@@ -5584,54 +6158,72 @@ describe('Protocol V2 firmware update targets', () => {
5584
6158
  .mockResolvedValue(undefined);
5585
6159
 
5586
6160
  await (method as any).executeProtocolV2Update({
5587
- bootloaderBinary: new Uint8Array([4, 5]).buffer,
5588
- fwBinaryMap: [
6161
+ // The active bootloader installs its component runners in records order and
6162
+ // leaves the bootloader record pending for romloader after reboot. Keep boot
6163
+ // deliberately after components to prove Core does not synthesize boot-first.
6164
+ installItems: [
5589
6165
  {
5590
- fileName: 'coprocessor.bin',
5591
- binary: new Uint8Array([6]).buffer,
5592
- targetId: 6,
6166
+ fileName: 'application_p1.bin',
6167
+ binary: new Uint8Array([8]).buffer,
6168
+ targetId: 4,
6169
+ kind: 'firmware',
5593
6170
  },
5594
6171
  {
5595
6172
  fileName: 'se01.bin',
5596
6173
  binary: new Uint8Array([7]).buffer,
5597
6174
  targetId: 7,
6175
+ kind: 'firmware',
5598
6176
  },
5599
6177
  {
5600
- fileName: 'application_p1.bin',
5601
- binary: new Uint8Array([8]).buffer,
5602
- targetId: 4,
6178
+ fileName: 'bootloader.bin',
6179
+ binary: new Uint8Array([4, 5]).buffer,
6180
+ targetId: 3,
6181
+ kind: 'bootloader',
6182
+ },
6183
+ {
6184
+ fileName: 'coprocessor.bin',
6185
+ binary: new Uint8Array([6]).buffer,
6186
+ targetId: 6,
6187
+ kind: 'firmware',
5603
6188
  },
5604
6189
  ],
5605
6190
  });
5606
6191
 
5607
6192
  expect(writtenPaths).toEqual([
6193
+ 'vol0:/application_p1.bin',
6194
+ 'vol0:/se01.bin',
5608
6195
  'vol0:/bootloader.bin',
5609
6196
  'vol0:/coprocessor.bin',
5610
- 'vol0:/se01.bin',
5611
- 'vol0:/application_p1.bin',
5612
6197
  ]);
5613
6198
  expect((method as any).verifyProtocolV2StagedFile).toHaveBeenCalledTimes(4);
5614
6199
  expect((method as any).verifyProtocolV2StagedFile).toHaveBeenNthCalledWith(
5615
6200
  2,
5616
- 'vol0:/coprocessor.bin',
6201
+ 'vol0:/se01.bin',
5617
6202
  1
5618
6203
  );
5619
- expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledTimes(2);
5620
- expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenNthCalledWith(1, {
5621
- targets: [{ target_id: 3, path: 'vol0:/bootloader.bin' }],
5622
- });
5623
- expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenNthCalledWith(2, {
6204
+ expect((method as any).enterProtocolV2BootloaderMode).toHaveBeenCalledTimes(1);
6205
+ expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledTimes(1);
6206
+ expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledWith({
5624
6207
  targets: [
5625
- { target_id: 6, path: 'vol0:/coprocessor.bin' },
5626
- { target_id: 7, path: 'vol0:/se01.bin' },
5627
6208
  { target_id: 4, path: 'vol0:/application_p1.bin' },
6209
+ { target_id: 7, path: 'vol0:/se01.bin' },
6210
+ { target_id: 3, path: 'vol0:/bootloader.bin' },
6211
+ { target_id: 6, path: 'vol0:/coprocessor.bin' },
5628
6212
  ],
5629
6213
  });
6214
+ expect((method as any).waitForProtocolV2FirmwareUpdateComplete).toHaveBeenCalledTimes(1);
6215
+ expect((method as any).waitForProtocolV2FirmwareUpdateComplete).toHaveBeenCalledWith([
6216
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
6217
+ { target_id: 7, path: 'vol0:/se01.bin' },
6218
+ { target_id: 3, path: 'vol0:/bootloader.bin' },
6219
+ { target_id: 6, path: 'vol0:/coprocessor.bin' },
6220
+ ]);
6221
+ expect((method as any).exitProtocolV2BootloaderToNormal).not.toHaveBeenCalled();
5630
6222
  expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'transferData');
5631
- expect((method as any).waitForProtocolV2FirmwareUpdateComplete).toHaveBeenCalled();
6223
+ expect((method as any).completeProtocolV2FinalVerification).toHaveBeenCalledTimes(1);
5632
6224
  });
5633
6225
 
5634
- test('announces separate resource and component transfer phases', async () => {
6226
+ test('uses one global transfer range for resources and firmware', async () => {
5635
6227
  const method = new FirmwareUpdateV4({
5636
6228
  id: 1,
5637
6229
  payload: {
@@ -5688,18 +6280,24 @@ describe('Protocol V2 firmware update targets', () => {
5688
6280
  ],
5689
6281
  });
5690
6282
 
5691
- expect(method.postTipMessage).toHaveBeenCalledTimes(3);
6283
+ expect((method as any).enterProtocolV2BootloaderMode).toHaveBeenCalledTimes(1);
6284
+ expect(method.postTipMessage).toHaveBeenCalledTimes(2);
5692
6285
  expect(method.postTipMessage).toHaveBeenNthCalledWith(1, 'StartTransferData');
5693
- expect(method.postTipMessage).toHaveBeenNthCalledWith(2, 'StartTransferData');
5694
- expect(method.postTipMessage).toHaveBeenNthCalledWith(3, 'ConfirmOnDevice');
6286
+ expect(method.postTipMessage).toHaveBeenNthCalledWith(2, 'ConfirmOnDevice');
5695
6287
  expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenNthCalledWith(
5696
6288
  1,
5697
- expect.objectContaining({ processedSize: 0, totalSize: 2 })
6289
+ expect.objectContaining({ processedSize: 0, totalSize: 3 })
5698
6290
  );
5699
6291
  expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenNthCalledWith(
5700
6292
  2,
5701
- expect.objectContaining({ processedSize: 0, totalSize: 1 })
6293
+ expect.objectContaining({ processedSize: 2, totalSize: 3 })
5702
6294
  );
6295
+ expect(method.postProgressMessage).toHaveBeenCalledTimes(1);
6296
+ expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'transferData');
6297
+ expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledTimes(1);
6298
+ expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledWith({
6299
+ targets: [{ target_id: 4, path: 'vol0:/application_p1.bin' }],
6300
+ });
5703
6301
  expect((method as any).isProtocolV2ResourceBundleUpToDate).toHaveBeenCalledWith(
5704
6302
  expect.objectContaining({
5705
6303
  version: [1, 2, 3],
@@ -5709,50 +6307,7 @@ describe('Protocol V2 firmware update targets', () => {
5709
6307
  );
5710
6308
  });
5711
6309
 
5712
- test('installs and verifies bootloader before syncing manifest resources', () => {
5713
- const method = new FirmwareUpdateV4({
5714
- id: 1,
5715
- payload: { method: 'firmwareUpdateV4' },
5716
- });
5717
- const phases = (method as any).buildProtocolV2ExecutionPhases({
5718
- installSources: [{ kind: 'bootloader' }, { kind: 'component' }],
5719
- resourceSources: [{ kind: 'resource' }],
5720
- });
5721
-
5722
- expect(phases.map((phase: { kind: string }) => phase.kind)).toEqual([
5723
- 'bootloader-install',
5724
- 'bootloader-verify',
5725
- 'resource-sync',
5726
- 'component-install',
5727
- 'final-verify',
5728
- ]);
5729
- });
5730
-
5731
- test('stages the mounted boot resource before any firmware install phase', () => {
5732
- const method = new FirmwareUpdateV4({
5733
- id: 1,
5734
- payload: { method: 'firmwareUpdateV4' },
5735
- });
5736
- const bootResource = {
5737
- devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg',
5738
- };
5739
- const phases = (method as any).buildProtocolV2ExecutionPhases({
5740
- installSources: [{ kind: 'bootloader' }, { kind: 'component' }],
5741
- resourceSources: [bootResource, { devicePath: 'vol0:/resource/images/images.okpkg' }],
5742
- });
5743
-
5744
- expect(phases.map((phase: { kind: string }) => phase.kind)).toEqual([
5745
- 'resource-sync',
5746
- 'bootloader-install',
5747
- 'bootloader-verify',
5748
- 'resource-sync',
5749
- 'component-install',
5750
- 'final-verify',
5751
- ]);
5752
- expect(phases[0].resourceSources).toEqual([bootResource]);
5753
- });
5754
-
5755
- test('removes stale boot resource staging before a component-only reboot', async () => {
6310
+ test('removes stale boot resource staging before the combined transfer', async () => {
5756
6311
  const method = new FirmwareUpdateV4({
5757
6312
  id: 1,
5758
6313
  payload: { method: 'firmwareUpdateV4' },
@@ -5779,18 +6334,18 @@ describe('Protocol V2 firmware update targets', () => {
5779
6334
  events.push('transfer-component');
5780
6335
  return Promise.resolve();
5781
6336
  });
5782
- (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockImplementation(() => {
5783
- events.push('reboot-normal');
5784
- return Promise.resolve();
6337
+ (method as any).completeProtocolV2FinalVerification = jest.fn().mockImplementation(() => {
6338
+ events.push('final-verify');
6339
+ return Promise.resolve({});
5785
6340
  });
5786
- (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
5787
6341
 
5788
6342
  await (method as any).executeProtocolV2SourceUpdate({
5789
6343
  installSources: [{ kind: 'firmware' }],
5790
6344
  resourceSources: [],
5791
6345
  });
5792
6346
 
5793
- expect(events).toEqual(['delete-stale-staging', 'transfer-component', 'reboot-normal']);
6347
+ expect((method as any).enterProtocolV2BootloaderMode).toHaveBeenCalledTimes(1);
6348
+ expect(events).toEqual(['delete-stale-staging', 'transfer-component', 'final-verify']);
5794
6349
  expect(typedCall).toHaveBeenNthCalledWith(2, 'FilesystemFileDelete', 'Success', {
5795
6350
  path: 'vol0:/loaders/bootloader/boot_resource.okpkg.staging',
5796
6351
  });
@@ -7137,6 +7692,9 @@ describe('Protocol V2 firmware update targets', () => {
7137
7692
  method.postProgressMessage = jest.fn();
7138
7693
  (method as any).protocolV2SourceUpdateProcess = jest.fn().mockResolvedValue(3);
7139
7694
  (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
7695
+ (method as any).ensureProtocolV2BootResourceStagingIsEmpty = jest
7696
+ .fn()
7697
+ .mockResolvedValue(undefined);
7140
7698
  (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
7141
7699
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
7142
7700
 
@@ -7263,6 +7821,86 @@ describe('Protocol V2 firmware update targets', () => {
7263
7821
  });
7264
7822
  });
7265
7823
 
7824
+ test('sends one monotonic device transfer progress range across multiple files', async () => {
7825
+ const method = new FirmwareUpdateV4({
7826
+ id: 1,
7827
+ payload: {
7828
+ method: 'firmwareUpdateV4',
7829
+ },
7830
+ });
7831
+ const typedCall = jest.fn(
7832
+ (
7833
+ _name: string,
7834
+ _resType: string,
7835
+ params: { file: { offset: number; data: { byteLength: number } } }
7836
+ ) =>
7837
+ Promise.resolve({
7838
+ type: 'FilesystemFile',
7839
+ message: {
7840
+ processed_byte: params.file.offset + params.file.data.byteLength,
7841
+ },
7842
+ })
7843
+ );
7844
+
7845
+ (method as any).device = stubDevice({
7846
+ getCommands: () => ({ typedCall }),
7847
+ getCurrentDeviceType: () => 'pro2',
7848
+ toMessageObject: () => ({ connectId: 'firmware-device' }),
7849
+ });
7850
+ method.postMessage = jest.fn();
7851
+ method.postTipMessage = jest.fn();
7852
+ method.postProgressMessage = jest.fn();
7853
+ (method as any).isProtocolV2ResourceBundleUpToDate = jest.fn().mockResolvedValue(false);
7854
+ (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
7855
+ (method as any).protocolV2StartFirmwareUpdate = jest.fn().mockResolvedValue(undefined);
7856
+ (method as any).waitForProtocolV2FirmwareUpdateComplete = jest
7857
+ .fn()
7858
+ .mockResolvedValue(undefined);
7859
+
7860
+ const resourceSource = await openFirmwareByteSource({
7861
+ binary: new Uint8Array(4097).buffer,
7862
+ });
7863
+ const firmwareSource = await openFirmwareByteSource({
7864
+ binary: new Uint8Array(4097).buffer,
7865
+ });
7866
+ try {
7867
+ await (method as any).executeProtocolV2TransferPhase({
7868
+ resourceSources: [
7869
+ {
7870
+ name: 'images.okpkg',
7871
+ source: resourceSource,
7872
+ devicePath: 'vol0:/resource/images/images.okpkg',
7873
+ },
7874
+ ],
7875
+ installSources: [
7876
+ {
7877
+ fileName: 'application_p1.bin',
7878
+ source: firmwareSource,
7879
+ targetId: 4,
7880
+ kind: 'firmware',
7881
+ },
7882
+ ],
7883
+ });
7884
+ } finally {
7885
+ await resourceSource?.close();
7886
+ await firmwareSource?.close();
7887
+ }
7888
+
7889
+ const writePayloads = typedCall.mock.calls.map(call => call[2]);
7890
+ const deviceProgress = writePayloads.map(payload => payload.ui_percentage);
7891
+ expect(deviceProgress).toEqual([0, 50, 99, 100]);
7892
+ expect(deviceProgress.filter(progress => progress === 0)).toHaveLength(1);
7893
+ expect(deviceProgress.filter(progress => progress === 100)).toHaveLength(1);
7894
+ expect(method.postTipMessage).toHaveBeenCalledTimes(2);
7895
+ expect(method.postTipMessage).toHaveBeenNthCalledWith(1, 'StartTransferData');
7896
+ expect(method.postTipMessage).toHaveBeenNthCalledWith(2, 'ConfirmOnDevice');
7897
+ expect(
7898
+ (method.postProgressMessage as jest.Mock).mock.calls.filter(
7899
+ ([progress, progressType]) => progress === 100 && progressType === 'transferData'
7900
+ )
7901
+ ).toHaveLength(1);
7902
+ });
7903
+
7266
7904
  // TODO(#850/#855): PR #855 added resume-on-retry and per-chunk retry on the
7267
7905
  // writeProtocolV2File path. PR #850 replaced that path with FirmwareByteSource
7268
7906
  // streaming (protocolV2SourceUpdateProcess), which restarts a failed transfer from