@onekeyfe/hd-core 1.2.0-alpha.99 → 1.2.1-alpha.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 +979 -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 +2199 -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 +20 -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 +2150 -1469
  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 +934 -739
  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,903 @@ 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
+ expect((method as any).protocolV2InstallDisconnectObserved).toBe(true);
5690
+ });
5691
+
5692
+ test.each([
5693
+ { name: 'bootloader', targetId: 3, path: 'vol0:/bootloader.bin' },
5694
+ { name: 'application', targetId: 4, path: 'vol0:/application_p1.bin' },
5695
+ { name: 'secure element', targetId: 7, path: 'vol0:/se01.bin' },
5696
+ ])(
5697
+ 'reconnects a released BLE install link before polling $name status without DeviceInfo',
5698
+ async ({ targetId, path }) => {
5699
+ const method = new FirmwareUpdateV4({
5700
+ id: 1,
5701
+ payload: {
5702
+ method: 'firmwareUpdateV4',
5703
+ },
5704
+ });
5705
+ const targets = [{ target_id: targetId, path }];
5706
+ const typedCall = jest
5707
+ .fn()
5708
+ .mockResolvedValueOnce({
5709
+ type: 'DeviceFirmwareUpdateStatus',
5710
+ message: { records: [{ ...targets[0], status: 1, payload_version: 0 }] },
5711
+ })
5712
+ .mockResolvedValueOnce({
5713
+ type: 'DeviceFirmwareUpdateStatus',
5714
+ message: { records: [{ ...targets[0], status: 2, payload_version: 0x010000 }] },
5715
+ });
5716
+ const reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
5717
+ const verifyProtocolV2ReconnectIdentity = jest.fn();
5718
+ const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation(((
5719
+ callback: () => void
5720
+ ) => {
5721
+ callback();
5722
+ return 0 as any;
5723
+ }) as typeof setTimeout);
5724
+
5725
+ (method as any).device = stubDevice({
5726
+ getCommands: () => ({ typedCall }),
5727
+ });
5728
+ (method as any).isBleReconnect = jest.fn(() => true);
5729
+ (method as any).protocolV2InstallNeedsReconnect = true;
5730
+ (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
5731
+ (method as any).verifyProtocolV2ReconnectIdentity = verifyProtocolV2ReconnectIdentity;
5732
+ method.postProgressMessage = jest.fn();
5733
+
5734
+ try {
5735
+ await expect(
5736
+ (method as any).waitForProtocolV2FirmwareUpdateComplete(targets, true)
5737
+ ).resolves.toBeUndefined();
5738
+ } finally {
5739
+ setTimeoutSpy.mockRestore();
5740
+ }
5741
+
5742
+ expect(reconnectProtocolV2Device).toHaveBeenCalledWith({ skipProtocolProbe: true });
5743
+ expect(verifyProtocolV2ReconnectIdentity).not.toHaveBeenCalled();
5744
+ expect(typedCall.mock.calls.map(callArgs => callArgs[0])).toEqual([
5745
+ 'DeviceFirmwareUpdateStatusGet',
5746
+ 'DeviceFirmwareUpdateStatusGet',
5747
+ ]);
5748
+ }
5749
+ );
5750
+
5751
+ test('polls only firmware status while Protocol V2 bootloader is installing', async () => {
5752
+ const method = new FirmwareUpdateV4({
5753
+ id: 1,
5754
+ payload: {
5755
+ method: 'firmwareUpdateV4',
5756
+ },
5757
+ });
5758
+ const typedCall = jest
5759
+ .fn()
5760
+ .mockResolvedValueOnce({
5761
+ type: 'DeviceFirmwareUpdateStatus',
5762
+ message: { records: [{ target_id: 4, status: 1 }] },
5763
+ })
5764
+ .mockResolvedValueOnce({
5765
+ type: 'DeviceFirmwareUpdateStatus',
5766
+ message: { records: [{ target_id: 4, status: 2 }] },
5767
+ });
5768
+ const reconnectProtocolV2Device = jest.fn();
5769
+ const verifyProtocolV2ReconnectIdentity = jest.fn();
5770
+
5771
+ (method as any).device = stubDevice({
5772
+ getCommands: () => ({ typedCall }),
5773
+ });
5774
+ (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
5775
+ (method as any).verifyProtocolV2ReconnectIdentity = verifyProtocolV2ReconnectIdentity;
5776
+ method.postProgressMessage = jest.fn();
5777
+ method.postTipMessage = jest.fn();
5778
+
5779
+ await (method as any).waitForProtocolV2FirmwareUpdateComplete([
5780
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
5781
+ ]);
5782
+
5783
+ expect(reconnectProtocolV2Device).not.toHaveBeenCalled();
5784
+ expect(verifyProtocolV2ReconnectIdentity).not.toHaveBeenCalled();
5785
+ expect(typedCall.mock.calls.map(call => call[0])).toEqual([
5786
+ 'DeviceFirmwareUpdateStatusGet',
5787
+ 'DeviceFirmwareUpdateStatusGet',
5788
+ ]);
5789
+ expect(typedCall.mock.calls.map(call => call[0])).not.toContain('DeviceStatusGet');
5790
+ expect(method.postProgressMessage).toHaveBeenCalledWith(1, 'installingFirmware');
5791
+ expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
5792
+ });
5793
+
5794
+ test('keeps polling after an empty status response until the target finishes', async () => {
5795
+ const method = new FirmwareUpdateV4({
5796
+ id: 1,
5797
+ payload: {
5798
+ method: 'firmwareUpdateV4',
5799
+ },
5800
+ });
5801
+ const typedCall = jest
5802
+ .fn()
5803
+ .mockResolvedValueOnce({
5804
+ type: 'DeviceFirmwareUpdateStatus',
5805
+ message: { records: [] },
5806
+ })
5807
+ .mockResolvedValueOnce({
5808
+ type: 'DeviceFirmwareUpdateStatus',
5809
+ message: { records: [] },
5810
+ })
5811
+ .mockResolvedValueOnce({
5812
+ type: 'DeviceFirmwareUpdateStatus',
5813
+ message: { records: [{ target_id: 4, status: 0 }] },
5814
+ })
5815
+ .mockResolvedValueOnce({
5816
+ type: 'DeviceFirmwareUpdateStatus',
5817
+ message: { records: [{ target_id: 4, status: 2 }] },
5818
+ });
5819
+ const reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
5820
+ const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation(((
5821
+ callback: () => void
5822
+ ) => {
5823
+ callback();
5824
+ return 0 as any;
5825
+ }) as typeof setTimeout);
5826
+
5827
+ (method as any).device = stubDevice({
5828
+ getCommands: () => ({ typedCall }),
5829
+ });
5830
+ (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
5831
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(undefined);
5832
+ method.postProgressMessage = jest.fn();
5833
+
5834
+ try {
5835
+ await (method as any).waitForProtocolV2FirmwareUpdateComplete([
5836
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
5837
+ ]);
5838
+ } finally {
5839
+ setTimeoutSpy.mockRestore();
5840
+ }
5841
+
5842
+ expect(reconnectProtocolV2Device).not.toHaveBeenCalled();
5843
+ expect(typedCall.mock.calls.map(call => call[0])).toEqual([
5844
+ 'DeviceFirmwareUpdateStatusGet',
5845
+ 'DeviceFirmwareUpdateStatusGet',
5846
+ 'DeviceFirmwareUpdateStatusGet',
5847
+ 'DeviceFirmwareUpdateStatusGet',
5848
+ ]);
5849
+ expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
5850
+ });
5851
+
5852
+ test('keeps waiting while firmware status records are empty before user confirmation', async () => {
5853
+ const method = new FirmwareUpdateV4({
5854
+ id: 1,
5855
+ payload: {
5856
+ method: 'firmwareUpdateV4',
5857
+ },
5858
+ });
5859
+ const typedCall = jest
5860
+ .fn()
5861
+ .mockResolvedValueOnce({
5862
+ type: 'DeviceFirmwareUpdateStatus',
5863
+ message: { records: [] },
5864
+ })
5865
+ .mockResolvedValueOnce({
5866
+ type: 'DeviceFirmwareUpdateStatus',
5867
+ message: { records: [{ target_id: 4, status: 0 }] },
5868
+ })
5869
+ .mockResolvedValueOnce({
5870
+ type: 'DeviceFirmwareUpdateStatus',
5871
+ message: { records: [{ target_id: 4, status: 2 }] },
5872
+ });
5873
+ const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation(((
5874
+ callback: () => void
5875
+ ) => {
5876
+ callback();
5877
+ return 0 as any;
5878
+ }) as typeof setTimeout);
5879
+
5880
+ (method as any).device = stubDevice({
5881
+ getCommands: () => ({ typedCall }),
5882
+ });
5883
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
5884
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(undefined);
5885
+ method.postProgressMessage = jest.fn();
5886
+
5887
+ try {
5888
+ await (method as any).waitForProtocolV2FirmwareUpdateComplete(
5889
+ [{ target_id: 4, path: 'vol0:/application_p1.bin' }],
5890
+ true
5891
+ );
5892
+ } finally {
5893
+ setTimeoutSpy.mockRestore();
5894
+ }
5895
+
5896
+ expect(typedCall).toHaveBeenCalledTimes(3);
5897
+ expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
5898
+ });
5899
+
5900
+ test('ignores stale finished records until the current install starts', async () => {
5901
+ const method = new FirmwareUpdateV4({
5902
+ id: 1,
5903
+ payload: {
5904
+ method: 'firmwareUpdateV4',
5905
+ },
5906
+ });
5907
+ const typedCall = jest
5908
+ .fn()
5909
+ .mockResolvedValueOnce({
5910
+ type: 'DeviceFirmwareUpdateStatus',
5911
+ message: { records: [{ target_id: 4, status: 2, path: 'vol0:/application_p1.bin' }] },
5912
+ })
5913
+ .mockResolvedValueOnce({
5914
+ type: 'DeviceFirmwareUpdateStatus',
5915
+ message: { records: [{ target_id: 4, status: 0, path: 'vol0:/application_p1.bin' }] },
5916
+ })
5917
+ .mockResolvedValueOnce({
5918
+ type: 'DeviceFirmwareUpdateStatus',
5919
+ message: { records: [{ target_id: 4, status: 2, path: 'vol0:/application_p1.bin' }] },
5920
+ });
5921
+ const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation(((
5922
+ callback: () => void
5923
+ ) => {
5924
+ callback();
5925
+ return 0 as any;
5926
+ }) as typeof setTimeout);
5927
+
5928
+ (method as any).device = stubDevice({
5929
+ getCommands: () => ({ typedCall }),
5930
+ });
5931
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
5932
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(undefined);
5933
+ method.postProgressMessage = jest.fn();
5934
+
5935
+ try {
5936
+ await (method as any).waitForProtocolV2FirmwareUpdateComplete(
5937
+ [{ target_id: 4, path: 'vol0:/application_p1.bin' }],
5938
+ true
5939
+ );
5940
+ } finally {
5941
+ setTimeoutSpy.mockRestore();
5942
+ }
5943
+
5944
+ expect(typedCall).toHaveBeenCalledTimes(3);
5945
+ expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
5946
+ });
5947
+
5948
+ test('does not count a stale finished target when only another target was live', () => {
5949
+ const method = new FirmwareUpdateV4({
5950
+ id: 1,
5951
+ payload: {
5952
+ method: 'firmwareUpdateV4',
5953
+ },
5954
+ });
5955
+ const assertProtocolV2TargetStatus = (method as any).assertProtocolV2TargetStatus.bind(method);
5956
+ const expectedTargetIds = new Set([3, 4]);
5957
+ const expectedPaths = new Map([
5958
+ [3, 'vol0:/bootloader.bin'],
5959
+ [4, 'vol0:/application_p1.bin'],
5960
+ ]);
5961
+ const liveTargetIds = new Set([4]);
5962
+ method.postProgressMessage = jest.fn();
5963
+
5964
+ const completed = assertProtocolV2TargetStatus(
5965
+ [
5966
+ { target_id: 3, status: 2, path: 'vol0:/bootloader.bin' },
5967
+ { target_id: 4, status: 2, path: 'vol0:/application_p1.bin' },
5968
+ ],
5969
+ expectedTargetIds,
5970
+ expectedPaths,
5971
+ liveTargetIds
5972
+ );
5973
+
5974
+ expect(completed).toBe(false);
5975
+ expect((method as any).protocolV2CompletedTargetIds).toEqual(new Set([4]));
5976
+ expect(method.postProgressMessage).toHaveBeenCalledWith(50, 'installingFirmware');
5977
+ expect(method.postProgressMessage).not.toHaveBeenCalledWith(100, 'installingFirmware');
5978
+ });
5979
+
5980
+ test('ignores stale failed records until the current install starts', async () => {
5981
+ const method = new FirmwareUpdateV4({
5982
+ id: 1,
5983
+ payload: {
5984
+ method: 'firmwareUpdateV4',
5985
+ },
5986
+ });
5987
+ const typedCall = jest
5988
+ .fn()
5989
+ .mockResolvedValueOnce({
5990
+ type: 'DeviceFirmwareUpdateStatus',
5991
+ message: { records: [{ target_id: 4, status: 3 }] },
5992
+ })
5993
+ .mockResolvedValueOnce({
5994
+ type: 'DeviceFirmwareUpdateStatus',
5995
+ message: { records: [{ target_id: 4, status: 0 }] },
5996
+ })
5997
+ .mockResolvedValueOnce({
5998
+ type: 'DeviceFirmwareUpdateStatus',
5999
+ message: { records: [{ target_id: 4, status: 3 }] },
6000
+ });
6001
+ const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation(((
6002
+ callback: () => void
6003
+ ) => {
6004
+ callback();
6005
+ return 0 as any;
6006
+ }) as typeof setTimeout);
6007
+
6008
+ (method as any).device = stubDevice({
6009
+ getCommands: () => ({ typedCall }),
6010
+ });
6011
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
6012
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(undefined);
6013
+
6014
+ try {
6015
+ await expect(
6016
+ (method as any).waitForProtocolV2FirmwareUpdateComplete(
6017
+ [{ target_id: 4, path: 'vol0:/application_p1.bin' }],
6018
+ true
6019
+ )
6020
+ ).rejects.toMatchObject({ errorCode: HardwareErrorCode.FirmwareError });
6021
+ } finally {
6022
+ setTimeoutSpy.mockRestore();
6023
+ }
6024
+
6025
+ expect(typedCall).toHaveBeenCalledTimes(3);
6026
+ });
6027
+
6028
+ test('ignores leftover failed records while another leftover target is still pending', async () => {
6029
+ const method = new FirmwareUpdateV4({
6030
+ id: 1,
6031
+ payload: {
6032
+ method: 'firmwareUpdateV4',
6033
+ },
6034
+ });
6035
+ const typedCall = jest
6036
+ .fn()
6037
+ .mockResolvedValueOnce({
6038
+ type: 'DeviceFirmwareUpdateStatus',
6039
+ message: {
6040
+ records: [
6041
+ { target_id: 3, status: 0, path: 'vol0:/bootloader.bin' },
6042
+ { target_id: 4, status: 3, path: 'vol0:/application_p1.bin' },
6043
+ ],
6044
+ },
6045
+ })
6046
+ .mockResolvedValueOnce({
6047
+ type: 'DeviceFirmwareUpdateStatus',
6048
+ message: {
6049
+ records: [
6050
+ { target_id: 3, status: 0, path: 'vol0:/bootloader.bin' },
6051
+ { target_id: 4, status: 0, path: 'vol0:/application_p1.bin' },
6052
+ ],
6053
+ },
6054
+ })
6055
+ .mockResolvedValueOnce({
6056
+ type: 'DeviceFirmwareUpdateStatus',
6057
+ message: {
6058
+ records: [
6059
+ { target_id: 3, status: 2, path: 'vol0:/bootloader.bin' },
6060
+ { target_id: 4, status: 2, path: 'vol0:/application_p1.bin' },
6061
+ ],
6062
+ },
6063
+ });
6064
+ const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation(((
6065
+ callback: () => void
6066
+ ) => {
6067
+ callback();
6068
+ return 0 as any;
6069
+ }) as typeof setTimeout);
6070
+
6071
+ (method as any).device = stubDevice({
6072
+ getCommands: () => ({ typedCall }),
6073
+ });
6074
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
6075
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(undefined);
6076
+ method.postProgressMessage = jest.fn();
6077
+
6078
+ try {
6079
+ await (method as any).waitForProtocolV2FirmwareUpdateComplete(
6080
+ [
6081
+ { target_id: 3, path: 'vol0:/bootloader.bin' },
6082
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
6083
+ ],
6084
+ true
6085
+ );
6086
+ } finally {
6087
+ setTimeoutSpy.mockRestore();
6088
+ }
6089
+
6090
+ expect(typedCall).toHaveBeenCalledTimes(3);
6091
+ expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
6092
+ });
6093
+
6094
+ test('reports failed only after that target was live in the current install', async () => {
6095
+ const method = new FirmwareUpdateV4({
6096
+ id: 1,
6097
+ payload: {
6098
+ method: 'firmwareUpdateV4',
6099
+ },
6100
+ });
6101
+ const typedCall = jest
6102
+ .fn()
6103
+ .mockResolvedValueOnce({
6104
+ type: 'DeviceFirmwareUpdateStatus',
6105
+ message: {
6106
+ records: [
6107
+ { target_id: 3, status: 0, path: 'vol0:/bootloader.bin' },
6108
+ { target_id: 4, status: 3, path: 'vol0:/application_p1.bin' },
6109
+ ],
6110
+ },
6111
+ })
6112
+ .mockResolvedValueOnce({
6113
+ type: 'DeviceFirmwareUpdateStatus',
6114
+ message: {
6115
+ records: [
6116
+ { target_id: 3, status: 0, path: 'vol0:/bootloader.bin' },
6117
+ { target_id: 4, status: 0, path: 'vol0:/application_p1.bin' },
6118
+ ],
6119
+ },
6120
+ })
6121
+ .mockResolvedValueOnce({
6122
+ type: 'DeviceFirmwareUpdateStatus',
6123
+ message: {
6124
+ records: [
6125
+ { target_id: 3, status: 0, path: 'vol0:/bootloader.bin' },
6126
+ { target_id: 4, status: 3, path: 'vol0:/application_p1.bin' },
6127
+ ],
6128
+ },
6129
+ });
6130
+ const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation(((
6131
+ callback: () => void
6132
+ ) => {
6133
+ callback();
6134
+ return 0 as any;
6135
+ }) as typeof setTimeout);
6136
+
6137
+ (method as any).device = stubDevice({
6138
+ getCommands: () => ({ typedCall }),
6139
+ });
6140
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
6141
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(undefined);
6142
+
6143
+ try {
6144
+ await expect(
6145
+ (method as any).waitForProtocolV2FirmwareUpdateComplete(
6146
+ [
6147
+ { target_id: 3, path: 'vol0:/bootloader.bin' },
6148
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
6149
+ ],
6150
+ true
6151
+ )
6152
+ ).rejects.toMatchObject({
6153
+ errorCode: HardwareErrorCode.FirmwareError,
6154
+ message: 'Protocol V2 firmware install failed',
6155
+ });
6156
+ } finally {
6157
+ setTimeoutSpy.mockRestore();
6158
+ }
6159
+
6160
+ expect(typedCall).toHaveBeenCalledTimes(3);
6161
+ });
6162
+
6163
+ test('waits five minutes before rejecting normal mode without install evidence', async () => {
6164
+ const method = new FirmwareUpdateV4({
6165
+ id: 1,
6166
+ payload: {
6167
+ method: 'firmwareUpdateV4',
6168
+ },
6169
+ });
6170
+ const typedCall = jest
6171
+ .fn()
6172
+ .mockRejectedValue(new Error('Failure: Handler not registered for this message'));
6173
+ const deviceInfo = { hw: { serial_no: 'PRO2-PHYSICAL-1' } };
6174
+ let now = 0;
6175
+ jest.spyOn(Date, 'now').mockImplementation(() => now);
6176
+ jest.spyOn(global, 'setTimeout').mockImplementation(((callback: () => void) => {
6177
+ now += 60 * 1000;
6178
+ callback();
6179
+ return 0 as any;
6180
+ }) as typeof setTimeout);
6181
+
6182
+ (method as any).device = stubDevice({
6183
+ getCommands: () => ({ typedCall }),
6184
+ });
6185
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
6186
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(deviceInfo);
6187
+ (method as any).probeProtocolV2NormalMode = jest.fn().mockResolvedValue(true);
6188
+ method.postProgressMessage = jest.fn();
6189
+
6190
+ await expect(
6191
+ (method as any).waitForProtocolV2FirmwareUpdateComplete([
6192
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
6193
+ ])
6194
+ ).rejects.toMatchObject({
6195
+ errorCode: HardwareErrorCode.FirmwareError,
6196
+ message: 'Protocol V2 firmware install timed out',
6197
+ params: { firmwareUpdateCode: 'FirmwareInstallTimeout' },
6198
+ });
6199
+
6200
+ expect(method.postProgressMessage).not.toHaveBeenCalledWith(100, 'installingFirmware');
6201
+ expect(typedCall).toHaveBeenCalledTimes(5);
6202
+ });
6203
+
6204
+ test('preserves multi-app completion evidence when App mode replaces the status endpoint', async () => {
6205
+ const method = new FirmwareUpdateV4({
6206
+ id: 1,
6207
+ payload: {
6208
+ method: 'firmwareUpdateV4',
6209
+ targetsToUpdate: ['app_v1', 'app_v2'],
6210
+ expectedTargetVersions: {
6211
+ app_v1: '2.0.0',
6212
+ app_v2: '2.0.0',
6213
+ },
6214
+ },
6215
+ });
6216
+ const typedCall = jest
6217
+ .fn()
6218
+ .mockRejectedValue(new Error('Failure: Handler not registered for this message'));
6219
+ const deviceInfo = { hw: { serial_no: 'PRO2-PHYSICAL-1' } };
6220
+ const probeProtocolV2RuntimeState = jest.fn().mockResolvedValue({
6221
+ mode: 'normal',
6222
+ bootloaderMode: false,
6223
+ firmwareVersion: '2.0.0',
6224
+ });
6225
+
6226
+ (method as any).device = stubDevice({
6227
+ getCommands: () => ({ typedCall }),
6228
+ probeProtocolV2RuntimeState,
6229
+ });
6230
+ (method as any).protocolV2InstallBaselineVersions = new Map([
6231
+ [4, '1.0.0'],
6232
+ [5, '1.0.0'],
6233
+ ]);
6234
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
6235
+ (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(deviceInfo);
6236
+ method.postProgressMessage = jest.fn();
6237
+
6238
+ await expect(
6239
+ (method as any).waitForProtocolV2FirmwareUpdateComplete([
6240
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
6241
+ { target_id: 5, path: 'vol0:/application_p2.bin' },
6242
+ ])
6243
+ ).resolves.toBeUndefined();
6244
+
6245
+ expect(probeProtocolV2RuntimeState).toHaveBeenCalledWith(deviceInfo, 5000);
6246
+ expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
6247
+ expect(Array.from((method as any).protocolV2CompletedTargetIds)).toEqual([4, 5]);
6248
+
6249
+ (method as any).protocolV2LatestFinalFeatures = {
6250
+ firmwareVersion: '2.0.0',
6251
+ };
6252
+ (method as any).protocolV2LatestFinalDeviceInfo = {
6253
+ main_mcu: {
6254
+ application: { version: '2.0.0' },
6255
+ },
6256
+ };
6257
+ expect(() => (method as any).assertExpectedProtocolV2Versions()).not.toThrow();
6258
+ });
6259
+
6260
+ test('preserves all target completion evidence when status records disappear after reboot', async () => {
6261
+ const method = new FirmwareUpdateV4({
6262
+ id: 1,
6263
+ payload: {
6264
+ method: 'firmwareUpdateV4',
6265
+ },
6266
+ });
6267
+ const typedCall = jest
6268
+ .fn()
6269
+ .mockRejectedValueOnce(new Error('Pro2 is rebooting'))
6270
+ .mockResolvedValueOnce({
6271
+ type: 'DeviceFirmwareUpdateStatus',
6272
+ message: { records: [] },
6273
+ });
6274
+ const deviceInfo = { hw: { serial_no: 'PRO2-PHYSICAL-1' } };
6275
+ const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation(((
6276
+ callback: () => void
6277
+ ) => {
6278
+ callback();
6279
+ return 0 as any;
6280
+ }) as typeof setTimeout);
4938
6281
 
4939
6282
  (method as any).device = stubDevice({
4940
6283
  getCommands: () => ({ typedCall }),
4941
6284
  });
4942
- (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
4943
- const deviceInfo = { hw: { serial_no: 'PRO2-PHYSICAL-1' } };
6285
+ (method as any).protocolV2InstallTerminalSuccessObserved = true;
6286
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
4944
6287
  (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(deviceInfo);
4945
6288
  (method as any).probeProtocolV2NormalMode = jest.fn().mockResolvedValue(true);
4946
6289
  method.postProgressMessage = jest.fn();
4947
6290
 
4948
- await (method as any).waitForProtocolV2FirmwareUpdateComplete([
4949
- { target_id: 4, path: 'vol0:/application_p1.bin' },
4950
- ]);
6291
+ try {
6292
+ await expect(
6293
+ (method as any).waitForProtocolV2FirmwareUpdateComplete([
6294
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
6295
+ { target_id: 5, path: 'vol0:/application_p2.bin' },
6296
+ ])
6297
+ ).resolves.toBeUndefined();
6298
+ } finally {
6299
+ setTimeoutSpy.mockRestore();
6300
+ }
4951
6301
 
4952
- expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
4953
- expect(typedCall.mock.calls.map(call => call[0])).toEqual(['DeviceFirmwareUpdateStatusGet']);
4954
- expect((method as any).probeProtocolV2NormalMode).toHaveBeenCalledWith(deviceInfo);
6302
+ expect(Array.from((method as any).protocolV2CompletedTargetIds)).toEqual([4, 5]);
4955
6303
  expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
4956
6304
  });
4957
6305
 
4958
- test('accepts an empty firmware status dump only after confirming normal mode', async () => {
6306
+ test('uses the confirmed install request as evidence after the same device returns', async () => {
4959
6307
  const method = new FirmwareUpdateV4({
4960
6308
  id: 1,
4961
6309
  payload: {
4962
6310
  method: 'firmwareUpdateV4',
4963
6311
  },
4964
6312
  });
4965
- const typedCall = jest.fn().mockResolvedValue({
4966
- type: 'DeviceFirmwareUpdateStatus',
4967
- message: { records: [] },
4968
- });
6313
+ const typedCall = jest
6314
+ .fn()
6315
+ .mockRejectedValueOnce(new Error('The USB device was disconnected'))
6316
+ .mockResolvedValueOnce({
6317
+ type: 'DeviceFirmwareUpdateStatus',
6318
+ message: { records: [] },
6319
+ });
4969
6320
  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;
6321
+ const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation(((
6322
+ callback: () => void
6323
+ ) => {
4974
6324
  callback();
4975
6325
  return 0 as any;
4976
6326
  }) as typeof setTimeout);
@@ -4978,24 +6328,32 @@ describe('Protocol V2 firmware update targets', () => {
4978
6328
  (method as any).device = stubDevice({
4979
6329
  getCommands: () => ({ typedCall }),
4980
6330
  });
6331
+ (method as any).protocolV2InstallTerminalSuccessObserved = true;
4981
6332
  (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
4982
6333
  (method as any).verifyProtocolV2ReconnectIdentity = jest.fn().mockResolvedValue(deviceInfo);
4983
6334
  (method as any).probeProtocolV2NormalMode = jest.fn().mockResolvedValue(true);
4984
6335
  method.postProgressMessage = jest.fn();
4985
6336
 
4986
- await expect(
4987
- (method as any).waitForProtocolV2FirmwareUpdateComplete([
4988
- { target_id: 4, path: 'vol0:/application_p1.bin' },
4989
- ])
4990
- ).resolves.toBeUndefined();
6337
+ try {
6338
+ await expect(
6339
+ (method as any).waitForProtocolV2FirmwareUpdateComplete(
6340
+ [{ target_id: 4, path: 'vol0:/application_p1.bin' }],
6341
+ true
6342
+ )
6343
+ ).resolves.toBeUndefined();
6344
+ } finally {
6345
+ setTimeoutSpy.mockRestore();
6346
+ }
4991
6347
 
4992
- expect(typedCall).toHaveBeenCalledTimes(1);
4993
- expect((method as any).probeProtocolV2NormalMode).toHaveBeenCalledWith(deviceInfo);
4994
- expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
6348
+ expect((method as any).reconnectProtocolV2Device).toHaveBeenCalledWith({
6349
+ skipProtocolProbe: true,
6350
+ });
6351
+ expect(method.postProgressMessage).toHaveBeenLastCalledWith(100, 'installingFirmware');
4995
6352
  });
4996
6353
 
4997
6354
  test.each([
4998
6355
  [{ mode: 'normal', bootloaderMode: false }, true],
6356
+ [{ mode: 'notInitialized', bootloaderMode: false }, true],
4999
6357
  [{ mode: 'bootloader', bootloaderMode: true }, false],
5000
6358
  ])('derives firmware completion from the runtime-state probe: %o', async (features, expected) => {
5001
6359
  const method = new FirmwareUpdateV4({
@@ -5054,7 +6412,9 @@ describe('Protocol V2 firmware update targets', () => {
5054
6412
  'DeviceFirmwareUpdateStatusGet',
5055
6413
  'DeviceFirmwareUpdateStatusGet',
5056
6414
  ]);
5057
- expect((method as any).probeProtocolV2NormalMode).toHaveBeenCalledWith(deviceInfo);
6415
+ expect((method as any).reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
6416
+ expect((method as any).verifyProtocolV2ReconnectIdentity).toHaveBeenCalledTimes(1);
6417
+ expect((method as any).probeProtocolV2NormalMode).not.toHaveBeenCalled();
5058
6418
  });
5059
6419
 
5060
6420
  test('rejects a finished target whose payload version conflicts with the plan', () => {
@@ -5169,8 +6529,8 @@ describe('Protocol V2 firmware update targets', () => {
5169
6529
  { target_id: 4, path: 'vol0:/application_p1.bin' },
5170
6530
  ])
5171
6531
  ).rejects.toMatchObject({
5172
- errorCode: HardwareErrorCode.RuntimeError,
5173
- message: 'Protocol V2 install status conflicts with target 4',
6532
+ errorCode: HardwareErrorCode.FirmwareError,
6533
+ message: 'Protocol V2 firmware install status conflict',
5174
6534
  params: {
5175
6535
  firmwareUpdateCode: 'FirmwareInstallStatusConflict',
5176
6536
  },
@@ -5183,7 +6543,7 @@ describe('Protocol V2 firmware update targets', () => {
5183
6543
  expect(typedCall).toHaveBeenCalledTimes(1);
5184
6544
  });
5185
6545
 
5186
- test('does not fail a completed multi-app install when final app versions are unobservable', () => {
6546
+ test('uses the device firmware version for P1 and install completion for unobservable P2', () => {
5187
6547
  const method = new FirmwareUpdateV4({
5188
6548
  id: 1,
5189
6549
  payload: {
@@ -5191,7 +6551,7 @@ describe('Protocol V2 firmware update targets', () => {
5191
6551
  platform: 'desktop',
5192
6552
  targetsToUpdate: ['app_v1', 'app_v2'],
5193
6553
  expectedTargetVersions: {
5194
- app_v1: '1.0.0',
6554
+ app_v1: '3.0.0',
5195
6555
  app_v2: '2.0.0',
5196
6556
  },
5197
6557
  },
@@ -5202,12 +6562,41 @@ describe('Protocol V2 firmware update targets', () => {
5202
6562
  minor_version: 0,
5203
6563
  patch_version: 0,
5204
6564
  };
5205
- (method as any).protocolV2FinalStatusVerified = true;
6565
+ (method as any).protocolV2CompletedTargetIds = new Set([4, 5]);
6566
+
6567
+ expect(() => (method as any).assertExpectedProtocolV2Versions()).not.toThrow();
6568
+ });
6569
+
6570
+ test('uses the firmware version for P1 and the optional DeviceInfo version for P2', () => {
6571
+ const method = new FirmwareUpdateV4({
6572
+ id: 1,
6573
+ payload: {
6574
+ method: 'firmwareUpdateV4',
6575
+ platform: 'desktop',
6576
+ targetsToUpdate: ['app_v1', 'app_v2'],
6577
+ expectedTargetVersions: {
6578
+ app_v1: '1.0.0',
6579
+ app_v2: '2.0.0',
6580
+ },
6581
+ },
6582
+ });
6583
+ method.init();
6584
+ (method as any).protocolV2LatestFinalFeatures = {
6585
+ major_version: 1,
6586
+ minor_version: 0,
6587
+ patch_version: 0,
6588
+ };
6589
+ (method as any).protocolV2LatestFinalDeviceInfo = {
6590
+ main_mcu: {
6591
+ application: { version: '9.9.9' },
6592
+ application_data: { version: '2.0.0' },
6593
+ },
6594
+ };
5206
6595
 
5207
6596
  expect(() => (method as any).assertExpectedProtocolV2Versions()).not.toThrow();
5208
6597
  });
5209
6598
 
5210
- test('rejects unobservable final versions after the status endpoint fallback', () => {
6599
+ test('rejects a mismatched final DeviceInfo application slot version', () => {
5211
6600
  const method = new FirmwareUpdateV4({
5212
6601
  id: 1,
5213
6602
  payload: {
@@ -5221,6 +6610,63 @@ describe('Protocol V2 firmware update targets', () => {
5221
6610
  },
5222
6611
  });
5223
6612
  method.init();
6613
+ (method as any).protocolV2LatestFinalFeatures = {
6614
+ major_version: 1,
6615
+ minor_version: 0,
6616
+ patch_version: 0,
6617
+ };
6618
+ (method as any).protocolV2LatestFinalDeviceInfo = {
6619
+ main_mcu: {
6620
+ application: { version: '1.0.0' },
6621
+ application_data: { version: '2.0.1' },
6622
+ },
6623
+ };
6624
+
6625
+ expect(() => (method as any).assertExpectedProtocolV2Versions()).toThrow(
6626
+ 'target app_v2 reached 2.0.1, expected 2.0.0'
6627
+ );
6628
+ });
6629
+
6630
+ test('rejects an unobservable P2 version without P2 completion evidence', () => {
6631
+ const method = new FirmwareUpdateV4({
6632
+ id: 1,
6633
+ payload: {
6634
+ method: 'firmwareUpdateV4',
6635
+ platform: 'desktop',
6636
+ targetsToUpdate: ['app_v1', 'app_v2'],
6637
+ expectedTargetVersions: {
6638
+ app_v1: '3.0.0',
6639
+ app_v2: '2.0.0',
6640
+ },
6641
+ },
6642
+ });
6643
+ method.init();
6644
+ (method as any).protocolV2LatestFinalFeatures = {
6645
+ major_version: 3,
6646
+ minor_version: 0,
6647
+ patch_version: 0,
6648
+ };
6649
+
6650
+ (method as any).protocolV2CompletedTargetIds = new Set([4]);
6651
+
6652
+ expect(() => (method as any).assertExpectedProtocolV2Versions()).toThrow(
6653
+ 'target app_v2 has no observable final version after status fallback'
6654
+ );
6655
+ });
6656
+
6657
+ test('still rejects an unobservable non-app version after the status endpoint fallback', () => {
6658
+ const method = new FirmwareUpdateV4({
6659
+ id: 1,
6660
+ payload: {
6661
+ method: 'firmwareUpdateV4',
6662
+ platform: 'desktop',
6663
+ targetsToUpdate: ['se01'],
6664
+ expectedTargetVersions: {
6665
+ se01: '1.0.0',
6666
+ },
6667
+ },
6668
+ });
6669
+ method.init();
5224
6670
  (method as any).protocolV2LatestFinalFeatures = {
5225
6671
  major_version: 3,
5226
6672
  minor_version: 0,
@@ -5228,11 +6674,11 @@ describe('Protocol V2 firmware update targets', () => {
5228
6674
  };
5229
6675
 
5230
6676
  expect(() => (method as any).assertExpectedProtocolV2Versions()).toThrow(
5231
- 'has no observable final version after status fallback'
6677
+ 'target se01 has no observable final version after status fallback'
5232
6678
  );
5233
6679
  });
5234
6680
 
5235
- test('polls target status after update ACK and finishes when all targets complete', async () => {
6681
+ test('polls target status after the install request and finishes when all targets complete', async () => {
5236
6682
  const method = new FirmwareUpdateV4({
5237
6683
  id: 1,
5238
6684
  payload: {
@@ -5264,7 +6710,7 @@ describe('Protocol V2 firmware update targets', () => {
5264
6710
  ])
5265
6711
  ).resolves.toBeUndefined();
5266
6712
 
5267
- expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
6713
+ expect(reconnectProtocolV2Device).not.toHaveBeenCalled();
5268
6714
  expect(typedCall).toHaveBeenCalledWith(
5269
6715
  'DeviceFirmwareUpdateStatusGet',
5270
6716
  'DeviceFirmwareUpdateStatus',
@@ -5274,7 +6720,7 @@ describe('Protocol V2 firmware update targets', () => {
5274
6720
  expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
5275
6721
  });
5276
6722
 
5277
- test('fails clearly when a requested target stays absent from the full status dump', async () => {
6723
+ test('keeps polling missing target records until the five-minute timeout', async () => {
5278
6724
  const method = new FirmwareUpdateV4({
5279
6725
  id: 1,
5280
6726
  payload: {
@@ -5290,7 +6736,7 @@ describe('Protocol V2 firmware update targets', () => {
5290
6736
  let now = 0;
5291
6737
  jest.spyOn(Date, 'now').mockImplementation(() => now);
5292
6738
  jest.spyOn(global, 'setTimeout').mockImplementation(((callback: () => void) => {
5293
- now += 31 * 1000;
6739
+ now += 60 * 1000;
5294
6740
  callback();
5295
6741
  return 0 as any;
5296
6742
  }) as typeof setTimeout);
@@ -5309,12 +6755,13 @@ describe('Protocol V2 firmware update targets', () => {
5309
6755
  ])
5310
6756
  ).rejects.toMatchObject({
5311
6757
  errorCode: HardwareErrorCode.FirmwareError,
5312
- message: expect.stringContaining('targetIds=3'),
6758
+ message: 'Protocol V2 firmware install timed out',
6759
+ params: { firmwareUpdateCode: 'FirmwareInstallTimeout' },
5313
6760
  });
5314
- expect(typedCall).toHaveBeenCalledTimes(2);
6761
+ expect(typedCall).toHaveBeenCalledTimes(5);
5315
6762
  });
5316
6763
 
5317
- test('resets the missing-record grace period after a Pro2 reboot interruption', async () => {
6764
+ test('keeps polling incomplete records after a Pro2 reboot interruption', async () => {
5318
6765
  const method = new FirmwareUpdateV4({
5319
6766
  id: 1,
5320
6767
  payload: {
@@ -5363,11 +6810,11 @@ describe('Protocol V2 firmware update targets', () => {
5363
6810
  { target_id: 4, path: 'vol0:/application_p1.bin' },
5364
6811
  ])
5365
6812
  ).resolves.toBeUndefined();
5366
- expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(2);
6813
+ expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
5367
6814
  expect(typedCall).toHaveBeenCalledTimes(4);
5368
6815
  });
5369
6816
 
5370
- test('uses an eight-minute Pro2 install status window', async () => {
6817
+ test('uses a five-minute Pro2 install status window', async () => {
5371
6818
  const method = new FirmwareUpdateV4({
5372
6819
  id: 1,
5373
6820
  payload: {
@@ -5377,13 +6824,17 @@ describe('Protocol V2 firmware update targets', () => {
5377
6824
  jest
5378
6825
  .spyOn(Date, 'now')
5379
6826
  .mockReturnValueOnce(0)
5380
- .mockReturnValue(8 * 60 * 1000);
6827
+ .mockReturnValue(5 * 60 * 1000);
5381
6828
 
5382
6829
  await expect(
5383
6830
  (method as any).waitForProtocolV2FirmwareUpdateComplete([
5384
6831
  { target_id: 4, path: 'vol0:/application_p1.bin' },
5385
6832
  ])
5386
- ).rejects.toThrow('within 480s');
6833
+ ).rejects.toMatchObject({
6834
+ errorCode: HardwareErrorCode.FirmwareError,
6835
+ message: 'Protocol V2 firmware install timed out',
6836
+ params: { firmwareUpdateCode: 'FirmwareInstallTimeout' },
6837
+ });
5387
6838
  });
5388
6839
 
5389
6840
  test('uses a 90-second Pro2 bootloader reconnect window', async () => {
@@ -5461,7 +6912,7 @@ describe('Protocol V2 firmware update targets', () => {
5461
6912
  ],
5462
6913
  new Set([4, 10])
5463
6914
  )
5464
- ).toThrow('Protocol V2 install status conflicts with target 4');
6915
+ ).toThrow('Protocol V2 firmware install status conflict');
5465
6916
  expect(method.postProgressMessage).not.toHaveBeenCalled();
5466
6917
  expect(method.postProgressMessage).not.toHaveBeenCalledWith(100, 'installingFirmware');
5467
6918
 
@@ -5501,8 +6952,8 @@ describe('Protocol V2 firmware update targets', () => {
5501
6952
  throw new Error('Expected Protocol V2 failed firmware status to throw');
5502
6953
  } catch (error: any) {
5503
6954
  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');
6955
+ expect(error.message).toBe('Protocol V2 firmware install failed');
6956
+ expect(error.params).toEqual({ firmwareUpdateCode: 'FirmwareInstallFailed' });
5506
6957
  }
5507
6958
  });
5508
6959
 
@@ -5539,7 +6990,8 @@ describe('Protocol V2 firmware update targets', () => {
5539
6990
  ])
5540
6991
  ).rejects.toMatchObject({
5541
6992
  errorCode: HardwareErrorCode.FirmwareError,
5542
- message: expect.stringContaining('vol0:/application_p1.bin'),
6993
+ message: 'Protocol V2 firmware install failed',
6994
+ params: { firmwareUpdateCode: 'FirmwareInstallFailed' },
5543
6995
  });
5544
6996
  expect(typedCall).toHaveBeenCalledWith(
5545
6997
  'DeviceFirmwareUpdateStatusGet',
@@ -5547,6 +6999,8 @@ describe('Protocol V2 firmware update targets', () => {
5547
6999
  {
5548
7000
  fields: {
5549
7001
  status: true,
7002
+ progress_percent: true,
7003
+ phase_info: true,
5550
7004
  payload_version: true,
5551
7005
  path: true,
5552
7006
  },
@@ -5555,7 +7009,7 @@ describe('Protocol V2 firmware update targets', () => {
5555
7009
  );
5556
7010
  });
5557
7011
 
5558
- test('passes bootloader, coprocessor, SE and app files to DeviceFirmwareUpdate targets', async () => {
7012
+ test('preserves component-first target order for the firmware-owned loader handoff', async () => {
5559
7013
  const method = new FirmwareUpdateV4({
5560
7014
  id: 1,
5561
7015
  payload: {
@@ -5584,54 +7038,75 @@ describe('Protocol V2 firmware update targets', () => {
5584
7038
  .mockResolvedValue(undefined);
5585
7039
 
5586
7040
  await (method as any).executeProtocolV2Update({
5587
- bootloaderBinary: new Uint8Array([4, 5]).buffer,
5588
- fwBinaryMap: [
7041
+ // The active bootloader installs its component runners in records order and
7042
+ // leaves the bootloader record pending for romloader after reboot. Keep boot
7043
+ // deliberately after components to prove Core does not synthesize boot-first.
7044
+ installItems: [
5589
7045
  {
5590
- fileName: 'coprocessor.bin',
5591
- binary: new Uint8Array([6]).buffer,
5592
- targetId: 6,
7046
+ fileName: 'application_p1.bin',
7047
+ binary: new Uint8Array([8]).buffer,
7048
+ targetId: 4,
7049
+ kind: 'firmware',
5593
7050
  },
5594
7051
  {
5595
7052
  fileName: 'se01.bin',
5596
7053
  binary: new Uint8Array([7]).buffer,
5597
7054
  targetId: 7,
7055
+ kind: 'firmware',
5598
7056
  },
5599
7057
  {
5600
- fileName: 'application_p1.bin',
5601
- binary: new Uint8Array([8]).buffer,
5602
- targetId: 4,
7058
+ fileName: 'bootloader.bin',
7059
+ binary: new Uint8Array([4, 5]).buffer,
7060
+ targetId: 3,
7061
+ kind: 'bootloader',
7062
+ },
7063
+ {
7064
+ fileName: 'coprocessor.bin',
7065
+ binary: new Uint8Array([6]).buffer,
7066
+ targetId: 6,
7067
+ kind: 'firmware',
5603
7068
  },
5604
7069
  ],
5605
7070
  });
5606
7071
 
5607
7072
  expect(writtenPaths).toEqual([
7073
+ 'vol0:/application_p1.bin',
7074
+ 'vol0:/se01.bin',
5608
7075
  'vol0:/bootloader.bin',
5609
7076
  'vol0:/coprocessor.bin',
5610
- 'vol0:/se01.bin',
5611
- 'vol0:/application_p1.bin',
5612
7077
  ]);
5613
7078
  expect((method as any).verifyProtocolV2StagedFile).toHaveBeenCalledTimes(4);
5614
7079
  expect((method as any).verifyProtocolV2StagedFile).toHaveBeenNthCalledWith(
5615
7080
  2,
5616
- 'vol0:/coprocessor.bin',
7081
+ 'vol0:/se01.bin',
5617
7082
  1
5618
7083
  );
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, {
7084
+ expect((method as any).enterProtocolV2BootloaderMode).toHaveBeenCalledTimes(1);
7085
+ expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledTimes(1);
7086
+ expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledWith({
5624
7087
  targets: [
5625
- { target_id: 6, path: 'vol0:/coprocessor.bin' },
5626
- { target_id: 7, path: 'vol0:/se01.bin' },
5627
7088
  { target_id: 4, path: 'vol0:/application_p1.bin' },
7089
+ { target_id: 7, path: 'vol0:/se01.bin' },
7090
+ { target_id: 3, path: 'vol0:/bootloader.bin' },
7091
+ { target_id: 6, path: 'vol0:/coprocessor.bin' },
5628
7092
  ],
5629
7093
  });
7094
+ expect((method as any).waitForProtocolV2FirmwareUpdateComplete).toHaveBeenCalledTimes(1);
7095
+ expect((method as any).waitForProtocolV2FirmwareUpdateComplete).toHaveBeenCalledWith(
7096
+ [
7097
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
7098
+ { target_id: 7, path: 'vol0:/se01.bin' },
7099
+ { target_id: 3, path: 'vol0:/bootloader.bin' },
7100
+ { target_id: 6, path: 'vol0:/coprocessor.bin' },
7101
+ ],
7102
+ true
7103
+ );
7104
+ expect((method as any).exitProtocolV2BootloaderToNormal).not.toHaveBeenCalled();
5630
7105
  expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'transferData');
5631
- expect((method as any).waitForProtocolV2FirmwareUpdateComplete).toHaveBeenCalled();
7106
+ expect((method as any).completeProtocolV2FinalVerification).toHaveBeenCalledTimes(1);
5632
7107
  });
5633
7108
 
5634
- test('announces separate resource and component transfer phases', async () => {
7109
+ test('uses one global transfer range for resources and firmware', async () => {
5635
7110
  const method = new FirmwareUpdateV4({
5636
7111
  id: 1,
5637
7112
  payload: {
@@ -5688,18 +7163,24 @@ describe('Protocol V2 firmware update targets', () => {
5688
7163
  ],
5689
7164
  });
5690
7165
 
5691
- expect(method.postTipMessage).toHaveBeenCalledTimes(3);
7166
+ expect((method as any).enterProtocolV2BootloaderMode).toHaveBeenCalledTimes(1);
7167
+ expect(method.postTipMessage).toHaveBeenCalledTimes(2);
5692
7168
  expect(method.postTipMessage).toHaveBeenNthCalledWith(1, 'StartTransferData');
5693
- expect(method.postTipMessage).toHaveBeenNthCalledWith(2, 'StartTransferData');
5694
- expect(method.postTipMessage).toHaveBeenNthCalledWith(3, 'ConfirmOnDevice');
7169
+ expect(method.postTipMessage).toHaveBeenNthCalledWith(2, 'ConfirmOnDevice');
5695
7170
  expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenNthCalledWith(
5696
7171
  1,
5697
- expect.objectContaining({ processedSize: 0, totalSize: 2 })
7172
+ expect.objectContaining({ processedSize: 0, totalSize: 3 })
5698
7173
  );
5699
7174
  expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenNthCalledWith(
5700
7175
  2,
5701
- expect.objectContaining({ processedSize: 0, totalSize: 1 })
7176
+ expect.objectContaining({ processedSize: 2, totalSize: 3 })
5702
7177
  );
7178
+ expect(method.postProgressMessage).toHaveBeenCalledTimes(1);
7179
+ expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'transferData');
7180
+ expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledTimes(1);
7181
+ expect((method as any).protocolV2StartFirmwareUpdate).toHaveBeenCalledWith({
7182
+ targets: [{ target_id: 4, path: 'vol0:/application_p1.bin' }],
7183
+ });
5703
7184
  expect((method as any).isProtocolV2ResourceBundleUpToDate).toHaveBeenCalledWith(
5704
7185
  expect.objectContaining({
5705
7186
  version: [1, 2, 3],
@@ -5709,50 +7190,7 @@ describe('Protocol V2 firmware update targets', () => {
5709
7190
  );
5710
7191
  });
5711
7192
 
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 () => {
7193
+ test('removes stale boot resource staging before the combined transfer', async () => {
5756
7194
  const method = new FirmwareUpdateV4({
5757
7195
  id: 1,
5758
7196
  payload: { method: 'firmwareUpdateV4' },
@@ -5779,18 +7217,18 @@ describe('Protocol V2 firmware update targets', () => {
5779
7217
  events.push('transfer-component');
5780
7218
  return Promise.resolve();
5781
7219
  });
5782
- (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockImplementation(() => {
5783
- events.push('reboot-normal');
5784
- return Promise.resolve();
7220
+ (method as any).completeProtocolV2FinalVerification = jest.fn().mockImplementation(() => {
7221
+ events.push('final-verify');
7222
+ return Promise.resolve({});
5785
7223
  });
5786
- (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
5787
7224
 
5788
7225
  await (method as any).executeProtocolV2SourceUpdate({
5789
7226
  installSources: [{ kind: 'firmware' }],
5790
7227
  resourceSources: [],
5791
7228
  });
5792
7229
 
5793
- expect(events).toEqual(['delete-stale-staging', 'transfer-component', 'reboot-normal']);
7230
+ expect((method as any).enterProtocolV2BootloaderMode).toHaveBeenCalledTimes(1);
7231
+ expect(events).toEqual(['delete-stale-staging', 'transfer-component', 'final-verify']);
5794
7232
  expect(typedCall).toHaveBeenNthCalledWith(2, 'FilesystemFileDelete', 'Success', {
5795
7233
  path: 'vol0:/loaders/bootloader/boot_resource.okpkg.staging',
5796
7234
  });
@@ -5976,41 +7414,51 @@ describe('Protocol V2 firmware update targets', () => {
5976
7414
  bootloader: {
5977
7415
  target: 'BOOTLOADER',
5978
7416
  url: 'https://example.com/bootloader.pp.bin',
7417
+ version: [1, 0, 0],
5979
7418
  ...componentIntegrity('https://example.com/bootloader.pp.bin'),
5980
7419
  },
5981
7420
  applicationP1: {
5982
7421
  target: 'APPLICATION_P1',
5983
7422
  url: 'https://example.com/applicationP1.pp.bin',
7423
+ version: [2, 0, 0],
5984
7424
  ...componentIntegrity('https://example.com/applicationP1.pp.bin'),
7425
+ // Core treats the package as opaque; the device owns its internal format validation.
7426
+ payloadHash: '00'.repeat(64),
5985
7427
  },
5986
7428
  applicationP2: {
5987
7429
  target: 'APPLICATION_P2',
5988
7430
  url: 'https://example.com/applicationP2.pp.bin',
7431
+ version: [2, 0, 1],
5989
7432
  ...componentIntegrity('https://example.com/applicationP2.pp.bin'),
5990
7433
  },
5991
7434
  coprocessor: {
5992
7435
  target: 'COPROCESSOR',
5993
7436
  url: 'https://example.com/coprocessor.pp.bin',
7437
+ version: [1, 1, 0],
5994
7438
  ...componentIntegrity('https://example.com/coprocessor.pp.bin'),
5995
7439
  },
5996
7440
  se01: {
5997
7441
  target: 'SE01',
5998
7442
  url: 'https://example.com/se01.pp.bin',
7443
+ version: [1, 2, 1],
5999
7444
  ...componentIntegrity('https://example.com/se01.pp.bin'),
6000
7445
  },
6001
7446
  se02: {
6002
7447
  target: 'SE02',
6003
7448
  url: 'https://example.com/se02.pp.bin',
7449
+ version: [1, 2, 2],
6004
7450
  ...componentIntegrity('https://example.com/se02.pp.bin'),
6005
7451
  },
6006
7452
  se03: {
6007
7453
  target: 'SE03',
6008
7454
  url: 'https://example.com/se03.pp.bin',
7455
+ version: [1, 2, 3],
6009
7456
  ...componentIntegrity('https://example.com/se03.pp.bin'),
6010
7457
  },
6011
7458
  se04: {
6012
7459
  target: 'SE04',
6013
7460
  url: 'https://example.com/se04.pp.bin',
7461
+ version: [1, 2, 4],
6014
7462
  ...componentIntegrity('https://example.com/se04.pp.bin'),
6015
7463
  },
6016
7464
  },
@@ -6048,6 +7496,16 @@ describe('Protocol V2 firmware update targets', () => {
6048
7496
  'https://example.com/se03.pp.bin',
6049
7497
  'https://example.com/se04.pp.bin',
6050
7498
  ]);
7499
+ expect((method as any).params.expectedTargetVersions).toEqual({
7500
+ boot: '1.0.0',
7501
+ app_v1: '2.0.0',
7502
+ app_v2: '2.0.1',
7503
+ coprocessor: '1.1.0',
7504
+ se01: '1.2.1',
7505
+ se02: '1.2.2',
7506
+ se03: '1.2.3',
7507
+ se04: '1.2.4',
7508
+ });
6051
7509
 
6052
7510
  getSysResourceBinarySpy.mockRestore();
6053
7511
  getFirmwareLatestReleaseSpy.mockRestore();
@@ -6062,6 +7520,9 @@ describe('Protocol V2 firmware update targets', () => {
6062
7520
  method: 'firmwareUpdateV4',
6063
7521
  platform: 'web',
6064
7522
  targetsToUpdate: ['app_v1', 'coprocessor'],
7523
+ expectedTargetVersions: {
7524
+ app_v1: '9.9.9',
7525
+ },
6065
7526
  },
6066
7527
  });
6067
7528
  method.init();
@@ -6080,12 +7541,14 @@ describe('Protocol V2 firmware update targets', () => {
6080
7541
  applicationP1: {
6081
7542
  target: 'APPLICATION_P1',
6082
7543
  url: 'https://example.com/applicationP1.pp.bin',
7544
+ version: [2, 0, 0],
6083
7545
  expectedSize: explicitApplicationBinary.byteLength,
6084
7546
  fingerprint: bytesToHex(sha256(new Uint8Array(explicitApplicationBinary))),
6085
7547
  },
6086
7548
  coprocessor: {
6087
7549
  target: 'COPROCESSOR',
6088
7550
  url: 'https://example.com/coprocessor.pp.bin',
7551
+ version: [1, 1, 0],
6089
7552
  expectedSize: remoteCoprocessorBinary.byteLength,
6090
7553
  fingerprint: bytesToHex(sha256(new Uint8Array(remoteCoprocessorBinary))),
6091
7554
  },
@@ -6126,6 +7589,10 @@ describe('Protocol V2 firmware update targets', () => {
6126
7589
  kind: 'firmware',
6127
7590
  },
6128
7591
  ]);
7592
+ expect((method as any).params.expectedTargetVersions).toEqual({
7593
+ app_v1: '9.9.9',
7594
+ coprocessor: '1.1.0',
7595
+ });
6129
7596
 
6130
7597
  getSysResourceBinarySpy.mockRestore();
6131
7598
  getFirmwareLatestReleaseSpy.mockRestore();
@@ -6295,7 +7762,7 @@ describe('Protocol V2 firmware update targets', () => {
6295
7762
  });
6296
7763
  });
6297
7764
 
6298
- test('maps a prepared resource archive manifest to artifact-backed resource sources', async () => {
7765
+ test('maps self-describing packages from a prepared resource archive', async () => {
6299
7766
  const method = new FirmwareUpdateV4({
6300
7767
  id: 1,
6301
7768
  payload: {
@@ -6303,62 +7770,20 @@ describe('Protocol V2 firmware update targets', () => {
6303
7770
  },
6304
7771
  });
6305
7772
  const imagesBinary = createProtocolV2OkppBinary();
6306
- const bootBinary = createProtocolV2OkppBinary({ payloadHashByte: 0x33 });
7773
+ const bootBinary = createProtocolV2OkppBinary({
7774
+ devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg.staging',
7775
+ payloadHashByte: 0x33,
7776
+ });
6307
7777
  const imagesSha256 = bytesToHex(sha256(new Uint8Array(imagesBinary)));
6308
7778
  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
7779
  const zip = new JSZip();
6347
- zip.file('manifest.json', manifestBinary);
6348
7780
  zip.file('bundles/images/images.okpkg', imagesBinary);
6349
7781
  zip.file('loaders/bootloader/boot_resource.okpkg', bootBinary);
7782
+ zip.file('manifest.json', '{"ignored":true}');
6350
7783
  const archiveBinary = await zip.generateAsync({ type: 'arraybuffer' });
6351
7784
  const preparedEntries = [
6352
7785
  {
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',
7786
+ entryName: 'pro2-resource/bundles/images/images.okpkg',
6362
7787
  artifact: {
6363
7788
  artifactRef: 'images',
6364
7789
  size: imagesBinary.byteLength,
@@ -6366,13 +7791,21 @@ describe('Protocol V2 firmware update targets', () => {
6366
7791
  },
6367
7792
  },
6368
7793
  {
6369
- entryName: 'loaders/bootloader/boot_resource.okpkg',
7794
+ entryName: 'pro2-resource/loaders/bootloader/boot_resource.okpkg',
6370
7795
  artifact: {
6371
7796
  artifactRef: 'boot',
6372
7797
  size: bootBinary.byteLength,
6373
7798
  sha256: bootSha256,
6374
7799
  },
6375
7800
  },
7801
+ {
7802
+ entryName: 'pro2-resource/build-info.txt',
7803
+ artifact: {
7804
+ artifactRef: 'build-info',
7805
+ size: 1,
7806
+ sha256: 'f'.repeat(64),
7807
+ },
7808
+ },
6376
7809
  ];
6377
7810
  const artifacts = new Map([['archive', archiveBinary]]);
6378
7811
  (method as any).params = {
@@ -6411,16 +7844,11 @@ describe('Protocol V2 firmware update targets', () => {
6411
7844
  }),
6412
7845
  expect.objectContaining({
6413
7846
  name: 'boot_resource.okpkg',
6414
- devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg',
7847
+ devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg.staging',
6415
7848
  version: [1, 2, 3],
6416
7849
  }),
6417
7850
  ]);
6418
7851
  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
7852
  });
6425
7853
 
6426
7854
  test('binds a prepared resource archive to the selected host generation', () => {
@@ -6462,9 +7890,9 @@ describe('Protocol V2 firmware update targets', () => {
6462
7890
  },
6463
7891
  materializedEntries: [
6464
7892
  {
6465
- entryName: 'manifest.json',
7893
+ entryName: 'bundles/images/images.okpkg',
6466
7894
  artifact: {
6467
- artifactRef: 'resource-manifest',
7895
+ artifactRef: 'resource-package',
6468
7896
  size: 2,
6469
7897
  sha256: 'b'.repeat(64),
6470
7898
  },
@@ -6528,7 +7956,7 @@ describe('Protocol V2 firmware update targets', () => {
6528
7956
  const componentArtifact = {
6529
7957
  artifactRef: 'prepared-bootloader',
6530
7958
  size: componentBinary.byteLength,
6531
- sha256: 'a'.repeat(64),
7959
+ sha256: bytesToHex(sha256(new Uint8Array(componentBinary))),
6532
7960
  };
6533
7961
  const planWithoutDigest = {
6534
7962
  schemaVersion: 2 as const,
@@ -6664,9 +8092,9 @@ describe('Protocol V2 firmware update targets', () => {
6664
8092
  },
6665
8093
  materializedEntries: [
6666
8094
  {
6667
- entryName: 'manifest.json',
8095
+ entryName: 'bundles/images/images.okpkg',
6668
8096
  artifact: {
6669
- artifactRef: 'prepared-resource-manifest',
8097
+ artifactRef: 'prepared-resource-package',
6670
8098
  size: 2,
6671
8099
  sha256: 'b'.repeat(64),
6672
8100
  },
@@ -6722,49 +8150,17 @@ describe('Protocol V2 firmware update targets', () => {
6722
8150
  }
6723
8151
  });
6724
8152
 
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
- };
8153
+ test('installs a local resource ZIP without matching a remote release', async () => {
8154
+ const imagesBinary = createProtocolV2OkppBinary({ version: [1, 0, 0] });
8155
+ const bootResourceBinary = createProtocolV2OkppBinary({
8156
+ version: [1, 0, 0],
8157
+ devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg.staging',
8158
+ });
6764
8159
  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);
8160
+ zip.file('pro2-resource/bundles/images/images.okpkg', imagesBinary);
8161
+ zip.file('pro2-resource/loaders/bootloader/boot_resource.okpkg', bootResourceBinary);
8162
+ zip.file('pro2-resource/manifest.json', '{"ignored":true}');
8163
+ zip.file('pro2-resource/build-info.txt', 'ignored');
6768
8164
  const resourceArchiveBinary = await zip.generateAsync({ type: 'arraybuffer' });
6769
8165
  const applicationP1Binary = new Uint8Array([7, 8, 9]).buffer;
6770
8166
  const bootloaderBinary = new Uint8Array([10, 11, 12]).buffer;
@@ -6804,16 +8200,7 @@ describe('Protocol V2 firmware update targets', () => {
6804
8200
  await expect(method.run()).resolves.toBe('local-resource-result');
6805
8201
  expect(releaseSpy).not.toHaveBeenCalled();
6806
8202
  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);
8203
+ expect((method as any).params.preparedPlan).toBeUndefined();
6817
8204
  expect((method as any).executeProtocolV2SourceUpdate).toHaveBeenCalledWith(
6818
8205
  expect.objectContaining({
6819
8206
  installSources: [
@@ -6829,7 +8216,7 @@ describe('Protocol V2 firmware update targets', () => {
6829
8216
  }),
6830
8217
  expect.objectContaining({
6831
8218
  name: 'boot_resource.okpkg',
6832
- devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg',
8219
+ devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg.staging',
6833
8220
  }),
6834
8221
  ],
6835
8222
  })
@@ -6840,51 +8227,59 @@ describe('Protocol V2 firmware update targets', () => {
6840
8227
  }
6841
8228
  });
6842
8229
 
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;
8230
+ test('accepts every okpkg in a local resource ZIP and ignores other files', async () => {
8231
+ const imagesBinary = createProtocolV2OkppBinary();
8232
+ const bootResourceBinary = createProtocolV2OkppBinary({
8233
+ devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg.staging',
8234
+ });
8235
+ const zip = new JSZip();
8236
+ zip.file('pro2-resource/bundles/images/images.okpkg', imagesBinary);
8237
+ zip.file('pro2-resource/loaders/bootloader/boot_resource.okpkg', bootResourceBinary);
8238
+ zip.file('pro2-resource/build-info.txt', 'ignored');
8239
+ zip.file(
8240
+ '__MACOSX/pro2-resource 2/bundles/._images.okpkg',
8241
+ new Uint8Array([0x00, 0x05, 0x16, 0x07])
8242
+ );
8243
+ const method = new FirmwareUpdateV4({
8244
+ id: 1,
8245
+ payload: {
8246
+ method: 'firmwareUpdateV4',
8247
+ platform: 'web',
8248
+ targetsToUpdate: ['resource'],
8249
+ },
8250
+ });
8251
+ method.init();
8252
+
8253
+ await expect(
8254
+ (method as any).prepareProtocolV2LocalResourceArchive(
8255
+ await zip.generateAsync({ type: 'arraybuffer' })
8256
+ )
8257
+ ).resolves.toMatchObject({
8258
+ resources: [
8259
+ {
8260
+ entryName: 'pro2-resource/bundles/images/images.okpkg',
8261
+ binary: imagesBinary,
8262
+ header: expect.objectContaining({
8263
+ devicePath: 'vol0:/bundles/images/images.okpkg',
8264
+ }),
8265
+ },
8266
+ {
8267
+ entryName: 'pro2-resource/loaders/bootloader/boot_resource.okpkg',
8268
+ binary: bootResourceBinary,
8269
+ header: expect.objectContaining({
8270
+ devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg.staging',
8271
+ }),
8272
+ },
8273
+ ],
8274
+ });
8275
+ });
8276
+
8277
+ test('rejects a local resource ZIP with duplicate package device paths', async () => {
8278
+ const resourceBinary = createProtocolV2OkppBinary();
8279
+ const duplicateResourceBinary = createProtocolV2OkppBinary({ payloadHashByte: 0x44 });
6846
8280
  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
8281
  zip.file('bundles/images/images.okpkg', resourceBinary);
6887
- zip.file('loaders/bootloader/boot_resource.okpkg', bootResourceBinary);
8282
+ zip.file('copies/images.okpkg', duplicateResourceBinary);
6888
8283
  const method = new FirmwareUpdateV4({
6889
8284
  id: 1,
6890
8285
  payload: {
@@ -6899,9 +8294,7 @@ describe('Protocol V2 firmware update targets', () => {
6899
8294
  (method as any).prepareProtocolV2LocalResourceArchive(
6900
8295
  await zip.generateAsync({ type: 'arraybuffer' })
6901
8296
  )
6902
- ).rejects.toThrow(
6903
- 'Protocol V2 local resource file does not match manifest: bundles/images/images.okpkg'
6904
- );
8297
+ ).rejects.toThrow('duplicate device path: vol0:/bundles/images/images.okpkg');
6905
8298
  });
6906
8299
 
6907
8300
  test('maps malformed resource ZIPs to a typed firmware preparation error', async () => {
@@ -6930,18 +8323,24 @@ describe('Protocol V2 firmware update targets', () => {
6930
8323
 
6931
8324
  test('rejects an oversized ZIP entry before allocating its decompressed bytes', async () => {
6932
8325
  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
- },
8326
+ const oversizedFileSize = 257 * 1024 * 1024;
8327
+ const zipFiles = {
8328
+ 'loaders/bootloader/boot_resource.okpkg': {
8329
+ name: 'loaders/bootloader/boot_resource.okpkg',
8330
+ dir: false,
8331
+ _data: { compressedSize: 1, uncompressedSize: oversizedFileSize },
8332
+ async: extractEntry,
8333
+ },
8334
+ 'bundles/images/images.okpkg': {
8335
+ name: 'bundles/images/images.okpkg',
8336
+ dir: false,
8337
+ _data: { compressedSize: 1, uncompressedSize: oversizedFileSize },
8338
+ async: extractEntry,
6944
8339
  },
8340
+ };
8341
+ const loadSpy = jest.spyOn(JSZip, 'loadAsync').mockResolvedValue({
8342
+ files: zipFiles,
8343
+ file: (name: string) => zipFiles[name as keyof typeof zipFiles] ?? null,
6945
8344
  } as unknown as JSZip);
6946
8345
  const method = new FirmwareUpdateV4({
6947
8346
  id: 1,
@@ -6956,7 +8355,7 @@ describe('Protocol V2 firmware update targets', () => {
6956
8355
  try {
6957
8356
  await expect(
6958
8357
  (method as any).prepareProtocolV2LocalResourceArchive(new Uint8Array([1]).buffer)
6959
- ).rejects.toThrow('declared size exceeds the allowed limit');
8358
+ ).rejects.toThrow('resource package size is invalid');
6960
8359
  expect(extractEntry).not.toHaveBeenCalled();
6961
8360
  } finally {
6962
8361
  loadSpy.mockRestore();
@@ -7108,17 +8507,17 @@ describe('Protocol V2 firmware update targets', () => {
7108
8507
  readAt: jest.fn(),
7109
8508
  close: jest.fn(),
7110
8509
  },
7111
- devicePath: 'vol0:/resource/images/images.okpkg',
8510
+ devicePath: 'vol0:/bundles/images/images.okpkg',
7112
8511
  },
7113
8512
  ],
7114
8513
  });
7115
8514
 
7116
8515
  expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenCalledTimes(1);
7117
8516
  expect((method as any).protocolV2SourceUpdateProcess).toHaveBeenCalledWith(
7118
- expect.objectContaining({ filePath: 'vol0:/resource/images/images.okpkg' })
8517
+ expect.objectContaining({ filePath: 'vol0:/bundles/images/images.okpkg' })
7119
8518
  );
7120
8519
  expect((method as any).verifyProtocolV2StagedFile).toHaveBeenCalledWith(
7121
- 'vol0:/resource/images/images.okpkg',
8520
+ 'vol0:/bundles/images/images.okpkg',
7122
8521
  3
7123
8522
  );
7124
8523
  expect((method as any).protocolV2StartFirmwareUpdate).not.toHaveBeenCalled();
@@ -7137,6 +8536,9 @@ describe('Protocol V2 firmware update targets', () => {
7137
8536
  method.postProgressMessage = jest.fn();
7138
8537
  (method as any).protocolV2SourceUpdateProcess = jest.fn().mockResolvedValue(3);
7139
8538
  (method as any).enterProtocolV2BootloaderMode = jest.fn().mockResolvedValue(undefined);
8539
+ (method as any).ensureProtocolV2BootResourceStagingIsEmpty = jest
8540
+ .fn()
8541
+ .mockResolvedValue(undefined);
7140
8542
  (method as any).completeProtocolV2FinalVerification = jest.fn().mockResolvedValue({});
7141
8543
  (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
7142
8544
 
@@ -7150,7 +8552,7 @@ describe('Protocol V2 firmware update targets', () => {
7150
8552
  readAt: jest.fn(),
7151
8553
  close: jest.fn(),
7152
8554
  },
7153
- devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg',
8555
+ devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg.staging',
7154
8556
  },
7155
8557
  ],
7156
8558
  });
@@ -7162,7 +8564,7 @@ describe('Protocol V2 firmware update targets', () => {
7162
8564
  expect((method as any).verifyProtocolV2StagedFile).toHaveBeenCalledWith(stagingPath, 3);
7163
8565
  });
7164
8566
 
7165
- test('rewrites the mounted boot resource staging file even when the final file is current', async () => {
8567
+ test('skips boot resource staging when the live package hash matches', async () => {
7166
8568
  const method = new FirmwareUpdateV4({
7167
8569
  id: 1,
7168
8570
  payload: {
@@ -7183,7 +8585,7 @@ describe('Protocol V2 firmware update targets', () => {
7183
8585
  readAt: jest.fn(),
7184
8586
  close: jest.fn(),
7185
8587
  },
7186
- devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg',
8588
+ devicePath: 'vol0:/loaders/bootloader/boot_resource.okpkg.staging',
7187
8589
  version: [1, 0, 0],
7188
8590
  payloadHash: '11'.repeat(64),
7189
8591
  headerHash: '22'.repeat(64),
@@ -7194,12 +8596,62 @@ describe('Protocol V2 firmware update targets', () => {
7194
8596
  resourceSources: [resource],
7195
8597
  });
7196
8598
 
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 })
8599
+ expect((method as any).isProtocolV2ResourceBundleUpToDate).toHaveBeenCalledWith(resource);
8600
+ expect((method as any).protocolV2SourceUpdateProcess).not.toHaveBeenCalled();
8601
+ });
8602
+
8603
+ test('compares boot resource staging against the live mounted package path', () => {
8604
+ const method = new FirmwareUpdateV4({
8605
+ id: 1,
8606
+ payload: {
8607
+ method: 'firmwareUpdateV4',
8608
+ },
8609
+ });
8610
+
8611
+ expect(
8612
+ (method as any).getProtocolV2ResourceComparePath(
8613
+ 'vol0:/loaders/bootloader/boot_resource.okpkg.staging'
8614
+ )
8615
+ ).toBe('vol0:/loaders/bootloader/boot_resource.okpkg');
8616
+ expect((method as any).getProtocolV2ResourceComparePath('vol0:/bundles/images.okpkg')).toBe(
8617
+ 'vol0:/bundles/images.okpkg'
7201
8618
  );
7202
- expect((method as any).verifyProtocolV2StagedFile).toHaveBeenCalledWith(stagingPath, 3);
8619
+ });
8620
+
8621
+ test('skips bootloader params staging when the on-device package is already current', async () => {
8622
+ const method = new FirmwareUpdateV4({
8623
+ id: 1,
8624
+ payload: {
8625
+ method: 'firmwareUpdateV4',
8626
+ },
8627
+ });
8628
+
8629
+ method.postTipMessage = jest.fn();
8630
+ method.postProgressMessage = jest.fn();
8631
+ (method as any).isProtocolV2ResourceBundleUpToDate = jest.fn().mockResolvedValue(true);
8632
+ (method as any).protocolV2SourceUpdateProcess = jest.fn().mockResolvedValue(3);
8633
+ (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
8634
+
8635
+ const resource = {
8636
+ name: 'params.okpkg',
8637
+ source: {
8638
+ size: 3,
8639
+ readAt: jest.fn(),
8640
+ close: jest.fn(),
8641
+ },
8642
+ devicePath: 'vol0:/loaders/bootloader/params.okpkg.staging',
8643
+ version: [1, 0, 0],
8644
+ payloadHash: '11'.repeat(64),
8645
+ headerHash: '22'.repeat(64),
8646
+ };
8647
+
8648
+ await (method as any).executeProtocolV2TransferPhase({
8649
+ installSources: [],
8650
+ resourceSources: [resource],
8651
+ });
8652
+
8653
+ expect((method as any).isProtocolV2ResourceBundleUpToDate).toHaveBeenCalledWith(resource);
8654
+ expect((method as any).protocolV2SourceUpdateProcess).not.toHaveBeenCalled();
7203
8655
  });
7204
8656
 
7205
8657
  test('uses absolute processed_byte offsets and disables append for firmware file writes', async () => {
@@ -7249,7 +8701,7 @@ describe('Protocol V2 firmware update targets', () => {
7249
8701
  expect(writePayloads.map(payload => payload.file.data.byteLength)).toEqual([4000, 97]);
7250
8702
  expect(writePayloads.map(payload => payload.overwrite)).toEqual([true, false]);
7251
8703
  expect(writePayloads.every(payload => payload.append === false)).toBe(true);
7252
- expect(writePayloads.map(payload => payload.ui_percentage)).toEqual([0, 100]);
8704
+ expect(writePayloads.map(payload => payload.ui_percentage)).toEqual([0, 99]);
7253
8705
  expect(method.postMessage).toHaveBeenLastCalledWith({
7254
8706
  event: 'UI_EVENT',
7255
8707
  type: UI_REQUEST.FIRMWARE_PROGRESS,
@@ -7263,6 +8715,176 @@ describe('Protocol V2 firmware update targets', () => {
7263
8715
  });
7264
8716
  });
7265
8717
 
8718
+ test('does not retry or wrap a stale BLE bond during a V4 file transfer', async () => {
8719
+ const method = new FirmwareUpdateV4({
8720
+ id: 1,
8721
+ payload: {
8722
+ method: 'firmwareUpdateV4',
8723
+ },
8724
+ });
8725
+ const staleBondError = ERRORS.TypedError(HardwareErrorCode.BlePeerRemovedPairingInformation);
8726
+ (method as any).fileWriteChunk = jest.fn().mockRejectedValue(staleBondError);
8727
+ const recoverProtocolV2FileTransfer = jest.fn();
8728
+ (method as any).recoverProtocolV2FileTransfer = recoverProtocolV2FileTransfer;
8729
+ const source = await openFirmwareByteSource({
8730
+ binary: new Uint8Array([1]).buffer,
8731
+ });
8732
+
8733
+ try {
8734
+ await expect(
8735
+ (method as any).protocolV2SourceUpdateProcess({
8736
+ source,
8737
+ filePath: 'vol1:firmware.bin',
8738
+ processedSize: 0,
8739
+ totalSize: 1,
8740
+ })
8741
+ ).rejects.toBe(staleBondError);
8742
+ } finally {
8743
+ await source?.close();
8744
+ }
8745
+ expect(recoverProtocolV2FileTransfer).not.toHaveBeenCalled();
8746
+ });
8747
+
8748
+ test('throttles repeated transfer progress while preserving file completion', async () => {
8749
+ const method = new FirmwareUpdateV4({
8750
+ id: 1,
8751
+ payload: {
8752
+ method: 'firmwareUpdateV4',
8753
+ },
8754
+ });
8755
+ const typedCall = jest.fn(
8756
+ (
8757
+ _name: string,
8758
+ _resType: string,
8759
+ params: { file: { offset: number; data: { byteLength: number } } }
8760
+ ) =>
8761
+ Promise.resolve({
8762
+ type: 'FilesystemFile',
8763
+ message: {
8764
+ processed_byte: params.file.offset + params.file.data.byteLength,
8765
+ },
8766
+ })
8767
+ );
8768
+
8769
+ (method as any).device = stubDevice({
8770
+ getCommands: () => ({ typedCall }),
8771
+ getCurrentDeviceType: () => 'pro2',
8772
+ });
8773
+ (method as any).getProtocolV2FirmwareChunkSize = jest.fn().mockReturnValue(1000);
8774
+ method.postProgressMessage = jest.fn();
8775
+ const dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(10_000);
8776
+
8777
+ const source = await openFirmwareByteSource({
8778
+ binary: new Uint8Array(2500).buffer,
8779
+ });
8780
+ try {
8781
+ await (method as any).protocolV2SourceUpdateProcess({
8782
+ source,
8783
+ filePath: 'vol0:/resource/images/images.okpkg',
8784
+ processedSize: 0,
8785
+ totalSize: 1_000_000,
8786
+ });
8787
+ } finally {
8788
+ dateNowSpy.mockRestore();
8789
+ await source?.close();
8790
+ }
8791
+
8792
+ expect(typedCall).toHaveBeenCalledTimes(3);
8793
+ expect(method.postProgressMessage).toHaveBeenCalledTimes(2);
8794
+ expect(method.postProgressMessage).toHaveBeenNthCalledWith(
8795
+ 1,
8796
+ 1,
8797
+ 'transferData',
8798
+ expect.objectContaining({ transferredBytes: 1000 })
8799
+ );
8800
+ expect(method.postProgressMessage).toHaveBeenNthCalledWith(
8801
+ 2,
8802
+ 1,
8803
+ 'transferData',
8804
+ expect.objectContaining({ transferredBytes: 2500 })
8805
+ );
8806
+ });
8807
+
8808
+ test('sends one monotonic device transfer progress range across multiple files', async () => {
8809
+ const method = new FirmwareUpdateV4({
8810
+ id: 1,
8811
+ payload: {
8812
+ method: 'firmwareUpdateV4',
8813
+ },
8814
+ });
8815
+ const typedCall = jest.fn(
8816
+ (
8817
+ _name: string,
8818
+ _resType: string,
8819
+ params: { file: { offset: number; data: { byteLength: number } } }
8820
+ ) =>
8821
+ Promise.resolve({
8822
+ type: 'FilesystemFile',
8823
+ message: {
8824
+ processed_byte: params.file.offset + params.file.data.byteLength,
8825
+ },
8826
+ })
8827
+ );
8828
+
8829
+ (method as any).device = stubDevice({
8830
+ getCommands: () => ({ typedCall }),
8831
+ getCurrentDeviceType: () => 'pro2',
8832
+ toMessageObject: () => ({ connectId: 'firmware-device' }),
8833
+ });
8834
+ method.postMessage = jest.fn();
8835
+ method.postTipMessage = jest.fn();
8836
+ method.postProgressMessage = jest.fn();
8837
+ (method as any).isProtocolV2ResourceBundleUpToDate = jest.fn().mockResolvedValue(false);
8838
+ (method as any).verifyProtocolV2StagedFile = jest.fn().mockResolvedValue(undefined);
8839
+ (method as any).protocolV2StartFirmwareUpdate = jest.fn().mockResolvedValue(undefined);
8840
+ (method as any).waitForProtocolV2FirmwareUpdateComplete = jest
8841
+ .fn()
8842
+ .mockResolvedValue(undefined);
8843
+
8844
+ const resourceSource = await openFirmwareByteSource({
8845
+ binary: new Uint8Array(4097).buffer,
8846
+ });
8847
+ const firmwareSource = await openFirmwareByteSource({
8848
+ binary: new Uint8Array(4097).buffer,
8849
+ });
8850
+ try {
8851
+ await (method as any).executeProtocolV2TransferPhase({
8852
+ resourceSources: [
8853
+ {
8854
+ name: 'images.okpkg',
8855
+ source: resourceSource,
8856
+ devicePath: 'vol0:/resource/images/images.okpkg',
8857
+ },
8858
+ ],
8859
+ installSources: [
8860
+ {
8861
+ fileName: 'application_p1.bin',
8862
+ source: firmwareSource,
8863
+ targetId: 4,
8864
+ kind: 'firmware',
8865
+ },
8866
+ ],
8867
+ });
8868
+ } finally {
8869
+ await resourceSource?.close();
8870
+ await firmwareSource?.close();
8871
+ }
8872
+
8873
+ const writePayloads = typedCall.mock.calls.map(call => call[2]);
8874
+ const deviceProgress = writePayloads.map(payload => payload.ui_percentage);
8875
+ expect(deviceProgress).toEqual([0, 50, 99, 99]);
8876
+ expect(deviceProgress.filter(progress => progress === 0)).toHaveLength(1);
8877
+ expect(deviceProgress.filter(progress => progress === 100)).toHaveLength(0);
8878
+ expect(method.postTipMessage).toHaveBeenCalledTimes(2);
8879
+ expect(method.postTipMessage).toHaveBeenNthCalledWith(1, 'StartTransferData');
8880
+ expect(method.postTipMessage).toHaveBeenNthCalledWith(2, 'ConfirmOnDevice');
8881
+ expect(
8882
+ (method.postProgressMessage as jest.Mock).mock.calls.filter(
8883
+ ([progress, progressType]) => progress === 100 && progressType === 'transferData'
8884
+ )
8885
+ ).toHaveLength(1);
8886
+ });
8887
+
7266
8888
  // TODO(#850/#855): PR #855 added resume-on-retry and per-chunk retry on the
7267
8889
  // writeProtocolV2File path. PR #850 replaced that path with FirmwareByteSource
7268
8890
  // streaming (protocolV2SourceUpdateProcess), which restarts a failed transfer from
@@ -7570,7 +9192,7 @@ describe('Protocol V2 firmware update targets', () => {
7570
9192
  const writePayloads = typedCall.mock.calls.map(call => call[2]);
7571
9193
  expect(writePayloads.map(payload => payload.file.offset)).toEqual([0, 1800]);
7572
9194
  expect(writePayloads.map(payload => payload.file.data.byteLength)).toEqual([1800, 1]);
7573
- expect(writePayloads.map(payload => payload.ui_percentage)).toEqual([0, 100]);
9195
+ expect(writePayloads.map(payload => payload.ui_percentage)).toEqual([0, 99]);
7574
9196
  expect(typedCall.mock.calls.map(call => call[3])).toEqual([
7575
9197
  expect.objectContaining({
7576
9198
  writeWithResponse: false,
@@ -7629,45 +9251,67 @@ describe('Protocol V2 firmware update targets', () => {
7629
9251
  expect(writePayloads.map(payload => payload.file.data.byteLength)).toEqual([1960, 1]);
7630
9252
  });
7631
9253
 
7632
- test('ends device confirmation and starts install progress only after Protocol V2 ACK', async () => {
9254
+ test('keeps confirmation active after the Request write until terminal Success arrives', async () => {
7633
9255
  const method = new FirmwareUpdateV4({
7634
9256
  id: 1,
7635
9257
  payload: {
7636
9258
  method: 'firmwareUpdateV4',
7637
9259
  },
7638
9260
  });
7639
- let resolveRequest: ((value: unknown) => void) | undefined;
7640
- const typedCall = jest.fn().mockImplementation(
7641
- () =>
7642
- new Promise(resolve => {
7643
- resolveRequest = resolve;
7644
- })
7645
- );
9261
+ let onResponseAfterWrite:
9262
+ | ((response: { type: string; message: Record<string, never> }) => void)
9263
+ | undefined;
9264
+ const typedCall = jest.fn().mockResolvedValue({ type: 'Success', message: {} });
9265
+ const call = jest.fn().mockImplementation((_type, _message, options) => {
9266
+ onResponseAfterWrite = options.onResponseAfterWrite;
9267
+ return Promise.resolve({ type: 'WriteCompleted', message: {} });
9268
+ });
9269
+ const clearCancelableAction = jest.fn();
7646
9270
 
7647
9271
  (method as any).device = stubDevice({
7648
- getCommands: () => ({ typedCall }),
9272
+ getCommands: () => ({ typedCall, call, cancelDevice: jest.fn() }),
9273
+ createProtocolV2UiPhaseMetadata: jest.fn().mockReturnValue(undefined),
9274
+ toMessageObject: jest.fn().mockReturnValue({ connectId: 'pro2' }),
9275
+ clearCancelableAction,
7649
9276
  });
9277
+ method.postMessage = jest.fn();
7650
9278
  method.postProgressMessage = jest.fn();
7651
9279
  method.postTipMessage = jest.fn();
7652
9280
 
7653
9281
  const startPromise = (method as any).protocolV2StartFirmwareUpdate({
7654
9282
  targets: [{ target_id: 4, path: 'vol1:firmware.bin' }],
7655
9283
  });
7656
- await Promise.resolve();
9284
+ await new Promise<void>(resolve => {
9285
+ setImmediate(resolve);
9286
+ });
7657
9287
 
7658
- expect(typedCall.mock.calls[0][1]).toBe('Success');
7659
- expect(typedCall.mock.calls[0][3]).toEqual(expect.objectContaining({ timeoutMs: 180_000 }));
9288
+ expect(typedCall.mock.calls[0]).toEqual([
9289
+ 'DeviceFirmwareUpdateStage',
9290
+ 'Success',
9291
+ { targets: [{ target_id: 4, path: 'vol1:firmware.bin' }] },
9292
+ ]);
9293
+ expect(call).toHaveBeenCalledWith(
9294
+ 'DeviceFirmwareUpdateRequest',
9295
+ {},
9296
+ expect.objectContaining({
9297
+ returnAfterWrite: true,
9298
+ expectedTypes: ['Success'],
9299
+ onResponseAfterWrite: expect.any(Function),
9300
+ })
9301
+ );
7660
9302
  expect(method.postTipMessage).not.toHaveBeenCalled();
7661
9303
  expect(method.postProgressMessage).not.toHaveBeenCalled();
9304
+ expect(clearCancelableAction).not.toHaveBeenCalled();
7662
9305
 
7663
- resolveRequest?.({ type: 'Success', message: { message: 'ok' } });
7664
9306
  await startPromise;
7665
- expect(method.postTipMessage).toHaveBeenCalledWith('FirmwareUpdating');
7666
- expect(method.postProgressMessage).toHaveBeenCalledWith(0, 'installingFirmware');
7667
- expect(method.postTipMessage).toHaveBeenCalledTimes(1);
9307
+ onResponseAfterWrite?.({ type: 'Success', message: {} });
9308
+ expect(clearCancelableAction).toHaveBeenCalledTimes(1);
9309
+ expect((method as any).protocolV2InstallTerminalSuccessObserved).toBe(true);
9310
+ expect(method.postTipMessage).not.toHaveBeenCalled();
9311
+ expect(method.postProgressMessage).not.toHaveBeenCalled();
7668
9312
  });
7669
9313
 
7670
- test('requests only a Success ACK for Protocol V2 firmware install', async () => {
9314
+ test('returns after the empty Protocol V2 firmware install request is written', async () => {
7671
9315
  const method = new FirmwareUpdateV4({
7672
9316
  id: 1,
7673
9317
  payload: {
@@ -7678,10 +9322,17 @@ describe('Protocol V2 firmware update targets', () => {
7678
9322
  type: 'Success',
7679
9323
  message: { message: 'accepted' },
7680
9324
  });
9325
+ const call = jest.fn().mockResolvedValue({
9326
+ type: 'WriteCompleted',
9327
+ message: {},
9328
+ });
7681
9329
 
7682
9330
  (method as any).device = stubDevice({
7683
- getCommands: () => ({ typedCall }),
9331
+ getCommands: () => ({ typedCall, call, cancelDevice: jest.fn() }),
9332
+ createProtocolV2UiPhaseMetadata: jest.fn().mockReturnValue(undefined),
9333
+ toMessageObject: jest.fn().mockReturnValue({ connectId: 'pro2' }),
7684
9334
  });
9335
+ method.postMessage = jest.fn();
7685
9336
  method.postProgressMessage = jest.fn();
7686
9337
  method.postTipMessage = jest.fn();
7687
9338
 
@@ -7689,13 +9340,23 @@ describe('Protocol V2 firmware update targets', () => {
7689
9340
  targets: [{ target_id: 4, path: 'vol1:firmware.bin' }],
7690
9341
  });
7691
9342
 
7692
- expect(typedCall.mock.calls[0][1]).toBe('Success');
7693
- expect(method.postTipMessage).toHaveBeenCalledWith('FirmwareUpdating');
9343
+ expect(typedCall.mock.calls[0][0]).toBe('DeviceFirmwareUpdateStage');
9344
+ expect(call).toHaveBeenCalledWith(
9345
+ 'DeviceFirmwareUpdateRequest',
9346
+ {},
9347
+ expect.objectContaining({
9348
+ returnAfterWrite: true,
9349
+ expectedTypes: ['Success'],
9350
+ onResponseAfterWrite: expect.any(Function),
9351
+ })
9352
+ );
9353
+ expect(method.postTipMessage).not.toHaveBeenCalled();
9354
+ expect(method.postProgressMessage).not.toHaveBeenCalled();
7694
9355
  });
7695
9356
  });
7696
9357
 
7697
9358
  describe('Protocol V2 firmware update method', () => {
7698
- test('returns DeviceFirmwareUpdateStatus from low-level update trigger', async () => {
9359
+ test('stages targets before sending the empty low-level update trigger', async () => {
7699
9360
  const method = new DeviceFirmwareUpdate({
7700
9361
  id: 1,
7701
9362
  payload: {
@@ -7706,22 +9367,25 @@ describe('Protocol V2 firmware update method', () => {
7706
9367
  });
7707
9368
  method.init();
7708
9369
 
7709
- const typedCall = jest.fn().mockResolvedValue({
7710
- type: 'DeviceFirmwareUpdateStatus',
7711
- message: { records: [{ target_id: 4, status: 1 }] },
7712
- });
9370
+ const typedCall = jest.fn().mockResolvedValue({ type: 'Success', message: {} });
9371
+ const call = jest.fn().mockResolvedValue({ type: 'WriteCompleted', message: {} });
7713
9372
 
7714
9373
  (method as any).device = stubDevice({
7715
- commands: { typedCall },
9374
+ commands: { typedCall, call },
7716
9375
  });
7717
9376
 
7718
9377
  await expect(method.run()).resolves.toEqual({
7719
- records: [{ target_id: 4, status: 1 }],
9378
+ message: 'Device firmware update started',
7720
9379
  });
7721
- expect(typedCall.mock.calls[0][1]).toEqual(['Success', 'DeviceFirmwareUpdateStatus']);
7722
- expect(typedCall.mock.calls[0][2]).toEqual({
9380
+ expect(typedCall).toHaveBeenCalledWith('DeviceFirmwareUpdateStage', 'Success', {
7723
9381
  targets: [{ target_id: 4, path: 'vol0:firmware.bin' }],
7724
9382
  });
9383
+ expect(call).toHaveBeenCalledWith(
9384
+ 'DeviceFirmwareUpdateRequest',
9385
+ {},
9386
+ { returnAfterWrite: true }
9387
+ );
9388
+ expect(typedCall.mock.invocationCallOrder[0]).toBeLessThan(call.mock.invocationCallOrder[0]);
7725
9389
  });
7726
9390
 
7727
9391
  test('treats WebUSB open NotFoundError from low-level update trigger as expected disconnect', async () => {
@@ -7820,6 +9484,7 @@ describe('Protocol V2 firmware update method', () => {
7820
9484
 
7821
9485
  test('accepts targetId alias inside firmware targets', async () => {
7822
9486
  const typedCall = jest.fn().mockResolvedValue({ message: {} });
9487
+ const call = jest.fn().mockResolvedValue({ type: 'WriteCompleted', message: {} });
7823
9488
  const method = new DeviceFirmwareUpdate({
7824
9489
  id: 1,
7825
9490
  payload: {
@@ -7835,7 +9500,7 @@ describe('Protocol V2 firmware update method', () => {
7835
9500
  });
7836
9501
  method.init();
7837
9502
  (method as any).device = stubDevice({
7838
- commands: { typedCall },
9503
+ commands: { typedCall, call },
7839
9504
  });
7840
9505
 
7841
9506
  await method.run();
@@ -7846,6 +9511,7 @@ describe('Protocol V2 firmware update method', () => {
7846
9511
 
7847
9512
  test('accepts firmware target enum names from low-level update params', async () => {
7848
9513
  const typedCall = jest.fn().mockResolvedValue({ message: {} });
9514
+ const call = jest.fn().mockResolvedValue({ type: 'WriteCompleted', message: {} });
7849
9515
  const method = new DeviceFirmwareUpdate({
7850
9516
  id: 1,
7851
9517
  payload: {
@@ -7856,7 +9522,7 @@ describe('Protocol V2 firmware update method', () => {
7856
9522
  });
7857
9523
  method.init();
7858
9524
  (method as any).device = stubDevice({
7859
- commands: { typedCall },
9525
+ commands: { typedCall, call },
7860
9526
  });
7861
9527
 
7862
9528
  await method.run();
@@ -7868,7 +9534,16 @@ describe('Protocol V2 firmware update method', () => {
7868
9534
  test('passes firmware update status fields through to Protocol V2', async () => {
7869
9535
  const typedCall = jest.fn().mockResolvedValue({
7870
9536
  message: {
7871
- records: [{ target_id: 4, status: 2, payload_version: 1, path: 'vol1:application_p1.bin' }],
9537
+ records: [
9538
+ {
9539
+ target_id: 4,
9540
+ status: 1,
9541
+ progress_percent: 42,
9542
+ phase_info: { phase: 1, progress_percent: 60 },
9543
+ payload_version: 1,
9544
+ path: 'vol1:application_p1.bin',
9545
+ },
9546
+ ],
7872
9547
  },
7873
9548
  });
7874
9549
  const method = new DeviceGetFirmwareUpdateStatus({
@@ -7877,6 +9552,8 @@ describe('Protocol V2 firmware update method', () => {
7877
9552
  method: 'deviceGetFirmwareUpdateStatus',
7878
9553
  fields: {
7879
9554
  status: true,
9555
+ progress_percent: true,
9556
+ phase_info: true,
7880
9557
  payload_version: true,
7881
9558
  path: true,
7882
9559
  },
@@ -7888,7 +9565,16 @@ describe('Protocol V2 firmware update method', () => {
7888
9565
  });
7889
9566
 
7890
9567
  await expect(method.run()).resolves.toEqual({
7891
- records: [{ target_id: 4, status: 2, payload_version: 1, path: 'vol1:application_p1.bin' }],
9568
+ records: [
9569
+ {
9570
+ target_id: 4,
9571
+ status: 1,
9572
+ progress_percent: 42,
9573
+ phase_info: { phase: 1, progress_percent: 60 },
9574
+ payload_version: 1,
9575
+ path: 'vol1:application_p1.bin',
9576
+ },
9577
+ ],
7892
9578
  });
7893
9579
  expect(typedCall).toHaveBeenCalledWith(
7894
9580
  'DeviceFirmwareUpdateStatusGet',
@@ -7896,6 +9582,8 @@ describe('Protocol V2 firmware update method', () => {
7896
9582
  {
7897
9583
  fields: {
7898
9584
  status: true,
9585
+ progress_percent: true,
9586
+ phase_info: true,
7899
9587
  payload_version: true,
7900
9588
  path: true,
7901
9589
  },
@@ -8088,7 +9776,7 @@ describe('Protocol V2 firmware reconnect identity', () => {
8088
9776
  const readChunkLengths: number[] = [];
8089
9777
  const typedCall = jest.fn((requestType: string, _responseType: string, request: any) => {
8090
9778
  if (requestType === 'FilesystemPathInfoQuery') {
8091
- return Promise.resolve({ message: { exist: true, directory: false, size: 0x52a0 } });
9779
+ return Promise.resolve({ message: { exist: true, directory: false, size: 0x5f91 } });
8092
9780
  }
8093
9781
  if (requestType === 'FilesystemFileRead') {
8094
9782
  readChunkLengths.push(request.chunk_len);
@@ -8255,6 +9943,8 @@ describe('Protocol V2 reboot methods', () => {
8255
9943
  const device = stubDevice({ commands: { typedCall } });
8256
9944
  (method as any).device = device;
8257
9945
 
9946
+ expect(method.unlockPolicy).toBe('unlock-before-run');
9947
+
8258
9948
  await method.run();
8259
9949
 
8260
9950
  expect(typedCall).toHaveBeenCalledWith('DeviceReboot', 'Success', {
@@ -8584,6 +10274,29 @@ describe('Protocol V2 protected method execution', () => {
8584
10274
  }
8585
10275
  );
8586
10276
 
10277
+ test('skips DeviceStatus and unlock when Protocol V2 is not initialized', async () => {
10278
+ const method = {
10279
+ name: 'firmwareUpdateV4',
10280
+ unlockPolicy: 'unlock-before-run',
10281
+ run: jest.fn().mockResolvedValue({ message: 'updating' }),
10282
+ };
10283
+ const device = {
10284
+ state: { status: { initialized: false } },
10285
+ commands: { typedCall: jest.fn() },
10286
+ isProtocolV2: () => true,
10287
+ isBootloader: () => false,
10288
+ isRomloader: () => false,
10289
+ unlockDevice: jest.fn(),
10290
+ };
10291
+
10292
+ await expect(runMethodWithUnlockPolicy(method as any, device as any)).resolves.toEqual({
10293
+ message: 'updating',
10294
+ });
10295
+ expect(device.commands.typedCall).not.toHaveBeenCalled();
10296
+ expect(device.unlockDevice).not.toHaveBeenCalled();
10297
+ expect(method.run).toHaveBeenCalledTimes(1);
10298
+ });
10299
+
8587
10300
  test('keeps Protocol V1 and lock-free methods outside the preflight path', async () => {
8588
10301
  for (const [isProtocolV2, unlockPolicy] of [
8589
10302
  [false, 'unlock-before-run'],
@@ -8763,7 +10476,7 @@ describe('Protocol V2 current low-level methods', () => {
8763
10476
  info: {
8764
10477
  version: undefined,
8765
10478
  serial_number: 'PR2SERIAL',
8766
- burn_in_completed: true,
10479
+ factory_burn_in_completed: true,
8767
10480
  factory_test_completed: undefined,
8768
10481
  manufacture_time: undefined,
8769
10482
  },
@@ -9013,7 +10726,7 @@ describe('Protocol V2 raw device info method', () => {
9013
10726
  'DeviceInfoGet',
9014
10727
  'DeviceInfo',
9015
10728
  {
9016
- targets: { hw: true, fw: true, coprocessor: true },
10729
+ targets: { hw: true, main_mcu: true, coprocessor: true },
9017
10730
  types: { version: true, specific: true },
9018
10731
  },
9019
10732
  { timeoutMs: PROTOCOL_V2_DEVICE_INFO_TIMEOUT_MS }