@onekeyfe/hd-core 1.2.0-alpha.104 → 1.2.0-alpha.106

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 (46) hide show
  1. package/__tests__/DeviceCommands.test.ts +100 -28
  2. package/__tests__/check-all-firmware-release-protocol-v2.test.ts +0 -2
  3. package/__tests__/device-lifecycle-events.test.ts +113 -2
  4. package/__tests__/device-pool-state.test.ts +1 -5
  5. package/__tests__/deviceSettings.test.ts +0 -1
  6. package/__tests__/firmware-update/firmware-update-v4-install-poll.test.ts +145 -0
  7. package/__tests__/get-device-state.test.ts +0 -50
  8. package/__tests__/logBlockEvent.test.ts +13 -1
  9. package/__tests__/protocol-v2.test.ts +466 -126
  10. package/__tests__/refresh-device-state.test.ts +0 -1
  11. package/__tests__/search-devices.test.ts +0 -2
  12. package/dist/api/FirmwareUpdateV4.d.ts +7 -3
  13. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  14. package/dist/api/GetDeviceState.d.ts.map +1 -1
  15. package/dist/api/SearchDevices.d.ts.map +1 -1
  16. package/dist/api/firmware/protocolV2Release.d.ts.map +1 -1
  17. package/dist/core/index.d.ts.map +1 -1
  18. package/dist/device/Device.d.ts +1 -3
  19. package/dist/device/Device.d.ts.map +1 -1
  20. package/dist/device/DeviceCommands.d.ts.map +1 -1
  21. package/dist/device/DevicePool.d.ts +1 -1
  22. package/dist/device/DevicePool.d.ts.map +1 -1
  23. package/dist/events/logBlockEvent.d.ts.map +1 -1
  24. package/dist/index.d.ts +1 -6
  25. package/dist/index.js +260 -196
  26. package/dist/protocols/protocol-v2/features.d.ts +1 -1
  27. package/dist/protocols/protocol-v2/features.d.ts.map +1 -1
  28. package/dist/types/api/getDeviceState.d.ts +0 -1
  29. package/dist/types/api/getDeviceState.d.ts.map +1 -1
  30. package/package.json +4 -4
  31. package/src/api/FirmwareUpdateV4.ts +262 -172
  32. package/src/api/GetDeviceState.ts +0 -1
  33. package/src/api/SearchDevices.ts +0 -2
  34. package/src/api/firmware/protocolV2Release.ts +0 -1
  35. package/src/core/index.ts +6 -6
  36. package/src/device/Device.ts +27 -63
  37. package/src/device/DeviceCommands.ts +4 -14
  38. package/src/device/DevicePool.ts +3 -11
  39. package/src/events/logBlockEvent.ts +14 -1
  40. package/src/protocols/protocol-v2/features.ts +9 -2
  41. package/src/types/api/getDeviceState.ts +0 -2
  42. package/src/utils/deviceSettings.ts +1 -1
  43. package/__tests__/protocol-v2-legacy-recovery.test.ts +0 -29
  44. package/dist/core/protocolV2LegacyRecovery.d.ts +0 -5
  45. package/dist/core/protocolV2LegacyRecovery.d.ts.map +0 -1
  46. package/src/core/protocolV2LegacyRecovery.ts +0 -12
@@ -1,6 +1,5 @@
1
1
  import { Buffer } from 'buffer';
2
-
3
- import { EFirmwareType, ERRORS, HardwareErrorCode } from '@onekeyfe/hd-shared';
2
+ import { EDeviceType, EFirmwareType, ERRORS, HardwareErrorCode } from '@onekeyfe/hd-shared';
4
3
  import { sha256 } from '@noble/hashes/sha256';
5
4
  import { bytesToHex } from '@noble/hashes/utils';
6
5
  import JSZip from 'jszip';
@@ -908,6 +907,34 @@ describe('Protocol V2 feature adapter', () => {
908
907
  ).toBeUndefined();
909
908
  });
910
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
+
911
938
  test('keeps the Protocol V2 main wallet on the default empty-passphrase context', async () => {
912
939
  const features = normalizeProtocolV2Features(descriptor as any);
913
940
  features.firmwareVersion = '1.2.3';
@@ -2756,19 +2783,35 @@ describe('Protocol V2 feature adapter', () => {
2756
2783
  });
2757
2784
  });
