UltimakerNetworkedPrinterOutputDevice.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417
  1. # Copyright (c) 2022 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 List, Optional, Dict
  6. from PyQt6.QtCore import pyqtProperty, pyqtSignal, QObject, pyqtSlot, QUrl
  7. from UM.Logger import Logger
  8. from UM.Qt.Duration import Duration, DurationFormat
  9. from cura.CuraApplication import CuraApplication
  10. from cura.PrinterOutput.Models.PrinterOutputModel import PrinterOutputModel
  11. from cura.PrinterOutput.NetworkedPrinterOutputDevice import NetworkedPrinterOutputDevice, AuthState
  12. from cura.PrinterOutput.PrinterOutputDevice import ConnectionType, ConnectionState
  13. from .Utils import formatTimeCompleted, formatDateCompleted
  14. from .ClusterOutputController import ClusterOutputController
  15. from .Messages.PrintJobUploadProgressMessage import PrintJobUploadProgressMessage
  16. from .Messages.NotClusterHostMessage import NotClusterHostMessage
  17. from .Models.UM3PrintJobOutputModel import UM3PrintJobOutputModel
  18. from .Models.Http.ClusterPrinterStatus import ClusterPrinterStatus
  19. from .Models.Http.ClusterPrintJobStatus import ClusterPrintJobStatus
  20. class UltimakerNetworkedPrinterOutputDevice(NetworkedPrinterOutputDevice):
  21. """Output device class that forms the basis of Ultimaker networked printer output devices.
  22. Currently used for local networking and cloud printing using Ultimaker Connect.
  23. This base class primarily contains all the Qt properties and slots needed for the monitor page to work.
  24. """
  25. META_NETWORK_KEY = "um_network_key"
  26. META_CLUSTER_ID = "um_cloud_cluster_id"
  27. # Signal emitted when the status of the print jobs for this cluster were changed over the network.
  28. printJobsChanged = pyqtSignal()
  29. # Signal emitted when the currently visible printer card in the UI was changed by the user.
  30. activePrinterChanged = pyqtSignal()
  31. # Notify can only use signals that are defined by the class that they are in, not inherited ones.
  32. # Therefore we create a private signal used to trigger the printersChanged signal.
  33. _clusterPrintersChanged = pyqtSignal()
  34. # States indicating if a print job is queued.
  35. QUEUED_PRINT_JOBS_STATES = {"queued", "error"}
  36. def __init__(self, device_id: str, address: str, properties: Dict[bytes, bytes], connection_type: ConnectionType,
  37. parent=None) -> None:
  38. super().__init__(device_id=device_id, address=address, properties=properties, connection_type=connection_type,
  39. parent=parent)
  40. # Trigger the printersChanged signal when the private signal is triggered.
  41. self.printersChanged.connect(self._clusterPrintersChanged)
  42. # Keeps track the last network response to determine if we are still connected.
  43. self._time_of_last_response = time()
  44. self._time_of_last_request = time()
  45. # Set the display name from the properties.
  46. self.setName(self.getProperty("name"))
  47. # Set the display name of the printer type.
  48. definitions = CuraApplication.getInstance().getContainerRegistry().findContainers(id = self.printerType)
  49. self._printer_type_name = definitions[0].getName() if definitions else ""
  50. # Keeps track of all printers in the cluster.
  51. self._printers = [] # type: List[PrinterOutputModel]
  52. self._has_received_printers = False
  53. # Keeps track of all print jobs in the cluster.
  54. self._print_jobs = [] # type: List[UM3PrintJobOutputModel]
  55. # Keep track of the printer currently selected in the UI.
  56. self._active_printer = None # type: Optional[PrinterOutputModel]
  57. # By default we are not authenticated. This state will be changed later.
  58. self._authentication_state = AuthState.NotAuthenticated
  59. # Load the Monitor UI elements.
  60. self._loadMonitorTab()
  61. # The job upload progress message modal.
  62. self._progress = PrintJobUploadProgressMessage()
  63. self._timeout_time = 30
  64. self._num_is_host_check_failed = 0
  65. @pyqtProperty(str, constant=True)
  66. def address(self) -> str:
  67. """The IP address of the printer."""
  68. return self._address
  69. @pyqtProperty(str, constant=True)
  70. def printerTypeName(self) -> str:
  71. """The display name of the printer."""
  72. return self._printer_type_name
  73. # Get all print jobs for this cluster.
  74. @pyqtProperty("QVariantList", notify=printJobsChanged)
  75. def printJobs(self) -> List[UM3PrintJobOutputModel]:
  76. return self._print_jobs
  77. # Get all print jobs for this cluster that are queued.
  78. @pyqtProperty("QVariantList", notify=printJobsChanged)
  79. def queuedPrintJobs(self) -> List[UM3PrintJobOutputModel]:
  80. return [print_job for print_job in self._print_jobs if print_job.state in self.QUEUED_PRINT_JOBS_STATES]
  81. # Get all print jobs for this cluster that are currently printing.
  82. @pyqtProperty("QVariantList", notify=printJobsChanged)
  83. def activePrintJobs(self) -> List[UM3PrintJobOutputModel]:
  84. return [print_job for print_job in self._print_jobs if
  85. print_job.assignedPrinter is not None and print_job.state not in self.QUEUED_PRINT_JOBS_STATES]
  86. @pyqtProperty(bool, notify=_clusterPrintersChanged)
  87. def receivedData(self) -> bool:
  88. return self._has_received_printers
  89. # Get the amount of printers in the cluster.
  90. @pyqtProperty(int, notify=_clusterPrintersChanged)
  91. def clusterSize(self) -> int:
  92. if not self._has_received_printers:
  93. discovered_size = self.getProperty("cluster_size")
  94. if discovered_size == "":
  95. return 1 # prevent false positives for new devices
  96. return int(discovered_size)
  97. return len(self._printers)
  98. # Get the amount of printer in the cluster per type.
  99. @pyqtProperty("QVariantList", notify=_clusterPrintersChanged)
  100. def connectedPrintersTypeCount(self) -> List[Dict[str, str]]:
  101. printer_count = {} # type: Dict[str, int]
  102. for printer in self._printers:
  103. if printer.type in printer_count:
  104. printer_count[printer.type] += 1
  105. else:
  106. printer_count[printer.type] = 1
  107. result = []
  108. for machine_type in printer_count:
  109. result.append({"machine_type": machine_type, "count": str(printer_count[machine_type])})
  110. return result
  111. # Get a list of all printers.
  112. @pyqtProperty("QVariantList", notify=_clusterPrintersChanged)
  113. def printers(self) -> List[PrinterOutputModel]:
  114. return self._printers
  115. # Get the currently active printer in the UI.
  116. @pyqtProperty(QObject, notify=activePrinterChanged)
  117. def activePrinter(self) -> Optional[PrinterOutputModel]:
  118. return self._active_printer
  119. # Set the currently active printer from the UI.
  120. @pyqtSlot(QObject, name="setActivePrinter")
  121. def setActivePrinter(self, printer: Optional[PrinterOutputModel]) -> None:
  122. if self.activePrinter == printer:
  123. return
  124. self._active_printer = printer
  125. self.activePrinterChanged.emit()
  126. @pyqtProperty(bool, constant=True)
  127. def supportsPrintJobActions(self) -> bool:
  128. """Whether the printer that this output device represents supports print job actions via the local network."""
  129. return True
  130. def setJobState(self, print_job_uuid: str, state: str) -> None:
  131. """Set the remote print job state."""
  132. raise NotImplementedError("setJobState must be implemented")
  133. @pyqtSlot(str, name="sendJobToTop")
  134. def sendJobToTop(self, print_job_uuid: str) -> None:
  135. raise NotImplementedError("sendJobToTop must be implemented")
  136. @pyqtSlot(str, name="deleteJobFromQueue")
  137. def deleteJobFromQueue(self, print_job_uuid: str) -> None:
  138. raise NotImplementedError("deleteJobFromQueue must be implemented")
  139. @pyqtSlot(str, name="forceSendJob")
  140. def forceSendJob(self, print_job_uuid: str) -> None:
  141. raise NotImplementedError("forceSendJob must be implemented")
  142. @pyqtProperty(bool, constant = True)
  143. def supportsPrintJobQueue(self) -> bool:
  144. """
  145. Whether this printer knows about queueing print jobs.
  146. """
  147. return True # This API always supports print job queueing.
  148. @pyqtProperty(bool, constant = True)
  149. def canReadPrintJobs(self) -> bool:
  150. """
  151. Whether this user can read the list of print jobs and their properties.
  152. """
  153. return True
  154. @pyqtProperty(bool, constant = True)
  155. def canWriteOthersPrintJobs(self) -> bool:
  156. """
  157. Whether this user can change things about print jobs made by other
  158. people.
  159. """
  160. return True
  161. @pyqtProperty(bool, constant = True)
  162. def canWriteOwnPrintJobs(self) -> bool:
  163. """
  164. Whether this user can change things about print jobs made by themself.
  165. """
  166. return True
  167. @pyqtProperty(bool, constant = True)
  168. def canReadPrinterDetails(self) -> bool:
  169. """
  170. Whether this user can read the status of the printer.
  171. """
  172. return True
  173. @pyqtSlot(name="openPrintJobControlPanel")
  174. def openPrintJobControlPanel(self) -> None:
  175. raise NotImplementedError("openPrintJobControlPanel must be implemented")
  176. @pyqtSlot(name="openPrinterControlPanel")
  177. def openPrinterControlPanel(self) -> None:
  178. raise NotImplementedError("openPrinterControlPanel must be implemented")
  179. @pyqtProperty(QUrl, notify=_clusterPrintersChanged)
  180. def activeCameraUrl(self) -> QUrl:
  181. return QUrl()
  182. @pyqtSlot(QUrl, name="setActiveCameraUrl")
  183. def setActiveCameraUrl(self, camera_url: QUrl) -> None:
  184. pass
  185. @pyqtSlot(int, result=str, name="getTimeCompleted")
  186. def getTimeCompleted(self, time_remaining: int) -> str:
  187. return formatTimeCompleted(time_remaining)
  188. @pyqtSlot(int, result=str, name="getDateCompleted")
  189. def getDateCompleted(self, time_remaining: int) -> str:
  190. return formatDateCompleted(time_remaining)
  191. @pyqtSlot(int, result=str, name="formatDuration")
  192. def formatDuration(self, seconds: int) -> str:
  193. return Duration(seconds).getDisplayString(DurationFormat.Format.Short)
  194. def _update(self) -> None:
  195. super()._update()
  196. self._checkStillConnected()
  197. def _checkStillConnected(self) -> None:
  198. """Check if we're still connected by comparing the last timestamps for network response and the current time.
  199. This implementation is similar to the base NetworkedPrinterOutputDevice, but is tweaked slightly.
  200. Re-connecting is handled automatically by the output device managers in this plugin.
  201. TODO: it would be nice to have this logic in the managers, but connecting those with signals causes crashes.
  202. """
  203. time_since_last_response = time() - self._time_of_last_response
  204. if time_since_last_response > self._timeout_time:
  205. Logger.log("d", "It has been %s seconds since the last response for outputdevice %s, so assume a timeout", time_since_last_response, self.key)
  206. self.setConnectionState(ConnectionState.Closed)
  207. if self.key in CuraApplication.getInstance().getOutputDeviceManager().getOutputDeviceIds():
  208. CuraApplication.getInstance().getOutputDeviceManager().removeOutputDevice(self.key)
  209. elif self.connectionState == ConnectionState.Closed:
  210. self._reconnectForActiveMachine()
  211. def _reconnectForActiveMachine(self) -> None:
  212. """Reconnect for the active output device.
  213. Does nothing if the device is not meant for the active machine.
  214. """
  215. active_machine = CuraApplication.getInstance().getGlobalContainerStack()
  216. if not active_machine:
  217. return
  218. # Indicate this device is now connected again.
  219. Logger.log("d", "Reconnecting output device after timeout.")
  220. self.setConnectionState(ConnectionState.Connected)
  221. # If the device was already registered we don't need to register it again.
  222. if self.key in CuraApplication.getInstance().getOutputDeviceManager().getOutputDeviceIds():
  223. return
  224. # Try for local network device.
  225. stored_device_id = active_machine.getMetaDataEntry(self.META_NETWORK_KEY)
  226. if self.key == stored_device_id:
  227. CuraApplication.getInstance().getOutputDeviceManager().addOutputDevice(self)
  228. # Try for cloud device.
  229. stored_cluster_id = active_machine.getMetaDataEntry(self.META_CLUSTER_ID)
  230. if self.key == stored_cluster_id:
  231. CuraApplication.getInstance().getOutputDeviceManager().addOutputDevice(self)
  232. def _responseReceived(self) -> None:
  233. self._time_of_last_response = time()
  234. def _updatePrinters(self, remote_printers: List[ClusterPrinterStatus]) -> None:
  235. self._responseReceived()
  236. # Keep track of the new printers to show.
  237. # We create a new list instead of changing the existing one to get the correct order.
  238. new_printers = [] # type: List[PrinterOutputModel]
  239. # Check which printers need to be created or updated.
  240. for index, printer_data in enumerate(remote_printers):
  241. printer = next(iter(printer for printer in self._printers if printer.key == printer_data.uuid), None)
  242. if printer is None:
  243. printer = printer_data.createOutputModel(ClusterOutputController(self))
  244. else:
  245. printer_data.updateOutputModel(printer)
  246. new_printers.append(printer)
  247. # Check which printers need to be removed (de-referenced).
  248. remote_printers_keys = [printer_data.uuid for printer_data in remote_printers]
  249. removed_printers = [printer for printer in self._printers if printer.key not in remote_printers_keys]
  250. for removed_printer in removed_printers:
  251. if self._active_printer and self._active_printer.key == removed_printer.key:
  252. self.setActivePrinter(None)
  253. self._printers = new_printers
  254. self._has_received_printers = True
  255. if self._printers and not self.activePrinter:
  256. self.setActivePrinter(self._printers[0])
  257. self.printersChanged.emit()
  258. self._checkIfClusterHost()
  259. def _checkIfClusterHost(self):
  260. """Check is this device is a cluster host and takes the needed actions when it is not."""
  261. if len(self._printers) < 1 and self.isConnected():
  262. self._num_is_host_check_failed += 1
  263. else:
  264. self._num_is_host_check_failed = 0
  265. # Since we request the status of the cluster itself way less frequent in the cloud, it can happen that a cloud
  266. # printer reports having 0 printers (since they are offline!) but we haven't asked if the entire cluster is
  267. # offline. (See CURA-7360)
  268. # So by just counting a number of subsequent times that this has happened fixes the incorrect display.
  269. if self._num_is_host_check_failed >= 6:
  270. NotClusterHostMessage(self).show()
  271. self.close()
  272. CuraApplication.getInstance().getOutputDeviceManager().removeOutputDevice(self.key)
  273. def _updatePrintJobs(self, remote_jobs: List[ClusterPrintJobStatus]) -> None:
  274. """Updates the local list of print jobs with the list received from the cluster.
  275. :param remote_jobs: The print jobs received from the cluster.
  276. """
  277. self._responseReceived()
  278. # Keep track of the new print jobs to show.
  279. # We create a new list instead of changing the existing one to get the correct order.
  280. new_print_jobs = []
  281. # Check which print jobs need to be created or updated.
  282. for print_job_data in remote_jobs:
  283. print_job = next(
  284. iter(print_job for print_job in self._print_jobs if print_job.key == print_job_data.uuid), None)
  285. if not print_job:
  286. new_print_jobs.append(self._createPrintJobModel(print_job_data))
  287. else:
  288. print_job_data.updateOutputModel(print_job)
  289. if print_job_data.printer_uuid:
  290. self._updateAssignedPrinter(print_job, print_job_data.printer_uuid)
  291. if print_job_data.assigned_to:
  292. self._updateAssignedPrinter(print_job, print_job_data.assigned_to)
  293. new_print_jobs.append(print_job)
  294. # Check which print job need to be removed (de-referenced).
  295. remote_job_keys = [print_job_data.uuid for print_job_data in remote_jobs]
  296. removed_jobs = [print_job for print_job in self._print_jobs if print_job.key not in remote_job_keys]
  297. for removed_job in removed_jobs:
  298. if removed_job.assignedPrinter:
  299. removed_job.assignedPrinter.updateActivePrintJob(None)
  300. self._print_jobs = new_print_jobs
  301. self.printJobsChanged.emit()
  302. def _createPrintJobModel(self, remote_job: ClusterPrintJobStatus) -> UM3PrintJobOutputModel:
  303. """Create a new print job model based on the remote status of the job.
  304. :param remote_job: The remote print job data.
  305. """
  306. model = remote_job.createOutputModel(ClusterOutputController(self))
  307. if remote_job.printer_uuid:
  308. self._updateAssignedPrinter(model, remote_job.printer_uuid)
  309. if remote_job.assigned_to:
  310. self._updateAssignedPrinter(model, remote_job.assigned_to)
  311. if remote_job.preview_url:
  312. model.loadPreviewImageFromUrl(remote_job.preview_url)
  313. return model
  314. def _updateAssignedPrinter(self, model: UM3PrintJobOutputModel, printer_uuid: str) -> None:
  315. """Updates the printer assignment for the given print job model."""
  316. printer = next((p for p in self._printers if printer_uuid == p.key), None)
  317. if not printer:
  318. return
  319. printer.updateActivePrintJob(model)
  320. model.updateAssignedPrinter(printer)
  321. def _loadMonitorTab(self) -> None:
  322. """Load Monitor tab QML."""
  323. plugin_registry = CuraApplication.getInstance().getPluginRegistry()
  324. if not plugin_registry:
  325. Logger.log("e", "Could not get plugin registry")
  326. return
  327. plugin_path = plugin_registry.getPluginPath("UM3NetworkPrinting")
  328. if not plugin_path:
  329. Logger.log("e", "Could not get plugin path")
  330. return
  331. self._monitor_view_qml_path = os.path.join(plugin_path, "resources", "qml", "MonitorStage.qml")