@onekeyfe/hd-core 1.2.0-alpha.19 → 1.2.0-alpha.20

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 (256) hide show
  1. package/__tests__/AllNetworkGetAddressBase.tracing.test.ts +75 -0
  2. package/__tests__/DeviceCommands.test.ts +143 -7
  3. package/__tests__/RequestQueue.test.ts +106 -0
  4. package/__tests__/UploadPortfolio.test.ts +46 -0
  5. package/__tests__/core-dispose.test.ts +34 -0
  6. package/__tests__/core-error-output.test.ts +39 -0
  7. package/__tests__/device-lifecycle-events.test.ts +117 -0
  8. package/__tests__/device-pool-state.test.ts +20 -1
  9. package/__tests__/device-settings.test.ts +184 -6
  10. package/__tests__/device-state-events.test.ts +1 -2
  11. package/__tests__/device-state-mapper.test.ts +33 -26
  12. package/__tests__/device-state-store.test.ts +1 -34
  13. package/__tests__/device-utils.test.ts +106 -0
  14. package/__tests__/device-wallet-session-store.test.ts +65 -29
  15. package/__tests__/deviceSettings.test.ts +21 -12
  16. package/__tests__/evmLedgerLegacySafety.test.ts +2 -2
  17. package/__tests__/evmSignTypedData.test.ts +80 -0
  18. package/__tests__/filesystemValidation.test.ts +53 -0
  19. package/__tests__/firmware-update/firmware-update-v2-download-before-boot.test.ts +402 -0
  20. package/__tests__/get-device-state.test.ts +77 -41
  21. package/__tests__/logBlockEvent.test.ts +24 -11
  22. package/__tests__/networkUtils.test.ts +386 -0
  23. package/__tests__/open-wallet-session-error-response.test.ts +43 -0
  24. package/__tests__/open-wallet-session.test.ts +559 -0
  25. package/__tests__/protocol-v2.test.ts +756 -452
  26. package/__tests__/public-device-state-api.test.ts +113 -13
  27. package/__tests__/public-pro2-api-boundary.test.ts +91 -0
  28. package/__tests__/search-devices.test.ts +43 -0
  29. package/dist/api/BaseMethod.d.ts +3 -0
  30. package/dist/api/BaseMethod.d.ts.map +1 -1
  31. package/dist/api/CheckBootloaderRelease.d.ts.map +1 -1
  32. package/dist/api/CheckFirmwareRelease.d.ts.map +1 -1
  33. package/dist/api/ClearSessionCache.d.ts.map +1 -1
  34. package/dist/api/DirList.d.ts.map +1 -1
  35. package/dist/api/FileRead.d.ts.map +1 -1
  36. package/dist/api/FirmwareUpdateV2.d.ts +2 -2
  37. package/dist/api/FirmwareUpdateV2.d.ts.map +1 -1
  38. package/dist/api/FirmwareUpdateV3.d.ts.map +1 -1
  39. package/dist/api/FirmwareUpdateV4.d.ts +3 -2
  40. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  41. package/dist/api/GetPassphraseState.d.ts.map +1 -1
  42. package/dist/api/OpenWalletSession.d.ts +14 -0
  43. package/dist/api/OpenWalletSession.d.ts.map +1 -0
  44. package/dist/api/PathInfo.d.ts.map +1 -1
  45. package/dist/api/PromptWebDeviceAccess.d.ts.map +1 -1
  46. package/dist/api/SearchDevices.d.ts +3 -0
  47. package/dist/api/SearchDevices.d.ts.map +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/DeviceFullyUploadResource.d.ts +1 -2
  52. package/dist/api/device/DeviceFullyUploadResource.d.ts.map +1 -1
  53. package/dist/api/device/DeviceSettings.d.ts.map +1 -1
  54. package/dist/api/device/DeviceUpdateBootloader.d.ts.map +1 -1
  55. package/dist/api/device/DeviceWipe.d.ts.map +1 -1
  56. package/dist/api/evm/EVMSignTypedData.d.ts.map +1 -1
  57. package/dist/api/firmware/getBinary.d.ts +7 -3
  58. package/dist/api/firmware/getBinary.d.ts.map +1 -1
  59. package/dist/api/helpers/filesystemValidation.d.ts +3 -0
  60. package/dist/api/helpers/filesystemValidation.d.ts.map +1 -1
  61. package/dist/api/helpers/paramsValidator.d.ts +1 -0
  62. package/dist/api/helpers/paramsValidator.d.ts.map +1 -1
  63. package/dist/api/helpers/protocolV2FileWrite.d.ts.map +1 -1
  64. package/dist/api/index.d.ts +7 -11
  65. package/dist/api/index.d.ts.map +1 -1
  66. package/dist/api/protocol-v2/DeviceGetFirmwareUpdateStatus.d.ts.map +1 -1
  67. package/dist/api/protocol-v2/DeviceSettingsPageShow.d.ts.map +1 -1
  68. package/dist/api/protocol-v2/DeviceSettingsSet.d.ts.map +1 -1
  69. package/dist/api/utils.d.ts.map +1 -1
  70. package/dist/core/RequestQueue.d.ts +17 -1
  71. package/dist/core/RequestQueue.d.ts.map +1 -1
  72. package/dist/core/index.d.ts +3 -1
  73. package/dist/core/index.d.ts.map +1 -1
  74. package/dist/device/Device.d.ts +6 -5
  75. package/dist/device/Device.d.ts.map +1 -1
  76. package/dist/device/DeviceCommands.d.ts +5 -5
  77. package/dist/device/DeviceCommands.d.ts.map +1 -1
  78. package/dist/device/DevicePool.d.ts +1 -0
  79. package/dist/device/DevicePool.d.ts.map +1 -1
  80. package/dist/device/DeviceStateMapper.d.ts +2 -2
  81. package/dist/device/DeviceStateMapper.d.ts.map +1 -1
  82. package/dist/device/DeviceStateProjector.d.ts +1 -2
  83. package/dist/device/DeviceStateProjector.d.ts.map +1 -1
  84. package/dist/device/DeviceStateStore.d.ts +1 -3
  85. package/dist/device/DeviceStateStore.d.ts.map +1 -1
  86. package/dist/device/DeviceWalletSessionStore.d.ts +6 -1
  87. package/dist/device/DeviceWalletSessionStore.d.ts.map +1 -1
  88. package/dist/device/cloneDeviceState.d.ts +2 -0
  89. package/dist/device/cloneDeviceState.d.ts.map +1 -0
  90. package/dist/deviceProfile/buildDeviceFeatures.d.ts +3 -1
  91. package/dist/deviceProfile/buildDeviceFeatures.d.ts.map +1 -1
  92. package/dist/events/logBlockEvent.d.ts.map +1 -1
  93. package/dist/index.d.ts +608 -75
  94. package/dist/index.js +2287 -1706
  95. package/dist/inject.d.ts.map +1 -1
  96. package/dist/protocols/protocol-v2/deviceSession.d.ts +7 -0
  97. package/dist/protocols/protocol-v2/deviceSession.d.ts.map +1 -0
  98. package/dist/protocols/protocol-v2/features.d.ts +11 -4
  99. package/dist/protocols/protocol-v2/features.d.ts.map +1 -1
  100. package/dist/protocols/protocol-v2/index.d.ts +2 -2
  101. package/dist/protocols/protocol-v2/index.d.ts.map +1 -1
  102. package/dist/protocols/protocol-v2/settingsUnlockPolicy.d.ts +16 -0
  103. package/dist/protocols/protocol-v2/settingsUnlockPolicy.d.ts.map +1 -0
  104. package/dist/protocols/protocol-v2/uiInteraction.d.ts.map +1 -1
  105. package/dist/protocols/protocol-v2/walletSession.d.ts +8 -3
  106. package/dist/protocols/protocol-v2/walletSession.d.ts.map +1 -1
  107. package/dist/types/api/checkAllFirmwareRelease.d.ts +1 -2
  108. package/dist/types/api/checkAllFirmwareRelease.d.ts.map +1 -1
  109. package/dist/types/api/deviceSettings.d.ts +1 -0
  110. package/dist/types/api/deviceSettings.d.ts.map +1 -1
  111. package/dist/types/api/deviceUnlock.d.ts +1 -1
  112. package/dist/types/api/deviceUnlock.d.ts.map +1 -1
  113. package/dist/types/api/getFeatures.d.ts.map +1 -1
  114. package/dist/types/api/getOnekeyFeatures.d.ts.map +1 -1
  115. package/dist/types/api/getPassphraseState.d.ts.map +1 -1
  116. package/dist/types/api/index.d.ts +13 -15
  117. package/dist/types/api/index.d.ts.map +1 -1
  118. package/dist/types/api/openWalletSession.d.ts +41 -0
  119. package/dist/types/api/openWalletSession.d.ts.map +1 -0
  120. package/dist/types/api/protocolV2.d.ts +6 -11
  121. package/dist/types/api/protocolV2.d.ts.map +1 -1
  122. package/dist/types/device.d.ts +2 -9
  123. package/dist/types/device.d.ts.map +1 -1
  124. package/dist/utils/assets.d.ts +2 -1
  125. package/dist/utils/assets.d.ts.map +1 -1
  126. package/dist/utils/deviceFeaturesCompat.d.ts +13 -0
  127. package/dist/utils/deviceFeaturesCompat.d.ts.map +1 -0
  128. package/dist/utils/deviceFeaturesUtils.d.ts +11 -1
  129. package/dist/utils/deviceFeaturesUtils.d.ts.map +1 -1
  130. package/dist/utils/deviceInfoUtils.d.ts +9 -8
  131. package/dist/utils/deviceInfoUtils.d.ts.map +1 -1
  132. package/dist/utils/deviceSettings.d.ts +2 -0
  133. package/dist/utils/deviceSettings.d.ts.map +1 -1
  134. package/dist/utils/deviceVersionUtils.d.ts +6 -5
  135. package/dist/utils/deviceVersionUtils.d.ts.map +1 -1
  136. package/dist/utils/index.d.ts +2 -2
  137. package/dist/utils/index.d.ts.map +1 -1
  138. package/dist/utils/networkUtils.d.ts +9 -1
  139. package/dist/utils/networkUtils.d.ts.map +1 -1
  140. package/dist/utils/patch.d.ts +1 -1
  141. package/dist/utils/patch.d.ts.map +1 -1
  142. package/dist/utils/pro2Wallpaper.d.ts.map +1 -1
  143. package/package.json +4 -4
  144. package/src/api/BaseMethod.ts +17 -10
  145. package/src/api/CheckBootloaderRelease.ts +1 -2
  146. package/src/api/CheckFirmwareRelease.ts +1 -2
  147. package/src/api/ClearSessionCache.ts +0 -4
  148. package/src/api/DirList.ts +5 -3
  149. package/src/api/DirMake.ts +3 -3
  150. package/src/api/DirRemove.ts +3 -3
  151. package/src/api/FileDelete.ts +3 -3
  152. package/src/api/FileRead.ts +9 -6
  153. package/src/api/FileWrite.ts +3 -3
  154. package/src/api/FirmwareUpdate.ts +4 -4
  155. package/src/api/FirmwareUpdateV2.ts +133 -45
  156. package/src/api/FirmwareUpdateV3.ts +3 -5
  157. package/src/api/FirmwareUpdateV4.ts +192 -154
  158. package/src/api/GetPassphraseState.ts +10 -16
  159. package/src/api/OpenWalletSession.ts +221 -0
  160. package/src/api/PathInfo.ts +5 -3
  161. package/src/api/PromptWebDeviceAccess.ts +2 -3
  162. package/src/api/SearchDevices.ts +6 -2
  163. package/src/api/UploadPortfolio.ts +12 -3
  164. package/src/api/alephium/AlephiumSignTransaction.ts +2 -2
  165. package/src/api/allnetwork/AllNetworkGetAddressBase.ts +4 -8
  166. package/src/api/benfen/BenfenGetAddress.ts +1 -1
  167. package/src/api/btc/BTCGetPublicKey.ts +1 -1
  168. package/src/api/device/DeviceChangePin.ts +1 -0
  169. package/src/api/device/DeviceFullyUploadResource.ts +4 -5
  170. package/src/api/device/DeviceSettings.ts +93 -0
  171. package/src/api/device/DeviceUpdateBootloader.ts +1 -2
  172. package/src/api/device/DeviceWipe.ts +1 -0
  173. package/src/api/evm/EVMSignTypedData.ts +3 -5
  174. package/src/api/evm/legacyV1/getAddress.ts +2 -2
  175. package/src/api/evm/legacyV1/getPublicKey.ts +2 -2
  176. package/src/api/evm/legacyV1/signMessage.ts +2 -2
  177. package/src/api/evm/legacyV1/signTypedData.ts +2 -2
  178. package/src/api/evm/legacyV1/signTypedHash.ts +2 -2
  179. package/src/api/evm/legacyV1/verifyMessage.ts +2 -2
  180. package/src/api/firmware/FirmwareUpdateBaseMethod.ts +3 -3
  181. package/src/api/firmware/getBinary.ts +8 -3
  182. package/src/api/helpers/batchGetPublickeys.ts +2 -2
  183. package/src/api/helpers/filesystemValidation.ts +61 -0
  184. package/src/api/helpers/paramsValidator.ts +1 -1
  185. package/src/api/helpers/protocolV2FileWrite.ts +3 -2
  186. package/src/api/index.ts +9 -12
  187. package/src/api/nervos/NervosSignTransaction.ts +2 -2
  188. package/src/api/nexa/NexaSignTransaction.ts +1 -1
  189. package/src/api/protocol-v2/DeviceFactoryInfoGet.ts +1 -1
  190. package/src/api/protocol-v2/DeviceFactoryInfoSet.ts +1 -1
  191. package/src/api/protocol-v2/DeviceFirmwareUpdate.ts +4 -4
  192. package/src/api/protocol-v2/DeviceGetFirmwareUpdateStatus.ts +6 -5
  193. package/src/api/protocol-v2/DeviceInfoGet.ts +4 -4
  194. package/src/api/protocol-v2/DeviceReboot.ts +1 -1
  195. package/src/api/protocol-v2/DeviceSettingsPageShow.ts +11 -8
  196. package/src/api/protocol-v2/DeviceSettingsSet.ts +4 -11
  197. package/src/api/protocol-v2/FilesystemFormat.ts +1 -1
  198. package/src/api/protocol-v2/FilesystemPermissionFix.ts +1 -1
  199. package/src/api/protocol-v2/Ping.ts +1 -1
  200. package/src/api/protocol-v2/ProtocolInfoRequest.ts +1 -1
  201. package/src/api/utils.ts +1 -3
  202. package/src/core/RequestQueue.ts +107 -6
  203. package/src/core/index.ts +91 -46
  204. package/src/data/messages/messages-protocol-v2.json +25 -79
  205. package/src/data-manager/DataManager.ts +6 -6
  206. package/src/device/Device.ts +186 -102
  207. package/src/device/DeviceCommands.ts +51 -21
  208. package/src/device/DeviceFeaturesState.ts +4 -4
  209. package/src/device/DevicePool.ts +17 -3
  210. package/src/device/DeviceStateMapper.ts +13 -29
  211. package/src/device/DeviceStateProjector.ts +11 -8
  212. package/src/device/DeviceStateStore.ts +11 -52
  213. package/src/device/DeviceWalletSessionStore.ts +31 -4
  214. package/src/device/cloneDeviceState.ts +39 -0
  215. package/src/device/deviceIdentity.ts +2 -2
  216. package/src/deviceProfile/buildDeviceFeatures.ts +29 -88
  217. package/src/events/logBlockEvent.ts +8 -1
  218. package/src/inject.ts +14 -24
  219. package/src/protocols/protocol-v2/deviceSession.ts +24 -0
  220. package/src/protocols/protocol-v2/features.ts +37 -49
  221. package/src/protocols/protocol-v2/firmware.ts +1 -1
  222. package/src/protocols/protocol-v2/index.ts +2 -3
  223. package/src/protocols/protocol-v2/settingsUnlockPolicy.ts +61 -0
  224. package/src/protocols/protocol-v2/uiInteraction.ts +4 -3
  225. package/src/protocols/protocol-v2/walletSession.ts +57 -146
  226. package/src/types/api/checkAllFirmwareRelease.ts +1 -2
  227. package/src/types/api/deviceSettings.ts +1 -0
  228. package/src/types/api/deviceUnlock.ts +1 -1
  229. package/src/types/api/firmwareUpdate.ts +5 -5
  230. package/src/types/api/getDeviceState.ts +1 -1
  231. package/src/types/api/getFeatures.ts +3 -0
  232. package/src/types/api/getOnekeyFeatures.ts +3 -0
  233. package/src/types/api/getPassphraseState.ts +6 -0
  234. package/src/types/api/index.ts +16 -23
  235. package/src/types/api/openWalletSession.ts +64 -0
  236. package/src/types/api/protocolV2.ts +36 -18
  237. package/src/types/device.ts +20 -14
  238. package/src/types/params.ts +1 -1
  239. package/src/types/settings.ts +8 -8
  240. package/src/utils/assets.ts +4 -1
  241. package/src/utils/deviceFeaturesCompat.ts +173 -0
  242. package/src/utils/deviceFeaturesUtils.ts +9 -8
  243. package/src/utils/deviceInfoUtils.ts +26 -64
  244. package/src/utils/deviceSettings.ts +18 -11
  245. package/src/utils/deviceVersionUtils.ts +25 -44
  246. package/src/utils/findDefectiveBatchDevice.ts +3 -3
  247. package/src/utils/index.ts +2 -5
  248. package/src/utils/networkUtils.ts +270 -14
  249. package/src/utils/pro2Wallpaper.ts +11 -13
  250. package/src/utils/tracing.ts +2 -2
  251. package/dist/api/internal.d.ts +0 -4
  252. package/dist/api/internal.d.ts.map +0 -1
  253. package/dist/api/protocol-v2/DeviceSessionOpen.d.ts +0 -28
  254. package/dist/api/protocol-v2/DeviceSessionOpen.d.ts.map +0 -1
  255. package/src/api/internal.ts +0 -9
  256. package/src/api/protocol-v2/DeviceSessionOpen.ts +0 -100