2758
2785
 
2759
- 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 });
2760
2788
  const cached = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
2761
2789
  (cached as any).features = {
2762
2790
  ...normalizeProtocolV2Features({ ...descriptor, protocolType: 'V2' } as any),
2763
2791
  deviceId: null,
2764
2792
  serialNo: 'CACHED-SERIAL',
2765
2793
  };
2794
+ (cached as any).commands = { typedCall };
2766
2795
 
2767
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
+ );
2768
2804
  current.updateFromCache(cached);
2769
2805
 
2806
+ await expect(current.ensureProtocolV2RuntimeContext()).resolves.toEqual(
2807
+ protocolV2ApplicationInfo
2808
+ );
2809
+
2770
2810
  expect(current.getCurrentDeviceId()).toBeUndefined();
2771
2811
  expect(current.getCurrentSerialNo()).toBe('CACHED-SERIAL');
2812
+ expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
2813
+ eventless_wallet_session: true,
2814
+ });
2772
2815
  expect(current.toMessageObject()).toMatchObject({
2773
2816
  serialNo: 'CACHED-SERIAL',
2774
2817
  uuid: 'CACHED-SERIAL',
@@ -3253,33 +3296,43 @@ describe('Protocol V2 feature adapter', () => {
3253
3296
  expect(typedCall).toHaveBeenCalledTimes(1);
3254
3297
  });
3255
3298
 
3256
- test('rejects a cached legacy ProtocolInfo outside discovery and firmware recovery', async () => {
3299
+ test('initializes a legacy Protocol V2 bootloader without compatibility options', async () => {
3257
3300
  const device = Device.fromDescriptor({
3258
3301
  path: 'usb-path',
3259
3302
  protocolType: 'V2',
3260
3303
  } as any);
3261
- const typedCall = jest.fn().mockResolvedValue({
3262
- type: 'ProtocolInfo',
3263
- message: {
3264
- version: 1,
3265
- build_fingerprint: '',
3266
- supported_messages: [],
3267
- protobuf_definition: null,
3268
- },
3269
- });
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
+ });
3270
3322
  (device as any).commands = { typedCall };
3271
3323
 
3272
- await expect(
3273
- device.probeProtocolV2RuntimeState({
3274
- hw: { Device_type: DeviceType.PRO2, serial_no: 'PR2SERIAL' },
3275
- fw: { application: { version: '1.2.3' } },
3276
- })
3277
- ).rejects.toMatchObject({ errorCode: HardwareErrorCode.DeviceInitializeFailed });
3324
+ await device.initialize();
3278
3325
 
3279
- expect(typedCall).toHaveBeenCalledTimes(1);
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
+ });
3280
3333
  });
3281
3334
 
