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 @@
+
+
\ 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 @@
+
+
\ 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 @@
+
+
\ No newline at end of file