@@ -18,13 +18,13 @@ import DeviceReboot from '../src/api/protocol-v2/DeviceReboot';
18
18
  import DeviceCancel from '../src/api/device/DeviceCancel';
19
19
  import DeviceChangePin from '../src/api/device/DeviceChangePin';
20
20
  import DeviceLock from '../src/api/device/DeviceLock';
21
+ import DeviceSettings from '../src/api/device/DeviceSettings';
21
22
  import DeviceUnlock from '../src/api/device/DeviceUnlock';
22
23
  import DeviceWipe from '../src/api/device/DeviceWipe';
23
24
  import DeviceSettingsGet from '../src/api/protocol-v2/DeviceSettingsGet';
24
25
  import DeviceSettingsPageShow from '../src/api/protocol-v2/DeviceSettingsPageShow';
25
26
  import DeviceSettingsSet from '../src/api/protocol-v2/DeviceSettingsSet';
26
27
  import DeviceUploadWallpaper from '../src/api/protocol-v2/DeviceUploadWallpaper';
27
- import DeviceSessionOpen from '../src/api/protocol-v2/DeviceSessionOpen';
28
28
  import DeviceStatusGet from '../src/api/protocol-v2/DeviceStatusGet';
29
29
  import FilesystemFormat from '../src/api/protocol-v2/FilesystemFormat';
30
30
  import FilesystemPermissionFix from '../src/api/protocol-v2/FilesystemPermissionFix';
@@ -58,10 +58,12 @@ import { DataManager } from '../src/data-manager';
58
58
  import { createCoreApi } from '../src/inject';
59
59
  import { Device, preloadSessionCache } from '../src/device/Device';
60
60
  import { DevicePool } from '../src/device/DevicePool';
61
- import { DEVICE } from '../src/events/device';
62
61
  import { UI_REQUEST } from '../src/events/ui-request';
63
62
  import {
64
63
  PROTOCOL_V2_DEVICE_INFO_TIMEOUT_MS,
64
+ type ProtocolV2DeviceInfo,
65
+ type ProtocolV2RuntimeMode,
66
+ getProtocolV2RuntimeMode,
65
67
  requestProtocolV2DeviceInfo,
66
68
  requestProtocolV2DeviceStatus,
67
69
  } from '../src/protocols/protocol-v2/features';
