CloudOutputDevice.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451
  1. # Copyright (c) 2018 Ultimaker B.V.
  2. # Cura is released under the terms of the LGPLv3 or higher.
  3. import os
  4. from time import time
  5. from typing import Dict, List, Optional, Set, cast
  6. from PyQt5.QtCore import QObject, QUrl, pyqtProperty, pyqtSignal, pyqtSlot
  7. from UM import i18nCatalog
  8. from UM.Backend.Backend import BackendState
  9. from UM.FileHandler.FileHandler import FileHandler
  10. from UM.Logger import Logger
  11. from UM.Message import Message
  12. from UM.PluginRegistry import PluginRegistry
  13. from UM.Qt.Duration import Duration, DurationFormat
  14. from UM.Scene.SceneNode import SceneNode
  15. from cura.CuraApplication import CuraApplication
  16. from cura.PrinterOutput.NetworkedPrinterOutputDevice import AuthState, NetworkedPrinterOutputDevice
  17. from cura.PrinterOutput.Models.PrinterOutputModel import PrinterOutputModel
  18. from cura.PrinterOutput.PrinterOutputDevice import ConnectionType
  19. from .CloudOutputController import CloudOutputController
  20. from ..MeshFormatHandler import MeshFormatHandler
  21. from ..UM3PrintJobOutputModel import UM3PrintJobOutputModel
  22. from .CloudProgressMessage import CloudProgressMessage
  23. from .CloudApiClient import CloudApiClient
  24. from .Models.CloudClusterResponse import CloudClusterResponse
  25. from .Models.CloudClusterStatus import CloudClusterStatus
  26. from .Models.CloudPrintJobUploadRequest import CloudPrintJobUploadRequest
  27. from .Models.CloudPrintResponse import CloudPrintResponse
  28. from .Models.CloudPrintJobResponse import CloudPrintJobResponse
  29. from .Models.CloudClusterPrinterStatus import CloudClusterPrinterStatus
  30. from .Models.CloudClusterPrintJobStatus import CloudClusterPrintJobStatus
  31. from .Utils import findChanges, formatDateCompleted, formatTimeCompleted
  32. I18N_CATALOG = i18nCatalog("cura")
  33. ## The cloud output device is a network output device that works remotely but has limited functionality.
  34. # Currently it only supports viewing the printer and print job status and adding a new job to the queue.
  35. # As such, those methods have been implemented here.
  36. # Note that this device represents a single remote cluster, not a list of multiple clusters.
  37. class CloudOutputDevice(NetworkedPrinterOutputDevice):
  38. # The interval with which the remote clusters are checked
  39. CHECK_CLUSTER_INTERVAL = 10.0 # seconds
  40. # Signal triggered when the print jobs in the queue were changed.
  41. printJobsChanged = pyqtSignal()
  42. # Signal triggered when the selected printer in the UI should be changed.
  43. activePrinterChanged = pyqtSignal()
  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. _clusterPrintersChanged = 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__(device_id = cluster.cluster_id, address = "",
  63. connection_type = ConnectionType.CloudConnection, properties = properties, parent = parent)
  64. self._api = api_client
  65. self._cluster = cluster
  66. self._setInterfaceElements()
  67. self._account = api_client.account
  68. # We use the Cura Connect monitor tab to get most functionality right away.
  69. if PluginRegistry.getInstance() is not None:
  70. plugin_path = PluginRegistry.getInstance().getPluginPath("UM3NetworkPrinting")
  71. if plugin_path is None:
  72. Logger.log("e", "Cloud not find plugin path for plugin UM3NetworkPrnting")
  73. raise RuntimeError("Cloud not find plugin path for plugin UM3NetworkPrnting")
  74. self._monitor_view_qml_path = os.path.join(plugin_path, "resources", "qml", "MonitorStage.qml")
  75. # Trigger the printersChanged signal when the private signal is triggered.
  76. self.printersChanged.connect(self._clusterPrintersChanged)
  77. # We keep track of which printer is visible in the monitor page.
  78. self._active_printer = None # type: Optional[PrinterOutputModel]
  79. # Properties to populate later on with received cloud data.
  80. self._print_jobs = [] # type: List[UM3PrintJobOutputModel]
  81. self._number_of_extruders = 2 # All networked printers are dual-extrusion Ultimaker machines.
  82. # We only allow a single upload at a time.
  83. self._progress = CloudProgressMessage()
  84. # Keep server string of the last generated time to avoid updating models more than once for the same response
  85. self._received_printers = None # type: Optional[List[CloudClusterPrinterStatus]]
  86. self._received_print_jobs = None # type: Optional[List[CloudClusterPrintJobStatus]]
  87. # A set of the user's job IDs that have finished
  88. self._finished_jobs = set() # type: Set[str]
  89. # Reference to the uploaded print job / mesh
  90. self._tool_path = None # type: Optional[bytes]
  91. self._uploaded_print_job = None # type: Optional[CloudPrintJobResponse]
  92. ## Connects this device.
  93. def connect(self) -> None:
  94. if self.isConnected():
  95. return
  96. super().connect()
  97. Logger.log("i", "Connected to cluster %s", self.key)
  98. CuraApplication.getInstance().getBackend().backendStateChange.connect(self._onBackendStateChange)
  99. ## Disconnects the device
  100. def disconnect(self) -> None:
  101. super().disconnect()
  102. Logger.log("i", "Disconnected from cluster %s", self.key)
  103. CuraApplication.getInstance().getBackend().backendStateChange.disconnect(self._onBackendStateChange)
  104. ## Resets the print job that was uploaded to force a new upload, runs whenever the user re-slices.
  105. def _onBackendStateChange(self, _: BackendState) -> None:
  106. self._tool_path = None
  107. self._uploaded_print_job = None
  108. ## Gets the cluster response from which this device was created.
  109. @property
  110. def clusterData(self) -> CloudClusterResponse:
  111. return self._cluster
  112. ## Updates the cluster data from the cloud.
  113. @clusterData.setter
  114. def clusterData(self, value: CloudClusterResponse) -> None:
  115. self._cluster = value
  116. ## Checks whether the given network key is found in the cloud's host name
  117. def matchesNetworkKey(self, network_key: str) -> bool:
  118. # Typically, a network key looks like "ultimakersystem-aabbccdd0011._ultimaker._tcp.local."
  119. # the host name should then be "ultimakersystem-aabbccdd0011"
  120. if network_key.startswith(self.clusterData.host_name):
  121. return True
  122. # However, for manually added printers, the local IP address is used in lieu of a proper
  123. # network key, so check for that as well
  124. if self.clusterData.host_internal_ip is not None and network_key.find(self.clusterData.host_internal_ip):
  125. return True
  126. return False
  127. ## Set all the interface elements and texts for this output device.
  128. def _setInterfaceElements(self) -> None:
  129. self.setPriority(2) # Make sure we end up below the local networking and above 'save to file'
  130. self.setName(self._id)
  131. self.setShortDescription(I18N_CATALOG.i18nc("@action:button", "Print via Cloud"))
  132. self.setDescription(I18N_CATALOG.i18nc("@properties:tooltip", "Print via Cloud"))
  133. self.setConnectionText(I18N_CATALOG.i18nc("@info:status", "Connected via Cloud"))
  134. ## Called when Cura requests an output device to receive a (G-code) file.
  135. def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mimetypes: bool = False,
  136. file_handler: Optional[FileHandler] = None, **kwargs: str) -> None:
  137. # Show an error message if we're already sending a job.
  138. if self._progress.visible:
  139. message = Message(
  140. text = I18N_CATALOG.i18nc("@info:status", "Sending new jobs (temporarily) blocked, still sending the previous print job."),
  141. title = I18N_CATALOG.i18nc("@info:title", "Cloud error"),
  142. lifetime = 10
  143. )
  144. message.show()
  145. return
  146. if self._uploaded_print_job:
  147. # The mesh didn't change, let's not upload it again
  148. self._api.requestPrint(self.key, self._uploaded_print_job.job_id, self._onPrintUploadCompleted)
  149. return
  150. # Indicate we have started sending a job.
  151. self.writeStarted.emit(self)
  152. mesh_format = MeshFormatHandler(file_handler, self.firmwareVersion)
  153. if not mesh_format.is_valid:
  154. Logger.log("e", "Missing file or mesh writer!")
  155. return self._onUploadError(I18N_CATALOG.i18nc("@info:status", "Could not export print job."))
  156. mesh = mesh_format.getBytes(nodes)
  157. self._tool_path = mesh
  158. request = CloudPrintJobUploadRequest(
  159. job_name = file_name or mesh_format.file_extension,
  160. file_size = len(mesh),
  161. content_type = mesh_format.mime_type,
  162. )
  163. self._api.requestUpload(request, self._onPrintJobCreated)
  164. ## Called when the network data should be updated.
  165. def _update(self) -> None:
  166. super()._update()
  167. if self._last_request_time and time() - self._last_request_time < self.CHECK_CLUSTER_INTERVAL:
  168. return # Avoid calling the cloud too often
  169. Logger.log("d", "Updating: %s - %s >= %s", time(), self._last_request_time, self.CHECK_CLUSTER_INTERVAL)
  170. if self._account.isLoggedIn:
  171. self.setAuthenticationState(AuthState.Authenticated)
  172. self._last_request_time = time()
  173. self._api.getClusterStatus(self.key, self._onStatusCallFinished)
  174. else:
  175. self.setAuthenticationState(AuthState.NotAuthenticated)
  176. ## Method called when HTTP request to status endpoint is finished.
  177. # Contains both printers and print jobs statuses in a single response.
  178. def _onStatusCallFinished(self, status: CloudClusterStatus) -> None:
  179. # Update all data from the cluster.
  180. self._last_response_time = time()
  181. if self._received_printers != status.printers:
  182. self._received_printers = status.printers
  183. self._updatePrinters(status.printers)
  184. if status.print_jobs != self._received_print_jobs:
  185. self._received_print_jobs = status.print_jobs
  186. self._updatePrintJobs(status.print_jobs)
  187. ## Updates the local list of printers with the list received from the cloud.
  188. # \param jobs: The printers received from the cloud.
  189. def _updatePrinters(self, printers: List[CloudClusterPrinterStatus]) -> None:
  190. previous = {p.key: p for p in self._printers} # type: Dict[str, PrinterOutputModel]
  191. received = {p.uuid: p for p in printers} # type: Dict[str, CloudClusterPrinterStatus]
  192. removed_printers, added_printers, updated_printers = findChanges(previous, received)
  193. for removed_printer in removed_printers:
  194. if self._active_printer == removed_printer:
  195. self.setActivePrinter(None)
  196. self._printers.remove(removed_printer)
  197. for added_printer in added_printers:
  198. self._printers.append(added_printer.createOutputModel(CloudOutputController(self)))
  199. for model, printer in updated_printers:
  200. printer.updateOutputModel(model)
  201. # Always have an active printer
  202. if self._printers and not self._active_printer:
  203. self.setActivePrinter(self._printers[0])
  204. if added_printers or removed_printers:
  205. self.printersChanged.emit()
  206. ## Updates the local list of print jobs with the list received from the cloud.
  207. # \param jobs: The print jobs received from the cloud.
  208. def _updatePrintJobs(self, jobs: List[CloudClusterPrintJobStatus]) -> None:
  209. received = {j.uuid: j for j in jobs} # type: Dict[str, CloudClusterPrintJobStatus]
  210. previous = {j.key: j for j in self._print_jobs} # type: Dict[str, UM3PrintJobOutputModel]
  211. removed_jobs, added_jobs, updated_jobs = findChanges(previous, received)
  212. for removed_job in removed_jobs:
  213. if removed_job.assignedPrinter:
  214. removed_job.assignedPrinter.updateActivePrintJob(None)
  215. removed_job.stateChanged.disconnect(self._onPrintJobStateChanged)
  216. self._print_jobs.remove(removed_job)
  217. for added_job in added_jobs:
  218. self._addPrintJob(added_job)
  219. for model, job in updated_jobs:
  220. job.updateOutputModel(model)
  221. if job.printer_uuid:
  222. self._updateAssignedPrinter(model, job.printer_uuid)
  223. # We only have to update when jobs are added or removed
  224. # updated jobs push their changes via their output model
  225. if added_jobs or removed_jobs:
  226. self.printJobsChanged.emit()
  227. ## Registers a new print job received via the cloud API.
  228. # \param job: The print job received.
  229. def _addPrintJob(self, job: CloudClusterPrintJobStatus) -> None:
  230. model = job.createOutputModel(CloudOutputController(self))
  231. model.stateChanged.connect(self._onPrintJobStateChanged)
  232. if job.printer_uuid:
  233. self._updateAssignedPrinter(model, job.printer_uuid)
  234. self._print_jobs.append(model)
  235. ## Handles the event of a change in a print job state
  236. def _onPrintJobStateChanged(self) -> None:
  237. user_name = self._getUserName()
  238. # TODO: confirm that notifications in Cura are still required
  239. for job in self._print_jobs:
  240. if job.state == "wait_cleanup" and job.key not in self._finished_jobs and job.owner == user_name:
  241. self._finished_jobs.add(job.key)
  242. Message(
  243. title = I18N_CATALOG.i18nc("@info:status", "Print finished"),
  244. text = (I18N_CATALOG.i18nc("@info:status", "Printer '{printer_name}' has finished printing '{job_name}'.").format(
  245. printer_name = job.assignedPrinter.name,
  246. job_name = job.name
  247. ) if job.assignedPrinter else
  248. I18N_CATALOG.i18nc("@info:status", "The print job '{job_name}' was finished.").format(
  249. job_name = job.name
  250. )),
  251. ).show()
  252. ## Updates the printer assignment for the given print job model.
  253. def _updateAssignedPrinter(self, model: UM3PrintJobOutputModel, printer_uuid: str) -> None:
  254. printer = next((p for p in self._printers if printer_uuid == p.key), None)
  255. if not printer:
  256. Logger.log("w", "Missing printer %s for job %s in %s", model.assignedPrinter, model.key,
  257. [p.key for p in self._printers])
  258. return
  259. printer.updateActivePrintJob(model)
  260. model.updateAssignedPrinter(printer)
  261. ## Uploads the mesh when the print job was registered with the cloud API.
  262. # \param job_response: The response received from the cloud API.
  263. def _onPrintJobCreated(self, job_response: CloudPrintJobResponse) -> None:
  264. self._progress.show()
  265. self._uploaded_print_job = job_response
  266. tool_path = cast(bytes, self._tool_path)
  267. self._api.uploadToolPath(job_response, tool_path, self._onPrintJobUploaded, self._progress.update, self._onUploadError)
  268. ## Requests the print to be sent to the printer when we finished uploading the mesh.
  269. def _onPrintJobUploaded(self) -> None:
  270. self._progress.update(100)
  271. print_job = cast(CloudPrintJobResponse, self._uploaded_print_job)
  272. self._api.requestPrint(self.key, print_job.job_id, self._onPrintUploadCompleted)
  273. ## Displays the given message if uploading the mesh has failed
  274. # \param message: The message to display.
  275. def _onUploadError(self, message: str = None) -> None:
  276. self._progress.hide()
  277. self._uploaded_print_job = None
  278. Message(
  279. text = message or I18N_CATALOG.i18nc("@info:text", "Could not upload the data to the printer."),
  280. title = I18N_CATALOG.i18nc("@info:title", "Cloud error"),
  281. lifetime = 10
  282. ).show()
  283. self.writeError.emit()
  284. ## Shows a message when the upload has succeeded
  285. # \param response: The response from the cloud API.
  286. def _onPrintUploadCompleted(self, response: CloudPrintResponse) -> None:
  287. Logger.log("d", "The cluster will be printing this print job with the ID %s", response.cluster_job_id)
  288. self._progress.hide()
  289. Message(
  290. text = I18N_CATALOG.i18nc("@info:status", "Print job was successfully sent to the printer."),
  291. title = I18N_CATALOG.i18nc("@info:title", "Data Sent"),
  292. lifetime = 5
  293. ).show()
  294. self.writeFinished.emit()
  295. ## Gets the number of printers in the cluster.
  296. # We use a minimum of 1 because cloud devices are always a cluster and printer discovery needs it.
  297. @pyqtProperty(int, notify = _clusterPrintersChanged)
  298. def clusterSize(self) -> int:
  299. return max(1, len(self._printers))
  300. ## Gets the remote printers.
  301. @pyqtProperty("QVariantList", notify=_clusterPrintersChanged)
  302. def printers(self) -> List[PrinterOutputModel]:
  303. return self._printers
  304. ## Get the active printer in the UI (monitor page).
  305. @pyqtProperty(QObject, notify = activePrinterChanged)
  306. def activePrinter(self) -> Optional[PrinterOutputModel]:
  307. return self._active_printer
  308. ## Set the active printer in the UI (monitor page).
  309. @pyqtSlot(QObject)
  310. def setActivePrinter(self, printer: Optional[PrinterOutputModel] = None) -> None:
  311. if printer != self._active_printer:
  312. self._active_printer = printer
  313. self.activePrinterChanged.emit()
  314. ## Get remote print jobs.
  315. @pyqtProperty("QVariantList", notify = printJobsChanged)
  316. def printJobs(self) -> List[UM3PrintJobOutputModel]:
  317. return self._print_jobs
  318. ## Get remote print jobs that are still in the print queue.
  319. @pyqtProperty("QVariantList", notify = printJobsChanged)
  320. def queuedPrintJobs(self) -> List[UM3PrintJobOutputModel]:
  321. return [print_job for print_job in self._print_jobs
  322. if print_job.state == "queued" or print_job.state == "error"]
  323. ## Get remote print jobs that are assigned to a printer.
  324. @pyqtProperty("QVariantList", notify = printJobsChanged)
  325. def activePrintJobs(self) -> List[UM3PrintJobOutputModel]:
  326. return [print_job for print_job in self._print_jobs if
  327. print_job.assignedPrinter is not None and print_job.state != "queued"]
  328. @pyqtSlot(int, result = str)
  329. def formatDuration(self, seconds: int) -> str:
  330. return Duration(seconds).getDisplayString(DurationFormat.Format.Short)
  331. @pyqtSlot(int, result = str)
  332. def getTimeCompleted(self, time_remaining: int) -> str:
  333. return formatTimeCompleted(time_remaining)
  334. @pyqtSlot(int, result = str)
  335. def getDateCompleted(self, time_remaining: int) -> str:
  336. return formatDateCompleted(time_remaining)
  337. ## TODO: The following methods are required by the monitor page QML, but are not actually available using cloud.
  338. # TODO: We fake the methods here to not break the monitor page.
  339. @pyqtProperty(QUrl, notify = _clusterPrintersChanged)
  340. def activeCameraUrl(self) -> "QUrl":
  341. return QUrl()
  342. @pyqtSlot(QUrl)
  343. def setActiveCameraUrl(self, camera_url: "QUrl") -> None:
  344. pass
  345. @pyqtProperty(bool, notify = printJobsChanged)
  346. def receivedPrintJobs(self) -> bool:
  347. return bool(self._print_jobs)
  348. @pyqtSlot()
  349. def openPrintJobControlPanel(self) -> None:
  350. pass
  351. @pyqtSlot()
  352. def openPrinterControlPanel(self) -> None:
  353. pass
  354. @pyqtSlot(str)
  355. def sendJobToTop(self, print_job_uuid: str) -> None:
  356. pass
  357. @pyqtSlot(str)
  358. def deleteJobFromQueue(self, print_job_uuid: str) -> None:
  359. pass
  360. @pyqtSlot(str)
  361. def forceSendJob(self, print_job_uuid: str) -> None:
  362. pass
  363. @pyqtProperty("QVariantList", notify = _clusterPrintersChanged)
  364. def connectedPrintersTypeCount(self) -> List[Dict[str, str]]:
  365. return []