diff --git a/cura/API/Account.py b/cura/API/Account.py index 7e8802eddd..c33f1c13d5 100644 --- a/cura/API/Account.py +++ b/cura/API/Account.py @@ -23,6 +23,7 @@ class SyncState: SYNCING = 0 SUCCESS = 1 ERROR = 2 + IDLE = 3 ## The account API provides a version-proof bridge to use Ultimaker Accounts @@ -50,6 +51,7 @@ class Account(QObject): """ lastSyncDateTimeChanged = pyqtSignal() syncStateChanged = pyqtSignal(int) # because SyncState is an int Enum + manualSyncEnabledChanged = pyqtSignal(bool) def __init__(self, application: "CuraApplication", parent = None) -> None: super().__init__(parent) @@ -58,7 +60,8 @@ class Account(QObject): self._error_message = None # type: Optional[Message] self._logged_in = False - self._sync_state = SyncState.SUCCESS + self._sync_state = SyncState.IDLE + self._manual_sync_enabled = False self._last_sync_str = "-" self._callback_port = 32118 @@ -106,16 +109,21 @@ class Account(QObject): :param state: One of SyncState """ + Logger.info("Service {service} enters sync state {state}", service = service_name, state = state) + prev_state = self._sync_state self._sync_services[service_name] = state if any(val == SyncState.SYNCING for val in self._sync_services.values()): self._sync_state = SyncState.SYNCING + self._setManualSyncEnabled(False) elif any(val == SyncState.ERROR for val in self._sync_services.values()): self._sync_state = SyncState.ERROR + self._setManualSyncEnabled(True) else: self._sync_state = SyncState.SUCCESS + self._setManualSyncEnabled(False) if self._sync_state != prev_state: self.syncStateChanged.emit(self._sync_state) @@ -157,11 +165,31 @@ class Account(QObject): self._logged_in = logged_in self.loginStateChanged.emit(logged_in) if logged_in: - self.sync() + self._setManualSyncEnabled(False) + self._sync() else: if self._update_timer.isActive(): self._update_timer.stop() + def _sync(self) -> None: + """Signals all sync services to start syncing + + This can be considered a forced sync: even when a + sync is currently running, a sync will be requested. + """ + + if self._update_timer.isActive(): + self._update_timer.stop() + elif self._sync_state == SyncState.SYNCING: + Logger.warning("Starting a new sync while previous sync was not completed\n{}", str(self._sync_services)) + + self.syncRequested.emit() + + def _setManualSyncEnabled(self, enabled: bool) -> None: + if self._manual_sync_enabled != enabled: + self._manual_sync_enabled = enabled + self.manualSyncEnabledChanged.emit(enabled) + @pyqtSlot() @pyqtSlot(bool) def login(self, force_logout_before_login: bool = False) -> None: @@ -212,20 +240,23 @@ class Account(QObject): def lastSyncDateTime(self) -> str: return self._last_sync_str + @pyqtProperty(bool, notify=manualSyncEnabledChanged) + def manualSyncEnabled(self) -> bool: + return self._manual_sync_enabled + @pyqtSlot() - def sync(self) -> None: - """Signals all sync services to start syncing + @pyqtSlot(bool) + def sync(self, user_initiated: bool = False) -> None: + if user_initiated: + self._setManualSyncEnabled(False) - This can be considered a forced sync: even when a - sync is currently running, a sync will be requested. - """ + self._sync() - if self._update_timer.isActive(): - self._update_timer.stop() - elif self._sync_state == SyncState.SYNCING: - Logger.warning("Starting a new sync while previous sync was not completed\n{}", str(self._sync_services)) - - self.syncRequested.emit() + @pyqtSlot() + def popupOpened(self) -> None: + self._setManualSyncEnabled(True) + self._sync_state = SyncState.IDLE + self.syncStateChanged.emit(self._sync_state) @pyqtSlot() def logout(self) -> None: diff --git a/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py b/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py index 9c372096af..ef8e82f576 100644 --- a/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py +++ b/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py @@ -67,6 +67,7 @@ class CloudPackageChecker(QObject): self._application.getHttpRequestManager().get(url, callback = self._onUserPackagesRequestFinished, error_callback = self._onUserPackagesRequestFinished, + timeout=10, scope = self._scope) def _onUserPackagesRequestFinished(self, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"] = None) -> None: diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py index 1c9670d87f..b45f549b3c 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py @@ -6,11 +6,15 @@ from time import time from typing import Callable, List, Type, TypeVar, Union, Optional, Tuple, Dict, Any, cast from PyQt5.QtCore import QUrl -from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply, QNetworkAccessManager +from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply from UM.Logger import Logger +from UM.TaskManagement.HttpRequestManager import HttpRequestManager +from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope from cura.API import Account +from cura.CuraApplication import CuraApplication from cura.UltimakerCloud import UltimakerCloudAuthentication +from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope from .ToolPathUploader import ToolPathUploader from ..Models.BaseModel import BaseModel from ..Models.Http.CloudClusterResponse import CloudClusterResponse @@ -33,16 +37,20 @@ class CloudApiClient: CLUSTER_API_ROOT = "{}/connect/v1".format(ROOT_PATH) CURA_API_ROOT = "{}/cura/v1".format(ROOT_PATH) + DEFAULT_REQUEST_TIMEOUT = 10 # seconds + # In order to avoid garbage collection we keep the callbacks in this list. - _anti_gc_callbacks = [] # type: List[Callable[[], None]] + _anti_gc_callbacks = [] # type: List[Callable[[Any], None]] ## Initializes a new cloud API client. # \param account: The user's account object # \param on_error: The callback to be called whenever we receive errors from the server. - def __init__(self, account: Account, on_error: Callable[[List[CloudError]], None]) -> None: + def __init__(self, app: CuraApplication, on_error: Callable[[List[CloudError]], None]) -> None: super().__init__() - self._manager = QNetworkAccessManager() - self._account = account + self._app = app + self._account = app.getCuraAPI().account + self._scope = JsonDecoratorScope(UltimakerCloudScope(app)) + self._http = HttpRequestManager.getInstance() self._on_error = on_error self._upload = None # type: Optional[ToolPathUploader] @@ -55,16 +63,21 @@ class CloudApiClient: # \param on_finished: The function to be called after the result is parsed. def getClusters(self, on_finished: Callable[[List[CloudClusterResponse]], Any], failed: Callable) -> None: url = "{}/clusters?status=active".format(self.CLUSTER_API_ROOT) - reply = self._manager.get(self._createEmptyRequest(url)) - self._addCallback(reply, on_finished, CloudClusterResponse, failed) + self._http.get(url, + scope = self._scope, + callback = self._parseCallback(on_finished, CloudClusterResponse, failed), + error_callback = failed, + timeout = self.DEFAULT_REQUEST_TIMEOUT) ## Retrieves the status of the given cluster. # \param cluster_id: The ID of the cluster. # \param on_finished: The function to be called after the result is parsed. def getClusterStatus(self, cluster_id: str, on_finished: Callable[[CloudClusterStatus], Any]) -> None: url = "{}/clusters/{}/status".format(self.CLUSTER_API_ROOT, cluster_id) - reply = self._manager.get(self._createEmptyRequest(url)) - self._addCallback(reply, on_finished, CloudClusterStatus) + self._http.get(url, + scope = self._scope, + callback = self._parseCallback(on_finished, CloudClusterStatus), + timeout = self.DEFAULT_REQUEST_TIMEOUT) ## Requests the cloud to register the upload of a print job mesh. # \param request: The request object. @@ -72,9 +85,13 @@ class CloudApiClient: def requestUpload(self, request: CloudPrintJobUploadRequest, on_finished: Callable[[CloudPrintJobResponse], Any]) -> None: url = "{}/jobs/upload".format(self.CURA_API_ROOT) - body = json.dumps({"data": request.toDict()}) - reply = self._manager.put(self._createEmptyRequest(url), body.encode()) - self._addCallback(reply, on_finished, CloudPrintJobResponse) + data = json.dumps({"data": request.toDict()}).encode() + + self._http.put(url, + scope = self._scope, + data = data, + callback = self._parseCallback(on_finished, CloudPrintJobResponse), + timeout = self.DEFAULT_REQUEST_TIMEOUT) ## Uploads a print job tool path to the cloud. # \param print_job: The object received after requesting an upload with `self.requestUpload`. @@ -84,7 +101,7 @@ class CloudApiClient: # \param on_error: A function to be called if the upload fails. def uploadToolPath(self, print_job: CloudPrintJobResponse, mesh: bytes, on_finished: Callable[[], Any], on_progress: Callable[[int], Any], on_error: Callable[[], Any]): - self._upload = ToolPathUploader(self._manager, print_job, mesh, on_finished, on_progress, on_error) + self._upload = ToolPathUploader(self._http, print_job, mesh, on_finished, on_progress, on_error) self._upload.start() # Requests a cluster to print the given print job. @@ -93,8 +110,11 @@ class CloudApiClient: # \param on_finished: The function to be called after the result is parsed. def requestPrint(self, cluster_id: str, job_id: str, on_finished: Callable[[CloudPrintResponse], Any]) -> None: url = "{}/clusters/{}/print/{}".format(self.CLUSTER_API_ROOT, cluster_id, job_id) - reply = self._manager.post(self._createEmptyRequest(url), b"") - self._addCallback(reply, on_finished, CloudPrintResponse) + self._http.post(url, + scope = self._scope, + data = b"", + callback = self._parseCallback(on_finished, CloudPrintResponse), + timeout = self.DEFAULT_REQUEST_TIMEOUT) ## Send a print job action to the cluster for the given print job. # \param cluster_id: The ID of the cluster. @@ -104,7 +124,10 @@ class CloudApiClient: data: Optional[Dict[str, Any]] = None) -> None: body = json.dumps({"data": data}).encode() if data else b"" url = "{}/clusters/{}/print_jobs/{}/action/{}".format(self.CLUSTER_API_ROOT, cluster_id, cluster_job_id, action) - self._manager.post(self._createEmptyRequest(url), body) + self._http.post(url, + scope = self._scope, + data = body, + timeout = self.DEFAULT_REQUEST_TIMEOUT) ## We override _createEmptyRequest in order to add the user credentials. # \param url: The URL to request @@ -162,13 +185,12 @@ class CloudApiClient: # \param on_finished: The callback in case the response is successful. Depending on the endpoint it will be either # a list or a single item. # \param model: The type of the model to convert the response to. - def _addCallback(self, - reply: QNetworkReply, - on_finished: Union[Callable[[CloudApiClientModel], Any], - Callable[[List[CloudApiClientModel]], Any]], - model: Type[CloudApiClientModel], - on_error: Optional[Callable] = None) -> None: - def parse() -> None: + def _parseCallback(self, + on_finished: Union[Callable[[CloudApiClientModel], Any], + Callable[[List[CloudApiClientModel]], Any]], + model: Type[CloudApiClientModel], + on_error: Optional[Callable] = None) -> Callable[[QNetworkReply], None]: + def parse(reply: QNetworkReply) -> None: self._anti_gc_callbacks.remove(parse) # Don't try to parse the reply if we didn't get one @@ -184,6 +206,4 @@ class CloudApiClient: self._parseModels(response, on_finished, model) self._anti_gc_callbacks.append(parse) - reply.finished.connect(parse) - if on_error is not None: - reply.error.connect(on_error) + return parse diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 3322bbd639..84698ff371 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -4,6 +4,7 @@ import os from typing import Dict, List, Optional from PyQt5.QtCore import QTimer +from PyQt5.QtNetwork import QNetworkReply from UM import i18nCatalog from UM.Logger import Logger # To log errors talking to the API. @@ -40,7 +41,7 @@ class CloudOutputDeviceManager: # Persistent dict containing the remote clusters for the authenticated user. self._remote_clusters = {} # type: Dict[str, CloudOutputDevice] self._account = CuraApplication.getInstance().getCuraAPI().account # type: Account - self._api = CloudApiClient(self._account, on_error = lambda error: Logger.log("e", str(error))) + self._api = CloudApiClient(CuraApplication.getInstance(), on_error = lambda error: Logger.log("e", str(error))) self._account.loginStateChanged.connect(self._onLoginStateChanged) # Ensure we don't start twice. @@ -118,7 +119,7 @@ class CloudOutputDeviceManager: self._syncing = False self._account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.SUCCESS) - def _onGetRemoteClusterFailed(self): + def _onGetRemoteClusterFailed(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None: self._syncing = False self._account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.ERROR) @@ -284,4 +285,4 @@ class CloudOutputDeviceManager: output_device_manager = CuraApplication.getInstance().getOutputDeviceManager() if device.key not in output_device_manager.getOutputDeviceIds(): - output_device_manager.addOutputDevice(device) \ No newline at end of file + output_device_manager.addOutputDevice(device) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/ToolPathUploader.py b/plugins/UM3NetworkPrinting/src/Cloud/ToolPathUploader.py index 6aa341c0e5..79178049da 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/ToolPathUploader.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/ToolPathUploader.py @@ -1,11 +1,11 @@ # Copyright (c) 2019 Ultimaker B.V. # !/usr/bin/env python # -*- coding: utf-8 -*- -from PyQt5.QtCore import QUrl -from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply, QNetworkAccessManager -from typing import Optional, Callable, Any, Tuple, cast +from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply +from typing import Callable, Any, Tuple, cast, Dict, Optional from UM.Logger import Logger +from UM.TaskManagement.HttpRequestManager import HttpRequestManager from ..Models.Http.CloudPrintJobResponse import CloudPrintJobResponse @@ -23,16 +23,16 @@ class ToolPathUploader: BYTES_PER_REQUEST = 256 * 1024 ## Creates a mesh upload object. - # \param manager: The network access manager that will handle the HTTP requests. + # \param http: The HttpRequestManager that will handle the HTTP requests. # \param print_job: The print job response that was returned by the cloud after registering the upload. # \param data: The mesh bytes to be uploaded. # \param on_finished: The method to be called when done. # \param on_progress: The method to be called when the progress changes (receives a percentage 0-100). # \param on_error: The method to be called when an error occurs. - def __init__(self, manager: QNetworkAccessManager, print_job: CloudPrintJobResponse, data: bytes, + def __init__(self, http: HttpRequestManager, print_job: CloudPrintJobResponse, data: bytes, on_finished: Callable[[], Any], on_progress: Callable[[int], Any], on_error: Callable[[], Any] ) -> None: - self._manager = manager + self._http = http self._print_job = print_job self._data = data @@ -43,25 +43,12 @@ class ToolPathUploader: self._sent_bytes = 0 self._retries = 0 self._finished = False - self._reply = None # type: Optional[QNetworkReply] ## Returns the print job for which this object was created. @property def printJob(self): return self._print_job - ## Creates a network request to the print job upload URL, adding the needed content range header. - def _createRequest(self) -> QNetworkRequest: - 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._print_job.upload_url) - - return request - ## Determines the bytes that should be uploaded next. # \return: A tuple with the first and the last byte to upload. def _chunkRange(self) -> Tuple[int, int]: @@ -88,13 +75,23 @@ class ToolPathUploader: raise ValueError("The upload is already finished") first_byte, last_byte = self._chunkRange() - request = self._createRequest() + content_range = "bytes {}-{}/{}".format(first_byte, last_byte - 1, len(self._data)) - # now send the reply and subscribe to the results - self._reply = self._manager.put(request, self._data[first_byte:last_byte]) - self._reply.finished.connect(self._finishedCallback) - self._reply.uploadProgress.connect(self._progressCallback) - self._reply.error.connect(self._errorCallback) + headers = { + "Content-Type": cast(str, self._print_job.content_type), + "Content-Range": content_range + } # type: Dict[str, str] + + Logger.log("i", "Uploading %s to %s", content_range, self._print_job.upload_url) + + self._http.put( + url = cast(str, self._print_job.upload_url), + headers_dict = headers, + data = self._data[first_byte:last_byte], + callback = self._finishedCallback, + error_callback = self._errorCallback, + upload_progress_callback = self._progressCallback + ) ## Handles an update to the upload progress # \param bytes_sent: The amount of bytes sent in the current request. @@ -106,16 +103,14 @@ class ToolPathUploader: self._on_progress(int(total_sent / len(self._data) * 100)) ## Handles an error uploading. - def _errorCallback(self) -> None: - reply = cast(QNetworkReply, self._reply) + def _errorCallback(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None: body = bytes(reply.readAll()).decode() Logger.log("e", "Received error while uploading: %s", body) self.stop() self._on_error() ## Checks whether a chunk of data was uploaded successfully, starting the next chunk if needed. - def _finishedCallback(self) -> None: - reply = cast(QNetworkReply, self._reply) + def _finishedCallback(self, reply: QNetworkReply) -> None: Logger.log("i", "Finished callback %s %s", reply.attribute(QNetworkRequest.HttpStatusCodeAttribute), reply.url().toString()) @@ -133,7 +128,7 @@ class ToolPathUploader: # Http codes that are not to be retried are assumed to be errors. if status_code > 308: - self._errorCallback() + self._errorCallback(reply, None) return Logger.log("d", "status_code: %s, Headers: %s, body: %s", status_code, diff --git a/resources/qml/Account/AccountWidget.qml b/resources/qml/Account/AccountWidget.qml index 26b491ce15..eed711e745 100644 --- a/resources/qml/Account/AccountWidget.qml +++ b/resources/qml/Account/AccountWidget.qml @@ -108,7 +108,15 @@ Item } } - onClicked: popup.opened ? popup.close() : popup.open() + onClicked: { + if (popup.opened) + { + popup.close() + } else { + Cura.API.account.popupOpened() + popup.open() + } + } } Popup @@ -119,6 +127,7 @@ Item x: parent.width - width closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutsideParent + onOpened: Cura.API.account.popupOpened() opacity: opened ? 1 : 0 Behavior on opacity { NumberAnimation { duration: 100 } } diff --git a/resources/qml/Account/SyncState.qml b/resources/qml/Account/SyncState.qml index 7126aec314..98e5991b5a 100644 --- a/resources/qml/Account/SyncState.qml +++ b/resources/qml/Account/SyncState.qml @@ -7,11 +7,7 @@ import Cura 1.1 as Cura Row // sync state icon + message { - property alias iconSource: icon.source - property alias labelText: stateLabel.text - property alias syncButtonVisible: accountSyncButton.visible - property alias animateIconRotation: updateAnimator.running - + id: syncRow width: childrenRect.width height: childrenRect.height anchors.horizontalCenter: parent.horizontalCenter @@ -23,7 +19,7 @@ Row // sync state icon + message width: 20 * screenScaleFactor height: width - source: UM.Theme.getIcon("update") + source: Cura.API.account.manualSyncEnabled ? UM.Theme.getIcon("update") : UM.Theme.getIcon("checked") color: palette.text RotationAnimator @@ -54,10 +50,11 @@ Row // sync state icon + message Label { id: stateLabel - text: catalog.i18nc("@state", "Checking...") + text: catalog.i18nc("@state", catalog.i18nc("@label", "You are in sync with your account")) color: UM.Theme.getColor("text") font: UM.Theme.getFont("medium") renderType: Text.NativeRendering + visible: !Cura.API.account.manualSyncEnabled } Label @@ -67,11 +64,13 @@ Row // sync state icon + message color: UM.Theme.getColor("secondary_button_text") font: UM.Theme.getFont("medium") renderType: Text.NativeRendering + visible: Cura.API.account.manualSyncEnabled + height: visible ? accountSyncButton.intrinsicHeight : 0 MouseArea { anchors.fill: parent - onClicked: Cura.API.account.sync() + onClicked: Cura.API.account.sync(true) hoverEnabled: true onEntered: accountSyncButton.font.underline = true onExited: accountSyncButton.font.underline = false @@ -82,25 +81,25 @@ Row // sync state icon + message signal syncStateChanged(string newState) onSyncStateChanged: { - if(newState == Cura.AccountSyncState.SYNCING){ - syncRow.iconSource = UM.Theme.getIcon("update") - syncRow.labelText = catalog.i18nc("@label", "Checking...") + if(newState == Cura.AccountSyncState.IDLE){ + icon.source = UM.Theme.getIcon("update") + } else if(newState == Cura.AccountSyncState.SYNCING){ + icon.source = UM.Theme.getIcon("update") + stateLabel.text = catalog.i18nc("@label", "Checking...") } else if (newState == Cura.AccountSyncState.SUCCESS) { - syncRow.iconSource = UM.Theme.getIcon("checked") - syncRow.labelText = catalog.i18nc("@label", "You are up to date") + icon.source = UM.Theme.getIcon("checked") + stateLabel.text = catalog.i18nc("@label", "You are in sync with your account") } else if (newState == Cura.AccountSyncState.ERROR) { - syncRow.iconSource = UM.Theme.getIcon("warning_light") - syncRow.labelText = catalog.i18nc("@label", "Something went wrong...") + icon.source = UM.Theme.getIcon("warning_light") + stateLabel.text = catalog.i18nc("@label", "Something went wrong...") } else { print("Error: unexpected sync state: " + newState) } if(newState == Cura.AccountSyncState.SYNCING){ - syncRow.animateIconRotation = true - syncRow.syncButtonVisible = false + updateAnimator.running = true } else { - syncRow.animateIconRotation = false - syncRow.syncButtonVisible = true + updateAnimator.running = false } } diff --git a/resources/qml/Account/UserOperations.qml b/resources/qml/Account/UserOperations.qml index f292c501f3..c0f33c74cd 100644 --- a/resources/qml/Account/UserOperations.qml +++ b/resources/qml/Account/UserOperations.qml @@ -9,7 +9,10 @@ import Cura 1.1 as Cura Column { - width: Math.max(title.width, accountButton.width) + 2 * UM.Theme.getSize("default_margin").width + width: Math.max( + Math.max(title.width, accountButton.width) + 2 * UM.Theme.getSize("default_margin").width, + syncRow.width + ) spacing: UM.Theme.getSize("default_margin").height @@ -29,13 +32,10 @@ Column color: UM.Theme.getColor("text") } - SyncState - { + SyncState { id: syncRow } - - Label { id: lastSyncLabel diff --git a/resources/themes/cura-light/icons/checked.svg b/resources/themes/cura-light/icons/checked.svg index e98e2abcd7..22d1278667 100644 --- a/resources/themes/cura-light/icons/checked.svg +++ b/resources/themes/cura-light/icons/checked.svg @@ -4,9 +4,9 @@ checked Created with Sketch. - - - + + + \ No newline at end of file