@onekeyfe/hd-core 1.2.0-alpha.98 → 1.2.0

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 (204) hide show
  1. package/__tests__/AllNetworkGetAddressBase.tracing.test.ts +20 -4
  2. package/__tests__/DeviceCommands.test.ts +203 -27
  3. package/__tests__/base64Data.test.ts +69 -0
  4. package/__tests__/check-all-firmware-release-protocol-v2.test.ts +89 -38
  5. package/__tests__/check-firmware-release-protocol-v2.test.ts +83 -14
  6. package/__tests__/device-connector-protocol.test.ts +21 -0
  7. package/__tests__/device-lifecycle-events.test.ts +427 -7
  8. package/__tests__/device-pool-state.test.ts +25 -0
  9. package/__tests__/device-settings.test.ts +237 -9
  10. package/__tests__/device-state-events.test.ts +2 -2
  11. package/__tests__/device-state-mapper.test.ts +61 -5
  12. package/__tests__/device-utils.test.ts +15 -1
  13. package/__tests__/device-wallet-session-store.test.ts +147 -21
  14. package/__tests__/deviceSettings.test.ts +7 -3
  15. package/__tests__/deviceUploadNft.test.ts +36 -4
  16. package/__tests__/firmware-update/device-update-bootloader.test.ts +14 -3
  17. package/__tests__/firmware-update/firmware-artifact-source.test.ts +61 -9
  18. package/__tests__/firmware-update/firmware-prepared-host-digest.test.ts +19 -9
  19. package/__tests__/firmware-update/firmware-prepared-resource-archive.test.ts +21 -0
  20. package/__tests__/firmware-update/firmware-update-bootloader-poll.test.ts +128 -2
  21. package/__tests__/firmware-update/firmware-update-plan-bootloader-identity.test.ts +80 -0
  22. package/__tests__/firmware-update/firmware-update-plan.test.ts +114 -50
  23. package/__tests__/firmware-update/firmware-update-prepared-plan.test.ts +19 -5
  24. package/__tests__/firmware-update/firmware-update-v3-reconnect.test.ts +116 -0
  25. package/__tests__/firmware-update/firmware-update-v4-install-poll.test.ts +678 -0
  26. package/__tests__/firmware-update/firmware-update-v4-parameters.test.ts +85 -0
  27. package/__tests__/get-device-state.test.ts +60 -21
  28. package/__tests__/logBlockEvent.test.ts +13 -1
  29. package/__tests__/open-wallet-session.test.ts +127 -1
  30. package/__tests__/protocol-v2-firmware-targets.test.ts +39 -0
  31. package/__tests__/protocol-v2-resources.test.ts +185 -88
  32. package/__tests__/protocol-v2-unlock-policy.test.ts +235 -2
  33. package/__tests__/protocol-v2.test.ts +2274 -466
  34. package/__tests__/refresh-device-state.test.ts +3 -1
  35. package/__tests__/resourceBase64Boundary.test.ts +48 -0
  36. package/__tests__/search-devices.test.ts +79 -32
  37. package/__tests__/ton-sign-message.test.ts +84 -0
  38. package/dist/api/BaseMethod.d.ts +2 -1
  39. package/dist/api/BaseMethod.d.ts.map +1 -1
  40. package/dist/api/CheckAllFirmwareRelease.d.ts.map +1 -1
  41. package/dist/api/CheckBootloaderRelease.d.ts.map +1 -1
  42. package/dist/api/FirmwareUpdateV2.d.ts.map +1 -1
  43. package/dist/api/FirmwareUpdateV3.d.ts.map +1 -1
  44. package/dist/api/FirmwareUpdateV4.d.ts +22 -7
  45. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  46. package/dist/api/OpenWalletSession.d.ts.map +1 -1
  47. package/dist/api/SearchDevices.d.ts.map +1 -1
  48. package/dist/api/UploadPortfolio.d.ts +1 -1
  49. package/dist/api/UploadPortfolio.d.ts.map +1 -1
  50. package/dist/api/allnetwork/AllNetworkGetAddressBase.d.ts.map +1 -1
  51. package/dist/api/device/DeviceChangePin.d.ts.map +1 -1
  52. package/dist/api/device/DeviceRebootToBoardloader.d.ts.map +1 -1
  53. package/dist/api/device/DeviceRebootToBootloader.d.ts.map +1 -1
  54. package/dist/api/device/DeviceSettings.d.ts.map +1 -1
  55. package/dist/api/device/DeviceVerify.d.ts +1 -0
  56. package/dist/api/device/DeviceVerify.d.ts.map +1 -1
  57. package/dist/api/device/DeviceWipe.d.ts.map +1 -1
  58. package/dist/api/firmware/FirmwareArtifactSource.d.ts +5 -1
  59. package/dist/api/firmware/FirmwareArtifactSource.d.ts.map +1 -1
  60. package/dist/api/firmware/FirmwareHostBinding.d.ts.map +1 -1
  61. package/dist/api/firmware/FirmwarePreparedResourceArchive.d.ts.map +1 -1
  62. package/dist/api/firmware/FirmwareUpdateBaseMethod.d.ts +2 -2
  63. package/dist/api/firmware/FirmwareUpdateBaseMethod.d.ts.map +1 -1
  64. package/dist/api/firmware/FirmwareUpdatePlan.d.ts +0 -15
  65. package/dist/api/firmware/FirmwareUpdatePlan.d.ts.map +1 -1
  66. package/dist/api/firmware/FirmwareUpdatePreparedPlan.d.ts +1 -0
  67. package/dist/api/firmware/FirmwareUpdatePreparedPlan.d.ts.map +1 -1
  68. package/dist/api/firmware/getBinary.d.ts +3 -2
  69. package/dist/api/firmware/getBinary.d.ts.map +1 -1
  70. package/dist/api/firmware/protocolV2Release.d.ts.map +1 -1
  71. package/dist/api/helpers/base64Data.d.ts +16 -0
  72. package/dist/api/helpers/base64Data.d.ts.map +1 -0
  73. package/dist/api/protocol-v2/DeviceFirmwareUpdate.d.ts +3 -1
  74. package/dist/api/protocol-v2/DeviceFirmwareUpdate.d.ts.map +1 -1
  75. package/dist/api/protocol-v2/DeviceGetFirmwareUpdateStatus.d.ts.map +1 -1
  76. package/dist/api/protocol-v2/DeviceInfoGet.d.ts +1 -1
  77. package/dist/api/protocol-v2/DeviceInfoGet.d.ts.map +1 -1
  78. package/dist/api/protocol-v2/DeviceReboot.d.ts +1 -1
  79. package/dist/api/protocol-v2/DeviceReboot.d.ts.map +1 -1
  80. package/dist/api/protocol-v2/DeviceUploadNft.d.ts +2 -3
  81. package/dist/api/protocol-v2/DeviceUploadNft.d.ts.map +1 -1
  82. package/dist/api/protocol-v2/DeviceUploadWallpaper.d.ts +2 -3
  83. package/dist/api/protocol-v2/DeviceUploadWallpaper.d.ts.map +1 -1
  84. package/dist/api/protocol-v2/helpers.d.ts.map +1 -1
  85. package/dist/api/ton/TonSignMessage.d.ts.map +1 -1
  86. package/dist/core/index.d.ts +6 -1
  87. package/dist/core/index.d.ts.map +1 -1
  88. package/dist/data-manager/DataManager.d.ts +4 -3
  89. package/dist/data-manager/DataManager.d.ts.map +1 -1
  90. package/dist/data-manager/TransportManager.d.ts.map +1 -1
  91. package/dist/device/Device.d.ts +16 -3
  92. package/dist/device/Device.d.ts.map +1 -1
  93. package/dist/device/DeviceCommands.d.ts +4 -4
  94. package/dist/device/DeviceCommands.d.ts.map +1 -1
  95. package/dist/device/DeviceConnector.d.ts +1 -1
  96. package/dist/device/DeviceConnector.d.ts.map +1 -1
  97. package/dist/device/DevicePool.d.ts.map +1 -1
  98. package/dist/device/DeviceStateMapper.d.ts.map +1 -1
  99. package/dist/deviceProfile/buildDeviceFeatures.d.ts.map +1 -1
  100. package/dist/events/logBlockEvent.d.ts.map +1 -1
  101. package/dist/events/ui-request.d.ts +3 -0
  102. package/dist/events/ui-request.d.ts.map +1 -1
  103. package/dist/index.d.ts +54 -84
  104. package/dist/index.d.ts.map +1 -1
  105. package/dist/index.js +2333 -1515
  106. package/dist/protocols/protocol-v2/features.d.ts +13 -5
  107. package/dist/protocols/protocol-v2/features.d.ts.map +1 -1
  108. package/dist/protocols/protocol-v2/index.d.ts +2 -2
  109. package/dist/protocols/protocol-v2/index.d.ts.map +1 -1
  110. package/dist/protocols/protocol-v2/resources.d.ts +12 -7
  111. package/dist/protocols/protocol-v2/resources.d.ts.map +1 -1
  112. package/dist/protocols/protocol-v2/unlockPolicyRunner.d.ts +3 -1
  113. package/dist/protocols/protocol-v2/unlockPolicyRunner.d.ts.map +1 -1
  114. package/dist/protocols/protocol-v2/walletSession.d.ts +3 -0
  115. package/dist/protocols/protocol-v2/walletSession.d.ts.map +1 -1
  116. package/dist/types/api/firmwareUpdatePlan.d.ts +2 -2
  117. package/dist/types/api/firmwareUpdatePlan.d.ts.map +1 -1
  118. package/dist/types/api/protocolV2.d.ts +1 -1
  119. package/dist/types/api/protocolV2.d.ts.map +1 -1
  120. package/dist/types/device.d.ts.map +1 -1
  121. package/dist/types/settings.d.ts +3 -31
  122. package/dist/types/settings.d.ts.map +1 -1
  123. package/dist/utils/deviceFeaturesCompat.d.ts.map +1 -1
  124. package/dist/utils/deviceFeaturesUtils.d.ts +3 -0
  125. package/dist/utils/deviceFeaturesUtils.d.ts.map +1 -1
  126. package/dist/utils/deviceInfoUtils.d.ts.map +1 -1
  127. package/dist/utils/deviceSettings.d.ts +1 -0
  128. package/dist/utils/deviceSettings.d.ts.map +1 -1
  129. package/dist/utils/index.d.ts +1 -1
  130. package/dist/utils/index.d.ts.map +1 -1
  131. package/dist/utils/patch.d.ts +1 -1
  132. package/dist/utils/patch.d.ts.map +1 -1
  133. package/dist/utils/pro2Nft.d.ts +7 -0
  134. package/dist/utils/pro2Nft.d.ts.map +1 -1
  135. package/package.json +6 -4
  136. package/src/api/BaseMethod.ts +8 -1
  137. package/src/api/CheckAllFirmwareRelease.ts +8 -5
  138. package/src/api/CheckBLEFirmwareRelease.ts +2 -2
  139. package/src/api/CheckBootloaderRelease.ts +3 -2
  140. package/src/api/CheckFirmwareRelease.ts +2 -2
  141. package/src/api/FirmwareUpdateV2.ts +130 -69
  142. package/src/api/FirmwareUpdateV3.ts +29 -19
  143. package/src/api/FirmwareUpdateV4.ts +966 -743
  144. package/src/api/OpenWalletSession.ts +39 -9
  145. package/src/api/PromptWebDeviceAccess.ts +2 -2
  146. package/src/api/SearchDevices.ts +6 -3
  147. package/src/api/UploadPortfolio.ts +9 -2
  148. package/src/api/allnetwork/AllNetworkGetAddressBase.ts +2 -1
  149. package/src/api/device/DeviceChangePin.ts +4 -1
  150. package/src/api/device/DeviceRebootToBoardloader.ts +3 -1
  151. package/src/api/device/DeviceRebootToBootloader.ts +3 -1
  152. package/src/api/device/DeviceSettings.ts +34 -20
  153. package/src/api/device/DeviceVerify.ts +25 -0
  154. package/src/api/device/DeviceWipe.ts +4 -1
  155. package/src/api/firmware/FirmwareArtifactSource.ts +41 -14
  156. package/src/api/firmware/FirmwareHostBinding.ts +10 -0
  157. package/src/api/firmware/FirmwarePreparedResourceArchive.ts +16 -27
  158. package/src/api/firmware/FirmwareUpdateBaseMethod.ts +131 -76
  159. package/src/api/firmware/FirmwareUpdatePlan.ts +55 -82
  160. package/src/api/firmware/FirmwareUpdatePreparedPlan.ts +32 -15
  161. package/src/api/firmware/protocolV2Release.ts +7 -2
  162. package/src/api/helpers/base64Data.ts +85 -0
  163. package/src/api/protocol-v2/DeviceFactoryInfoSet.ts +1 -1
  164. package/src/api/protocol-v2/DeviceFirmwareUpdate.ts +6 -28
  165. package/src/api/protocol-v2/DeviceGetFirmwareUpdateStatus.ts +7 -1
  166. package/src/api/protocol-v2/DeviceInfoGet.ts +3 -3
  167. package/src/api/protocol-v2/DeviceReboot.ts +5 -0
  168. package/src/api/protocol-v2/DeviceUploadNft.ts +61 -9
  169. package/src/api/protocol-v2/DeviceUploadWallpaper.ts +53 -19
  170. package/src/api/protocol-v2/helpers.ts +8 -0
  171. package/src/api/ton/TonSignMessage.ts +5 -3
  172. package/src/core/index.ts +264 -55
  173. package/src/data/messages/messages-protocol-v2.json +140 -13
  174. package/src/data-manager/DataManager.ts +46 -39
  175. package/src/data-manager/TransportManager.ts +3 -1
  176. package/src/device/Device.ts +210 -44
  177. package/src/device/DeviceCommands.ts +27 -18
  178. package/src/device/DeviceConnector.ts +17 -3
  179. package/src/device/DevicePool.ts +9 -2
  180. package/src/device/DeviceStateMapper.ts +30 -24
  181. package/src/deviceProfile/buildDeviceFeatures.ts +10 -5
  182. package/src/events/logBlockEvent.ts +14 -1
  183. package/src/events/ui-request.ts +3 -0
  184. package/src/index.ts +0 -10
  185. package/src/protocols/protocol-v2/features.ts +22 -5
  186. package/src/protocols/protocol-v2/index.ts +2 -0
  187. package/src/protocols/protocol-v2/resources.ts +110 -147
  188. package/src/protocols/protocol-v2/unlockPolicyRunner.ts +13 -1
  189. package/src/protocols/protocol-v2/walletSession.ts +65 -22
  190. package/src/types/api/firmwareUpdate.ts +1 -1
  191. package/src/types/api/firmwareUpdatePlan.ts +2 -2
  192. package/src/types/api/protocolV2.ts +1 -1
  193. package/src/types/device.ts +2 -1
  194. package/src/types/settings.ts +6 -34
  195. package/src/utils/deviceFeaturesCompat.ts +9 -4
  196. package/src/utils/deviceFeaturesUtils.ts +4 -0
  197. package/src/utils/deviceInfoUtils.ts +9 -2
  198. package/src/utils/deviceSettings.ts +11 -4
  199. package/src/utils/index.ts +1 -0
  200. package/src/utils/pro2Nft.ts +31 -8
  201. package/__tests__/firmware-memory-host.test.ts +0 -112
  202. package/dist/api/firmware/FirmwareMemoryHost.d.ts +0 -22
  203. package/dist/api/firmware/FirmwareMemoryHost.d.ts.map +0 -1
  204. package/src/api/firmware/FirmwareMemoryHost.ts +0 -142
@@ -1,7 +1,9 @@
1
- import { EFirmwareType, ERRORS, HardwareErrorCode } from '@onekeyfe/hd-shared';
1
+ import { Buffer } from 'buffer';
2
+ import { EDeviceType, EFirmwareType, ERRORS, HardwareErrorCode } from '@onekeyfe/hd-shared';
2
3
  import { sha256 } from '@noble/hashes/sha256';
3
4
  import { bytesToHex } from '@noble/hashes/utils';
4
5
  import JSZip from 'jszip';
6
+ import { encode as encodeJpeg } from 'jpeg-js';
5
7
  import {
6
8
  DeviceRebootType,
7
9
  DeviceSessionPinType,
@@ -123,23 +125,43 @@ jest.mock('../src/data/config', () => ({
123
125
  DEFAULT_DOMAIN: 'https://jssdk.onekey.so/1.0.0/',
124
126
  }));
125
127
 
128
+ const jpegBase64Cache = new Map<string, string>();
129
+
130
+ const createJpegBase64 = (width: number, height: number) => {
131
+ const key = `${width}x${height}`;
132
+ const cached = jpegBase64Cache.get(key);
133
+ if (cached) return cached;
134
+ const rgba = new Uint8Array(width * height * 4).fill(0xff);
135
+ const value = encodeJpeg({ width, height, data: rgba }, 80).data.toString('base64');
136
+ jpegBase64Cache.set(key, value);
137
+ return value;
138
+ };
139
+
126
140
  const createProtocolV2OkppBinary = ({
127
141
  version = [1, 2, 3],
142
+ devicePath = 'vol0:/bundles/images/images.okpkg',
128
143
  payloadHashByte = 0x11,
129
144
  headerHashByte = 0x22,
130
145
  }: {
131
146
  version?: [number, number, number];
147
+ devicePath?: string;
132
148
  payloadHashByte?: number;
133
149
  headerHashByte?: number;
134
150
  } = {}) => {
135
- const bytes = new Uint8Array(0x52a0);
151
+ const headerSize = 0x5f90;
152
+ const payloadSize = 1;
153
+ const bytes = new Uint8Array(headerSize + payloadSize);
136
154
  bytes.set([0x4f, 0x4b, 0x50, 0x50], 0);
137
155
  bytes.set([0x52, 0x45, 0x53, 0x43], 0x08);
138
156
  const view = new DataView(bytes.buffer);
139
- view.setUint32(0x0c, 0x52a0, true);
157
+ view.setUint32(0x04, 1, true);
158
+ view.setUint32(0x0c, headerSize, true);
140
159
  view.setUint32(0x10, version[0] * 0x10000 + version[1] * 0x100 + version[2], true);
160
+ view.setUint32(0x14, payloadSize, true);
161
+ bytes.set(new TextEncoder().encode(devicePath), 0x6c);
141
162
  bytes.fill(payloadHashByte, 0x200, 0x240);
142
163
  bytes.fill(headerHashByte, 0x240, 0x280);
164
+ bytes[headerSize] = 0xaa;
143
165
  return bytes.buffer;
144
166
  };
145
167
 
@@ -160,8 +182,19 @@ const createWalletSessionTypedCall = (
160
182
  });
161
183
 
