NetworkClusterPrinterOutputDevice.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736
  1. import datetime
  2. import getpass
  3. import gzip
  4. import json
  5. import os
  6. import os.path
  7. import time
  8. from enum import Enum
  9. from PyQt5.QtNetwork import QNetworkRequest, QHttpPart, QHttpMultiPart
  10. from PyQt5.QtCore import QUrl, pyqtSlot, pyqtProperty, QCoreApplication, QTimer, pyqtSignal, QObject
  11. from PyQt5.QtGui import QDesktopServices
  12. from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkReply
  13. from UM.Application import Application
  14. from UM.Logger import Logger
  15. from UM.Message import Message
  16. from UM.OutputDevice import OutputDeviceError
  17. from UM.i18n import i18nCatalog
  18. from UM.Qt.Duration import Duration, DurationFormat
  19. from UM.PluginRegistry import PluginRegistry
  20. from . import NetworkPrinterOutputDevice
  21. i18n_catalog = i18nCatalog("cura")
  22. class OutputStage(Enum):
  23. ready = 0
  24. uploading = 2
  25. class NetworkClusterPrinterOutputDevice(NetworkPrinterOutputDevice.NetworkPrinterOutputDevice):
  26. printJobsChanged = pyqtSignal()
  27. printersChanged = pyqtSignal()
  28. selectedPrinterChanged = pyqtSignal()
  29. def __init__(self, key, address, properties, api_prefix):
  30. super().__init__(key, address, properties, api_prefix)
  31. # Store the address of the master.
  32. self._master_address = address
  33. name_property = properties.get(b"name", b"")
  34. if name_property:
  35. name = name_property.decode("utf-8")
  36. else:
  37. name = key
  38. self._authentication_state = NetworkPrinterOutputDevice.AuthState.Authenticated # The printer is always authenticated
  39. self.setName(name)
  40. description = i18n_catalog.i18nc("@action:button Preceded by 'Ready to'.", "Print over network")
  41. self.setShortDescription(description)
  42. self.setDescription(description)
  43. self._stage = OutputStage.ready
  44. host_override = os.environ.get("CLUSTER_OVERRIDE_HOST", "")
  45. if host_override:
  46. Logger.log(
  47. "w",
  48. "Environment variable CLUSTER_OVERRIDE_HOST is set to [%s], cluster hosts are now set to this host",
  49. host_override)
  50. self._host = "http://" + host_override
  51. else:
  52. self._host = "http://" + address
  53. # is the same as in NetworkPrinterOutputDevicePlugin
  54. self._cluster_api_version = "1"
  55. self._cluster_api_prefix = "/cluster-api/v" + self._cluster_api_version + "/"
  56. self._api_base_uri = self._host + self._cluster_api_prefix
  57. self._file_name = None
  58. self._progress_message = None
  59. self._request = None
  60. self._reply = None
  61. # The main reason to keep the 'multipart' form data on the object
  62. # is to prevent the Python GC from claiming it too early.
  63. self._multipart = None
  64. self._print_view = None
  65. self._request_job = []
  66. self._monitor_view_qml_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ClusterMonitorItem.qml")
  67. self._control_view_qml_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ClusterControlItem.qml")
  68. self._print_jobs = []
  69. self._print_job_by_printer_uuid = {}
  70. self._print_job_by_uuid = {} # Print jobs by their own uuid
  71. self._printers = []
  72. self._printers_dict = {} # by unique_name
  73. self._connected_printers_type_count = []
  74. self._automatic_printer = {"unique_name": "", "friendly_name": "Automatic"} # empty unique_name IS automatic selection
  75. self._selected_printer = self._automatic_printer
  76. self._cluster_status_update_timer = QTimer()
  77. self._cluster_status_update_timer.setInterval(5000)
  78. self._cluster_status_update_timer.setSingleShot(False)
  79. self._cluster_status_update_timer.timeout.connect(self._requestClusterStatus)
  80. self._can_pause = True
  81. self._can_abort = True
  82. self._can_pre_heat_bed = False
  83. self._can_control_manually = False
  84. self._cluster_size = int(properties.get(b"cluster_size", 0))
  85. self._cleanupRequest()
  86. #These are texts that are to be translated for future features.
  87. temporary_translation = i18n_catalog.i18n("This printer is not set up to host a group of connected Ultimaker 3 printers.")
  88. temporary_translation2 = i18n_catalog.i18nc("Count is number of printers.", "This printer is the host for a group of {count} connected Ultimaker 3 printers.").format(count = 3)
  89. temporary_translation3 = i18n_catalog.i18n("{printer_name} has finished printing '{job_name}'. Please collect the print and confirm clearing the build plate.") #When finished.
  90. temporary_translation4 = i18n_catalog.i18n("{printer_name} is reserved to print '{job_name}'. Please change the printer's configuration to match the job, for it to start printing.") #When configuration changed.
  91. ## No authentication, so requestAuthentication should do exactly nothing
  92. @pyqtSlot()
  93. def requestAuthentication(self, message_id = None, action_id = "Retry"):
  94. pass # Cura Connect doesn't do any authorization
  95. def setAuthenticationState(self, auth_state):
  96. self._authentication_state = NetworkPrinterOutputDevice.AuthState.Authenticated # The printer is always authenticated
  97. def _verifyAuthentication(self):
  98. pass
  99. def _checkAuthentication(self):
  100. Logger.log("d", "_checkAuthentication Cura Connect - nothing to be done")
  101. @pyqtProperty(QObject, notify=selectedPrinterChanged)
  102. def controlItem(self):
  103. # TODO: Probably not the nicest way to do this. This needs to be done better at some point in time.
  104. if not self._control_item:
  105. self._createControlViewFromQML()
  106. name = self._selected_printer.get("friendly_name")
  107. if name == self._automatic_printer.get("friendly_name") or name == "":
  108. return self._control_item
  109. # Let cura use the default.
  110. return None
  111. @pyqtSlot(int, result = str)
  112. def getTimeCompleted(self, time_remaining):
  113. current_time = time.time()
  114. datetime_completed = datetime.datetime.fromtimestamp(current_time + time_remaining)
  115. return "{hour:02d}:{minute:02d}".format(hour = datetime_completed.hour, minute = datetime_completed.minute)
  116. @pyqtSlot(int, result = str)
  117. def getDateCompleted(self, time_remaining):
  118. current_time = time.time()
  119. datetime_completed = datetime.datetime.fromtimestamp(current_time + time_remaining)
  120. return (datetime_completed.strftime("%a %b ") + "{day}".format(day=datetime_completed.day)).upper()
  121. @pyqtProperty(int, constant = True)
  122. def clusterSize(self):
  123. return self._cluster_size
  124. @pyqtProperty(str, notify=selectedPrinterChanged)
  125. def name(self):
  126. # Show the name of the selected printer.
  127. # This is not the nicest way to do this, but changes to the Cura UI are required otherwise.
  128. name = self._selected_printer.get("friendly_name")
  129. if name != self._automatic_printer.get("friendly_name"):
  130. return name
  131. # Return name of cluster master.
  132. return self._properties.get(b"name", b"").decode("utf-8")
  133. def connect(self):
  134. super().connect()
  135. self._cluster_status_update_timer.start()
  136. def close(self):
  137. super().close()
  138. self._cluster_status_update_timer.stop()
  139. def _setJobState(self, job_state):
  140. if not self._selected_printer:
  141. return
  142. selected_printer_uuid = self._printers_dict[self._selected_printer["unique_name"]]["uuid"]
  143. if selected_printer_uuid not in self._print_job_by_printer_uuid:
  144. return
  145. print_job_uuid = self._print_job_by_printer_uuid[selected_printer_uuid]["uuid"]
  146. url = QUrl(self._api_base_uri + "print_jobs/" + print_job_uuid + "/action")
  147. put_request = QNetworkRequest(url)
  148. put_request.setHeader(QNetworkRequest.ContentTypeHeader, "application/json")
  149. data = '{"action": "' + job_state + '"}'
  150. self._manager.put(put_request, data.encode())
  151. def _requestClusterStatus(self):
  152. # TODO: Handle timeout. We probably want to know if the cluster is still reachable or not.
  153. url = QUrl(self._api_base_uri + "printers/")
  154. printers_request = QNetworkRequest(url)
  155. self._addUserAgentHeader(printers_request)
  156. self._manager.get(printers_request)
  157. # See _finishedPrintersRequest()
  158. if self._printers: # if printers is not empty
  159. url = QUrl(self._api_base_uri + "print_jobs/")
  160. print_jobs_request = QNetworkRequest(url)
  161. self._addUserAgentHeader(print_jobs_request)
  162. self._manager.get(print_jobs_request)
  163. # See _finishedPrintJobsRequest()
  164. def _finishedPrintJobsRequest(self, reply):
  165. try:
  166. json_data = json.loads(bytes(reply.readAll()).decode("utf-8"))
  167. except json.decoder.JSONDecodeError:
  168. Logger.log("w", "Received an invalid print job state message: Not valid JSON.")
  169. return
  170. self.setPrintJobs(json_data)
  171. def _finishedPrintersRequest(self, reply):
  172. try:
  173. json_data = json.loads(bytes(reply.readAll()).decode("utf-8"))
  174. except json.decoder.JSONDecodeError:
  175. Logger.log("w", "Received an invalid print job state message: Not valid JSON.")
  176. return
  177. self.setPrinters(json_data)
  178. def materialHotendChangedMessage(self, callback):
  179. # When there is just one printer, the activate configuration option is enabled
  180. if (self._cluster_size == 1):
  181. super().materialHotendChangedMessage(callback = callback)
  182. def _startCameraStream(self):
  183. ## Request new image
  184. url = QUrl("http://" + self._printers_dict[self._selected_printer["unique_name"]]["ip_address"] + ":8080/?action=stream")
  185. self._image_request = QNetworkRequest(url)
  186. self._addUserAgentHeader(self._image_request)
  187. self._image_reply = self._manager.get(self._image_request)
  188. self._image_reply.downloadProgress.connect(self._onStreamDownloadProgress)
  189. def spawnPrintView(self):
  190. if self._print_view is None:
  191. path = os.path.join(self._plugin_path, "PrintWindow.qml")
  192. self._print_view = Application.getInstance().createQmlComponent(path, {"OutputDevice": self})
  193. if self._print_view is not None:
  194. self._print_view.show()
  195. ## Store job info, show Print view for settings
  196. def requestWrite(self, nodes, file_name=None, filter_by_machine=False, file_handler=None, **kwargs):
  197. self._selected_printer = self._automatic_printer # reset to default option
  198. self._request_job = [nodes, file_name, filter_by_machine, file_handler, kwargs]
  199. # the build plates to be sent
  200. gcode_dict = getattr(Application.getInstance().getController().getScene(), "gcode_dict")
  201. self._job_list = list(gcode_dict.keys())
  202. Logger.log("d", "build plates to be sent to printer: %s", (self._job_list))
  203. if self._stage != OutputStage.ready:
  204. if self._error_message:
  205. self._error_message.hide()
  206. self._error_message = Message(
  207. i18n_catalog.i18nc("@info:status",
  208. "Sending new jobs (temporarily) blocked, still sending the previous print job."))
  209. self._error_message.show()
  210. return
  211. self._add_build_plate_number = len(self._job_list) > 1
  212. self.writeStarted.emit(self) # Allow postprocessing before sending data to the printer
  213. if len(self._printers) > 1:
  214. self.spawnPrintView() # Ask user how to print it.
  215. elif len(self._printers) == 1:
  216. # If there is only one printer, don't bother asking.
  217. self.selectAutomaticPrinter()
  218. self.sendPrintJob()
  219. else:
  220. # Cluster has no printers, warn the user of this.
  221. if self._error_message:
  222. self._error_message.hide()
  223. self._error_message = Message(
  224. i18n_catalog.i18nc("@info:status",
  225. "Unable to send new print job: this 3D printer is not (yet) set up to host a group of connected Ultimaker 3 printers."))
  226. self._error_message.show()
  227. ## Actually send the print job, called from the dialog
  228. # :param: require_printer_name: name of printer, or ""
  229. @pyqtSlot()
  230. def sendPrintJob(self):
  231. nodes, file_name, filter_by_machine, file_handler, kwargs = self._request_job
  232. output_build_plate_number = self._job_list.pop(0)
  233. gcode_dict = getattr(Application.getInstance().getController().getScene(), "gcode_dict")[output_build_plate_number]
  234. if not gcode_dict: # Empty build plate
  235. Logger.log("d", "Skipping empty job (build plate number %d).", output_build_plate_number)
  236. return self.sendPrintJob()
  237. active_build_plate_id = Application.getInstance().getBuildPlateModel().activeBuildPlate
  238. gcode_list = gcode_dict[active_build_plate_id]
  239. self._send_gcode_start = time.time()
  240. Logger.log("d", "Sending print job [%s] to host, build plate [%s]..." % (file_name, output_build_plate_number))
  241. if self._stage != OutputStage.ready:
  242. Logger.log("d", "Unable to send print job as the state is %s", self._stage)
  243. raise OutputDeviceError.DeviceBusyError()
  244. self._stage = OutputStage.uploading
  245. if self._add_build_plate_number:
  246. self._file_name = "%s_%d.gcode.gz" % (file_name, output_build_plate_number)
  247. else:
  248. self._file_name = "%s.gcode.gz" % (file_name)
  249. self._showProgressMessage()
  250. require_printer_name = self._selected_printer["unique_name"]
  251. new_request = self._buildSendPrintJobHttpRequest(require_printer_name, gcode_list)
  252. if new_request is None or self._stage != OutputStage.uploading:
  253. return
  254. self._request = new_request
  255. self._reply = self._manager.post(self._request, self._multipart)
  256. self._reply.uploadProgress.connect(self._onUploadProgress)
  257. # See _finishedPrintJobPostRequest()
  258. def _buildSendPrintJobHttpRequest(self, require_printer_name, gcode_list):
  259. api_url = QUrl(self._api_base_uri + "print_jobs/")
  260. request = QNetworkRequest(api_url)
  261. # Create multipart request and add the g-code.
  262. self._multipart = QHttpMultiPart(QHttpMultiPart.FormDataType)
  263. # Add gcode
  264. part = QHttpPart()
  265. part.setHeader(QNetworkRequest.ContentDispositionHeader,
  266. 'form-data; name="file"; filename="%s"' % (self._file_name))
  267. compressed_gcode = self._compressGcode(gcode_list)
  268. if compressed_gcode is None:
  269. return None # User aborted print, so stop trying.
  270. part.setBody(compressed_gcode)
  271. self._multipart.append(part)
  272. # require_printer_name "" means automatic
  273. if require_printer_name:
  274. self._multipart.append(self.__createKeyValueHttpPart("require_printer_name", require_printer_name))
  275. user_name = self.__get_username()
  276. if user_name is None:
  277. user_name = "unknown"
  278. self._multipart.append(self.__createKeyValueHttpPart("owner", user_name))
  279. self._addUserAgentHeader(request)
  280. return request
  281. def _compressGcode(self, gcode_list):
  282. self._compressing_print = True
  283. batched_line = ""
  284. max_chars_per_line = int(1024 * 1024 / 4) # 1 / 4 MB
  285. byte_array_file_data = b""
  286. def _compressDataAndNotifyQt(data_to_append):
  287. compressed_data = gzip.compress(data_to_append.encode("utf-8"))
  288. self._progress_message.setProgress(-1) # Tickle the message so that it's clear that it's still being used.
  289. QCoreApplication.processEvents() # Ensure that the GUI does not freeze.
  290. # Pretend that this is a response, as zipping might take a bit of time.
  291. self._last_response_time = time.time()
  292. return compressed_data
  293. if gcode_list is None:
  294. Logger.log("e", "Unable to find sliced gcode, returning empty.")
  295. return byte_array_file_data
  296. for line in gcode_list:
  297. if not self._compressing_print:
  298. self._progress_message.hide()
  299. return None # Stop trying to zip, abort was called.
  300. batched_line += line
  301. # if the gcode was read from a gcode file, self._gcode will be a list of all lines in that file.
  302. # Compressing line by line in this case is extremely slow, so we need to batch them.
  303. if len(batched_line) < max_chars_per_line:
  304. continue
  305. byte_array_file_data += _compressDataAndNotifyQt(batched_line)
  306. batched_line = ""
  307. # Also compress the leftovers.
  308. if batched_line:
  309. byte_array_file_data += _compressDataAndNotifyQt(batched_line)
  310. return byte_array_file_data
  311. def __createKeyValueHttpPart(self, key, value):
  312. metadata_part = QHttpPart()
  313. metadata_part.setHeader(QNetworkRequest.ContentTypeHeader, 'text/plain')
  314. metadata_part.setHeader(QNetworkRequest.ContentDispositionHeader, 'form-data; name="%s"' % (key))
  315. metadata_part.setBody(bytearray(value, "utf8"))
  316. return metadata_part
  317. def __get_username(self):
  318. try:
  319. return getpass.getuser()
  320. except:
  321. Logger.log("d", "Could not get the system user name, returning 'unknown' instead.")
  322. return None
  323. def _finishedPrintJobPostRequest(self, reply):
  324. self._stage = OutputStage.ready
  325. if self._progress_message:
  326. self._progress_message.hide()
  327. self._progress_message = None
  328. self.writeFinished.emit(self)
  329. if reply.error():
  330. self._showRequestFailedMessage(reply)
  331. self.writeError.emit(self)
  332. else:
  333. self._showRequestSucceededMessage()
  334. self.writeSuccess.emit(self)
  335. self._cleanupRequest()
  336. if self._job_list: # start sending next job
  337. self.sendPrintJob()
  338. def _showRequestFailedMessage(self, reply):
  339. if reply is not None:
  340. Logger.log("w", "Unable to send print job to group {cluster_name}: {error_string} ({error})".format(
  341. cluster_name = self.getName(),
  342. error_string = str(reply.errorString()),
  343. error = str(reply.error())))
  344. error_message_template = i18n_catalog.i18nc("@info:status", "Unable to send print job to group {cluster_name}.")
  345. message = Message(text=error_message_template.format(
  346. cluster_name = self.getName()))
  347. message.show()
  348. def _showRequestSucceededMessage(self):
  349. confirmation_message_template = i18n_catalog.i18nc(
  350. "@info:status",
  351. "Sent {file_name} to group {cluster_name}."
  352. )
  353. file_name = os.path.basename(self._file_name).split(".")[0]
  354. message_text = confirmation_message_template.format(cluster_name = self.getName(), file_name = file_name)
  355. message = Message(text=message_text)
  356. button_text = i18n_catalog.i18nc("@action:button", "Show print jobs")
  357. button_tooltip = i18n_catalog.i18nc("@info:tooltip", "Opens the print jobs interface in your browser.")
  358. message.addAction("open_browser", button_text, "globe", button_tooltip)
  359. message.actionTriggered.connect(self._onMessageActionTriggered)
  360. message.show()
  361. def setPrintJobs(self, print_jobs):
  362. #TODO: hack, last seen messes up the check, so drop it.
  363. for job in print_jobs:
  364. del job["last_seen"]
  365. # Strip any extensions
  366. job["name"] = self._removeGcodeExtension(job["name"])
  367. if self._print_jobs != print_jobs:
  368. old_print_jobs = self._print_jobs
  369. self._print_jobs = print_jobs
  370. self._notifyFinishedPrintJobs(old_print_jobs, print_jobs)
  371. self._notifyConfigurationChangeRequired(old_print_jobs, print_jobs)
  372. # Yes, this is a hacky way of doing it, but it's quick and the API doesn't give the print job per printer
  373. # for some reason. ugh.
  374. self._print_job_by_printer_uuid = {}
  375. self._print_job_by_uuid = {}
  376. for print_job in print_jobs:
  377. if "printer_uuid" in print_job and print_job["printer_uuid"] is not None:
  378. self._print_job_by_printer_uuid[print_job["printer_uuid"]] = print_job
  379. self._print_job_by_uuid[print_job["uuid"]] = print_job
  380. self.printJobsChanged.emit()
  381. def _removeGcodeExtension(self, name):
  382. parts = name.split(".")
  383. if parts[-1].upper() == "GZ":
  384. parts = parts[:-1]
  385. if parts[-1].upper() == "GCODE":
  386. parts = parts[:-1]
  387. return ".".join(parts)
  388. def _notifyFinishedPrintJobs(self, old_print_jobs, new_print_jobs):
  389. """Notify the user when any of their print jobs have just completed.
  390. Arguments:
  391. old_print_jobs -- the previous list of print job status information as returned by the cluster REST API.
  392. new_print_jobs -- the current list of print job status information as returned by the cluster REST API.
  393. """
  394. if old_print_jobs is None:
  395. return
  396. username = self.__get_username()
  397. if username is None:
  398. return
  399. our_old_print_jobs = self.__filterOurPrintJobs(old_print_jobs)
  400. our_old_not_finished_print_jobs = [pj for pj in our_old_print_jobs if pj["status"] != "wait_cleanup"]
  401. our_new_print_jobs = self.__filterOurPrintJobs(new_print_jobs)
  402. our_new_finished_print_jobs = [pj for pj in our_new_print_jobs if pj["status"] == "wait_cleanup"]
  403. old_not_finished_print_job_uuids = set([pj["uuid"] for pj in our_old_not_finished_print_jobs])
  404. for print_job in our_new_finished_print_jobs:
  405. if print_job["uuid"] in old_not_finished_print_job_uuids:
  406. printer_name = self.__getPrinterNameFromUuid(print_job["printer_uuid"])
  407. if printer_name is None:
  408. printer_name = i18n_catalog.i18nc("@label Printer name", "Unknown")
  409. message_text = (i18n_catalog.i18nc("@info:status",
  410. "Printer '{printer_name}' has finished printing '{job_name}'.")
  411. .format(printer_name=printer_name, job_name=print_job["name"]))
  412. message = Message(text=message_text, title=i18n_catalog.i18nc("@info:status", "Print finished"))
  413. Application.getInstance().showMessage(message)
  414. Application.getInstance().showToastMessage(
  415. i18n_catalog.i18nc("@info:status", "Print finished"),
  416. message_text)
  417. def __filterOurPrintJobs(self, print_jobs):
  418. username = self.__get_username()
  419. return [print_job for print_job in print_jobs if print_job["owner"] == username]
  420. def _notifyConfigurationChangeRequired(self, old_print_jobs, new_print_jobs):
  421. if old_print_jobs is None:
  422. return
  423. old_change_required_print_jobs = self.__filterConfigChangePrintJobs(self.__filterOurPrintJobs(old_print_jobs))
  424. new_change_required_print_jobs = self.__filterConfigChangePrintJobs(self.__filterOurPrintJobs(new_print_jobs))
  425. old_change_required_print_job_uuids = set([pj["uuid"] for pj in old_change_required_print_jobs])
  426. for print_job in new_change_required_print_jobs:
  427. if print_job["uuid"] not in old_change_required_print_job_uuids:
  428. printer_name = self.__getPrinterNameFromUuid(print_job["assigned_to"])
  429. if printer_name is None:
  430. # don't report on yet unknown printers
  431. continue
  432. message_text = (i18n_catalog.i18n("{printer_name} is reserved to print '{job_name}'. Please change the printer's configuration to match the job, for it to start printing.")
  433. .format(printer_name=printer_name, job_name=print_job["name"]))
  434. message = Message(text=message_text, title=i18n_catalog.i18nc("@label:status", "Action required"))
  435. Application.getInstance().showMessage(message)
  436. Application.getInstance().showToastMessage(
  437. i18n_catalog.i18nc("@label:status", "Action required"),
  438. message_text)
  439. def __filterConfigChangePrintJobs(self, print_jobs):
  440. return filter(self.__isConfigurationChangeRequiredPrintJob, print_jobs)
  441. def __isConfigurationChangeRequiredPrintJob(self, print_job):
  442. if print_job["status"] == "queued":
  443. changes_required = print_job.get("configuration_changes_required", [])
  444. return len(changes_required) != 0
  445. return False
  446. def __getPrinterNameFromUuid(self, printer_uuid):
  447. for printer in self._printers:
  448. if printer["uuid"] == printer_uuid:
  449. return printer["friendly_name"]
  450. return None
  451. def setPrinters(self, printers):
  452. if self._printers != printers:
  453. self._connected_printers_type_count = []
  454. printers_count = {}
  455. self._printers = printers
  456. self._printers_dict = dict((p["unique_name"], p) for p in printers) # for easy lookup by unique_name
  457. for printer in printers:
  458. variant = printer["machine_variant"]
  459. if variant in printers_count:
  460. printers_count[variant] += 1
  461. else:
  462. printers_count[variant] = 1
  463. for type in printers_count:
  464. self._connected_printers_type_count.append({"machine_type": type, "count": printers_count[type]})
  465. self.printersChanged.emit()
  466. @pyqtProperty("QVariantList", notify=printersChanged)
  467. def connectedPrintersTypeCount(self):
  468. return self._connected_printers_type_count
  469. @pyqtProperty("QVariantList", notify=printersChanged)
  470. def connectedPrinters(self):
  471. return self._printers
  472. @pyqtProperty(int, notify=printJobsChanged)
  473. def numJobsPrinting(self):
  474. num_jobs_printing = 0
  475. for job in self._print_jobs:
  476. if job["status"] in ["printing", "wait_cleanup", "sent_to_printer", "pre_print", "post_print"]:
  477. num_jobs_printing += 1
  478. return num_jobs_printing
  479. @pyqtProperty(int, notify=printJobsChanged)
  480. def numJobsQueued(self):
  481. num_jobs_queued = 0
  482. for job in self._print_jobs:
  483. if job["status"] == "queued":
  484. num_jobs_queued += 1
  485. return num_jobs_queued
  486. @pyqtProperty("QVariantMap", notify=printJobsChanged)
  487. def printJobsByUUID(self):
  488. return self._print_job_by_uuid
  489. @pyqtProperty("QVariantMap", notify=printJobsChanged)
  490. def printJobsByPrinterUUID(self):
  491. return self._print_job_by_printer_uuid
  492. @pyqtProperty("QVariantList", notify=printJobsChanged)
  493. def printJobs(self):
  494. return self._print_jobs
  495. @pyqtProperty("QVariantList", notify=printersChanged)
  496. def printers(self):
  497. return [self._automatic_printer, ] + self._printers
  498. @pyqtSlot(str, str)
  499. def selectPrinter(self, unique_name, friendly_name):
  500. self.stopCamera()
  501. self._selected_printer = {"unique_name": unique_name, "friendly_name": friendly_name}
  502. Logger.log("d", "Selected printer: %s %s", friendly_name, unique_name)
  503. # TODO: Probably not the nicest way to do this. This needs to be done better at some point in time.
  504. if unique_name == "":
  505. self._address = self._master_address
  506. else:
  507. self._address = self._printers_dict[self._selected_printer["unique_name"]]["ip_address"]
  508. self.selectedPrinterChanged.emit()
  509. def _updateJobState(self, job_state):
  510. name = self._selected_printer.get("friendly_name")
  511. if name == "" or name == "Automatic":
  512. # TODO: This is now a bit hacked; If no printer is selected, don't show job state.
  513. if self._job_state != "":
  514. self._job_state = ""
  515. self.jobStateChanged.emit()
  516. else:
  517. if self._job_state != job_state:
  518. self._job_state = job_state
  519. self.jobStateChanged.emit()
  520. @pyqtSlot()
  521. def selectAutomaticPrinter(self):
  522. self.stopCamera()
  523. self._selected_printer = self._automatic_printer
  524. self.selectedPrinterChanged.emit()
  525. @pyqtProperty("QVariant", notify=selectedPrinterChanged)
  526. def selectedPrinterName(self):
  527. return self._selected_printer.get("unique_name", "")
  528. def getPrintJobsUrl(self):
  529. return self._host + "/print_jobs"
  530. def getPrintersUrl(self):
  531. return self._host + "/printers"
  532. def _showProgressMessage(self):
  533. progress_message_template = i18n_catalog.i18nc("@info:progress",
  534. "Sending <filename>{file_name}</filename> to group {cluster_name}")
  535. file_name = os.path.basename(self._file_name).split(".")[0]
  536. self._progress_message = Message(progress_message_template.format(file_name = file_name, cluster_name = self.getName()), 0, False, -1)
  537. self._progress_message.addAction("Abort", i18n_catalog.i18nc("@action:button", "Cancel"), None, "")
  538. self._progress_message.actionTriggered.connect(self._onMessageActionTriggered)
  539. self._progress_message.show()
  540. def _addUserAgentHeader(self, request):
  541. request.setRawHeader(b"User-agent", b"CuraPrintClusterOutputDevice Plugin")
  542. def _cleanupRequest(self):
  543. self._request = None
  544. self._stage = OutputStage.ready
  545. self._file_name = None
  546. def _onFinished(self, reply):
  547. super()._onFinished(reply)
  548. reply_url = reply.url().toString()
  549. status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute)
  550. if status_code == 500:
  551. Logger.log("w", "Request to {url} returned a 500.".format(url = reply_url))
  552. return
  553. if reply.error() == QNetworkReply.ContentOperationNotPermittedError:
  554. # It was probably "/api/v1/materials" for legacy UM3
  555. return
  556. if reply.error() == QNetworkReply.ContentNotFoundError:
  557. # It was probably "/api/v1/print_job" for legacy UM3
  558. return
  559. if reply.operation() == QNetworkAccessManager.PostOperation:
  560. if self._cluster_api_prefix + "print_jobs" in reply_url:
  561. self._finishedPrintJobPostRequest(reply)
  562. return
  563. # We need to do this check *after* we process the post operation!
  564. # If the sending of g-code is cancelled by the user it will result in an error, but we do need to handle this.
  565. if reply.error() != QNetworkReply.NoError:
  566. Logger.log("e", "After requesting [%s] we got a network error [%s]. Not processing anything...", reply_url, reply.error())
  567. return
  568. elif reply.operation() == QNetworkAccessManager.GetOperation:
  569. if self._cluster_api_prefix + "print_jobs" in reply_url:
  570. self._finishedPrintJobsRequest(reply)
  571. elif self._cluster_api_prefix + "printers" in reply_url:
  572. self._finishedPrintersRequest(reply)
  573. @pyqtSlot()
  574. def openPrintJobControlPanel(self):
  575. Logger.log("d", "Opening print job control panel...")
  576. QDesktopServices.openUrl(QUrl(self.getPrintJobsUrl()))
  577. @pyqtSlot()
  578. def openPrinterControlPanel(self):
  579. Logger.log("d", "Opening printer control panel...")
  580. QDesktopServices.openUrl(QUrl(self.getPrintersUrl()))
  581. def _onMessageActionTriggered(self, message, action):
  582. if action == "open_browser":
  583. QDesktopServices.openUrl(QUrl(self.getPrintJobsUrl()))
  584. if action == "Abort":
  585. Logger.log("d", "User aborted sending print to remote.")
  586. self._progress_message.hide()
  587. self._compressing_print = False
  588. if self._reply:
  589. self._reply.abort()
  590. self._stage = OutputStage.ready
  591. Application.getInstance().getController().setActiveStage("PrepareStage")
  592. @pyqtSlot(int, result=str)
  593. def formatDuration(self, seconds):
  594. return Duration(seconds).getDisplayString(DurationFormat.Format.Short)
  595. ## For cluster below
  596. def _get_plugin_directory_name(self):
  597. current_file_absolute_path = os.path.realpath(__file__)
  598. directory_path = os.path.dirname(current_file_absolute_path)
  599. _, directory_name = os.path.split(directory_path)
  600. return directory_name
  601. @property
  602. def _plugin_path(self):
  603. return PluginRegistry.getInstance().getPluginPath(self._get_plugin_directory_name())