NetworkedPrinterOutputDevice.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  1. # Copyright (c) 2018 Ultimaker B.V.
  2. # Cura is released under the terms of the LGPLv3 or higher.
  3. from UM.FileHandler.FileHandler import FileHandler #For typing.
  4. from UM.Logger import Logger
  5. from UM.Scene.SceneNode import SceneNode #For typing.
  6. from cura.API import Account
  7. from cura.CuraApplication import CuraApplication
  8. from cura.PrinterOutput.PrinterOutputDevice import PrinterOutputDevice, ConnectionState, ConnectionType
  9. from PyQt5.QtNetwork import QHttpMultiPart, QHttpPart, QNetworkRequest, QNetworkAccessManager, QNetworkReply, QAuthenticator
  10. from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl, QCoreApplication
  11. from time import time
  12. from typing import Callable, Dict, List, Optional, Union
  13. from enum import IntEnum
  14. import os # To get the username
  15. import gzip
  16. from cura.Settings.CuraContainerRegistry import CuraContainerRegistry
  17. class AuthState(IntEnum):
  18. NotAuthenticated = 1
  19. AuthenticationRequested = 2
  20. Authenticated = 3
  21. AuthenticationDenied = 4
  22. AuthenticationReceived = 5
  23. class NetworkedPrinterOutputDevice(PrinterOutputDevice):
  24. authenticationStateChanged = pyqtSignal()
  25. def __init__(self, device_id, address: str, properties: Dict[bytes, bytes], connection_type: ConnectionType = ConnectionType.NetworkConnection, parent: QObject = None) -> None:
  26. super().__init__(device_id = device_id, connection_type = connection_type, parent = parent)
  27. self._manager = None # type: Optional[QNetworkAccessManager]
  28. self._last_manager_create_time = None # type: Optional[float]
  29. self._recreate_network_manager_time = 30
  30. self._timeout_time = 10 # After how many seconds of no response should a timeout occur?
  31. self._last_response_time = None # type: Optional[float]
  32. self._last_request_time = None # type: Optional[float]
  33. self._api_prefix = ""
  34. self._address = address
  35. self._properties = properties
  36. self._user_agent = "%s/%s " % (CuraApplication.getInstance().getApplicationName(),
  37. CuraApplication.getInstance().getVersion())
  38. self._onFinishedCallbacks = {} # type: Dict[str, Callable[[QNetworkReply], None]]
  39. self._authentication_state = AuthState.NotAuthenticated
  40. # QHttpMultiPart objects need to be kept alive and not garbage collected during the
  41. # HTTP which uses them. We hold references to these QHttpMultiPart objects here.
  42. self._kept_alive_multiparts = {} # type: Dict[QNetworkReply, QHttpMultiPart]
  43. self._sending_gcode = False
  44. self._compressing_gcode = False
  45. self._gcode = [] # type: List[str]
  46. self._connection_state_before_timeout = None # type: Optional[ConnectionState]
  47. def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mimetypes: bool = False,
  48. file_handler: Optional[FileHandler] = None, **kwargs: str) -> None:
  49. raise NotImplementedError("requestWrite needs to be implemented")
  50. def setAuthenticationState(self, authentication_state: AuthState) -> None:
  51. if self._authentication_state != authentication_state:
  52. self._authentication_state = authentication_state
  53. self.authenticationStateChanged.emit()
  54. @pyqtProperty(int, notify = authenticationStateChanged)
  55. def authenticationState(self) -> AuthState:
  56. return self._authentication_state
  57. def _compressDataAndNotifyQt(self, data_to_append: str) -> bytes:
  58. compressed_data = gzip.compress(data_to_append.encode("utf-8"))
  59. self._progress_message.setProgress(-1) # Tickle the message so that it's clear that it's still being used.
  60. QCoreApplication.processEvents() # Ensure that the GUI does not freeze.
  61. # Pretend that this is a response, as zipping might take a bit of time.
  62. # If we don't do this, the device might trigger a timeout.
  63. self._last_response_time = time()
  64. return compressed_data
  65. def _compressGCode(self) -> Optional[bytes]:
  66. self._compressing_gcode = True
  67. ## Mash the data into single string
  68. max_chars_per_line = int(1024 * 1024 / 4) # 1/4 MB per line.
  69. file_data_bytes_list = []
  70. batched_lines = []
  71. batched_lines_count = 0
  72. for line in self._gcode:
  73. if not self._compressing_gcode:
  74. self._progress_message.hide()
  75. # Stop trying to zip / send as abort was called.
  76. return None
  77. # if the gcode was read from a gcode file, self._gcode will be a list of all lines in that file.
  78. # Compressing line by line in this case is extremely slow, so we need to batch them.
  79. batched_lines.append(line)
  80. batched_lines_count += len(line)
  81. if batched_lines_count >= max_chars_per_line:
  82. file_data_bytes_list.append(self._compressDataAndNotifyQt("".join(batched_lines)))
  83. batched_lines = []
  84. batched_lines_count = 0
  85. # Don't miss the last batch (If any)
  86. if len(batched_lines) != 0:
  87. file_data_bytes_list.append(self._compressDataAndNotifyQt("".join(batched_lines)))
  88. self._compressing_gcode = False
  89. return b"".join(file_data_bytes_list)
  90. def _update(self) -> None:
  91. if self._last_response_time:
  92. time_since_last_response = time() - self._last_response_time
  93. else:
  94. time_since_last_response = 0
  95. if self._last_request_time:
  96. time_since_last_request = time() - self._last_request_time
  97. else:
  98. time_since_last_request = float("inf") # An irrelevantly large number of seconds
  99. if time_since_last_response > self._timeout_time >= time_since_last_request:
  100. # Go (or stay) into timeout.
  101. if self._connection_state_before_timeout is None:
  102. self._connection_state_before_timeout = self._connection_state
  103. self.setConnectionState(ConnectionState.Closed)
  104. # We need to check if the manager needs to be re-created. If we don't, we get some issues when OSX goes to
  105. # sleep.
  106. if time_since_last_response > self._recreate_network_manager_time:
  107. if self._last_manager_create_time is None or time() - self._last_manager_create_time > self._recreate_network_manager_time:
  108. self._createNetworkManager()
  109. assert(self._manager is not None)
  110. elif self._connection_state == ConnectionState.Closed:
  111. # Go out of timeout.
  112. if self._connection_state_before_timeout is not None: # sanity check, but it should never be None here
  113. self.setConnectionState(self._connection_state_before_timeout)
  114. self._connection_state_before_timeout = None
  115. def _createEmptyRequest(self, target: str, content_type: Optional[str] = "application/json") -> QNetworkRequest:
  116. url = QUrl("http://" + self._address + self._api_prefix + target)
  117. request = QNetworkRequest(url)
  118. if content_type is not None:
  119. request.setHeader(QNetworkRequest.ContentTypeHeader, content_type)
  120. request.setHeader(QNetworkRequest.UserAgentHeader, self._user_agent)
  121. return request
  122. ## This method was only available privately before, but it was actually called from SendMaterialJob.py.
  123. # We now have a public equivalent as well. We did not remove the private one as plugins might be using that.
  124. def createFormPart(self, content_header: str, data: bytes, content_type: Optional[str] = None) -> QHttpPart:
  125. return self._createFormPart(content_header, data, content_type)
  126. def _createFormPart(self, content_header: str, data: bytes, content_type: Optional[str] = None) -> QHttpPart:
  127. part = QHttpPart()
  128. if not content_header.startswith("form-data;"):
  129. content_header = "form_data; " + content_header
  130. part.setHeader(QNetworkRequest.ContentDispositionHeader, content_header)
  131. if content_type is not None:
  132. part.setHeader(QNetworkRequest.ContentTypeHeader, content_type)
  133. part.setBody(data)
  134. return part
  135. ## Convenience function to get the username, either from the cloud or from the OS.
  136. def _getUserName(self) -> str:
  137. # check first if we are logged in with the Ultimaker Account
  138. account = CuraApplication.getInstance().getCuraAPI().account # type: Account
  139. if account and account.isLoggedIn:
  140. return account.userName
  141. # Otherwise get the username from the US
  142. # The code below was copied from the getpass module, as we try to use as little dependencies as possible.
  143. for name in ("LOGNAME", "USER", "LNAME", "USERNAME"):
  144. user = os.environ.get(name)
  145. if user:
  146. return user
  147. return "Unknown User" # Couldn't find out username.
  148. def _clearCachedMultiPart(self, reply: QNetworkReply) -> None:
  149. if reply in self._kept_alive_multiparts:
  150. del self._kept_alive_multiparts[reply]
  151. def _validateManager(self) -> None:
  152. if self._manager is None:
  153. self._createNetworkManager()
  154. assert (self._manager is not None)
  155. ## Sends a put request to the given path.
  156. # \param url: The path after the API prefix.
  157. # \param data: The data to be sent in the body
  158. # \param content_type: The content type of the body data.
  159. # \param on_finished: The function to call when the response is received.
  160. # \param on_progress: The function to call when the progress changes. Parameters are bytes_sent / bytes_total.
  161. def put(self, url: str, data: Union[str, bytes], content_type: Optional[str] = "application/json",
  162. on_finished: Optional[Callable[[QNetworkReply], None]] = None,
  163. on_progress: Optional[Callable[[int, int], None]] = None) -> None:
  164. self._validateManager()
  165. request = self._createEmptyRequest(url, content_type = content_type)
  166. self._last_request_time = time()
  167. if not self._manager:
  168. Logger.log("e", "No network manager was created to execute the PUT call with.")
  169. return
  170. body = data if isinstance(data, bytes) else data.encode() # type: bytes
  171. reply = self._manager.put(request, body)
  172. self._registerOnFinishedCallback(reply, on_finished)
  173. if on_progress is not None:
  174. reply.uploadProgress.connect(on_progress)
  175. ## Sends a delete request to the given path.
  176. # \param url: The path after the API prefix.
  177. # \param on_finished: The function to be call when the response is received.
  178. def delete(self, url: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None:
  179. self._validateManager()
  180. request = self._createEmptyRequest(url)
  181. self._last_request_time = time()
  182. if not self._manager:
  183. Logger.log("e", "No network manager was created to execute the DELETE call with.")
  184. return
  185. reply = self._manager.deleteResource(request)
  186. self._registerOnFinishedCallback(reply, on_finished)
  187. ## Sends a get request to the given path.
  188. # \param url: The path after the API prefix.
  189. # \param on_finished: The function to be call when the response is received.
  190. def get(self, url: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None:
  191. self._validateManager()
  192. request = self._createEmptyRequest(url)
  193. self._last_request_time = time()
  194. if not self._manager:
  195. Logger.log("e", "No network manager was created to execute the GET call with.")
  196. return
  197. reply = self._manager.get(request)
  198. self._registerOnFinishedCallback(reply, on_finished)
  199. ## Sends a post request to the given path.
  200. # \param url: The path after the API prefix.
  201. # \param data: The data to be sent in the body
  202. # \param on_finished: The function to call when the response is received.
  203. # \param on_progress: The function to call when the progress changes. Parameters are bytes_sent / bytes_total.
  204. def post(self, url: str, data: Union[str, bytes],
  205. on_finished: Optional[Callable[[QNetworkReply], None]],
  206. on_progress: Optional[Callable[[int, int], None]] = None) -> None:
  207. self._validateManager()
  208. request = self._createEmptyRequest(url)
  209. self._last_request_time = time()
  210. if not self._manager:
  211. Logger.log("e", "Could not find manager.")
  212. return
  213. body = data if isinstance(data, bytes) else data.encode() # type: bytes
  214. reply = self._manager.post(request, body)
  215. if on_progress is not None:
  216. reply.uploadProgress.connect(on_progress)
  217. self._registerOnFinishedCallback(reply, on_finished)
  218. def postFormWithParts(self, target: str, parts: List[QHttpPart],
  219. on_finished: Optional[Callable[[QNetworkReply], None]],
  220. on_progress: Optional[Callable[[int, int], None]] = None) -> QNetworkReply:
  221. self._validateManager()
  222. request = self._createEmptyRequest(target, content_type=None)
  223. multi_post_part = QHttpMultiPart(QHttpMultiPart.FormDataType)
  224. for part in parts:
  225. multi_post_part.append(part)
  226. self._last_request_time = time()
  227. if self._manager is not None:
  228. reply = self._manager.post(request, multi_post_part)
  229. self._kept_alive_multiparts[reply] = multi_post_part
  230. if on_progress is not None:
  231. reply.uploadProgress.connect(on_progress)
  232. self._registerOnFinishedCallback(reply, on_finished)
  233. return reply
  234. else:
  235. Logger.log("e", "Could not find manager.")
  236. def postForm(self, target: str, header_data: str, body_data: bytes, on_finished: Optional[Callable[[QNetworkReply], None]], on_progress: Callable = None) -> None:
  237. post_part = QHttpPart()
  238. post_part.setHeader(QNetworkRequest.ContentDispositionHeader, header_data)
  239. post_part.setBody(body_data)
  240. self.postFormWithParts(target, [post_part], on_finished, on_progress)
  241. def _onAuthenticationRequired(self, reply: QNetworkReply, authenticator: QAuthenticator) -> None:
  242. Logger.log("w", "Request to {url} required authentication, which was not implemented".format(url = reply.url().toString()))
  243. def _createNetworkManager(self) -> None:
  244. Logger.log("d", "Creating network manager")
  245. if self._manager:
  246. self._manager.finished.disconnect(self._handleOnFinished)
  247. self._manager.authenticationRequired.disconnect(self._onAuthenticationRequired)
  248. self._manager = QNetworkAccessManager()
  249. self._manager.finished.connect(self._handleOnFinished)
  250. self._last_manager_create_time = time()
  251. self._manager.authenticationRequired.connect(self._onAuthenticationRequired)
  252. if self._properties.get(b"temporary", b"false") != b"true":
  253. self._checkCorrectGroupName(self.getId(), self.name)
  254. def _registerOnFinishedCallback(self, reply: QNetworkReply, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None:
  255. if on_finished is not None:
  256. self._onFinishedCallbacks[reply.url().toString() + str(reply.operation())] = on_finished
  257. ## This method checks if the name of the group stored in the definition container is correct.
  258. # After updating from 3.2 to 3.3 some group names may be temporary. If there is a mismatch in the name of the group
  259. # then all the container stacks are updated, both the current and the hidden ones.
  260. def _checkCorrectGroupName(self, device_id: str, group_name: str) -> None:
  261. global_container_stack = CuraApplication.getInstance().getGlobalContainerStack()
  262. active_machine_network_name = CuraApplication.getInstance().getMachineManager().activeMachineNetworkKey()
  263. if global_container_stack and device_id == active_machine_network_name:
  264. # Check if the group_name is correct. If not, update all the containers connected to the same printer
  265. if CuraApplication.getInstance().getMachineManager().activeMachineNetworkGroupName != group_name:
  266. metadata_filter = {"um_network_key": active_machine_network_name}
  267. containers = CuraContainerRegistry.getInstance().findContainerStacks(type="machine",
  268. **metadata_filter)
  269. for container in containers:
  270. container.setMetaDataEntry("group_name", group_name)
  271. def _handleOnFinished(self, reply: QNetworkReply) -> None:
  272. # Due to garbage collection, we need to cache certain bits of post operations.
  273. # As we don't want to keep them around forever, delete them if we get a reply.
  274. if reply.operation() == QNetworkAccessManager.PostOperation:
  275. self._clearCachedMultiPart(reply)
  276. if reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) is None:
  277. # No status code means it never even reached remote.
  278. return
  279. self._last_response_time = time()
  280. if self._connection_state == ConnectionState.Connecting:
  281. self.setConnectionState(ConnectionState.Connected)
  282. callback_key = reply.url().toString() + str(reply.operation())
  283. try:
  284. if callback_key in self._onFinishedCallbacks:
  285. self._onFinishedCallbacks[callback_key](reply)
  286. except Exception:
  287. Logger.logException("w", "something went wrong with callback")
  288. @pyqtSlot(str, result=str)
  289. def getProperty(self, key: str) -> str:
  290. bytes_key = key.encode("utf-8")
  291. if bytes_key in self._properties:
  292. return self._properties.get(bytes_key, b"").decode("utf-8")
  293. else:
  294. return ""
  295. def getProperties(self):
  296. return self._properties
  297. ## Get the unique key of this machine
  298. # \return key String containing the key of the machine.
  299. @pyqtProperty(str, constant = True)
  300. def key(self) -> str:
  301. return self._id
  302. ## The IP address of the printer.
  303. @pyqtProperty(str, constant = True)
  304. def address(self) -> str:
  305. return self._properties.get(b"address", b"").decode("utf-8")
  306. ## Name of the printer (as returned from the ZeroConf properties)
  307. @pyqtProperty(str, constant = True)
  308. def name(self) -> str:
  309. return self._properties.get(b"name", b"").decode("utf-8")
  310. ## Firmware version (as returned from the ZeroConf properties)
  311. @pyqtProperty(str, constant = True)
  312. def firmwareVersion(self) -> str:
  313. return self._properties.get(b"firmware_version", b"").decode("utf-8")
  314. @pyqtProperty(str, constant = True)
  315. def printerType(self) -> str:
  316. return self._properties.get(b"printer_type", b"Unknown").decode("utf-8")
  317. ## IP adress of this printer
  318. @pyqtProperty(str, constant = True)
  319. def ipAddress(self) -> str:
  320. return self._address