@onekeyfe/hd-core 1.2.8-alpha.128 → 1.2.8-alpha.129

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 (31) hide show
  1. package/__tests__/device-lifecycle-events.test.ts +96 -0
  2. package/__tests__/firmware-update/firmware-artifact-source.test.ts +11 -0
  3. package/__tests__/protocol-v2-resources.test.ts +14 -0
  4. package/__tests__/protocol-v2.test.ts +306 -14
  5. package/__tests__/protocolV2FileWrite.test.ts +434 -7
  6. package/dist/api/FileWrite.d.ts.map +1 -1
  7. package/dist/api/FirmwareUpdateV4.d.ts +0 -1
  8. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  9. package/dist/api/firmware/FirmwareArtifactSource.d.ts.map +1 -1
  10. package/dist/api/helpers/protocolV2FileWrite.d.ts +5 -1
  11. package/dist/api/helpers/protocolV2FileWrite.d.ts.map +1 -1
  12. package/dist/api/protocol-v2/DeviceUploadNft.d.ts.map +1 -1
  13. package/dist/api/protocol-v2/DeviceUploadWallpaper.d.ts.map +1 -1
  14. package/dist/device/Device.d.ts +1 -0
  15. package/dist/device/Device.d.ts.map +1 -1
  16. package/dist/events/ui-request.d.ts +1 -0
  17. package/dist/events/ui-request.d.ts.map +1 -1
  18. package/dist/index.d.ts +3 -0
  19. package/dist/index.js +616 -382
  20. package/dist/protocols/protocol-v2/resources.d.ts +1 -0
  21. package/dist/protocols/protocol-v2/resources.d.ts.map +1 -1
  22. package/package.json +4 -4
  23. package/src/api/FileWrite.ts +7 -1
  24. package/src/api/FirmwareUpdateV4.ts +56 -30
  25. package/src/api/firmware/FirmwareArtifactSource.ts +12 -3
  26. package/src/api/helpers/protocolV2FileWrite.ts +375 -111
  27. package/src/api/protocol-v2/DeviceUploadNft.ts +6 -1
  28. package/src/api/protocol-v2/DeviceUploadWallpaper.ts +15 -1
  29. package/src/device/Device.ts +18 -2
  30. package/src/events/ui-request.ts +1 -0
  31. package/src/protocols/protocol-v2/resources.ts +6 -1
@@ -80,6 +80,102 @@ describe('public device lifecycle events', () => {
80
80
  jest.restoreAllMocks();
81
81
  });
82
82
 
