@onekeyfe/hd-core 1.2.8 → 1.2.9

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 (167) hide show
  1. package/README.md +27 -0
  2. package/__tests__/AllNetworkGetAddressBase.tracing.test.ts +13 -4
  3. package/__tests__/DeviceCommands.test.ts +91 -13
  4. package/__tests__/allNetworkGetAddress-privacy-networks.test.ts +77 -0
  5. package/__tests__/allNetworkGetAddress-skippable.test.ts +49 -0
  6. package/__tests__/btc-derive-context-hash.test.ts +200 -0
  7. package/__tests__/core-dispose.test.ts +114 -1
  8. package/__tests__/core-error-output.test.ts +55 -3
  9. package/__tests__/device-connector-protocol.test.ts +5 -2
  10. package/__tests__/device-lifecycle-events.test.ts +191 -27
  11. package/__tests__/device-pool-state.test.ts +50 -0
  12. package/__tests__/firmware-update/firmware-artifact-source.test.ts +11 -0
  13. package/__tests__/logBlockEvent.test.ts +331 -9
  14. package/__tests__/logger.test.ts +18 -0
  15. package/__tests__/monero-get-address.test.ts +147 -0
  16. package/__tests__/monero-key-image-sync.test.ts +272 -0
  17. package/__tests__/monero-sign-transaction.test.ts +436 -0
  18. package/__tests__/protocol-v2-bootloader-mode.test.ts +43 -2
  19. package/__tests__/protocol-v2-key-export-confirmation.test.ts +75 -0
  20. package/__tests__/protocol-v2-resources.test.ts +14 -0
  21. package/__tests__/protocol-v2.test.ts +841 -46
  22. package/__tests__/protocolV2FileWrite.test.ts +454 -7
  23. package/__tests__/refresh-device-state.test.ts +19 -17
  24. package/__tests__/search-devices.test.ts +36 -0
  25. package/__tests__/sol-sign-offchain-message.test.ts +8 -0
  26. package/__tests__/usb-identity-probe.test.ts +358 -0
  27. package/__tests__/zcash-protobuf.test.ts +152 -0
  28. package/__tests__/zcash-sign-pczt.test.ts +525 -0
  29. package/dist/api/BaseMethod.d.ts.map +1 -1
  30. package/dist/api/FileWrite.d.ts.map +1 -1
  31. package/dist/api/FirmwareUpdateV4.d.ts +5 -0
  32. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  33. package/dist/api/GetDeviceState.d.ts.map +1 -1
  34. package/dist/api/SearchDevices.d.ts +21 -1
  35. package/dist/api/SearchDevices.d.ts.map +1 -1
  36. package/dist/api/allnetwork/AllNetworkGetAddressBase.d.ts +3 -0
  37. package/dist/api/allnetwork/AllNetworkGetAddressBase.d.ts.map +1 -1
  38. package/dist/api/btc/BTCDeriveContextHash.d.ts +16 -0
  39. package/dist/api/btc/BTCDeriveContextHash.d.ts.map +1 -0
  40. package/dist/api/firmware/FirmwareArtifactSource.d.ts.map +1 -1
  41. package/dist/api/helpers/pathUtils.d.ts +1 -0
  42. package/dist/api/helpers/pathUtils.d.ts.map +1 -1
  43. package/dist/api/helpers/protocolV2FileWrite.d.ts +5 -1
  44. package/dist/api/helpers/protocolV2FileWrite.d.ts.map +1 -1
  45. package/dist/api/index.d.ts +8 -0
  46. package/dist/api/index.d.ts.map +1 -1
  47. package/dist/api/monero/MoneroGetAddress.d.ts +11 -0
  48. package/dist/api/monero/MoneroGetAddress.d.ts.map +1 -0
  49. package/dist/api/monero/MoneroGetWatchKey.d.ts +11 -0
  50. package/dist/api/monero/MoneroGetWatchKey.d.ts.map +1 -0
  51. package/dist/api/monero/MoneroKeyImageSync.d.ts +20 -0
  52. package/dist/api/monero/MoneroKeyImageSync.d.ts.map +1 -0
  53. package/dist/api/monero/MoneroSignTransaction.d.ts +17 -0
  54. package/dist/api/monero/MoneroSignTransaction.d.ts.map +1 -0
  55. package/dist/api/monero/helpers.d.ts +5 -0
  56. package/dist/api/monero/helpers.d.ts.map +1 -0
  57. package/dist/api/protocol-v2/DeviceReboot.d.ts.map +1 -1
  58. package/dist/api/protocol-v2/DeviceUploadNft.d.ts.map +1 -1
  59. package/dist/api/protocol-v2/DeviceUploadWallpaper.d.ts.map +1 -1
  60. package/dist/api/zcash/ZcashGetAddress.d.ts +12 -0
  61. package/dist/api/zcash/ZcashGetAddress.d.ts.map +1 -0
  62. package/dist/api/zcash/ZcashGetFullViewingKey.d.ts +12 -0
  63. package/dist/api/zcash/ZcashGetFullViewingKey.d.ts.map +1 -0
  64. package/dist/api/zcash/ZcashSignPczt.d.ts +16 -0
  65. package/dist/api/zcash/ZcashSignPczt.d.ts.map +1 -0
  66. package/dist/api/zcash/helpers.d.ts +2 -0
  67. package/dist/api/zcash/helpers.d.ts.map +1 -0
  68. package/dist/core/index.d.ts +3 -2
  69. package/dist/core/index.d.ts.map +1 -1
  70. package/dist/device/Device.d.ts +3 -0
  71. package/dist/device/Device.d.ts.map +1 -1
  72. package/dist/device/DeviceCommands.d.ts +4 -4
  73. package/dist/device/DeviceCommands.d.ts.map +1 -1
  74. package/dist/device/DevicePool.d.ts.map +1 -1
  75. package/dist/events/logBlockEvent.d.ts +10 -0
  76. package/dist/events/logBlockEvent.d.ts.map +1 -1
  77. package/dist/events/ui-request.d.ts +2 -0
  78. package/dist/events/ui-request.d.ts.map +1 -1
  79. package/dist/index.d.ts +206 -7
  80. package/dist/index.d.ts.map +1 -1
  81. package/dist/index.js +4456 -2801
  82. package/dist/inject.d.ts.map +1 -1
  83. package/dist/protocols/protocol-v2/resources.d.ts +1 -0
  84. package/dist/protocols/protocol-v2/resources.d.ts.map +1 -1
  85. package/dist/protocols/protocol-v2/uiInteraction.d.ts +1 -0
  86. package/dist/protocols/protocol-v2/uiInteraction.d.ts.map +1 -1
  87. package/dist/types/api/allNetworkGetAddress.d.ts +4 -1
  88. package/dist/types/api/allNetworkGetAddress.d.ts.map +1 -1
  89. package/dist/types/api/btcDeriveContextHash.d.ts +10 -0
  90. package/dist/types/api/btcDeriveContextHash.d.ts.map +1 -0
  91. package/dist/types/api/export.d.ts +8 -0
  92. package/dist/types/api/export.d.ts.map +1 -1
  93. package/dist/types/api/index.d.ts +16 -0
  94. package/dist/types/api/index.d.ts.map +1 -1
  95. package/dist/types/api/moneroGetAddress.d.ts +15 -0
  96. package/dist/types/api/moneroGetAddress.d.ts.map +1 -0
  97. package/dist/types/api/moneroGetWatchKey.d.ts +15 -0
  98. package/dist/types/api/moneroGetWatchKey.d.ts.map +1 -0
  99. package/dist/types/api/moneroKeyImageSync.d.ts +24 -0
  100. package/dist/types/api/moneroKeyImageSync.d.ts.map +1 -0
  101. package/dist/types/api/moneroSignTransaction.d.ts +32 -0
  102. package/dist/types/api/moneroSignTransaction.d.ts.map +1 -0
  103. package/dist/types/api/zcashGetAddress.d.ts +20 -0
  104. package/dist/types/api/zcashGetAddress.d.ts.map +1 -0
  105. package/dist/types/api/zcashGetFullViewingKey.d.ts +13 -0
  106. package/dist/types/api/zcashGetFullViewingKey.d.ts.map +1 -0
  107. package/dist/types/api/zcashSignPczt.d.ts +9 -0
  108. package/dist/types/api/zcashSignPczt.d.ts.map +1 -0
  109. package/dist/types/device.d.ts +10 -0
  110. package/dist/types/device.d.ts.map +1 -1
  111. package/dist/types/params.d.ts +1 -0
  112. package/dist/types/params.d.ts.map +1 -1
  113. package/dist/utils/logger.d.ts.map +1 -1
  114. package/dist/utils/patch.d.ts +1 -1
  115. package/dist/utils/patch.d.ts.map +1 -1
  116. package/package.json +4 -4
  117. package/src/api/BaseMethod.ts +2 -0
  118. package/src/api/FileWrite.ts +7 -1
  119. package/src/api/FirmwareUpdateV4.ts +366 -99
  120. package/src/api/GetDeviceState.ts +2 -5
  121. package/src/api/SearchDevices.ts +172 -7
  122. package/src/api/allnetwork/AllNetworkGetAddressBase.ts +55 -15
  123. package/src/api/btc/BTCDeriveContextHash.ts +90 -0
  124. package/src/api/firmware/FirmwareArtifactSource.ts +12 -3
  125. package/src/api/helpers/pathUtils.ts +23 -0
  126. package/src/api/helpers/protocolV2FileWrite.ts +382 -111
  127. package/src/api/index.ts +9 -0
  128. package/src/api/monero/MoneroGetAddress.ts +71 -0
  129. package/src/api/monero/MoneroGetWatchKey.ts +75 -0
  130. package/src/api/monero/MoneroKeyImageSync.ts +170 -0
  131. package/src/api/monero/MoneroSignTransaction.ts +319 -0
  132. package/src/api/monero/helpers.ts +28 -0
  133. package/src/api/protocol-v2/DeviceReboot.ts +2 -0
  134. package/src/api/protocol-v2/DeviceUploadNft.ts +6 -1
  135. package/src/api/protocol-v2/DeviceUploadWallpaper.ts +15 -1
  136. package/src/api/zcash/ZcashGetAddress.ts +108 -0
  137. package/src/api/zcash/ZcashGetFullViewingKey.ts +78 -0
  138. package/src/api/zcash/ZcashSignPczt.ts +152 -0
  139. package/src/api/zcash/helpers.ts +16 -0
  140. package/src/core/index.ts +52 -24
  141. package/src/data/messages/messages-protocol-v2.json +152 -0
  142. package/src/data/messages/messages.json +51 -0
  143. package/src/device/Device.ts +32 -4
  144. package/src/device/DeviceCommands.ts +32 -1
  145. package/src/device/DeviceConnector.ts +4 -4
  146. package/src/device/DevicePool.ts +12 -2
  147. package/src/events/core.ts +2 -2
  148. package/src/events/logBlockEvent.ts +58 -3
  149. package/src/events/ui-request.ts +2 -0
  150. package/src/index.ts +1 -0
  151. package/src/inject.ts +17 -0
  152. package/src/protocols/protocol-v2/resources.ts +6 -1
  153. package/src/protocols/protocol-v2/uiInteraction.ts +1 -1
  154. package/src/types/api/allNetworkGetAddress.ts +8 -1
  155. package/src/types/api/btcDeriveContextHash.ts +15 -0
  156. package/src/types/api/export.ts +17 -0
  157. package/src/types/api/index.ts +24 -0
  158. package/src/types/api/moneroGetAddress.ts +27 -0
  159. package/src/types/api/moneroGetWatchKey.ts +28 -0
  160. package/src/types/api/moneroKeyImageSync.ts +45 -0
  161. package/src/types/api/moneroSignTransaction.ts +54 -0
  162. package/src/types/api/zcashGetAddress.ts +32 -0
  163. package/src/types/api/zcashGetFullViewingKey.ts +24 -0
  164. package/src/types/api/zcashSignPczt.ts +18 -0
  165. package/src/types/device.ts +18 -0
  166. package/src/types/params.ts +1 -1
  167. package/src/utils/logger.ts +4 -0
