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