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

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 (74) 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__/device-state-mapper.test.ts +4 -4
  6. package/__tests__/device-utils.test.ts +1 -1
  7. package/__tests__/deviceSettings.test.ts +0 -1
  8. package/__tests__/deviceUploadNft.test.ts +33 -4
  9. package/__tests__/firmware-update/firmware-update-v4-install-poll.test.ts +125 -0
  10. package/__tests__/get-device-state.test.ts +60 -21
  11. package/__tests__/logBlockEvent.test.ts +13 -1
  12. package/__tests__/protocol-v2-resources.test.ts +8 -0
  13. package/__tests__/protocol-v2.test.ts +821 -227
  14. package/__tests__/refresh-device-state.test.ts +3 -1
  15. package/__tests__/resourceBase64Boundary.test.ts +48 -0
  16. package/__tests__/ton-sign-message.test.ts +84 -0
  17. package/dist/api/FirmwareUpdateV4.d.ts +7 -3
  18. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  19. package/dist/api/UploadPortfolio.d.ts +1 -1
  20. package/dist/api/UploadPortfolio.d.ts.map +1 -1
  21. package/dist/api/helpers/base64Data.d.ts +16 -0
  22. package/dist/api/helpers/base64Data.d.ts.map +1 -0
  23. package/dist/api/protocol-v2/DeviceInfoGet.d.ts +1 -1
  24. package/dist/api/protocol-v2/DeviceInfoGet.d.ts.map +1 -1
  25. package/dist/api/protocol-v2/DeviceUploadNft.d.ts +2 -3
  26. package/dist/api/protocol-v2/DeviceUploadNft.d.ts.map +1 -1
  27. package/dist/api/protocol-v2/DeviceUploadWallpaper.d.ts +2 -3
  28. package/dist/api/protocol-v2/DeviceUploadWallpaper.d.ts.map +1 -1
  29. package/dist/api/ton/TonSignMessage.d.ts.map +1 -1
  30. package/dist/core/index.d.ts.map +1 -1
  31. package/dist/device/Device.d.ts +7 -2
  32. package/dist/device/Device.d.ts.map +1 -1
  33. package/dist/device/DeviceCommands.d.ts.map +1 -1
  34. package/dist/device/DevicePool.d.ts.map +1 -1
  35. package/dist/events/logBlockEvent.d.ts.map +1 -1
  36. package/dist/index.d.ts +16 -34
  37. package/dist/index.js +506 -329
  38. package/dist/protocols/protocol-v2/features.d.ts +13 -5
  39. package/dist/protocols/protocol-v2/features.d.ts.map +1 -1
  40. package/dist/protocols/protocol-v2/index.d.ts +2 -2
  41. package/dist/protocols/protocol-v2/index.d.ts.map +1 -1
  42. package/dist/protocols/protocol-v2/resources.d.ts.map +1 -1
  43. package/dist/types/api/protocolV2.d.ts +1 -1
  44. package/dist/types/api/protocolV2.d.ts.map +1 -1
  45. package/dist/types/settings.d.ts +4 -19
  46. package/dist/types/settings.d.ts.map +1 -1
  47. package/dist/utils/patch.d.ts +1 -1
  48. package/dist/utils/patch.d.ts.map +1 -1
  49. package/dist/utils/pro2Nft.d.ts +7 -0
  50. package/dist/utils/pro2Nft.d.ts.map +1 -1
  51. package/package.json +6 -4
  52. package/src/api/FirmwareUpdateV4.ts +307 -245
  53. package/src/api/UploadPortfolio.ts +9 -2
  54. package/src/api/helpers/base64Data.ts +85 -0
  55. package/src/api/protocol-v2/DeviceFactoryInfoSet.ts +1 -1
  56. package/src/api/protocol-v2/DeviceInfoGet.ts +3 -3
  57. package/src/api/protocol-v2/DeviceUploadNft.ts +56 -8
  58. package/src/api/protocol-v2/DeviceUploadWallpaper.ts +37 -18
  59. package/src/api/ton/TonSignMessage.ts +5 -3
  60. package/src/core/index.ts +6 -2
  61. package/src/data/messages/messages-protocol-v2.json +31 -14
  62. package/src/device/Device.ts +53 -31
  63. package/src/device/DeviceCommands.ts +4 -14
  64. package/src/device/DevicePool.ts +6 -2
  65. package/src/device/DeviceStateMapper.ts +15 -15
  66. package/src/deviceProfile/buildDeviceFeatures.ts +3 -3
  67. package/src/events/logBlockEvent.ts +14 -1
  68. package/src/protocols/protocol-v2/features.ts +22 -5
  69. package/src/protocols/protocol-v2/index.ts +2 -0
  70. package/src/protocols/protocol-v2/resources.ts +9 -46
  71. package/src/types/api/protocolV2.ts +1 -1
  72. package/src/types/settings.ts +4 -19
  73. package/src/utils/deviceSettings.ts +1 -1
  74. 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 = {
@@ -555,7 +613,7 @@ const protocolV2BootloaderDeviceInfo: ProtocolV2DeviceInfo = {
555
613
  hw: {
556
614
  serial_no: 'PR9999999999',
557
615
  },
558
- fw: {
616
+ main_mcu: {
559
617
  bootloader: {
560
618
  version: '0.0.1',
561
619
  },
@@ -619,7 +677,7 @@ describe('Protocol V2 feature adapter', () => {
619
677
  const deviceInfo: ProtocolV2DeviceInfo = {
620
678
  protocol_version: 1,
621
679
  hw: { serial_no: 'P2-STATIC' },
622
- fw: { application: { version: '1.0.0' } },
680
+ main_mcu: { application: { version: '1.0.0' } },
623
681
  coprocessor: { bt_adv_name: 'Pro2 STATIC' },
624
682
  };
625
683
  const deviceStatus: DeviceStatus = {
@@ -693,7 +751,7 @@ describe('Protocol V2 feature adapter', () => {
693
751
  Device_type: DeviceType.PRO2,
694
752
  serial_no: 'PR2SERIAL',
695
753
  },
696
- fw: {
754
+ main_mcu: {
697
755
  romloader: {
698
756
  version: '0.1.0',
699
757
  build_id: 'rom-build',
@@ -783,7 +841,7 @@ describe('Protocol V2 feature adapter', () => {
783
841
  hw: {
784
842
  serial_no: 'PR2BOOT',
785
843
  },
786
- fw: {
844
+ main_mcu: {
787
845
  bootloader: {
788
846
  version: '0.2.0',
789
847
  },
@@ -805,7 +863,7 @@ describe('Protocol V2 feature adapter', () => {
805
863
  const features = normalizeProtocolV2Features(descriptor as any, {
806
864
  protocol_version: 1,
807
865
  hw: { serial_no: 'PR2NORMAL' },
808
- fw: { bootloader: { version: '0.2.0' } },
866
+ main_mcu: { bootloader: { version: '0.2.0' } },
809
867
  });
810
868
 
811
869
  expect(features.mode).toBe('normal');
@@ -818,7 +876,7 @@ describe('Protocol V2 feature adapter', () => {
818
876
  hw: {
819
877
  serial_no: 'PR2ROM',
820
878
  },
821
- fw: {
879
+ main_mcu: {
822
880
  romloader: {
823
881
  version: '1.0.0',
824
882
  },
@@ -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
+ main_mcu: { bootloader: { version: '0.2.0' } },
921
+ })
922
+ ).toBe('bootloader');
923
+ expect(
924
+ getProtocolV2RuntimeMode(legacyProtocolInfo, {
925
+ main_mcu: { romloader: { version: '1.0.0' } },
926
+ })
927
+ ).toBe('romloader');
928
+ expect(
929
+ getProtocolV2RuntimeMode(legacyProtocolInfo, {
930
+ main_mcu: {
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';
@@ -1912,7 +1998,7 @@ describe('Protocol V2 feature adapter', () => {
1912
1998
  { ...descriptor, protocolType: 'V2' } as any,
1913
1999
  {
1914
2000
  protocol_version: 2,
1915
- fw: { application: { version: '1.2.3' } },
2001
+ main_mcu: { application: { version: '1.2.3' } },
1916
2002
  se1: { application: { version: '4.5.6' } },
1917
2003
  status: { unlocked: false, passphrase_enabled: false },
1918
2004
  }
@@ -1949,7 +2035,7 @@ describe('Protocol V2 feature adapter', () => {
1949
2035
  deviceInfo: {
1950
2036
  protocol_version: 1,
1951
2037
  hw: { serial_no: 'PR9999999999' },
1952
- fw: { application: { version: '1.0.0' } },
2038
+ main_mcu: { application: { version: '1.0.0' } },
1953
2039
  },
1954
2040
  deviceStatus: {
1955
2041
  device_id: 'firmware-reconnect-id',
@@ -2314,7 +2400,7 @@ describe('Protocol V2 feature adapter', () => {
2314
2400
  (device as any).features = {
2315
2401
  ...normalizeProtocolV2Features({ ...descriptor, protocolType: 'V2' } as any, {
2316
2402
  hw: { serial_no: 'PR2SERIAL' },
2317
- fw: { application: { version: '4.15.0' } },
2403
+ main_mcu: { application: { version: '4.15.0' } },
2318
2404
  status: { passphrase_enabled: true },
2319
2405
  }),
2320
2406
  unlocked: true,
@@ -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',
@@ -2723,7 +2825,7 @@ describe('Protocol V2 feature adapter', () => {
2723
2825
  type: 'DeviceInfo',
2724
2826
  message: {
2725
2827
  hw: { Device_type: DeviceType.PRO2, serial_no: 'PR2SERIAL' },
2726
- fw: { application: { version: '1.2.3' } },
2828
+ main_mcu: { application: { version: '1.2.3' } },
2727
2829
  },
2728
2830
  }),
2729
2831
  };
@@ -2744,7 +2846,7 @@ describe('Protocol V2 feature adapter', () => {
2744
2846
  {
2745
2847
  targets: {
2746
2848
  hw: true,
2747
- fw: true,
2849
+ main_mcu: true,
2748
2850
  coprocessor: true,
2749
2851
  },
2750
2852
  types: {
@@ -2853,7 +2955,7 @@ describe('Protocol V2 feature adapter', () => {
2853
2955
  const device = stubDevice({
2854
2956
  originalDescriptor: { protocolType: 'V2' },
2855
2957
  features: normalizeProtocolV2Features({ ...descriptor, protocolType: 'V2' } as any, {
2856
- fw: {
2958
+ main_mcu: {
2857
2959
  application: {
2858
2960
  version: '4.14.0',
2859
2961
  },
@@ -2907,7 +3009,7 @@ describe('Protocol V2 feature adapter', () => {
2907
3009
  type: 'DeviceInfo',
2908
3010
  message: {
2909
3011
  hw: { Device_type: DeviceType.PRO2, serial_no: 'PR2SERIAL' },
2910
- fw: { application: { version: '1.2.3' } },
3012
+ main_mcu: { application: { version: '1.2.3' } },
2911
3013
  },
2912
3014
  })
2913
3015
  .mockResolvedValueOnce({
@@ -2942,7 +3044,7 @@ describe('Protocol V2 feature adapter', () => {
2942
3044
  {
2943
3045
  targets: {
2944
3046
  hw: true,
2945
- fw: true,
3047
+ main_mcu: true,
2946
3048
  coprocessor: true,
2947
3049
  },
2948
3050
  types: {
@@ -2971,7 +3073,7 @@ describe('Protocol V2 feature adapter', () => {
2971
3073
  type: 'DeviceInfo',
2972
3074
  message: {
2973
3075
  hw: { serial_no: 'PR2SERIAL' },
2974
- fw: { bootloader: { version: '0.2.0' } },
3076
+ main_mcu: { bootloader: { version: '0.2.0' } },
2975
3077
  se1: {},
2976
3078
  },
2977
3079
  })
@@ -3021,7 +3123,7 @@ describe('Protocol V2 feature adapter', () => {
3021
3123
 
3022
3124
  await device.probeProtocolV2RuntimeState({
3023
3125
  hw: { Device_type: DeviceType.PRO2, serial_no: 'PR2SERIAL' },
3024
- fw: { romloader: { version: '1.0.0' } },
3126
+ main_mcu: { romloader: { version: '1.0.0' } },
3025
3127
  });
3026
3128
 
3027
3129
  expect(typedCall).toHaveBeenCalledTimes(1);
@@ -3049,7 +3151,7 @@ describe('Protocol V2 feature adapter', () => {
3049
3151
  await expect(
3050
3152
  device.probeProtocolV2RuntimeState({
3051
3153
  hw: { Device_type: DeviceType.PRO, serial_no: 'PRO-SERIAL' },
3052
- fw: { romloader: { version: '1.0.0' } },
3154
+ main_mcu: { romloader: { version: '1.0.0' } },
3053
3155
  })
3054
3156
  ).rejects.toMatchObject({ errorCode: HardwareErrorCode.DeviceInitializeFailed });
3055
3157
 
@@ -3074,7 +3176,7 @@ describe('Protocol V2 feature adapter', () => {
3074
3176
 
3075
3177
  await device.probeProtocolV2RuntimeState({
3076
3178
  hw: { serial_no: 'PR2SERIAL' },
3077
- fw: { application: { version: '1.2.3' } },
3179
+ main_mcu: { application: { version: '1.2.3' } },
3078
3180
  });
3079
3181
 
3080
3182
  expect(typedCall).toHaveBeenCalledTimes(1);
@@ -3106,7 +3208,7 @@ describe('Protocol V2 feature adapter', () => {
3106
3208
 
3107
3209
  await device.probeProtocolV2RuntimeState({
3108
3210
  hw: { serial_no: 'PR2SERIAL' },
3109
- fw: { application: { version: '1.2.3' } },
3211
+ main_mcu: { application: { version: '1.2.3' } },
3110
3212
  });
3111
3213
 
3112
3214
  expect(typedCall).toHaveBeenCalledTimes(2);
@@ -3134,7 +3236,7 @@ describe('Protocol V2 feature adapter', () => {
3134
3236
 
3135
3237
  await device.probeProtocolV2RuntimeState({
3136
3238
  hw: { Device_type: DeviceType.PRO2, serial_no: 'PR2SERIAL' },
3137
- fw: { bootloader: { version: '0.2.0' } },
3239
+ main_mcu: { bootloader: { version: '0.2.0' } },
3138
3240
  });
3139
3241
 
3140
3242
  expect(device.features).toMatchObject({
@@ -3163,7 +3265,7 @@ describe('Protocol V2 feature adapter', () => {
3163
3265
  await expect(
3164
3266
  device.probeProtocolV2RuntimeState({
3165
3267
  hw: { Device_type: DeviceType.PRO2, serial_no: 'PR2SERIAL' },
3166
- fw: { application: { version: '1.2.3' } },
3268
+ main_mcu: { application: { version: '1.2.3' } },
3167
3269
  })
3168
3270
  ).rejects.toBe(linkError);
3169
3271
 
@@ -3188,12 +3290,165 @@ describe('Protocol V2 feature adapter', () => {
3188
3290
  await expect(
3189
3291
  device.probeProtocolV2RuntimeState({
3190
3292
  hw: { serial_no: 'PR2SERIAL' },
3191
- fw: { application: { version: '1.2.3' } },
3293
+ main_mcu: { application: { version: '1.2.3' } },
3192
3294
  })
3193
3295
  ).rejects.toMatchObject({ errorCode: HardwareErrorCode.DeviceInitializeFailed });
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
+ main_mcu: { 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
+ main_mcu: { 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
+ main_mcu: { 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
+ main_mcu: { 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',
@@ -3219,7 +3474,7 @@ describe('Protocol V2 feature adapter', () => {
3219
3474
  await expect(
3220
3475
  device.probeProtocolV2RuntimeState({
3221
3476
  hw: { serial_no: 'PR2SERIAL' },
3222
- fw: { application: { version: '1.0.0' } },
3477
+ main_mcu: { application: { version: '1.0.0' } },
3223
3478
  })
3224
3479
  ).rejects.toThrow('state update failed');
3225
3480
 
@@ -3244,7 +3499,7 @@ describe('Protocol V2 feature adapter', () => {
3244
3499
  type: 'DeviceInfo',
3245
3500
  message: {
3246
3501
  hw: { Device_type: DeviceType.PRO2, serial_no: 'PR2SERIAL' },
3247
- fw: { application: { version: '1.2.3' } },
3502
+ main_mcu: { application: { version: '1.2.3' } },
3248
3503
  },
3249
3504
  })
3250
3505
  .mockResolvedValueOnce({
@@ -3259,7 +3514,7 @@ describe('Protocol V2 feature adapter', () => {
3259
3514
  type: 'DeviceInfo',
3260
3515
  message: {
3261
3516
  hw: { Device_type: DeviceType.PRO2, serial_no: 'PR2SERIAL' },
3262
- fw: { application: { version: '1.2.4' } },
3517
+ main_mcu: { application: { version: '1.2.4' } },
3263
3518
  },
3264
3519
  })
3265
3520
  .mockResolvedValueOnce({
@@ -3291,7 +3546,7 @@ describe('Protocol V2 feature adapter', () => {
3291
3546
  {
3292
3547
  targets: {
3293
3548
  hw: true,
3294
- fw: true,
3549
+ main_mcu: true,
3295
3550
  coprocessor: true,
3296
3551
  },
3297
3552
  types: {
@@ -3312,7 +3567,7 @@ describe('Protocol V2 feature adapter', () => {
3312
3567
  type: 'DeviceInfo',
3313
3568
  message: {
3314
3569
  hw: { serial_no: 'PR2SERIAL' },
3315
- fw: { application: { version: '1.2.4' } },
3570
+ main_mcu: { application: { version: '1.2.4' } },
3316
3571
  },
3317
3572
  })
3318
3573
  .mockResolvedValueOnce({
@@ -3389,7 +3644,7 @@ describe('Protocol V2 feature adapter', () => {
3389
3644
  { ...descriptor, protocolType: 'V2' } as any,
3390
3645
  {
3391
3646
  hw: { Device_type: DeviceType.PRO2, serial_no: 'PR2SERIAL' },
3392
- fw: { application: { version: '1.2.3' } },
3647
+ main_mcu: { application: { version: '1.2.3' } },
3393
3648
  status: { init_states: true },
3394
3649
  }
3395
3650
  );
@@ -3429,7 +3684,7 @@ describe('Protocol V2 feature adapter', () => {
3429
3684
  { ...descriptor, protocolType: 'V2' } as any,
3430
3685
  {
3431
3686
  hw: { serial_no: 'PR2SERIAL' },
3432
- fw: { application: { version: '4.15.0' } },
3687
+ main_mcu: { application: { version: '4.15.0' } },
3433
3688
  status: { passphrase_enabled: false },
3434
3689
  }
3435
3690
  );
@@ -3476,7 +3731,7 @@ describe('Protocol V2 feature adapter', () => {
3476
3731
  (device as any).features = normalizeProtocolV2Features(
3477
3732
  { ...descriptor, protocolType: 'V2' } as any,
3478
3733
  {
3479
- fw: { application: { version: '1.2.3' } },
3734
+ main_mcu: { application: { version: '1.2.3' } },
3480
3735
  status: { unlocked: false },
3481
3736
  }
3482
3737
  );
@@ -3905,7 +4160,7 @@ describe('Protocol V2 firmware update targets', () => {
3905
4160
  type: 'DeviceInfo',
3906
4161
  message: {
3907
4162
  hw: { serial_no: 'BLE-PRO2-SERIAL' },
3908
- fw: {
4163
+ main_mcu: {
3909
4164
  bootloader: { version: '0.0.0' },
3910
4165
  application: { version: '0.0.0' },
3911
4166
  },
@@ -3957,7 +4212,7 @@ describe('Protocol V2 firmware update targets', () => {
3957
4212
  {
3958
4213
  targets: {
3959
4214
  hw: true,
3960
- fw: true,
4215
+ main_mcu: true,
3961
4216
  coprocessor: true,
3962
4217
  se1: true,
3963
4218
  se2: true,
@@ -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,57 +4391,183 @@ 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
- }),
4153
- });
4154
- (method as any).protocolV2Reboot = jest.fn().mockResolvedValue({
4155
- message: 'Device rebooted successfully',
4156
- });
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}`));
4425
+ isRomloader: () => false,
4172
4426
  });
4173
- const reconnectProtocolV2Device = jest.fn().mockImplementation(() => {
4174
- (method as any).device.isBootloader = () => true;
4175
- return Promise.resolve();
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],
4176
4438
  });
4177
- (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
4178
- (method as any).device.getCommands = () => ({ typedCall });
4179
- (method as any).protocolV2ExpectedSerialNumber = 'PR9999999999';
4180
- (method as any).protocolV2ExpectedPath = 'usb-path';
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');
4181
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,
4450
+ });
4451
+ const getSysResourceBinarySpy = jest
4452
+ .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
4453
+ .mockResolvedValue({ binary: resourceArchiveBinary });
4182
4454
 
4183
- await (method as any).enterProtocolV2BootloaderMode();
4455
+ await expect(method.run()).resolves.toBe('sdk-managed-resource-result');
4184
4456
 
4185
- expect(method.postTipMessage).toHaveBeenCalledWith('AutoRebootToBootloader');
4186
- expect((method as any).protocolV2Reboot).toHaveBeenCalledWith(DeviceRebootType.Bootloader);
4187
- expect(method.postTipMessage).toHaveBeenCalledWith('GoToBootloaderSuccess');
4188
- expect((method as any).checkDeviceToBootloader).not.toHaveBeenCalled();
4457
+ expect(forceReloadDataSpy).toHaveBeenCalledWith({
4458
+ requireResources: true,
4459
+ resourceDeviceType: EDeviceType.Pro2,
4460
+ });
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';
4562
+ (method as any).protocolV2ExpectedPath = 'usb-path';
4563
+ method.postTipMessage = jest.fn();
4564
+
4565
+ await (method as any).enterProtocolV2BootloaderMode();
4566
+
4567
+ expect(method.postTipMessage).toHaveBeenCalledWith('AutoRebootToBootloader');
4568
+ expect((method as any).protocolV2Reboot).toHaveBeenCalledWith(DeviceRebootType.Bootloader);
4569
+ expect(method.postTipMessage).toHaveBeenCalledWith('GoToBootloaderSuccess');
4570
+ expect((method as any).checkDeviceToBootloader).not.toHaveBeenCalled();
4189
4571
  expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
4190
4572
  });
4191
4573
 
@@ -4697,7 +5079,7 @@ describe('Protocol V2 firmware update targets', () => {
4697
5079
  type: 'DeviceInfo',
4698
5080
  message: {
4699
5081
  hw: { serial_no: 'PR2SERIAL' },
4700
- fw: { application: { version: '1.0.0' } },
5082
+ main_mcu: { application: { version: '1.0.0' } },
4701
5083
  },
4702
5084
  });
4703
5085
  (method as any).protocolV2ExpectedSerialNumber = 'expected-serial';
@@ -4824,59 +5206,88 @@ describe('Protocol V2 firmware update targets', () => {
4824
5206
  );
4825
5207
  });
4826
5208
 
4827
- test('requires an explicit Protocol V2 update ACK before entering install state', async () => {
5209
+ test('stages targets before sending the empty Protocol V2 install request', async () => {
4828
5210
  const method = new FirmwareUpdateV4({
4829
5211
  id: 1,
4830
5212
  payload: {
4831
5213
  method: 'firmwareUpdateV4',
4832
5214
  },
4833
5215
  });
4834
- const typedCall = jest
4835
- .fn()
4836
- .mockRejectedValue(
4837
- Object.assign(
4838
- new Error("Failed to execute 'open' on 'USBDevice': The device was disconnected"),
4839
- { name: 'NotFoundError' }
4840
- )
4841
- );
5216
+ const targets = [{ target_id: 4, path: 'vol1:firmware.bin' }];
5217
+ const typedCall = jest.fn().mockResolvedValue({ type: 'Success', message: {} });
5218
+ const call = jest.fn().mockResolvedValue({ type: 'WriteCompleted', message: {} });
4842
5219
 
4843
5220
  (method as any).device = stubDevice({
4844
- getCommands: () => ({ typedCall }),
5221
+ getCommands: () => ({ typedCall, call }),
5222
+ });
5223
+ method.postTipMessage = jest.fn();
5224
+ method.postProgressMessage = jest.fn();
5225
+
5226
+ await expect(
5227
+ (method as any).protocolV2StartFirmwareUpdate({ targets })
5228
+ ).resolves.toBeUndefined();
5229
+
5230
+ expect(typedCall).toHaveBeenCalledWith('DeviceFirmwareUpdateStage', 'Success', { targets });
5231
+ expect(call).toHaveBeenCalledWith(
5232
+ 'DeviceFirmwareUpdateRequest',
5233
+ {},
5234
+ { returnAfterWrite: true }
5235
+ );
5236
+ expect(typedCall.mock.invocationCallOrder[0]).toBeLessThan(call.mock.invocationCallOrder[0]);
5237
+ expect(method.postTipMessage).toHaveBeenCalledWith('FirmwareUpdating');
5238
+ expect(method.postProgressMessage).toHaveBeenCalledWith(0, 'installingFirmware');
5239
+ });
5240
+
5241
+ test('does not send the install request when Protocol V2 staging fails', async () => {
5242
+ const method = new FirmwareUpdateV4({
5243
+ id: 1,
5244
+ payload: {
5245
+ method: 'firmwareUpdateV4',
5246
+ },
5247
+ });
5248
+ const typedCall = jest.fn().mockRejectedValue(new Error('stage failed'));
5249
+ const call = jest.fn();
5250
+
5251
+ (method as any).device = stubDevice({
5252
+ getCommands: () => ({ typedCall, call }),
4845
5253
  });
4846
5254
  method.postTipMessage = jest.fn();
4847
5255
  method.postProgressMessage = jest.fn();
4848
5256
 
4849
5257
  await expect(
4850
5258
  (method as any).protocolV2StartFirmwareUpdate({
4851
- targets: [{ target_id: 4, path: 'vol1:firmware.bin' }],
5259
+ targets: [{ target_id: 4, path: 'vol1:application_p1.bin' }],
4852
5260
  })
4853
- ).rejects.toThrow();
5261
+ ).rejects.toThrow('stage failed');
4854
5262
 
5263
+ expect(call).not.toHaveBeenCalled();
4855
5264
  expect(method.postTipMessage).not.toHaveBeenCalled();
4856
5265
  expect(method.postProgressMessage).not.toHaveBeenCalled();
4857
5266
  });
4858
5267
 
4859
- test('does not poll Protocol V2 install status when the update ACK times out', async () => {
5268
+ test('does not enter install state when writing the empty request fails', async () => {
4860
5269
  const method = new FirmwareUpdateV4({
4861
5270
  id: 1,
4862
5271
  payload: {
4863
5272
  method: 'firmwareUpdateV4',
4864
5273
  },
4865
5274
  });
4866
- const typedCall = jest.fn().mockRejectedValue(new Error('LIBUSB_TRANSFER_TIMED_OUT'));
5275
+ const typedCall = jest.fn().mockResolvedValue({ type: 'Success', message: {} });
5276
+ const call = jest.fn().mockRejectedValue(new Error('write failed'));
4867
5277
 
4868
5278
  (method as any).device = stubDevice({
4869
- getCommands: () => ({ typedCall }),
5279
+ getCommands: () => ({ typedCall, call }),
4870
5280
  });
4871
5281
  method.postTipMessage = jest.fn();
4872
5282
  method.postProgressMessage = jest.fn();
4873
5283
 
4874
5284
  await expect(
4875
5285
  (method as any).protocolV2StartFirmwareUpdate({
4876
- targets: [{ target_id: 4, path: 'vol1:application_p1.bin' }],
5286
+ targets: [{ target_id: 4, path: 'vol0:/application_p1.bin' }],
4877
5287
  })
4878
- ).rejects.toThrow('LIBUSB_TRANSFER_TIMED_OUT');
5288
+ ).rejects.toThrow('write failed');
4879
5289
 
5290
+ expect(call).toHaveBeenCalledTimes(1);
4880
5291
  expect(method.postTipMessage).not.toHaveBeenCalled();
4881
5292
  expect(method.postProgressMessage).not.toHaveBeenCalled();
4882
5293
  });
@@ -4924,25 +5335,21 @@ describe('Protocol V2 firmware update targets', () => {
4924
5335
  expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
4925
5336
  });
4926
5337
 
4927
- test('accepts a missing firmware status handler only after confirming normal mode', async () => {
5338
+ test('accepts the terminal Success returned by the install request', async () => {
4928
5339
  const method = new FirmwareUpdateV4({
4929
5340
  id: 1,
4930
5341
  payload: {
4931
5342
  method: 'firmwareUpdateV4',
4932
5343
  },
4933
5344
  });
4934
- const typedCall = jest
4935
- .fn()
4936
- .mockRejectedValue(new Error('Failure: Handler not registered for this message'));
5345
+ const typedCall = jest.fn().mockResolvedValue({ type: 'Success', message: {} });
4937
5346
  const reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
4938
5347
 
4939
5348
  (method as any).device = stubDevice({
4940
5349
  getCommands: () => ({ typedCall }),
4941
5350
  });
4942
5351
  (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
4943
- const deviceInfo = { hw: { serial_no: 'PRO2-PHYSICAL-1' } };
4944
- (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(deviceInfo);
4945
- (method as any).probeProtocolV2NormalMode = jest.fn().mockResolvedValue(true);
5352
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(undefined);
4946
5353
  method.postProgressMessage = jest.fn();
4947
5354
 
4948
5355
  await (method as any).waitForProtocolV2FirmwareUpdateComplete([
@@ -4951,21 +5358,20 @@ describe('Protocol V2 firmware update targets', () => {
4951
5358
 
4952
5359
  expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
4953
5360
  expect(typedCall.mock.calls.map(call => call[0])).toEqual(['DeviceFirmwareUpdateStatusGet']);
4954
- expect((method as any).probeProtocolV2NormalMode).toHaveBeenCalledWith(deviceInfo);
5361
+ expect((method as any).protocolV2FinalStatusVerified).toBe(true);
4955
5362
  expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
4956
5363
  });
4957
5364
 
4958
- test('accepts an empty firmware status dump only after confirming normal mode', async () => {
5365
+ test('rejects normal mode without install ACK, target status, or a version change', async () => {
4959
5366
  const method = new FirmwareUpdateV4({
4960
5367
  id: 1,
4961
5368
  payload: {
4962
5369
  method: 'firmwareUpdateV4',
4963
5370
  },
4964
5371
  });
4965
- const typedCall = jest.fn().mockResolvedValue({
4966
- type: 'DeviceFirmwareUpdateStatus',
4967
- message: { records: [] },
4968
- });
5372
+ const typedCall = jest
5373
+ .fn()
5374
+ .mockRejectedValue(new Error('Failure: Handler not registered for this message'));
4969
5375
  const deviceInfo = { hw: { serial_no: 'PRO2-PHYSICAL-1' } };
4970
5376
  let now = 0;
4971
5377
  jest.spyOn(Date, 'now').mockImplementation(() => now);
@@ -4983,14 +5389,48 @@ describe('Protocol V2 firmware update targets', () => {
4983
5389
  (method as any).probeProtocolV2NormalMode = jest.fn().mockResolvedValue(true);
4984
5390
  method.postProgressMessage = jest.fn();
4985
5391
 
5392
+ await expect(
5393
+ (method as any).waitForProtocolV2FirmwareUpdateComplete([
5394
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
5395
+ ])
5396
+ ).rejects.toMatchObject({ errorCode: HardwareErrorCode.FirmwareError });
5397
+
5398
+ expect(method.postProgressMessage).not.toHaveBeenCalledWith(100, 'installingFirmware');
5399
+ });
5400
+
5401
+ test('accepts ACK-less normal mode after the requested target version changes', async () => {
5402
+ const method = new FirmwareUpdateV4({
5403
+ id: 1,
5404
+ payload: {
5405
+ method: 'firmwareUpdateV4',
5406
+ },
5407
+ });
5408
+ const typedCall = jest
5409
+ .fn()
5410
+ .mockRejectedValue(new Error('Failure: Handler not registered for this message'));
5411
+ const deviceInfo = { hw: { serial_no: 'PRO2-PHYSICAL-1' } };
5412
+ const probeProtocolV2RuntimeState = jest.fn().mockResolvedValue({
5413
+ mode: 'normal',
5414
+ bootloaderMode: false,
5415
+ firmwareVersion: '2.0.0',
5416
+ });
5417
+
5418
+ (method as any).device = stubDevice({
5419
+ getCommands: () => ({ typedCall }),
5420
+ probeProtocolV2RuntimeState,
5421
+ });
5422
+ (method as any).protocolV2InstallBaselineVersions = new Map([[4, '1.0.0']]);
5423
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
5424
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(deviceInfo);
5425
+ method.postProgressMessage = jest.fn();
5426
+
4986
5427
  await expect(
4987
5428
  (method as any).waitForProtocolV2FirmwareUpdateComplete([
4988
5429
  { target_id: 4, path: 'vol0:/application_p1.bin' },
4989
5430
  ])
4990
5431
  ).resolves.toBeUndefined();
4991
5432
 
4992
- expect(typedCall).toHaveBeenCalledTimes(1);
4993
- expect((method as any).probeProtocolV2NormalMode).toHaveBeenCalledWith(deviceInfo);
5433
+ expect(probeProtocolV2RuntimeState).toHaveBeenCalledWith(deviceInfo, 5000);
4994
5434
  expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
4995
5435
  });
4996
5436
 
@@ -5232,7 +5672,7 @@ describe('Protocol V2 firmware update targets', () => {
5232
5672
  );
5233
5673
  });
5234
5674
 
5235
- test('polls target status after update ACK and finishes when all targets complete', async () => {
5675
+ test('polls target status after the install request and finishes when all targets complete', async () => {
5236
5676
  const method = new FirmwareUpdateV4({
5237
5677
  id: 1,
5238
5678
  payload: {
@@ -5267,7 +5707,7 @@ describe('Protocol V2 firmware update targets', () => {
5267
5707
  expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
5268
5708
  expect(typedCall).toHaveBeenCalledWith(
5269
5709
  'DeviceFirmwareUpdateStatusGet',
5270
- 'DeviceFirmwareUpdateStatus',
5710
+ ['DeviceFirmwareUpdateStatus', 'Success'],
5271
5711
  expect.anything(),
5272
5712
  expect.anything()
5273
5713
  );
@@ -5543,7 +5983,7 @@ describe('Protocol V2 firmware update targets', () => {
5543
5983
  });
5544
5984
  expect(typedCall).toHaveBeenCalledWith(
5545
5985
  'DeviceFirmwareUpdateStatusGet',
5546
- 'DeviceFirmwareUpdateStatus',
5986
+ ['DeviceFirmwareUpdateStatus', 'Success'],
5547
5987
  {
5548
5988
  fields: {
5549
5989
  status: true,
@@ -5555,7 +5995,7 @@ describe('Protocol V2 firmware update targets', () => {
5555
5995
  );
5556
5996
  });
5557
5997
 
5558
- test('passes bootloader, coprocessor, SE and app files to DeviceFirmwareUpdate targets', async () => {
5998
+ test('preserves component-first target order for the firmware-owned loader handoff', async () => {
5559
5999
  const method = new FirmwareUpdateV4({
5560
6000
  id: 1,
5561
6001
  payload: {
@@ -5584,54 +6024,72 @@ describe('Protocol V2 firmware update targets', () => {
5584
6024
  .mockResolvedValue(undefined);
5585
6025
 
5586
6026
  await (method as any).executeProtocolV2Update({
5587
- bootloaderBinary: new Uint8Array([4, 5]).buffer,
5588
- fwBinaryMap: [
6027
+ // The active bootloader installs its component runners in records order and
6028
+ // leaves the bootloader record pending for romloader after reboot. Keep boot
6029
+ // deliberately after components to prove Core does not synthesize boot-first.
6030
+ installItems: [
5589
6031
  {
5590
- fileName: 'coprocessor.bin',
5591
- binary: new Uint8Array([6]).buffer,
5592
- targetId: 6,
6032
+ fileName: 'application_p1.bin',
6033
+ binary: new Uint8Array([8]).buffer,
6034
+ targetId: 4,
6035
+ kind: 'firmware',
5593
6036
  },
5594
6037
  {
5595
6038
  fileName: 'se01.bin',
5596
6039
  binary: new Uint8Array([7]).buffer,
5597
6040
  targetId: 7,
6041
+ kind: 'firmware',
5598
6042
  },
5599
6043
  {
5600
- fileName: 'application_p1.bin',
5601
- binary: new Uint8Array([8]).buffer,
5602
- targetId: 4,
6044
+ fileName: 'bootloader.bin',
6045
+ binary: new Uint8Array([4, 5]).buffer,
6046
+ targetId: 3,
6047
+ kind: 'bootloader',
6048
+ },
6049
+ {
6050
+ fileName: 'coprocessor.bin',
6051
+ binary: new Uint8Array([6]).buffer,
6052
+ targetId: 6,
6053
+ kind: 'firmware',
5603
6054
  },
5604
6055
  ],
5605
6056
  });
5606
6057
 
5607
6058
  expect(writtenPaths).toEqual([
6059
+ 'vol0:/application_p1.bin',
6060
+ 'vol0:/se01.bin',
5608
6061
  'vol0:/bootloader.bin',
5609
6062
  'vol0:/coprocessor.bin',
5610
- 'vol0:/se01.bin',
5611
- 'vol0:/application_p1.bin',
5612
6063
  ]);
5613
6064
  expect((method as any).verifyProtocolV2StagedFile).toHaveBeenCalledTimes(4);
5614
6065
  expect((method as any).verifyProtocolV2StagedFile).toHaveBeenNthCalledWith(
5615
6066
  2,
5616
- 'vol0:/coprocessor.bin',
6067
+ 'vol0:/se01.bin',
5617
6068
  1
5618
6069
  );
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, {
6070
+ expect((method as any).enterProtocolV2BootloaderMode).toHaveBeenCalledTimes(1);
6071
+ expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledTimes(1);
6072
+ expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledWith({
5624
6073
  targets: [
5625
- { target_id: 6, path: 'vol0:/coprocessor.bin' },
5626
- { target_id: 7, path: 'vol0:/se01.bin' },
5627
6074
  { target_id: 4, path: 'vol0:/application_p1.bin' },
6075
+ { target_id: 7, path: 'vol0:/se01.bin' },
6076
+ { target_id: 3, path: 'vol0:/bootloader.bin' },
6077
+ { target_id: 6, path: 'vol0:/coprocessor.bin' },
5628
6078
  ],
5629
6079
  });
6080
+ expect((method as any).waitForProtocolV2FirmwareUpdateComplete).toHaveBeenCalledTimes(1);
6081
+ expect((method as any).waitForProtocolV2FirmwareUpdateComplete).toHaveBeenCalledWith([
6082
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
6083
+ { target_id: 7, path: 'vol0:/se01.bin' },
6084
+ { target_id: 3, path: 'vol0:/bootloader.bin' },
6085
+ { target_id: 6, path: 'vol0:/coprocessor.bin' },
6086
+ ]);
6087
+ expect((method as any).exitProtocolV2BootloaderToNormal).not.toHaveBeenCalled();
5630
6088
  expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'transferData');
5631
- expect((method as any).waitForProtocolV2FirmwareUpdateComplete).toHaveBeenCalled();
6089
+ expect((method as any).completeProtocolV2FinalVerification).toHaveBeenCalledTimes(1);
5632
6090
  });
5633
6091
 
5634
- test('announces separate resource and component transfer phases', async () => {
6092
+ test('uses one global transfer range for resources and firmware', async () => {
5635
6093
  const method = new FirmwareUpdateV4({
5636
6094
  id: 1,
5637
6095
  payload: {
@@ -5688,18 +6146,24 @@ describe('Protocol V2 firmware update targets', () => {
5688
6146
  ],
5689
6147
  });
5690
6148
 
5691
- expect(method.postTipMessage).toHaveBeenCalledTimes(3);
6149
+ expect((method as any).enterProtocolV2BootloaderMode).toHaveBeenCalledTimes(1);
6150
+ expect(method.postTipMessage).toHaveBeenCalledTimes(2);
5692
6151
  expect(method.postTipMessage).toHaveBeenNthCalledWith(1, 'StartTransferData');
5693
- expect(method.postTipMessage).toHaveBeenNthCalledWith(2, 'StartTransferData');
5694
- expect(method.postTipMessage).toHaveBeenNthCalledWith(3, 'ConfirmOnDevice');
6152
+ expect(method.postTipMessage).toHaveBeenNthCalledWith(2, 'ConfirmOnDevice');
5695
6153
  expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenNthCalledWith(
5696
6154
  1,
5697
- expect.objectContaining({ processedSize: 0, totalSize: 2 })
6155
+ expect.objectContaining({ processedSize: 0, totalSize: 3 })
5698
6156
  );
5699
6157
  expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenNthCalledWith(
5700
6158
  2,
5701
- expect.objectContaining({ processedSize: 0, totalSize: 1 })
6159
+ expect.objectContaining({ processedSize: 2, totalSize: 3 })
5702
6160
  );
6161
+ expect(method.postProgressMessage).toHaveBeenCalledTimes(1);
6162
+ expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'transferData');
6163
+ expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledTimes(1);
6164
+ expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledWith({
6165
+ targets: [{ target_id: 4, path: 'vol0:/application_p1.bin' }],
6166
+ });
5703
6167
  expect((method as any).isProtocolV2ResourceBundleUpToDate).toHaveBeenCalledWith(
5704
6168
  expect.objectContaining({
5705
6169
  version: [1, 2, 3],
@@ -5709,50 +6173,7 @@ describe('Protocol V2 firmware update targets', () => {
5709
6173
  );
5710
6174
  });
5711
6175
 
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 () => {
6176
+ test('removes stale boot resource staging before the combined transfer', async () => {
5756
6177
  const method = new FirmwareUpdateV4({
5757
6178
  id: 1,
5758
6179
  payload: { method: 'firmwareUpdateV4' },
@@ -5779,18 +6200,18 @@ describe('Protocol V2 firmware update targets', () => {
5779
6200
  events.push('transfer-component');
5780
6201
  return Promise.resolve();
5781
6202
  });
5782
- (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockImplementation(() => {
5783
- events.push('reboot-normal');
5784
- return Promise.resolve();
6203
+ (method as any).completeProtocolV2FinalVerification = jest.fn().mockImplementation(() => {
6204
+ events.push('final-verify');
6205
+ return Promise.resolve({});
5785
6206
  });
5786
- (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
5787
6207
 
5788
6208
  await (method as any).executeProtocolV2SourceUpdate({
5789
6209
  installSources: [{ kind: 'firmware' }],
5790
6210
  resourceSources: [],
5791
6211
  });
5792
6212
 
5793
- expect(events).toEqual(['delete-stale-staging', 'transfer-component', 'reboot-normal']);
6213
+ expect((method as any).enterProtocolV2BootloaderMode).toHaveBeenCalledTimes(1);
6214
+ expect(events).toEqual(['delete-stale-staging', 'transfer-component', 'final-verify']);
5794
6215
  expect(typedCall).toHaveBeenNthCalledWith(2, 'FilesystemFileDelete', 'Success', {
5795
6216
  path: 'vol0:/loaders/bootloader/boot_resource.okpkg.staging',
5796
6217
  });
@@ -6762,9 +7183,10 @@ describe('Protocol V2 firmware update targets', () => {
6762
7183
  ],
6763
7184
  };
6764
7185
  const zip = new JSZip();
6765
- zip.file('manifest.json', JSON.stringify(manifest));
6766
- zip.file('bundles/images/images.okpkg', imagesBinary);
6767
- zip.file('loaders/bootloader/boot_resource.okpkg', bootResourceBinary);
7186
+ zip.file('pro2-resource/manifest.json', JSON.stringify(manifest));
7187
+ zip.file('pro2-resource/bundles/images/images.okpkg', imagesBinary);
7188
+ zip.file('pro2-resource/loaders/bootloader/boot_resource.okpkg', bootResourceBinary);
7189
+ zip.file('pro2-resource/build-info.txt', 'ignored');
6768
7190
  const resourceArchiveBinary = await zip.generateAsync({ type: 'arraybuffer' });
6769
7191
  const applicationP1Binary = new Uint8Array([7, 8, 9]).buffer;
6770
7192
  const bootloaderBinary = new Uint8Array([10, 11, 12]).buffer;
@@ -6840,6 +7262,39 @@ describe('Protocol V2 firmware update targets', () => {
6840
7262
  }
6841
7263
  });
6842
7264
 
7265
+ test('accepts a local resource ZIP without manifest.json', async () => {
7266
+ const imagesBinary = new Uint8Array([1, 2, 3]).buffer;
7267
+ const bootResourceBinary = new Uint8Array([4, 5, 6]).buffer;
7268
+ const zip = new JSZip();
7269
+ zip.file('pro2-resource/bundles/images/images.okpkg', imagesBinary);
7270
+ zip.file('pro2-resource/loaders/bootloader/boot_resource.okpkg', bootResourceBinary);
7271
+ zip.file('pro2-resource/build-info.txt', 'ignored');
7272
+ const method = new FirmwareUpdateV4({
7273
+ id: 1,
7274
+ payload: {
7275
+ method: 'firmwareUpdateV4',
7276
+ platform: 'web',
7277
+ targetsToUpdate: ['resource'],
7278
+ },
7279
+ });
7280
+ method.init();
7281
+
7282
+ await expect(
7283
+ (method as any).prepareProtocolV2LocalResourceArchive(
7284
+ await zip.generateAsync({ type: 'arraybuffer' })
7285
+ )
7286
+ ).resolves.toMatchObject({
7287
+ materializedEntries: [
7288
+ { entryName: 'manifest.json' },
7289
+ { entryName: 'bundles/images/images.okpkg', binary: imagesBinary },
7290
+ {
7291
+ entryName: 'loaders/bootloader/boot_resource.okpkg',
7292
+ binary: bootResourceBinary,
7293
+ },
7294
+ ],
7295
+ });
7296
+ });
7297
+
6843
7298
  test('rejects a local resource ZIP whose file hash does not match its manifest', async () => {
6844
7299
  const resourceBinary = new Uint8Array([1, 2, 3]).buffer;
6845
7300
  const bootResourceBinary = new Uint8Array([4, 5, 6]).buffer;
@@ -6930,18 +7385,57 @@ describe('Protocol V2 firmware update targets', () => {
6930
7385
 
6931
7386
  test('rejects an oversized ZIP entry before allocating its decompressed bytes', async () => {
6932
7387
  const extractEntry = jest.fn();
6933
- const loadSpy = jest.spyOn(JSZip, 'loadAsync').mockResolvedValue({
6934
- files: {
6935
- 'manifest.json': {
6936
- name: 'manifest.json',
6937
- dir: false,
6938
- _data: {
6939
- compressedSize: 1,
6940
- uncompressedSize: 2 * 1024 * 1024,
7388
+ const oversizedFileSize = 257 * 1024 * 1024;
7389
+ const manifestBinary = new TextEncoder().encode(
7390
+ JSON.stringify({
7391
+ files: [
7392
+ {
7393
+ archive_path: 'loaders/bootloader/boot_resource.okpkg',
7394
+ original_name: 'boot_resource.okpkg',
7395
+ device_path: 'vol0:/loaders/bootloader/boot_resource.okpkg',
7396
+ size: oversizedFileSize,
7397
+ sha256: '0'.repeat(64),
7398
+ signed: true,
7399
+ sig_algo: 'ed25519',
6941
7400
  },
6942
- async: extractEntry,
7401
+ {
7402
+ archive_path: 'bundles/images/images.okpkg',
7403
+ original_name: 'images.okpkg',
7404
+ device_path: 'vol0:/bundles/images/images.okpkg',
7405
+ size: oversizedFileSize,
7406
+ sha256: '0'.repeat(64),
7407
+ signed: true,
7408
+ sig_algo: 'ed25519',
7409
+ },
7410
+ ],
7411
+ })
7412
+ );
7413
+ const zipFiles = {
7414
+ 'manifest.json': {
7415
+ name: 'manifest.json',
7416
+ dir: false,
7417
+ _data: {
7418
+ compressedSize: manifestBinary.byteLength,
7419
+ uncompressedSize: manifestBinary.byteLength,
6943
7420
  },
7421
+ async: jest.fn().mockResolvedValue(manifestBinary.buffer),
7422
+ },
7423
+ 'loaders/bootloader/boot_resource.okpkg': {
7424
+ name: 'loaders/bootloader/boot_resource.okpkg',
7425
+ dir: false,
7426
+ _data: { compressedSize: 1, uncompressedSize: oversizedFileSize },
7427
+ async: extractEntry,
6944
7428
  },
7429
+ 'bundles/images/images.okpkg': {
7430
+ name: 'bundles/images/images.okpkg',
7431
+ dir: false,
7432
+ _data: { compressedSize: 1, uncompressedSize: oversizedFileSize },
7433
+ async: extractEntry,
7434
+ },
7435
+ };
7436
+ const loadSpy = jest.spyOn(JSZip, 'loadAsync').mockResolvedValue({
7437
+ files: zipFiles,
7438
+ file: (name: string) => zipFiles[name as keyof typeof zipFiles] ?? null,
6945
7439
  } as unknown as JSZip);
6946
7440
  const method = new FirmwareUpdateV4({
6947
7441
  id: 1,
@@ -6956,7 +7450,7 @@ describe('Protocol V2 firmware update targets', () => {
6956
7450
  try {
6957
7451
  await expect(
6958
7452
  (method as any).prepareProtocolV2LocalResourceArchive(new Uint8Array([1]).buffer)
6959
- ).rejects.toThrow('declared size exceeds the allowed limit');
7453
+ ).rejects.toThrow('declared size is invalid');
6960
7454
  expect(extractEntry).not.toHaveBeenCalled();
6961
7455
  } finally {
6962
7456
  loadSpy.mockRestore();
@@ -7137,6 +7631,9 @@ describe('Protocol V2 firmware update targets', () => {
7137
7631
  method.postProgressMessage = jest.fn();
7138
7632
  (method as any).protocolV2SourceUpdateProcess = jest.fn().mockResolvedValue(3);
7139
7633
  (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
7634
+ (method as any).ensureProtocolV2BootResourceStagingIsEmpty = jest
7635
+ .fn()
7636
+ .mockResolvedValue(undefined);
7140
7637
  (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
7141
7638
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
7142
7639
 
@@ -7263,6 +7760,86 @@ describe('Protocol V2 firmware update targets', () => {
7263
7760
  });
7264
7761
  });
7265
7762
 
7763
+ test('sends one monotonic device transfer progress range across multiple files', async () => {
7764
+ const method = new FirmwareUpdateV4({
7765
+ id: 1,
7766
+ payload: {
7767
+ method: 'firmwareUpdateV4',
7768
+ },
7769
+ });
7770
+ const typedCall = jest.fn(
7771
+ (
7772
+ _name: string,
7773
+ _resType: string,
7774
+ params: { file: { offset: number; data: { byteLength: number } } }
7775
+ ) =>
7776
+ Promise.resolve({
7777
+ type: 'FilesystemFile',
7778
+ message: {
7779
+ processed_byte: params.file.offset + params.file.data.byteLength,
7780
+ },
7781
+ })
7782
+ );
7783
+
7784
+ (method as any).device = stubDevice({
7785
+ getCommands: () => ({ typedCall }),
7786
+ getCurrentDeviceType: () => 'pro2',
7787
+ toMessageObject: () => ({ connectId: 'firmware-device' }),
7788
+ });
7789
+ method.postMessage = jest.fn();
7790
+ method.postTipMessage = jest.fn();
7791
+ method.postProgressMessage = jest.fn();
7792
+ (method as any).isProtocolV2ResourceBundleUpToDate = jest.fn().mockResolvedValue(false);
7793
+ (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
7794
+ (method as any).protocolV2StartFirmwareUpdate = jest.fn().mockResolvedValue(undefined);
7795
+ (method as any).waitForProtocolV2FirmwareUpdateComplete = jest
7796
+ .fn()
7797
+ .mockResolvedValue(undefined);
7798
+
7799
+ const resourceSource = await openFirmwareByteSource({
7800
+ binary: new Uint8Array(4097).buffer,
7801
+ });
7802
+ const firmwareSource = await openFirmwareByteSource({
7803
+ binary: new Uint8Array(4097).buffer,
7804
+ });
7805
+ try {
7806
+ await (method as any).executeProtocolV2TransferPhase({
7807
+ resourceSources: [
7808
+ {
7809
+ name: 'images.okpkg',
7810
+ source: resourceSource,
7811
+ devicePath: 'vol0:/resource/images/images.okpkg',
7812
+ },
7813
+ ],
7814
+ installSources: [
7815
+ {
7816
+ fileName: 'application_p1.bin',
7817
+ source: firmwareSource,
7818
+ targetId: 4,
7819
+ kind: 'firmware',
7820
+ },
7821
+ ],
7822
+ });
7823
+ } finally {
7824
+ await resourceSource?.close();
7825
+ await firmwareSource?.close();
7826
+ }
7827
+
7828
+ const writePayloads = typedCall.mock.calls.map(call => call[2]);
7829
+ const deviceProgress = writePayloads.map(payload => payload.ui_percentage);
7830
+ expect(deviceProgress).toEqual([0, 50, 99, 100]);
7831
+ expect(deviceProgress.filter(progress => progress === 0)).toHaveLength(1);
7832
+ expect(deviceProgress.filter(progress => progress === 100)).toHaveLength(1);
7833
+ expect(method.postTipMessage).toHaveBeenCalledTimes(2);
7834
+ expect(method.postTipMessage).toHaveBeenNthCalledWith(1, 'StartTransferData');
7835
+ expect(method.postTipMessage).toHaveBeenNthCalledWith(2, 'ConfirmOnDevice');
7836
+ expect(
7837
+ (method.postProgressMessage as jest.Mock).mock.calls.filter(
7838
+ ([progress, progressType]) => progress === 100 && progressType === 'transferData'
7839
+ )
7840
+ ).toHaveLength(1);
7841
+ });
7842
+
7266
7843
  // TODO(#850/#855): PR #855 added resume-on-retry and per-chunk retry on the
7267
7844
  // writeProtocolV2File path. PR #850 replaced that path with FirmwareByteSource
7268
7845
  // streaming (protocolV2SourceUpdateProcess), which restarts a failed transfer from
@@ -7629,23 +8206,24 @@ describe('Protocol V2 firmware update targets', () => {
7629
8206
  expect(writePayloads.map(payload => payload.file.data.byteLength)).toEqual([1960, 1]);
7630
8207
  });
7631
8208
 
7632
- test('ends device confirmation and starts install progress only after Protocol V2 ACK', async () => {
8209
+ test('starts install progress only after Stage and the empty Request are written', async () => {
7633
8210
  const method = new FirmwareUpdateV4({
7634
8211
  id: 1,
7635
8212
  payload: {
7636
8213
  method: 'firmwareUpdateV4',
7637
8214
  },
7638
8215
  });
7639
- let resolveRequest: ((value: unknown) => void) | undefined;
7640
- const typedCall = jest.fn().mockImplementation(
8216
+ let resolveWrite: ((value: unknown) => void) | undefined;
8217
+ const typedCall = jest.fn().mockResolvedValue({ type: 'Success', message: {} });
8218
+ const call = jest.fn().mockImplementation(
7641
8219
  () =>
7642
8220
  new Promise(resolve => {
7643
- resolveRequest = resolve;
8221
+ resolveWrite = resolve;
7644
8222
  })
7645
8223
  );
7646
8224
 
7647
8225
  (method as any).device = stubDevice({
7648
- getCommands: () => ({ typedCall }),
8226
+ getCommands: () => ({ typedCall, call }),
7649
8227
  });
7650
8228
  method.postProgressMessage = jest.fn();
7651
8229
  method.postTipMessage = jest.fn();
@@ -7653,21 +8231,31 @@ describe('Protocol V2 firmware update targets', () => {
7653
8231
  const startPromise = (method as any).protocolV2StartFirmwareUpdate({
7654
8232
  targets: [{ target_id: 4, path: 'vol1:firmware.bin' }],
7655
8233
  });
7656
- await Promise.resolve();
8234
+ await new Promise<void>(resolve => {
8235
+ setImmediate(resolve);
8236
+ });
7657
8237
 
7658
- expect(typedCall.mock.calls[0][1]).toBe('Success');
7659
- expect(typedCall.mock.calls[0][3]).toEqual(expect.objectContaining({ timeoutMs: 180_000 }));
8238
+ expect(typedCall.mock.calls[0]).toEqual([
8239
+ 'DeviceFirmwareUpdateStage',
8240
+ 'Success',
8241
+ { targets: [{ target_id: 4, path: 'vol1:firmware.bin' }] },
8242
+ ]);
8243
+ expect(call).toHaveBeenCalledWith(
8244
+ 'DeviceFirmwareUpdateRequest',
8245
+ {},
8246
+ { returnAfterWrite: true }
8247
+ );
7660
8248
  expect(method.postTipMessage).not.toHaveBeenCalled();
7661
8249
  expect(method.postProgressMessage).not.toHaveBeenCalled();
7662
8250
 
7663
- resolveRequest?.({ type: 'Success', message: { message: 'ok' } });
8251
+ resolveWrite?.({ type: 'WriteCompleted', message: {} });
7664
8252
  await startPromise;
7665
8253
  expect(method.postTipMessage).toHaveBeenCalledWith('FirmwareUpdating');
7666
8254
  expect(method.postProgressMessage).toHaveBeenCalledWith(0, 'installingFirmware');
7667
8255
  expect(method.postTipMessage).toHaveBeenCalledTimes(1);
7668
8256
  });
7669
8257
 
7670
- test('requests only a Success ACK for Protocol V2 firmware install', async () => {
8258
+ test('sends an empty Protocol V2 firmware install request without waiting for its response', async () => {
7671
8259
  const method = new FirmwareUpdateV4({
7672
8260
  id: 1,
7673
8261
  payload: {
@@ -7678,9 +8266,10 @@ describe('Protocol V2 firmware update targets', () => {
7678
8266
  type: 'Success',
7679
8267
  message: { message: 'accepted' },
7680
8268
  });
8269
+ const call = jest.fn().mockResolvedValue({ type: 'WriteCompleted', message: {} });
7681
8270
 
7682
8271
  (method as any).device = stubDevice({
7683
- getCommands: () => ({ typedCall }),
8272
+ getCommands: () => ({ typedCall, call }),
7684
8273
  });
7685
8274
  method.postProgressMessage = jest.fn();
7686
8275
  method.postTipMessage = jest.fn();
@@ -7689,7 +8278,12 @@ describe('Protocol V2 firmware update targets', () => {
7689
8278
  targets: [{ target_id: 4, path: 'vol1:firmware.bin' }],
7690
8279
  });
7691
8280
 
7692
- expect(typedCall.mock.calls[0][1]).toBe('Success');
8281
+ expect(typedCall.mock.calls[0][0]).toBe('DeviceFirmwareUpdateStage');
8282
+ expect(call).toHaveBeenCalledWith(
8283
+ 'DeviceFirmwareUpdateRequest',
8284
+ {},
8285
+ { returnAfterWrite: true }
8286
+ );
7693
8287
  expect(method.postTipMessage).toHaveBeenCalledWith('FirmwareUpdating');
7694
8288
  });
7695
8289
  });
@@ -8763,7 +9357,7 @@ describe('Protocol V2 current low-level methods', () => {
8763
9357
  info: {
8764
9358
  version: undefined,
8765
9359
  serial_number: 'PR2SERIAL',
8766
- burn_in_completed: true,
9360
+ factory_burn_in_completed: true,
8767
9361
  factory_test_completed: undefined,
8768
9362
  manufacture_time: undefined,
8769
9363
  },
@@ -9013,7 +9607,7 @@ describe('Protocol V2 raw device info method', () => {
9013
9607
  'DeviceInfoGet',
9014
9608
  'DeviceInfo',
9015
9609
  {
9016
- targets: { hw: true, fw: true, coprocessor: true },
9610
+ targets: { hw: true, main_mcu: true, coprocessor: true },
9017
9611
  types: { version: true, specific: true },
9018
9612
  },
9019
9613
  { timeoutMs: PROTOCOL_V2_DEVICE_INFO_TIMEOUT_MS }