@@ -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';
@@ -44,6 +45,7 @@ import FirmwareUpdateV3 from '../src/api/FirmwareUpdateV3';
44
45
  import FirmwareUpdateV4, {
45
46
  assertProtocolV2FirmwareTargetsSupported,
46
47
  assertProtocolV2ReconnectIdentity,
48
+ supportsProtocolV2BleFileWriteWindow,
47
49
  } from '../src/api/FirmwareUpdateV4';
48
50
  import GetPassphraseState from '../src/api/GetPassphraseState';
49
51
  import GetOnekeyFeatures from '../src/api/GetOnekeyFeatures';
@@ -67,6 +69,7 @@ import StellarGetAddress from '../src/api/stellar/StellarGetAddress';
67
69
  import BenfenSignMessage from '../src/api/benfen/BenfenSignMessage';
68
70
  import { getBitcoinForkSupportedProtocols } from '../src/api/btc/helpers/versionLimit';
69
71
  import { DataManager } from '../src/data-manager';
72
+ import { LoggerNames, getLogger } from '../src/utils/logger';
70
73
  import { createCoreApi } from '../src/inject';
71
74
  import { Device, preloadSessionCache } from '../src/device/Device';
72
75
  import { deviceWalletSessionStore } from '../src/device/DeviceWalletSessionStore';
