Merge pull request #10607 from Ultimaker/CURA-8609_sync_materials_to_printer

Sync materials to printers via cloud
This commit is contained in:
Jaime van Kessel 2021-10-27 14:51:16 +02:00 committed by GitHub
commit f47738f558
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 1491 additions and 89 deletions

View File

@ -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"

View File

@ -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)

View File

@ -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()

View File

@ -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())

View File

@ -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:

View File

@ -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.

View File

@ -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()

View File

@ -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

View File

@ -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:") + "<ul>" + device_names + "</ul>"
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

View File

@ -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()]

View File

@ -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

View File

@ -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);
}
}
}

View File

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M13 6H11V8H13V6ZM13 10H11V17H13V10Z" />
</svg>

After

Width:  |  Height:  |  Size: 140 B

View File

@ -0,0 +1,29 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 895.1194 1107.2273">
<g transform="scale(4.2)">
<path d="M47.1391,70.7713S77.3619-1.4269,95.1241,2.127,104.66,69.6252,104.66,69.6252" style="fill:none;stroke:#b4b4b4;stroke-linecap:round;stroke-miterlimit:10;stroke-width:4px"/>
<path d="M165.9845,80.7713S135.7618,8.5731,118,12.127s-9.5354,67.4982-9.5354,67.4982" style="fill:none;stroke:#b4b4b4;stroke-linecap:round;stroke-miterlimit:10;stroke-width:4px"/>
<path d="M185.3682,262.6255l-.2889-.2722c-.0522-.0487-5.4914-5.0249-15.1015-5.0249H42.9639a19.5359,19.5359,0,0,0-13.2,4.9922l-.2946.3034-8.0293.0015a4.1443,4.1443,0,0,1-4.14-4.14V73.911a4.1443,4.1443,0,0,1,4.14-4.14H191.6833a4.1443,4.1443,0,0,1,4.14,4.14V258.4859a4.1443,4.1443,0,0,1-4.14,4.14Z" style="fill:#f3f8fe"/>
<path d="M191.6834,70.7714a3.14,3.14,0,0,1,3.14,3.14V258.4857a3.14,3.14,0,0,1-3.14,3.14H185.765s-5.6206-5.2969-15.7872-5.2969H42.9637a20.3309,20.3309,0,0,0-13.9185,5.2969H21.44a3.14,3.14,0,0,1-3.14-3.14V73.911a3.14,3.14,0,0,1,3.14-3.14H191.6834m0-2H21.44a5.1455,5.1455,0,0,0-5.14,5.14V258.4857a5.1456,5.1456,0,0,0,5.14,5.14h8.4555l.59-.6128a18.5748,18.5748,0,0,1,12.4776-4.6841H169.9778c9.1729,0,14.3707,4.711,14.4219,4.7588l.5752.5317.79.0064h5.9184a5.1456,5.1456,0,0,0,5.14-5.14V73.911a5.1455,5.1455,0,0,0-5.14-5.14Z" style="fill:#b4b4b4"/>
<path d="M41.31,230.6177a11.6933,11.6933,0,0,1-11.68-11.68V91.084a1.99,1.99,0,0,1,1.9872-1.9872h149.89a1.9894,1.9894,0,0,1,1.9871,1.9872V218.9377a11.6932,11.6932,0,0,1-11.68,11.68Z" style="fill:#fff"/>
<path d="M181.5067,90.0971a.9882.9882,0,0,1,.9873.9868v127.854a10.6921,10.6921,0,0,1-10.68,10.68H41.31a10.692,10.692,0,0,1-10.68-10.68V91.0839a.9882.9882,0,0,1,.9873-.9868h149.89m0-2H31.617A2.9868,2.9868,0,0,0,28.63,91.0839v127.854a12.68,12.68,0,0,0,12.68,12.68H171.8138a12.68,12.68,0,0,0,12.68-12.68V91.0839a2.9868,2.9868,0,0,0-2.9873-2.9868Z" style="fill:#fbfbfb"/>
<path d="M181.5067,88.0971a2.9868,2.9868,0,0,1,2.9873,2.9868v127.854a12.68,12.68,0,0,1-12.68,12.68H41.31a12.68,12.68,0,0,1-12.68-12.68V91.0839a2.9868,2.9868,0,0,1,2.9873-2.9868h149.89m0-2H31.617A4.9929,4.9929,0,0,0,26.63,91.0839v127.854a14.6967,14.6967,0,0,0,14.68,14.68H171.8138a14.6967,14.6967,0,0,0,14.68-14.68V91.0839a4.9929,4.9929,0,0,0-4.9873-4.9868Z" style="fill:#b4b4b4"/>
<circle cx="180.2767" cy="238.787" r="1.6497" style="fill:#b4b4b4"/>
<circle cx="32.4163" cy="238.787" r="1.6497" style="fill:#b4b4b4"/>
<circle cx="180.3485" cy="74.2255" r="1.6497" style="fill:#b4b4b4"/>
<circle cx="132.403" cy="74.2255" r="1.6497" style="fill:#b4b4b4"/>
<circle cx="81.7859" cy="74.2255" r="1.6497" style="fill:#b4b4b4"/>
<circle cx="32.4881" cy="74.2255" r="1.6497" style="fill:#b4b4b4"/>
<rect x="53.3813" y="242.7051" width="7.0114" height="2.4746" style="fill:#b4b4b4"/>
<polygon points="106.562 146.818 77.797 157.287 62.492 183.796 67.808 213.942 91.257 233.618 121.867 233.618 145.316 213.942 150.631 183.796 135.326 157.287 106.562 146.818" style="fill:#e5e5e5"/>
<rect x="81.3571" y="225.2077" width="50.4095" height="26.4368" rx="2.3351" style="fill:#fff"/>
<path d="M129.4315,226.2079a1.3349,1.3349,0,0,1,1.3349,1.335V249.31a1.3348,1.3348,0,0,1-1.3349,1.3349H83.6922a1.3349,1.3349,0,0,1-1.335-1.3349V227.5429a1.335,1.335,0,0,1,1.335-1.335h45.7393m0-2H83.6922a3.3388,3.3388,0,0,0-3.335,3.335V249.31a3.3388,3.3388,0,0,0,3.335,3.3349h45.7393a3.3388,3.3388,0,0,0,3.3349-3.3349V227.5429a3.3388,3.3388,0,0,0-3.3349-3.335Z" style="fill:#b4b4b4"/>
<path d="M128.7664,228.2079v20.4365H84.3572V228.2079h44.4092m.6651-2H83.6922a1.335,1.335,0,0,0-1.335,1.335V249.31a1.3349,1.3349,0,0,0,1.335,1.3349h45.7393a1.3348,1.3348,0,0,0,1.3349-1.3349V227.5429a1.3349,1.3349,0,0,0-1.3349-1.335Z" style="fill:#ececec"/>
<line x1="1" y1="262.6255" x2="212.1237" y2="262.6255" style="fill:none;stroke:#b4b4b4;stroke-linecap:round;stroke-miterlimit:10;stroke-width:2px"/>
<path d="M95.6985,110.2825a3.674,3.674,0,0,1-3.4684-2.4677l-4.372-11.9761a3.7037,3.7037,0,0,1-.2253-1.274v-4.61h37.8627l-.0219,4.75a3.7031,3.7031,0,0,1-.2314,1.2739l-4.4035,11.8829a3.6769,3.6769,0,0,1-3.4517,2.4207Z" style="fill:#fff"/>
<path d="M124.491,90.955l-.0175,3.7456a2.7135,2.7135,0,0,1-.169.9306l-4.4033,11.8833a2.6721,2.6721,0,0,1-2.5142,1.7681H95.6986a2.6709,2.6709,0,0,1-2.5235-1.7954L88.7977,95.496a2.7054,2.7054,0,0,1-.1651-.9312v-3.61H124.491m2.0093-2H86.6326v5.61a4.6967,4.6967,0,0,0,.2871,1.62l4.3765,11.9887a4.6634,4.6634,0,0,0,4.4024,3.1094H117.387a4.6825,4.6825,0,0,0,4.396-3.0913l4.397-11.8652a4.6957,4.6957,0,0,0,.2935-1.6162l.0175-3.7456L126.5,88.955Z" style="fill:#b4b4b4"/>
<path d="M99.383,112.1642a2.1419,2.1419,0,0,1-2.1381-2.2687L98.07,95.9931a1.9542,1.9542,0,0,1,1.9486-1.8365h13.0875a1.9543,1.9543,0,0,1,1.9486,1.8365l.8246,13.9024a2.1419,2.1419,0,0,1-2.1381,2.2687Z" style="fill:#fff"/>
<path d="M113.1058,95.1566a.9518.9518,0,0,1,.95.8955l.8247,13.9024a1.1422,1.1422,0,0,1-1.14,1.21H99.3831a1.1422,1.1422,0,0,1-1.14-1.21l.8247-13.9024a.9518.9518,0,0,1,.95-.8955h13.0879m0-2H100.0179a2.9552,2.9552,0,0,0-2.9468,2.7774l-.8247,13.9018a3.1421,3.1421,0,0,0,3.1367,3.3282h14.3574a3.142,3.142,0,0,0,3.1368-3.3267l-.8247-13.9038a2.9552,2.9552,0,0,0-2.9468-2.7769Z" style="fill:#b4b4b4"/>
<path d="M85.9586,94.6615a5.0986,5.0986,0,1,1,0-10.1972h41.2066a5.0986,5.0986,0,0,1,0,10.1972Z" style="fill:#fff"/>
<path d="M127.1653,85.4643a4.0986,4.0986,0,1,1,0,8.1972H85.9588a4.0986,4.0986,0,1,1,0-8.1972h41.2065m0-2H85.9588a6.0986,6.0986,0,1,0,0,12.1972h41.2065a6.0986,6.0986,0,1,0,0-12.1972Z" style="fill:#b4b4b4"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@ -0,0 +1,33 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 312 312">
<defs>
<style>
.cls-1,.cls-10{fill:#fff;}
.cls-2,.cls-9{fill:#e8f1fe;}
.cls-11,.cls-3,.cls-5,.cls-8{fill:none;}
.cls-10,.cls-3,.cls-4,.cls-9{stroke:#061884;}
.cls-10,.cls-11,.cls-3,.cls-4,.cls-8,.cls-9{stroke-miterlimit:10;}
.cls-10,.cls-11,.cls-3,.cls-4,.cls-9{stroke-width:2px;}
.cls-4{fill:#a3c5f9;}
.cls-6{fill:#f7f7f7;}
.cls-7{fill:#061884;}
.cls-8{stroke:#fff;stroke-width:6px;}
.cls-11{stroke:#f7f7f7;}
</style>
</defs>
<circle class="cls-1" cx="156" cy="156" r="132"/>
<path class="cls-2" d="M81.72,186.93c10.32-5.88,21.48-9.31,33.36-9.31H286.23A132.89,132.89,0,0,0,288,156c0-1.78-.05-3.55-.12-5.31H241.17a5.47,5.47,0,0,1-5.48-5.47V104.37H90.31a59.16,59.16,0,0,1-6.57-.38A53.25,53.25,0,0,1,46.33,82.52,132.23,132.23,0,0,0,30.5,197h34Zm0-54.66a5.5,5.5,0,0,1-5.5,5.5h-.84a5.5,5.5,0,0,1-5.5-5.5v-.85a5.5,5.5,0,0,1,5.5-5.5h.84a5.5,5.5,0,0,1,5.5,5.5Z"/>
<path class="cls-2" d="M281.49,144.69h-39.8V71.62h15.8c-1.71-2.06-3.47-4.06-5.3-6h-11a5.47,5.47,0,0,0-5.48,5.47v74.13a5.47,5.47,0,0,0,5.48,5.47h46.71c-.07-2-.21-4-.38-6Z"/>
<path class="cls-3" d="M252.19,65.62h-11a5.47,5.47,0,0,0-5.48,5.47v74.13a5.47,5.47,0,0,0,5.48,5.47h46.71"/>
<rect class="cls-4" x="136" y="127" width="40" height="13"/>
<path class="cls-5" d="M83.74,104a59.16,59.16,0,0,0,6.57.38H235.69V104Z"/>
<path class="cls-6" d="M115.08,177.62c-11.88,0-23,3.43-33.36,9.31L64.46,197h-34a132,132,0,0,0,255.73-19.38Z"/>
<path class="cls-3" d="M287.88,150.69H241.17a5.47,5.47,0,0,1-5.48-5.47V104.37H90.31a59.16,59.16,0,0,1-6.57-.38A53.25,53.25,0,0,1,46.33,82.52M30.5,197h34l17.26-10.07c10.32-5.88,21.48-9.31,33.36-9.31H286.23M81.69,132.27a5.5,5.5,0,0,1-5.5,5.5h-.84a5.5,5.5,0,0,1-5.5-5.5v-.85a5.5,5.5,0,0,1,5.5-5.5h.84a5.5,5.5,0,0,1,5.5,5.5Z"/>
<rect class="cls-7" x="69.85" y="125.92" width="11.85" height="11.85" rx="5.5"/>
<polyline class="cls-3" points="281.55 197.02 30.55 197.02 64.51 197.02"/>
<path class="cls-8" d="M171.68,187.56h.95V159H139.46v28.56h1a6.48,6.48,0,0,0-6.48,6.48v75.48a6.48,6.48,0,0,0,6.48,6.48h31.26a6.48,6.48,0,0,0,6.48-6.48V194A6.48,6.48,0,0,0,171.68,187.56Z"/>
<rect class="cls-9" x="133.94" y="187.56" width="44.22" height="88.44" rx="6.48"/>
<rect class="cls-10" x="139.46" y="159" width="33.17" height="28.56"/>
<rect class="cls-7" x="146.83" y="166.58" width="7.37" height="13.4"/>
<rect class="cls-7" x="157.87" y="166.54" width="7.42" height="13.49"/>
<circle class="cls-11" cx="156" cy="156" r="132"/>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -0,0 +1,89 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 335.22 139.01">
<defs>
<style>
.cls-1,.cls-6{fill:#f3f8fe;}
.cls-2{fill:#061884;}
.cls-14,.cls-15,.cls-16,.cls-3{fill:none;}
.cls-14,.cls-15,.cls-16,.cls-3,.cls-5,.cls-6,.cls-9{stroke:#061884;}
.cls-11,.cls-13,.cls-14,.cls-15,.cls-3,.cls-4,.cls-5,.cls-6,.cls-9{stroke-miterlimit:10;}
.cls-11,.cls-13,.cls-4,.cls-5,.cls-7,.cls-9{fill:#fff;}
.cls-4{stroke:#f3f8fe;stroke-width:3px;}
.cls-14,.cls-15,.cls-16,.cls-5{stroke-linecap:round;}
.cls-8{fill:#c5dbfb;}
.cls-10{fill:#dde9fd;}
.cls-11{stroke:#e8f1fe;}
.cls-12{fill:#e8f0fd;}
.cls-13{stroke:#d1e2fc;}
.cls-15{stroke-dasharray:4.9 4.9;}
.cls-16{stroke-linejoin:round;}
</style>
</defs>
<path class="cls-1" d="M194.33,117.61H98.66V118h0a4.37,4.37,0,0,0,4.37,4.37H190a4.37,4.37,0,0,0,4.37-4.37v-.35"/>
<path class="cls-2" d="M138.41,116.94a2.69,2.69,0,0,0,2.69,2.69h10.78a2.69,2.69,0,0,0,2.7-2.69H138.41Z"/>
<path class="cls-1" d="M186.58,117.28V63.72a2.36,2.36,0,0,0-2.36-2.35H108.77a2.36,2.36,0,0,0-2.36,2.35v53.56Z"/>
<path class="cls-3" d="M186.58,117.28V63.72a2.36,2.36,0,0,0-2.36-2.35H108.77a2.36,2.36,0,0,0-2.36,2.35v53.56"/>
<path class="cls-4" d="M109.49,117.32V66A1.52,1.52,0,0,1,111,64.51h71.16A1.52,1.52,0,0,1,183.68,66v51.29"/>
<path class="cls-3" d="M109.49,117.32V66A1.52,1.52,0,0,1,111,64.51h71.16A1.52,1.52,0,0,1,183.68,66v51.29"/>
<path class="cls-3" d="M190,122.67a4.71,4.71,0,0,0,4.7-4.71v-.35a.33.33,0,0,0-.33-.33H98.66a.33.33,0,0,0-.33.33V118a4.71,4.71,0,0,0,4.7,4.71Z"/>
<line class="cls-5" x1="217.22" y1="123.01" x2="323.22" y2="123.01"/>
<path class="cls-2" d="M266.49,9c-3.56,0-8,5.29-13.58,16.11-4.26,8.27-7.6,16.87-7.64,17a.79.79,0,1,0,1.48.58c5-13,14.95-33.13,20.15-32s4.92,19.66,3.56,31.08a.79.79,0,0,0,1.57.19c.38-3.17,3.46-31-4.8-32.82A3.65,3.65,0,0,0,266.49,9Z"/>
<path class="cls-2" d="M277.67,13.75a3.64,3.64,0,0,0-.74.07c-8.26,1.79-5.18,29.65-4.8,32.82a.79.79,0,1,0,1.57-.18c-1.36-11.43-1.6-30,3.57-31.09s15.11,19,20.14,32a.79.79,0,0,0,1.48-.57c0-.09-3.38-8.68-7.64-17C285.68,19,281.23,13.75,277.67,13.75Z"/>
<path class="cls-6" d="M309.23,122.69a2.07,2.07,0,0,0,2.06-2.06V43.25a2.11,2.11,0,0,0-2.06-2.11H232.6a2.12,2.12,0,0,0-2.06,2.11v77.38a2.08,2.08,0,0,0,2.06,2.06h5.57l.63-.36c3.28-1.83,3.63-2,8-2h47.5c4.35,0,4.69.19,8,2l.63.36Z"/>
<rect class="cls-2" x="248.25" y="114.3" width="3.17" height="0.79"/>
<circle class="cls-2" cx="239.54" cy="43.85" r="0.79"/>
<circle class="cls-2" cx="260.13" cy="43.85" r="0.79"/>
<circle class="cls-2" cx="280.71" cy="43.85" r="0.79"/>
<circle class="cls-2" cx="301.29" cy="43.85" r="0.79"/>
<circle class="cls-2" cx="239.54" cy="114.3" r="0.79"/>
<circle class="cls-2" cx="301.29" cy="114.3" r="0.79"/>
<path class="cls-7" d="M300.36,110a6.08,6.08,0,0,0,6.08-6.08V53.41a2.85,2.85,0,0,0-2.84-2.84H238a2.85,2.85,0,0,0-2.84,2.84v50.46a6.08,6.08,0,0,0,6.07,6.08Z"/>
<path class="cls-1" d="M303.6,51H238a2.45,2.45,0,0,0-2.45,2.44v50.46a5.69,5.69,0,0,0,5.68,5.68h59.1a5.68,5.68,0,0,0,5.68-5.68V53.41A2.44,2.44,0,0,0,303.6,51m3.23,2.44v50.46h0a6.46,6.46,0,0,1-6.47,6.47h-59.1a6.47,6.47,0,0,1-6.47-6.47V53.41A3.24,3.24,0,0,1,238,50.18H303.6A3.23,3.23,0,0,1,306.83,53.41Z"/>
<path class="cls-2" d="M303.6,50.39h0a3,3,0,0,1,3,3v50.46a6.3,6.3,0,0,1-6.27,6.27h-59.1a6.28,6.28,0,0,1-6.26-6.27V53.41a3,3,0,0,1,3-3H303.6m0-1H238a4,4,0,0,0-4,4v50.46a7.27,7.27,0,0,0,7.26,7.27h59.1a7.29,7.29,0,0,0,7.27-7.27V53.41a4,4,0,0,0-4-4Z"/>
<path class="cls-8" d="M279.92,106.39h-19v9.5h19v-9.5m0-.8h0a.79.79,0,0,1,.77.78v9.54h0a.78.78,0,0,1-.77.77h-19a.77.77,0,0,1-.77-.77v-9.54a.77.77,0,0,1,.77-.78Z"/>
<path class="cls-9" d="M276,58.49a1.23,1.23,0,0,0,1.18-.9l1.51-5.7V49.78H262.1v2.11l1.51,5.71a1.22,1.22,0,0,0,1.18.89Z"/>
<path class="cls-9" d="M274.28,60.07h.14a1.24,1.24,0,0,0,1.08-1.36l-.77-6.55h-8.62l-.77,6.55a.66.66,0,0,0,0,.14,1.23,1.23,0,0,0,1.23,1.22Z"/>
<path class="cls-9" d="M280.53,52.16a2.39,2.39,0,0,0,0-4.75H260.31a2.39,2.39,0,0,0,0,4.75h20.22Z"/>
<polygon class="cls-10" points="279.02 80.01 261.43 80.01 252.63 95.25 261.43 110.48 279.02 110.48 287.82 95.25 279.02 80.01"/>
<path class="cls-11" d="M260.9,106a.38.38,0,0,0-.38.38v9.54h0a.38.38,0,0,0,.38.37h19a.37.37,0,0,0,.37-.37v-9.54h0a.37.37,0,0,0-.37-.38Z"/>
<path class="cls-2" d="M279.94,105.8a.57.57,0,0,1,.56.57v9.54a.56.56,0,0,1-.56.56h-19a.57.57,0,0,1-.57-.56v-9.54a.58.58,0,0,1,.57-.57h19m0-1h-19a1.57,1.57,0,0,0-1.57,1.57v9.54a1.56,1.56,0,0,0,1.57,1.56h19a1.56,1.56,0,0,0,1.56-1.56v-9.54a1.56,1.56,0,0,0-1.56-1.57Z"/>
<line class="cls-9" x1="270.54" y1="47.41" x2="270.54" y2="52.07"/>
<circle class="cls-7" cx="42.11" cy="99.88" r="21.04"/>
<path class="cls-1" d="M42.11,80.27a19.61,19.61,0,1,0,19.6,19.61,19.65,19.65,0,0,0-19.6-19.61M64.58,99.88A22.47,22.47,0,1,1,42.11,77.41,22.47,22.47,0,0,1,64.58,99.88Z"/>
<path class="cls-3" d="M65.05,99.88A22.95,22.95,0,1,1,42.11,76.93,22.94,22.94,0,0,1,65.05,99.88Z"/>
<path class="cls-12" d="M45.49,108.05a8.85,8.85,0,1,0-3.38.67A8.86,8.86,0,0,0,45.49,108.05ZM43.3,97A3.11,3.11,0,0,1,45,98.69a3.11,3.11,0,0,1-4.06,4.06,3.09,3.09,0,0,1-1.68-1.69,3.1,3.1,0,0,1,1.68-4,3,3,0,0,1,1.19-.24A3.08,3.08,0,0,1,43.3,97Z"/>
<path class="cls-2" d="M42.11,103.22a3.35,3.35,0,0,1-1.28-6.44,3.35,3.35,0,1,1,1.28,6.44m1.09-.69a2.87,2.87,0,0,0,1.56-3.75,2.83,2.83,0,0,0-1.56-1.55,2.78,2.78,0,0,0-2.19,0,2.87,2.87,0,0,0-1.55,1.55,2.82,2.82,0,0,0,0,2.19A2.86,2.86,0,0,0,41,102.53a2.76,2.76,0,0,0,1.1.21A2.71,2.71,0,0,0,43.2,102.53Z"/>
<path class="cls-2" d="M41.15,91.27v2.39a1,1,0,1,0,1.91,0V91.27"/>
<path class="cls-3" d="M45.49,108.05a8.85,8.85,0,1,0-3.38.67A8.86,8.86,0,0,0,45.49,108.05ZM43.3,97A3.11,3.11,0,0,1,45,98.69a3.11,3.11,0,0,1-4.06,4.06,3.09,3.09,0,0,1-1.68-1.69,3.1,3.1,0,0,1,1.68-4,3,3,0,0,1,1.19-.24A3.08,3.08,0,0,1,43.3,97Z"/>
<line class="cls-3" x1="38.81" y1="91.93" x2="40.83" y2="96.78"/>
<line class="cls-3" x1="36.02" y1="93.79" x2="39.74" y2="97.51"/>
<line class="cls-3" x1="34.16" y1="96.58" x2="39.02" y2="98.59"/>
<line class="cls-3" x1="33.5" y1="99.88" x2="38.76" y2="99.88"/>
<line class="cls-3" x1="34.16" y1="103.17" x2="39.02" y2="101.16"/>
<line class="cls-3" x1="36.02" y1="105.96" x2="39.74" y2="102.24"/>
<line class="cls-3" x1="38.81" y1="107.83" x2="40.83" y2="102.97"/>
<line class="cls-3" x1="42.11" y1="103.22" x2="42.11" y2="108.48"/>
<line class="cls-3" x1="43.39" y1="102.97" x2="45.4" y2="107.83"/>
<line class="cls-3" x1="44.47" y1="102.24" x2="48.19" y2="105.96"/>
<line class="cls-3" x1="45.2" y1="101.16" x2="50.06" y2="103.17"/>
<line class="cls-3" x1="45.45" y1="99.88" x2="50.71" y2="99.88"/>
<line class="cls-3" x1="45.2" y1="98.59" x2="50.06" y2="96.58"/>
<line class="cls-3" x1="44.47" y1="97.51" x2="48.19" y2="93.79"/>
<line class="cls-3" x1="43.39" y1="96.78" x2="45.4" y2="91.93"/>
<path class="cls-13" d="M27.21,108.28a1.21,1.21,0,0,0,.6-1,1.2,1.2,0,1,0-1.19,1.2A1.21,1.21,0,0,0,27.21,108.28Z"/>
<path class="cls-13" d="M30.47,112a1.21,1.21,0,0,0,.4-.89,1.2,1.2,0,1,0-1.2,1.2A1.18,1.18,0,0,0,30.47,112Z"/>
<path class="cls-13" d="M59.2,92.54a1.17,1.17,0,0,0,.59-1A1.2,1.2,0,1,0,58.6,92.7,1.17,1.17,0,0,0,59.2,92.54Z"/>
<path class="cls-13" d="M56.34,88.57a1.2,1.2,0,0,0-.8-2.09,1.2,1.2,0,0,0,0,2.39A1.22,1.22,0,0,0,56.34,88.57Z"/>
<path class="cls-14" d="M10.55,115.93a13.57,13.57,0,0,0,2,1.1,13.2,13.2,0,0,0,17.12-6"/>
<line class="cls-5" x1="19.22" y1="123.01" x2="65.22" y2="123.01"/>
<line class="cls-14" x1="70.72" y1="99.51" x2="73.22" y2="99.51"/>
<line class="cls-15" x1="78.12" y1="99.51" x2="95.27" y2="99.51"/>
<line class="cls-14" x1="97.72" y1="99.51" x2="100.22" y2="99.51"/>
<line class="cls-5" x1="93.22" y1="123.01" x2="199.22" y2="123.01"/>
<line class="cls-14" x1="192.72" y1="99.51" x2="195.22" y2="99.51"/>
<line class="cls-15" x1="200.12" y1="99.51" x2="217.27" y2="99.51"/>
<line class="cls-14" x1="219.72" y1="99.51" x2="222.22" y2="99.51"/>
<polygon class="cls-6" points="151.1 106.59 132.22 106.59 132.22 87.71 140.31 79.62 159.19 79.62 159.19 98.5 151.1 106.59"/>
<path class="cls-16" d="M149.75,86.36H145.7a6.75,6.75,0,0,0,0,13.49h4.05"/>
<circle class="cls-7" cx="159.98" cy="80.25" r="7.93"/>
<path class="cls-2" d="M160.06,73.81a6.35,6.35,0,1,0,6.35,6.34A6.35,6.35,0,0,0,160.06,73.81Zm5.27,4.12h-3a9.48,9.48,0,0,0-1.57-3.44A5.73,5.73,0,0,1,165.33,77.93ZM162,80.15a8.79,8.79,0,0,1-.14,1.59H158.3a9,9,0,0,1,0-3.17h3.53A8.76,8.76,0,0,1,162,80.15Zm-3.53,2.23h3.25a8.6,8.6,0,0,1-1.63,3.32A8.77,8.77,0,0,1,158.44,82.38Zm0-4.45a8.77,8.77,0,0,1,1.62-3.32,8.6,8.6,0,0,1,1.63,3.32Zm.91-3.44a9.32,9.32,0,0,0-1.57,3.44h-3A5.73,5.73,0,0,1,159.35,74.49Zm-4.77,4.08h3.08a9,9,0,0,0,0,3.17h-3.08a5.58,5.58,0,0,1,0-3.17Zm.22,3.81h3a9.32,9.32,0,0,0,1.57,3.44A5.75,5.75,0,0,1,154.8,82.38Zm6,3.44a9.48,9.48,0,0,0,1.57-3.44h3A5.75,5.75,0,0,1,160.78,85.82Zm4.77-4.08h-3.08a9.73,9.73,0,0,0,0-3.17h3.08a5.58,5.58,0,0,1,0,3.17Z"/>
</svg>

After

Width:  |  Height:  |  Size: 8.7 KiB

View File

@ -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],

View File

@ -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

View File

@ -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")