@onekeyfe/hd-core 1.2.0-alpha.99 → 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 (200) 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 +48 -2
  5. package/__tests__/check-firmware-release-protocol-v2.test.ts +82 -13
  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 +13 -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 +63 -0
  21. package/__tests__/firmware-update/firmware-update-plan.test.ts +59 -55
  22. package/__tests__/firmware-update/firmware-update-prepared-plan.test.ts +19 -5
  23. package/__tests__/firmware-update/firmware-update-v3-reconnect.test.ts +116 -0
  24. package/__tests__/firmware-update/firmware-update-v4-install-poll.test.ts +678 -0
  25. package/__tests__/firmware-update/firmware-update-v4-parameters.test.ts +85 -0
  26. package/__tests__/get-device-state.test.ts +60 -21
  27. package/__tests__/logBlockEvent.test.ts +13 -1
  28. package/__tests__/open-wallet-session.test.ts +127 -1
  29. package/__tests__/protocol-v2-firmware-targets.test.ts +23 -0
  30. package/__tests__/protocol-v2-resources.test.ts +185 -88
  31. package/__tests__/protocol-v2-unlock-policy.test.ts +235 -2
  32. package/__tests__/protocol-v2.test.ts +2198 -486
  33. package/__tests__/refresh-device-state.test.ts +3 -1
  34. package/__tests__/resourceBase64Boundary.test.ts +48 -0
  35. package/__tests__/search-devices.test.ts +79 -32
  36. package/__tests__/ton-sign-message.test.ts +84 -0
  37. package/dist/api/BaseMethod.d.ts +2 -1
  38. package/dist/api/BaseMethod.d.ts.map +1 -1
  39. package/dist/api/CheckAllFirmwareRelease.d.ts.map +1 -1
  40. package/dist/api/CheckBootloaderRelease.d.ts.map +1 -1
  41. package/dist/api/FirmwareUpdateV2.d.ts.map +1 -1
  42. package/dist/api/FirmwareUpdateV3.d.ts.map +1 -1
  43. package/dist/api/FirmwareUpdateV4.d.ts +19 -6
  44. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  45. package/dist/api/OpenWalletSession.d.ts.map +1 -1
  46. package/dist/api/SearchDevices.d.ts.map +1 -1
  47. package/dist/api/UploadPortfolio.d.ts +1 -1
  48. package/dist/api/UploadPortfolio.d.ts.map +1 -1
  49. package/dist/api/allnetwork/AllNetworkGetAddressBase.d.ts.map +1 -1
  50. package/dist/api/device/DeviceChangePin.d.ts.map +1 -1
  51. package/dist/api/device/DeviceRebootToBoardloader.d.ts.map +1 -1
  52. package/dist/api/device/DeviceRebootToBootloader.d.ts.map +1 -1
  53. package/dist/api/device/DeviceSettings.d.ts.map +1 -1
  54. package/dist/api/device/DeviceVerify.d.ts +1 -0
  55. package/dist/api/device/DeviceVerify.d.ts.map +1 -1
  56. package/dist/api/device/DeviceWipe.d.ts.map +1 -1
  57. package/dist/api/firmware/FirmwareArtifactSource.d.ts +5 -1
  58. package/dist/api/firmware/FirmwareArtifactSource.d.ts.map +1 -1
  59. package/dist/api/firmware/FirmwarePreparedResourceArchive.d.ts.map +1 -1
  60. package/dist/api/firmware/FirmwareUpdateBaseMethod.d.ts +2 -2
  61. package/dist/api/firmware/FirmwareUpdateBaseMethod.d.ts.map +1 -1
  62. package/dist/api/firmware/FirmwareUpdatePlan.d.ts +0 -15
  63. package/dist/api/firmware/FirmwareUpdatePlan.d.ts.map +1 -1
  64. package/dist/api/firmware/FirmwareUpdatePreparedPlan.d.ts.map +1 -1
  65. package/dist/api/firmware/getBinary.d.ts +3 -2
  66. package/dist/api/firmware/getBinary.d.ts.map +1 -1
  67. package/dist/api/firmware/protocolV2Release.d.ts.map +1 -1
  68. package/dist/api/helpers/base64Data.d.ts +16 -0
  69. package/dist/api/helpers/base64Data.d.ts.map +1 -0
  70. package/dist/api/protocol-v2/DeviceFirmwareUpdate.d.ts +3 -1
  71. package/dist/api/protocol-v2/DeviceFirmwareUpdate.d.ts.map +1 -1
  72. package/dist/api/protocol-v2/DeviceGetFirmwareUpdateStatus.d.ts.map +1 -1
  73. package/dist/api/protocol-v2/DeviceInfoGet.d.ts +1 -1
  74. package/dist/api/protocol-v2/DeviceInfoGet.d.ts.map +1 -1
  75. package/dist/api/protocol-v2/DeviceReboot.d.ts +1 -1
  76. package/dist/api/protocol-v2/DeviceReboot.d.ts.map +1 -1
  77. package/dist/api/protocol-v2/DeviceUploadNft.d.ts +2 -3
  78. package/dist/api/protocol-v2/DeviceUploadNft.d.ts.map +1 -1
  79. package/dist/api/protocol-v2/DeviceUploadWallpaper.d.ts +2 -3
  80. package/dist/api/protocol-v2/DeviceUploadWallpaper.d.ts.map +1 -1
  81. package/dist/api/protocol-v2/helpers.d.ts.map +1 -1
  82. package/dist/api/ton/TonSignMessage.d.ts.map +1 -1
  83. package/dist/core/index.d.ts +6 -1
  84. package/dist/core/index.d.ts.map +1 -1
  85. package/dist/data-manager/DataManager.d.ts +4 -3
  86. package/dist/data-manager/DataManager.d.ts.map +1 -1
  87. package/dist/data-manager/TransportManager.d.ts.map +1 -1
  88. package/dist/device/Device.d.ts +16 -3
  89. package/dist/device/Device.d.ts.map +1 -1
  90. package/dist/device/DeviceCommands.d.ts +4 -4
  91. package/dist/device/DeviceCommands.d.ts.map +1 -1
  92. package/dist/device/DeviceConnector.d.ts +1 -1
  93. package/dist/device/DeviceConnector.d.ts.map +1 -1
  94. package/dist/device/DevicePool.d.ts.map +1 -1
  95. package/dist/device/DeviceStateMapper.d.ts.map +1 -1
  96. package/dist/deviceProfile/buildDeviceFeatures.d.ts.map +1 -1
  97. package/dist/events/logBlockEvent.d.ts.map +1 -1
  98. package/dist/events/ui-request.d.ts +3 -0
  99. package/dist/events/ui-request.d.ts.map +1 -1
  100. package/dist/index.d.ts +54 -84
  101. package/dist/index.d.ts.map +1 -1
  102. package/dist/index.js +2135 -1470
  103. package/dist/protocols/protocol-v2/features.d.ts +13 -5
  104. package/dist/protocols/protocol-v2/features.d.ts.map +1 -1
  105. package/dist/protocols/protocol-v2/index.d.ts +2 -2
  106. package/dist/protocols/protocol-v2/index.d.ts.map +1 -1
  107. package/dist/protocols/protocol-v2/resources.d.ts +12 -7
  108. package/dist/protocols/protocol-v2/resources.d.ts.map +1 -1
  109. package/dist/protocols/protocol-v2/unlockPolicyRunner.d.ts +3 -1
  110. package/dist/protocols/protocol-v2/unlockPolicyRunner.d.ts.map +1 -1
  111. package/dist/protocols/protocol-v2/walletSession.d.ts +3 -0
  112. package/dist/protocols/protocol-v2/walletSession.d.ts.map +1 -1
  113. package/dist/types/api/firmwareUpdatePlan.d.ts +2 -2
  114. package/dist/types/api/firmwareUpdatePlan.d.ts.map +1 -1
  115. package/dist/types/api/protocolV2.d.ts +1 -1
  116. package/dist/types/api/protocolV2.d.ts.map +1 -1
  117. package/dist/types/device.d.ts.map +1 -1
  118. package/dist/types/settings.d.ts +3 -31
  119. package/dist/types/settings.d.ts.map +1 -1
  120. package/dist/utils/deviceFeaturesCompat.d.ts.map +1 -1
  121. package/dist/utils/deviceFeaturesUtils.d.ts +3 -0
  122. package/dist/utils/deviceFeaturesUtils.d.ts.map +1 -1
  123. package/dist/utils/deviceInfoUtils.d.ts.map +1 -1
  124. package/dist/utils/deviceSettings.d.ts +1 -0
  125. package/dist/utils/deviceSettings.d.ts.map +1 -1
  126. package/dist/utils/index.d.ts +1 -1
  127. package/dist/utils/index.d.ts.map +1 -1
  128. package/dist/utils/patch.d.ts +1 -1
  129. package/dist/utils/patch.d.ts.map +1 -1
  130. package/dist/utils/pro2Nft.d.ts +7 -0
  131. package/dist/utils/pro2Nft.d.ts.map +1 -1
  132. package/package.json +6 -4
  133. package/src/api/BaseMethod.ts +8 -1
  134. package/src/api/CheckAllFirmwareRelease.ts +2 -4
  135. package/src/api/CheckBLEFirmwareRelease.ts +2 -2
  136. package/src/api/CheckBootloaderRelease.ts +3 -2
  137. package/src/api/CheckFirmwareRelease.ts +2 -2
  138. package/src/api/FirmwareUpdateV2.ts +12 -9
  139. package/src/api/FirmwareUpdateV3.ts +29 -19
  140. package/src/api/FirmwareUpdateV4.ts +918 -741
  141. package/src/api/OpenWalletSession.ts +39 -9
  142. package/src/api/PromptWebDeviceAccess.ts +2 -2
  143. package/src/api/SearchDevices.ts +6 -3
  144. package/src/api/UploadPortfolio.ts +9 -2
  145. package/src/api/allnetwork/AllNetworkGetAddressBase.ts +2 -1
  146. package/src/api/device/DeviceChangePin.ts +4 -1
  147. package/src/api/device/DeviceRebootToBoardloader.ts +3 -1
  148. package/src/api/device/DeviceRebootToBootloader.ts +3 -1
  149. package/src/api/device/DeviceSettings.ts +34 -20
  150. package/src/api/device/DeviceVerify.ts +25 -0
  151. package/src/api/device/DeviceWipe.ts +4 -1
  152. package/src/api/firmware/FirmwareArtifactSource.ts +41 -14
  153. package/src/api/firmware/FirmwarePreparedResourceArchive.ts +16 -27
  154. package/src/api/firmware/FirmwareUpdateBaseMethod.ts +9 -3
  155. package/src/api/firmware/FirmwareUpdatePlan.ts +37 -77
  156. package/src/api/firmware/FirmwareUpdatePreparedPlan.ts +10 -6
  157. package/src/api/firmware/protocolV2Release.ts +7 -2
  158. package/src/api/helpers/base64Data.ts +85 -0
  159. package/src/api/protocol-v2/DeviceFactoryInfoSet.ts +1 -1
  160. package/src/api/protocol-v2/DeviceFirmwareUpdate.ts +6 -28
  161. package/src/api/protocol-v2/DeviceGetFirmwareUpdateStatus.ts +7 -1
  162. package/src/api/protocol-v2/DeviceInfoGet.ts +3 -3
  163. package/src/api/protocol-v2/DeviceReboot.ts +5 -0
  164. package/src/api/protocol-v2/DeviceUploadNft.ts +61 -9
  165. package/src/api/protocol-v2/DeviceUploadWallpaper.ts +53 -19
  166. package/src/api/protocol-v2/helpers.ts +8 -0
  167. package/src/api/ton/TonSignMessage.ts +5 -3
  168. package/src/core/index.ts +264 -55
  169. package/src/data/messages/messages-protocol-v2.json +140 -13
  170. package/src/data-manager/DataManager.ts +46 -39
  171. package/src/data-manager/TransportManager.ts +3 -1
  172. package/src/device/Device.ts +210 -44
  173. package/src/device/DeviceCommands.ts +27 -18
  174. package/src/device/DeviceConnector.ts +17 -3
  175. package/src/device/DevicePool.ts +9 -2
  176. package/src/device/DeviceStateMapper.ts +30 -24
  177. package/src/deviceProfile/buildDeviceFeatures.ts +10 -5
  178. package/src/events/logBlockEvent.ts +14 -1
  179. package/src/events/ui-request.ts +3 -0
  180. package/src/index.ts +0 -10
  181. package/src/protocols/protocol-v2/features.ts +22 -5
  182. package/src/protocols/protocol-v2/index.ts +2 -0
  183. package/src/protocols/protocol-v2/resources.ts +110 -147
  184. package/src/protocols/protocol-v2/unlockPolicyRunner.ts +13 -1
  185. package/src/protocols/protocol-v2/walletSession.ts +65 -22
  186. package/src/types/api/firmwareUpdate.ts +1 -1
  187. package/src/types/api/firmwareUpdatePlan.ts +2 -2
  188. package/src/types/api/protocolV2.ts +1 -1
  189. package/src/types/device.ts +2 -1
  190. package/src/types/settings.ts +6 -34
  191. package/src/utils/deviceFeaturesCompat.ts +9 -4
  192. package/src/utils/deviceFeaturesUtils.ts +4 -0
  193. package/src/utils/deviceInfoUtils.ts +9 -2
  194. package/src/utils/deviceSettings.ts +11 -4
  195. package/src/utils/index.ts +1 -0
  196. package/src/utils/pro2Nft.ts +31 -8
  197. package/__tests__/firmware-memory-host.test.ts +0 -126
  198. package/dist/api/firmware/FirmwareMemoryHost.d.ts +0 -22
  199. package/dist/api/firmware/FirmwareMemoryHost.d.ts.map +0 -1
  200. package/src/api/firmware/FirmwareMemoryHost.ts +0 -143
