diff --git a/cura/API/Account.py b/cura/API/Account.py index ab45c4a4be..2d4b204333 100644 --- a/cura/API/Account.py +++ b/cura/API/Account.py @@ -62,7 +62,7 @@ class Account(QObject): updatePackagesEnabledChanged = pyqtSignal(bool) CLIENT_SCOPES = "account.user.read drive.backup.read drive.backup.write packages.download " \ - "packages.rating.read packages.rating.write connect.cluster.read connect.cluster.write " \ + "packages.rating.read packages.rating.write connect.cluster.read connect.cluster.write connect.material.write " \ "library.project.read library.project.write cura.printjob.read cura.printjob.write " \ "cura.mesh.read cura.mesh.write" diff --git a/cura/Machines/Models/GlobalStacksModel.py b/cura/Machines/Models/GlobalStacksModel.py index 712597c2e7..586bd11819 100644 --- a/cura/Machines/Models/GlobalStacksModel.py +++ b/cura/Machines/Models/GlobalStacksModel.py @@ -1,7 +1,8 @@ -# Copyright (c) 2018 Ultimaker B.V. +# Copyright (c) 2021 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from PyQt5.QtCore import Qt, QTimer +from PyQt5.QtCore import Qt, QTimer, pyqtProperty, pyqtSignal +from typing import Optional from UM.Qt.ListModel import ListModel from UM.i18n import i18nCatalog @@ -20,6 +21,7 @@ class GlobalStacksModel(ListModel): MetaDataRole = Qt.UserRole + 5 DiscoverySourceRole = Qt.UserRole + 6 # For separating local and remote printers in the machine management page RemovalWarningRole = Qt.UserRole + 7 + IsOnlineRole = Qt.UserRole + 8 def __init__(self, parent = None) -> None: super().__init__(parent) @@ -31,18 +33,49 @@ class GlobalStacksModel(ListModel): self.addRoleName(self.HasRemoteConnectionRole, "hasRemoteConnection") self.addRoleName(self.MetaDataRole, "metadata") self.addRoleName(self.DiscoverySourceRole, "discoverySource") + self.addRoleName(self.IsOnlineRole, "isOnline") self._change_timer = QTimer() self._change_timer.setInterval(200) self._change_timer.setSingleShot(True) self._change_timer.timeout.connect(self._update) + self._filter_connection_type = None # type: Optional[ConnectionType] + self._filter_online_only = False + # Listen to changes CuraContainerRegistry.getInstance().containerAdded.connect(self._onContainerChanged) CuraContainerRegistry.getInstance().containerMetaDataChanged.connect(self._onContainerChanged) CuraContainerRegistry.getInstance().containerRemoved.connect(self._onContainerChanged) self._updateDelayed() + filterConnectionTypeChanged = pyqtSignal() + def setFilterConnectionType(self, new_filter: Optional[ConnectionType]) -> None: + self._filter_connection_type = new_filter + + @pyqtProperty(int, fset = setFilterConnectionType, notify = filterConnectionTypeChanged) + def filterConnectionType(self) -> int: + """ + The connection type to filter the list of printers by. + + Only printers that match this connection type will be listed in the + model. + """ + if self._filter_connection_type is None: + return -1 + return self._filter_connection_type.value + + filterOnlineOnlyChanged = pyqtSignal() + def setFilterOnlineOnly(self, new_filter: bool) -> None: + self._filter_online_only = new_filter + + @pyqtProperty(bool, fset = setFilterOnlineOnly, notify = filterOnlineOnlyChanged) + def filterOnlineOnly(self) -> bool: + """ + Whether to filter the global stacks to show only printers that are online. + """ + return self._filter_online_only + def _onContainerChanged(self, container) -> None: """Handler for container added/removed events from registry""" @@ -58,6 +91,10 @@ class GlobalStacksModel(ListModel): container_stacks = CuraContainerRegistry.getInstance().findContainerStacks(type = "machine") for container_stack in container_stacks: + if self._filter_connection_type is not None: # We want to filter on connection types. + if not any((connection_type == self._filter_connection_type for connection_type in container_stack.configuredConnectionTypes)): + continue # No connection type on this printer matches the filter. + has_remote_connection = False for connection_type in container_stack.configuredConnectionTypes: @@ -67,6 +104,10 @@ class GlobalStacksModel(ListModel): if parseBool(container_stack.getMetaDataEntry("hidden", False)): continue + is_online = container_stack.getMetaDataEntry("is_online", False) + if self._filter_online_only and not is_online: + continue + device_name = container_stack.getMetaDataEntry("group_name", container_stack.getName()) section_name = "Connected printers" if has_remote_connection else "Preset printers" section_name = self._catalog.i18nc("@info:title", section_name) @@ -82,6 +123,7 @@ class GlobalStacksModel(ListModel): "hasRemoteConnection": has_remote_connection, "metadata": container_stack.getMetaData().copy(), "discoverySource": section_name, - "removalWarning": removal_warning}) + "removalWarning": removal_warning, + "isOnline": is_online}) items.sort(key=lambda i: (not i["hasRemoteConnection"], i["name"])) self.setItems(items) diff --git a/cura/Machines/Models/MaterialManagementModel.py b/cura/Machines/Models/MaterialManagementModel.py index 53c7721a8e..de91703ecf 100644 --- a/cura/Machines/Models/MaterialManagementModel.py +++ b/cura/Machines/Models/MaterialManagementModel.py @@ -2,21 +2,21 @@ # Cura is released under the terms of the LGPLv3 or higher. import copy # To duplicate materials. -from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl +from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, QUrl +from PyQt5.QtGui import QDesktopServices from typing import Any, Dict, Optional, TYPE_CHECKING import uuid # To generate new GUIDs for new materials. -import zipfile # To export all materials in a .zip archive. - -from PyQt5.QtGui import QDesktopServices +from UM.Message import Message from UM.i18n import i18nCatalog from UM.Logger import Logger -from UM.Message import Message +from UM.Resources import Resources # To find QML files. from UM.Signal import postponeSignals, CompressTechnique import cura.CuraApplication # Imported like this to prevent circular imports. from cura.Machines.ContainerTree import ContainerTree from cura.Settings.CuraContainerRegistry import CuraContainerRegistry # To find the sets of materials belonging to each other, and currently loaded extruder stacks. +from cura.UltimakerCloud.CloudMaterialSync import CloudMaterialSync if TYPE_CHECKING: from cura.Machines.MaterialNode import MaterialNode @@ -33,6 +33,7 @@ class MaterialManagementModel(QObject): def __init__(self, parent: Optional[QObject] = None) -> None: super().__init__(parent = parent) + self._material_sync = CloudMaterialSync(parent=self) self._checkIfNewMaterialsWereInstalled() def _checkIfNewMaterialsWereInstalled(self) -> None: @@ -89,6 +90,7 @@ class MaterialManagementModel(QObject): elif sync_message_action == "learn_more": QDesktopServices.openUrl(QUrl("https://support.ultimaker.com/hc/en-us/articles/360013137919?utm_source=cura&utm_medium=software&utm_campaign=sync-material-printer-message")) + @pyqtSlot("QVariant", result = bool) def canMaterialBeRemoved(self, material_node: "MaterialNode") -> bool: """Can a certain material be deleted, or is it still in use in one of the container stacks anywhere? @@ -323,52 +325,18 @@ class MaterialManagementModel(QObject): except ValueError: # Material was not in the favorites list. Logger.log("w", "Material {material_base_file} was already not a favorite material.".format(material_base_file = material_base_file)) - @pyqtSlot(result = QUrl) - def getPreferredExportAllPath(self) -> QUrl: + @pyqtSlot() + def openSyncAllWindow(self) -> None: """ - Get the preferred path to export materials to. + Opens the window to sync all materials. + """ + self._material_sync.reset() - If there is a removable drive, that should be the preferred path. Otherwise it should be the most recent local - file path. - :return: The preferred path to export all materials to. - """ - cura_application = cura.CuraApplication.CuraApplication.getInstance() - device_manager = cura_application.getOutputDeviceManager() - devices = device_manager.getOutputDevices() - for device in devices: - if device.__class__.__name__ == "RemovableDriveOutputDevice": - return QUrl.fromLocalFile(device.getId()) - else: # No removable drives? Use local path. - return cura_application.getDefaultPath("dialog_material_path") - - @pyqtSlot(QUrl) - def exportAll(self, file_path: QUrl) -> None: - """ - Export all materials to a certain file path. - :param file_path: The path to export the materials to. - """ - registry = CuraContainerRegistry.getInstance() - - try: - archive = zipfile.ZipFile(file_path.toLocalFile(), "w", compression = zipfile.ZIP_DEFLATED) - except OSError as e: - Logger.log("e", f"Can't write to destination {file_path.toLocalFile()}: {type(e)} - {str(e)}") - error_message = Message( - text = catalog.i18nc("@error:text Followed by an error message of why it could not save", "Could not save material archive to {filename}:").format(filename = file_path.toLocalFile()) + " " + str(e), - title = catalog.i18nc("@error:title", "Failed to save material archive"), - message_type = Message.MessageType.ERROR - ) - error_message.show() + if self._material_sync.sync_all_dialog is None: + qml_path = Resources.getPath(cura.CuraApplication.CuraApplication.ResourceTypes.QmlFiles, "Preferences", "Materials", "MaterialsSyncDialog.qml") + self._material_sync.sync_all_dialog = cura.CuraApplication.CuraApplication.getInstance().createQmlComponent(qml_path, {}) + if self._material_sync.sync_all_dialog is None: # Failed to load QML file. return - for metadata in registry.findInstanceContainersMetadata(type = "material"): - if metadata["base_file"] != metadata["id"]: # Only process base files. - continue - if metadata["id"] == "empty_material": # Don't export the empty material. - continue - material = registry.findContainers(id = metadata["id"])[0] - suffix = registry.getMimeTypeForContainer(type(material)).preferredSuffix - filename = metadata["id"] + "." + suffix - try: - archive.writestr(filename, material.serialize()) - except OSError as e: - Logger.log("e", f"An error has occurred while writing the material \'{metadata['id']}\' in the file \'{filename}\': {e}.") + self._material_sync.sync_all_dialog.setProperty("syncModel", self._material_sync) + self._material_sync.sync_all_dialog.setProperty("pageIndex", 0) # Return to first page. + self._material_sync.sync_all_dialog.show() diff --git a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py index 9979354dba..42c1cd78aa 100644 --- a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py +++ b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py @@ -1,4 +1,4 @@ -# Copyright (c) 2018 Ultimaker B.V. +# Copyright (c) 2021 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from UM.FileHandler.FileHandler import FileHandler #For typing. @@ -114,6 +114,11 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): return b"".join(file_data_bytes_list) def _update(self) -> None: + """ + Update the connection state of this device. + + This is called on regular intervals. + """ if self._last_response_time: time_since_last_response = time() - self._last_response_time else: @@ -127,11 +132,11 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): if time_since_last_response > self._timeout_time >= time_since_last_request: # Go (or stay) into timeout. if self._connection_state_before_timeout is None: - self._connection_state_before_timeout = self._connection_state + self._connection_state_before_timeout = self.connectionState self.setConnectionState(ConnectionState.Closed) - elif self._connection_state == ConnectionState.Closed: + elif self.connectionState == ConnectionState.Closed: # Go out of timeout. if self._connection_state_before_timeout is not None: # sanity check, but it should never be None here self.setConnectionState(self._connection_state_before_timeout) @@ -361,7 +366,7 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): self._last_response_time = time() - if self._connection_state == ConnectionState.Connecting: + if self.connectionState == ConnectionState.Connecting: self.setConnectionState(ConnectionState.Connected) callback_key = reply.url().toString() + str(reply.operation()) diff --git a/cura/PrinterOutput/PrinterOutputDevice.py b/cura/PrinterOutput/PrinterOutputDevice.py index 526d713748..2939076a9a 100644 --- a/cura/PrinterOutput/PrinterOutputDevice.py +++ b/cura/PrinterOutput/PrinterOutputDevice.py @@ -1,11 +1,13 @@ -# Copyright (c) 2018 Ultimaker B.V. +# Copyright (c) 2021 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. + from enum import IntEnum from typing import Callable, List, Optional, Union from PyQt5.QtCore import pyqtProperty, pyqtSignal, QObject, QTimer, QUrl from PyQt5.QtWidgets import QMessageBox +import cura.CuraApplication # Imported like this to prevent circular imports. from UM.Logger import Logger from UM.Signal import signalemitter from UM.Qt.QtApplication import QtApplication @@ -120,11 +122,22 @@ class PrinterOutputDevice(QObject, OutputDevice): callback(QMessageBox.Yes) def isConnected(self) -> bool: - return self._connection_state != ConnectionState.Closed and self._connection_state != ConnectionState.Error + """ + Returns whether we could theoretically send commands to this printer. + :return: `True` if we are connected, or `False` if not. + """ + return self.connectionState != ConnectionState.Closed and self.connectionState != ConnectionState.Error def setConnectionState(self, connection_state: "ConnectionState") -> None: - if self._connection_state != connection_state: + """ + Store the connection state of the printer. + + Causes everything that displays the connection state to update its QML models. + :param connection_state: The new connection state to store. + """ + if self.connectionState != connection_state: self._connection_state = connection_state + cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack().setMetaDataEntry("is_online", self.isConnected()) self.connectionStateChanged.emit(self._id) @pyqtProperty(int, constant = True) @@ -133,6 +146,10 @@ class PrinterOutputDevice(QObject, OutputDevice): @pyqtProperty(int, notify = connectionStateChanged) def connectionState(self) -> "ConnectionState": + """ + Get the connection state of the printer, e.g. whether it is connected, still connecting, error state, etc. + :return: The current connection state of this output device. + """ return self._connection_state def _update(self) -> None: diff --git a/cura/PrinterOutput/UploadMaterialsJob.py b/cura/PrinterOutput/UploadMaterialsJob.py new file mode 100644 index 0000000000..166b692ea5 --- /dev/null +++ b/cura/PrinterOutput/UploadMaterialsJob.py @@ -0,0 +1,256 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + +import enum +import functools # For partial methods to use as callbacks with information pre-filled. +import json # To serialise metadata for API calls. +import os # To delete the archive when we're done. +from PyQt5.QtCore import QUrl +import tempfile # To create an archive before we upload it. + +import cura.CuraApplication # Imported like this to prevent circular imports. +from cura.Settings.CuraContainerRegistry import CuraContainerRegistry # To find all printers to upload to. +from cura.UltimakerCloud import UltimakerCloudConstants # To know where the API is. +from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope # To know how to communicate with this server. +from UM.i18n import i18nCatalog +from UM.Job import Job +from UM.Logger import Logger +from UM.Signal import Signal +from UM.TaskManagement.HttpRequestManager import HttpRequestManager # To call the API. +from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope + +from typing import Any, cast, Dict, List, Optional, TYPE_CHECKING +if TYPE_CHECKING: + from PyQt5.QtNetwork import QNetworkReply + from cura.UltimakerCloud.CloudMaterialSync import CloudMaterialSync + +catalog = i18nCatalog("cura") + + +class UploadMaterialsError(Exception): + """ + Class to indicate something went wrong while uploading. + """ + pass + + +class UploadMaterialsJob(Job): + """ + Job that uploads a set of materials to the Digital Factory. + + The job has a number of stages: + - First, it generates an archive of all materials. This typically takes a lot of processing power during which the + GIL remains locked. + - Then it requests the API to upload an archive. + - Then it uploads the archive to the URL given by the first request. + - Then it tells the API that the archive can be distributed to the printers. + """ + + UPLOAD_REQUEST_URL = f"{UltimakerCloudConstants.CuraCloudAPIRoot}/connect/v1/materials/upload" + UPLOAD_CONFIRM_URL = UltimakerCloudConstants.CuraCloudAPIRoot + "/connect/v1/clusters/{cluster_id}/printers/{cluster_printer_id}/action/import_material" + + class Result(enum.IntEnum): + SUCCESS = 0 + FAILED = 1 + + class PrinterStatus(enum.Enum): + UPLOADING = "uploading" + SUCCESS = "success" + FAILED = "failed" + + def __init__(self, material_sync: "CloudMaterialSync"): + super().__init__() + self._material_sync = material_sync + self._scope = JsonDecoratorScope(UltimakerCloudScope(cura.CuraApplication.CuraApplication.getInstance())) # type: JsonDecoratorScope + self._archive_filename = None # type: Optional[str] + self._archive_remote_id = None # type: Optional[str] # ID that the server gives to this archive. Used to communicate about the archive to the server. + self._printer_sync_status = {} # type: Dict[str, str] + self._printer_metadata = [] # type: List[Dict[str, Any]] + self.processProgressChanged.connect(self._onProcessProgressChanged) + + uploadCompleted = Signal() # Triggered when the job is really complete, including uploading to the cloud. + processProgressChanged = Signal() # Triggered when we've made progress creating the archive. + uploadProgressChanged = Signal() # Triggered when we've made progress with the complete job. This signal emits a progress fraction (0-1) as well as the status of every printer. + + def run(self) -> None: + """ + Generates an archive of materials and starts uploading that archive to the cloud. + """ + self._printer_metadata = CuraContainerRegistry.getInstance().findContainerStacksMetadata( + type = "machine", + connection_type = "3", # Only cloud printers. + is_online = "True", # Only online printers. Otherwise the server gives an error. + host_guid = "*", # Required metadata field. Otherwise we get a KeyError. + um_cloud_cluster_id = "*" # Required metadata field. Otherwise we get a KeyError. + ) + for printer in self._printer_metadata: + self._printer_sync_status[printer["host_guid"]] = self.PrinterStatus.UPLOADING.value + + try: + archive_file = tempfile.NamedTemporaryFile("wb", delete = False) + archive_file.close() + self._archive_filename = archive_file.name + self._material_sync.exportAll(QUrl.fromLocalFile(self._archive_filename), notify_progress = self.processProgressChanged) + except OSError as e: + Logger.error(f"Failed to create archive of materials to sync with printers: {type(e)} - {e}") + self.failed(UploadMaterialsError(catalog.i18nc("@text:error", "Failed to create archive of materials to sync with printers."))) + return + + try: + file_size = os.path.getsize(self._archive_filename) + except OSError as e: + Logger.error(f"Failed to load the archive of materials to sync it with printers: {type(e)} - {e}") + self.failed(UploadMaterialsError(catalog.i18nc("@text:error", "Failed to load the archive of materials to sync it with printers."))) + return + + request_metadata = { + "data": { + "file_size": file_size, + "material_profile_name": "cura.umm", # File name can be anything as long as it's .umm. It's not used by anyone. + "content_type": "application/zip", # This endpoint won't receive files of different MIME types. + "origin": "cura" # Some identifier against hackers intercepting this upload request, apparently. + } + } + request_payload = json.dumps(request_metadata).encode("UTF-8") + + http = HttpRequestManager.getInstance() + http.put( + url = self.UPLOAD_REQUEST_URL, + data = request_payload, + callback = self.onUploadRequestCompleted, + error_callback = self.onError, + scope = self._scope + ) + + def onUploadRequestCompleted(self, reply: "QNetworkReply") -> None: + """ + Triggered when we successfully requested to upload a material archive. + + We then need to start uploading the material archive to the URL that the request answered with. + :param reply: The reply from the server to our request to upload an archive. + """ + response_data = HttpRequestManager.readJSON(reply) + if response_data is None: + Logger.error(f"Invalid response to material upload request. Could not parse JSON data.") + self.failed(UploadMaterialsError(catalog.i18nc("@text:error", "The response from Digital Factory appears to be corrupted."))) + return + if "data" not in response_data: + Logger.error(f"Invalid response to material upload request: Missing 'data' field that contains the entire response.") + self.failed(UploadMaterialsError(catalog.i18nc("@text:error", "The response from Digital Factory is missing important information."))) + return + if "upload_url" not in response_data["data"]: + Logger.error(f"Invalid response to material upload request: Missing 'upload_url' field to upload archive to.") + self.failed(UploadMaterialsError(catalog.i18nc("@text:error", "The response from Digital Factory is missing important information."))) + return + if "material_profile_id" not in response_data["data"]: + Logger.error(f"Invalid response to material upload request: Missing 'material_profile_id' to communicate about the materials with the server.") + self.failed(UploadMaterialsError(catalog.i18nc("@text:error", "The response from Digital Factory is missing important information."))) + return + + upload_url = response_data["data"]["upload_url"] + self._archive_remote_id = response_data["data"]["material_profile_id"] + try: + with open(cast(str, self._archive_filename), "rb") as f: + file_data = f.read() + except OSError as e: + Logger.error(f"Failed to load archive back in for sending to cloud: {type(e)} - {e}") + self.failed(UploadMaterialsError(catalog.i18nc("@text:error", "Failed to load the archive of materials to sync it with printers."))) + return + http = HttpRequestManager.getInstance() + http.put( + url = upload_url, + data = file_data, + callback = self.onUploadCompleted, + error_callback = self.onError, + scope = self._scope + ) + + def onUploadCompleted(self, reply: "QNetworkReply") -> None: + """ + When we've successfully uploaded the archive to the cloud, we need to notify the API to start syncing that + archive to every printer. + :param reply: The reply from the cloud storage when the upload succeeded. + """ + for container_stack in self._printer_metadata: + cluster_id = container_stack["um_cloud_cluster_id"] + printer_id = container_stack["host_guid"] + + http = HttpRequestManager.getInstance() + http.post( + url = self.UPLOAD_CONFIRM_URL.format(cluster_id = cluster_id, cluster_printer_id = printer_id), + callback = functools.partial(self.onUploadConfirmed, printer_id), + error_callback = functools.partial(self.onUploadConfirmed, printer_id), # Let this same function handle the error too. + scope = self._scope, + data = json.dumps({"data": {"material_profile_id": self._archive_remote_id}}).encode("UTF-8") + ) + + def onUploadConfirmed(self, printer_id: str, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"] = None) -> None: + """ + Triggered when we've got a confirmation that the material is synced with the printer, or that syncing failed. + + If syncing succeeded we mark this printer as having the status "success". If it failed we mark the printer as + "failed". If this is the last upload that needed to be completed, we complete the job with either a success + state (every printer successfully synced) or a failed state (any printer failed). + :param printer_id: The printer host_guid that we completed syncing with. + :param reply: The reply that the server gave to confirm. + :param error: If the request failed, this error gives an indication what happened. + """ + if error is not None: + Logger.error(f"Failed to confirm uploading material archive to printer {printer_id}: {error}") + self._printer_sync_status[printer_id] = self.PrinterStatus.FAILED.value + else: + self._printer_sync_status[printer_id] = self.PrinterStatus.SUCCESS.value + + still_uploading = len([val for val in self._printer_sync_status.values() if val == self.PrinterStatus.UPLOADING.value]) + self.uploadProgressChanged.emit(0.8 + (len(self._printer_sync_status) - still_uploading) / len(self._printer_sync_status), self.getPrinterSyncStatus()) + + if still_uploading == 0: # This is the last response to be processed. + if self.PrinterStatus.FAILED.value in self._printer_sync_status.values(): + self.setResult(self.Result.FAILED) + self.setError(UploadMaterialsError(catalog.i18nc("@text:error", "Failed to connect to Digital Factory to sync materials with some of the printers."))) + else: + self.setResult(self.Result.SUCCESS) + self.uploadCompleted.emit(self.getResult(), self.getError()) + + def onError(self, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"]) -> None: + """ + Used as callback from HTTP requests when the request failed. + + The given network error from the `HttpRequestManager` is logged, and the job is marked as failed. + :param reply: The main reply of the server. This reply will most likely not be valid. + :param error: The network error (Qt's enum) that occurred. + """ + Logger.error(f"Failed to upload material archive: {error}") + self.failed(UploadMaterialsError(catalog.i18nc("@text:error", "Failed to connect to Digital Factory."))) + + def getPrinterSyncStatus(self) -> Dict[str, str]: + """ + For each printer, identified by host_guid, this gives the current status of uploading the material archive. + + The possible states are given in the PrinterStatus enum. + :return: A dictionary with printer host_guids as keys, and their status as values. + """ + return self._printer_sync_status + + def failed(self, error: UploadMaterialsError) -> None: + """ + Helper function for when we have a general failure. + + This sets the sync status for all printers to failed, sets the error on + the job and the result of the job to FAILED. + :param error: An error to show to the user. + """ + self.setResult(self.Result.FAILED) + self.setError(error) + for printer_id in self._printer_sync_status: + self._printer_sync_status[printer_id] = self.PrinterStatus.FAILED.value + self.uploadProgressChanged.emit(1.0, self.getPrinterSyncStatus()) + self.uploadCompleted.emit(self.getResult(), self.getError()) + + def _onProcessProgressChanged(self, progress: float) -> None: + """ + When we progress in the process of uploading materials, we not only signal the new progress (float from 0 to 1) + but we also signal the current status of every printer. These are emitted as the two parameters of the signal. + :param progress: The progress of this job, between 0 and 1. + """ + self.uploadProgressChanged.emit(progress * 0.8, self.getPrinterSyncStatus()) # The processing is 80% of the progress bar. diff --git a/cura/UltimakerCloud/CloudMaterialSync.py b/cura/UltimakerCloud/CloudMaterialSync.py new file mode 100644 index 0000000000..05f65bb822 --- /dev/null +++ b/cura/UltimakerCloud/CloudMaterialSync.py @@ -0,0 +1,200 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl +from PyQt5.QtGui import QDesktopServices +from typing import Dict, Optional, TYPE_CHECKING +import zipfile # To export all materials in a .zip archive. + +import cura.CuraApplication # Imported like this to prevent circular imports. +from cura.PrinterOutput.UploadMaterialsJob import UploadMaterialsJob, UploadMaterialsError # To export materials to the output printer. +from cura.Settings.CuraContainerRegistry import CuraContainerRegistry +from UM.i18n import i18nCatalog +from UM.Logger import Logger +from UM.Message import Message + +if TYPE_CHECKING: + from UM.Signal import Signal +catalog = i18nCatalog("cura") + +class CloudMaterialSync(QObject): + """ + Handles the synchronisation of material profiles with cloud accounts. + """ + + def __init__(self, parent: QObject = None): + super().__init__(parent) + self.sync_all_dialog = None # type: Optional[QObject] + self._export_upload_status = "idle" + self._checkIfNewMaterialsWereInstalled() + self._export_progress = 0.0 + self._printer_status = {} # type: Dict[str, str] + + def _checkIfNewMaterialsWereInstalled(self) -> None: + """ + Checks whether new material packages were installed in the latest startup. If there were, then it shows + a message prompting the user to sync the materials with their printers. + """ + application = cura.CuraApplication.CuraApplication.getInstance() + for package_id, package_data in application.getPackageManager().getPackagesInstalledOnStartup().items(): + if package_data["package_info"]["package_type"] == "material": + # At least one new material was installed + self._showSyncNewMaterialsMessage() + break + + def _showSyncNewMaterialsMessage(self) -> None: + sync_materials_message = Message( + text = catalog.i18nc("@action:button", + "Please sync the material profiles with your printers before starting to print."), + title = catalog.i18nc("@action:button", "New materials installed"), + message_type = Message.MessageType.WARNING, + lifetime = 0 + ) + + sync_materials_message.addAction( + "sync", + name = catalog.i18nc("@action:button", "Sync materials with printers"), + icon = "", + description = "Sync your newly installed materials with your printers.", + button_align = Message.ActionButtonAlignment.ALIGN_RIGHT + ) + + sync_materials_message.addAction( + "learn_more", + name = catalog.i18nc("@action:button", "Learn more"), + icon = "", + description = "Learn more about syncing your newly installed materials with your printers.", + button_align = Message.ActionButtonAlignment.ALIGN_LEFT, + button_style = Message.ActionButtonStyle.LINK + ) + sync_materials_message.actionTriggered.connect(self._onSyncMaterialsMessageActionTriggered) + + # Show the message only if there are printers that support material export + container_registry = cura.CuraApplication.CuraApplication.getInstance().getContainerRegistry() + global_stacks = container_registry.findContainerStacks(type = "machine") + if any([stack.supportsMaterialExport for stack in global_stacks]): + sync_materials_message.show() + + def _onSyncMaterialsMessageActionTriggered(self, sync_message: Message, sync_message_action: str): + if sync_message_action == "sync": + self.openSyncAllWindow() + sync_message.hide() + elif sync_message_action == "learn_more": + QDesktopServices.openUrl(QUrl("https://support.ultimaker.com/hc/en-us/articles/360013137919?utm_source=cura&utm_medium=software&utm_campaign=sync-material-printer-message")) + + @pyqtSlot(result = QUrl) + def getPreferredExportAllPath(self) -> QUrl: + """ + Get the preferred path to export materials to. + + If there is a removable drive, that should be the preferred path. Otherwise it should be the most recent local + file path. + :return: The preferred path to export all materials to. + """ + cura_application = cura.CuraApplication.CuraApplication.getInstance() + device_manager = cura_application.getOutputDeviceManager() + devices = device_manager.getOutputDevices() + for device in devices: + if device.__class__.__name__ == "RemovableDriveOutputDevice": + return QUrl.fromLocalFile(device.getId()) + else: # No removable drives? Use local path. + return cura_application.getDefaultPath("dialog_material_path") + + @pyqtSlot(QUrl) + def exportAll(self, file_path: QUrl, notify_progress: Optional["Signal"] = None) -> None: + """ + Export all materials to a certain file path. + :param file_path: The path to export the materials to. + """ + registry = CuraContainerRegistry.getInstance() + + # Create empty archive. + try: + archive = zipfile.ZipFile(file_path.toLocalFile(), "w", compression = zipfile.ZIP_DEFLATED) + except OSError as e: + Logger.log("e", f"Can't write to destination {file_path.toLocalFile()}: {type(e)} - {str(e)}") + error_message = Message( + text = catalog.i18nc("@message:text", "Could not save material archive to {}:").format(file_path.toLocalFile()) + " " + str(e), + title = catalog.i18nc("@message:title", "Failed to save material archive"), + message_type = Message.MessageType.ERROR + ) + error_message.show() + return + + materials_metadata = registry.findInstanceContainersMetadata(type = "material") + for index, metadata in enumerate(materials_metadata): + if notify_progress is not None: + progress = index / len(materials_metadata) + notify_progress.emit(progress) + if metadata["base_file"] != metadata["id"]: # Only process base files. + continue + if metadata["id"] == "empty_material": # Don't export the empty material. + continue + material = registry.findContainers(id = metadata["id"])[0] + suffix = registry.getMimeTypeForContainer(type(material)).preferredSuffix + filename = metadata["id"] + "." + suffix + try: + archive.writestr(filename, material.serialize()) + except OSError as e: + Logger.log("e", f"An error has occurred while writing the material \'{metadata['id']}\' in the file \'{filename}\': {e}.") + + exportUploadStatusChanged = pyqtSignal() + + @pyqtProperty(str, notify = exportUploadStatusChanged) + def exportUploadStatus(self) -> str: + return self._export_upload_status + + @pyqtSlot() + def exportUpload(self) -> None: + """ + Export all materials and upload them to the user's account. + """ + self._export_upload_status = "uploading" + self.exportUploadStatusChanged.emit() + job = UploadMaterialsJob(self) + job.uploadProgressChanged.connect(self._onUploadProgressChanged) + job.uploadCompleted.connect(self.exportUploadCompleted) + job.start() + + def _onUploadProgressChanged(self, progress: float, printers_status: Dict[str, str]): + self.setExportProgress(progress) + self.setPrinterStatus(printers_status) + + def exportUploadCompleted(self, job_result: UploadMaterialsJob.Result, job_error: Optional[Exception]): + if not self.sync_all_dialog: # Shouldn't get triggered before the dialog is open, but better to check anyway. + return + if job_result == UploadMaterialsJob.Result.FAILED: + if isinstance(job_error, UploadMaterialsError): + self.sync_all_dialog.setProperty("syncStatusText", catalog.i18nc("@text", "Error sending materials to the Digital Factory:") + " " + str(job_error)) + else: # Could be "None" + self.sync_all_dialog.setProperty("syncStatusText", catalog.i18nc("@text", "Unknown error.")) + self._export_upload_status = "error" + else: + self._export_upload_status = "success" + self.exportUploadStatusChanged.emit() + + exportProgressChanged = pyqtSignal(float) + + def setExportProgress(self, progress: float) -> None: + self._export_progress = progress + self.exportProgressChanged.emit(self._export_progress) + + @pyqtProperty(float, fset = setExportProgress, notify = exportProgressChanged) + def exportProgress(self) -> float: + return self._export_progress + + printerStatusChanged = pyqtSignal() + + def setPrinterStatus(self, new_status: Dict[str, str]) -> None: + self._printer_status = new_status + self.printerStatusChanged.emit() + + @pyqtProperty("QVariantMap", fset = setPrinterStatus, notify = printerStatusChanged) + def printerStatus(self) -> Dict[str, str]: + return self._printer_status + + def reset(self) -> None: + self.setPrinterStatus({}) + self.setExportProgress(0.0) + self._export_upload_status = "idle" + self.exportUploadStatusChanged.emit() diff --git a/cura/UltimakerCloud/UltimakerCloudScope.py b/cura/UltimakerCloud/UltimakerCloudScope.py index 5477423099..bbcc8e2aa9 100644 --- a/cura/UltimakerCloud/UltimakerCloudScope.py +++ b/cura/UltimakerCloud/UltimakerCloudScope.py @@ -1,9 +1,15 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + from PyQt5.QtNetwork import QNetworkRequest from UM.Logger import Logger from UM.TaskManagement.HttpRequestScope import DefaultUserAgentScope -from cura.API import Account -from cura.CuraApplication import CuraApplication + +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from cura.CuraApplication import CuraApplication + from cura.API.Account import Account class UltimakerCloudScope(DefaultUserAgentScope): @@ -12,7 +18,7 @@ class UltimakerCloudScope(DefaultUserAgentScope): Also add the user agent headers (see DefaultUserAgentScope). """ - def __init__(self, application: CuraApplication): + def __init__(self, application: "CuraApplication"): super().__init__(application) api = application.getCuraAPI() self._account = api.account # type: Account diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index b35cd5b5f5..5b1844e7cb 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -1,4 +1,4 @@ -# Copyright (c) 2020 Ultimaker B.V. +# Copyright (c) 2021 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. import os @@ -16,6 +16,7 @@ from UM.Util import parseBool from cura.API import Account from cura.API.Account import SyncState from cura.CuraApplication import CuraApplication +from cura.Settings.CuraContainerRegistry import CuraContainerRegistry # To update printer metadata with information received about cloud printers. from cura.Settings.CuraStackBuilder import CuraStackBuilder from cura.Settings.GlobalStack import GlobalStack from cura.UltimakerCloud.UltimakerCloudConstants import META_UM_LINKED_TO_ACCOUNT @@ -129,6 +130,8 @@ class CloudOutputDeviceManager: self._um_cloud_printers[device_id].setMetaDataEntry(META_UM_LINKED_TO_ACCOUNT, True) self._onDevicesDiscovered(new_clusters) + self._updateOnlinePrinters(all_clusters) + # Hide the current removed_printers_message, if there is any if self._removed_printers_message: self._removed_printers_message.actionTriggered.disconnect(self._onRemovedPrintersMessageActionTriggered) @@ -154,6 +157,8 @@ class CloudOutputDeviceManager: self._syncing = False self._account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.SUCCESS) + Logger.debug("Synced cloud printers with account.") + def _onGetRemoteClusterFailed(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None: self._syncing = False self._account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.ERROR) @@ -255,6 +260,16 @@ class CloudOutputDeviceManager: message_text = self.i18n_catalog.i18nc("info:status", "Printers added from Digital Factory:") + "" message.setText(message_text) + def _updateOnlinePrinters(self, printer_responses: Dict[str, CloudClusterResponse]) -> None: + """ + Update the metadata of the printers to store whether they are online or not. + :param printer_responses: The responses received from the API about the printer statuses. + """ + for container_stack in CuraContainerRegistry.getInstance().findContainerStacks(type = "machine"): + cluster_id = container_stack.getMetaDataEntry("um_cloud_cluster_id", "") + if cluster_id in printer_responses: + container_stack.setMetaDataEntry("is_online", printer_responses[cluster_id].is_online) + def _updateOutdatedMachine(self, outdated_machine: GlobalStack, new_cloud_output_device: CloudOutputDevice) -> None: """ Update the cloud metadata of a pre-existing machine that is rediscovered (e.g. if the printer was removed and diff --git a/plugins/VersionUpgrade/VersionUpgrade411to412/VersionUpgrade411to412.py b/plugins/VersionUpgrade/VersionUpgrade411to412/VersionUpgrade411to412.py index 4fb18486c2..2ae94a11f7 100644 --- a/plugins/VersionUpgrade/VersionUpgrade411to412/VersionUpgrade411to412.py +++ b/plugins/VersionUpgrade/VersionUpgrade411to412/VersionUpgrade411to412.py @@ -3,6 +3,7 @@ import configparser import io +import json import os.path from typing import List, Tuple @@ -49,6 +50,28 @@ class VersionUpgrade411to412(VersionUpgrade): # Update version number. parser["metadata"]["setting_version"] = "19" + # If the account scope in 4.11 is outdated, delete it so that the user is enforced to log in again and get the + # correct permissions. + new_scopes = {"account.user.read", + "drive.backup.read", + "drive.backup.write", + "packages.download", + "packages.rating.read", + "packages.rating.write", + "connect.cluster.read", + "connect.cluster.write", + "library.project.read", + "library.project.write", + "cura.printjob.read", + "cura.printjob.write", + "cura.mesh.read", + "cura.mesh.write", + "cura.material.write"} + if "ultimaker_auth_data" in parser["general"]: + ultimaker_auth_data = json.loads(parser["general"]["ultimaker_auth_data"]) + if new_scopes - set(ultimaker_auth_data["scope"].split(" ")): + parser["general"]["ultimaker_auth_data"] = "{}" + result = io.StringIO() parser.write(result) return [filename], [result.getvalue()] diff --git a/resources/qml/Preferences/Materials/MaterialsPage.qml b/resources/qml/Preferences/Materials/MaterialsPage.qml index 4de3ad918b..6ec23f001f 100644 --- a/resources/qml/Preferences/Materials/MaterialsPage.qml +++ b/resources/qml/Preferences/Materials/MaterialsPage.qml @@ -201,8 +201,7 @@ Item onClicked: { forceActiveFocus(); - exportAllMaterialsDialog.folder = base.materialManagementModel.getPreferredExportAllPath(); - exportAllMaterialsDialog.open(); + base.materialManagementModel.openSyncAllWindow(); } visible: Cura.MachineManager.activeMachine.supportsMaterialExport } @@ -383,19 +382,6 @@ Item } } - FileDialog - { - id: exportAllMaterialsDialog - title: catalog.i18nc("@title:window", "Export All Materials") - selectExisting: false - nameFilters: ["Material archives (*.umm)", "All files (*)"] - onAccepted: - { - base.materialManagementModel.exportAll(fileUrl); - CuraApplication.setDefaultPath("dialog_material_path", folder); - } - } - MessageDialog { id: messageDialog diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml new file mode 100644 index 0000000000..5d0f0dd922 --- /dev/null +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -0,0 +1,724 @@ +//Copyright (c) 2021 Ultimaker B.V. +//Cura is released under the terms of the LGPLv3 or higher. + +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Dialogs 1.2 +import QtQuick.Layouts 1.15 +import QtQuick.Window 2.1 +import Cura 1.1 as Cura +import UM 1.4 as UM + +Window +{ + id: materialsSyncDialog + property variant catalog: UM.I18nCatalog { name: "cura" } + + title: catalog.i18nc("@title:window", "Sync materials with printers") + minimumWidth: UM.Theme.getSize("modal_window_minimum").width + minimumHeight: UM.Theme.getSize("modal_window_minimum").height + width: minimumWidth + height: minimumHeight + modality: Qt.ApplicationModal + + property variant syncModel + property alias pageIndex: swipeView.currentIndex + property alias syncStatusText: syncStatusLabel.text + + SwipeView + { + id: swipeView + anchors.fill: parent + interactive: false + + Rectangle + { + id: introPage + color: UM.Theme.getColor("main_background") + Column + { + spacing: UM.Theme.getSize("default_margin").height + anchors.fill: parent + anchors.margins: UM.Theme.getSize("default_margin").width + + Label + { + text: catalog.i18nc("@title:header", "Sync materials with printers") + font: UM.Theme.getFont("large_bold") + color: UM.Theme.getColor("text") + } + Label + { + text: catalog.i18nc("@text", "Following a few simple steps, you will be able to synchronize all your material profiles with your printers.") + font: UM.Theme.getFont("medium") + color: UM.Theme.getColor("text") + wrapMode: Text.Wrap + width: parent.width + } + Image + { + source: UM.Theme.getImage("material_ecosystem") + width: parent.width + sourceSize.width: width + } + } + + Cura.PrimaryButton + { + id: startButton + anchors + { + right: parent.right + rightMargin: UM.Theme.getSize("default_margin").width + bottom: parent.bottom + bottomMargin: UM.Theme.getSize("default_margin").height + } + text: catalog.i18nc("@button", "Start") + onClicked: + { + if(Cura.API.account.isLoggedIn) + { + swipeView.currentIndex += 2; //Skip sign in page. + } + else + { + swipeView.currentIndex += 1; + } + } + } + Cura.TertiaryButton + { + anchors + { + left: parent.left + leftMargin: UM.Theme.getSize("default_margin").width + verticalCenter: startButton.verticalCenter + } + text: catalog.i18nc("@button", "Why do I need to sync material profiles?") + iconSource: UM.Theme.getIcon("LinkExternal") + isIconOnRightSide: true + onClicked: Qt.openUrlExternally("https://support.ultimaker.com/hc/en-us/articles/360013137919?utm_source=cura&utm_medium=software&utm_campaign=sync-material-printer-why") + } + } + + Rectangle + { + id: signinPage + color: UM.Theme.getColor("main_background") + + Connections //While this page is active, continue to the next page if the user logs in. + { + target: Cura.API.account + function onLoginStateChanged(is_logged_in) + { + if(is_logged_in && signinPage.SwipeView.isCurrentItem) + { + swipeView.currentIndex += 1; + } + } + } + + ColumnLayout + { + spacing: UM.Theme.getSize("default_margin").height + anchors.fill: parent + anchors.margins: UM.Theme.getSize("default_margin").width + + Label + { + text: catalog.i18nc("@title:header", "Sign in") + font: UM.Theme.getFont("large_bold") + color: UM.Theme.getColor("text") + Layout.preferredHeight: height + } + Label + { + text: catalog.i18nc("@text", "To automatically sync the material profiles with all your printers connected to Digital Factory you need to be signed in in Cura.") + font: UM.Theme.getFont("medium") + color: UM.Theme.getColor("text") + wrapMode: Text.Wrap + width: parent.width + Layout.maximumWidth: width + Layout.preferredHeight: height + } + Item + { + Layout.preferredWidth: parent.width + Layout.fillHeight: true + Image + { + source: UM.Theme.getImage("first_run_ultimaker_cloud") + width: parent.width / 2 + sourceSize.width: width + anchors.centerIn: parent + } + } + Item + { + width: parent.width + height: childrenRect.height + Layout.preferredHeight: height + Cura.SecondaryButton + { + anchors.left: parent.left + text: catalog.i18nc("@button", "Sync materials with USB") + onClicked: swipeView.currentIndex = removableDriveSyncPage.SwipeView.index + } + Cura.PrimaryButton + { + anchors.right: parent.right + text: catalog.i18nc("@button", "Sign in") + onClicked: Cura.API.account.login() + } + } + } + } + + Rectangle + { + id: printerListPage + color: UM.Theme.getColor("main_background") + + ColumnLayout + { + spacing: UM.Theme.getSize("default_margin").height + anchors.fill: parent + anchors.margins: UM.Theme.getSize("default_margin").width + visible: cloudPrinterList.count > 0 + + Row + { + Layout.preferredHeight: childrenRect.height + spacing: UM.Theme.getSize("default_margin").width + + states: [ + State + { + name: "idle" + when: typeof syncModel === "undefined" || syncModel.exportUploadStatus == "idle" || syncModel.exportUploadStatus == "uploading" + PropertyChanges { target: printerListHeader; text: catalog.i18nc("@title:header", "The following printers will receive the new material profiles:") } + PropertyChanges { target: printerListHeaderIcon; status: UM.StatusIcon.Status.NEUTRAL } + }, + State + { + name: "error" + when: typeof syncModel !== "undefined" && syncModel.exportUploadStatus == "error" + PropertyChanges { target: printerListHeader; text: catalog.i18nc("@title:header", "Something went wrong when sending the materials to the printers.") } + PropertyChanges { target: printerListHeaderIcon; status: UM.StatusIcon.Status.ERROR } + }, + State + { + name: "success" + when: typeof syncModel !== "undefined" && syncModel.exportUploadStatus == "success" + PropertyChanges { target: printerListHeader; text: catalog.i18nc("@title:header", "Material profiles successfully synced with the following printers:") } + PropertyChanges { target: printerListHeaderIcon; status: UM.StatusIcon.Status.POSITIVE } + } + ] + + UM.StatusIcon + { + id: printerListHeaderIcon + width: UM.Theme.getSize("section_icon").width + height: width + anchors.verticalCenter: parent.verticalCenter + } + Label + { + id: printerListHeader + anchors.verticalCenter: parent.verticalCenter + //Text is always defined by the states above. + font: UM.Theme.getFont("large_bold") + color: UM.Theme.getColor("text") + } + } + Row + { + Layout.preferredWidth: parent.width + Layout.preferredHeight: childrenRect.height + + Label + { + id: syncStatusLabel + + width: parent.width - UM.Theme.getSize("default_margin").width - troubleshootingLink.width + anchors.verticalCenter: troubleshootingLink.verticalCenter + + elide: Text.ElideRight + visible: text !== "" + text: "" + color: UM.Theme.getColor("text") + font: UM.Theme.getFont("medium") + } + Cura.TertiaryButton + { + id: troubleshootingLink + text: catalog.i18nc("@button", "Troubleshooting") + visible: typeof syncModel !== "undefined" && syncModel.exportUploadStatus == "error" + iconSource: UM.Theme.getIcon("LinkExternal") + Layout.preferredHeight: height + onClicked: Qt.openUrlExternally("https://support.ultimaker.com/hc/en-us/articles/360012019239?utm_source=cura&utm_medium=software&utm_campaign=sync-material-wizard-troubleshoot-cloud-printer") + } + } + ScrollView + { + id: printerListScrollView + width: parent.width + Layout.preferredWidth: width + Layout.fillHeight: true + clip: true + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + + ListView + { + id: printerList + width: parent.width + spacing: UM.Theme.getSize("default_margin").height + + model: cloudPrinterList + delegate: Rectangle + { + id: delegateContainer + border.color: UM.Theme.getColor("lining") + border.width: UM.Theme.getSize("default_lining").width + width: printerListScrollView.width + height: UM.Theme.getSize("card").height + + property string syncStatus: + { + var printer_id = model.metadata["host_guid"] + if(syncModel.printerStatus[printer_id] === undefined) //No status information available. Could be added after we started syncing. + { + return "idle"; + } + return syncModel.printerStatus[printer_id]; + } + + Cura.IconWithText + { + anchors + { + verticalCenter: parent.verticalCenter + left: parent.left + leftMargin: Math.round(parent.height - height) / 2 //Equal margin on the left as above and below. + right: parent.right + rightMargin: Math.round(parent.height - height) / 2 + } + + text: model.name + font: UM.Theme.getFont("medium") + + source: UM.Theme.getIcon("Printer", "medium") + iconColor: UM.Theme.getColor("machine_selector_printer_icon") + iconSize: UM.Theme.getSize("machine_selector_icon").width + + //Printer status badge (always cloud, but whether it's online or offline). + UM.RecolorImage + { + width: UM.Theme.getSize("printer_status_icon").width + height: UM.Theme.getSize("printer_status_icon").height + anchors + { + bottom: parent.bottom + bottomMargin: -Math.round(height / 6) + left: parent.left + leftMargin: parent.iconSize - Math.round(width * 5 / 6) + } + + source: UM.Theme.getIcon("CloudBadge", "low") + color: UM.Theme.getColor("primary") + + //Make a themeable circle in the background so we can change it in other themes. + Rectangle + { + anchors.centerIn: parent + width: parent.width - 1.5 //1.5 pixels smaller (at least sqrt(2), regardless of pixel scale) so that the circle doesn't show up behind the icon due to anti-aliasing. + height: parent.height - 1.5 + radius: width / 2 + color: UM.Theme.getColor("connection_badge_background") + z: parent.z - 1 + } + } + } + + UM.RecolorImage + { + id: printerSpinner + width: UM.Theme.getSize("section_icon").width + height: width + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + anchors.rightMargin: Math.round((parent.height - height) / 2) //Same margin on the right as above and below. + + visible: delegateContainer.syncStatus === "uploading" + source: UM.Theme.getIcon("ArrowDoubleCircleRight") + color: UM.Theme.getColor("primary") + + RotationAnimator + { + target: printerSpinner + from: 0 + to: 360 + duration: 1000 + loops: Animation.Infinite + running: true + } + } + UM.StatusIcon + { + width: UM.Theme.getSize("section_icon").width + height: width + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + anchors.rightMargin: Math.round((parent.height - height) / 2) //Same margin on the right as above and below. + + visible: delegateContainer.syncStatus === "failed" || delegateContainer.syncStatus === "success" + status: delegateContainer.syncStatus === "success" ? UM.StatusIcon.Status.POSITIVE : UM.StatusIcon.Status.ERROR + } + } + + footer: Item + { + width: printerListScrollView.width + height: visible ? UM.Theme.getSize("card").height + UM.Theme.getSize("default_margin").height : 0 + visible: includeOfflinePrinterList.count - cloudPrinterList.count > 0 + Rectangle + { + border.color: UM.Theme.getColor("lining") + border.width: UM.Theme.getSize("default_lining").width + anchors.fill: parent + anchors.topMargin: UM.Theme.getSize("default_margin").height + + RowLayout + { + anchors + { + fill: parent + leftMargin: (parent.height - infoIcon.height) / 2 //Same margin on the left as top and bottom. + rightMargin: (parent.height - infoIcon.height) / 2 + } + spacing: UM.Theme.getSize("default_margin").width + + UM.StatusIcon + { + id: infoIcon + width: UM.Theme.getSize("section_icon").width + height: width + Layout.alignment: Qt.AlignVCenter + + status: UM.StatusIcon.Status.WARNING + } + + Label + { + text: catalog.i18nc("@text Asking the user whether printers are missing in a list.", "Printers missing?") + + "\n" + + catalog.i18nc("@text", "Make sure all your printers are turned ON and connected to Digital Factory.") + font: UM.Theme.getFont("medium") + elide: Text.ElideRight + + Layout.alignment: Qt.AlignVCenter + Layout.fillWidth: true + } + + Cura.SecondaryButton + { + id: refreshListButton + text: catalog.i18nc("@button", "Refresh List") + iconSource: UM.Theme.getIcon("ArrowDoubleCircleRight") + + Layout.alignment: Qt.AlignVCenter + Layout.preferredWidth: width + + onClicked: Cura.API.account.sync(true) + } + } + } + } + } + } + Item + { + width: parent.width + height: childrenRect.height + Layout.preferredWidth: width + Layout.preferredHeight: height + + Cura.SecondaryButton + { + anchors.left: parent.left + text: catalog.i18nc("@button", "Sync materials with USB") + onClicked: swipeView.currentIndex = removableDriveSyncPage.SwipeView.index + } + Cura.PrimaryButton + { + id: syncButton + anchors.right: parent.right + text: + { + if(typeof syncModel !== "undefined" && syncModel.exportUploadStatus == "error") + { + return catalog.i18nc("@button", "Try again"); + } + if(typeof syncModel !== "undefined" && syncModel.exportUploadStatus == "success") + { + return catalog.i18nc("@button", "Done"); + } + return catalog.i18nc("@button", "Sync"); + } + onClicked: + { + if(typeof syncModel !== "undefined" && syncModel.exportUploadStatus == "success") + { + materialsSyncDialog.close(); + } + else + { + syncModel.exportUpload(); + } + } + visible: + { + if(!syncModel) //When the dialog is created, this is not set yet. + { + return true; + } + return syncModel.exportUploadStatus != "uploading"; + } + } + Item + { + anchors.right: parent.right + width: childrenRect.width + height: syncButton.height + + visible: !syncButton.visible + + UM.RecolorImage + { + id: syncingIcon + height: UM.Theme.getSize("action_button_icon").height + width: height + anchors.verticalCenter: syncingLabel.verticalCenter + + source: UM.Theme.getIcon("ArrowDoubleCircleRight") + color: UM.Theme.getColor("primary") + + RotationAnimator + { + target: syncingIcon + from: 0 + to: 360 + duration: 1000 + loops: Animation.Infinite + running: true + } + } + Label + { + id: syncingLabel + anchors.left: syncingIcon.right + anchors.leftMargin: UM.Theme.getSize("narrow_margin").width + + text: catalog.i18nc("@button", "Syncing") + color: UM.Theme.getColor("primary") + font: UM.Theme.getFont("medium") + } + } + } + } + + ColumnLayout //Placeholder for when the user has no cloud printers. + { + spacing: UM.Theme.getSize("default_margin").height + anchors.fill: parent + anchors.margins: UM.Theme.getSize("default_margin").width + visible: cloudPrinterList.count == 0 + + Label + { + text: catalog.i18nc("@title:header", "No printers found") + font: UM.Theme.getFont("large_bold") + color: UM.Theme.getColor("text") + Layout.preferredWidth: width + Layout.preferredHeight: height + } + Image + { + source: UM.Theme.getImage("3d_printer_faded") + sourceSize.width: width + fillMode: Image.PreserveAspectFit + Layout.alignment: Qt.AlignHCenter + Layout.preferredWidth: parent.width / 3 + } + Label + { + text: catalog.i18nc("@text", "It seems like you don't have access to any printers connected to Digital Factory.") + width: parent.width + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.Wrap + Layout.preferredWidth: width + Layout.preferredHeight: height + } + Item + { + Layout.preferredWidth: parent.width + Layout.fillHeight: true + Cura.TertiaryButton + { + text: catalog.i18nc("@button", "Learn how to connect your printer to Digital Factory") + iconSource: UM.Theme.getIcon("LinkExternal") + onClicked: Qt.openUrlExternally("https://support.ultimaker.com/hc/en-us/articles/360012019239?utm_source=cura&utm_medium=software&utm_campaign=sync-material-wizard-add-cloud-printer") + anchors.horizontalCenter: parent.horizontalCenter + } + } + Item + { + width: parent.width + height: childrenRect.height + Layout.preferredWidth: width + Layout.preferredHeight: height + + Cura.SecondaryButton + { + anchors.left: parent.left + text: catalog.i18nc("@button", "Sync materials with USB") + onClicked: swipeView.currentIndex = removableDriveSyncPage.SwipeView.index + } + Cura.PrimaryButton + { + id: disabledSyncButton + anchors.right: parent.right + text: catalog.i18nc("@button", "Sync") + enabled: false //If there are no printers, always disable this button. + } + Cura.SecondaryButton + { + anchors.right: disabledSyncButton.left + anchors.rightMargin: UM.Theme.getSize("default_margin").width + text: catalog.i18nc("@button", "Refresh") + iconSource: UM.Theme.getIcon("ArrowDoubleCircleRight") + outlineColor: "transparent" + onClicked: Cura.API.account.sync(true) + } + } + } + } + + Rectangle + { + id: removableDriveSyncPage + color: UM.Theme.getColor("main_background") + + ColumnLayout + { + spacing: UM.Theme.getSize("default_margin").height + anchors.fill: parent + anchors.margins: UM.Theme.getSize("default_margin").width + + Label + { + text: catalog.i18nc("@title:header", "Sync material profiles via USB") + font: UM.Theme.getFont("large_bold") + color: UM.Theme.getColor("text") + Layout.preferredHeight: height + } + Label + { + text: catalog.i18nc("@text In the UI this is followed by a list of steps the user needs to take.", "Follow the following steps to load the new material profiles to your printer.") + font: UM.Theme.getFont("medium") + color: UM.Theme.getColor("text") + wrapMode: Text.Wrap + width: parent.width + Layout.maximumWidth: width + Layout.preferredHeight: height + } + Row + { + width: parent.width + Layout.preferredWidth: width + Layout.fillHeight: true + spacing: UM.Theme.getSize("default_margin").width + + Image + { + source: UM.Theme.getImage("insert_usb") + width: parent.width / 3 + height: width + anchors.verticalCenter: parent.verticalCenter + sourceSize.width: width + } + Label + { + text: "1. " + catalog.i18nc("@text", "Click the export material archive button.") + + "\n2. " + catalog.i18nc("@text", "Save the .umm file on a USB stick.") + + "\n3. " + catalog.i18nc("@text", "Insert the USB stick into your printer and launch the procedure to load new material profiles.") + font: UM.Theme.getFont("medium") + color: UM.Theme.getColor("text") + wrapMode: Text.Wrap + width: parent.width * 2 / 3 - UM.Theme.getSize("default_margin").width + anchors.verticalCenter: parent.verticalCenter + } + } + + Cura.TertiaryButton + { + text: catalog.i18nc("@button", "How to load new material profiles to my printer") + iconSource: UM.Theme.getIcon("LinkExternal") + onClicked: Qt.openUrlExternally("https://support.ultimaker.com/hc/en-us/articles/360013137919?utm_source=cura&utm_medium=software&utm_campaign=sync-material-wizard-how-usb") + } + + Item + { + width: parent.width + height: childrenRect.height + Layout.preferredWidth: width + Layout.preferredHeight: height + + Cura.SecondaryButton + { + anchors.left: parent.left + text: catalog.i18nc("@button", "Back") + onClicked: swipeView.currentIndex = 0 //Reset to first page. + } + Cura.PrimaryButton + { + anchors.right: parent.right + text: catalog.i18nc("@button", "Export material archive") + onClicked: + { + exportUsbDialog.folder = syncModel.getPreferredExportAllPath(); + exportUsbDialog.open(); + } + } + } + } + } + } + + Cura.GlobalStacksModel + { + id: cloudPrinterList + filterConnectionType: 3 //Only show cloud connections. + filterOnlineOnly: true //Only show printers that are online. + } + Cura.GlobalStacksModel + { + //In order to show a refresh button only when there are offline cloud printers, we need to know if there are any offline printers. + //A global stacks model without the filter for online-only printers allows this. + id: includeOfflinePrinterList + filterConnectionType: 3 //Still only show cloud connections. + } + + FileDialog + { + id: exportUsbDialog + title: catalog.i18nc("@title:window", "Export All Materials") + selectExisting: false + nameFilters: ["Material archives (*.umm)", "All files (*)"] + onAccepted: + { + syncModel.exportAll(fileUrl); + CuraApplication.setDefaultPath("dialog_material_path", folder); + } + } +} \ No newline at end of file diff --git a/resources/themes/cura-light/icons/default/EmptyInfo.svg b/resources/themes/cura-light/icons/default/EmptyInfo.svg new file mode 100644 index 0000000000..49d67746d1 --- /dev/null +++ b/resources/themes/cura-light/icons/default/EmptyInfo.svg @@ -0,0 +1,3 @@ + + + diff --git a/resources/themes/cura-light/images/3d_printer_faded.svg b/resources/themes/cura-light/images/3d_printer_faded.svg new file mode 100644 index 0000000000..001b12e266 --- /dev/null +++ b/resources/themes/cura-light/images/3d_printer_faded.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/resources/themes/cura-light/images/insert_usb.svg b/resources/themes/cura-light/images/insert_usb.svg new file mode 100644 index 0000000000..4a343e1477 --- /dev/null +++ b/resources/themes/cura-light/images/insert_usb.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/resources/themes/cura-light/images/material_ecosystem.svg b/resources/themes/cura-light/images/material_ecosystem.svg new file mode 100644 index 0000000000..30cf7a6473 --- /dev/null +++ b/resources/themes/cura-light/images/material_ecosystem.svg @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/resources/themes/cura-light/theme.json b/resources/themes/cura-light/theme.json index 78676da926..9dc3d8d114 100644 --- a/resources/themes/cura-light/theme.json +++ b/resources/themes/cura-light/theme.json @@ -540,6 +540,7 @@ "section_icon": [2, 2], "section_icon_column": [2.5, 2.5], "rating_star": [1.0, 1.0], + "card": [25.0, 6.0], "setting": [25.0, 1.8], "setting_control": [11.0, 2.0], diff --git a/tests/PrinterOutput/TestNetworkedPrinterOutputDevice.py b/tests/PrinterOutput/TestNetworkedPrinterOutputDevice.py index 84ad7f0473..2a5cc8a2d5 100644 --- a/tests/PrinterOutput/TestNetworkedPrinterOutputDevice.py +++ b/tests/PrinterOutput/TestNetworkedPrinterOutputDevice.py @@ -1,3 +1,6 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + import time from unittest.mock import MagicMock, patch @@ -122,8 +125,9 @@ def test_put(): def test_timeout(): with patch("UM.Qt.QtApplication.QtApplication.getInstance"): - output_device = NetworkedPrinterOutputDevice(device_id="test", address="127.0.0.1", properties={}) - output_device.setConnectionState(ConnectionState.Connected) + output_device = NetworkedPrinterOutputDevice(device_id = "test", address = "127.0.0.1", properties = {}) + with patch("cura.CuraApplication.CuraApplication.getInstance"): + output_device.setConnectionState(ConnectionState.Connected) assert output_device.connectionState == ConnectionState.Connected output_device._update() @@ -131,9 +135,8 @@ def test_timeout(): output_device._last_response_time = time.time() - 15 # But we did recently ask for a response! output_device._last_request_time = time.time() - 5 - output_device._update() + with patch("cura.CuraApplication.CuraApplication.getInstance"): + output_device._update() # The connection should now be closed, since it went into timeout. assert output_device.connectionState == ConnectionState.Closed - - diff --git a/tests/PrinterOutput/TestPrinterOutputDevice.py b/tests/PrinterOutput/TestPrinterOutputDevice.py index 7a9e4e2cc5..7913e156b0 100644 --- a/tests/PrinterOutput/TestPrinterOutputDevice.py +++ b/tests/PrinterOutput/TestPrinterOutputDevice.py @@ -1,7 +1,8 @@ -from unittest.mock import MagicMock +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. import pytest -from unittest.mock import patch +from unittest.mock import MagicMock, patch from cura.PrinterOutput.Models.ExtruderConfigurationModel import ExtruderConfigurationModel from cura.PrinterOutput.Models.MaterialOutputModel import MaterialOutputModel @@ -33,7 +34,8 @@ def test_getAndSet(data, printer_output_device): setattr(model, data["attribute"] + "Changed", MagicMock()) # Attempt to set the value - getattr(model, "set" + attribute)(data["value"]) + with patch("cura.CuraApplication.CuraApplication.getInstance"): + getattr(model, "set" + attribute)(data["value"]) # Check if signal fired. signal = getattr(model, data["attribute"] + "Changed")