NetworkPrinterOutputDevice.py 69 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308
  1. # Copyright (c) 2017 Ultimaker B.V.
  2. # Cura is released under the terms of the LGPLv3 or higher.
  3. from UM.i18n import i18nCatalog
  4. from UM.Application import Application
  5. from UM.Logger import Logger
  6. from UM.Signal import signalemitter
  7. from UM.Message import Message
  8. import UM.Settings.ContainerRegistry
  9. import UM.Version #To compare firmware version numbers.
  10. from cura.PrinterOutputDevice import PrinterOutputDevice, ConnectionState
  11. from cura.Settings.ContainerManager import ContainerManager
  12. import cura.Settings.ExtruderManager
  13. from PyQt5.QtNetwork import QHttpMultiPart, QHttpPart, QNetworkRequest, QNetworkAccessManager, QNetworkReply
  14. from PyQt5.QtCore import QUrl, QTimer, pyqtSignal, pyqtProperty, pyqtSlot, QCoreApplication
  15. from PyQt5.QtGui import QImage, QColor
  16. from PyQt5.QtWidgets import QMessageBox
  17. import json
  18. import os
  19. import gzip
  20. from time import time
  21. from time import gmtime
  22. from enum import IntEnum
  23. i18n_catalog = i18nCatalog("cura")
  24. class AuthState(IntEnum):
  25. NotAuthenticated = 1
  26. AuthenticationRequested = 2
  27. Authenticated = 3
  28. AuthenticationDenied = 4
  29. ## Network connected (wifi / lan) printer that uses the Ultimaker API
  30. @signalemitter
  31. class NetworkPrinterOutputDevice(PrinterOutputDevice):
  32. def __init__(self, key, address, properties, api_prefix):
  33. super().__init__(key)
  34. self._address = address
  35. self._key = key
  36. self._properties = properties # Properties dict as provided by zero conf
  37. self._api_prefix = api_prefix
  38. self._gcode = None
  39. self._print_finished = True # _print_finished == False means we're halfway in a print
  40. self._write_finished = True # _write_finished == False means we're currently sending a G-code file
  41. self._use_gzip = True # Should we use g-zip compression before sending the data?
  42. # This holds the full JSON file that was received from the last request.
  43. # The JSON looks like:
  44. #{
  45. # "led": {"saturation": 0.0, "brightness": 100.0, "hue": 0.0},
  46. # "beep": {},
  47. # "network": {
  48. # "wifi_networks": [],
  49. # "ethernet": {"connected": true, "enabled": true},
  50. # "wifi": {"ssid": "xxxx", "connected": False, "enabled": False}
  51. # },
  52. # "diagnostics": {},
  53. # "bed": {"temperature": {"target": 60.0, "current": 44.4}},
  54. # "heads": [{
  55. # "max_speed": {"z": 40.0, "y": 300.0, "x": 300.0},
  56. # "position": {"z": 20.0, "y": 6.0, "x": 180.0},
  57. # "fan": 0.0,
  58. # "jerk": {"z": 0.4, "y": 20.0, "x": 20.0},
  59. # "extruders": [
  60. # {
  61. # "feeder": {"max_speed": 45.0, "jerk": 5.0, "acceleration": 3000.0},
  62. # "active_material": {"guid": "xxxxxxx", "length_remaining": -1.0},
  63. # "hotend": {"temperature": {"target": 0.0, "current": 22.8}, "id": "AA 0.4"}
  64. # },
  65. # {
  66. # "feeder": {"max_speed": 45.0, "jerk": 5.0, "acceleration": 3000.0},
  67. # "active_material": {"guid": "xxxx", "length_remaining": -1.0},
  68. # "hotend": {"temperature": {"target": 0.0, "current": 22.8}, "id": "BB 0.4"}
  69. # }
  70. # ],
  71. # "acceleration": 3000.0
  72. # }],
  73. # "status": "printing"
  74. #}
  75. self._json_printer_state = {}
  76. ## Todo: Hardcoded value now; we should probably read this from the machine file.
  77. ## It's okay to leave this for now, as this plugin is um3 only (and has 2 extruders by definition)
  78. self._num_extruders = 2
  79. # These are reinitialised here (from PrinterOutputDevice) to match the new _num_extruders
  80. self._hotend_temperatures = [0] * self._num_extruders
  81. self._target_hotend_temperatures = [0] * self._num_extruders
  82. self._material_ids = [""] * self._num_extruders
  83. self._hotend_ids = [""] * self._num_extruders
  84. self._target_bed_temperature = 0
  85. self._processing_preheat_requests = True
  86. self._can_control_manually = False
  87. self.setPriority(3) # Make sure the output device gets selected above local file output
  88. self.setName(key)
  89. self.setShortDescription(i18n_catalog.i18nc("@action:button Preceded by 'Ready to'.", "Print over network"))
  90. self.setDescription(i18n_catalog.i18nc("@properties:tooltip", "Print over network"))
  91. self.setIconName("print")
  92. self._manager = None
  93. self._post_request = None
  94. self._post_reply = None
  95. self._post_multi_part = None
  96. self._post_part = None
  97. self._material_multi_part = None
  98. self._material_part = None
  99. self._progress_message = None
  100. self._error_message = None
  101. self._connection_message = None
  102. self._update_timer = QTimer()
  103. self._update_timer.setInterval(2000) # TODO; Add preference for update interval
  104. self._update_timer.setSingleShot(False)
  105. self._update_timer.timeout.connect(self._update)
  106. self._camera_timer = QTimer()
  107. self._camera_timer.setInterval(500) # Todo: Add preference for camera update interval
  108. self._camera_timer.setSingleShot(False)
  109. self._camera_timer.timeout.connect(self._updateCamera)
  110. self._image_request = None
  111. self._image_reply = None
  112. self._use_stream = True
  113. self._stream_buffer = b""
  114. self._stream_buffer_start_index = -1
  115. self._camera_image_id = 0
  116. self._authentication_counter = 0
  117. self._max_authentication_counter = 5 * 60 # Number of attempts before authentication timed out (5 min)
  118. self._authentication_timer = QTimer()
  119. self._authentication_timer.setInterval(1000) # TODO; Add preference for update interval
  120. self._authentication_timer.setSingleShot(False)
  121. self._authentication_timer.timeout.connect(self._onAuthenticationTimer)
  122. self._authentication_request_active = False
  123. self._authentication_state = AuthState.NotAuthenticated
  124. self._authentication_id = None
  125. self._authentication_key = None
  126. 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, title = i18n_catalog.i18nc("@info:title", "Connection status"))
  127. self._authentication_failed_message = Message(i18n_catalog.i18nc("@info:status", ""), title = i18n_catalog.i18nc("@info:title", "Connection Status"))
  128. self._authentication_failed_message.addAction("Retry", i18n_catalog.i18nc("@action:button", "Retry"), None, i18n_catalog.i18nc("@info:tooltip", "Re-send the access request"))
  129. self._authentication_failed_message.actionTriggered.connect(self.requestAuthentication)
  130. self._authentication_succeeded_message = Message(i18n_catalog.i18nc("@info:status", "Access to the printer accepted"), title = i18n_catalog.i18nc("@info:title", "Connection Status"))
  131. self._not_authenticated_message = Message(i18n_catalog.i18nc("@info:status", "No access to print with this printer. Unable to send print job."), title = i18n_catalog.i18nc("@info:title", "Connection Status"))
  132. 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"))
  133. self._not_authenticated_message.actionTriggered.connect(self.requestAuthentication)
  134. self._camera_image = QImage()
  135. self._material_post_objects = {}
  136. self._connection_state_before_timeout = None
  137. self._last_response_time = time()
  138. self._last_request_time = None
  139. self._response_timeout_time = 10
  140. self._recreate_network_manager_time = 30 # If we have no connection, re-create network manager every 30 sec.
  141. self._recreate_network_manager_count = 1
  142. self._send_gcode_start = time() # Time when the sending of the g-code started.
  143. self._last_command = ""
  144. self._compressing_print = False
  145. self._monitor_view_qml_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "MonitorItem.qml")
  146. printer_type = self._properties.get(b"machine", b"").decode("utf-8")
  147. if printer_type.startswith("9511"):
  148. self._updatePrinterType("ultimaker3_extended")
  149. elif printer_type.startswith("9066"):
  150. self._updatePrinterType("ultimaker3")
  151. else:
  152. self._updatePrinterType("unknown")
  153. Application.getInstance().getOutputDeviceManager().outputDevicesChanged.connect(self._onOutputDevicesChanged)
  154. def _onNetworkAccesibleChanged(self, accessible):
  155. Logger.log("d", "Network accessible state changed to: %s", accessible)
  156. ## Triggered when the output device manager changes devices.
  157. #
  158. # This is how we can detect that our device is no longer active now.
  159. def _onOutputDevicesChanged(self):
  160. if self.getId() not in Application.getInstance().getOutputDeviceManager().getOutputDeviceIds():
  161. self.stopCamera()
  162. def _onAuthenticationTimer(self):
  163. self._authentication_counter += 1
  164. self._authentication_requested_message.setProgress(self._authentication_counter / self._max_authentication_counter * 100)
  165. if self._authentication_counter > self._max_authentication_counter:
  166. self._authentication_timer.stop()
  167. Logger.log("i", "Authentication timer ended. Setting authentication to denied for printer: %s" % self._key)
  168. self.setAuthenticationState(AuthState.AuthenticationDenied)
  169. def _onAuthenticationRequired(self, reply, authenticator):
  170. if self._authentication_id is not None and self._authentication_key is not None:
  171. Logger.log("d", "Authentication was required for printer: %s. Setting up authenticator with ID %s and key %s", self._key, self._authentication_id, self._getSafeAuthKey())
  172. authenticator.setUser(self._authentication_id)
  173. authenticator.setPassword(self._authentication_key)
  174. else:
  175. Logger.log("d", "No authentication is available to use for %s, but we did got a request for it.", self._key)
  176. def getProperties(self):
  177. return self._properties
  178. @pyqtSlot(str, result = str)
  179. def getProperty(self, key):
  180. key = key.encode("utf-8")
  181. if key in self._properties:
  182. return self._properties.get(key, b"").decode("utf-8")
  183. else:
  184. return ""
  185. ## Get the unique key of this machine
  186. # \return key String containing the key of the machine.
  187. @pyqtSlot(result = str)
  188. def getKey(self):
  189. return self._key
  190. ## The IP address of the printer.
  191. @pyqtProperty(str, constant = True)
  192. def address(self):
  193. return self._properties.get(b"address", b"").decode("utf-8")
  194. ## Name of the printer (as returned from the ZeroConf properties)
  195. @pyqtProperty(str, constant = True)
  196. def name(self):
  197. return self._properties.get(b"name", b"").decode("utf-8")
  198. ## Firmware version (as returned from the ZeroConf properties)
  199. @pyqtProperty(str, constant=True)
  200. def firmwareVersion(self):
  201. return self._properties.get(b"firmware_version", b"").decode("utf-8")
  202. ## IPadress of this printer
  203. @pyqtProperty(str, constant=True)
  204. def ipAddress(self):
  205. return self._address
  206. ## Pre-heats the heated bed of the printer.
  207. #
  208. # \param temperature The temperature to heat the bed to, in degrees
  209. # Celsius.
  210. # \param duration How long the bed should stay warm, in seconds.
  211. @pyqtSlot(float, float)
  212. def preheatBed(self, temperature, duration):
  213. temperature = round(temperature) #The API doesn't allow floating point.
  214. duration = round(duration)
  215. if UM.Version.Version(self.firmwareVersion) < UM.Version.Version("3.5.92"): #Real bed pre-heating support is implemented from 3.5.92 and up.
  216. self.setTargetBedTemperature(temperature = temperature) #No firmware-side duration support then.
  217. return
  218. url = QUrl("http://" + self._address + self._api_prefix + "printer/bed/pre_heat")
  219. if duration > 0:
  220. data = """{"temperature": "%i", "timeout": "%i"}""" % (temperature, duration)
  221. else:
  222. data = """{"temperature": "%i"}""" % temperature
  223. Logger.log("i", "Pre-heating bed to %i degrees.", temperature)
  224. put_request = QNetworkRequest(url)
  225. put_request.setHeader(QNetworkRequest.ContentTypeHeader, "application/json")
  226. self._processing_preheat_requests = False
  227. self._manager.put(put_request, data.encode())
  228. self._preheat_bed_timer.start(self._preheat_bed_timeout * 1000) #Times 1000 because it needs to be provided as milliseconds.
  229. self.preheatBedRemainingTimeChanged.emit()
  230. ## Cancels pre-heating the heated bed of the printer.
  231. #
  232. # If the bed is not pre-heated, nothing happens.
  233. @pyqtSlot()
  234. def cancelPreheatBed(self):
  235. Logger.log("i", "Cancelling pre-heating of the bed.")
  236. self.preheatBed(temperature = 0, duration = 0)
  237. self._preheat_bed_timer.stop()
  238. self._preheat_bed_timer.setInterval(0)
  239. self.preheatBedRemainingTimeChanged.emit()
  240. ## Changes the target bed temperature on the printer.
  241. #
  242. # /param temperature The new target temperature of the bed.
  243. def _setTargetBedTemperature(self, temperature):
  244. if not self._updateTargetBedTemperature(temperature):
  245. return
  246. url = QUrl("http://" + self._address + self._api_prefix + "printer/bed/temperature/target")
  247. data = str(temperature)
  248. put_request = QNetworkRequest(url)
  249. put_request.setHeader(QNetworkRequest.ContentTypeHeader, "application/json")
  250. self._manager.put(put_request, data.encode())
  251. ## Updates the target bed temperature from the printer, and emit a signal if it was changed.
  252. #
  253. # /param temperature The new target temperature of the bed.
  254. # /return boolean, True if the temperature was changed, false if the new temperature has the same value as the already stored temperature
  255. def _updateTargetBedTemperature(self, temperature):
  256. if self._target_bed_temperature == temperature:
  257. return False
  258. self._target_bed_temperature = temperature
  259. self.targetBedTemperatureChanged.emit()
  260. return True
  261. ## Updates the target hotend temperature from the printer, and emit a signal if it was changed.
  262. #
  263. # /param index The index of the hotend.
  264. # /param temperature The new target temperature of the hotend.
  265. # /return boolean, True if the temperature was changed, false if the new temperature has the same value as the already stored temperature
  266. def _updateTargetHotendTemperature(self, index, temperature):
  267. if self._target_hotend_temperatures[index] == temperature:
  268. return False
  269. self._target_hotend_temperatures[index] = temperature
  270. self.targetHotendTemperaturesChanged.emit()
  271. return True
  272. def _stopCamera(self):
  273. self._stream_buffer = b""
  274. self._stream_buffer_start_index = -1
  275. if self._camera_timer.isActive():
  276. self._camera_timer.stop()
  277. if self._image_reply:
  278. try:
  279. # disconnect the signal
  280. try:
  281. self._image_reply.downloadProgress.disconnect(self._onStreamDownloadProgress)
  282. except Exception:
  283. pass
  284. # abort the request if it's not finished
  285. if not self._image_reply.isFinished():
  286. self._image_reply.close()
  287. except Exception as e: #RuntimeError
  288. pass # It can happen that the wrapped c++ object is already deleted.
  289. self._image_reply = None
  290. self._image_request = None
  291. def _startCamera(self):
  292. if self._use_stream:
  293. self._startCameraStream()
  294. else:
  295. self._camera_timer.start()
  296. def _startCameraStream(self):
  297. ## Request new image
  298. url = QUrl("http://" + self._address + ":8080/?action=stream")
  299. self._image_request = QNetworkRequest(url)
  300. self._image_reply = self._manager.get(self._image_request)
  301. self._image_reply.downloadProgress.connect(self._onStreamDownloadProgress)
  302. def _updateCamera(self):
  303. if not self._manager.networkAccessible():
  304. return
  305. ## Request new image
  306. url = QUrl("http://" + self._address + ":8080/?action=snapshot")
  307. image_request = QNetworkRequest(url)
  308. self._manager.get(image_request)
  309. self._last_request_time = time()
  310. ## Set the authentication state.
  311. # \param auth_state \type{AuthState} Enum value representing the new auth state
  312. def setAuthenticationState(self, auth_state):
  313. if auth_state == self._authentication_state:
  314. return # Nothing to do here.
  315. Logger.log("d", "Attempting to update auth state from %s to %s for printer %s" % (self._authentication_state, auth_state, self._key))
  316. if auth_state == AuthState.AuthenticationRequested:
  317. Logger.log("d", "Authentication state changed to authentication requested.")
  318. self.setAcceptsCommands(False)
  319. self.setConnectionText(i18n_catalog.i18nc("@info:status", "Connected over the network. Please approve the access request on the printer."))
  320. self._authentication_requested_message.show()
  321. self._authentication_request_active = True
  322. self._authentication_timer.start() # Start timer so auth will fail after a while.
  323. elif auth_state == AuthState.Authenticated:
  324. Logger.log("d", "Authentication state changed to authenticated")
  325. self.setAcceptsCommands(True)
  326. self.setConnectionText(i18n_catalog.i18nc("@info:status", "Connected over the network."))
  327. self._authentication_requested_message.hide()
  328. if self._authentication_request_active:
  329. self._authentication_succeeded_message.show()
  330. # Stop waiting for a response
  331. self._authentication_timer.stop()
  332. self._authentication_counter = 0
  333. # Once we are authenticated we need to send all material profiles.
  334. self.sendMaterialProfiles()
  335. elif auth_state == AuthState.AuthenticationDenied:
  336. self.setAcceptsCommands(False)
  337. self.setConnectionText(i18n_catalog.i18nc("@info:status", "Connected over the network. No access to control the printer."))
  338. self._authentication_requested_message.hide()
  339. if self._authentication_request_active:
  340. if self._authentication_timer.remainingTime() > 0:
  341. Logger.log("d", "Authentication state changed to authentication denied before the request timeout.")
  342. self._authentication_failed_message.setText(i18n_catalog.i18nc("@info:status", "Access request was denied on the printer."))
  343. else:
  344. Logger.log("d", "Authentication state changed to authentication denied due to a timeout")
  345. self._authentication_failed_message.setText(i18n_catalog.i18nc("@info:status", "Access request failed due to a timeout."))
  346. self._authentication_failed_message.show()
  347. self._authentication_request_active = False
  348. # Stop waiting for a response
  349. self._authentication_timer.stop()
  350. self._authentication_counter = 0
  351. self._authentication_state = auth_state
  352. self.authenticationStateChanged.emit()
  353. authenticationStateChanged = pyqtSignal()
  354. @pyqtProperty(int, notify = authenticationStateChanged)
  355. def authenticationState(self):
  356. return self._authentication_state
  357. @pyqtSlot()
  358. def requestAuthentication(self, message_id = None, action_id = "Retry"):
  359. if action_id == "Request" or action_id == "Retry":
  360. Logger.log("d", "Requestion authentication for %s due to action %s" % (self._key, action_id))
  361. self._authentication_failed_message.hide()
  362. self._not_authenticated_message.hide()
  363. self.setAuthenticationState(AuthState.NotAuthenticated)
  364. self._authentication_counter = 0
  365. self._authentication_requested_message.setProgress(0)
  366. self._authentication_id = None
  367. self._authentication_key = None
  368. self._createNetworkManager() # Re-create network manager to force re-authentication.
  369. ## Request data from the connected device.
  370. def _update(self):
  371. if self._last_response_time:
  372. time_since_last_response = time() - self._last_response_time
  373. else:
  374. time_since_last_response = 0
  375. if self._last_request_time:
  376. time_since_last_request = time() - self._last_request_time
  377. else:
  378. time_since_last_request = float("inf") # An irrelevantly large number of seconds
  379. # Connection is in timeout, check if we need to re-start the connection.
  380. # Sometimes the qNetwork manager incorrectly reports the network status on Mac & Windows.
  381. # Re-creating the QNetworkManager seems to fix this issue.
  382. if self._last_response_time and self._connection_state_before_timeout:
  383. if time_since_last_response > self._recreate_network_manager_time * self._recreate_network_manager_count:
  384. self._recreate_network_manager_count += 1
  385. counter = 0 # Counter to prevent possible indefinite while loop.
  386. # It can happen that we had a very long timeout (multiple times the recreate time).
  387. # In that case we should jump through the point that the next update won't be right away.
  388. while time_since_last_response - self._recreate_network_manager_time * self._recreate_network_manager_count > self._recreate_network_manager_time and counter < 10:
  389. counter += 1
  390. self._recreate_network_manager_count += 1
  391. Logger.log("d", "Timeout lasted over %.0f seconds (%.1fs), re-checking connection.", self._recreate_network_manager_time, time_since_last_response)
  392. self._createNetworkManager()
  393. return
  394. # Check if we have an connection in the first place.
  395. if not self._manager.networkAccessible():
  396. if not self._connection_state_before_timeout:
  397. Logger.log("d", "The network connection seems to be disabled. Going into timeout mode")
  398. self._connection_state_before_timeout = self._connection_state
  399. self.setConnectionState(ConnectionState.error)
  400. self._connection_message = Message(i18n_catalog.i18nc("@info:status",
  401. "The connection with the network was lost."),
  402. title = i18n_catalog.i18nc("@info:title", "Connection Status"))
  403. self._connection_message.show()
  404. if self._progress_message:
  405. self._progress_message.hide()
  406. # Check if we were uploading something. Abort if this is the case.
  407. # Some operating systems handle this themselves, others give weird issues.
  408. if self._post_reply:
  409. Logger.log("d", "Stopping post upload because the connection was lost.")
  410. self._finalizePostReply()
  411. return
  412. else:
  413. if not self._connection_state_before_timeout:
  414. self._recreate_network_manager_count = 1
  415. # Check that we aren't in a timeout state
  416. if self._last_response_time and self._last_request_time and not self._connection_state_before_timeout:
  417. if time_since_last_response > self._response_timeout_time and time_since_last_request <= self._response_timeout_time:
  418. # Go into timeout state.
  419. 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)
  420. self._connection_state_before_timeout = self._connection_state
  421. 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."),
  422. title = i18n_catalog.i18nc("@info:title", "Connection Status"))
  423. self._connection_message.show()
  424. if self._progress_message:
  425. self._progress_message.hide()
  426. # Check if we were uploading something. Abort if this is the case.
  427. # Some operating systems handle this themselves, others give weird issues.
  428. if self._post_reply:
  429. Logger.log("d", "Stopping post upload because the connection was lost.")
  430. self._finalizePostReply()
  431. self.setConnectionState(ConnectionState.error)
  432. return
  433. if self._authentication_state == AuthState.NotAuthenticated:
  434. self._verifyAuthentication() # We don't know if we are authenticated; check if we have correct auth.
  435. elif self._authentication_state == AuthState.AuthenticationRequested:
  436. self._checkAuthentication() # We requested authentication at some point. Check if we got permission.
  437. ## Request 'general' printer data
  438. url = QUrl("http://" + self._address + self._api_prefix + "printer")
  439. printer_request = QNetworkRequest(url)
  440. self._manager.get(printer_request)
  441. ## Request print_job data
  442. url = QUrl("http://" + self._address + self._api_prefix + "print_job")
  443. print_job_request = QNetworkRequest(url)
  444. self._manager.get(print_job_request)
  445. self._last_request_time = time()
  446. def _finalizePostReply(self):
  447. # Indicate uploading was finished (so another file can be send)
  448. self._write_finished = True
  449. if self._post_reply is None:
  450. return
  451. try:
  452. try:
  453. self._post_reply.uploadProgress.disconnect(self._onUploadProgress)
  454. except TypeError:
  455. pass # The disconnection can fail on mac in some cases. Ignore that.
  456. try:
  457. self._post_reply.finished.disconnect(self._onUploadFinished)
  458. except TypeError:
  459. pass # The disconnection can fail on mac in some cases. Ignore that.
  460. self._post_reply.abort()
  461. self._post_reply = None
  462. except RuntimeError:
  463. self._post_reply = None # It can happen that the wrapped c++ object is already deleted.
  464. def _createNetworkManager(self):
  465. if self._manager:
  466. self._manager.finished.disconnect(self._onFinished)
  467. self._manager.networkAccessibleChanged.disconnect(self._onNetworkAccesibleChanged)
  468. self._manager.authenticationRequired.disconnect(self._onAuthenticationRequired)
  469. self._manager = QNetworkAccessManager()
  470. self._manager.finished.connect(self._onFinished)
  471. self._manager.authenticationRequired.connect(self._onAuthenticationRequired)
  472. self._manager.networkAccessibleChanged.connect(self._onNetworkAccesibleChanged) # for debug purposes
  473. ## Convenience function that gets information from the received json data and converts it to the right internal
  474. # values / variables
  475. def _spliceJSONData(self):
  476. # Check for hotend temperatures
  477. for index in range(0, self._num_extruders):
  478. temperatures = self._json_printer_state["heads"][0]["extruders"][index]["hotend"]["temperature"]
  479. self._setHotendTemperature(index, temperatures["current"])
  480. self._updateTargetHotendTemperature(index, temperatures["target"])
  481. try:
  482. material_id = self._json_printer_state["heads"][0]["extruders"][index]["active_material"]["guid"]
  483. except KeyError:
  484. material_id = ""
  485. self._setMaterialId(index, material_id)
  486. try:
  487. hotend_id = self._json_printer_state["heads"][0]["extruders"][index]["hotend"]["id"]
  488. except KeyError:
  489. hotend_id = ""
  490. self._setHotendId(index, hotend_id)
  491. bed_temperatures = self._json_printer_state["bed"]["temperature"]
  492. self._setBedTemperature(bed_temperatures["current"])
  493. self._updateTargetBedTemperature(bed_temperatures["target"])
  494. head_x = self._json_printer_state["heads"][0]["position"]["x"]
  495. head_y = self._json_printer_state["heads"][0]["position"]["y"]
  496. head_z = self._json_printer_state["heads"][0]["position"]["z"]
  497. self._updateHeadPosition(head_x, head_y, head_z)
  498. self._updatePrinterState(self._json_printer_state["status"])
  499. if self._processing_preheat_requests:
  500. try:
  501. is_preheating = self._json_printer_state["bed"]["pre_heat"]["active"]
  502. except KeyError: #Old firmware doesn't support that.
  503. pass #Don't update the pre-heat remaining time.
  504. else:
  505. if is_preheating:
  506. try:
  507. remaining_preheat_time = self._json_printer_state["bed"]["pre_heat"]["remaining"]
  508. except KeyError: #Error in firmware. If "active" is supported, "remaining" should also be supported.
  509. pass #Anyway, don't update.
  510. else:
  511. #Only update if time estimate is significantly off (>5000ms).
  512. #Otherwise we get issues with latency causing the timer to count inconsistently.
  513. if abs(self._preheat_bed_timer.remainingTime() - remaining_preheat_time * 1000) > 5000:
  514. self._preheat_bed_timer.setInterval(remaining_preheat_time * 1000)
  515. self._preheat_bed_timer.start()
  516. self.preheatBedRemainingTimeChanged.emit()
  517. else: #Not pre-heating. Must've cancelled.
  518. if self._preheat_bed_timer.isActive():
  519. self._preheat_bed_timer.setInterval(0)
  520. self._preheat_bed_timer.stop()
  521. self.preheatBedRemainingTimeChanged.emit()
  522. def close(self):
  523. Logger.log("d", "Closing connection of printer %s with ip %s", self._key, self._address)
  524. self._updateJobState("")
  525. self.setConnectionState(ConnectionState.closed)
  526. if self._progress_message:
  527. self._progress_message.hide()
  528. # Reset authentication state
  529. self._authentication_requested_message.hide()
  530. self.setAuthenticationState(AuthState.NotAuthenticated)
  531. self._authentication_counter = 0
  532. self._authentication_timer.stop()
  533. self._authentication_requested_message.hide()
  534. self._authentication_failed_message.hide()
  535. self._authentication_succeeded_message.hide()
  536. # Reset stored material & hotend data.
  537. self._material_ids = [""] * self._num_extruders
  538. self._hotend_ids = [""] * self._num_extruders
  539. if self._error_message:
  540. self._error_message.hide()
  541. # Reset timeout state
  542. self._connection_state_before_timeout = None
  543. self._last_response_time = time()
  544. self._last_request_time = None
  545. # Stop update timers
  546. self._update_timer.stop()
  547. self.stopCamera()
  548. ## Request the current scene to be sent to a network-connected printer.
  549. #
  550. # \param nodes A collection of scene nodes to send. This is ignored.
  551. # \param file_name \type{string} A suggestion for a file name to write.
  552. # This is ignored.
  553. # \param filter_by_machine Whether to filter MIME types by machine. This
  554. # is ignored.
  555. # \param kwargs Keyword arguments.
  556. def requestWrite(self, nodes, file_name=None, filter_by_machine=False, file_handler=None, **kwargs):
  557. if self._printer_state not in ["idle", ""]:
  558. self._error_message = Message(
  559. i18n_catalog.i18nc("@info:status", "Unable to start a new print job, printer is busy. Current printer status is %s.") % self._printer_state,
  560. title = i18n_catalog.i18nc("@info:title", "Printer Status"))
  561. self._error_message.show()
  562. return
  563. elif self._authentication_state != AuthState.Authenticated:
  564. self._not_authenticated_message.show()
  565. Logger.log("d", "Attempting to perform an action without authentication for printer %s. Auth state is %s", self._key, self._authentication_state)
  566. return
  567. Application.getInstance().getController().setActiveStage("MonitorStage")
  568. self._print_finished = True
  569. self.writeStarted.emit(self)
  570. active_build_plate = Application.getInstance().getBuildPlateModel().activeBuildPlate
  571. self._gcode = getattr(Application.getInstance().getController().getScene(), "gcode_dict")[active_build_plate]
  572. print_information = Application.getInstance().getPrintInformation()
  573. warnings = [] # There might be multiple things wrong. Keep a list of all the stuff we need to warn about.
  574. # Only check for mistakes if there is material length information.
  575. if print_information.materialLengths:
  576. # Check if PrintCores / materials are loaded at all. Any failure in these results in an Error.
  577. for index in range(0, self._num_extruders):
  578. if index < len(print_information.materialLengths) and print_information.materialLengths[index] != 0:
  579. if self._json_printer_state["heads"][0]["extruders"][index]["hotend"]["id"] == "":
  580. Logger.log("e", "No cartridge loaded in slot %s, unable to start print", index + 1)
  581. self._error_message = Message(
  582. i18n_catalog.i18nc("@info:status", "Unable to start a new print job. No Printcore loaded in slot {0}".format(index + 1)),
  583. title = i18n_catalog.i18nc("@info:title", "Error"))
  584. self._error_message.show()
  585. return
  586. if self._json_printer_state["heads"][0]["extruders"][index]["active_material"]["guid"] == "":
  587. Logger.log("e", "No material loaded in slot %s, unable to start print", index + 1)
  588. self._error_message = Message(
  589. i18n_catalog.i18nc("@info:status",
  590. "Unable to start a new print job. No material loaded in slot {0}".format(index + 1)),
  591. title = i18n_catalog.i18nc("@info:title", "Error"))
  592. self._error_message.show()
  593. return
  594. for index in range(0, self._num_extruders):
  595. # Check if there is enough material. Any failure in these results in a warning.
  596. material_length = self._json_printer_state["heads"][0]["extruders"][index]["active_material"]["length_remaining"]
  597. if material_length != -1 and index < len(print_information.materialLengths) and print_information.materialLengths[index] > material_length:
  598. 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)
  599. warnings.append(i18n_catalog.i18nc("@label", "Not enough material for spool {0}.").format(index+1))
  600. # Check if the right cartridges are loaded. Any failure in these results in a warning.
  601. extruder_manager = cura.Settings.ExtruderManager.ExtruderManager.getInstance()
  602. if index < len(print_information.materialLengths) and print_information.materialLengths[index] != 0:
  603. variant = extruder_manager.getExtruderStack(index).findContainer({"type": "variant"})
  604. core_name = self._json_printer_state["heads"][0]["extruders"][index]["hotend"]["id"]
  605. if variant:
  606. if variant.getName() != core_name:
  607. Logger.log("w", "Extruder %s has a different Cartridge (%s) as Cura (%s)", index + 1, core_name, variant.getName())
  608. warnings.append(i18n_catalog.i18nc("@label", "Different PrintCore (Cura: {0}, Printer: {1}) selected for extruder {2}".format(variant.getName(), core_name, index + 1)))
  609. material = extruder_manager.getExtruderStack(index).findContainer({"type": "material"})
  610. if material:
  611. remote_material_guid = self._json_printer_state["heads"][0]["extruders"][index]["active_material"]["guid"]
  612. if material.getMetaDataEntry("GUID") != remote_material_guid:
  613. Logger.log("w", "Extruder %s has a different material (%s) as Cura (%s)", index + 1,
  614. remote_material_guid,
  615. material.getMetaDataEntry("GUID"))
  616. remote_materials = UM.Settings.ContainerRegistry.ContainerRegistry.getInstance().findInstanceContainersMetadata(type = "material", GUID = remote_material_guid, read_only = True)
  617. remote_material_name = "Unknown"
  618. if remote_materials:
  619. remote_material_name = remote_materials[0]["name"]
  620. warnings.append(i18n_catalog.i18nc("@label", "Different material (Cura: {0}, Printer: {1}) selected for extruder {2}").format(material.getName(), remote_material_name, index + 1))
  621. try:
  622. is_offset_calibrated = self._json_printer_state["heads"][0]["extruders"][index]["hotend"]["offset"]["state"] == "valid"
  623. except KeyError: # Older versions of the API don't expose the offset property, so we must asume that all is well.
  624. is_offset_calibrated = True
  625. if not is_offset_calibrated:
  626. warnings.append(i18n_catalog.i18nc("@label", "PrintCore {0} is not properly calibrated. XY calibration needs to be performed on the printer.").format(index + 1))
  627. else:
  628. Logger.log("w", "There was no material usage found. No check to match used material with machine is done.")
  629. if warnings:
  630. text = i18n_catalog.i18nc("@label", "Are you sure you wish to print with the selected configuration?")
  631. informative_text = i18n_catalog.i18nc("@label", "There is a mismatch between the configuration or calibration of the printer and Cura. "
  632. "For the best result, always slice for the PrintCores and materials that are inserted in your printer.")
  633. detailed_text = ""
  634. for warning in warnings:
  635. detailed_text += warning + "\n"
  636. Application.getInstance().messageBox(i18n_catalog.i18nc("@window:title", "Mismatched configuration"),
  637. text,
  638. informative_text,
  639. detailed_text,
  640. buttons=QMessageBox.Yes + QMessageBox.No,
  641. icon=QMessageBox.Question,
  642. callback=self._configurationMismatchMessageCallback
  643. )
  644. return
  645. self.startPrint()
  646. def _configurationMismatchMessageCallback(self, button):
  647. def delayedCallback():
  648. if button == QMessageBox.Yes:
  649. self.startPrint()
  650. else:
  651. Application.getInstance().getController().setActiveStage("PrepareStage")
  652. # For some unknown reason Cura on OSX will hang if we do the call back code
  653. # immediately without first returning and leaving QML's event system.
  654. QTimer.singleShot(100, delayedCallback)
  655. def isConnected(self):
  656. return self._connection_state != ConnectionState.closed and self._connection_state != ConnectionState.error
  657. ## Start requesting data from printer
  658. def connect(self):
  659. # Don't allow to connect to a printer with a faulty connection state.
  660. # For instance when switching printers but the printer is disconnected from the network
  661. if self._connection_state == ConnectionState.error:
  662. return
  663. if self.isConnected():
  664. self.close() # Close previous connection
  665. self._createNetworkManager()
  666. self._last_response_time = time() # Ensure we reset the time when trying to connect (again)
  667. self.setConnectionState(ConnectionState.connecting)
  668. self._update() # Manually trigger the first update, as we don't want to wait a few secs before it starts.
  669. if not self._use_stream:
  670. self._updateCamera()
  671. Logger.log("d", "Connection with printer %s with ip %s started", self._key, self._address)
  672. ## Check if this machine was authenticated before.
  673. self._authentication_id = Application.getInstance().getGlobalContainerStack().getMetaDataEntry("network_authentication_id", None)
  674. self._authentication_key = Application.getInstance().getGlobalContainerStack().getMetaDataEntry("network_authentication_key", None)
  675. if self._authentication_id is None and self._authentication_key is None:
  676. Logger.log("d", "No authentication found in metadata.")
  677. else:
  678. Logger.log("d", "Loaded authentication id %s and key %s from the metadata entry for printer %s", self._authentication_id, self._getSafeAuthKey(), self._key)
  679. self._update_timer.start()
  680. ## Stop requesting data from printer
  681. def disconnect(self):
  682. Logger.log("d", "Connection with printer %s with ip %s stopped", self._key, self._address)
  683. self.close()
  684. newImage = pyqtSignal()
  685. @pyqtProperty(QUrl, notify = newImage)
  686. def cameraImage(self):
  687. self._camera_image_id += 1
  688. # There is an image provider that is called "camera". In order to ensure that the image qml object, that
  689. # requires a QUrl to function, updates correctly we add an increasing number. This causes to see the QUrl
  690. # as new (instead of relying on cached version and thus forces an update.
  691. temp = "image://camera/" + str(self._camera_image_id)
  692. return QUrl(temp, QUrl.TolerantMode)
  693. def getCameraImage(self):
  694. return self._camera_image
  695. def _setJobState(self, job_state):
  696. self._last_command = job_state
  697. url = QUrl("http://" + self._address + self._api_prefix + "print_job/state")
  698. put_request = QNetworkRequest(url)
  699. put_request.setHeader(QNetworkRequest.ContentTypeHeader, "application/json")
  700. data = "{\"target\": \"%s\"}" % job_state
  701. self._manager.put(put_request, data.encode())
  702. ## Convenience function to get the username from the OS.
  703. # The code was copied from the getpass module, as we try to use as little dependencies as possible.
  704. def _getUserName(self):
  705. for name in ("LOGNAME", "USER", "LNAME", "USERNAME"):
  706. user = os.environ.get(name)
  707. if user:
  708. return user
  709. return "Unknown User" # Couldn't find out username.
  710. def _progressMessageActionTrigger(self, message_id = None, action_id = None):
  711. if action_id == "Abort":
  712. Logger.log("d", "User aborted sending print to remote.")
  713. self._progress_message.hide()
  714. self._compressing_print = False
  715. self._write_finished = True # post_reply does not always exist, so make sure we unblock writing
  716. if self._post_reply:
  717. self._finalizePostReply()
  718. Application.getInstance().getController().setActiveStage("PrepareStage")
  719. ## Attempt to start a new print.
  720. # This function can fail to actually start a print due to not being authenticated or another print already
  721. # being in progress.
  722. def startPrint(self):
  723. # Check if we're already writing
  724. if not self._write_finished:
  725. self._error_message = Message(
  726. i18n_catalog.i18nc("@info:status",
  727. "Sending new jobs (temporarily) blocked, still sending the previous print job."))
  728. self._error_message.show()
  729. return
  730. # Indicate we're starting a new write action, is set back to True at the end of this method
  731. self._write_finished = False
  732. try:
  733. self._send_gcode_start = time()
  734. self._progress_message = Message(i18n_catalog.i18nc("@info:status", "Sending data to printer"), 0, False, -1, i18n_catalog.i18nc("@info:title", "Sending Data"))
  735. self._progress_message.addAction("Abort", i18n_catalog.i18nc("@action:button", "Cancel"), None, "")
  736. self._progress_message.actionTriggered.connect(self._progressMessageActionTrigger)
  737. self._progress_message.show()
  738. Logger.log("d", "Started sending g-code to remote printer.")
  739. self._compressing_print = True
  740. ## Mash the data into single string
  741. max_chars_per_line = 1024 * 1024 / 4 # 1 / 4 MB
  742. byte_array_file_data = b""
  743. batched_line = ""
  744. def _compress_data_and_notify_qt(data_to_append):
  745. compressed_data = gzip.compress(data_to_append.encode("utf-8"))
  746. self._progress_message.setProgress(-1) # Tickle the message so that it's clear that it's still being used.
  747. QCoreApplication.processEvents() # Ensure that the GUI does not freeze.
  748. # Pretend that this is a response, as zipping might take a bit of time.
  749. self._last_response_time = time()
  750. return compressed_data
  751. for line in self._gcode:
  752. if not self._compressing_print:
  753. self._progress_message.hide()
  754. return # Stop trying to zip, abort was called.
  755. if self._use_gzip:
  756. batched_line += line
  757. # if the gcode was read from a gcode file, self._gcode will be a list of all lines in that file.
  758. # Compressing line by line in this case is extremely slow, so we need to batch them.
  759. if len(batched_line) < max_chars_per_line:
  760. continue
  761. byte_array_file_data += _compress_data_and_notify_qt(batched_line)
  762. batched_line = ""
  763. else:
  764. byte_array_file_data += line.encode("utf-8")
  765. # don't miss the last batch if it's there
  766. if self._use_gzip:
  767. if batched_line:
  768. byte_array_file_data += _compress_data_and_notify_qt(batched_line)
  769. if self._use_gzip:
  770. file_name = "%s.gcode.gz" % Application.getInstance().getPrintInformation().jobName
  771. else:
  772. file_name = "%s.gcode" % Application.getInstance().getPrintInformation().jobName
  773. self._compressing_print = False
  774. ## Create multi_part request
  775. self._post_multi_part = QHttpMultiPart(QHttpMultiPart.FormDataType)
  776. ## Create part (to be placed inside multipart)
  777. self._post_part = QHttpPart()
  778. self._post_part.setHeader(QNetworkRequest.ContentDispositionHeader,
  779. "form-data; name=\"file\"; filename=\"%s\"" % file_name)
  780. self._post_part.setBody(byte_array_file_data)
  781. self._post_multi_part.append(self._post_part)
  782. url = QUrl("http://" + self._address + self._api_prefix + "print_job")
  783. ## Create the QT request
  784. self._post_request = QNetworkRequest(url)
  785. ## Post request + data
  786. self._post_reply = self._manager.post(self._post_request, self._post_multi_part)
  787. self._post_reply.uploadProgress.connect(self._onUploadProgress)
  788. self._post_reply.finished.connect(self._onUploadFinished) # used to unblock new write actions
  789. except IOError:
  790. self._progress_message.hide()
  791. self._error_message = Message(i18n_catalog.i18nc("@info:status", "Unable to send data to printer. Is another job still active?"),
  792. title = i18n_catalog.i18nc("@info:title", "Warning"))
  793. self._error_message.show()
  794. except Exception as e:
  795. self._progress_message.hide()
  796. Logger.log("e", "An exception occurred in network connection: %s" % str(e))
  797. ## Verify if we are authenticated to make requests.
  798. def _verifyAuthentication(self):
  799. url = QUrl("http://" + self._address + self._api_prefix + "auth/verify")
  800. request = QNetworkRequest(url)
  801. self._manager.get(request)
  802. ## Check if the authentication request was allowed by the printer.
  803. def _checkAuthentication(self):
  804. Logger.log("d", "Checking if authentication is correct for id %s and key %s", self._authentication_id, self._getSafeAuthKey())
  805. self._manager.get(QNetworkRequest(QUrl("http://" + self._address + self._api_prefix + "auth/check/" + str(self._authentication_id))))
  806. ## Request a authentication key from the printer so we can be authenticated
  807. def _requestAuthentication(self):
  808. url = QUrl("http://" + self._address + self._api_prefix + "auth/request")
  809. request = QNetworkRequest(url)
  810. request.setHeader(QNetworkRequest.ContentTypeHeader, "application/json")
  811. self._authentication_key = None
  812. self._authentication_id = None
  813. self._manager.post(request, json.dumps({"application": "Cura-" + Application.getInstance().getVersion(), "user": self._getUserName()}).encode())
  814. self.setAuthenticationState(AuthState.AuthenticationRequested)
  815. ## Send all material profiles to the printer.
  816. def sendMaterialProfiles(self):
  817. registry = UM.Settings.ContainerRegistry.ContainerRegistry.getInstance()
  818. for container in registry.findInstanceContainers(type = "material"):
  819. try:
  820. xml_data = container.serialize()
  821. if xml_data == "" or xml_data is None:
  822. continue
  823. names = ContainerManager.getInstance().getLinkedMaterials(container.getId())
  824. if names:
  825. # There are other materials that share this GUID.
  826. if not registry.isReadOnly(container.getId()):
  827. continue # If it's not readonly, it's created by user, so skip it.
  828. material_multi_part = QHttpMultiPart(QHttpMultiPart.FormDataType)
  829. material_part = QHttpPart()
  830. file_name = "none.xml"
  831. material_part.setHeader(QNetworkRequest.ContentDispositionHeader, "form-data; name=\"file\";filename=\"%s\"" % file_name)
  832. material_part.setBody(xml_data.encode())
  833. material_multi_part.append(material_part)
  834. url = QUrl("http://" + self._address + self._api_prefix + "materials")
  835. material_post_request = QNetworkRequest(url)
  836. reply = self._manager.post(material_post_request, material_multi_part)
  837. # Keep reference to material_part, material_multi_part and reply so the garbage collector won't touch them.
  838. self._material_post_objects[id(reply)] = (material_part, material_multi_part, reply)
  839. except NotImplementedError:
  840. # If the material container is not the most "generic" one it can't be serialized an will raise a
  841. # NotImplementedError. We can simply ignore these.
  842. pass
  843. ## Handler for all requests that have finished.
  844. def _onFinished(self, reply):
  845. if reply.error() == QNetworkReply.TimeoutError:
  846. Logger.log("w", "Received a timeout on a request to the printer")
  847. self._connection_state_before_timeout = self._connection_state
  848. # Check if we were uploading something. Abort if this is the case.
  849. # Some operating systems handle this themselves, others give weird issues.
  850. if self._post_reply:
  851. self._finalizePostReply()
  852. Logger.log("d", "Uploading of print failed after %s", time() - self._send_gcode_start)
  853. self._progress_message.hide()
  854. self.setConnectionState(ConnectionState.error)
  855. return
  856. if self._connection_state_before_timeout and reply.error() == QNetworkReply.NoError: # There was a timeout, but we got a correct answer again.
  857. 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)
  858. # Camera was active before timeout. Start it again
  859. if self._camera_active:
  860. self._startCamera()
  861. self.setConnectionState(self._connection_state_before_timeout)
  862. self._connection_state_before_timeout = None
  863. if reply.error() == QNetworkReply.NoError:
  864. self._last_response_time = time()
  865. status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute)
  866. if not status_code:
  867. if self._connection_state != ConnectionState.error:
  868. Logger.log("d", "A reply from %s did not have status code.", reply.url().toString())
  869. # Received no or empty reply
  870. return
  871. reply_url = reply.url().toString()
  872. if reply.operation() == QNetworkAccessManager.GetOperation:
  873. # "printer" is also in "printers", therefore _api_prefix is added.
  874. if self._api_prefix + "printer" in reply_url: # Status update from printer.
  875. if status_code == 200:
  876. if self._connection_state == ConnectionState.connecting:
  877. self.setConnectionState(ConnectionState.connected)
  878. try:
  879. self._json_printer_state = json.loads(bytes(reply.readAll()).decode("utf-8"))
  880. except json.decoder.JSONDecodeError:
  881. Logger.log("w", "Received an invalid printer state message: Not valid JSON.")
  882. return
  883. self._spliceJSONData()
  884. # Hide connection error message if the connection was restored
  885. if self._connection_message:
  886. self._connection_message.hide()
  887. self._connection_message = None
  888. else:
  889. Logger.log("w", "We got an unexpected status (%s) while requesting printer state", status_code)
  890. pass # TODO: Handle errors
  891. elif self._api_prefix + "print_job" in reply_url: # Status update from print_job:
  892. if status_code == 200:
  893. try:
  894. json_data = json.loads(bytes(reply.readAll()).decode("utf-8"))
  895. except json.decoder.JSONDecodeError:
  896. Logger.log("w", "Received an invalid print job state message: Not valid JSON.")
  897. return
  898. progress = json_data["progress"]
  899. ## If progress is 0 add a bit so another print can't be sent.
  900. if progress == 0:
  901. progress += 0.001
  902. elif progress == 1:
  903. self._print_finished = True
  904. else:
  905. self._print_finished = False
  906. self.setProgress(progress * 100)
  907. state = json_data["state"]
  908. # There is a short period after aborting or finishing a print where the printer
  909. # reports a "none" state (but the printer is not ready to receive a print)
  910. # If this happens before the print has reached progress == 1, the print has
  911. # been aborted.
  912. if state == "none" or state == "":
  913. if self._last_command == "abort":
  914. self.setErrorText(i18n_catalog.i18nc("@label:MonitorStatus", "Aborting print..."))
  915. state = "error"
  916. else:
  917. state = "printing"
  918. if state == "wait_cleanup" and self._last_command == "abort":
  919. # Keep showing the "aborted" error state until after the buildplate has been cleaned
  920. self.setErrorText(i18n_catalog.i18nc("@label:MonitorStatus", "Print aborted. Please check the printer"))
  921. state = "error"
  922. # NB/TODO: the following two states are intentionally added for future proofing the i18n strings
  923. # but are currently non-functional
  924. if state == "!pausing":
  925. self.setErrorText(i18n_catalog.i18nc("@label:MonitorStatus", "Pausing print..."))
  926. if state == "!resuming":
  927. self.setErrorText(i18n_catalog.i18nc("@label:MonitorStatus", "Resuming print..."))
  928. self._updateJobState(state)
  929. self.setTimeElapsed(json_data["time_elapsed"])
  930. self.setTimeTotal(json_data["time_total"])
  931. self.setJobName(json_data["name"])
  932. elif status_code == 404:
  933. self.setProgress(0) # No print job found, so there can't be progress or other data.
  934. self._updateJobState("")
  935. self.setErrorText("")
  936. self.setTimeElapsed(0)
  937. self.setTimeTotal(0)
  938. self.setJobName("")
  939. else:
  940. Logger.log("w", "We got an unexpected status (%s) while requesting print job state", status_code)
  941. elif "snapshot" in reply_url: # Status update from image:
  942. if status_code == 200:
  943. self._camera_image.loadFromData(reply.readAll())
  944. self.newImage.emit()
  945. elif "auth/verify" in reply_url: # Answer when requesting authentication
  946. if status_code == 401:
  947. if self._authentication_state != AuthState.AuthenticationRequested:
  948. # Only request a new authentication when we have not already done so.
  949. Logger.log("i", "Not authenticated (Current auth state is %s). Attempting to request authentication for printer %s", self._authentication_state, self._key )
  950. self._requestAuthentication()
  951. elif status_code == 403:
  952. # If we already had an auth (eg; didn't request one), we only need a single 403 to see it as denied.
  953. if self._authentication_state != AuthState.AuthenticationRequested:
  954. Logger.log("d", "While trying to verify the authentication state, we got a forbidden response. Our own auth state was %s", self._authentication_state)
  955. self.setAuthenticationState(AuthState.AuthenticationDenied)
  956. elif status_code == 200:
  957. self.setAuthenticationState(AuthState.Authenticated)
  958. global_container_stack = Application.getInstance().getGlobalContainerStack()
  959. ## Save authentication details.
  960. if global_container_stack:
  961. if "network_authentication_key" in global_container_stack.getMetaData():
  962. global_container_stack.setMetaDataEntry("network_authentication_key", self._authentication_key)
  963. else:
  964. global_container_stack.addMetaDataEntry("network_authentication_key", self._authentication_key)
  965. if "network_authentication_id" in global_container_stack.getMetaData():
  966. global_container_stack.setMetaDataEntry("network_authentication_id", self._authentication_id)
  967. else:
  968. global_container_stack.addMetaDataEntry("network_authentication_id", self._authentication_id)
  969. Logger.log("i", "Authentication succeeded for id %s and key %s", self._authentication_id, self._getSafeAuthKey())
  970. Application.getInstance().saveStack(global_container_stack) # Force save so we are sure the data is not lost.
  971. else:
  972. Logger.log("w", "Unable to save authentication for id %s and key %s", self._authentication_id, self._getSafeAuthKey())
  973. # Request 'system' printer data once, when we know we have authentication, so we know we can set the system time.
  974. url = QUrl("http://" + self._address + self._api_prefix + "system")
  975. system_data_request = QNetworkRequest(url)
  976. self._manager.get(system_data_request)
  977. else: # Got a response that we didn't expect, so something went wrong.
  978. Logger.log("e", "While trying to authenticate, we got an unexpected response: %s", reply.attribute(QNetworkRequest.HttpStatusCodeAttribute))
  979. self.setAuthenticationState(AuthState.NotAuthenticated)
  980. elif "auth/check" in reply_url: # Check if we are authenticated (user can refuse this!)
  981. try:
  982. data = json.loads(bytes(reply.readAll()).decode("utf-8"))
  983. except json.decoder.JSONDecodeError:
  984. Logger.log("w", "Received an invalid authentication check from printer: Not valid JSON.")
  985. return
  986. if data.get("message", "") == "authorized":
  987. Logger.log("i", "Authentication was approved")
  988. self._verifyAuthentication() # Ensure that the verification is really used and correct.
  989. elif data.get("message", "") == "unauthorized":
  990. Logger.log("i", "Authentication was denied.")
  991. self.setAuthenticationState(AuthState.AuthenticationDenied)
  992. else:
  993. pass
  994. elif self._api_prefix + "system" in reply_url:
  995. # Check if the printer has time, and if this has a valid system time.
  996. try:
  997. data = json.loads(bytes(reply.readAll()).decode("utf-8"))
  998. except json.decoder.JSONDecodeError:
  999. Logger.log("w", "Received an invalid authentication request reply from printer: Not valid JSON.")
  1000. return
  1001. if "time" in data and "utc" in data["time"]:
  1002. try:
  1003. printer_time = gmtime(float(data["time"]["utc"]))
  1004. Logger.log("i", "Printer has system time of: %s", str(printer_time))
  1005. except ValueError:
  1006. printer_time = None
  1007. if printer_time is not None and printer_time.tm_year < 1990:
  1008. # The system time is not valid, sync our current system time to it, so we at least have some reasonable time in the printer.
  1009. Logger.log("w", "Printer system time invalid, setting system time")
  1010. url = QUrl("http://" + self._address + self._api_prefix + "system/time/utc")
  1011. put_request = QNetworkRequest(url)
  1012. put_request.setHeader(QNetworkRequest.ContentTypeHeader, "application/json")
  1013. self._manager.put(put_request, str(time()).encode())
  1014. elif reply.operation() == QNetworkAccessManager.PostOperation:
  1015. if "/auth/request" in reply_url:
  1016. # We got a response to requesting authentication.
  1017. try:
  1018. data = json.loads(bytes(reply.readAll()).decode("utf-8"))
  1019. except json.decoder.JSONDecodeError:
  1020. Logger.log("w", "Received an invalid authentication request reply from printer: Not valid JSON.")
  1021. return
  1022. global_container_stack = Application.getInstance().getGlobalContainerStack()
  1023. if global_container_stack: # Remove any old data.
  1024. Logger.log("d", "Removing old network authentication data for %s as a new one was requested.", self._key)
  1025. global_container_stack.removeMetaDataEntry("network_authentication_key")
  1026. global_container_stack.removeMetaDataEntry("network_authentication_id")
  1027. Application.getInstance().saveStack(global_container_stack) # Force saving so we don't keep wrong auth data.
  1028. self._authentication_key = data["key"]
  1029. self._authentication_id = data["id"]
  1030. Logger.log("i", "Got a new authentication ID (%s) and KEY (%s). Waiting for authorization.", self._authentication_id, self._getSafeAuthKey())
  1031. # Check if the authentication is accepted.
  1032. self._checkAuthentication()
  1033. elif "materials" in reply_url:
  1034. # Remove cached post request items.
  1035. del self._material_post_objects[id(reply)]
  1036. elif "print_job" in reply_url:
  1037. self._onUploadFinished() # Make sure the upload flag is reset as reply.finished is not always triggered
  1038. try:
  1039. reply.uploadProgress.disconnect(self._onUploadProgress)
  1040. except:
  1041. pass
  1042. try:
  1043. reply.finished.disconnect(self._onUploadFinished)
  1044. except:
  1045. pass
  1046. Logger.log("d", "Uploading of print succeeded after %s", time() - self._send_gcode_start)
  1047. # Only reset the _post_reply if it was the same one.
  1048. if reply == self._post_reply:
  1049. self._post_reply = None
  1050. self._progress_message.hide()
  1051. elif reply.operation() == QNetworkAccessManager.PutOperation:
  1052. if "printer/bed/pre_heat" in reply_url: #Pre-heat command has completed. Re-enable syncing pre-heating.
  1053. self._processing_preheat_requests = True
  1054. if status_code in [200, 201, 202, 204]:
  1055. pass # Request was successful!
  1056. else:
  1057. Logger.log("d", "Something went wrong when trying to update data of API (%s). Message: %s Statuscode: %s", reply_url, reply.readAll(), status_code)
  1058. else:
  1059. Logger.log("d", "NetworkPrinterOutputDevice got an unhandled operation %s", reply.operation())
  1060. def _onStreamDownloadProgress(self, bytes_received, bytes_total):
  1061. # An MJPG stream is (for our purpose) a stream of concatenated JPG images.
  1062. # JPG images start with the marker 0xFFD8, and end with 0xFFD9
  1063. if self._image_reply is None:
  1064. return
  1065. self._stream_buffer += self._image_reply.readAll()
  1066. if len(self._stream_buffer) > 2000000: # No single camera frame should be 2 Mb or larger
  1067. Logger.log("w", "MJPEG buffer exceeds reasonable size. Restarting stream...")
  1068. self._stopCamera() # resets stream buffer and start index
  1069. self._startCamera()
  1070. return
  1071. if self._stream_buffer_start_index == -1:
  1072. self._stream_buffer_start_index = self._stream_buffer.indexOf(b'\xff\xd8')
  1073. stream_buffer_end_index = self._stream_buffer.lastIndexOf(b'\xff\xd9')
  1074. # If this happens to be more than a single frame, then so be it; the JPG decoder will
  1075. # ignore the extra data. We do it like this in order not to get a buildup of frames
  1076. if self._stream_buffer_start_index != -1 and stream_buffer_end_index != -1:
  1077. jpg_data = self._stream_buffer[self._stream_buffer_start_index:stream_buffer_end_index + 2]
  1078. self._stream_buffer = self._stream_buffer[stream_buffer_end_index + 2:]
  1079. self._stream_buffer_start_index = -1
  1080. self._camera_image.loadFromData(jpg_data)
  1081. self.newImage.emit()
  1082. def _onUploadProgress(self, bytes_sent, bytes_total):
  1083. if bytes_total > 0:
  1084. new_progress = bytes_sent / bytes_total * 100
  1085. # Treat upload progress as response. Uploading can take more than 10 seconds, so if we don't, we can get
  1086. # timeout responses if this happens.
  1087. self._last_response_time = time()
  1088. if new_progress > self._progress_message.getProgress():
  1089. self._progress_message.show() # Ensure that the message is visible.
  1090. self._progress_message.setProgress(bytes_sent / bytes_total * 100)
  1091. else:
  1092. self._progress_message.setProgress(0)
  1093. self._progress_message.hide()
  1094. ## Allow new write actions (uploads) again when uploading is finished.
  1095. def _onUploadFinished(self):
  1096. self._write_finished = True
  1097. ## Let the user decide if the hotends and/or material should be synced with the printer
  1098. def materialHotendChangedMessage(self, callback):
  1099. Application.getInstance().messageBox(i18n_catalog.i18nc("@window:title", "Sync with your printer"),
  1100. i18n_catalog.i18nc("@label",
  1101. "Would you like to use your current printer configuration in Cura?"),
  1102. i18n_catalog.i18nc("@label",
  1103. "The PrintCores and/or materials on your printer differ from those within your current project. For the best result, always slice for the PrintCores and materials that are inserted in your printer."),
  1104. buttons=QMessageBox.Yes + QMessageBox.No,
  1105. icon=QMessageBox.Question,
  1106. callback=callback
  1107. )
  1108. ## Convenience function to "blur" out all but the last 5 characters of the auth key.
  1109. # This can be used to debug print the key, without it compromising the security.
  1110. def _getSafeAuthKey(self):
  1111. if self._authentication_key is not None:
  1112. result = self._authentication_key[-5:]
  1113. result = "********" + result
  1114. return result
  1115. return self._authentication_key