diff --git a/cura/API/Account.py b/cura/API/Account.py index ef46368474..1250d8b81a 100644 --- a/cura/API/Account.py +++ b/cura/API/Account.py @@ -10,7 +10,7 @@ from UM.Message import Message from UM.i18n import i18nCatalog from cura.OAuth2.AuthorizationService import AuthorizationService from cura.OAuth2.Models import OAuth2Settings -from cura.UltimakerCloud import UltimakerCloudAuthentication +from cura.UltimakerCloud import UltimakerCloudConstants if TYPE_CHECKING: from cura.CuraApplication import CuraApplication @@ -69,7 +69,7 @@ class Account(QObject): self._last_sync_str = "-" self._callback_port = 32118 - self._oauth_root = UltimakerCloudAuthentication.CuraCloudAccountAPIRoot + self._oauth_root = UltimakerCloudConstants.CuraCloudAccountAPIRoot self._oauth_settings = OAuth2Settings( OAUTH_SERVER_URL= self._oauth_root, diff --git a/cura/API/ConnectionStatus.py b/cura/API/ConnectionStatus.py index 332e519ca9..007f03fdd1 100644 --- a/cura/API/ConnectionStatus.py +++ b/cura/API/ConnectionStatus.py @@ -4,14 +4,14 @@ from PyQt5.QtCore import QObject, pyqtSignal, QTimer, pyqtProperty from PyQt5.QtNetwork import QNetworkReply from UM.TaskManagement.HttpRequestManager import HttpRequestManager -from cura.UltimakerCloud import UltimakerCloudAuthentication +from cura.UltimakerCloud import UltimakerCloudConstants class ConnectionStatus(QObject): """Status info for some web services""" UPDATE_INTERVAL = 10.0 # seconds - ULTIMAKER_CLOUD_STATUS_URL = UltimakerCloudAuthentication.CuraCloudAPIRoot + "/connect/v1/" + ULTIMAKER_CLOUD_STATUS_URL = UltimakerCloudConstants.CuraCloudAPIRoot + "/connect/v1/" __instance = None # type: Optional[ConnectionStatus] diff --git a/cura/CuraApplication.py b/cura/CuraApplication.py index 75b1d67697..a4d7bc303e 100755 --- a/cura/CuraApplication.py +++ b/cura/CuraApplication.py @@ -106,7 +106,7 @@ from cura.UI.RecommendedMode import RecommendedMode from cura.UI.TextManager import TextManager from cura.UI.WelcomePagesModel import WelcomePagesModel from cura.UI.WhatsNewPagesModel import WhatsNewPagesModel -from cura.UltimakerCloud import UltimakerCloudAuthentication +from cura.UltimakerCloud import UltimakerCloudConstants from cura.Utils.NetworkingUtil import NetworkingUtil from . import BuildVolume from . import CameraAnimation @@ -255,11 +255,11 @@ class CuraApplication(QtApplication): @pyqtProperty(str, constant=True) def ultimakerCloudApiRootUrl(self) -> str: - return UltimakerCloudAuthentication.CuraCloudAPIRoot + return UltimakerCloudConstants.CuraCloudAPIRoot @pyqtProperty(str, constant = True) def ultimakerCloudAccountRootUrl(self) -> str: - return UltimakerCloudAuthentication.CuraCloudAccountAPIRoot + return UltimakerCloudConstants.CuraCloudAccountAPIRoot def addCommandLineOptions(self): """Adds command line options to the command line parser. diff --git a/cura/Settings/MachineManager.py b/cura/Settings/MachineManager.py index 5086c68a73..523572dae0 100755 --- a/cura/Settings/MachineManager.py +++ b/cura/Settings/MachineManager.py @@ -22,6 +22,7 @@ from UM.Settings.SettingFunction import SettingFunction from UM.Signal import postponeSignals, CompressTechnique import cura.CuraApplication # Imported like this to prevent circular references. +from UM.Util import parseBool from cura.Machines.ContainerNode import ContainerNode from cura.Machines.ContainerTree import ContainerTree @@ -37,6 +38,7 @@ from cura.Settings.ExtruderStack import ExtruderStack from cura.Settings.cura_empty_instance_containers import (empty_definition_changes_container, empty_variant_container, empty_material_container, empty_quality_container, empty_quality_changes_container, empty_intent_container) +from cura.UltimakerCloud.UltimakerCloudConstants import META_UM_LINKED_TO_ACCOUNT from .CuraStackBuilder import CuraStackBuilder @@ -494,6 +496,10 @@ class MachineManager(QObject): group_size = int(self.activeMachine.getMetaDataEntry("group_size", "-1")) return group_size > 1 + @pyqtProperty(bool, notify = printerConnectedStatusChanged) + def activeMachineIsLinkedToCurrentAccount(self) -> bool: + return parseBool(self.activeMachine.getMetaDataEntry(META_UM_LINKED_TO_ACCOUNT, "True")) + @pyqtProperty(bool, notify = printerConnectedStatusChanged) def activeMachineHasNetworkConnection(self) -> bool: # A network connection is only available if any output device is actually a network connected device. diff --git a/cura/UltimakerCloud/UltimakerCloudAuthentication.py b/cura/UltimakerCloud/UltimakerCloudConstants.py similarity index 87% rename from cura/UltimakerCloud/UltimakerCloudAuthentication.py rename to cura/UltimakerCloud/UltimakerCloudConstants.py index c8346e5c4e..624d2e7b8f 100644 --- a/cura/UltimakerCloud/UltimakerCloudAuthentication.py +++ b/cura/UltimakerCloud/UltimakerCloudConstants.py @@ -8,6 +8,10 @@ DEFAULT_CLOUD_API_ROOT = "https://api.ultimaker.com" # type: str DEFAULT_CLOUD_API_VERSION = "1" # type: str DEFAULT_CLOUD_ACCOUNT_API_ROOT = "https://account.ultimaker.com" # type: str +# Container Metadata keys +META_UM_LINKED_TO_ACCOUNT = "um_linked_to_account" +"""(bool) Whether a cloud printer is linked to an Ultimaker account""" + try: from cura.CuraVersion import CuraCloudAPIRoot # type: ignore if CuraCloudAPIRoot == "": diff --git a/plugins/CuraDrive/src/Settings.py b/plugins/CuraDrive/src/Settings.py index 639c63b45f..56158922dc 100644 --- a/plugins/CuraDrive/src/Settings.py +++ b/plugins/CuraDrive/src/Settings.py @@ -1,13 +1,13 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from cura.UltimakerCloud import UltimakerCloudAuthentication +from cura.UltimakerCloud import UltimakerCloudConstants class Settings: # Keeps the plugin settings. DRIVE_API_VERSION = 1 - DRIVE_API_URL = "{}/cura-drive/v{}".format(UltimakerCloudAuthentication.CuraCloudAPIRoot, str(DRIVE_API_VERSION)) + DRIVE_API_URL = "{}/cura-drive/v{}".format(UltimakerCloudConstants.CuraCloudAPIRoot, str(DRIVE_API_VERSION)) AUTO_BACKUP_ENABLED_PREFERENCE_KEY = "cura_drive/auto_backup_enabled" AUTO_BACKUP_LAST_DATE_PREFERENCE_KEY = "cura_drive/auto_backup_date" diff --git a/plugins/Toolbox/src/CloudApiModel.py b/plugins/Toolbox/src/CloudApiModel.py index b4ff00c6cd..bef37d8173 100644 --- a/plugins/Toolbox/src/CloudApiModel.py +++ b/plugins/Toolbox/src/CloudApiModel.py @@ -1,13 +1,13 @@ from typing import Union from cura import ApplicationMetadata -from cura.UltimakerCloud import UltimakerCloudAuthentication +from cura.UltimakerCloud import UltimakerCloudConstants class CloudApiModel: sdk_version = ApplicationMetadata.CuraSDKVersion # type: Union[str, int] - cloud_api_version = UltimakerCloudAuthentication.CuraCloudAPIVersion # type: str - cloud_api_root = UltimakerCloudAuthentication.CuraCloudAPIRoot # type: str + cloud_api_version = UltimakerCloudConstants.CuraCloudAPIVersion # type: str + cloud_api_root = UltimakerCloudConstants.CuraCloudAPIRoot # type: str api_url = "{cloud_api_root}/cura-packages/v{cloud_api_version}/cura/v{sdk_version}".format( cloud_api_root = cloud_api_root, cloud_api_version = cloud_api_version, diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py index ab14d7ff06..713ee25170 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py @@ -13,7 +13,7 @@ from UM.TaskManagement.HttpRequestManager import HttpRequestManager from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope from cura.API import Account from cura.CuraApplication import CuraApplication -from cura.UltimakerCloud import UltimakerCloudAuthentication +from cura.UltimakerCloud import UltimakerCloudConstants from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope from .ToolPathUploader import ToolPathUploader from ..Models.BaseModel import BaseModel @@ -35,7 +35,7 @@ class CloudApiClient: """ # The cloud URL to use for this remote cluster. - ROOT_PATH = UltimakerCloudAuthentication.CuraCloudAPIRoot + ROOT_PATH = UltimakerCloudConstants.CuraCloudAPIRoot CLUSTER_API_ROOT = "{}/connect/v1".format(ROOT_PATH) CURA_API_ROOT = "{}/cura/v1".format(ROOT_PATH) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 3b150ba4e0..41e7d5e478 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -1,9 +1,8 @@ # Copyright (c) 2019 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. import os -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Set -from PyQt5.QtCore import QTimer from PyQt5.QtNetwork import QNetworkReply from UM import i18nCatalog @@ -11,11 +10,13 @@ from UM.Logger import Logger # To log errors talking to the API. from UM.Message import Message from UM.Settings.Interfaces import ContainerInterface from UM.Signal import Signal +from UM.Util import parseBool 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 +from cura.UltimakerCloud.UltimakerCloudConstants import META_UM_LINKED_TO_ACCOUNT from .CloudApiClient import CloudApiClient from .CloudOutputDevice import CloudOutputDevice from ..Models.Http.CloudClusterResponse import CloudClusterResponse @@ -30,6 +31,7 @@ class CloudOutputDeviceManager: META_CLUSTER_ID = "um_cloud_cluster_id" META_NETWORK_KEY = "um_network_key" + SYNC_SERVICE_NAME = "CloudOutputDeviceManager" # The translation catalog for this device. @@ -41,6 +43,10 @@ class CloudOutputDeviceManager: def __init__(self) -> None: # Persistent dict containing the remote clusters for the authenticated user. self._remote_clusters = {} # type: Dict[str, CloudOutputDevice] + + # Dictionary containing all the cloud printers loaded in Cura + self._um_cloud_printers = {} # type: Dict[str, GlobalStack] + self._account = CuraApplication.getInstance().getCuraAPI().account # type: Account self._api = CloudApiClient(CuraApplication.getInstance(), on_error = lambda error: Logger.log("e", str(error))) self._account.loginStateChanged.connect(self._onLoginStateChanged) @@ -98,23 +104,36 @@ class CloudOutputDeviceManager: def _onGetRemoteClustersFinished(self, clusters: List[CloudClusterResponse]) -> None: """Callback for when the request for getting the clusters is finished.""" + self._um_cloud_printers = {m.getMetaDataEntry(self.META_CLUSTER_ID): m for m in + CuraApplication.getInstance().getContainerRegistry().findContainerStacks( + type = "machine") if m.getMetaDataEntry(self.META_CLUSTER_ID, None)} new_clusters = [] all_clusters = {c.cluster_id: c for c in clusters} # type: Dict[str, CloudClusterResponse] online_clusters = {c.cluster_id: c for c in clusters if c.is_online} # type: Dict[str, CloudClusterResponse] + # Add the new printers in Cura. If a printer was previously added and is rediscovered, set its metadata to + # reflect that and mark the printer not removed from the account for device_id, cluster_data in all_clusters.items(): if device_id not in self._remote_clusters: new_clusters.append(cluster_data) - + if device_id in self._um_cloud_printers and not parseBool(self._um_cloud_printers[device_id].getMetaDataEntry(META_UM_LINKED_TO_ACCOUNT, "true")): + self._um_cloud_printers[device_id].setMetaDataEntry(META_UM_LINKED_TO_ACCOUNT, True) self._onDevicesDiscovered(new_clusters) - removed_device_keys = set(self._remote_clusters.keys()) - set(online_clusters.keys()) - for device_id in removed_device_keys: + # Remove the CloudOutput device for offline printers + offline_device_keys = set(self._remote_clusters.keys()) - set(online_clusters.keys()) + for device_id in offline_device_keys: self._onDiscoveredDeviceRemoved(device_id) - if new_clusters or removed_device_keys: - self.discoveredDevicesChanged.emit() + # Handle devices that were previously added in Cura but do not exist in the account anymore (i.e. they were + # removed from the account) + removed_device_keys = set(self._um_cloud_printers.keys()) - set(all_clusters.keys()) if removed_device_keys: + self._devicesRemovedFromAccount(removed_device_keys) + + if new_clusters or offline_device_keys or removed_device_keys: + self.discoveredDevicesChanged.emit() + if offline_device_keys: # If the removed device was active we should connect to the new active device self._connectToActiveMachine() @@ -144,10 +163,13 @@ class CloudOutputDeviceManager: if machine_manager.getMachine(device.printerType, {self.META_CLUSTER_ID: device.key}) is None \ and machine_manager.getMachine(device.printerType, {self.META_NETWORK_KEY: cluster_data.host_name + "*"}) is None: # The host name is part of the network key. new_devices.append(device) - elif device.getId() not in self._remote_clusters: self._remote_clusters[device.getId()] = device remote_clusters_added = True + # If a printer that was removed from the account is re-added, change its metadata to mark it not removed + # from the account + elif not parseBool(self._um_cloud_printers[device.key].getMetaDataEntry(META_UM_LINKED_TO_ACCOUNT, "true")): + self._um_cloud_printers[device.key].setMetaDataEntry(META_UM_LINKED_TO_ACCOUNT, True) # Inform the Cloud printers model about new devices. new_devices_list_of_dicts = [{ @@ -208,19 +230,86 @@ class CloudOutputDeviceManager: max_disp_devices = 3 if len(new_devices) > max_disp_devices: num_hidden = len(new_devices) - max_disp_devices + 1 - device_name_list = ["- {} ({})".format(device.name, device.printerTypeName) for device in new_devices[0:num_hidden]] - device_name_list.append(self.I18N_CATALOG.i18nc("info:hidden list items", "- and {} others", num_hidden)) + device_name_list = ["
  • {} ({})
  • ".format(device.name, device.printerTypeName) for device in new_devices[0:num_hidden]] + device_name_list.append(self.I18N_CATALOG.i18nc("info:hidden list items", "
  • ... and {} others
  • ", num_hidden)) device_names = "\n".join(device_name_list) else: - device_names = "\n".join(["- {} ({})".format(device.name, device.printerTypeName) for device in new_devices]) + device_names = "\n".join(["
  • {} ({})
  • ".format(device.name, device.printerTypeName) for device in new_devices]) message_text = self.I18N_CATALOG.i18nc( "info:status", - "Cloud printers added from your account:\n{}", + "Cloud printers added from your account:\n", device_names ) message.setText(message_text) + def _devicesRemovedFromAccount(self, removed_device_ids: Set[str]) -> None: + """ + Removes the CloudOutputDevice from the received device ids and marks the specific printers as "removed from + account". In addition, it generates a message to inform the user about the printers that are no longer linked to + his/her account. The message is not generated if all the printers have been previously reported as not linked + to the account. + + :param removed_device_ids: Set of device ids, whose CloudOutputDevice needs to be removed + :return: None + """ + + if not CuraApplication.getInstance().getCuraAPI().account.isLoggedIn: + return + + # Do not report device ids which have been previously marked as non-linked to the account + ignored_device_ids = set() + for device_id in removed_device_ids: + if not parseBool(self._um_cloud_printers[device_id].getMetaDataEntry(META_UM_LINKED_TO_ACCOUNT, "true")): + ignored_device_ids.add(device_id) + # Keep the reported_device_ids list in a class variable, so that the message button actions can access it and + # take the necessary steps to fulfill their purpose. + self.reported_device_ids = removed_device_ids - ignored_device_ids + if not self.reported_device_ids: + return + + # Generate message + removed_printers_message = Message( + title = self.I18N_CATALOG.i18ncp( + "info:status", + "Cloud connection is not available for a printer", + "Cloud connection is not available for some printers", + len(self.reported_device_ids) + ), + lifetime = 0 + ) + device_names = "\n".join(["
  • {} ({})
  • ".format(self._um_cloud_printers[device].name, self._um_cloud_printers[device].definition.name) for device in self.reported_device_ids]) + message_text = self.I18N_CATALOG.i18ncp( + "info:status", + "The following cloud printer is not linked to your account:\n", + "The following cloud printers are not linked to your account:\n", + len(self.reported_device_ids) + ) + message_text += self.I18N_CATALOG.i18nc( + "info:status", + "\nTo establish a connection, please visit the " + "Ultimaker Digital Factory.", + device_names + ) + removed_printers_message.setText(message_text) + + output_device_manager = CuraApplication.getInstance().getOutputDeviceManager() + + # Remove the output device from the printers + for device_id in removed_device_ids: + device = self._um_cloud_printers.get(device_id, None) # type: Optional[GlobalStack] + if not device: + continue + if device_id in output_device_manager.getOutputDeviceIds(): + output_device_manager.removeOutputDevice(device_id) + if device_id in self._remote_clusters: + del self._remote_clusters[device_id] + + # Update the printer's metadata to mark it as not linked to the account + device.setMetaDataEntry(META_UM_LINKED_TO_ACCOUNT, False) + + removed_printers_message.show() + def _onDiscoveredDeviceRemoved(self, device_id: str) -> None: device = self._remote_clusters.pop(device_id, None) # type: Optional[CloudOutputDevice] if not device: diff --git a/resources/qml/PrinterSelector/MachineSelector.qml b/resources/qml/PrinterSelector/MachineSelector.qml index 56a3d858ec..0907767eea 100644 --- a/resources/qml/PrinterSelector/MachineSelector.qml +++ b/resources/qml/PrinterSelector/MachineSelector.qml @@ -35,14 +35,21 @@ Cura.ExpandablePopup } } - readonly property string connectionStatusMessage: { + function getConnectionStatusMessage() { if (connectionStatus == "printer_cloud_not_available") { if(Cura.API.connectionStatus.isInternetReachable) { if (Cura.API.account.isLoggedIn) { - return catalog.i18nc("@status", "The cloud printer is offline. Please check if the printer is turned on and connected to the internet.") + if (Cura.MachineManager.activeMachineIsLinkedToCurrentAccount) + { + return catalog.i18nc("@status", "The cloud printer is offline. Please check if the printer is turned on and connected to the internet.") + } + else + { + return catalog.i18nc("@status", "This printer is not linked to your account. Please visit the Ultimaker Digital Factory to establish a connection.") + } } else { @@ -139,12 +146,13 @@ Cura.ExpandablePopup { id: connectionStatusTooltipHoverArea anchors.fill: parent - hoverEnabled: connectionStatusMessage !== "" + hoverEnabled: getConnectionStatusMessage() !== "" acceptedButtons: Qt.NoButton // react to hover only, don't steal clicks onEntered: { machineSelector.mouseArea.entered() // we want both this and the outer area to be entered + tooltip.tooltipText = getConnectionStatusMessage() tooltip.show() } onExited: { tooltip.hide() } @@ -155,7 +163,7 @@ Cura.ExpandablePopup id: tooltip width: 250 * screenScaleFactor - tooltipText: connectionStatusMessage + tooltipText: getConnectionStatusMessage() arrowSize: UM.Theme.getSize("button_tooltip_arrow").width x: connectionStatusImage.x - UM.Theme.getSize("narrow_margin").width y: connectionStatusImage.y + connectionStatusImage.height + UM.Theme.getSize("narrow_margin").height