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:") + "