diff --git a/cura/NetworkClient.py b/cura/NetworkClient.py index 4c43e58c4f..878158542a 100644 --- a/cura/NetworkClient.py +++ b/cura/NetworkClient.py @@ -3,7 +3,7 @@ from time import time from typing import Optional, Dict, Callable, List, Union -from PyQt5.QtCore import QUrl, QObject +from PyQt5.QtCore import QUrl from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkReply, QHttpMultiPart, QNetworkRequest, QHttpPart, \ QAuthenticator @@ -13,7 +13,7 @@ from UM.Logger import Logger ## Abstraction of QNetworkAccessManager for easier networking in Cura. # This was originally part of NetworkedPrinterOutputDevice but was moved out for re-use in other classes. -class NetworkClient(QObject): +class NetworkClient: def __init__(self) -> None: super().__init__() diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py index 7c3c08e044..8cdedd1229 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py @@ -4,12 +4,12 @@ import json from json import JSONDecodeError from typing import Callable, List, Type, TypeVar, Union, Optional, Tuple, Dict, Any -from PyQt5.QtCore import QObject, QUrl +from PyQt5.QtCore import QUrl from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply, QNetworkAccessManager from UM.Logger import Logger from cura.API import Account -from .ResumableUpload import ResumableUpload +from .MeshUploader import MeshUploader from ..Models import BaseModel from .Models.CloudClusterResponse import CloudClusterResponse from .Models.CloudErrorObject import CloudErrorObject @@ -37,6 +37,7 @@ class CloudApiClient: self._manager = QNetworkAccessManager() self._account = account self._on_error = on_error + self._upload = None # type: Optional[MeshUploader] ## Gets the account used for the API. @property @@ -77,10 +78,10 @@ class CloudApiClient: # \param on_finished: The function to be called after the result is parsed. It receives the print job ID. # \param on_progress: A function to be called during upload progress. It receives a percentage (0-100). # \param on_error: A function to be called if the upload fails. It receives a dict with the error. - def uploadMesh(self, upload_response: CloudPrintJobResponse, mesh: bytes, on_finished: Callable[[], Any], + def uploadMesh(self, print_job: CloudPrintJobResponse, mesh: bytes, on_finished: Callable[[], Any], on_progress: Callable[[int], Any], on_error: Callable[[], Any]): - ResumableUpload(self._manager, upload_response.upload_url, upload_response.content_type, mesh, on_finished, - on_progress, on_error).start() + self._upload = MeshUploader(self._manager, print_job, mesh, on_finished, on_progress, on_error) + self._upload.start() # Requests a cluster to print the given print job. # \param cluster_id: The ID of the cluster. diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index 09677d5e48..88c2f8da1d 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -8,11 +8,13 @@ from typing import Dict, List, Optional, Set from PyQt5.QtCore import QObject, QUrl, pyqtProperty, pyqtSignal, pyqtSlot from UM import i18nCatalog +from UM.Backend.Backend import BackendState from UM.FileHandler.FileHandler import FileHandler from UM.Logger import Logger from UM.Message import Message from UM.Qt.Duration import Duration, DurationFormat from UM.Scene.SceneNode import SceneNode +from cura.CuraApplication import CuraApplication from cura.PrinterOutput.NetworkedPrinterOutputDevice import AuthState, NetworkedPrinterOutputDevice from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel from plugins.UM3NetworkPrinting.src.Cloud.CloudOutputController import CloudOutputController @@ -92,6 +94,8 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): self._device_id = device_id self._account = api_client.account + CuraApplication.getInstance().getBackend().backendStateChange.connect(self._onBackendStateChange) + # We use the Cura Connect monitor tab to get most functionality right away. self._monitor_view_qml_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "../../resources/qml/MonitorStage.qml") @@ -116,6 +120,17 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): # A set of the user's job IDs that have finished self._finished_jobs = set() # type: Set[str] + # Reference to the uploaded print job + self._mesh = None # type: Optional[bytes] + self._uploaded_print_job = None # type: Optional[CloudPrintJobResponse] + + def disconnect(self) -> None: + CuraApplication.getInstance().getBackend().backendStateChange.disconnect(self._onBackendStateChange) + + def _onBackendStateChange(self, _: BackendState) -> None: + self._mesh = None + self._uploaded_print_job = None + ## Gets the host name of this device @property def host_name(self) -> str: @@ -146,7 +161,16 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): # Show an error message if we're already sending a job. if self._progress.visible: - self._onUploadError(T.BLOCKED_UPLOADING) + Message( + text = T.BLOCKED_UPLOADING, + title = T.ERROR, + lifetime = 10, + ).show() + return + + if self._uploaded_print_job: + # the mesh didn't change, let's not upload it again + self._api.requestPrint(self._device_id, self._uploaded_print_job.job_id, self._onPrintRequested) return # Indicate we have started sending a job. @@ -157,14 +181,15 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): Logger.log("e", "Missing file or mesh writer!") return self._onUploadError(T.COULD_NOT_EXPORT) - mesh_bytes = mesh_format.getBytes(nodes) + mesh = mesh_format.getBytes(nodes) + self._mesh = mesh request = CloudPrintJobUploadRequest( job_name = file_name, - file_size = len(mesh_bytes), + file_size = len(mesh), content_type = mesh_format.mime_type, ) - self._api.requestUpload(request, lambda response: self._onPrintJobCreated(mesh_bytes, response)) + self._api.requestUpload(request, self._onPrintJobCreated) ## Called when the network data should be updated. def _update(self) -> None: @@ -281,21 +306,22 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): ## Uploads the mesh when the print job was registered with the cloud API. # \param mesh: The bytes to upload. # \param job_response: The response received from the cloud API. - def _onPrintJobCreated(self, mesh: bytes, job_response: CloudPrintJobResponse) -> None: + def _onPrintJobCreated(self, job_response: CloudPrintJobResponse) -> None: self._progress.show() - self._api.uploadMesh(job_response, mesh, lambda: self._onPrintJobUploaded(job_response.job_id), - self._progress.update, self._onUploadError) + self._uploaded_print_job = job_response + self._api.uploadMesh(job_response, self._mesh, self._onPrintJobUploaded, self._progress.update, + self._onUploadError) ## Requests the print to be sent to the printer when we finished uploading the mesh. - # \param job_id: The ID of the job. - def _onPrintJobUploaded(self, job_id: str) -> None: + def _onPrintJobUploaded(self) -> None: self._progress.update(100) - self._api.requestPrint(self._device_id, job_id, self._onPrintRequested) + self._api.requestPrint(self._device_id, self._uploaded_print_job.job_id, self._onPrintRequested) ## Displays the given message if uploading the mesh has failed # \param message: The message to display. def _onUploadError(self, message = None) -> None: self._progress.hide() + self._uploaded_print_job = None Message( text = message or T.UPLOAD_ERROR, title = T.ERROR, diff --git a/plugins/UM3NetworkPrinting/src/Cloud/ResumableUpload.py b/plugins/UM3NetworkPrinting/src/Cloud/MeshUploader.py similarity index 91% rename from plugins/UM3NetworkPrinting/src/Cloud/ResumableUpload.py rename to plugins/UM3NetworkPrinting/src/Cloud/MeshUploader.py index 5e3bc9545e..4f0d6f2e81 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/ResumableUpload.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/MeshUploader.py @@ -6,9 +6,10 @@ from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply, QNetworkAccessManage from typing import Optional, Callable, Any, Tuple from UM.Logger import Logger +from src.Cloud.Models.CloudPrintJobResponse import CloudPrintJobResponse -class ResumableUpload: +class MeshUploader: MAX_RETRIES = 10 BYTES_PER_REQUEST = 256 * 1024 RETRY_HTTP_CODES = {500, 502, 503, 504} @@ -18,11 +19,10 @@ class ResumableUpload: # \param content_length: The total content length of the file, in bytes. # \param http_method: The HTTP method to be used, e.g. "POST" or "PUT". # \param timeout: The timeout for each chunk upload. Important: If None, no timeout is applied at all. - def __init__(self, manager: QNetworkAccessManager, url: str, content_type: str, data: bytes, + def __init__(self, manager: QNetworkAccessManager, print_job: CloudPrintJobResponse, data: bytes, on_finished: Callable[[], Any], on_progress: Callable[[int], Any], on_error: Callable[[], Any]): self._manager = manager - self._url = url - self._content_type = content_type + self._print_job = print_job self._data = data self._on_finished = on_finished @@ -34,17 +34,21 @@ class ResumableUpload: self._finished = False self._reply = None # type: Optional[QNetworkReply] + @property + def printJob(self): + return self._print_job + ## We override _createRequest in order to add the user credentials. # \param url: The URL to request # \param content_type: The type of the body contents. def _createRequest(self) -> QNetworkRequest: - request = QNetworkRequest(QUrl(self._url)) - request.setHeader(QNetworkRequest.ContentTypeHeader, self._content_type) + request = QNetworkRequest(QUrl(self._print_job.upload_url)) + request.setHeader(QNetworkRequest.ContentTypeHeader, self._print_job.content_type) first_byte, last_byte = self._chunkRange() content_range = "bytes {}-{}/{}".format(first_byte, last_byte - 1, len(self._data)) request.setRawHeader(b"Content-Range", content_range.encode()) - Logger.log("i", "Uploading %s to %s", content_range, self._url) + Logger.log("i", "Uploading %s to %s", content_range, self._print_job.upload_url) return request diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py index c391dc75dd..d31f59f85a 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py @@ -5,6 +5,7 @@ from unittest import TestCase from unittest.mock import patch, MagicMock from UM.Scene.SceneNode import SceneNode +from UM.Signal import Signal from cura.CuraApplication import CuraApplication from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel from src.Cloud.CloudApiClient import CloudApiClient @@ -26,6 +27,9 @@ class TestCloudOutputDevice(TestCase): def setUp(self): super().setUp() self.app = CuraApplication.getInstance() + self.backend = MagicMock(backendStateChange = Signal()) + self.app.setBackend(self.backend) + self.network = NetworkManagerMock() self.account = MagicMock(isLoggedIn=True, accessToken="TestAccessToken") self.onError = MagicMock()