@onekeyfe/hd-core 1.2.0-alpha.107 → 1.2.0-alpha.108

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 (57) hide show
  1. package/__tests__/DeviceCommands.test.ts +140 -26
  2. package/__tests__/base64Data.test.ts +70 -0
  3. package/__tests__/device-lifecycle-events.test.ts +113 -2
  4. package/__tests__/device-pool-state.test.ts +25 -0
  5. package/__tests__/deviceSettings.test.ts +0 -1
  6. package/__tests__/deviceUploadNft.test.ts +33 -4
  7. package/__tests__/firmware-update/firmware-update-v4-install-poll.test.ts +178 -0
  8. package/__tests__/get-device-state.test.ts +52 -13
  9. package/__tests__/logBlockEvent.test.ts +13 -1
  10. package/__tests__/protocol-v2.test.ts +762 -124
  11. package/__tests__/refresh-device-state.test.ts +3 -1
  12. package/__tests__/resourceBase64Boundary.test.ts +48 -0
  13. package/__tests__/ton-sign-message.test.ts +84 -0
  14. package/dist/api/FirmwareUpdateV4.d.ts +10 -3
  15. package/dist/api/FirmwareUpdateV4.d.ts.map +1 -1
  16. package/dist/api/UploadPortfolio.d.ts +1 -1
  17. package/dist/api/UploadPortfolio.d.ts.map +1 -1
  18. package/dist/api/helpers/base64Data.d.ts +16 -0
  19. package/dist/api/helpers/base64Data.d.ts.map +1 -0
  20. package/dist/api/protocol-v2/DeviceUploadNft.d.ts +2 -3
  21. package/dist/api/protocol-v2/DeviceUploadNft.d.ts.map +1 -1
  22. package/dist/api/protocol-v2/DeviceUploadWallpaper.d.ts +2 -3
  23. package/dist/api/protocol-v2/DeviceUploadWallpaper.d.ts.map +1 -1
  24. package/dist/api/ton/TonSignMessage.d.ts.map +1 -1
  25. package/dist/core/index.d.ts.map +1 -1
  26. package/dist/device/Device.d.ts +7 -2
  27. package/dist/device/Device.d.ts.map +1 -1
  28. package/dist/device/DeviceCommands.d.ts.map +1 -1
  29. package/dist/device/DevicePool.d.ts.map +1 -1
  30. package/dist/events/logBlockEvent.d.ts.map +1 -1
  31. package/dist/index.d.ts +11 -14
  32. package/dist/index.js +455 -200
  33. package/dist/protocols/protocol-v2/features.d.ts +9 -1
  34. package/dist/protocols/protocol-v2/features.d.ts.map +1 -1
  35. package/dist/protocols/protocol-v2/index.d.ts +2 -2
  36. package/dist/protocols/protocol-v2/index.d.ts.map +1 -1
  37. package/dist/types/api/protocolV2.d.ts +1 -1
  38. package/dist/types/api/protocolV2.d.ts.map +1 -1
  39. package/dist/utils/pro2Nft.d.ts +7 -0
  40. package/dist/utils/pro2Nft.d.ts.map +1 -1
  41. package/package.json +6 -4
  42. package/src/api/FirmwareUpdateV4.ts +326 -174
  43. package/src/api/UploadPortfolio.ts +9 -2
  44. package/src/api/helpers/base64Data.ts +85 -0
  45. package/src/api/protocol-v2/DeviceUploadNft.ts +56 -8
  46. package/src/api/protocol-v2/DeviceUploadWallpaper.ts +37 -18
  47. package/src/api/ton/TonSignMessage.ts +5 -3
  48. package/src/core/index.ts +6 -2
  49. package/src/device/Device.ts +53 -31
  50. package/src/device/DeviceCommands.ts +4 -14
  51. package/src/device/DevicePool.ts +6 -2
  52. package/src/events/logBlockEvent.ts +14 -1
  53. package/src/protocols/protocol-v2/features.ts +19 -2
  54. package/src/protocols/protocol-v2/index.ts +2 -0
  55. package/src/types/api/protocolV2.ts +1 -1
  56. package/src/utils/deviceSettings.ts +1 -1
  57. package/src/utils/pro2Nft.ts +31 -8
@@ -28,6 +28,7 @@ import { DevicePool } from '../device/DevicePool';
28
28
  import {
29
29
  PROTOCOL_V2_VERSIONS_DEVICE_INFO_REQUEST,
30
30
  ProtocolV2FirmwareTargetType,
31
+ isLegacyProtocolV2ProtocolInfo,
31
32
  } from '../protocols/protocol-v2';
32
33
  import { requestProtocolV2DeviceInfo } from '../protocols/protocol-v2/features';
33
34
  import {
@@ -243,15 +244,7 @@ type JSZipSizedEntry = JSZip.JSZipObject & {
243
244
  };
244
245
  };
245
246
 
246
- type ProtocolV2ExecutionPhaseKind =
247
- | 'resource-sync'
248
- | 'bootloader-install'
249
- | 'bootloader-verify'
250
- | 'component-install'
251
- | 'final-verify';
252
-
253
- type ProtocolV2ExecutionPhase = {
254
- kind: ProtocolV2ExecutionPhaseKind;
247
+ type ProtocolV2TransferBatch = {
255
248
  installSources: ProtocolV2InstallSource[];
256
249
  resourceSources: ProtocolV2ResourceBundleSource[];
257
250
  };
@@ -384,6 +377,31 @@ const isProtocolV2ReconnectProbeError = (error: unknown) => {
384
377
  );
385
378
  };