@@ -92,7 +94,6 @@ import {
92
94
  import type { DeviceCommands } from '../src/device/DeviceCommands';
93
95
  import type { Features } from '../src/types';
94
96
  import type { DeviceStatus } from '@onekeyfe/hd-transport';
95
- import type { ProtocolV2DeviceInfo } from '../src/protocols/protocol-v2/features';
96
97
 
97
98
  jest.mock('../src/data/config', () => ({
98
99
  getSDKVersion: jest.fn(() => '1.0.0'),
@@ -215,6 +216,7 @@ describe('UploadPortfolio', () => {
215
216
 
216
217
  expect(method.unlockPolicy).toBe('retry-on-locked');
217
218
  expect(method.protocolV2UiMode).toBe('none');
219
+ expect(method.executionPriority).toBe('background');
218
220
  expect(method.protocolV2UiInteraction).toBeUndefined();
219
221
  expect(method.payload.emitProgress).toBe(false);
220
222
  expect(typedCall).toHaveBeenNthCalledWith(
@@ -232,9 +234,15 @@ describe('UploadPortfolio', () => {
232
234
  append: false,
233
235
  ui_percentage: 100,
234
236
  },
235
- { timeoutMs: undefined }
237
+ { timeoutMs: 5_000 }
238
+ );
239
+ expect(typedCall).toHaveBeenNthCalledWith(
240
+ 2,
241
+ 'PortfolioUpdate',
242
+ 'Success',
243
+ {},
244
+ { timeoutMs: 5_000 }
236
245
  );
237
- expect(typedCall).toHaveBeenNthCalledWith(2, 'PortfolioUpdate', 'Success', {});
238
246
  expect(method.postMessage).not.toHaveBeenCalled();
239
247
  expect(result).toMatchObject({
240
248
  path: 'vol1:/portfolio/portfolio.okpkg.pending',
@@ -294,8 +302,8 @@ const descriptor = {
294
302
  };
295
303
 
296
304
  /**
297
- * 为纯对象 stub 设备补齐 Device 的协议判别与 getCurrent* accessor,
298
- * 缺省实现与 Device.ts 语义保持一致;已有同名字段(如 jest.fn())不覆盖。
305
+ * Add Device protocol and getCurrent* accessors to plain-object stubs.
306
+ * Defaults match Device.ts and never replace an existing member such as jest.fn().
299
307
  */
300
308
  function stubDevice<T extends Record<string, any>>(device: T): T {
301
309
  const d = device as any;
@@ -320,15 +328,54 @@ function stubDevice<T extends Record<string, any>>(device: T): T {
320
328
  };
321
329
  d.updateProtocolV2Features ??= (
322
330
  deviceInfo?: ProtocolV2DeviceInfo,
323
- deviceStatus?: DeviceStatus
331
+ deviceStatus?: DeviceStatus,
332
+ runtimeMode?: ProtocolV2RuntimeMode
324
333
  ) => {
325
334
  d.features = buildProtocolV2FeaturesPayload({
326
335
  deviceInfo,
327
336
  deviceStatus: deviceStatus ?? deviceInfo?.status,
328
337
  previous: d.features,
338
+ runtimeMode: runtimeMode ?? (deviceStatus ? 'normal' : undefined),
329
339
  });
330
340
  return d.features;
331
341
  };
342
+ d.probeProtocolV2RuntimeState ??= async (
343
+ deviceInfo: ProtocolV2DeviceInfo,
344
+ timeoutMs?: number
345
+ ) => {
346
+ try {
347
+ const response = await d
348
+ .getCommands()
349
+ .typedCall(
350
+ 'DeviceStatusGet',
351
+ 'DeviceStatus',
352
+ {},
353
+ timeoutMs === undefined ? undefined : { timeoutMs }
354
+ );
355
+ const features = d.updateProtocolV2Features(deviceInfo, response.message, 'normal');
356
+ return (
357
+ features ??
358
+ buildProtocolV2FeaturesPayload({
359
+ deviceInfo,
360
+ deviceStatus: response.message,
361
+ previous: d.features,
362
+ runtimeMode: 'normal',
363
+ })
364
+ );
365
+ } catch {
366
+ const runtimeMode = getProtocolV2RuntimeMode({
367
+ deviceInfo,
368
+ deviceStatusAvailable: false,
369
+ });
370
+ const features = d.updateProtocolV2Features(deviceInfo, null, runtimeMode);
371
+ if (features?.mode === runtimeMode) return features;
372
+ return buildProtocolV2FeaturesPayload({
373
+ deviceInfo,
374
+ previous: d.features,
375
+ runtimeMode,
376
+ });
377
+ }
378
+ };
332
379
  d.updateProtocolV2Status ??= (deviceStatus: DeviceStatus) => {
333
380
  d.features = buildProtocolV2FeaturesPayload({
334
381
  deviceInfo: d.features?.raw?.protocolV2DeviceInfo,
@@ -348,10 +395,15 @@ function stubDevice<T extends Record<string, any>>(device: T): T {
348
395
  }
349
396
 
350
397
  // 直接复用生产映射函数,避免测试内副本与实现漂移(之前的手抄副本已缺失 se boot 字段)
351
- function normalizeProtocolV2Features(_descriptor: unknown, deviceInfo?: ProtocolV2DeviceInfo) {
398
+ function normalizeProtocolV2Features(
399
+ _descriptor: unknown,
400
+ deviceInfo?: ProtocolV2DeviceInfo,
401
+ runtimeMode: ProtocolV2RuntimeMode = 'normal'
402
+ ) {
352
403
  return buildProtocolV2FeaturesPayload({
353
404
  deviceInfo,
354
405
  deviceStatus: deviceInfo?.status,
406
+ runtimeMode,
355
407
  });
356
408
  }
357
409
 
@@ -365,6 +417,7 @@ const protocolV2BootloaderDeviceInfo: ProtocolV2DeviceInfo = {
365
417
  version: '0.0.1',
366
418
  },
367
419
  },
420
+ se1: {},
368
421
  };
369
422
 
370
423
  async function requestProtocolV2Features({
@@ -576,7 +629,7 @@ describe('Protocol V2 feature adapter', () => {
576
629
  expect(features.bleEnabled).toBeNull();
577
630
  });
578
631
 
579
- test('marks Protocol V2 DeviceInfo without status as bootloader mode', () => {
632
+ test('marks Protocol V2 DeviceInfo as bootloader when DeviceStatusGet fails', () => {
580
633
  const deviceInfo = {
581
634
  protocol_version: 1,
582
635
  hw: {
@@ -587,14 +640,34 @@ describe('Protocol V2 feature adapter', () => {
587
640
  version: '0.2.0',
588
641
  },
589
642
  },
643
+ se1: {},
590
644
  };
591
- const features = normalizeProtocolV2Features(descriptor as any, deviceInfo);
645
+ const runtimeMode = getProtocolV2RuntimeMode({
646
+ deviceInfo: deviceInfo as ProtocolV2DeviceInfo,
647
+ deviceStatusAvailable: false,
648
+ });
649
+ const features = normalizeProtocolV2Features(
650
+ descriptor as any,
651
+ deviceInfo as ProtocolV2DeviceInfo,
652
+ runtimeMode
653
+ );
592
654
  expect(features.mode).toBe('bootloader');
593
655
  expect(features.bootloaderMode).toBe(true);
594
656
  expect(features.initialized).toBeNull();
595
657
  });
596
658
 
597
- test('marks current romloader-shaped Protocol V2 DeviceInfo as romloader mode', () => {
659
+ test('keeps normal mode when DeviceStatusGet succeeds even if application metadata is absent', () => {
660
+ const features = normalizeProtocolV2Features(descriptor as any, {
661
+ protocol_version: 1,
662
+ hw: { serial_no: 'PR2NORMAL' },
663
+ fw: { bootloader: { version: '0.2.0' } },
664
+ });
665
+
666
+ expect(features.mode).toBe('normal');
667
+ expect(features.bootloaderMode).toBe(false);
668
+ });
669
+
670
+ test('temporarily treats romloader metadata as bootloader when DeviceStatusGet fails', () => {
598
671
  const deviceInfo = {
599
672
  protocol_version: 1,
600
673
  hw: {
@@ -609,9 +682,17 @@ describe('Protocol V2 feature adapter', () => {
609
682
  },
610
683
  },
611
684
  };
612
- const features = normalizeProtocolV2Features(descriptor as any, deviceInfo);
613
- expect(features.mode).toBe('romloader');
614
- expect(features.bootloaderMode).toBe(false);
685
+ const runtimeMode = getProtocolV2RuntimeMode({
686
+ deviceInfo: deviceInfo as ProtocolV2DeviceInfo,
687
+ deviceStatusAvailable: false,
688
+ });
689
+ const features = normalizeProtocolV2Features(
690
+ descriptor as any,
691
+ deviceInfo as ProtocolV2DeviceInfo,
692
+ runtimeMode
693
+ );
694
+ expect(features.mode).toBe('bootloader');
695
+ expect(features.bootloaderMode).toBe(true);
615
696
  expect(features.boardVersion).toBe('1.0.0');
616
697
  });
617
698
 
@@ -639,6 +720,7 @@ describe('Protocol V2 feature adapter', () => {
639
720
  passphraseState: undefined,
640
721
  newSession: undefined,
641
722
  unlockedAttachPin: false,
723
+ resumed: false,
642
724
  });
643
725
  expect(typedCall).not.toHaveBeenCalled();
644
726
  expect(device.getInternalState()).toBeUndefined();
@@ -660,6 +742,7 @@ describe('Protocol V2 feature adapter', () => {
660
742
  passphraseState: undefined,
661
743
  newSession: undefined,
662
744
  unlockedAttachPin: false,
745
+ resumed: false,
663
746
  });
664
747
 
665
748
  expect(promptPassphrase).not.toHaveBeenCalled();
@@ -718,7 +801,7 @@ describe('Protocol V2 feature adapter', () => {
718
801
  }
719
802
  );
720
803
  const typedCall = jest.fn().mockImplementation((request: string) => {
721
- if (request === 'DeviceSessionOpen') {
804
+ if (request === 'DeviceSessionGet') {
722
805
  return Promise.resolve({
723
806
  message: {
724
807
  btc_test_address: 'state-after-unlock',
@@ -748,6 +831,7 @@ describe('Protocol V2 feature adapter', () => {
748
831
  passphraseState: undefined,
749
832
  newSession: undefined,
750
833
  unlockedAttachPin: false,
834
+ resumed: false,
751
835
  });
752
836
 
753
837
  expect(unlockDevice).toHaveBeenCalledTimes(1);
@@ -773,26 +857,7 @@ describe('Protocol V2 feature adapter', () => {
773
857
  expect(device.getInternalState()).toBe('hidden-session');
774
858
  });
775
859
 
776
- test.each([
777
- {
778
- name: 'Host Passphrase',
779
- response: { passphrase: 'hidden\u00e9 secret' },
780
- select: { host_passphrase: { passphrase: 'hidden\u0065\u0301 secret' } },
781
- event: undefined,
782
- },
783
- {
784
- name: '设备 Passphrase',
785
- response: { passphraseOnDevice: true },
786
- select: { passphrase_on_device: {} },
787
- event: DEVICE.PASSPHRASE_ON_DEVICE,
788
- },
789
- {
790
- name: 'Attach PIN',
791
- response: { attachPinOnDevice: true },
792
- select: { attach_pin_on_device: {} },
793
- event: DEVICE.ATTACH_PIN_ON_DEVICE,
794
- },
795
- ])('maps $name selection to DeviceSessionOpen', async ({ response, select, event }) => {
860
+ test('gets the wallet session selected by the Protocol V2 device', async () => {
796
861
  const device = Device.fromDescriptor({
797
862
  id: 'select-device',
798
863
  path: 'select-path',
@@ -813,33 +878,16 @@ describe('Protocol V2 feature adapter', () => {
813
878
  type: 'DeviceSession',
814
879
  message: { session_id: 'hidden-session', btc_test_address: 'hidden-state' },
815
880
  });
816
- const promptPassphrase = jest.fn().mockResolvedValue(response);
881
+ const promptPassphrase = jest.fn();
817
882
  (device as any).commands = { typedCall, promptPassphrase };
818
- const emit = jest.spyOn(device, 'emit');
819
883
 
820
884
  await expect(getProtocolV2WalletSession(device)).resolves.toMatchObject({
821
885
  newSession: 'hidden-session',
822
886
  passphraseState: 'hidden-state',
823
887
  });
824
888
 
825
- expect(promptPassphrase).toHaveBeenCalledWith(
826
- expect.objectContaining({
827
- source: 'wallet-session-coordinator',
828
- reason: 'open-wallet',
829
- existsAttachPinUser: true,
830
- }),
831
- { cancelDeviceOnReject: false }
832
- );
833
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionOpen', 'DeviceSession', {
834
- select,
835
- });
836
- if (event) {
837
- expect(emit).toHaveBeenCalledWith(
838
- event,
839
- device,
840
- expect.objectContaining({ source: 'wallet-session-coordinator' })
841
- );
842
- }
889
+ expect(promptPassphrase).not.toHaveBeenCalled();
890
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {});
843
891
  });
844
892
 
845
893
  test('deviceStatusGet returns raw DeviceStatus and updates dynamic features', async () => {
@@ -900,77 +948,18 @@ describe('Protocol V2 feature adapter', () => {
900
948
  expect(method.requireProtocolV2).toBe(true);
901
949
  });
902
950
 
903
- test('deviceSessionOpen sends the explicit wallet selection and does not mutate wallet cache', async () => {
904
- const typedCall = jest.fn().mockResolvedValue({
905
- type: 'DeviceSession',
906
- message: { session_id: 'new-session', btc_test_address: 'state-a' },
907
- });
908
- const updateInternalState = jest.fn();
909
- const method = new DeviceSessionOpen({
910
- payload: {
911
- method: 'deviceSessionOpen',
912
- connectId: 'connect-id',
913
- select: { host_passphrase: { passphrase: 'hidden secret' } },
914
- },
915
- });
916
- method.init();
917
- method.device = stubDevice({
918
- originalDescriptor: { ...descriptor, protocolType: 'V2' },
919
- commands: { typedCall },
920
- updateInternalState,
921
- }) as any;
922
-
923
- await expect(method.run()).resolves.toEqual({
924
- session_id: 'new-session',
925
- btc_test_address: 'state-a',
926
- });
927
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionOpen', 'DeviceSession', {
928
- select: { host_passphrase: { passphrase: 'hidden secret' } },
929
- });
930
- expect(updateInternalState).not.toHaveBeenCalled();
931
- });
932
-
933
- test.each([
934
- { payload: {}, reason: 'missing mode' },
935
- { payload: { resume: { session_id: '' } }, reason: 'empty resume session' },
936
- { payload: { select: { wallet_type: 0 } }, reason: 'removed standard wallet selection' },
937
- {
938
- payload: { select: { host_passphrase: { passphrase: 'secret' }, passphrase_on_device: {} } },
939
- reason: 'multiple hidden wallet access modes',
940
- },
941
- ])('rejects invalid deviceSessionOpen payload: $reason', ({ payload }) => {
942
- const method = new DeviceSessionOpen({
943
- payload: {
944
- method: 'deviceSessionOpen',
945
- connectId: 'connect-id',
946
- ...payload,
947
- } as any,
948
- });
949
-
950
- expect(() => method.init()).toThrow();
951
- });
952
-
953
- test('routes public onboarding and session methods through CoreApi', async () => {
951
+ test('routes onboarding without exposing the raw session command through CoreApi', async () => {
954
952
  const call = jest.fn().mockResolvedValue({ success: true, payload: {} });
955
953
  const api = createCoreApi(call as any);
956
954
 
957
955
  await api.deviceGetOnboardingStatus('connect-id', { retryCount: 1 });
958
- await api.deviceSessionOpen('connect-id', {
959
- retryCount: 1,
960
- select: { passphrase_on_device: {} },
961
- });
962
956
 
963
- expect(call).toHaveBeenNthCalledWith(1, {
957
+ expect(call).toHaveBeenCalledWith({
964
958
  method: 'deviceGetOnboardingStatus',
965
959
  connectId: 'connect-id',
966
960
  retryCount: 1,
967
961
  });
968
- expect(call).toHaveBeenNthCalledWith(2, {
969
- method: 'deviceSessionOpen',
970
- connectId: 'connect-id',
971
- retryCount: 1,
972
- select: { passphrase_on_device: {} },
973
- });
962
+ expect(api).not.toHaveProperty('deviceSessionOpen');
974
963
  });
975
964
 
976
965
  test('reuses the cached session id for the selected Pro2 wallet', async () => {
@@ -1002,8 +991,8 @@ describe('Protocol V2 feature adapter', () => {
1002
991
  newSession: 'session-b',
1003
992
  });
1004
993
 
1005
- expect(typedCall).toHaveBeenCalledWith('DeviceSessionOpen', 'DeviceSession', {
1006
- resume: { session_id: 'session-a' },
994
+ expect(typedCall).toHaveBeenCalledWith('DeviceSessionGet', 'DeviceSession', {
995
+ session_id: 'session-a',
1007
996
  });
1008
997
  expect(device.getInternalState()).toBe('session-b');
1009
998
  });
@@ -1031,7 +1020,7 @@ describe('Protocol V2 feature adapter', () => {
1031
1020
  expect(device.getInternalState()).toBeUndefined();
1032
1021
  });
1033
1022
 
1034
- test('retries without the selected Pro2 cache entry after invalid session rejection', async () => {
1023
+ test('clears the selected Pro2 cache entry after session rejection', async () => {
1035
1024
  const device = Device.fromDescriptor({
1036
1025
  id: 'cache-device-3',
1037
1026
  path: 'cache-path-3',
@@ -1049,33 +1038,22 @@ describe('Protocol V2 feature adapter', () => {
1049
1038
  );
1050
1039
  device.passphraseState = 'state-a';
1051
1040
  preloadSessionCache('stable-device-3', 'state-a', 'session-a');
1052
- const typedCall = jest
1053
- .fn()
1054
- .mockRejectedValueOnce(new Error('Failure_ProcessError,Failure_InvalidSession'))
1055
- .mockResolvedValueOnce({
1056
- type: 'DeviceSession',
1057
- message: { session_id: 'session-b', btc_test_address: 'state-a' },
1058
- });
1059
- const promptPassphrase = jest.fn().mockResolvedValue({ passphrase: 'state-a-secret' });
1041
+ const typedCall = jest.fn().mockRejectedValue(
1042
+ Object.assign(new Error('SE request failed'), {
1043
+ errorCode: HardwareErrorCode.RuntimeError,
1044
+ })
1045
+ );
1046
+ const promptPassphrase = jest.fn();
1060
1047
  (device as any).commands = { typedCall, promptPassphrase };
1061
1048
 
1062
- await expect(getProtocolV2WalletSession(device)).resolves.toMatchObject({
1063
- passphraseState: 'state-a',
1064
- newSession: 'session-b',
1049
+ await expect(getProtocolV2WalletSession(device)).rejects.toMatchObject({
1050
+ errorCode: HardwareErrorCode.RuntimeError,
1065
1051
  });
1066
1052
  expect(typedCall.mock.calls).toEqual([
1067
- ['DeviceSessionOpen', 'DeviceSession', { resume: { session_id: 'session-a' } }],
1068
- [
1069
- 'DeviceSessionOpen',
1070
- 'DeviceSession',
1071
- {
1072
- select: {
1073
- host_passphrase: { passphrase: 'state-a-secret' },
1074
- },
1075
- },
1076
- ],
1053
+ ['DeviceSessionGet', 'DeviceSession', { session_id: 'session-a' }],
1077
1054
  ]);
1078
- expect(device.getInternalState()).toBe('session-b');
1055
+ expect(promptPassphrase).not.toHaveBeenCalled();
1056
+ expect(device.getInternalState()).toBeUndefined();
1079
1057
  });
1080
1058
 
1081
1059
  test('does not retry an invalid Pro2 session when no cached session was sent', async () => {
@@ -1094,26 +1072,20 @@ describe('Protocol V2 feature adapter', () => {
1094
1072
  },
1095
1073
  }
1096
1074
  );
1097
- const typedCall = jest
1098
- .fn()
1099
- .mockRejectedValue(new Error('Failure_ProcessError,Failure_InvalidSession'));
1075
+ const typedCall = jest.fn().mockRejectedValue(
1076
+ Object.assign(new Error('Invalid session'), {
1077
+ errorCode: HardwareErrorCode.WalletSessionInvalid,
1078
+ })
1079
+ );
1100
1080
  (device as any).commands = {
1101
1081
  typedCall,
1102
1082
  promptPassphrase: jest.fn().mockResolvedValue({ passphrase: 'new-secret' }),
1103
1083
  };
1104
1084
 
1105
- await expect(getProtocolV2WalletSession(device)).rejects.toThrow('Failure_InvalidSession');
1106
- expect(typedCall.mock.calls).toEqual([
1107
- [
1108
- 'DeviceSessionOpen',
1109
- 'DeviceSession',
1110
- {
1111
- select: {
1112
- host_passphrase: { passphrase: 'new-secret' },
1113
- },
1114
- },
1115
- ],
1116
- ]);
1085
+ await expect(getProtocolV2WalletSession(device)).rejects.toMatchObject({
1086
+ errorCode: HardwareErrorCode.WalletSessionInvalid,
1087
+ });
1088
+ expect(typedCall.mock.calls).toEqual([['DeviceSessionGet', 'DeviceSession', {}]]);
1117
1089
  });
1118
1090
 
1119
1091
  test('rejects a mismatched Pro2 wallet state and clears the selected cache entry', async () => {
@@ -1151,7 +1123,7 @@ describe('Protocol V2 feature adapter', () => {
1151
1123
  expect(device.getInternalState()).toBeUndefined();
1152
1124
  });
1153
1125
 
1154
- test('unlocks and retries DeviceSessionOpen when hidden-wallet selection is locked', async () => {
1126
+ test('unlocks and retries DeviceSessionGet when wallet selection is locked', async () => {
1155
1127
  const device = Device.fromDescriptor({
1156
1128
  id: 'cache-device-4',
1157
1129
  path: 'cache-path-4',
@@ -1168,7 +1140,10 @@ describe('Protocol V2 feature adapter', () => {
1168
1140
  .fn()
1169
1141
  .mockRejectedValueOnce(lockedError)
1170
1142
  .mockResolvedValueOnce({
1171
- message: { session_id: 'session-after-unlock', btc_test_address: '' },
1143
+ message: {
1144
+ session_id: 'session-after-unlock',
1145
+ btc_test_address: 'state-after-unlock',
1146
+ },
1172
1147
  });
1173
1148
  (device as any).commands = {
1174
1149
  typedCall,
@@ -1178,6 +1153,7 @@ describe('Protocol V2 feature adapter', () => {
1178
1153
 
1179
1154
  await expect(getProtocolV2WalletSession(device)).resolves.toMatchObject({
1180
1155
  newSession: 'session-after-unlock',
1156
+ passphraseState: 'state-after-unlock',
1181
1157
  });
1182
1158
  expect(unlockDevice).toHaveBeenCalledTimes(1);
1183
1159
  expect(typedCall).toHaveBeenCalledTimes(2);
@@ -1256,7 +1232,7 @@ describe('Protocol V2 feature adapter', () => {
1256
1232
  } as any,
1257
1233
  });
1258
1234
 
1259
- (device as any).updateProtocolV2Features(protocolV2BootloaderDeviceInfo, null);
1235
+ (device as any).updateProtocolV2Features(protocolV2BootloaderDeviceInfo, null, 'bootloader');
1260
1236
 
1261
1237
  expect(device.features?.mode).toBe('bootloader');
1262
1238
  expect(device.features?.bootloaderMode).toBe(true);
@@ -1311,7 +1287,7 @@ describe('Protocol V2 feature adapter', () => {
1311
1287
  );
1312
1288
  });
1313
1289
 
1314
- test('returns passphrase state string for Pro2 devices', async () => {
1290
+ test('does not expose getPassphraseState on Pro2 devices', async () => {
1315
1291
  const features = {
1316
1292
  deviceId: null,
1317
1293
  deviceType: 'pro2',
@@ -1349,7 +1325,9 @@ describe('Protocol V2 feature adapter', () => {
1349
1325
  getCurrentPassphraseProtection: () => true,
1350
1326
  }) as any;
1351
1327
 
1352
- await expect(method.run()).resolves.toBe('state-pro2');
1328
+ await expect(method.run()).rejects.toMatchObject({
1329
+ errorCode: HardwareErrorCode.DeviceNotSupportMethod,
1330
+ });
1353
1331
  expect(getFeatures).not.toHaveBeenCalled();
1354
1332
  });
1355
1333
 
@@ -1382,7 +1360,9 @@ describe('Protocol V2 feature adapter', () => {
1382
1360
  getCurrentPassphraseProtection: () => false,
1383
1361
  }) as any;
1384
1362
 
1385
- await expect(method.run()).resolves.toBeUndefined();
1363
+ await expect(method.run()).rejects.toMatchObject({
1364
+ errorCode: HardwareErrorCode.DeviceNotSupportMethod,
1365
+ });
1386
1366
  expect(typedCall).not.toHaveBeenCalled();
1387
1367
  expect(updateInternalState).not.toHaveBeenCalled();
1388
1368
  });
@@ -1424,15 +1404,11 @@ describe('Protocol V2 feature adapter', () => {
1424
1404
  getCurrentPassphraseProtection: () => true,
1425
1405
  }) as any;
1426
1406
 
1427
- await expect(method.run()).resolves.toBe('state-pro2-new');
1428
- expect(clearInternalState).toHaveBeenCalledTimes(1);
1429
- expect(updateInternalState).toHaveBeenCalledWith(
1430
- true,
1431
- 'state-pro2-new',
1432
- 'pro2-device-id',
1433
- undefined,
1434
- null
1435
- );
1407
+ await expect(method.run()).rejects.toMatchObject({
1408
+ errorCode: HardwareErrorCode.DeviceNotSupportMethod,
1409
+ });
1410
+ expect(clearInternalState).not.toHaveBeenCalled();
1411
+ expect(updateInternalState).not.toHaveBeenCalled();
1436
1412
  });
1437
1413
 
1438
1414
  test('stores Pro2 passphrase session cache without synthetic device id', async () => {
@@ -1507,11 +1483,7 @@ describe('Protocol V2 feature adapter', () => {
1507
1483
  expect(device.getInternalState()).toBeUndefined();
1508
1484
  device.passphraseState = 'state-auto';
1509
1485
  expect(device.getInternalState()).toBe('session-auto');
1510
- expect(typedCall).toHaveBeenLastCalledWith('DeviceSessionOpen', 'DeviceSession', {
1511
- select: {
1512
- host_passphrase: { passphrase: 'state-auto-secret' },
1513
- },
1514
- });
1486
+ expect(typedCall).toHaveBeenLastCalledWith('DeviceSessionGet', 'DeviceSession', {});
1515
1487
  });
1516
1488
 
1517
1489
  test('does not mark Pro2 passphrase enabled from a main PIN session alone', async () => {
@@ -1581,11 +1553,32 @@ describe('Protocol V2 feature adapter', () => {
1581
1553
  );
1582
1554
 
1583
1555
  expect(device.getInternalState()).toBeUndefined();
1584
- expect(typedCall).toHaveBeenNthCalledWith(1, 'DeviceSessionOpen', 'DeviceSession', {
1585
- select: {
1586
- host_passphrase: { passphrase: 'expected-secret' },
1587
- },
1588
- });
1556
+ expect(typedCall).toHaveBeenNthCalledWith(1, 'DeviceSessionGet', 'DeviceSession', {});
1557
+ });
1558
+
1559
+ test('useEmptyPassphrase ignores a stale hidden-wallet state during Protocol V2 safety check', async () => {
1560
+ const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
1561
+ const typedCall = jest.fn();
1562
+
1563
+ (device as any).features = normalizeProtocolV2Features(
1564
+ { ...descriptor, protocolType: 'V2' } as any,
1565
+ {
1566
+ status: {
1567
+ device_id: 'main-wallet-device',
1568
+ unlocked: true,
1569
+ passphrase_enabled: true,
1570
+ unlocked_by_attach_to_pin: false,
1571
+ },
1572
+ }
1573
+ );
1574
+ (device as any).features.firmwareVersion = '4.15.0';
1575
+ (device as any).commands = { typedCall };
1576
+ device.passphraseState = 'stale-hidden-state';
1577
+
1578
+ await expect(
1579
+ device.checkPassphraseStateSafety('stale-hidden-state', true, false)
1580
+ ).resolves.toBe(true);
1581
+ expect(typedCall).not.toHaveBeenCalled();
1589
1582
  });
1590
1583
 
1591
1584
  test('marks fallback features as unavailable when DeviceInfo is missing', () => {
@@ -1647,15 +1640,17 @@ describe('Protocol V2 feature adapter', () => {
1647
1640
  passphraseProtection: true,
1648
1641
  };
1649
1642
 
1650
- expect(device.toMessageObject()).toMatchObject({
1643
+ const message = device.toMessageObject();
1644
+ expect(message).toMatchObject({
1645
+ serialNo: 'CACHED-SERIAL',
1651
1646
  uuid: 'CACHED-SERIAL',
1652
1647
  deviceId: null,
1653
1648
  bleName: 'Cached BLE',
1654
1649
  name: 'Cached BLE',
1655
- displayName: 'Cached Label',
1656
1650
  label: 'Cached Label',
1657
1651
  deviceType: 'pro2',
1658
1652
  });
1653
+ expect(message).not.toHaveProperty('sessionId');
1659
1654
  expect(device.getCurrentPassphraseProtection()).toBe(true);
1660
1655
  expect(device.hasUsePassphrase()).toBe(true);
1661
1656
  });
@@ -1674,6 +1669,7 @@ describe('Protocol V2 feature adapter', () => {
1674
1669
  expect(current.getCurrentDeviceId()).toBeUndefined();
1675
1670
  expect(current.getCurrentSerialNo()).toBe('CACHED-SERIAL');
1676
1671
  expect(current.toMessageObject()).toMatchObject({
1672
+ serialNo: 'CACHED-SERIAL',
1677
1673
  uuid: 'CACHED-SERIAL',
1678
1674
  deviceId: null,
1679
1675
  });
@@ -1708,6 +1704,10 @@ describe('Protocol V2 feature adapter', () => {
1708
1704
  hw: true,
1709
1705
  fw: true,
1710
1706
  coprocessor: true,
1707
+ se1: true,
1708
+ se2: true,
1709
+ se3: true,
1710
+ se4: true,
1711
1711
  },
1712
1712
  types: {
1713
1713
  version: true,
@@ -1869,18 +1869,24 @@ describe('Protocol V2 feature adapter', () => {
1869
1869
  expect(typedCall).not.toHaveBeenCalled();
1870
1870
  });
1871
1871
 
1872
- test('initializes and reuses Protocol V2 features from DeviceInfo only', async () => {
1872
+ test('initializes and reuses Protocol V2 features after DeviceStatusGet succeeds', async () => {
1873
1873
  const device = Device.fromDescriptor({
1874
1874
  path: 'usb-path',
1875
1875
  protocolType: 'V2',
1876
1876
  } as any);
1877
- const typedCall = jest.fn().mockResolvedValueOnce({
1878
- type: 'DeviceInfo',
1879
- message: {
1880
- hw: { serial_no: 'PR2SERIAL' },
1881
- fw: { application: { version: '1.2.3' } },
1882
- },
1883
- });
1877
+ const typedCall = jest
1878
+ .fn()
1879
+ .mockResolvedValueOnce({
1880
+ type: 'DeviceInfo',
1881
+ message: {
1882
+ hw: { serial_no: 'PR2SERIAL' },
1883
+ fw: { application: { version: '1.2.3' } },
1884
+ },
1885
+ })
1886
+ .mockResolvedValueOnce({
1887
+ type: 'DeviceStatus',
1888
+ message: { init_states: true, unlocked: true },
1889
+ });
1884
1890
 
1885
1891
  (device as any).commands = { typedCall };
1886
1892
 
@@ -1897,8 +1903,9 @@ describe('Protocol V2 feature adapter', () => {
1897
1903
  expect(device.features?.passphraseProtection).toBeNull();
1898
1904
  expect(device.features?.label).toBeNull();
1899
1905
  expect(device.features?.firmwareVersion).toBe('1.2.3');
1900
- expect(typedCall).toHaveBeenCalledTimes(1);
1901
- expect(typedCall).toHaveBeenCalledWith(
1906
+ expect(typedCall).toHaveBeenCalledTimes(2);
1907
+ expect(typedCall).toHaveBeenNthCalledWith(
1908
+ 1,
1902
1909
  'DeviceInfoGet',
1903
1910
  'DeviceInfo',
1904
1911
  {
@@ -1906,6 +1913,10 @@ describe('Protocol V2 feature adapter', () => {
1906
1913
  hw: true,
1907
1914
  fw: true,
1908
1915
  coprocessor: true,
1916
+ se1: true,
1917
+ se2: true,
1918
+ se3: true,
1919
+ se4: true,
1909
1920
  },
1910
1921
  types: {
1911
1922
  version: true,
@@ -1916,9 +1927,10 @@ describe('Protocol V2 feature adapter', () => {
1916
1927
  timeoutMs: PROTOCOL_V2_DEVICE_INFO_TIMEOUT_MS,
1917
1928
  }
1918
1929
  );
1930
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'DeviceStatusGet', 'DeviceStatus', {});
1919
1931
  });
1920
1932
 
1921
- test('recognizes bootloader DeviceInfo without requesting device status', async () => {
1933
+ test('recognizes bootloader when DeviceStatusGet fails', async () => {
1922
1934
  const device = Device.fromDescriptor({
1923
1935
  path: 'usb-path',
1924
1936
  protocolType: 'V2',
@@ -1930,21 +1942,18 @@ describe('Protocol V2 feature adapter', () => {
1930
1942
  message: {
1931
1943
  hw: { serial_no: 'PR2SERIAL' },
1932
1944
  fw: { bootloader: { version: '0.2.0' } },
1945
+ se1: {},
1933
1946
  },
1934
1947
  })
1935
- .mockResolvedValueOnce({
1936
- type: 'DeviceStatus',
1937
- message: {
1938
- device_id: 'UNEXPECTED-SEPARATE-STATUS',
1939
- },
1940
- });
1948
+ .mockRejectedValueOnce(new Error('DeviceStatusGet unsupported in bootloader'));
1941
1949
 
1942
1950
  (device as any).commands = { typedCall };
1943
1951
 
1944
1952
  await device.initialize();
1945
1953
 
1946
- expect(typedCall).toHaveBeenCalledTimes(1);
1947
- expect(typedCall).toHaveBeenCalledWith(
1954
+ expect(typedCall).toHaveBeenCalledTimes(2);
1955
+ expect(typedCall).toHaveBeenNthCalledWith(
1956
+ 1,
1948
1957
  'DeviceInfoGet',
1949
1958
  'DeviceInfo',
1950
1959
  expect.not.objectContaining({
@@ -1952,6 +1961,7 @@ describe('Protocol V2 feature adapter', () => {
1952
1961
  }),
1953
1962
  expect.anything()
1954
1963
  );
1964
+ expect(typedCall).toHaveBeenNthCalledWith(2, 'DeviceStatusGet', 'DeviceStatus', {});
1955
1965
  expect(device.features).toMatchObject({
1956
1966
  deviceId: null,
1957
1967
  bootloaderMode: true,
@@ -1959,72 +1969,36 @@ describe('Protocol V2 feature adapter', () => {
1959
1969
  });
1960
1970
  });
1961
1971
 
1962
- test('does not probe DeviceStatusGet while firmware device is still in bootloader mode', async () => {
1963
- const method = new FirmwareUpdateV4({
1964
- id: 1,
1965
- payload: {
1966
- method: 'firmwareUpdateV4',
1967
- },
1972
+ test('does not reinterpret a state update error as DeviceStatusGet failure', async () => {
1973
+ const device = Device.fromDescriptor({
1974
+ path: 'usb-path',
1975
+ protocolType: 'V2',
1976
+ } as any);
1977
+ const typedCall = jest.fn().mockResolvedValue({
1978
+ type: 'DeviceStatus',
1979
+ message: { init_states: true, unlocked: true },
1968
1980
  });
1969
- const typedCall = jest.fn().mockImplementation((request: string) => {
1970
- if (request === 'DeviceStatusGet') {
1971
- throw new Error('bootloader must not receive DeviceStatusGet');
1972
- }
1973
- return Promise.resolve({
1974
- type: 'DeviceInfo',
1975
- message: {
1976
- hw: { serial_no: 'PR2BOOT' },
1977
- fw: { bootloader: { version: '0.2.0' } },
1978
- se1: {},
1979
- },
1981
+ const updateProtocolV2Features = jest
1982
+ .spyOn(device, 'updateProtocolV2Features')
1983
+ .mockImplementation(() => {
1984
+ throw new Error('state update failed');
1980
1985
  });
1981
- });
1982
- (method as any).device = stubDevice({
1983
- getCommands: () => ({ typedCall }),
1984
- updateProtocolV2Features: jest.fn(),
1985
- });
1986
-
1987
- await expect((method as any).probeProtocolV2NormalMode()).resolves.toBe(false);
1988
- expect(typedCall).toHaveBeenCalledTimes(1);
1989
- expect(typedCall).toHaveBeenCalledWith(
1990
- 'DeviceInfoGet',
1991
- 'DeviceInfo',
1992
- expect.not.objectContaining({
1993
- targets: expect.objectContaining({ status: true }),
1994
- }),
1995
- expect.anything()
1996
- );
1997
- });
1986
+ (device as any).commands = { typedCall };
1998
1987
 
1999
- test('does not probe DeviceStatusGet while firmware device is still in romloader mode', async () => {
2000
- const method = new FirmwareUpdateV4({
2001
- id: 1,
2002
- payload: {
2003
- method: 'firmwareUpdateV4',
2004
- },
2005
- });
2006
- const typedCall = jest.fn().mockImplementation((request: string) => {
2007
- if (request === 'DeviceStatusGet') {
2008
- throw new Error('romloader must not receive DeviceStatusGet');
2009
- }
2010
- return Promise.resolve({
2011
- type: 'DeviceInfo',
2012
- message: {
2013
- hw: { serial_no: 'PR2ROM' },
2014
- fw: {
2015
- romloader: { version: '0.1.0' },
2016
- bootloader: { version: '0.2.0' },
2017
- },
2018
- },
2019
- });
2020
- });
2021
- (method as any).device = stubDevice({
2022
- getCommands: () => ({ typedCall }),
2023
- updateProtocolV2Features: jest.fn(),
2024
- });
1988
+ await expect(
1989
+ device.probeProtocolV2RuntimeState({
1990
+ hw: { serial_no: 'PR2SERIAL' },
1991
+ fw: { application: { version: '1.0.0' } },
1992
+ })
1993
+ ).rejects.toThrow('state update failed');
2025
1994
 
2026
- await expect((method as any).probeProtocolV2NormalMode()).resolves.toBe(false);
2027
1995
  expect(typedCall).toHaveBeenCalledTimes(1);
1996
+ expect(updateProtocolV2Features).toHaveBeenCalledTimes(1);
1997
+ expect(updateProtocolV2Features).toHaveBeenCalledWith(
1998
+ expect.anything(),
1999
+ expect.objectContaining({ init_states: true }),
2000
+ 'normal'
2001
+ );
2028
2002
  });
2029
2003
 
2030
2004
  test('refreshes Protocol V2 features without falling back to V1 GetFeatures', async () => {
@@ -2041,12 +2015,20 @@ describe('Protocol V2 feature adapter', () => {
2041
2015
  fw: { application: { version: '1.2.3' } },
2042
2016
  },
2043
2017
  })
2018
+ .mockResolvedValueOnce({
2019
+ type: 'DeviceStatus',
2020
+ message: { init_states: true, unlocked: true },
2021
+ })
2044
2022
  .mockResolvedValueOnce({
2045
2023
  type: 'DeviceInfo',
2046
2024
  message: {
2047
2025
  hw: { serial_no: 'PR2SERIAL' },
2048
2026
  fw: { application: { version: '1.2.4' } },
2049
2027
  },
2028
+ })
2029
+ .mockResolvedValueOnce({
2030
+ type: 'DeviceStatus',
2031
+ message: { init_states: true, unlocked: true },
2050
2032
  });
2051
2033
 
2052
2034
  (device as any).commands = { typedCall };
@@ -2065,9 +2047,9 @@ describe('Protocol V2 feature adapter', () => {
2065
2047
  firmwareVersion: '1.2.4',
2066
2048
  passphraseProtection: null,
2067
2049
  });
2068
- expect(typedCall).toHaveBeenCalledTimes(2);
2050
+ expect(typedCall).toHaveBeenCalledTimes(4);
2069
2051
  expect(typedCall).toHaveBeenNthCalledWith(
2070
- 2,
2052
+ 3,
2071
2053
  'DeviceInfoGet',
2072
2054
  'DeviceInfo',
2073
2055
  {
@@ -2075,6 +2057,10 @@ describe('Protocol V2 feature adapter', () => {
2075
2057
  hw: true,
2076
2058
  fw: true,
2077
2059
  coprocessor: true,
2060
+ se1: true,
2061
+ se2: true,
2062
+ se3: true,
2063
+ se4: true,
2078
2064
  },
2079
2065
  types: {
2080
2066
  version: true,
@@ -2142,12 +2128,22 @@ describe('Protocol V2 feature adapter', () => {
2142
2128
  message: { message: 'ok' },
2143
2129
  };
2144
2130
  }
2131
+ if (requestType === 'DeviceStatusGet') {
2132
+ return {
2133
+ type: 'DeviceStatus',
2134
+ message: {
2135
+ init_states: true,
2136
+ unlocked: true,
2137
+ passphrase_enabled: false,
2138
+ },
2139
+ };
2140
+ }
2145
2141
  throw new Error(`Unexpected request: ${requestType}`);
2146
2142
  });
2147
2143
 
2148
2144
  (device as any).commands = { typedCall };
2149
- // Pro2 版本线独立于 Pro(这里是 1.2.3,不满足 Pro 系列 4.15.0 门槛),
2150
- // Protocol V2 走独立的设备端 PIN 解锁流程,不走 Pro 系列版本门槛或 GetAddress 探测回退。
2145
+ // Pro2 uses an independent version line and dedicated device-side PIN flow,
2146
+ // without the Pro version threshold or GetAddress probe fallback.
2151
2147
  (device as any).features = normalizeProtocolV2Features(
2152
2148
  { ...descriptor, protocolType: 'V2' } as any,
2153
2149
  {
@@ -2161,6 +2157,7 @@ describe('Protocol V2 feature adapter', () => {
2161
2157
 
2162
2158
  expect(typedCall.mock.calls).toEqual([
2163
2159
  ['DeviceSessionAskPin', 'Success', undefined, { timeoutMs: 120_000 }],
2160
+ ['DeviceStatusGet', 'DeviceStatus', {}],
2164
2161
  ]);
2165
2162
  expect(typedCall).not.toHaveBeenCalledWith('GetAddress', 'Address', expect.anything());
2166
2163
  expect(typedCall).not.toHaveBeenCalledWith('GetFeatures', 'Features', {});
@@ -2171,7 +2168,7 @@ describe('Protocol V2 feature adapter', () => {
2171
2168
  });
2172
2169
  });
2173
2170
 
2174
- test('preserves cached Protocol V2 passphrase state without polling status after unlock', async () => {
2171
+ test('refreshes Protocol V2 passphrase and attach-PIN status after unlock', async () => {
2175
2172
  const device = Device.fromDescriptor({ ...descriptor, protocolType: 'V2' } as any);
2176
2173
  (device as any).features = normalizeProtocolV2Features(
2177
2174
  { ...descriptor, protocolType: 'V2' } as any,
@@ -2188,6 +2185,18 @@ describe('Protocol V2 feature adapter', () => {
2188
2185
  message: { message: 'ok' },
2189
2186
  };
2190
2187
  }
2188
+ if (requestType === 'DeviceStatusGet') {
2189
+ return {
2190
+ type: 'DeviceStatus',
2191
+ message: {
2192
+ init_states: true,
2193
+ unlocked: true,
2194
+ passphrase_enabled: true,
2195
+ attach_to_pin_enabled: true,
2196
+ unlocked_by_attach_to_pin: true,
2197
+ },
2198
+ };
2199
+ }
2191
2200
  throw new Error(`Unexpected request: ${requestType}`);
2192
2201
  });
2193
2202
  (device as any).commands = { typedCall };
@@ -2196,11 +2205,12 @@ describe('Protocol V2 feature adapter', () => {
2196
2205
 
2197
2206
  expect(typedCall.mock.calls).toEqual([
2198
2207
  ['DeviceSessionAskPin', 'Success', undefined, { timeoutMs: 120_000 }],
2208
+ ['DeviceStatusGet', 'DeviceStatus', {}],
2199
2209
  ]);
2200
2210
  expect((device as any).profile).toBeUndefined();
2201
2211
  expect(device.features?.unlocked).toBe(true);
2202
- expect(device.features?.passphraseProtection).toBeNull();
2203
- expect(device.features?.unlockedAttachPin).toBeUndefined();
2212
+ expect(device.features?.passphraseProtection).toBe(true);
2213
+ expect(device.features?.unlockedAttachPin).toBe(true);
2204
2214
  });
2205
2215
 
2206
2216
  test('maps unsupported Protocol V2 DeviceSessionAskPin to DeviceNotSupportMethod', async () => {
@@ -2227,7 +2237,7 @@ describe('Protocol V2 feature adapter', () => {
2227
2237
  ['DeviceSessionAskPin', 'Success', undefined, { timeoutMs: 120_000 }],
2228
2238
  ]);
2229
2239
  expect(typedCall).not.toHaveBeenCalledWith(
2230
- 'DeviceSessionOpen',
2240
+ 'DeviceSessionGet',
2231
2241
  'DeviceSession',
2232
2242
  expect.anything()
2233
2243
  );
@@ -2680,6 +2690,12 @@ describe('Protocol V2 firmware update targets', () => {
2680
2690
  },
2681
2691
  });
2682
2692
  }
2693
+ if (name === 'DeviceStatusGet') {
2694
+ return Promise.resolve({
2695
+ type: 'DeviceStatus',
2696
+ message: { init_states: true, unlocked: true },
2697
+ });
2698
+ }
2683
2699
  return Promise.reject(new Error(`unexpected call ${name}`));
2684
2700
  });
2685
2701
  const commands = { typedCall };
@@ -2701,7 +2717,7 @@ describe('Protocol V2 firmware update targets', () => {
2701
2717
  const versions = await (method as any).waitForProtocolV2FinalFeatures();
2702
2718
 
2703
2719
  expect(acquire).toHaveBeenCalledWith('ble-id', null, true, 'V2');
2704
- // 更新完成判定使用 VERSIONS 请求(含 SE targets),scope 与请求内容一致
2720
+ // Completion uses the VERSIONS request, including SE targets, with matching scope.
2705
2721
  expect(typedCall).toHaveBeenNthCalledWith(
2706
2722
  1,
2707
2723
  'DeviceInfoGet',
@@ -2723,7 +2739,7 @@ describe('Protocol V2 firmware update targets', () => {
2723
2739
  },
2724
2740
  { timeoutMs: 5000 }
2725
2741
  );
2726
- expect(typedCall).toHaveBeenCalledTimes(1);
2742
+ expect(typedCall).toHaveBeenCalledTimes(2);
2727
2743
  expect(typedCall).not.toHaveBeenCalledWith('Initialize', 'Features', {});
2728
2744
  expect(versions).toEqual({
2729
2745
  bootloaderVersion: '0.0.0',
@@ -2795,9 +2811,14 @@ describe('Protocol V2 firmware update targets', () => {
2795
2811
  message: 'Device rebooted successfully',
2796
2812
  });
2797
2813
  (method as any).checkDeviceToBootloader = jest.fn();
2798
- const typedCall = jest.fn().mockResolvedValue({
2799
- type: 'DeviceInfo',
2800
- message: protocolV2BootloaderDeviceInfo,
2814
+ const typedCall = jest.fn().mockImplementation((requestType: string) => {
2815
+ if (requestType === 'DeviceInfoGet') {
2816
+ return Promise.resolve({
2817
+ type: 'DeviceInfo',
2818
+ message: protocolV2BootloaderDeviceInfo,
2819
+ });
2820
+ }
2821
+ return Promise.reject(new Error('DeviceStatusGet unavailable in bootloader'));
2801
2822
  });
2802
2823
  const reconnectProtocolV2Device = jest.fn().mockImplementation(() => {
2803
2824
  (method as any).device.isBootloader = () => true;
@@ -2851,9 +2872,14 @@ describe('Protocol V2 firmware update targets', () => {
2851
2872
  features: { bootloader_mode: false, capabilities: [] },
2852
2873
  isBootloader: () => false,
2853
2874
  });
2854
- const typedCall = jest.fn().mockResolvedValue({
2855
- type: 'DeviceInfo',
2856
- message: protocolV2BootloaderDeviceInfo,
2875
+ const typedCall = jest.fn().mockImplementation((requestType: string) => {
2876
+ if (requestType === 'DeviceInfoGet') {
2877
+ return Promise.resolve({
2878
+ type: 'DeviceInfo',
2879
+ message: protocolV2BootloaderDeviceInfo,
2880
+ });
2881
+ }
2882
+ return Promise.reject(new Error('DeviceStatusGet unavailable in bootloader'));
2857
2883
  });
2858
2884
  const reconnectProtocolV2Device = jest
2859
2885
  .fn()
@@ -2871,6 +2897,7 @@ describe('Protocol V2 firmware update targets', () => {
2871
2897
 
2872
2898
  expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(3);
2873
2899
  expect(typedCall).toHaveBeenCalledTimes(1);
2900
+ expect(typedCall.mock.calls.map(call => call[0])).toEqual(['DeviceInfoGet']);
2874
2901
  });
2875
2902
 
2876
2903
  test('does not run generic initialize during Protocol V2 USB firmware reconnect', async () => {
@@ -2891,6 +2918,7 @@ describe('Protocol V2 firmware update targets', () => {
2891
2918
  }),
2892
2919
  },
2893
2920
  updateFromCache: jest.fn(),
2921
+ hasDeviceAcquire: jest.fn(() => false),
2894
2922
  acquire: jest.fn().mockResolvedValue(undefined),
2895
2923
  initialize,
2896
2924
  commands,
@@ -2914,7 +2942,92 @@ describe('Protocol V2 firmware update targets', () => {
2914
2942
  expect(initialize).not.toHaveBeenCalled();
2915
2943
  });
2916
2944
 
2917
- test('reboots Protocol V2 firmware flow back to normal after install status is finished', async () => {
2945
+ test('reuses an acquired Protocol V2 USB session while polling reconnect readiness', async () => {
2946
+ const method = new FirmwareUpdateV4({
2947
+ id: 1,
2948
+ payload: {
2949
+ method: 'firmwareUpdateV4',
2950
+ },
2951
+ });
2952
+ const commands = { disposed: false, mainId: 'usb-session' };
2953
+ const acquire = jest.fn().mockResolvedValue(undefined);
2954
+ const device = stubDevice({
2955
+ originalDescriptor: {
2956
+ id: 'usb-id',
2957
+ path: 'usb-path',
2958
+ session: 'usb-session',
2959
+ protocolType: 'V2',
2960
+ },
2961
+ deviceConnector: {
2962
+ enumerate: jest.fn().mockResolvedValue({
2963
+ descriptors: [
2964
+ {
2965
+ id: 'usb-id',
2966
+ path: 'usb-path',
2967
+ session: 'usb-session',
2968
+ protocolType: 'V2',
2969
+ },
2970
+ ],
2971
+ }),
2972
+ },
2973
+ updateDescriptor: jest.fn(),
2974
+ updateFromCache: jest.fn(),
2975
+ hasDeviceAcquire: jest.fn(() => true),
2976
+ acquire,
2977
+ commands,
2978
+ getCommands: () => commands,
2979
+ mainId: 'usb-session',
2980
+ });
2981
+ const getDevices = jest.spyOn(DevicePool, 'getDevices').mockResolvedValue({
2982
+ devices: {},
2983
+ deviceList: [{ getConnectId: () => 'usb-path' }],
2984
+ } as any);
2985
+ (method as any).device = device;
2986
+
2987
+ try {
2988
+ await (method as any).reconnectProtocolV2Device();
2989
+ await (method as any).reconnectProtocolV2Device();
2990
+ } finally {
2991
+ getDevices.mockRestore();
2992
+ }
2993
+
2994
+ expect(acquire).not.toHaveBeenCalled();
2995
+ expect(device.hasDeviceAcquire).toHaveBeenCalledTimes(2);
2996
+ });
2997
+
2998
+ test('stops final reconnect polling immediately when firmware changes device identity', async () => {
2999
+ const method = new FirmwareUpdateV4({
3000
+ id: 1,
3001
+ payload: {
3002
+ method: 'firmwareUpdateV4',
3003
+ },
3004
+ });
3005
+ const reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
3006
+ const typedCall = jest.fn().mockResolvedValue({
3007
+ type: 'DeviceInfo',
3008
+ message: {
3009
+ hw: { serial_no: 'PR2SERIAL' },
3010
+ fw: { application: { version: '1.0.0' } },
3011
+ },
3012
+ });
3013
+ (method as any).protocolV2ExpectedDeviceId = 'expected-device';
3014
+ (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
3015
+ (method as any).device = stubDevice({
3016
+ getCommands: () => ({ typedCall }),
3017
+ probeProtocolV2RuntimeState: jest.fn().mockResolvedValue({
3018
+ deviceId: 'changed-device',
3019
+ mode: 'normal',
3020
+ bootloaderMode: false,
3021
+ }),
3022
+ });
3023
+
3024
+ await expect((method as any).waitForProtocolV2ReconnectAndFeatures(60_000)).rejects.toThrow(
3025
+ 'identity mismatch'
3026
+ );
3027
+ expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
3028
+ });
3029
+
3030
+ test('reboots Protocol V2 firmware flow back to normal without legacy switch-firmware prompt', async () => {
2918
3031
  const method = new FirmwareUpdateV4({
2919
3032
  id: 1,
2920
3033
  payload: {
@@ -2922,13 +3035,15 @@ describe('Protocol V2 firmware update targets', () => {
2922
3035
  },
2923
3036
  });
2924
3037
  method.postTipMessage = jest.fn();
3038
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
2925
3039
  (method as any).protocolV2Reboot = jest.fn().mockResolvedValue({
2926
3040
  message: 'Device rebooted successfully',
2927
3041
  });
2928
3042
 
2929
3043
  await (method as any).exitProtocolV2BootloaderToNormal();
2930
3044
 
2931
- expect(method.postTipMessage).toHaveBeenCalledWith('SwitchFirmwareReconnectDevice');
3045
+ expect(method.postTipMessage).not.toHaveBeenCalledWith('SwitchFirmwareReconnectDevice');
3046
+ expect((method as any).reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
2932
3047
  expect((method as any).protocolV2Reboot).toHaveBeenCalledWith(DeviceRebootType.Normal);
2933
3048
  });
2934
3049
 
@@ -2974,7 +3089,7 @@ describe('Protocol V2 firmware update targets', () => {
2974
3089
  });
2975
3090
  });
2976
3091
 
2977
- test('treats WebUSB open NotFoundError during Protocol V2 firmware update as expected disconnect', async () => {
3092
+ test('requires an explicit Protocol V2 update ACK before entering install state', async () => {
2978
3093
  const method = new FirmwareUpdateV4({
2979
3094
  id: 1,
2980
3095
  payload: {
@@ -2994,17 +3109,19 @@ describe('Protocol V2 firmware update targets', () => {
2994
3109
  getCommands: () => ({ typedCall }),
2995
3110
  });
2996
3111
  method.postTipMessage = jest.fn();
3112
+ method.postProgressMessage = jest.fn();
2997
3113
 
2998
3114
  await expect(
2999
3115
  (method as any).protocolV2StartFirmwareUpdate({
3000
3116
  targets: [{ target_id: 4, path: 'vol1:firmware.bin' }],
3001
3117
  })
3002
- ).resolves.toBeUndefined();
3118
+ ).rejects.toThrow();
3003
3119
 
3004
- expect(method.postTipMessage).toHaveBeenCalledWith('FirmwareUpdating');
3120
+ expect(method.postTipMessage).not.toHaveBeenCalled();
3121
+ expect(method.postProgressMessage).not.toHaveBeenCalled();
3005
3122
  });
3006
3123
 
3007
- test('continues Protocol V2 install polling when update request transfer times out', async () => {
3124
+ test('does not poll Protocol V2 install status when the update ACK times out', async () => {
3008
3125
  const method = new FirmwareUpdateV4({
3009
3126
  id: 1,
3010
3127
  payload: {
@@ -3017,207 +3134,125 @@ describe('Protocol V2 firmware update targets', () => {
3017
3134
  getCommands: () => ({ typedCall }),
3018
3135
  });
3019
3136
  method.postTipMessage = jest.fn();
3137
+ method.postProgressMessage = jest.fn();
3020
3138
 
3021
3139
  await expect(
3022
3140
  (method as any).protocolV2StartFirmwareUpdate({
3023
3141
  targets: [{ target_id: 4, path: 'vol1:application_p1.bin' }],
3024
3142
  })
3025
- ).resolves.toBeUndefined();
3143
+ ).rejects.toThrow('LIBUSB_TRANSFER_TIMED_OUT');
3026
3144
 
3027
- expect(method.postTipMessage).toHaveBeenCalledWith('FirmwareUpdating');
3145
+ expect(method.postTipMessage).not.toHaveBeenCalled();
3146
+ expect(method.postProgressMessage).not.toHaveBeenCalled();
3028
3147
  });
3029
3148
 
3030
- test('retries final device readiness through temporary Protocol V2 probe failures', async () => {
3149
+ test('polls only firmware status while Protocol V2 bootloader is installing', async () => {
3031
3150
  const method = new FirmwareUpdateV4({
3032
3151
  id: 1,
3033
3152
  payload: {
3034
3153
  method: 'firmwareUpdateV4',
3035
3154
  },
3036
3155
  });
3037
- const typedCall = jest.fn().mockImplementation((name: string) => {
3038
- if (name === 'DeviceStatusGet') {
3039
- const callCount = typedCall.mock.calls.filter(call => call[0] === name).length;
3040
- if (callCount === 1) {
3041
- return Promise.reject(
3042
- new Error(
3043
- 'Device protocol mismatch: expected V2, but device did not respond to expected protocol'
3044
- )
3045
- );
3046
- }
3047
- return Promise.resolve({
3048
- type: 'DeviceStatus',
3049
- message: { device_id: 'PRO2-DEVICE-ID', init_states: true },
3050
- });
3051
- }
3052
- if (name === 'DeviceInfoGet') {
3053
- return Promise.resolve({
3054
- type: 'DeviceInfo',
3055
- message: {
3056
- hw: { serial_no: 'PR9999999999' },
3057
- fw: { application: { version: '9.9.9' } },
3058
- },
3059
- });
3060
- }
3061
- return Promise.reject(new Error(`unexpected call ${name}`));
3062
- });
3063
- const reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
3064
- const updateProtocolV2Features = jest.fn(() => ({
3065
- deviceId: 'PRO2-DEVICE-ID',
3066
- bootloaderMode: false,
3067
- mode: 'normal',
3068
- }));
3156
+ const typedCall = jest
3157
+ .fn()
3158
+ .mockResolvedValueOnce({
3159
+ type: 'DeviceFirmwareUpdateStatus',
3160
+ message: { records: [{ target_id: 4, status: 1 }] },
3161
+ })
3162
+ .mockResolvedValueOnce({
3163
+ type: 'DeviceFirmwareUpdateStatus',
3164
+ message: { records: [{ target_id: 4, status: 2 }] },
3165
+ });
3166
+ const reconnectProtocolV2Device = jest
3167
+ .fn()
3168
+ .mockRejectedValueOnce(new Error('Device rebooting'))
3169
+ .mockResolvedValue(undefined);
3069
3170
 
3070
3171
  (method as any).device = stubDevice({
3071
3172
  getCommands: () => ({ typedCall }),
3072
- updateProtocolV2Features,
3073
3173
  });
3074
3174
  (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
3075
3175
  method.postProgressMessage = jest.fn();
3076
3176
 
3077
3177
  await (method as any).waitForProtocolV2FirmwareUpdateComplete([
3078
- { target_id: 6, path: 'vol1:ble-firmware.bin' },
3178
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
3079
3179
  ]);
3080
3180
 
3081
- expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(2);
3181
+ expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(3);
3082
3182
  expect(typedCall.mock.calls.map(call => call[0])).toEqual([
3083
- 'DeviceInfoGet',
3084
- 'DeviceStatusGet',
3085
- 'DeviceInfoGet',
3086
- 'DeviceStatusGet',
3183
+ 'DeviceFirmwareUpdateStatusGet',
3184
+ 'DeviceFirmwareUpdateStatusGet',
3087
3185
  ]);
3186
+ expect(typedCall.mock.calls.map(call => call[0])).not.toContain('DeviceStatusGet');
3187
+ expect(method.postProgressMessage).toHaveBeenCalledWith(1, 'installingFirmware');
3188
+ expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
3088
3189
  });
3089
3190
 
3090
- test('retries final device readiness after the USB handle is no longer acquired', async () => {
3191
+ test('accepts a missing firmware status handler as an app reboot signal', async () => {
3091
3192
  const method = new FirmwareUpdateV4({
3092
3193
  id: 1,
3093
3194
  payload: {
3094
3195
  method: 'firmwareUpdateV4',
3095
3196
  },
3096
3197
  });
3097
- const typedCall = jest.fn().mockImplementation((name: string) => {
3098
- if (name === 'DeviceStatusGet') {
3099
- const callCount = typedCall.mock.calls.filter(call => call[0] === name).length;
3100
- if (callCount === 1) {
3101
- return Promise.reject(new Error('Device not acquired: PR9999999999'));
3102
- }
3103
- return Promise.resolve({
3104
- type: 'DeviceStatus',
3105
- message: { device_id: 'PRO2-DEVICE-ID', init_states: true },
3106
- });
3107
- }
3108
- if (name === 'DeviceInfoGet') {
3109
- return Promise.resolve({
3110
- type: 'DeviceInfo',
3111
- message: {
3112
- hw: { serial_no: 'PR9999999999' },
3113
- fw: { application: { version: '9.9.9' } },
3114
- },
3115
- });
3116
- }
3117
- return Promise.reject(new Error(`unexpected call ${name}`));
3118
- });
3198
+ const typedCall = jest
3199
+ .fn()
3200
+ .mockRejectedValue(new Error('Failure: Handler not registered for this message'));
3119
3201
  const reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
3120
- const updateProtocolV2Features = jest.fn(() => ({
3121
- deviceId: 'PRO2-DEVICE-ID',
3122
- bootloaderMode: false,
3123
- mode: 'normal',
3124
- }));
3125
3202
 
3126
3203
  (method as any).device = stubDevice({
3127
3204
  getCommands: () => ({ typedCall }),
3128
- updateProtocolV2Features,
3129
3205
  });
3130
3206
  (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
3131
3207
  method.postProgressMessage = jest.fn();
3132
3208
 
3133
3209
  await (method as any).waitForProtocolV2FirmwareUpdateComplete([
3134
- { target_id: 3, path: 'vol0:/bootloader.bin' },
3210
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
3135
3211
  ]);
3136
3212
 
3137
- expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(2);
3138
- expect(typedCall.mock.calls.map(call => call[0])).toEqual([
3139
- 'DeviceInfoGet',
3140
- 'DeviceStatusGet',
3141
- 'DeviceInfoGet',
3142
- 'DeviceStatusGet',
3143
- ]);
3213
+ expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
3214
+ expect(typedCall.mock.calls.map(call => call[0])).toEqual(['DeviceFirmwareUpdateStatusGet']);
3144
3215
  });
3145
3216
 
3146
- test('treats DeviceStatusGet and DeviceInfoGet readiness as firmware update complete', async () => {
3217
+ test('polls target status after update ACK and finishes when all targets complete', async () => {
3147
3218
  const method = new FirmwareUpdateV4({
3148
3219
  id: 1,
3149
3220
  payload: {
3150
3221
  method: 'firmwareUpdateV4',
3151
3222
  },
3152
3223
  });
3153
- const typedCall = jest.fn().mockImplementation((name: string) => {
3154
- if (name === 'DeviceStatusGet') {
3155
- return Promise.resolve({
3156
- type: 'DeviceStatus',
3157
- message: {
3158
- device_id: 'PRO2-DEVICE-ID',
3159
- init_states: true,
3160
- },
3161
- });
3162
- }
3163
- if (name === 'DeviceInfoGet') {
3164
- return Promise.resolve({
3165
- type: 'DeviceInfo',
3166
- message: {
3167
- hw: { serial_no: 'PR9999999999' },
3168
- fw: {
3169
- bootloader: { version: '9.9.9' },
3170
- application: { version: '9.9.9' },
3171
- },
3172
- },
3173
- });
3174
- }
3175
- return Promise.reject(new Error(`unexpected call ${name}`));
3176
- });
3224
+ const typedCall = jest.fn().mockResolvedValue({
3225
+ type: 'DeviceFirmwareUpdateStatus',
3226
+ message: {
3227
+ records: [
3228
+ { target_id: 3, status: 2 },
3229
+ { target_id: 4, status: 2 },
3230
+ ],
3231
+ },
3232
+ });
3177
3233
  const reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
3178
- const updateProtocolV2Features = jest.fn(() => ({
3179
- deviceId: 'PRO2-DEVICE-ID',
3180
- bootloaderMode: false,
3181
- mode: 'normal',
3182
- }));
3183
3234
 
3184
3235
  (method as any).device = stubDevice({
3185
3236
  getCommands: () => ({ typedCall }),
3186
- updateProtocolV2Features,
3187
3237
  });
3188
3238
  (method as any).reconnectProtocolV2Device = reconnectProtocolV2Device;
3189
3239
  method.postProgressMessage = jest.fn();
3190
3240
 
3191
3241
  await expect(
3192
- (method as any).waitForProtocolV2FirmwareUpdateComplete(
3193
- [
3194
- { target_id: 3, path: 'vol1:bootloader.bin' },
3195
- { target_id: 4, path: 'vol1:application_p1.bin' },
3196
- ],
3197
- {
3198
- type: 'DeviceFirmwareUpdateStatus',
3199
- message: {
3200
- records: [
3201
- { target_id: 3, status: 2 },
3202
- { target_id: 4, status: 2 },
3203
- ],
3204
- },
3205
- }
3206
- )
3242
+ (method as any).waitForProtocolV2FirmwareUpdateComplete([
3243
+ { target_id: 3, path: 'vol1:bootloader.bin' },
3244
+ { target_id: 4, path: 'vol1:application_p1.bin' },
3245
+ ])
3207
3246
  ).resolves.toBeUndefined();
3208
3247
 
3209
3248
  expect(reconnectProtocolV2Device).toHaveBeenCalledTimes(1);
3210
- expect(updateProtocolV2Features).toHaveBeenCalledTimes(1);
3211
- expect(updateProtocolV2Features).toHaveBeenCalledWith(
3212
- expect.objectContaining({ hw: { serial_no: 'PR9999999999' } }),
3213
- expect.objectContaining({ device_id: 'PRO2-DEVICE-ID' })
3214
- );
3215
- expect(typedCall.mock.calls.map(call => call[0])).toEqual(['DeviceInfoGet', 'DeviceStatusGet']);
3216
- expect(typedCall).not.toHaveBeenCalledWith(
3249
+ expect(typedCall).toHaveBeenCalledWith(
3217
3250
  'DeviceFirmwareUpdateStatusGet',
3251
+ 'DeviceFirmwareUpdateStatus',
3218
3252
  expect.anything(),
3219
3253
  expect.anything()
3220
3254
  );
3255
+ expect(method.postProgressMessage).toHaveBeenCalledWith(100, 'installingFirmware');
3221
3256
  });
3222
3257
 
3223
3258
  test('uses SDK decoded enum names for Protocol V2 install polling', () => {
@@ -3262,7 +3297,7 @@ describe('Protocol V2 firmware update targets', () => {
3262
3297
  expectedTargetIds
3263
3298
  )
3264
3299
  ).toBe(false);
3265
- expect(method.postProgressMessage).toHaveBeenCalledWith(99, 'installingFirmware');
3300
+ expect(method.postProgressMessage).toHaveBeenCalledWith(1, 'installingFirmware');
3266
3301
 
3267
3302
  try {
3268
3303
  (method as any).assertProtocolV2TargetStatus(
@@ -3276,15 +3311,72 @@ describe('Protocol V2 firmware update targets', () => {
3276
3311
 
3277
3312
  try {
3278
3313
  (method as any).assertProtocolV2TargetStatus(
3279
- [{ target_id: 4, status: 'FW_MGMT_UPDATER_TASK_STATUS_FAILED_VERIFY' }],
3314
+ [
3315
+ {
3316
+ target_id: 4,
3317
+ status: 'FW_MGMT_UPDATER_TASK_STATUS_FAILED_VERIFY',
3318
+ payload_version: 0x010000,
3319
+ path: 'vol0:/application_p1.bin',
3320
+ },
3321
+ ],
3280
3322
  expectedTargetIds
3281
3323
  );
3282
3324
  throw new Error('Expected Protocol V2 failed firmware status to throw');
3283
3325
  } catch (error: any) {
3284
3326
  expect(error.errorCode).toBe(HardwareErrorCode.FirmwareError);
3327
+ expect(error.message).toContain('FW_MGMT_UPDATER_TASK_STATUS_FAILED_VERIFY');
3328
+ expect(error.message).toContain('vol0:/application_p1.bin');
3285
3329
  }
3286
3330
  });
3287
3331
 
3332
+ test('reports failed Protocol V2 install status before the normal-mode timeout', async () => {
3333
+ const method = new FirmwareUpdateV4({
3334
+ id: 1,
3335
+ payload: {
3336
+ method: 'firmwareUpdateV4',
3337
+ },
3338
+ });
3339
+ const typedCall = jest.fn().mockResolvedValue({
3340
+ type: 'DeviceFirmwareUpdateStatus',
3341
+ message: {
3342
+ records: [
3343
+ {
3344
+ target_id: 4,
3345
+ status: 'FW_MGMT_UPDATER_TASK_STATUS_FAILED_VERIFY',
3346
+ payload_version: 0x010000,
3347
+ path: 'vol0:/application_p1.bin',
3348
+ },
3349
+ ],
3350
+ },
3351
+ });
3352
+ (method as any).device = stubDevice({
3353
+ getCommands: () => ({ typedCall }),
3354
+ });
3355
+ (method as any).reconnectProtocolV2Device = jest.fn().mockResolvedValue(undefined);
3356
+ (method as any).probeProtocolV2NormalMode = jest.fn().mockResolvedValue(false);
3357
+
3358
+ await expect(
3359
+ (method as any).waitForProtocolV2FirmwareUpdateComplete([
3360
+ { target_id: 4, path: 'vol0:/application_p1.bin' },
3361
+ ])
3362
+ ).rejects.toMatchObject({
3363
+ errorCode: HardwareErrorCode.FirmwareError,
3364
+ message: expect.stringContaining('vol0:/application_p1.bin'),
3365
+ });
3366
+ expect(typedCall).toHaveBeenCalledWith(
3367
+ 'DeviceFirmwareUpdateStatusGet',
3368
+ 'DeviceFirmwareUpdateStatus',
3369
+ {
3370
+ fields: {
3371
+ status: true,
3372
+ payload_version: true,
3373
+ path: true,
3374
+ },
3375
+ },
3376
+ { timeoutMs: 5000 }
3377
+ );
3378
+ });
3379
+
3288
3380
  test('passes bootloader, coprocessor, SE and app files to DeviceFirmwareUpdate targets', async () => {
3289
3381
  const method = new FirmwareUpdateV4({
3290
3382
  id: 1,
@@ -3679,6 +3771,137 @@ describe('Protocol V2 firmware update targets', () => {
3679
3771
  getFirmwareLatestReleaseSpy.mockRestore();
3680
3772
  });
3681
3773
 
3774
+ test('downloads and validates remote RESC bundles before entering bootloader', async () => {
3775
+ const method = new FirmwareUpdateV4({
3776
+ id: 1,
3777
+ payload: {
3778
+ method: 'firmwareUpdateV4',
3779
+ platform: 'web',
3780
+ targetsToUpdate: ['resource'],
3781
+ },
3782
+ });
3783
+ method.init();
3784
+
3785
+ const order: string[] = [];
3786
+ const resourceBinary = new Uint8Array([1, 2, 3]).buffer;
3787
+ const getSysResourceBinarySpy = jest
3788
+ .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
3789
+ .mockImplementation(() => {
3790
+ order.push('download');
3791
+ return Promise.resolve({ binary: resourceBinary });
3792
+ });
3793
+ const getFirmwareLatestReleaseSpy = jest
3794
+ .spyOn(DataManager, 'getFirmwareLatestRelease')
3795
+ .mockReturnValue({
3796
+ required: false,
3797
+ version: [1, 0, 0],
3798
+ url: '',
3799
+ resourceBundles: [
3800
+ {
3801
+ name: 'images',
3802
+ url: 'https://example.com/images.okpkg',
3803
+ devicePath: 'vol0:/resource/images/images.okpkg',
3804
+ },
3805
+ ],
3806
+ fingerprint: '',
3807
+ changelog: {
3808
+ 'zh-CN': '',
3809
+ 'en-US': '',
3810
+ },
3811
+ });
3812
+
3813
+ (method as any).device = stubDevice({
3814
+ originalDescriptor: { protocolType: 'V2' },
3815
+ features: { deviceType: 'pro2', firmwareVersion: '1.0.0', capabilities: [] },
3816
+ });
3817
+ (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue({
3818
+ bootloaderBinary: null,
3819
+ fwBinaryMap: [],
3820
+ });
3821
+ (method as any).enterProtocolV2BootloaderMode = jest.fn().mockImplementation(() => {
3822
+ order.push('bootloader');
3823
+ return Promise.resolve(true);
3824
+ });
3825
+ (method as any).executeProtocolV2Update = jest.fn().mockResolvedValue(undefined);
3826
+ (method as any).exitProtocolV2BootloaderToNormal = jest.fn().mockResolvedValue(undefined);
3827
+ (method as any).waitForProtocolV2FinalFeatures = jest.fn().mockResolvedValue({
3828
+ bootloaderVersion: '1.0.0',
3829
+ bleVersion: '0.0.0',
3830
+ firmwareVersion: '1.0.0',
3831
+ });
3832
+ method.postTipMessage = jest.fn();
3833
+
3834
+ await method.run();
3835
+
3836
+ expect(order).toEqual(['download', 'bootloader']);
3837
+ expect((method as any).executeProtocolV2Update).toHaveBeenCalledWith({
3838
+ bootloaderBinary: null,
3839
+ fwBinaryMap: [],
3840
+ resourceBundles: [
3841
+ {
3842
+ name: 'images',
3843
+ binary: resourceBinary,
3844
+ devicePath: 'vol0:/resource/images/images.okpkg',
3845
+ url: 'https://example.com/images.okpkg',
3846
+ },
3847
+ ],
3848
+ });
3849
+
3850
+ getSysResourceBinarySpy.mockRestore();
3851
+ getFirmwareLatestReleaseSpy.mockRestore();
3852
+ });
3853
+
3854
+ test('rejects an empty remote RESC bundle before entering bootloader', async () => {
3855
+ const method = new FirmwareUpdateV4({
3856
+ id: 1,
3857
+ payload: {
3858
+ method: 'firmwareUpdateV4',
3859
+ platform: 'web',
3860
+ targetsToUpdate: ['resource'],
3861
+ },
3862
+ });
3863
+ method.init();
3864
+
3865
+ const getSysResourceBinarySpy = jest
3866
+ .spyOn(firmwareBinaryApi, 'getSysResourceBinary')
3867
+ .mockResolvedValue({ binary: new ArrayBuffer(0) });
3868
+ const getFirmwareLatestReleaseSpy = jest
3869
+ .spyOn(DataManager, 'getFirmwareLatestRelease')
3870
+ .mockReturnValue({
3871
+ required: false,
3872
+ version: [1, 0, 0],
3873
+ url: '',
3874
+ resourceBundles: [
3875
+ {
3876
+ name: 'images',
3877
+ url: 'https://example.com/images.okpkg',
3878
+ devicePath: 'vol0:/resource/images/images.okpkg',
3879
+ },
3880
+ ],
3881
+ fingerprint: '',
3882
+ changelog: {
3883
+ 'zh-CN': '',
3884
+ 'en-US': '',
3885
+ },
3886
+ });
3887
+ (method as any).device = stubDevice({
3888
+ originalDescriptor: { protocolType: 'V2' },
3889
+ features: { deviceType: 'pro2', firmwareVersion: '1.0.0', capabilities: [] },
3890
+ });
3891
+ (method as any).prepareRemoteProtocolV2Binaries = jest.fn().mockResolvedValue({
3892
+ bootloaderBinary: null,
3893
+ fwBinaryMap: [],
3894
+ });
3895
+ (method as any).enterProtocolV2BootloaderMode = jest.fn();
3896
+ method.postTipMessage = jest.fn();
3897
+
3898
+ await expect(method.run()).rejects.toThrow('Protocol V2 RESC bundle is empty: images');
3899
+ expect((method as any).enterProtocolV2BootloaderMode).not.toHaveBeenCalled();
3900
+
3901
+ getSysResourceBinarySpy.mockRestore();
3902
+ getFirmwareLatestReleaseSpy.mockRestore();
3903
+ });
3904
+
3682
3905
  test('does not download Pro2 firmware components that App did not select', async () => {
3683
3906
  const method = new FirmwareUpdateV4({
3684
3907
  id: 1,
@@ -4073,14 +4296,20 @@ describe('Protocol V2 firmware update targets', () => {
4073
4296
  expect(writePayloads.map(payload => payload.ui_percentage)).toEqual([0, 100]);
4074
4297
  });
4075
4298
 
4076
- test('consumes Protocol V2 install progress before final update success', async () => {
4299
+ test('ends device confirmation and starts install progress only after Protocol V2 ACK', async () => {
4077
4300
  const method = new FirmwareUpdateV4({
4078
4301
  id: 1,
4079
4302
  payload: {
4080
4303
  method: 'firmwareUpdateV4',
4081
4304
  },
4082
4305
  });
4083
- const typedCall = jest.fn().mockResolvedValue({ type: 'Success', message: { message: 'ok' } });
4306
+ let resolveRequest: ((value: unknown) => void) | undefined;
4307
+ const typedCall = jest.fn().mockImplementation(
4308
+ () =>
4309
+ new Promise(resolve => {
4310
+ resolveRequest = resolve;
4311
+ })
4312
+ );
4084
4313
 
4085
4314
  (method as any).device = stubDevice({
4086
4315
  getCommands: () => ({ typedCall }),
@@ -4088,22 +4317,24 @@ describe('Protocol V2 firmware update targets', () => {
4088
4317
  method.postProgressMessage = jest.fn();
4089
4318
  method.postTipMessage = jest.fn();
4090
4319
 
4091
- await (method as any).protocolV2StartFirmwareUpdate({
4320
+ const startPromise = (method as any).protocolV2StartFirmwareUpdate({
4092
4321
  targets: [{ target_id: 4, path: 'vol1:firmware.bin' }],
4093
4322
  });
4323
+ await Promise.resolve();
4094
4324
 
4095
- const callOptions = typedCall.mock.calls[0][3];
4096
- expect(typedCall.mock.calls[0][1]).toEqual(['Success', 'DeviceFirmwareUpdateStatus']);
4097
- expect(callOptions.intermediateTypes).toEqual(['DeviceFirmwareUpdateStatus']);
4098
- callOptions.onIntermediateResponse({
4099
- type: 'DeviceFirmwareUpdateStatus',
4100
- message: { records: [{ target_id: 4, status: 1 }] },
4101
- });
4325
+ expect(typedCall.mock.calls[0][1]).toBe('Success');
4326
+ expect(typedCall.mock.calls[0][3]).toEqual(expect.objectContaining({ timeoutMs: 180_000 }));
4327
+ expect(method.postTipMessage).not.toHaveBeenCalled();
4328
+ expect(method.postProgressMessage).not.toHaveBeenCalled();
4102
4329
 
4103
- expect(method.postProgressMessage).toHaveBeenCalledWith(99, 'installingFirmware');
4330
+ resolveRequest?.({ type: 'Success', message: { message: 'ok' } });
4331
+ await startPromise;
4332
+ expect(method.postTipMessage).toHaveBeenCalledWith('FirmwareUpdating');
4333
+ expect(method.postProgressMessage).toHaveBeenCalledWith(0, 'installingFirmware');
4334
+ expect(method.postTipMessage).toHaveBeenCalledTimes(1);
4104
4335
  });
4105
4336
 
4106
- test('accepts Protocol V2 firmware update status as start response', async () => {
4337
+ test('requests only a Success ACK for Protocol V2 firmware install', async () => {
4107
4338
  const method = new FirmwareUpdateV4({
4108
4339
  id: 1,
4109
4340
  payload: {
@@ -4111,8 +4342,8 @@ describe('Protocol V2 firmware update targets', () => {
4111
4342
  },
4112
4343
  });
4113
4344
  const typedCall = jest.fn().mockResolvedValue({
4114
- type: 'DeviceFirmwareUpdateStatus',
4115
- message: { records: [{ target_id: 4, status: 1 }] },
4345
+ type: 'Success',
4346
+ message: { message: 'accepted' },
4116
4347
  });
4117
4348
 
4118
4349
  (method as any).device = stubDevice({
@@ -4125,7 +4356,7 @@ describe('Protocol V2 firmware update targets', () => {
4125
4356
  targets: [{ target_id: 4, path: 'vol1:firmware.bin' }],
4126
4357
  });
4127
4358
 
4128
- expect(typedCall.mock.calls[0][1]).toEqual(['Success', 'DeviceFirmwareUpdateStatus']);
4359
+ expect(typedCall.mock.calls[0][1]).toBe('Success');
4129
4360
  expect(method.postTipMessage).toHaveBeenCalledWith('FirmwareUpdating');
4130
4361
  });
4131
4362
  });
@@ -4472,6 +4703,24 @@ describe('Protocol V2 protected method execution', () => {
4472
4703
  expect(method.unlockPolicy).toBe('none');
4473
4704
  });
4474
4705
 
4706
+ test('uses the same label interaction for the compatible deviceSettings entry point', () => {
4707
+ const method = new DeviceSettings({
4708
+ id: 2,
4709
+ payload: { method: 'deviceSettings', label: 'My Pro 2' },
4710
+ });
4711
+
4712
+ method.init();
4713
+
4714
+ expect(method.protocolV2UiInteraction).toEqual({
4715
+ request: 'button',
4716
+ source: 'method-lifecycle',
4717
+ reason: 'device-management',
4718
+ completion: 'operation-completed',
4719
+ deviceOnly: true,
4720
+ operation: 'change-label',
4721
+ });
4722
+ });
4723
+
4475
4724
  test.each([
4476
4725
  ['deviceInfoGet', DeviceInfoGet],
4477
4726
  ['deviceStatusGet', DeviceStatusGet],
@@ -4722,6 +4971,7 @@ describe('Protocol V2 current low-level methods', () => {
4722
4971
  });
4723
4972
 
4724
4973
  await expect(v2Method.run()).resolves.toEqual({ message: 'accepted' });
4974
+ expect(v2Method.unlockPolicy).toBe('retry-on-locked');
4725
4975
  expect(v2TypedCall).toHaveBeenCalledWith('DeviceSettingsPageShow', 'Success', {
4726
4976
  page: DeviceSettingsPage.DeviceReset,
4727
4977
  });
@@ -4764,6 +5014,7 @@ describe('Protocol V2 current low-level methods', () => {
4764
5014
  });
4765
5015
 
4766
5016
  await expect(v2Method.run()).resolves.toEqual({ message: 'accepted' });
5017
+ expect(v2Method.unlockPolicy).toBe('retry-on-locked');
4767
5018
  expect(v2TypedCall).toHaveBeenCalledWith('DeviceSettingsPageShow', 'Success', {
4768
5019
  page: DeviceSettingsPage.DevicePinChange,
4769
5020
  });
@@ -4927,6 +5178,9 @@ describe('Protocol V2 current low-level methods', () => {
4927
5178
 
4928
5179
  test('opens non-passphrase Protocol V2 settings pages', async () => {
4929
5180
  const typedCall = jest.fn().mockResolvedValue({ message: { message: 'ok' } });
5181
+ const getDeviceState = jest.fn().mockResolvedValue({
5182
+ settings: { airgapMode: true },
5183
+ });
4930
5184
  const method = new DeviceSettingsPageShow({
4931
5185
  id: 1,
4932
5186
  payload: {
@@ -4936,7 +5190,7 @@ describe('Protocol V2 current low-level methods', () => {
4936
5190
  },
4937
5191
  });
4938
5192
  method.init();
4939
- (method as any).device = stubDevice({ commands: { typedCall } });
5193
+ (method as any).device = stubDevice({ commands: { typedCall }, getDeviceState });
4940
5194
 
4941
5195
  await method.run();
4942
5196
 
@@ -4952,10 +5206,14 @@ describe('Protocol V2 current low-level methods', () => {
4952
5206
  deviceOnly: true,
4953
5207
  page: DeviceSettingsPage.DeviceAirgap,
4954
5208
  });
5209
+ expect(getDeviceState).toHaveBeenCalledWith({ refreshSections: ['settings'] });
4955
5210
  });
4956
5211
 
4957
5212
  test('opens the Protocol V2 passphrase settings page', async () => {
4958
5213
  const typedCall = jest.fn().mockResolvedValue({ message: { message: 'ok' } });
5214
+ const getDeviceState = jest.fn().mockResolvedValue({
5215
+ status: { passphraseProtection: true },
5216
+ });
4959
5217
  const method = new DeviceSettingsPageShow({
4960
5218
  id: 1,
4961
5219
  payload: {
@@ -4964,7 +5222,7 @@ describe('Protocol V2 current low-level methods', () => {
4964
5222
  },
4965
5223
  });
4966
5224
  method.init();
4967
- (method as any).device = stubDevice({ commands: { typedCall } });
5225
+ (method as any).device = stubDevice({ commands: { typedCall }, getDeviceState });
4968
5226
 
4969
5227
  await method.run();
4970
5228
 
@@ -4972,6 +5230,7 @@ describe('Protocol V2 current low-level methods', () => {
4972
5230
  page: 2,
4973
5231
  field_name: undefined,
4974
5232
  });
5233
+ expect(getDeviceState).toHaveBeenCalledWith({ refreshSections: ['status'] });
4975
5234
  });
4976
5235
 
4977
5236
  test('does not unlock before reading Protocol V2 device settings', () => {
@@ -4985,22 +5244,67 @@ describe('Protocol V2 current low-level methods', () => {
4985
5244
  expect(method.unlockPolicy).toBe('none');
4986
5245
  });
4987
5246
 
4988
- test('marks Protocol V2 settings mutations for unlock-on-locked retry', () => {
4989
- const methods = [
4990
- new DeviceSettingsSet({
4991
- id: 2,
4992
- payload: { method: 'deviceSettingsSet', settings: { brightness: 80 } },
4993
- }),
4994
- new DeviceSettingsPageShow({
4995
- id: 3,
4996
- payload: { method: 'deviceSettingsPageShow', page: 'DevicePassphrase' },
4997
- }),
4998
- ];
5247
+ test.each([
5248
+ ['label', { label: 'My Pro 2' }],
5249
+ ['language', { language: 'ja-JP' }],
5250
+ ['brightness', { brightness: 80 }],
5251
+ ['haptic feedback', { haptic_feedback: true }],
5252
+ [
5253
+ 'combined lock-free settings',
5254
+ {
5255
+ label: 'My Pro 2',
5256
+ language: 'ja-JP',
5257
+ brightness: 80,
5258
+ haptic_feedback: true,
5259
+ },
5260
+ ],
5261
+ ])('does not unlock before changing Protocol V2 %s', (_name, settings) => {
5262
+ const method = new DeviceSettingsSet({
5263
+ id: 2,
5264
+ payload: { method: 'deviceSettingsSet', settings },
5265
+ });
4999
5266
 
5000
- methods.forEach(method => {
5001
- method.init();
5002
- expect(method.unlockPolicy).toBe('retry-on-locked');
5267
+ method.init();
5268
+
5269
+ expect(method.unlockPolicy).toBe('none');
5270
+ });
5271
+
5272
+ test.each([
5273
+ ['auto lock', { autolock_delay_ms: 60_000 }],
5274
+ ['auto shutdown', { autoshutdown_delay_ms: 120_000 }],
5275
+ ['mixed protected settings', { brightness: 80, autolock_delay_ms: 60_000 }],
5276
+ ['other protected settings', { usb_lock_enable: true }],
5277
+ ])('unlocks before changing Protocol V2 %s', (_name, settings) => {
5278
+ const method = new DeviceSettingsSet({
5279
+ id: 2,
5280
+ payload: { method: 'deviceSettingsSet', settings },
5281
+ });
5282
+
5283
+ method.init();
5284
+
5285
+ expect(method.unlockPolicy).toBe('retry-on-locked');
5286
+ });
5287
+
5288
+ test('unlocks before opening Protocol V2 settings pages', () => {
5289
+ const method = new DeviceSettingsPageShow({
5290
+ id: 3,
5291
+ payload: { method: 'deviceSettingsPageShow', page: 'DevicePassphrase' },
5292
+ });
5293
+
5294
+ method.init();
5295
+
5296
+ expect(method.unlockPolicy).toBe('retry-on-locked');
5297
+ });
5298
+
5299
+ test('keeps firmwareUpdateV4 unlock-before-reboot behavior explicit', () => {
5300
+ const method = new FirmwareUpdateV4({
5301
+ id: 4,
5302
+ payload: { method: 'firmwareUpdateV4' },
5003
5303
  });
5304
+
5305
+ method.init();
5306
+
5307
+ expect(method.unlockPolicy).toBe('retry-on-locked');
5004
5308
  });
5005
5309
 
5006
5310
  test('sends FilesystemPermissionFix from filesystemPermissionFix', async () => {