diff --git a/cura/API/Account.py b/cura/API/Account.py index 7273479de4..d1fda63d2b 100644 --- a/cura/API/Account.py +++ b/cura/API/Account.py @@ -1,9 +1,11 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from typing import Optional, Dict, TYPE_CHECKING +from datetime import datetime +from typing import Optional, Dict, TYPE_CHECKING, Union -from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty +from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty, QTimer, Q_ENUMS +from UM.Logger import Logger from UM.Message import Message from UM.i18n import i18nCatalog from cura.OAuth2.AuthorizationService import AuthorizationService @@ -16,6 +18,13 @@ if TYPE_CHECKING: i18n_catalog = i18nCatalog("cura") +class SyncState: + """QML: Cura.AccountSyncState""" + SYNCING = 0 + SUCCESS = 1 + ERROR = 2 + + ## The account API provides a version-proof bridge to use Ultimaker Accounts # # Usage: @@ -26,9 +35,21 @@ i18n_catalog = i18nCatalog("cura") # api.account.userProfile # Who is logged in`` # class Account(QObject): + # The interval in which sync services are automatically triggered + SYNC_INTERVAL = 30.0 # seconds + Q_ENUMS(SyncState) + # Signal emitted when user logged in or out. loginStateChanged = pyqtSignal(bool) accessTokenChanged = pyqtSignal() + syncRequested = pyqtSignal() + """Sync services may connect to this signal to receive sync triggers. + Services should be resilient to receiving a signal while they are still syncing, + either by ignoring subsequent signals or restarting a sync. + See setSyncState() for providing user feedback on the state of your service. + """ + lastSyncDateTimeChanged = pyqtSignal() + syncStateChanged = pyqtSignal(int) # because SyncState is an int Enum def __init__(self, application: "CuraApplication", parent = None) -> None: super().__init__(parent) @@ -37,6 +58,8 @@ class Account(QObject): self._error_message = None # type: Optional[Message] self._logged_in = False + self._sync_state = SyncState.SUCCESS + self._last_sync_str = "-" self._callback_port = 32118 self._oauth_root = UltimakerCloudAuthentication.CuraCloudAccountAPIRoot @@ -56,6 +79,16 @@ class Account(QObject): self._authorization_service = AuthorizationService(self._oauth_settings) + # Create a timer for automatic account sync + self._update_timer = QTimer() + self._update_timer.setInterval(int(self.SYNC_INTERVAL * 1000)) + # The timer is restarted explicitly after an update was processed. This prevents 2 concurrent updates + self._update_timer.setSingleShot(True) + self._update_timer.timeout.connect(self.syncRequested) + + self._sync_services = {} # type: Dict[str, int] + """contains entries "service_name" : SyncState""" + def initialize(self) -> None: self._authorization_service.initialize(self._application.getPreferences()) self._authorization_service.onAuthStateChanged.connect(self._onLoginStateChanged) @@ -63,6 +96,39 @@ class Account(QObject): self._authorization_service.accessTokenChanged.connect(self._onAccessTokenChanged) self._authorization_service.loadAuthDataFromPreferences() + def setSyncState(self, service_name: str, state: int) -> None: + """ Can be used to register sync services and update account sync states + + Contract: A sync service is expected exit syncing state in all cases, within reasonable time + + Example: `setSyncState("PluginSyncService", SyncState.SYNCING)` + :param service_name: A unique name for your service, such as `plugins` or `backups` + :param state: One of SyncState + """ + + 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 + elif any(val == SyncState.ERROR for val in self._sync_services.values()): + self._sync_state = SyncState.ERROR + else: + self._sync_state = SyncState.SUCCESS + + if self._sync_state != prev_state: + self.syncStateChanged.emit(self._sync_state) + + if self._sync_state == SyncState.SUCCESS: + self._last_sync_str = datetime.now().strftime("%d/%m/%Y %H:%M") + self.lastSyncDateTimeChanged.emit() + + if self._sync_state != SyncState.SYNCING: + # schedule new auto update after syncing completed (for whatever reason) + if not self._update_timer.isActive(): + self._update_timer.start() + def _onAccessTokenChanged(self): self.accessTokenChanged.emit() @@ -83,11 +149,18 @@ class Account(QObject): self._error_message.show() self._logged_in = False self.loginStateChanged.emit(False) + if self._update_timer.isActive(): + self._update_timer.stop() return if self._logged_in != logged_in: self._logged_in = logged_in self.loginStateChanged.emit(logged_in) + if logged_in: + self.sync() + else: + if self._update_timer.isActive(): + self._update_timer.stop() @pyqtSlot() def login(self) -> None: @@ -123,6 +196,25 @@ class Account(QObject): return None return user_profile.__dict__ + @pyqtProperty(str, notify=lastSyncDateTimeChanged) + def lastSyncDateTime(self) -> str: + return self._last_sync_str + + @pyqtSlot() + 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() + @pyqtSlot() def logout(self) -> None: if not self._logged_in: diff --git a/cura/CuraApplication.py b/cura/CuraApplication.py index fdbfb6a669..c679432104 100755 --- a/cura/CuraApplication.py +++ b/cura/CuraApplication.py @@ -48,6 +48,7 @@ from UM.Workspace.WorkspaceReader import WorkspaceReader from UM.i18n import i18nCatalog from cura import ApplicationMetadata from cura.API import CuraAPI +from cura.API.Account import Account from cura.Arranging.Arrange import Arrange from cura.Arranging.ArrangeObjectsAllBuildPlatesJob import ArrangeObjectsAllBuildPlatesJob from cura.Arranging.ArrangeObjectsJob import ArrangeObjectsJob @@ -1113,6 +1114,7 @@ class CuraApplication(QtApplication): from cura.API import CuraAPI qmlRegisterSingletonType(CuraAPI, "Cura", 1, 1, "API", self.getCuraAPI) + qmlRegisterUncreatableType(Account, "Cura", 1, 0, "AccountSyncState", "Could not create AccountSyncState") # As of Qt5.7, it is necessary to get rid of any ".." in the path for the singleton to work. actions_url = QUrl.fromLocalFile(os.path.abspath(Resources.getPath(CuraApplication.ResourceTypes.QmlFiles, "Actions.qml"))) diff --git a/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py b/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py index c6a8fb6b49..9c372096af 100644 --- a/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py +++ b/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py @@ -2,7 +2,7 @@ # Cura is released under the terms of the LGPLv3 or higher. import json -from typing import List, Dict, Any +from typing import List, Dict, Any, Set from typing import Optional from PyQt5.QtCore import QObject @@ -13,6 +13,7 @@ from UM.Logger import Logger from UM.Message import Message from UM.Signal import Signal from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope +from cura.API.Account import SyncState from cura.CuraApplication import CuraApplication, ApplicationMetadata from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope from .SubscribedPackagesModel import SubscribedPackagesModel @@ -20,6 +21,9 @@ from ..CloudApiModel import CloudApiModel class CloudPackageChecker(QObject): + + SYNC_SERVICE_NAME = "CloudPackageChecker" + def __init__(self, application: CuraApplication) -> None: super().__init__() @@ -32,23 +36,32 @@ class CloudPackageChecker(QObject): self._application.initializationFinished.connect(self._onAppInitialized) self._i18n_catalog = i18nCatalog("cura") self._sdk_version = ApplicationMetadata.CuraSDKVersion + self._last_notified_packages = set() # type: Set[str] + """Packages for which a notification has been shown. No need to bother the user twice fo equal content""" # This is a plugin, so most of the components required are not ready when # this is initialized. Therefore, we wait until the application is ready. def _onAppInitialized(self) -> None: self._package_manager = self._application.getPackageManager() # initial check - self._onLoginStateChanged() - # check again whenever the login state changes + self._getPackagesIfLoggedIn() + self._application.getCuraAPI().account.loginStateChanged.connect(self._onLoginStateChanged) + self._application.getCuraAPI().account.syncRequested.connect(self._getPackagesIfLoggedIn) def _onLoginStateChanged(self) -> None: + # reset session + self._last_notified_packages = set() + self._getPackagesIfLoggedIn() + + def _getPackagesIfLoggedIn(self) -> None: if self._application.getCuraAPI().account.isLoggedIn: self._getUserSubscribedPackages() else: self._hideSyncMessage() def _getUserSubscribedPackages(self) -> None: + self._application.getCuraAPI().account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.SYNCING) Logger.debug("Requesting subscribed packages metadata from server.") url = CloudApiModel.api_url_user_packages self._application.getHttpRequestManager().get(url, @@ -61,6 +74,7 @@ class CloudPackageChecker(QObject): Logger.log("w", "Requesting user packages failed, response code %s while trying to connect to %s", reply.attribute(QNetworkRequest.HttpStatusCodeAttribute), reply.url()) + self._application.getCuraAPI().account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.ERROR) return try: @@ -69,15 +83,22 @@ class CloudPackageChecker(QObject): if "errors" in json_data: for error in json_data["errors"]: Logger.log("e", "%s", error["title"]) + self._application.getCuraAPI().account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.ERROR) return self._handleCompatibilityData(json_data["data"]) except json.decoder.JSONDecodeError: Logger.log("w", "Received invalid JSON for user subscribed packages from the Web Marketplace") + self._application.getCuraAPI().account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.SUCCESS) + def _handleCompatibilityData(self, subscribed_packages_payload: List[Dict[str, Any]]) -> None: user_subscribed_packages = {plugin["package_id"] for plugin in subscribed_packages_payload} user_installed_packages = self._package_manager.getAllInstalledPackageIDs() + if user_subscribed_packages == self._last_notified_packages: + # already notified user about these + return + # We need to re-evaluate the dismissed packages # (i.e. some package might got updated to the correct SDK version in the meantime, # hence remove them from the Dismissed Incompatible list) @@ -87,12 +108,13 @@ class CloudPackageChecker(QObject): user_installed_packages += user_dismissed_packages # We check if there are packages installed in Web Marketplace but not in Cura marketplace - package_discrepancy = list(set(user_subscribed_packages).difference(user_installed_packages)) + package_discrepancy = list(user_subscribed_packages.difference(user_installed_packages)) if package_discrepancy: Logger.log("d", "Discrepancy found between Cloud subscribed packages and Cura installed packages") self._model.addDiscrepancies(package_discrepancy) self._model.initialize(self._package_manager, subscribed_packages_payload) self._showSyncMessage() + self._last_notified_packages = user_subscribed_packages def _showSyncMessage(self) -> None: """Show the message if it is not already shown""" diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py index 6fec436843..1c9670d87f 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py @@ -53,10 +53,10 @@ class CloudApiClient: ## Retrieves all the clusters for the user that is currently logged in. # \param on_finished: The function to be called after the result is parsed. - def getClusters(self, on_finished: Callable[[List[CloudClusterResponse]], Any]) -> None: + 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) + self._addCallback(reply, on_finished, CloudClusterResponse, failed) ## Retrieves the status of the given cluster. # \param cluster_id: The ID of the cluster. @@ -166,16 +166,24 @@ class CloudApiClient: reply: QNetworkReply, on_finished: Union[Callable[[CloudApiClientModel], Any], Callable[[List[CloudApiClientModel]], Any]], - model: Type[CloudApiClientModel]) -> None: + model: Type[CloudApiClientModel], + on_error: Optional[Callable] = None) -> None: def parse() -> None: self._anti_gc_callbacks.remove(parse) # Don't try to parse the reply if we didn't get one if reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) is None: + if on_error is not None: + on_error() return status_code, response = self._parseReply(reply) - self._parseModels(response, on_finished, model) + if status_code >= 300 and on_error is not None: + on_error() + else: + 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) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 1ed765d154..f233e59fe5 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -10,6 +10,7 @@ from UM.Logger import Logger # To log errors talking to the API. from UM.Message import Message from UM.Signal import Signal from cura.API import Account +from cura.API.Account import SyncState from cura.CuraApplication import CuraApplication from cura.Settings.CuraStackBuilder import CuraStackBuilder from cura.Settings.GlobalStack import GlobalStack @@ -27,9 +28,7 @@ class CloudOutputDeviceManager: META_CLUSTER_ID = "um_cloud_cluster_id" META_NETWORK_KEY = "um_network_key" - - # The interval with which the remote clusters are checked - CHECK_CLUSTER_INTERVAL = 30.0 # seconds + SYNC_SERVICE_NAME = "CloudOutputDeviceManager" # The translation catalog for this device. I18N_CATALOG = i18nCatalog("cura") @@ -44,16 +43,11 @@ class CloudOutputDeviceManager: self._api = CloudApiClient(self._account, on_error = lambda error: Logger.log("e", str(error))) self._account.loginStateChanged.connect(self._onLoginStateChanged) - # Create a timer to update the remote cluster list - self._update_timer = QTimer() - self._update_timer.setInterval(int(self.CHECK_CLUSTER_INTERVAL * 1000)) - # The timer is restarted explicitly after an update was processed. This prevents 2 concurrent updates - self._update_timer.setSingleShot(True) - self._update_timer.timeout.connect(self._getRemoteClusters) - # Ensure we don't start twice. self._running = False + self._syncing = False + def start(self): """Starts running the cloud output device manager, thus periodically requesting cloud data.""" @@ -62,18 +56,16 @@ class CloudOutputDeviceManager: if not self._account.isLoggedIn: return self._running = True - if not self._update_timer.isActive(): - self._update_timer.start() self._getRemoteClusters() + self._account.syncRequested.connect(self._getRemoteClusters) + def stop(self): """Stops running the cloud output device manager.""" if not self._running: return self._running = False - if self._update_timer.isActive(): - self._update_timer.stop() self._onGetRemoteClustersFinished([]) # Make sure we remove all cloud output devices. def refreshConnections(self) -> None: @@ -92,7 +84,14 @@ class CloudOutputDeviceManager: def _getRemoteClusters(self) -> None: """Gets all remote clusters from the API.""" - self._api.getClusters(self._onGetRemoteClustersFinished) + if self._syncing: + return + + Logger.info("Syncing cloud printer clusters") + + self._syncing = True + self._account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.SYNCING) + self._api.getClusters(self._onGetRemoteClustersFinished, self._onGetRemoteClusterFailed) def _onGetRemoteClustersFinished(self, clusters: List[CloudClusterResponse]) -> None: """Callback for when the request for getting the clusters is finished.""" @@ -115,8 +114,13 @@ class CloudOutputDeviceManager: if removed_device_keys: # If the removed device was active we should connect to the new active device self._connectToActiveMachine() - # Schedule a new update - self._update_timer.start() + + self._syncing = False + self._account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.SUCCESS) + + def _onGetRemoteClusterFailed(self): + self._syncing = False + self._account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.ERROR) def _onDevicesDiscovered(self, clusters: List[CloudClusterResponse]) -> None: """**Synchronously** create machines for discovered devices diff --git a/resources/qml/Account/SyncState.qml b/resources/qml/Account/SyncState.qml new file mode 100644 index 0000000000..7126aec314 --- /dev/null +++ b/resources/qml/Account/SyncState.qml @@ -0,0 +1,110 @@ +import QtQuick 2.10 +import QtQuick.Controls 2.3 + +import UM 1.4 as UM +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 + + width: childrenRect.width + height: childrenRect.height + anchors.horizontalCenter: parent.horizontalCenter + spacing: UM.Theme.getSize("narrow_margin").height + + UM.RecolorImage + { + id: icon + width: 20 * screenScaleFactor + height: width + + source: UM.Theme.getIcon("update") + color: palette.text + + RotationAnimator + { + id: updateAnimator + target: icon + from: 0 + to: 360 + duration: 1000 + loops: Animation.Infinite + running: true + + // reset rotation when stopped + onRunningChanged: { + if(!running) + { + icon.rotation = 0 + } + } + } + } + + Column + { + width: childrenRect.width + height: childrenRect.height + + Label + { + id: stateLabel + text: catalog.i18nc("@state", "Checking...") + color: UM.Theme.getColor("text") + font: UM.Theme.getFont("medium") + renderType: Text.NativeRendering + } + + Label + { + id: accountSyncButton + text: catalog.i18nc("@button", "Check for account updates") + color: UM.Theme.getColor("secondary_button_text") + font: UM.Theme.getFont("medium") + renderType: Text.NativeRendering + + MouseArea + { + anchors.fill: parent + onClicked: Cura.API.account.sync() + hoverEnabled: true + onEntered: accountSyncButton.font.underline = true + onExited: accountSyncButton.font.underline = false + } + } + } + + signal syncStateChanged(string newState) + + onSyncStateChanged: { + if(newState == Cura.AccountSyncState.SYNCING){ + syncRow.iconSource = UM.Theme.getIcon("update") + syncRow.labelText = 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") + } else if (newState == Cura.AccountSyncState.ERROR) { + syncRow.iconSource = UM.Theme.getIcon("warning_light") + syncRow.labelText = catalog.i18nc("@label", "Something went wrong...") + } else { + print("Error: unexpected sync state: " + newState) + } + + if(newState == Cura.AccountSyncState.SYNCING){ + syncRow.animateIconRotation = true + syncRow.syncButtonVisible = false + } else { + syncRow.animateIconRotation = false + syncRow.syncButtonVisible = true + } + } + + Component.onCompleted: Cura.API.account.syncStateChanged.connect(syncStateChanged) + + +} \ No newline at end of file diff --git a/resources/qml/Account/UserOperations.qml b/resources/qml/Account/UserOperations.qml index 10a4119dfc..f292c501f3 100644 --- a/resources/qml/Account/UserOperations.qml +++ b/resources/qml/Account/UserOperations.qml @@ -13,6 +13,11 @@ Column spacing: UM.Theme.getSize("default_margin").height + SystemPalette + { + id: palette + } + Label { id: title @@ -24,6 +29,24 @@ Column color: UM.Theme.getColor("text") } + SyncState + { + id: syncRow + } + + + + Label + { + id: lastSyncLabel + anchors.horizontalCenter: parent.horizontalCenter + horizontalAlignment: Text.AlignHCenter + renderType: Text.NativeRendering + text: catalog.i18nc("@label The argument is a timestamp", "Last update: %1").arg(Cura.API.account.lastSyncDateTime) + font: UM.Theme.getFont("default") + color: UM.Theme.getColor("text_medium") + } + Cura.SecondaryButton { id: accountButton @@ -53,4 +76,5 @@ Column onExited: signOutButton.font.underline = false } } + } diff --git a/resources/themes/cura-light/icons/checked.svg b/resources/themes/cura-light/icons/checked.svg new file mode 100644 index 0000000000..e98e2abcd7 --- /dev/null +++ b/resources/themes/cura-light/icons/checked.svg @@ -0,0 +1,12 @@ + + + + checked + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/resources/themes/cura-light/icons/update.svg b/resources/themes/cura-light/icons/update.svg new file mode 100644 index 0000000000..0a6e8fee5a --- /dev/null +++ b/resources/themes/cura-light/icons/update.svg @@ -0,0 +1,12 @@ + + + + update + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/resources/themes/cura-light/icons/warning_light.svg b/resources/themes/cura-light/icons/warning_light.svg new file mode 100644 index 0000000000..f9ca90f6a9 --- /dev/null +++ b/resources/themes/cura-light/icons/warning_light.svg @@ -0,0 +1,13 @@ + + + + warning + Created with Sketch. + + + + + + + + \ No newline at end of file