386
379
 
380
+ const PROTOCOL_V2_BLE_INSTALL_INTERRUPTION_ERROR_CODES = new Set<number>([
381
+ HardwareErrorCode.BleConnectedError,
382
+ HardwareErrorCode.BleCharacteristicNotifyError,
383
+ HardwareErrorCode.BleForceCleanRunPromise,
384
+ HardwareErrorCode.BleDeviceDisconnected,
385
+ ]);
386
+
387
+ const isProtocolV2BleInstallInterruptionError = (error: unknown) => {
388
+ if (
389
+ error instanceof HardwareError &&
390
+ PROTOCOL_V2_BLE_INSTALL_INTERRUPTION_ERROR_CODES.has(error.errorCode)
391
+ ) {
392
+ return true;
393
+ }
394
+
395
+ const message = getProtocolV2UnknownErrorText(error).toLowerCase();
396
+ const compactMessage = message.replace(/\s+/gu, '');
397
+ return (
398
+ /react native ble transport (?:released|disconnected)/u.test(message) ||
399
+ (compactMessage.includes('rxerrorerror6') &&
400
+ (compactMessage.includes('multiplatformbleadapter') ||
401
+ compactMessage.includes('multipalformebleadapter')))
402
+ );
403
+ };
404
+
387
405
  const isProtocolV2FirmwareStatusEndpointUnavailable = (error: unknown) => {
388
406
  const message = getProtocolV2UnknownErrorText(error).toLowerCase();
389
407
  return (
@@ -393,6 +411,15 @@ const isProtocolV2FirmwareStatusEndpointUnavailable = (error: unknown) => {
393
411
  );
394
412
  };
395
413
 
414
+ const isProtocolV2FirmwareUpdateEndpointUnavailable = (error: unknown) => {
415
+ const message = getProtocolV2UnknownErrorText(error).toLowerCase();
416
+ return (
417
+ message.includes('handler not registered') ||
418
+ message.includes('message handler not found') ||
419
+ message.includes('unsupported message')
420
+ );
421
+ };
422
+
396
423
  const isProtocolV2TerminalInstallStatusError = (error: unknown) =>
397
424
  error instanceof HardwareError &&
398
425
  (error.errorCode === HardwareErrorCode.FirmwareError ||
@@ -587,6 +614,8 @@ export default class FirmwareUpdateV4 extends FirmwareUpdateBaseMethod<FirmwareU
587
614
 
588
615
  private protocolV2ExecutionInLoader = false;
589
616
 
617
+ private protocolV2LegacyDirectUpdate = false;
618
+
590
619
  private protocolV2BootResourceStagingSafe = false;
591
620
 
592
621
  private protocolV2CompletedTargetVersions = new Map<number, number>();
@@ -595,6 +624,12 @@ export default class FirmwareUpdateV4 extends FirmwareUpdateBaseMethod<FirmwareU
595
624
 
596
625
  private protocolV2FinalStatusVerified = false;
597
626
 
627
+ private protocolV2InstallAckReceived = false;
628
+
629
+ private protocolV2InstallBaselineVersions = new Map<number, string>();
630
+
631
+ private protocolV2LastRuntimeProbeFeatures?: Features;
632
+
598
633
  init() {
599
634
  this.allowDeviceMode = [UI_REQUEST.BOOTLOADER, UI_REQUEST.NOT_INITIALIZE];
600
635
  this.requireDeviceMode = [];
@@ -813,6 +848,9 @@ export default class FirmwareUpdateV4 extends FirmwareUpdateBaseMethod<FirmwareU
813
848
  private async runProtocolV2() {
814
849
  await this.captureProtocolV2PhysicalIdentity();
815
850
  const deviceFeatures = await this.getProtocolV2DeviceFeatures();
851
+ this.protocolV2InstallBaselineVersions =
852
+ this.getProtocolV2ObservableTargetVersions(deviceFeatures);
853
+ this.protocolV2LastRuntimeProbeFeatures = undefined;
816
854
  const currentDeviceType = this.device.getCurrentDeviceType();
817
855
  const capabilityDeviceType =
818
856
  currentDeviceType === EDeviceType.Pro2 || currentDeviceType === EDeviceType.Neo
@@ -826,6 +864,7 @@ export default class FirmwareUpdateV4 extends FirmwareUpdateBaseMethod<FirmwareU
826
864
  const deviceFirmwareType = getFirmwareType(deviceFeatures);
827
865
  const firmwareType = this.params.firmwareType ?? deviceFirmwareType;
828
866
  this.validateExpectedTargetVersions();
867
+ const wantsResources = !!this.params.targetsToUpdate?.includes('resource');
829
868
 
830
869
  if (
831
870
  !this.params.preparedPlan &&
@@ -849,11 +888,11 @@ export default class FirmwareUpdateV4 extends FirmwareUpdateBaseMethod<FirmwareU
849
888
  if (this.params.preparedPlan || hasPreparedComponentArtifacts) {
850
889
  return this.runProtocolV2PreparedArtifacts(deviceFeatures, firmwareType);
851
890
  }
852
- const wantsResources = !!this.params.targetsToUpdate?.includes('resource');
853
891
 
854
892
  let fwBinaryMap: ProtocolV2TargetBinary[] = [];
855
893
  let bootloaderBinary: ArrayBuffer | null = null;
856
894
  let installItems: ProtocolV2InstallItem[] | undefined;
895
+ let resourceMemoryHost: FirmwareUpdateV4MemoryHost | undefined;
857
896
  try {
858
897
  this.postTipMessage(FirmwareUpdateTipMessage.StartDownloadFirmware);
859
898
  fwBinaryMap = this.collectExplicitTargetBinaries();
@@ -866,17 +905,9 @@ export default class FirmwareUpdateV4 extends FirmwareUpdateBaseMethod<FirmwareU
866
905
  const needsRemoteFirmware = this.params.targetsToUpdate?.length
867
906
  ? missingFirmwareTargets.length > 0
868
907
  : explicitInstallItems.length === 0;
869
- if (wantsResources) {
870
- throw ERRORS.TypedError(
871
- HardwareErrorCode.RuntimeError,
872
- 'Protocol V2 resource archive must be provided through a local or external PreparedPlan',
873
- {
874
- firmwareUpdateCode: 'FirmwareArtifactsNotPrepared',
875
- }
876
- );
877
- }
908
+ const needsSdkManagedArtifacts = needsRemoteFirmware || wantsResources;
878
909
  if (
879
- needsRemoteFirmware &&
910
+ needsSdkManagedArtifacts &&
880
911
  (this.params.artifactReader ||
881
912
  DataManager.getSettings('firmwareManifestMode') === 'external-only')
882
913
  ) {
@@ -888,9 +919,13 @@ export default class FirmwareUpdateV4 extends FirmwareUpdateBaseMethod<FirmwareU
888
919
  }
889
920
  );
890
921
  }
891
- if (needsRemoteFirmware) {
922
+ if (needsSdkManagedArtifacts) {
892
923
  // Remote updates must use a freshly fetched config before any reboot or file write.
893
- await DataManager.forceReloadData();
924
+ await DataManager.forceReloadData({
925
+ requireResources: wantsResources,
926
+ resourceDeviceType:
927
+ capabilityDeviceType === EDeviceType.Neo ? EDeviceType.Neo : EDeviceType.Pro2,
928
+ });
894
929
  }
895
930
  if (needsRemoteFirmware) {
896
931
  const remoteBinaries = await this.prepareRemoteProtocolV2Binaries(
@@ -913,8 +948,19 @@ export default class FirmwareUpdateV4 extends FirmwareUpdateBaseMethod<FirmwareU
913
948
  targetId: item.targetId,
914
949
  }));
915
950
  }
951
+ if (wantsResources) {
952
+ this.params.resourceArchiveBinary = await this.downloadRemoteProtocolV2ResourceArchive(
953
+ deviceFeatures
954
+ );
955
+ resourceMemoryHost = await this.prepareProtocolV2LocalMemoryHost({
956
+ features: deviceFeatures,
957
+ firmwareType,
958
+ availableInstallItems: installItems ?? explicitInstallItems,
959
+ });
960
+ }
916
961
  this.postTipMessage(FirmwareUpdateTipMessage.FinishDownloadFirmware);
917
962
  } catch (err) {
963
+ resourceMemoryHost?.release();
918
964
  if (
919
965
  typeof err === 'object' &&
920
966
  err !== null &&
@@ -929,6 +975,14 @@ export default class FirmwareUpdateV4 extends FirmwareUpdateBaseMethod<FirmwareU
929
975
  throw normalizeFirmwarePreparationError(err);
930
976
  }
931
977
 
978
+ if (resourceMemoryHost) {
979
+ try {
980
+ return await this.runProtocolV2PreparedArtifacts(deviceFeatures, firmwareType, false);
981
+ } finally {
982
+ resourceMemoryHost.release();
983
+ }
984
+ }
985
+
932
986
  if (!bootloaderBinary && fwBinaryMap.length === 0 && !installItems?.length) {
933
987
  throw ERRORS.TypedError(
934
988
  HardwareErrorCode.FirmwareUpdateDownloadFailed,
@@ -1082,14 +1136,15 @@ export default class FirmwareUpdateV4 extends FirmwareUpdateBaseMethod<FirmwareU
1082
1136
  private async prepareProtocolV2LocalMemoryHost({
1083
1137
  features,
1084
1138
  firmwareType,
1139
+ availableInstallItems = this.buildProtocolV2InstallItems({
1140
+ bootloaderBinary: this.prepareBootloaderBinary(),
1141
+ fwBinaryMap: this.collectExplicitTargetBinaries(),
1142
+ }),
1085
1143
  }: {
1086
1144
  features: Features;
1087
1145
  firmwareType: EFirmwareType;
1146
+ availableInstallItems?: ProtocolV2InstallItem[];
1088
1147
  }): Promise<FirmwareUpdateV4MemoryHost> {
1089
- const availableInstallItems = this.buildProtocolV2InstallItems({
1090
- bootloaderBinary: this.prepareBootloaderBinary(),
1091
- fwBinaryMap: this.collectExplicitTargetBinaries(),
1092
- });
1093
1148
  const requestedComponentTargets = new Set(
1094
1149
  (this.params.targetsToUpdate ?? []).filter(
1095
1150
  (target): target is Exclude<FirmwareUpdateV4Target, 'resource' | 'boot_resources'> =>
@@ -1517,9 +1572,15 @@ export default class FirmwareUpdateV4 extends FirmwareUpdateBaseMethod<FirmwareU
1517
1572
  return sources;
1518
1573
  }
1519
1574
 
1520
- private async runProtocolV2PreparedArtifacts(features: Features, firmwareType: EFirmwareType) {
1575
+ private async runProtocolV2PreparedArtifacts(
1576
+ features: Features,
1577
+ firmwareType: EFirmwareType,
1578
+ announceDownload = true
1579
+ ) {
1521
1580
  try {
1522
- this.postTipMessage(FirmwareUpdateTipMessage.StartDownloadFirmware);
1581
+ if (announceDownload) {
1582
+ this.postTipMessage(FirmwareUpdateTipMessage.StartDownloadFirmware);
1583
+ }
1523
1584
  const installSources = await this.prepareProtocolV2InstallSources(firmwareType, features);
1524
1585
  const resourceSources = await this.prepareProtocolV2ResourceSources();
1525
1586
  if (installSources.length === 0 && resourceSources.length === 0) {
@@ -1528,7 +1589,9 @@ export default class FirmwareUpdateV4 extends FirmwareUpdateBaseMethod<FirmwareU
1528
1589
  'No firmware to update'
1529
1590
  );
1530
1591
  }
1531
- this.postTipMessage(FirmwareUpdateTipMessage.FinishDownloadFirmware);
1592
+ if (announceDownload) {
1593
+ this.postTipMessage(FirmwareUpdateTipMessage.FinishDownloadFirmware);
1594
+ }
1532
1595
 
1533
1596
  return await this.executeProtocolV2SourceUpdate({
1534
1597
  installSources,
@@ -1882,6 +1945,44 @@ export default class FirmwareUpdateV4 extends FirmwareUpdateBaseMethod<FirmwareU
1882
1945
  };
1883
1946
  }
1884
1947
 
1948
+ private async downloadRemoteProtocolV2ResourceArchive(features: Features): Promise<ArrayBuffer> {
1949
+ const deviceType = getDeviceType(features);
1950
+ if (deviceType !== EDeviceType.Pro2 && deviceType !== EDeviceType.Neo) {
1951
+ throw ERRORS.TypedError(
1952
+ HardwareErrorCode.RuntimeError,
1953
+ 'Protocol V2 resource archive requires a Pro2 or Neo device'
1954
+ );
1955
+ }
1956
+ const source = DataManager.getProtocolV2ResourceSource(deviceType);
1957
+ const expectedSha256 = normalizeProtocolV2Hex(source?.archiveSha256);
1958
+ if (
1959
+ !source?.archiveUrl ||
1960
+ !Number.isSafeInteger(source.archiveSize) ||
1961
+ source.archiveSize <= 0 ||
1962
+ source.archiveSize > PROTOCOL_V2_RESOURCE_TOTAL_MAX_BYTES ||
1963
+ !expectedSha256 ||
1964
+ !/^[0-9a-f]{64}$/u.test(expectedSha256)
1965
+ ) {
1966
+ throw ERRORS.TypedError(
1967
+ HardwareErrorCode.RuntimeError,
1968
+ 'Protocol V2 resource archive integrity metadata is invalid',
1969
+ { firmwareUpdateCode: 'FirmwarePlanInvalid' }
1970
+ );
1971
+ }
1972
+ const { binary } = await getSysResourceBinary(source.archiveUrl);
1973
+ if (
1974
+ binary.byteLength !== source.archiveSize ||
1975
+ bytesToHex(sha256(new Uint8Array(binary))) !== expectedSha256
1976
+ ) {
1977
+ throw ERRORS.TypedError(
1978
+ HardwareErrorCode.RuntimeError,
1979
+ 'Protocol V2 resource archive does not match the remote config',
1980
+ { firmwareUpdateCode: 'FirmwareArtifactReceiptMismatch' }
1981
+ );
1982
+ }
1983
+ return binary;
1984
+ }
1985
+
1885
1986
  private async prepareRemoteProtocolV2Binaries(
1886
1987
  firmwareType: EFirmwareType,
1887
1988
  features: Features,
@@ -2067,21 +2168,12 @@ export default class FirmwareUpdateV4 extends FirmwareUpdateBaseMethod<FirmwareU
2067
2168
  return this.device.features?.mode === 'romloader';
2068
2169
  }
2069
2170
 
2070
- async enterProtocolV2BootloaderMode() {
2071
- // romloader is the first update environment and forwards targets to bootloader.
2072
- // It rejects DeviceRebootType.Bootloader, so reuse the current connection.
2073
- if (this.isProtocolV2RomloaderMode()) {
2074
- Log.debug('Protocol V2 device is in romloader mode; start firmware update directly');
2075
- this.protocolV2ExecutionInLoader = true;
2076
- return false;
2077
- }
2078
- if (this.isProtocolV2BootloaderMode()) {
2079
- Log.debug('Protocol V2 device is already in bootloader mode, skip reboot');
2080
- this.protocolV2ExecutionInLoader = true;
2081
- this.postTipMessage(FirmwareUpdateTipMessage.GoToBootloaderSuccess);
2082
- return false;
2083
- }
2171
+ private isLegacyProtocolV2Runtime() {
2172
+ const protocolInfo = this.device.state?.raw?.protocolV2ProtocolInfo;
2173
+ return protocolInfo ? isLegacyProtocolV2ProtocolInfo(protocolInfo) : false;
2174
+ }
2084
2175
 
2176
+ private async rebootProtocolV2ToBootloader() {
2085
2177
  try {
2086
2178
  this.postTipMessage(FirmwareUpdateTipMessage.AutoRebootToBootloader);
2087
2179
  await this.protocolV2Reboot(DeviceRebootType.Bootloader);
@@ -2099,6 +2191,27 @@ export default class FirmwareUpdateV4 extends FirmwareUpdateBaseMethod<FirmwareU
2099
2191
  }
2100
2192
  }
2101
2193
 
2194
+ async enterProtocolV2BootloaderMode() {
2195
+ this.protocolV2LegacyDirectUpdate = false;
2196
+ // romloader is the first update environment and forwards targets to bootloader.
2197
+ // It rejects DeviceRebootType.Bootloader, so reuse the current connection.
2198
+ if (this.isProtocolV2RomloaderMode()) {
2199
+ Log.debug('Protocol V2 device is in romloader mode; start firmware update directly');
2200
+ this.protocolV2LegacyDirectUpdate = this.isLegacyProtocolV2Runtime();
2201
+ this.protocolV2ExecutionInLoader = true;
2202
+ return false;
2203
+ }
2204
+ if (this.isProtocolV2BootloaderMode()) {
2205
+ Log.debug('Protocol V2 device is already in bootloader mode, skip reboot');
2206
+ this.protocolV2LegacyDirectUpdate = this.isLegacyProtocolV2Runtime();
2207
+ this.protocolV2ExecutionInLoader = true;
2208
+ this.postTipMessage(FirmwareUpdateTipMessage.GoToBootloaderSuccess);
2209
+ return false;
2210
+ }
2211
+
2212
+ return this.rebootProtocolV2ToBootloader();
2213
+ }
2214
+
2102
2215
  private async waitForProtocolV2BootloaderMode(
2103
2216
  timeout = PROTOCOL_V2_BOOTLOADER_RECONNECT_TIMEOUT,
2104
2217
  retryInterval = 1000
@@ -2183,68 +2296,7 @@ export default class FirmwareUpdateV4 extends FirmwareUpdateBaseMethod<FirmwareU
2183
2296
  return entries;
2184
2297
  }
2185
2298
 
2186
- private buildProtocolV2ExecutionPhases({
2187
- installSources,
2188
- resourceSources,
2189
- }: {
2190
- installSources: ProtocolV2InstallSource[];
2191
- resourceSources: ProtocolV2ResourceBundleSource[];
2192
- }): ProtocolV2ExecutionPhase[] {
2193
- const phases: ProtocolV2ExecutionPhase[] = [];
2194
- const bootResourceSources = resourceSources.filter(source =>
2195
- isProtocolV2BootResourcePackagePath(source.devicePath)
2196
- );
2197
- const remainingResourceSources = resourceSources.filter(
2198
- source => !isProtocolV2BootResourcePackagePath(source.devicePath)
2199
- );
2200
- // A previous interrupted run may have left a promotable staging file. Replace
2201
- // it with the current approved package before any firmware-triggered reboot.
2202
- if (bootResourceSources.length > 0) {
2203
- phases.push({
2204
- kind: 'resource-sync',
2205
- installSources: [],
2206
- resourceSources: bootResourceSources,
2207
- });
2208
- }
2209
- const bootloaderSources = installSources.filter(source => source.kind === 'bootloader');
2210
- if (bootloaderSources.length > 0) {
2211
- phases.push(
2212
- {
2213
- kind: 'bootloader-install',
2214
- installSources: bootloaderSources,
2215
- resourceSources: [],
2216
- },
2217
- {
2218
- kind: 'bootloader-verify',
2219
- installSources: [],
2220
- resourceSources: [],
2221
- }
2222
- );
2223
- }
2224
- if (remainingResourceSources.length > 0) {
2225
- phases.push({
2226
- kind: 'resource-sync',
2227
- installSources: [],
2228
- resourceSources: remainingResourceSources,
2229
- });
2230
- }
2231
- const componentSources = installSources.filter(source => source.kind !== 'bootloader');
2232
- if (componentSources.length > 0) {
2233
- phases.push({
2234
- kind: 'component-install',
2235
- installSources: componentSources,
2236
- resourceSources: [],
2237
- });
2238
- }
2239
- phases.push({
2240
- kind: 'final-verify',
2241
- installSources: [],
2242
- resourceSources: [],
2243
- });
2244
- return phases;
2245
- }
2246
-
2247
- private async executeProtocolV2Phases({
2299
+ private async executeProtocolV2SourceUpdate({
2248
2300
  installSources,
2249
2301
  resourceSources,
2250
2302
  }: {
@@ -2252,38 +2304,22 @@ export default class FirmwareUpdateV4 extends FirmwareUpdateBaseMethod<FirmwareU
2252
2304
  resourceSources: ProtocolV2ResourceBundleSource[];
2253
2305
  }) {
2254
2306
  this.protocolV2BootResourceStagingSafe = false;
2255
- const phases = this.buildProtocolV2ExecutionPhases({
2256
- installSources,
2257
- resourceSources,
2258
- });
2259
- for (const phase of phases) {
2260
- if (phase.kind === 'final-verify') {
2261
- return this.completeProtocolV2FinalVerification();
2262
- }
2263
- if (phase.kind === 'resource-sync') {
2264
- await this.enterProtocolV2BootloaderMode();
2265
- if (
2266
- !phase.resourceSources.some(source =>
2267
- isProtocolV2BootResourcePackagePath(source.devicePath)
2268
- )
2269
- ) {
2270
- await this.ensureProtocolV2BootResourceStagingIsEmpty();
2271
- }
2272
- await this.executeProtocolV2TransferPhase(phase);
2273
- } else if (phase.kind === 'bootloader-install' || phase.kind === 'component-install') {
2274
- await this.enterProtocolV2BootloaderMode();
2275
- await this.ensureProtocolV2BootResourceStagingIsEmpty();
2276
- await this.executeProtocolV2TransferPhase(phase);
2277
- await this.exitProtocolV2BootloaderToNormal();
2278
- } else if (phase.kind === 'bootloader-verify') {
2279
- await this.waitForProtocolV2FinalFeatures();
2280
- this.assertExpectedProtocolV2Versions(['boot']);
2281
- }
2307
+ if (installSources.length > 0 || resourceSources.length > 0) {
2308
+ await this.enterProtocolV2BootloaderMode();
2309
+ // Clear stale boot-resource staging before writing any artifacts. The new
2310
+ // boot resource, resources and firmware then share one transfer session,
2311
+ // one global progress range and one multi-target install request.
2312
+ // Preserve the approved source order instead of synthesizing a boot-first
2313
+ // phase. Loader firmware owns the cross-stage handoff: bootloader runs its
2314
+ // component targets and leaves boot pending for romloader, while pending
2315
+ // runner-backed records always resume before the application boots.
2316
+ await this.ensureProtocolV2BootResourceStagingIsEmpty();
2317
+ await this.executeProtocolV2TransferPhase({
2318
+ installSources,
2319
+ resourceSources,
2320
+ });
2282
2321
  }
2283
- throw ERRORS.TypedError(
2284
- HardwareErrorCode.RuntimeError,
2285
- 'Protocol V2 execution has no final verification phase'
2286
- );
2322
+ return this.completeProtocolV2FinalVerification();
2287
2323
  }
2288
2324
 
2289
2325
  private async ensureProtocolV2BootResourceStagingIsEmpty() {
@@ -2315,19 +2351,6 @@ export default class FirmwareUpdateV4 extends FirmwareUpdateBaseMethod<FirmwareU
2315
2351
  this.protocolV2BootResourceStagingSafe = true;
2316
2352
  }
2317
2353
 
2318
- private async executeProtocolV2SourceUpdate({
2319
- installSources,
2320
- resourceSources,
2321
- }: {
2322
- installSources: ProtocolV2InstallSource[];
2323
- resourceSources: ProtocolV2ResourceBundleSource[];
2324
- }) {
2325
- return this.executeProtocolV2Phases({
2326
- installSources,
2327
- resourceSources,
2328
- });
2329
- }
2330
-
2331
2354
  private async executeProtocolV2Update({
2332
2355
  fwBinaryMap,
2333
2356
  bootloaderBinary,
@@ -2352,7 +2375,7 @@ export default class FirmwareUpdateV4 extends FirmwareUpdateBaseMethod<FirmwareU
2352
2375
  kind: item.kind,
2353
2376
  }))
2354
2377
  );
2355
- return await this.executeProtocolV2Phases({
2378
+ return await this.executeProtocolV2SourceUpdate({
2356
2379
  installSources,
2357
2380
  resourceSources: [],
2358
2381
  });
@@ -2364,7 +2387,7 @@ export default class FirmwareUpdateV4 extends FirmwareUpdateBaseMethod<FirmwareU
2364
2387
  private async executeProtocolV2TransferPhase({
2365
2388
  installSources,
2366
2389
  resourceSources,
2367
- }: ProtocolV2ExecutionPhase) {
2390
+ }: ProtocolV2TransferBatch) {
2368
2391
  let totalSize = installSources.reduce((total, item) => total + item.source.size, 0);
2369
2392
  const resourcesToSync: ProtocolV2ResourceBundleSource[] = [];
2370
2393
  for (const resource of resourceSources) {
@@ -2637,6 +2660,47 @@ export default class FirmwareUpdateV4 extends FirmwareUpdateBaseMethod<FirmwareU
2637
2660
  return Array.from(expectedTargetIds).filter(targetId => !reportedTargetIds.has(targetId));
2638
2661
  }
2639
2662
 
2663
+ private getProtocolV2ObservableTargetVersions(features: Features) {
2664
+ const versions = new Map<number, string>();
2665
+ versions.set(
2666
+ ProtocolV2FirmwareTargetType.FW_MGMT_TARGET_BOOTLOADER,
2667
+ getDeviceBootloaderVersion(features).join('.')
2668
+ );
2669
+ const applicationVersion = getDeviceFirmwareVersion(features).join('.');
2670
+ versions.set(ProtocolV2FirmwareTargetType.FW_MGMT_TARGET_APPLICATION_P1, applicationVersion);
2671
+ versions.set(ProtocolV2FirmwareTargetType.FW_MGMT_TARGET_APPLICATION_P2, applicationVersion);
2672
+ versions.set(
2673
+ ProtocolV2FirmwareTargetType.FW_MGMT_TARGET_COPROCESSOR,
2674
+ getDeviceBLEFirmwareVersion(features).join('.')
2675
+ );
2676
+ const secureElementVersions: Array<[number, string | null | undefined]> = [
2677
+ [ProtocolV2FirmwareTargetType.FW_MGMT_TARGET_SE01, features.se01Version],
2678
+ [ProtocolV2FirmwareTargetType.FW_MGMT_TARGET_SE02, features.se02Version],
2679
+ [ProtocolV2FirmwareTargetType.FW_MGMT_TARGET_SE03, features.se03Version],
2680
+ [ProtocolV2FirmwareTargetType.FW_MGMT_TARGET_SE04, features.se04Version],
2681
+ ];
2682
+ secureElementVersions.forEach(([targetId, version]) => {
2683
+ if (version) versions.set(targetId, version);
2684
+ });
2685
+ return versions;
2686
+ }
2687
+
2688
+ private hasProtocolV2InstallVersionChanged(expectedTargetIds: Set<number>) {
2689
+ if (!this.protocolV2LastRuntimeProbeFeatures) return false;
2690
+ const currentVersions = this.getProtocolV2ObservableTargetVersions(
2691
+ this.protocolV2LastRuntimeProbeFeatures
2692
+ );
2693
+ return Array.from(expectedTargetIds).some(targetId => {
2694
+ const previousVersion = this.protocolV2InstallBaselineVersions.get(targetId);
2695
+ const currentVersion = currentVersions.get(targetId);
2696
+ return (
2697
+ previousVersion !== undefined &&
2698
+ currentVersion !== undefined &&
2699
+ previousVersion !== currentVersion
2700
+ );
2701
+ });
2702
+ }
2703
+
2640
2704
  private async waitForProtocolV2FirmwareUpdateComplete(
2641
2705
  targets: Array<{ target_id: number; path: string }>
2642
2706
  ) {
@@ -2649,12 +2713,17 @@ export default class FirmwareUpdateV4 extends FirmwareUpdateBaseMethod<FirmwareU
2649
2713
  let deviceInfo: ProtocolV2DeviceInfo | undefined;
2650
2714
  let missingTargetStatusSince: number | undefined;
2651
2715
  let missingTargetStatusKey: string | undefined;
2716
+ let normalModeWithoutInstallEvidenceSince: number | undefined;
2717
+ let installEvidenceObserved = this.protocolV2InstallAckReceived;
2652
2718
  const resetMissingTargetStatusGrace = () => {
2653
2719
  missingTargetStatusSince = undefined;
2654
2720
  missingTargetStatusKey = undefined;
2655
2721
  };
2656
2722
 
2657
2723
  while (Date.now() - startTime < PROTOCOL_V2_INSTALL_TIMEOUT) {
2724
+ // A transport release caused by an explicit workflow cancellation must not
2725
+ // be mistaken for the expected device reboot during installation.
2726
+ this.throwIfAborted();
2658
2727
  try {
2659
2728
  if (shouldReconnect) {
2660
2729
  await this.reconnectProtocolV2Device();
@@ -2677,21 +2746,33 @@ export default class FirmwareUpdateV4 extends FirmwareUpdateBaseMethod<FirmwareU
2677
2746
  );
2678
2747
  const statusTargets = (statusResponse.message.records ??
2679
2748
  []) as ProtocolV2FirmwareUpdateStatusTarget[];
2749
+ if (
2750
+ statusTargets.some(target => {
2751
+ const targetId = normalizeProtocolV2TargetId(target.target_id);
2752
+ return targetId !== undefined && expectedTargetIds.has(targetId);
2753
+ })
2754
+ ) {
2755
+ installEvidenceObserved = true;
2756
+ normalModeWithoutInstallEvidenceSince = undefined;
2757
+ }
2680
2758
  if (this.assertProtocolV2TargetStatus(statusTargets, expectedTargetIds, expectedPaths)) {
2681
2759
  this.protocolV2FinalStatusVerified = true;
2682
2760
  return;
2683
2761
  }
2684
2762
 
2685
- if (
2686
- statusTargets.length === 0 &&
2687
- currentDeviceInfo &&
2688
- (await this.probeProtocolV2NormalMode(currentDeviceInfo))
2689
- ) {
2690
- Log.log(
2691
- '[FirmwareUpdateV4] empty firmware status after confirmed App reboot; update complete'
2692
- );
2693
- this.postProgressMessage(100, 'installingFirmware');
2694
- return;
2763
+ if (statusTargets.length === 0 && currentDeviceInfo) {
2764
+ const isNormalMode = await this.probeProtocolV2NormalMode(currentDeviceInfo);
2765
+ if (
2766
+ isNormalMode &&
2767
+ (installEvidenceObserved ||
2768
+ this.hasProtocolV2InstallVersionChanged(expectedTargetIds))
2769
+ ) {
2770
+ Log.log(
2771
+ '[FirmwareUpdateV4] empty firmware status after confirmed App reboot; update complete'
2772
+ );
2773
+ this.postProgressMessage(100, 'installingFirmware');
2774
+ return;
2775
+ }
2695
2776
  }
2696
2777
 
2697
2778
  const missingTargetIds = this.getProtocolV2MissingTargetIds(
@@ -2741,16 +2822,39 @@ export default class FirmwareUpdateV4 extends FirmwareUpdateBaseMethod<FirmwareU
2741
2822
  'Protocol V2 device identity is unavailable during install polling'
2742
2823
  );
2743
2824
  }
2744
- if (await this.probeProtocolV2NormalMode(currentDeviceInfo)) {
2825
+ const isNormalMode = await this.probeProtocolV2NormalMode(currentDeviceInfo);
2826
+ if (
2827
+ isNormalMode &&
2828
+ (installEvidenceObserved ||
2829
+ this.hasProtocolV2InstallVersionChanged(expectedTargetIds))
2830
+ ) {
2745
2831
  Log.log(
2746
2832
  '[FirmwareUpdateV4] firmware status endpoint unavailable after confirmed App reboot'
2747
2833
  );
2748
2834
  this.postProgressMessage(100, 'installingFirmware');
2749
2835
  return;
2750
2836
  }
2751
- lastError = new Error(
2752
- 'Protocol V2 firmware status endpoint is unavailable while the device remains in loader mode'
2753
- );
2837
+ if (isNormalMode) {
2838
+ const now = Date.now();
2839
+ normalModeWithoutInstallEvidenceSince ??= now;
2840
+ if (
2841
+ now - normalModeWithoutInstallEvidenceSince >=
2842
+ PROTOCOL_V2_MISSING_TARGET_STATUS_GRACE_TIMEOUT
2843
+ ) {
2844
+ throw ERRORS.TypedError(
2845
+ HardwareErrorCode.FirmwareError,
2846
+ 'Protocol V2 device returned to normal mode without install ACK, target status, or version change'
2847
+ );
2848
+ }
2849
+ lastError = new Error(
2850
+ 'Protocol V2 device is in normal mode but installation is not yet confirmed'
2851
+ );
2852
+ } else {
2853
+ normalModeWithoutInstallEvidenceSince = undefined;
2854
+ lastError = new Error(
2855
+ 'Protocol V2 firmware status endpoint is unavailable while the device remains in loader mode'
2856
+ );
2857
+ }
2754
2858
  } else {
2755
2859
  shouldReconnect = true;
2756
2860
  deviceInfo = undefined;
@@ -2811,6 +2915,7 @@ export default class FirmwareUpdateV4 extends FirmwareUpdateBaseMethod<FirmwareU
2811
2915
  deviceInfo,
2812
2916
  PROTOCOL_V2_SHORT_RESPONSE_TIMEOUT
2813
2917
  );
2918
+ this.protocolV2LastRuntimeProbeFeatures = features;
2814
2919
  return features.mode === 'normal' && !features.bootloaderMode;
2815
2920
  }
2816
2921
 
@@ -3043,15 +3148,62 @@ export default class FirmwareUpdateV4 extends FirmwareUpdateBaseMethod<FirmwareU
3043
3148
  }: {
3044
3149
  targets: Array<{ target_id: number; path: string }>;
3045
3150
  }) {
3046
- const commands = this.device.getCommands();
3047
- const response: ProtocolV2FirmwareUpdateStartResponse = await commands.typedCall(
3048
- 'DeviceFirmwareUpdateRequest',
3049
- 'Success',
3050
- { targets },
3051
- { timeoutMs: PROTOCOL_V2_START_UPDATE_TIMEOUT }
3052
- );
3053
- // Success acknowledges that the device accepted installation. End confirmation
3054
- // and begin status polling only after receiving this ACK.
3151
+ this.protocolV2InstallAckReceived = false;
3152
+ this.protocolV2LastRuntimeProbeFeatures = undefined;
3153
+ const startUpdate = () =>
3154
+ this.device
3155
+ .getCommands()
3156
+ .typedCall(
3157
+ 'DeviceFirmwareUpdateRequest',
3158
+ 'Success',
3159
+ { targets },
3160
+ { timeoutMs: PROTOCOL_V2_START_UPDATE_TIMEOUT }
3161
+ );
3162
+ let response: ProtocolV2FirmwareUpdateStartResponse | undefined;
3163
+ try {
3164
+ response = await startUpdate();
3165
+ this.protocolV2InstallAckReceived = true;
3166
+ } catch (error) {
3167
+ if (this.isBleReconnect() && isProtocolV2BleInstallInterruptionError(error)) {
3168
+ this.throwIfAborted();
3169
+ // Installation can reboot the device before the Success response reaches
3170
+ // the host. The request has side effects, so do not replay it; reconnect
3171
+ // and let status polling determine whether installation was accepted.
3172
+ Log.log(
3173
+ '[FirmwareUpdateV4] install request interrupted by device reboot; continue status polling: ',
3174
+ error
3175
+ );
3176
+ } else if (
3177
+ this.protocolV2LegacyDirectUpdate &&
3178
+ isProtocolV2FirmwareUpdateEndpointUnavailable(error)
3179
+ ) {
3180
+ // Both legacy loaders register this request. A missing handler therefore identifies
3181
+ // a legacy App before dispatch, so it is safe to reboot and retry the unhandled request.
3182
+ this.protocolV2LegacyDirectUpdate = false;
3183
+ Log.debug(
3184
+ '[FirmwareUpdateV4] legacy App does not expose DeviceFirmwareUpdateRequest; rebooting to bootloader'
3185
+ );
3186
+ await this.rebootProtocolV2ToBootloader();
3187
+ try {
3188
+ response = await startUpdate();
3189
+ this.protocolV2InstallAckReceived = true;
3190
+ } catch (retryError) {
3191
+ if (!(this.isBleReconnect() && isProtocolV2BleInstallInterruptionError(retryError))) {
3192
+ throw retryError;
3193
+ }
3194
+ this.throwIfAborted();
3195
+ Log.log(
3196
+ '[FirmwareUpdateV4] install request interrupted after legacy reboot; continue status polling: ',
3197
+ retryError
3198
+ );
3199
+ }
3200
+ } else {
3201
+ throw error;
3202
+ }
3203
+ }
3204
+ this.protocolV2LegacyDirectUpdate = false;
3205
+ // A Success ACK or an install-time disconnect both end confirmation. In the
3206
+ // latter case only status polling may establish the final outcome.
3055
3207
  this.postTipMessage(FirmwareUpdateTipMessage.FirmwareUpdating);
3056
3208
  this.postProgressMessage(0, 'installingFirmware');
3057
3209
  return response;