3282
- test('uses DeviceStatusGet to identify App mode for an allowed legacy ProtocolInfo', async () => {
3335
+ test('uses DeviceStatusGet to identify App mode for legacy ProtocolInfo', async () => {
3283
3336
  const device = Device.fromDescriptor({
3284
3337
  path: 'usb-path',
3285
3338
  protocolType: 'V2',
@@ -3306,8 +3359,7 @@ describe('Protocol V2 feature adapter', () => {
3306
3359
  hw: { Device_type: DeviceType.PRO2, serial_no: 'PR2SERIAL' },
3307
3360
  fw: { application: { version: '1.2.3' } },
3308
3361
  },
3309
- 5000,
3310
- { allowLegacyProtocolV2ProtocolInfo: true }
3362
+ 5000
3311
3363
  );
3312
3364
 
3313
3365
  expect(typedCall).toHaveBeenNthCalledWith(
@@ -3332,38 +3384,32 @@ describe('Protocol V2 feature adapter', () => {
3332
3384
  });
3333
3385
  });
3334
3386
 
3335
- test('maps an allowed legacy loader to bootloader only after an explicit unsupported status', async () => {
3387
+ test('maps legacy bootloader metadata without calling DeviceStatusGet', async () => {
3336
3388
  const device = Device.fromDescriptor({
3337
3389
  path: 'usb-path',
3338
3390
  protocolType: 'V2',
3339
3391
  } 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
- .mockRejectedValueOnce(new Error('Failure_UnexpectedMessage,Unknown message'));
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
+ });
3352
3401
  (device as any).commands = { typedCall };
3353
3402
 
3354
- await device.probeProtocolV2RuntimeState(
3355
- {
3356
- hw: { Device_type: DeviceType.NEO, serial_no: 'NEOSERIAL' },
3357
- fw: { bootloader: { version: '0.2.0' } },
3358
- },
3359
- undefined,
3360
- { allowLegacyProtocolV2ProtocolInfo: true }
3361
- );
3403
+ await device.probeProtocolV2RuntimeState({
3404
+ hw: { Device_type: DeviceType.NEO, serial_no: 'NEOSERIAL' },
3405
+ fw: { bootloader: { version: '0.2.0' } },
3406
+ });
3362
3407
 
3363
3408
  expect(typedCall).toHaveBeenNthCalledWith(1, 'ProtocolInfoRequest', 'ProtocolInfo', {
3364
3409
  eventless_wallet_session: true,
3365
3410
  });
3366
- expect(typedCall).toHaveBeenNthCalledWith(2, 'DeviceStatusGet', 'DeviceStatus', {});
3411
+ expect(typedCall).toHaveBeenCalledTimes(1);
3412
+ expect(typedCall).not.toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
3367
3413
  expect(device.features).toMatchObject({
3368
3414
  deviceType: 'neo',
3369
3415
  mode: 'bootloader',
@@ -3372,6 +3418,37 @@ describe('Protocol V2 feature adapter', () => {
3372
3418
  });
3373
3419
  });
3374
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
+
3375
3452
  test('does not reinterpret a state update error as runtime detection failure', async () => {
3376
3453
  const device = Device.fromDescriptor({
3377
3454
  path: 'usb-path',
@@ -4276,7 +4353,7 @@ describe('Protocol V2 firmware update targets', () => {
4276
4353
  ).toEqual([3, 4]);
4277
4354
  });
4278
4355
 
4279
- 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 () => {
4280
4357
  const method = new FirmwareUpdateV4({
4281
4358
  id: 1,
4282
4359
  payload: {
@@ -4286,6 +4363,7 @@ describe('Protocol V2 firmware update targets', () => {
4286
4363
  },
4287
4364
  });
4288
4365
  method.init();
4366
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue('external-only' as never);
4289
4367
 
4290
4368
  (method as any).device = stubDevice({
4291
4369
  originalDescriptor: { id: 'usb-id', path: 'app-path', protocolType: 'V2' },
@@ -4313,6 +4391,132 @@ describe('Protocol V2 firmware update targets', () => {
4313
4391
  expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
4314
4392
  });
4315
4393
 
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)));
4404
+ const method = new FirmwareUpdateV4({
4405
+ id: 1,
4406
+ payload: {
4407
+ method: 'firmwareUpdateV4',
4408
+ platform: 'ext',
4409
+ targetsToUpdate: ['app_v1', 'resource'],
4410
+ },
4411
+ });
4412
+ method.init();
4413
+
4414
+ (method as any).device = stubDevice({
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',
4424
+ isBootloader: () => false,
4425
+ isRomloader: () => false,
4426
+ });
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],
4438
+ });
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,
4450
+ });
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,
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
+
4316
4520
  test('reboots Protocol V2 normal-mode device to bootloader before transfer', async () => {
4317
4521
  const method = new FirmwareUpdateV4({
4318
4522
  id: 1,
@@ -5116,9 +5320,9 @@ describe('Protocol V2 firmware update targets', () => {
5116
5320
  getCommands: () => activeCommands,
5117
5321
  });
5118
5322
  (method as any).protocolV2LegacyDirectUpdate = true;
5119
- (method as any).rebootProtocolV2ToBootloader = jest.fn().mockImplementation(async () => {
5323
+ (method as any).rebootProtocolV2ToBootloader = jest.fn().mockImplementation(() => {
5120
5324
  activeCommands = { typedCall: bootloaderTypedCall };
5121
- return true;
5325
+ return Promise.resolve(true);
5122
5326
  });
5123
5327
  method.postTipMessage = jest.fn();
5124
5328
  method.postProgressMessage = jest.fn();
@@ -5238,6 +5442,7 @@ describe('Protocol V2 firmware update targets', () => {
5238
5442
  const deviceInfo = { hw: { serial_no: 'PRO2-PHYSICAL-1' } };
5239
5443
  (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(deviceInfo);
5240
5444
  (method as any).probeProtocolV2NormalMode = jest.fn().mockResolvedValue(true);
5445
+ (method as any).protocolV2InstallAckReceived = true;
5241
5446
  method.postProgressMessage = jest.fn();
5242
5447
 
5243
5448
  await (method as any).waitForProtocolV2FirmwareUpdateComplete([
@@ -5276,6 +5481,7 @@ describe('Protocol V2 firmware update targets', () => {
5276
5481
  (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
5277
5482
  (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(deviceInfo);
5278
5483
  (method as any).probeProtocolV2NormalMode = jest.fn().mockResolvedValue(true);
5484
+ (method as any).protocolV2InstallAckReceived = true;
5279
5485
  method.postProgressMessage = jest.fn();
5280
5486
 
5281
5487
  await expect(
@@ -5289,6 +5495,78 @@ describe('Protocol V2 firmware update targets', () => {
5289
5495
  expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
5290
5496
  });
5291
5497
 
5498
+ test('rejects normal mode without install ACK, target status, or a version change', async () => {
5499
+ const method = new FirmwareUpdateV4({
5500
+ id: 1,
5501
+ payload: {
5502
+ method: 'firmwareUpdateV4',
5503
+ },
5504
+ });
5505
+ const typedCall = jest
5506
+ .fn()
5507
+ .mockRejectedValue(new Error('Failure: Handler not registered for this message'));
5508
+ const deviceInfo = { hw: { serial_no: 'PRO2-PHYSICAL-1' } };
5509
+ let now = 0;
5510
+ jest.spyOn(Date, 'now').mockImplementation(() => now);
5511
+ jest.spyOn(global, 'setTimeout').mockImplementation(((callback: () => void) => {
5512
+ now += 31 * 1000;
5513
+ callback();
5514
+ return 0 as any;
5515
+ }) as typeof setTimeout);
5516
+
5517
+ (method as any).device = stubDevice({
5518
+ getCommands: () => ({ typedCall }),
5519
+ });
5520
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
5521
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(deviceInfo);
5522
+ (method as any).probeProtocolV2NormalMode = jest.fn().mockResolvedValue(true);
5523
+ method.postProgressMessage = jest.fn();
5524
+
5525
+ await expect(
5526
+ (method as any).waitForProtocolV2FirmwareUpdateComplete([
5527
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
5528
+ ])
5529
+ ).rejects.toMatchObject({ errorCode: HardwareErrorCode.FirmwareError });
5530
+
5531
+ expect(method.postProgressMessage).not.toHaveBeenCalledWith(100, 'installingFirmware');
5532
+ });
5533
+
5534
+ test('accepts ACK-less normal mode after the requested target version changes', async () => {
5535
+ const method = new FirmwareUpdateV4({
5536
+ id: 1,
5537
+ payload: {
5538
+ method: 'firmwareUpdateV4',
5539
+ },
5540
+ });
5541
+ const typedCall = jest
5542
+ .fn()
5543
+ .mockRejectedValue(new Error('Failure: Handler not registered for this message'));
5544
+ const deviceInfo = { hw: { serial_no: 'PRO2-PHYSICAL-1' } };
5545
+ const probeProtocolV2RuntimeState = jest.fn().mockResolvedValue({
5546
+ mode: 'normal',
5547
+ bootloaderMode: false,
5548
+ firmwareVersion: '2.0.0',
5549
+ });
5550
+
5551
+ (method as any).device = stubDevice({
5552
+ getCommands: () => ({ typedCall }),
5553
+ probeProtocolV2RuntimeState,
5554
+ });
5555
+ (method as any).protocolV2InstallBaselineVersions = new Map([[4, '1.0.0']]);
5556
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
5557
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(deviceInfo);
5558
+ method.postProgressMessage = jest.fn();
5559
+
5560
+ await expect(
5561
+ (method as any).waitForProtocolV2FirmwareUpdateComplete([
5562
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
5563
+ ])
5564
+ ).resolves.toBeUndefined();
5565
+
5566
+ expect(probeProtocolV2RuntimeState).toHaveBeenCalledWith(deviceInfo, 5000);
5567
+ expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
5568
+ });
5569
+
5292
5570
  test.each([
5293
5571
  [{ mode: 'normal', bootloaderMode: false }, true],
5294
5572
  [{ mode: 'bootloader', bootloaderMode: true }, false],
@@ -5304,9 +5582,7 @@ describe('Protocol V2 firmware update targets', () => {
5304
5582
  (method as any).device = stubDevice({ probeProtocolV2RuntimeState });
5305
5583
 
5306
5584
  await expect((method as any).probeProtocolV2NormalMode(deviceInfo)).resolves.toBe(expected);
5307
- expect(probeProtocolV2RuntimeState).toHaveBeenCalledWith(deviceInfo, 5000, {
5308
- allowLegacyProtocolV2ProtocolInfo: true,
5309
- });
5585
+ expect(probeProtocolV2RuntimeState).toHaveBeenCalledWith(deviceInfo, 5000);
5310
5586
  });
5311
5587
 
5312
5588
  test('keeps polling when the firmware status handler is missing in loader mode', async () => {
@@ -5852,7 +6128,7 @@ describe('Protocol V2 firmware update targets', () => {
5852
6128
  );
5853
6129
  });
5854
6130
 
5855
- test('passes bootloader, coprocessor, SE and app files to DeviceFirmwareUpdate targets', async () => {
6131
+ test('preserves component-first target order for the firmware-owned loader handoff', async () => {
5856
6132
  const method = new FirmwareUpdateV4({
5857
6133
  id: 1,
5858
6134
  payload: {
@@ -5881,54 +6157,72 @@ describe('Protocol V2 firmware update targets', () => {
5881
6157
  .mockResolvedValue(undefined);
5882
6158
 
5883
6159
  await (method as any).executeProtocolV2Update({
5884
- bootloaderBinary: new Uint8Array([4, 5]).buffer,
5885
- fwBinaryMap: [
6160
+ // The active bootloader installs its component runners in records order and
6161
+ // leaves the bootloader record pending for romloader after reboot. Keep boot
6162
+ // deliberately after components to prove Core does not synthesize boot-first.
6163
+ installItems: [
5886
6164
  {
5887
- fileName: 'coprocessor.bin',
5888
- binary: new Uint8Array([6]).buffer,
5889
- targetId: 6,
6165
+ fileName: 'application_p1.bin',
6166
+ binary: new Uint8Array([8]).buffer,
6167
+ targetId: 4,
6168
+ kind: 'firmware',
5890
6169
  },
5891
6170
  {
5892
6171
  fileName: 'se01.bin',
5893
6172
  binary: new Uint8Array([7]).buffer,
5894
6173
  targetId: 7,
6174
+ kind: 'firmware',
5895
6175
  },
5896
6176
  {
5897
- fileName: 'application_p1.bin',
5898
- binary: new Uint8Array([8]).buffer,
5899
- targetId: 4,
6177
+ fileName: 'bootloader.bin',
6178
+ binary: new Uint8Array([4, 5]).buffer,
6179
+ targetId: 3,
6180
+ kind: 'bootloader',
6181
+ },
6182
+ {
6183
+ fileName: 'coprocessor.bin',
6184
+ binary: new Uint8Array([6]).buffer,
6185
+ targetId: 6,
6186
+ kind: 'firmware',
5900
6187
  },
5901
6188
  ],
5902
6189
  });
5903
6190
 
5904
6191
  expect(writtenPaths).toEqual([
6192
+ 'vol0:/application_p1.bin',
6193
+ 'vol0:/se01.bin',
5905
6194
  'vol0:/bootloader.bin',
5906
6195
  'vol0:/coprocessor.bin',
5907
- 'vol0:/se01.bin',
5908
- 'vol0:/application_p1.bin',
5909
6196
  ]);
5910
6197
  expect((method as any).verifyProtocolV2StagedFile).toHaveBeenCalledTimes(4);
5911
6198
  expect((method as any).verifyProtocolV2StagedFile).toHaveBeenNthCalledWith(
5912
6199
  2,
5913
- 'vol0:/coprocessor.bin',
6200
+ 'vol0:/se01.bin',
5914
6201
  1
5915
6202
  );
5916
- expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledTimes(2);
5917
- expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenNthCalledWith(1, {
5918
- targets: [{ target_id: 3, path: 'vol0:/bootloader.bin' }],
5919
- });
5920
- expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenNthCalledWith(2, {
6203
+ expect((method as any).enterProtocolV2BootloaderMode).toHaveBeenCalledTimes(1);
6204
+ expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledTimes(1);
6205
+ expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledWith({
5921
6206
  targets: [
5922
- { target_id: 6, path: 'vol0:/coprocessor.bin' },
5923
- { target_id: 7, path: 'vol0:/se01.bin' },
5924
6207
  { target_id: 4, path: 'vol0:/application_p1.bin' },
6208
+ { target_id: 7, path: 'vol0:/se01.bin' },
6209
+ { target_id: 3, path: 'vol0:/bootloader.bin' },
6210
+ { target_id: 6, path: 'vol0:/coprocessor.bin' },
5925
6211
  ],
5926
6212
  });
6213
+ expect((method as any).waitForProtocolV2FirmwareUpdateComplete).toHaveBeenCalledTimes(1);
6214
+ expect((method as any).waitForProtocolV2FirmwareUpdateComplete).toHaveBeenCalledWith([
6215
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
6216
+ { target_id: 7, path: 'vol0:/se01.bin' },
6217
+ { target_id: 3, path: 'vol0:/bootloader.bin' },
6218
+ { target_id: 6, path: 'vol0:/coprocessor.bin' },
6219
+ ]);
6220
+ expect((method as any).exitProtocolV2BootloaderToNormal).not.toHaveBeenCalled();
5927
6221
  expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'transferData');
5928
- expect((method as any).waitForProtocolV2FirmwareUpdateComplete).toHaveBeenCalled();
6222
+ expect((method as any).completeProtocolV2FinalVerification).toHaveBeenCalledTimes(1);
5929
6223
  });
5930
6224
 
5931
- test('announces separate resource and component transfer phases', async () => {
6225
+ test('uses one global transfer range for resources and firmware', async () => {
5932
6226
  const method = new FirmwareUpdateV4({
5933
6227
  id: 1,
5934
6228
  payload: {
@@ -5985,18 +6279,24 @@ describe('Protocol V2 firmware update targets', () => {
5985
6279
  ],
5986
6280
  });
5987
6281
 
5988
- expect(method.postTipMessage).toHaveBeenCalledTimes(3);
6282
+ expect((method as any).enterProtocolV2BootloaderMode).toHaveBeenCalledTimes(1);
6283
+ expect(method.postTipMessage).toHaveBeenCalledTimes(2);
5989
6284
  expect(method.postTipMessage).toHaveBeenNthCalledWith(1, 'StartTransferData');
5990
- expect(method.postTipMessage).toHaveBeenNthCalledWith(2, 'StartTransferData');
5991
- expect(method.postTipMessage).toHaveBeenNthCalledWith(3, 'ConfirmOnDevice');
6285
+ expect(method.postTipMessage).toHaveBeenNthCalledWith(2, 'ConfirmOnDevice');
5992
6286
  expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenNthCalledWith(
5993
6287
  1,
5994
- expect.objectContaining({ processedSize: 0, totalSize: 2 })
6288
+ expect.objectContaining({ processedSize: 0, totalSize: 3 })
5995
6289
  );
5996
6290
  expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenNthCalledWith(
5997
6291
  2,
5998
- expect.objectContaining({ processedSize: 0, totalSize: 1 })
6292
+ expect.objectContaining({ processedSize: 2, totalSize: 3 })
5999
6293
  );
6294
+ expect(method.postProgressMessage).toHaveBeenCalledTimes(1);
6295
+ expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'transferData');
6296
+ expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledTimes(1);
6297
+ expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledWith({
6298
+ targets: [{ target_id: 4, path: 'vol0:/application_p1.bin' }],
6299
+ });
6000
6300
  expect((method as any).isProtocolV2ResourceBundleUpToDate).toHaveBeenCalledWith(
6001
6301
  expect.objectContaining({
6002
6302
  version: [1, 2, 3],
@@ -6006,50 +6306,7 @@ describe('Protocol V2 firmware update targets', () => {
6006
6306
  );
6007
6307
  });
6008
6308
 
6009
- test('installs and verifies bootloader before syncing manifest resources', () => {
6010
- const method = new FirmwareUpdateV4({
6011
- id: 1,
6012
- payload: { method: 'firmwareUpdateV4' },
6013
- });
6014
- const phases = (method as any).buildProtocolV2ExecutionPhases({
6015
- installSources: [{ kind: 'bootloader' }, { kind: 'component' }],
6016
- resourceSources: [{ kind: 'resource' }],
6017
- });
6018
-
6019
- expect(phases.map((phase: { kind: string }) => phase.kind)).toEqual([
6020
- 'bootloader-install',
6021
- 'bootloader-verify',
6022
- 'resource-sync',
6023
- 'component-install',
6024
- 'final-verify',
6025
- ]);
6026
- });
6027
-
6028
- test('stages the mounted boot resource before any firmware install phase', () => {
6029
- const method = new FirmwareUpdateV4({
6030
- id: 1,
6031
- payload: { method: 'firmwareUpdateV4' },
6032
- });
6033
- const bootResource = {
6034
- devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg',
6035
- };
6036
- const phases = (method as any).buildProtocolV2ExecutionPhases({
6037
- installSources: [{ kind: 'bootloader' }, { kind: 'component' }],
6038
- resourceSources: [bootResource, { devicePath: 'vol0:/resource/images/images.okpkg' }],
6039
- });
6040
-
6041
- expect(phases.map((phase: { kind: string }) => phase.kind)).toEqual([
6042
- 'resource-sync',
6043
- 'bootloader-install',
6044
- 'bootloader-verify',
6045
- 'resource-sync',
6046
- 'component-install',
6047
- 'final-verify',
6048
- ]);
6049
- expect(phases[0].resourceSources).toEqual([bootResource]);
6050
- });
6051
-
6052
- test('removes stale boot resource staging before a component-only reboot', async () => {
6309
+ test('removes stale boot resource staging before the combined transfer', async () => {
6053
6310
  const method = new FirmwareUpdateV4({
6054
6311
  id: 1,
6055
6312
  payload: { method: 'firmwareUpdateV4' },
@@ -6076,18 +6333,18 @@ describe('Protocol V2 firmware update targets', () => {
6076
6333
  events.push('transfer-component');
6077
6334
  return Promise.resolve();
6078
6335
  });
6079
- (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockImplementation(() => {
6080
- events.push('reboot-normal');
6081
- return Promise.resolve();
6336
+ (method as any).completeProtocolV2FinalVerification = jest.fn().mockImplementation(() => {
6337
+ events.push('final-verify');
6338
+ return Promise.resolve({});
6082
6339
  });
6083
- (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
6084
6340
 
6085
6341
  await (method as any).executeProtocolV2SourceUpdate({
6086
6342
  installSources: [{ kind: 'firmware' }],
6087
6343
  resourceSources: [],
6088
6344
  });
6089
6345
 
6090
- expect(events).toEqual(['delete-stale-staging', 'transfer-component', 'reboot-normal']);
6346
+ expect((method as any).enterProtocolV2BootloaderMode).toHaveBeenCalledTimes(1);
6347
+ expect(events).toEqual(['delete-stale-staging', 'transfer-component', 'final-verify']);
6091
6348
  expect(typedCall).toHaveBeenNthCalledWith(2, 'FilesystemFileDelete', 'Success', {
6092
6349
  path: 'vol0:/loaders/bootloader/boot_resource.okpkg.staging',
6093
6350
  });
@@ -7434,6 +7691,9 @@ describe('Protocol V2 firmware update targets', () => {
7434
7691
  method.postProgressMessage = jest.fn();
7435
7692
  (method as any).protocolV2SourceUpdateProcess = jest.fn().mockResolvedValue(3);
7436
7693
  (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
7694
+ (method as any).ensureProtocolV2BootResourceStagingIsEmpty = jest
7695
+ .fn()
7696
+ .mockResolvedValue(undefined);
7437
7697
  (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
7438
7698
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
7439
7699
 
@@ -7560,6 +7820,86 @@ describe('Protocol V2 firmware update targets', () => {
7560
7820
  });
7561
7821
  });
7562
7822
 
7823
+ test('sends one monotonic device transfer progress range across multiple files', async () => {
7824
+ const method = new FirmwareUpdateV4({
7825
+ id: 1,
7826
+ payload: {
7827
+ method: 'firmwareUpdateV4',
7828
+ },
7829
+ });
7830
+ const typedCall = jest.fn(
7831
+ (
7832
+ _name: string,
7833
+ _resType: string,
7834
+ params: { file: { offset: number; data: { byteLength: number } } }
7835
+ ) =>
7836
+ Promise.resolve({
7837
+ type: 'FilesystemFile',
7838
+ message: {
7839
+ processed_byte: params.file.offset + params.file.data.byteLength,
7840
+ },
7841
+ })
7842
+ );
7843
+
7844
+ (method as any).device = stubDevice({
7845
+ getCommands: () => ({ typedCall }),
7846
+ getCurrentDeviceType: () => 'pro2',
7847
+ toMessageObject: () => ({ connectId: 'firmware-device' }),
7848
+ });
7849
+ method.postMessage = jest.fn();
7850
+ method.postTipMessage = jest.fn();
7851
+ method.postProgressMessage = jest.fn();
7852
+ (method as any).isProtocolV2ResourceBundleUpToDate = jest.fn().mockResolvedValue(false);
7853
+ (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
7854
+ (method as any).protocolV2StartFirmwareUpdate = jest.fn().mockResolvedValue(undefined);
7855
+ (method as any).waitForProtocolV2FirmwareUpdateComplete = jest
7856
+ .fn()
7857
+ .mockResolvedValue(undefined);
7858
+
7859
+ const resourceSource = await openFirmwareByteSource({
7860
+ binary: new Uint8Array(4097).buffer,
7861
+ });
7862
+ const firmwareSource = await openFirmwareByteSource({
7863
+ binary: new Uint8Array(4097).buffer,
7864
+ });
7865
+ try {
7866
+ await (method as any).executeProtocolV2TransferPhase({
7867
+ resourceSources: [
7868
+ {
7869
+ name: 'images.okpkg',
7870
+ source: resourceSource,
7871
+ devicePath: 'vol0:/resource/images/images.okpkg',
7872
+ },
7873
+ ],
7874
+ installSources: [
7875
+ {
7876
+ fileName: 'application_p1.bin',
7877
+ source: firmwareSource,
7878
+ targetId: 4,
7879
+ kind: 'firmware',
7880
+ },
7881
+ ],
7882
+ });
7883
+ } finally {
7884
+ await resourceSource?.close();
7885
+ await firmwareSource?.close();
7886
+ }
7887
+
7888
+ const writePayloads = typedCall.mock.calls.map(call => call[2]);
7889
+ const deviceProgress = writePayloads.map(payload => payload.ui_percentage);
7890
+ expect(deviceProgress).toEqual([0, 50, 99, 100]);
7891
+ expect(deviceProgress.filter(progress => progress === 0)).toHaveLength(1);
7892
+ expect(deviceProgress.filter(progress => progress === 100)).toHaveLength(1);
7893
+ expect(method.postTipMessage).toHaveBeenCalledTimes(2);
7894
+ expect(method.postTipMessage).toHaveBeenNthCalledWith(1, 'StartTransferData');
7895
+ expect(method.postTipMessage).toHaveBeenNthCalledWith(2, 'ConfirmOnDevice');
7896
+ expect(
7897
+ (method.postProgressMessage as jest.Mock).mock.calls.filter(
7898
+ ([progress, progressType]) => progress === 100 && progressType === 'transferData'
7899
+ )
7900
+ ).toHaveLength(1);
7901
+ });
7902
+
7563
7903
  // TODO(#850/#855): PR #855 added resume-on-retry and per-chunk retry on the
7564
7904
  // writeProtocolV2File path. PR #850 replaced that path with FirmwareByteSource
7565
7905
  // streaming (protocolV2SourceUpdateProcess), which restarts a failed transfer from