CloudOutputDevice.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. # Copyright (c) 2019 Ultimaker B.V.
  2. # Cura is released under the terms of the LGPLv3 or higher.
  3. from time import time
  4. import os
  5. from typing import List, Optional, cast
  6. from PyQt5.QtCore import QObject, QUrl, pyqtProperty, pyqtSignal, pyqtSlot
  7. from PyQt5.QtGui import QDesktopServices
  8. from UM import i18nCatalog
  9. from UM.Backend.Backend import BackendState
  10. from UM.FileHandler.FileHandler import FileHandler
  11. from UM.Logger import Logger
  12. from UM.Scene.SceneNode import SceneNode
  13. from UM.Version import Version
  14. from cura.CuraApplication import CuraApplication
  15. from cura.PrinterOutput.NetworkedPrinterOutputDevice import AuthState
  16. from cura.PrinterOutput.PrinterOutputDevice import ConnectionType
  17. from .CloudApiClient import CloudApiClient
  18. from ..ExportFileJob import ExportFileJob
  19. from ..UltimakerNetworkedPrinterOutputDevice import UltimakerNetworkedPrinterOutputDevice
  20. from ..Messages.PrintJobUploadBlockedMessage import PrintJobUploadBlockedMessage
  21. from ..Messages.PrintJobUploadErrorMessage import PrintJobUploadErrorMessage
  22. from ..Messages.PrintJobUploadSuccessMessage import PrintJobUploadSuccessMessage
  23. from ..Models.Http.CloudClusterResponse import CloudClusterResponse
  24. from ..Models.Http.CloudClusterStatus import CloudClusterStatus
  25. from ..Models.Http.CloudPrintJobUploadRequest import CloudPrintJobUploadRequest
  26. from ..Models.Http.CloudPrintResponse import CloudPrintResponse
  27. from ..Models.Http.CloudPrintJobResponse import CloudPrintJobResponse
  28. from ..Models.Http.ClusterPrinterStatus import ClusterPrinterStatus
  29. from ..Models.Http.ClusterPrintJobStatus import ClusterPrintJobStatus
  30. I18N_CATALOG = i18nCatalog("cura")
  31. ## The cloud output device is a network output device that works remotely but has limited functionality.
  32. # Currently it only supports viewing the printer and print job status and adding a new job to the queue.
  33. # As such, those methods have been implemented here.
  34. # Note that this device represents a single remote cluster, not a list of multiple clusters.
  35. class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice):
  36. # The interval with which the remote cluster is checked.
  37. # We can do this relatively often as this API call is quite fast.
  38. CHECK_CLUSTER_INTERVAL = 10.0 # seconds
  39. # Override the network response timeout in seconds after which we consider the device offline.
  40. # For cloud this needs to be higher because the interval at which we check the status is higher as well.
  41. NETWORK_RESPONSE_CONSIDER_OFFLINE = 15.0 # seconds
  42. # The minimum version of firmware that support print job actions over cloud.
  43. PRINT_JOB_ACTIONS_MIN_VERSION = Version("5.3.0")
  44. # Notify can only use signals that are defined by the class that they are in, not inherited ones.
  45. # Therefore we create a private signal used to trigger the printersChanged signal.
  46. _cloudClusterPrintersChanged = pyqtSignal()
  47. ## Creates a new cloud output device
  48. # \param api_client: The client that will run the API calls
  49. # \param cluster: The device response received from the cloud API.
  50. # \param parent: The optional parent of this output device.
  51. def __init__(self, api_client: CloudApiClient, cluster: CloudClusterResponse, parent: QObject = None) -> None:
  52. # The following properties are expected on each networked output device.
  53. # Because the cloud connection does not off all of these, we manually construct this version here.
  54. # An example of why this is needed is the selection of the compatible file type when exporting the tool path.
  55. properties = {
  56. b"address": cluster.host_internal_ip.encode() if cluster.host_internal_ip else b"",
  57. b"name": cluster.friendly_name.encode() if cluster.friendly_name else b"",
  58. b"firmware_version": cluster.host_version.encode() if cluster.host_version else b"",
  59. b"printer_type": cluster.printer_type.encode() if cluster.printer_type else b"",
  60. b"cluster_size": b"1" # cloud devices are always clusters of at least one
  61. }
  62. super().__init__(
  63. device_id=cluster.cluster_id,
  64. address="",
  65. connection_type=ConnectionType.CloudConnection,
  66. properties=properties,
  67. parent=parent
  68. )
  69. self._api = api_client
  70. self._account = api_client.account
  71. self._cluster = cluster
  72. self.setAuthenticationState(AuthState.NotAuthenticated)
  73. self._setInterfaceElements()
  74. # Trigger the printersChanged signal when the private signal is triggered.
  75. self.printersChanged.connect(self._cloudClusterPrintersChanged)
  76. # Keep server string of the last generated time to avoid updating models more than once for the same response
  77. self._received_printers = None # type: Optional[List[ClusterPrinterStatus]]
  78. self._received_print_jobs = None # type: Optional[List[ClusterPrintJobStatus]]
  79. # Reference to the uploaded print job / mesh
  80. # We do this to prevent re-uploading the same file multiple times.
  81. self._tool_path = None # type: Optional[bytes]
  82. self._uploaded_print_job = None # type: Optional[CloudPrintJobResponse]
  83. ## Connects this device.
  84. def connect(self) -> None:
  85. if self.isConnected():
  86. return
  87. super().connect()
  88. Logger.log("i", "Connected to cluster %s", self.key)
  89. CuraApplication.getInstance().getBackend().backendStateChange.connect(self._onBackendStateChange)
  90. self._update()
  91. ## Disconnects the device
  92. def disconnect(self) -> None:
  93. if not self.isConnected():
  94. return
  95. super().disconnect()
  96. Logger.log("i", "Disconnected from cluster %s", self.key)
  97. CuraApplication.getInstance().getBackend().backendStateChange.disconnect(self._onBackendStateChange)
  98. ## Resets the print job that was uploaded to force a new upload, runs whenever the user re-slices.
  99. def _onBackendStateChange(self, _: BackendState) -> None:
  100. self._tool_path = None
  101. self._uploaded_print_job = None
  102. ## Checks whether the given network key is found in the cloud's host name
  103. def matchesNetworkKey(self, network_key: str) -> bool:
  104. # Typically, a network key looks like "ultimakersystem-aabbccdd0011._ultimaker._tcp.local."
  105. # the host name should then be "ultimakersystem-aabbccdd0011"
  106. if network_key.startswith(str(self.clusterData.host_name or "")):
  107. return True
  108. # However, for manually added printers, the local IP address is used in lieu of a proper
  109. # network key, so check for that as well. It is in the format "manual:10.1.10.1".
  110. if network_key.endswith(str(self.clusterData.host_internal_ip or "")):
  111. return True
  112. return False
  113. ## Set all the interface elements and texts for this output device.
  114. def _setInterfaceElements(self) -> None:
  115. self.setPriority(2) # Make sure we end up below the local networking and above 'save to file'.
  116. self.setShortDescription(I18N_CATALOG.i18nc("@action:button", "Print via Cloud"))
  117. self.setDescription(I18N_CATALOG.i18nc("@properties:tooltip", "Print via Cloud"))
  118. self.setConnectionText(I18N_CATALOG.i18nc("@info:status", "Connected via Cloud"))
  119. ## Called when the network data should be updated.
  120. def _update(self) -> None:
  121. super()._update()
  122. if time() - self._time_of_last_request < self.CHECK_CLUSTER_INTERVAL:
  123. return # avoid calling the cloud too often
  124. self._time_of_last_request = time()
  125. if self._account.isLoggedIn:
  126. self.setAuthenticationState(AuthState.Authenticated)
  127. self._last_request_time = time()
  128. self._api.getClusterStatus(self.key, self._onStatusCallFinished)
  129. else:
  130. self.setAuthenticationState(AuthState.NotAuthenticated)
  131. ## Method called when HTTP request to status endpoint is finished.
  132. # Contains both printers and print jobs statuses in a single response.
  133. def _onStatusCallFinished(self, status: CloudClusterStatus) -> None:
  134. self._responseReceived()
  135. if status.printers != self._received_printers:
  136. self._received_printers = status.printers
  137. self._updatePrinters(status.printers)
  138. if status.print_jobs != self._received_print_jobs:
  139. self._received_print_jobs = status.print_jobs
  140. self._updatePrintJobs(status.print_jobs)
  141. ## Called when Cura requests an output device to receive a (G-code) file.
  142. def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mimetypes: bool = False,
  143. file_handler: Optional[FileHandler] = None, filter_by_machine: bool = False, **kwargs) -> None:
  144. # Show an error message if we're already sending a job.
  145. if self._progress.visible:
  146. PrintJobUploadBlockedMessage().show()
  147. return
  148. # Indicate we have started sending a job.
  149. self.writeStarted.emit(self)
  150. # The mesh didn't change, let's not upload it to the cloud again.
  151. # Note that self.writeFinished is called in _onPrintUploadCompleted as well.
  152. if self._uploaded_print_job:
  153. self._api.requestPrint(self.key, self._uploaded_print_job.job_id, self._onPrintUploadCompleted)
  154. return
  155. # Export the scene to the correct file type.
  156. job = ExportFileJob(file_handler=file_handler, nodes=nodes, firmware_version=self.firmwareVersion)
  157. job.finished.connect(self._onPrintJobCreated)
  158. job.start()
  159. ## Handler for when the print job was created locally.
  160. # It can now be sent over the cloud.
  161. def _onPrintJobCreated(self, job: ExportFileJob) -> None:
  162. output = job.getOutput()
  163. self._tool_path = output # store the tool path to prevent re-uploading when printing the same file again
  164. file_name = job.getFileName()
  165. request = CloudPrintJobUploadRequest(
  166. job_name=os.path.splitext(file_name)[0],
  167. file_size=len(output),
  168. content_type=job.getMimeType(),
  169. )
  170. self._api.requestUpload(request, self._uploadPrintJob)
  171. ## Uploads the mesh when the print job was registered with the cloud API.
  172. # \param job_response: The response received from the cloud API.
  173. def _uploadPrintJob(self, job_response: CloudPrintJobResponse) -> None:
  174. if not self._tool_path:
  175. return self._onUploadError()
  176. self._progress.show()
  177. self._uploaded_print_job = job_response # store the last uploaded job to prevent re-upload of the same file
  178. self._api.uploadToolPath(job_response, self._tool_path, self._onPrintJobUploaded, self._progress.update,
  179. self._onUploadError)
  180. ## Requests the print to be sent to the printer when we finished uploading the mesh.
  181. def _onPrintJobUploaded(self) -> None:
  182. self._progress.update(100)
  183. print_job = cast(CloudPrintJobResponse, self._uploaded_print_job)
  184. self._api.requestPrint(self.key, print_job.job_id, self._onPrintUploadCompleted)
  185. ## Shows a message when the upload has succeeded
  186. # \param response: The response from the cloud API.
  187. def _onPrintUploadCompleted(self, response: CloudPrintResponse) -> None:
  188. self._progress.hide()
  189. PrintJobUploadSuccessMessage().show()
  190. self.writeFinished.emit()
  191. ## Displays the given message if uploading the mesh has failed
  192. # \param message: The message to display.
  193. def _onUploadError(self, message: str = None) -> None:
  194. self._progress.hide()
  195. self._uploaded_print_job = None
  196. PrintJobUploadErrorMessage(message).show()
  197. self.writeError.emit()
  198. ## Whether the printer that this output device represents supports print job actions via the cloud.
  199. @pyqtProperty(bool, notify=_cloudClusterPrintersChanged)
  200. def supportsPrintJobActions(self) -> bool:
  201. if not self._printers:
  202. return False
  203. version_number = self.printers[0].firmwareVersion.split(".")
  204. firmware_version = Version([version_number[0], version_number[1], version_number[2]])
  205. return firmware_version >= self.PRINT_JOB_ACTIONS_MIN_VERSION
  206. ## Set the remote print job state.
  207. def setJobState(self, print_job_uuid: str, state: str) -> None:
  208. self._api.doPrintJobAction(self._cluster.cluster_id, print_job_uuid, state)
  209. @pyqtSlot(str, name="sendJobToTop")
  210. def sendJobToTop(self, print_job_uuid: str) -> None:
  211. self._api.doPrintJobAction(self._cluster.cluster_id, print_job_uuid, "move",
  212. {"list": "queued", "to_position": 0})
  213. @pyqtSlot(str, name="deleteJobFromQueue")
  214. def deleteJobFromQueue(self, print_job_uuid: str) -> None:
  215. self._api.doPrintJobAction(self._cluster.cluster_id, print_job_uuid, "remove")
  216. @pyqtSlot(str, name="forceSendJob")
  217. def forceSendJob(self, print_job_uuid: str) -> None:
  218. self._api.doPrintJobAction(self._cluster.cluster_id, print_job_uuid, "force")
  219. @pyqtSlot(name="openPrintJobControlPanel")
  220. def openPrintJobControlPanel(self) -> None:
  221. QDesktopServices.openUrl(QUrl(self.clusterCloudUrl))
  222. @pyqtSlot(name="openPrinterControlPanel")
  223. def openPrinterControlPanel(self) -> None:
  224. QDesktopServices.openUrl(QUrl(self.clusterCloudUrl))
  225. ## Gets the cluster response from which this device was created.
  226. @property
  227. def clusterData(self) -> CloudClusterResponse:
  228. return self._cluster
  229. ## Updates the cluster data from the cloud.
  230. @clusterData.setter
  231. def clusterData(self, value: CloudClusterResponse) -> None:
  232. self._cluster = value
  233. ## Gets the URL on which to monitor the cluster via the cloud.
  234. @property
  235. def clusterCloudUrl(self) -> str:
  236. root_url_prefix = "-staging" if self._account.is_staging else ""
  237. return "https://mycloud{}.ultimaker.com/app/jobs/{}".format(root_url_prefix, self.clusterData.cluster_id)