UploadMaterialsJob.py 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  1. # Copyright (c) 2021 Ultimaker B.V.
  2. # Cura is released under the terms of the LGPLv3 or higher.
  3. from PyQt5.QtCore import QUrl
  4. import os # To delete the archive when we're done.
  5. import tempfile # To create an archive before we upload it.
  6. import enum
  7. import functools
  8. import cura.CuraApplication # Imported like this to prevent circular imports.
  9. from cura.Settings.CuraContainerRegistry import CuraContainerRegistry # To find all printers to upload to.
  10. from cura.UltimakerCloud import UltimakerCloudConstants # To know where the API is.
  11. from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope # To know how to communicate with this server.
  12. from UM.i18n import i18nCatalog
  13. from UM.Job import Job
  14. from UM.Logger import Logger
  15. from UM.Signal import Signal
  16. from UM.TaskManagement.HttpRequestManager import HttpRequestManager # To call the API.
  17. from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope
  18. from typing import Optional, TYPE_CHECKING
  19. if TYPE_CHECKING:
  20. from PyQt5.QtNetwork import QNetworkReply
  21. from cura.UltimakerCloud.CloudMaterialSync import CloudMaterialSync
  22. catalog = i18nCatalog("cura")
  23. class UploadMaterialsJob(Job):
  24. """
  25. Job that uploads a set of materials to the Digital Factory.
  26. """
  27. UPLOAD_REQUEST_URL = f"{UltimakerCloudConstants.CuraCloudAPIRoot}/connect/v1/materials/upload"
  28. UPLOAD_CONFIRM_URL = UltimakerCloudConstants.CuraCloudAPIRoot + "/connect/v1/clusters/{cluster_id}/printers/{cluster_printer_id}/action/confirm_material_upload"
  29. class Result(enum.IntEnum):
  30. SUCCCESS = 0
  31. FAILED = 1
  32. def __init__(self, material_sync: "CloudMaterialSync"):
  33. super().__init__()
  34. self._material_sync = material_sync
  35. self._scope = JsonDecoratorScope(UltimakerCloudScope(cura.CuraApplication.CuraApplication.getInstance())) # type: JsonDecoratorScope
  36. self._archive_filename = None # type: Optional[str]
  37. self._archive_remote_id = None # type: Optional[str] # ID that the server gives to this archive. Used to communicate about the archive to the server.
  38. self._num_synced_printers = 0
  39. self._completed_printers = set() # The printers that have successfully completed the upload.
  40. self._failed_printers = set()
  41. uploadCompleted = Signal()
  42. uploadProgressChanged = Signal()
  43. def run(self):
  44. archive_file = tempfile.NamedTemporaryFile("wb", delete = False)
  45. archive_file.close()
  46. self._archive_filename = archive_file.name
  47. self._material_sync.exportAll(QUrl.fromLocalFile(self._archive_filename), notify_progress = self.uploadProgressChanged)
  48. file_size = os.path.getsize(self._archive_filename)
  49. http = HttpRequestManager.getInstance()
  50. http.get(
  51. url = self.UPLOAD_REQUEST_URL + f"?file_size={file_size}&file_name=cura.umm", # File name can be anything as long as it's .umm. It's not used by Cloud or firmware.
  52. callback = self.onUploadRequestCompleted,
  53. error_callback = self.onError,
  54. scope = self._scope
  55. )
  56. def onUploadRequestCompleted(self, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"]):
  57. if error is not None:
  58. Logger.error(f"Could not request URL to upload material archive to: {error}")
  59. self.setError(UploadMaterialsError(catalog.i18nc("@text:error", "Failed to connect to Digital Factory.")))
  60. self.setResult(self.Result.FAILED)
  61. self.uploadCompleted.emit(self.getResult(), self.getError())
  62. return
  63. response_data = HttpRequestManager.readJSON(reply)
  64. if response_data is None:
  65. Logger.error(f"Invalid response to material upload request. Could not parse JSON data.")
  66. self.setError(UploadMaterialsError(catalog.i18nc("@text:error", "The response from Digital Factory appears to be corrupted.")))
  67. self.setResult(self.Result.FAILED)
  68. self.uploadCompleted.emit(self.getResult(), self.getError())
  69. return
  70. if "upload_url" not in response_data:
  71. Logger.error(f"Invalid response to material upload request: Missing 'upload_url' field to upload archive to.")
  72. self.setError(UploadMaterialsError(catalog.i18nc("@text:error", "The response from Digital Factory is missing important information.")))
  73. self.setResult(self.Result.FAILED)
  74. self.uploadCompleted.emit(self.getResult(), self.getError())
  75. return
  76. if "material_profile_id" not in response_data:
  77. Logger.error(f"Invalid response to material upload request: Missing 'material_profile_id' to communicate about the materials with the server.")
  78. self.setError(UploadMaterialsError(catalog.i18nc("@text:error", "The response from Digital Factory is missing important information.")))
  79. self.setResult(self.Result.FAILED)
  80. self.uploadCompleted.emit(self.getResult(), self.getError())
  81. return
  82. upload_url = response_data["upload_url"]
  83. self._archive_remote_id = response_data["material_profile_id"]
  84. file_data = open(self._archive_filename, "rb").read()
  85. http = HttpRequestManager.getInstance()
  86. http.put(
  87. url = upload_url,
  88. data = file_data,
  89. callback = self.onUploadCompleted,
  90. error_callback = self.onError,
  91. scope = self._scope
  92. )
  93. def onUploadCompleted(self, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"]):
  94. if error is not None:
  95. Logger.error(f"Failed to upload material archive: {error}")
  96. self.setError(UploadMaterialsError(catalog.i18nc("@text:error", "Failed to connect to Digital Factory.")))
  97. self.setResult(self.Result.FAILED)
  98. self.uploadCompleted.emit(self.getResult(), self.getError())
  99. return
  100. online_cloud_printers = CuraContainerRegistry.getInstance().findContainerStacksMetadata(
  101. type = "machine",
  102. connection_type = 3, # Only cloud printers.
  103. is_online = True, # Only online printers. Otherwise the server gives an error.
  104. host_guid = "*", # Required metadata field. Otherwise we get a KeyError.
  105. um_cloud_cluster_id = "*" # Required metadata field. Otherwise we get a KeyError.
  106. )
  107. self._num_synced_printers = len(online_cloud_printers)
  108. for container_stack in online_cloud_printers:
  109. cluster_id = container_stack["um_cloud_cluster_id"]
  110. printer_id = container_stack["host_guid"]
  111. http = HttpRequestManager.getInstance()
  112. http.get(
  113. url = self.UPLOAD_CONFIRM_URL.format(cluster_id = cluster_id, cluster_printer_id = printer_id),
  114. callback = functools.partialmethod(self.onUploadConfirmed, printer_id),
  115. error_callback = functools.partialmethod(self.onUploadConfirmed, printer_id), # Let this same function handle the error too.
  116. scope = self._scope
  117. )
  118. def onUploadConfirmed(self, printer_id: str, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"]) -> None:
  119. if error is not None:
  120. Logger.error(f"Failed to confirm uploading material archive to printer {printer_id}: {error}")
  121. self._failed_printers.add(printer_id)
  122. else:
  123. self._completed_printers.add(printer_id)
  124. if len(self._completed_printers) + len(self._failed_printers) >= self._num_synced_printers: # This is the last response to be processed.
  125. if self._failed_printers:
  126. self.setResult(self.Result.FAILED)
  127. self.setError(UploadMaterialsError(catalog.i18nc("@text:error", "Failed to connect to Digital Factory to sync materials with some of the printers.")))
  128. else:
  129. self.setResult(self.Result.SUCCESS)
  130. self.uploadCompleted.emit(self.getResult(), self.getError())
  131. def onError(self, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"]):
  132. Logger.error(f"Failed to upload material archive: {error}")
  133. self.setResult(self.Result.FAILED)
  134. self.setError(UploadMaterialsError(catalog.i18nc("@text:error", "Failed to connect to Digital Factory.")))
  135. self.uploadCompleted.emit(self.getResult(), self.getError())
  136. class UploadMaterialsError(Exception):
  137. """
  138. Marker class to indicate something went wrong while uploading.
  139. """
  140. pass