83
+ test.each([
84
+ ['react-native', 'V2', EDeviceType.Pro2, true],
85
+ ['react-native', 'V2', EDeviceType.Neo, true],
86
+ ['desktop-web-ble', 'V2', EDeviceType.Pro2, true],
87
+ ['desktop-web-ble', 'V2', EDeviceType.Neo, true],
88
+ ['react-native', 'V1', EDeviceType.Pro, false],
89
+ ['desktop-web-ble', 'V1', EDeviceType.Touch, false],
90
+ ['react-native', 'V1', EDeviceType.Pro2, false],
91
+ ['react-native', 'V2', EDeviceType.Unknown, false],
92
+ ['desktop-webusb', 'V2', EDeviceType.Pro2, false],
93
+ ['lowlevel', 'V2', EDeviceType.Neo, false],
94
+ ] as const)(
95
+ 'releases the task and keeps only the intended BLE links: %s / %s / %s',
96
+ async (env, protocol, deviceType, keepConnection) => {
97
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue(env as never);
98
+ const device = createInitializedDevice(protocol);
99
+ device.features = { ...device.features, deviceType } as never;
100
+ device.originalDescriptor.session = device.mainId;
101
+ (device as unknown as { deviceAcquired: boolean }).deviceAcquired = true;
102
+ const release = jest.fn().mockResolvedValue(true);
103
+ const disconnect = jest.fn().mockResolvedValue(undefined);
104
+ device.deviceConnector = { release, disconnect } as never;
105
+ const commands = {
106
+ disposed: false,
107
+ dispose: jest.fn(() => {
108
+ commands.disposed = true;
109
+ return Promise.resolve();
110
+ }),
111
+ cancel: jest.fn().mockResolvedValue(undefined),
112
+ };
113
+ device.commands = commands as never;
114
+
115
+ await device.release();
116
+
117
+ expect(release).toHaveBeenCalledWith(device.mainId, false, keepConnection);
118
+ expect(device.hasDeviceAcquire()).toBe(false);
119
+ expect(commands.disposed).toBe(true);
120
+ expect(device.keepSession).toBe(false);
121
+
122
+ await device.interruptionFromUser();
123
+ expect(disconnect).toHaveBeenCalledTimes(keepConnection ? 0 : 1);
124
+ }
125
+ );
126
+
127
+ test('still disconnects when setup is cancelled after a kept-alive release', async () => {
128
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue('react-native' as never);
129
+ const device = createInitializedDevice('V2');
130
+ const pendingAcquire = createDeferred<{ uuid: string; protocolType: 'V2' }>();
131
+ const release = jest.fn().mockResolvedValue(true);
132
+ const disconnect = jest.fn().mockResolvedValue(undefined);
133
+ device.deviceConnector = {
134
+ release,
135
+ disconnect,
136
+ acquire: jest.fn(() => pendingAcquire.promise),
137
+ } as never;
138
+ device.commands = {
139
+ disposed: true,
140
+ dispose: jest.fn().mockResolvedValue(undefined),
141
+ cancel: jest.fn().mockResolvedValue(undefined),
142
+ } as never;
143
+ await device.release();
144
+ device.beginConnectionAttempt();
145
+ const acquiring = device.acquire('V2');
146
+
147
+ await device.interruptionFromUser();
148
+ expect(disconnect).toHaveBeenCalledWith(device.mainId);
149
+ pendingAcquire.resolve({ uuid: 'late-kept-alive-session', protocolType: 'V2' });
150
+ await expect(acquiring).rejects.toMatchObject({
151
+ errorCode: HardwareErrorCode.DeviceInterruptedFromUser,
152
+ });
153
+ expect(disconnect).toHaveBeenLastCalledWith('late-kept-alive-session');
154
+ });
155
+
156
+ test.each(['release failure', 'disconnect event'])(
157
+ 'does not treat %s as a kept-alive connection',
158
+ async reason => {
159
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue('react-native' as never);
160
+ const device = createInitializedDevice('V2');
161
+ const release = jest.fn().mockResolvedValue(true);
162
+ if (reason === 'release failure') release.mockRejectedValueOnce(new Error('release failed'));
163
+ const disconnect = jest.fn().mockResolvedValue(undefined);
164
+ device.deviceConnector = { release, disconnect } as never;
165
+ device.commands = {
166
+ disposed: true,
167
+ dispose: jest.fn().mockResolvedValue(undefined),
168
+ cancel: jest.fn().mockResolvedValue(undefined),
169
+ } as never;
170
+ await device.release();
171
+ if (reason === 'disconnect event') device.markTransportDisconnected();
172
+
173
+ await device.interruptionFromUser();
174
+
175
+ expect(disconnect).toHaveBeenCalledWith(device.mainId);
176
+ }
177
+ );
178
+
83
179
  test.each(['V1', 'V2'] as const)(
84
180
  'preserves the %s acknowledged result and waits for release before the next acquire',
85
181
  async protocol => {
@@ -31,6 +31,10 @@ const createReader = (bytes = artifactBytes) => {
31
31
  };
32
32
 
33
33
  describe('FirmwareArtifactSource', () => {
34
+ afterEach(() => {
35
+ jest.restoreAllMocks();
36
+ });
37
+
34
38
  it('materializes verified bytes before exposing the source', async () => {
35
39
  const backingBytes = Uint8Array.from(artifactBytes);
36
40
  const { reader, read, close } = createReader(backingBytes);
@@ -86,6 +90,10 @@ describe('FirmwareArtifactSource', () => {
86
90
  sha256: bytesToHex(sha256(bytes)),
87
91
  };
88
92
  const { reader, read } = createReader(bytes);
93
+ const hasher = sha256.create();
94
+ const update = jest.spyOn(hasher, 'update');
95
+ const digest = jest.spyOn(hasher, 'digest');
96
+ jest.spyOn(sha256, 'create').mockReturnValue(hasher);
89
97
 
90
98
  const source = await openFirmwareByteSource({ artifact: largeArtifact, reader });
91
99
  if (!source) {
@@ -103,6 +111,9 @@ describe('FirmwareArtifactSource', () => {
103
111
  length: 3,
104
112
  },
105
113
  ]);
114
+ expect(update).toHaveBeenCalledTimes(2);
115
+ expect(update.mock.invocationCallOrder[0]).toBeLessThan(read.mock.invocationCallOrder[1]);
116
+ expect(digest.mock.invocationCallOrder[0]).toBeGreaterThan(update.mock.invocationCallOrder[1]);
106
117
  await source.close();
107
118
  });
108
119
 
@@ -5,6 +5,7 @@ import { DataManager } from '../src/data-manager';
5
5
  import {
6
6
  isProtocolV2ResourceArchiveEntryName,
7
7
  parseProtocolV2ResourcePackage,
8
+ parseProtocolV2ResourcePackageHeader,
8
9
  parseProtocolV2Resources,
9
10
  } from '../src/protocols/protocol-v2/resources';
10
11
 
@@ -163,6 +164,19 @@ describe('Pro2 resource configuration', () => {
163
164
  ).toThrow('device path');
164
165
  });
165
166
 
167
+ test('compares installed resources from 640 bytes without accepting a truncated local package', () => {
168
+ const bytes = createResourcePackage('vol0:/bundles/images.okpkg');
169
+ const prefix = bytes.slice(0, 640);
170
+ expect(parseProtocolV2ResourcePackageHeader(prefix, bytes.byteLength)).toEqual(
171
+ parseProtocolV2ResourcePackage(bytes)
172
+ );
173
+ expect(() => parseProtocolV2ResourcePackage(prefix)).toThrow('shorter than its header');
174
+ expect(() =>
175
+ parseProtocolV2ResourcePackageHeader(prefix.slice(0, -1), bytes.byteLength)
176
+ ).toThrow();
177
+ expect(() => parseProtocolV2ResourcePackageHeader(prefix, bytes.byteLength + 1)).toThrow();
178
+ });
179
+
166
180
  test('rejects missing or malformed release sources', () => {
167
181
  expect(() => parseProtocolV2Resources({})).toThrow('source is required');
168
182
  expect(() =>
@@ -34,6 +34,7 @@ import DeviceSettings from '../src/api/device/DeviceSettings';
34
34
  import DeviceUnlock from '../src/api/device/DeviceUnlock';
35
35
  import DeviceWipe from '../src/api/device/DeviceWipe';
36
36
  import DeviceUploadWallpaper from '../src/api/protocol-v2/DeviceUploadWallpaper';
37
+ import DeviceUploadNft from '../src/api/protocol-v2/DeviceUploadNft';
37
38
  import DeviceStatusGet from '../src/api/protocol-v2/DeviceStatusGet';
38
39
  import FilesystemFormat from '../src/api/protocol-v2/FilesystemFormat';
39
40
  import FilesystemPermissionFix from '../src/api/protocol-v2/FilesystemPermissionFix';
@@ -68,6 +69,7 @@ import StellarGetAddress from '../src/api/stellar/StellarGetAddress';
68
69
  import BenfenSignMessage from '../src/api/benfen/BenfenSignMessage';
69
70
  import { getBitcoinForkSupportedProtocols } from '../src/api/btc/helpers/versionLimit';
70
71
  import { DataManager } from '../src/data-manager';
72
+ import { LoggerNames, getLogger } from '../src/utils/logger';
71
73
  import { createCoreApi } from '../src/inject';
72
74
  import { Device, preloadSessionCache } from '../src/device/Device';
73
75
  import { deviceWalletSessionStore } from '../src/device/DeviceWalletSessionStore';
@@ -121,7 +123,13 @@ import {
121
123
 
122
124
  import type { DeviceCommands } from '../src/device/DeviceCommands';
123
125
  import type { Features } from '../src/types';
124
- import type { DeviceStatus, ProtocolInfo } from '@onekeyfe/hd-transport';
126
+ import type {
127
+ DeviceStatus,
128
+ FilesystemFileWrite,
129
+ MessageFromOneKey,
130
+ ProtocolInfo,
131
+ TransportCallOptions,
132
+ } from '@onekeyfe/hd-transport';
125
133
 
126
134
  jest.mock('../src/data/config', () => ({
127
135
  getSDKVersion: jest.fn(() => '1.0.0'),
@@ -654,6 +662,162 @@ describe('UploadPortfolio', () => {
654
662
  });
655
663
  });
656
664
 
665
+ describe('Pro2 / Neo application asset pipelines', () => {
666
+ afterEach(() => jest.restoreAllMocks());
667
+
668
+ test.each(
669
+ [EDeviceType.Pro2, EDeviceType.Neo].flatMap(deviceType =>
670
+ (['wallpaper', 'nft', 'portfolio'] as const).flatMap(asset =>
671
+ [
672
+ { fail: false, recover: false },
673
+ { fail: true, recover: false },
674
+ { fail: true, recover: true },
675
+ ].map(outcome => ({ deviceType, asset, ...outcome }))
676
+ )
677
+ )
678
+ )(
679
+ '$deviceType $asset applies only a fully confirmed upload (fail=$fail, recover=$recover)',
680
+ async ({ deviceType, asset, fail, recover }) => {
681
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue('react-native');
682
+ jest.spyOn(DataManager, 'isBleConnect').mockReturnValue(true);
683
+ const failure = new Error('BLE response timeout');
684
+ let pipelineAttempts = 0;
685
+ const applyMessage = {
686
+ wallpaper: 'DeviceSettingsSet',
687
+ nft: 'NftUpdate',
688
+ portfolio: 'PortfolioUpdate',
689
+ }[asset];
690
+ const typedCall = jest.fn(
691
+ async (
692
+ name: string,
693
+ _response: string,
694
+ first: FilesystemFileWrite,
695
+ options?: TransportCallOptions
696
+ ) => {
697
+ if (name !== 'FilesystemFileWrite') return { type: 'Success', message: {} };
698
+ const pipeline = options?.fileWritePipeline;
699
+ if (!pipeline) {
700
+ if (fail && !recover) throw failure;
701
+ const { data } = first.file;
702
+ if (!(data instanceof Uint8Array)) throw new Error('Expected Uint8Array file data');
703
+ return {
704
+ type: 'FilesystemFile',
705
+ message: { ...first.file, processed_byte: first.file.offset + data.byteLength },
706
+ };
707
+ }
708
+ pipelineAttempts += 1;
709
+ let request: FilesystemFileWrite | undefined = first;
710
+ let response: MessageFromOneKey = { type: 'FilesystemFile', message: {} };
711
+ while (request) {
712
+ const { data } = request.file;
713
+ if (!(data instanceof Uint8Array)) throw new Error('Expected Uint8Array file data');
714
+ expect(data.byteLength).toBeLessThanOrEqual(1800);
715
+ expect(typedCall.mock.calls.some(call => call[0] === applyMessage)).toBe(false);
716
+ response = {
717
+ type: 'FilesystemFile',
718
+ message: { ...request.file, processed_byte: request.file.offset + data.byteLength },
719
+ };
720
+ pipeline.onResponse(response);
721
+ if (fail && (!recover || pipelineAttempts === 1)) throw failure;
722
+ request = (await pipeline.next()) as FilesystemFileWrite | undefined;
723
+ }
724
+ return response;
725
+ }
726
+ );
727
+ let method: DeviceUploadWallpaper | DeviceUploadNft | UploadPortfolio;
728
+ if (asset === 'wallpaper') {
729
+ method = new DeviceUploadWallpaper({
730
+ id: 1,
731
+ payload: { method: 'deviceUploadWallpaper', jpegBase64: createJpegBase64(604, 1024) },
732
+ });
733
+ } else if (asset === 'nft') {
734
+ method = new DeviceUploadNft({
735
+ id: 1,
736
+ payload: {
737
+ method: 'deviceUploadNft',
738
+ imageJpegBase64: createJpegBase64(540, 540),
739
+ thumbnailJpegBase64: createJpegBase64(263, 263),
740
+ title: 'NFT',
741
+ subtitle: '',
742
+ timestampMs: 1_760_000_000_000,
743
+ },
744
+ });
745
+ } else {
746
+ method = new UploadPortfolio({
747
+ id: 1,
748
+ payload: {
749
+ method: 'uploadPortfolio',
750
+ packageBase64: Buffer.from(new Uint8Array(3601)).toString('base64'),
751
+ uiMode: 'progress',
752
+ },
753
+ });
754
+ }
755
+ const commands = { typedCall, device: undefined as Device | undefined };
756
+ const acquire = jest.fn().mockResolvedValue(undefined);
757
+ method.device = stubDevice({
758
+ commands,
759
+ originalDescriptor: { protocolType: 'V2' },
760
+ features: { deviceType, mode: 'normal' },
761
+ state: {
762
+ identity: { serialNo: 'test-asset-device', deviceType },
763
+ status: { mode: 'normal', initialized: false },
764
+ versions: { firmware: '1.0.3', bootloader: '1.0.0' },
765
+ },
766
+ acquire,
767
+ initialize: jest.fn().mockResolvedValue(undefined),
768
+ ensureProtocolV2RuntimeContext: jest.fn().mockResolvedValue({
769
+ version: 2,
770
+ supported_messages: [60412, 60805, 60809, 61400, 61500],
771
+ }),
772
+ refreshProtocolV2SettingsAfterMutation: jest.fn().mockResolvedValue({}),
773
+ }) as unknown as Device;
774
+ commands.device = method.device;
775
+ method.postMessage = jest.fn();
776
+ method.init();
777
+
778
+ if (fail && !recover) {
779
+ await expect(method.run()).rejects.toBe(failure);
780
+ expect(typedCall.mock.calls.some(call => call[0] === applyMessage)).toBe(false);
781
+ expect(method.postMessage).not.toHaveBeenCalledWith(
782
+ expect.objectContaining({ type: UI_REQUEST.REQUEST_BUTTON })
783
+ );
784
+ } else {
785
+ await method.run();
786
+ expect(typedCall.mock.calls[typedCall.mock.calls.length - 1][0]).toBe(applyMessage);
787
+ expect(typedCall.mock.calls.filter(call => call[0] === applyMessage)).toHaveLength(1);
788
+ expect(method.postMessage).toHaveBeenCalledWith(
789
+ expect.objectContaining({
790
+ type: UI_REQUEST.DEVICE_PROGRESS,
791
+ payload: expect.objectContaining({ progress: 100 }),
792
+ })
793
+ );
794
+ if (asset === 'wallpaper') {
795
+ expect(method.postMessage).toHaveBeenLastCalledWith(
796
+ expect.objectContaining({
797
+ type: UI_REQUEST.REQUEST_BUTTON,
798
+ payload: expect.objectContaining({
799
+ deviceOnly: true,
800
+ completion: 'operation-completed',
801
+ operation: 'deviceUploadWallpaper',
802
+ }),
803
+ })
804
+ );
805
+ const postMessageMock = method.postMessage as jest.Mock;
806
+ const promptOrder =
807
+ postMessageMock.mock.invocationCallOrder[postMessageMock.mock.calls.length - 1];
808
+ const applyOrder = typedCall.mock.invocationCallOrder[typedCall.mock.calls.length - 1];
809
+ expect(promptOrder).toBeLessThan(applyOrder);
810
+ }
811
+ }
812
+ const recoveryAttempts = recover ? 1 : 3;
813
+ expect(typedCall.mock.calls.filter(call => call[3]?.fileWritePipeline)).toHaveLength(
814
+ fail ? recoveryAttempts + 1 : 1
815
+ );
816
+ expect(acquire).toHaveBeenCalledTimes(fail ? recoveryAttempts : 0);
817
+ }
818
+ );
819
+ });
820
+
657
821
  const descriptor = {
658
822
  id: 'ble-id',
659
823
  path: 'usb-path',
@@ -4992,6 +5156,76 @@ describe('Protocol V2 firmware update targets', () => {
4992
5156
  expect(typedCall.mock.calls.map(call => call[0])).toEqual(['DeviceInfoGet']);
4993
5157
  });
4994
5158
 
5159
+ test.each([undefined, 1500])(
5160
+ 'uses short BLE readiness attempts and reconnects after a lost response (override %s)',
5161
+ async protocolV2DeviceInfoTimeoutMs => {
5162
+ const method = new FirmwareUpdateV4({
5163
+ id: 1,
5164
+ payload: { method: 'firmwareUpdateV4', protocolV2DeviceInfoTimeoutMs },
5165
+ });
5166
+ const typedCall = jest
5167
+ .fn()
5168
+ .mockRejectedValueOnce(new Error('BLE response timeout'))
5169
+ .mockResolvedValue({ type: 'DeviceInfo', message: protocolV2BootloaderDeviceInfo });
5170
+ method.device = stubDevice({
5171
+ originalDescriptor: { id: 'ble-id', protocolType: 'V2' },
5172
+ getCommands: () => ({ typedCall }),
5173
+ probeProtocolV2RuntimeState: jest.fn().mockResolvedValue({ mode: 'bootloader' }),
5174
+ }) as unknown as Device;
5175
+ jest.spyOn(method, 'isBleReconnect').mockReturnValue(true);
5176
+ const update = method as unknown as {
5177
+ reconnectProtocolV2Device: jest.Mock;
5178
+ waitForProtocolV2BootloaderMode: (timeout: number, interval: number) => Promise<unknown>;
5179
+ protocolV2ExpectedSerialNumber: string;
5180
+ getProtocolV2DeviceInfoTimeout: () => number;
5181
+ };
5182
+ update.protocolV2ExpectedSerialNumber = 'PR9999999999';
5183
+ update.reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
5184
+
5185
+ await update.waitForProtocolV2BootloaderMode(60_000, 0);
5186
+
5187
+ expect(typedCall).toHaveBeenCalledTimes(2);
5188
+ expect(typedCall).toHaveBeenNthCalledWith(
5189
+ 1,
5190
+ 'DeviceInfoGet',
5191
+ 'DeviceInfo',
5192
+ expect.anything(),
5193
+ { timeoutMs: protocolV2DeviceInfoTimeoutMs ?? 5000 }
5194
+ );
5195
+ expect(update.reconnectProtocolV2Device).toHaveBeenCalledTimes(2);
5196
+ expect(update.reconnectProtocolV2Device).toHaveBeenCalledWith({ skipProtocolProbe: true });
5197
+ expect(update.getProtocolV2DeviceInfoTimeout()).toBe(protocolV2DeviceInfoTimeoutMs ?? 30_000);
5198
+ }
5199
+ );
5200
+
5201
+ test('disconnects the pre-reboot BLE connection before waiting for bootloader readiness', async () => {
5202
+ const method = new FirmwareUpdateV4({ id: 1, payload: { method: 'firmwareUpdateV4' } });
5203
+ const disconnect = jest.fn().mockResolvedValue(undefined);
5204
+ method.device = stubDevice({
5205
+ originalDescriptor: { id: 'ble-id', protocolType: 'V2' },
5206
+ deviceConnector: { disconnect },
5207
+ }) as unknown as Device;
5208
+ jest.spyOn(method, 'isBleReconnect').mockReturnValue(true);
5209
+ method.postTipMessage = jest.fn();
5210
+ const update = method as unknown as {
5211
+ protocolV2Reboot: jest.Mock;
5212
+ waitForProtocolV2BootloaderMode: jest.Mock;
5213
+ rebootProtocolV2ToBootloader: () => Promise<boolean>;
5214
+ };
5215
+ update.protocolV2Reboot = jest.fn().mockResolvedValue(undefined);
5216
+ update.waitForProtocolV2BootloaderMode = jest.fn().mockResolvedValue({ mode: 'bootloader' });
5217
+
5218
+ await expect(update.rebootProtocolV2ToBootloader()).resolves.toBe(true);
5219
+
5220
+ expect(disconnect).toHaveBeenCalledWith('ble-id');
5221
+ expect(disconnect.mock.invocationCallOrder[0]).toBeGreaterThan(
5222
+ update.protocolV2Reboot.mock.invocationCallOrder[0]
5223
+ );
5224
+ expect(disconnect.mock.invocationCallOrder[0]).toBeLessThan(
5225
+ update.waitForProtocolV2BootloaderMode.mock.invocationCallOrder[0]
5226
+ );
5227
+ });
5228
+
4995
5229
  test('polls again when Protocol V2 bootloader serial is temporarily unavailable', async () => {
4996
5230
  const method = new FirmwareUpdateV4({
4997
5231
  id: 1,
@@ -9160,7 +9394,7 @@ describe('Protocol V2 firmware update targets', () => {
9160
9394
  type: 'FilesystemFile',
9161
9395
  message: { processed_byte: request.file.offset + request.file.data.byteLength },
9162
9396
  });
9163
- const streamAll = (requests: any[]) =>
9397
+ const streamAll = (requests: any[], onConfirmed?: () => void) =>
9164
9398
  jest.fn(async (_name: string, _resType: string, first: any, options: any) => {
9165
9399
  // A serial write sends one chunk per call.
9166
9400
  if (!options.fileWritePipeline) {
@@ -9173,6 +9407,7 @@ describe('Protocol V2 firmware update targets', () => {
9173
9407
  while (request) {
9174
9408
  requests.push(request);
9175
9409
  last = confirm(request);
9410
+ onConfirmed?.();
9176
9411
  onResponse(last);
9177
9412
  request = await next();
9178
9413
  }
@@ -9277,7 +9512,7 @@ describe('Protocol V2 firmware update targets', () => {
9277
9512
  .mockRejectedValueOnce(new Error('Protocol V2 file write response does not match'))
9278
9513
  .mockImplementation(streamAll(requests));
9279
9514
 
9280
- test('restarts the whole file serially after a failed windowed write', async () => {
9515
+ test('restarts the whole file with dual frames after a failed windowed write', async () => {
9281
9516
  const requests: any[] = [];
9282
9517
  const typedCall = failOnceThenSucceed(requests);
9283
9518
  const method = createMethod(typedCall);
@@ -9285,22 +9520,22 @@ describe('Protocol V2 firmware update targets', () => {
9285
9520
  await expect(transfer(method)).resolves.toBe(3000);
9286
9521
 
9287
9522
  expect((method as any).recoverProtocolV2FileTransfer).toHaveBeenCalledTimes(1);
9288
- // One failed windowed call, then one serial call per chunk from offset 0.
9289
- expect(typedCall).toHaveBeenCalledTimes(4);
9523
+ // One failed pipeline, then a fresh pipeline overwriting the file from offset 0.
9524
+ expect(typedCall).toHaveBeenCalledTimes(2);
9290
9525
  typedCall.mock.calls
9291
9526
  .slice(1)
9292
- .forEach(call => expect(call[3]).not.toHaveProperty('fileWritePipeline'));
9527
+ .forEach(call => expect(call[3]).toHaveProperty('fileWritePipeline'));
9293
9528
  expect(typedCall.mock.calls[1][2]).toEqual(
9294
9529
  expect.objectContaining({ overwrite: true, file: expect.objectContaining({ offset: 0 }) })
9295
9530
  );
9296
9531
  expect(requests.map(request => request.file.offset)).toEqual([0, 1000, 2000]);
9297
- // The retry sizes its chunks for serial writes, not the window.
9532
+ // Retries retain the two-frame chunk limit.
9298
9533
  const chunkSize = (method as any).getProtocolV2FirmwareChunkSize as jest.Mock;
9299
9534
  expect(chunkSize).toHaveBeenNthCalledWith(1, 'write', 'vol0:/application_p1.bin', true);
9300
- expect(chunkSize).toHaveBeenNthCalledWith(2, 'write', 'vol0:/application_p1.bin', false);
9535
+ expect(chunkSize).toHaveBeenNthCalledWith(2, 'write', 'vol0:/application_p1.bin', true);
9301
9536
  });
9302
9537
 
9303
- test('keeps later files serial once a windowed attempt failed', async () => {
9538
+ test('keeps later files on dual frames after a windowed attempt failed', async () => {
9304
9539
  const typedCall = failOnceThenSucceed([]);
9305
9540
  const method = createMethod(typedCall);
9306
9541
  await transfer(method);
@@ -9308,9 +9543,62 @@ describe('Protocol V2 firmware update targets', () => {
9308
9543
 
9309
9544
  await expect(transfer(method)).resolves.toBe(3000);
9310
9545
 
9546
+ expect(typedCall).toHaveBeenCalledTimes(1);
9547
+ typedCall.mock.calls.forEach(call => expect(call[3]).toHaveProperty('fileWritePipeline'));
9548
+ expect((method as any).shouldUseProtocolV2BleFileWriteWindow).toHaveBeenCalledTimes(3);
9549
+ });
9550
+
9551
+ test('bounds failed dual-frame transfers to three attempts', async () => {
9552
+ const typedCall = jest.fn().mockRejectedValue(new Error('BLE response timeout'));
9553
+ const method = createMethod(typedCall);
9554
+ await expect(transfer(method)).rejects.toMatchObject({
9555
+ errorCode: HardwareErrorCode.EmmcFileWriteFirmwareError,
9556
+ });
9311
9557
  expect(typedCall).toHaveBeenCalledTimes(3);
9312
- typedCall.mock.calls.forEach(call => expect(call[3]).not.toHaveProperty('fileWritePipeline'));
9313
- expect((method as any).shouldUseProtocolV2BleFileWriteWindow).toHaveBeenCalledTimes(1);
9558
+ expect((method as any).recoverProtocolV2FileTransfer).toHaveBeenCalledTimes(2);
9559
+ typedCall.mock.calls.forEach(call => expect(call[3]).toHaveProperty('fileWritePipeline'));
9560
+ });
9561
+
9562
+ test('logs confirmed progress every ten seconds and average speed through Core', async () => {
9563
+ let now = 1000;
9564
+ const clock = jest.spyOn(Date, 'now').mockImplementation(() => now);
9565
+ const metrics = jest.spyOn(getLogger(LoggerNames.Core), 'log');
9566
+ const method = createMethod(
9567
+ streamAll([], () => {
9568
+ now += 5000;
9569
+ })
9570
+ );
9571
+ try {
9572
+ await expect(transfer(method)).resolves.toBe(3000);
9573
+ expect(metrics).toHaveBeenCalledTimes(3);
9574
+ expect(metrics).toHaveBeenNthCalledWith(
9575
+ 2,
9576
+ '[FirmwareUpdateV4] FileWrite metrics',
9577
+ expect.objectContaining({
9578
+ status: 'progress',
9579
+ fileWriteWindow: 2,
9580
+ chunkSize: 1000,
9581
+ transferredBytes: 2000,
9582
+ totalBytes: 2500,
9583
+ elapsedMs: 10_000,
9584
+ rateBytesPerSecond: 200,
9585
+ attempt: 1,
9586
+ retryCount: 0,
9587
+ })
9588
+ );
9589
+ expect(metrics).toHaveBeenLastCalledWith(
9590
+ '[FirmwareUpdateV4] FileWrite metrics',
9591
+ expect.objectContaining({
9592
+ status: 'completed',
9593
+ transferredBytes: 2500,
9594
+ elapsedMs: 15_000,
9595
+ rateBytesPerSecond: 167,
9596
+ })
9597
+ );
9598
+ } finally {
9599
+ clock.mockRestore();
9600
+ metrics.mockRestore();
9601
+ }
9314
9602
  });
9315
9603
 
9316
9604
  test('stops sending on cancel and raises it after the transport call returns', async () => {
@@ -9398,7 +9686,6 @@ describe('Protocol V2 firmware update targets', () => {
9398
9686
  await source?.close();
9399
9687
  }
9400
9688
  expect(typedCall).toHaveBeenCalledTimes(1);
9401
- expect((method as any).protocolV2FileWriteWindowDisabled).toBe(true);
9402
9689
  });
9403
9690
  });
9404
9691
 
@@ -10690,8 +10977,7 @@ describe('Protocol V2 firmware reconnect identity', () => {
10690
10977
  });
10691
10978
 
10692
10979
  await expect((method as any).readProtocolV2DeviceFileHeader('bundle.okpp')).resolves.toBeNull();
10693
- expect(readChunkLengths.length).toBeGreaterThan(1);
10694
- expect(Math.max(...readChunkLengths)).toBe(900);
10980
+ expect(readChunkLengths).toEqual([640]);
10695
10981
  });
10696
10982
 
10697
10983
  test('skips resource transfer when installed okpkg hashes match the downloaded package', async () => {
@@ -10747,6 +11033,12 @@ describe('Protocol V2 firmware reconnect identity', () => {
10747
11033
  path: 'vol0:/bundles/images/images.okpkg',
10748
11034
  });
10749
11035
  expect((method as any).protocolV2SourceUpdateProcess).not.toHaveBeenCalled();
11036
+ expect(typedCall).toHaveBeenCalledTimes(2);
11037
+ expect(typedCall).toHaveBeenCalledWith('FilesystemFileRead', 'FilesystemFile', {
11038
+ file: { path: 'vol0:/bundles/images/images.okpkg', offset: 0, total_size: 0 },
11039
+ chunk_len: 640,
11040
+ ui_percentage: undefined,
11041
+ });
10750
11042
  });
10751
11043
 
10752
11044
  test('updates a resource when the installed file size differs despite matching headers', async () => {