@@ -120,7 +123,13 @@ import {
120
123
 
121
124
  import type { DeviceCommands } from '../src/device/DeviceCommands';
122
125
  import type { Features } from '../src/types';
123
- 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';
124
133
 
125
134
  jest.mock('../src/data/config', () => ({
126
135
  getSDKVersion: jest.fn(() => '1.0.0'),
@@ -653,6 +662,162 @@ describe('UploadPortfolio', () => {
653
662
  });
654
663
  });
655
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
+
656
821
  const descriptor = {
657
822
  id: 'ble-id',
658
823
  path: 'usb-path',
@@ -4991,21 +5156,74 @@ describe('Protocol V2 firmware update targets', () => {
4991
5156
  expect(typedCall.mock.calls.map(call => call[0])).toEqual(['DeviceInfoGet']);
4992
5157
  });
4993
5158
 
4994
- test('stops V4 bootloader reconnect polling on a stale BLE bond', async () => {
4995
- const method = new FirmwareUpdateV4({
4996
- id: 1,
4997
- payload: {
4998
- method: 'firmwareUpdateV4',
4999
- },
5000
- });
5001
- const staleBondError = ERRORS.TypedError(HardwareErrorCode.BlePeerRemovedPairingInformation);
5002
- const reconnectProtocolV2Device = jest.fn().mockRejectedValue(staleBondError);
5003
- (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
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);
5004
5184
 
5005
- await expect((method as any).waitForProtocolV2BootloaderMode(60_000, 0)).rejects.toBe(
5006
- staleBondError
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]
5007
5226
  );
5008
- expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
5009
5227
  });
5010
5228
 
5011
5229
  test('polls again when Protocol V2 bootloader serial is temporarily unavailable', async () => {
@@ -8900,6 +9118,587 @@ describe('Protocol V2 firmware update targets', () => {
8900
9118
  expect((method as any).protocolV2SourceUpdateProcess).not.toHaveBeenCalled();
8901
9119
  });
8902
9120
 
9121
+ describe('RESC header check link recovery', () => {
9122
+ const currentHeader = {
9123
+ version: [1, 0, 0],
9124
+ payloadHash: '11'.repeat(64),
9125
+ headerHash: '22'.repeat(64),
9126
+ };
9127
+ const linkLost = () =>
9128
+ ERRORS.TypedError(
9129
+ HardwareErrorCode.RuntimeError,
9130
+ 'Device protocol has not been detected for AA:BB'
9131
+ );
9132
+ const createResource = (name: string) => ({
9133
+ name,
9134
+ source: { size: 3, readAt: jest.fn(), close: jest.fn() },
9135
+ devicePath: `vol0:/bundles/${name}`,
9136
+ ...currentHeader,
9137
+ });
9138
+ const createMethod = () => {
9139
+ const method = new FirmwareUpdateV4({
9140
+ id: 1,
9141
+ payload: {
9142
+ method: 'firmwareUpdateV4',
9143
+ },
9144
+ });
9145
+ method.postTipMessage = jest.fn();
9146
+ method.postProgressMessage = jest.fn();
9147
+ (method as any).protocolV2SourceUpdateProcess = jest.fn(
9148
+ ({ processedSize }: { processedSize: number }) => Promise.resolve(processedSize + 3)
9149
+ );
9150
+ (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
9151
+ (method as any).recoverProtocolV2FileTransfer = jest.fn().mockResolvedValue(undefined);
9152
+ return method;
9153
+ };
9154
+
9155
+ test('reconnects and asks again when the link drops during a header check', async () => {
9156
+ const method = createMethod();
9157
+ (method as any).readProtocolV2DeviceFileHeader = jest
9158
+ .fn()
9159
+ .mockRejectedValueOnce(linkLost())
9160
+ .mockResolvedValue(currentHeader);
9161
+
9162
+ await (method as any).executeProtocolV2TransferPhase({
9163
+ installSources: [],
9164
+ resourceSources: [createResource('images.okpkg'), createResource('fonts.okpkg')],
9165
+ });
9166
+
9167
+ expect((method as any).recoverProtocolV2FileTransfer).toHaveBeenCalledTimes(1);
9168
+ expect((method as any).readProtocolV2DeviceFileHeader).toHaveBeenCalledTimes(3);
9169
+ expect((method as any).protocolV2SourceUpdateProcess).not.toHaveBeenCalled();
9170
+ });
9171
+
9172
+ test.each([
9173
+ ['BLE disconnect', () => ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected)],
9174
+ ['BLE timeout', () => ERRORS.TypedError(HardwareErrorCode.BleTimeoutError)],
9175
+ [
9176
+ 'invalidated Protocol V2 link',
9177
+ () =>
9178
+ Object.assign(new Error('Protocol V2 link generation changed'), {
9179
+ name: 'ProtocolV2LinkError',
9180
+ code: 'generation',
9181
+ }),
9182
+ ],
9183
+ [
9184
+ 'released React Native BLE transport',
9185
+ () => new Error('React Native BLE transport released'),
9186
+ ],
9187
+ ['released Electron BLE transport', () => new Error('Electron BLE transport released')],
9188
+ ['cleaned Electron BLE state', () => new Error('Electron BLE device state cleaned')],
9189
+ [
9190
+ 'Electron BLE device that is no longer connected',
9191
+ () => ERRORS.TypedError(HardwareErrorCode.TransportNotFound, 'Device AA:BB not connected'),
9192
+ ],
9193
+ ])('rethrows a %s from the header check for recovery', async (_label, createError) => {
9194
+ const method = createMethod();
9195
+ const error = createError();
9196
+ (method as any).readProtocolV2DeviceFileHeader = jest.fn().mockRejectedValue(error);
9197
+
9198
+ await expect(
9199
+ (method as any).isProtocolV2ResourceBundleUpToDate(createResource('images.okpkg'))
9200
+ ).rejects.toBe(error);
9201
+ });
9202
+
9203
+ test('treats a device-side header read failure as stale without reconnecting', async () => {
9204
+ const method = createMethod();
9205
+ (method as any).readProtocolV2DeviceFileHeader = jest
9206
+ .fn()
9207
+ .mockRejectedValue(ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Failure: busy'));
9208
+
9209
+ await (method as any).executeProtocolV2TransferPhase({
9210
+ installSources: [],
9211
+ resourceSources: [createResource('images.okpkg')],
9212
+ });
9213
+
9214
+ expect((method as any).recoverProtocolV2FileTransfer).not.toHaveBeenCalled();
9215
+ expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenCalledTimes(1);
9216
+ });
9217
+
9218
+ test('limits header-check reconnects per transfer phase', async () => {
9219
+ const method = createMethod();
9220
+ (method as any).readProtocolV2DeviceFileHeader = jest.fn().mockRejectedValue(linkLost());
9221
+
9222
+ await (method as any).executeProtocolV2TransferPhase({
9223
+ installSources: [],
9224
+ resourceSources: ['a.okpkg', 'b.okpkg', 'c.okpkg'].map(createResource),
9225
+ });
9226
+
9227
+ expect((method as any).recoverProtocolV2FileTransfer).toHaveBeenCalledTimes(2);
9228
+ expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenCalledTimes(3);
9229
+ });
9230
+
9231
+ test.each([
9232
+ ['no device to reconnect to', () => ERRORS.TypedError(HardwareErrorCode.DeviceNotFound)],
9233
+ [
9234
+ 'an identity mismatch',
9235
+ () =>
9236
+ ERRORS.TypedError(
9237
+ HardwareErrorCode.DeviceNotFound,
9238
+ 'Protocol V2 reconnect physical identity mismatch: expected A, received B'
9239
+ ),
9240
+ ],
9241
+ [
9242
+ 'an unavailable identity',
9243
+ () =>
9244
+ ERRORS.TypedError(
9245
+ HardwareErrorCode.DeviceNotFound,
9246
+ 'Protocol V2 reconnect physical identity unavailable: expected path A'
9247
+ ),
9248
+ ],
9249
+ [
9250
+ 'a failed identity query',
9251
+ () => ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Failure: busy'),
9252
+ ],
9253
+ ])(
9254
+ 'aborts instead of resending when the header-check reconnect fails with %s',
9255
+ async (_label, createError) => {
9256
+ const method = createMethod();
9257
+ const error = createError();
9258
+ (method as any).readProtocolV2DeviceFileHeader = jest.fn().mockRejectedValue(linkLost());
9259
+ (method as any).recoverProtocolV2FileTransfer = jest.fn().mockRejectedValue(error);
9260
+
9261
+ await expect(
9262
+ (method as any).executeProtocolV2TransferPhase({
9263
+ installSources: [],
9264
+ resourceSources: [createResource('images.okpkg'), createResource('fonts.okpkg')],
9265
+ })
9266
+ ).rejects.toBe(error);
9267
+ expect((method as any).recoverProtocolV2FileTransfer).toHaveBeenCalledTimes(1);
9268
+ expect((method as any).protocolV2SourceUpdateProcess).not.toHaveBeenCalled();
9269
+ }
9270
+ );
9271
+
9272
+ test('writes nothing to a reconnected device whose identity query fails', async () => {
9273
+ const method = createMethod();
9274
+ // Run the real recovery: the session is replaced before the identity check.
9275
+ delete (method as any).recoverProtocolV2FileTransfer;
9276
+ const initialize = jest.fn();
9277
+ (method as any).device = stubDevice({ initialize });
9278
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
9279
+ (method as any).verifyProtocolV2ReconnectIdentity = jest
9280
+ .fn()
9281
+ .mockRejectedValue(ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Failure: busy'));
9282
+ (method as any).readProtocolV2DeviceFileHeader = jest.fn().mockRejectedValue(linkLost());
9283
+
9284
+ await expect(
9285
+ (method as any).executeProtocolV2TransferPhase({
9286
+ installSources: [],
9287
+ resourceSources: [createResource('images.okpkg')],
9288
+ })
9289
+ ).rejects.toMatchObject({ message: expect.stringContaining('Failure: busy') });
9290
+ expect((method as any).reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
9291
+ expect(initialize).not.toHaveBeenCalled();
9292
+ expect((method as any).protocolV2SourceUpdateProcess).not.toHaveBeenCalled();
9293
+ });
9294
+
9295
+ test('reports a cancel that tears the header-check reconnect down as the cancel', async () => {
9296
+ const method = createMethod();
9297
+ delete (method as any).recoverProtocolV2FileTransfer;
9298
+ const abortController = new AbortController();
9299
+ method.abortSignal = abortController.signal;
9300
+ (method as any).reconnectProtocolV2Device = jest.fn(() => {
9301
+ abortController.abort();
9302
+ return Promise.reject(ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected));
9303
+ });
9304
+ (method as any).readProtocolV2DeviceFileHeader = jest.fn().mockRejectedValue(linkLost());
9305
+
9306
+ await expect(
9307
+ (method as any).executeProtocolV2TransferPhase({
9308
+ installSources: [],
9309
+ resourceSources: [createResource('images.okpkg')],
9310
+ })
9311
+ ).rejects.toMatchObject({ errorCode: HardwareErrorCode.CallQueueActionCancelled });
9312
+ expect((method as any).protocolV2SourceUpdateProcess).not.toHaveBeenCalled();
9313
+ });
9314
+ });
9315
+
9316
+ test.each([
9317
+ ['1.0.3', '1.0.3', true],
9318
+ ['1.0.10', '1.0.3', true],
9319
+ ['1.0.2', '1.0.3', false],
9320
+ ['0.0.0.1', '1.0.3', false],
9321
+ ['1.0.3', '', false],
9322
+ ] as const)(
9323
+ 'gates the BLE file write window on bootloader %s with minimum %s',
9324
+ (bootloaderVersion, minBootloaderVersion, expected) => {
9325
+ expect(supportsProtocolV2BleFileWriteWindow(bootloaderVersion, minBootloaderVersion)).toBe(
9326
+ expected
9327
+ );
9328
+ }
9329
+ );
9330
+
9331
+ test('keeps released 1.0.2 bootloaders serial by default', () => {
9332
+ expect(supportsProtocolV2BleFileWriteWindow('1.0.3')).toBe(true);
9333
+ expect(supportsProtocolV2BleFileWriteWindow('1.0.2')).toBe(false);
9334
+ });
9335
+
9336
+ test.each([
9337
+ ['react-native', 'bootloader', true],
9338
+ ['desktop-web-ble', 'bootloader', true],
9339
+ ['lowlevel', 'bootloader', false],
9340
+ ['webusb', 'bootloader', false],
9341
+ ['react-native', 'normal', false],
9342
+ ] as const)(
9343
+ 'enables the BLE file write window for %s in %s mode only with a supported bootloader',
9344
+ (env, mode, expected) => {
9345
+ const getSettingsSpy = jest.spyOn(DataManager, 'getSettings').mockReturnValue(env);
9346
+ const method = new FirmwareUpdateV4({
9347
+ id: 1,
9348
+ payload: {
9349
+ method: 'firmwareUpdateV4',
9350
+ },
9351
+ });
9352
+ (method as any).device = stubDevice({
9353
+ features: { mode, bootloaderMode: mode === 'bootloader', bootloaderVersion: '1.0.3' },
9354
+ });
9355
+
9356
+ try {
9357
+ expect((method as any).shouldUseProtocolV2BleFileWriteWindow('1.0.3')).toBe(expected);
9358
+ expect((method as any).shouldUseProtocolV2BleFileWriteWindow('1.0.4')).toBe(false);
9359
+ } finally {
9360
+ getSettingsSpy.mockRestore();
9361
+ }
9362
+ }
9363
+ );
9364
+
9365
+ test.each([
9366
+ ['1.0.2', false],
9367
+ ['1.0.3', true],
9368
+ // The parsed bootloader version drops prerelease tags.
9369
+ ['1.0.3-rc.1', true],
9370
+ ] as const)(
9371
+ 'gates the default minimum on a running %s bootloader in React Native',
9372
+ (bootloaderVersion, expected) => {
9373
+ const getSettingsSpy = jest.spyOn(DataManager, 'getSettings').mockReturnValue('react-native');
9374
+ const method = new FirmwareUpdateV4({
9375
+ id: 1,
9376
+ payload: {
9377
+ method: 'firmwareUpdateV4',
9378
+ },
9379
+ });
9380
+ (method as any).device = stubDevice({
9381
+ features: { mode: 'bootloader', bootloaderMode: true, bootloaderVersion },
9382
+ });
9383
+
9384
+ try {
9385
+ expect((method as any).shouldUseProtocolV2BleFileWriteWindow()).toBe(expected);
9386
+ } finally {
9387
+ getSettingsSpy.mockRestore();
9388
+ }
9389
+ }
9390
+ );
9391
+
9392
+ describe('windowed BLE file write', () => {
9393
+ const confirm = (request: { file: { offset: number; data: ArrayBuffer } }) => ({
9394
+ type: 'FilesystemFile',
9395
+ message: { processed_byte: request.file.offset + request.file.data.byteLength },
9396
+ });
9397
+ const streamAll = (requests: any[], onConfirmed?: () => void) =>
9398
+ jest.fn(async (_name: string, _resType: string, first: any, options: any) => {
9399
+ // A serial write sends one chunk per call.
9400
+ if (!options.fileWritePipeline) {
9401
+ requests.push(first);
9402
+ return confirm(first);
9403
+ }
9404
+ const { next, onResponse } = options.fileWritePipeline;
9405
+ let request = first;
9406
+ let last;
9407
+ while (request) {
9408
+ requests.push(request);
9409
+ last = confirm(request);
9410
+ onConfirmed?.();
9411
+ onResponse(last);
9412
+ request = await next();
9413
+ }
9414
+ return last;
9415
+ });
9416
+ const createMethod = (typedCall: jest.Mock) => {
9417
+ const method = new FirmwareUpdateV4({
9418
+ id: 1,
9419
+ payload: {
9420
+ method: 'firmwareUpdateV4',
9421
+ },
9422
+ });
9423
+ (method as any).device = stubDevice({ getCommands: () => ({ typedCall }) });
9424
+ (method as any).shouldUseProtocolV2BleFileWriteWindow = jest.fn().mockReturnValue(true);
9425
+ (method as any).getProtocolV2FirmwareChunkSize = jest.fn().mockReturnValue(1000);
9426
+ (method as any).recoverProtocolV2FileTransfer = jest.fn().mockResolvedValue(undefined);
9427
+ method.postProgressMessage = jest.fn();
9428
+ return method;
9429
+ };
9430
+ const transfer = async (method: FirmwareUpdateV4, filePath = 'vol0:/application_p1.bin') => {
9431
+ const source = await openFirmwareByteSource({ binary: new Uint8Array(2500).buffer });
9432
+ try {
9433
+ const processedSize: number = await (method as any).protocolV2SourceUpdateProcess({
9434
+ source,
9435
+ filePath,
9436
+ processedSize: 500,
9437
+ totalSize: 3000,
9438
+ });
9439
+ return processedSize;
9440
+ } finally {
9441
+ await source?.close();
9442
+ }
9443
+ };
9444
+
9445
+ test('streams the file through one FileWrite call and reports confirmed chunks', async () => {
9446
+ const requests: any[] = [];
9447
+ const typedCall = streamAll(requests);
9448
+ const method = createMethod(typedCall);
9449
+
9450
+ await expect(transfer(method)).resolves.toBe(3000);
9451
+
9452
+ expect(typedCall).toHaveBeenCalledTimes(1);
9453
+ expect(typedCall).toHaveBeenCalledWith(
9454
+ 'FilesystemFileWrite',
9455
+ 'FilesystemFile',
9456
+ expect.anything(),
9457
+ expect.objectContaining({
9458
+ writeWithResponse: false,
9459
+ fileWritePipeline: expect.objectContaining({
9460
+ next: expect.any(Function),
9461
+ onResponse: expect.any(Function),
9462
+ }),
9463
+ })
9464
+ );
9465
+ expect(requests.map(request => request.file.offset)).toEqual([0, 1000, 2000]);
9466
+ expect(requests.map(request => request.file.data.byteLength)).toEqual([1000, 1000, 500]);
9467
+ expect(requests.map(request => request.overwrite)).toEqual([true, false, false]);
9468
+ expect(requests.map(request => request.ui_percentage)).toEqual([50, 84, 99]);
9469
+ requests.forEach(request =>
9470
+ expect(request).toMatchObject({
9471
+ append: false,
9472
+ file: { total_size: 2500, path: 'vol0:/application_p1.bin' },
9473
+ })
9474
+ );
9475
+ const transferredBytes = (method.postProgressMessage as jest.Mock).mock.calls.map(
9476
+ ([, , metrics]) => metrics.transferredBytes
9477
+ );
9478
+ expect(transferredBytes).toEqual([1500, 2500, 3000]);
9479
+ });
9480
+
9481
+ test('caps windowed staging writes at the generic BLE chunk', async () => {
9482
+ const requests: any[] = [];
9483
+ const method = createMethod(streamAll(requests));
9484
+ delete (method as any).getProtocolV2FirmwareChunkSize;
9485
+ (method as any).params = { platform: 'native', chunkSize: 4096 };
9486
+
9487
+ await expect(transfer(method)).resolves.toBe(3000);
9488
+
9489
+ expect(requests.map(request => request.file.offset)).toEqual([0, 1800]);
9490
+ expect(requests.map(request => request.file.data.byteLength)).toEqual([1800, 700]);
9491
+ });
9492
+
9493
+ test.each(['react-native', 'desktop-web-ble'] as const)(
9494
+ 'bounds each windowed response wait in %s',
9495
+ async env => {
9496
+ const getSettingsSpy = jest.spyOn(DataManager, 'getSettings').mockReturnValue(env);
9497
+ const typedCall = streamAll([]);
9498
+
9499
+ try {
9500
+ await transfer(createMethod(typedCall));
9501
+ } finally {
9502
+ getSettingsSpy.mockRestore();
9503
+ }
9504
+
9505
+ expect(typedCall.mock.calls[0][3]).toEqual(expect.objectContaining({ timeoutMs: 10_000 }));
9506
+ }
9507
+ );
9508
+
9509
+ const failWindowThenWriteSerial = (requests: any[]) => {
9510
+ const writeSerial = streamAll(requests);
9511
+ return jest.fn((name: string, resType: string, first: any, options: any) => {
9512
+ if (options.fileWritePipeline) {
9513
+ return Promise.reject(new Error('Protocol V2 file write response does not match'));
9514
+ }
9515
+ return writeSerial(name, resType, first, options);
9516
+ });
9517
+ };
9518
+
9519
+ test('falls back to serial whole-file overwrite after a failed windowed exchange', async () => {
9520
+ const requests: any[] = [];
9521
+ const typedCall = failWindowThenWriteSerial(requests);
9522
+ const method = createMethod(typedCall);
9523
+
9524
+ await expect(transfer(method)).resolves.toBe(3000);
9525
+
9526
+ expect((method as any).recoverProtocolV2FileTransfer).toHaveBeenCalledTimes(1);
9527
+ // One failed pipeline, then serial chunks overwriting the file from offset 0.
9528
+ expect(typedCall).toHaveBeenCalledTimes(4);
9529
+ expect(typedCall.mock.calls[0][3]).toHaveProperty('fileWritePipeline');
9530
+ typedCall.mock.calls
9531
+ .slice(1)
9532
+ .forEach(call => expect(call[3]).not.toHaveProperty('fileWritePipeline'));
9533
+ expect(typedCall.mock.calls[1][2]).toEqual(
9534
+ expect.objectContaining({ overwrite: true, file: expect.objectContaining({ offset: 0 }) })
9535
+ );
9536
+ expect(requests.map(request => request.file.offset)).toEqual([0, 1000, 2000]);
9537
+ const chunkSize = (method as any).getProtocolV2FirmwareChunkSize as jest.Mock;
9538
+ expect(chunkSize).toHaveBeenNthCalledWith(1, 'write', 'vol0:/application_p1.bin', true);
9539
+ expect(chunkSize).toHaveBeenNthCalledWith(2, 'write', 'vol0:/application_p1.bin', false);
9540
+ });
9541
+
9542
+ test('keeps subsequent files serial after a failed windowed exchange', async () => {
9543
+ const typedCall = failWindowThenWriteSerial([]);
9544
+ const method = createMethod(typedCall);
9545
+ await transfer(method);
9546
+ typedCall.mockClear();
9547
+
9548
+ await expect(transfer(method, 'vol0:/resource.bin')).resolves.toBe(3000);
9549
+
9550
+ expect(typedCall).toHaveBeenCalledTimes(3);
9551
+ typedCall.mock.calls.forEach(call => {
9552
+ expect(call[3]).not.toHaveProperty('fileWritePipeline');
9553
+ expect(call[2].file.path).toBe('vol0:/resource.bin');
9554
+ });
9555
+ expect((method as any).shouldUseProtocolV2BleFileWriteWindow).toHaveBeenCalledTimes(1);
9556
+ });
9557
+
9558
+ test('bounds failed file transfers to three attempts after serial fallback', async () => {
9559
+ const typedCall = jest.fn().mockRejectedValue(new Error('BLE response timeout'));
9560
+ const method = createMethod(typedCall);
9561
+ await expect(transfer(method)).rejects.toMatchObject({
9562
+ errorCode: HardwareErrorCode.EmmcFileWriteFirmwareError,
9563
+ });
9564
+ expect(typedCall).toHaveBeenCalledTimes(3);
9565
+ expect((method as any).recoverProtocolV2FileTransfer).toHaveBeenCalledTimes(2);
9566
+ expect(typedCall.mock.calls[0][3]).toHaveProperty('fileWritePipeline');
9567
+ typedCall.mock.calls
9568
+ .slice(1)
9569
+ .forEach(call => expect(call[3]).not.toHaveProperty('fileWritePipeline'));
9570
+ });
9571
+
9572
+ test('logs confirmed progress every ten seconds and average speed through Core', async () => {
9573
+ let now = 1000;
9574
+ const clock = jest.spyOn(Date, 'now').mockImplementation(() => now);
9575
+ const metrics = jest.spyOn(getLogger(LoggerNames.Core), 'log');
9576
+ const method = createMethod(
9577
+ streamAll([], () => {
9578
+ now += 5000;
9579
+ })
9580
+ );
9581
+ try {
9582
+ await expect(transfer(method)).resolves.toBe(3000);
9583
+ expect(metrics).toHaveBeenCalledTimes(3);
9584
+ expect(metrics).toHaveBeenNthCalledWith(
9585
+ 2,
9586
+ '[FirmwareUpdateV4] FileWrite metrics',
9587
+ expect.objectContaining({
9588
+ status: 'progress',
9589
+ fileWriteWindow: 2,
9590
+ chunkSize: 1000,
9591
+ transferredBytes: 2000,
9592
+ totalBytes: 2500,
9593
+ elapsedMs: 10_000,
9594
+ rateBytesPerSecond: 200,
9595
+ attempt: 1,
9596
+ retryCount: 0,
9597
+ })
9598
+ );
9599
+ expect(metrics).toHaveBeenLastCalledWith(
9600
+ '[FirmwareUpdateV4] FileWrite metrics',
9601
+ expect.objectContaining({
9602
+ status: 'completed',
9603
+ transferredBytes: 2500,
9604
+ elapsedMs: 15_000,
9605
+ rateBytesPerSecond: 167,
9606
+ })
9607
+ );
9608
+ } finally {
9609
+ clock.mockRestore();
9610
+ metrics.mockRestore();
9611
+ }
9612
+ });
9613
+
9614
+ test('stops sending on cancel and raises it after the transport call returns', async () => {
9615
+ const abortController = new AbortController();
9616
+ const requests: any[] = [];
9617
+ const succeed = streamAll(requests);
9618
+ const typedCall = jest.fn((name: string, resType: string, first: any, options: any) =>
9619
+ succeed(name, resType, first, {
9620
+ ...options,
9621
+ fileWritePipeline: {
9622
+ ...options.fileWritePipeline,
9623
+ onResponse: (response: any) => {
9624
+ options.fileWritePipeline.onResponse(response);
9625
+ abortController.abort();
9626
+ },
9627
+ },
9628
+ })
9629
+ );
9630
+ const method = createMethod(typedCall);
9631
+ method.abortSignal = abortController.signal;
9632
+
9633
+ await expect(transfer(method)).rejects.toMatchObject({
9634
+ errorCode: HardwareErrorCode.CallQueueActionCancelled,
9635
+ });
9636
+ expect(requests.map(request => request.file.offset)).toEqual([0]);
9637
+ expect((method as any).recoverProtocolV2FileTransfer).not.toHaveBeenCalled();
9638
+ });
9639
+
9640
+ test('raises a source read error after the windowed call and retries the file windowed', async () => {
9641
+ const requests: any[] = [];
9642
+ const typedCall = streamAll(requests);
9643
+ const method = createMethod(typedCall);
9644
+ const bytes = new Uint8Array(2500);
9645
+ let failed = false;
9646
+ const source = {
9647
+ size: bytes.length,
9648
+ readAt: jest.fn((offset: number, length: number) => {
9649
+ if (offset === 1000 && !failed) {
9650
+ failed = true;
9651
+ return Promise.reject(new Error('artifact read failed'));
9652
+ }
9653
+ return Promise.resolve(bytes.slice(offset, offset + length).buffer);
9654
+ }),
9655
+ close: jest.fn(),
9656
+ };
9657
+
9658
+ await expect(
9659
+ (method as any).protocolV2SourceUpdateProcess({
9660
+ source,
9661
+ filePath: 'vol0:/application_p1.bin',
9662
+ processedSize: 0,
9663
+ totalSize: 2500,
9664
+ })
9665
+ ).resolves.toBe(2500);
9666
+
9667
+ // A host-side read error says nothing about the bootloader, so the retry keeps the window.
9668
+ expect(typedCall).toHaveBeenCalledTimes(2);
9669
+ typedCall.mock.calls.forEach(call => expect(call[3]).toHaveProperty('fileWritePipeline'));
9670
+ expect((method as any).recoverProtocolV2FileTransfer).toHaveBeenCalledTimes(1);
9671
+ expect(requests.map(request => request.file.offset)).toEqual([0, 0, 1000, 2000]);
9672
+ });
9673
+
9674
+ test('rejects a windowed write that does not confirm the whole file', async () => {
9675
+ const typedCall = jest.fn((_name: string, _resType: string, first: any) =>
9676
+ Promise.resolve(confirm(first))
9677
+ );
9678
+ const method = createMethod(typedCall);
9679
+ const source = await openFirmwareByteSource({ binary: new Uint8Array(2500).buffer });
9680
+
9681
+ try {
9682
+ await expect(
9683
+ (method as any).writeProtocolV2SourceWindowed({
9684
+ source,
9685
+ filePath: 'vol0:/application_p1.bin',
9686
+ chunkSize: 1000,
9687
+ processedSize: 500,
9688
+ totalSize: 3000,
9689
+ onChunkConfirmed: jest.fn(),
9690
+ })
9691
+ ).rejects.toMatchObject({
9692
+ errorCode: HardwareErrorCode.EmmcFileWriteFirmwareError,
9693
+ message: expect.stringContaining('invalid processed_byte 1000 for file size 2500'),
9694
+ });
9695
+ } finally {
9696
+ await source?.close();
9697
+ }
9698
+ expect(typedCall).toHaveBeenCalledTimes(1);
9699
+ });
9700
+ });
9701
+
8903
9702
  test('uses absolute processed_byte offsets and disables append for firmware file writes', async () => {
8904
9703
  const method = new FirmwareUpdateV4({
8905
9704
  id: 1,
@@ -9009,36 +9808,6 @@ describe('Protocol V2 firmware update targets', () => {
9009
9808
  );
9010
9809
  });
9011
9810
 
9012
- test('does not retry or wrap a stale BLE bond during a V4 file transfer', async () => {
9013
- const method = new FirmwareUpdateV4({
9014
- id: 1,
9015
- payload: {
9016
- method: 'firmwareUpdateV4',
9017
- },
9018
- });
9019
- const staleBondError = ERRORS.TypedError(HardwareErrorCode.BlePeerRemovedPairingInformation);
9020
- (method as any).fileWriteChunk = jest.fn().mockRejectedValue(staleBondError);
9021
- const recoverProtocolV2FileTransfer = jest.fn();
9022
- (method as any).recoverProtocolV2FileTransfer = recoverProtocolV2FileTransfer;
9023
- const source = await openFirmwareByteSource({
9024
- binary: new Uint8Array([1]).buffer,
9025
- });
9026
-
9027
- try {
9028
- await expect(
9029
- (method as any).protocolV2SourceUpdateProcess({
9030
- source,
9031
- filePath: 'vol1:firmware.bin',
9032
- processedSize: 0,
9033
- totalSize: 1,
9034
- })
9035
- ).rejects.toBe(staleBondError);
9036
- } finally {
9037
- await source?.close();
9038
- }
9039
- expect(recoverProtocolV2FileTransfer).not.toHaveBeenCalled();
9040
- });
9041
-
9042
9811
  test('does not recover or wrap a cancelled V4 file transfer', async () => {
9043
9812
  const method = new FirmwareUpdateV4({
9044
9813
  id: 1,
@@ -9659,6 +10428,27 @@ describe('Protocol V2 firmware update targets', () => {
9659
10428
  expect(writePayloads.map(payload => payload.file.data.byteLength)).toEqual([1960, 1]);
9660
10429
  });
9661
10430
 
10431
+ test.each([
10432
+ [false, 'vol0:/application_p1.bin', 1960],
10433
+ [true, 'vol0:/application_p1.bin', 1800],
10434
+ [true, 'vol0:/bundles/images/animation.okpkg', 1800],
10435
+ ])(
10436
+ 'sizes BLE write chunks for fileWriteWindow=%s and %s',
10437
+ (fileWriteWindow, filePath, expected) => {
10438
+ const method = new FirmwareUpdateV4({
10439
+ id: 1,
10440
+ payload: {
10441
+ method: 'firmwareUpdateV4',
10442
+ },
10443
+ });
10444
+ (method as any).params = { platform: 'native', chunkSize: 4096 };
10445
+
10446
+ expect(
10447
+ (method as any).getProtocolV2FirmwareChunkSize('write', filePath, fileWriteWindow)
10448
+ ).toBe(expected);
10449
+ }
10450
+ );
10451
+
9662
10452
  test('keeps confirmation active after the Request write until terminal Success arrives', async () => {
9663
10453
  const method = new FirmwareUpdateV4({
9664
10454
  id: 1,
@@ -10197,8 +10987,7 @@ describe('Protocol V2 firmware reconnect identity', () => {
10197
10987
  });
10198
10988
 
10199
10989
  await expect((method as any).readProtocolV2DeviceFileHeader('bundle.okpp')).resolves.toBeNull();
10200
- expect(readChunkLengths.length).toBeGreaterThan(1);
10201
- expect(Math.max(...readChunkLengths)).toBe(900);
10990
+ expect(readChunkLengths).toEqual([640]);
10202
10991
  });
10203
10992
 
10204
10993
  test('skips resource transfer when installed okpkg hashes match the downloaded package', async () => {
@@ -10254,6 +11043,12 @@ describe('Protocol V2 firmware reconnect identity', () => {
10254
11043
  path: 'vol0:/bundles/images/images.okpkg',
10255
11044
  });
10256
11045
  expect((method as any).protocolV2SourceUpdateProcess).not.toHaveBeenCalled();
11046
+ expect(typedCall).toHaveBeenCalledTimes(2);
11047
+ expect(typedCall).toHaveBeenCalledWith('FilesystemFileRead', 'FilesystemFile', {
11048
+ file: { path: 'vol0:/bundles/images/images.okpkg', offset: 0, total_size: 0 },
11049
+ chunk_len: 640,
11050
+ ui_percentage: undefined,
11051
+ });
10257
11052
  });
10258
11053
 
10259
11054
  test('updates a resource when the installed file size differs despite matching headers', async () => {