NetworkPrinterOutputDevice.py 46 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867
  1. from UM.i18n import i18nCatalog
  2. from UM.Application import Application
  3. from UM.Logger import Logger
  4. from UM.Signal import signalemitter
  5. from UM.Message import Message
  6. import UM.Settings
  7. from cura.PrinterOutputDevice import PrinterOutputDevice, ConnectionState
  8. import cura.Settings.ExtruderManager
  9. from PyQt5.QtNetwork import QHttpMultiPart, QHttpPart, QNetworkRequest, QNetworkAccessManager, QNetworkReply
  10. from PyQt5.QtCore import QUrl, QTimer, pyqtSignal, pyqtProperty, pyqtSlot, QCoreApplication
  11. from PyQt5.QtGui import QImage
  12. from PyQt5.QtWidgets import QMessageBox
  13. import json
  14. import os
  15. import gzip
  16. import zlib
  17. from time import time
  18. from time import sleep
  19. i18n_catalog = i18nCatalog("cura")
  20. from enum import IntEnum
  21. class AuthState(IntEnum):
  22. NotAuthenticated = 1
  23. AuthenticationRequested = 2
  24. Authenticated = 3
  25. AuthenticationDenied = 4
  26. ## Network connected (wifi / lan) printer that uses the Ultimaker API
  27. @signalemitter
  28. class NetworkPrinterOutputDevice(PrinterOutputDevice):
  29. def __init__(self, key, address, properties):
  30. super().__init__(key)
  31. self._address = address
  32. self._key = key
  33. self._properties = properties # Properties dict as provided by zero conf
  34. self._gcode = None
  35. self._print_finished = True # _print_finsihed == False means we're halfway in a print
  36. self._use_gzip = True # Should we use g-zip compression before sending the data?
  37. # This holds the full JSON file that was received from the last request.
  38. # The JSON looks like:
  39. # {'led': {'saturation': 0.0, 'brightness': 100.0, 'hue': 0.0},
  40. # 'beep': {}, 'network': {'wifi_networks': [],
  41. # 'ethernet': {'connected': True, 'enabled': True},
  42. # 'wifi': {'ssid': 'xxxx', 'connected': False, 'enabled': False}},
  43. # 'diagnostics': {},
  44. # 'bed': {'temperature': {'target': 60.0, 'current': 44.4}},
  45. # 'heads': [{'max_speed': {'z': 40.0, 'y': 300.0, 'x': 300.0},
  46. # 'position': {'z': 20.0, 'y': 6.0, 'x': 180.0},
  47. # 'fan': 0.0,
  48. # 'jerk': {'z': 0.4, 'y': 20.0, 'x': 20.0},
  49. # 'extruders': [
  50. # {'feeder': {'max_speed': 45.0, 'jerk': 5.0, 'acceleration': 3000.0},
  51. # 'active_material': {'GUID': 'xxxxxxx', 'length_remaining': -1.0},
  52. # 'hotend': {'temperature': {'target': 0.0, 'current': 22.8}, 'id': 'AA 0.4'}},
  53. # {'feeder': {'max_speed': 45.0, 'jerk': 5.0, 'acceleration': 3000.0},
  54. # 'active_material': {'GUID': 'xxxx', 'length_remaining': -1.0},
  55. # 'hotend': {'temperature': {'target': 0.0, 'current': 22.8}, 'id': 'BB 0.4'}}],
  56. # 'acceleration': 3000.0}],
  57. # 'status': 'printing'}
  58. self._json_printer_state = {}
  59. ## Todo: Hardcoded value now; we should probably read this from the machine file.
  60. ## It's okay to leave this for now, as this plugin is um3 only (and has 2 extruders by definition)
  61. self._num_extruders = 2
  62. # These are reinitialised here (from PrinterOutputDevice) to match the new _num_extruders
  63. self._hotend_temperatures = [0] * self._num_extruders
  64. self._target_hotend_temperatures = [0] * self._num_extruders
  65. self._material_ids = [""] * self._num_extruders
  66. self._hotend_ids = [""] * self._num_extruders
  67. self._api_version = "1"
  68. self._api_prefix = "/api/v" + self._api_version + "/"
  69. self.setPriority(2) # Make sure the output device gets selected above local file output
  70. self.setName(key)
  71. self.setShortDescription(i18n_catalog.i18nc("@action:button", "Print over network"))
  72. self.setDescription(i18n_catalog.i18nc("@properties:tooltip", "Print over network"))
  73. self.setIconName("print")
  74. self._manager = None
  75. self._post_request = None
  76. self._post_reply = None
  77. self._post_multi_part = None
  78. self._post_part = None
  79. self._material_multi_part = None
  80. self._material_part = None
  81. self._progress_message = None
  82. self._error_message = None
  83. self._connection_message = None
  84. self._update_timer = QTimer()
  85. self._update_timer.setInterval(2000) # TODO; Add preference for update interval
  86. self._update_timer.setSingleShot(False)
  87. self._update_timer.timeout.connect(self._update)
  88. self._camera_timer = QTimer()
  89. self._camera_timer.setInterval(2000) # Todo: Add preference for camera update interval
  90. self._camera_timer.setSingleShot(False)
  91. self._camera_timer.timeout.connect(self._update_camera)
  92. self._camera_image_id = 0
  93. self._authentication_counter = 0
  94. self._max_authentication_counter = 5 * 60 # Number of attempts before authentication timed out (5 min)
  95. self._authentication_timer = QTimer()
  96. self._authentication_timer.setInterval(1000) # TODO; Add preference for update interval
  97. self._authentication_timer.setSingleShot(False)
  98. self._authentication_timer.timeout.connect(self._onAuthenticationTimer)
  99. self._authentication_request_active = False
  100. self._authentication_state = AuthState.NotAuthenticated
  101. self._authentication_id = None
  102. self._authentication_key = None
  103. self._authentication_requested_message = Message(i18n_catalog.i18nc("@info:status", "Access to the printer requested. Please approve the request on the printer"), lifetime = 0, dismissable = False, progress = 0)
  104. self._authentication_failed_message = Message(i18n_catalog.i18nc("@info:status", ""))
  105. self._authentication_failed_message.addAction("Retry", i18n_catalog.i18nc("@action:button", "Retry"), None, i18n_catalog.i18nc("@info:tooltip", "Re-send the access request"))
  106. self._authentication_failed_message.actionTriggered.connect(self.requestAuthentication)
  107. self._authentication_succeeded_message = Message(i18n_catalog.i18nc("@info:status", "Access to the printer accepted"))
  108. self._not_authenticated_message = Message(i18n_catalog.i18nc("@info:status", "No access to print with this printer. Unable to send print job."))
  109. self._not_authenticated_message.addAction("Request", i18n_catalog.i18nc("@action:button", "Request Access"), None, i18n_catalog.i18nc("@info:tooltip", "Send access request to the printer"))
  110. self._not_authenticated_message.actionTriggered.connect(self.requestAuthentication)
  111. self._camera_image = QImage()
  112. self._material_post_objects = {}
  113. self._connection_state_before_timeout = None
  114. self._last_response_time = time()
  115. self._response_timeout_time = 10
  116. self._recreate_network_manager_time = 30 # If we have no connection, re-create network manager every 30 sec.
  117. self._recreate_network_manager_count = 1
  118. self._send_gcode_start = time() # Time when the sending of the g-code started.
  119. self._last_command = ""
  120. def _onNetworkAccesibleChanged(self, accessible):
  121. Logger.log("d", "Network accessible state changed to: %s", accessible)
  122. def _onAuthenticationTimer(self):
  123. self._authentication_counter += 1
  124. self._authentication_requested_message.setProgress(self._authentication_counter / self._max_authentication_counter * 100)
  125. if self._authentication_counter > self._max_authentication_counter:
  126. self._authentication_timer.stop()
  127. Logger.log("i", "Authentication timer ended. Setting authentication to denied")
  128. self.setAuthenticationState(AuthState.AuthenticationDenied)
  129. def _onAuthenticationRequired(self, reply, authenticator):
  130. if self._authentication_id is not None and self._authentication_key is not None:
  131. Logger.log("d", "Authentication was required. Setting up authenticator.")
  132. authenticator.setUser(self._authentication_id)
  133. authenticator.setPassword(self._authentication_key)
  134. def getProperties(self):
  135. return self._properties
  136. ## Get the unique key of this machine
  137. # \return key String containing the key of the machine.
  138. @pyqtSlot(result = str)
  139. def getKey(self):
  140. return self._key
  141. ## Name of the printer (as returned from the zeroConf properties)
  142. @pyqtProperty(str, constant = True)
  143. def name(self):
  144. return self._properties.get(b"name", b"").decode("utf-8")
  145. ## Firmware version (as returned from the zeroConf properties)
  146. @pyqtProperty(str, constant=True)
  147. def firmwareVersion(self):
  148. return self._properties.get(b"firmware_version", b"").decode("utf-8")
  149. ## IPadress of this printer
  150. @pyqtProperty(str, constant=True)
  151. def ipAddress(self):
  152. return self._address
  153. def _update_camera(self):
  154. if not self._manager.networkAccessible():
  155. return
  156. ## Request new image
  157. url = QUrl("http://" + self._address + ":8080/?action=snapshot")
  158. image_request = QNetworkRequest(url)
  159. self._manager.get(image_request)
  160. ## Set the authentication state.
  161. # \param auth_state \type{AuthState} Enum value representing the new auth state
  162. def setAuthenticationState(self, auth_state):
  163. if auth_state == AuthState.AuthenticationRequested:
  164. Logger.log("d", "Authentication state changed to authentication requested.")
  165. self.setAcceptsCommands(False)
  166. self.setConnectionText(i18n_catalog.i18nc("@info:status", "Connected over the network to {0}. Please approve the access request on the printer.").format(self.name))
  167. self._authentication_requested_message.show()
  168. self._authentication_request_active = True
  169. self._authentication_timer.start() # Start timer so auth will fail after a while.
  170. elif auth_state == AuthState.Authenticated:
  171. Logger.log("d", "Authentication state changed to authenticated")
  172. self.setAcceptsCommands(True)
  173. self.setConnectionText(i18n_catalog.i18nc("@info:status", "Connected over the network to {0}.").format(self.name))
  174. self._authentication_requested_message.hide()
  175. if self._authentication_request_active:
  176. self._authentication_succeeded_message.show()
  177. # Stop waiting for a response
  178. self._authentication_timer.stop()
  179. self._authentication_counter = 0
  180. # Once we are authenticated we need to send all material profiles.
  181. self.sendMaterialProfiles()
  182. elif auth_state == AuthState.AuthenticationDenied:
  183. Logger.log("d", "Authentication state changed to authentication denied")
  184. self.setAcceptsCommands(False)
  185. self.setConnectionText(i18n_catalog.i18nc("@info:status", "Connected over the network to {0}. No access to control the printer.").format(self.name))
  186. self._authentication_requested_message.hide()
  187. if self._authentication_request_active:
  188. if self._authentication_timer.remainingTime() > 0:
  189. self._authentication_failed_message.setText(i18n_catalog.i18nc("@info:status", "Access request was denied on the printer."))
  190. else:
  191. self._authentication_failed_message.setText(i18n_catalog.i18nc("@info:status", "Access request failed due to a timeout."))
  192. self._authentication_failed_message.show()
  193. self._authentication_request_active = False
  194. # Stop waiting for a response
  195. self._authentication_timer.stop()
  196. self._authentication_counter = 0
  197. if auth_state != self._authentication_state:
  198. self._authentication_state = auth_state
  199. self.authenticationStateChanged.emit()
  200. authenticationStateChanged = pyqtSignal()
  201. @pyqtProperty(int, notify = authenticationStateChanged)
  202. def authenticationState(self):
  203. return self._authentication_state
  204. @pyqtSlot()
  205. def requestAuthentication(self, message_id = None, action_id = "Retry"):
  206. if action_id == "Request" or action_id == "Retry":
  207. self._authentication_failed_message.hide()
  208. self._not_authenticated_message.hide()
  209. self._authentication_state = AuthState.NotAuthenticated
  210. self._authentication_counter = 0
  211. self._authentication_requested_message.setProgress(0)
  212. self._authentication_id = None
  213. self._authentication_key = None
  214. self._createNetworkManager() # Re-create network manager to force re-authentication.
  215. ## Request data from the connected device.
  216. def _update(self):
  217. if self._last_response_time:
  218. time_since_last_response = time() - self._last_response_time
  219. else:
  220. time_since_last_response = 0
  221. # Connection is in timeout, check if we need to re-start the connection.
  222. # Sometimes the qNetwork manager incorrectly reports the network status on Mac & Windows.
  223. # Re-creating the QNetworkManager seems to fix this issue.
  224. if self._last_response_time and self._connection_state_before_timeout:
  225. if time_since_last_response > self._recreate_network_manager_time * self._recreate_network_manager_count:
  226. self._recreate_network_manager_count += 1
  227. # It can happen that we had a very long timeout (multiple times the recreate time).
  228. # In that case we should jump through the point that the next update won't be right away.
  229. while time_since_last_response - self._recreate_network_manager_time * self._recreate_network_manager_count > self._recreate_network_manager_time:
  230. self._recreate_network_manager_count += 1
  231. Logger.log("d", "Timeout lasted over 30 seconds (%.1fs), re-checking connection.", time_since_last_response)
  232. self._createNetworkManager()
  233. return
  234. # Check if we have an connection in the first place.
  235. if not self._manager.networkAccessible():
  236. if not self._connection_state_before_timeout:
  237. Logger.log("d", "The network connection seems to be disabled. Going into timeout mode")
  238. self._connection_state_before_timeout = self._connection_state
  239. self.setConnectionState(ConnectionState.error)
  240. self._connection_message = Message(i18n_catalog.i18nc("@info:status",
  241. "The connection with the network was lost."))
  242. self._connection_message.show()
  243. # Check if we were uploading something. Abort if this is the case.
  244. # Some operating systems handle this themselves, others give weird issues.
  245. try:
  246. if self._post_reply:
  247. Logger.log("d", "Stopping post upload because the connection was lost.")
  248. try:
  249. self._post_reply.uploadProgress.disconnect(self._onUploadProgress)
  250. except TypeError:
  251. pass # The disconnection can fail on mac in some cases. Ignore that.
  252. self._post_reply.abort()
  253. self._progress_message.hide()
  254. except RuntimeError:
  255. self._post_reply = None # It can happen that the wrapped c++ object is already deleted.
  256. return
  257. else:
  258. if not self._connection_state_before_timeout:
  259. self._recreate_network_manager_count = 1
  260. # Check that we aren't in a timeout state
  261. if self._last_response_time and not self._connection_state_before_timeout:
  262. if time_since_last_response > self._response_timeout_time:
  263. # Go into timeout state.
  264. Logger.log("d", "We did not receive a response for %0.1f seconds, so it seems the printer is no longer accessible.", time_since_last_response)
  265. self._connection_state_before_timeout = self._connection_state
  266. self._connection_message = Message(i18n_catalog.i18nc("@info:status", "The connection with the printer was lost. Check your printer to see if it is connected."))
  267. self._connection_message.show()
  268. # Check if we were uploading something. Abort if this is the case.
  269. # Some operating systems handle this themselves, others give weird issues.
  270. try:
  271. if self._post_reply:
  272. Logger.log("d", "Stopping post upload because the connection was lost.")
  273. try:
  274. self._post_reply.uploadProgress.disconnect(self._onUploadProgress)
  275. except TypeError:
  276. pass # The disconnection can fail on mac in some cases. Ignore that.
  277. self._post_reply.abort()
  278. self._progress_message.hide()
  279. except RuntimeError:
  280. self._post_reply = None # It can happen that the wrapped c++ object is already deleted.
  281. self.setConnectionState(ConnectionState.error)
  282. return
  283. if self._authentication_state == AuthState.NotAuthenticated:
  284. self._verifyAuthentication() # We don't know if we are authenticated; check if we have correct auth.
  285. elif self._authentication_state == AuthState.AuthenticationRequested:
  286. self._checkAuthentication() # We requested authentication at some point. Check if we got permission.
  287. ## Request 'general' printer data
  288. url = QUrl("http://" + self._address + self._api_prefix + "printer")
  289. printer_request = QNetworkRequest(url)
  290. self._manager.get(printer_request)
  291. ## Request print_job data
  292. url = QUrl("http://" + self._address + self._api_prefix + "print_job")
  293. print_job_request = QNetworkRequest(url)
  294. self._manager.get(print_job_request)
  295. def _createNetworkManager(self):
  296. if self._manager:
  297. self._manager.finished.disconnect(self._onFinished)
  298. self._manager.networkAccessibleChanged.disconnect(self._onNetworkAccesibleChanged)
  299. self._manager.authenticationRequired.disconnect(self._onAuthenticationRequired)
  300. self._manager = QNetworkAccessManager()
  301. self._manager.finished.connect(self._onFinished)
  302. self._manager.authenticationRequired.connect(self._onAuthenticationRequired)
  303. self._manager.networkAccessibleChanged.connect(self._onNetworkAccesibleChanged) # for debug purposes
  304. ## Convenience function that gets information from the received json data and converts it to the right internal
  305. # values / variables
  306. def _spliceJSONData(self):
  307. # Check for hotend temperatures
  308. for index in range(0, self._num_extruders):
  309. temperature = self._json_printer_state["heads"][0]["extruders"][index]["hotend"]["temperature"]["current"]
  310. self._setHotendTemperature(index, temperature)
  311. try:
  312. material_id = self._json_printer_state["heads"][0]["extruders"][index]["active_material"]["GUID"]
  313. except KeyError:
  314. material_id = ""
  315. self._setMaterialId(index, material_id)
  316. try:
  317. hotend_id = self._json_printer_state["heads"][0]["extruders"][index]["hotend"]["id"]
  318. except KeyError:
  319. hotend_id = ""
  320. self._setHotendId(index, hotend_id)
  321. bed_temperature = self._json_printer_state["bed"]["temperature"]["current"]
  322. self._setBedTemperature(bed_temperature)
  323. head_x = self._json_printer_state["heads"][0]["position"]["x"]
  324. head_y = self._json_printer_state["heads"][0]["position"]["y"]
  325. head_z = self._json_printer_state["heads"][0]["position"]["z"]
  326. self._updateHeadPosition(head_x, head_y, head_z)
  327. self._updatePrinterState(self._json_printer_state["status"])
  328. def close(self):
  329. self._updateJobState("")
  330. self.setConnectionState(ConnectionState.closed)
  331. if self._progress_message:
  332. self._progress_message.hide()
  333. # Reset authentication state
  334. self._authentication_requested_message.hide()
  335. self._authentication_state = AuthState.NotAuthenticated
  336. self._authentication_counter = 0
  337. self._authentication_timer.stop()
  338. self._authentication_requested_message.hide()
  339. self._authentication_failed_message.hide()
  340. self._authentication_succeeded_message.hide()
  341. # Reset stored material & hotend data.
  342. self._material_ids = [""] * self._num_extruders
  343. self._hotend_ids = [""] * self._num_extruders
  344. if self._error_message:
  345. self._error_message.hide()
  346. # Reset timeout state
  347. self._connection_state_before_timeout = None
  348. self._last_response_time = time()
  349. # Stop update timers
  350. self._update_timer.stop()
  351. self._camera_timer.stop()
  352. def requestWrite(self, node, file_name = None, filter_by_machine = False):
  353. if self._progress != 0:
  354. self._error_message = Message(i18n_catalog.i18nc("@info:status", "Unable to start a new print job because the printer is busy. Please check the printer."))
  355. self._error_message.show()
  356. return
  357. if self._printer_state != "idle":
  358. self._error_message = Message(
  359. i18n_catalog.i18nc("@info:status", "Unable to start a new print job, printer is busy. Current printer status is %s.") % self._printer_state)
  360. self._error_message.show()
  361. return
  362. elif self._authentication_state != AuthState.Authenticated:
  363. self._not_authenticated_message.show()
  364. Logger.log("d", "Attempting to perform an action without authentication. Auth state is %s", self._authentication_state)
  365. return
  366. Application.getInstance().showPrintMonitor.emit(True)
  367. self._print_finished = True
  368. self._gcode = getattr(Application.getInstance().getController().getScene(), "gcode_list")
  369. print_information = Application.getInstance().getPrintInformation()
  370. # Check if PrintCores / materials are loaded at all. Any failure in these results in an Error.
  371. for index in range(0, self._num_extruders):
  372. if print_information.materialLengths[index] != 0:
  373. if self._json_printer_state["heads"][0]["extruders"][index]["hotend"]["id"] == "":
  374. Logger.log("e", "No cartridge loaded in slot %s, unable to start print", index + 1)
  375. self._error_message = Message(
  376. i18n_catalog.i18nc("@info:status", "Unable to start a new print job. No PrinterCore loaded in slot {0}".format(index + 1)))
  377. self._error_message.show()
  378. return
  379. if self._json_printer_state["heads"][0]["extruders"][index]["active_material"]["GUID"] == "":
  380. Logger.log("e", "No material loaded in slot %s, unable to start print", index + 1)
  381. self._error_message = Message(
  382. i18n_catalog.i18nc("@info:status",
  383. "Unable to start a new print job. No material loaded in slot {0}".format(index + 1)))
  384. self._error_message.show()
  385. return
  386. warnings = [] # There might be multiple things wrong. Keep a list of all the stuff we need to warn about.
  387. for index in range(0, self._num_extruders):
  388. # Check if there is enough material. Any failure in these results in a warning.
  389. material_length = self._json_printer_state["heads"][0]["extruders"][index]["active_material"]["length_remaining"]
  390. if material_length != -1 and print_information.materialLengths[index] > material_length:
  391. Logger.log("w", "Printer reports that there is not enough material left for extruder %s. We need %s and the printer has %s", index + 1, print_information.materialLengths[index], material_length)
  392. warnings.append(i18n_catalog.i18nc("@label", "Not enough material for spool {0}.").format(index+1))
  393. # Check if the right cartridges are loaded. Any failure in these results in a warning.
  394. extruder_manager = cura.Settings.ExtruderManager.getInstance()
  395. if print_information.materialLengths[index] != 0:
  396. variant = extruder_manager.getExtruderStack(index).findContainer({"type": "variant"})
  397. core_name = self._json_printer_state["heads"][0]["extruders"][index]["hotend"]["id"]
  398. if variant:
  399. if variant.getName() != core_name:
  400. Logger.log("w", "Extruder %s has a different Cartridge (%s) as Cura (%s)", index + 1, core_name, variant.getName())
  401. warnings.append(i18n_catalog.i18nc("@label", "Different PrintCore (Cura: {0}, Printer: {1}) selected for extruder {2}".format(variant.getName(), core_name, index + 1)))
  402. material = extruder_manager.getExtruderStack(index).findContainer({"type": "material"})
  403. if material:
  404. remote_material_guid = self._json_printer_state["heads"][0]["extruders"][index]["active_material"]["GUID"]
  405. if material.getMetaDataEntry("GUID") != remote_material_guid:
  406. Logger.log("w", "Extruder %s has a different material (%s) as Cura (%s)", index + 1,
  407. remote_material_guid,
  408. material.getMetaDataEntry("GUID"))
  409. remote_materials = UM.Settings.ContainerRegistry.getInstance().findInstanceContainers(type = "material", GUID = remote_material_guid, read_only = True)
  410. remote_material_name = "Unknown"
  411. if remote_materials:
  412. remote_material_name = remote_materials[0].getName()
  413. warnings.append(i18n_catalog.i18nc("@label", "Different material (Cura: {0}, Printer: {1}) selected for extruder {2}").format(material.getName(), remote_material_name, index + 1))
  414. if warnings:
  415. text = i18n_catalog.i18nc("@label", "Are you sure you wish to print with the selected configuration?")
  416. informative_text = i18n_catalog.i18nc("@label", "There is a mismatch between the configuration of the printer and Cura. "
  417. "For the best result, always slice for the PrintCores and materials that are inserted in your printer.")
  418. detailed_text = ""
  419. for warning in warnings:
  420. detailed_text += warning + "\n"
  421. Application.getInstance().messageBox(i18n_catalog.i18nc("@window:title", "Mismatched configuration"),
  422. text,
  423. informative_text,
  424. detailed_text,
  425. buttons=QMessageBox.Yes + QMessageBox.No,
  426. icon=QMessageBox.Question,
  427. callback=self._configurationMismatchMessageCallback
  428. )
  429. return
  430. self.startPrint()
  431. def _configurationMismatchMessageCallback(self, button):
  432. if button == QMessageBox.Yes:
  433. self.startPrint()
  434. else:
  435. Application.getInstance().showPrintMonitor.emit(False)
  436. def isConnected(self):
  437. return self._connection_state != ConnectionState.closed and self._connection_state != ConnectionState.error
  438. ## Start requesting data from printer
  439. def connect(self):
  440. self.close() # Ensure that previous connection (if any) is killed.
  441. self._createNetworkManager()
  442. self.setConnectionState(ConnectionState.connecting)
  443. self._update() # Manually trigger the first update, as we don't want to wait a few secs before it starts.
  444. self._update_camera()
  445. Logger.log("d", "Connection with printer %s with ip %s started", self._key, self._address)
  446. ## Check if this machine was authenticated before.
  447. self._authentication_id = Application.getInstance().getGlobalContainerStack().getMetaDataEntry("network_authentication_id", None)
  448. self._authentication_key = Application.getInstance().getGlobalContainerStack().getMetaDataEntry("network_authentication_key", None)
  449. self._update_timer.start()
  450. self._camera_timer.start()
  451. ## Stop requesting data from printer
  452. def disconnect(self):
  453. Logger.log("d", "Connection with printer %s with ip %s stopped", self._key, self._address)
  454. self.close()
  455. newImage = pyqtSignal()
  456. @pyqtProperty(QUrl, notify = newImage)
  457. def cameraImage(self):
  458. self._camera_image_id += 1
  459. # There is an image provider that is called "camera". In order to ensure that the image qml object, that
  460. # requires a QUrl to function, updates correctly we add an increasing number. This causes to see the QUrl
  461. # as new (instead of relying on cached version and thus forces an update.
  462. temp = "image://camera/" + str(self._camera_image_id)
  463. return QUrl(temp, QUrl.TolerantMode)
  464. def getCameraImage(self):
  465. return self._camera_image
  466. def _setJobState(self, job_state):
  467. self._last_command = job_state
  468. url = QUrl("http://" + self._address + self._api_prefix + "print_job/state")
  469. put_request = QNetworkRequest(url)
  470. put_request.setHeader(QNetworkRequest.ContentTypeHeader, "application/json")
  471. data = "{\"target\": \"%s\"}" % job_state
  472. self._manager.put(put_request, data.encode())
  473. ## Convenience function to get the username from the OS.
  474. # The code was copied from the getpass module, as we try to use as little dependencies as possible.
  475. def _getUserName(self):
  476. for name in ("LOGNAME", "USER", "LNAME", "USERNAME"):
  477. user = os.environ.get(name)
  478. if user:
  479. return user
  480. return "Unknown User" # Couldn't find out username.
  481. ## Attempt to start a new print.
  482. # This function can fail to actually start a print due to not being authenticated or another print already
  483. # being in progress.
  484. def startPrint(self):
  485. try:
  486. self._send_gcode_start = time()
  487. self._progress_message = Message(i18n_catalog.i18nc("@info:status", "Sending data to printer"), 0, False, -1)
  488. self._progress_message.show()
  489. Logger.log("d", "Started sending g-code to remote printer.")
  490. ## Mash the data into single string
  491. byte_array_file_data = b""
  492. for line in self._gcode:
  493. if self._use_gzip:
  494. byte_array_file_data += gzip.compress(line.encode("utf-8"))
  495. QCoreApplication.processEvents() # Ensure that the GUI does not freeze.
  496. # Pretend that this is a response, as zipping might take a bit of time.
  497. self._last_response_time = time()
  498. else:
  499. byte_array_file_data += line.encode("utf-8")
  500. if self._use_gzip:
  501. file_name = "%s.gcode.gz" % Application.getInstance().getPrintInformation().jobName
  502. else:
  503. file_name = "%s.gcode" % Application.getInstance().getPrintInformation().jobName
  504. ## Create multi_part request
  505. self._post_multi_part = QHttpMultiPart(QHttpMultiPart.FormDataType)
  506. ## Create part (to be placed inside multipart)
  507. self._post_part = QHttpPart()
  508. self._post_part.setHeader(QNetworkRequest.ContentDispositionHeader,
  509. "form-data; name=\"file\"; filename=\"%s\"" % file_name)
  510. self._post_part.setBody(byte_array_file_data)
  511. self._post_multi_part.append(self._post_part)
  512. url = QUrl("http://" + self._address + self._api_prefix + "print_job")
  513. ## Create the QT request
  514. self._post_request = QNetworkRequest(url)
  515. ## Post request + data
  516. self._post_reply = self._manager.post(self._post_request, self._post_multi_part)
  517. self._post_reply.uploadProgress.connect(self._onUploadProgress)
  518. except IOError:
  519. self._progress_message.hide()
  520. self._error_message = Message(i18n_catalog.i18nc("@info:status", "Unable to send data to printer. Is another job still active?"))
  521. self._error_message.show()
  522. except Exception as e:
  523. self._progress_message.hide()
  524. Logger.log("e", "An exception occurred in network connection: %s" % str(e))
  525. ## Verify if we are authenticated to make requests.
  526. def _verifyAuthentication(self):
  527. url = QUrl("http://" + self._address + self._api_prefix + "auth/verify")
  528. request = QNetworkRequest(url)
  529. self._manager.get(request)
  530. ## Check if the authentication request was allowed by the printer.
  531. def _checkAuthentication(self):
  532. Logger.log("d", "Checking if authentication is correct.")
  533. self._manager.get(QNetworkRequest(QUrl("http://" + self._address + self._api_prefix + "auth/check/" + str(self._authentication_id))))
  534. ## Request a authentication key from the printer so we can be authenticated
  535. def _requestAuthentication(self):
  536. url = QUrl("http://" + self._address + self._api_prefix + "auth/request")
  537. request = QNetworkRequest(url)
  538. request.setHeader(QNetworkRequest.ContentTypeHeader, "application/json")
  539. self.setAuthenticationState(AuthState.AuthenticationRequested)
  540. self._manager.post(request, json.dumps({"application": "Cura-" + Application.getInstance().getVersion(), "user": self._getUserName()}).encode())
  541. ## Send all material profiles to the printer.
  542. def sendMaterialProfiles(self):
  543. for container in UM.Settings.ContainerRegistry.getInstance().findInstanceContainers(type = "material"):
  544. try:
  545. xml_data = container.serialize()
  546. if xml_data == "" or xml_data is None:
  547. continue
  548. material_multi_part = QHttpMultiPart(QHttpMultiPart.FormDataType)
  549. material_part = QHttpPart()
  550. file_name = "none.xml"
  551. material_part.setHeader(QNetworkRequest.ContentDispositionHeader, "form-data; name=\"file\";filename=\"%s\"" % file_name)
  552. material_part.setBody(xml_data.encode())
  553. material_multi_part.append(material_part)
  554. url = QUrl("http://" + self._address + self._api_prefix + "materials")
  555. material_post_request = QNetworkRequest(url)
  556. reply = self._manager.post(material_post_request, material_multi_part)
  557. # Keep reference to material_part, material_multi_part and reply so the garbage collector won't touch them.
  558. self._material_post_objects[id(reply)] = (material_part, material_multi_part, reply)
  559. except NotImplementedError:
  560. # If the material container is not the most "generic" one it can't be serialized an will raise a
  561. # NotImplementedError. We can simply ignore these.
  562. pass
  563. ## Handler for all requests that have finished.
  564. def _onFinished(self, reply):
  565. if reply.error() == QNetworkReply.TimeoutError:
  566. Logger.log("w", "Received a timeout on a request to the printer")
  567. self._connection_state_before_timeout = self._connection_state
  568. # Check if we were uploading something. Abort if this is the case.
  569. # Some operating systems handle this themselves, others give weird issues.
  570. if self._post_reply:
  571. self._post_reply.abort()
  572. self._post_reply.uploadProgress.disconnect(self._onUploadProgress)
  573. Logger.log("d", "Uploading of print failed after %s", time() - self._send_gcode_start)
  574. self._progress_message.hide()
  575. self.setConnectionState(ConnectionState.error)
  576. return
  577. if self._connection_state_before_timeout and reply.error() == QNetworkReply.NoError: # There was a timeout, but we got a correct answer again.
  578. Logger.log("d", "We got a response (%s) from the server after %0.1f of silence. Going back to previous state %s", reply.url().toString(), time() - self._last_response_time, self._connection_state_before_timeout)
  579. self.setConnectionState(self._connection_state_before_timeout)
  580. self._connection_state_before_timeout = None
  581. if reply.error() == QNetworkReply.NoError:
  582. self._last_response_time = time()
  583. status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute)
  584. if not status_code:
  585. if self._connection_state != ConnectionState.error:
  586. Logger.log("d", "A reply from %s did not have status code.", reply.url().toString())
  587. # Received no or empty reply
  588. return
  589. reply_url = reply.url().toString()
  590. if reply.operation() == QNetworkAccessManager.GetOperation:
  591. if "printer" in reply_url: # Status update from printer.
  592. if status_code == 200:
  593. if self._connection_state == ConnectionState.connecting:
  594. self.setConnectionState(ConnectionState.connected)
  595. self._json_printer_state = json.loads(bytes(reply.readAll()).decode("utf-8"))
  596. self._spliceJSONData()
  597. # Hide connection error message if the connection was restored
  598. if self._connection_message:
  599. self._connection_message.hide()
  600. self._connection_message = None
  601. else:
  602. Logger.log("w", "We got an unexpected status (%s) while requesting printer state", status_code)
  603. pass # TODO: Handle errors
  604. elif "print_job" in reply_url: # Status update from print_job:
  605. if status_code == 200:
  606. json_data = json.loads(bytes(reply.readAll()).decode("utf-8"))
  607. progress = json_data["progress"]
  608. ## If progress is 0 add a bit so another print can't be sent.
  609. if progress == 0:
  610. progress += 0.001
  611. elif progress == 1:
  612. self._print_finished = True
  613. else:
  614. self._print_finished = False
  615. self.setProgress(progress * 100)
  616. state = json_data["state"]
  617. # There is a short period after aborting or finishing a print where the printer
  618. # reports a "none" state (but the printer is not ready to receive a print)
  619. # If this happens before the print has reached progress == 1, the print has
  620. # been aborted.
  621. if state == "none" or state == "":
  622. if self._last_command == "abort":
  623. self.setErrorText(i18n_catalog.i18nc("@label:MonitorStatus", "Aborting print..."))
  624. state = "error"
  625. else:
  626. state = "printing"
  627. if state == "wait_cleanup" and self._last_command == "abort":
  628. # Keep showing the "aborted" error state until after the buildplate has been cleaned
  629. self.setErrorText(i18n_catalog.i18nc("@label:MonitorStatus", "Print aborted. Please check the printer"))
  630. state = "error"
  631. self._updateJobState(state)
  632. self.setTimeElapsed(json_data["time_elapsed"])
  633. self.setTimeTotal(json_data["time_total"])
  634. self.setJobName(json_data["name"])
  635. elif status_code == 404:
  636. self.setProgress(0) # No print job found, so there can't be progress or other data.
  637. self._updateJobState("")
  638. self.setErrorText("")
  639. self.setTimeElapsed(0)
  640. self.setTimeTotal(0)
  641. self.setJobName("")
  642. else:
  643. Logger.log("w", "We got an unexpected status (%s) while requesting print job state", status_code)
  644. elif "snapshot" in reply_url: # Status update from image:
  645. if status_code == 200:
  646. self._camera_image.loadFromData(reply.readAll())
  647. self.newImage.emit()
  648. elif "auth/verify" in reply_url: # Answer when requesting authentication
  649. if status_code == 401:
  650. if self._authentication_state != AuthState.AuthenticationRequested:
  651. # Only request a new authentication when we have not already done so.
  652. Logger.log("i", "Not authenticated. Attempting to request authentication")
  653. self._requestAuthentication()
  654. elif status_code == 403:
  655. # If we already had an auth (eg; didn't request one), we only need a single 403 to see it as denied.
  656. if self._authentication_state != AuthState.AuthenticationRequested:
  657. self.setAuthenticationState(AuthState.AuthenticationDenied)
  658. elif status_code == 200:
  659. self.setAuthenticationState(AuthState.Authenticated)
  660. global_container_stack = Application.getInstance().getGlobalContainerStack()
  661. ## Save authentication details.
  662. if global_container_stack:
  663. if "network_authentication_key" in global_container_stack.getMetaData():
  664. global_container_stack.setMetaDataEntry("network_authentication_key", self._authentication_key)
  665. else:
  666. global_container_stack.addMetaDataEntry("network_authentication_key", self._authentication_key)
  667. if "network_authentication_id" in global_container_stack.getMetaData():
  668. global_container_stack.setMetaDataEntry("network_authentication_id", self._authentication_id)
  669. else:
  670. global_container_stack.addMetaDataEntry("network_authentication_id", self._authentication_id)
  671. Application.getInstance().saveStack(global_container_stack) # Force save so we are sure the data is not lost.
  672. Logger.log("i", "Authentication succeeded")
  673. else: # Got a response that we didn't expect, so something went wrong.
  674. Logger.log("w", "While trying to authenticate, we got an unexpected response: %s", reply.attribute(QNetworkRequest.HttpStatusCodeAttribute))
  675. self.setAuthenticationState(AuthState.NotAuthenticated)
  676. elif "auth/check" in reply_url: # Check if we are authenticated (user can refuse this!)
  677. data = json.loads(bytes(reply.readAll()).decode("utf-8"))
  678. if data.get("message", "") == "authorized":
  679. Logger.log("i", "Authentication was approved")
  680. self._verifyAuthentication() # Ensure that the verification is really used and correct.
  681. elif data.get("message", "") == "unauthorized":
  682. Logger.log("i", "Authentication was denied.")
  683. self.setAuthenticationState(AuthState.AuthenticationDenied)
  684. else:
  685. pass
  686. elif reply.operation() == QNetworkAccessManager.PostOperation:
  687. if "/auth/request" in reply_url:
  688. # We got a response to requesting authentication.
  689. data = json.loads(bytes(reply.readAll()).decode("utf-8"))
  690. global_container_stack = Application.getInstance().getGlobalContainerStack()
  691. if global_container_stack: # Remove any old data.
  692. global_container_stack.removeMetaDataEntry("network_authentication_key")
  693. global_container_stack.removeMetaDataEntry("network_authentication_id")
  694. Application.getInstance().saveStack(global_container_stack) # Force saving so we don't keep wrong auth data.
  695. self._authentication_key = data["key"]
  696. self._authentication_id = data["id"]
  697. Logger.log("i", "Got a new authentication ID. Waiting for authorization: %s", self._authentication_id )
  698. # Check if the authentication is accepted.
  699. self._checkAuthentication()
  700. elif "materials" in reply_url:
  701. # Remove cached post request items.
  702. del self._material_post_objects[id(reply)]
  703. elif "print_job" in reply_url:
  704. reply.uploadProgress.disconnect(self._onUploadProgress)
  705. Logger.log("d", "Uploading of print succeeded after %s", time() - self._send_gcode_start)
  706. self._progress_message.hide()
  707. elif reply.operation() == QNetworkAccessManager.PutOperation:
  708. if status_code == 204:
  709. pass # Request was successful!
  710. else:
  711. Logger.log("d", "Something went wrong when trying to update data of API (%s). Message: %s Statuscode: %s", reply_url, reply.readAll(), status_code)
  712. else:
  713. Logger.log("d", "NetworkPrinterOutputDevice got an unhandled operation %s", reply.operation())
  714. def _onUploadProgress(self, bytes_sent, bytes_total):
  715. if bytes_total > 0:
  716. new_progress = bytes_sent / bytes_total * 100
  717. # Treat upload progress as response. Uploading can take more than 10 seconds, so if we don't, we can get
  718. # timeout responses if this happens.
  719. self._last_response_time = time()
  720. if new_progress > self._progress_message.getProgress():
  721. self._progress_message.show() # Ensure that the message is visible.
  722. self._progress_message.setProgress(bytes_sent / bytes_total * 100)
  723. else:
  724. self._progress_message.setProgress(0)
  725. self._progress_message.hide()
  726. ## Let the user decide if the hotends and/or material should be synced with the printer
  727. def materialHotendChangedMessage(self, callback):
  728. Application.getInstance().messageBox(i18n_catalog.i18nc("@window:title", "Changes on the Printer"),
  729. i18n_catalog.i18nc("@label",
  730. "Do you want to change the PrintCores and materials in Cura to match your printer?"),
  731. i18n_catalog.i18nc("@label",
  732. "The PrintCores and/or materials on your printer were changed. For the best result, always slice for the PrintCores and materials that are inserted in your printer."),
  733. buttons=QMessageBox.Yes + QMessageBox.No,
  734. icon=QMessageBox.Question,
  735. callback=callback
  736. )