ClusterUM3OutputDevice.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458
  1. # Copyright (c) 2017 Ultimaker B.V.
  2. # Cura is released under the terms of the LGPLv3 or higher.
  3. from UM.Logger import Logger
  4. from UM.Application import Application
  5. from UM.Settings.ContainerRegistry import ContainerRegistry
  6. from UM.i18n import i18nCatalog
  7. from UM.Message import Message
  8. from UM.Qt.Duration import Duration, DurationFormat
  9. from cura.PrinterOutput.NetworkedPrinterOutputDevice import NetworkedPrinterOutputDevice, AuthState
  10. from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel
  11. from cura.PrinterOutput.PrintJobOutputModel import PrintJobOutputModel
  12. from cura.PrinterOutput.MaterialOutputModel import MaterialOutputModel
  13. from cura.PrinterOutput.NetworkCamera import NetworkCamera
  14. from .ClusterUM3PrinterOutputController import ClusterUM3PrinterOutputController
  15. from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply
  16. from PyQt5.QtGui import QDesktopServices
  17. from PyQt5.QtCore import pyqtSlot, QUrl, pyqtSignal, pyqtProperty, QObject
  18. from time import time
  19. from datetime import datetime
  20. from typing import Optional
  21. import json
  22. import os
  23. i18n_catalog = i18nCatalog("cura")
  24. class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice):
  25. printJobsChanged = pyqtSignal()
  26. activePrinterChanged = pyqtSignal()
  27. # This is a bit of a hack, as the notify can only use signals that are defined by the class that they are in.
  28. # Inheritance doesn't seem to work. Tying them together does work, but i'm open for better suggestions.
  29. clusterPrintersChanged = pyqtSignal()
  30. def __init__(self, device_id, address, properties, parent = None):
  31. super().__init__(device_id = device_id, address = address, properties=properties, parent = parent)
  32. self._api_prefix = "/cluster-api/v1/"
  33. self._number_of_extruders = 2
  34. self._print_jobs = []
  35. self._monitor_view_qml_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ClusterMonitorItem.qml")
  36. self._control_view_qml_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ClusterControlItem.qml")
  37. # See comments about this hack with the clusterPrintersChanged signal
  38. self.printersChanged.connect(self.clusterPrintersChanged)
  39. self._accepts_commands = True
  40. # Cluster does not have authentication, so default to authenticated
  41. self._authentication_state = AuthState.Authenticated
  42. self._error_message = None
  43. self._progress_message = None
  44. self._active_printer = None # type: Optional[PrinterOutputModel]
  45. self._printer_selection_dialog = None
  46. self.setPriority(3) # Make sure the output device gets selected above local file output
  47. self.setName(self._id)
  48. self.setShortDescription(i18n_catalog.i18nc("@action:button Preceded by 'Ready to'.", "Print over network"))
  49. self.setDescription(i18n_catalog.i18nc("@properties:tooltip", "Print over network"))
  50. self.setConnectionText(i18n_catalog.i18nc("@info:status", "Connected over the network"))
  51. self._printer_uuid_to_unique_name_mapping = {}
  52. self._finished_jobs = []
  53. self._cluster_size = int(properties.get(b"cluster_size", 0))
  54. self._latest_reply_handler = None
  55. def requestWrite(self, nodes, file_name=None, filter_by_machine=False, file_handler=None, **kwargs):
  56. self.writeStarted.emit(self)
  57. gcode_dict = getattr(Application.getInstance().getController().getScene(), "gcode_dict", [])
  58. active_build_plate_id = Application.getInstance().getMultiBuildPlateModel().activeBuildPlate
  59. gcode_list = gcode_dict[active_build_plate_id]
  60. if not gcode_list:
  61. # Unable to find g-code. Nothing to send
  62. return
  63. self._gcode = gcode_list
  64. is_job_sent = True
  65. if len(self._printers) > 1:
  66. self._spawnPrinterSelectionDialog()
  67. else:
  68. is_job_sent = self.sendPrintJob()
  69. # Notify the UI that a switch to the print monitor should happen
  70. if is_job_sent:
  71. Application.getInstance().getController().setActiveStage("MonitorStage")
  72. def _spawnPrinterSelectionDialog(self):
  73. if self._printer_selection_dialog is None:
  74. path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "PrintWindow.qml")
  75. self._printer_selection_dialog = Application.getInstance().createQmlComponent(path, {"OutputDevice": self})
  76. if self._printer_selection_dialog is not None:
  77. self._printer_selection_dialog.show()
  78. @pyqtProperty(int, constant=True)
  79. def clusterSize(self):
  80. return self._cluster_size
  81. @pyqtSlot()
  82. @pyqtSlot(str)
  83. def sendPrintJob(self, target_printer = ""):
  84. Logger.log("i", "Sending print job to printer.")
  85. if self._sending_gcode:
  86. self._error_message = Message(
  87. i18n_catalog.i18nc("@info:status",
  88. "Sending new jobs (temporarily) blocked, still sending the previous print job."))
  89. self._error_message.show()
  90. return False
  91. self._sending_gcode = True
  92. self._progress_message = Message(i18n_catalog.i18nc("@info:status", "Sending data to printer"), 0, False, -1,
  93. i18n_catalog.i18nc("@info:title", "Sending Data"))
  94. self._progress_message.addAction("Abort", i18n_catalog.i18nc("@action:button", "Cancel"), None, "")
  95. self._progress_message.actionTriggered.connect(self._progressMessageActionTriggered)
  96. self._progress_message.show()
  97. compressed_gcode = self._compressGCode()
  98. if compressed_gcode is None:
  99. # Abort was called.
  100. return False
  101. parts = []
  102. # If a specific printer was selected, it should be printed with that machine.
  103. if target_printer:
  104. target_printer = self._printer_uuid_to_unique_name_mapping[target_printer]
  105. parts.append(self._createFormPart("name=require_printer_name", bytes(target_printer, "utf-8"), "text/plain"))
  106. # Add user name to the print_job
  107. parts.append(self._createFormPart("name=owner", bytes(self._getUserName(), "utf-8"), "text/plain"))
  108. file_name = "%s.gcode.gz" % Application.getInstance().getPrintInformation().jobName
  109. parts.append(self._createFormPart("name=\"file\"; filename=\"%s\"" % file_name, compressed_gcode))
  110. self._latest_reply_handler = self.postFormWithParts("print_jobs/", parts, onFinished=self._onPostPrintJobFinished, onProgress=self._onUploadPrintJobProgress)
  111. return True
  112. @pyqtProperty(QObject, notify=activePrinterChanged)
  113. def activePrinter(self) -> Optional["PrinterOutputModel"]:
  114. return self._active_printer
  115. @pyqtSlot(QObject)
  116. def setActivePrinter(self, printer):
  117. if self._active_printer != printer:
  118. if self._active_printer and self._active_printer.camera:
  119. self._active_printer.camera.stop()
  120. self._active_printer = printer
  121. self.activePrinterChanged.emit()
  122. def _onPostPrintJobFinished(self, reply):
  123. self._progress_message.hide()
  124. self._compressing_gcode = False
  125. self._sending_gcode = False
  126. def _onUploadPrintJobProgress(self, bytes_sent, bytes_total):
  127. if bytes_total > 0:
  128. new_progress = bytes_sent / bytes_total * 100
  129. # Treat upload progress as response. Uploading can take more than 10 seconds, so if we don't, we can get
  130. # timeout responses if this happens.
  131. self._last_response_time = time()
  132. if new_progress > self._progress_message.getProgress():
  133. self._progress_message.show() # Ensure that the message is visible.
  134. self._progress_message.setProgress(bytes_sent / bytes_total * 100)
  135. else:
  136. self._progress_message.setProgress(0)
  137. self._progress_message.hide()
  138. def _progressMessageActionTriggered(self, message_id=None, action_id=None):
  139. if action_id == "Abort":
  140. Logger.log("d", "User aborted sending print to remote.")
  141. self._progress_message.hide()
  142. self._compressing_gcode = False
  143. self._sending_gcode = False
  144. Application.getInstance().getController().setActiveStage("PrepareStage")
  145. # After compressing the sliced model Cura sends data to printer, to stop receiving updates from the request
  146. # the "reply" should be disconnected
  147. if self._latest_reply_handler:
  148. self._latest_reply_handler.disconnect()
  149. self._latest_reply_handler = None
  150. @pyqtSlot()
  151. def openPrintJobControlPanel(self):
  152. Logger.log("d", "Opening print job control panel...")
  153. QDesktopServices.openUrl(QUrl("http://" + self._address + "/print_jobs"))
  154. @pyqtSlot()
  155. def openPrinterControlPanel(self):
  156. Logger.log("d", "Opening printer control panel...")
  157. QDesktopServices.openUrl(QUrl("http://" + self._address + "/printers"))
  158. @pyqtProperty("QVariantList", notify=printJobsChanged)
  159. def printJobs(self):
  160. return self._print_jobs
  161. @pyqtProperty("QVariantList", notify=printJobsChanged)
  162. def queuedPrintJobs(self):
  163. return [print_job for print_job in self._print_jobs if print_job.assignedPrinter is None or print_job.state == "queued"]
  164. @pyqtProperty("QVariantList", notify=printJobsChanged)
  165. def activePrintJobs(self):
  166. return [print_job for print_job in self._print_jobs if print_job.assignedPrinter is not None and print_job.state != "queued"]
  167. @pyqtProperty("QVariantList", notify=clusterPrintersChanged)
  168. def connectedPrintersTypeCount(self):
  169. printer_count = {}
  170. for printer in self._printers:
  171. if printer.type in printer_count:
  172. printer_count[printer.type] += 1
  173. else:
  174. printer_count[printer.type] = 1
  175. result = []
  176. for machine_type in printer_count:
  177. result.append({"machine_type": machine_type, "count": printer_count[machine_type]})
  178. return result
  179. @pyqtSlot(int, result=str)
  180. def formatDuration(self, seconds):
  181. return Duration(seconds).getDisplayString(DurationFormat.Format.Short)
  182. @pyqtSlot(int, result=str)
  183. def getTimeCompleted(self, time_remaining):
  184. current_time = time()
  185. datetime_completed = datetime.fromtimestamp(current_time + time_remaining)
  186. return "{hour:02d}:{minute:02d}".format(hour=datetime_completed.hour, minute=datetime_completed.minute)
  187. @pyqtSlot(int, result=str)
  188. def getDateCompleted(self, time_remaining):
  189. current_time = time()
  190. datetime_completed = datetime.fromtimestamp(current_time + time_remaining)
  191. return (datetime_completed.strftime("%a %b ") + "{day}".format(day=datetime_completed.day)).upper()
  192. def _printJobStateChanged(self):
  193. username = self._getUserName()
  194. if username is None:
  195. return # We only want to show notifications if username is set.
  196. finished_jobs = [job for job in self._print_jobs if job.state == "wait_cleanup"]
  197. newly_finished_jobs = [job for job in finished_jobs if job not in self._finished_jobs and job.owner == username]
  198. for job in newly_finished_jobs:
  199. if job.assignedPrinter:
  200. job_completed_text = i18n_catalog.i18nc("@info:status", "Printer '{printer_name}' has finished printing '{job_name}'.".format(printer_name=job.assignedPrinter.name, job_name = job.name))
  201. else:
  202. job_completed_text = i18n_catalog.i18nc("@info:status", "The print job '{job_name}' was finished.".format(job_name = job.name))
  203. job_completed_message = Message(text=job_completed_text, title = i18n_catalog.i18nc("@info:status", "Print finished"))
  204. job_completed_message.show()
  205. # Ensure UI gets updated
  206. self.printJobsChanged.emit()
  207. # Keep a list of all completed jobs so we know if something changed next time.
  208. self._finished_jobs = finished_jobs
  209. def _update(self):
  210. if not super()._update():
  211. return
  212. self.get("printers/", onFinished=self._onGetPrintersDataFinished)
  213. self.get("print_jobs/", onFinished=self._onGetPrintJobsFinished)
  214. def _onGetPrintJobsFinished(self, reply: QNetworkReply):
  215. if not checkValidGetReply(reply):
  216. return
  217. result = loadJsonFromReply(reply)
  218. if result is None:
  219. return
  220. print_jobs_seen = []
  221. job_list_changed = False
  222. for print_job_data in result:
  223. print_job = findByKey(self._print_jobs, print_job_data["uuid"])
  224. if print_job is None:
  225. print_job = self._createPrintJobModel(print_job_data)
  226. job_list_changed = True
  227. self._updatePrintJob(print_job, print_job_data)
  228. if print_job.state != "queued": # Print job should be assigned to a printer.
  229. if print_job.state in ["failed", "finished", "aborted"]:
  230. # Print job was already completed, so don't attach it to a printer.
  231. printer = None
  232. else:
  233. printer = self._getPrinterByKey(print_job_data["printer_uuid"])
  234. else: # The job can "reserve" a printer if some changes are required.
  235. printer = self._getPrinterByKey(print_job_data["assigned_to"])
  236. if printer:
  237. printer.updateActivePrintJob(print_job)
  238. print_jobs_seen.append(print_job)
  239. # Check what jobs need to be removed.
  240. removed_jobs = [print_job for print_job in self._print_jobs if print_job not in print_jobs_seen]
  241. for removed_job in removed_jobs:
  242. job_list_changed |= self._removeJob(removed_job)
  243. if job_list_changed:
  244. self.printJobsChanged.emit() # Do a single emit for all print job changes.
  245. def _onGetPrintersDataFinished(self, reply: QNetworkReply):
  246. if not checkValidGetReply(reply):
  247. return
  248. result = loadJsonFromReply(reply)
  249. if result is None:
  250. return
  251. printer_list_changed = False
  252. printers_seen = []
  253. for printer_data in result:
  254. printer = findByKey(self._printers, printer_data["uuid"])
  255. if printer is None:
  256. printer = self._createPrinterModel(printer_data)
  257. printer_list_changed = True
  258. printers_seen.append(printer)
  259. self._updatePrinter(printer, printer_data)
  260. removed_printers = [printer for printer in self._printers if printer not in printers_seen]
  261. for printer in removed_printers:
  262. self._removePrinter(printer)
  263. if removed_printers or printer_list_changed:
  264. self.printersChanged.emit()
  265. def _createPrinterModel(self, data):
  266. printer = PrinterOutputModel(output_controller=ClusterUM3PrinterOutputController(self),
  267. number_of_extruders=self._number_of_extruders)
  268. printer.setCamera(NetworkCamera("http://" + data["ip_address"] + ":8080/?action=stream"))
  269. self._printers.append(printer)
  270. return printer
  271. def _createPrintJobModel(self, data):
  272. print_job = PrintJobOutputModel(output_controller=ClusterUM3PrinterOutputController(self),
  273. key=data["uuid"], name= data["name"])
  274. print_job.stateChanged.connect(self._printJobStateChanged)
  275. self._print_jobs.append(print_job)
  276. return print_job
  277. def _updatePrintJob(self, print_job, data):
  278. print_job.updateTimeTotal(data["time_total"])
  279. print_job.updateTimeElapsed(data["time_elapsed"])
  280. print_job.updateState(data["status"])
  281. print_job.updateOwner(data["owner"])
  282. def _updatePrinter(self, printer, data):
  283. # For some unknown reason the cluster wants UUID for everything, except for sending a job directly to a printer.
  284. # Then we suddenly need the unique name. So in order to not have to mess up all the other code, we save a mapping.
  285. self._printer_uuid_to_unique_name_mapping[data["uuid"]] = data["unique_name"]
  286. printer.updateName(data["friendly_name"])
  287. printer.updateKey(data["uuid"])
  288. printer.updateType(data["machine_variant"])
  289. if not data["enabled"]:
  290. printer.updateState("disabled")
  291. else:
  292. printer.updateState(data["status"])
  293. for index in range(0, self._number_of_extruders):
  294. extruder = printer.extruders[index]
  295. try:
  296. extruder_data = data["configuration"][index]
  297. except IndexError:
  298. break
  299. extruder.updateHotendID(extruder_data.get("print_core_id", ""))
  300. material_data = extruder_data["material"]
  301. if extruder.activeMaterial is None or extruder.activeMaterial.guid != material_data["guid"]:
  302. containers = ContainerRegistry.getInstance().findInstanceContainers(type="material",
  303. GUID=material_data["guid"])
  304. if containers:
  305. color = containers[0].getMetaDataEntry("color_code")
  306. brand = containers[0].getMetaDataEntry("brand")
  307. material_type = containers[0].getMetaDataEntry("material")
  308. name = containers[0].getName()
  309. else:
  310. Logger.log("w",
  311. "Unable to find material with guid {guid}. Using data as provided by cluster".format(
  312. guid=material_data["guid"]))
  313. color = material_data["color"]
  314. brand = material_data["brand"]
  315. material_type = material_data["material"]
  316. name = "Empty" if material_data["material"] == "empty" else "Unknown"
  317. material = MaterialOutputModel(guid=material_data["guid"], type=material_type,
  318. brand=brand, color=color, name=name)
  319. extruder.updateActiveMaterial(material)
  320. def _removeJob(self, job):
  321. if job not in self._print_jobs:
  322. return False
  323. if job.assignedPrinter:
  324. job.assignedPrinter.updateActivePrintJob(None)
  325. job.stateChanged.disconnect(self._printJobStateChanged)
  326. self._print_jobs.remove(job)
  327. return True
  328. def _removePrinter(self, printer):
  329. self._printers.remove(printer)
  330. if self._active_printer == printer:
  331. self._active_printer = None
  332. self.activePrinterChanged.emit()
  333. def loadJsonFromReply(reply):
  334. try:
  335. result = json.loads(bytes(reply.readAll()).decode("utf-8"))
  336. except json.decoder.JSONDecodeError:
  337. Logger.logException("w", "Unable to decode JSON from reply.")
  338. return
  339. return result
  340. def checkValidGetReply(reply):
  341. status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute)
  342. if status_code != 200:
  343. Logger.log("w", "Got status code {status_code} while trying to get data".format(status_code=status_code))
  344. return False
  345. return True
  346. def findByKey(list, key):
  347. for item in list:
  348. if item.key == key:
  349. return item