From 4fd5c6e337930db6f2160acd740d02a6ba3fc715 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Thu, 30 Apr 2020 17:18:20 +0200 Subject: [PATCH 01/27] Add account sync assets CURA-7290 --- resources/themes/cura-light/icons/checked.svg | 12 ++++++++++++ resources/themes/cura-light/icons/update.svg | 12 ++++++++++++ resources/themes/cura-light/icons/warning_light.svg | 13 +++++++++++++ 3 files changed, 37 insertions(+) create mode 100644 resources/themes/cura-light/icons/checked.svg create mode 100644 resources/themes/cura-light/icons/update.svg create mode 100644 resources/themes/cura-light/icons/warning_light.svg 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 From 828e931f522da64ea6dba8d28409ddfa8628bcce Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Thu, 30 Apr 2020 17:19:14 +0200 Subject: [PATCH 02/27] Add manual sync button (non-functional) CURA-7290 --- cura/API/Account.py | 7 ++++ resources/qml/Account/UserOperations.qml | 42 ++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/cura/API/Account.py b/cura/API/Account.py index 9864de1aaa..ef72d972c1 100644 --- a/cura/API/Account.py +++ b/cura/API/Account.py @@ -4,6 +4,7 @@ from typing import Optional, Dict, TYPE_CHECKING from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty +from UM.Logger import Logger from UM.Message import Message from UM.i18n import i18nCatalog from cura.OAuth2.AuthorizationService import AuthorizationService @@ -128,6 +129,12 @@ class Account(QObject): return None return user_profile.__dict__ + @pyqtSlot() + def sync(self) -> None: + """Checks for new cloud printers""" + + Logger.info("Starting account sync") + @pyqtSlot() def logout(self) -> None: if not self._logged_in: diff --git a/resources/qml/Account/UserOperations.qml b/resources/qml/Account/UserOperations.qml index 10a4119dfc..2bf570a995 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,43 @@ Column color: UM.Theme.getColor("text") } + Row + { + width: childrenRect.width + height: childrenRect.height + anchors.horizontalCenter: parent.horizontalCenter + spacing: UM.Theme.getSize("narrow_margin").height + + + UM.RecolorImage + { + width: 20 * screenScaleFactor + height: width + + source: UM.Theme.getIcon("update") + color: palette.text + + } + + 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 + } + } + } + Cura.SecondaryButton { id: accountButton From 42f5456ed42e3d086b82e818cec19e125bcc45b7 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Thu, 30 Apr 2020 17:26:12 +0200 Subject: [PATCH 03/27] Animate the sync icon CURA-7290 --- resources/qml/Account/UserOperations.qml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/resources/qml/Account/UserOperations.qml b/resources/qml/Account/UserOperations.qml index 2bf570a995..dfeb8b0961 100644 --- a/resources/qml/Account/UserOperations.qml +++ b/resources/qml/Account/UserOperations.qml @@ -45,6 +45,15 @@ Column source: UM.Theme.getIcon("update") color: palette.text + RotationAnimator + { + from: 0; + to: 360; + duration: 1500 + loops: Animation.Infinite + running: false + } + } Label From a6c0ee6c82ea6d9d5577b77c43f5b0c29e88e48d Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Fri, 1 May 2020 14:50:18 +0200 Subject: [PATCH 04/27] Connect rotation animation to isSyncingChanged Code would be much cleaner if alwaysRunToEnd could be combined with Animation.Infinite loops to complete the current loop CURA-7290 --- resources/qml/Account/UserOperations.qml | 34 +++++++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/resources/qml/Account/UserOperations.qml b/resources/qml/Account/UserOperations.qml index dfeb8b0961..08c3e0a61a 100644 --- a/resources/qml/Account/UserOperations.qml +++ b/resources/qml/Account/UserOperations.qml @@ -37,23 +37,49 @@ Column spacing: UM.Theme.getSize("narrow_margin").height + UM.RecolorImage { + id: updateImage width: 20 * screenScaleFactor height: width source: UM.Theme.getIcon("update") color: palette.text + signal syncingChanged(bool newSyncing) + property double animationDuration: 1500 + + RotationAnimator { - from: 0; + id: updateAnimator + target: updateImage to: 360; - duration: 1500 - loops: Animation.Infinite - running: false } + onSyncingChanged: + { + if(newSyncing) + { + // start infinite rotation loop + updateAnimator.from = 0 + updateAnimator.duration = animationDuration + updateAnimator.loops = Animation.Infinite + updateAnimator.start() + } else { + // complete current rotation + updateAnimator.stop() + updateAnimator.from = updateImage.rotation + updateAnimator.duration = ((360 - updateImage.rotation) / 360) * animationDuration + updateAnimator.loops = 1 + updateAnimator.start() + } + } + + Component.onCompleted: Cura.API.account.isSyncingChanged.connect(syncingChanged) //todo connect to pyqtsignal or a pyqtproperty? + + } Label From 0aed6d3731f89a7e6ba2273d8eecab28c6401b7e Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Fri, 1 May 2020 15:56:09 +0200 Subject: [PATCH 05/27] Connect the manual sync button CURA-7290 --- cura/API/Account.py | 4 +++- .../src/Cloud/CloudOutputDeviceManager.py | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/cura/API/Account.py b/cura/API/Account.py index ef72d972c1..7c81358379 100644 --- a/cura/API/Account.py +++ b/cura/API/Account.py @@ -31,6 +31,8 @@ class Account(QObject): loginStateChanged = pyqtSignal(bool) accessTokenChanged = pyqtSignal() cloudPrintersDetectedChanged = pyqtSignal(bool) + isSyncingChanged = pyqtSignal(bool) + manualSyncRequested = pyqtSignal() def __init__(self, application: "CuraApplication", parent = None) -> None: super().__init__(parent) @@ -133,7 +135,7 @@ class Account(QObject): def sync(self) -> None: """Checks for new cloud printers""" - Logger.info("Starting account sync") + self.manualSyncRequested.emit() @pyqtSlot() def logout(self) -> None: diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 46136e3a1b..ef478f338a 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -54,6 +54,8 @@ class CloudOutputDeviceManager: # 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.""" @@ -66,6 +68,8 @@ class CloudOutputDeviceManager: self._update_timer.start() self._getRemoteClusters() + self._account.manualSyncRequested.connect(self._getRemoteClusters) + def stop(self): """Stops running the cloud output device manager.""" @@ -92,6 +96,14 @@ class CloudOutputDeviceManager: def _getRemoteClusters(self) -> None: """Gets all remote clusters from the API.""" + if self._syncing: + return + + if self._update_timer.isActive(): + self._update_timer.stop() + + self._syncing = True + self._account.isSyncingChanged.emit(True) self._api.getClusters(self._onGetRemoteClustersFinished) def _onGetRemoteClustersFinished(self, clusters: List[CloudClusterResponse]) -> None: @@ -119,6 +131,9 @@ class CloudOutputDeviceManager: if removed_device_keys: # If the removed device was active we should connect to the new active device self._connectToActiveMachine() + + self._syncing = False + self._account.isSyncingChanged.emit(False) # Schedule a new update self._update_timer.start() From acf36d1e42a1cd16ebca7909369e8d52064dee15 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Fri, 1 May 2020 17:44:58 +0200 Subject: [PATCH 06/27] Add Account sync last update datetime CURA-7290 --- cura/API/Account.py | 15 +++++++++++++++ resources/qml/Account/UserOperations.qml | 11 +++++++++++ 2 files changed, 26 insertions(+) diff --git a/cura/API/Account.py b/cura/API/Account.py index 7c81358379..84bbd566cf 100644 --- a/cura/API/Account.py +++ b/cura/API/Account.py @@ -1,5 +1,6 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. +from datetime import datetime from typing import Optional, Dict, TYPE_CHECKING from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty @@ -33,6 +34,7 @@ class Account(QObject): cloudPrintersDetectedChanged = pyqtSignal(bool) isSyncingChanged = pyqtSignal(bool) manualSyncRequested = pyqtSignal() + lastSyncDateTimeChanged = pyqtSignal() def __init__(self, application: "CuraApplication", parent = None) -> None: super().__init__(parent) @@ -41,6 +43,7 @@ class Account(QObject): self._error_message = None # type: Optional[Message] self._logged_in = False + self._last_sync_str = "-" self._callback_port = 32118 self._oauth_root = UltimakerCloudAuthentication.CuraCloudAccountAPIRoot @@ -66,6 +69,7 @@ class Account(QObject): self._authorization_service.onAuthenticationError.connect(self._onLoginStateChanged) self._authorization_service.accessTokenChanged.connect(self._onAccessTokenChanged) self._authorization_service.loadAuthDataFromPreferences() + self.isSyncingChanged.connect(self._onIsSyncingChanged) def _onAccessTokenChanged(self): self.accessTokenChanged.emit() @@ -131,6 +135,17 @@ class Account(QObject): return None return user_profile.__dict__ + def _onIsSyncingChanged(self, active: bool): + Logger.info("active: {}", active) + if not active: + # finished + self._last_sync_str = datetime.now().strftime("%d/%m/%Y %H:%M:%S") + self.lastSyncDateTimeChanged.emit() + + @pyqtProperty(str, notify=lastSyncDateTimeChanged) + def lastSyncDateTime(self) -> str: + return self._last_sync_str + @pyqtSlot() def sync(self) -> None: """Checks for new cloud printers""" diff --git a/resources/qml/Account/UserOperations.qml b/resources/qml/Account/UserOperations.qml index 08c3e0a61a..188f1d56ea 100644 --- a/resources/qml/Account/UserOperations.qml +++ b/resources/qml/Account/UserOperations.qml @@ -101,6 +101,17 @@ Column } } + 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 From 4b88247af8383966bb5eb21b454c893cb12f232e Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Fri, 1 May 2020 17:49:40 +0200 Subject: [PATCH 07/27] Account sync: remove debug info CURA-7290 --- cura/API/Account.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cura/API/Account.py b/cura/API/Account.py index 84bbd566cf..4703e92e2f 100644 --- a/cura/API/Account.py +++ b/cura/API/Account.py @@ -136,10 +136,9 @@ class Account(QObject): return user_profile.__dict__ def _onIsSyncingChanged(self, active: bool): - Logger.info("active: {}", active) if not active: # finished - self._last_sync_str = datetime.now().strftime("%d/%m/%Y %H:%M:%S") + self._last_sync_str = datetime.now().strftime("%d/%m/%Y %H:%M") self.lastSyncDateTimeChanged.emit() @pyqtProperty(str, notify=lastSyncDateTimeChanged) From 4e7f446fe14e6e0eed830ab3504cef00e4463c8a Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Mon, 4 May 2020 11:26:20 +0200 Subject: [PATCH 08/27] Account sync: create additional sync states CURA-7290 --- cura/API/Account.py | 13 +++- resources/qml/Account/SyncStateError.qml | 38 ++++++++++ resources/qml/Account/SyncStateIdle.qml | 44 +++++++++++ resources/qml/Account/SyncStateSuccess.qml | 32 ++++++++ resources/qml/Account/SyncStateSyncing.qml | 43 +++++++++++ resources/qml/Account/UserOperations.qml | 86 ++++------------------ 6 files changed, 184 insertions(+), 72 deletions(-) create mode 100644 resources/qml/Account/SyncStateError.qml create mode 100644 resources/qml/Account/SyncStateIdle.qml create mode 100644 resources/qml/Account/SyncStateSuccess.qml create mode 100644 resources/qml/Account/SyncStateSyncing.qml diff --git a/cura/API/Account.py b/cura/API/Account.py index 4703e92e2f..41f2b20c86 100644 --- a/cura/API/Account.py +++ b/cura/API/Account.py @@ -35,6 +35,7 @@ class Account(QObject): isSyncingChanged = pyqtSignal(bool) manualSyncRequested = pyqtSignal() lastSyncDateTimeChanged = pyqtSignal() + syncStateChanged = pyqtSignal() def __init__(self, application: "CuraApplication", parent = None) -> None: super().__init__(parent) @@ -43,6 +44,7 @@ class Account(QObject): self._error_message = None # type: Optional[Message] self._logged_in = False + self._sync_state = "idle" self._last_sync_str = "-" self._callback_port = 32118 @@ -136,15 +138,24 @@ class Account(QObject): return user_profile.__dict__ def _onIsSyncingChanged(self, active: bool): - if not active: + if active: + self._sync_state = "syncing" + else: # finished + self._sync_state = "success" self._last_sync_str = datetime.now().strftime("%d/%m/%Y %H:%M") self.lastSyncDateTimeChanged.emit() + self.syncStateChanged.emit() + @pyqtProperty(str, notify=lastSyncDateTimeChanged) def lastSyncDateTime(self) -> str: return self._last_sync_str + @pyqtProperty(str, notify=syncStateChanged) + def syncState(self) -> str: + return self._sync_state + @pyqtSlot() def sync(self) -> None: """Checks for new cloud printers""" diff --git a/resources/qml/Account/SyncStateError.qml b/resources/qml/Account/SyncStateError.qml new file mode 100644 index 0000000000..ad8e3dfe2d --- /dev/null +++ b/resources/qml/Account/SyncStateError.qml @@ -0,0 +1,38 @@ +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 +{ + width: childrenRect.width + height: childrenRect.height + anchors.horizontalCenter: parent.horizontalCenter + spacing: UM.Theme.getSize("narrow_margin").height + + + + UM.RecolorImage + { + id: updateImage + width: 20 * screenScaleFactor + height: width + + source: UM.Theme.getIcon("warning_light") + color: palette.text + + signal syncingChanged(bool newSyncing) + property double animationDuration: 1500 + + } + + Label + { + id: syncStateSuccessLabel + text: catalog.i18nc("@info", "Something went wrong...\nPlease try again later.") + color: UM.Theme.getColor("text") + font: UM.Theme.getFont("medium") + renderType: Text.NativeRendering + } +} \ No newline at end of file diff --git a/resources/qml/Account/SyncStateIdle.qml b/resources/qml/Account/SyncStateIdle.qml new file mode 100644 index 0000000000..d08bcc7bad --- /dev/null +++ b/resources/qml/Account/SyncStateIdle.qml @@ -0,0 +1,44 @@ +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 +{ + width: childrenRect.width + height: childrenRect.height + anchors.horizontalCenter: parent.horizontalCenter + spacing: UM.Theme.getSize("narrow_margin").height + + + + UM.RecolorImage + { + id: updateImage + width: 20 * screenScaleFactor + height: width + + source: UM.Theme.getIcon("update") + color: palette.text + + } + + 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 + } + } +} \ No newline at end of file diff --git a/resources/qml/Account/SyncStateSuccess.qml b/resources/qml/Account/SyncStateSuccess.qml new file mode 100644 index 0000000000..fa6051a71b --- /dev/null +++ b/resources/qml/Account/SyncStateSuccess.qml @@ -0,0 +1,32 @@ +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 +{ + width: childrenRect.width + height: childrenRect.height + anchors.horizontalCenter: parent.horizontalCenter + spacing: UM.Theme.getSize("narrow_margin").height + + UM.RecolorImage + { + id: updateImage + width: 20 * screenScaleFactor + height: width + + source: UM.Theme.getIcon("checked") + color: palette.text + } + + Label + { + id: syncStateSuccessLabel + text: catalog.i18nc("@info", "You are up to date") + color: UM.Theme.getColor("text") + font: UM.Theme.getFont("medium") + renderType: Text.NativeRendering + } +} \ No newline at end of file diff --git a/resources/qml/Account/SyncStateSyncing.qml b/resources/qml/Account/SyncStateSyncing.qml new file mode 100644 index 0000000000..1dd5101254 --- /dev/null +++ b/resources/qml/Account/SyncStateSyncing.qml @@ -0,0 +1,43 @@ +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 +{ + width: childrenRect.width + height: childrenRect.height + anchors.horizontalCenter: parent.horizontalCenter + spacing: UM.Theme.getSize("narrow_margin").height + + UM.RecolorImage + { + id: updateImage + width: 20 * screenScaleFactor + height: width + + source: UM.Theme.getIcon("update") + color: palette.text + + RotationAnimator + { + id: updateAnimator + target: updateImage + from: 0 + to: 360 + duration: 1000 + loops: Animation.Infinite + running: true + } + } + + Label + { + id: accountSyncButton + text: catalog.i18nc("@button", "Checking...") + color: UM.Theme.getColor("text") + font: UM.Theme.getFont("medium") + renderType: Text.NativeRendering + } +} \ No newline at end of file diff --git a/resources/qml/Account/UserOperations.qml b/resources/qml/Account/UserOperations.qml index 188f1d56ea..da4d4dabbe 100644 --- a/resources/qml/Account/UserOperations.qml +++ b/resources/qml/Account/UserOperations.qml @@ -29,79 +29,23 @@ Column color: UM.Theme.getColor("text") } - Row - { - width: childrenRect.width - height: childrenRect.height - anchors.horizontalCenter: parent.horizontalCenter - spacing: UM.Theme.getSize("narrow_margin").height - - - - UM.RecolorImage - { - id: updateImage - width: 20 * screenScaleFactor - height: width - - source: UM.Theme.getIcon("update") - color: palette.text - - signal syncingChanged(bool newSyncing) - property double animationDuration: 1500 - - - RotationAnimator - { - id: updateAnimator - target: updateImage - to: 360; - } - - onSyncingChanged: - { - if(newSyncing) - { - // start infinite rotation loop - updateAnimator.from = 0 - updateAnimator.duration = animationDuration - updateAnimator.loops = Animation.Infinite - updateAnimator.start() - } else { - // complete current rotation - updateAnimator.stop() - updateAnimator.from = updateImage.rotation - updateAnimator.duration = ((360 - updateImage.rotation) / 360) * animationDuration - updateAnimator.loops = 1 - updateAnimator.start() - } - } - - Component.onCompleted: Cura.API.account.isSyncingChanged.connect(syncingChanged) //todo connect to pyqtsignal or a pyqtproperty? - - - } - - 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 - } - } + SyncStateIdle { + visible: Cura.API.account.syncState == "idle" } - Label + SyncStateSyncing { + visible: Cura.API.account.syncState == "syncing" + } + + SyncStateSuccess { + visible: Cura.API.account.syncState == "success" + } + + SyncStateError { + visible: Cura.API.account.syncState == "error" + } + + Label { id: lastSyncLabel anchors.horizontalCenter: parent.horizontalCenter From 81d02d5d58ee30293ee0d343b821815db34ba5f4 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Mon, 4 May 2020 13:19:16 +0200 Subject: [PATCH 09/27] Implement printer cloud sync error state CURA-7290 --- cura/API/Account.py | 11 ++++------- .../src/Cloud/CloudApiClient.py | 16 ++++++++++++---- .../src/Cloud/CloudOutputDeviceManager.py | 12 +++++++++--- 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/cura/API/Account.py b/cura/API/Account.py index 41f2b20c86..ebab9c6677 100644 --- a/cura/API/Account.py +++ b/cura/API/Account.py @@ -32,7 +32,7 @@ class Account(QObject): loginStateChanged = pyqtSignal(bool) accessTokenChanged = pyqtSignal() cloudPrintersDetectedChanged = pyqtSignal(bool) - isSyncingChanged = pyqtSignal(bool) + isSyncingChanged = pyqtSignal(str) manualSyncRequested = pyqtSignal() lastSyncDateTimeChanged = pyqtSignal() syncStateChanged = pyqtSignal() @@ -137,15 +137,12 @@ class Account(QObject): return None return user_profile.__dict__ - def _onIsSyncingChanged(self, active: bool): - if active: - self._sync_state = "syncing" - else: - # finished - self._sync_state = "success" + def _onIsSyncingChanged(self, newState: str): + if newState == "success": self._last_sync_str = datetime.now().strftime("%d/%m/%Y %H:%M") self.lastSyncDateTimeChanged.emit() + self._sync_state = newState self.syncStateChanged.emit() @pyqtProperty(str, notify=lastSyncDateTimeChanged) 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 ef478f338a..c95d94f0fd 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -103,8 +103,8 @@ class CloudOutputDeviceManager: self._update_timer.stop() self._syncing = True - self._account.isSyncingChanged.emit(True) - self._api.getClusters(self._onGetRemoteClustersFinished) + self._account.isSyncingChanged.emit("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.""" @@ -133,7 +133,13 @@ class CloudOutputDeviceManager: self._connectToActiveMachine() self._syncing = False - self._account.isSyncingChanged.emit(False) + self._account.isSyncingChanged.emit("success") + # Schedule a new update + self._update_timer.start() + + def _onGetRemoteClusterFailed(self): + self._syncing = False + self._account.isSyncingChanged.emit("error") # Schedule a new update self._update_timer.start() From 049c1946d4d416fae92948a2352a0f26cdf0f32f Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Mon, 4 May 2020 13:52:40 +0200 Subject: [PATCH 10/27] Create Account sync state interface to allow for multiple sync services Needed to add the package sync CURA-7290 --- cura/API/Account.py | 44 ++++++++++++++----- .../src/Cloud/CloudOutputDeviceManager.py | 7 +-- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/cura/API/Account.py b/cura/API/Account.py index ebab9c6677..96ba16c53b 100644 --- a/cura/API/Account.py +++ b/cura/API/Account.py @@ -32,11 +32,12 @@ class Account(QObject): loginStateChanged = pyqtSignal(bool) accessTokenChanged = pyqtSignal() cloudPrintersDetectedChanged = pyqtSignal(bool) - isSyncingChanged = pyqtSignal(str) manualSyncRequested = pyqtSignal() lastSyncDateTimeChanged = pyqtSignal() syncStateChanged = pyqtSignal() + SYNC_STATES = ["syncing", "success", "error"] + def __init__(self, application: "CuraApplication", parent = None) -> None: super().__init__(parent) self._application = application @@ -65,13 +66,44 @@ class Account(QObject): self._authorization_service = AuthorizationService(self._oauth_settings) + self._sync_clients = {} + """contains entries "client_name" : "state["success"|"error|"syncing"]""" + def initialize(self) -> None: self._authorization_service.initialize(self._application.getPreferences()) self._authorization_service.onAuthStateChanged.connect(self._onLoginStateChanged) self._authorization_service.onAuthenticationError.connect(self._onLoginStateChanged) self._authorization_service.accessTokenChanged.connect(self._onAccessTokenChanged) self._authorization_service.loadAuthDataFromPreferences() - self.isSyncingChanged.connect(self._onIsSyncingChanged) + + def setSyncState(self, service_name: str, state: str) -> None: + """ Can be used to register and update account sync states + + Example: `setSyncState("packages", "syncing")` + :param service_name: A unique name for your service, such as `plugins` or `backups` + :param state: One of Account.SYNC_STATES + """ + + prev_state = self._sync_state + + if state not in Account.SYNC_STATES: + raise AttributeError("Invalid state parameter: {}".format(state)) + + self._sync_clients[service_name] = state + + if any(val == "syncing" for val in self._sync_clients.values()): + self._sync_state = "syncing" + elif any(val == "error" for val in self._sync_clients.values()): + self._sync_state = "error" + else: + self._sync_state = "success" + + if self._sync_state != prev_state: + self.syncStateChanged.emit() + + if self._sync_state == "success": + self._last_sync_str = datetime.now().strftime("%d/%m/%Y %H:%M") + self.lastSyncDateTimeChanged.emit() def _onAccessTokenChanged(self): self.accessTokenChanged.emit() @@ -137,14 +169,6 @@ class Account(QObject): return None return user_profile.__dict__ - def _onIsSyncingChanged(self, newState: str): - if newState == "success": - self._last_sync_str = datetime.now().strftime("%d/%m/%Y %H:%M") - self.lastSyncDateTimeChanged.emit() - - self._sync_state = newState - self.syncStateChanged.emit() - @pyqtProperty(str, notify=lastSyncDateTimeChanged) def lastSyncDateTime(self) -> str: return self._last_sync_str diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index c95d94f0fd..8334960025 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -27,6 +27,7 @@ class CloudOutputDeviceManager: META_CLUSTER_ID = "um_cloud_cluster_id" META_NETWORK_KEY = "um_network_key" + SYNC_SERVICE_NAME = "CloudOutputDeviceManager" # The interval with which the remote clusters are checked CHECK_CLUSTER_INTERVAL = 30.0 # seconds @@ -103,7 +104,7 @@ class CloudOutputDeviceManager: self._update_timer.stop() self._syncing = True - self._account.isSyncingChanged.emit("syncing") + self._account.setSyncState(self.SYNC_SERVICE_NAME, "syncing") self._api.getClusters(self._onGetRemoteClustersFinished, self._onGetRemoteClusterFailed) def _onGetRemoteClustersFinished(self, clusters: List[CloudClusterResponse]) -> None: @@ -133,13 +134,13 @@ class CloudOutputDeviceManager: self._connectToActiveMachine() self._syncing = False - self._account.isSyncingChanged.emit("success") + self._account.setSyncState(self.SYNC_SERVICE_NAME, "success") # Schedule a new update self._update_timer.start() def _onGetRemoteClusterFailed(self): self._syncing = False - self._account.isSyncingChanged.emit("error") + self._account.setSyncState(self.SYNC_SERVICE_NAME, "error") # Schedule a new update self._update_timer.start() From eeea1692fda42f6a1d04accf25897c552e1dc079 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Mon, 4 May 2020 14:00:17 +0200 Subject: [PATCH 11/27] Register CloudPackageChecker as Account sync service CURA-7290 --- .../src/CloudSync/CloudPackageChecker.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py b/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py index 5fdd919749..3e1c4f5cdf 100644 --- a/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py +++ b/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py @@ -20,6 +20,9 @@ from ..CloudApiModel import CloudApiModel class CloudPackageChecker(QObject): + + SYNC_SERVICE_NAME = "CloudPackageChecker" + def __init__(self, application: CuraApplication) -> None: super().__init__() @@ -38,17 +41,19 @@ class CloudPackageChecker(QObject): def _onAppInitialized(self) -> None: self._package_manager = self._application.getPackageManager() # initial check - self._onLoginStateChanged() - # check again whenever the login state changes - self._application.getCuraAPI().account.loginStateChanged.connect(self._onLoginStateChanged) + self._getPackagesIfLoggedIn() - def _onLoginStateChanged(self) -> None: + self._application.getCuraAPI().account.loginStateChanged.connect(self._getPackagesIfLoggedIn) + self._application.getCuraAPI().account.manualSyncRequested.connect(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, "syncing") Logger.debug("Requesting subscribed packages metadata from server.") url = CloudApiModel.api_url_user_packages self._application.getHttpRequestManager().get(url, @@ -61,6 +66,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, "error") return try: @@ -69,11 +75,14 @@ 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, "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, "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.getUserInstalledPackages() From 637a241d992340577497a02378c0da5e5e5ee89d Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Mon, 4 May 2020 15:13:38 +0200 Subject: [PATCH 12/27] Use single qml file for sync states CURA-7290 --- cura/API/Account.py | 4 +- resources/qml/Account/SyncState.qml | 81 ++++++++++++++++++++++ resources/qml/Account/SyncStateError.qml | 38 ---------- resources/qml/Account/SyncStateIdle.qml | 44 ------------ resources/qml/Account/SyncStateSuccess.qml | 32 --------- resources/qml/Account/SyncStateSyncing.qml | 43 ------------ resources/qml/Account/UserOperations.qml | 43 ++++++++---- 7 files changed, 114 insertions(+), 171 deletions(-) create mode 100644 resources/qml/Account/SyncState.qml delete mode 100644 resources/qml/Account/SyncStateError.qml delete mode 100644 resources/qml/Account/SyncStateIdle.qml delete mode 100644 resources/qml/Account/SyncStateSuccess.qml delete mode 100644 resources/qml/Account/SyncStateSyncing.qml diff --git a/cura/API/Account.py b/cura/API/Account.py index 96ba16c53b..93467790b4 100644 --- a/cura/API/Account.py +++ b/cura/API/Account.py @@ -34,7 +34,7 @@ class Account(QObject): cloudPrintersDetectedChanged = pyqtSignal(bool) manualSyncRequested = pyqtSignal() lastSyncDateTimeChanged = pyqtSignal() - syncStateChanged = pyqtSignal() + syncStateChanged = pyqtSignal(str) SYNC_STATES = ["syncing", "success", "error"] @@ -99,7 +99,7 @@ class Account(QObject): self._sync_state = "success" if self._sync_state != prev_state: - self.syncStateChanged.emit() + self.syncStateChanged.emit(self._sync_state) if self._sync_state == "success": self._last_sync_str = datetime.now().strftime("%d/%m/%Y %H:%M") diff --git a/resources/qml/Account/SyncState.qml b/resources/qml/Account/SyncState.qml new file mode 100644 index 0000000000..8cb832a853 --- /dev/null +++ b/resources/qml/Account/SyncState.qml @@ -0,0 +1,81 @@ +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 + } + } + } +} \ No newline at end of file diff --git a/resources/qml/Account/SyncStateError.qml b/resources/qml/Account/SyncStateError.qml deleted file mode 100644 index ad8e3dfe2d..0000000000 --- a/resources/qml/Account/SyncStateError.qml +++ /dev/null @@ -1,38 +0,0 @@ -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 -{ - width: childrenRect.width - height: childrenRect.height - anchors.horizontalCenter: parent.horizontalCenter - spacing: UM.Theme.getSize("narrow_margin").height - - - - UM.RecolorImage - { - id: updateImage - width: 20 * screenScaleFactor - height: width - - source: UM.Theme.getIcon("warning_light") - color: palette.text - - signal syncingChanged(bool newSyncing) - property double animationDuration: 1500 - - } - - Label - { - id: syncStateSuccessLabel - text: catalog.i18nc("@info", "Something went wrong...\nPlease try again later.") - color: UM.Theme.getColor("text") - font: UM.Theme.getFont("medium") - renderType: Text.NativeRendering - } -} \ No newline at end of file diff --git a/resources/qml/Account/SyncStateIdle.qml b/resources/qml/Account/SyncStateIdle.qml deleted file mode 100644 index d08bcc7bad..0000000000 --- a/resources/qml/Account/SyncStateIdle.qml +++ /dev/null @@ -1,44 +0,0 @@ -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 -{ - width: childrenRect.width - height: childrenRect.height - anchors.horizontalCenter: parent.horizontalCenter - spacing: UM.Theme.getSize("narrow_margin").height - - - - UM.RecolorImage - { - id: updateImage - width: 20 * screenScaleFactor - height: width - - source: UM.Theme.getIcon("update") - color: palette.text - - } - - 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 - } - } -} \ No newline at end of file diff --git a/resources/qml/Account/SyncStateSuccess.qml b/resources/qml/Account/SyncStateSuccess.qml deleted file mode 100644 index fa6051a71b..0000000000 --- a/resources/qml/Account/SyncStateSuccess.qml +++ /dev/null @@ -1,32 +0,0 @@ -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 -{ - width: childrenRect.width - height: childrenRect.height - anchors.horizontalCenter: parent.horizontalCenter - spacing: UM.Theme.getSize("narrow_margin").height - - UM.RecolorImage - { - id: updateImage - width: 20 * screenScaleFactor - height: width - - source: UM.Theme.getIcon("checked") - color: palette.text - } - - Label - { - id: syncStateSuccessLabel - text: catalog.i18nc("@info", "You are up to date") - color: UM.Theme.getColor("text") - font: UM.Theme.getFont("medium") - renderType: Text.NativeRendering - } -} \ No newline at end of file diff --git a/resources/qml/Account/SyncStateSyncing.qml b/resources/qml/Account/SyncStateSyncing.qml deleted file mode 100644 index 1dd5101254..0000000000 --- a/resources/qml/Account/SyncStateSyncing.qml +++ /dev/null @@ -1,43 +0,0 @@ -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 -{ - width: childrenRect.width - height: childrenRect.height - anchors.horizontalCenter: parent.horizontalCenter - spacing: UM.Theme.getSize("narrow_margin").height - - UM.RecolorImage - { - id: updateImage - width: 20 * screenScaleFactor - height: width - - source: UM.Theme.getIcon("update") - color: palette.text - - RotationAnimator - { - id: updateAnimator - target: updateImage - from: 0 - to: 360 - duration: 1000 - loops: Animation.Infinite - running: true - } - } - - Label - { - id: accountSyncButton - text: catalog.i18nc("@button", "Checking...") - color: UM.Theme.getColor("text") - font: UM.Theme.getFont("medium") - renderType: Text.NativeRendering - } -} \ No newline at end of file diff --git a/resources/qml/Account/UserOperations.qml b/resources/qml/Account/UserOperations.qml index da4d4dabbe..2a8a84c384 100644 --- a/resources/qml/Account/UserOperations.qml +++ b/resources/qml/Account/UserOperations.qml @@ -29,21 +29,12 @@ Column color: UM.Theme.getColor("text") } - SyncStateIdle { - visible: Cura.API.account.syncState == "idle" + SyncState + { + id: syncRow } - SyncStateSyncing { - visible: Cura.API.account.syncState == "syncing" - } - SyncStateSuccess { - visible: Cura.API.account.syncState == "success" - } - - SyncStateError { - visible: Cura.API.account.syncState == "error" - } Label { @@ -85,4 +76,32 @@ Column onExited: signOutButton.font.underline = false } } + + signal syncStateChanged(string newState) + + onSyncStateChanged: { + if(newState == "syncing"){ + syncRow.iconSource = UM.Theme.getIcon("update") + syncRow.labelText = catalog.i18nc("@label", "Checking...") + } else if (newState == "success") { + syncRow.iconSource = UM.Theme.getIcon("checked") + syncRow.labelText = catalog.i18nc("@label", "You are up to date") + } else if (newState == "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 == "syncing"){ + syncRow.animateIconRotation = true + syncRow.syncButtonVisible = false + } else { + syncRow.animateIconRotation = false + syncRow.syncButtonVisible = true + } + } + + Component.onCompleted: Cura.API.account.syncStateChanged.connect(syncStateChanged) + } From 88ff68e40c4297a22a14452985804569caaecee6 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Mon, 4 May 2020 16:56:09 +0200 Subject: [PATCH 13/27] Also check for package updates automatically Moves the 30-second sync timer from CloudOutputDeviceManager to Account and subscribes CloudPackageChecker to the timer. CURA-7290 --- cura/API/Account.py | 26 ++++++++++++++----- .../src/CloudSync/CloudPackageChecker.py | 17 ++++++++++-- .../src/Cloud/CloudOutputDeviceManager.py | 23 ++-------------- 3 files changed, 37 insertions(+), 29 deletions(-) diff --git a/cura/API/Account.py b/cura/API/Account.py index 93467790b4..bda0281120 100644 --- a/cura/API/Account.py +++ b/cura/API/Account.py @@ -3,9 +3,8 @@ from datetime import datetime from typing import Optional, Dict, TYPE_CHECKING -from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty +from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty, QTimer -from UM.Logger import Logger from UM.Message import Message from UM.i18n import i18nCatalog from cura.OAuth2.AuthorizationService import AuthorizationService @@ -28,11 +27,14 @@ i18n_catalog = i18nCatalog("cura") # api.account.userProfile # Who is logged in`` # class Account(QObject): + # The interval with which the remote clusters are checked + SYNC_INTERVAL = 30.0 # seconds + # Signal emitted when user logged in or out. loginStateChanged = pyqtSignal(bool) accessTokenChanged = pyqtSignal() cloudPrintersDetectedChanged = pyqtSignal(bool) - manualSyncRequested = pyqtSignal() + syncRequested = pyqtSignal() lastSyncDateTimeChanged = pyqtSignal() syncStateChanged = pyqtSignal(str) @@ -66,6 +68,13 @@ 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_clients = {} """contains entries "client_name" : "state["success"|"error|"syncing"]""" @@ -77,9 +86,9 @@ class Account(QObject): self._authorization_service.loadAuthDataFromPreferences() def setSyncState(self, service_name: str, state: str) -> None: - """ Can be used to register and update account sync states + """ Can be used to register sync services and update account sync states - Example: `setSyncState("packages", "syncing")` + Example: `setSyncState("PluginSyncService", "syncing")` :param service_name: A unique name for your service, such as `plugins` or `backups` :param state: One of Account.SYNC_STATES """ @@ -105,6 +114,11 @@ class Account(QObject): self._last_sync_str = datetime.now().strftime("%d/%m/%Y %H:%M") self.lastSyncDateTimeChanged.emit() + if self._sync_state != "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() @@ -181,7 +195,7 @@ class Account(QObject): def sync(self) -> None: """Checks for new cloud printers""" - self.manualSyncRequested.emit() + self.syncRequested.emit() @pyqtSlot() def logout(self) -> None: diff --git a/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py b/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py index 3e1c4f5cdf..5f46f64e62 100644 --- a/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py +++ b/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py @@ -35,6 +35,9 @@ class CloudPackageChecker(QObject): self._application.initializationFinished.connect(self._onAppInitialized) self._i18n_catalog = i18nCatalog("cura") self._sdk_version = ApplicationMetadata.CuraSDKVersion + self._last_check_packages = [] + """Result from a previous check within the same user session. + Used to prevent duplicate notifications""" # 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. @@ -43,8 +46,13 @@ class CloudPackageChecker(QObject): # initial check self._getPackagesIfLoggedIn() - self._application.getCuraAPI().account.loginStateChanged.connect(self._getPackagesIfLoggedIn) - self._application.getCuraAPI().account.manualSyncRequested.connect(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_check_packages = [] + self._getPackagesIfLoggedIn() def _getPackagesIfLoggedIn(self) -> None: if self._application.getCuraAPI().account.isLoggedIn: @@ -87,6 +95,10 @@ class CloudPackageChecker(QObject): user_subscribed_packages = [plugin["package_id"] for plugin in subscribed_packages_payload] user_installed_packages = self._package_manager.getUserInstalledPackages() + if user_subscribed_packages == self._last_check_packages: + # nothing new here + 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) @@ -102,6 +114,7 @@ class CloudPackageChecker(QObject): self._model.addDiscrepancies(package_discrepancy) self._model.initialize(self._package_manager, subscribed_packages_payload) self._showSyncMessage() + self._last_check_packages = user_subscribed_packages def _showSyncMessage(self) -> None: """Show the message if it is not already shown""" diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 8334960025..cab1739e03 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -29,9 +29,6 @@ class CloudOutputDeviceManager: META_NETWORK_KEY = "um_network_key" SYNC_SERVICE_NAME = "CloudOutputDeviceManager" - # The interval with which the remote clusters are checked - CHECK_CLUSTER_INTERVAL = 30.0 # seconds - # The translation catalog for this device. I18N_CATALOG = i18nCatalog("cura") @@ -45,13 +42,6 @@ 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 @@ -65,11 +55,9 @@ 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.manualSyncRequested.connect(self._getRemoteClusters) + self._account.syncRequested.connect(self._getRemoteClusters) def stop(self): """Stops running the cloud output device manager.""" @@ -77,8 +65,6 @@ class CloudOutputDeviceManager: 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: @@ -100,8 +86,7 @@ class CloudOutputDeviceManager: if self._syncing: return - if self._update_timer.isActive(): - self._update_timer.stop() + Logger.info("Syncing cloud printer clusters") self._syncing = True self._account.setSyncState(self.SYNC_SERVICE_NAME, "syncing") @@ -135,14 +120,10 @@ class CloudOutputDeviceManager: self._syncing = False self._account.setSyncState(self.SYNC_SERVICE_NAME, "success") - # Schedule a new update - self._update_timer.start() def _onGetRemoteClusterFailed(self): self._syncing = False self._account.setSyncState(self.SYNC_SERVICE_NAME, "error") - # Schedule a new update - self._update_timer.start() def _onDevicesDiscovered(self, clusters: List[CloudClusterResponse]) -> None: """**Synchronously** create machines for discovered devices From eac884fcd2bfe0413e3d96e7c2fe7d026a8d68eb Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Mon, 4 May 2020 17:26:38 +0200 Subject: [PATCH 14/27] Convert SYNC_STATES to Enum CURA-7290 --- cura/API/Account.py | 36 ++++++++++--------- .../src/CloudSync/CloudPackageChecker.py | 3 +- .../src/Cloud/CloudOutputDeviceManager.py | 2 +- 3 files changed, 23 insertions(+), 18 deletions(-) diff --git a/cura/API/Account.py b/cura/API/Account.py index 93467790b4..cd48adb8d6 100644 --- a/cura/API/Account.py +++ b/cura/API/Account.py @@ -1,6 +1,7 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from datetime import datetime +from enum import Enum from typing import Optional, Dict, TYPE_CHECKING from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty @@ -28,6 +29,14 @@ i18n_catalog = i18nCatalog("cura") # api.account.userProfile # Who is logged in`` # class Account(QObject): + + class SyncState(Enum): + """Caution: values used in qml (eg. UserOperations.qml)""" + + SYNCING = "syncing", + SUCCESS = "success", + ERROR = "error" + # Signal emitted when user logged in or out. loginStateChanged = pyqtSignal(bool) accessTokenChanged = pyqtSignal() @@ -36,8 +45,6 @@ class Account(QObject): lastSyncDateTimeChanged = pyqtSignal() syncStateChanged = pyqtSignal(str) - SYNC_STATES = ["syncing", "success", "error"] - def __init__(self, application: "CuraApplication", parent = None) -> None: super().__init__(parent) self._application = application @@ -45,7 +52,7 @@ class Account(QObject): self._error_message = None # type: Optional[Message] self._logged_in = False - self._sync_state = "idle" + self._sync_state = self.SyncState.SUCCESS self._last_sync_str = "-" self._callback_port = 32118 @@ -67,7 +74,7 @@ class Account(QObject): self._authorization_service = AuthorizationService(self._oauth_settings) self._sync_clients = {} - """contains entries "client_name" : "state["success"|"error|"syncing"]""" + """contains entries "client_name" : "SyncState""" def initialize(self) -> None: self._authorization_service.initialize(self._application.getPreferences()) @@ -76,32 +83,29 @@ class Account(QObject): self._authorization_service.accessTokenChanged.connect(self._onAccessTokenChanged) self._authorization_service.loadAuthDataFromPreferences() - def setSyncState(self, service_name: str, state: str) -> None: + def setSyncState(self, service_name: str, state: SyncState) -> None: """ Can be used to register and update account sync states - Example: `setSyncState("packages", "syncing")` + Example: `setSyncState("packages", Account.SyncState.SYNCING)` :param service_name: A unique name for your service, such as `plugins` or `backups` :param state: One of Account.SYNC_STATES """ prev_state = self._sync_state - if state not in Account.SYNC_STATES: - raise AttributeError("Invalid state parameter: {}".format(state)) - self._sync_clients[service_name] = state - if any(val == "syncing" for val in self._sync_clients.values()): - self._sync_state = "syncing" - elif any(val == "error" for val in self._sync_clients.values()): - self._sync_state = "error" + if any(val == self.SyncState.SYNCING for val in self._sync_clients.values()): + self._sync_state = self.SyncState.SYNCING + elif any(val == self.SyncState.ERROR for val in self._sync_clients.values()): + self._sync_state = self.SyncState.ERROR else: - self._sync_state = "success" + self._sync_state = self.SyncState.SUCCESS if self._sync_state != prev_state: - self.syncStateChanged.emit(self._sync_state) + self.syncStateChanged.emit(self._sync_state.value[0]) - if self._sync_state == "success": + if self._sync_state == self.SyncState.SUCCESS: self._last_sync_str = datetime.now().strftime("%d/%m/%Y %H:%M") self.lastSyncDateTimeChanged.emit() diff --git a/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py b/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py index 3e1c4f5cdf..049d9409fd 100644 --- a/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py +++ b/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py @@ -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 import Account from cura.CuraApplication import CuraApplication, ApplicationMetadata from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope from .SubscribedPackagesModel import SubscribedPackagesModel @@ -53,7 +54,7 @@ class CloudPackageChecker(QObject): self._hideSyncMessage() def _getUserSubscribedPackages(self) -> None: - self._application.getCuraAPI().account.setSyncState(self.SYNC_SERVICE_NAME, "syncing") + self._application.getCuraAPI().account.setSyncState(self.SYNC_SERVICE_NAME, Account.SyncState.SYNCING) Logger.debug("Requesting subscribed packages metadata from server.") url = CloudApiModel.api_url_user_packages self._application.getHttpRequestManager().get(url, diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 8334960025..bc26d7f8b5 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -104,7 +104,7 @@ class CloudOutputDeviceManager: self._update_timer.stop() self._syncing = True - self._account.setSyncState(self.SYNC_SERVICE_NAME, "syncing") + self._account.setSyncState(self.SYNC_SERVICE_NAME, Account.SyncState.SYNCING) self._api.getClusters(self._onGetRemoteClustersFinished, self._onGetRemoteClusterFailed) def _onGetRemoteClustersFinished(self, clusters: List[CloudClusterResponse]) -> None: From 7322526791a5e7105e8f781cf4d841dfb79a58b8 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Mon, 4 May 2020 17:30:32 +0200 Subject: [PATCH 15/27] Fix typing warning CURA-7290 --- cura/API/Account.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cura/API/Account.py b/cura/API/Account.py index cd48adb8d6..9b123c2838 100644 --- a/cura/API/Account.py +++ b/cura/API/Account.py @@ -73,8 +73,8 @@ class Account(QObject): self._authorization_service = AuthorizationService(self._oauth_settings) - self._sync_clients = {} - """contains entries "client_name" : "SyncState""" + self._sync_services = {} # type: Dict[str, Account.SyncState] + """contains entries "service_name" : SyncState""" def initialize(self) -> None: self._authorization_service.initialize(self._application.getPreferences()) @@ -93,11 +93,11 @@ class Account(QObject): prev_state = self._sync_state - self._sync_clients[service_name] = state + self._sync_services[service_name] = state - if any(val == self.SyncState.SYNCING for val in self._sync_clients.values()): + if any(val == self.SyncState.SYNCING for val in self._sync_services.values()): self._sync_state = self.SyncState.SYNCING - elif any(val == self.SyncState.ERROR for val in self._sync_clients.values()): + elif any(val == self.SyncState.ERROR for val in self._sync_services.values()): self._sync_state = self.SyncState.ERROR else: self._sync_state = self.SyncState.SUCCESS From 5b245434d024f9bea5adafc54167d4af2a943195 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Mon, 4 May 2020 17:32:54 +0200 Subject: [PATCH 16/27] Add _last_check_packages type hint CURA-7290 --- plugins/Toolbox/src/CloudSync/CloudPackageChecker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py b/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py index 5f46f64e62..5822ee4719 100644 --- a/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py +++ b/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py @@ -35,7 +35,7 @@ class CloudPackageChecker(QObject): self._application.initializationFinished.connect(self._onAppInitialized) self._i18n_catalog = i18nCatalog("cura") self._sdk_version = ApplicationMetadata.CuraSDKVersion - self._last_check_packages = [] + self._last_check_packages = [] # type: List[str] """Result from a previous check within the same user session. Used to prevent duplicate notifications""" From 50ae2064b587e2e5d3cc42459eb7469af47d1a81 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Wed, 6 May 2020 11:47:38 +0200 Subject: [PATCH 17/27] Refactor SyncState logic to SyncState.qml CURA-7290 --- resources/qml/Account/SyncState.qml | 29 ++++++++++++++++++++++++ resources/qml/Account/UserOperations.qml | 27 ---------------------- 2 files changed, 29 insertions(+), 27 deletions(-) diff --git a/resources/qml/Account/SyncState.qml b/resources/qml/Account/SyncState.qml index 8cb832a853..20c0ca9032 100644 --- a/resources/qml/Account/SyncState.qml +++ b/resources/qml/Account/SyncState.qml @@ -78,4 +78,33 @@ Row // sync state icon + message } } } + + signal syncStateChanged(string newState) + + onSyncStateChanged: { + if(newState == "syncing"){ + syncRow.iconSource = UM.Theme.getIcon("update") + syncRow.labelText = catalog.i18nc("@label", "Checking...") + } else if (newState == "success") { + syncRow.iconSource = UM.Theme.getIcon("checked") + syncRow.labelText = catalog.i18nc("@label", "You are up to date") + } else if (newState == "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 == "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 2a8a84c384..f292c501f3 100644 --- a/resources/qml/Account/UserOperations.qml +++ b/resources/qml/Account/UserOperations.qml @@ -77,31 +77,4 @@ Column } } - signal syncStateChanged(string newState) - - onSyncStateChanged: { - if(newState == "syncing"){ - syncRow.iconSource = UM.Theme.getIcon("update") - syncRow.labelText = catalog.i18nc("@label", "Checking...") - } else if (newState == "success") { - syncRow.iconSource = UM.Theme.getIcon("checked") - syncRow.labelText = catalog.i18nc("@label", "You are up to date") - } else if (newState == "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 == "syncing"){ - syncRow.animateIconRotation = true - syncRow.syncButtonVisible = false - } else { - syncRow.animateIconRotation = false - syncRow.syncButtonVisible = true - } - } - - Component.onCompleted: Cura.API.account.syncStateChanged.connect(syncStateChanged) - } From ef6cf8d5cc14d7bc5bd8b4facd984b4b4d382bed Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Wed, 6 May 2020 12:08:55 +0200 Subject: [PATCH 18/27] Remove unused pyqtProperty CURA-7290 --- cura/API/Account.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/cura/API/Account.py b/cura/API/Account.py index 9b123c2838..e9d4a52c08 100644 --- a/cura/API/Account.py +++ b/cura/API/Account.py @@ -6,7 +6,6 @@ from typing import Optional, Dict, TYPE_CHECKING from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty -from UM.Logger import Logger from UM.Message import Message from UM.i18n import i18nCatalog from cura.OAuth2.AuthorizationService import AuthorizationService @@ -31,7 +30,7 @@ i18n_catalog = i18nCatalog("cura") class Account(QObject): class SyncState(Enum): - """Caution: values used in qml (eg. UserOperations.qml)""" + """Caution: values used in qml (eg. SyncState.qml)""" SYNCING = "syncing", SUCCESS = "success", @@ -177,10 +176,6 @@ class Account(QObject): def lastSyncDateTime(self) -> str: return self._last_sync_str - @pyqtProperty(str, notify=syncStateChanged) - def syncState(self) -> str: - return self._sync_state - @pyqtSlot() def sync(self) -> None: """Checks for new cloud printers""" From bfae7e9fb11d3d791d523f5d905295f096a06cae Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Wed, 6 May 2020 12:54:07 +0200 Subject: [PATCH 19/27] Replace some hard-coded sync states CURA-7290 --- .../UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index bc26d7f8b5..efb0b30e4b 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -134,13 +134,13 @@ class CloudOutputDeviceManager: self._connectToActiveMachine() self._syncing = False - self._account.setSyncState(self.SYNC_SERVICE_NAME, "success") + self._account.setSyncState(self.SYNC_SERVICE_NAME, Account.SyncState.SUCCESS) # Schedule a new update self._update_timer.start() def _onGetRemoteClusterFailed(self): self._syncing = False - self._account.setSyncState(self.SYNC_SERVICE_NAME, "error") + self._account.setSyncState(self.SYNC_SERVICE_NAME, Account.SyncState.ERROR) # Schedule a new update self._update_timer.start() From 8937c63219dd61ec4ae16e77f4610bdaa761cfa3 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Wed, 6 May 2020 16:08:47 +0200 Subject: [PATCH 20/27] Finish merge of auto sync packages CURA-7290 --- cura/API/Account.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cura/API/Account.py b/cura/API/Account.py index 7ad2a1802c..d4cf1f7106 100644 --- a/cura/API/Account.py +++ b/cura/API/Account.py @@ -96,7 +96,7 @@ class Account(QObject): Example: `setSyncState("PluginSyncService", Account.SyncState.SYNCING)` :param service_name: A unique name for your service, such as `plugins` or `backups` - :param state: One of Account.SYNC_STATES + :param state: One of Account.SyncState """ prev_state = self._sync_state @@ -117,7 +117,7 @@ class Account(QObject): self._last_sync_str = datetime.now().strftime("%d/%m/%Y %H:%M") self.lastSyncDateTimeChanged.emit() - if self._sync_state != "syncing": + if self._sync_state != self.SyncState.SYNCING: # schedule new auto update after syncing completed (for whatever reason) if not self._update_timer.isActive(): self._update_timer.start() From 1ae050bbc5ba22565282f1f1029a1c49165329b5 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Fri, 8 May 2020 11:09:48 +0200 Subject: [PATCH 21/27] Expose Account.SyncState as an Enum to QML Provides a single source of truth CURA-7290 --- cura/API/Account.py | 44 +++++++++---------- cura/CuraApplication.py | 2 + .../src/CloudSync/CloudPackageChecker.py | 10 ++--- .../src/Cloud/CloudOutputDeviceManager.py | 7 +-- resources/qml/Account/SyncState.qml | 10 ++--- 5 files changed, 38 insertions(+), 35 deletions(-) diff --git a/cura/API/Account.py b/cura/API/Account.py index d4cf1f7106..eae59e8a71 100644 --- a/cura/API/Account.py +++ b/cura/API/Account.py @@ -1,10 +1,9 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from datetime import datetime -from enum import Enum from typing import Optional, Dict, TYPE_CHECKING -from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty, QTimer +from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty, QTimer, Q_ENUMS from UM.Message import Message from UM.i18n import i18nCatalog @@ -18,6 +17,13 @@ if TYPE_CHECKING: i18n_catalog = i18nCatalog("cura") +class SyncState(QObject): + """QML: Cura.AccountSyncState""" + SYNCING = 0 + SUCCESS = 1 + ERROR = 2 + + ## The account API provides a version-proof bridge to use Ultimaker Accounts # # Usage: @@ -30,13 +36,7 @@ i18n_catalog = i18nCatalog("cura") class Account(QObject): # The interval with which the remote clusters are checked SYNC_INTERVAL = 30.0 # seconds - - class SyncState(Enum): - """Caution: values used in qml (eg. SyncState.qml)""" - - SYNCING = "syncing", - SUCCESS = "success", - ERROR = "error" + Q_ENUMS(SyncState) # Signal emitted when user logged in or out. loginStateChanged = pyqtSignal(bool) @@ -44,7 +44,7 @@ class Account(QObject): cloudPrintersDetectedChanged = pyqtSignal(bool) syncRequested = pyqtSignal() lastSyncDateTimeChanged = pyqtSignal() - syncStateChanged = pyqtSignal(str) + syncStateChanged = pyqtSignal(int) # because it's an int Enum def __init__(self, application: "CuraApplication", parent = None) -> None: super().__init__(parent) @@ -53,7 +53,7 @@ class Account(QObject): self._error_message = None # type: Optional[Message] self._logged_in = False - self._sync_state = self.SyncState.SUCCESS + self._sync_state = SyncState.SUCCESS self._last_sync_str = "-" self._callback_port = 32118 @@ -81,7 +81,7 @@ class Account(QObject): self._update_timer.setSingleShot(True) self._update_timer.timeout.connect(self.syncRequested) - self._sync_services = {} # type: Dict[str, Account.SyncState] + self._sync_services = {} # type: Dict[str, SyncState] """contains entries "service_name" : SyncState""" def initialize(self) -> None: @@ -94,30 +94,30 @@ class Account(QObject): def setSyncState(self, service_name: str, state: SyncState) -> None: """ Can be used to register sync services and update account sync states - Example: `setSyncState("PluginSyncService", Account.SyncState.SYNCING)` + Example: `setSyncState("PluginSyncService", SyncState.SYNCING)` :param service_name: A unique name for your service, such as `plugins` or `backups` - :param state: One of Account.SyncState + :param state: One of SyncState """ prev_state = self._sync_state self._sync_services[service_name] = state - if any(val == self.SyncState.SYNCING for val in self._sync_services.values()): - self._sync_state = self.SyncState.SYNCING - elif any(val == self.SyncState.ERROR for val in self._sync_services.values()): - self._sync_state = self.SyncState.ERROR + 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 = self.SyncState.SUCCESS + self._sync_state = SyncState.SUCCESS if self._sync_state != prev_state: - self.syncStateChanged.emit(self._sync_state.value[0]) + self.syncStateChanged.emit(self._sync_state) - if self._sync_state == self.SyncState.SUCCESS: + 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 != self.SyncState.SYNCING: + 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() diff --git a/cura/CuraApplication.py b/cura/CuraApplication.py index 993bb15ae2..4355bc5450 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 @@ -1106,6 +1107,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 0c51b7ff8b..4444900e36 100644 --- a/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py +++ b/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py @@ -13,7 +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 import Account +from cura.API.Account import SyncState from cura.CuraApplication import CuraApplication, ApplicationMetadata from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope from .SubscribedPackagesModel import SubscribedPackagesModel @@ -62,7 +62,7 @@ class CloudPackageChecker(QObject): self._hideSyncMessage() def _getUserSubscribedPackages(self) -> None: - self._application.getCuraAPI().account.setSyncState(self.SYNC_SERVICE_NAME, Account.SyncState.SYNCING) + 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, @@ -75,7 +75,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, "error") + self._application.getCuraAPI().account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.ERROR) return try: @@ -84,13 +84,13 @@ 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, "error") + 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, "success") + 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] diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 10ef460044..381d01e56a 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 @@ -89,7 +90,7 @@ class CloudOutputDeviceManager: Logger.info("Syncing cloud printer clusters") self._syncing = True - self._account.setSyncState(self.SYNC_SERVICE_NAME, Account.SyncState.SYNCING) + self._account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.SYNCING) self._api.getClusters(self._onGetRemoteClustersFinished, self._onGetRemoteClusterFailed) def _onGetRemoteClustersFinished(self, clusters: List[CloudClusterResponse]) -> None: @@ -119,11 +120,11 @@ class CloudOutputDeviceManager: self._connectToActiveMachine() self._syncing = False - self._account.setSyncState(self.SYNC_SERVICE_NAME, Account.SyncState.SUCCESS) + self._account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.SUCCESS) def _onGetRemoteClusterFailed(self): self._syncing = False - self._account.setSyncState(self.SYNC_SERVICE_NAME, Account.SyncState.ERROR) + 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 index 20c0ca9032..7126aec314 100644 --- a/resources/qml/Account/SyncState.qml +++ b/resources/qml/Account/SyncState.qml @@ -82,20 +82,20 @@ Row // sync state icon + message signal syncStateChanged(string newState) onSyncStateChanged: { - if(newState == "syncing"){ + if(newState == Cura.AccountSyncState.SYNCING){ syncRow.iconSource = UM.Theme.getIcon("update") syncRow.labelText = catalog.i18nc("@label", "Checking...") - } else if (newState == "success") { + } 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 == "error") { - syncRow.iconSource = UM.Theme.getIcon("warning-light") + } 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 == "syncing"){ + if(newState == Cura.AccountSyncState.SYNCING){ syncRow.animateIconRotation = true syncRow.syncButtonVisible = false } else { From 8e75da552dd0b0cb32962f0dac1a37506d5dc4ea Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Tue, 12 May 2020 11:23:14 +0200 Subject: [PATCH 22/27] Do not inherit SyncState from QObject not necessary and fixes mypy warning CURA-7290 --- cura/API/Account.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cura/API/Account.py b/cura/API/Account.py index eae59e8a71..c02e685263 100644 --- a/cura/API/Account.py +++ b/cura/API/Account.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: i18n_catalog = i18nCatalog("cura") -class SyncState(QObject): +class SyncState: """QML: Cura.AccountSyncState""" SYNCING = 0 SUCCESS = 1 From 3e1b695c430579717029321aa5d69061acde6dd0 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Tue, 12 May 2020 11:55:50 +0200 Subject: [PATCH 23/27] Add some missing account sync timer logic CURA-7290 --- cura/API/Account.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/cura/API/Account.py b/cura/API/Account.py index c02e685263..4741c4002c 100644 --- a/cura/API/Account.py +++ b/cura/API/Account.py @@ -146,11 +146,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: @@ -192,8 +199,14 @@ class Account(QObject): @pyqtSlot() def sync(self) -> None: - """Checks for new cloud printers""" + """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() self.syncRequested.emit() @pyqtSlot() From 6af502088e47695daf641cf4c210eb2d79353278 Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Tue, 12 May 2020 13:13:48 +0200 Subject: [PATCH 24/27] Fix typing issue CURA-7290 --- cura/API/Account.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cura/API/Account.py b/cura/API/Account.py index 4741c4002c..cf6aeefc6e 100644 --- a/cura/API/Account.py +++ b/cura/API/Account.py @@ -1,7 +1,7 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from datetime import datetime -from typing import Optional, Dict, TYPE_CHECKING +from typing import Optional, Dict, TYPE_CHECKING, Union from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty, QTimer, Q_ENUMS @@ -81,7 +81,7 @@ class Account(QObject): self._update_timer.setSingleShot(True) self._update_timer.timeout.connect(self.syncRequested) - self._sync_services = {} # type: Dict[str, SyncState] + self._sync_services = {} # type: Dict[str, int] """contains entries "service_name" : SyncState""" def initialize(self) -> None: @@ -91,7 +91,7 @@ class Account(QObject): self._authorization_service.accessTokenChanged.connect(self._onAccessTokenChanged) self._authorization_service.loadAuthDataFromPreferences() - def setSyncState(self, service_name: str, state: SyncState) -> None: + def setSyncState(self, service_name: str, state: int) -> None: """ Can be used to register sync services and update account sync states Example: `setSyncState("PluginSyncService", SyncState.SYNCING)` From 903c251f341bea18c3446c79625d395e3076fa2c Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Tue, 12 May 2020 14:19:36 +0200 Subject: [PATCH 25/27] Additional account sync documentation CURA-7290 --- cura/API/Account.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/cura/API/Account.py b/cura/API/Account.py index 4741c4002c..bb4d541c0b 100644 --- a/cura/API/Account.py +++ b/cura/API/Account.py @@ -5,6 +5,7 @@ from typing import Optional, Dict, TYPE_CHECKING 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 @@ -34,7 +35,7 @@ class SyncState: # api.account.userProfile # Who is logged in`` # class Account(QObject): - # The interval with which the remote clusters are checked + # The interval in which sync services are automatically triggered SYNC_INTERVAL = 30.0 # seconds Q_ENUMS(SyncState) @@ -43,8 +44,13 @@ class Account(QObject): accessTokenChanged = pyqtSignal() cloudPrintersDetectedChanged = pyqtSignal(bool) 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 it's an int Enum + syncStateChanged = pyqtSignal(int) # because SyncState is an int Enum def __init__(self, application: "CuraApplication", parent = None) -> None: super().__init__(parent) @@ -94,6 +100,8 @@ class Account(QObject): def setSyncState(self, service_name: str, state: SyncState) -> 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 @@ -207,6 +215,9 @@ class Account(QObject): 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() From 2cc6b7c54d2b049f8a48ce08d49545caa1a837c2 Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Tue, 12 May 2020 15:12:28 +0200 Subject: [PATCH 26/27] Appease mypy --- plugins/Toolbox/src/CloudSync/CloudPackageChecker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py b/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py index 346cbfa812..9345cb33e9 100644 --- a/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py +++ b/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py @@ -115,7 +115,7 @@ class CloudPackageChecker(QObject): self._model.addDiscrepancies(package_discrepancy) self._model.initialize(self._package_manager, subscribed_packages_payload) self._showSyncMessage() - self._last_check_packages = user_subscribed_packages + self._last_check_packages = list(user_subscribed_packages) def _showSyncMessage(self) -> None: """Show the message if it is not already shown""" From fbc0be30ecb6bc5b671788dabd92e099262b1fa3 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Tue, 12 May 2020 15:40:53 +0200 Subject: [PATCH 27/27] Fix typing issue in CloudPackageChecker CURA-7290 --- .../src/CloudSync/CloudPackageChecker.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py b/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py index 346cbfa812..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 @@ -36,9 +36,8 @@ class CloudPackageChecker(QObject): self._application.initializationFinished.connect(self._onAppInitialized) self._i18n_catalog = i18nCatalog("cura") self._sdk_version = ApplicationMetadata.CuraSDKVersion - self._last_check_packages = [] # type: List[str] - """Result from a previous check within the same user session. - Used to prevent duplicate notifications""" + 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. @@ -52,7 +51,7 @@ class CloudPackageChecker(QObject): def _onLoginStateChanged(self) -> None: # reset session - self._last_check_packages = [] + self._last_notified_packages = set() self._getPackagesIfLoggedIn() def _getPackagesIfLoggedIn(self) -> None: @@ -96,8 +95,8 @@ class CloudPackageChecker(QObject): 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_check_packages: - # nothing new here + if user_subscribed_packages == self._last_notified_packages: + # already notified user about these return # We need to re-evaluate the dismissed packages @@ -109,13 +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_check_packages = user_subscribed_packages + self._last_notified_packages = user_subscribed_packages def _showSyncMessage(self) -> None: """Show the message if it is not already shown"""