@@ -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
 
@@ -4072,6 +4376,33 @@ describe('Protocol V2 firmware update targets', () => {
4072
4376
  });
4073
4377
  });
4074
4378
 
4379
+ test('uses the DeviceState identity for Protocol V2 firmware artifact lookups', async () => {
4380
+ const method = new FirmwareUpdateV4({
4381
+ id: 1,
4382
+ payload: {
4383
+ method: 'firmwareUpdateV4',
4384
+ platform: 'web',
4385
+ },
4386
+ });
4387
+ method.init();
4388
+ (method as any).device = stubDevice({
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
+
4075
4406
  test('keeps all local binaries when no target list was supplied', () => {
4076
4407
  const method = new FirmwareUpdateV4({
4077
4408
  id: 1,
@@ -4098,7 +4429,7 @@ describe('Protocol V2 firmware update targets', () => {
4098
4429
  ).toEqual([3, 4]);
4099
4430
  });
4100
4431
 
4101
- test('requires the external host to prepare manifest resources before bootloader entry', async () => {
4432
+ test('keeps external-only resource updates on the prepared host path', async () => {
4102
4433
  const method = new FirmwareUpdateV4({
4103
4434
  id: 1,
4104
4435
  payload: {
@@ -4108,6 +4439,7 @@ describe('Protocol V2 firmware update targets', () => {
4108
4439
  },
4109
4440
  });
4110
4441
  method.init();
4442
+ jest.spyOn(DataManager, 'getSettings').mockReturnValue('external-only' as never);
4111
4443
 
4112
4444
  (method as any).device = stubDevice({
4113
4445
  originalDescriptor: { id: 'usb-id', path: 'app-path', protocolType: 'V2' },
@@ -4135,21 +4467,140 @@ describe('Protocol V2 firmware update targets', () => {
4135
4467
  expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
4136
4468
  });
4137
4469
 
4138
- test('reboots Protocol V2 normal-mode device to bootloader before transfer', async () => {
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)));
4139
4480
  const method = new FirmwareUpdateV4({
4140
4481
  id: 1,
4141
4482
  payload: {
4142
4483
  method: 'firmwareUpdateV4',
4484
+ platform: 'ext',
4485
+ targetsToUpdate: ['app_v1', 'resource'],
4143
4486
  },
4144
4487
  });
4488
+ method.init();
4489
+
4145
4490
  (method as any).device = stubDevice({
4146
- originalDescriptor: { id: 'usb-id', path: 'usb-path', protocolType: 'V2' },
4147
- features: { bootloader_mode: false, capabilities: [] },
4148
- isBootloader: () => false,
4149
- probeProtocolV2RuntimeState: jest.fn().mockResolvedValue({
4150
- mode: 'bootloader',
4151
- bootloaderMode: true,
4152
- }),
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',
4602
+ bootloaderMode: true,
4603
+ }),
4153
4604
  });
4154
4605
  (method as any).protocolV2Reboot = jest.fn().mockResolvedValue({
4155
4606
  message: 'Device rebooted successfully',
@@ -4372,6 +4823,23 @@ describe('Protocol V2 firmware update targets', () => {
4372
4823
  expect(typedCall.mock.calls.map(call => call[0])).toEqual(['DeviceInfoGet']);
4373
4824
  });
4374
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
+
4375
4843
  test('polls again when Protocol V2 bootloader serial is temporarily unavailable', async () => {
4376
4844
  const method = new FirmwareUpdateV4({
4377
4845
  id: 1,
@@ -4453,6 +4921,87 @@ describe('Protocol V2 firmware update targets', () => {
4453
4921
  expect(initialize).not.toHaveBeenCalled();
4454
4922
  });
4455
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
+
4456
5005
  test('selects the matching physical device from multiple Protocol V2 USB reconnect candidates', async () => {
4457
5006
  const method = new FirmwareUpdateV4({
4458
5007
  id: 1,
@@ -4697,7 +5246,7 @@ describe('Protocol V2 firmware update targets', () => {
4697
5246
  type: 'DeviceInfo',
4698
5247
  message: {
4699
5248
  hw: { serial_no: 'PR2SERIAL' },
4700
- fw: { application: { version: '1.0.0' } },
5249
+ main_mcu: { application: { version: '1.0.0' } },
4701
5250
  },
4702
5251
  });
4703
5252
  (method as any).protocolV2ExpectedSerialNumber = 'expected-serial';
@@ -4718,6 +5267,45 @@ describe('Protocol V2 firmware update targets', () => {
4718
5267
  expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
4719
5268
  });
4720
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
+
4721
5309
  test('reboots Protocol V2 firmware flow back to normal without legacy switch-firmware prompt', async () => {
4722
5310
  const method = new FirmwareUpdateV4({
4723
5311
  id: 1,
@@ -4803,28 +5391,30 @@ describe('Protocol V2 firmware update targets', () => {
4803
5391
  });
4804
5392
  });
4805
5393
 
4806
- 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 () => {
4807
5395
  const method = new FirmwareUpdateV4({
4808
5396
  id: 1,
4809
5397
  payload: {
4810
5398
  method: 'firmwareUpdateV4',
4811
5399
  },
4812
5400
  });
4813
- const typedCall = jest.fn().mockRejectedValue(new Error('React Native BLE transport released'));
5401
+ const typedCall = jest
5402
+ .fn()
5403
+ .mockRejectedValue(ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected));
4814
5404
 
4815
5405
  (method as any).device = stubDevice({
4816
5406
  getCommands: () => ({ typedCall }),
4817
5407
  });
4818
5408
 
4819
- await expect((method as any).protocolV2Reboot(DeviceRebootType.Bootloader)).resolves.toEqual({
5409
+ await expect((method as any).protocolV2Reboot(DeviceRebootType.Normal)).resolves.toEqual({
4820
5410
  message: 'Device rebooted successfully',
4821
5411
  });
4822
5412
  expect((method as any).device.markProtocolV2Reboot).toHaveBeenCalledWith(
4823
- DeviceRebootType.Bootloader
5413
+ DeviceRebootType.Normal
4824
5414
  );
4825
5415
  });
4826
5416
 
4827
- 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 () => {
4828
5418
  const method = new FirmwareUpdateV4({
4829
5419
  id: 1,
4830
5420
  payload: {
@@ -4834,143 +5424,902 @@ describe('Protocol V2 firmware update targets', () => {
4834
5424
  const typedCall = jest
4835
5425
  .fn()
4836
5426
  .mockRejectedValue(
4837
- Object.assign(
4838
- new Error("Failed to execute 'open' on 'USBDevice': The device was disconnected"),
4839
- { name: 'NotFoundError' }
5427
+ ERRORS.TypedError(
5428
+ HardwareErrorCode.BleTimeoutError,
5429
+ 'Lowlevel response timeout after 5000ms for DeviceReboot'
4840
5430
  )
4841
5431
  );
4842
5432
 
4843
5433
  (method as any).device = stubDevice({
4844
5434
  getCommands: () => ({ typedCall }),
4845
5435
  });
4846
- method.postTipMessage = jest.fn();
4847
- method.postProgressMessage = jest.fn();
4848
-
4849
- await expect(
4850
- (method as any).protocolV2StartFirmwareUpdate({
4851
- targets: [{ target_id: 4, path: 'vol1:firmware.bin' }],
4852
- })
4853
- ).rejects.toThrow();
4854
5436
 
4855
- expect(method.postTipMessage).not.toHaveBeenCalled();
4856
- expect(method.postProgressMessage).not.toHaveBeenCalled();
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
+ );
4857
5449
  });
4858
5450
 
4859
- test('does not poll Protocol V2 install status when the update ACK times out', async () => {
5451
+ test('continues after a USB Protocol V2 reboot response timeout', async () => {
4860
5452
  const method = new FirmwareUpdateV4({
4861
5453
  id: 1,
4862
5454
  payload: {
4863
5455
  method: 'firmwareUpdateV4',
4864
5456
  },
4865
5457
  });
4866
- const typedCall = jest.fn().mockRejectedValue(new Error('LIBUSB_TRANSFER_TIMED_OUT'));
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);
4867
5463
 
4868
5464
  (method as any).device = stubDevice({
4869
5465
  getCommands: () => ({ typedCall }),
4870
5466
  });
4871
- method.postTipMessage = jest.fn();
4872
- method.postProgressMessage = jest.fn();
4873
-
4874
- await expect(
4875
- (method as any).protocolV2StartFirmwareUpdate({
4876
- targets: [{ target_id: 4, path: 'vol1:application_p1.bin' }],
4877
- })
4878
- ).rejects.toThrow('LIBUSB_TRANSFER_TIMED_OUT');
4879
5467
 
4880
- expect(method.postTipMessage).not.toHaveBeenCalled();
4881
- expect(method.postProgressMessage).not.toHaveBeenCalled();
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
+ );
4882
5474
  });
4883
5475
 
4884
- test('polls only firmware status while Protocol V2 bootloader is installing', async () => {
5476
+ test('propagates non-transport errors from a Protocol V2 reboot', async () => {
4885
5477
  const method = new FirmwareUpdateV4({
4886
5478
  id: 1,
4887
5479
  payload: {
4888
5480
  method: 'firmwareUpdateV4',
4889
5481
  },
4890
5482
  });
4891
- const typedCall = jest
4892
- .fn()
4893
- .mockResolvedValueOnce({
4894
- type: 'DeviceFirmwareUpdateStatus',
4895
- message: { records: [{ target_id: 4, status: 1 }] },
4896
- })
4897
- .mockResolvedValueOnce({
4898
- type: 'DeviceFirmwareUpdateStatus',
4899
- message: { records: [{ target_id: 4, status: 2 }] },
4900
- });
4901
- const reconnectProtocolV2Device = jest
4902
- .fn()
4903
- .mockRejectedValueOnce(new Error('Device rebooting'))
4904
- .mockResolvedValue(undefined);
5483
+ const rebootError = new Error('Device rejected reboot');
5484
+ const typedCall = jest.fn().mockRejectedValue(rebootError);
4905
5485
 
4906
5486
  (method as any).device = stubDevice({
4907
5487
  getCommands: () => ({ typedCall }),
4908
5488
  });
4909
- (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
4910
- (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(undefined);
4911
- method.postProgressMessage = jest.fn();
4912
5489
 
4913
- await (method as any).waitForProtocolV2FirmwareUpdateComplete([
4914
- { target_id: 4, path: 'vol0:/application_p1.bin' },
4915
- ]);
5490
+ await expect((method as any).protocolV2Reboot(DeviceRebootType.Normal)).rejects.toBe(
5491
+ rebootError
5492
+ );
5493
+ expect((method as any).device.markProtocolV2Reboot).not.toHaveBeenCalled();
5494
+ });
4916
5495
 
4917
- expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(2);
4918
- expect(typedCall.mock.calls.map(call => call[0])).toEqual([
4919
- 'DeviceFirmwareUpdateStatusGet',
4920
- 'DeviceFirmwareUpdateStatusGet',
4921
- ]);
4922
- expect(typedCall.mock.calls.map(call => call[0])).not.toContain('DeviceStatusGet');
4923
- expect(method.postProgressMessage).toHaveBeenCalledWith(1, 'installingFirmware');
4924
- expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
5496
+ test('treats Android BLE transport release during Protocol V2 reboot as expected', async () => {
5497
+ const method = new FirmwareUpdateV4({
5498
+ id: 1,
5499
+ payload: {
5500
+ method: 'firmwareUpdateV4',
5501
+ },
5502
+ });
5503
+ const typedCall = jest.fn().mockRejectedValue(new Error('React Native BLE transport released'));
5504
+
5505
+ (method as any).device = stubDevice({
5506
+ getCommands: () => ({ typedCall }),
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
+ );
4925
5515
  });
4926
5516
 
4927
- test('accepts a missing firmware status handler only after confirming normal mode', async () => {
5517
+ test('stages targets before sending the empty Protocol V2 install request', async () => {
4928
5518
  const method = new FirmwareUpdateV4({
4929
5519
  id: 1,
4930
5520
  payload: {
4931
5521
  method: 'firmwareUpdateV4',
4932
5522
  },
4933
5523
  });
4934
- const typedCall = jest
4935
- .fn()
4936
- .mockRejectedValue(new Error('Failure: Handler not registered for this message'));
4937
- const reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
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();
5601
+ method.postTipMessage = jest.fn();
5602
+ method.postProgressMessage = jest.fn();
5603
+
5604
+ await expect(
5605
+ (method as any).protocolV2StartFirmwareUpdate({
5606
+ targets: [{ target_id: 4, path: 'vol1:application_p1.bin' }],
5607
+ })
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');
5640
+
5641
+ expect(call).toHaveBeenCalledWith(
5642
+ 'DeviceFirmwareUpdateRequest',
5643
+ {},
5644
+ expect.objectContaining({
5645
+ returnAfterWrite: true,
5646
+ expectedTypes: ['Success'],
5647
+ onResponseAfterWrite: expect.any(Function),
5648
+ })
5649
+ );
5650
+ expect(method.postTipMessage).not.toHaveBeenCalled();
5651
+ expect(method.postProgressMessage).not.toHaveBeenCalled();
5652
+ });
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
+
5750
+ test('polls only firmware status while Protocol V2 bootloader is installing', async () => {
5751
+ const method = new FirmwareUpdateV4({
5752
+ id: 1,
5753
+ payload: {
5754
+ method: 'firmwareUpdateV4',
5755
+ },
5756
+ });
5757
+ const typedCall = jest
5758
+ .fn()
5759
+ .mockResolvedValueOnce({
5760
+ type: 'DeviceFirmwareUpdateStatus',
5761
+ message: { records: [{ target_id: 4, status: 1 }] },
5762
+ })
5763
+ .mockResolvedValueOnce({
5764
+ type: 'DeviceFirmwareUpdateStatus',
5765
+ message: { records: [{ target_id: 4, status: 2 }] },
5766
+ });
5767
+ const reconnectProtocolV2Device = jest.fn();
5768
+ const verifyProtocolV2ReconnectIdentity = jest.fn();
5769
+
5770
+ (method as any).device = stubDevice({
5771
+ getCommands: () => ({ typedCall }),
5772
+ });
5773
+ (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
5774
+ (method as any).verifyProtocolV2ReconnectIdentity = verifyProtocolV2ReconnectIdentity;
5775
+ method.postProgressMessage = jest.fn();
5776
+ method.postTipMessage = jest.fn();
5777
+
5778
+ await (method as any).waitForProtocolV2FirmwareUpdateComplete([
5779
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
5780
+ ]);
5781
+
5782
+ expect(reconnectProtocolV2Device).not.toHaveBeenCalled();
5783
+ expect(verifyProtocolV2ReconnectIdentity).not.toHaveBeenCalled();
5784
+ expect(typedCall.mock.calls.map(call => call[0])).toEqual([
5785
+ 'DeviceFirmwareUpdateStatusGet',
5786
+ 'DeviceFirmwareUpdateStatusGet',
5787
+ ]);
5788
+ expect(typedCall.mock.calls.map(call => call[0])).not.toContain('DeviceStatusGet');
5789
+ expect(method.postProgressMessage).toHaveBeenCalledWith(1, 'installingFirmware');
5790
+ expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
5791
+ });
5792
+
5793
+ test('keeps polling after an empty status response until the target finishes', async () => {
5794
+ const method = new FirmwareUpdateV4({
5795
+ id: 1,
5796
+ payload: {
5797
+ method: 'firmwareUpdateV4',
5798
+ },
5799
+ });
5800
+ const typedCall = jest
5801
+ .fn()
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
+ });
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);
5825
+
5826
+ (method as any).device = stubDevice({
5827
+ getCommands: () => ({ typedCall }),
5828
+ });
5829
+ (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
5830
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(undefined);
5831
+ method.postProgressMessage = jest.fn();
5832
+
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',
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);
4938
6280
 
4939
6281
  (method as any).device = stubDevice({
4940
6282
  getCommands: () => ({ typedCall }),
4941
6283
  });
4942
- (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
4943
- const deviceInfo = { hw: { serial_no: 'PRO2-PHYSICAL-1' } };
6284
+ (method as any).protocolV2InstallTerminalSuccessObserved = true;
6285
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
4944
6286
  (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(deviceInfo);
4945
6287
  (method as any).probeProtocolV2NormalMode = jest.fn().mockResolvedValue(true);
4946
6288
  method.postProgressMessage = jest.fn();
4947
6289
 
4948
- await (method as any).waitForProtocolV2FirmwareUpdateComplete([
4949
- { target_id: 4, path: 'vol0:/application_p1.bin' },
4950
- ]);
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
+ }
4951
6300
 
4952
- expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
4953
- expect(typedCall.mock.calls.map(call => call[0])).toEqual(['DeviceFirmwareUpdateStatusGet']);
4954
- expect((method as any).probeProtocolV2NormalMode).toHaveBeenCalledWith(deviceInfo);
6301
+ expect(Array.from((method as any).protocolV2CompletedTargetIds)).toEqual([4, 5]);
4955
6302
  expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
4956
6303
  });
4957
6304
 
4958
- 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 () => {
4959
6306
  const method = new FirmwareUpdateV4({
4960
6307
  id: 1,
4961
6308
  payload: {
4962
6309
  method: 'firmwareUpdateV4',
4963
6310
  },
4964
6311
  });
4965
- const typedCall = jest.fn().mockResolvedValue({
4966
- type: 'DeviceFirmwareUpdateStatus',
4967
- message: { records: [] },
4968
- });
6312
+ const typedCall = jest
6313
+ .fn()
6314
+ .mockRejectedValueOnce(new Error('The USB device was disconnected'))
6315
+ .mockResolvedValueOnce({
6316
+ type: 'DeviceFirmwareUpdateStatus',
6317
+ message: { records: [] },
6318
+ });
4969
6319
  const deviceInfo = { hw: { serial_no: 'PRO2-PHYSICAL-1' } };
4970
- let now = 0;
4971
- jest.spyOn(Date, 'now').mockImplementation(() => now);
4972
- jest.spyOn(global, 'setTimeout').mockImplementation(((callback: () => void) => {
4973
- now += 31 * 1000;
6320
+ const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation(((
6321
+ callback: () => void
6322
+ ) => {
4974
6323
  callback();
4975
6324
  return 0 as any;
4976
6325
  }) as typeof setTimeout);
@@ -4978,24 +6327,32 @@ describe('Protocol V2 firmware update targets', () => {
4978
6327
  (method as any).device = stubDevice({
4979
6328
  getCommands: () => ({ typedCall }),
4980
6329
  });
6330
+ (method as any).protocolV2InstallTerminalSuccessObserved = true;
4981
6331
  (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
4982
6332
  (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(deviceInfo);
4983
6333
  (method as any).probeProtocolV2NormalMode = jest.fn().mockResolvedValue(true);
4984
6334
  method.postProgressMessage = jest.fn();
4985
6335
 
4986
- await expect(
4987
- (method as any).waitForProtocolV2FirmwareUpdateComplete([
4988
- { target_id: 4, path: 'vol0:/application_p1.bin' },
4989
- ])
4990
- ).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
+ }
4991
6346
 
4992
- expect(typedCall).toHaveBeenCalledTimes(1);
4993
- expect((method as any).probeProtocolV2NormalMode).toHaveBeenCalledWith(deviceInfo);
4994
- expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
6347
+ expect((method as any).reconnectProtocolV2Device).toHaveBeenCalledWith({
6348
+ skipProtocolProbe: true,
6349
+ });
6350
+ expect(method.postProgressMessage).toHaveBeenLastCalledWith(100, 'installingFirmware');
4995
6351
  });
4996
6352
 
4997
6353
  test.each([
4998
6354
  [{ mode: 'normal', bootloaderMode: false }, true],
6355
+ [{ mode: 'notInitialized', bootloaderMode: false }, true],
4999
6356
  [{ mode: 'bootloader', bootloaderMode: true }, false],
5000
6357
  ])('derives firmware completion from the runtime-state probe: %o', async (features, expected) => {
5001
6358
  const method = new FirmwareUpdateV4({
@@ -5054,7 +6411,9 @@ describe('Protocol V2 firmware update targets', () => {
5054
6411
  'DeviceFirmwareUpdateStatusGet',
5055
6412
  'DeviceFirmwareUpdateStatusGet',
5056
6413
  ]);
5057
- 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();
5058
6417
  });
5059
6418
 
5060
6419
  test('rejects a finished target whose payload version conflicts with the plan', () => {
@@ -5169,8 +6528,8 @@ describe('Protocol V2 firmware update targets', () => {
5169
6528
  { target_id: 4, path: 'vol0:/application_p1.bin' },
5170
6529
  ])
5171
6530
  ).rejects.toMatchObject({
5172
- errorCode: HardwareErrorCode.RuntimeError,
5173
- message: 'Protocol V2 install status conflicts with target 4',
6531
+ errorCode: HardwareErrorCode.FirmwareError,
6532
+ message: 'Protocol V2 firmware install status conflict',
5174
6533
  params: {
5175
6534
  firmwareUpdateCode: 'FirmwareInstallStatusConflict',
5176
6535
  },
@@ -5183,7 +6542,7 @@ describe('Protocol V2 firmware update targets', () => {
5183
6542
  expect(typedCall).toHaveBeenCalledTimes(1);
5184
6543
  });
5185
6544
 
5186
- 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', () => {
5187
6546
  const method = new FirmwareUpdateV4({
5188
6547
  id: 1,
5189
6548
  payload: {
@@ -5191,7 +6550,7 @@ describe('Protocol V2 firmware update targets', () => {
5191
6550
  platform: 'desktop',
5192
6551
  targetsToUpdate: ['app_v1', 'app_v2'],
5193
6552
  expectedTargetVersions: {
5194
- app_v1: '1.0.0',
6553
+ app_v1: '3.0.0',
5195
6554
  app_v2: '2.0.0',
5196
6555
  },
5197
6556
  },
@@ -5202,12 +6561,41 @@ describe('Protocol V2 firmware update targets', () => {
5202
6561
  minor_version: 0,
5203
6562
  patch_version: 0,
5204
6563
  };
5205
- (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
+ };
5206
6594
 
5207
6595
  expect(() => (method as any).assertExpectedProtocolV2Versions()).not.toThrow();
5208
6596
  });
5209
6597
 
5210
- test('rejects unobservable final versions after the status endpoint fallback', () => {
6598
+ test('rejects a mismatched final DeviceInfo application slot version', () => {
5211
6599
  const method = new FirmwareUpdateV4({
5212
6600
  id: 1,
5213
6601
  payload: {
@@ -5221,6 +6609,63 @@ describe('Protocol V2 firmware update targets', () => {
5221
6609
  },
5222
6610
  });
5223
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();
5224
6669
  (method as any).protocolV2LatestFinalFeatures = {
5225
6670
  major_version: 3,
5226
6671
  minor_version: 0,
@@ -5228,11 +6673,11 @@ describe('Protocol V2 firmware update targets', () => {
5228
6673
  };
5229
6674
 
5230
6675
  expect(() => (method as any).assertExpectedProtocolV2Versions()).toThrow(
5231
- 'has no observable final version after status fallback'
6676
+ 'target se01 has no observable final version after status fallback'
5232
6677
  );
5233
6678
  });
5234
6679
 
5235
- 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 () => {
5236
6681
  const method = new FirmwareUpdateV4({
5237
6682
  id: 1,
5238
6683
  payload: {
@@ -5264,7 +6709,7 @@ describe('Protocol V2 firmware update targets', () => {
5264
6709
  ])
5265
6710
  ).resolves.toBeUndefined();
5266
6711
 
5267
- expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
6712
+ expect(reconnectProtocolV2Device).not.toHaveBeenCalled();
5268
6713
  expect(typedCall).toHaveBeenCalledWith(
5269
6714
  'DeviceFirmwareUpdateStatusGet',
5270
6715
  'DeviceFirmwareUpdateStatus',
@@ -5274,7 +6719,7 @@ describe('Protocol V2 firmware update targets', () => {
5274
6719
  expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
5275
6720
  });
5276
6721
 
5277
- 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 () => {
5278
6723
  const method = new FirmwareUpdateV4({
5279
6724
  id: 1,
5280
6725
  payload: {
@@ -5290,7 +6735,7 @@ describe('Protocol V2 firmware update targets', () => {
5290
6735
  let now = 0;
5291
6736
  jest.spyOn(Date, 'now').mockImplementation(() => now);
5292
6737
  jest.spyOn(global, 'setTimeout').mockImplementation(((callback: () => void) => {
5293
- now += 31 * 1000;
6738
+ now += 60 * 1000;
5294
6739
  callback();
5295
6740
  return 0 as any;
5296
6741
  }) as typeof setTimeout);
@@ -5309,12 +6754,13 @@ describe('Protocol V2 firmware update targets', () => {
5309
6754
  ])
5310
6755
  ).rejects.toMatchObject({
5311
6756
  errorCode: HardwareErrorCode.FirmwareError,
5312
- message: expect.stringContaining('targetIds=3'),
6757
+ message: 'Protocol V2 firmware install timed out',
6758
+ params: { firmwareUpdateCode: 'FirmwareInstallTimeout' },
5313
6759
  });
5314
- expect(typedCall).toHaveBeenCalledTimes(2);
6760
+ expect(typedCall).toHaveBeenCalledTimes(5);
5315
6761
  });
5316
6762
 
5317
- 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 () => {
5318
6764
  const method = new FirmwareUpdateV4({
5319
6765
  id: 1,
5320
6766
  payload: {
@@ -5363,11 +6809,11 @@ describe('Protocol V2 firmware update targets', () => {
5363
6809
  { target_id: 4, path: 'vol0:/application_p1.bin' },
5364
6810
  ])
5365
6811
  ).resolves.toBeUndefined();
5366
- expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(2);
6812
+ expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
5367
6813
  expect(typedCall).toHaveBeenCalledTimes(4);
5368
6814
  });
5369
6815
 
5370
- test('uses an eight-minute Pro2 install status window', async () => {
6816
+ test('uses a five-minute Pro2 install status window', async () => {
5371
6817
  const method = new FirmwareUpdateV4({
5372
6818
  id: 1,
5373
6819
  payload: {
@@ -5377,13 +6823,17 @@ describe('Protocol V2 firmware update targets', () => {
5377
6823
  jest
5378
6824
  .spyOn(Date, 'now')
5379
6825
  .mockReturnValueOnce(0)
5380
- .mockReturnValue(8 * 60 * 1000);
6826
+ .mockReturnValue(5 * 60 * 1000);
5381
6827
 
5382
6828
  await expect(
5383
6829
  (method as any).waitForProtocolV2FirmwareUpdateComplete([
5384
6830
  { target_id: 4, path: 'vol0:/application_p1.bin' },
5385
6831
  ])
5386
- ).rejects.toThrow('within 480s');
6832
+ ).rejects.toMatchObject({
6833
+ errorCode: HardwareErrorCode.FirmwareError,
6834
+ message: 'Protocol V2 firmware install timed out',
6835
+ params: { firmwareUpdateCode: 'FirmwareInstallTimeout' },
6836
+ });
5387
6837
  });
5388
6838
 
5389
6839
  test('uses a 90-second Pro2 bootloader reconnect window', async () => {
@@ -5461,7 +6911,7 @@ describe('Protocol V2 firmware update targets', () => {
5461
6911
  ],
5462
6912
  new Set([4, 10])
5463
6913
  )
5464
- ).toThrow('Protocol V2 install status conflicts with target 4');
6914
+ ).toThrow('Protocol V2 firmware install status conflict');
5465
6915
  expect(method.postProgressMessage).not.toHaveBeenCalled();
5466
6916
  expect(method.postProgressMessage).not.toHaveBeenCalledWith(100, 'installingFirmware');
5467
6917
 
@@ -5501,8 +6951,8 @@ describe('Protocol V2 firmware update targets', () => {
5501
6951
  throw new Error('Expected Protocol V2 failed firmware status to throw');
5502
6952
  } catch (error: any) {
5503
6953
  expect(error.errorCode).toBe(HardwareErrorCode.FirmwareError);
5504
- expect(error.message).toContain('FW_MGMT_UPDATER_TASK_STATUS_FAILED_VERIFY');
5505
- 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' });
5506
6956
  }
5507
6957
  });
5508
6958
 
@@ -5539,7 +6989,8 @@ describe('Protocol V2 firmware update targets', () => {
5539
6989
  ])
5540
6990
  ).rejects.toMatchObject({
5541
6991
  errorCode: HardwareErrorCode.FirmwareError,
5542
- message: expect.stringContaining('vol0:/application_p1.bin'),
6992
+ message: 'Protocol V2 firmware install failed',
6993
+ params: { firmwareUpdateCode: 'FirmwareInstallFailed' },
5543
6994
  });
5544
6995
  expect(typedCall).toHaveBeenCalledWith(
5545
6996
  'DeviceFirmwareUpdateStatusGet',
@@ -5547,6 +6998,8 @@ describe('Protocol V2 firmware update targets', () => {
5547
6998
  {
5548
6999
  fields: {
5549
7000
  status: true,
7001
+ progress_percent: true,
7002
+ phase_info: true,
5550
7003
  payload_version: true,
5551
7004
  path: true,
5552
7005
  },
@@ -5555,7 +7008,7 @@ describe('Protocol V2 firmware update targets', () => {
5555
7008
  );
5556
7009
  });
5557
7010
 
5558
- 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 () => {
5559
7012
  const method = new FirmwareUpdateV4({
5560
7013
  id: 1,
5561
7014
  payload: {
@@ -5584,54 +7037,75 @@ describe('Protocol V2 firmware update targets', () => {
5584
7037
  .mockResolvedValue(undefined);
5585
7038
 
5586
7039
  await (method as any).executeProtocolV2Update({
5587
- bootloaderBinary: new Uint8Array([4, 5]).buffer,
5588
- 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: [
5589
7044
  {
5590
- fileName: 'coprocessor.bin',
5591
- binary: new Uint8Array([6]).buffer,
5592
- targetId: 6,
7045
+ fileName: 'application_p1.bin',
7046
+ binary: new Uint8Array([8]).buffer,
7047
+ targetId: 4,
7048
+ kind: 'firmware',
5593
7049
  },
5594
7050
  {
5595
7051
  fileName: 'se01.bin',
5596
7052
  binary: new Uint8Array([7]).buffer,
5597
7053
  targetId: 7,
7054
+ kind: 'firmware',
5598
7055
  },
5599
7056
  {
5600
- fileName: 'application_p1.bin',
5601
- binary: new Uint8Array([8]).buffer,
5602
- 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',
5603
7067
  },
5604
7068
  ],
5605
7069
  });
5606
7070
 
5607
7071
  expect(writtenPaths).toEqual([
7072
+ 'vol0:/application_p1.bin',
7073
+ 'vol0:/se01.bin',
5608
7074
  'vol0:/bootloader.bin',
5609
7075
  'vol0:/coprocessor.bin',
5610
- 'vol0:/se01.bin',
5611
- 'vol0:/application_p1.bin',
5612
7076
  ]);
5613
7077
  expect((method as any).verifyProtocolV2StagedFile).toHaveBeenCalledTimes(4);
5614
7078
  expect((method as any).verifyProtocolV2StagedFile).toHaveBeenNthCalledWith(
5615
7079
  2,
5616
- 'vol0:/coprocessor.bin',
7080
+ 'vol0:/se01.bin',
5617
7081
  1
5618
7082
  );
5619
- expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledTimes(2);
5620
- expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenNthCalledWith(1, {
5621
- targets: [{ target_id: 3, path: 'vol0:/bootloader.bin' }],
5622
- });
5623
- expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenNthCalledWith(2, {
7083
+ expect((method as any).enterProtocolV2BootloaderMode).toHaveBeenCalledTimes(1);
7084
+ expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledTimes(1);
7085
+ expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledWith({
5624
7086
  targets: [
5625
- { target_id: 6, path: 'vol0:/coprocessor.bin' },
5626
- { target_id: 7, path: 'vol0:/se01.bin' },
5627
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' },
5628
7091
  ],
5629
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();
5630
7104
  expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'transferData');
5631
- expect((method as any).waitForProtocolV2FirmwareUpdateComplete).toHaveBeenCalled();
7105
+ expect((method as any).completeProtocolV2FinalVerification).toHaveBeenCalledTimes(1);
5632
7106
  });
5633
7107
 
5634
- test('announces separate resource and component transfer phases', async () => {
7108
+ test('uses one global transfer range for resources and firmware', async () => {
5635
7109
  const method = new FirmwareUpdateV4({
5636
7110
  id: 1,
5637
7111
  payload: {
@@ -5688,18 +7162,24 @@ describe('Protocol V2 firmware update targets', () => {
5688
7162
  ],
5689
7163
  });
5690
7164
 
5691
- expect(method.postTipMessage).toHaveBeenCalledTimes(3);
7165
+ expect((method as any).enterProtocolV2BootloaderMode).toHaveBeenCalledTimes(1);
7166
+ expect(method.postTipMessage).toHaveBeenCalledTimes(2);
5692
7167
  expect(method.postTipMessage).toHaveBeenNthCalledWith(1, 'StartTransferData');
5693
- expect(method.postTipMessage).toHaveBeenNthCalledWith(2, 'StartTransferData');
5694
- expect(method.postTipMessage).toHaveBeenNthCalledWith(3, 'ConfirmOnDevice');
7168
+ expect(method.postTipMessage).toHaveBeenNthCalledWith(2, 'ConfirmOnDevice');
5695
7169
  expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenNthCalledWith(
5696
7170
  1,
5697
- expect.objectContaining({ processedSize: 0, totalSize: 2 })
7171
+ expect.objectContaining({ processedSize: 0, totalSize: 3 })
5698
7172
  );
5699
7173
  expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenNthCalledWith(
5700
7174
  2,
5701
- expect.objectContaining({ processedSize: 0, totalSize: 1 })
7175
+ expect.objectContaining({ processedSize: 2, totalSize: 3 })
5702
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
+ });
5703
7183
  expect((method as any).isProtocolV2ResourceBundleUpToDate).toHaveBeenCalledWith(
5704
7184
  expect.objectContaining({
5705
7185
  version: [1, 2, 3],
@@ -5709,50 +7189,7 @@ describe('Protocol V2 firmware update targets', () => {
5709
7189
  );
5710
7190
  });
5711
7191
 
5712
- test('installs and verifies bootloader before syncing manifest resources', () => {
5713
- const method = new FirmwareUpdateV4({
5714
- id: 1,
5715
- payload: { method: 'firmwareUpdateV4' },
5716
- });
5717
- const phases = (method as any).buildProtocolV2ExecutionPhases({
5718
- installSources: [{ kind: 'bootloader' }, { kind: 'component' }],
5719
- resourceSources: [{ kind: 'resource' }],
5720
- });
5721
-
5722
- expect(phases.map((phase: { kind: string }) => phase.kind)).toEqual([
5723
- 'bootloader-install',
5724
- 'bootloader-verify',
5725
- 'resource-sync',
5726
- 'component-install',
5727
- 'final-verify',
5728
- ]);
5729
- });
5730
-
5731
- test('stages the mounted boot resource before any firmware install phase', () => {
5732
- const method = new FirmwareUpdateV4({
5733
- id: 1,
5734
- payload: { method: 'firmwareUpdateV4' },
5735
- });
5736
- const bootResource = {
5737
- devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg',
5738
- };
5739
- const phases = (method as any).buildProtocolV2ExecutionPhases({
5740
- installSources: [{ kind: 'bootloader' }, { kind: 'component' }],
5741
- resourceSources: [bootResource, { devicePath: 'vol0:/resource/images/images.okpkg' }],
5742
- });
5743
-
5744
- expect(phases.map((phase: { kind: string }) => phase.kind)).toEqual([
5745
- 'resource-sync',
5746
- 'bootloader-install',
5747
- 'bootloader-verify',
5748
- 'resource-sync',
5749
- 'component-install',
5750
- 'final-verify',
5751
- ]);
5752
- expect(phases[0].resourceSources).toEqual([bootResource]);
5753
- });
5754
-
5755
- test('removes stale boot resource staging before a component-only reboot', async () => {
7192
+ test('removes stale boot resource staging before the combined transfer', async () => {
5756
7193
  const method = new FirmwareUpdateV4({
5757
7194
  id: 1,
5758
7195
  payload: { method: 'firmwareUpdateV4' },
@@ -5779,18 +7216,18 @@ describe('Protocol V2 firmware update targets', () => {
5779
7216
  events.push('transfer-component');
5780
7217
  return Promise.resolve();
5781
7218
  });
5782
- (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockImplementation(() => {
5783
- events.push('reboot-normal');
5784
- return Promise.resolve();
7219
+ (method as any).completeProtocolV2FinalVerification = jest.fn().mockImplementation(() => {
7220
+ events.push('final-verify');
7221
+ return Promise.resolve({});
5785
7222
  });
5786
- (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
5787
7223
 
5788
7224
  await (method as any).executeProtocolV2SourceUpdate({
5789
7225
  installSources: [{ kind: 'firmware' }],
5790
7226
  resourceSources: [],
5791
7227
  });
5792
7228
 
5793
- 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']);
5794
7231
  expect(typedCall).toHaveBeenNthCalledWith(2, 'FilesystemFileDelete', 'Success', {
5795
7232
  path: 'vol0:/loaders/bootloader/boot_resource.okpkg.staging',
5796
7233
  });
@@ -5976,41 +7413,51 @@ describe('Protocol V2 firmware update targets', () => {
5976
7413
  bootloader: {
5977
7414
  target: 'BOOTLOADER',
5978
7415
  url: 'https://example.com/bootloader.pp.bin',
7416
+ version: [1, 0, 0],
5979
7417
  ...componentIntegrity('https://example.com/bootloader.pp.bin'),
5980
7418
  },
5981
7419
  applicationP1: {
5982
7420
  target: 'APPLICATION_P1',
5983
7421
  url: 'https://example.com/applicationP1.pp.bin',
7422
+ version: [2, 0, 0],
5984
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),
5985
7426
  },
5986
7427
  applicationP2: {
5987
7428
  target: 'APPLICATION_P2',
5988
7429
  url: 'https://example.com/applicationP2.pp.bin',
7430
+ version: [2, 0, 1],
5989
7431
  ...componentIntegrity('https://example.com/applicationP2.pp.bin'),
5990
7432
  },
5991
7433
  coprocessor: {
5992
7434
  target: 'COPROCESSOR',
5993
7435
  url: 'https://example.com/coprocessor.pp.bin',
7436
+ version: [1, 1, 0],
5994
7437
  ...componentIntegrity('https://example.com/coprocessor.pp.bin'),
5995
7438
  },
5996
7439
  se01: {
5997
7440
  target: 'SE01',
5998
7441
  url: 'https://example.com/se01.pp.bin',
7442
+ version: [1, 2, 1],
5999
7443
  ...componentIntegrity('https://example.com/se01.pp.bin'),
6000
7444
  },
6001
7445
  se02: {
6002
7446
  target: 'SE02',
6003
7447
  url: 'https://example.com/se02.pp.bin',
7448
+ version: [1, 2, 2],
6004
7449
  ...componentIntegrity('https://example.com/se02.pp.bin'),
6005
7450
  },
6006
7451
  se03: {
6007
7452
  target: 'SE03',
6008
7453
  url: 'https://example.com/se03.pp.bin',
7454
+ version: [1, 2, 3],
6009
7455
  ...componentIntegrity('https://example.com/se03.pp.bin'),
6010
7456
  },
6011
7457
  se04: {
6012
7458
  target: 'SE04',
6013
7459
  url: 'https://example.com/se04.pp.bin',
7460
+ version: [1, 2, 4],
6014
7461
  ...componentIntegrity('https://example.com/se04.pp.bin'),
6015
7462
  },
6016
7463
  },
@@ -6048,6 +7495,16 @@ describe('Protocol V2 firmware update targets', () => {
6048
7495
  'https://example.com/se03.pp.bin',
6049
7496
  'https://example.com/se04.pp.bin',
6050
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
+ });
6051
7508
 
6052
7509
  getSysResourceBinarySpy.mockRestore();
6053
7510
  getFirmwareLatestReleaseSpy.mockRestore();
@@ -6062,6 +7519,9 @@ describe('Protocol V2 firmware update targets', () => {
6062
7519
  method: 'firmwareUpdateV4',
6063
7520
  platform: 'web',
6064
7521
  targetsToUpdate: ['app_v1', 'coprocessor'],
7522
+ expectedTargetVersions: {
7523
+ app_v1: '9.9.9',
7524
+ },
6065
7525
  },
6066
7526
  });
6067
7527
  method.init();
@@ -6080,12 +7540,14 @@ describe('Protocol V2 firmware update targets', () => {
6080
7540
  applicationP1: {
6081
7541
  target: 'APPLICATION_P1',
6082
7542
  url: 'https://example.com/applicationP1.pp.bin',
7543
+ version: [2, 0, 0],
6083
7544
  expectedSize: explicitApplicationBinary.byteLength,
6084
7545
  fingerprint: bytesToHex(sha256(new Uint8Array(explicitApplicationBinary))),
6085
7546
  },
6086
7547
  coprocessor: {
6087
7548
  target: 'COPROCESSOR',
6088
7549
  url: 'https://example.com/coprocessor.pp.bin',
7550
+ version: [1, 1, 0],
6089
7551
  expectedSize: remoteCoprocessorBinary.byteLength,
6090
7552
  fingerprint: bytesToHex(sha256(new Uint8Array(remoteCoprocessorBinary))),
6091
7553
  },
@@ -6126,6 +7588,10 @@ describe('Protocol V2 firmware update targets', () => {
6126
7588
  kind: 'firmware',
6127
7589
  },
6128
7590
  ]);
7591
+ expect((method as any).params.expectedTargetVersions).toEqual({
7592
+ app_v1: '9.9.9',
7593
+ coprocessor: '1.1.0',
7594
+ });
6129
7595
 
6130
7596
  getSysResourceBinarySpy.mockRestore();
6131
7597
  getFirmwareLatestReleaseSpy.mockRestore();
@@ -6295,7 +7761,7 @@ describe('Protocol V2 firmware update targets', () => {
6295
7761
  });
6296
7762
  });
6297
7763
 
6298
- 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 () => {
6299
7765
  const method = new FirmwareUpdateV4({
6300
7766
  id: 1,
6301
7767
  payload: {
@@ -6303,62 +7769,20 @@ describe('Protocol V2 firmware update targets', () => {
6303
7769
  },
6304
7770
  });
6305
7771
  const imagesBinary = createProtocolV2OkppBinary();
6306
- const bootBinary = createProtocolV2OkppBinary({ payloadHashByte: 0x33 });
7772
+ const bootBinary = createProtocolV2OkppBinary({
7773
+ devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg.staging',
7774
+ payloadHashByte: 0x33,
7775
+ });
6307
7776
  const imagesSha256 = bytesToHex(sha256(new Uint8Array(imagesBinary)));
6308
7777
  const bootSha256 = bytesToHex(sha256(new Uint8Array(bootBinary)));
6309
- const manifest = {
6310
- schema: 1,
6311
- artifact_name: 'pro2-resource',
6312
- release_name: 'test-release',
6313
- variant: 'resource',
6314
- commit: 'abc123',
6315
- short_sha: 'abc123',
6316
- timestamp_utc: '2026-08-09T00:00:00Z',
6317
- core_version: '1.0.0',
6318
- key_set: 'production',
6319
- device_root: 'vol0:',
6320
- restore_mode: 'bootloader_update',
6321
- trees: [{ path: 'bundles', device: 'pro2' }],
6322
- files: [
6323
- {
6324
- archive_path: 'bundles/images/images.okpkg',
6325
- original_name: 'images.okpkg',
6326
- device_path: 'vol0:/bundles/images/images.okpkg',
6327
- size: imagesBinary.byteLength,
6328
- sha256: imagesSha256,
6329
- signed: true,
6330
- sig_algo: 'ed25519',
6331
- payload_version: '1.2.3',
6332
- },
6333
- {
6334
- archive_path: 'loaders/bootloader/boot_resource.okpkg',
6335
- original_name: 'boot_resource.okpkg',
6336
- device_path: 'vol0:/loaders/bootloader/boot_resource.okpkg',
6337
- size: bootBinary.byteLength,
6338
- sha256: bootSha256,
6339
- signed: true,
6340
- sig_algo: 'ed25519',
6341
- payload_version: '1.2.3',
6342
- },
6343
- ],
6344
- };
6345
- const manifestBinary = new TextEncoder().encode(JSON.stringify(manifest)).buffer;
6346
7778
  const zip = new JSZip();
6347
- zip.file('manifest.json', manifestBinary);
6348
7779
  zip.file('bundles/images/images.okpkg', imagesBinary);
6349
7780
  zip.file('loaders/bootloader/boot_resource.okpkg', bootBinary);
7781
+ zip.file('manifest.json', '{"ignored":true}');
6350
7782
  const archiveBinary = await zip.generateAsync({ type: 'arraybuffer' });
6351
7783
  const preparedEntries = [
6352
7784
  {
6353
- entryName: 'manifest.json',
6354
- artifact: {
6355
- artifactRef: 'manifest',
6356
- size: manifestBinary.byteLength,
6357
- sha256: bytesToHex(sha256(new Uint8Array(manifestBinary))),
6358
- },
6359
- },
6360
- {
6361
- entryName: 'bundles/images/images.okpkg',
7785
+ entryName: 'pro2-resource/bundles/images/images.okpkg',
6362
7786
  artifact: {
6363
7787
  artifactRef: 'images',
6364
7788
  size: imagesBinary.byteLength,
@@ -6366,13 +7790,21 @@ describe('Protocol V2 firmware update targets', () => {
6366
7790
  },
6367
7791
  },
6368
7792
  {
6369
- entryName: 'loaders/bootloader/boot_resource.okpkg',
7793
+ entryName: 'pro2-resource/loaders/bootloader/boot_resource.okpkg',
6370
7794
  artifact: {
6371
7795
  artifactRef: 'boot',
6372
7796
  size: bootBinary.byteLength,
6373
7797
  sha256: bootSha256,
6374
7798
  },
6375
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
+ },
6376
7808
  ];
6377
7809
  const artifacts = new Map([['archive', archiveBinary]]);
6378
7810
  (method as any).params = {
@@ -6411,16 +7843,11 @@ describe('Protocol V2 firmware update targets', () => {
6411
7843
  }),
6412
7844
  expect.objectContaining({
6413
7845
  name: 'boot_resource.okpkg',
6414
- devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg',
7846
+ devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg.staging',
6415
7847
  version: [1, 2, 3],
6416
7848
  }),
6417
7849
  ]);
6418
7850
  await Promise.all(sources.map(source => source.source.close()));
6419
-
6420
- preparedEntries[1].artifact.sha256 = 'f'.repeat(64);
6421
- await expect((method as any).prepareProtocolV2ResourceArchiveSources()).rejects.toThrow(
6422
- 'entries do not match the approved archive'
6423
- );
6424
7851
  });
6425
7852
 
6426
7853
  test('binds a prepared resource archive to the selected host generation', () => {
@@ -6462,9 +7889,9 @@ describe('Protocol V2 firmware update targets', () => {
6462
7889
  },
6463
7890
  materializedEntries: [
6464
7891
  {
6465
- entryName: 'manifest.json',
7892
+ entryName: 'bundles/images/images.okpkg',
6466
7893
  artifact: {
6467
- artifactRef: 'resource-manifest',
7894
+ artifactRef: 'resource-package',
6468
7895
  size: 2,
6469
7896
  sha256: 'b'.repeat(64),
6470
7897
  },
@@ -6528,7 +7955,7 @@ describe('Protocol V2 firmware update targets', () => {
6528
7955
  const componentArtifact = {
6529
7956
  artifactRef: 'prepared-bootloader',
6530
7957
  size: componentBinary.byteLength,
6531
- sha256: 'a'.repeat(64),
7958
+ sha256: bytesToHex(sha256(new Uint8Array(componentBinary))),
6532
7959
  };
6533
7960
  const planWithoutDigest = {
6534
7961
  schemaVersion: 2 as const,
@@ -6664,9 +8091,9 @@ describe('Protocol V2 firmware update targets', () => {
6664
8091
  },
6665
8092
  materializedEntries: [
6666
8093
  {
6667
- entryName: 'manifest.json',
8094
+ entryName: 'bundles/images/images.okpkg',
6668
8095
  artifact: {
6669
- artifactRef: 'prepared-resource-manifest',
8096
+ artifactRef: 'prepared-resource-package',
6670
8097
  size: 2,
6671
8098
  sha256: 'b'.repeat(64),
6672
8099
  },
@@ -6722,49 +8149,17 @@ describe('Protocol V2 firmware update targets', () => {
6722
8149
  }
6723
8150
  });
6724
8151
 
6725
- test('converts a local resource ZIP into a local PreparedPlan without matching a remote release', async () => {
6726
- const imagesBinary = new Uint8Array([1, 2, 3]).buffer;
6727
- const bootResourceBinary = new Uint8Array([4, 5, 6]).buffer;
6728
- const manifest = {
6729
- schema: 1,
6730
- artifact_name: 'pro2-resource',
6731
- release_name: 'local-development',
6732
- variant: 'resource',
6733
- commit: 'local',
6734
- short_sha: 'local',
6735
- timestamp_utc: '2026-08-09T00:00:00Z',
6736
- core_version: '1.0.0',
6737
- key_set: 'development',
6738
- device_root: 'vol0:',
6739
- restore_mode: 'bootloader_update',
6740
- trees: [{ path: 'bundles', device: 'pro2' }],
6741
- files: [
6742
- {
6743
- archive_path: 'bundles/images/images.okpkg',
6744
- original_name: 'images.okpkg',
6745
- device_path: 'vol0:/bundles/images/images.okpkg',
6746
- size: imagesBinary.byteLength,
6747
- sha256: bytesToHex(sha256(new Uint8Array(imagesBinary))),
6748
- signed: true,
6749
- sig_algo: 'ed25519',
6750
- payload_version: '1.0.0',
6751
- },
6752
- {
6753
- archive_path: 'loaders/bootloader/boot_resource.okpkg',
6754
- original_name: 'boot_resource.okpkg',
6755
- device_path: 'vol0:/loaders/bootloader/boot_resource.okpkg',
6756
- size: bootResourceBinary.byteLength,
6757
- sha256: bytesToHex(sha256(new Uint8Array(bootResourceBinary))),
6758
- signed: true,
6759
- sig_algo: 'ed25519',
6760
- payload_version: '1.0.0',
6761
- },
6762
- ],
6763
- };
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
+ });
6764
8158
  const zip = new JSZip();
6765
- zip.file('manifest.json', JSON.stringify(manifest));
6766
- zip.file('bundles/images/images.okpkg', imagesBinary);
6767
- zip.file('loaders/bootloader/boot_resource.okpkg', bootResourceBinary);
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');
6768
8163
  const resourceArchiveBinary = await zip.generateAsync({ type: 'arraybuffer' });
6769
8164
  const applicationP1Binary = new Uint8Array([7, 8, 9]).buffer;
6770
8165
  const bootloaderBinary = new Uint8Array([10, 11, 12]).buffer;
@@ -6804,16 +8199,7 @@ describe('Protocol V2 firmware update targets', () => {
6804
8199
  await expect(method.run()).resolves.toBe('local-resource-result');
6805
8200
  expect(releaseSpy).not.toHaveBeenCalled();
6806
8201
  expect(reloadSpy).not.toHaveBeenCalled();
6807
- expect((method as any).params.preparedPlan).toMatchObject({
6808
- networkPolicy: 'forbid',
6809
- executor: 'v4',
6810
- targetsToUpdate: ['app_v1', 'resource'],
6811
- });
6812
- expect(
6813
- (method as any).params.preparedPlan.artifacts.some(
6814
- (artifact: { target: string }) => artifact.target === 'boot'
6815
- )
6816
- ).toBe(false);
8202
+ expect((method as any).params.preparedPlan).toBeUndefined();
6817
8203
  expect((method as any).executeProtocolV2SourceUpdate).toHaveBeenCalledWith(
6818
8204
  expect.objectContaining({
6819
8205
  installSources: [
@@ -6829,7 +8215,7 @@ describe('Protocol V2 firmware update targets', () => {
6829
8215
  }),
6830
8216
  expect.objectContaining({
6831
8217
  name: 'boot_resource.okpkg',
6832
- devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg',
8218
+ devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg.staging',
6833
8219
  }),
6834
8220
  ],
6835
8221
  })
@@ -6840,51 +8226,59 @@ describe('Protocol V2 firmware update targets', () => {
6840
8226
  }
6841
8227
  });
6842
8228
 
6843
- test('rejects a local resource ZIP whose file hash does not match its manifest', async () => {
6844
- const resourceBinary = new Uint8Array([1, 2, 3]).buffer;
6845
- 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
+ });
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');
8238
+ zip.file(
8239
+ '__MACOSX/pro2-resource 2/bundles/._images.okpkg',
8240
+ new Uint8Array([0x00, 0x05, 0x16, 0x07])
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 });
6846
8279
  const zip = new JSZip();
6847
- zip.file(
6848
- 'manifest.json',
6849
- JSON.stringify({
6850
- schema: 1,
6851
- artifact_name: 'pro2-resource',
6852
- release_name: 'local-development',
6853
- variant: 'resource',
6854
- commit: 'local',
6855
- short_sha: 'local',
6856
- timestamp_utc: '2026-08-09T00:00:00Z',
6857
- core_version: '1.0.0',
6858
- key_set: 'development',
6859
- device_root: 'vol0:',
6860
- restore_mode: 'bootloader_update',
6861
- trees: [{ path: 'bundles', device: 'pro2' }],
6862
- files: [
6863
- {
6864
- archive_path: 'bundles/images/images.okpkg',
6865
- original_name: 'images.okpkg',
6866
- device_path: 'vol0:/bundles/images/images.okpkg',
6867
- size: resourceBinary.byteLength,
6868
- sha256: '0'.repeat(64),
6869
- signed: true,
6870
- sig_algo: 'ed25519',
6871
- payload_version: '1.0.0',
6872
- },
6873
- {
6874
- archive_path: 'loaders/bootloader/boot_resource.okpkg',
6875
- original_name: 'boot_resource.okpkg',
6876
- device_path: 'vol0:/loaders/bootloader/boot_resource.okpkg',
6877
- size: bootResourceBinary.byteLength,
6878
- sha256: bytesToHex(sha256(new Uint8Array(bootResourceBinary))),
6879
- signed: true,
6880
- sig_algo: 'ed25519',
6881
- payload_version: '1.0.0',
6882
- },
6883
- ],
6884
- })
6885
- );
6886
8280
  zip.file('bundles/images/images.okpkg', resourceBinary);
6887
- zip.file('loaders/bootloader/boot_resource.okpkg', bootResourceBinary);
8281
+ zip.file('copies/images.okpkg', duplicateResourceBinary);
6888
8282
  const method = new FirmwareUpdateV4({
6889
8283
  id: 1,
6890
8284
  payload: {
@@ -6899,9 +8293,7 @@ describe('Protocol V2 firmware update targets', () => {
6899
8293
  (method as any).prepareProtocolV2LocalResourceArchive(
6900
8294
  await zip.generateAsync({ type: 'arraybuffer' })
6901
8295
  )
6902
- ).rejects.toThrow(
6903
- 'Protocol V2 local resource file does not match manifest: bundles/images/images.okpkg'
6904
- );
8296
+ ).rejects.toThrow('duplicate device path: vol0:/bundles/images/images.okpkg');
6905
8297
  });
6906
8298
 
6907
8299
  test('maps malformed resource ZIPs to a typed firmware preparation error', async () => {
@@ -6930,18 +8322,24 @@ describe('Protocol V2 firmware update targets', () => {
6930
8322
 
6931
8323
  test('rejects an oversized ZIP entry before allocating its decompressed bytes', async () => {
6932
8324
  const extractEntry = jest.fn();
6933
- const loadSpy = jest.spyOn(JSZip, 'loadAsync').mockResolvedValue({
6934
- files: {
6935
- 'manifest.json': {
6936
- name: 'manifest.json',
6937
- dir: false,
6938
- _data: {
6939
- compressedSize: 1,
6940
- uncompressedSize: 2 * 1024 * 1024,
6941
- },
6942
- async: extractEntry,
6943
- },
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,
6944
8338
  },
8339
+ };
8340
+ const loadSpy = jest.spyOn(JSZip, 'loadAsync').mockResolvedValue({
8341
+ files: zipFiles,
8342
+ file: (name: string) => zipFiles[name as keyof typeof zipFiles] ?? null,
6945
8343
  } as unknown as JSZip);
6946
8344
  const method = new FirmwareUpdateV4({
6947
8345
  id: 1,
@@ -6956,7 +8354,7 @@ describe('Protocol V2 firmware update targets', () => {
6956
8354
  try {
6957
8355
  await expect(
6958
8356
  (method as any).prepareProtocolV2LocalResourceArchive(new Uint8Array([1]).buffer)
6959
- ).rejects.toThrow('declared size exceeds the allowed limit');
8357
+ ).rejects.toThrow('resource package size is invalid');
6960
8358
  expect(extractEntry).not.toHaveBeenCalled();
6961
8359
  } finally {
6962
8360
  loadSpy.mockRestore();
@@ -7108,17 +8506,17 @@ describe('Protocol V2 firmware update targets', () => {
7108
8506
  readAt: jest.fn(),
7109
8507
  close: jest.fn(),
7110
8508
  },
7111
- devicePath: 'vol0:/resource/images/images.okpkg',
8509
+ devicePath: 'vol0:/bundles/images/images.okpkg',
7112
8510
  },
7113
8511
  ],
7114
8512
  });
7115
8513
 
7116
8514
  expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenCalledTimes(1);
7117
8515
  expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenCalledWith(
7118
- expect.objectContaining({ filePath: 'vol0:/resource/images/images.okpkg' })
8516
+ expect.objectContaining({ filePath: 'vol0:/bundles/images/images.okpkg' })
7119
8517
  );
7120
8518
  expect((method as any).verifyProtocolV2StagedFile).toHaveBeenCalledWith(
7121
- 'vol0:/resource/images/images.okpkg',
8519
+ 'vol0:/bundles/images/images.okpkg',
7122
8520
  3
7123
8521
  );
7124
8522
  expect((method as any).protocolV2StartFirmwareUpdate).not.toHaveBeenCalled();
@@ -7137,6 +8535,9 @@ describe('Protocol V2 firmware update targets', () => {
7137
8535
  method.postProgressMessage = jest.fn();
7138
8536
  (method as any).protocolV2SourceUpdateProcess = jest.fn().mockResolvedValue(3);
7139
8537
  (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
8538
+ (method as any).ensureProtocolV2BootResourceStagingIsEmpty = jest
8539
+ .fn()
8540
+ .mockResolvedValue(undefined);
7140
8541
  (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
7141
8542
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
7142
8543
 
@@ -7150,7 +8551,7 @@ describe('Protocol V2 firmware update targets', () => {
7150
8551
  readAt: jest.fn(),
7151
8552
  close: jest.fn(),
7152
8553
  },
7153
- devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg',
8554
+ devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg.staging',
7154
8555
  },
7155
8556
  ],
7156
8557
  });
@@ -7162,7 +8563,7 @@ describe('Protocol V2 firmware update targets', () => {
7162
8563
  expect((method as any).verifyProtocolV2StagedFile).toHaveBeenCalledWith(stagingPath, 3);
7163
8564
  });
7164
8565
 
7165
- 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 () => {
7166
8567
  const method = new FirmwareUpdateV4({
7167
8568
  id: 1,
7168
8569
  payload: {
@@ -7183,7 +8584,7 @@ describe('Protocol V2 firmware update targets', () => {
7183
8584
  readAt: jest.fn(),
7184
8585
  close: jest.fn(),
7185
8586
  },
7186
- devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg',
8587
+ devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg.staging',
7187
8588
  version: [1, 0, 0],
7188
8589
  payloadHash: '11'.repeat(64),
7189
8590
  headerHash: '22'.repeat(64),
@@ -7194,12 +8595,62 @@ describe('Protocol V2 firmware update targets', () => {
7194
8595
  resourceSources: [resource],
7195
8596
  });
7196
8597
 
7197
- const stagingPath = 'vol0:/loaders/bootloader/boot_resource.okpkg.staging';
7198
- expect((method as any).isProtocolV2ResourceBundleUpToDate).not.toHaveBeenCalled();
7199
- expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenCalledWith(
7200
- 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'
7201
8617
  );
7202
- 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();
7203
8654
  });
7204
8655
 
7205
8656
  test('uses absolute processed_byte offsets and disables append for firmware file writes', async () => {
@@ -7249,7 +8700,7 @@ describe('Protocol V2 firmware update targets', () => {
7249
8700
  expect(writePayloads.map(payload => payload.file.data.byteLength)).toEqual([4000, 97]);
7250
8701
  expect(writePayloads.map(payload => payload.overwrite)).toEqual([true, false]);
7251
8702
  expect(writePayloads.every(payload => payload.append === false)).toBe(true);
7252
- expect(writePayloads.map(payload => payload.ui_percentage)).toEqual([0, 100]);
8703
+ expect(writePayloads.map(payload => payload.ui_percentage)).toEqual([0, 99]);
7253
8704
  expect(method.postMessage).toHaveBeenLastCalledWith({
7254
8705
  event: 'UI_EVENT',
7255
8706
  type: UI_REQUEST.FIRMWARE_PROGRESS,
@@ -7263,6 +8714,176 @@ describe('Protocol V2 firmware update targets', () => {
7263
8714
  });
7264
8715
  });
7265
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
+
7266
8887
  // TODO(#850/#855): PR #855 added resume-on-retry and per-chunk retry on the
7267
8888
  // writeProtocolV2File path. PR #850 replaced that path with FirmwareByteSource
7268
8889
  // streaming (protocolV2SourceUpdateProcess), which restarts a failed transfer from
@@ -7570,7 +9191,7 @@ describe('Protocol V2 firmware update targets', () => {
7570
9191
  const writePayloads = typedCall.mock.calls.map(call => call[2]);
7571
9192
  expect(writePayloads.map(payload => payload.file.offset)).toEqual([0, 1800]);
7572
9193
  expect(writePayloads.map(payload => payload.file.data.byteLength)).toEqual([1800, 1]);
7573
- expect(writePayloads.map(payload => payload.ui_percentage)).toEqual([0, 100]);
9194
+ expect(writePayloads.map(payload => payload.ui_percentage)).toEqual([0, 99]);
7574
9195
  expect(typedCall.mock.calls.map(call => call[3])).toEqual([
7575
9196
  expect.objectContaining({
7576
9197
  writeWithResponse: false,
@@ -7629,45 +9250,67 @@ describe('Protocol V2 firmware update targets', () => {
7629
9250
  expect(writePayloads.map(payload => payload.file.data.byteLength)).toEqual([1960, 1]);
7630
9251
  });
7631
9252
 
7632
- 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 () => {
7633
9254
  const method = new FirmwareUpdateV4({
7634
9255
  id: 1,
7635
9256
  payload: {
7636
9257
  method: 'firmwareUpdateV4',
7637
9258
  },
7638
9259
  });
7639
- let resolveRequest: ((value: unknown) => void) | undefined;
7640
- const typedCall = jest.fn().mockImplementation(
7641
- () =>
7642
- new Promise(resolve => {
7643
- resolveRequest = resolve;
7644
- })
7645
- );
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();
7646
9269
 
7647
9270
  (method as any).device = stubDevice({
7648
- getCommands: () => ({ typedCall }),
9271
+ getCommands: () => ({ typedCall, call, cancelDevice: jest.fn() }),
9272
+ createProtocolV2UiPhaseMetadata: jest.fn().mockReturnValue(undefined),
9273
+ toMessageObject: jest.fn().mockReturnValue({ connectId: 'pro2' }),
9274
+ clearCancelableAction,
7649
9275
  });
9276
+ method.postMessage = jest.fn();
7650
9277
  method.postProgressMessage = jest.fn();
7651
9278
  method.postTipMessage = jest.fn();
7652
9279
 
7653
9280
  const startPromise = (method as any).protocolV2StartFirmwareUpdate({
7654
9281
  targets: [{ target_id: 4, path: 'vol1:firmware.bin' }],
7655
9282
  });
7656
- await Promise.resolve();
9283
+ await new Promise<void>(resolve => {
9284
+ setImmediate(resolve);
9285
+ });
7657
9286
 
7658
- expect(typedCall.mock.calls[0][1]).toBe('Success');
7659
- 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
+ );
7660
9301
  expect(method.postTipMessage).not.toHaveBeenCalled();
7661
9302
  expect(method.postProgressMessage).not.toHaveBeenCalled();
9303
+ expect(clearCancelableAction).not.toHaveBeenCalled();
7662
9304
 
7663
- resolveRequest?.({ type: 'Success', message: { message: 'ok' } });
7664
9305
  await startPromise;
7665
- expect(method.postTipMessage).toHaveBeenCalledWith('FirmwareUpdating');
7666
- expect(method.postProgressMessage).toHaveBeenCalledWith(0, 'installingFirmware');
7667
- 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();
7668
9311
  });
7669
9312
 
7670
- 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 () => {
7671
9314
  const method = new FirmwareUpdateV4({
7672
9315
  id: 1,
7673
9316
  payload: {
@@ -7678,10 +9321,17 @@ describe('Protocol V2 firmware update targets', () => {
7678
9321
  type: 'Success',
7679
9322
  message: { message: 'accepted' },
7680
9323
  });
9324
+ const call = jest.fn().mockResolvedValue({
9325
+ type: 'WriteCompleted',
9326
+ message: {},
9327
+ });
7681
9328
 
7682
9329
  (method as any).device = stubDevice({
7683
- getCommands: () => ({ typedCall }),
9330
+ getCommands: () => ({ typedCall, call, cancelDevice: jest.fn() }),
9331
+ createProtocolV2UiPhaseMetadata: jest.fn().mockReturnValue(undefined),
9332
+ toMessageObject: jest.fn().mockReturnValue({ connectId: 'pro2' }),
7684
9333
  });
9334
+ method.postMessage = jest.fn();
7685
9335
  method.postProgressMessage = jest.fn();
7686
9336
  method.postTipMessage = jest.fn();
7687
9337
 
@@ -7689,13 +9339,23 @@ describe('Protocol V2 firmware update targets', () => {
7689
9339
  targets: [{ target_id: 4, path: 'vol1:firmware.bin' }],
7690
9340
  });
7691
9341
 
7692
- expect(typedCall.mock.calls[0][1]).toBe('Success');
7693
- 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();
7694
9354
  });
7695
9355
  });
7696
9356
 
7697
9357
  describe('Protocol V2 firmware update method', () => {
7698
- test('returns DeviceFirmwareUpdateStatus from low-level update trigger', async () => {
9358
+ test('stages targets before sending the empty low-level update trigger', async () => {
7699
9359
  const method = new DeviceFirmwareUpdate({
7700
9360
  id: 1,
7701
9361
  payload: {
@@ -7706,22 +9366,25 @@ describe('Protocol V2 firmware update method', () => {
7706
9366
  });
7707
9367
  method.init();
7708
9368
 
7709
- const typedCall = jest.fn().mockResolvedValue({
7710
- type: 'DeviceFirmwareUpdateStatus',
7711
- message: { records: [{ target_id: 4, status: 1 }] },
7712
- });
9369
+ const typedCall = jest.fn().mockResolvedValue({ type: 'Success', message: {} });
9370
+ const call = jest.fn().mockResolvedValue({ type: 'WriteCompleted', message: {} });
7713
9371
 
7714
9372
  (method as any).device = stubDevice({
7715
- commands: { typedCall },
9373
+ commands: { typedCall, call },
7716
9374
  });
7717
9375
 
7718
9376
  await expect(method.run()).resolves.toEqual({
7719
- records: [{ target_id: 4, status: 1 }],
9377
+ message: 'Device firmware update started',
7720
9378
  });
7721
- expect(typedCall.mock.calls[0][1]).toEqual(['Success', 'DeviceFirmwareUpdateStatus']);
7722
- expect(typedCall.mock.calls[0][2]).toEqual({
9379
+ expect(typedCall).toHaveBeenCalledWith('DeviceFirmwareUpdateStage', 'Success', {
7723
9380
  targets: [{ target_id: 4, path: 'vol0:firmware.bin' }],
7724
9381
  });
9382
+ expect(call).toHaveBeenCalledWith(
9383
+ 'DeviceFirmwareUpdateRequest',
9384
+ {},
9385
+ { returnAfterWrite: true }
9386
+ );
9387
+ expect(typedCall.mock.invocationCallOrder[0]).toBeLessThan(call.mock.invocationCallOrder[0]);
7725
9388
  });
7726
9389
 
7727
9390
  test('treats WebUSB open NotFoundError from low-level update trigger as expected disconnect', async () => {
@@ -7820,6 +9483,7 @@ describe('Protocol V2 firmware update method', () => {
7820
9483
 
7821
9484
  test('accepts targetId alias inside firmware targets', async () => {
7822
9485
  const typedCall = jest.fn().mockResolvedValue({ message: {} });
9486
+ const call = jest.fn().mockResolvedValue({ type: 'WriteCompleted', message: {} });
7823
9487
  const method = new DeviceFirmwareUpdate({
7824
9488
  id: 1,
7825
9489
  payload: {
@@ -7835,7 +9499,7 @@ describe('Protocol V2 firmware update method', () => {
7835
9499
  });
7836
9500
  method.init();
7837
9501
  (method as any).device = stubDevice({
7838
- commands: { typedCall },
9502
+ commands: { typedCall, call },
7839
9503
  });
7840
9504
 
7841
9505
  await method.run();
@@ -7846,6 +9510,7 @@ describe('Protocol V2 firmware update method', () => {
7846
9510
 
7847
9511
  test('accepts firmware target enum names from low-level update params', async () => {
7848
9512
  const typedCall = jest.fn().mockResolvedValue({ message: {} });
9513
+ const call = jest.fn().mockResolvedValue({ type: 'WriteCompleted', message: {} });
7849
9514
  const method = new DeviceFirmwareUpdate({
7850
9515
  id: 1,
7851
9516
  payload: {
@@ -7856,7 +9521,7 @@ describe('Protocol V2 firmware update method', () => {
7856
9521
  });
7857
9522
  method.init();
7858
9523
  (method as any).device = stubDevice({
7859
- commands: { typedCall },
9524
+ commands: { typedCall, call },
7860
9525
  });
7861
9526
 
7862
9527
  await method.run();
@@ -7868,7 +9533,16 @@ describe('Protocol V2 firmware update method', () => {
7868
9533
  test('passes firmware update status fields through to Protocol V2', async () => {
7869
9534
  const typedCall = jest.fn().mockResolvedValue({
7870
9535
  message: {
7871
- 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
+ ],
7872
9546
  },
7873
9547
  });
7874
9548
  const method = new DeviceGetFirmwareUpdateStatus({
@@ -7877,6 +9551,8 @@ describe('Protocol V2 firmware update method', () => {
7877
9551
  method: 'deviceGetFirmwareUpdateStatus',
7878
9552
  fields: {
7879
9553
  status: true,
9554
+ progress_percent: true,
9555
+ phase_info: true,
7880
9556
  payload_version: true,
7881
9557
  path: true,
7882
9558
  },
@@ -7888,7 +9564,16 @@ describe('Protocol V2 firmware update method', () => {
7888
9564
  });
7889
9565
 
7890
9566
  await expect(method.run()).resolves.toEqual({
7891
- 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
+ ],
7892
9577
  });
7893
9578
  expect(typedCall).toHaveBeenCalledWith(
7894
9579
  'DeviceFirmwareUpdateStatusGet',
@@ -7896,6 +9581,8 @@ describe('Protocol V2 firmware update method', () => {
7896
9581
  {
7897
9582
  fields: {
7898
9583
  status: true,
9584
+ progress_percent: true,
9585
+ phase_info: true,
7899
9586
  payload_version: true,
7900
9587
  path: true,
7901
9588
  },
@@ -8088,7 +9775,7 @@ describe('Protocol V2 firmware reconnect identity', () => {
8088
9775
  const readChunkLengths: number[] = [];
8089
9776
  const typedCall = jest.fn((requestType: string, _responseType: string, request: any) => {
8090
9777
  if (requestType === 'FilesystemPathInfoQuery') {
8091
- return Promise.resolve({ message: { exist: true, directory: false, size: 0x52a0 } });
9778
+ return Promise.resolve({ message: { exist: true, directory: false, size: 0x5f91 } });
8092
9779
  }
8093
9780
  if (requestType === 'FilesystemFileRead') {
8094
9781
  readChunkLengths.push(request.chunk_len);
@@ -8255,6 +9942,8 @@ describe('Protocol V2 reboot methods', () => {
8255
9942
  const device = stubDevice({ commands: { typedCall } });
8256
9943
  (method as any).device = device;
8257
9944
 
9945
+ expect(method.unlockPolicy).toBe('unlock-before-run');
9946
+
8258
9947
  await method.run();
8259
9948
 
8260
9949
  expect(typedCall).toHaveBeenCalledWith('DeviceReboot', 'Success', {
@@ -8584,6 +10273,29 @@ describe('Protocol V2 protected method execution', () => {
8584
10273
  }
8585
10274
  );
8586
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
+
8587
10299
  test('keeps Protocol V1 and lock-free methods outside the preflight path', async () => {
8588
10300
  for (const [isProtocolV2, unlockPolicy] of [
8589
10301
  [false, 'unlock-before-run'],
@@ -8763,7 +10475,7 @@ describe('Protocol V2 current low-level methods', () => {
8763
10475
  info: {
8764
10476
  version: undefined,
8765
10477
  serial_number: 'PR2SERIAL',
8766
- burn_in_completed: true,
10478
+ factory_burn_in_completed: true,
8767
10479
  factory_test_completed: undefined,
8768
10480
  manufacture_time: undefined,
8769
10481
  },
@@ -9013,7 +10725,7 @@ describe('Protocol V2 raw device info method', () => {
9013
10725
  'DeviceInfoGet',
9014
10726
  'DeviceInfo',
9015
10727
  {
9016
- targets: { hw: true, fw: true, coprocessor: true },
10728
+ targets: { hw: true, main_mcu: true, coprocessor: true },
9017
10729
  types: { version: true, specific: true },
9018
10730
  },
9019
10731
  { timeoutMs: PROTOCOL_V2_DEVICE_INFO_TIMEOUT_MS }