162
184
  describe('DeviceUploadWallpaper', () => {
185
+ const wallpaperProtocolInfo = {
186
+ version: 2,
187
+ supported_messages: [60412, 60805, 60809],
188
+ };
189
+
190
+ const stubWallpaperDevice = <T extends Record<string, any>>(device: T) =>
191
+ stubDevice({
192
+ ...device,
193
+ ensureProtocolV2RuntimeContext: jest.fn().mockResolvedValue(wallpaperProtocolInfo),
194
+ refreshProtocolV2SettingsAfterMutation: jest.fn().mockResolvedValue({}),
195
+ });
196
+
163
197
  test('encodes, uploads and applies a Pro2 wallpaper', async () => {
164
- const rgba = new Uint8Array(604 * 1024 * 4).fill(255);
165
198
  const typedCall = jest.fn().mockImplementation((request, _response, params) => {
166
199
  if (request === 'FilesystemDirMake') return { message: { message: 'directory ready' } };
167
200
  if (request === 'FilesystemFileWrite') {
@@ -175,16 +208,21 @@ describe('DeviceUploadWallpaper', () => {
175
208
  });
176
209
  const method = new DeviceUploadWallpaper({
177
210
  id: 1,
178
- payload: { method: 'deviceUploadWallpaper', width: 604, height: 1024, rgba },
211
+ payload: {
212
+ method: 'deviceUploadWallpaper',
213
+ jpegBase64: createJpegBase64(604, 1024),
214
+ },
179
215
  });
180
- (method as any).device = stubDevice({ commands: { typedCall } });
216
+ const device = stubWallpaperDevice({ commands: { typedCall } });
217
+ (method as any).device = device;
181
218
  method.postMessage = jest.fn();
182
219
 
183
220
  method.init();
184
221
  const result = await method.run();
185
222
 
186
223
  expect(method.getSupportedProtocols()).toEqual(['V2']);
187
- expect(method.unlockPolicy).toBe('none');
224
+ expect(method.unlockPolicy).toBe('unlock-before-run');
225
+ expect(method.protocolV2PreUnlockPinType).toBe(DeviceSessionPinType.Any);
188
226
  expect(typedCall).toHaveBeenNthCalledWith(1, 'FilesystemDirMake', 'Success', {
189
227
  path: 'vol1:/wallpapers',
190
228
  });
@@ -197,6 +235,7 @@ describe('DeviceUploadWallpaper', () => {
197
235
  expect(typedCall).toHaveBeenLastCalledWith('DeviceSettingsSet', 'Success', {
198
236
  settings: { wallpaper_path: result.path },
199
237
  });
238
+ expect(device.refreshProtocolV2SettingsAfterMutation).toHaveBeenCalledTimes(1);
200
239
  expect(typedCall.mock.calls.some(call => call[0] === 'SetWallpaper')).toBe(false);
201
240
  expect(result).toMatchObject({ colorFormat: 'RGB565', message: 'wallpaper applied' });
202
241
  const fileWriteCallCount = typedCall.mock.calls.filter(
@@ -225,32 +264,82 @@ describe('DeviceUploadWallpaper', () => {
225
264
  id: 1,
226
265
  payload: {
227
266
  method: 'deviceUploadWallpaper',
228
- width: 604,
229
- height: 1024,
230
- rgba: new Uint8Array(604 * 1024 * 4),
267
+ jpegBase64: createJpegBase64(604, 1024),
231
268
  },
232
269
  });
233
- (method as any).device = stubDevice({ commands: { typedCall } });
270
+ (method as any).device = stubWallpaperDevice({ commands: { typedCall } });
234
271
  method.init();
235
272
 
236
273
  await expect(method.run()).rejects.toThrow('write failed');
237
274
  expect(typedCall.mock.calls.some(call => call[0] === 'DeviceSettingsSet')).toBe(false);
238
275
  });
239
276
 
277
+ test('returns success when wallpaper read-back fails after apply', async () => {
278
+ const typedCall = jest.fn().mockImplementation((request, _response, params) => {
279
+ if (request === 'FilesystemDirMake') return { message: {} };
280
+ if (request === 'FilesystemFileWrite') {
281
+ const file = params.file as { data: Uint8Array; offset: number };
282
+ return { message: { processed_byte: file.offset + file.data.byteLength } };
283
+ }
284
+ if (request === 'DeviceSettingsSet') {
285
+ return { message: { message: 'wallpaper applied' } };
286
+ }
287
+ throw new Error(`Unexpected request: ${request}`);
288
+ });
289
+ const method = new DeviceUploadWallpaper({
290
+ id: 1,
291
+ payload: {
292
+ method: 'deviceUploadWallpaper',
293
+ jpegBase64: createJpegBase64(604, 1024),
294
+ },
295
+ });
296
+ const device = stubWallpaperDevice({ commands: { typedCall } });
297
+ device.refreshProtocolV2SettingsAfterMutation.mockRejectedValueOnce(
298
+ new Error('settings read-back failed')
299
+ );
300
+ (method as any).device = device;
301
+ method.init();
302
+
303
+ await expect(method.run()).resolves.toMatchObject({ message: 'wallpaper applied' });
304
+ expect(device.refreshProtocolV2SettingsAfterMutation).toHaveBeenCalledTimes(1);
305
+ });
306
+
240
307
  test('rejects unsafe filenames before device communication', () => {
241
308
  const method = new DeviceUploadWallpaper({
242
309
  id: 1,
243
310
  payload: {
244
311
  method: 'deviceUploadWallpaper',
245
- width: 604,
246
- height: 1024,
247
- rgba: new Uint8Array(604 * 1024 * 4),
312
+ jpegBase64: createJpegBase64(604, 1024),
248
313
  fileName: '../wallpaper.bin',
249
314
  },
250
315
  });
251
316
 
252
317
  expect(() => method.init()).toThrow('fileName');
253
318
  });
319
+
320
+ test('rejects unsupported firmware before creating or writing files', async () => {
321
+ const typedCall = jest.fn();
322
+ const method = new DeviceUploadWallpaper({
323
+ id: 1,
324
+ payload: {
325
+ method: 'deviceUploadWallpaper',
326
+ jpegBase64: createJpegBase64(604, 1024),
327
+ },
328
+ });
329
+ (method as any).device = stubDevice({
330
+ commands: { typedCall },
331
+ ensureProtocolV2RuntimeContext: jest.fn().mockResolvedValue({
332
+ version: 2,
333
+ supported_messages: [60805, 60809],
334
+ }),
335
+ });
336
+ method.init();
337
+
338
+ await expect(method.run()).rejects.toMatchObject({
339
+ errorCode: HardwareErrorCode.DeviceNotSupportMethod,
340
+ });
341
+ expect(typedCall).not.toHaveBeenCalled();
342
+ });
254
343
  });
255
344
 
256
345
  describe('UploadPortfolio', () => {
@@ -266,7 +355,6 @@ describe('UploadPortfolio', () => {
266
355
  });
267
356
 
268
357
  test('writes and applies the portfolio while the device is locked without unlocking', async () => {
269
- const packageBytes = new Uint8Array([1, 2, 3]);
270
358
  const typedCall = jest
271
359
  .fn()
272
360
  .mockResolvedValueOnce({ message: { processed_byte: 3 } })
@@ -282,7 +370,7 @@ describe('UploadPortfolio', () => {
282
370
  id: 1,
283
371
  payload: {
284
372
  method: 'uploadPortfolio',
285
- packageBytes,
373
+ packageBase64: 'AQID',
286
374
  },
287
375
  });
288
376
  (method as any).device = device;
@@ -314,7 +402,7 @@ describe('UploadPortfolio', () => {
314
402
  id: 1,
315
403
  payload: {
316
404
  method: 'uploadPortfolio',
317
- packageBytes,
405
+ packageBase64: 'AQID',
318
406
  },
319
407
  });
320
408
  (method as any).device = stubPortfolioDevice({ commands: { typedCall } });
@@ -362,7 +450,7 @@ describe('UploadPortfolio', () => {
362
450
  id: 1,
363
451
  payload: {
364
452
  method: 'uploadPortfolio',
365
- packageBytes: new Uint8Array([1]),
453
+ packageBase64: 'AQ==',
366
454
  },
367
455
  });
368
456
  (method as any).device = stubPortfolioDevice({ commands: { typedCall } });
@@ -384,7 +472,7 @@ describe('UploadPortfolio', () => {
384
472
  id: 1,
385
473
  payload: {
386
474
  method: 'uploadPortfolio',
387
- packageBytes,
475
+ packageBase64: Buffer.from(packageBytes).toString('base64'),
388
476
  },
389
477
  });
390
478
  method.abortSignal = abortController.signal;
@@ -405,7 +493,7 @@ describe('UploadPortfolio', () => {
405
493
  id: 1,
406
494
  payload: {
407
495
  method: 'uploadPortfolio',
408
- packageBytes: new Uint8Array([1]),
496
+ packageBase64: 'AQ==',
409
497
  },
410
498
  });
411
499
  (method as any).device = stubDevice({
@@ -423,6 +511,18 @@ describe('UploadPortfolio', () => {
423
511
  });
424
512
  expect(typedCall).not.toHaveBeenCalled();
425
513
  });
514
+
515
+ test('rejects non-canonical Base64 before device communication', () => {
516
+ const method = new UploadPortfolio({
517
+ id: 1,
518
+ payload: {
519
+ method: 'uploadPortfolio',
520
+ packageBase64: 'data:application/octet-stream;base64,AQID',
521
+ },
522
+ });
523
+
524
+ expect(() => method.init()).toThrow('canonical Base64');
525
+ });
426
526
  });
427
527
 
428
528
  const descriptor = {
@@ -456,6 +556,10 @@ function stubDevice<T extends Record<string, any>>(device: T): T {
456
556
  const d = device as any;
457
557
  d.updateState ??= jest.fn();
458
558
  d.markProtocolV2Reboot ??= jest.fn();
559
+ d.setCancelableAction ??= jest.fn();
560
+ d.clearCancelableAction ??= jest.fn();
561
+ d.toMessageObject ??= jest.fn(() => ({ connectId: d.originalDescriptor?.id }));
562
+ d.createProtocolV2UiPhaseMetadata ??= jest.fn(() => undefined);
459
563
  d.isProtocolV2 ??= () => d.originalDescriptor?.protocolType === 'V2';
460
564
  d.isBootloader ??= () => d.features?.mode === 'bootloader';
461
565
  d.isRomloader ??= () => d.features?.mode === 'romloader';
@@ -555,7 +659,7 @@ const protocolV2BootloaderDeviceInfo: ProtocolV2DeviceInfo = {
555
659
  hw: {
556
660
  serial_no: 'PR9999999999',
557
661
  },
558
- fw: {
662
+ main_mcu: {
559
663
  bootloader: {
560
664
  version: '0.0.1',
561
665
  },
@@ -619,8 +723,8 @@ describe('Protocol V2 feature adapter', () => {
619
723
  const deviceInfo: ProtocolV2DeviceInfo = {
620
724
  protocol_version: 1,
621
725
  hw: { serial_no: 'P2-STATIC' },
622
- fw: { application: { version: '1.0.0' } },
623
- coprocessor: { bt_adv_name: 'Pro2 STATIC' },
726
+ main_mcu: { application: { version: '1.0.0' } },
727
+ coprocessor: { bt_adv_name: 'Pro2 A1B2' },
624
728
  };
625
729
  const deviceStatus: DeviceStatus = {
626
730
  device_id: 'P2-DYNAMIC',
@@ -640,7 +744,7 @@ describe('Protocol V2 feature adapter', () => {
640
744
  expect(features).toMatchObject({
641
745
  deviceId: 'P2-DYNAMIC',
642
746
  serialNo: 'P2-STATIC',
643
- bleName: 'Pro2 STATIC',
747
+ bleName: 'Pro 2 A1B2',
644
748
  initialized: true,
645
749
  unlocked: true,
646
750
  passphraseProtection: true,
@@ -663,7 +767,7 @@ describe('Protocol V2 feature adapter', () => {
663
767
  const protocolV2 = buildProtocolV2FeaturesPayload({
664
768
  deviceInfo: {
665
769
  hw: { Device_type: DeviceType.PRO2, serial_no: 'P2-001' },
666
- coprocessor: { bt_adv_name: 'Pro 2 BLE' },
770
+ coprocessor: { bt_adv_name: 'Pro 2 B1E2' },
667
771
  },
668
772
  deviceStatus: {
669
773
  device_id: 'v2-device',
@@ -680,7 +784,7 @@ describe('Protocol V2 feature adapter', () => {
680
784
  });
681
785
  expect(protocolV2).toMatchObject({
682
786
  device_id: 'v2-device',
683
- ble_name: 'Pro 2 BLE',
787
+ ble_name: 'Pro 2 B1E2',
684
788
  onekey_device_type: 'PRO2',
685
789
  passphrase_protection: true,
686
790
  });
@@ -693,7 +797,7 @@ describe('Protocol V2 feature adapter', () => {
693
797
  Device_type: DeviceType.PRO2,
694
798
  serial_no: 'PR2SERIAL',
695
799
  },
696
- fw: {
800
+ main_mcu: {
697
801
  romloader: {
698
802
  version: '0.1.0',
699
803
  build_id: 'rom-build',
@@ -721,7 +825,7 @@ describe('Protocol V2 feature adapter', () => {
721
825
  build_id: 'bt-build',
722
826
  hash: [12, 13],
723
827
  },
724
- bt_adv_name: 'Pro2 BLE',
828
+ bt_adv_name: 'Pro2 B1E2',
725
829
  },
726
830
  se1: {
727
831
  application: {
@@ -761,7 +865,7 @@ describe('Protocol V2 feature adapter', () => {
761
865
  expect(features.boardVersion).toBe('0.1.0');
762
866
  expect(features.verify?.boardBuildId).toBe('rom-build');
763
867
  expect(features.verify?.boardHash).toBe('0102ff');
764
- expect(features.bleName).toBe('Pro2 BLE');
868
+ expect(features.bleName).toBe('Pro 2 B1E2');
765
869
  expect(features.bleVersion).toBe('4.5.6');
766
870
  expect(features.verify?.bleHash).toBe('0c0d');
767
871
  expect(features.se01Version).toBe('7.8.9');
@@ -783,7 +887,7 @@ describe('Protocol V2 feature adapter', () => {
783
887
  hw: {
784
888
  serial_no: 'PR2BOOT',
785
889
  },
786
- fw: {
890
+ main_mcu: {
787
891
  bootloader: {
788
892
  version: '0.2.0',
789
893
  },
@@ -805,7 +909,7 @@ describe('Protocol V2 feature adapter', () => {
805
909
  const features = normalizeProtocolV2Features(descriptor as any, {
806
910
  protocol_version: 1,
807
911
  hw: { serial_no: 'PR2NORMAL' },
808
- fw: { bootloader: { version: '0.2.0' } },
912
+ main_mcu: { bootloader: { version: '0.2.0' } },
809
913
  });
810
914
 
811
915
  expect(features.mode).toBe('normal');
@@ -818,7 +922,7 @@ describe('Protocol V2 feature adapter', () => {
818
922
  hw: {
819
923
  serial_no: 'PR2ROM',
820
924
  },
821
- fw: {
925
+ main_mcu: {
822
926
  romloader: {
823
927
  version: '1.0.0',
824
928
  },
@@ -849,6 +953,34 @@ describe('Protocol V2 feature adapter', () => {
849
953
  ).toBeUndefined();
850
954
  });
851
955
 
956
+ test('infers legacy Protocol V2 loader mode from firmware image metadata', () => {
957
+ const legacyProtocolInfo = {
958
+ version: 1,
959
+ build_fingerprint: '',
960
+ supported_messages: [],
961
+ protobuf_definition: null,
962
+ };
963
+
964
+ expect(
965
+ getProtocolV2RuntimeMode(legacyProtocolInfo, {
966
+ main_mcu: { bootloader: { version: '0.2.0' } },
967
+ })
968
+ ).toBe('bootloader');
969
+ expect(
970
+ getProtocolV2RuntimeMode(legacyProtocolInfo, {
971
+ main_mcu: { romloader: { version: '1.0.0' } },
972
+ })
973
+ ).toBe('romloader');
974
+ expect(
975
+ getProtocolV2RuntimeMode(legacyProtocolInfo, {
976
+ main_mcu: {
977
+ application: { version: '1.2.3' },
978
+ bootloader: { version: '0.2.0' },
979
+ },
980
+ })
981
+ ).toBeUndefined();
982
+ });
983
+
852
984
  test('keeps the Protocol V2 main wallet on the default empty-passphrase context', async () => {
853
985
  const features = normalizeProtocolV2Features(descriptor as any);
854
986
  features.firmwareVersion = '1.2.3';
@@ -890,6 +1022,7 @@ describe('Protocol V2 feature adapter', () => {
890
1022
  newSession: 'session-1',
891
1023
  unlockedAttachPin: false,
892
1024
  resumed: false,
1025
+ walletStatusRefreshed: true,
893
1026
  });
894
1027
  expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
895
1028
  eventless_wallet_session: true,
@@ -1153,6 +1286,7 @@ describe('Protocol V2 feature adapter', () => {
1153
1286
  newSession: 'main-wallet-session',
1154
1287
  unlockedAttachPin: undefined,
1155
1288
  resumed: false,
1289
+ walletStatusRefreshed: true,
1156
1290
  });
1157
1291
 
1158
1292
  expect(promptPassphrase).toHaveBeenCalled();
@@ -1239,6 +1373,7 @@ describe('Protocol V2 feature adapter', () => {
1239
1373
  newSession: 'hidden-after-unlock-session',
1240
1374
  unlockedAttachPin: undefined,
1241
1375
  resumed: false,
1376
+ walletStatusRefreshed: true,
1242
1377
  });
1243
1378
 
1244
1379
  expect(unlockDevice).not.toHaveBeenCalled();
@@ -1912,7 +2047,7 @@ describe('Protocol V2 feature adapter', () => {
1912
2047
  { ...descriptor, protocolType: 'V2' } as any,
1913
2048
  {
1914
2049
  protocol_version: 2,
1915
- fw: { application: { version: '1.2.3' } },
2050
+ main_mcu: { application: { version: '1.2.3' } },
1916
2051
  se1: { application: { version: '4.5.6' } },
1917
2052
  status: { unlocked: false, passphrase_enabled: false },
1918
2053
  }
@@ -1949,7 +2084,7 @@ describe('Protocol V2 feature adapter', () => {
1949
2084
  deviceInfo: {
1950
2085
  protocol_version: 1,
1951
2086
  hw: { serial_no: 'PR9999999999' },
1952
- fw: { application: { version: '1.0.0' } },
2087
+ main_mcu: { application: { version: '1.0.0' } },
1953
2088
  },
1954
2089
  deviceStatus: {
1955
2090
  device_id: 'firmware-reconnect-id',
@@ -2314,7 +2449,7 @@ describe('Protocol V2 feature adapter', () => {
2314
2449
  (device as any).features = {
2315
2450
  ...normalizeProtocolV2Features({ ...descriptor, protocolType: 'V2' } as any, {
2316
2451
  hw: { serial_no: 'PR2SERIAL' },
2317
- fw: { application: { version: '4.15.0' } },
2452
+ main_mcu: { application: { version: '4.15.0' } },
2318
2453
  status: { passphrase_enabled: true },
2319
2454
  }),
2320
2455
  unlocked: true,
@@ -2674,9 +2809,9 @@ describe('Protocol V2 feature adapter', () => {
2674
2809
 
2675
2810
  expect(device.toMessageObject()).toMatchObject({
2676
2811
  serialNo: 'PR9999999999',
2677
- bleName: 'Pro2 6136',
2678
- name: 'Pro2 6136',
2679
- label: 'Pro2 6136',
2812
+ bleName: 'Pro 2 6136',
2813
+ name: 'Pro 2 6136',
2814
+ label: 'Pro 2 6136',
2680
2815
  });
2681
2816
  expect(device.features?.label).toBeNull();
2682
2817
  });
@@ -2691,25 +2826,41 @@ describe('Protocol V2 feature adapter', () => {
2691
2826
 
2692
2827
  expect(device.getCurrentDisplayName()).toBe('My Pro 2');
2693
2828
  expect(device.toMessageObject()).toMatchObject({
2694
- bleName: 'Pro2 6136',
2695
- name: 'Pro2 6136',
2829
+ bleName: 'Pro 2 6136',
2830
+ name: 'Pro 2 6136',
2696
2831
  label: 'My Pro 2',
2697
2832
  });
2698
2833
  });
2699
2834
 
2700
- test('syncs Protocol V2 cached features without cached profile', () => {
2835
+ test('syncs Protocol V2 cached features and renegotiates the adopted command channel', async () => {
2836
+ const typedCall = jest.fn().mockResolvedValue({ message: protocolV2ApplicationInfo });
2701
2837
  const cached = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
2702
2838
  (cached as any).features = {
2703
2839
  ...normalizeProtocolV2Features({ ...descriptor, protocolType: 'V2' } as any),
2704
2840
  deviceId: null,
2705
2841
  serialNo: 'CACHED-SERIAL',
2706
2842
  };
2843
+ (cached as any).commands = { typedCall };
2707
2844
 
2708
2845
  const current = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
2846
+ current.updateState(
2847
+ {
2848
+ raw: { protocolV2ProtocolInfo: protocolV2BootloaderInfo },
2849
+ status: { mode: 'bootloader' },
2850
+ },
2851
+ 'initialize'
2852
+ );
2709
2853
  current.updateFromCache(cached);
2710
2854
 
2855
+ await expect(current.ensureProtocolV2RuntimeContext()).resolves.toEqual(
2856
+ protocolV2ApplicationInfo
2857
+ );
2858
+
2711
2859
  expect(current.getCurrentDeviceId()).toBeUndefined();
2712
2860
  expect(current.getCurrentSerialNo()).toBe('CACHED-SERIAL');
2861
+ expect(typedCall).toHaveBeenCalledWith('ProtocolInfoRequest', 'ProtocolInfo', {
2862
+ eventless_wallet_session: true,
2863
+ });
2713
2864
  expect(current.toMessageObject()).toMatchObject({
2714
2865
  serialNo: 'CACHED-SERIAL',
2715
2866
  uuid: 'CACHED-SERIAL',
@@ -2723,7 +2874,7 @@ describe('Protocol V2 feature adapter', () => {
2723
2874
  type: 'DeviceInfo',
2724
2875
  message: {
2725
2876
  hw: { Device_type: DeviceType.PRO2, serial_no: 'PR2SERIAL' },
2726
- fw: { application: { version: '1.2.3' } },
2877
+ main_mcu: { application: { version: '1.2.3' } },
2727
2878
  },
2728
2879
  }),
2729
2880
  };
@@ -2744,7 +2895,7 @@ describe('Protocol V2 feature adapter', () => {
2744
2895
  {
2745
2896
  targets: {
2746
2897
  hw: true,
2747
- fw: true,
2898
+ main_mcu: true,
2748
2899
  coprocessor: true,
2749
2900
  },
2750
2901
  types: {
@@ -2853,7 +3004,7 @@ describe('Protocol V2 feature adapter', () => {
2853
3004
  const device = stubDevice({
2854
3005
  originalDescriptor: { protocolType: 'V2' },
2855
3006
  features: normalizeProtocolV2Features({ ...descriptor, protocolType: 'V2' } as any, {
2856
- fw: {
3007
+ main_mcu: {
2857
3008
  application: {
2858
3009
  version: '4.14.0',
2859
3010
  },
@@ -2907,7 +3058,7 @@ describe('Protocol V2 feature adapter', () => {
2907
3058
  type: 'DeviceInfo',
2908
3059
  message: {
2909
3060
  hw: { Device_type: DeviceType.PRO2, serial_no: 'PR2SERIAL' },
2910
- fw: { application: { version: '1.2.3' } },
3061
+ main_mcu: { application: { version: '1.2.3' } },
2911
3062
  },
2912
3063
  })
2913
3064
  .mockResolvedValueOnce({
@@ -2942,7 +3093,7 @@ describe('Protocol V2 feature adapter', () => {
2942
3093
  {
2943
3094
  targets: {
2944
3095
  hw: true,
2945
- fw: true,
3096
+ main_mcu: true,
2946
3097
  coprocessor: true,
2947
3098
  },
2948
3099
  types: {
@@ -2971,7 +3122,7 @@ describe('Protocol V2 feature adapter', () => {
2971
3122
  type: 'DeviceInfo',
2972
3123
  message: {
2973
3124
  hw: { serial_no: 'PR2SERIAL' },
2974
- fw: { bootloader: { version: '0.2.0' } },
3125
+ main_mcu: { bootloader: { version: '0.2.0' } },
2975
3126
  se1: {},
2976
3127
  },
2977
3128
  })
@@ -3021,7 +3172,7 @@ describe('Protocol V2 feature adapter', () => {
3021
3172
 
3022
3173
  await device.probeProtocolV2RuntimeState({
3023
3174
  hw: { Device_type: DeviceType.PRO2, serial_no: 'PR2SERIAL' },
3024
- fw: { romloader: { version: '1.0.0' } },
3175
+ main_mcu: { romloader: { version: '1.0.0' } },
3025
3176
  });
3026
3177
 
3027
3178
  expect(typedCall).toHaveBeenCalledTimes(1);
@@ -3049,7 +3200,7 @@ describe('Protocol V2 feature adapter', () => {
3049
3200
  await expect(
3050
3201
  device.probeProtocolV2RuntimeState({
3051
3202
  hw: { Device_type: DeviceType.PRO, serial_no: 'PRO-SERIAL' },
3052
- fw: { romloader: { version: '1.0.0' } },
3203
+ main_mcu: { romloader: { version: '1.0.0' } },
3053
3204
  })
3054
3205
  ).rejects.toMatchObject({ errorCode: HardwareErrorCode.DeviceInitializeFailed });
3055
3206
 
@@ -3074,7 +3225,7 @@ describe('Protocol V2 feature adapter', () => {
3074
3225
 
3075
3226
  await device.probeProtocolV2RuntimeState({
3076
3227
  hw: { serial_no: 'PR2SERIAL' },
3077
- fw: { application: { version: '1.2.3' } },
3228
+ main_mcu: { application: { version: '1.2.3' } },
3078
3229
  });
3079
3230
 
3080
3231
  expect(typedCall).toHaveBeenCalledTimes(1);
@@ -3106,7 +3257,7 @@ describe('Protocol V2 feature adapter', () => {
3106
3257
 
3107
3258
  await device.probeProtocolV2RuntimeState({
3108
3259
  hw: { serial_no: 'PR2SERIAL' },
3109
- fw: { application: { version: '1.2.3' } },
3260
+ main_mcu: { application: { version: '1.2.3' } },
3110
3261
  });
3111
3262
 
3112
3263
  expect(typedCall).toHaveBeenCalledTimes(2);
@@ -3134,7 +3285,7 @@ describe('Protocol V2 feature adapter', () => {
3134
3285
 
3135
3286
  await device.probeProtocolV2RuntimeState({
3136
3287
  hw: { Device_type: DeviceType.PRO2, serial_no: 'PR2SERIAL' },
3137
- fw: { bootloader: { version: '0.2.0' } },
3288
+ main_mcu: { bootloader: { version: '0.2.0' } },
3138
3289
  });
3139
3290
 
3140
3291
  expect(device.features).toMatchObject({
@@ -3163,7 +3314,7 @@ describe('Protocol V2 feature adapter', () => {
3163
3314
  await expect(
3164
3315
  device.probeProtocolV2RuntimeState({
3165
3316
  hw: { Device_type: DeviceType.PRO2, serial_no: 'PR2SERIAL' },
3166
- fw: { application: { version: '1.2.3' } },
3317
+ main_mcu: { application: { version: '1.2.3' } },
3167
3318
  })
3168
3319
  ).rejects.toBe(linkError);
3169
3320
 
@@ -3188,12 +3339,165 @@ describe('Protocol V2 feature adapter', () => {
3188
3339
  await expect(
3189
3340
  device.probeProtocolV2RuntimeState({
3190
3341
  hw: { serial_no: 'PR2SERIAL' },
3191
- fw: { application: { version: '1.2.3' } },
3342
+ main_mcu: { application: { version: '1.2.3' } },
3192
3343
  })
3193
3344
  ).rejects.toMatchObject({ errorCode: HardwareErrorCode.DeviceInitializeFailed });
3194
3345
  expect(typedCall).toHaveBeenCalledTimes(1);
3195
3346
  });
3196
3347
 
3348
+ test('initializes a legacy Protocol V2 bootloader without compatibility options', async () => {
3349
+ const device = Device.fromDescriptor({
3350
+ path: 'usb-path',
3351
+ protocolType: 'V2',
3352
+ } as any);
3353
+ const typedCall = jest
3354
+ .fn()
3355
+ .mockResolvedValueOnce({
3356
+ type: 'DeviceInfo',
3357
+ message: {
3358
+ hw: { Device_type: DeviceType.PRO2, serial_no: 'PR2SERIAL' },
3359
+ main_mcu: { bootloader: { version: '0.2.0' } },
3360
+ },
3361
+ })
3362
+ .mockResolvedValueOnce({
3363
+ type: 'ProtocolInfo',
3364
+ message: {
3365
+ version: 1,
3366
+ build_fingerprint: '',
3367
+ supported_messages: [],
3368
+ protobuf_definition: null,
3369
+ },
3370
+ });
3371
+ (device as any).commands = { typedCall };
3372
+
3373
+ await device.initialize();
3374
+
3375
+ expect(typedCall).toHaveBeenCalledTimes(2);
3376
+ expect(typedCall).not.toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
3377
+ expect(device.features).toMatchObject({
3378
+ mode: 'bootloader',
3379
+ bootloaderMode: true,
3380
+ initialized: null,
3381
+ });
3382
+ });
3383
+
3384
+ test('uses DeviceStatusGet to identify App mode for legacy ProtocolInfo', async () => {
3385
+ const device = Device.fromDescriptor({
3386
+ path: 'usb-path',
3387
+ protocolType: 'V2',
3388
+ } as any);
3389
+ const typedCall = jest
3390
+ .fn()
3391
+ .mockResolvedValueOnce({
3392
+ type: 'ProtocolInfo',
3393
+ message: {
3394
+ version: 1,
3395
+ build_fingerprint: '',
3396
+ supported_messages: [],
3397
+ protobuf_definition: null,
3398
+ },
3399
+ })
3400
+ .mockResolvedValueOnce({
3401
+ type: 'DeviceStatus',
3402
+ message: { init_states: true, unlocked: true },
3403
+ });
3404
+ (device as any).commands = { typedCall };
3405
+
3406
+ await device.probeProtocolV2RuntimeState(
3407
+ {
3408
+ hw: { Device_type: DeviceType.PRO2, serial_no: 'PR2SERIAL' },
3409
+ main_mcu: { application: { version: '1.2.3' } },
3410
+ },
3411
+ 5000
3412
+ );
3413
+
3414
+ expect(typedCall).toHaveBeenNthCalledWith(
3415
+ 1,
3416
+ 'ProtocolInfoRequest',
3417
+ 'ProtocolInfo',
3418
+ { eventless_wallet_session: true },
3419
+ { timeoutMs: 5000 }
3420
+ );
3421
+ expect(typedCall).toHaveBeenNthCalledWith(
3422
+ 2,
3423
+ 'DeviceStatusGet',
3424
+ 'DeviceStatus',
3425
+ {},
3426
+ { timeoutMs: 5000 }
3427
+ );
3428
+ expect(device.features).toMatchObject({
3429
+ mode: 'normal',
3430
+ bootloaderMode: false,
3431
+ initialized: true,
3432
+ unlocked: true,
3433
+ });
3434
+ });
3435
+
3436
+ test('maps legacy bootloader metadata without calling DeviceStatusGet', async () => {
3437
+ const device = Device.fromDescriptor({
3438
+ path: 'usb-path',
3439
+ protocolType: 'V2',
3440
+ } as any);
3441
+ const typedCall = jest.fn().mockResolvedValueOnce({
3442
+ type: 'ProtocolInfo',
3443
+ message: {
3444
+ version: 1,
3445
+ build_fingerprint: '',
3446
+ supported_messages: [],
3447
+ protobuf_definition: null,
3448
+ },
3449
+ });
3450
+ (device as any).commands = { typedCall };
3451
+
3452
+ await device.probeProtocolV2RuntimeState({
3453
+ hw: { Device_type: DeviceType.NEO, serial_no: 'NEOSERIAL' },
3454
+ main_mcu: { bootloader: { version: '0.2.0' } },
3455
+ });
3456
+
3457
+ expect(typedCall).toHaveBeenNthCalledWith(1, 'ProtocolInfoRequest', 'ProtocolInfo', {
3458
+ eventless_wallet_session: true,
3459
+ });
3460
+ expect(typedCall).toHaveBeenCalledTimes(1);
3461
+ expect(typedCall).not.toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
3462
+ expect(device.features).toMatchObject({
3463
+ deviceType: 'neo',
3464
+ mode: 'bootloader',
3465
+ bootloaderMode: true,
3466
+ initialized: null,
3467
+ });
3468
+ });
3469
+
3470
+ test('maps legacy romloader metadata without calling DeviceStatusGet', async () => {
3471
+ const device = Device.fromDescriptor({
3472
+ path: 'usb-path',
3473
+ protocolType: 'V2',
3474
+ } as any);
3475
+ const typedCall = jest.fn().mockResolvedValueOnce({
3476
+ type: 'ProtocolInfo',
3477
+ message: {
3478
+ version: 1,
3479
+ build_fingerprint: '',
3480
+ supported_messages: [],
3481
+ protobuf_definition: null,
3482
+ },
3483
+ });
3484
+ (device as any).commands = { typedCall };
3485
+
3486
+ await device.probeProtocolV2RuntimeState({
3487
+ hw: { Device_type: DeviceType.PRO2, serial_no: 'PR2SERIAL' },
3488
+ main_mcu: { romloader: { version: '1.0.0' } },
3489
+ });
3490
+
3491
+ expect(typedCall).toHaveBeenCalledTimes(1);
3492
+ expect(typedCall).not.toHaveBeenCalledWith('DeviceStatusGet', 'DeviceStatus', {});
3493
+ expect(device.features).toMatchObject({
3494
+ deviceType: 'pro2',
3495
+ mode: 'romloader',
3496
+ bootloaderMode: true,
3497
+ initialized: null,
3498
+ });
3499
+ });
3500
+
3197
3501
  test('does not reinterpret a state update error as runtime detection failure', async () => {
3198
3502
  const device = Device.fromDescriptor({
3199
3503
  path: 'usb-path',
@@ -3219,7 +3523,7 @@ describe('Protocol V2 feature adapter', () => {
3219
3523
  await expect(
3220
3524
  device.probeProtocolV2RuntimeState({
3221
3525
  hw: { serial_no: 'PR2SERIAL' },
3222
- fw: { application: { version: '1.0.0' } },
3526
+ main_mcu: { application: { version: '1.0.0' } },
3223
3527
  })
3224
3528
  ).rejects.toThrow('state update failed');
3225
3529
 
@@ -3244,7 +3548,7 @@ describe('Protocol V2 feature adapter', () => {
3244
3548
  type: 'DeviceInfo',
3245
3549
  message: {
3246
3550
  hw: { Device_type: DeviceType.PRO2, serial_no: 'PR2SERIAL' },
3247
- fw: { application: { version: '1.2.3' } },
3551
+ main_mcu: { application: { version: '1.2.3' } },
3248
3552
  },
3249
3553
  })
3250
3554
  .mockResolvedValueOnce({
@@ -3259,7 +3563,7 @@ describe('Protocol V2 feature adapter', () => {
3259
3563
  type: 'DeviceInfo',
3260
3564
  message: {
3261
3565
  hw: { Device_type: DeviceType.PRO2, serial_no: 'PR2SERIAL' },
3262
- fw: { application: { version: '1.2.4' } },
3566
+ main_mcu: { application: { version: '1.2.4' } },
3263
3567
  },
3264
3568
  })
3265
3569
  .mockResolvedValueOnce({
@@ -3291,7 +3595,7 @@ describe('Protocol V2 feature adapter', () => {
3291
3595
  {
3292
3596
  targets: {
3293
3597
  hw: true,
3294
- fw: true,
3598
+ main_mcu: true,
3295
3599
  coprocessor: true,
3296
3600
  },
3297
3601
  types: {
@@ -3312,7 +3616,7 @@ describe('Protocol V2 feature adapter', () => {
3312
3616
  type: 'DeviceInfo',
3313
3617
  message: {
3314
3618
  hw: { serial_no: 'PR2SERIAL' },
3315
- fw: { application: { version: '1.2.4' } },
3619
+ main_mcu: { application: { version: '1.2.4' } },
3316
3620
  },
3317
3621
  })
3318
3622
  .mockResolvedValueOnce({
@@ -3389,7 +3693,7 @@ describe('Protocol V2 feature adapter', () => {
3389
3693
  { ...descriptor, protocolType: 'V2' } as any,
3390
3694
  {
3391
3695
  hw: { Device_type: DeviceType.PRO2, serial_no: 'PR2SERIAL' },
3392
- fw: { application: { version: '1.2.3' } },
3696
+ main_mcu: { application: { version: '1.2.3' } },
3393
3697
  status: { init_states: true },
3394
3698
  }
3395
3699
  );
@@ -3429,7 +3733,7 @@ describe('Protocol V2 feature adapter', () => {
3429
3733
  { ...descriptor, protocolType: 'V2' } as any,
3430
3734
  {
3431
3735
  hw: { serial_no: 'PR2SERIAL' },
3432
- fw: { application: { version: '4.15.0' } },
3736
+ main_mcu: { application: { version: '4.15.0' } },
3433
3737
  status: { passphrase_enabled: false },
3434
3738
  }
3435
3739
  );
@@ -3476,7 +3780,7 @@ describe('Protocol V2 feature adapter', () => {
3476
3780
  (device as any).features = normalizeProtocolV2Features(
3477
3781
  { ...descriptor, protocolType: 'V2' } as any,
3478
3782
  {
3479
- fw: { application: { version: '1.2.3' } },
3783
+ main_mcu: { application: { version: '1.2.3' } },
3480
3784
  status: { unlocked: false },
3481
3785
  }
3482
3786
  );
@@ -3890,7 +4194,7 @@ describe('Protocol V2 firmware update targets', () => {
3890
4194
  );
3891
4195
  });
3892
4196
 
3893
- test('uses Protocol V2 features after BLE final reconnect without V1 Initialize', async () => {
4197
+ test('accepts uninitialized Protocol V2 features after BLE final reconnect', async () => {
3894
4198
  const method = new FirmwareUpdateV4({
3895
4199
  id: 1,
3896
4200
  payload: {
@@ -3905,7 +4209,7 @@ describe('Protocol V2 firmware update targets', () => {
3905
4209
  type: 'DeviceInfo',
3906
4210
  message: {
3907
4211
  hw: { serial_no: 'BLE-PRO2-SERIAL' },
3908
- fw: {
4212
+ main_mcu: {
3909
4213
  bootloader: { version: '0.0.0' },
3910
4214
  application: { version: '0.0.0' },
3911
4215
  },
@@ -3918,7 +4222,7 @@ describe('Protocol V2 firmware update targets', () => {
3918
4222
  if (name === 'DeviceStatusGet') {
3919
4223
  return Promise.resolve({
3920
4224
  type: 'DeviceStatus',
3921
- message: { init_states: true, unlocked: true },
4225
+ message: { init_states: false, unlocked: false },
3922
4226
  });
3923
4227
  }
3924
4228
  if (name === 'ProtocolInfoRequest') {
@@ -3939,7 +4243,7 @@ describe('Protocol V2 firmware update targets', () => {
3939
4243
  getCommands: () => commands,
3940
4244
  updateProtocolV2Features: jest.fn(() => ({
3941
4245
  bootloaderMode: false,
3942
- mode: 'normal',
4246
+ mode: 'notInitialized',
3943
4247
  firmwareVersion: '0.0.0',
3944
4248
  bootloaderVersion: '0.0.0',
3945
4249
  bleVersion: '0.0.0',
@@ -3957,7 +4261,7 @@ describe('Protocol V2 firmware update targets', () => {
3957
4261
  {
3958
4262
  targets: {
3959
4263
  hw: true,
3960
- fw: true,
4264
+ main_mcu: true,
3961
4265
  coprocessor: true,
3962
4266
  se1: true,
3963
4267
  se2: true,
@@ -4016,7 +4320,7 @@ describe('Protocol V2 firmware update targets', () => {
4016
4320
  });
4017
4321
  (method as any).protocolV2Reboot = jest.fn();
4018
4322
  (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(true);
4019
- method.postTipMessage = jest.fn();
4323
+ method.postMessage = jest.fn();
4020
4324
 
4021
4325
  await method.run();
4022
4326
 
@@ -4033,56 +4337,268 @@ describe('Protocol V2 firmware update targets', () => {
4033
4337
  });
4034
4338
  });
4035
4339
 
4036
- test('requires the external host to prepare manifest resources before bootloader entry', async () => {
4340
+ test('filters unrequested binaries from a direct local Protocol V2 update', async () => {
4341
+ const applicationP1Binary = new Uint8Array([1, 2, 3]).buffer;
4037
4342
  const method = new FirmwareUpdateV4({
4038
4343
  id: 1,
4039
4344
  payload: {
4040
4345
  method: 'firmwareUpdateV4',
4041
4346
  platform: 'web',
4042
- targetsToUpdate: ['resource'],
4347
+ targetsToUpdate: ['app_v1'],
4348
+ applicationP1Binary,
4349
+ bootloaderBinary: new Uint8Array([4, 5, 6]).buffer,
4043
4350
  },
4044
4351
  });
4045
4352
  method.init();
4046
-
4353
+ (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
4047
4354
  (method as any).device = stubDevice({
4048
- originalDescriptor: { id: 'usb-id', path: 'app-path', protocolType: 'V2' },
4355
+ originalDescriptor: { protocolType: 'V2' },
4049
4356
  features: {
4050
4357
  deviceType: 'pro2',
4051
4358
  firmwareVersion: '1.0.0',
4052
- mode: 'normal',
4053
- bootloaderMode: false,
4054
4359
  capabilities: [],
4055
4360
  },
4056
4361
  getCurrentDeviceType: () => 'pro2',
4057
- isBootloader: () => false,
4058
- isRomloader: () => false,
4059
4362
  });
4060
- (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
4061
- (method as any).enterProtocolV2BootloaderMode = jest.fn();
4363
+ (method as any).executeProtocolV2Update = jest.fn().mockResolvedValue('local-result');
4062
4364
  method.postTipMessage = jest.fn();
4063
4365
 
4064
- await expect(method.run()).rejects.toMatchObject({
4065
- errorCode: HardwareErrorCode.RuntimeError,
4066
- params: expect.objectContaining({
4067
- firmwareUpdateCode: 'FirmwareArtifactsNotPrepared',
4068
- }),
4366
+ await expect(method.run()).resolves.toBe('local-result');
4367
+ expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
4368
+ bootloaderBinary: null,
4369
+ fwBinaryMap: [
4370
+ {
4371
+ fileName: 'application_p1.bin',
4372
+ binary: applicationP1Binary,
4373
+ targetId: 4,
4374
+ },
4375
+ ],
4069
4376
  });
4070
- expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
4071
4377
  });
4072
4378
 
4073
- test('reboots Protocol V2 normal-mode device to bootloader before transfer', async () => {
4379
+ test('uses the DeviceState identity for Protocol V2 firmware artifact lookups', async () => {
4074
4380
  const method = new FirmwareUpdateV4({
4075
4381
  id: 1,
4076
4382
  payload: {
4077
4383
  method: 'firmwareUpdateV4',
4384
+ platform: 'web',
4078
4385
  },
4079
4386
  });
4387
+ method.init();
4080
4388
  (method as any).device = stubDevice({
4081
- originalDescriptor: { id: 'usb-id', path: 'usb-path', protocolType: 'V2' },
4082
- features: { bootloader_mode: false, capabilities: [] },
4083
- isBootloader: () => false,
4084
- probeProtocolV2RuntimeState: jest.fn().mockResolvedValue({
4085
- mode: 'bootloader',
4389
+ originalDescriptor: { protocolType: 'V2' },
4390
+ features: {
4391
+ firmwareVersion: '1.0.0',
4392
+ serialNo: 'device-without-model-prefix',
4393
+ },
4394
+ getCurrentDeviceType: () => EDeviceType.Pro2,
4395
+ getCurrentFirmwareType: () => EFirmwareType.Universal,
4396
+ });
4397
+
4398
+ await expect((method as any).getProtocolV2DeviceFeatures()).resolves.toEqual(
4399
+ expect.objectContaining({
4400
+ deviceType: EDeviceType.Pro2,
4401
+ firmwareType: EFirmwareType.Universal,
4402
+ })
4403
+ );
4404
+ });
4405
+
4406
+ test('keeps all local binaries when no target list was supplied', () => {
4407
+ const method = new FirmwareUpdateV4({
4408
+ id: 1,
4409
+ payload: {
4410
+ method: 'firmwareUpdateV4',
4411
+ platform: 'web',
4412
+ applicationP1Binary: new Uint8Array([1]).buffer,
4413
+ bootloaderBinary: new Uint8Array([2]).buffer,
4414
+ resourceArchiveBinary: new Uint8Array([3]).buffer,
4415
+ },
4416
+ });
4417
+ method.init();
4418
+
4419
+ const availableInstallItems = (method as any).buildProtocolV2InstallItems({
4420
+ bootloaderBinary: (method as any).prepareBootloaderBinary(),
4421
+ fwBinaryMap: (method as any).collectExplicitTargetBinaries(),
4422
+ });
4423
+
4424
+ expect((method as any).params.targetsToUpdate).toEqual(['resource']);
4425
+ expect(
4426
+ (method as any)
4427
+ .filterProtocolV2LocalInstallItems(availableInstallItems)
4428
+ .map((item: { targetId: number }) => item.targetId)
4429
+ ).toEqual([3, 4]);
4430
+ });
4431
+
4432
+ test('keeps external-only resource updates on the prepared host path', async () => {
4433
+ const method = new FirmwareUpdateV4({
4434
+ id: 1,
4435
+ payload: {
4436
+ method: 'firmwareUpdateV4',
4437
+ platform: 'web',
4438
+ targetsToUpdate: ['resource'],
4439
+ },
4440
+ });
4441
+ method.init();
4442
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue('external-only' as never);
4443
+
4444
+ (method as any).device = stubDevice({
4445
+ originalDescriptor: { id: 'usb-id', path: 'app-path', protocolType: 'V2' },
4446
+ features: {
4447
+ deviceType: 'pro2',
4448
+ firmwareVersion: '1.0.0',
4449
+ mode: 'normal',
4450
+ bootloaderMode: false,
4451
+ capabilities: [],
4452
+ },
4453
+ getCurrentDeviceType: () => 'pro2',
4454
+ isBootloader: () => false,
4455
+ isRomloader: () => false,
4456
+ });
4457
+ (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
4458
+ (method as any).enterProtocolV2BootloaderMode = jest.fn();
4459
+ method.postTipMessage = jest.fn();
4460
+
4461
+ await expect(method.run()).rejects.toMatchObject({
4462
+ errorCode: HardwareErrorCode.RuntimeError,
4463
+ params: expect.objectContaining({
4464
+ firmwareUpdateCode: 'FirmwareArtifactsNotPrepared',
4465
+ }),
4466
+ });
4467
+ expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
4468
+ });
4469
+
4470
+ test('downloads Protocol V2 resource archives in SDK-managed mode', async () => {
4471
+ const resourceArchiveBinary = new Uint8Array([1, 2, 3, 4]).buffer;
4472
+ const applicationP1Binary = new Uint8Array([5, 6, 7, 8]).buffer;
4473
+ const applicationP1InstallItem = {
4474
+ fileName: 'application_p1.bin',
4475
+ binary: applicationP1Binary,
4476
+ targetId: 4,
4477
+ kind: 'firmware',
4478
+ };
4479
+ const resourceArchiveSha256 = bytesToHex(sha256(new Uint8Array(resourceArchiveBinary)));
4480
+ const method = new FirmwareUpdateV4({
4481
+ id: 1,
4482
+ payload: {
4483
+ method: 'firmwareUpdateV4',
4484
+ platform: 'ext',
4485
+ targetsToUpdate: ['app_v1', 'resource'],
4486
+ },
4487
+ });
4488
+ method.init();
4489
+
4490
+ (method as any).device = stubDevice({
4491
+ originalDescriptor: { id: 'usb-id', path: 'app-path', protocolType: 'V2' },
4492
+ features: {
4493
+ deviceType: 'pro2',
4494
+ firmwareVersion: '1.0.0',
4495
+ mode: 'normal',
4496
+ bootloaderMode: false,
4497
+ capabilities: [],
4498
+ },
4499
+ getCurrentDeviceType: () => 'pro2',
4500
+ isBootloader: () => false,
4501
+ isRomloader: () => false,
4502
+ });
4503
+ (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
4504
+ (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue({
4505
+ bootloaderBinary: null,
4506
+ fwBinaryMap: [
4507
+ {
4508
+ fileName: applicationP1InstallItem.fileName,
4509
+ binary: applicationP1Binary,
4510
+ targetId: applicationP1InstallItem.targetId,
4511
+ },
4512
+ ],
4513
+ installItems: [applicationP1InstallItem],
4514
+ });
4515
+ (method as any).runProtocolV2DirectArtifacts = jest
4516
+ .fn()
4517
+ .mockResolvedValue('sdk-managed-resource-result');
4518
+ method.postTipMessage = jest.fn();
4519
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue('sdk-managed' as never);
4520
+ jest.spyOn(DataManager, 'getProtocolV2ResourceSource').mockReturnValue({
4521
+ archiveUrl: 'https://example.com/pro2-resource.zip',
4522
+ archiveSize: resourceArchiveBinary.byteLength,
4523
+ archiveSha256: resourceArchiveSha256,
4524
+ });
4525
+ const getSysResourceBinarySpy = jest
4526
+ .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
4527
+ .mockResolvedValue({ binary: resourceArchiveBinary });
4528
+
4529
+ await expect(method.run()).resolves.toBe('sdk-managed-resource-result');
4530
+
4531
+ expect(forceReloadDataSpy).toHaveBeenCalledWith({
4532
+ requireResources: true,
4533
+ resourceDeviceType: EDeviceType.Pro2,
4534
+ });
4535
+ expect(getSysResourceBinarySpy).toHaveBeenCalledWith('https://example.com/pro2-resource.zip');
4536
+ expect((method as any).params.resourceArchiveBinary).toBe(resourceArchiveBinary);
4537
+ expect((method as any).runProtocolV2DirectArtifacts).toHaveBeenCalledWith({
4538
+ availableInstallItems: [applicationP1InstallItem],
4539
+ announceDownload: false,
4540
+ });
4541
+ });
4542
+
4543
+ test('preserves SDK-managed resource installation errors after preparation', async () => {
4544
+ const resourceArchiveBinary = new Uint8Array([1, 2, 3, 4]).buffer;
4545
+ const installError = ERRORS.TypedError(
4546
+ HardwareErrorCode.FirmwareError,
4547
+ 'device rejected the prepared resource installation'
4548
+ );
4549
+ const method = new FirmwareUpdateV4({
4550
+ id: 1,
4551
+ payload: {
4552
+ method: 'firmwareUpdateV4',
4553
+ platform: 'ext',
4554
+ targetsToUpdate: ['resource'],
4555
+ },
4556
+ });
4557
+ method.init();
4558
+
4559
+ (method as any).device = stubDevice({
4560
+ originalDescriptor: { id: 'usb-id', path: 'app-path', protocolType: 'V2' },
4561
+ features: {
4562
+ deviceType: 'pro2',
4563
+ firmwareVersion: '1.0.0',
4564
+ mode: 'normal',
4565
+ bootloaderMode: false,
4566
+ capabilities: [],
4567
+ },
4568
+ getCurrentDeviceType: () => 'pro2',
4569
+ isBootloader: () => false,
4570
+ isRomloader: () => false,
4571
+ });
4572
+ (method as any).captureProtocolV2PhysicalIdentity = jest.fn().mockResolvedValue(undefined);
4573
+ (method as any).downloadRemoteProtocolV2ResourceArchive = jest
4574
+ .fn()
4575
+ .mockResolvedValue(resourceArchiveBinary);
4576
+ (method as any).runProtocolV2DirectArtifacts = jest.fn().mockRejectedValue(installError);
4577
+ method.postTipMessage = jest.fn();
4578
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue('sdk-managed' as never);
4579
+
4580
+ await expect(method.run()).rejects.toBe(installError);
4581
+
4582
+ expect(forceReloadDataSpy).toHaveBeenCalledWith({
4583
+ requireResources: true,
4584
+ resourceDeviceType: EDeviceType.Pro2,
4585
+ });
4586
+ expect((method as any).runProtocolV2DirectArtifacts).toHaveBeenCalledTimes(1);
4587
+ });
4588
+
4589
+ test('reboots Protocol V2 normal-mode device to bootloader before transfer', async () => {
4590
+ const method = new FirmwareUpdateV4({
4591
+ id: 1,
4592
+ payload: {
4593
+ method: 'firmwareUpdateV4',
4594
+ },
4595
+ });
4596
+ (method as any).device = stubDevice({
4597
+ originalDescriptor: { id: 'usb-id', path: 'usb-path', protocolType: 'V2' },
4598
+ features: { bootloader_mode: false, capabilities: [] },
4599
+ isBootloader: () => false,
4600
+ probeProtocolV2RuntimeState: jest.fn().mockResolvedValue({
4601
+ mode: 'bootloader',
4086
4602
  bootloaderMode: true,
4087
4603
  }),
4088
4604
  });
@@ -4307,6 +4823,23 @@ describe('Protocol V2 firmware update targets', () => {
4307
4823
  expect(typedCall.mock.calls.map(call => call[0])).toEqual(['DeviceInfoGet']);
4308
4824
  });
4309
4825
 
4826
+ test('stops V4 bootloader reconnect polling on a stale BLE bond', async () => {
4827
+ const method = new FirmwareUpdateV4({
4828
+ id: 1,
4829
+ payload: {
4830
+ method: 'firmwareUpdateV4',
4831
+ },
4832
+ });
4833
+ const staleBondError = ERRORS.TypedError(HardwareErrorCode.BlePeerRemovedPairingInformation);
4834
+ const reconnectProtocolV2Device = jest.fn().mockRejectedValue(staleBondError);
4835
+ (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
4836
+
4837
+ await expect((method as any).waitForProtocolV2BootloaderMode(60_000, 0)).rejects.toBe(
4838
+ staleBondError
4839
+ );
4840
+ expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
4841
+ });
4842
+
4310
4843
  test('polls again when Protocol V2 bootloader serial is temporarily unavailable', async () => {
4311
4844
  const method = new FirmwareUpdateV4({
4312
4845
  id: 1,
@@ -4388,6 +4921,87 @@ describe('Protocol V2 firmware update targets', () => {
4388
4921
  expect(initialize).not.toHaveBeenCalled();
4389
4922
  });
4390
4923
 
4924
+ test('reacquires the same WebUSB device without a protocol probe during install recovery', async () => {
4925
+ const method = new FirmwareUpdateV4({
4926
+ id: 1,
4927
+ payload: {
4928
+ method: 'firmwareUpdateV4',
4929
+ },
4930
+ });
4931
+ const acquire = jest.fn().mockResolvedValue('usb-path');
4932
+ const enumerate = jest.fn().mockResolvedValue({
4933
+ descriptors: [
4934
+ { id: 'other-usb', path: 'other-usb', protocolType: 'V2' },
4935
+ { id: 'usb-id', path: 'usb-path', protocolType: 'V2' },
4936
+ ],
4937
+ });
4938
+ const commands = { disposed: true, mainId: '' };
4939
+ const device = stubDevice({
4940
+ originalDescriptor: { id: 'usb-id', path: 'usb-path', protocolType: 'V2' },
4941
+ deviceConnector: { acquire, enumerate },
4942
+ updateDescriptor: jest.fn(),
4943
+ acquire: jest.fn(),
4944
+ commands,
4945
+ getCommands: () => commands,
4946
+ mainId: 'old-usb-path',
4947
+ });
4948
+ const getDevices = jest.spyOn(DevicePool, 'getDevices');
4949
+ const isBrowserWebUsb = jest.spyOn(DataManager, 'isBrowserWebUsb').mockReturnValue(false);
4950
+ const isDesktopWebUsb = jest.spyOn(DataManager, 'isDesktopWebUsb').mockReturnValue(true);
4951
+ (method as any).device = device;
4952
+ (method as any).protocolV2ExpectedPath = 'usb-path';
4953
+
4954
+ try {
4955
+ await (method as any).reconnectProtocolV2Device({ skipProtocolProbe: true });
4956
+ } finally {
4957
+ getDevices.mockRestore();
4958
+ isBrowserWebUsb.mockRestore();
4959
+ isDesktopWebUsb.mockRestore();
4960
+ }
4961
+
4962
+ expect(getDevices).not.toHaveBeenCalled();
4963
+ expect(acquire).toHaveBeenCalledWith(
4964
+ 'usb-path',
4965
+ null,
4966
+ undefined,
4967
+ 'V2',
4968
+ undefined,
4969
+ undefined,
4970
+ true
4971
+ );
4972
+ expect(device.acquire).not.toHaveBeenCalled();
4973
+ expect(commands.disposed).toBe(false);
4974
+ expect(commands.mainId).toBe('usb-path');
4975
+ });
4976
+
4977
+ test('reacquires the same BLE peripheral by id without scanning during install recovery', async () => {
4978
+ const method = new FirmwareUpdateV4({
4979
+ id: 1,
4980
+ payload: {
4981
+ method: 'firmwareUpdateV4',
4982
+ },
4983
+ });
4984
+ const acquire = jest.fn().mockResolvedValue({ uuid: 'ble-id', protocolType: 'V2' });
4985
+ const enumerate = jest.fn().mockResolvedValue({
4986
+ descriptors: [{ id: 'ble-id', name: 'OneKey Pro 2', protocolType: 'V2' }],
4987
+ });
4988
+ const commands = { disposed: true, mainId: '' };
4989
+ (method as any).isBleReconnect = jest.fn(() => true);
4990
+ (method as any).device = stubDevice({
4991
+ originalDescriptor: { id: 'ble-id', path: 'ble-path', protocolType: 'V2' },
4992
+ deviceConnector: { acquire, enumerate },
4993
+ commands,
4994
+ getCommands: () => commands,
4995
+ });
4996
+
4997
+ await (method as any).reconnectProtocolV2Device({ skipProtocolProbe: true });
4998
+
4999
+ expect(enumerate).not.toHaveBeenCalled();
5000
+ expect(acquire).toHaveBeenCalledWith('ble-id', null, true, 'V2', undefined, undefined, true);
5001
+ expect(commands.disposed).toBe(false);
5002
+ expect(commands.mainId).toBe('ble-id');
5003
+ });
5004
+
4391
5005
  test('selects the matching physical device from multiple Protocol V2 USB reconnect candidates', async () => {
4392
5006
  const method = new FirmwareUpdateV4({
4393
5007
  id: 1,
@@ -4632,7 +5246,7 @@ describe('Protocol V2 firmware update targets', () => {
4632
5246
  type: 'DeviceInfo',
4633
5247
  message: {
4634
5248
  hw: { serial_no: 'PR2SERIAL' },
4635
- fw: { application: { version: '1.0.0' } },
5249
+ main_mcu: { application: { version: '1.0.0' } },
4636
5250
  },
4637
5251
  });
4638
5252
  (method as any).protocolV2ExpectedSerialNumber = 'expected-serial';
@@ -4653,6 +5267,45 @@ describe('Protocol V2 firmware update targets', () => {
4653
5267
  expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
4654
5268
  });
4655
5269
 
5270
+ test('refreshes ProtocolInfo while waiting for App after a firmware reboot', async () => {
5271
+ const method = new FirmwareUpdateV4({
5272
+ id: 1,
5273
+ payload: {
5274
+ method: 'firmwareUpdateV4',
5275
+ },
5276
+ });
5277
+ const deviceInfo = {
5278
+ hw: { serial_no: 'PRO2-PHYSICAL-1' },
5279
+ main_mcu: { application: { version: '1.0.0' } },
5280
+ };
5281
+ const typedCall = jest.fn().mockResolvedValue({
5282
+ type: 'DeviceInfo',
5283
+ message: deviceInfo,
5284
+ });
5285
+ const probeProtocolV2RuntimeState = jest.fn(
5286
+ (_deviceInfo, _timeout, options?: { forceRuntimeContextRefresh?: boolean }) =>
5287
+ Promise.resolve({
5288
+ mode: options?.forceRuntimeContextRefresh ? 'normal' : 'bootloader',
5289
+ bootloaderMode: !options?.forceRuntimeContextRefresh,
5290
+ })
5291
+ );
5292
+ (method as any).protocolV2ExpectedSerialNumber = 'PRO2-PHYSICAL-1';
5293
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
5294
+ (method as any).device = stubDevice({
5295
+ getCommands: () => ({ typedCall }),
5296
+ probeProtocolV2RuntimeState,
5297
+ });
5298
+
5299
+ await expect(
5300
+ (method as any).waitForProtocolV2ReconnectAndFeatures(60_000)
5301
+ ).resolves.toMatchObject({ mode: 'normal', bootloaderMode: false });
5302
+
5303
+ expect(probeProtocolV2RuntimeState).toHaveBeenCalledWith(deviceInfo, 5000, {
5304
+ forceRuntimeContextRefresh: true,
5305
+ });
5306
+ expect((method as any).protocolV2LatestFinalDeviceInfo).toBe(deviceInfo);
5307
+ });
5308
+
4656
5309
  test('reboots Protocol V2 firmware flow back to normal without legacy switch-firmware prompt', async () => {
4657
5310
  const method = new FirmwareUpdateV4({
4658
5311
  id: 1,
@@ -4738,28 +5391,30 @@ describe('Protocol V2 firmware update targets', () => {
4738
5391
  });
4739
5392
  });
4740
5393
 
4741
- test('treats Android BLE transport release during Protocol V2 reboot as expected', async () => {
5394
+ test('treats a typed BLE disconnect during Protocol V2 normal reboot as expected', async () => {
4742
5395
  const method = new FirmwareUpdateV4({
4743
5396
  id: 1,
4744
5397
  payload: {
4745
5398
  method: 'firmwareUpdateV4',
4746
5399
  },
4747
5400
  });
4748
- const typedCall = jest.fn().mockRejectedValue(new Error('React Native BLE transport released'));
5401
+ const typedCall = jest
5402
+ .fn()
5403
+ .mockRejectedValue(ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected));
4749
5404
 
4750
5405
  (method as any).device = stubDevice({
4751
5406
  getCommands: () => ({ typedCall }),
4752
5407
  });
4753
5408
 
4754
- await expect((method as any).protocolV2Reboot(DeviceRebootType.Bootloader)).resolves.toEqual({
5409
+ await expect((method as any).protocolV2Reboot(DeviceRebootType.Normal)).resolves.toEqual({
4755
5410
  message: 'Device rebooted successfully',
4756
5411
  });
4757
5412
  expect((method as any).device.markProtocolV2Reboot).toHaveBeenCalledWith(
4758
- DeviceRebootType.Bootloader
5413
+ DeviceRebootType.Normal
4759
5414
  );
4760
5415
  });
4761
5416
 
4762
- test('requires an explicit Protocol V2 update ACK before entering install state', async () => {
5417
+ test('uses a short timeout and continues after a BLE reboot response timeout', async () => {
4763
5418
  const method = new FirmwareUpdateV4({
4764
5419
  id: 1,
4765
5420
  payload: {
@@ -4769,40 +5424,180 @@ describe('Protocol V2 firmware update targets', () => {
4769
5424
  const typedCall = jest
4770
5425
  .fn()
4771
5426
  .mockRejectedValue(
4772
- Object.assign(
4773
- new Error("Failed to execute 'open' on 'USBDevice': The device was disconnected"),
4774
- { name: 'NotFoundError' }
5427
+ ERRORS.TypedError(
5428
+ HardwareErrorCode.BleTimeoutError,
5429
+ 'Lowlevel response timeout after 5000ms for DeviceReboot'
4775
5430
  )
4776
5431
  );
4777
5432
 
4778
5433
  (method as any).device = stubDevice({
4779
5434
  getCommands: () => ({ typedCall }),
4780
5435
  });
4781
- method.postTipMessage = jest.fn();
4782
- method.postProgressMessage = jest.fn();
4783
-
4784
- await expect(
4785
- (method as any).protocolV2StartFirmwareUpdate({
4786
- targets: [{ target_id: 4, path: 'vol1:firmware.bin' }],
4787
- })
4788
- ).rejects.toThrow();
4789
-
4790
- expect(method.postTipMessage).not.toHaveBeenCalled();
4791
- expect(method.postProgressMessage).not.toHaveBeenCalled();
4792
- });
4793
5436
 
4794
- test('does not poll Protocol V2 install status when the update ACK times out', async () => {
4795
- const method = new FirmwareUpdateV4({
4796
- id: 1,
4797
- payload: {
5437
+ await expect((method as any).protocolV2Reboot(DeviceRebootType.Normal)).resolves.toEqual({
5438
+ message: 'Device rebooted successfully',
5439
+ });
5440
+ expect(typedCall).toHaveBeenCalledWith(
5441
+ 'DeviceReboot',
5442
+ 'Success',
5443
+ { reboot_type: DeviceRebootType.Normal },
5444
+ { timeoutMs: 5000 }
5445
+ );
5446
+ expect((method as any).device.markProtocolV2Reboot).toHaveBeenCalledWith(
5447
+ DeviceRebootType.Normal
5448
+ );
5449
+ });
5450
+
5451
+ test('continues after a USB Protocol V2 reboot response timeout', async () => {
5452
+ const method = new FirmwareUpdateV4({
5453
+ id: 1,
5454
+ payload: {
5455
+ method: 'firmwareUpdateV4',
5456
+ },
5457
+ });
5458
+ const timeoutError = Object.assign(
5459
+ new Error('Protocol V2 response timeout after 5000ms for DeviceReboot'),
5460
+ { code: 'response-timeout' }
5461
+ );
5462
+ const typedCall = jest.fn().mockRejectedValue(timeoutError);
5463
+
5464
+ (method as any).device = stubDevice({
5465
+ getCommands: () => ({ typedCall }),
5466
+ });
5467
+
5468
+ await expect((method as any).protocolV2Reboot(DeviceRebootType.Normal)).resolves.toEqual({
5469
+ message: 'Device rebooted successfully',
5470
+ });
5471
+ expect((method as any).device.markProtocolV2Reboot).toHaveBeenCalledWith(
5472
+ DeviceRebootType.Normal
5473
+ );
5474
+ });
5475
+
5476
+ test('propagates non-transport errors from a Protocol V2 reboot', async () => {
5477
+ const method = new FirmwareUpdateV4({
5478
+ id: 1,
5479
+ payload: {
5480
+ method: 'firmwareUpdateV4',
5481
+ },
5482
+ });
5483
+ const rebootError = new Error('Device rejected reboot');
5484
+ const typedCall = jest.fn().mockRejectedValue(rebootError);
5485
+
5486
+ (method as any).device = stubDevice({
5487
+ getCommands: () => ({ typedCall }),
5488
+ });
5489
+
5490
+ await expect((method as any).protocolV2Reboot(DeviceRebootType.Normal)).rejects.toBe(
5491
+ rebootError
5492
+ );
5493
+ expect((method as any).device.markProtocolV2Reboot).not.toHaveBeenCalled();
5494
+ });
5495
+
5496
+ test('treats Android BLE transport release during Protocol V2 reboot as expected', async () => {
5497
+ const method = new FirmwareUpdateV4({
5498
+ id: 1,
5499
+ payload: {
4798
5500
  method: 'firmwareUpdateV4',
4799
5501
  },
4800
5502
  });
4801
- const typedCall = jest.fn().mockRejectedValue(new Error('LIBUSB_TRANSFER_TIMED_OUT'));
5503
+ const typedCall = jest.fn().mockRejectedValue(new Error('React Native BLE transport released'));
4802
5504
 
4803
5505
  (method as any).device = stubDevice({
4804
5506
  getCommands: () => ({ typedCall }),
4805
5507
  });
5508
+
5509
+ await expect((method as any).protocolV2Reboot(DeviceRebootType.Bootloader)).resolves.toEqual({
5510
+ message: 'Device rebooted successfully',
5511
+ });
5512
+ expect((method as any).device.markProtocolV2Reboot).toHaveBeenCalledWith(
5513
+ DeviceRebootType.Bootloader
5514
+ );
5515
+ });
5516
+
5517
+ test('stages targets before sending the empty Protocol V2 install request', async () => {
5518
+ const method = new FirmwareUpdateV4({
5519
+ id: 1,
5520
+ payload: {
5521
+ method: 'firmwareUpdateV4',
5522
+ },
5523
+ });
5524
+ const targets = [{ target_id: 4, path: 'vol1:firmware.bin' }];
5525
+ const typedCall = jest.fn().mockResolvedValue({ type: 'Success', message: {} });
5526
+ const call = jest.fn().mockResolvedValue({ type: 'WriteCompleted', message: {} });
5527
+ const cancelDevice = jest.fn().mockResolvedValue(undefined);
5528
+ let cancelableAction: (() => Promise<unknown>) | undefined;
5529
+ const interaction = {
5530
+ interactionId: 'firmware-update',
5531
+ phaseId: 'firmware-update:phase-1',
5532
+ sequence: 1,
5533
+ phase: 'button',
5534
+ transition: 'start',
5535
+ protocol: 'V2',
5536
+ };
5537
+
5538
+ (method as any).device = stubDevice({
5539
+ getCommands: () => ({ typedCall, call, cancelDevice }),
5540
+ createProtocolV2UiPhaseMetadata: jest.fn().mockReturnValue(interaction),
5541
+ toMessageObject: jest.fn().mockReturnValue({ connectId: 'pro2' }),
5542
+ setCancelableAction: jest.fn(action => {
5543
+ cancelableAction = action;
5544
+ }),
5545
+ });
5546
+ method.postMessage = jest.fn();
5547
+ method.postTipMessage = jest.fn();
5548
+ method.postProgressMessage = jest.fn();
5549
+
5550
+ await expect(
5551
+ (method as any).protocolV2StartFirmwareUpdate({ targets })
5552
+ ).resolves.toBeUndefined();
5553
+
5554
+ expect(typedCall).toHaveBeenCalledWith('DeviceFirmwareUpdateStage', 'Success', { targets });
5555
+ expect(method.postMessage).toHaveBeenCalledWith({
5556
+ event: 'UI_EVENT',
5557
+ type: UI_REQUEST.REQUEST_BUTTON,
5558
+ payload: {
5559
+ device: { connectId: 'pro2' },
5560
+ source: 'method-lifecycle',
5561
+ reason: 'firmware-update',
5562
+ completion: 'operation-completed',
5563
+ deviceOnly: true,
5564
+ operation: 'firmwareUpdateV4',
5565
+ interaction,
5566
+ },
5567
+ });
5568
+ expect(call).toHaveBeenCalledWith(
5569
+ 'DeviceFirmwareUpdateRequest',
5570
+ {},
5571
+ expect.objectContaining({
5572
+ returnAfterWrite: true,
5573
+ expectedTypes: ['Success'],
5574
+ onResponseAfterWrite: expect.any(Function),
5575
+ })
5576
+ );
5577
+ expect(typedCall.mock.invocationCallOrder[0]).toBeLessThan(call.mock.invocationCallOrder[0]);
5578
+ expect((method.postMessage as jest.Mock).mock.invocationCallOrder[0]).toBeLessThan(
5579
+ call.mock.invocationCallOrder[0]
5580
+ );
5581
+ expect(method.postTipMessage).not.toHaveBeenCalled();
5582
+ expect(method.postProgressMessage).not.toHaveBeenCalled();
5583
+ await cancelableAction?.();
5584
+ expect(cancelDevice).toHaveBeenCalledTimes(1);
5585
+ });
5586
+
5587
+ test('does not send the install request when Protocol V2 staging fails', async () => {
5588
+ const method = new FirmwareUpdateV4({
5589
+ id: 1,
5590
+ payload: {
5591
+ method: 'firmwareUpdateV4',
5592
+ },
5593
+ });
5594
+ const typedCall = jest.fn().mockRejectedValue(new Error('stage failed'));
5595
+ const call = jest.fn();
5596
+
5597
+ (method as any).device = stubDevice({
5598
+ getCommands: () => ({ typedCall, call }),
5599
+ });
5600
+ method.postMessage = jest.fn();
4806
5601
  method.postTipMessage = jest.fn();
4807
5602
  method.postProgressMessage = jest.fn();
4808
5603
 
@@ -4810,12 +5605,148 @@ describe('Protocol V2 firmware update targets', () => {
4810
5605
  (method as any).protocolV2StartFirmwareUpdate({
4811
5606
  targets: [{ target_id: 4, path: 'vol1:application_p1.bin' }],
4812
5607
  })
4813
- ).rejects.toThrow('LIBUSB_TRANSFER_TIMED_OUT');
5608
+ ).rejects.toThrow('stage failed');
5609
+
5610
+ expect(call).not.toHaveBeenCalled();
5611
+ expect(method.postMessage).not.toHaveBeenCalled();
5612
+ expect(method.postTipMessage).not.toHaveBeenCalled();
5613
+ expect(method.postProgressMessage).not.toHaveBeenCalled();
5614
+ });
5615
+
5616
+ test('does not enter install state when the empty request fails', async () => {
5617
+ const method = new FirmwareUpdateV4({
5618
+ id: 1,
5619
+ payload: {
5620
+ method: 'firmwareUpdateV4',
5621
+ },
5622
+ });
5623
+ const typedCall = jest.fn().mockResolvedValue({ type: 'Success', message: {} });
5624
+ const call = jest.fn().mockRejectedValue(new Error('request failed'));
5625
+
5626
+ (method as any).device = stubDevice({
5627
+ getCommands: () => ({ typedCall, call }),
5628
+ createProtocolV2UiPhaseMetadata: jest.fn().mockReturnValue(undefined),
5629
+ toMessageObject: jest.fn().mockReturnValue({ connectId: 'pro2' }),
5630
+ });
5631
+ method.postMessage = jest.fn();
5632
+ method.postTipMessage = jest.fn();
5633
+ method.postProgressMessage = jest.fn();
5634
+
5635
+ await expect(
5636
+ (method as any).protocolV2StartFirmwareUpdate({
5637
+ targets: [{ target_id: 4, path: 'vol0:/application_p1.bin' }],
5638
+ })
5639
+ ).rejects.toThrow('request failed');
4814
5640
 
5641
+ expect(call).toHaveBeenCalledWith(
5642
+ 'DeviceFirmwareUpdateRequest',
5643
+ {},
5644
+ expect.objectContaining({
5645
+ returnAfterWrite: true,
5646
+ expectedTypes: ['Success'],
5647
+ onResponseAfterWrite: expect.any(Function),
5648
+ })
5649
+ );
4815
5650
  expect(method.postTipMessage).not.toHaveBeenCalled();
4816
5651
  expect(method.postProgressMessage).not.toHaveBeenCalled();
4817
5652
  });
4818
5653
 
5654
+ test('continues to install polling when the empty request releases BLE transport', async () => {
5655
+ const method = new FirmwareUpdateV4({
5656
+ id: 1,
5657
+ payload: {
5658
+ method: 'firmwareUpdateV4',
5659
+ },
5660
+ });
5661
+ const typedCall = jest.fn().mockResolvedValue({ type: 'Success', message: {} });
5662
+ const call = jest.fn().mockRejectedValue(new Error('React Native BLE transport released'));
5663
+
5664
+ (method as any).device = stubDevice({
5665
+ getCommands: () => ({ typedCall, call }),
5666
+ createProtocolV2UiPhaseMetadata: jest.fn().mockReturnValue(undefined),
5667
+ toMessageObject: jest.fn().mockReturnValue({ connectId: 'pro2' }),
5668
+ });
5669
+ (method as any).isBleReconnect = jest.fn(() => true);
5670
+ method.postMessage = jest.fn();
5671
+
5672
+ await expect(
5673
+ (method as any).protocolV2StartFirmwareUpdate({
5674
+ targets: [{ target_id: 4, path: 'vol0:/application_p1.bin' }],
5675
+ })
5676
+ ).resolves.toBeUndefined();
5677
+
5678
+ expect(call).toHaveBeenCalledWith(
5679
+ 'DeviceFirmwareUpdateRequest',
5680
+ {},
5681
+ expect.objectContaining({
5682
+ returnAfterWrite: true,
5683
+ expectedTypes: ['Success'],
5684
+ onResponseAfterWrite: expect.any(Function),
5685
+ })
5686
+ );
5687
+ expect(typedCall).not.toHaveBeenCalledWith('DeviceFirmwareUpdateRequest', 'Success', {});
5688
+ expect((method as any).protocolV2InstallNeedsReconnect).toBe(true);
5689
+ });
5690
+
5691
+ test.each([
5692
+ { name: 'bootloader', targetId: 3, path: 'vol0:/bootloader.bin' },
5693
+ { name: 'application', targetId: 4, path: 'vol0:/application_p1.bin' },
5694
+ { name: 'secure element', targetId: 7, path: 'vol0:/se01.bin' },
5695
+ ])(
5696
+ 'reconnects a released BLE install link before polling $name status without DeviceInfo',
5697
+ async ({ targetId, path }) => {
5698
+ const method = new FirmwareUpdateV4({
5699
+ id: 1,
5700
+ payload: {
5701
+ method: 'firmwareUpdateV4',
5702
+ },
5703
+ });
5704
+ const targets = [{ target_id: targetId, path }];
5705
+ const typedCall = jest
5706
+ .fn()
5707
+ .mockResolvedValueOnce({
5708
+ type: 'DeviceFirmwareUpdateStatus',
5709
+ message: { records: [{ ...targets[0], status: 1, payload_version: 0 }] },
5710
+ })
5711
+ .mockResolvedValueOnce({
5712
+ type: 'DeviceFirmwareUpdateStatus',
5713
+ message: { records: [{ ...targets[0], status: 2, payload_version: 0x010000 }] },
5714
+ });
5715
+ const reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
5716
+ const verifyProtocolV2ReconnectIdentity = jest.fn();
5717
+ const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation(((
5718
+ callback: () => void
5719
+ ) => {
5720
+ callback();
5721
+ return 0 as any;
5722
+ }) as typeof setTimeout);
5723
+
5724
+ (method as any).device = stubDevice({
5725
+ getCommands: () => ({ typedCall }),
5726
+ });
5727
+ (method as any).isBleReconnect = jest.fn(() => true);
5728
+ (method as any).protocolV2InstallNeedsReconnect = true;
5729
+ (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
5730
+ (method as any).verifyProtocolV2ReconnectIdentity = verifyProtocolV2ReconnectIdentity;
5731
+ method.postProgressMessage = jest.fn();
5732
+
5733
+ try {
5734
+ await expect(
5735
+ (method as any).waitForProtocolV2FirmwareUpdateComplete(targets, true)
5736
+ ).resolves.toBeUndefined();
5737
+ } finally {
5738
+ setTimeoutSpy.mockRestore();
5739
+ }
5740
+
5741
+ expect(reconnectProtocolV2Device).toHaveBeenCalledWith({ skipProtocolProbe: true });
5742
+ expect(verifyProtocolV2ReconnectIdentity).not.toHaveBeenCalled();
5743
+ expect(typedCall.mock.calls.map(callArgs => callArgs[0])).toEqual([
5744
+ 'DeviceFirmwareUpdateStatusGet',
5745
+ 'DeviceFirmwareUpdateStatusGet',
5746
+ ]);
5747
+ }
5748
+ );
5749
+
4819
5750
  test('polls only firmware status while Protocol V2 bootloader is installing', async () => {
4820
5751
  const method = new FirmwareUpdateV4({
4821
5752
  id: 1,
@@ -4833,23 +5764,23 @@ describe('Protocol V2 firmware update targets', () => {
4833
5764
  type: 'DeviceFirmwareUpdateStatus',
4834
5765
  message: { records: [{ target_id: 4, status: 2 }] },
4835
5766
  });
4836
- const reconnectProtocolV2Device = jest
4837
- .fn()
4838
- .mockRejectedValueOnce(new Error('Device rebooting'))
4839
- .mockResolvedValue(undefined);
5767
+ const reconnectProtocolV2Device = jest.fn();
5768
+ const verifyProtocolV2ReconnectIdentity = jest.fn();
4840
5769
 
4841
5770
  (method as any).device = stubDevice({
4842
5771
  getCommands: () => ({ typedCall }),
4843
5772
  });
4844
5773
  (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
4845
- (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(undefined);
5774
+ (method as any).verifyProtocolV2ReconnectIdentity = verifyProtocolV2ReconnectIdentity;
4846
5775
  method.postProgressMessage = jest.fn();
5776
+ method.postTipMessage = jest.fn();
4847
5777
 
4848
5778
  await (method as any).waitForProtocolV2FirmwareUpdateComplete([
4849
5779
  { target_id: 4, path: 'vol0:/application_p1.bin' },
4850
5780
  ]);
4851
5781
 
4852
- expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(2);
5782
+ expect(reconnectProtocolV2Device).not.toHaveBeenCalled();
5783
+ expect(verifyProtocolV2ReconnectIdentity).not.toHaveBeenCalled();
4853
5784
  expect(typedCall.mock.calls.map(call => call[0])).toEqual([
4854
5785
  'DeviceFirmwareUpdateStatusGet',
4855
5786
  'DeviceFirmwareUpdateStatusGet',
@@ -4859,7 +5790,7 @@ describe('Protocol V2 firmware update targets', () => {
4859
5790
  expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
4860
5791
  });
4861
5792
 
4862
- test('accepts a missing firmware status handler only after confirming normal mode', async () => {
5793
+ test('keeps polling after an empty status response until the target finishes', async () => {
4863
5794
  const method = new FirmwareUpdateV4({
4864
5795
  id: 1,
4865
5796
  payload: {
@@ -4868,44 +5799,527 @@ describe('Protocol V2 firmware update targets', () => {
4868
5799
  });
4869
5800
  const typedCall = jest
4870
5801
  .fn()
4871
- .mockRejectedValue(new Error('Failure: Handler not registered for this message'));
5802
+ .mockResolvedValueOnce({
5803
+ type: 'DeviceFirmwareUpdateStatus',
5804
+ message: { records: [] },
5805
+ })
5806
+ .mockResolvedValueOnce({
5807
+ type: 'DeviceFirmwareUpdateStatus',
5808
+ message: { records: [] },
5809
+ })
5810
+ .mockResolvedValueOnce({
5811
+ type: 'DeviceFirmwareUpdateStatus',
5812
+ message: { records: [{ target_id: 4, status: 0 }] },
5813
+ })
5814
+ .mockResolvedValueOnce({
5815
+ type: 'DeviceFirmwareUpdateStatus',
5816
+ message: { records: [{ target_id: 4, status: 2 }] },
5817
+ });
4872
5818
  const reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
5819
+ const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation(((
5820
+ callback: () => void
5821
+ ) => {
5822
+ callback();
5823
+ return 0 as any;
5824
+ }) as typeof setTimeout);
4873
5825
 
4874
5826
  (method as any).device = stubDevice({
4875
5827
  getCommands: () => ({ typedCall }),
4876
5828
  });
4877
5829
  (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
4878
- const deviceInfo = { hw: { serial_no: 'PRO2-PHYSICAL-1' } };
4879
- (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(deviceInfo);
4880
- (method as any).probeProtocolV2NormalMode = jest.fn().mockResolvedValue(true);
5830
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(undefined);
4881
5831
  method.postProgressMessage = jest.fn();
4882
5832
 
4883
- await (method as any).waitForProtocolV2FirmwareUpdateComplete([
4884
- { target_id: 4, path: 'vol0:/application_p1.bin' },
5833
+ try {
5834
+ await (method as any).waitForProtocolV2FirmwareUpdateComplete([
5835
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
5836
+ ]);
5837
+ } finally {
5838
+ setTimeoutSpy.mockRestore();
5839
+ }
5840
+
5841
+ expect(reconnectProtocolV2Device).not.toHaveBeenCalled();
5842
+ expect(typedCall.mock.calls.map(call => call[0])).toEqual([
5843
+ 'DeviceFirmwareUpdateStatusGet',
5844
+ 'DeviceFirmwareUpdateStatusGet',
5845
+ 'DeviceFirmwareUpdateStatusGet',
5846
+ 'DeviceFirmwareUpdateStatusGet',
4885
5847
  ]);
5848
+ expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
5849
+ });
5850
+
5851
+ test('keeps waiting while firmware status records are empty before user confirmation', async () => {
5852
+ const method = new FirmwareUpdateV4({
5853
+ id: 1,
5854
+ payload: {
5855
+ method: 'firmwareUpdateV4',
5856
+ },
5857
+ });
5858
+ const typedCall = jest
5859
+ .fn()
5860
+ .mockResolvedValueOnce({
5861
+ type: 'DeviceFirmwareUpdateStatus',
5862
+ message: { records: [] },
5863
+ })
5864
+ .mockResolvedValueOnce({
5865
+ type: 'DeviceFirmwareUpdateStatus',
5866
+ message: { records: [{ target_id: 4, status: 0 }] },
5867
+ })
5868
+ .mockResolvedValueOnce({
5869
+ type: 'DeviceFirmwareUpdateStatus',
5870
+ message: { records: [{ target_id: 4, status: 2 }] },
5871
+ });
5872
+ const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation(((
5873
+ callback: () => void
5874
+ ) => {
5875
+ callback();
5876
+ return 0 as any;
5877
+ }) as typeof setTimeout);
5878
+
5879
+ (method as any).device = stubDevice({
5880
+ getCommands: () => ({ typedCall }),
5881
+ });
5882
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
5883
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(undefined);
5884
+ method.postProgressMessage = jest.fn();
5885
+
5886
+ try {
5887
+ await (method as any).waitForProtocolV2FirmwareUpdateComplete(
5888
+ [{ target_id: 4, path: 'vol0:/application_p1.bin' }],
5889
+ true
5890
+ );
5891
+ } finally {
5892
+ setTimeoutSpy.mockRestore();
5893
+ }
5894
+
5895
+ expect(typedCall).toHaveBeenCalledTimes(3);
5896
+ expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
5897
+ });
5898
+
5899
+ test('ignores stale finished records until the current install starts', async () => {
5900
+ const method = new FirmwareUpdateV4({
5901
+ id: 1,
5902
+ payload: {
5903
+ method: 'firmwareUpdateV4',
5904
+ },
5905
+ });
5906
+ const typedCall = jest
5907
+ .fn()
5908
+ .mockResolvedValueOnce({
5909
+ type: 'DeviceFirmwareUpdateStatus',
5910
+ message: { records: [{ target_id: 4, status: 2, path: 'vol0:/application_p1.bin' }] },
5911
+ })
5912
+ .mockResolvedValueOnce({
5913
+ type: 'DeviceFirmwareUpdateStatus',
5914
+ message: { records: [{ target_id: 4, status: 0, path: 'vol0:/application_p1.bin' }] },
5915
+ })
5916
+ .mockResolvedValueOnce({
5917
+ type: 'DeviceFirmwareUpdateStatus',
5918
+ message: { records: [{ target_id: 4, status: 2, path: 'vol0:/application_p1.bin' }] },
5919
+ });
5920
+ const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation(((
5921
+ callback: () => void
5922
+ ) => {
5923
+ callback();
5924
+ return 0 as any;
5925
+ }) as typeof setTimeout);
5926
+
5927
+ (method as any).device = stubDevice({
5928
+ getCommands: () => ({ typedCall }),
5929
+ });
5930
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
5931
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(undefined);
5932
+ method.postProgressMessage = jest.fn();
5933
+
5934
+ try {
5935
+ await (method as any).waitForProtocolV2FirmwareUpdateComplete(
5936
+ [{ target_id: 4, path: 'vol0:/application_p1.bin' }],
5937
+ true
5938
+ );
5939
+ } finally {
5940
+ setTimeoutSpy.mockRestore();
5941
+ }
5942
+
5943
+ expect(typedCall).toHaveBeenCalledTimes(3);
5944
+ expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
5945
+ });
5946
+
5947
+ test('does not count a stale finished target when only another target was live', () => {
5948
+ const method = new FirmwareUpdateV4({
5949
+ id: 1,
5950
+ payload: {
5951
+ method: 'firmwareUpdateV4',
5952
+ },
5953
+ });
5954
+ const assertProtocolV2TargetStatus = (method as any).assertProtocolV2TargetStatus.bind(method);
5955
+ const expectedTargetIds = new Set([3, 4]);
5956
+ const expectedPaths = new Map([
5957
+ [3, 'vol0:/bootloader.bin'],
5958
+ [4, 'vol0:/application_p1.bin'],
5959
+ ]);
5960
+ const liveTargetIds = new Set([4]);
5961
+ method.postProgressMessage = jest.fn();
5962
+
5963
+ const completed = assertProtocolV2TargetStatus(
5964
+ [
5965
+ { target_id: 3, status: 2, path: 'vol0:/bootloader.bin' },
5966
+ { target_id: 4, status: 2, path: 'vol0:/application_p1.bin' },
5967
+ ],
5968
+ expectedTargetIds,
5969
+ expectedPaths,
5970
+ liveTargetIds
5971
+ );
5972
+
5973
+ expect(completed).toBe(false);
5974
+ expect((method as any).protocolV2CompletedTargetIds).toEqual(new Set([4]));
5975
+ expect(method.postProgressMessage).toHaveBeenCalledWith(50, 'installingFirmware');
5976
+ expect(method.postProgressMessage).not.toHaveBeenCalledWith(100, 'installingFirmware');
5977
+ });
5978
+
5979
+ test('ignores stale failed records until the current install starts', async () => {
5980
+ const method = new FirmwareUpdateV4({
5981
+ id: 1,
5982
+ payload: {
5983
+ method: 'firmwareUpdateV4',
5984
+ },
5985
+ });
5986
+ const typedCall = jest
5987
+ .fn()
5988
+ .mockResolvedValueOnce({
5989
+ type: 'DeviceFirmwareUpdateStatus',
5990
+ message: { records: [{ target_id: 4, status: 3 }] },
5991
+ })
5992
+ .mockResolvedValueOnce({
5993
+ type: 'DeviceFirmwareUpdateStatus',
5994
+ message: { records: [{ target_id: 4, status: 0 }] },
5995
+ })
5996
+ .mockResolvedValueOnce({
5997
+ type: 'DeviceFirmwareUpdateStatus',
5998
+ message: { records: [{ target_id: 4, status: 3 }] },
5999
+ });
6000
+ const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation(((
6001
+ callback: () => void
6002
+ ) => {
6003
+ callback();
6004
+ return 0 as any;
6005
+ }) as typeof setTimeout);
6006
+
6007
+ (method as any).device = stubDevice({
6008
+ getCommands: () => ({ typedCall }),
6009
+ });
6010
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
6011
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(undefined);
6012
+
6013
+ try {
6014
+ await expect(
6015
+ (method as any).waitForProtocolV2FirmwareUpdateComplete(
6016
+ [{ target_id: 4, path: 'vol0:/application_p1.bin' }],
6017
+ true
6018
+ )
6019
+ ).rejects.toMatchObject({ errorCode: HardwareErrorCode.FirmwareError });
6020
+ } finally {
6021
+ setTimeoutSpy.mockRestore();
6022
+ }
6023
+
6024
+ expect(typedCall).toHaveBeenCalledTimes(3);
6025
+ });
6026
+
6027
+ test('ignores leftover failed records while another leftover target is still pending', async () => {
6028
+ const method = new FirmwareUpdateV4({
6029
+ id: 1,
6030
+ payload: {
6031
+ method: 'firmwareUpdateV4',
6032
+ },
6033
+ });
6034
+ const typedCall = jest
6035
+ .fn()
6036
+ .mockResolvedValueOnce({
6037
+ type: 'DeviceFirmwareUpdateStatus',
6038
+ message: {
6039
+ records: [
6040
+ { target_id: 3, status: 0, path: 'vol0:/bootloader.bin' },
6041
+ { target_id: 4, status: 3, path: 'vol0:/application_p1.bin' },
6042
+ ],
6043
+ },
6044
+ })
6045
+ .mockResolvedValueOnce({
6046
+ type: 'DeviceFirmwareUpdateStatus',
6047
+ message: {
6048
+ records: [
6049
+ { target_id: 3, status: 0, path: 'vol0:/bootloader.bin' },
6050
+ { target_id: 4, status: 0, path: 'vol0:/application_p1.bin' },
6051
+ ],
6052
+ },
6053
+ })
6054
+ .mockResolvedValueOnce({
6055
+ type: 'DeviceFirmwareUpdateStatus',
6056
+ message: {
6057
+ records: [
6058
+ { target_id: 3, status: 2, path: 'vol0:/bootloader.bin' },
6059
+ { target_id: 4, status: 2, path: 'vol0:/application_p1.bin' },
6060
+ ],
6061
+ },
6062
+ });
6063
+ const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation(((
6064
+ callback: () => void
6065
+ ) => {
6066
+ callback();
6067
+ return 0 as any;
6068
+ }) as typeof setTimeout);
6069
+
6070
+ (method as any).device = stubDevice({
6071
+ getCommands: () => ({ typedCall }),
6072
+ });
6073
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
6074
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(undefined);
6075
+ method.postProgressMessage = jest.fn();
6076
+
6077
+ try {
6078
+ await (method as any).waitForProtocolV2FirmwareUpdateComplete(
6079
+ [
6080
+ { target_id: 3, path: 'vol0:/bootloader.bin' },
6081
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
6082
+ ],
6083
+ true
6084
+ );
6085
+ } finally {
6086
+ setTimeoutSpy.mockRestore();
6087
+ }
6088
+
6089
+ expect(typedCall).toHaveBeenCalledTimes(3);
6090
+ expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
6091
+ });
6092
+
6093
+ test('reports failed only after that target was live in the current install', async () => {
6094
+ const method = new FirmwareUpdateV4({
6095
+ id: 1,
6096
+ payload: {
6097
+ method: 'firmwareUpdateV4',
6098
+ },
6099
+ });
6100
+ const typedCall = jest
6101
+ .fn()
6102
+ .mockResolvedValueOnce({
6103
+ type: 'DeviceFirmwareUpdateStatus',
6104
+ message: {
6105
+ records: [
6106
+ { target_id: 3, status: 0, path: 'vol0:/bootloader.bin' },
6107
+ { target_id: 4, status: 3, path: 'vol0:/application_p1.bin' },
6108
+ ],
6109
+ },
6110
+ })
6111
+ .mockResolvedValueOnce({
6112
+ type: 'DeviceFirmwareUpdateStatus',
6113
+ message: {
6114
+ records: [
6115
+ { target_id: 3, status: 0, path: 'vol0:/bootloader.bin' },
6116
+ { target_id: 4, status: 0, path: 'vol0:/application_p1.bin' },
6117
+ ],
6118
+ },
6119
+ })
6120
+ .mockResolvedValueOnce({
6121
+ type: 'DeviceFirmwareUpdateStatus',
6122
+ message: {
6123
+ records: [
6124
+ { target_id: 3, status: 0, path: 'vol0:/bootloader.bin' },
6125
+ { target_id: 4, status: 3, path: 'vol0:/application_p1.bin' },
6126
+ ],
6127
+ },
6128
+ });
6129
+ const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation(((
6130
+ callback: () => void
6131
+ ) => {
6132
+ callback();
6133
+ return 0 as any;
6134
+ }) as typeof setTimeout);
6135
+
6136
+ (method as any).device = stubDevice({
6137
+ getCommands: () => ({ typedCall }),
6138
+ });
6139
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
6140
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(undefined);
6141
+
6142
+ try {
6143
+ await expect(
6144
+ (method as any).waitForProtocolV2FirmwareUpdateComplete(
6145
+ [
6146
+ { target_id: 3, path: 'vol0:/bootloader.bin' },
6147
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
6148
+ ],
6149
+ true
6150
+ )
6151
+ ).rejects.toMatchObject({
6152
+ errorCode: HardwareErrorCode.FirmwareError,
6153
+ message: 'Protocol V2 firmware install failed',
6154
+ });
6155
+ } finally {
6156
+ setTimeoutSpy.mockRestore();
6157
+ }
6158
+
6159
+ expect(typedCall).toHaveBeenCalledTimes(3);
6160
+ });
6161
+
6162
+ test('waits five minutes before rejecting normal mode without install evidence', async () => {
6163
+ const method = new FirmwareUpdateV4({
6164
+ id: 1,
6165
+ payload: {
6166
+ method: 'firmwareUpdateV4',
6167
+ },
6168
+ });
6169
+ const typedCall = jest
6170
+ .fn()
6171
+ .mockRejectedValue(new Error('Failure: Handler not registered for this message'));
6172
+ const deviceInfo = { hw: { serial_no: 'PRO2-PHYSICAL-1' } };
6173
+ let now = 0;
6174
+ jest.spyOn(Date, 'now').mockImplementation(() => now);
6175
+ jest.spyOn(global, 'setTimeout').mockImplementation(((callback: () => void) => {
6176
+ now += 60 * 1000;
6177
+ callback();
6178
+ return 0 as any;
6179
+ }) as typeof setTimeout);
6180
+
6181
+ (method as any).device = stubDevice({
6182
+ getCommands: () => ({ typedCall }),
6183
+ });
6184
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
6185
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(deviceInfo);
6186
+ (method as any).probeProtocolV2NormalMode = jest.fn().mockResolvedValue(true);
6187
+ method.postProgressMessage = jest.fn();
6188
+
6189
+ await expect(
6190
+ (method as any).waitForProtocolV2FirmwareUpdateComplete([
6191
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
6192
+ ])
6193
+ ).rejects.toMatchObject({
6194
+ errorCode: HardwareErrorCode.FirmwareError,
6195
+ message: 'Protocol V2 firmware install timed out',
6196
+ params: { firmwareUpdateCode: 'FirmwareInstallTimeout' },
6197
+ });
6198
+
6199
+ expect(method.postProgressMessage).not.toHaveBeenCalledWith(100, 'installingFirmware');
6200
+ expect(typedCall).toHaveBeenCalledTimes(5);
6201
+ });
6202
+
6203
+ test('preserves multi-app completion evidence when App mode replaces the status endpoint', async () => {
6204
+ const method = new FirmwareUpdateV4({
6205
+ id: 1,
6206
+ payload: {
6207
+ method: 'firmwareUpdateV4',
6208
+ targetsToUpdate: ['app_v1', 'app_v2'],
6209
+ expectedTargetVersions: {
6210
+ app_v1: '2.0.0',
6211
+ app_v2: '2.0.0',
6212
+ },
6213
+ },
6214
+ });
6215
+ const typedCall = jest
6216
+ .fn()
6217
+ .mockRejectedValue(new Error('Failure: Handler not registered for this message'));
6218
+ const deviceInfo = { hw: { serial_no: 'PRO2-PHYSICAL-1' } };
6219
+ const probeProtocolV2RuntimeState = jest.fn().mockResolvedValue({
6220
+ mode: 'normal',
6221
+ bootloaderMode: false,
6222
+ firmwareVersion: '2.0.0',
6223
+ });
6224
+
6225
+ (method as any).device = stubDevice({
6226
+ getCommands: () => ({ typedCall }),
6227
+ probeProtocolV2RuntimeState,
6228
+ });
6229
+ (method as any).protocolV2InstallBaselineVersions = new Map([
6230
+ [4, '1.0.0'],
6231
+ [5, '1.0.0'],
6232
+ ]);
6233
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
6234
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(deviceInfo);
6235
+ method.postProgressMessage = jest.fn();
6236
+
6237
+ await expect(
6238
+ (method as any).waitForProtocolV2FirmwareUpdateComplete([
6239
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
6240
+ { target_id: 5, path: 'vol0:/application_p2.bin' },
6241
+ ])
6242
+ ).resolves.toBeUndefined();
6243
+
6244
+ expect(probeProtocolV2RuntimeState).toHaveBeenCalledWith(deviceInfo, 5000);
6245
+ expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
6246
+ expect(Array.from((method as any).protocolV2CompletedTargetIds)).toEqual([4, 5]);
6247
+
6248
+ (method as any).protocolV2LatestFinalFeatures = {
6249
+ firmwareVersion: '2.0.0',
6250
+ };
6251
+ (method as any).protocolV2LatestFinalDeviceInfo = {
6252
+ main_mcu: {
6253
+ application: { version: '2.0.0' },
6254
+ },
6255
+ };
6256
+ expect(() => (method as any).assertExpectedProtocolV2Versions()).not.toThrow();
6257
+ });
6258
+
6259
+ test('preserves all target completion evidence when status records disappear after reboot', async () => {
6260
+ const method = new FirmwareUpdateV4({
6261
+ id: 1,
6262
+ payload: {
6263
+ method: 'firmwareUpdateV4',
6264
+ },
6265
+ });
6266
+ const typedCall = jest
6267
+ .fn()
6268
+ .mockRejectedValueOnce(new Error('Pro2 is rebooting'))
6269
+ .mockResolvedValueOnce({
6270
+ type: 'DeviceFirmwareUpdateStatus',
6271
+ message: { records: [] },
6272
+ });
6273
+ const deviceInfo = { hw: { serial_no: 'PRO2-PHYSICAL-1' } };
6274
+ const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation(((
6275
+ callback: () => void
6276
+ ) => {
6277
+ callback();
6278
+ return 0 as any;
6279
+ }) as typeof setTimeout);
6280
+
6281
+ (method as any).device = stubDevice({
6282
+ getCommands: () => ({ typedCall }),
6283
+ });
6284
+ (method as any).protocolV2InstallTerminalSuccessObserved = true;
6285
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
6286
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(deviceInfo);
6287
+ (method as any).probeProtocolV2NormalMode = jest.fn().mockResolvedValue(true);
6288
+ method.postProgressMessage = jest.fn();
6289
+
6290
+ try {
6291
+ await expect(
6292
+ (method as any).waitForProtocolV2FirmwareUpdateComplete([
6293
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
6294
+ { target_id: 5, path: 'vol0:/application_p2.bin' },
6295
+ ])
6296
+ ).resolves.toBeUndefined();
6297
+ } finally {
6298
+ setTimeoutSpy.mockRestore();
6299
+ }
4886
6300
 
4887
- expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
4888
- expect(typedCall.mock.calls.map(call => call[0])).toEqual(['DeviceFirmwareUpdateStatusGet']);
4889
- expect((method as any).probeProtocolV2NormalMode).toHaveBeenCalledWith(deviceInfo);
6301
+ expect(Array.from((method as any).protocolV2CompletedTargetIds)).toEqual([4, 5]);
4890
6302
  expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
4891
6303
  });
4892
6304
 
4893
- test('accepts an empty firmware status dump only after confirming normal mode', async () => {
6305
+ test('uses the confirmed install request as evidence after the same device returns', async () => {
4894
6306
  const method = new FirmwareUpdateV4({
4895
6307
  id: 1,
4896
6308
  payload: {
4897
6309
  method: 'firmwareUpdateV4',
4898
6310
  },
4899
6311
  });
4900
- const typedCall = jest.fn().mockResolvedValue({
4901
- type: 'DeviceFirmwareUpdateStatus',
4902
- message: { records: [] },
4903
- });
6312
+ const typedCall = jest
6313
+ .fn()
6314
+ .mockRejectedValueOnce(new Error('The USB device was disconnected'))
6315
+ .mockResolvedValueOnce({
6316
+ type: 'DeviceFirmwareUpdateStatus',
6317
+ message: { records: [] },
6318
+ });
4904
6319
  const deviceInfo = { hw: { serial_no: 'PRO2-PHYSICAL-1' } };
4905
- let now = 0;
4906
- jest.spyOn(Date, 'now').mockImplementation(() => now);
4907
- jest.spyOn(global, 'setTimeout').mockImplementation(((callback: () => void) => {
4908
- now += 31 * 1000;
6320
+ const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation(((
6321
+ callback: () => void
6322
+ ) => {
4909
6323
  callback();
4910
6324
  return 0 as any;
4911
6325
  }) as typeof setTimeout);
@@ -4913,24 +6327,32 @@ describe('Protocol V2 firmware update targets', () => {
4913
6327
  (method as any).device = stubDevice({
4914
6328
  getCommands: () => ({ typedCall }),
4915
6329
  });
6330
+ (method as any).protocolV2InstallTerminalSuccessObserved = true;
4916
6331
  (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
4917
6332
  (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(deviceInfo);
4918
6333
  (method as any).probeProtocolV2NormalMode = jest.fn().mockResolvedValue(true);
4919
6334
  method.postProgressMessage = jest.fn();
4920
6335
 
4921
- await expect(
4922
- (method as any).waitForProtocolV2FirmwareUpdateComplete([
4923
- { target_id: 4, path: 'vol0:/application_p1.bin' },
4924
- ])
4925
- ).resolves.toBeUndefined();
6336
+ try {
6337
+ await expect(
6338
+ (method as any).waitForProtocolV2FirmwareUpdateComplete(
6339
+ [{ target_id: 4, path: 'vol0:/application_p1.bin' }],
6340
+ true
6341
+ )
6342
+ ).resolves.toBeUndefined();
6343
+ } finally {
6344
+ setTimeoutSpy.mockRestore();
6345
+ }
4926
6346
 
4927
- expect(typedCall).toHaveBeenCalledTimes(1);
4928
- expect((method as any).probeProtocolV2NormalMode).toHaveBeenCalledWith(deviceInfo);
4929
- expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
6347
+ expect((method as any).reconnectProtocolV2Device).toHaveBeenCalledWith({
6348
+ skipProtocolProbe: true,
6349
+ });
6350
+ expect(method.postProgressMessage).toHaveBeenLastCalledWith(100, 'installingFirmware');
4930
6351
  });
4931
6352
 
4932
6353
  test.each([
4933
6354
  [{ mode: 'normal', bootloaderMode: false }, true],
6355
+ [{ mode: 'notInitialized', bootloaderMode: false }, true],
4934
6356
  [{ mode: 'bootloader', bootloaderMode: true }, false],
4935
6357
  ])('derives firmware completion from the runtime-state probe: %o', async (features, expected) => {
4936
6358
  const method = new FirmwareUpdateV4({
@@ -4989,7 +6411,9 @@ describe('Protocol V2 firmware update targets', () => {
4989
6411
  'DeviceFirmwareUpdateStatusGet',
4990
6412
  'DeviceFirmwareUpdateStatusGet',
4991
6413
  ]);
4992
- expect((method as any).probeProtocolV2NormalMode).toHaveBeenCalledWith(deviceInfo);
6414
+ expect((method as any).reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
6415
+ expect((method as any).verifyProtocolV2ReconnectIdentity).toHaveBeenCalledTimes(1);
6416
+ expect((method as any).probeProtocolV2NormalMode).not.toHaveBeenCalled();
4993
6417
  });
4994
6418
 
4995
6419
  test('rejects a finished target whose payload version conflicts with the plan', () => {
@@ -5104,8 +6528,8 @@ describe('Protocol V2 firmware update targets', () => {
5104
6528
  { target_id: 4, path: 'vol0:/application_p1.bin' },
5105
6529
  ])
5106
6530
  ).rejects.toMatchObject({
5107
- errorCode: HardwareErrorCode.RuntimeError,
5108
- message: 'Protocol V2 install status conflicts with target 4',
6531
+ errorCode: HardwareErrorCode.FirmwareError,
6532
+ message: 'Protocol V2 firmware install status conflict',
5109
6533
  params: {
5110
6534
  firmwareUpdateCode: 'FirmwareInstallStatusConflict',
5111
6535
  },
@@ -5118,7 +6542,7 @@ describe('Protocol V2 firmware update targets', () => {
5118
6542
  expect(typedCall).toHaveBeenCalledTimes(1);
5119
6543
  });
5120
6544
 
5121
- test('does not fail a completed multi-app install when final app versions are unobservable', () => {
6545
+ test('uses the device firmware version for P1 and install completion for unobservable P2', () => {
5122
6546
  const method = new FirmwareUpdateV4({
5123
6547
  id: 1,
5124
6548
  payload: {
@@ -5126,7 +6550,7 @@ describe('Protocol V2 firmware update targets', () => {
5126
6550
  platform: 'desktop',
5127
6551
  targetsToUpdate: ['app_v1', 'app_v2'],
5128
6552
  expectedTargetVersions: {
5129
- app_v1: '1.0.0',
6553
+ app_v1: '3.0.0',
5130
6554
  app_v2: '2.0.0',
5131
6555
  },
5132
6556
  },
@@ -5137,12 +6561,41 @@ describe('Protocol V2 firmware update targets', () => {
5137
6561
  minor_version: 0,
5138
6562
  patch_version: 0,
5139
6563
  };
5140
- (method as any).protocolV2FinalStatusVerified = true;
6564
+ (method as any).protocolV2CompletedTargetIds = new Set([4, 5]);
6565
+
6566
+ expect(() => (method as any).assertExpectedProtocolV2Versions()).not.toThrow();
6567
+ });
6568
+
6569
+ test('uses the firmware version for P1 and the optional DeviceInfo version for P2', () => {
6570
+ const method = new FirmwareUpdateV4({
6571
+ id: 1,
6572
+ payload: {
6573
+ method: 'firmwareUpdateV4',
6574
+ platform: 'desktop',
6575
+ targetsToUpdate: ['app_v1', 'app_v2'],
6576
+ expectedTargetVersions: {
6577
+ app_v1: '1.0.0',
6578
+ app_v2: '2.0.0',
6579
+ },
6580
+ },
6581
+ });
6582
+ method.init();
6583
+ (method as any).protocolV2LatestFinalFeatures = {
6584
+ major_version: 1,
6585
+ minor_version: 0,
6586
+ patch_version: 0,
6587
+ };
6588
+ (method as any).protocolV2LatestFinalDeviceInfo = {
6589
+ main_mcu: {
6590
+ application: { version: '9.9.9' },
6591
+ application_data: { version: '2.0.0' },
6592
+ },
6593
+ };
5141
6594
 
5142
6595
  expect(() => (method as any).assertExpectedProtocolV2Versions()).not.toThrow();
5143
6596
  });
5144
6597
 
5145
- test('rejects unobservable final versions after the status endpoint fallback', () => {
6598
+ test('rejects a mismatched final DeviceInfo application slot version', () => {
5146
6599
  const method = new FirmwareUpdateV4({
5147
6600
  id: 1,
5148
6601
  payload: {
@@ -5156,6 +6609,63 @@ describe('Protocol V2 firmware update targets', () => {
5156
6609
  },
5157
6610
  });
5158
6611
  method.init();
6612
+ (method as any).protocolV2LatestFinalFeatures = {
6613
+ major_version: 1,
6614
+ minor_version: 0,
6615
+ patch_version: 0,
6616
+ };
6617
+ (method as any).protocolV2LatestFinalDeviceInfo = {
6618
+ main_mcu: {
6619
+ application: { version: '1.0.0' },
6620
+ application_data: { version: '2.0.1' },
6621
+ },
6622
+ };
6623
+
6624
+ expect(() => (method as any).assertExpectedProtocolV2Versions()).toThrow(
6625
+ 'target app_v2 reached 2.0.1, expected 2.0.0'
6626
+ );
6627
+ });
6628
+
6629
+ test('rejects an unobservable P2 version without P2 completion evidence', () => {
6630
+ const method = new FirmwareUpdateV4({
6631
+ id: 1,
6632
+ payload: {
6633
+ method: 'firmwareUpdateV4',
6634
+ platform: 'desktop',
6635
+ targetsToUpdate: ['app_v1', 'app_v2'],
6636
+ expectedTargetVersions: {
6637
+ app_v1: '3.0.0',
6638
+ app_v2: '2.0.0',
6639
+ },
6640
+ },
6641
+ });
6642
+ method.init();
6643
+ (method as any).protocolV2LatestFinalFeatures = {
6644
+ major_version: 3,
6645
+ minor_version: 0,
6646
+ patch_version: 0,
6647
+ };
6648
+
6649
+ (method as any).protocolV2CompletedTargetIds = new Set([4]);
6650
+
6651
+ expect(() => (method as any).assertExpectedProtocolV2Versions()).toThrow(
6652
+ 'target app_v2 has no observable final version after status fallback'
6653
+ );
6654
+ });
6655
+
6656
+ test('still rejects an unobservable non-app version after the status endpoint fallback', () => {
6657
+ const method = new FirmwareUpdateV4({
6658
+ id: 1,
6659
+ payload: {
6660
+ method: 'firmwareUpdateV4',
6661
+ platform: 'desktop',
6662
+ targetsToUpdate: ['se01'],
6663
+ expectedTargetVersions: {
6664
+ se01: '1.0.0',
6665
+ },
6666
+ },
6667
+ });
6668
+ method.init();
5159
6669
  (method as any).protocolV2LatestFinalFeatures = {
5160
6670
  major_version: 3,
5161
6671
  minor_version: 0,
@@ -5163,11 +6673,11 @@ describe('Protocol V2 firmware update targets', () => {
5163
6673
  };
5164
6674
 
5165
6675
  expect(() => (method as any).assertExpectedProtocolV2Versions()).toThrow(
5166
- 'has no observable final version after status fallback'
6676
+ 'target se01 has no observable final version after status fallback'
5167
6677
  );
5168
6678
  });
5169
6679
 
5170
- test('polls target status after update ACK and finishes when all targets complete', async () => {
6680
+ test('polls target status after the install request and finishes when all targets complete', async () => {
5171
6681
  const method = new FirmwareUpdateV4({
5172
6682
  id: 1,
5173
6683
  payload: {
@@ -5199,7 +6709,7 @@ describe('Protocol V2 firmware update targets', () => {
5199
6709
  ])
5200
6710
  ).resolves.toBeUndefined();
5201
6711
 
5202
- expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
6712
+ expect(reconnectProtocolV2Device).not.toHaveBeenCalled();
5203
6713
  expect(typedCall).toHaveBeenCalledWith(
5204
6714
  'DeviceFirmwareUpdateStatusGet',
5205
6715
  'DeviceFirmwareUpdateStatus',
@@ -5209,7 +6719,7 @@ describe('Protocol V2 firmware update targets', () => {
5209
6719
  expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
5210
6720
  });
5211
6721
 
5212
- test('fails clearly when a requested target stays absent from the full status dump', async () => {
6722
+ test('keeps polling missing target records until the five-minute timeout', async () => {
5213
6723
  const method = new FirmwareUpdateV4({
5214
6724
  id: 1,
5215
6725
  payload: {
@@ -5225,7 +6735,7 @@ describe('Protocol V2 firmware update targets', () => {
5225
6735
  let now = 0;
5226
6736
  jest.spyOn(Date, 'now').mockImplementation(() => now);
5227
6737
  jest.spyOn(global, 'setTimeout').mockImplementation(((callback: () => void) => {
5228
- now += 31 * 1000;
6738
+ now += 60 * 1000;
5229
6739
  callback();
5230
6740
  return 0 as any;
5231
6741
  }) as typeof setTimeout);
@@ -5244,12 +6754,13 @@ describe('Protocol V2 firmware update targets', () => {
5244
6754
  ])
5245
6755
  ).rejects.toMatchObject({
5246
6756
  errorCode: HardwareErrorCode.FirmwareError,
5247
- message: expect.stringContaining('targetIds=3'),
6757
+ message: 'Protocol V2 firmware install timed out',
6758
+ params: { firmwareUpdateCode: 'FirmwareInstallTimeout' },
5248
6759
  });
5249
- expect(typedCall).toHaveBeenCalledTimes(2);
6760
+ expect(typedCall).toHaveBeenCalledTimes(5);
5250
6761
  });
5251
6762
 
5252
- test('resets the missing-record grace period after a Pro2 reboot interruption', async () => {
6763
+ test('keeps polling incomplete records after a Pro2 reboot interruption', async () => {
5253
6764
  const method = new FirmwareUpdateV4({
5254
6765
  id: 1,
5255
6766
  payload: {
@@ -5298,11 +6809,11 @@ describe('Protocol V2 firmware update targets', () => {
5298
6809
  { target_id: 4, path: 'vol0:/application_p1.bin' },
5299
6810
  ])
5300
6811
  ).resolves.toBeUndefined();
5301
- expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(2);
6812
+ expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
5302
6813
  expect(typedCall).toHaveBeenCalledTimes(4);
5303
6814
  });
5304
6815
 
5305
- test('uses an eight-minute Pro2 install status window', async () => {
6816
+ test('uses a five-minute Pro2 install status window', async () => {
5306
6817
  const method = new FirmwareUpdateV4({
5307
6818
  id: 1,
5308
6819
  payload: {
@@ -5312,13 +6823,17 @@ describe('Protocol V2 firmware update targets', () => {
5312
6823
  jest
5313
6824
  .spyOn(Date, 'now')
5314
6825
  .mockReturnValueOnce(0)
5315
- .mockReturnValue(8 * 60 * 1000);
6826
+ .mockReturnValue(5 * 60 * 1000);
5316
6827
 
5317
6828
  await expect(
5318
6829
  (method as any).waitForProtocolV2FirmwareUpdateComplete([
5319
6830
  { target_id: 4, path: 'vol0:/application_p1.bin' },
5320
6831
  ])
5321
- ).rejects.toThrow('within 480s');
6832
+ ).rejects.toMatchObject({
6833
+ errorCode: HardwareErrorCode.FirmwareError,
6834
+ message: 'Protocol V2 firmware install timed out',
6835
+ params: { firmwareUpdateCode: 'FirmwareInstallTimeout' },
6836
+ });
5322
6837
  });
5323
6838
 
5324
6839
  test('uses a 90-second Pro2 bootloader reconnect window', async () => {
@@ -5396,7 +6911,7 @@ describe('Protocol V2 firmware update targets', () => {
5396
6911
  ],
5397
6912
  new Set([4, 10])
5398
6913
  )
5399
- ).toThrow('Protocol V2 install status conflicts with target 4');
6914
+ ).toThrow('Protocol V2 firmware install status conflict');
5400
6915
  expect(method.postProgressMessage).not.toHaveBeenCalled();
5401
6916
  expect(method.postProgressMessage).not.toHaveBeenCalledWith(100, 'installingFirmware');
5402
6917
 
@@ -5436,8 +6951,8 @@ describe('Protocol V2 firmware update targets', () => {
5436
6951
  throw new Error('Expected Protocol V2 failed firmware status to throw');
5437
6952
  } catch (error: any) {
5438
6953
  expect(error.errorCode).toBe(HardwareErrorCode.FirmwareError);
5439
- expect(error.message).toContain('FW_MGMT_UPDATER_TASK_STATUS_FAILED_VERIFY');
5440
- expect(error.message).toContain('vol0:/application_p1.bin');
6954
+ expect(error.message).toBe('Protocol V2 firmware install failed');
6955
+ expect(error.params).toEqual({ firmwareUpdateCode: 'FirmwareInstallFailed' });
5441
6956
  }
5442
6957
  });
5443
6958
 
@@ -5474,7 +6989,8 @@ describe('Protocol V2 firmware update targets', () => {
5474
6989
  ])
5475
6990
  ).rejects.toMatchObject({
5476
6991
  errorCode: HardwareErrorCode.FirmwareError,
5477
- message: expect.stringContaining('vol0:/application_p1.bin'),
6992
+ message: 'Protocol V2 firmware install failed',
6993
+ params: { firmwareUpdateCode: 'FirmwareInstallFailed' },
5478
6994
  });
5479
6995
  expect(typedCall).toHaveBeenCalledWith(
5480
6996
  'DeviceFirmwareUpdateStatusGet',
@@ -5482,6 +6998,8 @@ describe('Protocol V2 firmware update targets', () => {
5482
6998
  {
5483
6999
  fields: {
5484
7000
  status: true,
7001
+ progress_percent: true,
7002
+ phase_info: true,
5485
7003
  payload_version: true,
5486
7004
  path: true,
5487
7005
  },
@@ -5490,7 +7008,7 @@ describe('Protocol V2 firmware update targets', () => {
5490
7008
  );
5491
7009
  });
5492
7010
 
5493
- test('passes bootloader, coprocessor, SE and app files to DeviceFirmwareUpdate targets', async () => {
7011
+ test('preserves component-first target order for the firmware-owned loader handoff', async () => {
5494
7012
  const method = new FirmwareUpdateV4({
5495
7013
  id: 1,
5496
7014
  payload: {
@@ -5519,54 +7037,75 @@ describe('Protocol V2 firmware update targets', () => {
5519
7037
  .mockResolvedValue(undefined);
5520
7038
 
5521
7039
  await (method as any).executeProtocolV2Update({
5522
- bootloaderBinary: new Uint8Array([4, 5]).buffer,
5523
- fwBinaryMap: [
7040
+ // The active bootloader installs its component runners in records order and
7041
+ // leaves the bootloader record pending for romloader after reboot. Keep boot
7042
+ // deliberately after components to prove Core does not synthesize boot-first.
7043
+ installItems: [
5524
7044
  {
5525
- fileName: 'coprocessor.bin',
5526
- binary: new Uint8Array([6]).buffer,
5527
- targetId: 6,
7045
+ fileName: 'application_p1.bin',
7046
+ binary: new Uint8Array([8]).buffer,
7047
+ targetId: 4,
7048
+ kind: 'firmware',
5528
7049
  },
5529
7050
  {
5530
7051
  fileName: 'se01.bin',
5531
7052
  binary: new Uint8Array([7]).buffer,
5532
7053
  targetId: 7,
7054
+ kind: 'firmware',
5533
7055
  },
5534
7056
  {
5535
- fileName: 'application_p1.bin',
5536
- binary: new Uint8Array([8]).buffer,
5537
- targetId: 4,
7057
+ fileName: 'bootloader.bin',
7058
+ binary: new Uint8Array([4, 5]).buffer,
7059
+ targetId: 3,
7060
+ kind: 'bootloader',
7061
+ },
7062
+ {
7063
+ fileName: 'coprocessor.bin',
7064
+ binary: new Uint8Array([6]).buffer,
7065
+ targetId: 6,
7066
+ kind: 'firmware',
5538
7067
  },
5539
7068
  ],
5540
7069
  });
5541
7070
 
5542
7071
  expect(writtenPaths).toEqual([
7072
+ 'vol0:/application_p1.bin',
7073
+ 'vol0:/se01.bin',
5543
7074
  'vol0:/bootloader.bin',
5544
7075
  'vol0:/coprocessor.bin',
5545
- 'vol0:/se01.bin',
5546
- 'vol0:/application_p1.bin',
5547
7076
  ]);
5548
7077
  expect((method as any).verifyProtocolV2StagedFile).toHaveBeenCalledTimes(4);
5549
7078
  expect((method as any).verifyProtocolV2StagedFile).toHaveBeenNthCalledWith(
5550
7079
  2,
5551
- 'vol0:/coprocessor.bin',
7080
+ 'vol0:/se01.bin',
5552
7081
  1
5553
7082
  );
5554
- expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledTimes(2);
5555
- expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenNthCalledWith(1, {
5556
- targets: [{ target_id: 3, path: 'vol0:/bootloader.bin' }],
5557
- });
5558
- expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenNthCalledWith(2, {
7083
+ expect((method as any).enterProtocolV2BootloaderMode).toHaveBeenCalledTimes(1);
7084
+ expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledTimes(1);
7085
+ expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledWith({
5559
7086
  targets: [
5560
- { target_id: 6, path: 'vol0:/coprocessor.bin' },
5561
- { target_id: 7, path: 'vol0:/se01.bin' },
5562
7087
  { target_id: 4, path: 'vol0:/application_p1.bin' },
7088
+ { target_id: 7, path: 'vol0:/se01.bin' },
7089
+ { target_id: 3, path: 'vol0:/bootloader.bin' },
7090
+ { target_id: 6, path: 'vol0:/coprocessor.bin' },
5563
7091
  ],
5564
7092
  });
7093
+ expect((method as any).waitForProtocolV2FirmwareUpdateComplete).toHaveBeenCalledTimes(1);
7094
+ expect((method as any).waitForProtocolV2FirmwareUpdateComplete).toHaveBeenCalledWith(
7095
+ [
7096
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
7097
+ { target_id: 7, path: 'vol0:/se01.bin' },
7098
+ { target_id: 3, path: 'vol0:/bootloader.bin' },
7099
+ { target_id: 6, path: 'vol0:/coprocessor.bin' },
7100
+ ],
7101
+ true
7102
+ );
7103
+ expect((method as any).exitProtocolV2BootloaderToNormal).not.toHaveBeenCalled();
5565
7104
  expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'transferData');
5566
- expect((method as any).waitForProtocolV2FirmwareUpdateComplete).toHaveBeenCalled();
7105
+ expect((method as any).completeProtocolV2FinalVerification).toHaveBeenCalledTimes(1);
5567
7106
  });
5568
7107
 
5569
- test('announces separate resource and component transfer phases', async () => {
7108
+ test('uses one global transfer range for resources and firmware', async () => {
5570
7109
  const method = new FirmwareUpdateV4({
5571
7110
  id: 1,
5572
7111
  payload: {
@@ -5623,18 +7162,24 @@ describe('Protocol V2 firmware update targets', () => {
5623
7162
  ],
5624
7163
  });
5625
7164
 
5626
- expect(method.postTipMessage).toHaveBeenCalledTimes(3);
7165
+ expect((method as any).enterProtocolV2BootloaderMode).toHaveBeenCalledTimes(1);
7166
+ expect(method.postTipMessage).toHaveBeenCalledTimes(2);
5627
7167
  expect(method.postTipMessage).toHaveBeenNthCalledWith(1, 'StartTransferData');
5628
- expect(method.postTipMessage).toHaveBeenNthCalledWith(2, 'StartTransferData');
5629
- expect(method.postTipMessage).toHaveBeenNthCalledWith(3, 'ConfirmOnDevice');
7168
+ expect(method.postTipMessage).toHaveBeenNthCalledWith(2, 'ConfirmOnDevice');
5630
7169
  expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenNthCalledWith(
5631
7170
  1,
5632
- expect.objectContaining({ processedSize: 0, totalSize: 2 })
7171
+ expect.objectContaining({ processedSize: 0, totalSize: 3 })
5633
7172
  );
5634
7173
  expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenNthCalledWith(
5635
7174
  2,
5636
- expect.objectContaining({ processedSize: 0, totalSize: 1 })
7175
+ expect.objectContaining({ processedSize: 2, totalSize: 3 })
5637
7176
  );
7177
+ expect(method.postProgressMessage).toHaveBeenCalledTimes(1);
7178
+ expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'transferData');
7179
+ expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledTimes(1);
7180
+ expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledWith({
7181
+ targets: [{ target_id: 4, path: 'vol0:/application_p1.bin' }],
7182
+ });
5638
7183
  expect((method as any).isProtocolV2ResourceBundleUpToDate).toHaveBeenCalledWith(
5639
7184
  expect.objectContaining({
5640
7185
  version: [1, 2, 3],
@@ -5644,50 +7189,7 @@ describe('Protocol V2 firmware update targets', () => {
5644
7189
  );
5645
7190
  });
5646
7191
 
5647
- test('installs and verifies bootloader before syncing manifest resources', () => {
5648
- const method = new FirmwareUpdateV4({
5649
- id: 1,
5650
- payload: { method: 'firmwareUpdateV4' },
5651
- });
5652
- const phases = (method as any).buildProtocolV2ExecutionPhases({
5653
- installSources: [{ kind: 'bootloader' }, { kind: 'component' }],
5654
- resourceSources: [{ kind: 'resource' }],
5655
- });
5656
-
5657
- expect(phases.map((phase: { kind: string }) => phase.kind)).toEqual([
5658
- 'bootloader-install',
5659
- 'bootloader-verify',
5660
- 'resource-sync',
5661
- 'component-install',
5662
- 'final-verify',
5663
- ]);
5664
- });
5665
-
5666
- test('stages the mounted boot resource before any firmware install phase', () => {
5667
- const method = new FirmwareUpdateV4({
5668
- id: 1,
5669
- payload: { method: 'firmwareUpdateV4' },
5670
- });
5671
- const bootResource = {
5672
- devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg',
5673
- };
5674
- const phases = (method as any).buildProtocolV2ExecutionPhases({
5675
- installSources: [{ kind: 'bootloader' }, { kind: 'component' }],
5676
- resourceSources: [bootResource, { devicePath: 'vol0:/resource/images/images.okpkg' }],
5677
- });
5678
-
5679
- expect(phases.map((phase: { kind: string }) => phase.kind)).toEqual([
5680
- 'resource-sync',
5681
- 'bootloader-install',
5682
- 'bootloader-verify',
5683
- 'resource-sync',
5684
- 'component-install',
5685
- 'final-verify',
5686
- ]);
5687
- expect(phases[0].resourceSources).toEqual([bootResource]);
5688
- });
5689
-
5690
- test('removes stale boot resource staging before a component-only reboot', async () => {
7192
+ test('removes stale boot resource staging before the combined transfer', async () => {
5691
7193
  const method = new FirmwareUpdateV4({
5692
7194
  id: 1,
5693
7195
  payload: { method: 'firmwareUpdateV4' },
@@ -5714,18 +7216,18 @@ describe('Protocol V2 firmware update targets', () => {
5714
7216
  events.push('transfer-component');
5715
7217
  return Promise.resolve();
5716
7218
  });
5717
- (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockImplementation(() => {
5718
- events.push('reboot-normal');
5719
- return Promise.resolve();
7219
+ (method as any).completeProtocolV2FinalVerification = jest.fn().mockImplementation(() => {
7220
+ events.push('final-verify');
7221
+ return Promise.resolve({});
5720
7222
  });
5721
- (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
5722
7223
 
5723
7224
  await (method as any).executeProtocolV2SourceUpdate({
5724
7225
  installSources: [{ kind: 'firmware' }],
5725
7226
  resourceSources: [],
5726
7227
  });
5727
7228
 
5728
- expect(events).toEqual(['delete-stale-staging', 'transfer-component', 'reboot-normal']);
7229
+ expect((method as any).enterProtocolV2BootloaderMode).toHaveBeenCalledTimes(1);
7230
+ expect(events).toEqual(['delete-stale-staging', 'transfer-component', 'final-verify']);
5729
7231
  expect(typedCall).toHaveBeenNthCalledWith(2, 'FilesystemFileDelete', 'Success', {
5730
7232
  path: 'vol0:/loaders/bootloader/boot_resource.okpkg.staging',
5731
7233
  });
@@ -5911,41 +7413,51 @@ describe('Protocol V2 firmware update targets', () => {
5911
7413
  bootloader: {
5912
7414
  target: 'BOOTLOADER',
5913
7415
  url: 'https://example.com/bootloader.pp.bin',
7416
+ version: [1, 0, 0],
5914
7417
  ...componentIntegrity('https://example.com/bootloader.pp.bin'),
5915
7418
  },
5916
7419
  applicationP1: {
5917
7420
  target: 'APPLICATION_P1',
5918
7421
  url: 'https://example.com/applicationP1.pp.bin',
7422
+ version: [2, 0, 0],
5919
7423
  ...componentIntegrity('https://example.com/applicationP1.pp.bin'),
7424
+ // Core treats the package as opaque; the device owns its internal format validation.
7425
+ payloadHash: '00'.repeat(64),
5920
7426
  },
5921
7427
  applicationP2: {
5922
7428
  target: 'APPLICATION_P2',
5923
7429
  url: 'https://example.com/applicationP2.pp.bin',
7430
+ version: [2, 0, 1],
5924
7431
  ...componentIntegrity('https://example.com/applicationP2.pp.bin'),
5925
7432
  },
5926
7433
  coprocessor: {
5927
7434
  target: 'COPROCESSOR',
5928
7435
  url: 'https://example.com/coprocessor.pp.bin',
7436
+ version: [1, 1, 0],
5929
7437
  ...componentIntegrity('https://example.com/coprocessor.pp.bin'),
5930
7438
  },
5931
7439
  se01: {
5932
7440
  target: 'SE01',
5933
7441
  url: 'https://example.com/se01.pp.bin',
7442
+ version: [1, 2, 1],
5934
7443
  ...componentIntegrity('https://example.com/se01.pp.bin'),
5935
7444
  },
5936
7445
  se02: {
5937
7446
  target: 'SE02',
5938
7447
  url: 'https://example.com/se02.pp.bin',
7448
+ version: [1, 2, 2],
5939
7449
  ...componentIntegrity('https://example.com/se02.pp.bin'),
5940
7450
  },
5941
7451
  se03: {
5942
7452
  target: 'SE03',
5943
7453
  url: 'https://example.com/se03.pp.bin',
7454
+ version: [1, 2, 3],
5944
7455
  ...componentIntegrity('https://example.com/se03.pp.bin'),
5945
7456
  },
5946
7457
  se04: {
5947
7458
  target: 'SE04',
5948
7459
  url: 'https://example.com/se04.pp.bin',
7460
+ version: [1, 2, 4],
5949
7461
  ...componentIntegrity('https://example.com/se04.pp.bin'),
5950
7462
  },
5951
7463
  },
@@ -5983,6 +7495,16 @@ describe('Protocol V2 firmware update targets', () => {
5983
7495
  'https://example.com/se03.pp.bin',
5984
7496
  'https://example.com/se04.pp.bin',
5985
7497
  ]);
7498
+ expect((method as any).params.expectedTargetVersions).toEqual({
7499
+ boot: '1.0.0',
7500
+ app_v1: '2.0.0',
7501
+ app_v2: '2.0.1',
7502
+ coprocessor: '1.1.0',
7503
+ se01: '1.2.1',
7504
+ se02: '1.2.2',
7505
+ se03: '1.2.3',
7506
+ se04: '1.2.4',
7507
+ });
5986
7508
 
5987
7509
  getSysResourceBinarySpy.mockRestore();
5988
7510
  getFirmwareLatestReleaseSpy.mockRestore();
@@ -5997,6 +7519,9 @@ describe('Protocol V2 firmware update targets', () => {
5997
7519
  method: 'firmwareUpdateV4',
5998
7520
  platform: 'web',
5999
7521
  targetsToUpdate: ['app_v1', 'coprocessor'],
7522
+ expectedTargetVersions: {
7523
+ app_v1: '9.9.9',
7524
+ },
6000
7525
  },
6001
7526
  });
6002
7527
  method.init();
@@ -6015,12 +7540,14 @@ describe('Protocol V2 firmware update targets', () => {
6015
7540
  applicationP1: {
6016
7541
  target: 'APPLICATION_P1',
6017
7542
  url: 'https://example.com/applicationP1.pp.bin',
7543
+ version: [2, 0, 0],
6018
7544
  expectedSize: explicitApplicationBinary.byteLength,
6019
7545
  fingerprint: bytesToHex(sha256(new Uint8Array(explicitApplicationBinary))),
6020
7546
  },
6021
7547
  coprocessor: {
6022
7548
  target: 'COPROCESSOR',
6023
7549
  url: 'https://example.com/coprocessor.pp.bin',
7550
+ version: [1, 1, 0],
6024
7551
  expectedSize: remoteCoprocessorBinary.byteLength,
6025
7552
  fingerprint: bytesToHex(sha256(new Uint8Array(remoteCoprocessorBinary))),
6026
7553
  },
@@ -6061,6 +7588,10 @@ describe('Protocol V2 firmware update targets', () => {
6061
7588
  kind: 'firmware',
6062
7589
  },
6063
7590
  ]);
7591
+ expect((method as any).params.expectedTargetVersions).toEqual({
7592
+ app_v1: '9.9.9',
7593
+ coprocessor: '1.1.0',
7594
+ });
6064
7595
 
6065
7596
  getSysResourceBinarySpy.mockRestore();
6066
7597
  getFirmwareLatestReleaseSpy.mockRestore();
@@ -6230,7 +7761,7 @@ describe('Protocol V2 firmware update targets', () => {
6230
7761
  });
6231
7762
  });
6232
7763
 
6233
- test('maps a prepared resource archive manifest to artifact-backed resource sources', async () => {
7764
+ test('maps self-describing packages from a prepared resource archive', async () => {
6234
7765
  const method = new FirmwareUpdateV4({
6235
7766
  id: 1,
6236
7767
  payload: {
@@ -6238,62 +7769,20 @@ describe('Protocol V2 firmware update targets', () => {
6238
7769
  },
6239
7770
  });
6240
7771
  const imagesBinary = createProtocolV2OkppBinary();
6241
- const bootBinary = createProtocolV2OkppBinary({ payloadHashByte: 0x33 });
7772
+ const bootBinary = createProtocolV2OkppBinary({
7773
+ devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg.staging',
7774
+ payloadHashByte: 0x33,
7775
+ });
6242
7776
  const imagesSha256 = bytesToHex(sha256(new Uint8Array(imagesBinary)));
6243
7777
  const bootSha256 = bytesToHex(sha256(new Uint8Array(bootBinary)));
6244
- const manifest = {
6245
- schema: 1,
6246
- artifact_name: 'pro2-resource',
6247
- release_name: 'test-release',
6248
- variant: 'resource',
6249
- commit: 'abc123',
6250
- short_sha: 'abc123',
6251
- timestamp_utc: '2026-08-09T00:00:00Z',
6252
- core_version: '1.0.0',
6253
- key_set: 'production',
6254
- device_root: 'vol0:',
6255
- restore_mode: 'bootloader_update',
6256
- trees: [{ path: 'bundles', device: 'pro2' }],
6257
- files: [
6258
- {
6259
- archive_path: 'bundles/images/images.okpkg',
6260
- original_name: 'images.okpkg',
6261
- device_path: 'vol0:/bundles/images/images.okpkg',
6262
- size: imagesBinary.byteLength,
6263
- sha256: imagesSha256,
6264
- signed: true,
6265
- sig_algo: 'ed25519',
6266
- payload_version: '1.2.3',
6267
- },
6268
- {
6269
- archive_path: 'loaders/bootloader/boot_resource.okpkg',
6270
- original_name: 'boot_resource.okpkg',
6271
- device_path: 'vol0:/loaders/bootloader/boot_resource.okpkg',
6272
- size: bootBinary.byteLength,
6273
- sha256: bootSha256,
6274
- signed: true,
6275
- sig_algo: 'ed25519',
6276
- payload_version: '1.2.3',
6277
- },
6278
- ],
6279
- };
6280
- const manifestBinary = new TextEncoder().encode(JSON.stringify(manifest)).buffer;
6281
7778
  const zip = new JSZip();
6282
- zip.file('manifest.json', manifestBinary);
6283
7779
  zip.file('bundles/images/images.okpkg', imagesBinary);
6284
7780
  zip.file('loaders/bootloader/boot_resource.okpkg', bootBinary);
7781
+ zip.file('manifest.json', '{"ignored":true}');
6285
7782
  const archiveBinary = await zip.generateAsync({ type: 'arraybuffer' });
6286
7783
  const preparedEntries = [
6287
7784
  {
6288
- entryName: 'manifest.json',
6289
- artifact: {
6290
- artifactRef: 'manifest',
6291
- size: manifestBinary.byteLength,
6292
- sha256: bytesToHex(sha256(new Uint8Array(manifestBinary))),
6293
- },
6294
- },
6295
- {
6296
- entryName: 'bundles/images/images.okpkg',
7785
+ entryName: 'pro2-resource/bundles/images/images.okpkg',
6297
7786
  artifact: {
6298
7787
  artifactRef: 'images',
6299
7788
  size: imagesBinary.byteLength,
@@ -6301,13 +7790,21 @@ describe('Protocol V2 firmware update targets', () => {
6301
7790
  },
6302
7791
  },
6303
7792
  {
6304
- entryName: 'loaders/bootloader/boot_resource.okpkg',
7793
+ entryName: 'pro2-resource/loaders/bootloader/boot_resource.okpkg',
6305
7794
  artifact: {
6306
7795
  artifactRef: 'boot',
6307
7796
  size: bootBinary.byteLength,
6308
7797
  sha256: bootSha256,
6309
7798
  },
6310
7799
  },
7800
+ {
7801
+ entryName: 'pro2-resource/build-info.txt',
7802
+ artifact: {
7803
+ artifactRef: 'build-info',
7804
+ size: 1,
7805
+ sha256: 'f'.repeat(64),
7806
+ },
7807
+ },
6311
7808
  ];
6312
7809
  const artifacts = new Map([['archive', archiveBinary]]);
6313
7810
  (method as any).params = {
@@ -6346,16 +7843,11 @@ describe('Protocol V2 firmware update targets', () => {
6346
7843
  }),
6347
7844
  expect.objectContaining({
6348
7845
  name: 'boot_resource.okpkg',
6349
- devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg',
7846
+ devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg.staging',
6350
7847
  version: [1, 2, 3],
6351
7848
  }),
6352
7849
  ]);
6353
7850
  await Promise.all(sources.map(source => source.source.close()));
6354
-
6355
- preparedEntries[1].artifact.sha256 = 'f'.repeat(64);
6356
- await expect((method as any).prepareProtocolV2ResourceArchiveSources()).rejects.toThrow(
6357
- 'entries do not match the approved archive'
6358
- );
6359
7851
  });
6360
7852
 
6361
7853
  test('binds a prepared resource archive to the selected host generation', () => {
@@ -6397,9 +7889,9 @@ describe('Protocol V2 firmware update targets', () => {
6397
7889
  },
6398
7890
  materializedEntries: [
6399
7891
  {
6400
- entryName: 'manifest.json',
7892
+ entryName: 'bundles/images/images.okpkg',
6401
7893
  artifact: {
6402
- artifactRef: 'resource-manifest',
7894
+ artifactRef: 'resource-package',
6403
7895
  size: 2,
6404
7896
  sha256: 'b'.repeat(64),
6405
7897
  },
@@ -6463,7 +7955,7 @@ describe('Protocol V2 firmware update targets', () => {
6463
7955
  const componentArtifact = {
6464
7956
  artifactRef: 'prepared-bootloader',
6465
7957
  size: componentBinary.byteLength,
6466
- sha256: 'a'.repeat(64),
7958
+ sha256: bytesToHex(sha256(new Uint8Array(componentBinary))),
6467
7959
  };
6468
7960
  const planWithoutDigest = {
6469
7961
  schemaVersion: 2 as const,
@@ -6599,9 +8091,9 @@ describe('Protocol V2 firmware update targets', () => {
6599
8091
  },
6600
8092
  materializedEntries: [
6601
8093
  {
6602
- entryName: 'manifest.json',
8094
+ entryName: 'bundles/images/images.okpkg',
6603
8095
  artifact: {
6604
- artifactRef: 'prepared-resource-manifest',
8096
+ artifactRef: 'prepared-resource-package',
6605
8097
  size: 2,
6606
8098
  sha256: 'b'.repeat(64),
6607
8099
  },
@@ -6657,51 +8149,20 @@ describe('Protocol V2 firmware update targets', () => {
6657
8149
  }
6658
8150
  });
6659
8151
 
6660
- test('converts a local resource ZIP into a local PreparedPlan without matching a remote release', async () => {
6661
- const imagesBinary = new Uint8Array([1, 2, 3]).buffer;
6662
- const bootResourceBinary = new Uint8Array([4, 5, 6]).buffer;
6663
- const manifest = {
6664
- schema: 1,
6665
- artifact_name: 'pro2-resource',
6666
- release_name: 'local-development',
6667
- variant: 'resource',
6668
- commit: 'local',
6669
- short_sha: 'local',
6670
- timestamp_utc: '2026-08-09T00:00:00Z',
6671
- core_version: '1.0.0',
6672
- key_set: 'development',
6673
- device_root: 'vol0:',
6674
- restore_mode: 'bootloader_update',
6675
- trees: [{ path: 'bundles', device: 'pro2' }],
6676
- files: [
6677
- {
6678
- archive_path: 'bundles/images/images.okpkg',
6679
- original_name: 'images.okpkg',
6680
- device_path: 'vol0:/bundles/images/images.okpkg',
6681
- size: imagesBinary.byteLength,
6682
- sha256: bytesToHex(sha256(new Uint8Array(imagesBinary))),
6683
- signed: true,
6684
- sig_algo: 'ed25519',
6685
- payload_version: '1.0.0',
6686
- },
6687
- {
6688
- archive_path: 'loaders/bootloader/boot_resource.okpkg',
6689
- original_name: 'boot_resource.okpkg',
6690
- device_path: 'vol0:/loaders/bootloader/boot_resource.okpkg',
6691
- size: bootResourceBinary.byteLength,
6692
- sha256: bytesToHex(sha256(new Uint8Array(bootResourceBinary))),
6693
- signed: true,
6694
- sig_algo: 'ed25519',
6695
- payload_version: '1.0.0',
6696
- },
6697
- ],
6698
- };
8152
+ test('installs a local resource ZIP without matching a remote release', async () => {
8153
+ const imagesBinary = createProtocolV2OkppBinary({ version: [1, 0, 0] });
8154
+ const bootResourceBinary = createProtocolV2OkppBinary({
8155
+ version: [1, 0, 0],
8156
+ devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg.staging',
8157
+ });
6699
8158
  const zip = new JSZip();
6700
- zip.file('manifest.json', JSON.stringify(manifest));
6701
- zip.file('bundles/images/images.okpkg', imagesBinary);
6702
- zip.file('loaders/bootloader/boot_resource.okpkg', bootResourceBinary);
8159
+ zip.file('pro2-resource/bundles/images/images.okpkg', imagesBinary);
8160
+ zip.file('pro2-resource/loaders/bootloader/boot_resource.okpkg', bootResourceBinary);
8161
+ zip.file('pro2-resource/manifest.json', '{"ignored":true}');
8162
+ zip.file('pro2-resource/build-info.txt', 'ignored');
6703
8163
  const resourceArchiveBinary = await zip.generateAsync({ type: 'arraybuffer' });
6704
8164
  const applicationP1Binary = new Uint8Array([7, 8, 9]).buffer;
8165
+ const bootloaderBinary = new Uint8Array([10, 11, 12]).buffer;
6705
8166
  const method = new FirmwareUpdateV4({
6706
8167
  id: 1,
6707
8168
  payload: {
@@ -6709,6 +8170,7 @@ describe('Protocol V2 firmware update targets', () => {
6709
8170
  platform: 'web',
6710
8171
  targetsToUpdate: ['app_v1'],
6711
8172
  applicationP1Binary,
8173
+ bootloaderBinary,
6712
8174
  resourceArchiveBinary,
6713
8175
  },
6714
8176
  });
@@ -6737,11 +8199,7 @@ describe('Protocol V2 firmware update targets', () => {
6737
8199
  await expect(method.run()).resolves.toBe('local-resource-result');
6738
8200
  expect(releaseSpy).not.toHaveBeenCalled();
6739
8201
  expect(reloadSpy).not.toHaveBeenCalled();
6740
- expect((method as any).params.preparedPlan).toMatchObject({
6741
- networkPolicy: 'forbid',
6742
- executor: 'v4',
6743
- targetsToUpdate: ['app_v1', 'resource'],
6744
- });
8202
+ expect((method as any).params.preparedPlan).toBeUndefined();
6745
8203
  expect((method as any).executeProtocolV2SourceUpdate).toHaveBeenCalledWith(
6746
8204
  expect.objectContaining({
6747
8205
  installSources: [
@@ -6757,7 +8215,7 @@ describe('Protocol V2 firmware update targets', () => {
6757
8215
  }),
6758
8216
  expect.objectContaining({
6759
8217
  name: 'boot_resource.okpkg',
6760
- devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg',
8218
+ devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg.staging',
6761
8219
  }),
6762
8220
  ],
6763
8221
  })
@@ -6768,51 +8226,78 @@ describe('Protocol V2 firmware update targets', () => {
6768
8226
  }
6769
8227
  });
6770
8228
 
6771
- test('rejects a local resource ZIP whose file hash does not match its manifest', async () => {
6772
- const resourceBinary = new Uint8Array([1, 2, 3]).buffer;
6773
- const bootResourceBinary = new Uint8Array([4, 5, 6]).buffer;
8229
+ test('accepts every okpkg in a local resource ZIP and ignores other files', async () => {
8230
+ const imagesBinary = createProtocolV2OkppBinary();
8231
+ const bootResourceBinary = createProtocolV2OkppBinary({
8232
+ devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg.staging',
8233
+ });
6774
8234
  const zip = new JSZip();
8235
+ zip.file('pro2-resource/bundles/images/images.okpkg', imagesBinary);
8236
+ zip.file('pro2-resource/loaders/bootloader/boot_resource.okpkg', bootResourceBinary);
8237
+ zip.file('pro2-resource/build-info.txt', 'ignored');
6775
8238
  zip.file(
6776
- 'manifest.json',
6777
- JSON.stringify({
6778
- schema: 1,
6779
- artifact_name: 'pro2-resource',
6780
- release_name: 'local-development',
6781
- variant: 'resource',
6782
- commit: 'local',
6783
- short_sha: 'local',
6784
- timestamp_utc: '2026-08-09T00:00:00Z',
6785
- core_version: '1.0.0',
6786
- key_set: 'development',
6787
- device_root: 'vol0:',
6788
- restore_mode: 'bootloader_update',
6789
- trees: [{ path: 'bundles', device: 'pro2' }],
6790
- files: [
6791
- {
6792
- archive_path: 'bundles/images/images.okpkg',
6793
- original_name: 'images.okpkg',
6794
- device_path: 'vol0:/bundles/images/images.okpkg',
6795
- size: resourceBinary.byteLength,
6796
- sha256: '0'.repeat(64),
6797
- signed: true,
6798
- sig_algo: 'ed25519',
6799
- payload_version: '1.0.0',
6800
- },
6801
- {
6802
- archive_path: 'loaders/bootloader/boot_resource.okpkg',
6803
- original_name: 'boot_resource.okpkg',
6804
- device_path: 'vol0:/loaders/bootloader/boot_resource.okpkg',
6805
- size: bootResourceBinary.byteLength,
6806
- sha256: bytesToHex(sha256(new Uint8Array(bootResourceBinary))),
6807
- signed: true,
6808
- sig_algo: 'ed25519',
6809
- payload_version: '1.0.0',
6810
- },
6811
- ],
6812
- })
8239
+ '__MACOSX/pro2-resource 2/bundles/._images.okpkg',
8240
+ new Uint8Array([0x00, 0x05, 0x16, 0x07])
6813
8241
  );
8242
+ const method = new FirmwareUpdateV4({
8243
+ id: 1,
8244
+ payload: {
8245
+ method: 'firmwareUpdateV4',
8246
+ platform: 'web',
8247
+ targetsToUpdate: ['resource'],
8248
+ },
8249
+ });
8250
+ method.init();
8251
+
8252
+ await expect(
8253
+ (method as any).prepareProtocolV2LocalResourceArchive(
8254
+ await zip.generateAsync({ type: 'arraybuffer' })
8255
+ )
8256
+ ).resolves.toMatchObject({
8257
+ resources: [
8258
+ {
8259
+ entryName: 'pro2-resource/bundles/images/images.okpkg',
8260
+ binary: imagesBinary,
8261
+ header: expect.objectContaining({
8262
+ devicePath: 'vol0:/bundles/images/images.okpkg',
8263
+ }),
8264
+ },
8265
+ {
8266
+ entryName: 'pro2-resource/loaders/bootloader/boot_resource.okpkg',
8267
+ binary: bootResourceBinary,
8268
+ header: expect.objectContaining({
8269
+ devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg.staging',
8270
+ }),
8271
+ },
8272
+ ],
8273
+ });
8274
+ });
8275
+
8276
+ test('rejects a local resource ZIP with duplicate package device paths', async () => {
8277
+ const resourceBinary = createProtocolV2OkppBinary();
8278
+ const duplicateResourceBinary = createProtocolV2OkppBinary({ payloadHashByte: 0x44 });
8279
+ const zip = new JSZip();
6814
8280
  zip.file('bundles/images/images.okpkg', resourceBinary);
6815
- zip.file('loaders/bootloader/boot_resource.okpkg', bootResourceBinary);
8281
+ zip.file('copies/images.okpkg', duplicateResourceBinary);
8282
+ const method = new FirmwareUpdateV4({
8283
+ id: 1,
8284
+ payload: {
8285
+ method: 'firmwareUpdateV4',
8286
+ platform: 'web',
8287
+ targetsToUpdate: ['resource'],
8288
+ },
8289
+ });
8290
+ method.init();
8291
+
8292
+ await expect(
8293
+ (method as any).prepareProtocolV2LocalResourceArchive(
8294
+ await zip.generateAsync({ type: 'arraybuffer' })
8295
+ )
8296
+ ).rejects.toThrow('duplicate device path: vol0:/bundles/images/images.okpkg');
8297
+ });
8298
+
8299
+ test('maps malformed resource ZIPs to a typed firmware preparation error', async () => {
8300
+ const loadSpy = jest.spyOn(JSZip, 'loadAsync').mockRejectedValue(new Error('corrupt ZIP'));
6816
8301
  const method = new FirmwareUpdateV4({
6817
8302
  id: 1,
6818
8303
  payload: {
@@ -6823,29 +8308,38 @@ describe('Protocol V2 firmware update targets', () => {
6823
8308
  });
6824
8309
  method.init();
6825
8310
 
6826
- await expect(
6827
- (method as any).prepareProtocolV2LocalResourceArchive(
6828
- await zip.generateAsync({ type: 'arraybuffer' })
6829
- )
6830
- ).rejects.toThrow(
6831
- 'Protocol V2 local resource file does not match manifest: bundles/images/images.okpkg'
6832
- );
8311
+ try {
8312
+ await expect(
8313
+ (method as any).prepareProtocolV2LocalResourceArchive(new Uint8Array([1]).buffer)
8314
+ ).rejects.toMatchObject({
8315
+ errorCode: HardwareErrorCode.RuntimeError,
8316
+ params: { firmwareUpdateCode: 'FirmwareArtifactsNotPrepared' },
8317
+ });
8318
+ } finally {
8319
+ loadSpy.mockRestore();
8320
+ }
6833
8321
  });
6834
8322
 
6835
8323
  test('rejects an oversized ZIP entry before allocating its decompressed bytes', async () => {
6836
8324
  const extractEntry = jest.fn();
6837
- const loadSpy = jest.spyOn(JSZip, 'loadAsync').mockResolvedValue({
6838
- files: {
6839
- 'manifest.json': {
6840
- name: 'manifest.json',
6841
- dir: false,
6842
- _data: {
6843
- compressedSize: 1,
6844
- uncompressedSize: 2 * 1024 * 1024,
6845
- },
6846
- async: extractEntry,
6847
- },
8325
+ const oversizedFileSize = 257 * 1024 * 1024;
8326
+ const zipFiles = {
8327
+ 'loaders/bootloader/boot_resource.okpkg': {
8328
+ name: 'loaders/bootloader/boot_resource.okpkg',
8329
+ dir: false,
8330
+ _data: { compressedSize: 1, uncompressedSize: oversizedFileSize },
8331
+ async: extractEntry,
8332
+ },
8333
+ 'bundles/images/images.okpkg': {
8334
+ name: 'bundles/images/images.okpkg',
8335
+ dir: false,
8336
+ _data: { compressedSize: 1, uncompressedSize: oversizedFileSize },
8337
+ async: extractEntry,
6848
8338
  },
8339
+ };
8340
+ const loadSpy = jest.spyOn(JSZip, 'loadAsync').mockResolvedValue({
8341
+ files: zipFiles,
8342
+ file: (name: string) => zipFiles[name as keyof typeof zipFiles] ?? null,
6849
8343
  } as unknown as JSZip);
6850
8344
  const method = new FirmwareUpdateV4({
6851
8345
  id: 1,
@@ -6860,7 +8354,7 @@ describe('Protocol V2 firmware update targets', () => {
6860
8354
  try {
6861
8355
  await expect(
6862
8356
  (method as any).prepareProtocolV2LocalResourceArchive(new Uint8Array([1]).buffer)
6863
- ).rejects.toThrow('declared size exceeds the allowed limit');
8357
+ ).rejects.toThrow('resource package size is invalid');
6864
8358
  expect(extractEntry).not.toHaveBeenCalled();
6865
8359
  } finally {
6866
8360
  loadSpy.mockRestore();
@@ -7012,17 +8506,17 @@ describe('Protocol V2 firmware update targets', () => {
7012
8506
  readAt: jest.fn(),
7013
8507
  close: jest.fn(),
7014
8508
  },
7015
- devicePath: 'vol0:/resource/images/images.okpkg',
8509
+ devicePath: 'vol0:/bundles/images/images.okpkg',
7016
8510
  },
7017
8511
  ],
7018
8512
  });
7019
8513
 
7020
8514
  expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenCalledTimes(1);
7021
8515
  expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenCalledWith(
7022
- expect.objectContaining({ filePath: 'vol0:/resource/images/images.okpkg' })
8516
+ expect.objectContaining({ filePath: 'vol0:/bundles/images/images.okpkg' })
7023
8517
  );
7024
8518
  expect((method as any).verifyProtocolV2StagedFile).toHaveBeenCalledWith(
7025
- 'vol0:/resource/images/images.okpkg',
8519
+ 'vol0:/bundles/images/images.okpkg',
7026
8520
  3
7027
8521
  );
7028
8522
  expect((method as any).protocolV2StartFirmwareUpdate).not.toHaveBeenCalled();
@@ -7041,6 +8535,9 @@ describe('Protocol V2 firmware update targets', () => {
7041
8535
  method.postProgressMessage = jest.fn();
7042
8536
  (method as any).protocolV2SourceUpdateProcess = jest.fn().mockResolvedValue(3);
7043
8537
  (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
8538
+ (method as any).ensureProtocolV2BootResourceStagingIsEmpty = jest
8539
+ .fn()
8540
+ .mockResolvedValue(undefined);
7044
8541
  (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
7045
8542
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
7046
8543
 
@@ -7054,7 +8551,7 @@ describe('Protocol V2 firmware update targets', () => {
7054
8551
  readAt: jest.fn(),
7055
8552
  close: jest.fn(),
7056
8553
  },
7057
- devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg',
8554
+ devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg.staging',
7058
8555
  },
7059
8556
  ],
7060
8557
  });
@@ -7066,7 +8563,7 @@ describe('Protocol V2 firmware update targets', () => {
7066
8563
  expect((method as any).verifyProtocolV2StagedFile).toHaveBeenCalledWith(stagingPath, 3);
7067
8564
  });
7068
8565
 
7069
- test('rewrites the mounted boot resource staging file even when the final file is current', async () => {
8566
+ test('skips boot resource staging when the live package hash matches', async () => {
7070
8567
  const method = new FirmwareUpdateV4({
7071
8568
  id: 1,
7072
8569
  payload: {
@@ -7087,7 +8584,7 @@ describe('Protocol V2 firmware update targets', () => {
7087
8584
  readAt: jest.fn(),
7088
8585
  close: jest.fn(),
7089
8586
  },
7090
- devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg',
8587
+ devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg.staging',
7091
8588
  version: [1, 0, 0],
7092
8589
  payloadHash: '11'.repeat(64),
7093
8590
  headerHash: '22'.repeat(64),
@@ -7098,12 +8595,62 @@ describe('Protocol V2 firmware update targets', () => {
7098
8595
  resourceSources: [resource],
7099
8596
  });
7100
8597
 
7101
- const stagingPath = 'vol0:/loaders/bootloader/boot_resource.okpkg.staging';
7102
- expect((method as any).isProtocolV2ResourceBundleUpToDate).not.toHaveBeenCalled();
7103
- expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenCalledWith(
7104
- expect.objectContaining({ source: resource.source, filePath: stagingPath, totalSize: 3 })
8598
+ expect((method as any).isProtocolV2ResourceBundleUpToDate).toHaveBeenCalledWith(resource);
8599
+ expect((method as any).protocolV2SourceUpdateProcess).not.toHaveBeenCalled();
8600
+ });
8601
+
8602
+ test('compares boot resource staging against the live mounted package path', () => {
8603
+ const method = new FirmwareUpdateV4({
8604
+ id: 1,
8605
+ payload: {
8606
+ method: 'firmwareUpdateV4',
8607
+ },
8608
+ });
8609
+
8610
+ expect(
8611
+ (method as any).getProtocolV2ResourceComparePath(
8612
+ 'vol0:/loaders/bootloader/boot_resource.okpkg.staging'
8613
+ )
8614
+ ).toBe('vol0:/loaders/bootloader/boot_resource.okpkg');
8615
+ expect((method as any).getProtocolV2ResourceComparePath('vol0:/bundles/images.okpkg')).toBe(
8616
+ 'vol0:/bundles/images.okpkg'
7105
8617
  );
7106
- expect((method as any).verifyProtocolV2StagedFile).toHaveBeenCalledWith(stagingPath, 3);
8618
+ });
8619
+
8620
+ test('skips bootloader params staging when the on-device package is already current', async () => {
8621
+ const method = new FirmwareUpdateV4({
8622
+ id: 1,
8623
+ payload: {
8624
+ method: 'firmwareUpdateV4',
8625
+ },
8626
+ });
8627
+
8628
+ method.postTipMessage = jest.fn();
8629
+ method.postProgressMessage = jest.fn();
8630
+ (method as any).isProtocolV2ResourceBundleUpToDate = jest.fn().mockResolvedValue(true);
8631
+ (method as any).protocolV2SourceUpdateProcess = jest.fn().mockResolvedValue(3);
8632
+ (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
8633
+
8634
+ const resource = {
8635
+ name: 'params.okpkg',
8636
+ source: {
8637
+ size: 3,
8638
+ readAt: jest.fn(),
8639
+ close: jest.fn(),
8640
+ },
8641
+ devicePath: 'vol0:/loaders/bootloader/params.okpkg.staging',
8642
+ version: [1, 0, 0],
8643
+ payloadHash: '11'.repeat(64),
8644
+ headerHash: '22'.repeat(64),
8645
+ };
8646
+
8647
+ await (method as any).executeProtocolV2TransferPhase({
8648
+ installSources: [],
8649
+ resourceSources: [resource],
8650
+ });
8651
+
8652
+ expect((method as any).isProtocolV2ResourceBundleUpToDate).toHaveBeenCalledWith(resource);
8653
+ expect((method as any).protocolV2SourceUpdateProcess).not.toHaveBeenCalled();
7107
8654
  });
7108
8655
 
7109
8656
  test('uses absolute processed_byte offsets and disables append for firmware file writes', async () => {
@@ -7153,7 +8700,7 @@ describe('Protocol V2 firmware update targets', () => {
7153
8700
  expect(writePayloads.map(payload => payload.file.data.byteLength)).toEqual([4000, 97]);
7154
8701
  expect(writePayloads.map(payload => payload.overwrite)).toEqual([true, false]);
7155
8702
  expect(writePayloads.every(payload => payload.append === false)).toBe(true);
7156
- expect(writePayloads.map(payload => payload.ui_percentage)).toEqual([0, 100]);
8703
+ expect(writePayloads.map(payload => payload.ui_percentage)).toEqual([0, 99]);
7157
8704
  expect(method.postMessage).toHaveBeenLastCalledWith({
7158
8705
  event: 'UI_EVENT',
7159
8706
  type: UI_REQUEST.FIRMWARE_PROGRESS,
@@ -7167,6 +8714,176 @@ describe('Protocol V2 firmware update targets', () => {
7167
8714
  });
7168
8715
  });
7169
8716
 
8717
+ test('does not retry or wrap a stale BLE bond during a V4 file transfer', async () => {
8718
+ const method = new FirmwareUpdateV4({
8719
+ id: 1,
8720
+ payload: {
8721
+ method: 'firmwareUpdateV4',
8722
+ },
8723
+ });
8724
+ const staleBondError = ERRORS.TypedError(HardwareErrorCode.BlePeerRemovedPairingInformation);
8725
+ (method as any).fileWriteChunk = jest.fn().mockRejectedValue(staleBondError);
8726
+ const recoverProtocolV2FileTransfer = jest.fn();
8727
+ (method as any).recoverProtocolV2FileTransfer = recoverProtocolV2FileTransfer;
8728
+ const source = await openFirmwareByteSource({
8729
+ binary: new Uint8Array([1]).buffer,
8730
+ });
8731
+
8732
+ try {
8733
+ await expect(
8734
+ (method as any).protocolV2SourceUpdateProcess({
8735
+ source,
8736
+ filePath: 'vol1:firmware.bin',
8737
+ processedSize: 0,
8738
+ totalSize: 1,
8739
+ })
8740
+ ).rejects.toBe(staleBondError);
8741
+ } finally {
8742
+ await source?.close();
8743
+ }
8744
+ expect(recoverProtocolV2FileTransfer).not.toHaveBeenCalled();
8745
+ });
8746
+
8747
+ test('throttles repeated transfer progress while preserving file completion', async () => {
8748
+ const method = new FirmwareUpdateV4({
8749
+ id: 1,
8750
+ payload: {
8751
+ method: 'firmwareUpdateV4',
8752
+ },
8753
+ });
8754
+ const typedCall = jest.fn(
8755
+ (
8756
+ _name: string,
8757
+ _resType: string,
8758
+ params: { file: { offset: number; data: { byteLength: number } } }
8759
+ ) =>
8760
+ Promise.resolve({
8761
+ type: 'FilesystemFile',
8762
+ message: {
8763
+ processed_byte: params.file.offset + params.file.data.byteLength,
8764
+ },
8765
+ })
8766
+ );
8767
+
8768
+ (method as any).device = stubDevice({
8769
+ getCommands: () => ({ typedCall }),
8770
+ getCurrentDeviceType: () => 'pro2',
8771
+ });
8772
+ (method as any).getProtocolV2FirmwareChunkSize = jest.fn().mockReturnValue(1000);
8773
+ method.postProgressMessage = jest.fn();
8774
+ const dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(10_000);
8775
+
8776
+ const source = await openFirmwareByteSource({
8777
+ binary: new Uint8Array(2500).buffer,
8778
+ });
8779
+ try {
8780
+ await (method as any).protocolV2SourceUpdateProcess({
8781
+ source,
8782
+ filePath: 'vol0:/resource/images/images.okpkg',
8783
+ processedSize: 0,
8784
+ totalSize: 1_000_000,
8785
+ });
8786
+ } finally {
8787
+ dateNowSpy.mockRestore();
8788
+ await source?.close();
8789
+ }
8790
+
8791
+ expect(typedCall).toHaveBeenCalledTimes(3);
8792
+ expect(method.postProgressMessage).toHaveBeenCalledTimes(2);
8793
+ expect(method.postProgressMessage).toHaveBeenNthCalledWith(
8794
+ 1,
8795
+ 1,
8796
+ 'transferData',
8797
+ expect.objectContaining({ transferredBytes: 1000 })
8798
+ );
8799
+ expect(method.postProgressMessage).toHaveBeenNthCalledWith(
8800
+ 2,
8801
+ 1,
8802
+ 'transferData',
8803
+ expect.objectContaining({ transferredBytes: 2500 })
8804
+ );
8805
+ });
8806
+
8807
+ test('sends one monotonic device transfer progress range across multiple files', async () => {
8808
+ const method = new FirmwareUpdateV4({
8809
+ id: 1,
8810
+ payload: {
8811
+ method: 'firmwareUpdateV4',
8812
+ },
8813
+ });
8814
+ const typedCall = jest.fn(
8815
+ (
8816
+ _name: string,
8817
+ _resType: string,
8818
+ params: { file: { offset: number; data: { byteLength: number } } }
8819
+ ) =>
8820
+ Promise.resolve({
8821
+ type: 'FilesystemFile',
8822
+ message: {
8823
+ processed_byte: params.file.offset + params.file.data.byteLength,
8824
+ },
8825
+ })
8826
+ );
8827
+
8828
+ (method as any).device = stubDevice({
8829
+ getCommands: () => ({ typedCall }),
8830
+ getCurrentDeviceType: () => 'pro2',
8831
+ toMessageObject: () => ({ connectId: 'firmware-device' }),
8832
+ });
8833
+ method.postMessage = jest.fn();
8834
+ method.postTipMessage = jest.fn();
8835
+ method.postProgressMessage = jest.fn();
8836
+ (method as any).isProtocolV2ResourceBundleUpToDate = jest.fn().mockResolvedValue(false);
8837
+ (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
8838
+ (method as any).protocolV2StartFirmwareUpdate = jest.fn().mockResolvedValue(undefined);
8839
+ (method as any).waitForProtocolV2FirmwareUpdateComplete = jest
8840
+ .fn()
8841
+ .mockResolvedValue(undefined);
8842
+
8843
+ const resourceSource = await openFirmwareByteSource({
8844
+ binary: new Uint8Array(4097).buffer,
8845
+ });
8846
+ const firmwareSource = await openFirmwareByteSource({
8847
+ binary: new Uint8Array(4097).buffer,
8848
+ });
8849
+ try {
8850
+ await (method as any).executeProtocolV2TransferPhase({
8851
+ resourceSources: [
8852
+ {
8853
+ name: 'images.okpkg',
8854
+ source: resourceSource,
8855
+ devicePath: 'vol0:/resource/images/images.okpkg',
8856
+ },
8857
+ ],
8858
+ installSources: [
8859
+ {
8860
+ fileName: 'application_p1.bin',
8861
+ source: firmwareSource,
8862
+ targetId: 4,
8863
+ kind: 'firmware',
8864
+ },
8865
+ ],
8866
+ });
8867
+ } finally {
8868
+ await resourceSource?.close();
8869
+ await firmwareSource?.close();
8870
+ }
8871
+
8872
+ const writePayloads = typedCall.mock.calls.map(call => call[2]);
8873
+ const deviceProgress = writePayloads.map(payload => payload.ui_percentage);
8874
+ expect(deviceProgress).toEqual([0, 50, 99, 99]);
8875
+ expect(deviceProgress.filter(progress => progress === 0)).toHaveLength(1);
8876
+ expect(deviceProgress.filter(progress => progress === 100)).toHaveLength(0);
8877
+ expect(method.postTipMessage).toHaveBeenCalledTimes(2);
8878
+ expect(method.postTipMessage).toHaveBeenNthCalledWith(1, 'StartTransferData');
8879
+ expect(method.postTipMessage).toHaveBeenNthCalledWith(2, 'ConfirmOnDevice');
8880
+ expect(
8881
+ (method.postProgressMessage as jest.Mock).mock.calls.filter(
8882
+ ([progress, progressType]) => progress === 100 && progressType === 'transferData'
8883
+ )
8884
+ ).toHaveLength(1);
8885
+ });
8886
+
7170
8887
  // TODO(#850/#855): PR #855 added resume-on-retry and per-chunk retry on the
7171
8888
  // writeProtocolV2File path. PR #850 replaced that path with FirmwareByteSource
7172
8889
  // streaming (protocolV2SourceUpdateProcess), which restarts a failed transfer from
@@ -7474,7 +9191,7 @@ describe('Protocol V2 firmware update targets', () => {
7474
9191
  const writePayloads = typedCall.mock.calls.map(call => call[2]);
7475
9192
  expect(writePayloads.map(payload => payload.file.offset)).toEqual([0, 1800]);
7476
9193
  expect(writePayloads.map(payload => payload.file.data.byteLength)).toEqual([1800, 1]);
7477
- expect(writePayloads.map(payload => payload.ui_percentage)).toEqual([0, 100]);
9194
+ expect(writePayloads.map(payload => payload.ui_percentage)).toEqual([0, 99]);
7478
9195
  expect(typedCall.mock.calls.map(call => call[3])).toEqual([
7479
9196
  expect.objectContaining({
7480
9197
  writeWithResponse: false,
@@ -7533,45 +9250,67 @@ describe('Protocol V2 firmware update targets', () => {
7533
9250
  expect(writePayloads.map(payload => payload.file.data.byteLength)).toEqual([1960, 1]);
7534
9251
  });
7535
9252
 
7536
- test('ends device confirmation and starts install progress only after Protocol V2 ACK', async () => {
9253
+ test('keeps confirmation active after the Request write until terminal Success arrives', async () => {
7537
9254
  const method = new FirmwareUpdateV4({
7538
9255
  id: 1,
7539
9256
  payload: {
7540
9257
  method: 'firmwareUpdateV4',
7541
9258
  },
7542
9259
  });
7543
- let resolveRequest: ((value: unknown) => void) | undefined;
7544
- const typedCall = jest.fn().mockImplementation(
7545
- () =>
7546
- new Promise(resolve => {
7547
- resolveRequest = resolve;
7548
- })
7549
- );
9260
+ let onResponseAfterWrite:
9261
+ | ((response: { type: string; message: Record<string, never> }) => void)
9262
+ | undefined;
9263
+ const typedCall = jest.fn().mockResolvedValue({ type: 'Success', message: {} });
9264
+ const call = jest.fn().mockImplementation((_type, _message, options) => {
9265
+ onResponseAfterWrite = options.onResponseAfterWrite;
9266
+ return Promise.resolve({ type: 'WriteCompleted', message: {} });
9267
+ });
9268
+ const clearCancelableAction = jest.fn();
7550
9269
 
7551
9270
  (method as any).device = stubDevice({
7552
- getCommands: () => ({ typedCall }),
9271
+ getCommands: () => ({ typedCall, call, cancelDevice: jest.fn() }),
9272
+ createProtocolV2UiPhaseMetadata: jest.fn().mockReturnValue(undefined),
9273
+ toMessageObject: jest.fn().mockReturnValue({ connectId: 'pro2' }),
9274
+ clearCancelableAction,
7553
9275
  });
9276
+ method.postMessage = jest.fn();
7554
9277
  method.postProgressMessage = jest.fn();
7555
9278
  method.postTipMessage = jest.fn();
7556
9279
 
7557
9280
  const startPromise = (method as any).protocolV2StartFirmwareUpdate({
7558
9281
  targets: [{ target_id: 4, path: 'vol1:firmware.bin' }],
7559
9282
  });
7560
- await Promise.resolve();
9283
+ await new Promise<void>(resolve => {
9284
+ setImmediate(resolve);
9285
+ });
7561
9286
 
7562
- expect(typedCall.mock.calls[0][1]).toBe('Success');
7563
- expect(typedCall.mock.calls[0][3]).toEqual(expect.objectContaining({ timeoutMs: 180_000 }));
9287
+ expect(typedCall.mock.calls[0]).toEqual([
9288
+ 'DeviceFirmwareUpdateStage',
9289
+ 'Success',
9290
+ { targets: [{ target_id: 4, path: 'vol1:firmware.bin' }] },
9291
+ ]);
9292
+ expect(call).toHaveBeenCalledWith(
9293
+ 'DeviceFirmwareUpdateRequest',
9294
+ {},
9295
+ expect.objectContaining({
9296
+ returnAfterWrite: true,
9297
+ expectedTypes: ['Success'],
9298
+ onResponseAfterWrite: expect.any(Function),
9299
+ })
9300
+ );
7564
9301
  expect(method.postTipMessage).not.toHaveBeenCalled();
7565
9302
  expect(method.postProgressMessage).not.toHaveBeenCalled();
9303
+ expect(clearCancelableAction).not.toHaveBeenCalled();
7566
9304
 
7567
- resolveRequest?.({ type: 'Success', message: { message: 'ok' } });
7568
9305
  await startPromise;
7569
- expect(method.postTipMessage).toHaveBeenCalledWith('FirmwareUpdating');
7570
- expect(method.postProgressMessage).toHaveBeenCalledWith(0, 'installingFirmware');
7571
- expect(method.postTipMessage).toHaveBeenCalledTimes(1);
9306
+ onResponseAfterWrite?.({ type: 'Success', message: {} });
9307
+ expect(clearCancelableAction).toHaveBeenCalledTimes(1);
9308
+ expect((method as any).protocolV2InstallTerminalSuccessObserved).toBe(true);
9309
+ expect(method.postTipMessage).not.toHaveBeenCalled();
9310
+ expect(method.postProgressMessage).not.toHaveBeenCalled();
7572
9311
  });
7573
9312
 
7574
- test('requests only a Success ACK for Protocol V2 firmware install', async () => {
9313
+ test('returns after the empty Protocol V2 firmware install request is written', async () => {
7575
9314
  const method = new FirmwareUpdateV4({
7576
9315
  id: 1,
7577
9316
  payload: {
@@ -7582,10 +9321,17 @@ describe('Protocol V2 firmware update targets', () => {
7582
9321
  type: 'Success',
7583
9322
  message: { message: 'accepted' },
7584
9323
  });
9324
+ const call = jest.fn().mockResolvedValue({
9325
+ type: 'WriteCompleted',
9326
+ message: {},
9327
+ });
7585
9328
 
7586
9329
  (method as any).device = stubDevice({
7587
- getCommands: () => ({ typedCall }),
9330
+ getCommands: () => ({ typedCall, call, cancelDevice: jest.fn() }),
9331
+ createProtocolV2UiPhaseMetadata: jest.fn().mockReturnValue(undefined),
9332
+ toMessageObject: jest.fn().mockReturnValue({ connectId: 'pro2' }),
7588
9333
  });
9334
+ method.postMessage = jest.fn();
7589
9335
  method.postProgressMessage = jest.fn();
7590
9336
  method.postTipMessage = jest.fn();
7591
9337
 
@@ -7593,13 +9339,23 @@ describe('Protocol V2 firmware update targets', () => {
7593
9339
  targets: [{ target_id: 4, path: 'vol1:firmware.bin' }],
7594
9340
  });
7595
9341
 
7596
- expect(typedCall.mock.calls[0][1]).toBe('Success');
7597
- expect(method.postTipMessage).toHaveBeenCalledWith('FirmwareUpdating');
9342
+ expect(typedCall.mock.calls[0][0]).toBe('DeviceFirmwareUpdateStage');
9343
+ expect(call).toHaveBeenCalledWith(
9344
+ 'DeviceFirmwareUpdateRequest',
9345
+ {},
9346
+ expect.objectContaining({
9347
+ returnAfterWrite: true,
9348
+ expectedTypes: ['Success'],
9349
+ onResponseAfterWrite: expect.any(Function),
9350
+ })
9351
+ );
9352
+ expect(method.postTipMessage).not.toHaveBeenCalled();
9353
+ expect(method.postProgressMessage).not.toHaveBeenCalled();
7598
9354
  });
7599
9355
  });
7600
9356
 
7601
9357
  describe('Protocol V2 firmware update method', () => {
7602
- test('returns DeviceFirmwareUpdateStatus from low-level update trigger', async () => {
9358
+ test('stages targets before sending the empty low-level update trigger', async () => {
7603
9359
  const method = new DeviceFirmwareUpdate({
7604
9360
  id: 1,
7605
9361
  payload: {
@@ -7610,22 +9366,25 @@ describe('Protocol V2 firmware update method', () => {
7610
9366
  });
7611
9367
  method.init();
7612
9368
 
7613
- const typedCall = jest.fn().mockResolvedValue({
7614
- type: 'DeviceFirmwareUpdateStatus',
7615
- message: { records: [{ target_id: 4, status: 1 }] },
7616
- });
9369
+ const typedCall = jest.fn().mockResolvedValue({ type: 'Success', message: {} });
9370
+ const call = jest.fn().mockResolvedValue({ type: 'WriteCompleted', message: {} });
7617
9371
 
7618
9372
  (method as any).device = stubDevice({
7619
- commands: { typedCall },
9373
+ commands: { typedCall, call },
7620
9374
  });
7621
9375
 
7622
9376
  await expect(method.run()).resolves.toEqual({
7623
- records: [{ target_id: 4, status: 1 }],
9377
+ message: 'Device firmware update started',
7624
9378
  });
7625
- expect(typedCall.mock.calls[0][1]).toEqual(['Success', 'DeviceFirmwareUpdateStatus']);
7626
- expect(typedCall.mock.calls[0][2]).toEqual({
9379
+ expect(typedCall).toHaveBeenCalledWith('DeviceFirmwareUpdateStage', 'Success', {
7627
9380
  targets: [{ target_id: 4, path: 'vol0:firmware.bin' }],
7628
9381
  });
9382
+ expect(call).toHaveBeenCalledWith(
9383
+ 'DeviceFirmwareUpdateRequest',
9384
+ {},
9385
+ { returnAfterWrite: true }
9386
+ );
9387
+ expect(typedCall.mock.invocationCallOrder[0]).toBeLessThan(call.mock.invocationCallOrder[0]);
7629
9388
  });
7630
9389
 
7631
9390
  test('treats WebUSB open NotFoundError from low-level update trigger as expected disconnect', async () => {
@@ -7724,6 +9483,7 @@ describe('Protocol V2 firmware update method', () => {
7724
9483
 
7725
9484
  test('accepts targetId alias inside firmware targets', async () => {
7726
9485
  const typedCall = jest.fn().mockResolvedValue({ message: {} });
9486
+ const call = jest.fn().mockResolvedValue({ type: 'WriteCompleted', message: {} });
7727
9487
  const method = new DeviceFirmwareUpdate({
7728
9488
  id: 1,
7729
9489
  payload: {
@@ -7739,7 +9499,7 @@ describe('Protocol V2 firmware update method', () => {
7739
9499
  });
7740
9500
  method.init();
7741
9501
  (method as any).device = stubDevice({
7742
- commands: { typedCall },
9502
+ commands: { typedCall, call },
7743
9503
  });
7744
9504
 
7745
9505
  await method.run();
@@ -7750,6 +9510,7 @@ describe('Protocol V2 firmware update method', () => {
7750
9510
 
7751
9511
  test('accepts firmware target enum names from low-level update params', async () => {
7752
9512
  const typedCall = jest.fn().mockResolvedValue({ message: {} });
9513
+ const call = jest.fn().mockResolvedValue({ type: 'WriteCompleted', message: {} });
7753
9514
  const method = new DeviceFirmwareUpdate({
7754
9515
  id: 1,
7755
9516
  payload: {
@@ -7760,7 +9521,7 @@ describe('Protocol V2 firmware update method', () => {
7760
9521
  });
7761
9522
  method.init();
7762
9523
  (method as any).device = stubDevice({
7763
- commands: { typedCall },
9524
+ commands: { typedCall, call },
7764
9525
  });
7765
9526
 
7766
9527
  await method.run();
@@ -7772,7 +9533,16 @@ describe('Protocol V2 firmware update method', () => {
7772
9533
  test('passes firmware update status fields through to Protocol V2', async () => {
7773
9534
  const typedCall = jest.fn().mockResolvedValue({
7774
9535
  message: {
7775
- records: [{ target_id: 4, status: 2, payload_version: 1, path: 'vol1:application_p1.bin' }],
9536
+ records: [
9537
+ {
9538
+ target_id: 4,
9539
+ status: 1,
9540
+ progress_percent: 42,
9541
+ phase_info: { phase: 1, progress_percent: 60 },
9542
+ payload_version: 1,
9543
+ path: 'vol1:application_p1.bin',
9544
+ },
9545
+ ],
7776
9546
  },
7777
9547
  });
7778
9548
  const method = new DeviceGetFirmwareUpdateStatus({
@@ -7781,6 +9551,8 @@ describe('Protocol V2 firmware update method', () => {
7781
9551
  method: 'deviceGetFirmwareUpdateStatus',
7782
9552
  fields: {
7783
9553
  status: true,
9554
+ progress_percent: true,
9555
+ phase_info: true,
7784
9556
  payload_version: true,
7785
9557
  path: true,
7786
9558
  },
@@ -7792,7 +9564,16 @@ describe('Protocol V2 firmware update method', () => {
7792
9564
  });
7793
9565
 
7794
9566
  await expect(method.run()).resolves.toEqual({
7795
- records: [{ target_id: 4, status: 2, payload_version: 1, path: 'vol1:application_p1.bin' }],
9567
+ records: [
9568
+ {
9569
+ target_id: 4,
9570
+ status: 1,
9571
+ progress_percent: 42,
9572
+ phase_info: { phase: 1, progress_percent: 60 },
9573
+ payload_version: 1,
9574
+ path: 'vol1:application_p1.bin',
9575
+ },
9576
+ ],
7796
9577
  });
7797
9578
  expect(typedCall).toHaveBeenCalledWith(
7798
9579
  'DeviceFirmwareUpdateStatusGet',
@@ -7800,6 +9581,8 @@ describe('Protocol V2 firmware update method', () => {
7800
9581
  {
7801
9582
  fields: {
7802
9583
  status: true,
9584
+ progress_percent: true,
9585
+ phase_info: true,
7803
9586
  payload_version: true,
7804
9587
  path: true,
7805
9588
  },
@@ -7992,7 +9775,7 @@ describe('Protocol V2 firmware reconnect identity', () => {
7992
9775
  const readChunkLengths: number[] = [];
7993
9776
  const typedCall = jest.fn((requestType: string, _responseType: string, request: any) => {
7994
9777
  if (requestType === 'FilesystemPathInfoQuery') {
7995
- return Promise.resolve({ message: { exist: true, directory: false, size: 0x52a0 } });
9778
+ return Promise.resolve({ message: { exist: true, directory: false, size: 0x5f91 } });
7996
9779
  }
7997
9780
  if (requestType === 'FilesystemFileRead') {
7998
9781
  readChunkLengths.push(request.chunk_len);
@@ -8159,6 +9942,8 @@ describe('Protocol V2 reboot methods', () => {
8159
9942
  const device = stubDevice({ commands: { typedCall } });
8160
9943
  (method as any).device = device;
8161
9944
 
9945
+ expect(method.unlockPolicy).toBe('unlock-before-run');
9946
+
8162
9947
  await method.run();
8163
9948
 
8164
9949
  expect(typedCall).toHaveBeenCalledWith('DeviceReboot', 'Success', {
@@ -8488,6 +10273,29 @@ describe('Protocol V2 protected method execution', () => {
8488
10273
  }
8489
10274
  );
8490
10275
 
10276
+ test('skips DeviceStatus and unlock when Protocol V2 is not initialized', async () => {
10277
+ const method = {
10278
+ name: 'firmwareUpdateV4',
10279
+ unlockPolicy: 'unlock-before-run',
10280
+ run: jest.fn().mockResolvedValue({ message: 'updating' }),
10281
+ };
10282
+ const device = {
10283
+ state: { status: { initialized: false } },
10284
+ commands: { typedCall: jest.fn() },
10285
+ isProtocolV2: () => true,
10286
+ isBootloader: () => false,
10287
+ isRomloader: () => false,
10288
+ unlockDevice: jest.fn(),
10289
+ };
10290
+
10291
+ await expect(runMethodWithUnlockPolicy(method as any, device as any)).resolves.toEqual({
10292
+ message: 'updating',
10293
+ });
10294
+ expect(device.commands.typedCall).not.toHaveBeenCalled();
10295
+ expect(device.unlockDevice).not.toHaveBeenCalled();
10296
+ expect(method.run).toHaveBeenCalledTimes(1);
10297
+ });
10298
+
8491
10299
  test('keeps Protocol V1 and lock-free methods outside the preflight path', async () => {
8492
10300
  for (const [isProtocolV2, unlockPolicy] of [
8493
10301
  [false, 'unlock-before-run'],
@@ -8667,7 +10475,7 @@ describe('Protocol V2 current low-level methods', () => {
8667
10475
  info: {
8668
10476
  version: undefined,
8669
10477
  serial_number: 'PR2SERIAL',
8670
- burn_in_completed: true,
10478
+ factory_burn_in_completed: true,
8671
10479
  factory_test_completed: undefined,
8672
10480
  manufacture_time: undefined,
8673
10481
  },
@@ -8917,7 +10725,7 @@ describe('Protocol V2 raw device info method', () => {
8917
10725
  'DeviceInfoGet',
8918
10726
  'DeviceInfo',
8919
10727
  {
8920
- targets: { hw: true, fw: true, coprocessor: true },
10728
+ targets: { hw: true, main_mcu: true, coprocessor: true },
8921
10729
  types: { version: true, specific: true },
8922
10730
  },
8923
10731
  { timeoutMs: PROTOCOL_V2_DEVICE_INFO_TIMEOUT_MS }