From 69ca7c0f893fa5f1d9af88526ddad2c796255b17 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Mon, 19 Nov 2018 20:08:05 +0100 Subject: [PATCH 001/178] CloudOutputDevice scaffolding --- .../src/Cloud/CloudOutputController.py | 18 +++ .../src/Cloud/CloudOutputDevice.py | 134 ++++++++++++++++++ .../UM3NetworkPrinting/src/Cloud/__init__.py | 0 plugins/UM3NetworkPrinting/src/__init__.py | 0 plugins/__init__.py | 0 5 files changed, 152 insertions(+) create mode 100644 plugins/UM3NetworkPrinting/src/Cloud/CloudOutputController.py create mode 100644 plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py create mode 100644 plugins/UM3NetworkPrinting/src/Cloud/__init__.py create mode 100644 plugins/UM3NetworkPrinting/src/__init__.py create mode 100644 plugins/__init__.py diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputController.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputController.py new file mode 100644 index 0000000000..d31d2bf486 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputController.py @@ -0,0 +1,18 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from cura.PrinterOutput.PrinterOutputController import PrinterOutputController + + +class CloudOutputController(PrinterOutputController): + def __init__(self, output_device): + super().__init__(output_device) + + # The cloud connection only supports fetching the printer and queue status and adding a job to the queue. + # To let the UI know this we mark all features below as False. + self.can_pause = False + self.can_abort = False + self.can_pre_heat_bed = False + self.can_pre_heat_hotends = False + self.can_send_raw_gcode = False + self.can_control_manually = False + self.can_update_firmware = False diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py new file mode 100644 index 0000000000..850eb11ed2 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -0,0 +1,134 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +import json +from typing import List, Optional, Dict + +from PyQt5.QtCore import QObject, pyqtProperty, pyqtSignal +from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest + +from UM import i18nCatalog +from UM.FileHandler.FileHandler import FileHandler +from UM.Logger import Logger +from UM.Scene.SceneNode import SceneNode +from cura.PrinterOutput.NetworkedPrinterOutputDevice import NetworkedPrinterOutputDevice, AuthState +from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel +from plugins.UM3NetworkPrinting.src.Cloud.CloudOutputController import CloudOutputController +from plugins.UM3NetworkPrinting.src.UM3PrintJobOutputModel import UM3PrintJobOutputModel + + +## The cloud output device is a network output device that works remotely but has limited functionality. +# Currently it only supports viewing the printer and print job status and adding a new job to the queue. +# As such, those methods have been implemented here. +# Note that this device represents a single remote cluster, not a list of multiple clusters. +# +# TODO: figure our how the QML interface for the cluster networking should operate with this limited functionality. +# TODO: figure out how to pair remote clusters, local networked clusters and local cura printer presets. +class CloudOutputDevice(NetworkedPrinterOutputDevice): + + # The translation catalog for this device. + I18N_CATALOG = i18nCatalog("cura") + + # The cloud URL to use for remote clusters. + API_ROOT_PATH_FORMAT = "https://api.ultimaker.com/connect/clusters/{cluster_id}" + + # Signal triggered when the printers in the remote cluster were changed. + printersChanged = pyqtSignal() + + # Signal triggered when the print jobs in the queue were changed. + printJobsChanged = pyqtSignal() + + def __init__(self, device_id: str, address: str, properties: Dict[bytes, bytes], parent: QObject = None): + super().__init__(device_id = device_id, address = address, properties = properties, parent = parent) + self._setInterfaceElements() + + # The API prefix is automatically added when doing any HTTP call on the device. + self._api_prefix = self.API_ROOT_PATH_FORMAT.format(device_id) # TODO: verify we can use device_id here + self._authentication_state = AuthState.Authenticated # TODO: use cura.API.Account to set this? + + # Properties to populate later on with received cloud data. + self._printers = [] + self._print_jobs = [] + self._number_of_extruders = 2 # All networked printers are dual-extrusion Ultimaker machines. + + ## Set all the interface elements and texts for this output device. + def _setInterfaceElements(self): + self.setPriority(3) + self.setName(self._id) + # TODO: how to name these? + self.setShortDescription(self.I18N_CATALOG.i18nc("@action:button", "Print via Cloud")) + self.setDescription(self.I18N_CATALOG.i18nc("@properties:tooltip", "Print via Cloud")) + self.setConnectionText(self.I18N_CATALOG.i18nc("@info:status", "Connected via Cloud")) + + ## Called when Cura requests an output device to receive a (G-code) file. + def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mime_types: bool = False, + file_handler: Optional[FileHandler] = None, **kwargs: str) -> None: + self.writeStarted.emit(self) + + # TODO: actually implement this + self._addPrintJobToQueue() + + ## Get remote printers. + @pyqtProperty("QVariantList", notify = printersChanged) + def printers(self): + return self._printers + + ## Get remote print jobs. + @pyqtProperty("QVariantList", notify = printJobsChanged) + def printJobs(self) -> List[UM3PrintJobOutputModel]: + return self._print_jobs + + ## Called when the connection to the cluster changes. + def connect(self) -> None: + super().connect() + + ## Called when the network data should be updated. + def _update(self) -> None: + super()._update() + self.get("/status", on_finished = self._onStatusCallFinished) + + def _onStatusCallFinished(self, reply: QNetworkReply) -> None: + status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) + if status_code != 200: + Logger.log("w", "Got unexpected response while trying to get cloud cluster data: {}, {}" + .format(status_code, reply.getErrorString())) + return + + data = self._parseStatusResponse(reply) + if data is None: + return + + # Update all data from the cluster. + self._updatePrinters(data.get("printers", [])) + self._updatePrintJobs(data.get("print_jobs", [])) + + @staticmethod + def _parseStatusResponse(reply: QNetworkReply) -> Optional[dict]: + try: + result = json.loads(bytes(reply.readAll()).decode("utf-8")) + # TODO: use model or named tuple here. + return result + except json.decoder.JSONDecodeError: + Logger.logException("w", "Unable to decode JSON from reply.") + return None + + def _updatePrinters(self, remote_printers: List[Dict[str, any]]) -> None: + # TODO: use model or tuple for remote_printers data + for printer in remote_printers: + + # If the printer does not exist yet, create it. + if not self._getPrinterByKey(printer["uuid"]): + self._printers.append(PrinterOutputModel( + output_controller = CloudOutputController(self), + number_of_extruders = self._number_of_extruders + )) + + # TODO: properly handle removed and updated printers + self.printersChanged.emit() + + def _updatePrintJobs(self, remote_print_jobs: List[Dict[str, any]]) -> None: + # TODO: use model or tuple for remote_print_jobs data + pass + + def _addPrintJobToQueue(self): + # TODO: implement this + pass diff --git a/plugins/UM3NetworkPrinting/src/Cloud/__init__.py b/plugins/UM3NetworkPrinting/src/Cloud/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/plugins/UM3NetworkPrinting/src/__init__.py b/plugins/UM3NetworkPrinting/src/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/plugins/__init__.py b/plugins/__init__.py new file mode 100644 index 0000000000..e69de29bb2 From 115936c46bb08cec4f4c02193edea7b5f0db0f32 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Mon, 19 Nov 2018 20:27:38 +0100 Subject: [PATCH 002/178] Target correct cloud API version --- plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index 850eb11ed2..75cef817c6 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -29,7 +29,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): I18N_CATALOG = i18nCatalog("cura") # The cloud URL to use for remote clusters. - API_ROOT_PATH_FORMAT = "https://api.ultimaker.com/connect/clusters/{cluster_id}" + API_ROOT_PATH_FORMAT = "https://api.ultimaker.com/connect/v1/clusters/{cluster_id}" # Signal triggered when the printers in the remote cluster were changed. printersChanged = pyqtSignal() From 228325eb892ee43d1173a296797e277505c3bd52 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Mon, 19 Nov 2018 21:59:57 +0100 Subject: [PATCH 003/178] Add CloudOutputDeviceManager, test implementation --- .../NetworkedPrinterOutputDevice.py | 1 + plugins/UM3NetworkPrinting/__init__.py | 8 +++-- .../src/Cloud/CloudOutputDevice.py | 30 +++++++++++++----- .../src/Cloud/CloudOutputDeviceManager.py | 31 +++++++++++++++++++ .../src/UM3OutputDevicePlugin.py | 25 ++++++++++----- 5 files changed, 78 insertions(+), 17 deletions(-) create mode 100644 plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py diff --git a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py index 9a3be936a2..9a6892ce4d 100644 --- a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py +++ b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py @@ -17,6 +17,7 @@ from enum import IntEnum import os # To get the username import gzip + class AuthState(IntEnum): NotAuthenticated = 1 AuthenticationRequested = 2 diff --git a/plugins/UM3NetworkPrinting/__init__.py b/plugins/UM3NetworkPrinting/__init__.py index e2ad5a2b12..23262aed94 100644 --- a/plugins/UM3NetworkPrinting/__init__.py +++ b/plugins/UM3NetworkPrinting/__init__.py @@ -1,11 +1,15 @@ # Copyright (c) 2017 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. - from .src import DiscoverUM3Action from .src import UM3OutputDevicePlugin + def getMetaData(): return {} + def register(app): - return { "output_device": UM3OutputDevicePlugin.UM3OutputDevicePlugin(), "machine_action": DiscoverUM3Action.DiscoverUM3Action()} \ No newline at end of file + return { + "output_device": UM3OutputDevicePlugin.UM3OutputDevicePlugin(app), + "machine_action": DiscoverUM3Action.DiscoverUM3Action() + } diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index 75cef817c6..61d22052be 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -3,13 +3,14 @@ import json from typing import List, Optional, Dict -from PyQt5.QtCore import QObject, pyqtProperty, pyqtSignal +from PyQt5.QtCore import QObject, pyqtProperty, pyqtSignal, QUrl from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest from UM import i18nCatalog from UM.FileHandler.FileHandler import FileHandler from UM.Logger import Logger from UM.Scene.SceneNode import SceneNode +from cura.CuraApplication import CuraApplication from cura.PrinterOutput.NetworkedPrinterOutputDevice import NetworkedPrinterOutputDevice, AuthState from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel from plugins.UM3NetworkPrinting.src.Cloud.CloudOutputController import CloudOutputController @@ -37,19 +38,34 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): # Signal triggered when the print jobs in the queue were changed. printJobsChanged = pyqtSignal() - def __init__(self, device_id: str, address: str, properties: Dict[bytes, bytes], parent: QObject = None): - super().__init__(device_id = device_id, address = address, properties = properties, parent = parent) + def __init__(self, device_id: str, parent: QObject = None): + super().__init__(device_id = device_id, address = "", properties = {}, parent = parent) self._setInterfaceElements() - # The API prefix is automatically added when doing any HTTP call on the device. - self._api_prefix = self.API_ROOT_PATH_FORMAT.format(device_id) # TODO: verify we can use device_id here - self._authentication_state = AuthState.Authenticated # TODO: use cura.API.Account to set this? + self._device_id = device_id + self._account = CuraApplication.getInstance().getCuraAPI().account # Properties to populate later on with received cloud data. self._printers = [] self._print_jobs = [] self._number_of_extruders = 2 # All networked printers are dual-extrusion Ultimaker machines. + ## We need to override _createEmptyRequest to work for the cloud. + def _createEmptyRequest(self, path: str, content_type: Optional[str] = "application/json") -> QNetworkRequest: + url = QUrl(self.API_ROOT_PATH_FORMAT.format(cluster_id = self._device_id) + path) + request = QNetworkRequest(url) + request.setHeader(QNetworkRequest.ContentTypeHeader, content_type) + request.setHeader(QNetworkRequest.UserAgentHeader, self._user_agent) + + if not self._account.isLoggedIn: + # TODO: show message to user to sign in + self.setAuthenticationState(AuthState.NotAuthenticated) + else: + self.setAuthenticationState(AuthState.Authenticated) + request.setRawHeader(b"Authorization", "Bearer {}".format(self._account.accessToken).encode()) + + return request + ## Set all the interface elements and texts for this output device. def _setInterfaceElements(self): self.setPriority(3) @@ -90,7 +106,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) if status_code != 200: Logger.log("w", "Got unexpected response while trying to get cloud cluster data: {}, {}" - .format(status_code, reply.getErrorString())) + .format(status_code, reply.readAll())) return data = self._parseStatusResponse(reply) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py new file mode 100644 index 0000000000..1f75edc2cc --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -0,0 +1,31 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from typing import TYPE_CHECKING + +from plugins.UM3NetworkPrinting.src.Cloud.CloudOutputDevice import CloudOutputDevice + + +if TYPE_CHECKING: + from cura.CuraApplication import CuraApplication + + +## The cloud output device manager is responsible for using the Ultimaker Cloud APIs to manage remote clusters. +# Keeping all cloud related logic in this class instead of the UM3OutputDevicePlugin results in more readable code. +class CloudOutputDeviceManager: + + def __init__(self, application: "CuraApplication"): + self._output_device_manager = application.getOutputDeviceManager() + self._account = application.getCuraAPI().account + self._getRemoteClusters() + + # For testing: + application.globalContainerStackChanged.connect(self._addCloudOutputDevice) + + def _getRemoteClusters(self): + # TODO: get list of remote clusters and create an output device for each. + pass + + def _addCloudOutputDevice(self): + device = CloudOutputDevice("xxxx-xxxx-xxxx-xxxx") + self._output_device_manager.addOutputDevice(device) + device.connect() diff --git a/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py b/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py index 9c070f2de2..d7a40626b9 100644 --- a/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py +++ b/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py @@ -1,11 +1,12 @@ # Copyright (c) 2017 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. +from typing import TYPE_CHECKING from UM.OutputDevice.OutputDevicePlugin import OutputDevicePlugin from UM.Logger import Logger -from UM.Application import Application from UM.Signal import Signal, signalemitter from UM.Version import Version +from plugins.UM3NetworkPrinting.src.Cloud.CloudOutputDeviceManager import CloudOutputDeviceManager from . import ClusterUM3OutputDevice, LegacyUM3OutputDevice @@ -19,6 +20,9 @@ from time import time import json +if TYPE_CHECKING: + from cura.CuraApplication import CuraApplication + ## This plugin handles the connection detection & creation of output device objects for the UM3 printer. # Zero-Conf is used to detect printers, which are saved in a dict. @@ -29,8 +33,10 @@ class UM3OutputDevicePlugin(OutputDevicePlugin): removeDeviceSignal = Signal() discoveredDevicesChanged = Signal() - def __init__(self): + def __init__(self, application: "CuraApplication"): super().__init__() + self._application = application + self._zero_conf = None self._zero_conf_browser = None @@ -38,7 +44,7 @@ class UM3OutputDevicePlugin(OutputDevicePlugin): self.addDeviceSignal.connect(self._onAddDevice) self.removeDeviceSignal.connect(self._onRemoveDevice) - Application.getInstance().globalContainerStackChanged.connect(self.reCheckConnections) + application.globalContainerStackChanged.connect(self.reCheckConnections) self._discovered_devices = {} @@ -53,7 +59,7 @@ class UM3OutputDevicePlugin(OutputDevicePlugin): self._cluster_api_prefix = "/cluster-api/v" + self._cluster_api_version + "/" # Get list of manual instances from preferences - self._preferences = Application.getInstance().getPreferences() + self._preferences = self._application.getPreferences() self._preferences.addPreference("um3networkprinting/manual_instances", "") # A comma-separated list of ip adresses or hostnames @@ -70,6 +76,9 @@ class UM3OutputDevicePlugin(OutputDevicePlugin): self._service_changed_request_event = Event() self._service_changed_request_thread = Thread(target=self._handleOnServiceChangedRequests, daemon=True) self._service_changed_request_thread.start() + + # Create a cloud output device manager that abstract all cloud connection logic away. + self._cloud_output_device_manager = CloudOutputDeviceManager(self._application) def getDiscoveredDevices(self): return self._discovered_devices @@ -104,7 +113,7 @@ class UM3OutputDevicePlugin(OutputDevicePlugin): self.resetLastManualDevice() def reCheckConnections(self): - active_machine = Application.getInstance().getGlobalContainerStack() + active_machine = self._application.getGlobalContainerStack() if not active_machine: return @@ -129,7 +138,7 @@ class UM3OutputDevicePlugin(OutputDevicePlugin): return if self._discovered_devices[key].isConnected(): # Sometimes the status changes after changing the global container and maybe the device doesn't belong to this machine - um_network_key = Application.getInstance().getGlobalContainerStack().getMetaDataEntry("um_network_key") + um_network_key = self._application.getGlobalContainerStack().getMetaDataEntry("um_network_key") if key == um_network_key: self.getOutputDeviceManager().addOutputDevice(self._discovered_devices[key]) else: @@ -281,7 +290,7 @@ class UM3OutputDevicePlugin(OutputDevicePlugin): self._discovered_devices[device.getId()] = device self.discoveredDevicesChanged.emit() - global_container_stack = Application.getInstance().getGlobalContainerStack() + global_container_stack = self._application.getGlobalContainerStack() if global_container_stack and device.getId() == global_container_stack.getMetaDataEntry("um_network_key"): device.connect() device.connectionStateChanged.connect(self._onDeviceConnectionStateChanged) @@ -299,7 +308,7 @@ class UM3OutputDevicePlugin(OutputDevicePlugin): self._service_changed_request_event.wait(timeout = 5.0) # Stop if the application is shutting down - if Application.getInstance().isShuttingDown(): + if self._application.isShuttingDown(): return self._service_changed_request_event.clear() From 10576d12426ae520f42167d3dcc2afa7ffd808d9 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Mon, 19 Nov 2018 22:24:35 +0100 Subject: [PATCH 004/178] Some scaffolding and implementation for cloud output device manager --- .../src/Cloud/CloudOutputDevice.py | 9 ++-- .../src/Cloud/CloudOutputDeviceManager.py | 42 +++++++++++++++---- .../src/UM3OutputDevicePlugin.py | 6 +-- 3 files changed, 41 insertions(+), 16 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index 61d22052be..ff83c3fd5e 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -23,14 +23,13 @@ from plugins.UM3NetworkPrinting.src.UM3PrintJobOutputModel import UM3PrintJobOut # Note that this device represents a single remote cluster, not a list of multiple clusters. # # TODO: figure our how the QML interface for the cluster networking should operate with this limited functionality. -# TODO: figure out how to pair remote clusters, local networked clusters and local cura printer presets. class CloudOutputDevice(NetworkedPrinterOutputDevice): # The translation catalog for this device. I18N_CATALOG = i18nCatalog("cura") - # The cloud URL to use for remote clusters. - API_ROOT_PATH_FORMAT = "https://api.ultimaker.com/connect/v1/clusters/{cluster_id}" + # The cloud URL to use for this remote cluster. + API_ROOT_PATH_FORMAT = "https://api-staging.ultimaker.com/connect/v1/clusters/{cluster_id}" # Signal triggered when the printers in the remote cluster were changed. printersChanged = pyqtSignal() @@ -79,8 +78,6 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mime_types: bool = False, file_handler: Optional[FileHandler] = None, **kwargs: str) -> None: self.writeStarted.emit(self) - - # TODO: actually implement this self._addPrintJobToQueue() ## Get remote printers. @@ -102,6 +99,8 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): super()._update() self.get("/status", on_finished = self._onStatusCallFinished) + ## Method called when HTTP request to status endpoint is finished. + # Contains both printers and print jobs statuses in a single response. def _onStatusCallFinished(self, reply: QNetworkReply) -> None: status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) if status_code != 200: diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 1f75edc2cc..53f64241e5 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -1,6 +1,6 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Dict from plugins.UM3NetworkPrinting.src.Cloud.CloudOutputDevice import CloudOutputDevice @@ -11,21 +11,47 @@ if TYPE_CHECKING: ## The cloud output device manager is responsible for using the Ultimaker Cloud APIs to manage remote clusters. # Keeping all cloud related logic in this class instead of the UM3OutputDevicePlugin results in more readable code. +# +# TODO: figure out how to pair remote clusters, local networked clusters and local cura printer presets. class CloudOutputDeviceManager: + # The cloud URL to use for remote clusters. + API_ROOT_PATH = "https://api-staging.ultimaker.com/connect/v1" + def __init__(self, application: "CuraApplication"): + self._application = application self._output_device_manager = application.getOutputDeviceManager() self._account = application.getCuraAPI().account - self._getRemoteClusters() - # For testing: - application.globalContainerStackChanged.connect(self._addCloudOutputDevice) + # Persistent dict containing the remote clusters for the authenticated user. + self._remote_clusters = {} # type: Dict[str, CloudOutputDevice] + + # When switching machines we check if we have to activate a remote cluster. + self._application.globalContainerStackChanged.connect(self._activeMachineChanged) + + # Fetch all remote clusters for the authenticated user. + self._getRemoteClusters() def _getRemoteClusters(self): # TODO: get list of remote clusters and create an output device for each. - pass + # For testing we add a dummy device: + self._addCloudOutputDevice({"cluster_id": "LJ0tciiuZZjarrXAvFLEZ6ox4Cvx8FvtXUlQv4vIhV6w"}) - def _addCloudOutputDevice(self): - device = CloudOutputDevice("xxxx-xxxx-xxxx-xxxx") + def _addCloudOutputDevice(self, cluster_data: Dict[str, any]): + # TODO: use model or named tuple for cluster_data + device = CloudOutputDevice(cluster_data["cluster_id"]) self._output_device_manager.addOutputDevice(device) - device.connect() + self._remote_clusters[cluster_data["cluster_id"]] = device + + def _activeMachineChanged(self): + active_machine = self._application.getGlobalContainerStack() + if not active_machine: + return + + stored_cluster_id = active_machine.getMetaDataEntry("um_cloud_cluster_id") + if stored_cluster_id not in self._remote_clusters.keys(): + # Currently authenticated user does not have access to stored cluster or no user is signed in. + return + + # We found the active machine as remote cluster so let's connect to it. + self._remote_clusters.get(stored_cluster_id).connect() diff --git a/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py b/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py index d7a40626b9..b441df9eb5 100644 --- a/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py +++ b/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py @@ -40,6 +40,9 @@ class UM3OutputDevicePlugin(OutputDevicePlugin): self._zero_conf = None self._zero_conf_browser = None + # Create a cloud output device manager that abstract all cloud connection logic away. + self._cloud_output_device_manager = CloudOutputDeviceManager(self._application) + # Because the model needs to be created in the same thread as the QMLEngine, we use a signal. self.addDeviceSignal.connect(self._onAddDevice) self.removeDeviceSignal.connect(self._onRemoveDevice) @@ -76,9 +79,6 @@ class UM3OutputDevicePlugin(OutputDevicePlugin): self._service_changed_request_event = Event() self._service_changed_request_thread = Thread(target=self._handleOnServiceChangedRequests, daemon=True) self._service_changed_request_thread.start() - - # Create a cloud output device manager that abstract all cloud connection logic away. - self._cloud_output_device_manager = CloudOutputDeviceManager(self._application) def getDiscoveredDevices(self): return self._discovered_devices From ca1c5fb48cacdeec7383d5bc6c8d579ee6ac44e4 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Mon, 19 Nov 2018 22:30:17 +0100 Subject: [PATCH 005/178] Add some documentation --- .../src/Cloud/CloudOutputDeviceManager.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 53f64241e5..d5c4abae09 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -12,7 +12,10 @@ if TYPE_CHECKING: ## The cloud output device manager is responsible for using the Ultimaker Cloud APIs to manage remote clusters. # Keeping all cloud related logic in this class instead of the UM3OutputDevicePlugin results in more readable code. # +# API spec is available on https://api.ultimaker.com/docs/connect/spec/. +# # TODO: figure out how to pair remote clusters, local networked clusters and local cura printer presets. +# TODO: for now we just have multiple output devices if the cluster is available both locally and remote. class CloudOutputDeviceManager: # The cloud URL to use for remote clusters. @@ -32,17 +35,20 @@ class CloudOutputDeviceManager: # Fetch all remote clusters for the authenticated user. self._getRemoteClusters() + ## Gets all remote clusters from the API. def _getRemoteClusters(self): # TODO: get list of remote clusters and create an output device for each. # For testing we add a dummy device: self._addCloudOutputDevice({"cluster_id": "LJ0tciiuZZjarrXAvFLEZ6ox4Cvx8FvtXUlQv4vIhV6w"}) + ## Adds a CloudOutputDevice for each entry in the remote cluster list from the API. def _addCloudOutputDevice(self, cluster_data: Dict[str, any]): # TODO: use model or named tuple for cluster_data device = CloudOutputDevice(cluster_data["cluster_id"]) self._output_device_manager.addOutputDevice(device) self._remote_clusters[cluster_data["cluster_id"]] = device + ## Callback for when the active machine was changed by the user. def _activeMachineChanged(self): active_machine = self._application.getGlobalContainerStack() if not active_machine: From 04cc6193d6a6f7098732a808cd87841235a8e29b Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Mon, 19 Nov 2018 23:25:54 +0100 Subject: [PATCH 006/178] More implementation for getting remote clusters, add some TODOs --- .../src/Cloud/CloudOutputDevice.py | 1 + .../src/Cloud/CloudOutputDeviceManager.py | 67 +++++++++++++++++-- 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index ff83c3fd5e..8f0bd62035 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -60,6 +60,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): # TODO: show message to user to sign in self.setAuthenticationState(AuthState.NotAuthenticated) else: + # TODO: not execute call at all when not signed in? self.setAuthenticationState(AuthState.Authenticated) request.setRawHeader(b"Authorization", "Bearer {}".format(self._account.accessToken).encode()) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index d5c4abae09..e88ee4dced 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -1,7 +1,12 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from typing import TYPE_CHECKING, Dict +import json +from typing import TYPE_CHECKING, Dict, Optional +from PyQt5.QtCore import QUrl +from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply + +from UM.Logger import Logger from plugins.UM3NetworkPrinting.src.Cloud.CloudOutputDevice import CloudOutputDevice @@ -19,31 +24,79 @@ if TYPE_CHECKING: class CloudOutputDeviceManager: # The cloud URL to use for remote clusters. - API_ROOT_PATH = "https://api-staging.ultimaker.com/connect/v1" + API_ROOT_PATH = "https://api.ultimaker.com/connect/v1" def __init__(self, application: "CuraApplication"): self._application = application self._output_device_manager = application.getOutputDeviceManager() self._account = application.getCuraAPI().account + # Network manager for getting the cluster list. + self._network_manager = QNetworkAccessManager() + self._network_manager.finished.connect(self._onNetworkRequestFinished) + # Persistent dict containing the remote clusters for the authenticated user. self._remote_clusters = {} # type: Dict[str, CloudOutputDevice] # When switching machines we check if we have to activate a remote cluster. self._application.globalContainerStackChanged.connect(self._activeMachineChanged) - + # Fetch all remote clusters for the authenticated user. - self._getRemoteClusters() + # TODO: update remote clusters periodically + self._account.loginStateChanged.connect(self._getRemoteClusters) ## Gets all remote clusters from the API. def _getRemoteClusters(self): - # TODO: get list of remote clusters and create an output device for each. - # For testing we add a dummy device: - self._addCloudOutputDevice({"cluster_id": "LJ0tciiuZZjarrXAvFLEZ6ox4Cvx8FvtXUlQv4vIhV6w"}) + url = QUrl("{}/clusters".format(self.API_ROOT_PATH)) + request = QNetworkRequest(url) + request.setHeader(QNetworkRequest.ContentTypeHeader, "application/json") + + if not self._account.isLoggedIn: + # TODO: show message to user to sign in + Logger.log("w", "User is not signed in, cannot get remote print clusters") + return + + request.setRawHeader(b"Authorization", "Bearer {}".format(self._account.accessToken).encode()) + self._network_manager.get(request) + + ## Callback for network requests. + def _onNetworkRequestFinished(self, reply: QNetworkReply): + # TODO: right now we assume that each reply is from /clusters, we should fix this + status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) + if status_code != 200: + # TODO: add correct scopes to OAuth2 client to use remote connect API. + Logger.log("w", "Got unexpected response while trying to get cloud cluster data: {}, {}" + .format(status_code, reply.readAll())) + return + + # Parse the response (returns the "data" field from the body). + clusters_data = self._parseStatusResponse(reply) + if not clusters_data: + return + + # Add an output device for each remote cluster. + # The clusters are an array of objects in a field called "data". + for cluster in clusters_data: + self._addCloudOutputDevice(cluster) + + # # For testing we add a dummy device: + # self._addCloudOutputDevice({ "cluster_id": "LJ0tciiuZZjarrXAvFLEZ6ox4Cvx8FvtXUlQv4vIhV6w" }) + + @staticmethod + def _parseStatusResponse(reply: QNetworkReply) -> Optional[dict]: + try: + result = json.loads(bytes(reply.readAll()).decode("utf-8")) + print("result=====", result) + # TODO: use model or named tuple here. + return result.data + except json.decoder.JSONDecodeError: + Logger.logException("w", "Unable to decode JSON from reply.") + return None ## Adds a CloudOutputDevice for each entry in the remote cluster list from the API. def _addCloudOutputDevice(self, cluster_data: Dict[str, any]): # TODO: use model or named tuple for cluster_data + print("cluster_data====", cluster_data) device = CloudOutputDevice(cluster_data["cluster_id"]) self._output_device_manager.addOutputDevice(device) self._remote_clusters[cluster_data["cluster_id"]] = device From c7bb6931f48604aebcefaaa8ed8007edc4f49f9f Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Tue, 20 Nov 2018 23:44:28 +0100 Subject: [PATCH 007/178] Refactor networked output device All networking related stuff is moved to a separate class called NetworkClient for reusability. As example it is now also used in the WIP CloudOutputDeviceManager to clean up network calling there. --- cura/NetworkClient.py | 220 ++++++++++++++++++ .../NetworkedPrinterOutputDevice.py | 189 ++------------- .../src/Cloud/CloudOutputDeviceManager.py | 43 ++-- 3 files changed, 260 insertions(+), 192 deletions(-) create mode 100644 cura/NetworkClient.py diff --git a/cura/NetworkClient.py b/cura/NetworkClient.py new file mode 100644 index 0000000000..ce9cc50cf6 --- /dev/null +++ b/cura/NetworkClient.py @@ -0,0 +1,220 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from time import time +from typing import Optional, Dict, Callable, List + +from PyQt5.QtCore import QUrl +from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkReply, QHttpMultiPart, QNetworkRequest, QHttpPart, \ + QAuthenticator + +from UM.Logger import Logger +from cura.CuraApplication import CuraApplication + + +## Abstraction of QNetworkAccessManager for easier networking in Cura. +# This was originally part of NetworkedPrinterOutputDevice but was moved out for re-use in other classes. +class NetworkClient: + + def __init__(self, application: CuraApplication = None): + + # Use the given application instance or get the singleton instance. + self._application = application or CuraApplication.getInstance() + + # Network manager instance to use for this client. + self._manager = None # type: Optional[QNetworkAccessManager] + + # Timings. + self._last_manager_create_time = None # type: Optional[float] + self._last_response_time = None # type: Optional[float] + self._last_request_time = None # type: Optional[float] + + # The user agent of Cura. + self._user_agent = "%s/%s " % (self._application.getApplicationName(), self._application.getVersion()) + + # Uses to store callback methods for finished network requests. + # This allows us to register network calls with a callback directly instead of having to dissect the reply. + self._on_finished_callbacks = {} # type: Dict[str, Callable[[QNetworkReply], None]] + + # QHttpMultiPart objects need to be kept alive and not garbage collected during the + # HTTP which uses them. We hold references to these QHttpMultiPart objects here. + self._kept_alive_multiparts = {} # type: Dict[QNetworkReply, QHttpMultiPart] + + ## Creates a network manager with all the required properties and event bindings. + def _createNetworkManager(self) -> None: + if self._manager: + self._manager.finished.disconnect(self.__handleOnFinished) + self._manager.authenticationRequired.disconnect(self._onAuthenticationRequired) + self._manager = QNetworkAccessManager() + self._manager.finished.connect(self.__handleOnFinished) + self._last_manager_create_time = time() + self._manager.authenticationRequired.connect(self._onAuthenticationRequired) + + ## Create a new empty network request. + # Automatically adds the required HTTP headers. + def _createEmptyRequest(self, url: str, content_type: Optional[str] = "application/json") -> QNetworkRequest: + request = QNetworkRequest(QUrl(url)) + if content_type: + request.setHeader(QNetworkRequest.ContentTypeHeader, content_type) + request.setHeader(QNetworkRequest.UserAgentHeader, self._user_agent) + return request + + ## Executes the correct callback method when a network request finishes. + def __handleOnFinished(self, reply: QNetworkReply) -> None: + + # Due to garbage collection, we need to cache certain bits of post operations. + # As we don't want to keep them around forever, delete them if we get a reply. + if reply.operation() == QNetworkAccessManager.PostOperation: + self._clearCachedMultiPart(reply) + + # No status code means it never even reached remote. + if reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) is None: + return + + # Not used by this class itself, but children might need it for better network handling. + # An example of this is the _update method in the NetworkedPrinterOutputDevice. + self._last_response_time = time() + + # Find the right callback and execute it. + # It always takes the full reply as single parameter. + callback_key = reply.url().toString() + str(reply.operation()) + if callback_key in self._on_finished_callbacks: + self._on_finished_callbacks[callback_key](reply) + + ## Removes all cached Multi-Part items. + def _clearCachedMultiPart(self, reply: QNetworkReply) -> None: + if reply in self._kept_alive_multiparts: + del self._kept_alive_multiparts[reply] + + ## Makes sure the network manager is created. + def _validateManager(self) -> None: + if self._manager is None: + self._createNetworkManager() + assert (self._manager is not None) + + ## Callback for when the network manager detects that authentication is required but was not given. + @staticmethod + def _onAuthenticationRequired(reply: QNetworkReply, authenticator: QAuthenticator) -> None: + Logger.log("w", "Request to {} required authentication but was not given".format(reply.url().toString())) + + ## Register a method to be executed when the associated network request finishes. + def _registerOnFinishedCallback(self, reply: QNetworkReply, + on_finished: Optional[Callable[[QNetworkReply], None]]) -> None: + if on_finished is not None: + self._on_finished_callbacks[reply.url().toString() + str(reply.operation())] = on_finished + + ## Add a part to a Multi-Part form. + @staticmethod + def _createFormPart(content_header: str, data: bytes, content_type: Optional[str] = None) -> QHttpPart: + part = QHttpPart() + + if not content_header.startswith("form-data;"): + content_header = "form_data; " + content_header + + part.setHeader(QNetworkRequest.ContentDispositionHeader, content_header) + + if content_type is not None: + part.setHeader(QNetworkRequest.ContentTypeHeader, content_type) + + part.setBody(data) + return part + + ## Public version of _createFormPart. Both are needed for backward compatibility with 3rd party plugins. + def createFormPart(self, content_header: str, data: bytes, content_type: Optional[str] = None) -> QHttpPart: + return self._createFormPart(content_header, data, content_type) + + ## Does a PUT request to the given URL. + def put(self, url: str, data: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None: + self._validateManager() + + request = self._createEmptyRequest(url) + self._last_request_time = time() + + if not self._manager: + Logger.log("e", "No network manager was created to execute the PUT call with.") + return + + reply = self._manager.put(request, data.encode()) + self._registerOnFinishedCallback(reply, on_finished) + + ## Does a DELETE request to the given URL. + def delete(self, url: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None: + self._validateManager() + + request = self._createEmptyRequest(url) + self._last_request_time = time() + + if not self._manager: + Logger.log("e", "No network manager was created to execute the DELETE call with.") + return + + reply = self._manager.deleteResource(request) + self._registerOnFinishedCallback(reply, on_finished) + + ## Does a GET request to the given URL. + def get(self, url: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None: + self._validateManager() + + request = self._createEmptyRequest(url) + self._last_request_time = time() + + if not self._manager: + Logger.log("e", "No network manager was created to execute the GET call with.") + return + + reply = self._manager.get(request) + self._registerOnFinishedCallback(reply, on_finished) + + ## Does a POST request to the given URL. + def post(self, url: str, data: str, on_finished: Optional[Callable[[QNetworkReply], None]], + on_progress: Callable = None) -> None: + self._validateManager() + + request = self._createEmptyRequest(url) + self._last_request_time = time() + + if not self._manager: + Logger.log("e", "No network manager was created to execute the GET call with.") + return + + reply = self._manager.post(request, data.encode()) + + if on_progress is not None: + reply.uploadProgress.connect(on_progress) + + self._registerOnFinishedCallback(reply, on_finished) + + ## Does a POST request with form data to the given URL. + def postForm(self, url: str, header_data: str, body_data: bytes, + on_finished: Optional[Callable[[QNetworkReply], None]], + on_progress: Callable = None) -> None: + post_part = QHttpPart() + post_part.setHeader(QNetworkRequest.ContentDispositionHeader, header_data) + post_part.setBody(body_data) + self.postFormWithParts(url, [post_part], on_finished, on_progress) + + ## Does a POST request with form parts to the given URL. + def postFormWithParts(self, target: str, parts: List[QHttpPart], + on_finished: Optional[Callable[[QNetworkReply], None]], + on_progress: Callable = None) -> None: + self._validateManager() + + request = self._createEmptyRequest(target, content_type = None) + multi_post_part = QHttpMultiPart(QHttpMultiPart.FormDataType) + + for part in parts: + multi_post_part.append(part) + + self._last_request_time = time() + + if not self._manager: + Logger.log("e", "No network manager was created to execute the POST call with.") + return + + reply = self._manager.post(request, multi_post_part) + + self._kept_alive_multiparts[reply] = multi_post_part + + if on_progress is not None: + reply.uploadProgress.connect(on_progress) + + self._registerOnFinishedCallback(reply, on_finished) diff --git a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py index 9a6892ce4d..b5bb1a5452 100644 --- a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py +++ b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py @@ -5,6 +5,7 @@ from UM.FileHandler.FileHandler import FileHandler #For typing. from UM.Logger import Logger from UM.Scene.SceneNode import SceneNode #For typing. from cura.CuraApplication import CuraApplication +from cura.NetworkClient import NetworkClient from cura.PrinterOutputDevice import PrinterOutputDevice, ConnectionState @@ -26,35 +27,29 @@ class AuthState(IntEnum): AuthenticationReceived = 5 -class NetworkedPrinterOutputDevice(PrinterOutputDevice): +class NetworkedPrinterOutputDevice(PrinterOutputDevice, NetworkClient): authenticationStateChanged = pyqtSignal() def __init__(self, device_id, address: str, properties: Dict[bytes, bytes], parent: QObject = None) -> None: - super().__init__(device_id = device_id, parent = parent) - self._manager = None # type: Optional[QNetworkAccessManager] - self._last_manager_create_time = None # type: Optional[float] - self._recreate_network_manager_time = 30 - self._timeout_time = 10 # After how many seconds of no response should a timeout occur? - - self._last_response_time = None # type: Optional[float] - self._last_request_time = None # type: Optional[float] - + PrinterOutputDevice.__init__(self, device_id = device_id, parent = parent) + NetworkClient.__init__(self) + self._api_prefix = "" self._address = address self._properties = properties - self._user_agent = "%s/%s " % (CuraApplication.getInstance().getApplicationName(), CuraApplication.getInstance().getVersion()) - - self._onFinishedCallbacks = {} # type: Dict[str, Callable[[QNetworkReply], None]] self._authentication_state = AuthState.NotAuthenticated - - # QHttpMultiPart objects need to be kept alive and not garbage collected during the - # HTTP which uses them. We hold references to these QHttpMultiPart objects here. - self._kept_alive_multiparts = {} # type: Dict[QNetworkReply, QHttpMultiPart] - self._sending_gcode = False self._compressing_gcode = False self._gcode = [] # type: List[str] + self._connection_state_before_timeout = None # type: Optional[ConnectionState] + self._timeout_time = 10 # After how many seconds of no response should a timeout occur? + self._recreate_network_manager_time = 30 + + ## Override creating empty request to compile the full URL. + # Needed to keep NetworkedPrinterOutputDevice backwards compatible after refactoring NetworkClient out of it. + def _createEmptyRequest(self, target: str, content_type: Optional[str] = "application/json") -> QNetworkRequest: + return super()._createEmptyRequest("http://" + self._address + self._api_prefix + target, content_type) def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mimetypes: bool = False, file_handler: Optional[FileHandler] = None, **kwargs: str) -> None: raise NotImplementedError("requestWrite needs to be implemented") @@ -140,30 +135,6 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): self.setConnectionState(self._connection_state_before_timeout) self._connection_state_before_timeout = None - def _createEmptyRequest(self, target: str, content_type: Optional[str] = "application/json") -> QNetworkRequest: - url = QUrl("http://" + self._address + self._api_prefix + target) - request = QNetworkRequest(url) - if content_type is not None: - request.setHeader(QNetworkRequest.ContentTypeHeader, "application/json") - request.setHeader(QNetworkRequest.UserAgentHeader, self._user_agent) - return request - - def createFormPart(self, content_header: str, data: bytes, content_type: Optional[str] = None) -> QHttpPart: - return self._createFormPart(content_header, data, content_type) - - def _createFormPart(self, content_header: str, data: bytes, content_type: Optional[str] = None) -> QHttpPart: - part = QHttpPart() - - if not content_header.startswith("form-data;"): - content_header = "form_data; " + content_header - part.setHeader(QNetworkRequest.ContentDispositionHeader, content_header) - - if content_type is not None: - part.setHeader(QNetworkRequest.ContentTypeHeader, content_type) - - part.setBody(data) - return part - ## Convenience function to get the username from the OS. # The code was copied from the getpass module, as we try to use as little dependencies as possible. def _getUserName(self) -> str: @@ -173,130 +144,7 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): return user return "Unknown User" # Couldn't find out username. - def _clearCachedMultiPart(self, reply: QNetworkReply) -> None: - if reply in self._kept_alive_multiparts: - del self._kept_alive_multiparts[reply] - - def _validateManager(self) -> None: - if self._manager is None: - self._createNetworkManager() - assert (self._manager is not None) - - def put(self, target: str, data: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None: - self._validateManager() - request = self._createEmptyRequest(target) - self._last_request_time = time() - if self._manager is not None: - reply = self._manager.put(request, data.encode()) - self._registerOnFinishedCallback(reply, on_finished) - else: - Logger.log("e", "Could not find manager.") - - def delete(self, target: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None: - self._validateManager() - request = self._createEmptyRequest(target) - self._last_request_time = time() - if self._manager is not None: - reply = self._manager.deleteResource(request) - self._registerOnFinishedCallback(reply, on_finished) - else: - Logger.log("e", "Could not find manager.") - - def get(self, target: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None: - self._validateManager() - request = self._createEmptyRequest(target) - self._last_request_time = time() - if self._manager is not None: - reply = self._manager.get(request) - self._registerOnFinishedCallback(reply, on_finished) - else: - Logger.log("e", "Could not find manager.") - - def post(self, target: str, data: str, on_finished: Optional[Callable[[QNetworkReply], None]], on_progress: Callable = None) -> None: - self._validateManager() - request = self._createEmptyRequest(target) - self._last_request_time = time() - if self._manager is not None: - reply = self._manager.post(request, data.encode()) - if on_progress is not None: - reply.uploadProgress.connect(on_progress) - self._registerOnFinishedCallback(reply, on_finished) - else: - Logger.log("e", "Could not find manager.") - - def postFormWithParts(self, target: str, parts: List[QHttpPart], on_finished: Optional[Callable[[QNetworkReply], None]], on_progress: Callable = None) -> QNetworkReply: - self._validateManager() - request = self._createEmptyRequest(target, content_type=None) - multi_post_part = QHttpMultiPart(QHttpMultiPart.FormDataType) - for part in parts: - multi_post_part.append(part) - - self._last_request_time = time() - - if self._manager is not None: - reply = self._manager.post(request, multi_post_part) - - self._kept_alive_multiparts[reply] = multi_post_part - - if on_progress is not None: - reply.uploadProgress.connect(on_progress) - self._registerOnFinishedCallback(reply, on_finished) - - return reply - else: - Logger.log("e", "Could not find manager.") - - def postForm(self, target: str, header_data: str, body_data: bytes, on_finished: Optional[Callable[[QNetworkReply], None]], on_progress: Callable = None) -> None: - post_part = QHttpPart() - post_part.setHeader(QNetworkRequest.ContentDispositionHeader, header_data) - post_part.setBody(body_data) - - self.postFormWithParts(target, [post_part], on_finished, on_progress) - - def _onAuthenticationRequired(self, reply: QNetworkReply, authenticator: QAuthenticator) -> None: - Logger.log("w", "Request to {url} required authentication, which was not implemented".format(url = reply.url().toString())) - - def _createNetworkManager(self) -> None: - Logger.log("d", "Creating network manager") - if self._manager: - self._manager.finished.disconnect(self.__handleOnFinished) - self._manager.authenticationRequired.disconnect(self._onAuthenticationRequired) - - self._manager = QNetworkAccessManager() - self._manager.finished.connect(self.__handleOnFinished) - self._last_manager_create_time = time() - self._manager.authenticationRequired.connect(self._onAuthenticationRequired) - - if self._properties.get(b"temporary", b"false") != b"true": - CuraApplication.getInstance().getMachineManager().checkCorrectGroupName(self.getId(), self.name) - - def _registerOnFinishedCallback(self, reply: QNetworkReply, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None: - if on_finished is not None: - self._onFinishedCallbacks[reply.url().toString() + str(reply.operation())] = on_finished - - def __handleOnFinished(self, reply: QNetworkReply) -> None: - # Due to garbage collection, we need to cache certain bits of post operations. - # As we don't want to keep them around forever, delete them if we get a reply. - if reply.operation() == QNetworkAccessManager.PostOperation: - self._clearCachedMultiPart(reply) - - if reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) is None: - # No status code means it never even reached remote. - return - - self._last_response_time = time() - - if self._connection_state == ConnectionState.connecting: - self.setConnectionState(ConnectionState.connected) - - callback_key = reply.url().toString() + str(reply.operation()) - try: - if callback_key in self._onFinishedCallbacks: - self._onFinishedCallbacks[callback_key](reply) - except Exception: - Logger.logException("w", "something went wrong with callback") - - @pyqtSlot(str, result=str) + @pyqtSlot(str, result = str) def getProperty(self, key: str) -> str: bytes_key = key.encode("utf-8") if bytes_key in self._properties: @@ -332,7 +180,14 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): def printerType(self) -> str: return self._properties.get(b"printer_type", b"Unknown").decode("utf-8") - ## IP adress of this printer + ## IP address of this printer @pyqtProperty(str, constant = True) def ipAddress(self) -> str: return self._address + + def __handleOnFinished(self, reply: QNetworkReply) -> None: + super().__handleOnFinished(reply) + + # Since we got a reply from the network manager we can now be sure we are actually connected. + if self._connection_state == ConnectionState.connecting: + self.setConnectionState(ConnectionState.connected) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index e88ee4dced..08e43152ae 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -3,10 +3,10 @@ import json from typing import TYPE_CHECKING, Dict, Optional -from PyQt5.QtCore import QUrl -from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply +from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply from UM.Logger import Logger +from cura.NetworkClient import NetworkClient from plugins.UM3NetworkPrinting.src.Cloud.CloudOutputDevice import CloudOutputDevice @@ -21,20 +21,17 @@ if TYPE_CHECKING: # # TODO: figure out how to pair remote clusters, local networked clusters and local cura printer presets. # TODO: for now we just have multiple output devices if the cluster is available both locally and remote. -class CloudOutputDeviceManager: +class CloudOutputDeviceManager(NetworkClient): # The cloud URL to use for remote clusters. API_ROOT_PATH = "https://api.ultimaker.com/connect/v1" def __init__(self, application: "CuraApplication"): - self._application = application + super().__init__(application) + self._output_device_manager = application.getOutputDeviceManager() self._account = application.getCuraAPI().account - # Network manager for getting the cluster list. - self._network_manager = QNetworkAccessManager() - self._network_manager.finished.connect(self._onNetworkRequestFinished) - # Persistent dict containing the remote clusters for the authenticated user. self._remote_clusters = {} # type: Dict[str, CloudOutputDevice] @@ -44,27 +41,24 @@ class CloudOutputDeviceManager: # Fetch all remote clusters for the authenticated user. # TODO: update remote clusters periodically self._account.loginStateChanged.connect(self._getRemoteClusters) + + ## Override _createEmptyRequest to add the needed authentication header for talking to the Ultimaker Cloud API. + def _createEmptyRequest(self, path: str, content_type: Optional[str] = "application/json") -> QNetworkRequest: + request = super()._createEmptyRequest(self.API_ROOT_PATH + path, content_type = content_type) + if self._account.isLoggedIn: + # TODO: add correct scopes to OAuth2 client to use remote connect API. + # TODO: don't create the client when not signed in? + request.setRawHeader(b"Authorization", "Bearer {}".format(self._account.accessToken).encode()) + return request ## Gets all remote clusters from the API. - def _getRemoteClusters(self): - url = QUrl("{}/clusters".format(self.API_ROOT_PATH)) - request = QNetworkRequest(url) - request.setHeader(QNetworkRequest.ContentTypeHeader, "application/json") - - if not self._account.isLoggedIn: - # TODO: show message to user to sign in - Logger.log("w", "User is not signed in, cannot get remote print clusters") - return - - request.setRawHeader(b"Authorization", "Bearer {}".format(self._account.accessToken).encode()) - self._network_manager.get(request) + def _getRemoteClusters(self) -> None: + self.get("/clusters", on_finished = self._onGetRemoteClustersFinished) - ## Callback for network requests. - def _onNetworkRequestFinished(self, reply: QNetworkReply): - # TODO: right now we assume that each reply is from /clusters, we should fix this + ## Callback for when the request for getting the clusters. is finished. + def _onGetRemoteClustersFinished(self, reply: QNetworkReply) -> None: status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) if status_code != 200: - # TODO: add correct scopes to OAuth2 client to use remote connect API. Logger.log("w", "Got unexpected response while trying to get cloud cluster data: {}, {}" .format(status_code, reply.readAll())) return @@ -86,7 +80,6 @@ class CloudOutputDeviceManager: def _parseStatusResponse(reply: QNetworkReply) -> Optional[dict]: try: result = json.loads(bytes(reply.readAll()).decode("utf-8")) - print("result=====", result) # TODO: use model or named tuple here. return result.data except json.decoder.JSONDecodeError: From 8ad8489af0afeb7278d325b1db9eb26e31473086 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Wed, 21 Nov 2018 00:03:36 +0100 Subject: [PATCH 008/178] Fix returning reply on postFormWithParts --- cura/NetworkClient.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cura/NetworkClient.py b/cura/NetworkClient.py index ce9cc50cf6..da27456ac8 100644 --- a/cura/NetworkClient.py +++ b/cura/NetworkClient.py @@ -195,7 +195,7 @@ class NetworkClient: ## Does a POST request with form parts to the given URL. def postFormWithParts(self, target: str, parts: List[QHttpPart], on_finished: Optional[Callable[[QNetworkReply], None]], - on_progress: Callable = None) -> None: + on_progress: Callable = None) -> QNetworkReply: self._validateManager() request = self._createEmptyRequest(target, content_type = None) @@ -218,3 +218,4 @@ class NetworkClient: reply.uploadProgress.connect(on_progress) self._registerOnFinishedCallback(reply, on_finished) + return reply From 8453cd693e98bf7d0e5a632b0a6ef8380826c931 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Wed, 21 Nov 2018 00:04:34 +0100 Subject: [PATCH 009/178] Make QNetworkReply optional --- cura/NetworkClient.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cura/NetworkClient.py b/cura/NetworkClient.py index da27456ac8..20c026f4e6 100644 --- a/cura/NetworkClient.py +++ b/cura/NetworkClient.py @@ -195,7 +195,7 @@ class NetworkClient: ## Does a POST request with form parts to the given URL. def postFormWithParts(self, target: str, parts: List[QHttpPart], on_finished: Optional[Callable[[QNetworkReply], None]], - on_progress: Callable = None) -> QNetworkReply: + on_progress: Callable = None) -> Optional[QNetworkReply]: self._validateManager() request = self._createEmptyRequest(target, content_type = None) From 2fc5061c41ae93d664320d30413c766eeaecdd5c Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Fri, 23 Nov 2018 14:11:43 +0100 Subject: [PATCH 010/178] Fix some PR comments, cleanup imports --- cura/NetworkClient.py | 6 ++--- .../NetworkedPrinterOutputDevice.py | 24 ++++++++----------- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/cura/NetworkClient.py b/cura/NetworkClient.py index 20c026f4e6..eeedfeaa79 100644 --- a/cura/NetworkClient.py +++ b/cura/NetworkClient.py @@ -15,10 +15,10 @@ from cura.CuraApplication import CuraApplication # This was originally part of NetworkedPrinterOutputDevice but was moved out for re-use in other classes. class NetworkClient: - def __init__(self, application: CuraApplication = None): + def __init__(self) -> None: # Use the given application instance or get the singleton instance. - self._application = application or CuraApplication.getInstance() + self._application = CuraApplication.getInstance() # Network manager instance to use for this client. self._manager = None # type: Optional[QNetworkAccessManager] @@ -89,7 +89,7 @@ class NetworkClient: def _validateManager(self) -> None: if self._manager is None: self._createNetworkManager() - assert (self._manager is not None) + assert self._manager is not None ## Callback for when the network manager detects that authentication is required but was not given. @staticmethod diff --git a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py index b5bb1a5452..12769208f4 100644 --- a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py +++ b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py @@ -1,22 +1,18 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. - -from UM.FileHandler.FileHandler import FileHandler #For typing. -from UM.Logger import Logger -from UM.Scene.SceneNode import SceneNode #For typing. -from cura.CuraApplication import CuraApplication -from cura.NetworkClient import NetworkClient - -from cura.PrinterOutputDevice import PrinterOutputDevice, ConnectionState - -from PyQt5.QtNetwork import QHttpMultiPart, QHttpPart, QNetworkRequest, QNetworkAccessManager, QNetworkReply, QAuthenticator -from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl, QCoreApplication +import os +import gzip from time import time -from typing import Any, Callable, Dict, List, Optional +from typing import Dict, List, Optional from enum import IntEnum -import os # To get the username -import gzip +from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QCoreApplication + +from UM.FileHandler.FileHandler import FileHandler +from UM.Scene.SceneNode import SceneNode +from cura.NetworkClient import NetworkClient +from cura.PrinterOutputDevice import PrinterOutputDevice, ConnectionState class AuthState(IntEnum): From 3e100775df7c295931cfc0613309205f8d8c0ea5 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Fri, 23 Nov 2018 14:14:35 +0100 Subject: [PATCH 011/178] Fix instantiating CloudOutputDeviceManager --- .../src/Cloud/CloudOutputDeviceManager.py | 8 ++++---- plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 08e43152ae..0ec07df923 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -26,11 +26,11 @@ class CloudOutputDeviceManager(NetworkClient): # The cloud URL to use for remote clusters. API_ROOT_PATH = "https://api.ultimaker.com/connect/v1" - def __init__(self, application: "CuraApplication"): - super().__init__(application) + def __init__(self): + super().__init__() - self._output_device_manager = application.getOutputDeviceManager() - self._account = application.getCuraAPI().account + self._output_device_manager = self._application.getOutputDeviceManager() + self._account = self._application.getCuraAPI().account # Persistent dict containing the remote clusters for the authenticated user. self._remote_clusters = {} # type: Dict[str, CloudOutputDevice] diff --git a/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py b/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py index b441df9eb5..f3e2b66d50 100644 --- a/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py +++ b/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py @@ -41,7 +41,7 @@ class UM3OutputDevicePlugin(OutputDevicePlugin): self._zero_conf_browser = None # Create a cloud output device manager that abstract all cloud connection logic away. - self._cloud_output_device_manager = CloudOutputDeviceManager(self._application) + self._cloud_output_device_manager = CloudOutputDeviceManager() # Because the model needs to be created in the same thread as the QMLEngine, we use a signal. self.addDeviceSignal.connect(self._onAddDevice) From ef5ca6f5a9f8b2eb5ee3f5ffd84998003404ac5b Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Fri, 23 Nov 2018 14:15:21 +0100 Subject: [PATCH 012/178] Remove unused typing import --- .../UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 0ec07df923..75d2efd4f7 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -8,10 +8,6 @@ from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply from UM.Logger import Logger from cura.NetworkClient import NetworkClient from plugins.UM3NetworkPrinting.src.Cloud.CloudOutputDevice import CloudOutputDevice - - -if TYPE_CHECKING: - from cura.CuraApplication import CuraApplication ## The cloud output device manager is responsible for using the Ultimaker Cloud APIs to manage remote clusters. From cf27a211df10e592c0d509321017c0f620159786 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Fri, 23 Nov 2018 14:15:40 +0100 Subject: [PATCH 013/178] Remove unused TYPE_CHECKING import --- .../UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 75d2efd4f7..8459527d39 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -1,7 +1,7 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. import json -from typing import TYPE_CHECKING, Dict, Optional +from typing import Dict, Optional from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply From 908628e2aadcff72826e099577049be0a6287a8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marijn=20De=C3=A9?= Date: Mon, 26 Nov 2018 10:47:53 +0100 Subject: [PATCH 014/178] Added a model to represent a cluster --- .../src/Cloud/CloudOutputDeviceManager.py | 32 +++++++++++-------- .../UM3NetworkPrinting/src/Cloud/Models.py | 11 +++++++ 2 files changed, 29 insertions(+), 14 deletions(-) create mode 100644 plugins/UM3NetworkPrinting/src/Cloud/Models.py diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 8459527d39..e93393d736 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -8,7 +8,8 @@ from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply from UM.Logger import Logger from cura.NetworkClient import NetworkClient from plugins.UM3NetworkPrinting.src.Cloud.CloudOutputDevice import CloudOutputDevice - +from .Models import Cluster + ## The cloud output device manager is responsible for using the Ultimaker Cloud APIs to manage remote clusters. # Keeping all cloud related logic in this class instead of the UM3OutputDevicePlugin results in more readable code. @@ -60,35 +61,38 @@ class CloudOutputDeviceManager(NetworkClient): return # Parse the response (returns the "data" field from the body). - clusters_data = self._parseStatusResponse(reply) - if not clusters_data: + clusters = self._parseStatusResponse(reply) + if not clusters: return # Add an output device for each remote cluster. # The clusters are an array of objects in a field called "data". - for cluster in clusters_data: + for cluster in clusters: self._addCloudOutputDevice(cluster) # # For testing we add a dummy device: # self._addCloudOutputDevice({ "cluster_id": "LJ0tciiuZZjarrXAvFLEZ6ox4Cvx8FvtXUlQv4vIhV6w" }) @staticmethod - def _parseStatusResponse(reply: QNetworkReply) -> Optional[dict]: + def _parseStatusResponse(reply: QNetworkReply) -> Optional[Cluster]: try: - result = json.loads(bytes(reply.readAll()).decode("utf-8")) - # TODO: use model or named tuple here. - return result.data + return [Cluster(**c) for c in json.loads(reply.readAll().data().decode("utf-8"))] except json.decoder.JSONDecodeError: Logger.logException("w", "Unable to decode JSON from reply.") return None - + except UnicodeDecodeError: + Logger.log("e", "Unable to read server response") + except json.JSONDecodeError: + Logger.logException("w", "Unable to decode JSON from reply.") + + return None + ## Adds a CloudOutputDevice for each entry in the remote cluster list from the API. - def _addCloudOutputDevice(self, cluster_data: Dict[str, any]): - # TODO: use model or named tuple for cluster_data - print("cluster_data====", cluster_data) - device = CloudOutputDevice(cluster_data["cluster_id"]) + def _addCloudOutputDevice(self, cluster: Cluster): + print("cluster_data====", cluster) + device = CloudOutputDevice(cluster["cluster_id"]) self._output_device_manager.addOutputDevice(device) - self._remote_clusters[cluster_data["cluster_id"]] = device + self._remote_clusters[cluster["cluster_id"]] = device ## Callback for when the active machine was changed by the user. def _activeMachineChanged(self): diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models.py b/plugins/UM3NetworkPrinting/src/Cloud/Models.py new file mode 100644 index 0000000000..7d9bba32f7 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models.py @@ -0,0 +1,11 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from collections import namedtuple + +Cluster = namedtuple("Cluster", [ + "cluster_id", # Type: str + "host_guid", # Type: str + "host_name", # Type: str + "host_version", # Type: str + "status", # Type: str +]) \ No newline at end of file From 21c81603b491ab7378752b5afd2e16dd0b648899 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Mon, 26 Nov 2018 13:33:14 +0100 Subject: [PATCH 015/178] Use application singleton instead of locally cached application --- cura/NetworkClient.py | 8 +++----- .../src/Cloud/CloudOutputDeviceManager.py | 14 ++++++++------ plugins/UM3NetworkPrinting/src/Cloud/Models.py | 2 +- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/cura/NetworkClient.py b/cura/NetworkClient.py index eeedfeaa79..b150f59011 100644 --- a/cura/NetworkClient.py +++ b/cura/NetworkClient.py @@ -7,8 +7,8 @@ from PyQt5.QtCore import QUrl from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkReply, QHttpMultiPart, QNetworkRequest, QHttpPart, \ QAuthenticator +from UM.Application import Application from UM.Logger import Logger -from cura.CuraApplication import CuraApplication ## Abstraction of QNetworkAccessManager for easier networking in Cura. @@ -17,9 +17,6 @@ class NetworkClient: def __init__(self) -> None: - # Use the given application instance or get the singleton instance. - self._application = CuraApplication.getInstance() - # Network manager instance to use for this client. self._manager = None # type: Optional[QNetworkAccessManager] @@ -29,7 +26,8 @@ class NetworkClient: self._last_request_time = None # type: Optional[float] # The user agent of Cura. - self._user_agent = "%s/%s " % (self._application.getApplicationName(), self._application.getVersion()) + application = Application.getInstance() + self._user_agent = "%s/%s " % (application.getApplicationName(), application.getVersion()) # Uses to store callback methods for finished network requests. # This allows us to register network calls with a callback directly instead of having to dissect the reply. diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index e93393d736..17e82417ef 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -6,6 +6,7 @@ from typing import Dict, Optional from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply from UM.Logger import Logger +from cura.CuraApplication import CuraApplication from cura.NetworkClient import NetworkClient from plugins.UM3NetworkPrinting.src.Cloud.CloudOutputDevice import CloudOutputDevice from .Models import Cluster @@ -25,15 +26,16 @@ class CloudOutputDeviceManager(NetworkClient): def __init__(self): super().__init__() - - self._output_device_manager = self._application.getOutputDeviceManager() - self._account = self._application.getCuraAPI().account - + # Persistent dict containing the remote clusters for the authenticated user. self._remote_clusters = {} # type: Dict[str, CloudOutputDevice] + + application = CuraApplication.getInstance() + self._output_device_manager = application.getOutputDeviceManager() + self._account = application.getCuraAPI().account # When switching machines we check if we have to activate a remote cluster. - self._application.globalContainerStackChanged.connect(self._activeMachineChanged) + application.globalContainerStackChanged.connect(self._activeMachineChanged) # Fetch all remote clusters for the authenticated user. # TODO: update remote clusters periodically @@ -96,7 +98,7 @@ class CloudOutputDeviceManager(NetworkClient): ## Callback for when the active machine was changed by the user. def _activeMachineChanged(self): - active_machine = self._application.getGlobalContainerStack() + active_machine = CuraApplication.getInstance().getGlobalContainerStack() if not active_machine: return diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models.py b/plugins/UM3NetworkPrinting/src/Cloud/Models.py index 7d9bba32f7..b118f3e61c 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models.py @@ -8,4 +8,4 @@ Cluster = namedtuple("Cluster", [ "host_name", # Type: str "host_version", # Type: str "status", # Type: str -]) \ No newline at end of file +]) From 014b1d6e4ee4e3e9193bed7ba8b188865416ed4d Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Mon, 26 Nov 2018 13:45:37 +0100 Subject: [PATCH 016/178] test --- cura/PrinterOutput/NetworkedPrinterOutputDevice.py | 10 ++++++---- .../UM3NetworkPrinting/src/UM3OutputDevicePlugin.py | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py index 12769208f4..a44b42a8ba 100644 --- a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py +++ b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py @@ -36,7 +36,7 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice, NetworkClient): self._authentication_state = AuthState.NotAuthenticated self._sending_gcode = False self._compressing_gcode = False - self._gcode = [] # type: List[str] + self._gcode = [] # type: List[str] self._connection_state_before_timeout = None # type: Optional[ConnectionState] self._timeout_time = 10 # After how many seconds of no response should a timeout occur? @@ -182,8 +182,10 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice, NetworkClient): return self._address def __handleOnFinished(self, reply: QNetworkReply) -> None: - super().__handleOnFinished(reply) - # Since we got a reply from the network manager we can now be sure we are actually connected. - if self._connection_state == ConnectionState.connecting: + # Since we got a 200 reply from the network manager we can now be sure we are actually connected. + if reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) == 200 and \ + self._connection_state == ConnectionState.connecting: self.setConnectionState(ConnectionState.connected) + + super().__handleOnFinished(reply) diff --git a/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py b/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py index 09aecb2187..47c3482aa5 100644 --- a/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py +++ b/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py @@ -41,7 +41,7 @@ class UM3OutputDevicePlugin(OutputDevicePlugin): self._zero_conf_browser = None # Create a cloud output device manager that abstract all cloud connection logic away. - self._cloud_output_device_manager = CloudOutputDeviceManager() + # self._cloud_output_device_manager = CloudOutputDeviceManager() # Because the model needs to be created in the same thread as the QMLEngine, we use a signal. self.addDeviceSignal.connect(self._onAddDevice) From 89e88a73bbf2e998e846fc11a803107fefafc170 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Mon, 26 Nov 2018 14:11:20 +0100 Subject: [PATCH 017/178] Temporary patch for when printer has one or more materials not installed --- plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py b/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py index 8314b0f089..54b888d2f0 100644 --- a/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py @@ -592,7 +592,7 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): def _createMaterialOutputModel(self, material_data: Dict[str, Any]) -> "MaterialOutputModel": material_manager = CuraApplication.getInstance().getMaterialManager() - material_group_list = material_manager.getMaterialGroupListByGUID(material_data["guid"]) + material_group_list = material_manager.getMaterialGroupListByGUID(material_data["guid"]) or [] # Sort the material groups by "is_read_only = True" first, and then the name alphabetically. read_only_material_group_list = list(filter(lambda x: x.is_read_only, material_group_list)) From 579522857a99505aedaf8c6bf7cfcd1ab0aca521 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marijn=20De=C3=A9?= Date: Mon, 26 Nov 2018 14:42:50 +0100 Subject: [PATCH 018/178] Fix the code-style test --- cura/NetworkClient.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cura/NetworkClient.py b/cura/NetworkClient.py index b150f59011..fbe0c63c36 100644 --- a/cura/NetworkClient.py +++ b/cura/NetworkClient.py @@ -206,7 +206,7 @@ class NetworkClient: if not self._manager: Logger.log("e", "No network manager was created to execute the POST call with.") - return + return None reply = self._manager.post(request, multi_post_part) From 6a43d10982e41e175c54338383435ca91d666199 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Mon, 26 Nov 2018 14:51:24 +0100 Subject: [PATCH 019/178] Use BaseModel for CloudCluster, some fixes --- plugins/UM3NetworkPrinting/__init__.py | 2 +- .../src/Cloud/CloudOutputDeviceManager.py | 21 ++++++++--------- .../UM3NetworkPrinting/src/Cloud/Models.py | 23 ++++++++++++------- .../src/UM3OutputDevicePlugin.py | 23 ++++++++----------- 4 files changed, 34 insertions(+), 35 deletions(-) diff --git a/plugins/UM3NetworkPrinting/__init__.py b/plugins/UM3NetworkPrinting/__init__.py index 23262aed94..3da7795589 100644 --- a/plugins/UM3NetworkPrinting/__init__.py +++ b/plugins/UM3NetworkPrinting/__init__.py @@ -10,6 +10,6 @@ def getMetaData(): def register(app): return { - "output_device": UM3OutputDevicePlugin.UM3OutputDevicePlugin(app), + "output_device": UM3OutputDevicePlugin.UM3OutputDevicePlugin(), "machine_action": DiscoverUM3Action.DiscoverUM3Action() } diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 17e82417ef..e48c06dbe9 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -9,7 +9,7 @@ from UM.Logger import Logger from cura.CuraApplication import CuraApplication from cura.NetworkClient import NetworkClient from plugins.UM3NetworkPrinting.src.Cloud.CloudOutputDevice import CloudOutputDevice -from .Models import Cluster +from .Models import CloudCluster ## The cloud output device manager is responsible for using the Ultimaker Cloud APIs to manage remote clusters. @@ -78,23 +78,20 @@ class CloudOutputDeviceManager(NetworkClient): @staticmethod def _parseStatusResponse(reply: QNetworkReply) -> Optional[Cluster]: try: - return [Cluster(**c) for c in json.loads(reply.readAll().data().decode("utf-8"))] + return [CloudCluster(**c) for c in json.loads(reply.readAll().data().decode("utf-8"))] + except UnicodeDecodeError: + Logger.log("w", "Unable to read server response") except json.decoder.JSONDecodeError: Logger.logException("w", "Unable to decode JSON from reply.") - return None - except UnicodeDecodeError: - Logger.log("e", "Unable to read server response") - except json.JSONDecodeError: - Logger.logException("w", "Unable to decode JSON from reply.") - + except ValueError: + Logger.logException("w", "Response was missing values.") return None ## Adds a CloudOutputDevice for each entry in the remote cluster list from the API. - def _addCloudOutputDevice(self, cluster: Cluster): - print("cluster_data====", cluster) - device = CloudOutputDevice(cluster["cluster_id"]) + def _addCloudOutputDevice(self, cluster: CloudCluster): + device = CloudOutputDevice(cluster.cluster_id) self._output_device_manager.addOutputDevice(device) - self._remote_clusters[cluster["cluster_id"]] = device + self._remote_clusters[cluster.cluster_id] = device ## Callback for when the active machine was changed by the user. def _activeMachineChanged(self): diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models.py b/plugins/UM3NetworkPrinting/src/Cloud/Models.py index b118f3e61c..3cbfecadfb 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models.py @@ -1,11 +1,18 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from collections import namedtuple +from plugins.UM3NetworkPrinting.src.Models import BaseModel -Cluster = namedtuple("Cluster", [ - "cluster_id", # Type: str - "host_guid", # Type: str - "host_name", # Type: str - "host_version", # Type: str - "status", # Type: str -]) + +## Class representing a cloud connected cluster. +class CloudCluster(BaseModel): + def __init__(self, **kwargs): + self.cluster_id = None # type: str + self.host_guid = None # type: str + self.host_name = None # type: str + self.host_version = None # type: str + self.status = None # type: str + super().__init__(**kwargs) + + def validate(self): + if not self.cluster_id: + raise ValueError("cluster_id is required on CloudCluster") diff --git a/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py b/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py index 47c3482aa5..086bca03e2 100644 --- a/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py +++ b/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py @@ -1,7 +1,6 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from typing import TYPE_CHECKING - +from UM.Application import Application from UM.OutputDevice.OutputDevicePlugin import OutputDevicePlugin from UM.Logger import Logger from UM.Signal import Signal, signalemitter @@ -20,9 +19,6 @@ from time import time import json -if TYPE_CHECKING: - from cura.CuraApplication import CuraApplication - ## This plugin handles the connection detection & creation of output device objects for the UM3 printer. # Zero-Conf is used to detect printers, which are saved in a dict. @@ -33,21 +29,20 @@ class UM3OutputDevicePlugin(OutputDevicePlugin): removeDeviceSignal = Signal() discoveredDevicesChanged = Signal() - def __init__(self, application: "CuraApplication"): + def __init__(self): super().__init__() - self._application = application self._zero_conf = None self._zero_conf_browser = None # Create a cloud output device manager that abstract all cloud connection logic away. - # self._cloud_output_device_manager = CloudOutputDeviceManager() + self._cloud_output_device_manager = CloudOutputDeviceManager() # Because the model needs to be created in the same thread as the QMLEngine, we use a signal. self.addDeviceSignal.connect(self._onAddDevice) self.removeDeviceSignal.connect(self._onRemoveDevice) - application.globalContainerStackChanged.connect(self.reCheckConnections) + Application.getInstance().globalContainerStackChanged.connect(self.reCheckConnections) self._discovered_devices = {} @@ -62,7 +57,7 @@ class UM3OutputDevicePlugin(OutputDevicePlugin): self._cluster_api_prefix = "/cluster-api/v" + self._cluster_api_version + "/" # Get list of manual instances from preferences - self._preferences = self._application.getPreferences() + self._preferences = Application.getInstance().getPreferences() self._preferences.addPreference("um3networkprinting/manual_instances", "") # A comma-separated list of ip adresses or hostnames @@ -113,7 +108,7 @@ class UM3OutputDevicePlugin(OutputDevicePlugin): self.resetLastManualDevice() def reCheckConnections(self): - active_machine = self._application.getGlobalContainerStack() + active_machine = Application.getInstance().getGlobalContainerStack() if not active_machine: return @@ -138,7 +133,7 @@ class UM3OutputDevicePlugin(OutputDevicePlugin): return if self._discovered_devices[key].isConnected(): # Sometimes the status changes after changing the global container and maybe the device doesn't belong to this machine - um_network_key = self._application.getGlobalContainerStack().getMetaDataEntry("um_network_key") + um_network_key = Application.getInstance().getGlobalContainerStack().getMetaDataEntry("um_network_key") if key == um_network_key: self.getOutputDeviceManager().addOutputDevice(self._discovered_devices[key]) else: @@ -290,7 +285,7 @@ class UM3OutputDevicePlugin(OutputDevicePlugin): self._discovered_devices[device.getId()] = device self.discoveredDevicesChanged.emit() - global_container_stack = self._application.getGlobalContainerStack() + global_container_stack = Application.getInstance().getGlobalContainerStack() if global_container_stack and device.getId() == global_container_stack.getMetaDataEntry("um_network_key"): device.connect() device.connectionStateChanged.connect(self._onDeviceConnectionStateChanged) @@ -308,7 +303,7 @@ class UM3OutputDevicePlugin(OutputDevicePlugin): self._service_changed_request_event.wait(timeout = 5.0) # Stop if the application is shutting down - if self._application.isShuttingDown(): + if Application.getInstance().isShuttingDown(): return self._service_changed_request_event.clear() From 856276d8b782b0bacaca0047a47ab2afbae867da Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Mon, 26 Nov 2018 14:53:08 +0100 Subject: [PATCH 020/178] Cleanup plugin imports --- .../src/UM3OutputDevicePlugin.py | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py b/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py index 086bca03e2..47720f3ef8 100644 --- a/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py +++ b/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py @@ -1,23 +1,22 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. +import json +from queue import Queue +from threading import Event, Thread +from time import time + +from zeroconf import Zeroconf, ServiceBrowser, ServiceStateChange, ServiceInfo +from PyQt5.QtNetwork import QNetworkRequest, QNetworkAccessManager +from PyQt5.QtCore import QUrl + from UM.Application import Application from UM.OutputDevice.OutputDevicePlugin import OutputDevicePlugin from UM.Logger import Logger from UM.Signal import Signal, signalemitter from UM.Version import Version -from plugins.UM3NetworkPrinting.src.Cloud.CloudOutputDeviceManager import CloudOutputDeviceManager from . import ClusterUM3OutputDevice, LegacyUM3OutputDevice - -from PyQt5.QtNetwork import QNetworkRequest, QNetworkAccessManager -from PyQt5.QtCore import QUrl - -from zeroconf import Zeroconf, ServiceBrowser, ServiceStateChange, ServiceInfo -from queue import Queue -from threading import Event, Thread -from time import time - -import json +from .Cloud.CloudOutputDeviceManager import CloudOutputDeviceManager ## This plugin handles the connection detection & creation of output device objects for the UM3 printer. From 269d596f5da45dc1d61a6002901092e00c0546a1 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Mon, 26 Nov 2018 14:54:24 +0100 Subject: [PATCH 021/178] Fix typing for cluster list --- .../src/Cloud/CloudOutputDeviceManager.py | 6 +++--- plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index e48c06dbe9..9aba01d164 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -1,7 +1,7 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. import json -from typing import Dict, Optional +from typing import Dict, Optional, List from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply @@ -76,7 +76,7 @@ class CloudOutputDeviceManager(NetworkClient): # self._addCloudOutputDevice({ "cluster_id": "LJ0tciiuZZjarrXAvFLEZ6ox4Cvx8FvtXUlQv4vIhV6w" }) @staticmethod - def _parseStatusResponse(reply: QNetworkReply) -> Optional[Cluster]: + def _parseStatusResponse(reply: QNetworkReply) -> List[CloudCluster]: try: return [CloudCluster(**c) for c in json.loads(reply.readAll().data().decode("utf-8"))] except UnicodeDecodeError: @@ -85,7 +85,7 @@ class CloudOutputDeviceManager(NetworkClient): Logger.logException("w", "Unable to decode JSON from reply.") except ValueError: Logger.logException("w", "Response was missing values.") - return None + return [] ## Adds a CloudOutputDevice for each entry in the remote cluster list from the API. def _addCloudOutputDevice(self, cluster: CloudCluster): diff --git a/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py b/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py index 47720f3ef8..e4b4c2bb0a 100644 --- a/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py +++ b/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py @@ -34,7 +34,7 @@ class UM3OutputDevicePlugin(OutputDevicePlugin): self._zero_conf = None self._zero_conf_browser = None - # Create a cloud output device manager that abstract all cloud connection logic away. + # Create a cloud output device manager that abstracts all cloud connection logic away. self._cloud_output_device_manager = CloudOutputDeviceManager() # Because the model needs to be created in the same thread as the QMLEngine, we use a signal. From 39f92ced90194a3f818c87c653ccb1604a2d87d6 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Mon, 26 Nov 2018 15:02:52 +0100 Subject: [PATCH 022/178] Comment out test --- .../UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 9aba01d164..8efce87094 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -72,8 +72,8 @@ class CloudOutputDeviceManager(NetworkClient): for cluster in clusters: self._addCloudOutputDevice(cluster) - # # For testing we add a dummy device: - # self._addCloudOutputDevice({ "cluster_id": "LJ0tciiuZZjarrXAvFLEZ6ox4Cvx8FvtXUlQv4vIhV6w" }) + # For testing we add a dummy device: + # self._addCloudOutputDevice(CloudCluster(cluster_id = "LJ0tciiuZZjarrXAvFLEZ6ox4Cvx8FvtXUlQv4vIhV6w")) @staticmethod def _parseStatusResponse(reply: QNetworkReply) -> List[CloudCluster]: From a9fedb4f66a8717c1d35e9ecca51197b06ad082e Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Mon, 26 Nov 2018 15:05:53 +0100 Subject: [PATCH 023/178] Restore NetworkedPrinterOutputDevice for now --- .../NetworkedPrinterOutputDevice.py | 211 +++++++++++++++--- 1 file changed, 177 insertions(+), 34 deletions(-) diff --git a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py index a44b42a8ba..35d2ce014a 100644 --- a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py +++ b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py @@ -1,19 +1,21 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -import os -import gzip -from time import time -from typing import Dict, List, Optional -from enum import IntEnum -from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply -from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QCoreApplication +from UM.FileHandler.FileHandler import FileHandler #For typing. +from UM.Logger import Logger +from UM.Scene.SceneNode import SceneNode #For typing. +from cura.CuraApplication import CuraApplication -from UM.FileHandler.FileHandler import FileHandler -from UM.Scene.SceneNode import SceneNode -from cura.NetworkClient import NetworkClient from cura.PrinterOutputDevice import PrinterOutputDevice, ConnectionState +from PyQt5.QtNetwork import QHttpMultiPart, QHttpPart, QNetworkRequest, QNetworkAccessManager, QNetworkReply, QAuthenticator +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl, QCoreApplication +from time import time +from typing import Any, Callable, Dict, List, Optional +from enum import IntEnum + +import os # To get the username +import gzip class AuthState(IntEnum): NotAuthenticated = 1 @@ -23,29 +25,35 @@ class AuthState(IntEnum): AuthenticationReceived = 5 -class NetworkedPrinterOutputDevice(PrinterOutputDevice, NetworkClient): +class NetworkedPrinterOutputDevice(PrinterOutputDevice): authenticationStateChanged = pyqtSignal() def __init__(self, device_id, address: str, properties: Dict[bytes, bytes], parent: QObject = None) -> None: - PrinterOutputDevice.__init__(self, device_id = device_id, parent = parent) - NetworkClient.__init__(self) - + super().__init__(device_id = device_id, parent = parent) + self._manager = None # type: Optional[QNetworkAccessManager] + self._last_manager_create_time = None # type: Optional[float] + self._recreate_network_manager_time = 30 + self._timeout_time = 10 # After how many seconds of no response should a timeout occur? + + self._last_response_time = None # type: Optional[float] + self._last_request_time = None # type: Optional[float] + self._api_prefix = "" self._address = address self._properties = properties + self._user_agent = "%s/%s " % (CuraApplication.getInstance().getApplicationName(), CuraApplication.getInstance().getVersion()) + + self._onFinishedCallbacks = {} # type: Dict[str, Callable[[QNetworkReply], None]] self._authentication_state = AuthState.NotAuthenticated + + # QHttpMultiPart objects need to be kept alive and not garbage collected during the + # HTTP which uses them. We hold references to these QHttpMultiPart objects here. + self._kept_alive_multiparts = {} # type: Dict[QNetworkReply, QHttpMultiPart] + self._sending_gcode = False self._compressing_gcode = False - self._gcode = [] # type: List[str] - + self._gcode = [] # type: List[str] self._connection_state_before_timeout = None # type: Optional[ConnectionState] - self._timeout_time = 10 # After how many seconds of no response should a timeout occur? - self._recreate_network_manager_time = 30 - - ## Override creating empty request to compile the full URL. - # Needed to keep NetworkedPrinterOutputDevice backwards compatible after refactoring NetworkClient out of it. - def _createEmptyRequest(self, target: str, content_type: Optional[str] = "application/json") -> QNetworkRequest: - return super()._createEmptyRequest("http://" + self._address + self._api_prefix + target, content_type) def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mimetypes: bool = False, file_handler: Optional[FileHandler] = None, **kwargs: str) -> None: raise NotImplementedError("requestWrite needs to be implemented") @@ -131,6 +139,27 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice, NetworkClient): self.setConnectionState(self._connection_state_before_timeout) self._connection_state_before_timeout = None + def _createEmptyRequest(self, target: str, content_type: Optional[str] = "application/json") -> QNetworkRequest: + url = QUrl("http://" + self._address + self._api_prefix + target) + request = QNetworkRequest(url) + if content_type is not None: + request.setHeader(QNetworkRequest.ContentTypeHeader, "application/json") + request.setHeader(QNetworkRequest.UserAgentHeader, self._user_agent) + return request + + def _createFormPart(self, content_header: str, data: bytes, content_type: Optional[str] = None) -> QHttpPart: + part = QHttpPart() + + if not content_header.startswith("form-data;"): + content_header = "form_data; " + content_header + part.setHeader(QNetworkRequest.ContentDispositionHeader, content_header) + + if content_type is not None: + part.setHeader(QNetworkRequest.ContentTypeHeader, content_type) + + part.setBody(data) + return part + ## Convenience function to get the username from the OS. # The code was copied from the getpass module, as we try to use as little dependencies as possible. def _getUserName(self) -> str: @@ -140,7 +169,130 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice, NetworkClient): return user return "Unknown User" # Couldn't find out username. - @pyqtSlot(str, result = str) + def _clearCachedMultiPart(self, reply: QNetworkReply) -> None: + if reply in self._kept_alive_multiparts: + del self._kept_alive_multiparts[reply] + + def _validateManager(self) -> None: + if self._manager is None: + self._createNetworkManager() + assert (self._manager is not None) + + def put(self, target: str, data: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None: + self._validateManager() + request = self._createEmptyRequest(target) + self._last_request_time = time() + if self._manager is not None: + reply = self._manager.put(request, data.encode()) + self._registerOnFinishedCallback(reply, on_finished) + else: + Logger.log("e", "Could not find manager.") + + def delete(self, target: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None: + self._validateManager() + request = self._createEmptyRequest(target) + self._last_request_time = time() + if self._manager is not None: + reply = self._manager.deleteResource(request) + self._registerOnFinishedCallback(reply, on_finished) + else: + Logger.log("e", "Could not find manager.") + + def get(self, target: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None: + self._validateManager() + request = self._createEmptyRequest(target) + self._last_request_time = time() + if self._manager is not None: + reply = self._manager.get(request) + self._registerOnFinishedCallback(reply, on_finished) + else: + Logger.log("e", "Could not find manager.") + + def post(self, target: str, data: str, on_finished: Optional[Callable[[QNetworkReply], None]], on_progress: Callable = None) -> None: + self._validateManager() + request = self._createEmptyRequest(target) + self._last_request_time = time() + if self._manager is not None: + reply = self._manager.post(request, data.encode()) + if on_progress is not None: + reply.uploadProgress.connect(on_progress) + self._registerOnFinishedCallback(reply, on_finished) + else: + Logger.log("e", "Could not find manager.") + + def postFormWithParts(self, target: str, parts: List[QHttpPart], on_finished: Optional[Callable[[QNetworkReply], None]], on_progress: Callable = None) -> QNetworkReply: + self._validateManager() + request = self._createEmptyRequest(target, content_type=None) + multi_post_part = QHttpMultiPart(QHttpMultiPart.FormDataType) + for part in parts: + multi_post_part.append(part) + + self._last_request_time = time() + + if self._manager is not None: + reply = self._manager.post(request, multi_post_part) + + self._kept_alive_multiparts[reply] = multi_post_part + + if on_progress is not None: + reply.uploadProgress.connect(on_progress) + self._registerOnFinishedCallback(reply, on_finished) + + return reply + else: + Logger.log("e", "Could not find manager.") + + def postForm(self, target: str, header_data: str, body_data: bytes, on_finished: Optional[Callable[[QNetworkReply], None]], on_progress: Callable = None) -> None: + post_part = QHttpPart() + post_part.setHeader(QNetworkRequest.ContentDispositionHeader, header_data) + post_part.setBody(body_data) + + self.postFormWithParts(target, [post_part], on_finished, on_progress) + + def _onAuthenticationRequired(self, reply: QNetworkReply, authenticator: QAuthenticator) -> None: + Logger.log("w", "Request to {url} required authentication, which was not implemented".format(url = reply.url().toString())) + + def _createNetworkManager(self) -> None: + Logger.log("d", "Creating network manager") + if self._manager: + self._manager.finished.disconnect(self.__handleOnFinished) + self._manager.authenticationRequired.disconnect(self._onAuthenticationRequired) + + self._manager = QNetworkAccessManager() + self._manager.finished.connect(self.__handleOnFinished) + self._last_manager_create_time = time() + self._manager.authenticationRequired.connect(self._onAuthenticationRequired) + + if self._properties.get(b"temporary", b"false") != b"true": + CuraApplication.getInstance().getMachineManager().checkCorrectGroupName(self.getId(), self.name) + + def _registerOnFinishedCallback(self, reply: QNetworkReply, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None: + if on_finished is not None: + self._onFinishedCallbacks[reply.url().toString() + str(reply.operation())] = on_finished + + def __handleOnFinished(self, reply: QNetworkReply) -> None: + # Due to garbage collection, we need to cache certain bits of post operations. + # As we don't want to keep them around forever, delete them if we get a reply. + if reply.operation() == QNetworkAccessManager.PostOperation: + self._clearCachedMultiPart(reply) + + if reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) is None: + # No status code means it never even reached remote. + return + + self._last_response_time = time() + + if self._connection_state == ConnectionState.connecting: + self.setConnectionState(ConnectionState.connected) + + callback_key = reply.url().toString() + str(reply.operation()) + try: + if callback_key in self._onFinishedCallbacks: + self._onFinishedCallbacks[callback_key](reply) + except Exception: + Logger.logException("w", "something went wrong with callback") + + @pyqtSlot(str, result=str) def getProperty(self, key: str) -> str: bytes_key = key.encode("utf-8") if bytes_key in self._properties: @@ -176,16 +328,7 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice, NetworkClient): def printerType(self) -> str: return self._properties.get(b"printer_type", b"Unknown").decode("utf-8") - ## IP address of this printer + ## IP adress of this printer @pyqtProperty(str, constant = True) def ipAddress(self) -> str: return self._address - - def __handleOnFinished(self, reply: QNetworkReply) -> None: - - # Since we got a 200 reply from the network manager we can now be sure we are actually connected. - if reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) == 200 and \ - self._connection_state == ConnectionState.connecting: - self.setConnectionState(ConnectionState.connected) - - super().__handleOnFinished(reply) From 42ccabc7b6426307fe370abbf5a7312d267353ae Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Mon, 26 Nov 2018 15:09:56 +0100 Subject: [PATCH 024/178] Fix relative imports for plugin --- plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py | 4 ++-- .../UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index 8f0bd62035..93e97a7c71 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -13,8 +13,8 @@ from UM.Scene.SceneNode import SceneNode from cura.CuraApplication import CuraApplication from cura.PrinterOutput.NetworkedPrinterOutputDevice import NetworkedPrinterOutputDevice, AuthState from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel -from plugins.UM3NetworkPrinting.src.Cloud.CloudOutputController import CloudOutputController -from plugins.UM3NetworkPrinting.src.UM3PrintJobOutputModel import UM3PrintJobOutputModel +from .CloudOutputController import CloudOutputController +from ..UM3PrintJobOutputModel import UM3PrintJobOutputModel ## The cloud output device is a network output device that works remotely but has limited functionality. diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 8efce87094..a252f9e4d3 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -8,7 +8,8 @@ from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply from UM.Logger import Logger from cura.CuraApplication import CuraApplication from cura.NetworkClient import NetworkClient -from plugins.UM3NetworkPrinting.src.Cloud.CloudOutputDevice import CloudOutputDevice + +from .CloudOutputDevice import CloudOutputDevice from .Models import CloudCluster From aaf0f69820ee06f8c2a8646328c68b6e3b7e6f3e Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Mon, 26 Nov 2018 15:10:36 +0100 Subject: [PATCH 025/178] Fix some more relative imports --- plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py | 1 + plugins/UM3NetworkPrinting/src/Cloud/Models.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index 93e97a7c71..accc8429b1 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -13,6 +13,7 @@ from UM.Scene.SceneNode import SceneNode from cura.CuraApplication import CuraApplication from cura.PrinterOutput.NetworkedPrinterOutputDevice import NetworkedPrinterOutputDevice, AuthState from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel + from .CloudOutputController import CloudOutputController from ..UM3PrintJobOutputModel import UM3PrintJobOutputModel diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models.py b/plugins/UM3NetworkPrinting/src/Cloud/Models.py index 3cbfecadfb..e98d848d51 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models.py @@ -1,6 +1,6 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from plugins.UM3NetworkPrinting.src.Models import BaseModel +from ..Models import BaseModel ## Class representing a cloud connected cluster. From fb019ba987fc3542a178c6ff926d4fc11a115043 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marijn=20De=C3=A9?= Date: Tue, 27 Nov 2018 14:48:47 +0100 Subject: [PATCH 026/178] In CloudOutputDeviceManager start a thread to periodically check for changes in the connected clusters --- .../src/Cloud/CloudOutputDevice.py | 1 + .../src/Cloud/CloudOutputDeviceManager.py | 51 ++++++++++++++----- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index accc8429b1..79a3d46949 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -30,6 +30,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): I18N_CATALOG = i18nCatalog("cura") # The cloud URL to use for this remote cluster. + # TODO: Make sure that this url goes to the live api before release API_ROOT_PATH_FORMAT = "https://api-staging.ultimaker.com/connect/v1/clusters/{cluster_id}" # Signal triggered when the printers in the remote cluster were changed. diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index a252f9e4d3..546b9b270f 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -1,6 +1,8 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. import json +from time import sleep +from threading import Thread from typing import Dict, Optional, List from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply @@ -24,6 +26,9 @@ class CloudOutputDeviceManager(NetworkClient): # The cloud URL to use for remote clusters. API_ROOT_PATH = "https://api.ultimaker.com/connect/v1" + + # The interval with wich the remote clusters are checked + CHECK_CLUSTER_INTERVAL = 5 # seconds def __init__(self): super().__init__() @@ -42,6 +47,11 @@ class CloudOutputDeviceManager(NetworkClient): # TODO: update remote clusters periodically self._account.loginStateChanged.connect(self._getRemoteClusters) + # Periodically check the cloud for an update on the clusters connected to the user's account + self._update_clusters_thread = Thread(target=self._updateClusters, daemon=True) + self._update_clusters_thread.start() + + ## Override _createEmptyRequest to add the needed authentication header for talking to the Ultimaker Cloud API. def _createEmptyRequest(self, path: str, content_type: Optional[str] = "application/json") -> QNetworkRequest: request = super()._createEmptyRequest(self.API_ROOT_PATH + path, content_type = content_type) @@ -50,6 +60,12 @@ class CloudOutputDeviceManager(NetworkClient): # TODO: don't create the client when not signed in? request.setRawHeader(b"Authorization", "Bearer {}".format(self._account.accessToken).encode()) return request + + ## Update the clusters + def _updateClusters(self) -> None: + while True: + self._getRemoteClusters() + sleep(self.CHECK_CLUSTER_INTERVAL) ## Gets all remote clusters from the API. def _getRemoteClusters(self) -> None: @@ -64,36 +80,47 @@ class CloudOutputDeviceManager(NetworkClient): return # Parse the response (returns the "data" field from the body). - clusters = self._parseStatusResponse(reply) - if not clusters: + found_clusters = self._parseStatusResponse(reply) + if not found_clusters: return - - # Add an output device for each remote cluster. - # The clusters are an array of objects in a field called "data". - for cluster in clusters: - self._addCloudOutputDevice(cluster) - + + known_cluster_ids = set(self._remote_clusters.keys()) + found_clusters_ids = set(found_clusters.keys()) + + # Add an output device for each new remote cluster. + for cluster_id in found_clusters_ids.difference(known_cluster_ids): + self._addCloudOutputDevice(found_clusters[cluster_id]) + + # Remove output devices that are gone + for cluster_id in known_cluster_ids.difference(found_clusters_ids): + self._removeCloudOutputDevice(found_clusters[cluster_id]) + # For testing we add a dummy device: # self._addCloudOutputDevice(CloudCluster(cluster_id = "LJ0tciiuZZjarrXAvFLEZ6ox4Cvx8FvtXUlQv4vIhV6w")) @staticmethod - def _parseStatusResponse(reply: QNetworkReply) -> List[CloudCluster]: + def _parseStatusResponse(reply: QNetworkReply) -> Dict[str, CloudCluster]: try: - return [CloudCluster(**c) for c in json.loads(reply.readAll().data().decode("utf-8"))] + return {c["guid"]: CloudCluster(**c) for c in json.loads(reply.readAll().data().decode("utf-8"))} except UnicodeDecodeError: Logger.log("w", "Unable to read server response") except json.decoder.JSONDecodeError: Logger.logException("w", "Unable to decode JSON from reply.") except ValueError: Logger.logException("w", "Response was missing values.") - return [] + return {} ## Adds a CloudOutputDevice for each entry in the remote cluster list from the API. def _addCloudOutputDevice(self, cluster: CloudCluster): device = CloudOutputDevice(cluster.cluster_id) self._output_device_manager.addOutputDevice(device) self._remote_clusters[cluster.cluster_id] = device - + + ## Remove a CloudOutputDevice + def _removeCloudOutputDevice(self, cluster: CloudCluster): + self._output_device_manager.removeOutputDevice(cluster.cluster_id) + del self._remote_clusters[cluster.cluster_id] + ## Callback for when the active machine was changed by the user. def _activeMachineChanged(self): active_machine = CuraApplication.getInstance().getGlobalContainerStack() From 172e003e1b65650d7f8150f5c6077f97f0c46685 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marijn=20De=C3=A9?= Date: Tue, 27 Nov 2018 14:53:23 +0100 Subject: [PATCH 027/178] Removed the inital call to clusters --- .../src/Cloud/CloudOutputDeviceManager.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 546b9b270f..3ebaeea9a4 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -43,11 +43,7 @@ class CloudOutputDeviceManager(NetworkClient): # When switching machines we check if we have to activate a remote cluster. application.globalContainerStackChanged.connect(self._activeMachineChanged) - # Fetch all remote clusters for the authenticated user. - # TODO: update remote clusters periodically - self._account.loginStateChanged.connect(self._getRemoteClusters) - - # Periodically check the cloud for an update on the clusters connected to the user's account + # Periodically check all remote clusters for the authenticated user. self._update_clusters_thread = Thread(target=self._updateClusters, daemon=True) self._update_clusters_thread.start() From fd4d1113b9c45d697d817c53d64aab9be6c19a3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marijn=20De=C3=A9?= Date: Tue, 27 Nov 2018 15:42:26 +0100 Subject: [PATCH 028/178] use right plural in variable name --- .../src/Cloud/CloudOutputDeviceManager.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 3ebaeea9a4..97b0787c73 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -81,14 +81,14 @@ class CloudOutputDeviceManager(NetworkClient): return known_cluster_ids = set(self._remote_clusters.keys()) - found_clusters_ids = set(found_clusters.keys()) + found_cluster_ids = set(found_clusters.keys()) # Add an output device for each new remote cluster. - for cluster_id in found_clusters_ids.difference(known_cluster_ids): + for cluster_id in found_cluster_ids.difference(known_cluster_ids): self._addCloudOutputDevice(found_clusters[cluster_id]) # Remove output devices that are gone - for cluster_id in known_cluster_ids.difference(found_clusters_ids): + for cluster_id in known_cluster_ids.difference(found_cluster_ids): self._removeCloudOutputDevice(found_clusters[cluster_id]) # For testing we add a dummy device: @@ -122,6 +122,11 @@ class CloudOutputDeviceManager(NetworkClient): active_machine = CuraApplication.getInstance().getGlobalContainerStack() if not active_machine: return + + local_device_id = active_machine.getMetaDataEntry("um_network_key") + if local_device_id: + active_output_device = CuraApplication.getInstance().getOutputDeviceManager().getActiveDevice() + active_output_device.id stored_cluster_id = active_machine.getMetaDataEntry("um_cloud_cluster_id") if stored_cluster_id not in self._remote_clusters.keys(): From 26f107d1763387848e4faf9b7b9726ac9cee90f9 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Tue, 27 Nov 2018 17:03:19 +0100 Subject: [PATCH 029/178] Use cluster_id as dict key, remove unneeded white space --- .../src/Cloud/CloudOutputDeviceManager.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 3ebaeea9a4..e20a658994 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -47,7 +47,6 @@ class CloudOutputDeviceManager(NetworkClient): self._update_clusters_thread = Thread(target=self._updateClusters, daemon=True) self._update_clusters_thread.start() - ## Override _createEmptyRequest to add the needed authentication header for talking to the Ultimaker Cloud API. def _createEmptyRequest(self, path: str, content_type: Optional[str] = "application/json") -> QNetworkRequest: request = super()._createEmptyRequest(self.API_ROOT_PATH + path, content_type = content_type) @@ -81,14 +80,14 @@ class CloudOutputDeviceManager(NetworkClient): return known_cluster_ids = set(self._remote_clusters.keys()) - found_clusters_ids = set(found_clusters.keys()) + found_cluster_ids = set(found_clusters.keys()) # Add an output device for each new remote cluster. - for cluster_id in found_clusters_ids.difference(known_cluster_ids): + for cluster_id in found_cluster_ids.difference(known_cluster_ids): self._addCloudOutputDevice(found_clusters[cluster_id]) # Remove output devices that are gone - for cluster_id in known_cluster_ids.difference(found_clusters_ids): + for cluster_id in known_cluster_ids.difference(found_cluster_ids): self._removeCloudOutputDevice(found_clusters[cluster_id]) # For testing we add a dummy device: @@ -97,7 +96,7 @@ class CloudOutputDeviceManager(NetworkClient): @staticmethod def _parseStatusResponse(reply: QNetworkReply) -> Dict[str, CloudCluster]: try: - return {c["guid"]: CloudCluster(**c) for c in json.loads(reply.readAll().data().decode("utf-8"))} + return {c["cluster_id"]: CloudCluster(**c) for c in json.loads(reply.readAll().data().decode("utf-8"))} except UnicodeDecodeError: Logger.log("w", "Unable to read server response") except json.decoder.JSONDecodeError: From 763291821f9b8afa3af231a7346fc56231619625 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marijn=20De=C3=A9?= Date: Thu, 29 Nov 2018 17:03:11 +0100 Subject: [PATCH 030/178] Added models to process the data from the api results Added code to update the UI models --- .../src/Cloud/CloudOutputDevice.py | 208 +++++++++++++++--- .../src/Cloud/CloudOutputDeviceManager.py | 6 +- .../UM3NetworkPrinting/src/Cloud/Models.py | 63 ++++++ 3 files changed, 246 insertions(+), 31 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index 79a3d46949..06e5656392 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -1,18 +1,23 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. import json +import os from typing import List, Optional, Dict -from PyQt5.QtCore import QObject, pyqtProperty, pyqtSignal, QUrl +from PyQt5.QtCore import QObject, pyqtSignal, QUrl, pyqtProperty from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest from UM import i18nCatalog from UM.FileHandler.FileHandler import FileHandler from UM.Logger import Logger from UM.Scene.SceneNode import SceneNode +from UM.Settings import ContainerRegistry from cura.CuraApplication import CuraApplication +from cura.PrinterOutput import PrinterOutputController, PrintJobOutputModel +from cura.PrinterOutput.MaterialOutputModel import MaterialOutputModel from cura.PrinterOutput.NetworkedPrinterOutputDevice import NetworkedPrinterOutputDevice, AuthState from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel +from .Models import CloudClusterPrinter, CloudClusterPrinterConfiguration, CloudClusterPrinterConfigurationMaterial, CloudClusterPrintJob, CloudClusterPrintJobConstraint from .CloudOutputController import CloudOutputController from ..UM3PrintJobOutputModel import UM3PrintJobOutputModel @@ -38,17 +43,23 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): # Signal triggered when the print jobs in the queue were changed. printJobsChanged = pyqtSignal() - + def __init__(self, device_id: str, parent: QObject = None): super().__init__(device_id = device_id, address = "", properties = {}, parent = parent) self._setInterfaceElements() self._device_id = device_id self._account = CuraApplication.getInstance().getCuraAPI().account + + # We re-use the Cura Connect monitor tab to get the most functionality right away. + self._monitor_view_qml_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), + "../../resources/qml/ClusterMonitorItem.qml") + self._control_view_qml_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), + "../../resources/qml/ClusterControlItem.qml") # Properties to populate later on with received cloud data. - self._printers = [] - self._print_jobs = [] + self._printers = {} # type: Dict[str, PrinterOutputModel] + self._print_jobs = {} # type: Dict[str, PrintJobOutputModel] self._number_of_extruders = 2 # All networked printers are dual-extrusion Ultimaker machines. ## We need to override _createEmptyRequest to work for the cloud. @@ -90,8 +101,8 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): ## Get remote print jobs. @pyqtProperty("QVariantList", notify = printJobsChanged) - def printJobs(self) -> List[UM3PrintJobOutputModel]: - return self._print_jobs + def queuedPrintJobs(self) -> List[UM3PrintJobOutputModel]: + return [print_job for print_job in self._print_jobs if print_job.state == "queued" or print_job.state == "error"] ## Called when the connection to the cluster changes. def connect(self) -> None: @@ -111,41 +122,182 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): .format(status_code, reply.readAll())) return - data = self._parseStatusResponse(reply) - if data is None: + printers, print_jobs = self._parseStatusResponse(reply) + if not printers and not print_jobs: return # Update all data from the cluster. - self._updatePrinters(data.get("printers", [])) - self._updatePrintJobs(data.get("print_jobs", [])) + self._updatePrinters(printers) + self._updatePrintJobs(print_jobs) @staticmethod - def _parseStatusResponse(reply: QNetworkReply) -> Optional[dict]: + def _parseStatusResponse(reply: QNetworkReply): # Optional[(CloudClusterPrinter, CloudClusterPrintJob)] doesn't work + + printers = [] + print_jobs = [] + s = '' try: - result = json.loads(bytes(reply.readAll()).decode("utf-8")) - # TODO: use model or named tuple here. - return result + s = json.loads(bytes(reply.readAll()).decode("utf-8")) + + for p in s["printers"]: + printer = CloudClusterPrinter(**p) + configuration = printer.configuration + printer.configuration = [] + for c in configuration: + extruder = CloudClusterPrinterConfiguration(**c) + extruder.material = CloudClusterPrinterConfigurationMaterial(extruder.material) + printer.configuration.append(extruder) + + printers.append(printer) + + for j in s["print_jobs"]: + job = CloudClusterPrintJob(**j) + constraints = job.constraints + job.constraints = [] + for c in constraints: + job.constraints.append(CloudClusterPrintJobConstraint(**c)) + + configuration = job.configuration + job.configuration = [] + for c in configuration: + configuration = CloudClusterPrinterConfiguration(**c) + configuration.material = CloudClusterPrinterConfigurationMaterial(configuration.material) + job.configuration.append(configuration) + + print_jobs.append(job) + except json.decoder.JSONDecodeError: Logger.logException("w", "Unable to decode JSON from reply.") return None - def _updatePrinters(self, remote_printers: List[Dict[str, any]]) -> None: - # TODO: use model or tuple for remote_printers data - for printer in remote_printers: - - # If the printer does not exist yet, create it. - if not self._getPrinterByKey(printer["uuid"]): - self._printers.append(PrinterOutputModel( - output_controller = CloudOutputController(self), - number_of_extruders = self._number_of_extruders - )) - + return printers, print_jobs + + def _updatePrinters(self, printers: List[CloudClusterPrinter]) -> None: + remote_printers = {p.uuid: p for p in printers} + + removed_printers = set(self._printers.keys()).difference(set(remote_printers.keys())) + new_printers = set(remote_printers.keys()).difference(set(self._printers.keys())) + updated_printers = set(self._printers.keys()).intersection(set(remote_printers.keys())) + + for p in removed_printers: + self._removePrinter(p) + + for p in new_printers: + self._addPrinter(printers[p]) + self._updatePrinter(printers[p]) + + for p in updated_printers: + self._updatePrinter(printers[p]) + # TODO: properly handle removed and updated printers self.printersChanged.emit() - def _updatePrintJobs(self, remote_print_jobs: List[Dict[str, any]]) -> None: - # TODO: use model or tuple for remote_print_jobs data - pass + + def _addPrinter(self, printer): + self._printers[printer.uuid] = self._createPrinterOutputModel(self, printer) + + def _createPrinterOutputModel(self, printer: CloudClusterPrinter) -> PrinterOutputModel: + return PrinterOutputModel(PrinterOutputController(self), len(printer.configuration), + firmware_version=printer.firmware_version) + + def _updatePrinter(self, guid : str, printer : CloudClusterPrinter): + model = self._printers[guid] + self._printers[guid] = self._updatePrinterOutputModel(self, printer) + + def _updatePrinterOutputModel(self, printer: CloudClusterPrinter, model : PrinterOutputModel) -> PrinterOutputModel: + model.updateKey(printer.uuid) + model.updateName(printer.friendly_name) + model.updateType(printer.machine_variant) + model.updateState(printer.status if printer.enabled else "disabled") + + for index in range(0, len(printer.configuration)): + try: + extruder = model.extruders[index] + extruder_data = printer.configuration[index] + except IndexError: + break + + extruder.updateHotendID(extruder_data.print_core_id) + + material_data = extruder_data.material + if extruder.activeMaterial is None or extruder.activeMaterial.guid != material.guid: + material = self._createMaterialOutputModel(material_data) + extruder.updateActiveMaterial(material) + + def _createMaterialOutputModel(self, material: CloudClusterPrinterConfigurationMaterial) -> MaterialOutputModel: + material_manager = CuraApplication.getInstance().getMaterialManager() + material_group_list = material_manager.getMaterialGroupListByGUID(material.guid) or [] + + # Sort the material groups by "is_read_only = True" first, and then the name alphabetically. + read_only_material_group_list = list(filter(lambda x: x.is_read_only, material_group_list)) + non_read_only_material_group_list = list(filter(lambda x: not x.is_read_only, material_group_list)) + material_group = None + if read_only_material_group_list: + read_only_material_group_list = sorted(read_only_material_group_list, key=lambda x: x.name) + material_group = read_only_material_group_list[0] + elif non_read_only_material_group_list: + non_read_only_material_group_list = sorted(non_read_only_material_group_list, key=lambda x: x.name) + material_group = non_read_only_material_group_list[0] + + if material_group: + container = material_group.root_material_node.getContainer() + color = container.getMetaDataEntry("color_code") + brand = container.getMetaDataEntry("brand") + material_type = container.getMetaDataEntry("material") + name = container.getName() + else: + Logger.log("w", + "Unable to find material with guid {guid}. Using data as provided by cluster".format( + guid=material.guid)) + color = material.color + brand = material.brand + material_type = material.material + name = "Empty" if material.material == "empty" else "Unknown" + + return MaterialOutputModel(guid=material.guid, type=material_type, brand=brand, color=color, name=name) + + + def _removePrinter(self, guid): + del self._printers[guid] + + def _updatePrintJobs(self, jobs: List[CloudClusterPrintJob]) -> None: + remote_jobs = {j.uuid: j for j in jobs} + + removed_jobs = set(self._print_jobs.keys()).difference(set(remote_jobs.keys())) + new_jobs = set(remote_jobs.keys()).difference(set(self._print_jobs.keys())) + updated_jobs = set(self._print_jobs.keys()).intersection(set(remote_jobs.keys())) + + for j in removed_jobs: + self._removePrintJob(j) + + for j in new_jobs: + self._addPrintJob(jobs[j]) + + for j in updated_jobs: + self._updatePrintJob(jobs[j]) + + # TODO: properly handle removed and updated printers + self.printJobsChanged() + + def _addPrintJob(self, job: CloudClusterPrintJob): + self._print_jobs[job.uuid] = self._createPrintJobOutputModel(job) + + def _createPrintJobOutputModel(self, job:CloudClusterPrintJob) -> PrintJobOutputModel: + controller = self._printers[job.printer_uuid]._controller # TODO: Can we access this property? + model = PrintJobOutputModel(controller, job.uuid, job.name) + assigned_printer = self._printes[job.printer_uuid] # TODO: Or do we have to use the assigned_to field? + model.updateAssignedPrinter(assigned_printer) + + def _updatePrintJobOutputModel(self, guid: str, job:CloudClusterPrintJob): + model =self._print_jobs[guid] + + model.updateTimeTotal(job.time_total) + model.updateTimeElapsed(job.time_elapsed) + model.updateOwner(job.owner) + model.updateState(job.status) + + def _removePrintJob(self, guid:str): + del self._print_jobs[guid] def _addPrintJobToQueue(self): # TODO: implement this diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 4de7263df1..f6542e3c76 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -124,9 +124,9 @@ class CloudOutputDeviceManager(NetworkClient): local_device_id = active_machine.getMetaDataEntry("um_network_key") if local_device_id: - active_output_device = CuraApplication.getInstance().getOutputDeviceManager().getActiveDevice() - active_output_device.id - + active_output_device = self._output_device_manager.getActiveDevice() + # We must find a match for the active machine and a cloud device + stored_cluster_id = active_machine.getMetaDataEntry("um_cloud_cluster_id") if stored_cluster_id not in self._remote_clusters.keys(): # Currently authenticated user does not have access to stored cluster or no user is signed in. diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models.py b/plugins/UM3NetworkPrinting/src/Cloud/Models.py index e98d848d51..7d6db9c8c0 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models.py @@ -16,3 +16,66 @@ class CloudCluster(BaseModel): def validate(self): if not self.cluster_id: raise ValueError("cluster_id is required on CloudCluster") + + +## Class representing a cloud cluster printer configuration +class CloudClusterPrinterConfigurationMaterial(BaseModel): + def __init__(self, **kwargs): + self.guid = None # type: str + self.brand = None # type: str + self.color = None # type: str + self.material = None # type: str + super().__init__(**kwargs) + + +## Class representing a cloud cluster printer configuration +class CloudClusterPrinterConfiguration(BaseModel): + def __init__(self, **kwargs): + self.extruder_index = None # type: str + self.material = None # type: CloudClusterPrinterConfigurationMaterial + self.nozzle_diameter = None # type: str + self.printer_core_id = None # type: str + super().__init__(**kwargs) + + +## Class representing a cluster printer +class CloudClusterPrinter(BaseModel): + def __init__(self, **kwargs): + self.configuration = None # type: CloudClusterPrinterConfiguration + self.enabled = None # type: str + self.firmware_version = None # type: str + self.friendly_name = None # type: str + self.ip_address = None # type: str + self.machine_variant = None # type: str + self.status = None # type: str + self.unique_name = None # type: str + self.uuid = None # type: str + super().__init__(**kwargs) + + +## Class representing a cloud cluster print job constraint +class CloudClusterPrintJobConstraint(BaseModel): + def __init__(self, **kwargs): + self.require_printer_name: None # type: str + super().__init__(**kwargs) + +## Class representing a print job +class CloudClusterPrintJob(BaseModel): + def __init__(self, **kwargs): + self.assigned_to = None # type: str + self.configuration = None # type: str + self.constraints = None # type: str + self.created_at = None # type: str + self.force = None # type: str + self.last_seen = None # type: str + self.machine_variant = None # type: str + self.name = None # type: str + self.network_error_count = None # type: str + self.owner = None # type: str + self.printer_uuid = None # type: str + self.started = None # type: str + self.status = None # type: str + self.time_elapsed = None # type: str + self.time_total = None # type: str + self.uuid = None # type: str + super().__init__(**kwargs) From 8b42b8461846a7a11cb06bf2e984c8bf15c88750 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marijn=20De=C3=A9?= Date: Fri, 30 Nov 2018 16:07:20 +0100 Subject: [PATCH 031/178] Made a start with uploading jobs to the printer --- .../src/Cloud/CloudOutputDevice.py | 122 ++++++++++++++++-- .../UM3NetworkPrinting/src/Cloud/Models.py | 68 ++++++---- 2 files changed, 151 insertions(+), 39 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index 06e5656392..5d2d140704 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -1,25 +1,27 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. +import io import json import os -from typing import List, Optional, Dict +from typing import List, Optional, Dict, cast from PyQt5.QtCore import QObject, pyqtSignal, QUrl, pyqtProperty from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest from UM import i18nCatalog +from UM.FileHandler import FileWriter from UM.FileHandler.FileHandler import FileHandler from UM.Logger import Logger +from UM.OutputDevice import OutputDeviceError from UM.Scene.SceneNode import SceneNode -from UM.Settings import ContainerRegistry +from UM.Version import Version from cura.CuraApplication import CuraApplication from cura.PrinterOutput import PrinterOutputController, PrintJobOutputModel from cura.PrinterOutput.MaterialOutputModel import MaterialOutputModel from cura.PrinterOutput.NetworkedPrinterOutputDevice import NetworkedPrinterOutputDevice, AuthState from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel -from .Models import CloudClusterPrinter, CloudClusterPrinterConfiguration, CloudClusterPrinterConfigurationMaterial, CloudClusterPrintJob, CloudClusterPrintJobConstraint - -from .CloudOutputController import CloudOutputController +from .Models import CloudClusterPrinter, CloudClusterPrinterConfiguration, CloudClusterPrinterConfigurationMaterial, \ + CloudClusterPrintJob, CloudClusterPrintJobConstraint, JobUploadRequest from ..UM3PrintJobOutputModel import UM3PrintJobOutputModel @@ -36,8 +38,11 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): # The cloud URL to use for this remote cluster. # TODO: Make sure that this url goes to the live api before release - API_ROOT_PATH_FORMAT = "https://api-staging.ultimaker.com/connect/v1/clusters/{cluster_id}" - + ROOT_PATH= "https://api-staging.ultimaker.com" + CLUSTER_API_ROOT = "{}/connect/v1/".format(ROOT_PATH) + CURA_API_ROOT = "{}/cura/v1/".format(ROOT_PATH) + CURA_DRIVE_API_ROOT = "{}/cura-drive/v1/".format(ROOT_PATH) + # Signal triggered when the printers in the remote cluster were changed. printersChanged = pyqtSignal() @@ -64,7 +69,8 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): ## We need to override _createEmptyRequest to work for the cloud. def _createEmptyRequest(self, path: str, content_type: Optional[str] = "application/json") -> QNetworkRequest: - url = QUrl(self.API_ROOT_PATH_FORMAT.format(cluster_id = self._device_id) + path) + #url = QUrl(self.CLUSTER_API_ROOT_PATH_FORMAT.format(cluster_id = self._device_id) + path) + url = QUrl(path) request = QNetworkRequest(url) request.setHeader(QNetworkRequest.ContentTypeHeader, content_type) request.setHeader(QNetworkRequest.UserAgentHeader, self._user_agent) @@ -92,7 +98,72 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mime_types: bool = False, file_handler: Optional[FileHandler] = None, **kwargs: str) -> None: self.writeStarted.emit(self) - self._addPrintJobToQueue() + file_format = self._determineFileFormat(file_handler) + writer = self._determineWriter(file_format) + + # This function pauses with the yield, waiting on instructions on which printer it needs to print with. + if not writer: + Logger.log("e", "Missing file or mesh writer!") + return + + stream = io.BytesIO() # type: Union[io.BytesIO, io.StringIO]# Binary mode. + if file_format["mode"] == FileWriter.OutputMode.TextMode: + stream = io.StringIO() + + writer.write(stream, nodes) + + stream.seek(0, io.SEEK_END) + size = stream.tell() + stream.seek(0, io.SEEK_SET) + + request = JobUploadRequest() + request.job_name = file_name + request.file_size = size + + self._addPrintJobToQueue(stream, request) + + # TODO: This is yanked right out of ClusterUM3OoutputDevice, great candidate for a utility or base class + def _determineFileFormat(self, file_handler) -> None: + # Formats supported by this application (file types that we can actually write). + if file_handler: + file_formats = file_handler.getSupportedFileTypesWrite() + else: + file_formats = CuraApplication.getInstance().getMeshFileHandler().getSupportedFileTypesWrite() + + global_stack = CuraApplication.getInstance().getGlobalContainerStack() + # Create a list from the supported file formats string. + if not global_stack: + Logger.log("e", "Missing global stack!") + return + + machine_file_formats = global_stack.getMetaDataEntry("file_formats").split(";") + machine_file_formats = [file_type.strip() for file_type in machine_file_formats] + # Exception for UM3 firmware version >=4.4: UFP is now supported and should be the preferred file format. + if "application/x-ufp" not in machine_file_formats and Version(self.firmwareVersion) >= Version("4.4"): + machine_file_formats = ["application/x-ufp"] + machine_file_formats + + # Take the intersection between file_formats and machine_file_formats. + format_by_mimetype = {format["mime_type"]: format for format in file_formats} + file_formats = [format_by_mimetype[mimetype] for mimetype in machine_file_formats] #Keep them ordered according to the preference in machine_file_formats. + + if len(file_formats) == 0: + Logger.log("e", "There are no file formats available to write with!") + raise OutputDeviceError.WriteRequestFailedError(self.I18N_CATALOG.i18nc("@info:status", "There are no file formats available to write with!")) + return file_formats[0] + + # TODO: This is yanked right out of ClusterUM3OoutputDevice, great candidate for a utility or base class + def _determineWriter(self, file_handler, file_format): + # Just take the first file format available. + if file_handler is not None: + writer = file_handler.getWriterByMimeType(cast(str, file_format["mime_type"])) + else: + writer = CuraApplication.getInstance().getMeshFileHandler().getWriterByMimeType(cast(str, file_format["mime_type"])) + + if not writer: + Logger.log("e", "Unexpected error when trying to get the FileWriter") + return + + return writer ## Get remote printers. @pyqtProperty("QVariantList", notify = printersChanged) @@ -111,7 +182,9 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): ## Called when the network data should be updated. def _update(self) -> None: super()._update() - self.get("/status", on_finished = self._onStatusCallFinished) + self.get("{root}/cluster/{cluster_id}/status".format(self.CLUSTER_API_ROOT, self._device_id), + on_finished = self._onStatusCallFinished) + ## Method called when HTTP request to status endpoint is finished. # Contains both printers and print jobs statuses in a single response. @@ -201,7 +274,6 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): firmware_version=printer.firmware_version) def _updatePrinter(self, guid : str, printer : CloudClusterPrinter): - model = self._printers[guid] self._printers[guid] = self._updatePrinterOutputModel(self, printer) def _updatePrinterOutputModel(self, printer: CloudClusterPrinter, model : PrinterOutputModel) -> PrinterOutputModel: @@ -299,6 +371,28 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): def _removePrintJob(self, guid:str): del self._print_jobs[guid] - def _addPrintJobToQueue(self): - # TODO: implement this - pass + def _addPrintJobToQueue(self, stream, request:JobUploadRequest): + self.put("{}/jobs/upload".format(self.CURA_API_ROOT), data = json.dumps(request.__dict__), + on_finished = lambda reply: self._onAddPrintJobToQueueFinished(stream, reply)) + + def _onAddPrintJobToQueueFinished(self, stream, reply: QNetworkReply) -> None: + s = json.loads(bytes(reply.readAll()).decode("utf-8")) + + self.put() + + # try: + # r = requests.put(self._job.output_url, data=data) + # if r.status_code == 200: + # Logger.log("d", "Finished writing %s to remote URL %s", "", self._job.output_url) + # self.onWriteSuccess.emit(r.text) + # else: + # Logger.log("d", "Error writing %s to remote URL %s", "", self._job.output_url) + # self.onWriteFailed.emit("Failed to export G-code to remote URL: {}".format(r.text)) + # except requests.ConnectionError as e: + # Logger.log("e", "There was a connection error when uploading the G-code to a remote URL: %s", e) + # self.onWriteFailed.emit("Failed to export G-code to remote URL: {}".format(e)) + # except requests.HTTPError as e: + # Logger.log("e", "There was an HTTP error when uploading the G-code to a remote URL: %s", e) + # self.onWriteFailed.emit("Failed to export G-code to remote URL: {}".format(e)) + + pass \ No newline at end of file diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models.py b/plugins/UM3NetworkPrinting/src/Cloud/Models.py index 7d6db9c8c0..86a48fb1f2 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models.py @@ -41,15 +41,15 @@ class CloudClusterPrinterConfiguration(BaseModel): ## Class representing a cluster printer class CloudClusterPrinter(BaseModel): def __init__(self, **kwargs): - self.configuration = None # type: CloudClusterPrinterConfiguration - self.enabled = None # type: str - self.firmware_version = None # type: str - self.friendly_name = None # type: str - self.ip_address = None # type: str - self.machine_variant = None # type: str - self.status = None # type: str - self.unique_name = None # type: str - self.uuid = None # type: str + self.configuration = None # type: CloudClusterPrinterConfiguration + self.enabled = None # type: str + self.firmware_version = None # type: str + self.friendly_name = None # type: str + self.ip_address = None # type: str + self.machine_variant = None # type: str + self.status = None # type: str + self.unique_name = None # type: str + self.uuid = None # type: str super().__init__(**kwargs) @@ -62,20 +62,38 @@ class CloudClusterPrintJobConstraint(BaseModel): ## Class representing a print job class CloudClusterPrintJob(BaseModel): def __init__(self, **kwargs): - self.assigned_to = None # type: str - self.configuration = None # type: str - self.constraints = None # type: str - self.created_at = None # type: str - self.force = None # type: str - self.last_seen = None # type: str - self.machine_variant = None # type: str - self.name = None # type: str - self.network_error_count = None # type: str - self.owner = None # type: str - self.printer_uuid = None # type: str - self.started = None # type: str - self.status = None # type: str - self.time_elapsed = None # type: str - self.time_total = None # type: str - self.uuid = None # type: str + self.assigned_to = None # type: str + self.configuration = None # type: str + self.constraints = None # type: str + self.created_at = None # type: str + self.force = None # type: str + self.last_seen = None # type: str + self.machine_variant = None # type: str + self.name = None # type: str + self.network_error_count = None # type: str + self.owner = None # type: str + self.printer_uuid = None # type: str + self.started = None # type: str + self.status = None # type: str + self.time_elapsed = None # type: str + self.time_total = None # type: str + self.uuid = None # type: str + super().__init__(**kwargs) + + +class JobUploadRequest(BaseModel): + def __init__(self, **kwargs): + self.file_size = None # type: int + self.job_name = None # type: str + super().__init__(**kwargs) + + +class JobUploadResponse(BaseModel): + def __init__(self, **kwargs): + self.download_url = None # type: str + self.job_id = None # type: str + self.job_name = None # type: str + self.slicing_details = None # type: str + self.status = None # type: str + self.upload_url = None # type: str super().__init__(**kwargs) From 8066074a2fbb71b3d124ad8966a3327298872ccb Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Mon, 3 Dec 2018 11:02:49 +0100 Subject: [PATCH 032/178] STAR-332: Fixing warnings --- .../NetworkedPrinterOutputDevice.py | 6 +- .../src/Cloud/CloudOutputDevice.py | 263 +++++++++--------- .../src/Cloud/CloudOutputDeviceManager.py | 2 +- .../UM3NetworkPrinting/src/Cloud/Models.py | 6 +- 4 files changed, 146 insertions(+), 131 deletions(-) diff --git a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py index 35d2ce014a..7125de4002 100644 --- a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py +++ b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py @@ -41,7 +41,8 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): self._api_prefix = "" self._address = address self._properties = properties - self._user_agent = "%s/%s " % (CuraApplication.getInstance().getApplicationName(), CuraApplication.getInstance().getVersion()) + self._user_agent = "%s/%s " % (CuraApplication.getInstance().getApplicationName(), + CuraApplication.getInstance().getVersion()) self._onFinishedCallbacks = {} # type: Dict[str, Callable[[QNetworkReply], None]] self._authentication_state = AuthState.NotAuthenticated @@ -55,7 +56,8 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): self._gcode = [] # type: List[str] self._connection_state_before_timeout = None # type: Optional[ConnectionState] - def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mimetypes: bool = False, file_handler: Optional[FileHandler] = None, **kwargs: str) -> None: + def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mimetypes: bool = False, + file_handler: Optional[FileHandler] = None, **kwargs: str) -> None: raise NotImplementedError("requestWrite needs to be implemented") def setAuthenticationState(self, authentication_state: AuthState) -> None: diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index 5d2d140704..008633e198 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -3,20 +3,20 @@ import io import json import os -from typing import List, Optional, Dict, cast +from typing import List, Optional, Dict, cast, Union from PyQt5.QtCore import QObject, pyqtSignal, QUrl, pyqtProperty from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest from UM import i18nCatalog -from UM.FileHandler import FileWriter +from UM.FileHandler.FileWriter import FileWriter from UM.FileHandler.FileHandler import FileHandler from UM.Logger import Logger from UM.OutputDevice import OutputDeviceError from UM.Scene.SceneNode import SceneNode from UM.Version import Version from cura.CuraApplication import CuraApplication -from cura.PrinterOutput import PrinterOutputController, PrintJobOutputModel +from cura.PrinterOutput.PrinterOutputController import PrinterOutputController from cura.PrinterOutput.MaterialOutputModel import MaterialOutputModel from cura.PrinterOutput.NetworkedPrinterOutputDevice import NetworkedPrinterOutputDevice, AuthState from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel @@ -38,7 +38,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): # The cloud URL to use for this remote cluster. # TODO: Make sure that this url goes to the live api before release - ROOT_PATH= "https://api-staging.ultimaker.com" + ROOT_PATH = "https://api-staging.ultimaker.com" CLUSTER_API_ROOT = "{}/connect/v1/".format(ROOT_PATH) CURA_API_ROOT = "{}/cura/v1/".format(ROOT_PATH) CURA_DRIVE_API_ROOT = "{}/cura-drive/v1/".format(ROOT_PATH) @@ -66,10 +66,26 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): self._printers = {} # type: Dict[str, PrinterOutputModel] self._print_jobs = {} # type: Dict[str, PrintJobOutputModel] self._number_of_extruders = 2 # All networked printers are dual-extrusion Ultimaker machines. - + + @staticmethod + def _parseReply(reply: QNetworkReply) -> Tuple[int, Union[None, str, bytes]]: + """ + Parses a reply from the stardust server. + :param reply: The reply received from the server. + :return: The status code and the response dict. + """ + status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) + response = None + try: + response = bytes(reply.readAll()).decode("utf-8") + response = json.loads(response) + except JSONDecodeError: + Logger.logException("w", "Unable to decode JSON from reply.") + return status_code, response + ## We need to override _createEmptyRequest to work for the cloud. def _createEmptyRequest(self, path: str, content_type: Optional[str] = "application/json") -> QNetworkRequest: - #url = QUrl(self.CLUSTER_API_ROOT_PATH_FORMAT.format(cluster_id = self._device_id) + path) + # noinspection PyArgumentList url = QUrl(path) request = QNetworkRequest(url) request.setHeader(QNetworkRequest.ContentTypeHeader, content_type) @@ -98,8 +114,9 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mime_types: bool = False, file_handler: Optional[FileHandler] = None, **kwargs: str) -> None: self.writeStarted.emit(self) + file_format = self._determineFileFormat(file_handler) - writer = self._determineWriter(file_format) + writer = self._determineWriter(file_handler, file_format) # This function pauses with the yield, waiting on instructions on which printer it needs to print with. if not writer: @@ -123,7 +140,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): self._addPrintJobToQueue(stream, request) # TODO: This is yanked right out of ClusterUM3OoutputDevice, great candidate for a utility or base class - def _determineFileFormat(self, file_handler) -> None: + def _determineFileFormat(self, file_handler) -> Optional[Dict[str, Union[str, int]]]: # Formats supported by this application (file types that we can actually write). if file_handler: file_formats = file_handler.getSupportedFileTypesWrite() @@ -143,21 +160,28 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): machine_file_formats = ["application/x-ufp"] + machine_file_formats # Take the intersection between file_formats and machine_file_formats. - format_by_mimetype = {format["mime_type"]: format for format in file_formats} - file_formats = [format_by_mimetype[mimetype] for mimetype in machine_file_formats] #Keep them ordered according to the preference in machine_file_formats. + format_by_mimetype = {f["mime_type"]: f for f in file_formats} + + # Keep them ordered according to the preference in machine_file_formats. + file_formats = [format_by_mimetype[mimetype] for mimetype in machine_file_formats] if len(file_formats) == 0: Logger.log("e", "There are no file formats available to write with!") - raise OutputDeviceError.WriteRequestFailedError(self.I18N_CATALOG.i18nc("@info:status", "There are no file formats available to write with!")) + raise OutputDeviceError.WriteRequestFailedError( + self.I18N_CATALOG.i18nc("@info:status", "There are no file formats available to write with!") + ) return file_formats[0] # TODO: This is yanked right out of ClusterUM3OoutputDevice, great candidate for a utility or base class - def _determineWriter(self, file_handler, file_format): + @staticmethod + def _determineWriter(file_handler, file_format) -> Optional[FileWriter]: # Just take the first file format available. if file_handler is not None: writer = file_handler.getWriterByMimeType(cast(str, file_format["mime_type"])) else: - writer = CuraApplication.getInstance().getMeshFileHandler().getWriterByMimeType(cast(str, file_format["mime_type"])) + writer = CuraApplication.getInstance().getMeshFileHandler().getWriterByMimeType( + cast(str, file_format["mime_type"]) + ) if not writer: Logger.log("e", "Unexpected error when trying to get the FileWriter") @@ -173,7 +197,8 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): ## Get remote print jobs. @pyqtProperty("QVariantList", notify = printJobsChanged) def queuedPrintJobs(self) -> List[UM3PrintJobOutputModel]: - return [print_job for print_job in self._print_jobs if print_job.state == "queued" or print_job.state == "error"] + return [print_job for print_job in self._print_jobs.values() + if print_job.state == "queued" or print_job.state == "error"] ## Called when the connection to the cluster changes. def connect(self) -> None: @@ -182,20 +207,19 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): ## Called when the network data should be updated. def _update(self) -> None: super()._update() - self.get("{root}/cluster/{cluster_id}/status".format(self.CLUSTER_API_ROOT, self._device_id), + self.get("{root}/cluster/{cluster_id}/status".format(root=self.CLUSTER_API_ROOT, cluster_id=self._device_id), on_finished = self._onStatusCallFinished) - ## Method called when HTTP request to status endpoint is finished. # Contains both printers and print jobs statuses in a single response. def _onStatusCallFinished(self, reply: QNetworkReply) -> None: - status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) - if status_code != 200: + status_code, response = self._parseReply(reply) + if status_code > 204: Logger.log("w", "Got unexpected response while trying to get cloud cluster data: {}, {}" - .format(status_code, reply.readAll())) + .format(status_code, status, response)) return - - printers, print_jobs = self._parseStatusResponse(reply) + + printers, print_jobs = self._parseStatusResponse(response) if not printers and not print_jobs: return @@ -204,79 +228,69 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): self._updatePrintJobs(print_jobs) @staticmethod - def _parseStatusResponse(reply: QNetworkReply): # Optional[(CloudClusterPrinter, CloudClusterPrintJob)] doesn't work - + def _parseStatusResponse(response: dict) -> Optional[Tuple[CloudClusterPrinter, CloudClusterPrintJob]]: printers = [] print_jobs = [] - s = '' - try: - s = json.loads(bytes(reply.readAll()).decode("utf-8")) - for p in s["printers"]: - printer = CloudClusterPrinter(**p) - configuration = printer.configuration - printer.configuration = [] - for c in configuration: - extruder = CloudClusterPrinterConfiguration(**c) - extruder.material = CloudClusterPrinterConfigurationMaterial(extruder.material) - printer.configuration.append(extruder) + data = response["data"] + for p in data["printers"]: + printer = CloudClusterPrinter(**p) + configuration = printer.configuration + printer.configuration = [] + for c in configuration: + extruder = CloudClusterPrinterConfiguration(**c) + extruder.material = CloudClusterPrinterConfigurationMaterial(material=extruder.material) + printer.configuration.append(extruder) - printers.append(printer) + printers.append(printer) - for j in s["print_jobs"]: - job = CloudClusterPrintJob(**j) - constraints = job.constraints - job.constraints = [] - for c in constraints: - job.constraints.append(CloudClusterPrintJobConstraint(**c)) + for j in data["print_jobs"]: + job = CloudClusterPrintJob(**j) + constraints = job.constraints + job.constraints = [] + for c in constraints: + job.constraints.append(CloudClusterPrintJobConstraint(**c)) - configuration = job.configuration - job.configuration = [] - for c in configuration: - configuration = CloudClusterPrinterConfiguration(**c) - configuration.material = CloudClusterPrinterConfigurationMaterial(configuration.material) - job.configuration.append(configuration) + configuration = job.configuration + job.configuration = [] + for c in configuration: + configuration = CloudClusterPrinterConfiguration(**c) + configuration.material = CloudClusterPrinterConfigurationMaterial(material=configuration.material) + job.configuration.append(configuration) - print_jobs.append(job) - - except json.decoder.JSONDecodeError: - Logger.logException("w", "Unable to decode JSON from reply.") - return None + print_jobs.append(job) return printers, print_jobs def _updatePrinters(self, printers: List[CloudClusterPrinter]) -> None: - remote_printers = {p.uuid: p for p in printers} + remote_printers = {p.uuid: p for p in printers} # type: Dict[str, CloudClusterPrinter] - removed_printers = set(self._printers.keys()).difference(set(remote_printers.keys())) - new_printers = set(remote_printers.keys()).difference(set(self._printers.keys())) - updated_printers = set(self._printers.keys()).intersection(set(remote_printers.keys())) + removed_printer_ids = set(self._printers).difference(remote_printers) + new_printer_ids = set(remote_printers).difference(self._printers) + updated_printer_ids = set(self._printers).intersection(remote_printers) - for p in removed_printers: - self._removePrinter(p) + for printer_guid in removed_printer_ids: + self._removePrinter(printer_guid) - for p in new_printers: - self._addPrinter(printers[p]) - self._updatePrinter(printers[p]) + for printer_guid in new_printer_ids: + self._addPrinter(remote_printers[printer_guid]) + self._updatePrinter(remote_printers[printer_guid]) - for p in updated_printers: - self._updatePrinter(printers[p]) + for printer_guid in updated_printer_ids: + self._updatePrinter(remote_printers[printer_guid]) # TODO: properly handle removed and updated printers self.printersChanged.emit() - - def _addPrinter(self, printer): - self._printers[printer.uuid] = self._createPrinterOutputModel(self, printer) + def _addPrinter(self, printer: CloudClusterPrinter) -> None: + self._printers[printer.uuid] = self._createPrinterOutputModel(printer) def _createPrinterOutputModel(self, printer: CloudClusterPrinter) -> PrinterOutputModel: return PrinterOutputModel(PrinterOutputController(self), len(printer.configuration), firmware_version=printer.firmware_version) - def _updatePrinter(self, guid : str, printer : CloudClusterPrinter): - self._printers[guid] = self._updatePrinterOutputModel(self, printer) - - def _updatePrinterOutputModel(self, printer: CloudClusterPrinter, model : PrinterOutputModel) -> PrinterOutputModel: + def _updatePrinter(self, printer: CloudClusterPrinter) -> None: + model = self._printers[printer.uuid] model.updateKey(printer.uuid) model.updateName(printer.friendly_name) model.updateType(printer.machine_variant) @@ -291,43 +305,42 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): extruder.updateHotendID(extruder_data.print_core_id) - material_data = extruder_data.material - if extruder.activeMaterial is None or extruder.activeMaterial.guid != material.guid: - material = self._createMaterialOutputModel(material_data) + if extruder.activeMaterial is None or extruder.activeMaterial.guid != extruder_data.material.guid: + material = self._createMaterialOutputModel(extruder_data.material) extruder.updateActiveMaterial(material) - def _createMaterialOutputModel(self, material: CloudClusterPrinterConfigurationMaterial) -> MaterialOutputModel: - material_manager = CuraApplication.getInstance().getMaterialManager() - material_group_list = material_manager.getMaterialGroupListByGUID(material.guid) or [] + @staticmethod + def _createMaterialOutputModel(material: CloudClusterPrinterConfigurationMaterial) -> MaterialOutputModel: + material_manager = CuraApplication.getInstance().getMaterialManager() + material_group_list = material_manager.getMaterialGroupListByGUID(material.guid) or [] - # Sort the material groups by "is_read_only = True" first, and then the name alphabetically. - read_only_material_group_list = list(filter(lambda x: x.is_read_only, material_group_list)) - non_read_only_material_group_list = list(filter(lambda x: not x.is_read_only, material_group_list)) - material_group = None - if read_only_material_group_list: - read_only_material_group_list = sorted(read_only_material_group_list, key=lambda x: x.name) - material_group = read_only_material_group_list[0] - elif non_read_only_material_group_list: - non_read_only_material_group_list = sorted(non_read_only_material_group_list, key=lambda x: x.name) - material_group = non_read_only_material_group_list[0] + # Sort the material groups by "is_read_only = True" first, and then the name alphabetically. + read_only_material_group_list = list(filter(lambda x: x.is_read_only, material_group_list)) + non_read_only_material_group_list = list(filter(lambda x: not x.is_read_only, material_group_list)) + material_group = None + if read_only_material_group_list: + read_only_material_group_list = sorted(read_only_material_group_list, key=lambda x: x.name) + material_group = read_only_material_group_list[0] + elif non_read_only_material_group_list: + non_read_only_material_group_list = sorted(non_read_only_material_group_list, key=lambda x: x.name) + material_group = non_read_only_material_group_list[0] - if material_group: - container = material_group.root_material_node.getContainer() - color = container.getMetaDataEntry("color_code") - brand = container.getMetaDataEntry("brand") - material_type = container.getMetaDataEntry("material") - name = container.getName() - else: - Logger.log("w", - "Unable to find material with guid {guid}. Using data as provided by cluster".format( - guid=material.guid)) - color = material.color - brand = material.brand - material_type = material.material - name = "Empty" if material.material == "empty" else "Unknown" - - return MaterialOutputModel(guid=material.guid, type=material_type, brand=brand, color=color, name=name) + if material_group: + container = material_group.root_material_node.getContainer() + color = container.getMetaDataEntry("color_code") + brand = container.getMetaDataEntry("brand") + material_type = container.getMetaDataEntry("material") + name = container.getName() + else: + Logger.log("w", + "Unable to find material with guid {guid}. Using data as provided by cluster".format( + guid=material.guid)) + color = material.color + brand = material.brand + material_type = material.material + name = "Empty" if material.material == "empty" else "Unknown" + return MaterialOutputModel(guid=material.guid, type=material_type, brand=brand, color=color, name=name) def _removePrinter(self, guid): del self._printers[guid] @@ -346,53 +359,51 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): self._addPrintJob(jobs[j]) for j in updated_jobs: - self._updatePrintJob(jobs[j]) + self._updatePrintJob(remote_jobs[j]) # TODO: properly handle removed and updated printers self.printJobsChanged() - def _addPrintJob(self, job: CloudClusterPrintJob): + def _addPrintJob(self, job: CloudClusterPrintJob) -> None: self._print_jobs[job.uuid] = self._createPrintJobOutputModel(job) - def _createPrintJobOutputModel(self, job:CloudClusterPrintJob) -> PrintJobOutputModel: + def _createPrintJobOutputModel(self, job: CloudClusterPrintJob) -> PrintJobOutputModel: controller = self._printers[job.printer_uuid]._controller # TODO: Can we access this property? model = PrintJobOutputModel(controller, job.uuid, job.name) assigned_printer = self._printes[job.printer_uuid] # TODO: Or do we have to use the assigned_to field? model.updateAssignedPrinter(assigned_printer) + return model - def _updatePrintJobOutputModel(self, guid: str, job:CloudClusterPrintJob): - model =self._print_jobs[guid] + def _updatePrintJobOutputModel(self, guid: str, job: CloudClusterPrintJob) -> None: + model = self._print_jobs[guid] model.updateTimeTotal(job.time_total) model.updateTimeElapsed(job.time_elapsed) model.updateOwner(job.owner) model.updateState(job.status) - def _removePrintJob(self, guid:str): + def _removePrintJob(self, guid: str): del self._print_jobs[guid] - def _addPrintJobToQueue(self, stream, request:JobUploadRequest): + def _addPrintJobToQueue(self, stream, request: JobUploadRequest): self.put("{}/jobs/upload".format(self.CURA_API_ROOT), data = json.dumps(request.__dict__), on_finished = lambda reply: self._onAddPrintJobToQueueFinished(stream, reply)) def _onAddPrintJobToQueueFinished(self, stream, reply: QNetworkReply) -> None: - s = json.loads(bytes(reply.readAll()).decode("utf-8")) + status_code, response = self._parseReply(reply) # type: Tuple[int, dict] + if status_code > 204 or not isinstance(dict, response) or "data" not in response: + Logger.error() + return - self.put() + job_response = JobUploadResponse(**response.get("data")) + self.put(job_response.upload_url, data=stream.getvalue(), on_finished=self._onPrintJobUploaded) - # try: - # r = requests.put(self._job.output_url, data=data) - # if r.status_code == 200: - # Logger.log("d", "Finished writing %s to remote URL %s", "", self._job.output_url) - # self.onWriteSuccess.emit(r.text) - # else: - # Logger.log("d", "Error writing %s to remote URL %s", "", self._job.output_url) - # self.onWriteFailed.emit("Failed to export G-code to remote URL: {}".format(r.text)) - # except requests.ConnectionError as e: - # Logger.log("e", "There was a connection error when uploading the G-code to a remote URL: %s", e) - # self.onWriteFailed.emit("Failed to export G-code to remote URL: {}".format(e)) - # except requests.HTTPError as e: - # Logger.log("e", "There was an HTTP error when uploading the G-code to a remote URL: %s", e) - # self.onWriteFailed.emit("Failed to export G-code to remote URL: {}".format(e)) + def _onPrintJobUploaded(self, reply: QNetworkReply) -> None: + status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) + if status_code > 204: + self.onWriteFailed.emit("Failed to export G-code to remote URL: {}".format(r.text)) + Logger.logException("w", "Received unexpected response from the job upload: %s, %s.", status_code, + bytes(reply.readAll()).decode()) + return - pass \ No newline at end of file + self.onWriteSuccess.emit(r.text) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index f6542e3c76..421f24bc25 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -3,7 +3,7 @@ import json from time import sleep from threading import Thread -from typing import Dict, Optional, List +from typing import Dict, Optional from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models.py b/plugins/UM3NetworkPrinting/src/Cloud/Models.py index 86a48fb1f2..5363f49c00 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models.py @@ -1,5 +1,7 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. +from typing import List + from ..Models import BaseModel @@ -41,7 +43,7 @@ class CloudClusterPrinterConfiguration(BaseModel): ## Class representing a cluster printer class CloudClusterPrinter(BaseModel): def __init__(self, **kwargs): - self.configuration = None # type: CloudClusterPrinterConfiguration + self.configuration = None # type: List[CloudClusterPrinterConfiguration] self.enabled = None # type: str self.firmware_version = None # type: str self.friendly_name = None # type: str @@ -56,7 +58,7 @@ class CloudClusterPrinter(BaseModel): ## Class representing a cloud cluster print job constraint class CloudClusterPrintJobConstraint(BaseModel): def __init__(self, **kwargs): - self.require_printer_name: None # type: str + self.require_printer_name = None # type: str super().__init__(**kwargs) ## Class representing a print job From fc26ccd6fa91f016f1eec7d6bd80c4da742b7496 Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Mon, 3 Dec 2018 14:41:36 +0100 Subject: [PATCH 033/178] STAR-332: Some improvements to get the job in connect --- cura/API/Account.py | 6 +- .../NetworkedPrinterOutputDevice.py | 11 +- .../src/Cloud/CloudOutputDevice.py | 167 ++++++++++-------- .../src/Cloud/CloudOutputDeviceManager.py | 19 +- .../UM3NetworkPrinting/src/Cloud/Models.py | 10 +- 5 files changed, 121 insertions(+), 92 deletions(-) diff --git a/cura/API/Account.py b/cura/API/Account.py index 64d63c7025..d78c7e8826 100644 --- a/cura/API/Account.py +++ b/cura/API/Account.py @@ -37,14 +37,16 @@ class Account(QObject): self._logged_in = False self._callback_port = 32118 - self._oauth_root = "https://account.ultimaker.com" + self._oauth_root = "https://account-staging.ultimaker.com" self._oauth_settings = OAuth2Settings( OAUTH_SERVER_URL= self._oauth_root, CALLBACK_PORT=self._callback_port, CALLBACK_URL="http://localhost:{}/callback".format(self._callback_port), CLIENT_ID="um----------------------------ultimaker_cura", - CLIENT_SCOPES="account.user.read drive.backup.read drive.backup.write packages.download packages.rating.read packages.rating.write", + 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 " + "cura.printjob.read cura.printjob.write cura.mesh.read cura.mesh.write", AUTH_DATA_PREFERENCE_KEY="general/ultimaker_auth_data", AUTH_SUCCESS_REDIRECT="{}/app/auth-success".format(self._oauth_root), AUTH_FAILED_REDIRECT="{}/app/auth-error".format(self._oauth_root) diff --git a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py index 7125de4002..0a799d4cd3 100644 --- a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py +++ b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py @@ -11,7 +11,7 @@ from cura.PrinterOutputDevice import PrinterOutputDevice, ConnectionState from PyQt5.QtNetwork import QHttpMultiPart, QHttpPart, QNetworkRequest, QNetworkAccessManager, QNetworkReply, QAuthenticator from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl, QCoreApplication from time import time -from typing import Any, Callable, Dict, List, Optional +from typing import Callable, Dict, List, Optional, Union from enum import IntEnum import os # To get the username @@ -180,12 +180,12 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): self._createNetworkManager() assert (self._manager is not None) - def put(self, target: str, data: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None: + def put(self, target: str, data: Union[str, bytes], on_finished: Optional[Callable[[QNetworkReply], None]]) -> None: self._validateManager() request = self._createEmptyRequest(target) self._last_request_time = time() if self._manager is not None: - reply = self._manager.put(request, data.encode()) + reply = self._manager.put(request, data if isinstance(data, bytes) else data.encode()) self._registerOnFinishedCallback(reply, on_finished) else: Logger.log("e", "Could not find manager.") @@ -210,12 +210,13 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): else: Logger.log("e", "Could not find manager.") - def post(self, target: str, data: str, on_finished: Optional[Callable[[QNetworkReply], None]], on_progress: Callable = None) -> None: + def post(self, target: str, data: Union[str, bytes], on_finished: Optional[Callable[[QNetworkReply], None]], + on_progress: Callable = None) -> None: self._validateManager() request = self._createEmptyRequest(target) self._last_request_time = time() if self._manager is not None: - reply = self._manager.post(request, data.encode()) + reply = self._manager.post(request, data if isinstance(data, bytes) else data.encode()) if on_progress is not None: reply.uploadProgress.connect(on_progress) self._registerOnFinishedCallback(reply, on_finished) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index 008633e198..caffa64a95 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -3,7 +3,8 @@ import io import json import os -from typing import List, Optional, Dict, cast, Union +from json import JSONDecodeError +from typing import List, Optional, Dict, cast, Union, Tuple from PyQt5.QtCore import QObject, pyqtSignal, QUrl, pyqtProperty from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest @@ -20,9 +21,9 @@ from cura.PrinterOutput.PrinterOutputController import PrinterOutputController from cura.PrinterOutput.MaterialOutputModel import MaterialOutputModel from cura.PrinterOutput.NetworkedPrinterOutputDevice import NetworkedPrinterOutputDevice, AuthState from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel +from plugins.UM3NetworkPrinting.src.UM3PrintJobOutputModel import UM3PrintJobOutputModel from .Models import CloudClusterPrinter, CloudClusterPrinterConfiguration, CloudClusterPrinterConfigurationMaterial, \ - CloudClusterPrintJob, CloudClusterPrintJobConstraint, JobUploadRequest -from ..UM3PrintJobOutputModel import UM3PrintJobOutputModel + CloudClusterPrintJob, CloudClusterPrintJobConstraint, JobUploadRequest, JobUploadResponse, PrintResponse ## The cloud output device is a network output device that works remotely but has limited functionality. @@ -37,11 +38,10 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): I18N_CATALOG = i18nCatalog("cura") # The cloud URL to use for this remote cluster. - # TODO: Make sure that this url goes to the live api before release + # TODO: Make sure that this URL goes to the live api before release ROOT_PATH = "https://api-staging.ultimaker.com" CLUSTER_API_ROOT = "{}/connect/v1/".format(ROOT_PATH) CURA_API_ROOT = "{}/cura/v1/".format(ROOT_PATH) - CURA_DRIVE_API_ROOT = "{}/cura-drive/v1/".format(ROOT_PATH) # Signal triggered when the printers in the remote cluster were changed. printersChanged = pyqtSignal() @@ -56,6 +56,9 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): self._device_id = device_id self._account = CuraApplication.getInstance().getCuraAPI().account + # Cluster does not have authentication, so default to authenticated + self._authentication_state = AuthState.Authenticated + # We re-use the Cura Connect monitor tab to get the most functionality right away. self._monitor_view_qml_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "../../resources/qml/ClusterMonitorItem.qml") @@ -63,8 +66,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): "../../resources/qml/ClusterControlItem.qml") # Properties to populate later on with received cloud data. - self._printers = {} # type: Dict[str, PrinterOutputModel] - self._print_jobs = {} # type: Dict[str, PrintJobOutputModel] + self._print_jobs = [] # type: List[UM3PrintJobOutputModel] self._number_of_extruders = 2 # All networked printers are dual-extrusion Ultimaker machines. @staticmethod @@ -123,23 +125,11 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): Logger.log("e", "Missing file or mesh writer!") return - stream = io.BytesIO() # type: Union[io.BytesIO, io.StringIO]# Binary mode. - if file_format["mode"] == FileWriter.OutputMode.TextMode: - stream = io.StringIO() - + stream = io.StringIO() if file_format["mode"] == FileWriter.OutputMode.TextMode else io.BytesIO() writer.write(stream, nodes) + self._sendPrintJob(file_name + "." + file_format["extension"], stream) - stream.seek(0, io.SEEK_END) - size = stream.tell() - stream.seek(0, io.SEEK_SET) - - request = JobUploadRequest() - request.job_name = file_name - request.file_size = size - - self._addPrintJobToQueue(stream, request) - - # TODO: This is yanked right out of ClusterUM3OoutputDevice, great candidate for a utility or base class + # TODO: This is yanked right out of ClusterUM3OutputDevice, great candidate for a utility or base class def _determineFileFormat(self, file_handler) -> Optional[Dict[str, Union[str, int]]]: # Formats supported by this application (file types that we can actually write). if file_handler: @@ -172,7 +162,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): ) return file_formats[0] - # TODO: This is yanked right out of ClusterUM3OoutputDevice, great candidate for a utility or base class + # TODO: This is yanked right out of ClusterUM3OutputDevice, great candidate for a utility or base class @staticmethod def _determineWriter(file_handler, file_format) -> Optional[FileWriter]: # Just take the first file format available. @@ -194,10 +184,14 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): def printers(self): return self._printers + @pyqtProperty("QVariantList", notify = printJobsChanged) + def printJobs(self)-> List[UM3PrintJobOutputModel]: + return self._print_jobs + ## Get remote print jobs. @pyqtProperty("QVariantList", notify = printJobsChanged) def queuedPrintJobs(self) -> List[UM3PrintJobOutputModel]: - return [print_job for print_job in self._print_jobs.values() + return [print_job for print_job in self._print_jobs if print_job.state == "queued" or print_job.state == "error"] ## Called when the connection to the cluster changes. @@ -207,6 +201,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): ## Called when the network data should be updated. def _update(self) -> None: super()._update() + Logger.log("i", "Calling the cloud cluster") self.get("{root}/cluster/{cluster_id}/status".format(root=self.CLUSTER_API_ROOT, cluster_id=self._device_id), on_finished = self._onStatusCallFinished) @@ -214,11 +209,12 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): # Contains both printers and print jobs statuses in a single response. def _onStatusCallFinished(self, reply: QNetworkReply) -> None: status_code, response = self._parseReply(reply) - if status_code > 204: - Logger.log("w", "Got unexpected response while trying to get cloud cluster data: {}, {}" - .format(status_code, status, response)) + if status_code > 204 or not isinstance(response, dict): + Logger.log("w", "Got unexpected response while trying to get cloud cluster data: %s, %s", + status_code, response) return + Logger.log("d", "Got response form the cloud cluster %s, %s", status_code, response) printers, print_jobs = self._parseStatusResponse(response) if not printers and not print_jobs: return @@ -228,7 +224,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): self._updatePrintJobs(print_jobs) @staticmethod - def _parseStatusResponse(response: dict) -> Optional[Tuple[CloudClusterPrinter, CloudClusterPrintJob]]: + def _parseStatusResponse(response: dict) -> Tuple[List[CloudClusterPrinter], List[CloudClusterPrintJob]]: printers = [] print_jobs = [] @@ -264,33 +260,31 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): def _updatePrinters(self, printers: List[CloudClusterPrinter]) -> None: remote_printers = {p.uuid: p for p in printers} # type: Dict[str, CloudClusterPrinter] + current_printers = {p.key: p for p in self._printers} - removed_printer_ids = set(self._printers).difference(remote_printers) - new_printer_ids = set(remote_printers).difference(self._printers) - updated_printer_ids = set(self._printers).intersection(remote_printers) + removed_printer_ids = set(current_printers).difference(remote_printers) + new_printer_ids = set(remote_printers).difference(current_printers) + updated_printer_ids = set(current_printers).intersection(remote_printers) for printer_guid in removed_printer_ids: - self._removePrinter(printer_guid) + self._printers.remove(current_printers[printer_guid]) for printer_guid in new_printer_ids: self._addPrinter(remote_printers[printer_guid]) - self._updatePrinter(remote_printers[printer_guid]) for printer_guid in updated_printer_ids: - self._updatePrinter(remote_printers[printer_guid]) + self._updatePrinter(current_printers[printer_guid], remote_printers[printer_guid]) - # TODO: properly handle removed and updated printers self.printersChanged.emit() def _addPrinter(self, printer: CloudClusterPrinter) -> None: - self._printers[printer.uuid] = self._createPrinterOutputModel(printer) + model = PrinterOutputModel( + PrinterOutputController(self), len(printer.configuration), firmware_version=printer.firmware_version + ) + self._printers.append(model) + self._updatePrinter(model, printer) - def _createPrinterOutputModel(self, printer: CloudClusterPrinter) -> PrinterOutputModel: - return PrinterOutputModel(PrinterOutputController(self), len(printer.configuration), - firmware_version=printer.firmware_version) - - def _updatePrinter(self, printer: CloudClusterPrinter) -> None: - model = self._printers[printer.uuid] + def _updatePrinter(self, model: PrinterOutputModel, printer: CloudClusterPrinter) -> None: model.updateKey(printer.uuid) model.updateName(printer.friendly_name) model.updateType(printer.machine_variant) @@ -342,68 +336,85 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): return MaterialOutputModel(guid=material.guid, type=material_type, brand=brand, color=color, name=name) - def _removePrinter(self, guid): - del self._printers[guid] - def _updatePrintJobs(self, jobs: List[CloudClusterPrintJob]) -> None: remote_jobs = {j.uuid: j for j in jobs} + current_jobs = {j.key: j for j in self._print_jobs} - removed_jobs = set(self._print_jobs.keys()).difference(set(remote_jobs.keys())) - new_jobs = set(remote_jobs.keys()).difference(set(self._print_jobs.keys())) - updated_jobs = set(self._print_jobs.keys()).intersection(set(remote_jobs.keys())) + removed_job_ids = set(current_jobs).difference(set(remote_jobs)) + new_job_ids = set(remote_jobs.keys()).difference(set(current_jobs)) + updated_job_ids = set(current_jobs).intersection(set(remote_jobs)) - for j in removed_jobs: - self._removePrintJob(j) + for job_id in removed_job_ids: + self._print_jobs.remove(current_jobs[job_id]) - for j in new_jobs: - self._addPrintJob(jobs[j]) + for job_id in new_job_ids: + self._addPrintJob(remote_jobs[job_id]) - for j in updated_jobs: - self._updatePrintJob(remote_jobs[j]) + for job_id in updated_job_ids: + self._updateUM3PrintJobOutputModel(current_jobs[job_id], remote_jobs[job_id]) # TODO: properly handle removed and updated printers - self.printJobsChanged() + self.printJobsChanged.emit() def _addPrintJob(self, job: CloudClusterPrintJob) -> None: - self._print_jobs[job.uuid] = self._createPrintJobOutputModel(job) + try: + printer = next(p for p in self._printers if job.printer_uuid == p.key) + except StopIteration: + return Logger.log("w", "Missing printer %s for job %s in %s", job.printer_uuid, job.uuid, + [p.key for p in self._printers]) - def _createPrintJobOutputModel(self, job: CloudClusterPrintJob) -> PrintJobOutputModel: - controller = self._printers[job.printer_uuid]._controller # TODO: Can we access this property? - model = PrintJobOutputModel(controller, job.uuid, job.name) - assigned_printer = self._printes[job.printer_uuid] # TODO: Or do we have to use the assigned_to field? - model.updateAssignedPrinter(assigned_printer) - return model - - def _updatePrintJobOutputModel(self, guid: str, job: CloudClusterPrintJob) -> None: - model = self._print_jobs[guid] + model = UM3PrintJobOutputModel(printer.getController(), job.uuid, job.name) + model.updateAssignedPrinter(printer) + self._print_jobs.append(model) + @staticmethod + def _updateUM3PrintJobOutputModel(model: PrinterOutputModel, job: CloudClusterPrintJob) -> None: model.updateTimeTotal(job.time_total) model.updateTimeElapsed(job.time_elapsed) model.updateOwner(job.owner) model.updateState(job.status) - def _removePrintJob(self, guid: str): - del self._print_jobs[guid] + def _sendPrintJob(self, file_name: str, stream: Union[io.StringIO, io.BytesIO]) -> None: + mesh = stream.getvalue() - def _addPrintJobToQueue(self, stream, request: JobUploadRequest): - self.put("{}/jobs/upload".format(self.CURA_API_ROOT), data = json.dumps(request.__dict__), - on_finished = lambda reply: self._onAddPrintJobToQueueFinished(stream, reply)) + request = JobUploadRequest() + request.job_name = file_name + request.file_size = len(mesh) - def _onAddPrintJobToQueueFinished(self, stream, reply: QNetworkReply) -> None: - status_code, response = self._parseReply(reply) # type: Tuple[int, dict] - if status_code > 204 or not isinstance(dict, response) or "data" not in response: - Logger.error() + Logger.log("i", "Creating new cloud print job: %s", request.__dict__) + self.put("{}/jobs/upload".format(self.CURA_API_ROOT), data = json.dumps({"data": request.__dict__}), + on_finished = lambda reply: self._onPrintJobCreated(mesh, reply)) + + def _onPrintJobCreated(self, mesh: bytes, reply: QNetworkReply) -> None: + status_code, response = self._parseReply(reply) + if status_code > 204 or not isinstance(response, dict) or "data" not in response: + Logger.log("w", "Got unexpected response while trying to add print job to cluster: {}, {}" + .format(status_code, response)) return + # TODO: Multipart upload job_response = JobUploadResponse(**response.get("data")) - self.put(job_response.upload_url, data=stream.getvalue(), on_finished=self._onPrintJobUploaded) + Logger.log("i", "Print job created successfully: %s", job_response.__dict__) + self.put(job_response.upload_url, data=mesh, + on_finished=lambda r: self._onPrintJobUploaded(job_response.job_id, r)) - def _onPrintJobUploaded(self, reply: QNetworkReply) -> None: + def _onPrintJobUploaded(self, job_id: str, reply: QNetworkReply) -> None: status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) if status_code > 204: - self.onWriteFailed.emit("Failed to export G-code to remote URL: {}".format(r.text)) Logger.logException("w", "Received unexpected response from the job upload: %s, %s.", status_code, bytes(reply.readAll()).decode()) return - self.onWriteSuccess.emit(r.text) + Logger.log("i", "Print job uploaded successfully: %s", reply.readAll()) + url = "{}/cluster/{}/print/{}".format(self.CLUSTER_API_ROOT, self._device_id, job_id) + self.post(url, data="", on_finished=self._onPrintJobRequested) + + def _onPrintJobRequested(self, reply: QNetworkReply) -> None: + status_code, response = self._parseReply(reply) + if status_code > 204 or not isinstance(response, dict): + Logger.log("w", "Got unexpected response while trying to request printing: %s, %s", + status_code, response) + return + + print_response = PrintResponse(**response.get("data")) + Logger.log("i", "Print job requested successfully: %s", print_response.__dict__) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 421f24bc25..7c10cb4e50 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -25,9 +25,9 @@ from .Models import CloudCluster class CloudOutputDeviceManager(NetworkClient): # The cloud URL to use for remote clusters. - API_ROOT_PATH = "https://api.ultimaker.com/connect/v1" + API_ROOT_PATH = "https://api-staging.ultimaker.com/connect/v1" - # The interval with wich the remote clusters are checked + # The interval with which the remote clusters are checked CHECK_CLUSTER_INTERVAL = 5 # seconds def __init__(self): @@ -39,13 +39,14 @@ class CloudOutputDeviceManager(NetworkClient): application = CuraApplication.getInstance() self._output_device_manager = application.getOutputDeviceManager() self._account = application.getCuraAPI().account + self._account.loginStateChanged.connect(self._getRemoteClusters) # When switching machines we check if we have to activate a remote cluster. application.globalContainerStackChanged.connect(self._activeMachineChanged) # Periodically check all remote clusters for the authenticated user. - self._update_clusters_thread = Thread(target=self._updateClusters, daemon=True) - self._update_clusters_thread.start() + # self._update_clusters_thread = Thread(target=self._updateClusters, daemon=True) + # self._update_clusters_thread.start() ## Override _createEmptyRequest to add the needed authentication header for talking to the Ultimaker Cloud API. def _createEmptyRequest(self, path: str, content_type: Optional[str] = "application/json") -> QNetworkRequest: @@ -64,18 +65,22 @@ class CloudOutputDeviceManager(NetworkClient): ## Gets all remote clusters from the API. def _getRemoteClusters(self) -> None: + Logger.log("i", "Retrieving remote clusters") self.get("/clusters", on_finished = self._onGetRemoteClustersFinished) ## Callback for when the request for getting the clusters. is finished. def _onGetRemoteClustersFinished(self, reply: QNetworkReply) -> None: + Logger.log("i", "Received remote clusters") + status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) - if status_code != 200: + if status_code > 204: Logger.log("w", "Got unexpected response while trying to get cloud cluster data: {}, {}" .format(status_code, reply.readAll())) return # Parse the response (returns the "data" field from the body). found_clusters = self._parseStatusResponse(reply) + Logger.log("i", "Parsed remote clusters to %s", found_clusters) if not found_clusters: return @@ -96,7 +101,8 @@ class CloudOutputDeviceManager(NetworkClient): @staticmethod def _parseStatusResponse(reply: QNetworkReply) -> Dict[str, CloudCluster]: try: - return {c["cluster_id"]: CloudCluster(**c) for c in json.loads(reply.readAll().data().decode("utf-8"))} + response = bytes(reply.readAll()).decode() + return {c["cluster_id"]: CloudCluster(**c) for c in json.loads(response)["data"]} except UnicodeDecodeError: Logger.log("w", "Unable to read server response") except json.decoder.JSONDecodeError: @@ -110,6 +116,7 @@ class CloudOutputDeviceManager(NetworkClient): device = CloudOutputDevice(cluster.cluster_id) self._output_device_manager.addOutputDevice(device) self._remote_clusters[cluster.cluster_id] = device + device.connect() # TODO: Only connect the current device ## Remove a CloudOutputDevice def _removeCloudOutputDevice(self, cluster: CloudCluster): diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models.py b/plugins/UM3NetworkPrinting/src/Cloud/Models.py index 5363f49c00..435f265300 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models.py @@ -36,7 +36,7 @@ class CloudClusterPrinterConfiguration(BaseModel): self.extruder_index = None # type: str self.material = None # type: CloudClusterPrinterConfigurationMaterial self.nozzle_diameter = None # type: str - self.printer_core_id = None # type: str + self.print_core_id = None # type: str super().__init__(**kwargs) @@ -99,3 +99,11 @@ class JobUploadResponse(BaseModel): self.status = None # type: str self.upload_url = None # type: str super().__init__(**kwargs) + + +class PrintResponse(BaseModel): + def __init__(self, **kwargs): + self.cluster_job_id: str = None + self.job_id: str = None + self.status: str = None + super().__init__(**kwargs) From f8f6670cae4afc5069a8eeb8034d0c0074c2903b Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Mon, 3 Dec 2018 15:06:01 +0100 Subject: [PATCH 034/178] STAR-322: Letting models convert sub-models --- .../src/Cloud/CloudOutputDevice.py | 67 +++------- .../UM3NetworkPrinting/src/Cloud/Models.py | 115 +++++++++++------- 2 files changed, 84 insertions(+), 98 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index caffa64a95..c478f15ade 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -22,8 +22,10 @@ from cura.PrinterOutput.MaterialOutputModel import MaterialOutputModel from cura.PrinterOutput.NetworkedPrinterOutputDevice import NetworkedPrinterOutputDevice, AuthState from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel from plugins.UM3NetworkPrinting.src.UM3PrintJobOutputModel import UM3PrintJobOutputModel -from .Models import CloudClusterPrinter, CloudClusterPrinterConfiguration, CloudClusterPrinterConfigurationMaterial, \ - CloudClusterPrintJob, CloudClusterPrintJobConstraint, JobUploadRequest, JobUploadResponse, PrintResponse +from .Models import ( + CloudClusterPrinter, CloudClusterPrintJob, JobUploadRequest, JobUploadResponse, PrintResponse, CloudClusterStatus, + CloudClusterPrinterConfigurationMaterial +) ## The cloud output device is a network output device that works remotely but has limited functionality. @@ -209,58 +211,21 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): # Contains both printers and print jobs statuses in a single response. def _onStatusCallFinished(self, reply: QNetworkReply) -> None: status_code, response = self._parseReply(reply) - if status_code > 204 or not isinstance(response, dict): + if status_code > 204 or not isinstance(response, dict) or "data" not in response: Logger.log("w", "Got unexpected response while trying to get cloud cluster data: %s, %s", status_code, response) return Logger.log("d", "Got response form the cloud cluster %s, %s", status_code, response) - printers, print_jobs = self._parseStatusResponse(response) - if not printers and not print_jobs: - return - + status = CloudClusterStatus(**response["data"]) + # Update all data from the cluster. - self._updatePrinters(printers) - self._updatePrintJobs(print_jobs) - - @staticmethod - def _parseStatusResponse(response: dict) -> Tuple[List[CloudClusterPrinter], List[CloudClusterPrintJob]]: - printers = [] - print_jobs = [] - - data = response["data"] - for p in data["printers"]: - printer = CloudClusterPrinter(**p) - configuration = printer.configuration - printer.configuration = [] - for c in configuration: - extruder = CloudClusterPrinterConfiguration(**c) - extruder.material = CloudClusterPrinterConfigurationMaterial(material=extruder.material) - printer.configuration.append(extruder) - - printers.append(printer) - - for j in data["print_jobs"]: - job = CloudClusterPrintJob(**j) - constraints = job.constraints - job.constraints = [] - for c in constraints: - job.constraints.append(CloudClusterPrintJobConstraint(**c)) - - configuration = job.configuration - job.configuration = [] - for c in configuration: - configuration = CloudClusterPrinterConfiguration(**c) - configuration.material = CloudClusterPrinterConfigurationMaterial(material=configuration.material) - job.configuration.append(configuration) - - print_jobs.append(job) - - return printers, print_jobs + self._updatePrinters(status.printers) + self._updatePrintJobs(status.print_jobs) def _updatePrinters(self, printers: List[CloudClusterPrinter]) -> None: - remote_printers = {p.uuid: p for p in printers} # type: Dict[str, CloudClusterPrinter] - current_printers = {p.key: p for p in self._printers} + remote_printers: Dict[str, CloudClusterPrinter] = {p.uuid: p for p in printers} + current_printers: Dict[str, PrinterOutputModel] = {p.key: p for p in self._printers} removed_printer_ids = set(current_printers).difference(remote_printers) new_printer_ids = set(remote_printers).difference(current_printers) @@ -337,8 +302,8 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): return MaterialOutputModel(guid=material.guid, type=material_type, brand=brand, color=color, name=name) def _updatePrintJobs(self, jobs: List[CloudClusterPrintJob]) -> None: - remote_jobs = {j.uuid: j for j in jobs} - current_jobs = {j.key: j for j in self._print_jobs} + remote_jobs: Dict[str, CloudClusterPrintJob] = {j.uuid: j for j in jobs} + current_jobs: Dict[str, UM3PrintJobOutputModel] = {j.key: j for j in self._print_jobs} removed_job_ids = set(current_jobs).difference(set(remote_jobs)) new_job_ids = set(remote_jobs.keys()).difference(set(current_jobs)) @@ -368,7 +333,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): self._print_jobs.append(model) @staticmethod - def _updateUM3PrintJobOutputModel(model: PrinterOutputModel, job: CloudClusterPrintJob) -> None: + def _updateUM3PrintJobOutputModel(model: UM3PrintJobOutputModel, job: CloudClusterPrintJob) -> None: model.updateTimeTotal(job.time_total) model.updateTimeElapsed(job.time_elapsed) model.updateOwner(job.owner) @@ -411,10 +376,10 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): def _onPrintJobRequested(self, reply: QNetworkReply) -> None: status_code, response = self._parseReply(reply) - if status_code > 204 or not isinstance(response, dict): + if status_code > 204 or not isinstance(response, dict) or "data" not in response: Logger.log("w", "Got unexpected response while trying to request printing: %s, %s", status_code, response) return - print_response = PrintResponse(**response.get("data")) + print_response = PrintResponse(**response["data"]) Logger.log("i", "Print job requested successfully: %s", print_response.__dict__) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models.py b/plugins/UM3NetworkPrinting/src/Cloud/Models.py index 435f265300..780fa06172 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models.py @@ -8,11 +8,11 @@ from ..Models import BaseModel ## Class representing a cloud connected cluster. class CloudCluster(BaseModel): def __init__(self, **kwargs): - self.cluster_id = None # type: str - self.host_guid = None # type: str - self.host_name = None # type: str - self.host_version = None # type: str - self.status = None # type: str + self.cluster_id: str = None + self.host_guid: str = None + self.host_name: str = None + self.host_version: str = None + self.status: str = None super().__init__(**kwargs) def validate(self): @@ -23,81 +23,102 @@ class CloudCluster(BaseModel): ## Class representing a cloud cluster printer configuration class CloudClusterPrinterConfigurationMaterial(BaseModel): def __init__(self, **kwargs): - self.guid = None # type: str - self.brand = None # type: str - self.color = None # type: str - self.material = None # type: str + self.guid: str = None + self.brand: str = None + self.color: str = None + self.material: str = None super().__init__(**kwargs) ## Class representing a cloud cluster printer configuration class CloudClusterPrinterConfiguration(BaseModel): def __init__(self, **kwargs): - self.extruder_index = None # type: str - self.material = None # type: CloudClusterPrinterConfigurationMaterial - self.nozzle_diameter = None # type: str - self.print_core_id = None # type: str + self.extruder_index: str = None + self.material: CloudClusterPrinterConfigurationMaterial = None + self.nozzle_diameter: str = None + self.print_core_id: str = None super().__init__(**kwargs) + if isinstance(self.material, dict): + self.material = CloudClusterPrinterConfigurationMaterial(**self.material) + ## Class representing a cluster printer class CloudClusterPrinter(BaseModel): def __init__(self, **kwargs): - self.configuration = None # type: List[CloudClusterPrinterConfiguration] - self.enabled = None # type: str - self.firmware_version = None # type: str - self.friendly_name = None # type: str - self.ip_address = None # type: str - self.machine_variant = None # type: str - self.status = None # type: str - self.unique_name = None # type: str - self.uuid = None # type: str + self.configuration: List[CloudClusterPrinterConfiguration] = [] + self.enabled: str = None + self.firmware_version: str = None + self.friendly_name: str = None + self.ip_address: str = None + self.machine_variant: str = None + self.status: str = None + self.unique_name: str = None + self.uuid: str = None super().__init__(**kwargs) + self.configuration = [CloudClusterPrinterConfiguration(**c) + if isinstance(c, dict) else c for c in self.configuration] + ## Class representing a cloud cluster print job constraint class CloudClusterPrintJobConstraint(BaseModel): def __init__(self, **kwargs): - self.require_printer_name = None # type: str + self.require_printer_name: str = None super().__init__(**kwargs) + ## Class representing a print job class CloudClusterPrintJob(BaseModel): def __init__(self, **kwargs): - self.assigned_to = None # type: str - self.configuration = None # type: str - self.constraints = None # type: str - self.created_at = None # type: str - self.force = None # type: str - self.last_seen = None # type: str - self.machine_variant = None # type: str - self.name = None # type: str - self.network_error_count = None # type: str - self.owner = None # type: str - self.printer_uuid = None # type: str - self.started = None # type: str - self.status = None # type: str - self.time_elapsed = None # type: str - self.time_total = None # type: str - self.uuid = None # type: str + self.assigned_to: str = None + self.configuration: List[CloudClusterPrinterConfiguration] = [] + self.constraints: List[CloudClusterPrintJobConstraint] = [] + self.created_at: str = None + self.force: str = None + self.last_seen: str = None + self.machine_variant: str = None + self.name: str = None + self.network_error_count: int = None + self.owner: str = None + self.printer_uuid: str = None + self.started: str = None + self.status: str = None + self.time_elapsed: str = None + self.time_total: str = None + self.uuid: str = None super().__init__(**kwargs) + self.printers = [CloudClusterPrinterConfiguration(**c) if isinstance(c, dict) else c + for c in self.configuration] + self.printers = [CloudClusterPrintJobConstraint(**p) if isinstance(p, dict) else p + for p in self.constraints] + + +class CloudClusterStatus(BaseModel): + def __init__(self, **kwargs): + self.printers: List[CloudClusterPrinter] = [] + self.print_jobs: List[CloudClusterPrintJob] = [] + super().__init__(**kwargs) + + self.printers = [CloudClusterPrinter(**p) if isinstance(p, dict) else p for p in self.printers] + self.print_jobs = [CloudClusterPrintJob(**j) if isinstance(j, dict) else j for j in self.print_jobs] class JobUploadRequest(BaseModel): def __init__(self, **kwargs): - self.file_size = None # type: int - self.job_name = None # type: str + self.file_size: int = None + self.job_name: str = None super().__init__(**kwargs) class JobUploadResponse(BaseModel): def __init__(self, **kwargs): - self.download_url = None # type: str - self.job_id = None # type: str - self.job_name = None # type: str - self.slicing_details = None # type: str - self.status = None # type: str - self.upload_url = None # type: str + self.download_url: str = None + self.job_id: str = None + self.job_name: str = None + self.slicing_details: str = None + self.status: str = None + self.upload_url: str = None super().__init__(**kwargs) From ddf958d39ab3fe5c8a9712cfbe441d44dc853bd8 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Mon, 3 Dec 2018 16:23:44 +0100 Subject: [PATCH 035/178] Fix typing in CloudOutputDevice --- plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index c478f15ade..3d577d0991 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -224,8 +224,8 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): self._updatePrintJobs(status.print_jobs) def _updatePrinters(self, printers: List[CloudClusterPrinter]) -> None: - remote_printers: Dict[str, CloudClusterPrinter] = {p.uuid: p for p in printers} - current_printers: Dict[str, PrinterOutputModel] = {p.key: p for p in self._printers} + remote_printers = {p.uuid: p for p in printers} # type: Dict[str, CloudClusterPrinter] + current_printers = {p.key: p for p in self._printers} # type: Dict[str, PrinterOutputModel] removed_printer_ids = set(current_printers).difference(remote_printers) new_printer_ids = set(remote_printers).difference(current_printers) @@ -302,8 +302,8 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): return MaterialOutputModel(guid=material.guid, type=material_type, brand=brand, color=color, name=name) def _updatePrintJobs(self, jobs: List[CloudClusterPrintJob]) -> None: - remote_jobs: Dict[str, CloudClusterPrintJob] = {j.uuid: j for j in jobs} - current_jobs: Dict[str, UM3PrintJobOutputModel] = {j.key: j for j in self._print_jobs} + remote_jobs = {j.uuid: j for j in jobs} # type: Dict[str, CloudClusterPrintJob] + current_jobs = {j.key: j for j in self._print_jobs} # type: Dict[str, UM3PrintJobOutputModel] removed_job_ids = set(current_jobs).difference(set(remote_jobs)) new_job_ids = set(remote_jobs.keys()).difference(set(current_jobs)) From 186c2cf3f5e7430fd50fbd7dc83a40f6d29be023 Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Mon, 3 Dec 2018 16:35:51 +0100 Subject: [PATCH 036/178] START-322: Python 3.5 compatibility --- .../src/Cloud/CloudOutputDevice.py | 8 +- .../UM3NetworkPrinting/src/Cloud/Models.py | 104 +++++++++--------- 2 files changed, 56 insertions(+), 56 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index c478f15ade..3d577d0991 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -224,8 +224,8 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): self._updatePrintJobs(status.print_jobs) def _updatePrinters(self, printers: List[CloudClusterPrinter]) -> None: - remote_printers: Dict[str, CloudClusterPrinter] = {p.uuid: p for p in printers} - current_printers: Dict[str, PrinterOutputModel] = {p.key: p for p in self._printers} + remote_printers = {p.uuid: p for p in printers} # type: Dict[str, CloudClusterPrinter] + current_printers = {p.key: p for p in self._printers} # type: Dict[str, PrinterOutputModel] removed_printer_ids = set(current_printers).difference(remote_printers) new_printer_ids = set(remote_printers).difference(current_printers) @@ -302,8 +302,8 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): return MaterialOutputModel(guid=material.guid, type=material_type, brand=brand, color=color, name=name) def _updatePrintJobs(self, jobs: List[CloudClusterPrintJob]) -> None: - remote_jobs: Dict[str, CloudClusterPrintJob] = {j.uuid: j for j in jobs} - current_jobs: Dict[str, UM3PrintJobOutputModel] = {j.key: j for j in self._print_jobs} + remote_jobs = {j.uuid: j for j in jobs} # type: Dict[str, CloudClusterPrintJob] + current_jobs = {j.key: j for j in self._print_jobs} # type: Dict[str, UM3PrintJobOutputModel] removed_job_ids = set(current_jobs).difference(set(remote_jobs)) new_job_ids = set(remote_jobs.keys()).difference(set(current_jobs)) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models.py b/plugins/UM3NetworkPrinting/src/Cloud/Models.py index 780fa06172..7b9ad460c5 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models.py @@ -8,11 +8,11 @@ from ..Models import BaseModel ## Class representing a cloud connected cluster. class CloudCluster(BaseModel): def __init__(self, **kwargs): - self.cluster_id: str = None - self.host_guid: str = None - self.host_name: str = None - self.host_version: str = None - self.status: str = None + self.cluster_id = None # type: str + self.host_guid = None # type: str + self.host_name = None # type: str + self.host_version = None # type: str + self.status = None # type: str super().__init__(**kwargs) def validate(self): @@ -23,20 +23,20 @@ class CloudCluster(BaseModel): ## Class representing a cloud cluster printer configuration class CloudClusterPrinterConfigurationMaterial(BaseModel): def __init__(self, **kwargs): - self.guid: str = None - self.brand: str = None - self.color: str = None - self.material: str = None + self.guid = None # type: str + self.brand = None # type: str + self.color = None # type: str + self.material = None # type: str super().__init__(**kwargs) ## Class representing a cloud cluster printer configuration class CloudClusterPrinterConfiguration(BaseModel): def __init__(self, **kwargs): - self.extruder_index: str = None - self.material: CloudClusterPrinterConfigurationMaterial = None - self.nozzle_diameter: str = None - self.print_core_id: str = None + self.extruder_index = None # type: str + self.material = None # type: CloudClusterPrinterConfigurationMaterial + self.nozzle_diameter = None # type: str + self.print_core_id = None # type: str super().__init__(**kwargs) if isinstance(self.material, dict): @@ -46,15 +46,15 @@ class CloudClusterPrinterConfiguration(BaseModel): ## Class representing a cluster printer class CloudClusterPrinter(BaseModel): def __init__(self, **kwargs): - self.configuration: List[CloudClusterPrinterConfiguration] = [] - self.enabled: str = None - self.firmware_version: str = None - self.friendly_name: str = None - self.ip_address: str = None - self.machine_variant: str = None - self.status: str = None - self.unique_name: str = None - self.uuid: str = None + self.configuration = [] # type: List[CloudClusterPrinterConfiguration] + self.enabled = None # type: str + self.firmware_version = None # type: str + self.friendly_name = None # type: str + self.ip_address = None # type: str + self.machine_variant = None # type: str + self.status = None # type: str + self.unique_name = None # type: str + self.uuid = None # type: str super().__init__(**kwargs) self.configuration = [CloudClusterPrinterConfiguration(**c) @@ -64,29 +64,29 @@ class CloudClusterPrinter(BaseModel): ## Class representing a cloud cluster print job constraint class CloudClusterPrintJobConstraint(BaseModel): def __init__(self, **kwargs): - self.require_printer_name: str = None + self.require_printer_name = None # type: str super().__init__(**kwargs) ## Class representing a print job class CloudClusterPrintJob(BaseModel): def __init__(self, **kwargs): - self.assigned_to: str = None - self.configuration: List[CloudClusterPrinterConfiguration] = [] - self.constraints: List[CloudClusterPrintJobConstraint] = [] - self.created_at: str = None - self.force: str = None - self.last_seen: str = None - self.machine_variant: str = None - self.name: str = None - self.network_error_count: int = None - self.owner: str = None - self.printer_uuid: str = None - self.started: str = None - self.status: str = None - self.time_elapsed: str = None - self.time_total: str = None - self.uuid: str = None + self.assigned_to = None # type: str + self.configuration = [] # type: List[CloudClusterPrinterConfiguration] + self.constraints = [] # type: List[CloudClusterPrintJobConstraint] + self.created_at = None # type: str + self.force = None # type: str + self.last_seen = None # type: str + self.machine_variant = None # type: str + self.name = None # type: str + self.network_error_count = None # type: int + self.owner = None # type: str + self.printer_uuid = None # type: str + self.started = None # type: str + self.status = None # type: str + self.time_elapsed = None # type: str + self.time_total = None # type: str + self.uuid = None # type: str super().__init__(**kwargs) self.printers = [CloudClusterPrinterConfiguration(**c) if isinstance(c, dict) else c for c in self.configuration] @@ -96,8 +96,8 @@ class CloudClusterPrintJob(BaseModel): class CloudClusterStatus(BaseModel): def __init__(self, **kwargs): - self.printers: List[CloudClusterPrinter] = [] - self.print_jobs: List[CloudClusterPrintJob] = [] + self.printers = [] # type: List[CloudClusterPrinter] + self.print_jobs = [] # type: List[CloudClusterPrintJob] super().__init__(**kwargs) self.printers = [CloudClusterPrinter(**p) if isinstance(p, dict) else p for p in self.printers] @@ -106,25 +106,25 @@ class CloudClusterStatus(BaseModel): class JobUploadRequest(BaseModel): def __init__(self, **kwargs): - self.file_size: int = None - self.job_name: str = None + self.file_size = None # type: int + self.job_name = None # type: str super().__init__(**kwargs) class JobUploadResponse(BaseModel): def __init__(self, **kwargs): - self.download_url: str = None - self.job_id: str = None - self.job_name: str = None - self.slicing_details: str = None - self.status: str = None - self.upload_url: str = None + self.download_url = None # type: str + self.job_id = None # type: str + self.job_name = None # type: str + self.slicing_details = None # type: str + self.status = None # type: str + self.upload_url = None # type: str super().__init__(**kwargs) class PrintResponse(BaseModel): def __init__(self, **kwargs): - self.cluster_job_id: str = None - self.job_id: str = None - self.status: str = None + self.cluster_job_id = None # type: str + self.job_id = None # type: str + self.status = None # type: str super().__init__(**kwargs) From 5db6bd9a9bd7334e2f8d27de892094a39df806d9 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Mon, 3 Dec 2018 17:09:04 +0100 Subject: [PATCH 037/178] Send content type to API when uploading print job --- .../UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py | 9 +++++++-- plugins/UM3NetworkPrinting/src/Cloud/Models.py | 1 + 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index 3d577d0991..dbb5ebf263 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -129,7 +129,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): stream = io.StringIO() if file_format["mode"] == FileWriter.OutputMode.TextMode else io.BytesIO() writer.write(stream, nodes) - self._sendPrintJob(file_name + "." + file_format["extension"], stream) + self._sendPrintJob(file_name + "." + file_format["extension"], file_format["mime_type"], stream) # TODO: This is yanked right out of ClusterUM3OutputDevice, great candidate for a utility or base class def _determineFileFormat(self, file_handler) -> Optional[Dict[str, Union[str, int]]]: @@ -339,12 +339,13 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): model.updateOwner(job.owner) model.updateState(job.status) - def _sendPrintJob(self, file_name: str, stream: Union[io.StringIO, io.BytesIO]) -> None: + def _sendPrintJob(self, file_name: str, content_type: str, stream: Union[io.StringIO, io.BytesIO]) -> None: mesh = stream.getvalue() request = JobUploadRequest() request.job_name = file_name request.file_size = len(mesh) + request.content_type = content_type Logger.log("i", "Creating new cloud print job: %s", request.__dict__) self.put("{}/jobs/upload".format(self.CURA_API_ROOT), data = json.dumps({"data": request.__dict__}), @@ -355,6 +356,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): if status_code > 204 or not isinstance(response, dict) or "data" not in response: Logger.log("w", "Got unexpected response while trying to add print job to cluster: {}, {}" .format(status_code, response)) + self.writeError.emit() return # TODO: Multipart upload @@ -368,6 +370,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): if status_code > 204: Logger.logException("w", "Received unexpected response from the job upload: %s, %s.", status_code, bytes(reply.readAll()).decode()) + self.writeError.emit() return Logger.log("i", "Print job uploaded successfully: %s", reply.readAll()) @@ -379,7 +382,9 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): if status_code > 204 or not isinstance(response, dict) or "data" not in response: Logger.log("w", "Got unexpected response while trying to request printing: %s, %s", status_code, response) + self.writeError.emit() return print_response = PrintResponse(**response["data"]) Logger.log("i", "Print job requested successfully: %s", print_response.__dict__) + self.writeFinished.emit() diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models.py b/plugins/UM3NetworkPrinting/src/Cloud/Models.py index 7b9ad460c5..22a733c70e 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models.py @@ -108,6 +108,7 @@ class JobUploadRequest(BaseModel): def __init__(self, **kwargs): self.file_size = None # type: int self.job_name = None # type: str + self.content_type = None # type: str super().__init__(**kwargs) From 90ec3f6cf95e2d1d545320845c642f5af3901605 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Mon, 3 Dec 2018 17:22:21 +0100 Subject: [PATCH 038/178] Add TODO for progress messages --- plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index dbb5ebf263..28b219469a 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -359,6 +359,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): self.writeError.emit() return + # TODO: add progress messages so we have visual feedback when uploading to cloud # TODO: Multipart upload job_response = JobUploadResponse(**response.get("data")) Logger.log("i", "Print job created successfully: %s", job_response.__dict__) From d91efc656a28b4d5b94e9f2bff987a3c1ccca493 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Mon, 3 Dec 2018 17:38:31 +0100 Subject: [PATCH 039/178] Add some more todo's for UI messages --- .../src/Cloud/CloudOutputDevice.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index 28b219469a..2f59b6aeea 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -13,6 +13,7 @@ from UM import i18nCatalog from UM.FileHandler.FileWriter import FileWriter from UM.FileHandler.FileHandler import FileHandler from UM.Logger import Logger +from UM.Message import Message from UM.OutputDevice import OutputDeviceError from UM.Scene.SceneNode import SceneNode from UM.Version import Version @@ -389,3 +390,17 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): print_response = PrintResponse(**response["data"]) Logger.log("i", "Print job requested successfully: %s", print_response.__dict__) self.writeFinished.emit() + + def _showUploadErrorMessage(self): + message = Message(self.I18N_CATALOG.i18nc( + "@info:status", "Sending new jobs (temporarily) blocked, still sending the previous print job.")) + message.show() + + def _showOrUpdateUploadProgressMessage(self, new_progress = 0): + # TODO: implement this + # See ClusterUM3OutputDevice for inspiration + pass + + def _showUploadSuccessMessage(self): + # TODO: implement this + pass From 0852d2ebefe7a6732124ead1498910feafafe845 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Mon, 3 Dec 2018 17:42:39 +0100 Subject: [PATCH 040/178] add one more TODO --- .../UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 7c10cb4e50..2df07fca77 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -132,7 +132,7 @@ class CloudOutputDeviceManager(NetworkClient): local_device_id = active_machine.getMetaDataEntry("um_network_key") if local_device_id: active_output_device = self._output_device_manager.getActiveDevice() - # We must find a match for the active machine and a cloud device + # TODO: We must find a match for the active machine and a cloud device stored_cluster_id = active_machine.getMetaDataEntry("um_cloud_cluster_id") if stored_cluster_id not in self._remote_clusters.keys(): From 8d6f109092619b7ff91ae88f6dc7781cc31e5e6d Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Mon, 3 Dec 2018 20:11:53 +0100 Subject: [PATCH 041/178] Set priority to 2 --- plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index 2f59b6aeea..ee84762cf8 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -108,7 +108,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): ## Set all the interface elements and texts for this output device. def _setInterfaceElements(self): - self.setPriority(3) + self.setPriority(2) # make sure we end up below the local networking and above 'save to file' self.setName(self._id) # TODO: how to name these? self.setShortDescription(self.I18N_CATALOG.i18nc("@action:button", "Print via Cloud")) From 08e1b4691b5791fe1ae19ccd56cbac226ae3813e Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Mon, 3 Dec 2018 20:19:16 +0100 Subject: [PATCH 042/178] Remove TODO --- plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index ee84762cf8..faca2472ad 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -110,7 +110,6 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): def _setInterfaceElements(self): self.setPriority(2) # make sure we end up below the local networking and above 'save to file' self.setName(self._id) - # TODO: how to name these? self.setShortDescription(self.I18N_CATALOG.i18nc("@action:button", "Print via Cloud")) self.setDescription(self.I18N_CATALOG.i18nc("@properties:tooltip", "Print via Cloud")) self.setConnectionText(self.I18N_CATALOG.i18nc("@info:status", "Connected via Cloud")) From 5fdff1778261c2eb8a5267bca4711f50127a080e Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Mon, 3 Dec 2018 22:12:18 +0100 Subject: [PATCH 043/178] Add upload messages for UI feedback, needs some refactoring --- .../NetworkedPrinterOutputDevice.py | 5 +- .../src/Cloud/CloudOutputDevice.py | 98 ++++++++++++++----- .../UM3NetworkPrinting/src/Cloud/Models.py | 1 + 3 files changed, 76 insertions(+), 28 deletions(-) diff --git a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py index 0a799d4cd3..5677106782 100644 --- a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py +++ b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py @@ -180,13 +180,16 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): self._createNetworkManager() assert (self._manager is not None) - def put(self, target: str, data: Union[str, bytes], on_finished: Optional[Callable[[QNetworkReply], None]]) -> None: + def put(self, target: str, data: Union[str, bytes], on_finished: Optional[Callable[[QNetworkReply], None]], + on_progress: Optional[Callable] = None) -> None: self._validateManager() request = self._createEmptyRequest(target) self._last_request_time = time() if self._manager is not None: reply = self._manager.put(request, data if isinstance(data, bytes) else data.encode()) self._registerOnFinishedCallback(reply, on_finished) + if on_progress is not None: + reply.uploadProgress.connect(on_progress) else: Logger.log("e", "Could not find manager.") diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index faca2472ad..db5ad21b06 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -71,6 +71,10 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): # Properties to populate later on with received cloud data. self._print_jobs = [] # type: List[UM3PrintJobOutputModel] self._number_of_extruders = 2 # All networked printers are dual-extrusion Ultimaker machines. + + # We only allow a single upload at a time. + self._sending_job = False + self._progress_message = None # type: Optional[Message] @staticmethod def _parseReply(reply: QNetworkReply) -> Tuple[int, Union[None, str, bytes]]: @@ -117,14 +121,22 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): ## Called when Cura requests an output device to receive a (G-code) file. def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mime_types: bool = False, file_handler: Optional[FileHandler] = None, **kwargs: str) -> None: + + # Show an error message if we're already sending a job. + if self._sending_job: + self._onUploadError(self.I18N_CATALOG.i18nc( + "@info:status", "Sending new jobs (temporarily) blocked, still sending the previous print job.")) + return + + # Indicate we have started sending a job. + self._sending_job = True self.writeStarted.emit(self) file_format = self._determineFileFormat(file_handler) writer = self._determineWriter(file_handler, file_format) - - # This function pauses with the yield, waiting on instructions on which printer it needs to print with. if not writer: Logger.log("e", "Missing file or mesh writer!") + self._onUploadError(self.I18N_CATALOG.i18nc("@info:status", "Could not export print job.")) return stream = io.StringIO() if file_format["mode"] == FileWriter.OutputMode.TextMode else io.BytesIO() @@ -186,11 +198,12 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): def printers(self): return self._printers + ## Get remote print jobs. @pyqtProperty("QVariantList", notify = printJobsChanged) def printJobs(self)-> List[UM3PrintJobOutputModel]: return self._print_jobs - ## Get remote print jobs. + ## Get remote print jobs that are still in the print queue. @pyqtProperty("QVariantList", notify = printJobsChanged) def queuedPrintJobs(self) -> List[UM3PrintJobOutputModel]: return [print_job for print_job in self._print_jobs @@ -354,24 +367,27 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): def _onPrintJobCreated(self, mesh: bytes, reply: QNetworkReply) -> None: status_code, response = self._parseReply(reply) if status_code > 204 or not isinstance(response, dict) or "data" not in response: - Logger.log("w", "Got unexpected response while trying to add print job to cluster: {}, {}" - .format(status_code, response)) - self.writeError.emit() + Logger.log("w", "Unexpected response while adding to queue: {}, {}".format(status_code, response)) + self._onUploadError(self.I18N_CATALOG.i18nc("@info:status", "Could not add print job to queue.")) return - # TODO: add progress messages so we have visual feedback when uploading to cloud # TODO: Multipart upload job_response = JobUploadResponse(**response.get("data")) Logger.log("i", "Print job created successfully: %s", job_response.__dict__) self.put(job_response.upload_url, data=mesh, - on_finished=lambda r: self._onPrintJobUploaded(job_response.job_id, r)) + on_finished=lambda r: self._onPrintJobUploaded(job_response.job_id, r), + on_progress = self._onUploadPrintJobProgress) + + def _onUploadPrintJobProgress(self, bytes_sent: int, bytes_total: int) -> None: + if bytes_total > 0: + self._updateUploadProgress(int((bytes_sent / bytes_total) * 100)) def _onPrintJobUploaded(self, job_id: str, reply: QNetworkReply) -> None: status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) if status_code > 204: - Logger.logException("w", "Received unexpected response from the job upload: %s, %s.", status_code, - bytes(reply.readAll()).decode()) - self.writeError.emit() + Logger.log("w", "Received unexpected response from the job upload: %s, %s.", status_code, + bytes(reply.readAll()).decode()) + self._onUploadError(self.I18N_CATALOG.i18nc("@info:status", "Could not add print job to queue.")) return Logger.log("i", "Print job uploaded successfully: %s", reply.readAll()) @@ -381,25 +397,53 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): def _onPrintJobRequested(self, reply: QNetworkReply) -> None: status_code, response = self._parseReply(reply) if status_code > 204 or not isinstance(response, dict) or "data" not in response: - Logger.log("w", "Got unexpected response while trying to request printing: %s, %s", - status_code, response) - self.writeError.emit() + Logger.log("w", "Got unexpected response while trying to request printing: %s, %s", status_code, response) + self._onUploadError(self.I18N_CATALOG.i18nc("@info:status", "Could not add print job to queue.")) return print_response = PrintResponse(**response["data"]) Logger.log("i", "Print job requested successfully: %s", print_response.__dict__) - self.writeFinished.emit() + self._onUploadSuccess() - def _showUploadErrorMessage(self): - message = Message(self.I18N_CATALOG.i18nc( - "@info:status", "Sending new jobs (temporarily) blocked, still sending the previous print job.")) + def _updateUploadProgress(self, progress: int): + if not self._progress_message: + self._progress_message = Message( + text = self.I18N_CATALOG.i18nc("@info:status", "Sending data to remote cluster"), + title = self.I18N_CATALOG.i18nc("@info:title", "Sending Data..."), + progress = -1, + lifetime = 0, + dismissable = False, + use_inactivity_timer = False + ) + self._progress_message.setProgress(progress) + self._progress_message.show() + + def _resetUploadProgress(self): + if self._progress_message: + self._progress_message.hide() + self._progress_message = None + + def _onUploadError(self, message: str = None): + self._resetUploadProgress() + if message: + message = Message( + text = message, + title = self.I18N_CATALOG.i18nc("@info:title", "Error"), + lifetime = 10, + dismissable = True + ) + message.show() + self._sending_job = False # the upload has failed so we're not sending a job anymore + self.writeError.emit() + + def _onUploadSuccess(self): + self._resetUploadProgress() + message = Message( + text = self.I18N_CATALOG.i18nc("@info:status", "Print job was successfully sent to the printer."), + title = self.I18N_CATALOG.i18nc("@info:title", "Data Sent"), + lifetime = 5, + dismissable = True, + ) message.show() - - def _showOrUpdateUploadProgressMessage(self, new_progress = 0): - # TODO: implement this - # See ClusterUM3OutputDevice for inspiration - pass - - def _showUploadSuccessMessage(self): - # TODO: implement this - pass + self._sending_job = False # the upload has finished so we're not sending a job anymore + self.writeFinished.emit() diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models.py b/plugins/UM3NetworkPrinting/src/Cloud/Models.py index 22a733c70e..e1c25cc662 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models.py @@ -120,6 +120,7 @@ class JobUploadResponse(BaseModel): self.slicing_details = None # type: str self.status = None # type: str self.upload_url = None # type: str + self.content_type = None # type: str super().__init__(**kwargs) From c40d76f9ee6479b4c14c1b35bb061aaa282a1411 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Mon, 3 Dec 2018 23:13:24 +0100 Subject: [PATCH 044/178] Fix formatting according to Cura code style --- .../src/Cloud/CloudOutputDevice.py | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index db5ad21b06..e2957cacae 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -217,7 +217,8 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): def _update(self) -> None: super()._update() Logger.log("i", "Calling the cloud cluster") - self.get("{root}/cluster/{cluster_id}/status".format(root=self.CLUSTER_API_ROOT, cluster_id=self._device_id), + self.get("{root}/cluster/{cluster_id}/status".format(root = self.CLUSTER_API_ROOT, + cluster_id = self._device_id), on_finished = self._onStatusCallFinished) ## Method called when HTTP request to status endpoint is finished. @@ -257,7 +258,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): def _addPrinter(self, printer: CloudClusterPrinter) -> None: model = PrinterOutputModel( - PrinterOutputController(self), len(printer.configuration), firmware_version=printer.firmware_version + PrinterOutputController(self), len(printer.configuration), firmware_version = printer.firmware_version ) self._printers.append(model) self._updatePrinter(model, printer) @@ -291,10 +292,10 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): non_read_only_material_group_list = list(filter(lambda x: not x.is_read_only, material_group_list)) material_group = None if read_only_material_group_list: - read_only_material_group_list = sorted(read_only_material_group_list, key=lambda x: x.name) + read_only_material_group_list = sorted(read_only_material_group_list, key = lambda x: x.name) material_group = read_only_material_group_list[0] elif non_read_only_material_group_list: - non_read_only_material_group_list = sorted(non_read_only_material_group_list, key=lambda x: x.name) + non_read_only_material_group_list = sorted(non_read_only_material_group_list, key = lambda x: x.name) material_group = non_read_only_material_group_list[0] if material_group: @@ -304,15 +305,15 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): material_type = container.getMetaDataEntry("material") name = container.getName() else: - Logger.log("w", - "Unable to find material with guid {guid}. Using data as provided by cluster".format( - guid=material.guid)) + Logger.log("w", "Unable to find material with guid {guid}. Using data as provided by cluster" + .format(guid = material.guid)) color = material.color brand = material.brand material_type = material.material name = "Empty" if material.material == "empty" else "Unknown" - return MaterialOutputModel(guid=material.guid, type=material_type, brand=brand, color=color, name=name) + return MaterialOutputModel(guid = material.guid, type = material_type, brand = brand, color = color, + name = name) def _updatePrintJobs(self, jobs: List[CloudClusterPrintJob]) -> None: remote_jobs = {j.uuid: j for j in jobs} # type: Dict[str, CloudClusterPrintJob] @@ -374,8 +375,8 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): # TODO: Multipart upload job_response = JobUploadResponse(**response.get("data")) Logger.log("i", "Print job created successfully: %s", job_response.__dict__) - self.put(job_response.upload_url, data=mesh, - on_finished=lambda r: self._onPrintJobUploaded(job_response.job_id, r), + self.put(job_response.upload_url, data = mesh, + on_finished = lambda r: self._onPrintJobUploaded(job_response.job_id, r), on_progress = self._onUploadPrintJobProgress) def _onUploadPrintJobProgress(self, bytes_sent: int, bytes_total: int) -> None: @@ -392,7 +393,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): Logger.log("i", "Print job uploaded successfully: %s", reply.readAll()) url = "{}/cluster/{}/print/{}".format(self.CLUSTER_API_ROOT, self._device_id, job_id) - self.post(url, data="", on_finished=self._onPrintJobRequested) + self.post(url, data = "", on_finished = self._onPrintJobRequested) def _onPrintJobRequested(self, reply: QNetworkReply) -> None: status_code, response = self._parseReply(reply) From b50e9427684d4bbc81eeca3cd7c7ef693749c7e1 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Mon, 3 Dec 2018 23:42:41 +0100 Subject: [PATCH 045/178] Describe TODO for association by hostname, only connect when online --- .../src/Cloud/CloudOutputDeviceManager.py | 39 +++++++++---------- .../UM3NetworkPrinting/src/Cloud/Models.py | 1 + 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 2df07fca77..6da6c4f4d7 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -20,8 +20,6 @@ from .Models import CloudCluster # # API spec is available on https://api.ultimaker.com/docs/connect/spec/. # -# TODO: figure out how to pair remote clusters, local networked clusters and local cura printer presets. -# TODO: for now we just have multiple output devices if the cluster is available both locally and remote. class CloudOutputDeviceManager(NetworkClient): # The cloud URL to use for remote clusters. @@ -42,8 +40,9 @@ class CloudOutputDeviceManager(NetworkClient): self._account.loginStateChanged.connect(self._getRemoteClusters) # When switching machines we check if we have to activate a remote cluster. - application.globalContainerStackChanged.connect(self._activeMachineChanged) - + application.globalContainerStackChanged.connect(self._connectToActiveMachine) + + # TODO: fix this # Periodically check all remote clusters for the authenticated user. # self._update_clusters_thread = Thread(target=self._updateClusters, daemon=True) # self._update_clusters_thread.start() @@ -89,15 +88,13 @@ class CloudOutputDeviceManager(NetworkClient): # Add an output device for each new remote cluster. for cluster_id in found_cluster_ids.difference(known_cluster_ids): - self._addCloudOutputDevice(found_clusters[cluster_id]) + if found_clusters[cluster_id].is_online: + self._addCloudOutputDevice(found_clusters[cluster_id]) # Remove output devices that are gone for cluster_id in known_cluster_ids.difference(found_cluster_ids): self._removeCloudOutputDevice(found_clusters[cluster_id]) - # For testing we add a dummy device: - # self._addCloudOutputDevice(CloudCluster(cluster_id = "LJ0tciiuZZjarrXAvFLEZ6ox4Cvx8FvtXUlQv4vIhV6w")) - @staticmethod def _parseStatusResponse(reply: QNetworkReply) -> Dict[str, CloudCluster]: try: @@ -116,7 +113,9 @@ class CloudOutputDeviceManager(NetworkClient): device = CloudOutputDevice(cluster.cluster_id) self._output_device_manager.addOutputDevice(device) self._remote_clusters[cluster.cluster_id] = device - device.connect() # TODO: Only connect the current device + if cluster.is_online: + # We found a new online cluster, we might need to connect to it. + self._connectToActiveMachine() ## Remove a CloudOutputDevice def _removeCloudOutputDevice(self, cluster: CloudCluster): @@ -124,20 +123,20 @@ class CloudOutputDeviceManager(NetworkClient): del self._remote_clusters[cluster.cluster_id] ## Callback for when the active machine was changed by the user. - def _activeMachineChanged(self): + def _connectToActiveMachine(self) -> None: active_machine = CuraApplication.getInstance().getGlobalContainerStack() if not active_machine: return - - local_device_id = active_machine.getMetaDataEntry("um_network_key") - if local_device_id: - active_output_device = self._output_device_manager.getActiveDevice() - # TODO: We must find a match for the active machine and a cloud device - + + # Check if the stored cluster_id for the active machine is in our list of remote clusters. stored_cluster_id = active_machine.getMetaDataEntry("um_cloud_cluster_id") - if stored_cluster_id not in self._remote_clusters.keys(): - # Currently authenticated user does not have access to stored cluster or no user is signed in. + if stored_cluster_id in self._remote_clusters.keys(): + self._remote_clusters.get(stored_cluster_id).connect() return - # We found the active machine as remote cluster so let's connect to it. - self._remote_clusters.get(stored_cluster_id).connect() + # TODO: See if this cloud cluster still has to be associated to the active machine. + # TODO: We have to get a common piece of data, like local network hostname, from the active machine and + # TODO: cloud cluster and then set the "um_cloud_cluster_id" meta data key on the active machine. + # TODO: If so, we can also immediate connect to it. + # active_machine.setMetaDataEntry("um_cloud_cluster_id", "") + # self._remote_clusters.get(stored_cluster_id).connect() diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models.py b/plugins/UM3NetworkPrinting/src/Cloud/Models.py index e1c25cc662..d7cb68e5d3 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models.py @@ -13,6 +13,7 @@ class CloudCluster(BaseModel): self.host_name = None # type: str self.host_version = None # type: str self.status = None # type: str + self.is_online = None # type: bool super().__init__(**kwargs) def validate(self): From 1bcabc6f42867fb8a352255fd572f67f66f84fbe Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Mon, 3 Dec 2018 23:44:52 +0100 Subject: [PATCH 046/178] Fix is_online logic --- .../UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 6da6c4f4d7..06beb8bce4 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -87,6 +87,7 @@ class CloudOutputDeviceManager(NetworkClient): found_cluster_ids = set(found_clusters.keys()) # Add an output device for each new remote cluster. + # We only add when is_online as we don't want the option in the drop down if the cluster is not online. for cluster_id in found_cluster_ids.difference(known_cluster_ids): if found_clusters[cluster_id].is_online: self._addCloudOutputDevice(found_clusters[cluster_id]) @@ -113,9 +114,7 @@ class CloudOutputDeviceManager(NetworkClient): device = CloudOutputDevice(cluster.cluster_id) self._output_device_manager.addOutputDevice(device) self._remote_clusters[cluster.cluster_id] = device - if cluster.is_online: - # We found a new online cluster, we might need to connect to it. - self._connectToActiveMachine() + self._connectToActiveMachine() ## Remove a CloudOutputDevice def _removeCloudOutputDevice(self, cluster: CloudCluster): From 12b3f0088d49c2fb064f775a6c6d26177a32f41d Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Tue, 4 Dec 2018 10:07:43 +0100 Subject: [PATCH 047/178] Add content type to file upload --- cura/PrinterOutput/NetworkedPrinterOutputDevice.py | 5 +++-- plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py index 5677106782..72b6319020 100644 --- a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py +++ b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py @@ -180,10 +180,11 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): self._createNetworkManager() assert (self._manager is not None) - def put(self, target: str, data: Union[str, bytes], on_finished: Optional[Callable[[QNetworkReply], None]], + def put(self, target: str, data: Union[str, bytes], content_type: str = None, + on_finished: Optional[Callable[[QNetworkReply], None]] = None, on_progress: Optional[Callable] = None) -> None: self._validateManager() - request = self._createEmptyRequest(target) + request = self._createEmptyRequest(target, content_type = content_type) self._last_request_time = time() if self._manager is not None: reply = self._manager.put(request, data if isinstance(data, bytes) else data.encode()) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index e2957cacae..63a109cf5c 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -375,7 +375,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): # TODO: Multipart upload job_response = JobUploadResponse(**response.get("data")) Logger.log("i", "Print job created successfully: %s", job_response.__dict__) - self.put(job_response.upload_url, data = mesh, + self.put(job_response.upload_url, data = mesh, content_type = job_response.content_type, on_finished = lambda r: self._onPrintJobUploaded(job_response.job_id, r), on_progress = self._onUploadPrintJobProgress) From 97607419cfaddaa0403e8c48775fcb2530dcf114 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Tue, 4 Dec 2018 12:14:21 +0100 Subject: [PATCH 048/178] Start with some fakes for monitor page --- .../src/Cloud/CloudOutputDevice.py | 31 ++++++++++++++++++- .../src/Cloud/CloudOutputDeviceManager.py | 1 + 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index 63a109cf5c..747d911407 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -6,7 +6,7 @@ import os from json import JSONDecodeError from typing import List, Optional, Dict, cast, Union, Tuple -from PyQt5.QtCore import QObject, pyqtSignal, QUrl, pyqtProperty +from PyQt5.QtCore import QObject, pyqtSignal, QUrl, pyqtProperty, pyqtSlot from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest from UM import i18nCatalog @@ -209,6 +209,16 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): return [print_job for print_job in self._print_jobs if print_job.state == "queued" or print_job.state == "error"] + ## Get remote print jobs that are assigned to a printer. + @pyqtProperty("QVariantList", notify = printJobsChanged) + def activePrintJobs(self) -> List[UM3PrintJobOutputModel]: + return [print_job for print_job in self._print_jobs if + print_job.assignedPrinter is not None and print_job.state != "queued"] + + @pyqtProperty(bool, notify = printJobsChanged) + def receivedPrintJobs(self) -> bool: + return not self._sending_job + ## Called when the connection to the cluster changes. def connect(self) -> None: super().connect() @@ -448,3 +458,22 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): message.show() self._sending_job = False # the upload has finished so we're not sending a job anymore self.writeFinished.emit() + + ## TODO: The following methods are required by the monitor page QML, but are not actually available using cloud. + # TODO: We fake the methods here to not break the monitor page. + + @pyqtProperty(QObject, notify = printersChanged) + def activePrinter(self) -> Optional[PrinterOutputModel]: + return self._printers[0] or None + + @pyqtSlot(QObject) + def setActivePrinter(self, printer: Optional[PrinterOutputModel]) -> None: + pass + + @pyqtProperty(QUrl, notify = printersChanged) + def activeCameraUrl(self) -> "QUrl": + return QUrl() + + @pyqtSlot(QUrl) + def setActiveCameraUrl(self, camera_url: "QUrl") -> None: + pass diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 06beb8bce4..bc871ec7ac 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -114,6 +114,7 @@ class CloudOutputDeviceManager(NetworkClient): device = CloudOutputDevice(cluster.cluster_id) self._output_device_manager.addOutputDevice(device) self._remote_clusters[cluster.cluster_id] = device + device.connect() # TODO: remove this self._connectToActiveMachine() ## Remove a CloudOutputDevice From 894c69685a740641b62ab7431b338cd985e11009 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marijn=20De=C3=A9?= Date: Tue, 4 Dec 2018 13:06:27 +0100 Subject: [PATCH 049/178] Periodically update the remote clusters and printjobs --- .../src/Cloud/CloudOutputDeviceManager.py | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 06beb8bce4..e50cd6540c 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -8,6 +8,7 @@ from typing import Dict, Optional from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply from UM.Logger import Logger +from UM.Signal import Signal from cura.CuraApplication import CuraApplication from cura.NetworkClient import NetworkClient @@ -42,10 +43,14 @@ class CloudOutputDeviceManager(NetworkClient): # When switching machines we check if we have to activate a remote cluster. application.globalContainerStackChanged.connect(self._connectToActiveMachine) - # TODO: fix this # Periodically check all remote clusters for the authenticated user. - # self._update_clusters_thread = Thread(target=self._updateClusters, daemon=True) - # self._update_clusters_thread.start() + # This is done by emitting to _on_cluster_received by _update_clusters_thread + # The thread is only started after the user is authenticated, otherwise the api call results in + # an authentication error + self._on_cluster_received = Signal() + self._on_cluster_received.connect(self._getRemoteClusters) + self._update_clusters_thread = Thread(target=self._updateClusters, daemon=True) + ## Override _createEmptyRequest to add the needed authentication header for talking to the Ultimaker Cloud API. def _createEmptyRequest(self, path: str, content_type: Optional[str] = "application/json") -> QNetworkRequest: @@ -59,13 +64,25 @@ class CloudOutputDeviceManager(NetworkClient): ## Update the clusters def _updateClusters(self) -> None: while True: - self._getRemoteClusters() - sleep(self.CHECK_CLUSTER_INTERVAL) - + + # Stop if the application is shutting down + if CuraApplication.getInstance().isShuttingDown(): + return + + self._on_cluster_received.emit() + sleep(5) + ## Gets all remote clusters from the API. def _getRemoteClusters(self) -> None: Logger.log("i", "Retrieving remote clusters") - self.get("/clusters", on_finished = self._onGetRemoteClustersFinished) + if self._account.isLoggedIn: + self.get("/clusters", on_finished = self._onGetRemoteClustersFinished) + + # Only start the polling thread after the user is authenticated + # The first call to _getRemoteClusters comes from self._account.loginStateChanged + if not self._update_clusters_thread.is_alive(): + self._update_clusters_thread.start() + ## Callback for when the request for getting the clusters. is finished. def _onGetRemoteClustersFinished(self, reply: QNetworkReply) -> None: From 02963eb9bfdeac576786889e5568203ee11ab764 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marijn=20De=C3=A9?= Date: Tue, 4 Dec 2018 13:19:12 +0100 Subject: [PATCH 050/178] Use a timer for the periodic update of the remote clusters and printjobs --- .../src/Cloud/CloudOutputDeviceManager.py | 22 +++---------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 37198fd7c6..85e734f7a3 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -2,7 +2,7 @@ # Cura is released under the terms of the LGPLv3 or higher. import json from time import sleep -from threading import Thread +from threading import Timer from typing import Dict, Optional from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply @@ -43,13 +43,8 @@ class CloudOutputDeviceManager(NetworkClient): # When switching machines we check if we have to activate a remote cluster. application.globalContainerStackChanged.connect(self._connectToActiveMachine) - # Periodically check all remote clusters for the authenticated user. - # This is done by emitting to _on_cluster_received by _update_clusters_thread - # The thread is only started after the user is authenticated, otherwise the api call results in - # an authentication error self._on_cluster_received = Signal() self._on_cluster_received.connect(self._getRemoteClusters) - self._update_clusters_thread = Thread(target=self._updateClusters, daemon=True) ## Override _createEmptyRequest to add the needed authentication header for talking to the Ultimaker Cloud API. @@ -61,17 +56,6 @@ class CloudOutputDeviceManager(NetworkClient): request.setRawHeader(b"Authorization", "Bearer {}".format(self._account.accessToken).encode()) return request - ## Update the clusters - def _updateClusters(self) -> None: - while True: - - # Stop if the application is shutting down - if CuraApplication.getInstance().isShuttingDown(): - return - - self._on_cluster_received.emit() - sleep(5) - ## Gets all remote clusters from the API. def _getRemoteClusters(self) -> None: Logger.log("i", "Retrieving remote clusters") @@ -80,8 +64,8 @@ class CloudOutputDeviceManager(NetworkClient): # Only start the polling thread after the user is authenticated # The first call to _getRemoteClusters comes from self._account.loginStateChanged - if not self._update_clusters_thread.is_alive(): - self._update_clusters_thread.start() + timer = Timer(5.0, self._on_cluster_received.emit) + timer.start() ## Callback for when the request for getting the clusters. is finished. From 8e3e0c149e6963f01485ff411902ad5e932b764d Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Tue, 4 Dec 2018 13:23:31 +0100 Subject: [PATCH 051/178] fixes --- .../src/Cloud/CloudOutputDevice.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index 747d911407..d17728f513 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -215,10 +215,6 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): return [print_job for print_job in self._print_jobs if print_job.assignedPrinter is not None and print_job.state != "queued"] - @pyqtProperty(bool, notify = printJobsChanged) - def receivedPrintJobs(self) -> bool: - return not self._sending_job - ## Called when the connection to the cluster changes. def connect(self) -> None: super().connect() @@ -464,7 +460,9 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): @pyqtProperty(QObject, notify = printersChanged) def activePrinter(self) -> Optional[PrinterOutputModel]: - return self._printers[0] or None + if not self._printers: + return None + return self._printers[0] @pyqtSlot(QObject) def setActivePrinter(self, printer: Optional[PrinterOutputModel]) -> None: @@ -477,3 +475,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): @pyqtSlot(QUrl) def setActiveCameraUrl(self, camera_url: "QUrl") -> None: pass + + @pyqtProperty(bool, notify = printJobsChanged) + def receivedPrintJobs(self) -> bool: + return True From 5d77209cfbadbf5f43571bf6cf350273ed874c79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marijn=20De=C3=A9?= Date: Tue, 4 Dec 2018 13:57:41 +0100 Subject: [PATCH 052/178] Be more efficient in updating the print jobs --- plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py | 6 ++++-- .../src/Cloud/CloudOutputDeviceManager.py | 1 - 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index d17728f513..0f3a92f9d8 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -338,8 +338,10 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): for job_id in updated_job_ids: self._updateUM3PrintJobOutputModel(current_jobs[job_id], remote_jobs[job_id]) - # TODO: properly handle removed and updated printers - self.printJobsChanged.emit() + # We only have to update when jobs are added or removed + # updated jobs push their changes via their outputmodel + if len(removed_job_ids) > 0 or len(new_job_ids) > 0: + self.printJobsChanged.emit() def _addPrintJob(self, job: CloudClusterPrintJob) -> None: try: diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 85e734f7a3..c6134d9a63 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -1,7 +1,6 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. import json -from time import sleep from threading import Timer from typing import Dict, Optional From e98f3bff384c384eaab419c6997de52e55a7bb25 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Tue, 4 Dec 2018 14:09:20 +0100 Subject: [PATCH 053/178] Implement test version of showing cloud connected printers in list --- cura/Settings/MachineManager.py | 6 +++ .../src/Cloud/CloudOutputDeviceManager.py | 9 +++- resources/qml/Menus/CloudPrinterMenu.qml | 26 +++++++++++ resources/qml/Menus/PrinterMenu.qml | 17 +++++++ .../PrinterSelector/MachineSelectorList.qml | 44 ++++++++++++++++++- 5 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 resources/qml/Menus/CloudPrinterMenu.qml diff --git a/cura/Settings/MachineManager.py b/cura/Settings/MachineManager.py index 53390ca88d..15e2c67c33 100755 --- a/cura/Settings/MachineManager.py +++ b/cura/Settings/MachineManager.py @@ -527,6 +527,12 @@ class MachineManager(QObject): return self._global_container_stack.getMetaDataEntry("um_network_key", "") return "" + @pyqtProperty(str, notify=printerConnectedStatusChanged) + def activeMachineCloudKey(self) -> str: + if self._global_container_stack: + return self._global_container_stack.getMetaDataEntry("um_cloud_cluster_id", "") + return "" + @pyqtProperty(str, notify = printerConnectedStatusChanged) def activeMachineNetworkGroupName(self) -> str: if self._global_container_stack: diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 85e734f7a3..6c5d681a39 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -116,7 +116,7 @@ class CloudOutputDeviceManager(NetworkClient): self._output_device_manager.addOutputDevice(device) self._remote_clusters[cluster.cluster_id] = device device.connect() # TODO: remove this - self._connectToActiveMachine() + self._connectToActiveMachine(cluster.cluster_id, cluster.host_name) ## Remove a CloudOutputDevice def _removeCloudOutputDevice(self, cluster: CloudCluster): @@ -124,10 +124,15 @@ class CloudOutputDeviceManager(NetworkClient): del self._remote_clusters[cluster.cluster_id] ## Callback for when the active machine was changed by the user. - def _connectToActiveMachine(self) -> None: + def _connectToActiveMachine(self, cluster_id: Optional[str] = None, host_name: Optional[str] = None) -> None: active_machine = CuraApplication.getInstance().getGlobalContainerStack() if not active_machine: return + + # TODO: Remove this once correct pairing has been added (see below). + if cluster_id: + active_machine.setMetaDataEntry("um_cloud_cluster_id", cluster_id) + active_machine.setMetaDataEntry("connect_group_name", host_name) # Check if the stored cluster_id for the active machine is in our list of remote clusters. stored_cluster_id = active_machine.getMetaDataEntry("um_cloud_cluster_id") diff --git a/resources/qml/Menus/CloudPrinterMenu.qml b/resources/qml/Menus/CloudPrinterMenu.qml new file mode 100644 index 0000000000..4ceebbfdfc --- /dev/null +++ b/resources/qml/Menus/CloudPrinterMenu.qml @@ -0,0 +1,26 @@ +// Copyright (c) 2018 Ultimaker B.V. +// Cura is released under the terms of the LGPLv3 or higher. +import QtQuick 2.2 +import QtQuick.Controls 1.4 + +import UM 1.2 as UM +import Cura 1.0 as Cura + +Instantiator { + + model: UM.ContainerStacksModel { + filter: {"type": "machine", "um_cloud_cluster_id": "*", "hidden": "False"} + } + + MenuItem { + // iconSource: UM.Theme.getIcon("printer_single") TODO: use cloud icon here + text: model.name + checkable: true + checked: true // cloud printers are only listed if they are actually online + exclusiveGroup: group; + onTriggered: Cura.MachineManager.setActiveMachine(model.id); + } + + onObjectAdded: menu.insertItem(index, object) + onObjectRemoved: menu.removeItem(object) +} diff --git a/resources/qml/Menus/PrinterMenu.qml b/resources/qml/Menus/PrinterMenu.qml index 741d927c13..a924b0e589 100644 --- a/resources/qml/Menus/PrinterMenu.qml +++ b/resources/qml/Menus/PrinterMenu.qml @@ -37,6 +37,23 @@ Menu visible: networkPrinterMenu.count > 0 } + MenuItem + { + text: catalog.i18nc("@label:category menu label", "Cloud enabled printers") + enabled: false + visible: cloudPrinterMenu.count > 0 + } + + CloudPrinterMenu + { + id: cloudPrinterMenu + } + + MenuSeparator + { + visible: cloudPrinterMenu.count > 0 + } + MenuItem { text: catalog.i18nc("@label:category menu label", "Local printers") diff --git a/resources/qml/PrinterSelector/MachineSelectorList.qml b/resources/qml/PrinterSelector/MachineSelectorList.qml index 5ef04b7351..26c703fddd 100644 --- a/resources/qml/PrinterSelector/MachineSelectorList.qml +++ b/resources/qml/PrinterSelector/MachineSelectorList.qml @@ -50,6 +50,46 @@ Column } } + Label + { + text: catalog.i18nc("@label", "Cloud connected printers") + visible: cloudPrintersModel.items.length > 0 + leftPadding: UM.Theme.getSize("default_margin").width + height: visible ? contentHeight + 2 * UM.Theme.getSize("default_margin").height : 0 + renderType: Text.NativeRendering + font: UM.Theme.getFont("medium") + color: UM.Theme.getColor("text_medium") + verticalAlignment: Text.AlignVCenter + } + + Repeater + { + id: cloudPrinters + + model: UM.ContainerStacksModel + { + id: cloudPrintersModel + filter: + { + "type": "machine", + "um_cloud_cluster_id": "*" + } + } + + delegate: MachineSelectorButton + { + text: model.metadata["connect_group_name"] + checked: Cura.MachineManager.activeMachineNetworkGroupName == model.metadata["connect_group_name"] + outputDevice: Cura.MachineManager.printerOutputDevices.length >= 1 ? Cura.MachineManager.printerOutputDevices[0] : null + + Connections + { + target: Cura.MachineManager + onActiveMachineNetworkGroupNameChanged: checked = Cura.MachineManager.activeMachineNetworkGroupName == model.metadata["connect_group_name"] + } + } + } + Label { text: catalog.i18nc("@label", "Preset printers") @@ -71,7 +111,9 @@ Column id: virtualPrintersModel filter: { - "type": "machine", "um_network_key": null + "type": "machine", + "um_network_key": null, + "um_cloud_cluster_id": null } } From 7de947f5fab60e8cc9d38c17d5f0c62653cd13f0 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Tue, 4 Dec 2018 14:15:19 +0100 Subject: [PATCH 054/178] use correct label text --- resources/qml/Menus/CloudPrinterMenu.qml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/qml/Menus/CloudPrinterMenu.qml b/resources/qml/Menus/CloudPrinterMenu.qml index 4ceebbfdfc..bd03890642 100644 --- a/resources/qml/Menus/CloudPrinterMenu.qml +++ b/resources/qml/Menus/CloudPrinterMenu.qml @@ -9,12 +9,12 @@ import Cura 1.0 as Cura Instantiator { model: UM.ContainerStacksModel { - filter: {"type": "machine", "um_cloud_cluster_id": "*", "hidden": "False"} + filter: {"type": "machine", "um_cloud_cluster_id": "*"} } MenuItem { // iconSource: UM.Theme.getIcon("printer_single") TODO: use cloud icon here - text: model.name + text: model.metadata["connect_group_name"] checkable: true checked: true // cloud printers are only listed if they are actually online exclusiveGroup: group; From a9273ec2b5563f57781eab7d096f7a8793b1a4d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marijn=20De=C3=A9?= Date: Tue, 4 Dec 2018 15:20:24 +0100 Subject: [PATCH 055/178] Use QTimer instead of threading.Timer --- .../src/Cloud/CloudOutputDeviceManager.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 6f3f1fb9d7..f5f3555145 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -1,9 +1,9 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. import json -from threading import Timer from typing import Dict, Optional +from PyQt5.QtCore import QTimer from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply from UM.Logger import Logger @@ -45,6 +45,11 @@ class CloudOutputDeviceManager(NetworkClient): self._on_cluster_received = Signal() self._on_cluster_received.connect(self._getRemoteClusters) + self.update_timer = QTimer(CuraApplication.getInstance()) + self.update_timer.setInterval(self.CHECK_CLUSTER_INTERVAL * 1000) + self.update_timer.setSingleShot(False) + self.update_timer.timeout.connect(self._on_cluster_received.emit) + ## Override _createEmptyRequest to add the needed authentication header for talking to the Ultimaker Cloud API. def _createEmptyRequest(self, path: str, content_type: Optional[str] = "application/json") -> QNetworkRequest: @@ -61,10 +66,9 @@ class CloudOutputDeviceManager(NetworkClient): if self._account.isLoggedIn: self.get("/clusters", on_finished = self._onGetRemoteClustersFinished) - # Only start the polling thread after the user is authenticated + # Only start the polling timer after the user is authenticated # The first call to _getRemoteClusters comes from self._account.loginStateChanged - timer = Timer(5.0, self._on_cluster_received.emit) - timer.start() + self.update_timer.start() ## Callback for when the request for getting the clusters. is finished. From 9046b39b436ffaf067de668eabb3bbdf0bf56e29 Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Tue, 4 Dec 2018 16:14:08 +0100 Subject: [PATCH 056/178] STAR-322: Creating a Cloud API client to handle the interaction --- cura/API/Account.py | 5 + cura/NetworkClient.py | 87 ++++++---- .../NetworkedPrinterOutputDevice.py | 111 ++++++++----- .../src/Cloud/CloudApiClient.py | 155 ++++++++++++++++++ .../src/Cloud/CloudOutputDevice.py | 138 ++++------------ .../src/Cloud/CloudOutputDeviceManager.py | 78 ++++----- .../UM3NetworkPrinting/src/Cloud/Models.py | 28 +++- 7 files changed, 367 insertions(+), 235 deletions(-) create mode 100644 plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py diff --git a/cura/API/Account.py b/cura/API/Account.py index d78c7e8826..70881000a3 100644 --- a/cura/API/Account.py +++ b/cura/API/Account.py @@ -61,6 +61,11 @@ class Account(QObject): self._authorization_service.onAuthenticationError.connect(self._onLoginStateChanged) self._authorization_service.loadAuthDataFromPreferences() + ## Returns a boolean indicating whether the given authentication is applied against staging or not. + @property + def is_staging(self) -> bool: + return "staging" in self._oauth_root + @pyqtProperty(bool, notify=loginStateChanged) def isLoggedIn(self) -> bool: return self._logged_in diff --git a/cura/NetworkClient.py b/cura/NetworkClient.py index fbe0c63c36..8a321b6af4 100644 --- a/cura/NetworkClient.py +++ b/cura/NetworkClient.py @@ -1,7 +1,7 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from time import time -from typing import Optional, Dict, Callable, List +from typing import Optional, Dict, Callable, List, Union from PyQt5.QtCore import QUrl from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkReply, QHttpMultiPart, QNetworkRequest, QHttpPart, \ @@ -49,6 +49,8 @@ class NetworkClient: ## Create a new empty network request. # Automatically adds the required HTTP headers. + # \param url: The URL to request + # \param content_type: The type of the body contents. def _createEmptyRequest(self, url: str, content_type: Optional[str] = "application/json") -> QNetworkRequest: request = QNetworkRequest(QUrl(url)) if content_type: @@ -120,67 +122,82 @@ class NetworkClient: def createFormPart(self, content_header: str, data: bytes, content_type: Optional[str] = None) -> QHttpPart: return self._createFormPart(content_header, data, content_type) - ## Does a PUT request to the given URL. - def put(self, url: str, data: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None: + ## Sends a put request to the given path. + # url: The path after the API prefix. + # data: The data to be sent in the body + # content_type: The content type of the body data. + # on_finished: The function to call when the response is received. + # on_progress: The function to call when the progress changes. Parameters are bytes_sent / bytes_total. + def put(self, url: str, data: Union[str, bytes], content_type: Optional[str] = None, + on_finished: Optional[Callable[[QNetworkReply], None]] = None, + on_progress: Optional[Callable[[int, int], None]] = None) -> None: self._validateManager() - - request = self._createEmptyRequest(url) - self._last_request_time = time() - - if not self._manager: - Logger.log("e", "No network manager was created to execute the PUT call with.") - return - reply = self._manager.put(request, data.encode()) + request = self._createEmptyRequest(url, content_type = content_type) + self._last_request_time = time() + + if not self._manager: + return Logger.log("e", "No network manager was created to execute the PUT call with.") + + body = data if isinstance(data, bytes) else data.encode() # type: bytes + reply = self._manager.put(request, body) self._registerOnFinishedCallback(reply, on_finished) - ## Does a DELETE request to the given URL. + if on_progress is not None: + reply.uploadProgress.connect(on_progress) + + ## Sends a delete request to the given path. + # url: The path after the API prefix. + # on_finished: The function to be call when the response is received. def delete(self, url: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None: self._validateManager() - + request = self._createEmptyRequest(url) self._last_request_time = time() - + if not self._manager: - Logger.log("e", "No network manager was created to execute the DELETE call with.") - return - + return Logger.log("e", "No network manager was created to execute the DELETE call with.") + reply = self._manager.deleteResource(request) self._registerOnFinishedCallback(reply, on_finished) - ## Does a GET request to the given URL. + ## Sends a get request to the given path. + # \param url: The path after the API prefix. + # \param on_finished: The function to be call when the response is received. def get(self, url: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None: self._validateManager() - + request = self._createEmptyRequest(url) self._last_request_time = time() - + if not self._manager: - Logger.log("e", "No network manager was created to execute the GET call with.") - return - + return Logger.log("e", "No network manager was created to execute the GET call with.") + reply = self._manager.get(request) self._registerOnFinishedCallback(reply, on_finished) - ## Does a POST request to the given URL. - def post(self, url: str, data: str, on_finished: Optional[Callable[[QNetworkReply], None]], - on_progress: Callable = None) -> None: + ## Sends a post request to the given path. + # \param url: The path after the API prefix. + # \param data: The data to be sent in the body + # \param on_finished: The function to call when the response is received. + # \param on_progress: The function to call when the progress changes. Parameters are bytes_sent / bytes_total. + def post(self, url: str, data: Union[str, bytes], + on_finished: Optional[Callable[[QNetworkReply], None]], + on_progress: Optional[Callable[[int, int], None]] = None) -> None: self._validateManager() - + request = self._createEmptyRequest(url) self._last_request_time = time() - + if not self._manager: - Logger.log("e", "No network manager was created to execute the GET call with.") - return - - reply = self._manager.post(request, data.encode()) - + return Logger.log("e", "Could not find manager.") + + body = data if isinstance(data, bytes) else data.encode() # type: bytes + reply = self._manager.post(request, body) if on_progress is not None: reply.uploadProgress.connect(on_progress) - self._registerOnFinishedCallback(reply, on_finished) - + ## Does a POST request with form data to the given URL. def postForm(self, url: str, header_data: str, body_data: bytes, on_finished: Optional[Callable[[QNetworkReply], None]], diff --git a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py index 72b6319020..300ed5194d 100644 --- a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py +++ b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py @@ -145,7 +145,7 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): url = QUrl("http://" + self._address + self._api_prefix + target) request = QNetworkRequest(url) if content_type is not None: - request.setHeader(QNetworkRequest.ContentTypeHeader, "application/json") + request.setHeader(QNetworkRequest.ContentTypeHeader, content_type) request.setHeader(QNetworkRequest.UserAgentHeader, self._user_agent) return request @@ -180,54 +180,85 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): self._createNetworkManager() assert (self._manager is not None) - def put(self, target: str, data: Union[str, bytes], content_type: str = None, + ## Sends a put request to the given path. + # url: The path after the API prefix. + # data: The data to be sent in the body + # content_type: The content type of the body data. + # on_finished: The function to call when the response is received. + # on_progress: The function to call when the progress changes. Parameters are bytes_sent / bytes_total. + def put(self, url: str, data: Union[str, bytes], content_type: Optional[str] = None, on_finished: Optional[Callable[[QNetworkReply], None]] = None, - on_progress: Optional[Callable] = None) -> None: + on_progress: Optional[Callable[[int, int], None]] = None) -> None: self._validateManager() - request = self._createEmptyRequest(target, content_type = content_type) - self._last_request_time = time() - if self._manager is not None: - reply = self._manager.put(request, data if isinstance(data, bytes) else data.encode()) - self._registerOnFinishedCallback(reply, on_finished) - if on_progress is not None: - reply.uploadProgress.connect(on_progress) - else: - Logger.log("e", "Could not find manager.") - def delete(self, target: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None: + request = self._createEmptyRequest(url, content_type = content_type) + self._last_request_time = time() + + if not self._manager: + return Logger.log("e", "No network manager was created to execute the PUT call with.") + + body = data if isinstance(data, bytes) else data.encode() # type: bytes + reply = self._manager.put(request, body) + self._registerOnFinishedCallback(reply, on_finished) + + if on_progress is not None: + reply.uploadProgress.connect(on_progress) + + ## Sends a delete request to the given path. + # url: The path after the API prefix. + # on_finished: The function to be call when the response is received. + def delete(self, url: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None: self._validateManager() - request = self._createEmptyRequest(target) - self._last_request_time = time() - if self._manager is not None: - reply = self._manager.deleteResource(request) - self._registerOnFinishedCallback(reply, on_finished) - else: - Logger.log("e", "Could not find manager.") - def get(self, target: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None: + request = self._createEmptyRequest(url) + self._last_request_time = time() + + if not self._manager: + return Logger.log("e", "No network manager was created to execute the DELETE call with.") + + reply = self._manager.deleteResource(request) + self._registerOnFinishedCallback(reply, on_finished) + + ## Sends a get request to the given path. + # \param url: The path after the API prefix. + # \param on_finished: The function to be call when the response is received. + def get(self, url: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None: self._validateManager() - request = self._createEmptyRequest(target) - self._last_request_time = time() - if self._manager is not None: - reply = self._manager.get(request) - self._registerOnFinishedCallback(reply, on_finished) - else: - Logger.log("e", "Could not find manager.") - def post(self, target: str, data: Union[str, bytes], on_finished: Optional[Callable[[QNetworkReply], None]], - on_progress: Callable = None) -> None: + request = self._createEmptyRequest(url) + self._last_request_time = time() + + if not self._manager: + return Logger.log("e", "No network manager was created to execute the GET call with.") + + reply = self._manager.get(request) + self._registerOnFinishedCallback(reply, on_finished) + + ## Sends a post request to the given path. + # \param url: The path after the API prefix. + # \param data: The data to be sent in the body + # \param on_finished: The function to call when the response is received. + # \param on_progress: The function to call when the progress changes. Parameters are bytes_sent / bytes_total. + def post(self, url: str, data: Union[str, bytes], + on_finished: Optional[Callable[[QNetworkReply], None]], + on_progress: Optional[Callable[[int, int], None]] = None) -> None: self._validateManager() - request = self._createEmptyRequest(target) - self._last_request_time = time() - if self._manager is not None: - reply = self._manager.post(request, data if isinstance(data, bytes) else data.encode()) - if on_progress is not None: - reply.uploadProgress.connect(on_progress) - self._registerOnFinishedCallback(reply, on_finished) - else: - Logger.log("e", "Could not find manager.") - def postFormWithParts(self, target: str, parts: List[QHttpPart], on_finished: Optional[Callable[[QNetworkReply], None]], on_progress: Callable = None) -> QNetworkReply: + request = self._createEmptyRequest(url) + self._last_request_time = time() + + if not self._manager: + return Logger.log("e", "Could not find manager.") + + body = data if isinstance(data, bytes) else data.encode() # type: bytes + reply = self._manager.post(request, body) + if on_progress is not None: + reply.uploadProgress.connect(on_progress) + self._registerOnFinishedCallback(reply, on_finished) + + def postFormWithParts(self, target: str, parts: List[QHttpPart], + on_finished: Optional[Callable[[QNetworkReply], None]], + on_progress: Callable = None) -> QNetworkReply: self._validateManager() request = self._createEmptyRequest(target, content_type=None) multi_post_part = QHttpMultiPart(QHttpMultiPart.FormDataType) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py new file mode 100644 index 0000000000..1d2de1d9bf --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py @@ -0,0 +1,155 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +import json +from json import JSONDecodeError +from typing import Callable, List, Type, TypeVar, Union, Optional, Tuple, Dict + +from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply + +from UM.Logger import Logger +from cura.API import Account +from cura.NetworkClient import NetworkClient +from plugins.UM3NetworkPrinting.src.Models import BaseModel +from plugins.UM3NetworkPrinting.src.Cloud.Models import ( + CloudCluster, CloudErrorObject, CloudClusterStatus, CloudJobUploadRequest, + CloudJobResponse, + CloudPrintResponse +) + + +## The cloud API client is responsible for handling the requests and responses from the cloud. +# Each method should only handle models instead of exposing any HTTP details. +class CloudApiClient(NetworkClient): + + # The cloud URL to use for this remote cluster. + # TODO: Make sure that this URL goes to the live api before release + ROOT_PATH = "https://api-staging.ultimaker.com" + CLUSTER_API_ROOT = "{}/connect/v1/".format(ROOT_PATH) + CURA_API_ROOT = "{}/cura/v1/".format(ROOT_PATH) + + ## Initializes a new cloud API client. + # \param account: The user's account object + # \param on_error: The callback to be called whenever we receive errors from the server. + def __init__(self, account: Account, on_error: Callable[[List[CloudErrorObject]], None]): + super().__init__() + self._account = account + self._on_error = on_error + + ## Retrieves all the clusters for the user that is currently logged in. + # \param on_finished: The function to be called after the result is parsed. + def getClusters(self, on_finished: Callable[[List[CloudCluster]], any]) -> None: + url = "/clusters" + self.get(url, on_finished=self._createCallback(on_finished, CloudCluster)) + + ## Retrieves the status of the given cluster. + # \param cluster_id: The ID of the cluster. + # \param on_finished: The function to be called after the result is parsed. + def getClusterStatus(self, cluster_id: str, on_finished: Callable[[CloudClusterStatus], any]) -> None: + url = "{}/cluster/{}/status".format(self.CLUSTER_API_ROOT, cluster_id) + self.get(url, on_finished=self._createCallback(on_finished, CloudClusterStatus)) + + ## Requests the cloud to register the upload of a print job mesh. + # \param request: The request object. + # \param on_finished: The function to be called after the result is parsed. + def requestUpload(self, request: CloudJobUploadRequest, on_finished: Callable[[CloudJobResponse], any]) -> None: + url = "{}/jobs/upload".format(self.CURA_API_ROOT) + body = json.dumps({"data": request.__dict__}) + self.put(url, body, on_finished=self._createCallback(on_finished, CloudJobResponse)) + + ## Requests the cloud to register the upload of a print job mesh. + # \param upload_response: The object received after requesting an upload with `self.requestUpload`. + # \param on_finished: The function to be called after the result is parsed. It receives the print job ID. + def uploadMesh(self, upload_response: CloudJobResponse, mesh: bytes, on_finished: Callable[[str], any], + on_progress: Callable[[int], any]): + + def progressCallback(bytes_sent: int, bytes_total: int) -> None: + if bytes_total: + on_progress(int((bytes_sent / bytes_total) * 100)) + + def finishedCallback(reply: QNetworkReply): + status_code, response = self._parseReply(reply) + if status_code < 300: + on_finished(upload_response.job_id) + else: + self._uploadMeshError(status_code, response) + + # TODO: Multipart upload + self.put(upload_response.upload_url, data = mesh, content_type = upload_response.content_type, + on_finished = finishedCallback, on_progress = progressCallback) + + # Requests a cluster to print the given print job. + # \param cluster_id: The ID of the cluster. + # \param job_id: The ID of the print job. + # \param on_finished: The function to be called after the result is parsed. + def requestPrint(self, cluster_id: str, job_id: str, on_finished: Callable[[], any]) -> None: + url = "{}/cluster/{}/print/{}".format(self.CLUSTER_API_ROOT, cluster_id, job_id) + self.post(url, data = "", on_finished=self._createCallback(on_finished, CloudPrintResponse)) + + ## We override _createEmptyRequest in order to add the user credentials. + # \param url: The URL to request + # \param content_type: The type of the body contents. + def _createEmptyRequest(self, path: str, content_type: Optional[str] = "application/json") -> QNetworkRequest: + request = super()._createEmptyRequest(path, content_type) + if self._account.isLoggedIn: + request.setRawHeader(b"Authorization", "Bearer {}".format(self._account.accessToken).encode()) + return request + + ## Parses the given JSON network reply into a status code and a dictionary, handling unexpected errors as well. + # \param reply: The reply from the server. + # \return A tuple with a status code and a dictionary. + @staticmethod + def _parseReply(reply: QNetworkReply) -> Tuple[int, Dict[str, any]]: + status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) + try: + response = bytes(reply.readAll()).decode() + Logger.log("i", "Received an HTTP %s from %s with %s", status_code, reply.url, response) + return status_code, json.loads(response) + except (UnicodeDecodeError, JSONDecodeError, ValueError) as err: + error = {"code": type(err).__name__, "title": str(err), "http_code": str(status_code)} + Logger.logException("e", "Could not parse the stardust response: %s", error) + return status_code, {"errors": [error]} + + ## Calls the error handler that is responsible for handling errors uploading meshes. + # \param http_status - The status of the HTTP request. + # \param response - The response received from the upload endpoint. This is not formatted according to the standard + # JSON-api response. + def _uploadMeshError(self, http_status: int, response: Dict[str, any]) -> None: + error = CloudErrorObject( + code = "uploadError", + http_status = str(http_status), + title = "Could not upload the mesh", + meta = response + ) + self._on_error([error]) + + ## The generic type variable used to document the methods below. + Model = TypeVar("Model", bound=BaseModel) + + ## Parses the given models and calls the correct callback depending on the result. + # \param response: The response from the server, after being converted to a dict. + # \param on_finished: The callback in case the response is successful. + # \param model: The type of the model to convert the response to. It may either be a single record or a list. + def _parseModels(self, response: Dict[str, any], + on_finished: Callable[[Union[Model, List[Model]]], any], + model: Type[Model]) -> None: + if "data" in response: + data = response["data"] + result = [model(**c) for c in data] if isinstance(data, list) else model(**data) + on_finished(result) + elif "error" in response: + self._on_error([CloudErrorObject(**error) for error in response["errors"]]) + else: + Logger.log("e", "Cannot find data or errors in the cloud response: %s", response) + + ## Creates a callback function that includes the parsing of the response into the correct model. + # \param on_finished: The callback in case the response is successful. + # \param model: The type of the model to convert the response to. It may either be a single record or a list. + # \return: A function that can be passed to the + def _createCallback(self, + on_finished: Callable[[Union[Model, List[Model]]], any], + model: Type[Model], + ) -> Callable[[QNetworkReply], None]: + def parse(reply: QNetworkReply) -> None: + status_code, response = self._parseReply(reply) + return self._parseModels(response, on_finished, model) + return parse diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index d17728f513..adc670ad1e 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -1,13 +1,10 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. import io -import json import os -from json import JSONDecodeError -from typing import List, Optional, Dict, cast, Union, Tuple +from typing import List, Optional, Dict, cast, Union from PyQt5.QtCore import QObject, pyqtSignal, QUrl, pyqtProperty, pyqtSlot -from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest from UM import i18nCatalog from UM.FileHandler.FileWriter import FileWriter @@ -22,10 +19,11 @@ from cura.PrinterOutput.PrinterOutputController import PrinterOutputController from cura.PrinterOutput.MaterialOutputModel import MaterialOutputModel from cura.PrinterOutput.NetworkedPrinterOutputDevice import NetworkedPrinterOutputDevice, AuthState from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel +from plugins.UM3NetworkPrinting.src.Cloud.CloudApiClient import CloudApiClient from plugins.UM3NetworkPrinting.src.UM3PrintJobOutputModel import UM3PrintJobOutputModel from .Models import ( - CloudClusterPrinter, CloudClusterPrintJob, JobUploadRequest, JobUploadResponse, PrintResponse, CloudClusterStatus, - CloudClusterPrinterConfigurationMaterial + CloudClusterPrinter, CloudClusterPrintJob, CloudJobUploadRequest, CloudJobResponse, CloudClusterStatus, + CloudClusterPrinterConfigurationMaterial, CloudErrorObject ) @@ -40,20 +38,16 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): # The translation catalog for this device. I18N_CATALOG = i18nCatalog("cura") - # The cloud URL to use for this remote cluster. - # TODO: Make sure that this URL goes to the live api before release - ROOT_PATH = "https://api-staging.ultimaker.com" - CLUSTER_API_ROOT = "{}/connect/v1/".format(ROOT_PATH) - CURA_API_ROOT = "{}/cura/v1/".format(ROOT_PATH) - # Signal triggered when the printers in the remote cluster were changed. printersChanged = pyqtSignal() # Signal triggered when the print jobs in the queue were changed. printJobsChanged = pyqtSignal() - def __init__(self, device_id: str, parent: QObject = None): + def __init__(self, api_client: CloudApiClient, device_id: str, parent: QObject = None): super().__init__(device_id = device_id, address = "", properties = {}, parent = parent) + self._api = api_client + self._setInterfaceElements() self._device_id = device_id @@ -76,40 +70,6 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): self._sending_job = False self._progress_message = None # type: Optional[Message] - @staticmethod - def _parseReply(reply: QNetworkReply) -> Tuple[int, Union[None, str, bytes]]: - """ - Parses a reply from the stardust server. - :param reply: The reply received from the server. - :return: The status code and the response dict. - """ - status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) - response = None - try: - response = bytes(reply.readAll()).decode("utf-8") - response = json.loads(response) - except JSONDecodeError: - Logger.logException("w", "Unable to decode JSON from reply.") - return status_code, response - - ## We need to override _createEmptyRequest to work for the cloud. - def _createEmptyRequest(self, path: str, content_type: Optional[str] = "application/json") -> QNetworkRequest: - # noinspection PyArgumentList - url = QUrl(path) - request = QNetworkRequest(url) - request.setHeader(QNetworkRequest.ContentTypeHeader, content_type) - request.setHeader(QNetworkRequest.UserAgentHeader, self._user_agent) - - if not self._account.isLoggedIn: - # TODO: show message to user to sign in - self.setAuthenticationState(AuthState.NotAuthenticated) - else: - # TODO: not execute call at all when not signed in? - self.setAuthenticationState(AuthState.Authenticated) - request.setRawHeader(b"Authorization", "Bearer {}".format(self._account.accessToken).encode()) - - return request - ## Set all the interface elements and texts for this output device. def _setInterfaceElements(self): self.setPriority(2) # make sure we end up below the local networking and above 'save to file' @@ -223,22 +183,16 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): def _update(self) -> None: super()._update() Logger.log("i", "Calling the cloud cluster") - self.get("{root}/cluster/{cluster_id}/status".format(root = self.CLUSTER_API_ROOT, - cluster_id = self._device_id), - on_finished = self._onStatusCallFinished) + if self._account.isLoggedIn: + self.setAuthenticationState(AuthState.Authenticated) + self._api.getClusterStatus(self._device_id, self._onStatusCallFinished) + else: + self.setAuthenticationState(AuthState.NotAuthenticated) ## Method called when HTTP request to status endpoint is finished. # Contains both printers and print jobs statuses in a single response. - def _onStatusCallFinished(self, reply: QNetworkReply) -> None: - status_code, response = self._parseReply(reply) - if status_code > 204 or not isinstance(response, dict) or "data" not in response: - Logger.log("w", "Got unexpected response while trying to get cloud cluster data: %s, %s", - status_code, response) - return - - Logger.log("d", "Got response form the cloud cluster %s, %s", status_code, response) - status = CloudClusterStatus(**response["data"]) - + def _onStatusCallFinished(self, status: CloudClusterStatus) -> None: + Logger.log("d", "Got response form the cloud cluster: %s", status.__dict__) # Update all data from the cluster. self._updatePrinters(status.printers) self._updatePrintJobs(status.print_jobs) @@ -325,18 +279,14 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): remote_jobs = {j.uuid: j for j in jobs} # type: Dict[str, CloudClusterPrintJob] current_jobs = {j.key: j for j in self._print_jobs} # type: Dict[str, UM3PrintJobOutputModel] - removed_job_ids = set(current_jobs).difference(set(remote_jobs)) - new_job_ids = set(remote_jobs.keys()).difference(set(current_jobs)) - updated_job_ids = set(current_jobs).intersection(set(remote_jobs)) + for removed_job_id in set(current_jobs).difference(remote_jobs): + self._print_jobs.remove(current_jobs[removed_job_id]) - for job_id in removed_job_ids: - self._print_jobs.remove(current_jobs[job_id]) + for new_job_id in set(remote_jobs.keys()).difference(current_jobs): + self._addPrintJob(remote_jobs[new_job_id]) - for job_id in new_job_ids: - self._addPrintJob(remote_jobs[job_id]) - - for job_id in updated_job_ids: - self._updateUM3PrintJobOutputModel(current_jobs[job_id], remote_jobs[job_id]) + for updated_job_id in set(current_jobs).intersection(remote_jobs): + self._updateUM3PrintJobOutputModel(current_jobs[updated_job_id], remote_jobs[updated_job_id]) # TODO: properly handle removed and updated printers self.printJobsChanged.emit() @@ -362,56 +312,25 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): def _sendPrintJob(self, file_name: str, content_type: str, stream: Union[io.StringIO, io.BytesIO]) -> None: mesh = stream.getvalue() - request = JobUploadRequest() + request = CloudJobUploadRequest() request.job_name = file_name request.file_size = len(mesh) request.content_type = content_type Logger.log("i", "Creating new cloud print job: %s", request.__dict__) - self.put("{}/jobs/upload".format(self.CURA_API_ROOT), data = json.dumps({"data": request.__dict__}), - on_finished = lambda reply: self._onPrintJobCreated(mesh, reply)) + self._api.requestUpload(request, lambda response: self._onPrintJobCreated(mesh, response)) - def _onPrintJobCreated(self, mesh: bytes, reply: QNetworkReply) -> None: - status_code, response = self._parseReply(reply) - if status_code > 204 or not isinstance(response, dict) or "data" not in response: - Logger.log("w", "Unexpected response while adding to queue: {}, {}".format(status_code, response)) - self._onUploadError(self.I18N_CATALOG.i18nc("@info:status", "Could not add print job to queue.")) - return - - # TODO: Multipart upload - job_response = JobUploadResponse(**response.get("data")) + def _onPrintJobCreated(self, mesh: bytes, job_response: CloudJobResponse) -> None: Logger.log("i", "Print job created successfully: %s", job_response.__dict__) - self.put(job_response.upload_url, data = mesh, content_type = job_response.content_type, - on_finished = lambda r: self._onPrintJobUploaded(job_response.job_id, r), - on_progress = self._onUploadPrintJobProgress) + self._api.uploadMesh(job_response, mesh, self._onPrintJobUploaded, self._onUploadPrintJobProgress) + + def _onPrintJobUploaded(self, job_id: str) -> None: + self._api.requestPrint(self._device_id, job_id, self._onUploadSuccess) def _onUploadPrintJobProgress(self, bytes_sent: int, bytes_total: int) -> None: if bytes_total > 0: self._updateUploadProgress(int((bytes_sent / bytes_total) * 100)) - def _onPrintJobUploaded(self, job_id: str, reply: QNetworkReply) -> None: - status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) - if status_code > 204: - Logger.log("w", "Received unexpected response from the job upload: %s, %s.", status_code, - bytes(reply.readAll()).decode()) - self._onUploadError(self.I18N_CATALOG.i18nc("@info:status", "Could not add print job to queue.")) - return - - Logger.log("i", "Print job uploaded successfully: %s", reply.readAll()) - url = "{}/cluster/{}/print/{}".format(self.CLUSTER_API_ROOT, self._device_id, job_id) - self.post(url, data = "", on_finished = self._onPrintJobRequested) - - def _onPrintJobRequested(self, reply: QNetworkReply) -> None: - status_code, response = self._parseReply(reply) - if status_code > 204 or not isinstance(response, dict) or "data" not in response: - Logger.log("w", "Got unexpected response while trying to request printing: %s, %s", status_code, response) - self._onUploadError(self.I18N_CATALOG.i18nc("@info:status", "Could not add print job to queue.")) - return - - print_response = PrintResponse(**response["data"]) - Logger.log("i", "Print job requested successfully: %s", print_response.__dict__) - self._onUploadSuccess() - def _updateUploadProgress(self, progress: int): if not self._progress_message: self._progress_message = Message( @@ -479,3 +398,6 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): @pyqtProperty(bool, notify = printJobsChanged) def receivedPrintJobs(self) -> bool: return True + + def _onApiError(self, errors: List[CloudErrorObject]) -> None: + pass # TODO: Show errors... diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 85e734f7a3..22e2d57b05 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -1,19 +1,17 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -import json -from time import sleep from threading import Timer -from typing import Dict, Optional - -from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply +from typing import Dict, List +from UM import i18nCatalog from UM.Logger import Logger +from UM.Message import Message from UM.Signal import Signal from cura.CuraApplication import CuraApplication -from cura.NetworkClient import NetworkClient +from plugins.UM3NetworkPrinting.src.Cloud.CloudApiClient import CloudApiClient from .CloudOutputDevice import CloudOutputDevice -from .Models import CloudCluster +from .Models import CloudCluster, CloudErrorObject ## The cloud output device manager is responsible for using the Ultimaker Cloud APIs to manage remote clusters. @@ -21,14 +19,14 @@ from .Models import CloudCluster # # API spec is available on https://api.ultimaker.com/docs/connect/spec/. # -class CloudOutputDeviceManager(NetworkClient): - - # The cloud URL to use for remote clusters. - API_ROOT_PATH = "https://api-staging.ultimaker.com/connect/v1" +class CloudOutputDeviceManager: # The interval with which the remote clusters are checked CHECK_CLUSTER_INTERVAL = 5 # seconds - + + # The translation catalog for this device. + I18N_CATALOG = i18nCatalog("cura") + def __init__(self): super().__init__() @@ -37,8 +35,10 @@ class CloudOutputDeviceManager(NetworkClient): application = CuraApplication.getInstance() self._output_device_manager = application.getOutputDeviceManager() + self._account = application.getCuraAPI().account self._account.loginStateChanged.connect(self._getRemoteClusters) + self._api = CloudApiClient(self._account, self._onApiError) # When switching machines we check if we have to activate a remote cluster. application.globalContainerStackChanged.connect(self._connectToActiveMachine) @@ -46,40 +46,21 @@ class CloudOutputDeviceManager(NetworkClient): self._on_cluster_received = Signal() self._on_cluster_received.connect(self._getRemoteClusters) - - ## Override _createEmptyRequest to add the needed authentication header for talking to the Ultimaker Cloud API. - def _createEmptyRequest(self, path: str, content_type: Optional[str] = "application/json") -> QNetworkRequest: - request = super()._createEmptyRequest(self.API_ROOT_PATH + path, content_type = content_type) - if self._account.isLoggedIn: - # TODO: add correct scopes to OAuth2 client to use remote connect API. - # TODO: don't create the client when not signed in? - request.setRawHeader(b"Authorization", "Bearer {}".format(self._account.accessToken).encode()) - return request - ## Gets all remote clusters from the API. def _getRemoteClusters(self) -> None: Logger.log("i", "Retrieving remote clusters") if self._account.isLoggedIn: - self.get("/clusters", on_finished = self._onGetRemoteClustersFinished) + self._api.getClusters(self._onGetRemoteClustersFinished) # Only start the polling thread after the user is authenticated # The first call to _getRemoteClusters comes from self._account.loginStateChanged timer = Timer(5.0, self._on_cluster_received.emit) timer.start() - ## Callback for when the request for getting the clusters. is finished. - def _onGetRemoteClustersFinished(self, reply: QNetworkReply) -> None: - Logger.log("i", "Received remote clusters") + def _onGetRemoteClustersFinished(self, clusters: List[CloudCluster]) -> None: + found_clusters = {c.cluster_id: c for c in clusters} - status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) - if status_code > 204: - Logger.log("w", "Got unexpected response while trying to get cloud cluster data: {}, {}" - .format(status_code, reply.readAll())) - return - - # Parse the response (returns the "data" field from the body). - found_clusters = self._parseStatusResponse(reply) Logger.log("i", "Parsed remote clusters to %s", found_clusters) if not found_clusters: return @@ -97,28 +78,17 @@ class CloudOutputDeviceManager(NetworkClient): for cluster_id in known_cluster_ids.difference(found_cluster_ids): self._removeCloudOutputDevice(found_clusters[cluster_id]) - @staticmethod - def _parseStatusResponse(reply: QNetworkReply) -> Dict[str, CloudCluster]: - try: - response = bytes(reply.readAll()).decode() - return {c["cluster_id"]: CloudCluster(**c) for c in json.loads(response)["data"]} - except UnicodeDecodeError: - Logger.log("w", "Unable to read server response") - except json.decoder.JSONDecodeError: - Logger.logException("w", "Unable to decode JSON from reply.") - except ValueError: - Logger.logException("w", "Response was missing values.") - return {} - ## Adds a CloudOutputDevice for each entry in the remote cluster list from the API. + # \param cluster: The cluster that was added. def _addCloudOutputDevice(self, cluster: CloudCluster): - device = CloudOutputDevice(cluster.cluster_id) + device = CloudOutputDevice(self._api, cluster.cluster_id) self._output_device_manager.addOutputDevice(device) self._remote_clusters[cluster.cluster_id] = device device.connect() # TODO: remove this self._connectToActiveMachine() ## Remove a CloudOutputDevice + # \param cluster: The cluster that was removed def _removeCloudOutputDevice(self, cluster: CloudCluster): self._output_device_manager.removeOutputDevice(cluster.cluster_id) del self._remote_clusters[cluster.cluster_id] @@ -141,3 +111,15 @@ class CloudOutputDeviceManager(NetworkClient): # TODO: If so, we can also immediate connect to it. # active_machine.setMetaDataEntry("um_cloud_cluster_id", "") # self._remote_clusters.get(stored_cluster_id).connect() + + ## Handles an API error received from the cloud. + # \param errors: The errors received + def _onApiError(self, errors: List[CloudErrorObject]) -> None: + message = ". ".join(e.title for e in errors) # TODO: translate errors + message = Message( + text = message, + title = self.I18N_CATALOG.i18nc("@info:title", "Error"), + lifetime = 10, + dismissable = True + ) + message.show() diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models.py b/plugins/UM3NetworkPrinting/src/Cloud/Models.py index d7cb68e5d3..27ff7df604 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models.py @@ -1,10 +1,22 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from typing import List +from typing import List, Dict from ..Models import BaseModel +## Class representing errors generated by the cloud servers, according to the json-api standard. +class CloudErrorObject(BaseModel): + def __init__(self, **kwargs): + self.id = None # type: str + self.code = None # type: str + self.http_status = None # type: str + self.title = None # type: str + self.detail = None # type: str + self.meta = None # type: Dict[str, any] + super().__init__(**kwargs) + + ## Class representing a cloud connected cluster. class CloudCluster(BaseModel): def __init__(self, **kwargs): @@ -95,17 +107,23 @@ class CloudClusterPrintJob(BaseModel): for p in self.constraints] +# Model that represents the status of the cluster for the cloud class CloudClusterStatus(BaseModel): def __init__(self, **kwargs): + # a list of the printers self.printers = [] # type: List[CloudClusterPrinter] + # a list of the print jobs self.print_jobs = [] # type: List[CloudClusterPrintJob] + super().__init__(**kwargs) + # converting any dictionaries into models self.printers = [CloudClusterPrinter(**p) if isinstance(p, dict) else p for p in self.printers] self.print_jobs = [CloudClusterPrintJob(**j) if isinstance(j, dict) else j for j in self.print_jobs] -class JobUploadRequest(BaseModel): +# Model that represents the request to upload a print job to the cloud +class CloudJobUploadRequest(BaseModel): def __init__(self, **kwargs): self.file_size = None # type: int self.job_name = None # type: str @@ -113,7 +131,8 @@ class JobUploadRequest(BaseModel): super().__init__(**kwargs) -class JobUploadResponse(BaseModel): +# Model that represents the response received from the cloud after requesting to upload a print job +class CloudJobResponse(BaseModel): def __init__(self, **kwargs): self.download_url = None # type: str self.job_id = None # type: str @@ -125,7 +144,8 @@ class JobUploadResponse(BaseModel): super().__init__(**kwargs) -class PrintResponse(BaseModel): +# Model that represents the responses received from the cloud after requesting a job to be printed. +class CloudPrintResponse(BaseModel): def __init__(self, **kwargs): self.cluster_job_id = None # type: str self.job_id = None # type: str From b32d6812db2abfba8973ab3b3466c02357b038ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marijn=20De=C3=A9?= Date: Tue, 4 Dec 2018 16:20:27 +0100 Subject: [PATCH 057/178] We don't need a Signal with QTimer --- .../src/Cloud/CloudOutputDeviceManager.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 6ab72d8ee3..b7ad4e9f6a 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -7,7 +7,6 @@ from PyQt5.QtCore import QTimer from UM import i18nCatalog from UM.Logger import Logger from UM.Message import Message -from UM.Signal import Signal from cura.CuraApplication import CuraApplication from plugins.UM3NetworkPrinting.src.Cloud.CloudApiClient import CloudApiClient from .CloudOutputDevice import CloudOutputDevice @@ -43,13 +42,10 @@ class CloudOutputDeviceManager: # When switching machines we check if we have to activate a remote cluster. application.globalContainerStackChanged.connect(self._connectToActiveMachine) - self._on_cluster_received = Signal() - self._on_cluster_received.connect(self._getRemoteClusters) - self.update_timer = QTimer(CuraApplication.getInstance()) self.update_timer.setInterval(self.CHECK_CLUSTER_INTERVAL * 1000) self.update_timer.setSingleShot(False) - self.update_timer.timeout.connect(self._on_cluster_received.emit) + self.update_timer.timeout.connect(self._getRemoteClusters) ## Gets all remote clusters from the API. def _getRemoteClusters(self) -> None: @@ -59,7 +55,8 @@ class CloudOutputDeviceManager: # Only start the polling timer after the user is authenticated # The first call to _getRemoteClusters comes from self._account.loginStateChanged - self.update_timer.start() + if not self.update_timer.isActive(): + self.update_timer.start() ## Callback for when the request for getting the clusters. is finished. def _onGetRemoteClustersFinished(self, clusters: List[CloudCluster]) -> None: From 0887817f7dd0c157c758a8459bd54a46196cb766 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Tue, 4 Dec 2018 16:35:09 +0100 Subject: [PATCH 058/178] use special icon for cloud connected --- resources/qml/PrinterSelector/MachineSelector.qml | 5 +++-- resources/qml/PrinterSelector/MachineSelectorList.qml | 2 +- .../themes/cura-light/icons/printer_cloud_connected.svg | 0 3 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 resources/themes/cura-light/icons/printer_cloud_connected.svg diff --git a/resources/qml/PrinterSelector/MachineSelector.qml b/resources/qml/PrinterSelector/MachineSelector.qml index 15cd773c90..780b5baa74 100644 --- a/resources/qml/PrinterSelector/MachineSelector.qml +++ b/resources/qml/PrinterSelector/MachineSelector.qml @@ -12,6 +12,7 @@ Cura.ExpandableComponent id: machineSelector property bool isNetworkPrinter: Cura.MachineManager.activeMachineNetworkKey != "" + property bool isCloudConnected: Cura.MachineManager.activeMachineCloudKey != "" property bool isPrinterConnected: Cura.MachineManager.printerConnected property var outputDevice: Cura.MachineManager.printerOutputDevices.length >= 1 ? Cura.MachineManager.printerOutputDevices[0] : null @@ -55,7 +56,7 @@ Cura.ExpandableComponent leftMargin: UM.Theme.getSize("thick_margin").width } - source: UM.Theme.getIcon("printer_connected") + source: isCloudConnected ? UM.Theme.getIcon("printer_cloud_connected") : UM.Theme.getIcon("printer_connected") width: UM.Theme.getSize("printer_status_icon").width height: UM.Theme.getSize("printer_status_icon").height @@ -63,7 +64,7 @@ Cura.ExpandableComponent sourceSize.height: height color: UM.Theme.getColor("primary") - visible: isNetworkPrinter && isPrinterConnected + visible: isNetworkPrinter && (isPrinterConnected || isCloudConnected) // Make a themable circle in the background so we can change it in other themes Rectangle diff --git a/resources/qml/PrinterSelector/MachineSelectorList.qml b/resources/qml/PrinterSelector/MachineSelectorList.qml index 26c703fddd..e605f23f73 100644 --- a/resources/qml/PrinterSelector/MachineSelectorList.qml +++ b/resources/qml/PrinterSelector/MachineSelectorList.qml @@ -79,7 +79,7 @@ Column delegate: MachineSelectorButton { text: model.metadata["connect_group_name"] - checked: Cura.MachineManager.activeMachineNetworkGroupName == model.metadata["connect_group_name"] + checked: true // cloud devices are always online if they are available outputDevice: Cura.MachineManager.printerOutputDevices.length >= 1 ? Cura.MachineManager.printerOutputDevices[0] : null Connections diff --git a/resources/themes/cura-light/icons/printer_cloud_connected.svg b/resources/themes/cura-light/icons/printer_cloud_connected.svg new file mode 100644 index 0000000000..e69de29bb2 From 3a733bb0a3b11b02e4cae838b8809c593bee6120 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marijn=20De=C3=A9?= Date: Tue, 4 Dec 2018 16:35:48 +0100 Subject: [PATCH 059/178] Check before removing a printer --- .../UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index b7ad4e9f6a..5440795e5d 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -92,7 +92,8 @@ class CloudOutputDeviceManager: # \param cluster: The cluster that was removed def _removeCloudOutputDevice(self, cluster: CloudCluster): self._output_device_manager.removeOutputDevice(cluster.cluster_id) - del self._remote_clusters[cluster.cluster_id] + if cluster.cluster_id in self._remote_clusters: + del self._remote_clusters[cluster.cluster_id] ## Callback for when the active machine was changed by the user. def _connectToActiveMachine(self, cluster_id: Optional[str] = None, host_name: Optional[str] = None) -> None: From 9df49a1232152f22133f627a16c2c935aa5b49c7 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Tue, 4 Dec 2018 16:39:46 +0100 Subject: [PATCH 060/178] Add the actual icon contents --- .../cura-light/icons/printer_cloud_connected.svg | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/resources/themes/cura-light/icons/printer_cloud_connected.svg b/resources/themes/cura-light/icons/printer_cloud_connected.svg index e69de29bb2..fff2bf7c44 100644 --- a/resources/themes/cura-light/icons/printer_cloud_connected.svg +++ b/resources/themes/cura-light/icons/printer_cloud_connected.svg @@ -0,0 +1,13 @@ + + + + noun_Cloud_377836 + Created with Sketch. + + + + + + + + \ No newline at end of file From 27dc17f9930958a3a4682d7ac3dcbc586e2311ca Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Tue, 4 Dec 2018 16:49:26 +0100 Subject: [PATCH 061/178] STAR-322: Extracting translations --- .../src/Cloud/CloudOutputDevice.py | 75 ++++++++++++------- 1 file changed, 50 insertions(+), 25 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index c91944fe4d..af9324c9b0 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -2,7 +2,7 @@ # Cura is released under the terms of the LGPLv3 or higher. import io import os -from typing import List, Optional, Dict, cast, Union +from typing import List, Optional, Dict, cast, Union, Set from PyQt5.QtCore import QObject, pyqtSignal, QUrl, pyqtProperty, pyqtSlot @@ -27,6 +27,32 @@ from .Models import ( ) +## Private class that contains all the translations for this component. +class T: + # The translation catalog for this device. + + _I18N_CATALOG = i18nCatalog("cura") + + PRINT_VIA_CLOUD_BUTTON = _I18N_CATALOG.i18nc("@action:button", "Print via Cloud") + PRINT_VIA_CLOUD_TOOLTIP = _I18N_CATALOG.i18nc("@properties:tooltip", "Print via Cloud") + + CONNECTED_VIA_CLOUD = _I18N_CATALOG.i18nc("@info:status", "Connected via Cloud") + BLOCKED_UPLOADING = _I18N_CATALOG.i18nc("@info:status", "Sending new jobs (temporarily) blocked, still sending " + "the previous print job.") + + COULD_NOT_EXPORT = _I18N_CATALOG.i18nc("@info:status", "Could not export print job.") + WRITE_FAILED = _I18N_CATALOG.i18nc("@info:status", "There are no file formats available to write with!") + + SENDING_DATA_TEXT = _I18N_CATALOG.i18nc("@info:status", "Sending data to remote cluster") + SENDING_DATA_TITLE = _I18N_CATALOG.i18nc("@info:status", "Sending data to remote cluster") + + ERROR = _I18N_CATALOG.i18nc("@info:title", "Error") + UPLOAD_ERROR = _I18N_CATALOG.i18nc("@info:text", "Could not upload the data to the printer.") + + UPLOAD_SUCCESS_TITLE = _I18N_CATALOG.i18nc("@info:title", "Data Sent") + UPLOAD_SUCCESS_TEXT = _I18N_CATALOG.i18nc("@info:status", "Print job was successfully sent to the printer.") + + ## The cloud output device is a network output device that works remotely but has limited functionality. # Currently it only supports viewing the printer and print job status and adding a new job to the queue. # As such, those methods have been implemented here. @@ -34,9 +60,6 @@ from .Models import ( # # TODO: figure our how the QML interface for the cluster networking should operate with this limited functionality. class CloudOutputDevice(NetworkedPrinterOutputDevice): - - # The translation catalog for this device. - I18N_CATALOG = i18nCatalog("cura") # Signal triggered when the printers in the remote cluster were changed. printersChanged = pyqtSignal() @@ -74,9 +97,9 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): def _setInterfaceElements(self): self.setPriority(2) # make sure we end up below the local networking and above 'save to file' self.setName(self._id) - self.setShortDescription(self.I18N_CATALOG.i18nc("@action:button", "Print via Cloud")) - self.setDescription(self.I18N_CATALOG.i18nc("@properties:tooltip", "Print via Cloud")) - self.setConnectionText(self.I18N_CATALOG.i18nc("@info:status", "Connected via Cloud")) + self.setShortDescription(T.PRINT_VIA_CLOUD_BUTTON) + self.setDescription(T.PRINT_VIA_CLOUD_TOOLTIP) + self.setConnectionText(T.CONNECTED_VIA_CLOUD) ## Called when Cura requests an output device to receive a (G-code) file. def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mime_types: bool = False, @@ -84,8 +107,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): # Show an error message if we're already sending a job. if self._sending_job: - self._onUploadError(self.I18N_CATALOG.i18nc( - "@info:status", "Sending new jobs (temporarily) blocked, still sending the previous print job.")) + self._onUploadError(T.BLOCKED_UPLOADING) return # Indicate we have started sending a job. @@ -96,7 +118,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): writer = self._determineWriter(file_handler, file_format) if not writer: Logger.log("e", "Missing file or mesh writer!") - self._onUploadError(self.I18N_CATALOG.i18nc("@info:status", "Could not export print job.")) + self._onUploadError(T.COULD_NOT_EXPORT) return stream = io.StringIO() if file_format["mode"] == FileWriter.OutputMode.TextMode else io.BytesIO() @@ -131,9 +153,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): if len(file_formats) == 0: Logger.log("e", "There are no file formats available to write with!") - raise OutputDeviceError.WriteRequestFailedError( - self.I18N_CATALOG.i18nc("@info:status", "There are no file formats available to write with!") - ) + raise OutputDeviceError.WriteRequestFailedError(T.WRITE_FAILED) return file_formats[0] # TODO: This is yanked right out of ClusterUM3OutputDevice, great candidate for a utility or base class @@ -279,18 +299,21 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): remote_jobs = {j.uuid: j for j in jobs} # type: Dict[str, CloudClusterPrintJob] current_jobs = {j.key: j for j in self._print_jobs} # type: Dict[str, UM3PrintJobOutputModel] - for removed_job_id in set(current_jobs).difference(remote_jobs): + remote_job_ids = set(remote_jobs) # type: Set[str] + current_job_ids = set(current_jobs) # type: Set[str] + + for removed_job_id in current_job_ids.difference(remote_job_ids): self._print_jobs.remove(current_jobs[removed_job_id]) - for new_job_id in set(remote_jobs.keys()).difference(current_jobs): + for new_job_id in remote_job_ids.difference(current_jobs): self._addPrintJob(remote_jobs[new_job_id]) - for updated_job_id in set(current_jobs).intersection(remote_jobs): + for updated_job_id in current_job_ids.intersection(remote_job_ids): self._updateUM3PrintJobOutputModel(current_jobs[updated_job_id], remote_jobs[updated_job_id]) # We only have to update when jobs are added or removed - # updated jobs push their changes via their outputmodel - if len(removed_job_ids) > 0 or len(new_job_ids) > 0: + # updated jobs push their changes via their output model + if remote_job_ids != current_job_ids: self.printJobsChanged.emit() def _addPrintJob(self, job: CloudClusterPrintJob) -> None: @@ -324,7 +347,8 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): def _onPrintJobCreated(self, mesh: bytes, job_response: CloudJobResponse) -> None: Logger.log("i", "Print job created successfully: %s", job_response.__dict__) - self._api.uploadMesh(job_response, mesh, self._onPrintJobUploaded, self._onUploadPrintJobProgress) + self._api.uploadMesh(job_response, mesh, self._onPrintJobUploaded, self._onUploadPrintJobProgress, + lambda error: self._onUploadError(T.UPLOAD_ERROR)) def _onPrintJobUploaded(self, job_id: str) -> None: self._api.requestPrint(self._device_id, job_id, self._onUploadSuccess) @@ -336,8 +360,8 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): def _updateUploadProgress(self, progress: int): if not self._progress_message: self._progress_message = Message( - text = self.I18N_CATALOG.i18nc("@info:status", "Sending data to remote cluster"), - title = self.I18N_CATALOG.i18nc("@info:title", "Sending Data..."), + text = T.SENDING_DATA_TEXT, + title = T.SENDING_DATA_TITLE, progress = -1, lifetime = 0, dismissable = False, @@ -356,19 +380,20 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): if message: message = Message( text = message, - title = self.I18N_CATALOG.i18nc("@info:title", "Error"), + title = T.ERROR, lifetime = 10, dismissable = True ) message.show() - self._sending_job = False # the upload has failed so we're not sending a job anymore + self._sending_job = False # the upload has finished so we're not sending a job anymore self.writeError.emit() + # Shows a message when the upload has succeeded def _onUploadSuccess(self): self._resetUploadProgress() message = Message( - text = self.I18N_CATALOG.i18nc("@info:status", "Print job was successfully sent to the printer."), - title = self.I18N_CATALOG.i18nc("@info:title", "Data Sent"), + text = T.UPLOAD_SUCCESS_TEXT, + title = T.UPLOAD_SUCCESS_TITLE, lifetime = 5, dismissable = True, ) From 02efc9e1a905d1345a493d17108f74ca68b82110 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Tue, 4 Dec 2018 16:54:36 +0100 Subject: [PATCH 062/178] Fix cloud status icon size --- resources/themes/cura-light/icons/printer_cloud_connected.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/themes/cura-light/icons/printer_cloud_connected.svg b/resources/themes/cura-light/icons/printer_cloud_connected.svg index fff2bf7c44..ef6f0f2910 100644 --- a/resources/themes/cura-light/icons/printer_cloud_connected.svg +++ b/resources/themes/cura-light/icons/printer_cloud_connected.svg @@ -1,5 +1,5 @@ - + noun_Cloud_377836 Created with Sketch. From 8ea4edf67e576014c9ec321e5f847f3659d13e00 Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Tue, 4 Dec 2018 17:37:58 +0100 Subject: [PATCH 063/178] STAR-322: Fixing job uploads --- cura/NetworkClient.py | 4 +- .../src/Cloud/CloudApiClient.py | 54 ++++++++----------- .../src/Cloud/CloudOutputDevice.py | 43 ++++++++------- .../src/Cloud/CloudOutputDeviceManager.py | 2 +- 4 files changed, 49 insertions(+), 54 deletions(-) diff --git a/cura/NetworkClient.py b/cura/NetworkClient.py index 8a321b6af4..5294813fb7 100644 --- a/cura/NetworkClient.py +++ b/cura/NetworkClient.py @@ -60,7 +60,7 @@ class NetworkClient: ## Executes the correct callback method when a network request finishes. def __handleOnFinished(self, reply: QNetworkReply) -> None: - + # Due to garbage collection, we need to cache certain bits of post operations. # As we don't want to keep them around forever, delete them if we get a reply. if reply.operation() == QNetworkAccessManager.PostOperation: @@ -79,6 +79,8 @@ class NetworkClient: callback_key = reply.url().toString() + str(reply.operation()) if callback_key in self._on_finished_callbacks: self._on_finished_callbacks[callback_key](reply) + else: + Logger.log("w", "Received reply to URL %s but no callbacks are registered", reply.url()) ## Removes all cached Multi-Part items. def _clearCachedMultiPart(self, reply: QNetworkReply) -> None: diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py index 1d2de1d9bf..d6c20d387b 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py @@ -11,9 +11,7 @@ from cura.API import Account from cura.NetworkClient import NetworkClient from plugins.UM3NetworkPrinting.src.Models import BaseModel from plugins.UM3NetworkPrinting.src.Cloud.Models import ( - CloudCluster, CloudErrorObject, CloudClusterStatus, CloudJobUploadRequest, - CloudJobResponse, - CloudPrintResponse + CloudCluster, CloudErrorObject, CloudClusterStatus, CloudJobUploadRequest, CloudPrintResponse, CloudJobResponse ) @@ -24,8 +22,8 @@ class CloudApiClient(NetworkClient): # The cloud URL to use for this remote cluster. # TODO: Make sure that this URL goes to the live api before release ROOT_PATH = "https://api-staging.ultimaker.com" - CLUSTER_API_ROOT = "{}/connect/v1/".format(ROOT_PATH) - CURA_API_ROOT = "{}/cura/v1/".format(ROOT_PATH) + CLUSTER_API_ROOT = "{}/connect/v1".format(ROOT_PATH) + CURA_API_ROOT = "{}/cura/v1".format(ROOT_PATH) ## Initializes a new cloud API client. # \param account: The user's account object @@ -38,15 +36,15 @@ class CloudApiClient(NetworkClient): ## Retrieves all the clusters for the user that is currently logged in. # \param on_finished: The function to be called after the result is parsed. def getClusters(self, on_finished: Callable[[List[CloudCluster]], any]) -> None: - url = "/clusters" - self.get(url, on_finished=self._createCallback(on_finished, CloudCluster)) + url = "{}/clusters".format(self.CLUSTER_API_ROOT) + self.get(url, on_finished=self._wrapCallback(on_finished, CloudCluster)) ## Retrieves the status of the given cluster. # \param cluster_id: The ID of the cluster. # \param on_finished: The function to be called after the result is parsed. def getClusterStatus(self, cluster_id: str, on_finished: Callable[[CloudClusterStatus], any]) -> None: url = "{}/cluster/{}/status".format(self.CLUSTER_API_ROOT, cluster_id) - self.get(url, on_finished=self._createCallback(on_finished, CloudClusterStatus)) + self.get(url, on_finished=self._wrapCallback(on_finished, CloudClusterStatus)) ## Requests the cloud to register the upload of a print job mesh. # \param request: The request object. @@ -54,13 +52,16 @@ class CloudApiClient(NetworkClient): def requestUpload(self, request: CloudJobUploadRequest, on_finished: Callable[[CloudJobResponse], any]) -> None: url = "{}/jobs/upload".format(self.CURA_API_ROOT) body = json.dumps({"data": request.__dict__}) - self.put(url, body, on_finished=self._createCallback(on_finished, CloudJobResponse)) + self.put(url, body, on_finished=self._wrapCallback(on_finished, CloudJobResponse)) ## Requests the cloud to register the upload of a print job mesh. # \param upload_response: The object received after requesting an upload with `self.requestUpload`. + # \param mesh: The mesh data to be uploaded. # \param on_finished: The function to be called after the result is parsed. It receives the print job ID. + # \param on_progress: A function to be called during upload progress. It receives a percentage (0-100). + # \param on_error: A function to be called if the upload fails. It receives a dict with the error. def uploadMesh(self, upload_response: CloudJobResponse, mesh: bytes, on_finished: Callable[[str], any], - on_progress: Callable[[int], any]): + on_progress: Callable[[int], any], on_error: Callable[[dict], any]): def progressCallback(bytes_sent: int, bytes_total: int) -> None: if bytes_total: @@ -71,7 +72,8 @@ class CloudApiClient(NetworkClient): if status_code < 300: on_finished(upload_response.job_id) else: - self._uploadMeshError(status_code, response) + Logger.log("e", "Received unexpected response %s uploading mesh: %s", status_code, response) + on_error(response) # TODO: Multipart upload self.put(upload_response.upload_url, data = mesh, content_type = upload_response.content_type, @@ -81,9 +83,9 @@ class CloudApiClient(NetworkClient): # \param cluster_id: The ID of the cluster. # \param job_id: The ID of the print job. # \param on_finished: The function to be called after the result is parsed. - def requestPrint(self, cluster_id: str, job_id: str, on_finished: Callable[[], any]) -> None: + def requestPrint(self, cluster_id: str, job_id: str, on_finished: Callable[[CloudPrintResponse], any]) -> None: url = "{}/cluster/{}/print/{}".format(self.CLUSTER_API_ROOT, cluster_id, job_id) - self.post(url, data = "", on_finished=self._createCallback(on_finished, CloudPrintResponse)) + self.post(url, data = "", on_finished=self._wrapCallback(on_finished, CloudPrintResponse)) ## We override _createEmptyRequest in order to add the user credentials. # \param url: The URL to request @@ -92,6 +94,7 @@ class CloudApiClient(NetworkClient): request = super()._createEmptyRequest(path, content_type) if self._account.isLoggedIn: request.setRawHeader(b"Authorization", "Bearer {}".format(self._account.accessToken).encode()) + Logger.log("i", "Created request for URL %s. Logged in = %s", path, self._account.isLoggedIn) return request ## Parses the given JSON network reply into a status code and a dictionary, handling unexpected errors as well. @@ -102,26 +105,13 @@ class CloudApiClient(NetworkClient): status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) try: response = bytes(reply.readAll()).decode() - Logger.log("i", "Received an HTTP %s from %s with %s", status_code, reply.url, response) + Logger.log("i", "Received a reply %s from %s with %s", status_code, reply.url().toString(), response) return status_code, json.loads(response) except (UnicodeDecodeError, JSONDecodeError, ValueError) as err: error = {"code": type(err).__name__, "title": str(err), "http_code": str(status_code)} Logger.logException("e", "Could not parse the stardust response: %s", error) return status_code, {"errors": [error]} - ## Calls the error handler that is responsible for handling errors uploading meshes. - # \param http_status - The status of the HTTP request. - # \param response - The response received from the upload endpoint. This is not formatted according to the standard - # JSON-api response. - def _uploadMeshError(self, http_status: int, response: Dict[str, any]) -> None: - error = CloudErrorObject( - code = "uploadError", - http_status = str(http_status), - title = "Could not upload the mesh", - meta = response - ) - self._on_error([error]) - ## The generic type variable used to document the methods below. Model = TypeVar("Model", bound=BaseModel) @@ -141,14 +131,14 @@ class CloudApiClient(NetworkClient): else: Logger.log("e", "Cannot find data or errors in the cloud response: %s", response) - ## Creates a callback function that includes the parsing of the response into the correct model. + ## Wraps a callback function so that it includes the parsing of the response into the correct model. # \param on_finished: The callback in case the response is successful. # \param model: The type of the model to convert the response to. It may either be a single record or a list. # \return: A function that can be passed to the - def _createCallback(self, - on_finished: Callable[[Union[Model, List[Model]]], any], - model: Type[Model], - ) -> Callable[[QNetworkReply], None]: + def _wrapCallback(self, + on_finished: Callable[[Union[Model, List[Model]]], any], + model: Type[Model], + ) -> Callable[[QNetworkReply], None]: def parse(reply: QNetworkReply) -> None: status_code, response = self._parseReply(reply) return self._parseModels(response, on_finished, model) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index af9324c9b0..27bf3a821e 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -2,6 +2,7 @@ # Cura is released under the terms of the LGPLv3 or higher. import io import os +from time import time from typing import List, Optional, Dict, cast, Union, Set from PyQt5.QtCore import QObject, pyqtSignal, QUrl, pyqtProperty, pyqtSlot @@ -23,11 +24,12 @@ from plugins.UM3NetworkPrinting.src.Cloud.CloudApiClient import CloudApiClient from plugins.UM3NetworkPrinting.src.UM3PrintJobOutputModel import UM3PrintJobOutputModel from .Models import ( CloudClusterPrinter, CloudClusterPrintJob, CloudJobUploadRequest, CloudJobResponse, CloudClusterStatus, - CloudClusterPrinterConfigurationMaterial, CloudErrorObject + CloudClusterPrinterConfigurationMaterial, CloudErrorObject, + CloudPrintResponse ) -## Private class that contains all the translations for this component. +## Class that contains all the translations for this module. class T: # The translation catalog for this device. @@ -61,13 +63,20 @@ class T: # TODO: figure our how the QML interface for the cluster networking should operate with this limited functionality. class CloudOutputDevice(NetworkedPrinterOutputDevice): + # The interval with which the remote clusters are checked + CHECK_CLUSTER_INTERVAL = 2.0 # seconds + # Signal triggered when the printers in the remote cluster were changed. printersChanged = pyqtSignal() # Signal triggered when the print jobs in the queue were changed. printJobsChanged = pyqtSignal() - def __init__(self, api_client: CloudApiClient, device_id: str, parent: QObject = None): + ## Creates a new cloud output device + # \param api_client: The client that will run the API calls + # \param device_id: The ID of the device (i.e. the cluster_id for the cloud API) + # \param parent: The optional parent of this output device. + def __init__(self, api_client: CloudApiClient, device_id: str, parent: QObject = None) -> None: super().__init__(device_id = device_id, address = "", properties = {}, parent = parent) self._api = api_client @@ -76,10 +85,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): self._device_id = device_id self._account = CuraApplication.getInstance().getCuraAPI().account - # Cluster does not have authentication, so default to authenticated - self._authentication_state = AuthState.Authenticated - - # We re-use the Cura Connect monitor tab to get the most functionality right away. + # We use the Cura Connect monitor tab to get most functionality right away. self._monitor_view_qml_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "../../resources/qml/ClusterMonitorItem.qml") self._control_view_qml_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), @@ -118,11 +124,12 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): writer = self._determineWriter(file_handler, file_format) if not writer: Logger.log("e", "Missing file or mesh writer!") - self._onUploadError(T.COULD_NOT_EXPORT) - return + return self._onUploadError(T.COULD_NOT_EXPORT) stream = io.StringIO() if file_format["mode"] == FileWriter.OutputMode.TextMode else io.BytesIO() writer.write(stream, nodes) + + # TODO: Remove extension from the file name, since we are using content types now self._sendPrintJob(file_name + "." + file_format["extension"], file_format["mime_type"], stream) # TODO: This is yanked right out of ClusterUM3OutputDevice, great candidate for a utility or base class @@ -202,7 +209,9 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): ## Called when the network data should be updated. def _update(self) -> None: super()._update() - Logger.log("i", "Calling the cloud cluster") + if self._last_response_time and time() - self._last_response_time < self.CHECK_CLUSTER_INTERVAL: + return # avoid calling the cloud too often + if self._account.isLoggedIn: self.setAuthenticationState(AuthState.Authenticated) self._api.getClusterStatus(self._device_id, self._onStatusCallFinished) @@ -212,7 +221,6 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): ## Method called when HTTP request to status endpoint is finished. # Contains both printers and print jobs statuses in a single response. def _onStatusCallFinished(self, status: CloudClusterStatus) -> None: - Logger.log("d", "Got response form the cloud cluster: %s", status.__dict__) # Update all data from the cluster. self._updatePrinters(status.printers) self._updatePrintJobs(status.print_jobs) @@ -342,21 +350,15 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): request.file_size = len(mesh) request.content_type = content_type - Logger.log("i", "Creating new cloud print job: %s", request.__dict__) self._api.requestUpload(request, lambda response: self._onPrintJobCreated(mesh, response)) def _onPrintJobCreated(self, mesh: bytes, job_response: CloudJobResponse) -> None: - Logger.log("i", "Print job created successfully: %s", job_response.__dict__) - self._api.uploadMesh(job_response, mesh, self._onPrintJobUploaded, self._onUploadPrintJobProgress, - lambda error: self._onUploadError(T.UPLOAD_ERROR)) + self._api.uploadMesh(job_response, mesh, self._onPrintJobUploaded, self._updateUploadProgress, + lambda _: self._onUploadError(T.UPLOAD_ERROR)) def _onPrintJobUploaded(self, job_id: str) -> None: self._api.requestPrint(self._device_id, job_id, self._onUploadSuccess) - def _onUploadPrintJobProgress(self, bytes_sent: int, bytes_total: int) -> None: - if bytes_total > 0: - self._updateUploadProgress(int((bytes_sent / bytes_total) * 100)) - def _updateUploadProgress(self, progress: int): if not self._progress_message: self._progress_message = Message( @@ -389,7 +391,8 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): self.writeError.emit() # Shows a message when the upload has succeeded - def _onUploadSuccess(self): + def _onUploadSuccess(self, response: CloudPrintResponse): + Logger.log("i", "The cluster will be printing this print job with the ID %s", response.cluster_job_id) self._resetUploadProgress() message = Message( text = T.UPLOAD_SUCCESS_TEXT, diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 5440795e5d..772d40edd4 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -21,7 +21,7 @@ from .Models import CloudCluster, CloudErrorObject class CloudOutputDeviceManager: # The interval with which the remote clusters are checked - CHECK_CLUSTER_INTERVAL = 5 # seconds + CHECK_CLUSTER_INTERVAL = 5.0 # seconds # The translation catalog for this device. I18N_CATALOG = i18nCatalog("cura") From d0513e40e15b831b41b5ec9821178e2b8634172b Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Tue, 4 Dec 2018 22:21:36 +0100 Subject: [PATCH 064/178] Remove crappy implementation --- cura/Settings/MachineManager.py | 6 --- .../src/Cloud/CloudOutputDeviceManager.py | 5 +- resources/qml/Menus/CloudPrinterMenu.qml | 26 ----------- resources/qml/Menus/PrinterMenu.qml | 17 ------- .../qml/PrinterSelector/MachineSelector.qml | 5 +- .../PrinterSelector/MachineSelectorList.qml | 46 ++----------------- .../icons/printer_cloud_connected.svg | 2 +- 7 files changed, 8 insertions(+), 99 deletions(-) delete mode 100644 resources/qml/Menus/CloudPrinterMenu.qml diff --git a/cura/Settings/MachineManager.py b/cura/Settings/MachineManager.py index 15e2c67c33..53390ca88d 100755 --- a/cura/Settings/MachineManager.py +++ b/cura/Settings/MachineManager.py @@ -527,12 +527,6 @@ class MachineManager(QObject): return self._global_container_stack.getMetaDataEntry("um_network_key", "") return "" - @pyqtProperty(str, notify=printerConnectedStatusChanged) - def activeMachineCloudKey(self) -> str: - if self._global_container_stack: - return self._global_container_stack.getMetaDataEntry("um_cloud_cluster_id", "") - return "" - @pyqtProperty(str, notify = printerConnectedStatusChanged) def activeMachineNetworkGroupName(self) -> str: if self._global_container_stack: diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 772d40edd4..9f7e8fa74a 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -86,7 +86,7 @@ class CloudOutputDeviceManager: self._output_device_manager.addOutputDevice(device) self._remote_clusters[cluster.cluster_id] = device device.connect() # TODO: remove this - self._connectToActiveMachine(cluster.cluster_id, cluster.host_name) + self._connectToActiveMachine(cluster.cluster_id) ## Remove a CloudOutputDevice # \param cluster: The cluster that was removed @@ -96,7 +96,7 @@ class CloudOutputDeviceManager: del self._remote_clusters[cluster.cluster_id] ## Callback for when the active machine was changed by the user. - def _connectToActiveMachine(self, cluster_id: Optional[str] = None, host_name: Optional[str] = None) -> None: + def _connectToActiveMachine(self, cluster_id: Optional[str] = None) -> None: active_machine = CuraApplication.getInstance().getGlobalContainerStack() if not active_machine: return @@ -104,7 +104,6 @@ class CloudOutputDeviceManager: # TODO: Remove this once correct pairing has been added (see below). if cluster_id: active_machine.setMetaDataEntry("um_cloud_cluster_id", cluster_id) - active_machine.setMetaDataEntry("connect_group_name", host_name) # Check if the stored cluster_id for the active machine is in our list of remote clusters. stored_cluster_id = active_machine.getMetaDataEntry("um_cloud_cluster_id") diff --git a/resources/qml/Menus/CloudPrinterMenu.qml b/resources/qml/Menus/CloudPrinterMenu.qml deleted file mode 100644 index bd03890642..0000000000 --- a/resources/qml/Menus/CloudPrinterMenu.qml +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) 2018 Ultimaker B.V. -// Cura is released under the terms of the LGPLv3 or higher. -import QtQuick 2.2 -import QtQuick.Controls 1.4 - -import UM 1.2 as UM -import Cura 1.0 as Cura - -Instantiator { - - model: UM.ContainerStacksModel { - filter: {"type": "machine", "um_cloud_cluster_id": "*"} - } - - MenuItem { - // iconSource: UM.Theme.getIcon("printer_single") TODO: use cloud icon here - text: model.metadata["connect_group_name"] - checkable: true - checked: true // cloud printers are only listed if they are actually online - exclusiveGroup: group; - onTriggered: Cura.MachineManager.setActiveMachine(model.id); - } - - onObjectAdded: menu.insertItem(index, object) - onObjectRemoved: menu.removeItem(object) -} diff --git a/resources/qml/Menus/PrinterMenu.qml b/resources/qml/Menus/PrinterMenu.qml index a924b0e589..741d927c13 100644 --- a/resources/qml/Menus/PrinterMenu.qml +++ b/resources/qml/Menus/PrinterMenu.qml @@ -37,23 +37,6 @@ Menu visible: networkPrinterMenu.count > 0 } - MenuItem - { - text: catalog.i18nc("@label:category menu label", "Cloud enabled printers") - enabled: false - visible: cloudPrinterMenu.count > 0 - } - - CloudPrinterMenu - { - id: cloudPrinterMenu - } - - MenuSeparator - { - visible: cloudPrinterMenu.count > 0 - } - MenuItem { text: catalog.i18nc("@label:category menu label", "Local printers") diff --git a/resources/qml/PrinterSelector/MachineSelector.qml b/resources/qml/PrinterSelector/MachineSelector.qml index 780b5baa74..15cd773c90 100644 --- a/resources/qml/PrinterSelector/MachineSelector.qml +++ b/resources/qml/PrinterSelector/MachineSelector.qml @@ -12,7 +12,6 @@ Cura.ExpandableComponent id: machineSelector property bool isNetworkPrinter: Cura.MachineManager.activeMachineNetworkKey != "" - property bool isCloudConnected: Cura.MachineManager.activeMachineCloudKey != "" property bool isPrinterConnected: Cura.MachineManager.printerConnected property var outputDevice: Cura.MachineManager.printerOutputDevices.length >= 1 ? Cura.MachineManager.printerOutputDevices[0] : null @@ -56,7 +55,7 @@ Cura.ExpandableComponent leftMargin: UM.Theme.getSize("thick_margin").width } - source: isCloudConnected ? UM.Theme.getIcon("printer_cloud_connected") : UM.Theme.getIcon("printer_connected") + source: UM.Theme.getIcon("printer_connected") width: UM.Theme.getSize("printer_status_icon").width height: UM.Theme.getSize("printer_status_icon").height @@ -64,7 +63,7 @@ Cura.ExpandableComponent sourceSize.height: height color: UM.Theme.getColor("primary") - visible: isNetworkPrinter && (isPrinterConnected || isCloudConnected) + visible: isNetworkPrinter && isPrinterConnected // Make a themable circle in the background so we can change it in other themes Rectangle diff --git a/resources/qml/PrinterSelector/MachineSelectorList.qml b/resources/qml/PrinterSelector/MachineSelectorList.qml index e605f23f73..445940ab50 100644 --- a/resources/qml/PrinterSelector/MachineSelectorList.qml +++ b/resources/qml/PrinterSelector/MachineSelectorList.qml @@ -32,7 +32,8 @@ Column id: networkedPrintersModel filter: { - "type": "machine", "um_network_key": "*", "hidden": "False" + "type": "machine", + "um_network_key": "*" } } @@ -50,46 +51,6 @@ Column } } - Label - { - text: catalog.i18nc("@label", "Cloud connected printers") - visible: cloudPrintersModel.items.length > 0 - leftPadding: UM.Theme.getSize("default_margin").width - height: visible ? contentHeight + 2 * UM.Theme.getSize("default_margin").height : 0 - renderType: Text.NativeRendering - font: UM.Theme.getFont("medium") - color: UM.Theme.getColor("text_medium") - verticalAlignment: Text.AlignVCenter - } - - Repeater - { - id: cloudPrinters - - model: UM.ContainerStacksModel - { - id: cloudPrintersModel - filter: - { - "type": "machine", - "um_cloud_cluster_id": "*" - } - } - - delegate: MachineSelectorButton - { - text: model.metadata["connect_group_name"] - checked: true // cloud devices are always online if they are available - outputDevice: Cura.MachineManager.printerOutputDevices.length >= 1 ? Cura.MachineManager.printerOutputDevices[0] : null - - Connections - { - target: Cura.MachineManager - onActiveMachineNetworkGroupNameChanged: checked = Cura.MachineManager.activeMachineNetworkGroupName == model.metadata["connect_group_name"] - } - } - } - Label { text: catalog.i18nc("@label", "Preset printers") @@ -112,8 +73,7 @@ Column filter: { "type": "machine", - "um_network_key": null, - "um_cloud_cluster_id": null + "um_network_key": null } } diff --git a/resources/themes/cura-light/icons/printer_cloud_connected.svg b/resources/themes/cura-light/icons/printer_cloud_connected.svg index ef6f0f2910..59ca67e93e 100644 --- a/resources/themes/cura-light/icons/printer_cloud_connected.svg +++ b/resources/themes/cura-light/icons/printer_cloud_connected.svg @@ -1,5 +1,5 @@ - + noun_Cloud_377836 Created with Sketch. From b57f6c5c6a6f7b227963bbbfb06633e7e306c113 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Tue, 4 Dec 2018 22:58:50 +0100 Subject: [PATCH 065/178] Do no stop when no clusters are found, we still might need to remove some --- .../src/Cloud/CloudOutputDeviceManager.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 9f7e8fa74a..0fbeeb82b6 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -63,8 +63,6 @@ class CloudOutputDeviceManager: found_clusters = {c.cluster_id: c for c in clusters} Logger.log("i", "Parsed remote clusters to %s", found_clusters) - if not found_clusters: - return known_cluster_ids = set(self._remote_clusters.keys()) found_cluster_ids = set(found_clusters.keys()) @@ -85,7 +83,6 @@ class CloudOutputDeviceManager: device = CloudOutputDevice(self._api, cluster.cluster_id) self._output_device_manager.addOutputDevice(device) self._remote_clusters[cluster.cluster_id] = device - device.connect() # TODO: remove this self._connectToActiveMachine(cluster.cluster_id) ## Remove a CloudOutputDevice @@ -95,13 +92,14 @@ class CloudOutputDeviceManager: if cluster.cluster_id in self._remote_clusters: del self._remote_clusters[cluster.cluster_id] - ## Callback for when the active machine was changed by the user. + ## Callback for when the active machine was changed by the user or a new remote cluster was found. def _connectToActiveMachine(self, cluster_id: Optional[str] = None) -> None: active_machine = CuraApplication.getInstance().getGlobalContainerStack() if not active_machine: return # TODO: Remove this once correct pairing has been added (see below). + # TODO: This just adds any available cluster to the active device for testing. if cluster_id: active_machine.setMetaDataEntry("um_cloud_cluster_id", cluster_id) From 01e443049fb51943369d3f1eca376bfb03f82b5a Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Wed, 5 Dec 2018 10:37:58 +0100 Subject: [PATCH 066/178] STAR-322: Creating a subclass for connect devices --- .../src/BaseCuraConnectDevice.py | 8 ++++++++ .../src/Cloud/CloudOutputDevice.py | 6 ++++-- .../src/ClusterUM3OutputDevice.py | 19 +++++++++---------- .../src/UM3OutputDevicePlugin.py | 2 +- 4 files changed, 22 insertions(+), 13 deletions(-) create mode 100644 plugins/UM3NetworkPrinting/src/BaseCuraConnectDevice.py diff --git a/plugins/UM3NetworkPrinting/src/BaseCuraConnectDevice.py b/plugins/UM3NetworkPrinting/src/BaseCuraConnectDevice.py new file mode 100644 index 0000000000..dc3d577cd5 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/BaseCuraConnectDevice.py @@ -0,0 +1,8 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from cura.PrinterOutput.NetworkedPrinterOutputDevice import NetworkedPrinterOutputDevice + + +## this is the base class for the UM3 output devices (via connect or cloud) +class BaseCuraConnectDevice(NetworkedPrinterOutputDevice): + pass diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index 27bf3a821e..4fa8d3b376 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -2,6 +2,7 @@ # Cura is released under the terms of the LGPLv3 or higher. import io import os +from datetime import datetime, timedelta from time import time from typing import List, Optional, Dict, cast, Union, Set @@ -18,8 +19,9 @@ from UM.Version import Version from cura.CuraApplication import CuraApplication from cura.PrinterOutput.PrinterOutputController import PrinterOutputController from cura.PrinterOutput.MaterialOutputModel import MaterialOutputModel -from cura.PrinterOutput.NetworkedPrinterOutputDevice import NetworkedPrinterOutputDevice, AuthState +from cura.PrinterOutput.NetworkedPrinterOutputDevice import AuthState from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel +from plugins.UM3NetworkPrinting.src.BaseCuraConnectDevice import BaseCuraConnectDevice from plugins.UM3NetworkPrinting.src.Cloud.CloudApiClient import CloudApiClient from plugins.UM3NetworkPrinting.src.UM3PrintJobOutputModel import UM3PrintJobOutputModel from .Models import ( @@ -61,7 +63,7 @@ class T: # Note that this device represents a single remote cluster, not a list of multiple clusters. # # TODO: figure our how the QML interface for the cluster networking should operate with this limited functionality. -class CloudOutputDevice(NetworkedPrinterOutputDevice): +class CloudOutputDevice(BaseCuraConnectDevice): # The interval with which the remote clusters are checked CHECK_CLUSTER_INTERVAL = 2.0 # seconds diff --git a/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py b/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py index 8881584416..7015f71be4 100644 --- a/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py @@ -1,7 +1,13 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from typing import Any, cast, Optional, Set, Tuple, Union +from typing import Any, cast, Tuple, Union, Optional, Dict, List +from time import time +from datetime import datetime + +import io # To create the correct buffers for sending data to the printer. +import json +import os from UM.FileHandler.FileHandler import FileHandler from UM.FileHandler.FileWriter import FileWriter # To choose based on the output file mode (text vs. binary). @@ -22,6 +28,7 @@ from cura.PrinterOutput.ExtruderConfigurationModel import ExtruderConfigurationM from cura.PrinterOutput.NetworkedPrinterOutputDevice import NetworkedPrinterOutputDevice, AuthState from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel from cura.PrinterOutput.MaterialOutputModel import MaterialOutputModel +from plugins.UM3NetworkPrinting.src.BaseCuraConnectDevice import BaseCuraConnectDevice from .ClusterUM3PrinterOutputController import ClusterUM3PrinterOutputController from .SendMaterialJob import SendMaterialJob @@ -32,18 +39,10 @@ from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply from PyQt5.QtGui import QDesktopServices, QImage from PyQt5.QtCore import pyqtSlot, QUrl, pyqtSignal, pyqtProperty, QObject -from time import time -from datetime import datetime -from typing import Optional, Dict, List - -import io # To create the correct buffers for sending data to the printer. -import json -import os - i18n_catalog = i18nCatalog("cura") -class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): +class ClusterUM3OutputDevice(BaseCuraConnectDevice): printJobsChanged = pyqtSignal() activePrinterChanged = pyqtSignal() activeCameraUrlChanged = pyqtSignal() diff --git a/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py b/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py index 91c9cb32b9..6a80ae046e 100644 --- a/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py +++ b/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py @@ -363,4 +363,4 @@ class UM3OutputDevicePlugin(OutputDevicePlugin): Logger.log("d", "Bonjour service removed: %s" % name) self.removeDeviceSignal.emit(str(name)) - return True \ No newline at end of file + return True From d99e2d15339b8638b6161fc4659e77f9eb618c19 Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Wed, 5 Dec 2018 11:21:17 +0100 Subject: [PATCH 067/178] STAR-322: Extracting file handler methods --- .../src/BaseCuraConnectDevice.py | 68 ++++++++++++++++++- .../src/Cloud/CloudOutputDevice.py | 68 +++---------------- .../src/ClusterUM3OutputDevice.py | 53 +++------------ 3 files changed, 83 insertions(+), 106 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/BaseCuraConnectDevice.py b/plugins/UM3NetworkPrinting/src/BaseCuraConnectDevice.py index dc3d577cd5..0abf5955cf 100644 --- a/plugins/UM3NetworkPrinting/src/BaseCuraConnectDevice.py +++ b/plugins/UM3NetworkPrinting/src/BaseCuraConnectDevice.py @@ -1,8 +1,72 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. +from typing import Optional, Dict, Union + +from UM.FileHandler.FileHandler import FileHandler +from UM.FileHandler.FileWriter import FileWriter +from UM.Logger import Logger +from UM.OutputDevice import OutputDeviceError # To show that something went wrong when writing. +from UM.Version import Version # To check against firmware versions for support. +from UM.i18n import i18nCatalog +from cura.CuraApplication import CuraApplication from cura.PrinterOutput.NetworkedPrinterOutputDevice import NetworkedPrinterOutputDevice -## this is the base class for the UM3 output devices (via connect or cloud) +## Class that contains all the translations for this module. +class T: + # The translation catalog for this device. + + _I18N_CATALOG = i18nCatalog("cura") + NO_FORMATS_AVAILABLE = _I18N_CATALOG.i18nc("@info:status", "There are no file formats available to write with!") + + +## This is the base class for the UM3 output devices (via connect or cloud) class BaseCuraConnectDevice(NetworkedPrinterOutputDevice): - pass + + ## Gets the default file handler + @property + def defaultFileHandler(self) -> FileHandler: + return CuraApplication.getInstance().getMeshFileHandler() + + ## Chooses the preferred file format for the given file handler. + # \param file_handler: The file handler. + # \return A dict with the file format details, with format: + # {id: str, extension: str, description: str, mime_type: str, mode: int, hide_in_file_dialog: bool} + def _getPreferredFormat(self, file_handler: Optional[FileHandler]) -> Optional[Dict[str, Union[str, int, bool]]]: + # Formats supported by this application (file types that we can actually write). + application = CuraApplication.getInstance() + + file_handler = file_handler or self.defaultFileHandler + file_formats = file_handler.getSupportedFileTypesWrite() + + global_stack = application.getGlobalContainerStack() + # Create a list from the supported file formats string. + if not global_stack: + Logger.log("e", "Missing global stack!") + return + + machine_file_formats = global_stack.getMetaDataEntry("file_formats").split(";") + machine_file_formats = [file_type.strip() for file_type in machine_file_formats] + # Exception for UM3 firmware version >=4.4: UFP is now supported and should be the preferred file format. + if "application/x-ufp" not in machine_file_formats and Version(self.firmwareVersion) >= Version("4.4"): + machine_file_formats = ["application/x-ufp"] + machine_file_formats + + # Take the intersection between file_formats and machine_file_formats. + format_by_mimetype = {f["mime_type"]: f for f in file_formats} + + # Keep them ordered according to the preference in machine_file_formats. + file_formats = [format_by_mimetype[mimetype] for mimetype in machine_file_formats] + + if len(file_formats) == 0: + Logger.log("e", "There are no file formats available to write with!") + raise OutputDeviceError.WriteRequestFailedError(T.NO_FORMATS_AVAILABLE) + return file_formats[0] + + ## Gets the file writer for the given file handler and mime type. + # \param file_handler: The file handler. + # \param mime_type: The mine type. + # \return A file writer. + def _getWriter(self, file_handler: Optional[FileHandler], mime_type: str) -> Optional[FileWriter]: + # Just take the first file format available. + file_handler = file_handler or self.defaultFileHandler + return file_handler.getWriterByMimeType(mime_type) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index 4fa8d3b376..0c6d11c708 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -2,9 +2,8 @@ # Cura is released under the terms of the LGPLv3 or higher. import io import os -from datetime import datetime, timedelta from time import time -from typing import List, Optional, Dict, cast, Union, Set +from typing import List, Optional, Dict, Union, Set from PyQt5.QtCore import QObject, pyqtSignal, QUrl, pyqtProperty, pyqtSlot @@ -13,9 +12,7 @@ from UM.FileHandler.FileWriter import FileWriter from UM.FileHandler.FileHandler import FileHandler from UM.Logger import Logger from UM.Message import Message -from UM.OutputDevice import OutputDeviceError from UM.Scene.SceneNode import SceneNode -from UM.Version import Version from cura.CuraApplication import CuraApplication from cura.PrinterOutput.PrinterOutputController import PrinterOutputController from cura.PrinterOutput.MaterialOutputModel import MaterialOutputModel @@ -45,7 +42,6 @@ class T: "the previous print job.") COULD_NOT_EXPORT = _I18N_CATALOG.i18nc("@info:status", "Could not export print job.") - WRITE_FAILED = _I18N_CATALOG.i18nc("@info:status", "There are no file formats available to write with!") SENDING_DATA_TEXT = _I18N_CATALOG.i18nc("@info:status", "Sending data to remote cluster") SENDING_DATA_TITLE = _I18N_CATALOG.i18nc("@info:status", "Sending data to remote cluster") @@ -69,7 +65,7 @@ class CloudOutputDevice(BaseCuraConnectDevice): CHECK_CLUSTER_INTERVAL = 2.0 # seconds # Signal triggered when the printers in the remote cluster were changed. - printersChanged = pyqtSignal() + clusterPrintersChanged = pyqtSignal() # Signal triggered when the print jobs in the queue were changed. printJobsChanged = pyqtSignal() @@ -122,8 +118,8 @@ class CloudOutputDevice(BaseCuraConnectDevice): self._sending_job = True self.writeStarted.emit(self) - file_format = self._determineFileFormat(file_handler) - writer = self._determineWriter(file_handler, file_format) + file_format = self._getPreferredFormat(file_handler) + writer = self._getWriter(file_handler, file_format["mime_type"]) if not writer: Logger.log("e", "Missing file or mesh writer!") return self._onUploadError(T.COULD_NOT_EXPORT) @@ -134,56 +130,8 @@ class CloudOutputDevice(BaseCuraConnectDevice): # TODO: Remove extension from the file name, since we are using content types now self._sendPrintJob(file_name + "." + file_format["extension"], file_format["mime_type"], stream) - # TODO: This is yanked right out of ClusterUM3OutputDevice, great candidate for a utility or base class - def _determineFileFormat(self, file_handler) -> Optional[Dict[str, Union[str, int]]]: - # Formats supported by this application (file types that we can actually write). - if file_handler: - file_formats = file_handler.getSupportedFileTypesWrite() - else: - file_formats = CuraApplication.getInstance().getMeshFileHandler().getSupportedFileTypesWrite() - - global_stack = CuraApplication.getInstance().getGlobalContainerStack() - # Create a list from the supported file formats string. - if not global_stack: - Logger.log("e", "Missing global stack!") - return - - machine_file_formats = global_stack.getMetaDataEntry("file_formats").split(";") - machine_file_formats = [file_type.strip() for file_type in machine_file_formats] - # Exception for UM3 firmware version >=4.4: UFP is now supported and should be the preferred file format. - if "application/x-ufp" not in machine_file_formats and Version(self.firmwareVersion) >= Version("4.4"): - machine_file_formats = ["application/x-ufp"] + machine_file_formats - - # Take the intersection between file_formats and machine_file_formats. - format_by_mimetype = {f["mime_type"]: f for f in file_formats} - - # Keep them ordered according to the preference in machine_file_formats. - file_formats = [format_by_mimetype[mimetype] for mimetype in machine_file_formats] - - if len(file_formats) == 0: - Logger.log("e", "There are no file formats available to write with!") - raise OutputDeviceError.WriteRequestFailedError(T.WRITE_FAILED) - return file_formats[0] - - # TODO: This is yanked right out of ClusterUM3OutputDevice, great candidate for a utility or base class - @staticmethod - def _determineWriter(file_handler, file_format) -> Optional[FileWriter]: - # Just take the first file format available. - if file_handler is not None: - writer = file_handler.getWriterByMimeType(cast(str, file_format["mime_type"])) - else: - writer = CuraApplication.getInstance().getMeshFileHandler().getWriterByMimeType( - cast(str, file_format["mime_type"]) - ) - - if not writer: - Logger.log("e", "Unexpected error when trying to get the FileWriter") - return - - return writer - ## Get remote printers. - @pyqtProperty("QVariantList", notify = printersChanged) + @pyqtProperty("QVariantList", notify = clusterPrintersChanged) def printers(self): return self._printers @@ -244,7 +192,7 @@ class CloudOutputDevice(BaseCuraConnectDevice): for printer_guid in updated_printer_ids: self._updatePrinter(current_printers[printer_guid], remote_printers[printer_guid]) - self.printersChanged.emit() + self.clusterPrintersChanged.emit() def _addPrinter(self, printer: CloudClusterPrinter) -> None: model = PrinterOutputModel( @@ -409,7 +357,7 @@ class CloudOutputDevice(BaseCuraConnectDevice): ## TODO: The following methods are required by the monitor page QML, but are not actually available using cloud. # TODO: We fake the methods here to not break the monitor page. - @pyqtProperty(QObject, notify = printersChanged) + @pyqtProperty(QObject, notify = clusterPrintersChanged) def activePrinter(self) -> Optional[PrinterOutputModel]: if not self._printers: return None @@ -419,7 +367,7 @@ class CloudOutputDevice(BaseCuraConnectDevice): def setActivePrinter(self, printer: Optional[PrinterOutputModel]) -> None: pass - @pyqtProperty(QUrl, notify = printersChanged) + @pyqtProperty(QUrl, notify = clusterPrintersChanged) def activeCameraUrl(self) -> "QUrl": return QUrl() diff --git a/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py b/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py index a1530c128d..c77c4b93c2 100644 --- a/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py @@ -18,14 +18,12 @@ from UM.i18n import i18nCatalog from UM.Message import Message from UM.Qt.Duration import Duration, DurationFormat -from UM.OutputDevice import OutputDeviceError # To show that something went wrong when writing. from UM.Scene.SceneNode import SceneNode # For typing. -from UM.Version import Version # To check against firmware versions for support. from cura.CuraApplication import CuraApplication from cura.PrinterOutput.ConfigurationModel import ConfigurationModel from cura.PrinterOutput.ExtruderConfigurationModel import ExtruderConfigurationModel -from cura.PrinterOutput.NetworkedPrinterOutputDevice import NetworkedPrinterOutputDevice, AuthState +from cura.PrinterOutput.NetworkedPrinterOutputDevice import AuthState from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel from cura.PrinterOutput.MaterialOutputModel import MaterialOutputModel from plugins.UM3NetworkPrinting.src.BaseCuraConnectDevice import BaseCuraConnectDevice @@ -50,7 +48,7 @@ class ClusterUM3OutputDevice(BaseCuraConnectDevice): # This is a bit of a hack, as the notify can only use signals that are defined by the class that they are in. # Inheritance doesn't seem to work. Tying them together does work, but i'm open for better suggestions. - clusterPrintersChanged = pyqtSignal() + _clusterPrintersChanged = pyqtSignal() def __init__(self, device_id, address, properties, parent = None) -> None: super().__init__(device_id = device_id, address = address, properties=properties, parent = parent) @@ -66,7 +64,7 @@ class ClusterUM3OutputDevice(BaseCuraConnectDevice): self._monitor_view_qml_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "../resources/qml/ClusterMonitorItem.qml") # See comments about this hack with the clusterPrintersChanged signal - self.printersChanged.connect(self.clusterPrintersChanged) + self.printersChanged.connect(self._clusterPrintersChanged) self._accepts_commands = True # type: bool @@ -99,47 +97,14 @@ class ClusterUM3OutputDevice(BaseCuraConnectDevice): self._active_camera_url = QUrl() # type: QUrl - def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mimetypes: bool = False, file_handler: Optional[FileHandler] = None, **kwargs: str) -> None: + def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mimetypes: bool = False, + file_handler: Optional[FileHandler] = None, **kwargs: str) -> None: self.writeStarted.emit(self) self.sendMaterialProfiles() - # Formats supported by this application (file types that we can actually write). - if file_handler: - file_formats = file_handler.getSupportedFileTypesWrite() - else: - file_formats = CuraApplication.getInstance().getMeshFileHandler().getSupportedFileTypesWrite() - - global_stack = CuraApplication.getInstance().getGlobalContainerStack() - # Create a list from the supported file formats string. - if not global_stack: - Logger.log("e", "Missing global stack!") - return - - machine_file_formats = global_stack.getMetaDataEntry("file_formats").split(";") - machine_file_formats = [file_type.strip() for file_type in machine_file_formats] - # Exception for UM3 firmware version >=4.4: UFP is now supported and should be the preferred file format. - if "application/x-ufp" not in machine_file_formats and Version(self.firmwareVersion) >= Version("4.4"): - machine_file_formats = ["application/x-ufp"] + machine_file_formats - - # Take the intersection between file_formats and machine_file_formats. - format_by_mimetype = {format["mime_type"]: format for format in file_formats} - file_formats = [format_by_mimetype[mimetype] for mimetype in machine_file_formats] #Keep them ordered according to the preference in machine_file_formats. - - if len(file_formats) == 0: - Logger.log("e", "There are no file formats available to write with!") - raise OutputDeviceError.WriteRequestFailedError(i18n_catalog.i18nc("@info:status", "There are no file formats available to write with!")) - preferred_format = file_formats[0] - - # Just take the first file format available. - if file_handler is not None: - writer = file_handler.getWriterByMimeType(cast(str, preferred_format["mime_type"])) - else: - writer = CuraApplication.getInstance().getMeshFileHandler().getWriterByMimeType(cast(str, preferred_format["mime_type"])) - - if not writer: - Logger.log("e", "Unexpected error when trying to get the FileWriter") - return + preferred_format = self._getPreferredFormat(file_handler) + writer = self._getWriter(file_handler, preferred_format["mime_type"]) # This function pauses with the yield, waiting on instructions on which printer it needs to print with. if not writer: @@ -355,7 +320,7 @@ class ClusterUM3OutputDevice(BaseCuraConnectDevice): def activePrintJobs(self) -> List[UM3PrintJobOutputModel]: return [print_job for print_job in self._print_jobs if print_job.assignedPrinter is not None and print_job.state != "queued"] - @pyqtProperty("QVariantList", notify = clusterPrintersChanged) + @pyqtProperty("QVariantList", notify = _clusterPrintersChanged) def connectedPrintersTypeCount(self) -> List[Dict[str, str]]: printer_count = {} # type: Dict[str, int] for printer in self._printers: @@ -368,7 +333,7 @@ class ClusterUM3OutputDevice(BaseCuraConnectDevice): result.append({"machine_type": machine_type, "count": str(printer_count[machine_type])}) return result - @pyqtProperty("QVariantList", notify=clusterPrintersChanged) + @pyqtProperty("QVariantList", notify=_clusterPrintersChanged) def printers(self): return self._printers From 163226f9400651b45a088af884a0edad383dc8e7 Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Wed, 5 Dec 2018 12:02:04 +0100 Subject: [PATCH 068/178] STAR-322: Using composition rather than inheritance --- cura/PrinterOutputDevice.py | 9 ++- .../src/Cloud/CloudOutputDevice.py | 35 ++++----- .../src/ClusterUM3OutputDevice.py | 45 ++++++----- ...aConnectDevice.py => MeshFormatHandler.py} | 74 ++++++++++++++----- 4 files changed, 97 insertions(+), 66 deletions(-) rename plugins/UM3NetworkPrinting/src/{BaseCuraConnectDevice.py => MeshFormatHandler.py} (50%) diff --git a/cura/PrinterOutputDevice.py b/cura/PrinterOutputDevice.py index 99c48189cc..8c00ea1aa6 100644 --- a/cura/PrinterOutputDevice.py +++ b/cura/PrinterOutputDevice.py @@ -131,7 +131,8 @@ class PrinterOutputDevice(QObject, OutputDevice): return None - def requestWrite(self, nodes: List["SceneNode"], file_name: Optional[str] = None, limit_mimetypes: bool = False, file_handler: Optional["FileHandler"] = None, **kwargs: str) -> None: + def requestWrite(self, nodes: List["SceneNode"], file_name: Optional[str] = None, limit_mimetypes: bool = False, + file_handler: Optional["FileHandler"] = None, **kwargs: str) -> None: raise NotImplementedError("requestWrite needs to be implemented") @pyqtProperty(QObject, notify = printersChanged) @@ -207,8 +208,10 @@ class PrinterOutputDevice(QObject, OutputDevice): return self._unique_configurations def _updateUniqueConfigurations(self) -> None: - self._unique_configurations = list(set([printer.printerConfiguration for printer in self._printers if printer.printerConfiguration is not None])) - self._unique_configurations.sort(key = lambda k: k.printerType) + self._unique_configurations = sorted( + {printer.printerConfiguration for printer in self._printers if printer.printerConfiguration is not None}, + key=lambda config: config.printerType, + ) self.uniqueConfigurationsChanged.emit() # Returns the unique configurations of the printers within this output device diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index 0c6d11c708..91f5721aa6 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -1,14 +1,12 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -import io import os from time import time -from typing import List, Optional, Dict, Union, Set +from typing import List, Optional, Dict, Set from PyQt5.QtCore import QObject, pyqtSignal, QUrl, pyqtProperty, pyqtSlot from UM import i18nCatalog -from UM.FileHandler.FileWriter import FileWriter from UM.FileHandler.FileHandler import FileHandler from UM.Logger import Logger from UM.Message import Message @@ -16,10 +14,10 @@ from UM.Scene.SceneNode import SceneNode from cura.CuraApplication import CuraApplication from cura.PrinterOutput.PrinterOutputController import PrinterOutputController from cura.PrinterOutput.MaterialOutputModel import MaterialOutputModel -from cura.PrinterOutput.NetworkedPrinterOutputDevice import AuthState +from cura.PrinterOutput.NetworkedPrinterOutputDevice import AuthState, NetworkedPrinterOutputDevice from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel -from plugins.UM3NetworkPrinting.src.BaseCuraConnectDevice import BaseCuraConnectDevice from plugins.UM3NetworkPrinting.src.Cloud.CloudApiClient import CloudApiClient +from plugins.UM3NetworkPrinting.src.MeshFormatHandler import MeshFormatHandler from plugins.UM3NetworkPrinting.src.UM3PrintJobOutputModel import UM3PrintJobOutputModel from .Models import ( CloudClusterPrinter, CloudClusterPrintJob, CloudJobUploadRequest, CloudJobResponse, CloudClusterStatus, @@ -59,7 +57,7 @@ class T: # Note that this device represents a single remote cluster, not a list of multiple clusters. # # TODO: figure our how the QML interface for the cluster networking should operate with this limited functionality. -class CloudOutputDevice(BaseCuraConnectDevice): +class CloudOutputDevice(NetworkedPrinterOutputDevice): # The interval with which the remote clusters are checked CHECK_CLUSTER_INTERVAL = 2.0 # seconds @@ -118,17 +116,20 @@ class CloudOutputDevice(BaseCuraConnectDevice): self._sending_job = True self.writeStarted.emit(self) - file_format = self._getPreferredFormat(file_handler) - writer = self._getWriter(file_handler, file_format["mime_type"]) - if not writer: + mesh_format = MeshFormatHandler(file_handler, self.firmwareVersion) + if not mesh_format.is_valid: Logger.log("e", "Missing file or mesh writer!") return self._onUploadError(T.COULD_NOT_EXPORT) - stream = io.StringIO() if file_format["mode"] == FileWriter.OutputMode.TextMode else io.BytesIO() - writer.write(stream, nodes) + mesh_bytes = mesh_format.getBytes(nodes) # TODO: Remove extension from the file name, since we are using content types now - self._sendPrintJob(file_name + "." + file_format["extension"], file_format["mime_type"], stream) + request = CloudJobUploadRequest( + job_name = file_name + "." + mesh_format.file_extension, + file_size = len(mesh_bytes), + content_type = mesh_format.mime_type, + ) + self._api.requestUpload(request, lambda response: self._onPrintJobCreated(mesh_bytes, response)) ## Get remote printers. @pyqtProperty("QVariantList", notify = clusterPrintersChanged) @@ -292,16 +293,6 @@ class CloudOutputDevice(BaseCuraConnectDevice): model.updateOwner(job.owner) model.updateState(job.status) - def _sendPrintJob(self, file_name: str, content_type: str, stream: Union[io.StringIO, io.BytesIO]) -> None: - mesh = stream.getvalue() - - request = CloudJobUploadRequest() - request.job_name = file_name - request.file_size = len(mesh) - request.content_type = content_type - - self._api.requestUpload(request, lambda response: self._onPrintJobCreated(mesh, response)) - def _onPrintJobCreated(self, mesh: bytes, job_response: CloudJobResponse) -> None: self._api.uploadMesh(job_response, mesh, self._onPrintJobUploaded, self._updateUploadProgress, lambda _: self._onUploadError(T.UPLOAD_ERROR)) diff --git a/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py b/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py index c77c4b93c2..64ac613723 100644 --- a/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py @@ -10,7 +10,6 @@ import json import os from UM.FileHandler.FileHandler import FileHandler -from UM.FileHandler.FileWriter import FileWriter # To choose based on the output file mode (text vs. binary). from UM.FileHandler.WriteFileJob import WriteFileJob # To call the file writer asynchronously. from UM.Logger import Logger from UM.Settings.ContainerRegistry import ContainerRegistry @@ -23,10 +22,10 @@ from UM.Scene.SceneNode import SceneNode # For typing. from cura.CuraApplication import CuraApplication from cura.PrinterOutput.ConfigurationModel import ConfigurationModel from cura.PrinterOutput.ExtruderConfigurationModel import ExtruderConfigurationModel -from cura.PrinterOutput.NetworkedPrinterOutputDevice import AuthState +from cura.PrinterOutput.NetworkedPrinterOutputDevice import AuthState, NetworkedPrinterOutputDevice from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel from cura.PrinterOutput.MaterialOutputModel import MaterialOutputModel -from plugins.UM3NetworkPrinting.src.BaseCuraConnectDevice import BaseCuraConnectDevice +from plugins.UM3NetworkPrinting.src.MeshFormatHandler import MeshFormatHandler from .ClusterUM3PrinterOutputController import ClusterUM3PrinterOutputController from .SendMaterialJob import SendMaterialJob @@ -40,7 +39,7 @@ from PyQt5.QtCore import pyqtSlot, QUrl, pyqtSignal, pyqtProperty, QObject i18n_catalog = i18nCatalog("cura") -class ClusterUM3OutputDevice(BaseCuraConnectDevice): +class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): printJobsChanged = pyqtSignal() activePrinterChanged = pyqtSignal() activeCameraUrlChanged = pyqtSignal() @@ -103,14 +102,13 @@ class ClusterUM3OutputDevice(BaseCuraConnectDevice): self.sendMaterialProfiles() - preferred_format = self._getPreferredFormat(file_handler) - writer = self._getWriter(file_handler, preferred_format["mime_type"]) + mesh_format = MeshFormatHandler(file_handler, self.firmwareVersion) # This function pauses with the yield, waiting on instructions on which printer it needs to print with. - if not writer: + if not mesh_format.is_valid: Logger.log("e", "Missing file or mesh writer!") return - self._sending_job = self._sendPrintJob(writer, preferred_format, nodes) + self._sending_job = self._sendPrintJob(mesh_format, nodes) if self._sending_job is not None: self._sending_job.send(None) # Start the generator. @@ -150,11 +148,8 @@ class ClusterUM3OutputDevice(BaseCuraConnectDevice): # greenlet in order to optionally wait for selectPrinter() to select a # printer. # The greenlet yields exactly three times: First time None, - # \param writer The file writer to use to create the data. - # \param preferred_format A dictionary containing some information about - # what format to write to. This is necessary to create the correct buffer - # types and file extension and such. - def _sendPrintJob(self, writer: FileWriter, preferred_format: Dict, nodes: List[SceneNode]): + # \param mesh_format Object responsible for choosing the right kind of format to write with. + def _sendPrintJob(self, mesh_format: MeshFormatHandler, nodes: List[SceneNode]): Logger.log("i", "Sending print job to printer.") if self._sending_gcode: self._error_message = Message( @@ -172,17 +167,17 @@ class ClusterUM3OutputDevice(BaseCuraConnectDevice): # Using buffering greatly reduces the write time for many lines of gcode - stream = io.BytesIO() # type: Union[io.BytesIO, io.StringIO]# Binary mode. - if preferred_format["mode"] == FileWriter.OutputMode.TextMode: - stream = io.StringIO() + stream = mesh_format.createStream() - job = WriteFileJob(writer, stream, nodes, preferred_format["mode"]) + job = WriteFileJob(mesh_format.writer, stream, nodes, mesh_format.file_mode) - self._write_job_progress_message = Message(i18n_catalog.i18nc("@info:status", "Sending data to printer"), lifetime = 0, dismissable = False, progress = -1, - title = i18n_catalog.i18nc("@info:title", "Sending Data"), use_inactivity_timer = False) + self._write_job_progress_message = Message(i18n_catalog.i18nc("@info:status", "Sending data to printer"), + lifetime = 0, dismissable = False, progress = -1, + title = i18n_catalog.i18nc("@info:title", "Sending Data"), + use_inactivity_timer = False) self._write_job_progress_message.show() - self._dummy_lambdas = (target_printer, preferred_format, stream) + self._dummy_lambdas = (target_printer, mesh_format.preferred_format, stream) job.finished.connect(self._sendPrintJobWaitOnWriteJobFinished) job.start() @@ -194,9 +189,11 @@ class ClusterUM3OutputDevice(BaseCuraConnectDevice): if self._write_job_progress_message: self._write_job_progress_message.hide() - self._progress_message = Message(i18n_catalog.i18nc("@info:status", "Sending data to printer"), lifetime = 0, dismissable = False, progress = -1, + self._progress_message = Message(i18n_catalog.i18nc("@info:status", "Sending data to printer"), lifetime = 0, + dismissable = False, progress = -1, title = i18n_catalog.i18nc("@info:title", "Sending Data")) - self._progress_message.addAction("Abort", i18n_catalog.i18nc("@action:button", "Cancel"), icon = None, description = "") + self._progress_message.addAction("Abort", i18n_catalog.i18nc("@action:button", "Cancel"), icon = None, + description = "") self._progress_message.actionTriggered.connect(self._progressMessageActionTriggered) self._progress_message.show() parts = [] @@ -220,7 +217,9 @@ class ClusterUM3OutputDevice(BaseCuraConnectDevice): parts.append(self._createFormPart("name=\"file\"; filename=\"%s\"" % file_name, output)) - self._latest_reply_handler = self.postFormWithParts("print_jobs/", parts, on_finished = self._onPostPrintJobFinished, on_progress = self._onUploadPrintJobProgress) + self._latest_reply_handler = self.postFormWithParts("print_jobs/", parts, + on_finished = self._onPostPrintJobFinished, + on_progress = self._onUploadPrintJobProgress) @pyqtProperty(QObject, notify = activePrinterChanged) def activePrinter(self) -> Optional[PrinterOutputModel]: diff --git a/plugins/UM3NetworkPrinting/src/BaseCuraConnectDevice.py b/plugins/UM3NetworkPrinting/src/MeshFormatHandler.py similarity index 50% rename from plugins/UM3NetworkPrinting/src/BaseCuraConnectDevice.py rename to plugins/UM3NetworkPrinting/src/MeshFormatHandler.py index 0abf5955cf..c2bd997fbb 100644 --- a/plugins/UM3NetworkPrinting/src/BaseCuraConnectDevice.py +++ b/plugins/UM3NetworkPrinting/src/MeshFormatHandler.py @@ -1,15 +1,16 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from typing import Optional, Dict, Union +import io +from typing import Optional, Dict, Union, List from UM.FileHandler.FileHandler import FileHandler from UM.FileHandler.FileWriter import FileWriter from UM.Logger import Logger from UM.OutputDevice import OutputDeviceError # To show that something went wrong when writing. +from UM.Scene.SceneNode import SceneNode from UM.Version import Version # To check against firmware versions for support. from UM.i18n import i18nCatalog from cura.CuraApplication import CuraApplication -from cura.PrinterOutput.NetworkedPrinterOutputDevice import NetworkedPrinterOutputDevice ## Class that contains all the translations for this module. @@ -20,24 +21,63 @@ class T: NO_FORMATS_AVAILABLE = _I18N_CATALOG.i18nc("@info:status", "There are no file formats available to write with!") -## This is the base class for the UM3 output devices (via connect or cloud) -class BaseCuraConnectDevice(NetworkedPrinterOutputDevice): +## This class is responsible for choosing the formats used by the connected clusters. +class MeshFormatHandler: + + def __init__(self, file_handler: Optional[FileHandler], firmware_version: str) -> None: + self._file_handler = file_handler or CuraApplication.getInstance().getMeshFileHandler() + self._preferred_format = self._getPreferredFormat(firmware_version) + self._writer = self._getWriter(self._preferred_format["mime_type"]) if self._preferred_format else None - ## Gets the default file handler @property - def defaultFileHandler(self) -> FileHandler: - return CuraApplication.getInstance().getMeshFileHandler() + def is_valid(self) -> bool: + return bool(self._writer) + + ## Chooses the preferred file format. + # \return A dict with the file format details, with the following keys: + # {id: str, extension: str, description: str, mime_type: str, mode: int, hide_in_file_dialog: bool} + @property + def preferred_format(self) -> Optional[Dict[str, Union[str, int, bool]]]: + return self._preferred_format + + ## Gets the file writer for the given file handler and mime type. + # \return A file writer. + @property + def writer(self) -> Optional[FileWriter]: + return self._writer + + @property + def mime_type(self) -> str: + return self._preferred_format["mime_type"] + + ## Gets the file mode (FileWriter.OutputMode.TextMode or FileWriter.OutputMode.BinaryMode) + @property + def file_mode(self) -> int: + return self._preferred_format["mode"] + + ## Gets the file extension + @property + def file_extension(self) -> str: + return self._preferred_format["extension"] + + ## Creates the right kind of stream based on the preferred format. + def createStream(self) -> Union[io.BytesIO, io.StringIO]: + return io.StringIO() if self.file_mode == FileWriter.OutputMode.TextMode else io.BytesIO() + + ## Writes the mesh and returns its value. + def getBytes(self, nodes: List[SceneNode]) -> bytes: + stream = self.createStream() + self.writer.write(stream, nodes) + return stream.getvalue() ## Chooses the preferred file format for the given file handler. - # \param file_handler: The file handler. - # \return A dict with the file format details, with format: - # {id: str, extension: str, description: str, mime_type: str, mode: int, hide_in_file_dialog: bool} - def _getPreferredFormat(self, file_handler: Optional[FileHandler]) -> Optional[Dict[str, Union[str, int, bool]]]: + # \param firmware_version: The version of the firmware. + # \return A dict with the file format details. + def _getPreferredFormat(self, firmware_version: str) -> Optional[Dict[str, Union[str, int, bool]]]: # Formats supported by this application (file types that we can actually write). application = CuraApplication.getInstance() - file_handler = file_handler or self.defaultFileHandler - file_formats = file_handler.getSupportedFileTypesWrite() + file_formats = self._file_handler.getSupportedFileTypesWrite() global_stack = application.getGlobalContainerStack() # Create a list from the supported file formats string. @@ -48,7 +88,7 @@ class BaseCuraConnectDevice(NetworkedPrinterOutputDevice): machine_file_formats = global_stack.getMetaDataEntry("file_formats").split(";") machine_file_formats = [file_type.strip() for file_type in machine_file_formats] # Exception for UM3 firmware version >=4.4: UFP is now supported and should be the preferred file format. - if "application/x-ufp" not in machine_file_formats and Version(self.firmwareVersion) >= Version("4.4"): + if "application/x-ufp" not in machine_file_formats and Version(firmware_version) >= Version("4.4"): machine_file_formats = ["application/x-ufp"] + machine_file_formats # Take the intersection between file_formats and machine_file_formats. @@ -63,10 +103,8 @@ class BaseCuraConnectDevice(NetworkedPrinterOutputDevice): return file_formats[0] ## Gets the file writer for the given file handler and mime type. - # \param file_handler: The file handler. # \param mime_type: The mine type. # \return A file writer. - def _getWriter(self, file_handler: Optional[FileHandler], mime_type: str) -> Optional[FileWriter]: + def _getWriter(self, mime_type: str) -> Optional[FileWriter]: # Just take the first file format available. - file_handler = file_handler or self.defaultFileHandler - return file_handler.getWriterByMimeType(mime_type) + return self._file_handler.getWriterByMimeType(mime_type) From 7d7dd6bdde7738e3b8e56a879e07a535557eaad6 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Wed, 5 Dec 2018 14:02:53 +0100 Subject: [PATCH 069/178] First idea for pairing local and remote clusters --- .../src/Cloud/CloudOutputDeviceManager.py | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 0fbeeb82b6..9405ea8490 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -77,13 +77,15 @@ class CloudOutputDeviceManager: for cluster_id in known_cluster_ids.difference(found_cluster_ids): self._removeCloudOutputDevice(found_clusters[cluster_id]) + # TODO: not pass clusters that are not online? + self._connectToActiveMachine(clusters) + ## Adds a CloudOutputDevice for each entry in the remote cluster list from the API. # \param cluster: The cluster that was added. def _addCloudOutputDevice(self, cluster: CloudCluster): device = CloudOutputDevice(self._api, cluster.cluster_id) self._output_device_manager.addOutputDevice(device) self._remote_clusters[cluster.cluster_id] = device - self._connectToActiveMachine(cluster.cluster_id) ## Remove a CloudOutputDevice # \param cluster: The cluster that was removed @@ -93,28 +95,28 @@ class CloudOutputDeviceManager: del self._remote_clusters[cluster.cluster_id] ## Callback for when the active machine was changed by the user or a new remote cluster was found. - def _connectToActiveMachine(self, cluster_id: Optional[str] = None) -> None: + def _connectToActiveMachine(self, clusters: List[CloudCluster]) -> None: active_machine = CuraApplication.getInstance().getGlobalContainerStack() if not active_machine: return - # TODO: Remove this once correct pairing has been added (see below). - # TODO: This just adds any available cluster to the active device for testing. - if cluster_id: - active_machine.setMetaDataEntry("um_cloud_cluster_id", cluster_id) - # Check if the stored cluster_id for the active machine is in our list of remote clusters. stored_cluster_id = active_machine.getMetaDataEntry("um_cloud_cluster_id") if stored_cluster_id in self._remote_clusters.keys(): - self._remote_clusters.get(stored_cluster_id).connect() + return self._remote_clusters.get(stored_cluster_id).connect() + + # Check if the active printer has a local network connection and match this key to the remote cluster. + # The local network key is formatted as ultimakersystem-xxxxxxxxxxxx._ultimaker._tcp.local. + # The optional remote host_name is formatted as ultimakersystem-xxxxxxxxxxxx. + # This means we can match the two by checking if the host_name is in the network key string. + + local_network_key = active_machine.getMetaDataEntry("um_network_key") + if not local_network_key: return - # TODO: See if this cloud cluster still has to be associated to the active machine. - # TODO: We have to get a common piece of data, like local network hostname, from the active machine and - # TODO: cloud cluster and then set the "um_cloud_cluster_id" meta data key on the active machine. - # TODO: If so, we can also immediate connect to it. - # active_machine.setMetaDataEntry("um_cloud_cluster_id", "") - # self._remote_clusters.get(stored_cluster_id).connect() + cluster_id = next(local_network_key in cluster.host_name for cluster in clusters) + if cluster_id in self._remote_clusters.keys(): + return self._remote_clusters.get(cluster_id).connect() ## Handles an API error received from the cloud. # \param errors: The errors received From b693b9d98fa592ddde05f5049845b803fb0a405d Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Wed, 5 Dec 2018 14:08:40 +0100 Subject: [PATCH 070/178] STAR-322: Extracting models to be able for converting themselves --- __init__.py | 0 .../src/Cloud/CloudApiClient.py | 11 +- .../src/Cloud/CloudOutputDevice.py | 119 ++++---------- .../src/Cloud/CloudOutputDeviceManager.py | 5 +- .../UM3NetworkPrinting/src/Cloud/Models.py | 153 ------------------ .../src/Cloud/Models/CloudCluster.py | 21 +++ .../src/Cloud/Models/CloudClusterPrintJob.py | 52 ++++++ .../Models/CloudClusterPrintJobConstraint.py | 10 ++ .../src/Cloud/Models/CloudClusterPrinter.py | 44 +++++ .../CloudClusterPrinterConfiguration.py | 27 ++++ ...loudClusterPrinterConfigurationMaterial.py | 46 ++++++ .../src/Cloud/Models/CloudClusterStatus.py | 22 +++ .../src/Cloud/Models/CloudErrorObject.py | 17 ++ .../src/Cloud/Models/CloudJobResponse.py | 16 ++ .../src/Cloud/Models/CloudJobUploadRequest.py | 12 ++ .../src/Cloud/Models/CloudPrintResponse.py | 12 ++ .../src/Cloud/Models/__init__.py | 2 + .../src/ClusterUM3OutputDevice.py | 4 +- plugins/UM3NetworkPrinting/src/Models.py | 3 + .../tests/TestSendMaterialJob.py | 2 +- 20 files changed, 324 insertions(+), 254 deletions(-) create mode 100644 __init__.py delete mode 100644 plugins/UM3NetworkPrinting/src/Cloud/Models.py create mode 100644 plugins/UM3NetworkPrinting/src/Cloud/Models/CloudCluster.py create mode 100644 plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJob.py create mode 100644 plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobConstraint.py create mode 100644 plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinter.py create mode 100644 plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterConfiguration.py create mode 100644 plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterConfigurationMaterial.py create mode 100644 plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterStatus.py create mode 100644 plugins/UM3NetworkPrinting/src/Cloud/Models/CloudErrorObject.py create mode 100644 plugins/UM3NetworkPrinting/src/Cloud/Models/CloudJobResponse.py create mode 100644 plugins/UM3NetworkPrinting/src/Cloud/Models/CloudJobUploadRequest.py create mode 100644 plugins/UM3NetworkPrinting/src/Cloud/Models/CloudPrintResponse.py create mode 100644 plugins/UM3NetworkPrinting/src/Cloud/Models/__init__.py diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py index d6c20d387b..3ede206d45 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py @@ -9,10 +9,13 @@ from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply from UM.Logger import Logger from cura.API import Account from cura.NetworkClient import NetworkClient -from plugins.UM3NetworkPrinting.src.Models import BaseModel -from plugins.UM3NetworkPrinting.src.Cloud.Models import ( - CloudCluster, CloudErrorObject, CloudClusterStatus, CloudJobUploadRequest, CloudPrintResponse, CloudJobResponse -) +from ..Models import BaseModel +from .Models.CloudCluster import CloudCluster +from .Models.CloudErrorObject import CloudErrorObject +from .Models.CloudClusterStatus import CloudClusterStatus +from .Models.CloudJobUploadRequest import CloudJobUploadRequest +from .Models.CloudPrintResponse import CloudPrintResponse +from .Models.CloudJobResponse import CloudJobResponse ## The cloud API client is responsible for handling the requests and responses from the cloud. diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index 91f5721aa6..c6397fc41f 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -2,9 +2,9 @@ # Cura is released under the terms of the LGPLv3 or higher. import os from time import time -from typing import List, Optional, Dict, Set +from typing import Dict, List, Optional, Set -from PyQt5.QtCore import QObject, pyqtSignal, QUrl, pyqtProperty, pyqtSlot +from PyQt5.QtCore import QObject, QUrl, pyqtProperty, pyqtSignal, pyqtSlot from UM import i18nCatalog from UM.FileHandler.FileHandler import FileHandler @@ -12,18 +12,19 @@ from UM.Logger import Logger from UM.Message import Message from UM.Scene.SceneNode import SceneNode from cura.CuraApplication import CuraApplication -from cura.PrinterOutput.PrinterOutputController import PrinterOutputController -from cura.PrinterOutput.MaterialOutputModel import MaterialOutputModel from cura.PrinterOutput.NetworkedPrinterOutputDevice import AuthState, NetworkedPrinterOutputDevice +from cura.PrinterOutput.PrinterOutputController import PrinterOutputController from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel -from plugins.UM3NetworkPrinting.src.Cloud.CloudApiClient import CloudApiClient -from plugins.UM3NetworkPrinting.src.MeshFormatHandler import MeshFormatHandler -from plugins.UM3NetworkPrinting.src.UM3PrintJobOutputModel import UM3PrintJobOutputModel -from .Models import ( - CloudClusterPrinter, CloudClusterPrintJob, CloudJobUploadRequest, CloudJobResponse, CloudClusterStatus, - CloudClusterPrinterConfigurationMaterial, CloudErrorObject, - CloudPrintResponse -) +from ..MeshFormatHandler import MeshFormatHandler +from ..UM3PrintJobOutputModel import UM3PrintJobOutputModel +from .CloudApiClient import CloudApiClient +from .Models.CloudErrorObject import CloudErrorObject +from .Models.CloudClusterStatus import CloudClusterStatus +from .Models.CloudJobUploadRequest import CloudJobUploadRequest +from .Models.CloudPrintResponse import CloudPrintResponse +from .Models.CloudJobResponse import CloudJobResponse +from .Models.CloudClusterPrinter import CloudClusterPrinter +from .Models.CloudClusterPrintJob import CloudClusterPrintJob ## Class that contains all the translations for this module. @@ -180,80 +181,23 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): remote_printers = {p.uuid: p for p in printers} # type: Dict[str, CloudClusterPrinter] current_printers = {p.key: p for p in self._printers} # type: Dict[str, PrinterOutputModel] - removed_printer_ids = set(current_printers).difference(remote_printers) - new_printer_ids = set(remote_printers).difference(current_printers) - updated_printer_ids = set(current_printers).intersection(remote_printers) + remote_printer_ids = set(remote_printers) # type: Set[str] + current_printer_ids = set(current_printers) # type: Set[str] - for printer_guid in removed_printer_ids: - self._printers.remove(current_printers[printer_guid]) + for removed_printer_id in current_printer_ids.difference(remote_printer_ids): + removed_printer = current_printers[removed_printer_id] + self._printers.remove(removed_printer) - for printer_guid in new_printer_ids: - self._addPrinter(remote_printers[printer_guid]) + for new_printer_id in remote_printer_ids.difference(current_printer_ids): + new_printer = remote_printers[new_printer_id] + controller = PrinterOutputController(self) + self._printers.append(new_printer.createOutputModel(controller)) - for printer_guid in updated_printer_ids: - self._updatePrinter(current_printers[printer_guid], remote_printers[printer_guid]) + for updated_printer_guid in current_printer_ids.intersection(remote_printer_ids): + remote_printers[updated_printer_guid].updateOutputModel(current_printers[updated_printer_guid]) self.clusterPrintersChanged.emit() - def _addPrinter(self, printer: CloudClusterPrinter) -> None: - model = PrinterOutputModel( - PrinterOutputController(self), len(printer.configuration), firmware_version = printer.firmware_version - ) - self._printers.append(model) - self._updatePrinter(model, printer) - - def _updatePrinter(self, model: PrinterOutputModel, printer: CloudClusterPrinter) -> None: - model.updateKey(printer.uuid) - model.updateName(printer.friendly_name) - model.updateType(printer.machine_variant) - model.updateState(printer.status if printer.enabled else "disabled") - - for index in range(0, len(printer.configuration)): - try: - extruder = model.extruders[index] - extruder_data = printer.configuration[index] - except IndexError: - break - - extruder.updateHotendID(extruder_data.print_core_id) - - if extruder.activeMaterial is None or extruder.activeMaterial.guid != extruder_data.material.guid: - material = self._createMaterialOutputModel(extruder_data.material) - extruder.updateActiveMaterial(material) - - @staticmethod - def _createMaterialOutputModel(material: CloudClusterPrinterConfigurationMaterial) -> MaterialOutputModel: - material_manager = CuraApplication.getInstance().getMaterialManager() - material_group_list = material_manager.getMaterialGroupListByGUID(material.guid) or [] - - # Sort the material groups by "is_read_only = True" first, and then the name alphabetically. - read_only_material_group_list = list(filter(lambda x: x.is_read_only, material_group_list)) - non_read_only_material_group_list = list(filter(lambda x: not x.is_read_only, material_group_list)) - material_group = None - if read_only_material_group_list: - read_only_material_group_list = sorted(read_only_material_group_list, key = lambda x: x.name) - material_group = read_only_material_group_list[0] - elif non_read_only_material_group_list: - non_read_only_material_group_list = sorted(non_read_only_material_group_list, key = lambda x: x.name) - material_group = non_read_only_material_group_list[0] - - if material_group: - container = material_group.root_material_node.getContainer() - color = container.getMetaDataEntry("color_code") - brand = container.getMetaDataEntry("brand") - material_type = container.getMetaDataEntry("material") - name = container.getName() - else: - Logger.log("w", "Unable to find material with guid {guid}. Using data as provided by cluster" - .format(guid = material.guid)) - color = material.color - brand = material.brand - material_type = material.material - name = "Empty" if material.material == "empty" else "Unknown" - - return MaterialOutputModel(guid = material.guid, type = material_type, brand = brand, color = color, - name = name) - def _updatePrintJobs(self, jobs: List[CloudClusterPrintJob]) -> None: remote_jobs = {j.uuid: j for j in jobs} # type: Dict[str, CloudClusterPrintJob] current_jobs = {j.key: j for j in self._print_jobs} # type: Dict[str, UM3PrintJobOutputModel] @@ -264,11 +208,11 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): for removed_job_id in current_job_ids.difference(remote_job_ids): self._print_jobs.remove(current_jobs[removed_job_id]) - for new_job_id in remote_job_ids.difference(current_jobs): + for new_job_id in remote_job_ids.difference(current_job_ids): self._addPrintJob(remote_jobs[new_job_id]) for updated_job_id in current_job_ids.intersection(remote_job_ids): - self._updateUM3PrintJobOutputModel(current_jobs[updated_job_id], remote_jobs[updated_job_id]) + remote_jobs[updated_job_id].updateOutputModel(current_jobs[updated_job_id]) # We only have to update when jobs are added or removed # updated jobs push their changes via their output model @@ -282,16 +226,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): return Logger.log("w", "Missing printer %s for job %s in %s", job.printer_uuid, job.uuid, [p.key for p in self._printers]) - model = UM3PrintJobOutputModel(printer.getController(), job.uuid, job.name) - model.updateAssignedPrinter(printer) - self._print_jobs.append(model) - - @staticmethod - def _updateUM3PrintJobOutputModel(model: UM3PrintJobOutputModel, job: CloudClusterPrintJob) -> None: - model.updateTimeTotal(job.time_total) - model.updateTimeElapsed(job.time_elapsed) - model.updateOwner(job.owner) - model.updateState(job.status) + self._print_jobs.append(job.createOutputModel(printer)) def _onPrintJobCreated(self, mesh: bytes, job_response: CloudJobResponse) -> None: self._api.uploadMesh(job_response, mesh, self._onPrintJobUploaded, self._updateUploadProgress, diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 0fbeeb82b6..51c7f49074 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -8,9 +8,10 @@ from UM import i18nCatalog from UM.Logger import Logger from UM.Message import Message from cura.CuraApplication import CuraApplication -from plugins.UM3NetworkPrinting.src.Cloud.CloudApiClient import CloudApiClient +from .CloudApiClient import CloudApiClient from .CloudOutputDevice import CloudOutputDevice -from .Models import CloudCluster, CloudErrorObject +from .Models.CloudCluster import CloudCluster +from .Models.CloudErrorObject import CloudErrorObject ## The cloud output device manager is responsible for using the Ultimaker Cloud APIs to manage remote clusters. diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models.py b/plugins/UM3NetworkPrinting/src/Cloud/Models.py deleted file mode 100644 index 27ff7df604..0000000000 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models.py +++ /dev/null @@ -1,153 +0,0 @@ -# Copyright (c) 2018 Ultimaker B.V. -# Cura is released under the terms of the LGPLv3 or higher. -from typing import List, Dict - -from ..Models import BaseModel - - -## Class representing errors generated by the cloud servers, according to the json-api standard. -class CloudErrorObject(BaseModel): - def __init__(self, **kwargs): - self.id = None # type: str - self.code = None # type: str - self.http_status = None # type: str - self.title = None # type: str - self.detail = None # type: str - self.meta = None # type: Dict[str, any] - super().__init__(**kwargs) - - -## Class representing a cloud connected cluster. -class CloudCluster(BaseModel): - def __init__(self, **kwargs): - self.cluster_id = None # type: str - self.host_guid = None # type: str - self.host_name = None # type: str - self.host_version = None # type: str - self.status = None # type: str - self.is_online = None # type: bool - super().__init__(**kwargs) - - def validate(self): - if not self.cluster_id: - raise ValueError("cluster_id is required on CloudCluster") - - -## Class representing a cloud cluster printer configuration -class CloudClusterPrinterConfigurationMaterial(BaseModel): - def __init__(self, **kwargs): - self.guid = None # type: str - self.brand = None # type: str - self.color = None # type: str - self.material = None # type: str - super().__init__(**kwargs) - - -## Class representing a cloud cluster printer configuration -class CloudClusterPrinterConfiguration(BaseModel): - def __init__(self, **kwargs): - self.extruder_index = None # type: str - self.material = None # type: CloudClusterPrinterConfigurationMaterial - self.nozzle_diameter = None # type: str - self.print_core_id = None # type: str - super().__init__(**kwargs) - - if isinstance(self.material, dict): - self.material = CloudClusterPrinterConfigurationMaterial(**self.material) - - -## Class representing a cluster printer -class CloudClusterPrinter(BaseModel): - def __init__(self, **kwargs): - self.configuration = [] # type: List[CloudClusterPrinterConfiguration] - self.enabled = None # type: str - self.firmware_version = None # type: str - self.friendly_name = None # type: str - self.ip_address = None # type: str - self.machine_variant = None # type: str - self.status = None # type: str - self.unique_name = None # type: str - self.uuid = None # type: str - super().__init__(**kwargs) - - self.configuration = [CloudClusterPrinterConfiguration(**c) - if isinstance(c, dict) else c for c in self.configuration] - - -## Class representing a cloud cluster print job constraint -class CloudClusterPrintJobConstraint(BaseModel): - def __init__(self, **kwargs): - self.require_printer_name = None # type: str - super().__init__(**kwargs) - - -## Class representing a print job -class CloudClusterPrintJob(BaseModel): - def __init__(self, **kwargs): - self.assigned_to = None # type: str - self.configuration = [] # type: List[CloudClusterPrinterConfiguration] - self.constraints = [] # type: List[CloudClusterPrintJobConstraint] - self.created_at = None # type: str - self.force = None # type: str - self.last_seen = None # type: str - self.machine_variant = None # type: str - self.name = None # type: str - self.network_error_count = None # type: int - self.owner = None # type: str - self.printer_uuid = None # type: str - self.started = None # type: str - self.status = None # type: str - self.time_elapsed = None # type: str - self.time_total = None # type: str - self.uuid = None # type: str - super().__init__(**kwargs) - self.printers = [CloudClusterPrinterConfiguration(**c) if isinstance(c, dict) else c - for c in self.configuration] - self.printers = [CloudClusterPrintJobConstraint(**p) if isinstance(p, dict) else p - for p in self.constraints] - - -# Model that represents the status of the cluster for the cloud -class CloudClusterStatus(BaseModel): - def __init__(self, **kwargs): - # a list of the printers - self.printers = [] # type: List[CloudClusterPrinter] - # a list of the print jobs - self.print_jobs = [] # type: List[CloudClusterPrintJob] - - super().__init__(**kwargs) - - # converting any dictionaries into models - self.printers = [CloudClusterPrinter(**p) if isinstance(p, dict) else p for p in self.printers] - self.print_jobs = [CloudClusterPrintJob(**j) if isinstance(j, dict) else j for j in self.print_jobs] - - -# Model that represents the request to upload a print job to the cloud -class CloudJobUploadRequest(BaseModel): - def __init__(self, **kwargs): - self.file_size = None # type: int - self.job_name = None # type: str - self.content_type = None # type: str - super().__init__(**kwargs) - - -# Model that represents the response received from the cloud after requesting to upload a print job -class CloudJobResponse(BaseModel): - def __init__(self, **kwargs): - self.download_url = None # type: str - self.job_id = None # type: str - self.job_name = None # type: str - self.slicing_details = None # type: str - self.status = None # type: str - self.upload_url = None # type: str - self.content_type = None # type: str - super().__init__(**kwargs) - - -# Model that represents the responses received from the cloud after requesting a job to be printed. -class CloudPrintResponse(BaseModel): - def __init__(self, **kwargs): - self.cluster_job_id = None # type: str - self.job_id = None # type: str - self.status = None # type: str - super().__init__(**kwargs) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudCluster.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudCluster.py new file mode 100644 index 0000000000..dd1e2e85bf --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudCluster.py @@ -0,0 +1,21 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from ...Models import BaseModel + + +## Class representing a cloud connected cluster. +class CloudCluster(BaseModel): + def __init__(self, **kwargs): + self.cluster_id = None # type: str + self.host_guid = None # type: str + self.host_name = None # type: str + self.host_version = None # type: str + self.status = None # type: str + self.is_online = None # type: bool + super().__init__(**kwargs) + + # Validates the model, raising an exception if the model is invalid. + def validate(self) -> None: + super().validate() + if not self.cluster_id: + raise ValueError("cluster_id is required on CloudCluster") diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJob.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJob.py new file mode 100644 index 0000000000..e2e3787435 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJob.py @@ -0,0 +1,52 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from typing import List + +from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel +from .CloudClusterPrinterConfiguration import CloudClusterPrinterConfiguration +from .CloudClusterPrintJobConstraint import CloudClusterPrintJobConstraint +from ...Models import BaseModel + + +## Class representing a print job +from plugins.UM3NetworkPrinting.src.UM3PrintJobOutputModel import UM3PrintJobOutputModel + + +class CloudClusterPrintJob(BaseModel): + def __init__(self, **kwargs) -> None: + self.assigned_to = None # type: str + self.configuration = [] # type: List[CloudClusterPrinterConfiguration] + self.constraints = [] # type: List[CloudClusterPrintJobConstraint] + self.created_at = None # type: str + self.force = None # type: str + self.last_seen = None # type: str + self.machine_variant = None # type: str + self.name = None # type: str + self.network_error_count = None # type: int + self.owner = None # type: str + self.printer_uuid = None # type: str + self.started = None # type: str + self.status = None # type: str + self.time_elapsed = None # type: str + self.time_total = None # type: str + self.uuid = None # type: str + super().__init__(**kwargs) + self.printers = [CloudClusterPrinterConfiguration(**c) if isinstance(c, dict) else c + for c in self.configuration] + self.printers = [CloudClusterPrintJobConstraint(**p) if isinstance(p, dict) else p + for p in self.constraints] + + ## Creates an UM3 print job output model based on this cloud cluster print job. + # \param printer: The output model of the printer + def createOutputModel(self, printer: PrinterOutputModel) -> UM3PrintJobOutputModel: + model = UM3PrintJobOutputModel(printer.getController(), self.uuid, self.name) + model.updateAssignedPrinter(printer) + return model + + ## Updates an UM3 print job output model based on this cloud cluster print job. + # \param model: The model to update. + def updateOutputModel(self, model: UM3PrintJobOutputModel) -> None: + model.updateTimeTotal(self.time_total) + model.updateTimeElapsed(self.time_elapsed) + model.updateOwner(self.owner) + model.updateState(self.status) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobConstraint.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobConstraint.py new file mode 100644 index 0000000000..884ff8f0c2 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobConstraint.py @@ -0,0 +1,10 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from ...Models import BaseModel + + +## Class representing a cloud cluster print job constraint +class CloudClusterPrintJobConstraint(BaseModel): + def __init__(self, **kwargs) -> None: + self.require_printer_name = None # type: str + super().__init__(**kwargs) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinter.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinter.py new file mode 100644 index 0000000000..dd65dffa26 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinter.py @@ -0,0 +1,44 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from typing import List + +from cura.PrinterOutput.PrinterOutputController import PrinterOutputController +from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel +from .CloudClusterPrinterConfiguration import CloudClusterPrinterConfiguration +from ...Models import BaseModel + + +## Class representing a cluster printer +class CloudClusterPrinter(BaseModel): + def __init__(self, **kwargs) -> None: + self.configuration = [] # type: List[CloudClusterPrinterConfiguration] + self.enabled = None # type: str + self.firmware_version = None # type: str + self.friendly_name = None # type: str + self.ip_address = None # type: str + self.machine_variant = None # type: str + self.status = None # type: str + self.unique_name = None # type: str + self.uuid = None # type: str + super().__init__(**kwargs) + + self.configuration = [CloudClusterPrinterConfiguration(**c) + if isinstance(c, dict) else c for c in self.configuration] + + ## Creates a new output model. + # \param controller - The controller of the model. + def createOutputModel(self, controller: PrinterOutputController) -> PrinterOutputModel: + model = PrinterOutputModel(controller, len(self.configuration), firmware_version = self.firmware_version) + self.updateOutputModel(model) + return model + + ## Updates the given output model. + # \param model - The output model to update. + def updateOutputModel(self, model: PrinterOutputModel) -> None: + model.updateKey(self.uuid) + model.updateName(self.friendly_name) + model.updateType(self.machine_variant) + model.updateState(self.status if self.enabled else "disabled") + + for configuration, extruder in zip(self.configuration, model.extruders): + configuration.updateOutputModel(extruder) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterConfiguration.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterConfiguration.py new file mode 100644 index 0000000000..be92549015 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterConfiguration.py @@ -0,0 +1,27 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from cura.PrinterOutput.ExtruderOutputModel import ExtruderOutputModel +from .CloudClusterPrinterConfigurationMaterial import CloudClusterPrinterConfigurationMaterial +from ...Models import BaseModel + + +## Class representing a cloud cluster printer configuration +class CloudClusterPrinterConfiguration(BaseModel): + def __init__(self, **kwargs) -> None: + self.extruder_index = None # type: str + self.material = None # type: CloudClusterPrinterConfigurationMaterial + self.nozzle_diameter = None # type: str + self.print_core_id = None # type: str + super().__init__(**kwargs) + + if isinstance(self.material, dict): + self.material = CloudClusterPrinterConfigurationMaterial(**self.material) + + ## Updates the given output model. + # \param model - The output model to update. + def updateOutputModel(self, model: ExtruderOutputModel) -> None: + model.updateHotendID(self.print_core_id) + + if model.activeMaterial is None or model.activeMaterial.guid != self.material.guid: + material = self.material.createOutputModel() + model.updateActiveMaterial(material) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterConfigurationMaterial.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterConfigurationMaterial.py new file mode 100644 index 0000000000..8023784925 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterConfigurationMaterial.py @@ -0,0 +1,46 @@ +from UM.Logger import Logger +from cura.CuraApplication import CuraApplication +from cura.PrinterOutput.MaterialOutputModel import MaterialOutputModel +from ...Models import BaseModel + + +## Class representing a cloud cluster printer configuration +class CloudClusterPrinterConfigurationMaterial(BaseModel): + def __init__(self, **kwargs) -> None: + self.guid = None # type: str + self.brand = None # type: str + self.color = None # type: str + self.material = None # type: str + super().__init__(**kwargs) + + ## Creates a material output model based on this cloud printer material. + def createOutputModel(self) -> MaterialOutputModel: + material_manager = CuraApplication.getInstance().getMaterialManager() + material_group_list = material_manager.getMaterialGroupListByGUID(self.guid) or [] + + # Sort the material groups by "is_read_only = True" first, and then the name alphabetically. + read_only_material_group_list = list(filter(lambda x: x.is_read_only, material_group_list)) + non_read_only_material_group_list = list(filter(lambda x: not x.is_read_only, material_group_list)) + material_group = None + if read_only_material_group_list: + read_only_material_group_list = sorted(read_only_material_group_list, key = lambda x: x.name) + material_group = read_only_material_group_list[0] + elif non_read_only_material_group_list: + non_read_only_material_group_list = sorted(non_read_only_material_group_list, key = lambda x: x.name) + material_group = non_read_only_material_group_list[0] + + if material_group: + container = material_group.root_material_node.getContainer() + color = container.getMetaDataEntry("color_code") + brand = container.getMetaDataEntry("brand") + material_type = container.getMetaDataEntry("material") + name = container.getName() + else: + Logger.log("w", "Unable to find material with guid {guid}. Using data as provided by cluster" + .format(guid = self.guid)) + color = self.color + brand = self.brand + material_type = self.material + name = "Empty" if self.material == "empty" else "Unknown" + + return MaterialOutputModel(guid = self.guid, type = material_type, brand = brand, color = color, name = name) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterStatus.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterStatus.py new file mode 100644 index 0000000000..a44b665973 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterStatus.py @@ -0,0 +1,22 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from typing import List + +from .CloudClusterPrinter import CloudClusterPrinter +from .CloudClusterPrintJob import CloudClusterPrintJob +from ...Models import BaseModel + + +# Model that represents the status of the cluster for the cloud +class CloudClusterStatus(BaseModel): + def __init__(self, **kwargs) -> None: + # a list of the printers + self.printers = [] # type: List[CloudClusterPrinter] + # a list of the print jobs + self.print_jobs = [] # type: List[CloudClusterPrintJob] + + super().__init__(**kwargs) + + # converting any dictionaries into models + self.printers = [CloudClusterPrinter(**p) if isinstance(p, dict) else p for p in self.printers] + self.print_jobs = [CloudClusterPrintJob(**j) if isinstance(j, dict) else j for j in self.print_jobs] diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudErrorObject.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudErrorObject.py new file mode 100644 index 0000000000..813ef957e4 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudErrorObject.py @@ -0,0 +1,17 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from typing import Dict + +from ...Models import BaseModel + + +## Class representing errors generated by the cloud servers, according to the json-api standard. +class CloudErrorObject(BaseModel): + def __init__(self, **kwargs) -> None: + self.id = None # type: str + self.code = None # type: str + self.http_status = None # type: str + self.title = None # type: str + self.detail = None # type: str + self.meta = None # type: Dict[str, any] + super().__init__(**kwargs) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudJobResponse.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudJobResponse.py new file mode 100644 index 0000000000..0b611dd2d3 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudJobResponse.py @@ -0,0 +1,16 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from ...Models import BaseModel + + +# Model that represents the response received from the cloud after requesting to upload a print job +class CloudJobResponse(BaseModel): + def __init__(self, **kwargs) -> None: + self.download_url = None # type: str + self.job_id = None # type: str + self.job_name = None # type: str + self.slicing_details = None # type: str + self.status = None # type: str + self.upload_url = None # type: str + self.content_type = None # type: str + super().__init__(**kwargs) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudJobUploadRequest.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudJobUploadRequest.py new file mode 100644 index 0000000000..3e038b343e --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudJobUploadRequest.py @@ -0,0 +1,12 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from ...Models import BaseModel + + +# Model that represents the request to upload a print job to the cloud +class CloudJobUploadRequest(BaseModel): + def __init__(self, **kwargs) -> None: + self.file_size = None # type: int + self.job_name = None # type: str + self.content_type = None # type: str + super().__init__(**kwargs) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudPrintResponse.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudPrintResponse.py new file mode 100644 index 0000000000..ff05ad5b19 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudPrintResponse.py @@ -0,0 +1,12 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from ...Models import BaseModel + + +# Model that represents the responses received from the cloud after requesting a job to be printed. +class CloudPrintResponse(BaseModel): + def __init__(self, **kwargs) -> None: + self.cluster_job_id = None # type: str + self.job_id = None # type: str + self.status = None # type: str + super().__init__(**kwargs) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/__init__.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/__init__.py new file mode 100644 index 0000000000..f3f6970c54 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. diff --git a/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py b/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py index 64ac613723..aa8be9ecd9 100644 --- a/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py @@ -25,11 +25,11 @@ from cura.PrinterOutput.ExtruderConfigurationModel import ExtruderConfigurationM from cura.PrinterOutput.NetworkedPrinterOutputDevice import AuthState, NetworkedPrinterOutputDevice from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel from cura.PrinterOutput.MaterialOutputModel import MaterialOutputModel -from plugins.UM3NetworkPrinting.src.MeshFormatHandler import MeshFormatHandler from .ClusterUM3PrinterOutputController import ClusterUM3PrinterOutputController -from .SendMaterialJob import SendMaterialJob from .ConfigurationChangeModel import ConfigurationChangeModel +from .MeshFormatHandler import MeshFormatHandler +from .SendMaterialJob import SendMaterialJob from .UM3PrintJobOutputModel import UM3PrintJobOutputModel from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply diff --git a/plugins/UM3NetworkPrinting/src/Models.py b/plugins/UM3NetworkPrinting/src/Models.py index 2bcac70766..c5b9b16665 100644 --- a/plugins/UM3NetworkPrinting/src/Models.py +++ b/plugins/UM3NetworkPrinting/src/Models.py @@ -8,6 +8,7 @@ class BaseModel: self.__dict__.update(kwargs) self.validate() + # Validates the model, raising an exception if the model is invalid. def validate(self) -> None: pass @@ -34,7 +35,9 @@ class LocalMaterial(BaseModel): self.version = version # type: int super().__init__(**kwargs) + # def validate(self) -> None: + super().validate() if not self.GUID: raise ValueError("guid is required on LocalMaterial") if not self.version: diff --git a/plugins/UM3NetworkPrinting/tests/TestSendMaterialJob.py b/plugins/UM3NetworkPrinting/tests/TestSendMaterialJob.py index b669eb192a..e3ec9faeaf 100644 --- a/plugins/UM3NetworkPrinting/tests/TestSendMaterialJob.py +++ b/plugins/UM3NetworkPrinting/tests/TestSendMaterialJob.py @@ -10,7 +10,7 @@ from PyQt5.QtCore import QByteArray from UM.MimeTypeDatabase import MimeType from UM.Application import Application -from plugins.UM3NetworkPrinting.src.SendMaterialJob import SendMaterialJob +from ..src.SendMaterialJob import SendMaterialJob @patch("builtins.open", lambda _, __: io.StringIO("")) From cd67100097fe092bfc8fc96e4b8941c0f90b3e7d Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Wed, 5 Dec 2018 14:19:57 +0100 Subject: [PATCH 071/178] Comment out some currently broken code --- .../src/Cloud/CloudOutputDeviceManager.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 936ef03ddc..3ddd865c5f 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -79,7 +79,7 @@ class CloudOutputDeviceManager: self._removeCloudOutputDevice(found_clusters[cluster_id]) # TODO: not pass clusters that are not online? - self._connectToActiveMachine(clusters) + self._connectToActiveMachine() ## Adds a CloudOutputDevice for each entry in the remote cluster list from the API. # \param cluster: The cluster that was added. @@ -96,7 +96,7 @@ class CloudOutputDeviceManager: del self._remote_clusters[cluster.cluster_id] ## Callback for when the active machine was changed by the user or a new remote cluster was found. - def _connectToActiveMachine(self, clusters: List[CloudCluster]) -> None: + def _connectToActiveMachine(self) -> None: active_machine = CuraApplication.getInstance().getGlobalContainerStack() if not active_machine: return @@ -115,9 +115,10 @@ class CloudOutputDeviceManager: if not local_network_key: return - cluster_id = next(local_network_key in cluster.host_name for cluster in clusters) - if cluster_id in self._remote_clusters.keys(): - return self._remote_clusters.get(cluster_id).connect() + # TODO: get host_name in the output device so we can iterate here + # cluster_id = next(local_network_key in cluster.host_name for cluster in self._remote_clusters.items()) + # if cluster_id in self._remote_clusters.keys(): + # return self._remote_clusters.get(cluster_id).connect() ## Handles an API error received from the cloud. # \param errors: The errors received From 7e769137368af1b6d19416289040e5b4c953ce4e Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Wed, 5 Dec 2018 16:02:38 +0100 Subject: [PATCH 072/178] STAR-322: Fixing printer matching by network key --- .../src/Cloud/CloudOutputDevice.py | 43 ++++++--- .../src/Cloud/CloudOutputDeviceManager.py | 91 +++++++++---------- .../src/Cloud/Models/CloudCluster.py | 2 +- plugins/UM3NetworkPrinting/src/Cloud/Utils.py | 19 ++++ .../src/ClusterUM3OutputDevice.py | 6 +- 5 files changed, 97 insertions(+), 64 deletions(-) create mode 100644 plugins/UM3NetworkPrinting/src/Cloud/Utils.py diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index c6397fc41f..e8f7687b03 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -18,7 +18,6 @@ from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel from ..MeshFormatHandler import MeshFormatHandler from ..UM3PrintJobOutputModel import UM3PrintJobOutputModel from .CloudApiClient import CloudApiClient -from .Models.CloudErrorObject import CloudErrorObject from .Models.CloudClusterStatus import CloudClusterStatus from .Models.CloudJobUploadRequest import CloudJobUploadRequest from .Models.CloudPrintResponse import CloudPrintResponse @@ -63,19 +62,21 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): # The interval with which the remote clusters are checked CHECK_CLUSTER_INTERVAL = 2.0 # seconds - # Signal triggered when the printers in the remote cluster were changed. - clusterPrintersChanged = pyqtSignal() - # Signal triggered when the print jobs in the queue were changed. printJobsChanged = pyqtSignal() + # Notify can only use signals that are defined by the class that they are in, not inherited ones. + # Therefore we create a private signal used to trigger the printersChanged signal. + _clusterPrintersChanged = pyqtSignal() + ## Creates a new cloud output device # \param api_client: The client that will run the API calls # \param device_id: The ID of the device (i.e. the cluster_id for the cloud API) # \param parent: The optional parent of this output device. - def __init__(self, api_client: CloudApiClient, device_id: str, parent: QObject = None) -> None: + def __init__(self, api_client: CloudApiClient, device_id: str, host_name: str, parent: QObject = None) -> None: super().__init__(device_id = device_id, address = "", properties = {}, parent = parent) self._api = api_client + self._host_name = host_name self._setInterfaceElements() @@ -87,7 +88,10 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): "../../resources/qml/ClusterMonitorItem.qml") self._control_view_qml_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "../../resources/qml/ClusterControlItem.qml") - + + # trigger the printersChanged signal when the private signal is triggered + self.printersChanged.connect(self._clusterPrintersChanged) + # Properties to populate later on with received cloud data. self._print_jobs = [] # type: List[UM3PrintJobOutputModel] self._number_of_extruders = 2 # All networked printers are dual-extrusion Ultimaker machines. @@ -96,6 +100,22 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): self._sending_job = False self._progress_message = None # type: Optional[Message] + ## Gets the host name of this device + @property + def host_name(self) -> str: + return self._host_name + + ## Updates the host name of the output device + @host_name.setter + def host_name(self, value: str) -> None: + self._host_name = value + + ## Checks whether the given network key is found in the cloud's host name + def matchesNetworkKey(self, network_key: str) -> bool: + # A network key looks like "ultimakersystem-aabbccdd0011._ultimaker._tcp.local." + # the host name should then be "ultimakersystem-aabbccdd0011" + return network_key.startswith(self._host_name) + ## Set all the interface elements and texts for this output device. def _setInterfaceElements(self): self.setPriority(2) # make sure we end up below the local networking and above 'save to file' @@ -133,7 +153,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): self._api.requestUpload(request, lambda response: self._onPrintJobCreated(mesh_bytes, response)) ## Get remote printers. - @pyqtProperty("QVariantList", notify = clusterPrintersChanged) + @pyqtProperty("QVariantList", notify = _clusterPrintersChanged) def printers(self): return self._printers @@ -196,7 +216,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): for updated_printer_guid in current_printer_ids.intersection(remote_printer_ids): remote_printers[updated_printer_guid].updateOutputModel(current_printers[updated_printer_guid]) - self.clusterPrintersChanged.emit() + self._clusterPrintersChanged.emit() def _updatePrintJobs(self, jobs: List[CloudClusterPrintJob]) -> None: remote_jobs = {j.uuid: j for j in jobs} # type: Dict[str, CloudClusterPrintJob] @@ -283,7 +303,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): ## TODO: The following methods are required by the monitor page QML, but are not actually available using cloud. # TODO: We fake the methods here to not break the monitor page. - @pyqtProperty(QObject, notify = clusterPrintersChanged) + @pyqtProperty(QObject, notify = _clusterPrintersChanged) def activePrinter(self) -> Optional[PrinterOutputModel]: if not self._printers: return None @@ -293,7 +313,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): def setActivePrinter(self, printer: Optional[PrinterOutputModel]) -> None: pass - @pyqtProperty(QUrl, notify = clusterPrintersChanged) + @pyqtProperty(QUrl, notify = _clusterPrintersChanged) def activeCameraUrl(self) -> "QUrl": return QUrl() @@ -304,6 +324,3 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): @pyqtProperty(bool, notify = printJobsChanged) def receivedPrintJobs(self) -> bool: return True - - def _onApiError(self, errors: List[CloudErrorObject]) -> None: - pass # TODO: Show errors... diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 3ddd865c5f..f11d41a7bd 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -1,6 +1,6 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from typing import Dict, List, Optional +from typing import Dict, List from PyQt5.QtCore import QTimer @@ -8,10 +8,12 @@ from UM import i18nCatalog from UM.Logger import Logger from UM.Message import Message from cura.CuraApplication import CuraApplication +from cura.Settings.GlobalStack import GlobalStack from .CloudApiClient import CloudApiClient from .CloudOutputDevice import CloudOutputDevice from .Models.CloudCluster import CloudCluster from .Models.CloudErrorObject import CloudErrorObject +from .Utils import findChanges ## The cloud output device manager is responsible for using the Ultimaker Cloud APIs to manage remote clusters. @@ -19,7 +21,9 @@ from .Models.CloudErrorObject import CloudErrorObject # # API spec is available on https://api.ultimaker.com/docs/connect/spec/. # + class CloudOutputDeviceManager: + META_CLUSTER_ID = "um_cloud_cluster_id" # The interval with which the remote clusters are checked CHECK_CLUSTER_INTERVAL = 5.0 # seconds @@ -42,59 +46,48 @@ class CloudOutputDeviceManager: # When switching machines we check if we have to activate a remote cluster. application.globalContainerStackChanged.connect(self._connectToActiveMachine) - - self.update_timer = QTimer(CuraApplication.getInstance()) - self.update_timer.setInterval(self.CHECK_CLUSTER_INTERVAL * 1000) - self.update_timer.setSingleShot(False) - self.update_timer.timeout.connect(self._getRemoteClusters) + + # create a timer to update the remote cluster list + self._update_timer = QTimer(application) + self._update_timer.setInterval(self.CHECK_CLUSTER_INTERVAL * 1000) + self._update_timer.setSingleShot(False) + self._update_timer.timeout.connect(self._getRemoteClusters) ## Gets all remote clusters from the API. def _getRemoteClusters(self) -> None: Logger.log("i", "Retrieving remote clusters") if self._account.isLoggedIn: self._api.getClusters(self._onGetRemoteClustersFinished) - - # Only start the polling timer after the user is authenticated - # The first call to _getRemoteClusters comes from self._account.loginStateChanged - if not self.update_timer.isActive(): - self.update_timer.start() + # Only start the polling timer after the user is authenticated + # The first call to _getRemoteClusters comes from self._account.loginStateChanged + if not self._update_timer.isActive(): + self._update_timer.start() ## Callback for when the request for getting the clusters. is finished. def _onGetRemoteClustersFinished(self, clusters: List[CloudCluster]) -> None: - found_clusters = {c.cluster_id: c for c in clusters} + online_clusters = {c.cluster_id: c for c in clusters if c.is_online} # type: Dict[str, CloudCluster] - Logger.log("i", "Parsed remote clusters to %s", found_clusters) + removed_devices, added_clusters, updates = findChanges(self._remote_clusters, online_clusters) - known_cluster_ids = set(self._remote_clusters.keys()) - found_cluster_ids = set(found_clusters.keys()) + Logger.log("i", "Parsed remote clusters to %s", online_clusters) + + # Remove output devices that are gone + for removed_cluster in removed_devices: + self._output_device_manager.removeOutputDevice(removed_cluster.key) + del self._remote_clusters[removed_cluster.key] # Add an output device for each new remote cluster. # We only add when is_online as we don't want the option in the drop down if the cluster is not online. - for cluster_id in found_cluster_ids.difference(known_cluster_ids): - if found_clusters[cluster_id].is_online: - self._addCloudOutputDevice(found_clusters[cluster_id]) + for added_cluster in added_clusters: + device = CloudOutputDevice(self._api, added_cluster.cluster_id, added_cluster.host_name) + self._output_device_manager.addOutputDevice(device) + self._remote_clusters[added_cluster.cluster_id] = device - # Remove output devices that are gone - for cluster_id in known_cluster_ids.difference(found_cluster_ids): - self._removeCloudOutputDevice(found_clusters[cluster_id]) + for device, cluster in updates: + device.host_name = cluster.host_name - # TODO: not pass clusters that are not online? self._connectToActiveMachine() - ## Adds a CloudOutputDevice for each entry in the remote cluster list from the API. - # \param cluster: The cluster that was added. - def _addCloudOutputDevice(self, cluster: CloudCluster): - device = CloudOutputDevice(self._api, cluster.cluster_id) - self._output_device_manager.addOutputDevice(device) - self._remote_clusters[cluster.cluster_id] = device - - ## Remove a CloudOutputDevice - # \param cluster: The cluster that was removed - def _removeCloudOutputDevice(self, cluster: CloudCluster): - self._output_device_manager.removeOutputDevice(cluster.cluster_id) - if cluster.cluster_id in self._remote_clusters: - del self._remote_clusters[cluster.cluster_id] - ## Callback for when the active machine was changed by the user or a new remote cluster was found. def _connectToActiveMachine(self) -> None: active_machine = CuraApplication.getInstance().getGlobalContainerStack() @@ -102,23 +95,27 @@ class CloudOutputDeviceManager: return # Check if the stored cluster_id for the active machine is in our list of remote clusters. - stored_cluster_id = active_machine.getMetaDataEntry("um_cloud_cluster_id") - if stored_cluster_id in self._remote_clusters.keys(): - return self._remote_clusters.get(stored_cluster_id).connect() + stored_cluster_id = active_machine.getMetaDataEntry(self.META_CLUSTER_ID) + if stored_cluster_id in self._remote_clusters: + device = self._remote_clusters[stored_cluster_id] + if not device.isConnected(): + device.connect() + else: + self._connectByNetworkKey(active_machine) + ## Tries to match the + def _connectByNetworkKey(self, active_machine: GlobalStack) -> None: # Check if the active printer has a local network connection and match this key to the remote cluster. - # The local network key is formatted as ultimakersystem-xxxxxxxxxxxx._ultimaker._tcp.local. - # The optional remote host_name is formatted as ultimakersystem-xxxxxxxxxxxx. - # This means we can match the two by checking if the host_name is in the network key string. - local_network_key = active_machine.getMetaDataEntry("um_network_key") if not local_network_key: return - # TODO: get host_name in the output device so we can iterate here - # cluster_id = next(local_network_key in cluster.host_name for cluster in self._remote_clusters.items()) - # if cluster_id in self._remote_clusters.keys(): - # return self._remote_clusters.get(cluster_id).connect() + device = next((c for c in self._remote_clusters.values() if c.matchesNetworkKey(local_network_key)), None) + if not device: + return + + active_machine.setMetaDataEntry(self.META_CLUSTER_ID, device.key) + return device.connect() ## Handles an API error received from the cloud. # \param errors: The errors received diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudCluster.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudCluster.py index dd1e2e85bf..28e95a097a 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudCluster.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudCluster.py @@ -11,7 +11,7 @@ class CloudCluster(BaseModel): self.host_name = None # type: str self.host_version = None # type: str self.status = None # type: str - self.is_online = None # type: bool + self.is_online = False # type: bool super().__init__(**kwargs) # Validates the model, raising an exception if the model is invalid. diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Utils.py b/plugins/UM3NetworkPrinting/src/Cloud/Utils.py new file mode 100644 index 0000000000..9a2a160492 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/Utils.py @@ -0,0 +1,19 @@ +from typing import TypeVar, Dict, Tuple, List + +T = TypeVar("T") +U = TypeVar("U") + + +def findChanges(previous: Dict[str, T], received: Dict[str, U]) -> Tuple[List[T], List[U], List[Tuple[T, U]]]: + previous_ids = set(previous) + received_ids = set(received) + + removed_ids = previous_ids.difference(received_ids) + new_ids = received_ids.difference(previous_ids) + updated_ids = received_ids.intersection(previous_ids) + + removed = [previous[removed_id] for removed_id in removed_ids] + added = [received[new_id] for new_id in new_ids] + updated = [(previous[updated_id], received[updated_id]) for updated_id in updated_ids] + + return removed, added, updated diff --git a/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py b/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py index aa8be9ecd9..70f4d2d0ee 100644 --- a/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py @@ -45,8 +45,8 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): activeCameraUrlChanged = pyqtSignal() receivedPrintJobsChanged = pyqtSignal() - # This is a bit of a hack, as the notify can only use signals that are defined by the class that they are in. - # Inheritance doesn't seem to work. Tying them together does work, but i'm open for better suggestions. + # Notify can only use signals that are defined by the class that they are in, not inherited ones. + # Therefore we create a private signal used to trigger the printersChanged signal. _clusterPrintersChanged = pyqtSignal() def __init__(self, device_id, address, properties, parent = None) -> None: @@ -62,7 +62,7 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): self._monitor_view_qml_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "../resources/qml/ClusterMonitorItem.qml") - # See comments about this hack with the clusterPrintersChanged signal + # trigger the printersChanged signal when the private signal is triggered self.printersChanged.connect(self._clusterPrintersChanged) self._accepts_commands = True # type: bool From 657e76331870f502adc6ce4424b6de20c67127ec Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Wed, 5 Dec 2018 16:15:51 +0100 Subject: [PATCH 073/178] STAR-322: Using findChanges method to simplify code --- .../src/Cloud/CloudOutputDevice.py | 41 +++++++++---------- plugins/UM3NetworkPrinting/src/Cloud/Utils.py | 7 ++++ .../src/MeshFormatHandler.py | 3 +- 3 files changed, 27 insertions(+), 24 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index e8f7687b03..980b9efa9e 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -24,6 +24,7 @@ from .Models.CloudPrintResponse import CloudPrintResponse from .Models.CloudJobResponse import CloudJobResponse from .Models.CloudClusterPrinter import CloudClusterPrinter from .Models.CloudClusterPrintJob import CloudClusterPrintJob +from .Utils import findChanges ## Class that contains all the translations for this module. @@ -198,45 +199,41 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): self._updatePrintJobs(status.print_jobs) def _updatePrinters(self, printers: List[CloudClusterPrinter]) -> None: - remote_printers = {p.uuid: p for p in printers} # type: Dict[str, CloudClusterPrinter] - current_printers = {p.key: p for p in self._printers} # type: Dict[str, PrinterOutputModel] + previous = {p.key: p for p in self._printers} # type: Dict[str, PrinterOutputModel] + received = {p.uuid: p for p in printers} # type: Dict[str, CloudClusterPrinter] - remote_printer_ids = set(remote_printers) # type: Set[str] - current_printer_ids = set(current_printers) # type: Set[str] + removed_printers, added_printers, updated_printers = findChanges(previous, received) - for removed_printer_id in current_printer_ids.difference(remote_printer_ids): - removed_printer = current_printers[removed_printer_id] + for removed_printer in removed_printers: self._printers.remove(removed_printer) - for new_printer_id in remote_printer_ids.difference(current_printer_ids): - new_printer = remote_printers[new_printer_id] + for added_printer in added_printers: controller = PrinterOutputController(self) - self._printers.append(new_printer.createOutputModel(controller)) + self._printers.append(added_printer.createOutputModel(controller)) - for updated_printer_guid in current_printer_ids.intersection(remote_printer_ids): - remote_printers[updated_printer_guid].updateOutputModel(current_printers[updated_printer_guid]) + for model, printer in updated_printers: + printer.updateOutputModel(model) self._clusterPrintersChanged.emit() def _updatePrintJobs(self, jobs: List[CloudClusterPrintJob]) -> None: - remote_jobs = {j.uuid: j for j in jobs} # type: Dict[str, CloudClusterPrintJob] - current_jobs = {j.key: j for j in self._print_jobs} # type: Dict[str, UM3PrintJobOutputModel] + received = {j.uuid: j for j in jobs} # type: Dict[str, CloudClusterPrintJob] + previous = {j.key: j for j in self._print_jobs} # type: Dict[str, UM3PrintJobOutputModel] - remote_job_ids = set(remote_jobs) # type: Set[str] - current_job_ids = set(current_jobs) # type: Set[str] + removed_jobs, added_jobs, updated_jobs = findChanges(previous, received) - for removed_job_id in current_job_ids.difference(remote_job_ids): - self._print_jobs.remove(current_jobs[removed_job_id]) + for removed_job in removed_jobs: + self._print_jobs.remove(removed_job) - for new_job_id in remote_job_ids.difference(current_job_ids): - self._addPrintJob(remote_jobs[new_job_id]) + for added_job in added_jobs: + self._addPrintJob(added_job) - for updated_job_id in current_job_ids.intersection(remote_job_ids): - remote_jobs[updated_job_id].updateOutputModel(current_jobs[updated_job_id]) + for model, job in updated_jobs: + job.updateOutputModel(model) # We only have to update when jobs are added or removed # updated jobs push their changes via their output model - if remote_job_ids != current_job_ids: + if added_jobs or removed_jobs: self.printJobsChanged.emit() def _addPrintJob(self, job: CloudClusterPrintJob) -> None: diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Utils.py b/plugins/UM3NetworkPrinting/src/Cloud/Utils.py index 9a2a160492..58eaf5edb9 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Utils.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Utils.py @@ -4,6 +4,13 @@ T = TypeVar("T") U = TypeVar("U") +## Splits the given dictionaries into three lists (in a tuple): +# - `removed`: Items that were in the first argument but removed in the second one. +# - `added`: Items that were not in the first argument but were included in the second one. +# - `updated`: Items that were in both dictionaries. Both values are given in a tuple. +# \param previous: The previous items +# \param received: The received items +# \return: The tuple (removed, added, updated) as explained above. def findChanges(previous: Dict[str, T], received: Dict[str, U]) -> Tuple[List[T], List[U], List[Tuple[T, U]]]: previous_ids = set(previous) received_ids = set(received) diff --git a/plugins/UM3NetworkPrinting/src/MeshFormatHandler.py b/plugins/UM3NetworkPrinting/src/MeshFormatHandler.py index c2bd997fbb..d64861ea91 100644 --- a/plugins/UM3NetworkPrinting/src/MeshFormatHandler.py +++ b/plugins/UM3NetworkPrinting/src/MeshFormatHandler.py @@ -15,8 +15,7 @@ from cura.CuraApplication import CuraApplication ## Class that contains all the translations for this module. class T: - # The translation catalog for this device. - + # The translation catalog for this module. _I18N_CATALOG = i18nCatalog("cura") NO_FORMATS_AVAILABLE = _I18N_CATALOG.i18nc("@info:status", "There are no file formats available to write with!") From 117cf10a2c60fa4687044ef473303b184634660d Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Wed, 5 Dec 2018 16:26:20 +0100 Subject: [PATCH 074/178] STAR-322: Removing devices when logging off --- plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py | 2 +- .../src/Cloud/CloudOutputDeviceManager.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py index 3ede206d45..b4c8774140 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py @@ -129,7 +129,7 @@ class CloudApiClient(NetworkClient): data = response["data"] result = [model(**c) for c in data] if isinstance(data, list) else model(**data) on_finished(result) - elif "error" in response: + elif "errors" in response: self._on_error([CloudErrorObject(**error) for error in response["errors"]]) else: Logger.log("e", "Cannot find data or errors in the cloud response: %s", response) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index f11d41a7bd..29514870ac 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -62,6 +62,10 @@ class CloudOutputDeviceManager: # The first call to _getRemoteClusters comes from self._account.loginStateChanged if not self._update_timer.isActive(): self._update_timer.start() + else: + self._onGetRemoteClustersFinished([]) + if self._update_timer.isActive(): + self._update_timer.stop() ## Callback for when the request for getting the clusters. is finished. def _onGetRemoteClustersFinished(self, clusters: List[CloudCluster]) -> None: @@ -73,6 +77,8 @@ class CloudOutputDeviceManager: # Remove output devices that are gone for removed_cluster in removed_devices: + if removed_cluster.isConnected(): + removed_cluster.disconnect() self._output_device_manager.removeOutputDevice(removed_cluster.key) del self._remote_clusters[removed_cluster.key] From 5e15858cae6f36596b806841e94390c2b1fd13fb Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Wed, 5 Dec 2018 17:05:21 +0100 Subject: [PATCH 075/178] more mocks for monitor page, fix showing current print job in monitor page, add todo --- .../src/Cloud/CloudOutputDevice.py | 20 ++++++++++++++++--- .../src/Cloud/Models/CloudClusterPrintJob.py | 7 ++++--- .../src/ClusterUM3OutputDevice.py | 5 +++-- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index 980b9efa9e..ec7e7f4acf 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -155,9 +155,13 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): ## Get remote printers. @pyqtProperty("QVariantList", notify = _clusterPrintersChanged) - def printers(self): + def printers(self) -> List[PrinterOutputModel]: return self._printers + @pyqtProperty(int, notify = _clusterPrintersChanged) + def clusterSize(self) -> int: + return len(self._printers) + ## Get remote print jobs. @pyqtProperty("QVariantList", notify = printJobsChanged) def printJobs(self)-> List[UM3PrintJobOutputModel]: @@ -237,13 +241,15 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): self.printJobsChanged.emit() def _addPrintJob(self, job: CloudClusterPrintJob) -> None: + # TODO: somehow we don't see the queued print jobs on the monitor page yet, we have to figure out why. try: - printer = next(p for p in self._printers if job.printer_uuid == p.key) + printer = next(p for p in self._printers if job.printer_uuid == p.key or job.assigned_to == p.key) except StopIteration: return Logger.log("w", "Missing printer %s for job %s in %s", job.printer_uuid, job.uuid, [p.key for p in self._printers]) - self._print_jobs.append(job.createOutputModel(printer)) + print_job = job.createOutputModel(printer) + self._print_jobs.append(print_job) def _onPrintJobCreated(self, mesh: bytes, job_response: CloudJobResponse) -> None: self._api.uploadMesh(job_response, mesh, self._onPrintJobUploaded, self._updateUploadProgress, @@ -321,3 +327,11 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): @pyqtProperty(bool, notify = printJobsChanged) def receivedPrintJobs(self) -> bool: return True + + @pyqtSlot() + def openPrintJobControlPanel(self) -> None: + pass + + @pyqtSlot() + def openPrinterControlPanel(self) -> None: + pass diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJob.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJob.py index e2e3787435..36d878d46f 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJob.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJob.py @@ -1,6 +1,6 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from typing import List +from typing import List, Optional from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel from .CloudClusterPrinterConfiguration import CloudClusterPrinterConfiguration @@ -33,14 +33,15 @@ class CloudClusterPrintJob(BaseModel): super().__init__(**kwargs) self.printers = [CloudClusterPrinterConfiguration(**c) if isinstance(c, dict) else c for c in self.configuration] - self.printers = [CloudClusterPrintJobConstraint(**p) if isinstance(p, dict) else p - for p in self.constraints] + self.print_jobs = [CloudClusterPrintJobConstraint(**p) if isinstance(p, dict) else p + for p in self.constraints] ## Creates an UM3 print job output model based on this cloud cluster print job. # \param printer: The output model of the printer def createOutputModel(self, printer: PrinterOutputModel) -> UM3PrintJobOutputModel: model = UM3PrintJobOutputModel(printer.getController(), self.uuid, self.name) model.updateAssignedPrinter(printer) + printer.updateActivePrintJob(model) return model ## Updates an UM3 print job output model based on this cloud cluster print job. diff --git a/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py b/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py index 70f4d2d0ee..965a698029 100644 --- a/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py @@ -411,8 +411,9 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): ## Called when the connection to the cluster changes. def connect(self) -> None: - super().connect() - self.sendMaterialProfiles() + pass + # super().connect() + # self.sendMaterialProfiles() def _onGetPreviewImageFinished(self, reply: QNetworkReply) -> None: reply_url = reply.url().toString() From 66690dfef72e0e8466927d0d71d7fa84c86ff18e Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Wed, 5 Dec 2018 17:06:22 +0100 Subject: [PATCH 076/178] Add testing todo --- plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py b/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py index 965a698029..cc5b128479 100644 --- a/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py @@ -412,6 +412,7 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): ## Called when the connection to the cluster changes. def connect(self) -> None: pass + # TODO: uncomment this once cloud implementation works for testing # super().connect() # self.sendMaterialProfiles() From ce07e31bbf299f4ec86f24fd7acd66795ec14da3 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Wed, 5 Dec 2018 17:21:46 +0100 Subject: [PATCH 077/178] Implement active printer for cloud device to get monitor page functionality working --- .../src/Cloud/CloudOutputDevice.py | 86 ++++++++++++++++--- 1 file changed, 75 insertions(+), 11 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index ec7e7f4acf..9dab829825 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -1,6 +1,8 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. import os +from datetime import datetime + from time import time from typing import Dict, List, Optional, Set @@ -10,6 +12,7 @@ from UM import i18nCatalog from UM.FileHandler.FileHandler import FileHandler from UM.Logger import Logger from UM.Message import Message +from UM.Qt.Duration import Duration, DurationFormat from UM.Scene.SceneNode import SceneNode from cura.CuraApplication import CuraApplication from cura.PrinterOutput.NetworkedPrinterOutputDevice import AuthState, NetworkedPrinterOutputDevice @@ -66,6 +69,9 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): # Signal triggered when the print jobs in the queue were changed. printJobsChanged = pyqtSignal() + # Signal triggered when the selected printer in the UI should be changed. + activePrinterChanged = pyqtSignal() + # Notify can only use signals that are defined by the class that they are in, not inherited ones. # Therefore we create a private signal used to trigger the printersChanged signal. _clusterPrintersChanged = pyqtSignal() @@ -90,9 +96,12 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): self._control_view_qml_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "../../resources/qml/ClusterControlItem.qml") - # trigger the printersChanged signal when the private signal is triggered + # Trigger the printersChanged signal when the private signal is triggered. self.printersChanged.connect(self._clusterPrintersChanged) + # We keep track of which printer is visible in the monitor page. + self._active_printer = None # type: Optional[PrinterOutputModel] + # Properties to populate later on with received cloud data. self._print_jobs = [] # type: List[UM3PrintJobOutputModel] self._number_of_extruders = 2 # All networked printers are dual-extrusion Ultimaker machines. @@ -158,6 +167,18 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): def printers(self) -> List[PrinterOutputModel]: return self._printers + ## Get the active printer in the UI (monitor page). + @pyqtProperty(QObject, notify = activePrinterChanged) + def activePrinter(self) -> Optional[PrinterOutputModel]: + return self._active_printer + + ## Set the active printer in the UI (monitor page). + @pyqtSlot(QObject) + def setActivePrinter(self, printer: Optional[PrinterOutputModel] = None) -> None: + if printer != self._active_printer: + self._active_printer = printer + self.activePrinterChanged.emit() + @pyqtProperty(int, notify = _clusterPrintersChanged) def clusterSize(self) -> int: return len(self._printers) @@ -179,6 +200,37 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): return [print_job for print_job in self._print_jobs if print_job.assignedPrinter is not None and print_job.state != "queued"] + @pyqtSlot(int, result = str) + def formatDuration(self, seconds: int) -> str: + # TODO: this really shouldn't be in this class + return Duration(seconds).getDisplayString(DurationFormat.Format.Short) + + @pyqtSlot(int, result = str) + def getTimeCompleted(self, time_remaining: int) -> str: + # TODO: this really shouldn't be in this class + current_time = time() + datetime_completed = datetime.fromtimestamp(current_time + time_remaining) + return "{hour:02d}:{minute:02d}".format(hour = datetime_completed.hour, minute = datetime_completed.minute) + + @pyqtSlot(int, result = str) + def getDateCompleted(self, time_remaining: int) -> str: + # TODO: this really shouldn't be in this class + current_time = time() + completed = datetime.fromtimestamp(current_time + time_remaining) + today = datetime.fromtimestamp(current_time) + # If finishing date is more than 7 days out, using "Mon Dec 3 at HH:MM" format + if completed.toordinal() > today.toordinal() + 7: + return completed.strftime("%a %b ") + "{day}".format(day = completed.day) + # If finishing date is within the next week, use "Monday at HH:MM" format + elif completed.toordinal() > today.toordinal() + 1: + return completed.strftime("%a") + # If finishing tomorrow, use "tomorrow at HH:MM" format + elif completed.toordinal() > today.toordinal(): + return "tomorrow" + # If finishing today, use "today at HH:MM" format + else: + return "today" + ## Called when the connection to the cluster changes. def connect(self) -> None: super().connect() @@ -209,6 +261,8 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): removed_printers, added_printers, updated_printers = findChanges(previous, received) for removed_printer in removed_printers: + if self._active_printer == removed_printer: + self.setActivePrinter(None) self._printers.remove(removed_printer) for added_printer in added_printers: @@ -218,6 +272,10 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): for model, printer in updated_printers: printer.updateOutputModel(model) + # Always have an active printer + if not self._active_printer: + self.setActivePrinter(self._printers[0]) + self._clusterPrintersChanged.emit() def _updatePrintJobs(self, jobs: List[CloudClusterPrintJob]) -> None: @@ -306,16 +364,6 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): ## TODO: The following methods are required by the monitor page QML, but are not actually available using cloud. # TODO: We fake the methods here to not break the monitor page. - @pyqtProperty(QObject, notify = _clusterPrintersChanged) - def activePrinter(self) -> Optional[PrinterOutputModel]: - if not self._printers: - return None - return self._printers[0] - - @pyqtSlot(QObject) - def setActivePrinter(self, printer: Optional[PrinterOutputModel]) -> None: - pass - @pyqtProperty(QUrl, notify = _clusterPrintersChanged) def activeCameraUrl(self) -> "QUrl": return QUrl() @@ -335,3 +383,19 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): @pyqtSlot() def openPrinterControlPanel(self) -> None: pass + + @pyqtSlot(str) + def sendJobToTop(self, print_job_uuid: str) -> None: + pass + + @pyqtSlot(str) + def deleteJobFromQueue(self, print_job_uuid: str) -> None: + pass + + @pyqtSlot(str) + def forceSendJob(self, print_job_uuid: str) -> None: + pass + + @pyqtProperty("QVariantList", notify = _clusterPrintersChanged) + def connectedPrintersTypeCount(self) -> List[Dict[str, str]]: + return [] From 94f31378a68b8daa05cc3b7e3434107171fd2540 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Wed, 5 Dec 2018 17:24:47 +0100 Subject: [PATCH 078/178] Add a TODO --- .../UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJob.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJob.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJob.py index 36d878d46f..f4d211f8f4 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJob.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJob.py @@ -40,6 +40,7 @@ class CloudClusterPrintJob(BaseModel): # \param printer: The output model of the printer def createOutputModel(self, printer: PrinterOutputModel) -> UM3PrintJobOutputModel: model = UM3PrintJobOutputModel(printer.getController(), self.uuid, self.name) + # TODO: implement more data as shown in ClusterUM3OutputDevice._createPrintJobModel model.updateAssignedPrinter(printer) printer.updateActivePrintJob(model) return model From 57efca13769a9328c5e2ac31efc66201ef89c540 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Wed, 5 Dec 2018 19:31:58 +0100 Subject: [PATCH 079/178] Fix a possible division by zero error --- cura/PrinterOutput/PrintJobOutputModel.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cura/PrinterOutput/PrintJobOutputModel.py b/cura/PrinterOutput/PrintJobOutputModel.py index 256c9dffe9..a77ac81909 100644 --- a/cura/PrinterOutput/PrintJobOutputModel.py +++ b/cura/PrinterOutput/PrintJobOutputModel.py @@ -132,9 +132,9 @@ class PrintJobOutputModel(QObject): @pyqtProperty(float, notify = timeElapsedChanged) def progress(self) -> float: - result = self.timeElapsed / self.timeTotal - # Never get a progress past 1.0 - return min(result, 1.0) + time_elapsed = max(float(self.timeElapsed), 1.0) # Prevent a division by zero exception + result = time_elapsed / self.timeTotal + return min(result, 1.0) # Never get a progress past 1.0 @pyqtProperty(str, notify=stateChanged) def state(self) -> str: From eb3777ed9fb48ed6f0385c230d9f12fc5aa338f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marijn=20De=C3=A9?= Date: Thu, 6 Dec 2018 10:40:06 +0100 Subject: [PATCH 080/178] Cleaner login and update cluster flow, start update cluster timer at startup when the user is already logged in --- .../src/Cloud/CloudOutputDeviceManager.py | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 29514870ac..5d55d30548 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -41,7 +41,7 @@ class CloudOutputDeviceManager: self._output_device_manager = application.getOutputDeviceManager() self._account = application.getCuraAPI().account - self._account.loginStateChanged.connect(self._getRemoteClusters) + self._account.loginStateChanged.connect(self._onLoginStateChanged) self._api = CloudApiClient(self._account, self._onApiError) # When switching machines we check if we have to activate a remote cluster. @@ -49,23 +49,26 @@ class CloudOutputDeviceManager: # create a timer to update the remote cluster list self._update_timer = QTimer(application) - self._update_timer.setInterval(self.CHECK_CLUSTER_INTERVAL * 1000) + self._update_timer.setInterval(int(self.CHECK_CLUSTER_INTERVAL * 1000)) self._update_timer.setSingleShot(False) self._update_timer.timeout.connect(self._getRemoteClusters) + # Make sure the timer is started in case we missed the loginChanged signal + self._onLoginStateChanged() + + # Called when the uses logs in or out + def _onLoginStateChanged(self) -> None: + if self._account.isLoggedIn and not self._update_timer.isActive(): + self._update_timer.start() + else: + self._update_timer.stop() + # Notify that all clusters have disappeared + self._onGetRemoteClustersFinished([]) + ## Gets all remote clusters from the API. def _getRemoteClusters(self) -> None: Logger.log("i", "Retrieving remote clusters") - if self._account.isLoggedIn: - self._api.getClusters(self._onGetRemoteClustersFinished) - # Only start the polling timer after the user is authenticated - # The first call to _getRemoteClusters comes from self._account.loginStateChanged - if not self._update_timer.isActive(): - self._update_timer.start() - else: - self._onGetRemoteClustersFinished([]) - if self._update_timer.isActive(): - self._update_timer.stop() + self._api.getClusters(self._onGetRemoteClustersFinished) ## Callback for when the request for getting the clusters. is finished. def _onGetRemoteClustersFinished(self, clusters: List[CloudCluster]) -> None: From 05ca0b372af64bdc198ed5cf3c64d605c7981f71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marijn=20De=C3=A9?= Date: Thu, 6 Dec 2018 14:04:12 +0100 Subject: [PATCH 081/178] Updated TODO, printjobs are not displayed in the monitor page because data returned from cura connect api contains None's instead of printer uuid's --- plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index 9dab829825..eb6fb3a789 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -299,7 +299,8 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): self.printJobsChanged.emit() def _addPrintJob(self, job: CloudClusterPrintJob) -> None: - # TODO: somehow we don't see the queued print jobs on the monitor page yet, we have to figure out why. + # TODO: we don't see the queued print jobs on the monitor page yet because job.printer_uuid and job.assigned_to + # are always None try: printer = next(p for p in self._printers if job.printer_uuid == p.key or job.assigned_to == p.key) except StopIteration: From 373c953dbf565abcda33919f226af6e101e7ca19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marijn=20De=C3=A9?= Date: Thu, 6 Dec 2018 16:25:04 +0100 Subject: [PATCH 082/178] Showing print queue, works with multiple printers in cluster, Add TODO --- .../src/Cloud/CloudOutputDevice.py | 36 ++++++++++++------- .../src/Cloud/Models/CloudClusterPrintJob.py | 8 ++--- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index eb6fb3a789..fd21d32256 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -18,6 +18,7 @@ from cura.CuraApplication import CuraApplication from cura.PrinterOutput.NetworkedPrinterOutputDevice import AuthState, NetworkedPrinterOutputDevice from cura.PrinterOutput.PrinterOutputController import PrinterOutputController from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel +from plugins.UM3NetworkPrinting.src.Cloud.CloudOutputController import CloudOutputController from ..MeshFormatHandler import MeshFormatHandler from ..UM3PrintJobOutputModel import UM3PrintJobOutputModel from .CloudApiClient import CloudApiClient @@ -266,8 +267,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): self._printers.remove(removed_printer) for added_printer in added_printers: - controller = PrinterOutputController(self) - self._printers.append(added_printer.createOutputModel(controller)) + self._printers.append(added_printer.createOutputModel(CloudOutputController(self))) for model, printer in updated_printers: printer.updateOutputModel(model) @@ -276,7 +276,8 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): if not self._active_printer: self.setActivePrinter(self._printers[0]) - self._clusterPrintersChanged.emit() + if removed_printers or added_printers or updated_printers: + self._clusterPrintersChanged.emit() def _updatePrintJobs(self, jobs: List[CloudClusterPrintJob]) -> None: received = {j.uuid: j for j in jobs} # type: Dict[str, CloudClusterPrintJob] @@ -284,6 +285,9 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): removed_jobs, added_jobs, updated_jobs = findChanges(previous, received) + # TODO: we see that not all data in the UI is correctly updated when the queue and active jobs change. + # TODO: we need to fix this here somehow by updating the correct output models. + for removed_job in removed_jobs: self._print_jobs.remove(removed_job) @@ -292,24 +296,30 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): for model, job in updated_jobs: job.updateOutputModel(model) + self._updatePrintJobDetails(model) # We only have to update when jobs are added or removed # updated jobs push their changes via their output model - if added_jobs or removed_jobs: + if added_jobs or removed_jobs or updated_jobs: self.printJobsChanged.emit() def _addPrintJob(self, job: CloudClusterPrintJob) -> None: - # TODO: we don't see the queued print jobs on the monitor page yet because job.printer_uuid and job.assigned_to - # are always None - try: - printer = next(p for p in self._printers if job.printer_uuid == p.key or job.assigned_to == p.key) - except StopIteration: - return Logger.log("w", "Missing printer %s for job %s in %s", job.printer_uuid, job.uuid, - [p.key for p in self._printers]) - - print_job = job.createOutputModel(printer) + print_job = job.createOutputModel(CloudOutputController(self)) + self._updatePrintJobDetails(print_job) self._print_jobs.append(print_job) + def _updatePrintJobDetails(self, print_job: UM3PrintJobOutputModel): + printer = None + try: + printer = next(p for p in self._printers if print_job.assignedPrinter == p.key) + except StopIteration: + Logger.log("w", "Missing printer %s for job %s in %s", print_job.assignedPrinter, print_job.key, + [p.key for p in self._printers]) + + if printer: + printer.updateActivePrintJob(print_job) + print_job.updateAssignedPrinter(printer) + def _onPrintJobCreated(self, mesh: bytes, job_response: CloudJobResponse) -> None: self._api.uploadMesh(job_response, mesh, self._onPrintJobUploaded, self._updateUploadProgress, lambda _: self._onUploadError(T.UPLOAD_ERROR)) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJob.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJob.py index f4d211f8f4..cf3e28aef7 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJob.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJob.py @@ -2,7 +2,7 @@ # Cura is released under the terms of the LGPLv3 or higher. from typing import List, Optional -from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel +from plugins.UM3NetworkPrinting.src.Cloud.CloudOutputController import CloudOutputController from .CloudClusterPrinterConfiguration import CloudClusterPrinterConfiguration from .CloudClusterPrintJobConstraint import CloudClusterPrintJobConstraint from ...Models import BaseModel @@ -38,11 +38,9 @@ class CloudClusterPrintJob(BaseModel): ## Creates an UM3 print job output model based on this cloud cluster print job. # \param printer: The output model of the printer - def createOutputModel(self, printer: PrinterOutputModel) -> UM3PrintJobOutputModel: - model = UM3PrintJobOutputModel(printer.getController(), self.uuid, self.name) + def createOutputModel(self, controller: CloudOutputController) -> UM3PrintJobOutputModel: + model = UM3PrintJobOutputModel(controller, self.uuid, self.name) # TODO: implement more data as shown in ClusterUM3OutputDevice._createPrintJobModel - model.updateAssignedPrinter(printer) - printer.updateActivePrintJob(model) return model ## Updates an UM3 print job output model based on this cloud cluster print job. From 2af33738d17f95c87dcb8c826b0940576e4359c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marijn=20De=C3=A9?= Date: Thu, 6 Dec 2018 16:32:00 +0100 Subject: [PATCH 083/178] Don't look at the timer --- .../src/Cloud/CloudOutputDeviceManager.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 5d55d30548..bc410a4a1d 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -58,10 +58,13 @@ class CloudOutputDeviceManager: # Called when the uses logs in or out def _onLoginStateChanged(self) -> None: - if self._account.isLoggedIn and not self._update_timer.isActive(): - self._update_timer.start() + if self._account.isLoggedIn: + if not self._update_timer.isActive(): + self._update_timer.start() else: - self._update_timer.stop() + if self._update_timer.isActive(): + self._update_timer.stop() + # Notify that all clusters have disappeared self._onGetRemoteClustersFinished([]) From 3ebefa4f8a232b87a5885937aeaf5818f0573271 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Thu, 6 Dec 2018 16:59:09 +0100 Subject: [PATCH 084/178] Add more TODO comments to clarify --- plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py | 2 ++ .../UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJob.py | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index fd21d32256..9f5857dff6 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -287,6 +287,8 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): # TODO: we see that not all data in the UI is correctly updated when the queue and active jobs change. # TODO: we need to fix this here somehow by updating the correct output models. + # TODO: also the configuration drop down in the slice window is not populated because we are missing some data. + # TODO: to fix this we need to implement more data as shown in ClusterUM3OutputDevice._createPrintJobModel for removed_job in removed_jobs: self._print_jobs.remove(removed_job) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJob.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJob.py index cf3e28aef7..c9255b8da8 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJob.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJob.py @@ -40,7 +40,6 @@ class CloudClusterPrintJob(BaseModel): # \param printer: The output model of the printer def createOutputModel(self, controller: CloudOutputController) -> UM3PrintJobOutputModel: model = UM3PrintJobOutputModel(controller, self.uuid, self.name) - # TODO: implement more data as shown in ClusterUM3OutputDevice._createPrintJobModel return model ## Updates an UM3 print job output model based on this cloud cluster print job. From 4bffa6d90f2e6e9784c7e5508b61e00a92ecb74f Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Fri, 7 Dec 2018 10:22:32 +0100 Subject: [PATCH 085/178] Revert "fix merge conflict" This reverts commit ec03b012a77a766e45a43a224bbb76cd32d0915a, reversing changes made to 2af33738d17f95c87dcb8c826b0940576e4359c3. --- cura/Settings/ExtruderManager.py | 4 +- cura/Settings/ExtruderStack.py | 4 +- cura/Settings/ExtrudersModel.py | 6 +- cura/Settings/MachineManager.py | 4 +- .../ProcessSlicedLayersJob.py | 2 +- .../MachineSettingsAction.qml | 4 +- plugins/ModelChecker/ModelChecker.qml | 1 + .../PerObjectSettingsPanel.qml | 1 + .../PostProcessingPlugin.qml | 4 + plugins/PrepareStage/PrepareMenu.qml | 3 +- .../SimulationViewMainComponent.qml | 7 +- .../resources/qml/ToolboxAuthorPage.qml | 8 +- .../qml/ToolboxCompatibilityChart.qml | 2 +- .../resources/qml/ToolboxDetailPage.qml | 16 +- .../qml/ToolboxDownloadsGridTile.qml | 3 +- .../qml/ToolboxDownloadsShowcaseTile.qml | 2 + .../resources/qml/DiscoverUM3Action.qml | 16 +- .../qml/MonitorBuildplateConfiguration.qml | 2 +- .../qml/MonitorExtruderConfiguration.qml | 4 +- .../resources/qml/MonitorPrinterCard.qml | 10 +- .../src/ClusterUM3OutputDevice.py | 12 +- resources/qml/ActionButton.qml | 35 +- .../ActionPanel/OutputDevicesActionButton.qml | 4 - .../qml/ActionPanel/OutputProcessWidget.qml | 27 +- .../ActionPanel/PrintInformationWidget.qml | 3 + .../qml/ActionPanel/PrintJobInformation.qml | 8 +- .../qml/ActionPanel/SliceProcessWidget.qml | 7 +- resources/qml/Cura.qml | 89 +- resources/qml/CustomConfigurationSelector.qml | 357 ++++ resources/qml/Dialogs/AboutDialog.qml | 6 +- resources/qml/Dialogs/AddMachineDialog.qml | 5 +- resources/qml/ExpandableComponent.qml | 6 +- resources/qml/ExtruderIcon.qml | 7 +- resources/qml/IconLabel.qml | 5 +- resources/qml/IconWithText.qml | 2 + resources/qml/JobSpecs.qml | 3 +- resources/qml/MainWindow/MainWindowHeader.qml | 30 +- .../ConfigurationMenu/AutoConfiguration.qml | 39 - .../ConfigurationMenu/ConfigurationItem.qml | 212 +- .../ConfigurationListView.qml | 77 +- .../ConfigurationMenu/ConfigurationMenu.qml | 203 -- .../ConfigurationMenu/CustomConfiguration.qml | 250 --- .../PrintCoreConfiguration.qml | 107 +- .../QuickConfigurationSelector.qml | 243 +++ .../Menus/ConfigurationMenu/SyncButton.qml | 102 + resources/qml/Menus/ProfileMenu.qml | 8 +- resources/qml/ObjectsList.qml | 3 +- resources/qml/Preferences/MachinesPage.qml | 8 +- .../Materials/MaterialsBrandSection.qml | 5 +- .../Preferences/Materials/MaterialsList.qml | 8 +- .../Preferences/Materials/MaterialsSlot.qml | 2 + .../Materials/MaterialsTypeSection.qml | 2 + resources/qml/Preferences/ProfilesPage.qml | 24 +- .../qml/Preferences/SettingVisibilityPage.qml | 2 +- resources/qml/PrinterOutput/ExtruderBox.qml | 2 +- resources/qml/PrinterOutput/HeatedBedBox.qml | 2 +- .../qml/PrinterOutput/OutputDeviceHeader.qml | 4 +- .../qml/PrinterSelector/MachineSelector.qml | 60 +- .../PrinterSelector/MachineSelectorList.qml | 2 +- .../PrinterTypeLabel.qml | 2 +- resources/qml/Settings/SettingCategory.qml | 13 +- resources/qml/Settings/SettingCheckBox.qml | 4 +- .../qml/Settings/SettingOptionalExtruder.qml | 13 +- resources/qml/Settings/SettingView.qml | 11 +- resources/qml/SidebarSimple.qml | 7 +- resources/qml/Toolbar.qml | 2 +- resources/qml/ViewsSelector.qml | 6 +- resources/qml/qmldir | 1 + resources/themes/cura-dark/theme.json | 10 +- .../cura-light/images/header_pattern.svg | 1902 +---------------- .../themes/cura-light/images/logo_about.svg | 172 -- resources/themes/cura-light/styles.qml | 9 +- resources/themes/cura-light/theme.json | 51 +- 73 files changed, 1223 insertions(+), 3054 deletions(-) create mode 100644 resources/qml/CustomConfigurationSelector.qml delete mode 100644 resources/qml/Menus/ConfigurationMenu/AutoConfiguration.qml delete mode 100644 resources/qml/Menus/ConfigurationMenu/ConfigurationMenu.qml delete mode 100644 resources/qml/Menus/ConfigurationMenu/CustomConfiguration.qml create mode 100644 resources/qml/Menus/ConfigurationMenu/QuickConfigurationSelector.qml create mode 100644 resources/qml/Menus/ConfigurationMenu/SyncButton.qml rename resources/qml/{ => PrinterSelector}/PrinterTypeLabel.qml (95%) delete mode 100644 resources/themes/cura-light/images/logo_about.svg diff --git a/cura/Settings/ExtruderManager.py b/cura/Settings/ExtruderManager.py index b0bcf3b100..9089ba96e9 100755 --- a/cura/Settings/ExtruderManager.py +++ b/cura/Settings/ExtruderManager.py @@ -63,7 +63,7 @@ class ExtruderManager(QObject): if not self._application.getGlobalContainerStack(): return None # No active machine, so no active extruder. try: - return self._extruder_trains[self._application.getGlobalContainerStack().getId()][str(self.activeExtruderIndex)].getId() + return self._extruder_trains[self._application.getGlobalContainerStack().getId()][str(self._active_extruder_index)].getId() except KeyError: # Extruder index could be -1 if the global tab is selected, or the entry doesn't exist if the machine definition is wrong. return None @@ -144,7 +144,7 @@ class ExtruderManager(QObject): @pyqtSlot(result = QObject) def getActiveExtruderStack(self) -> Optional["ExtruderStack"]: - return self.getExtruderStack(self.activeExtruderIndex) + return self.getExtruderStack(self._active_extruder_index) ## Get an extruder stack by index def getExtruderStack(self, index) -> Optional["ExtruderStack"]: diff --git a/cura/Settings/ExtruderStack.py b/cura/Settings/ExtruderStack.py index d626ef06da..d7faedb71c 100644 --- a/cura/Settings/ExtruderStack.py +++ b/cura/Settings/ExtruderStack.py @@ -52,8 +52,8 @@ class ExtruderStack(CuraContainerStack): return super().getNextStack() def setEnabled(self, enabled: bool) -> None: - if self.getMetaDataEntry("enabled", True) == enabled: #No change. - return #Don't emit a signal then. + if "enabled" not in self._metadata: + self.setMetaDataEntry("enabled", "True") self.setMetaDataEntry("enabled", str(enabled)) self.enabledChanged.emit() diff --git a/cura/Settings/ExtrudersModel.py b/cura/Settings/ExtrudersModel.py index 5f10ac99d4..14a8dadc69 100644 --- a/cura/Settings/ExtrudersModel.py +++ b/cura/Settings/ExtrudersModel.py @@ -1,4 +1,4 @@ -# Copyright (c) 2018 Ultimaker B.V. +# Copyright (c) 2017 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from PyQt5.QtCore import Qt, pyqtSignal, pyqtSlot, pyqtProperty, QTimer @@ -165,7 +165,7 @@ class ExtrudersModel(UM.Qt.ListModel.ListModel): def __updateExtruders(self): extruders_changed = False - if self.count != 0: + if self.rowCount() != 0: extruders_changed = True items = [] @@ -177,7 +177,7 @@ class ExtrudersModel(UM.Qt.ListModel.ListModel): machine_extruder_count = global_container_stack.getProperty("machine_extruder_count", "value") for extruder in Application.getInstance().getExtruderManager().getActiveExtruderStacks(): - position = extruder.getMetaDataEntry("position", default = "0") + position = extruder.getMetaDataEntry("position", default = "0") # Get the position try: position = int(position) except ValueError: diff --git a/cura/Settings/MachineManager.py b/cura/Settings/MachineManager.py index a472cc7f09..53390ca88d 100755 --- a/cura/Settings/MachineManager.py +++ b/cura/Settings/MachineManager.py @@ -874,7 +874,7 @@ class MachineManager(QObject): caution_message = Message(catalog.i18nc( "@info:generic", "Settings have been changed to match the current availability of extruders: [%s]" % ", ".join(add_user_changes)), - lifetime = 0, + lifetime=0, title = catalog.i18nc("@info:title", "Settings updated")) caution_message.show() @@ -1553,7 +1553,7 @@ class MachineManager(QObject): elif word.isdigit(): abbr_machine += word else: - stripped_word = "".join(char for char in unicodedata.normalize("NFD", word.upper()) if unicodedata.category(char) != "Mn") + stripped_word = ''.join(char for char in unicodedata.normalize('NFD', word.upper()) if unicodedata.category(char) != 'Mn') # - use only the first character if the word is too long (> 3 characters) # - use the whole word if it's not too long (<= 3 characters) if len(stripped_word) > 3: diff --git a/plugins/CuraEngineBackend/ProcessSlicedLayersJob.py b/plugins/CuraEngineBackend/ProcessSlicedLayersJob.py index 71c96880e8..594bf3a43e 100644 --- a/plugins/CuraEngineBackend/ProcessSlicedLayersJob.py +++ b/plugins/CuraEngineBackend/ProcessSlicedLayersJob.py @@ -195,7 +195,7 @@ class ProcessSlicedLayersJob(Job): if extruders: material_color_map = numpy.zeros((len(extruders), 4), dtype=numpy.float32) for extruder in extruders: - position = int(extruder.getMetaDataEntry("position", default = "0")) + position = int(extruder.getMetaDataEntry("position", default="0")) # Get the position try: default_color = ExtrudersModel.defaultColors[position] except IndexError: diff --git a/plugins/MachineSettingsAction/MachineSettingsAction.qml b/plugins/MachineSettingsAction/MachineSettingsAction.qml index c88a721a84..004b4e3cfc 100644 --- a/plugins/MachineSettingsAction/MachineSettingsAction.qml +++ b/plugins/MachineSettingsAction/MachineSettingsAction.qml @@ -1,4 +1,4 @@ -// Copyright (c) 2018 Ultimaker B.V. +// Copyright (c) 2016 Ultimaker B.V. // Cura is released under the terms of the LGPLv3 or higher. import QtQuick 2.2 @@ -23,7 +23,7 @@ Cura.MachineAction target: base.extrudersModel onModelChanged: { - var extruderCount = base.extrudersModel.count; + var extruderCount = base.extrudersModel.rowCount(); base.extruderTabsCount = extruderCount; } } diff --git a/plugins/ModelChecker/ModelChecker.qml b/plugins/ModelChecker/ModelChecker.qml index 437df29516..5e41591d6b 100644 --- a/plugins/ModelChecker/ModelChecker.qml +++ b/plugins/ModelChecker/ModelChecker.qml @@ -33,6 +33,7 @@ Button { width: UM.Theme.getSize("save_button_specs_icons").width; height: UM.Theme.getSize("save_button_specs_icons").height; + sourceSize.width: width; sourceSize.height: width; color: control.hovered ? UM.Theme.getColor("text_scene_hover") : UM.Theme.getColor("text_scene"); source: "model_checker.svg" diff --git a/plugins/PerObjectSettingsTool/PerObjectSettingsPanel.qml b/plugins/PerObjectSettingsTool/PerObjectSettingsPanel.qml index 0e2bd88619..5d4e17a102 100644 --- a/plugins/PerObjectSettingsTool/PerObjectSettingsPanel.qml +++ b/plugins/PerObjectSettingsTool/PerObjectSettingsPanel.qml @@ -265,6 +265,7 @@ Item { anchors.verticalCenter: parent.verticalCenter width: parent.width height: width + sourceSize.width: width sourceSize.height: width color: control.hovered ? UM.Theme.getColor("setting_control_button_hover") : UM.Theme.getColor("setting_control_button") source: UM.Theme.getIcon("minus") diff --git a/plugins/PostProcessingPlugin/PostProcessingPlugin.qml b/plugins/PostProcessingPlugin/PostProcessingPlugin.qml index 3fa10c23b9..bd4d361d35 100644 --- a/plugins/PostProcessingPlugin/PostProcessingPlugin.qml +++ b/plugins/PostProcessingPlugin/PostProcessingPlugin.qml @@ -141,6 +141,7 @@ UM.Dialog anchors.horizontalCenter: parent.horizontalCenter width: Math.round(control.width / 2.7) height: Math.round(control.height / 2.7) + sourceSize.width: width sourceSize.height: width color: palette.text source: UM.Theme.getIcon("cross1") @@ -175,6 +176,7 @@ UM.Dialog anchors.horizontalCenter: parent.horizontalCenter width: Math.round(control.width / 2.5) height: Math.round(control.height / 2.5) + sourceSize.width: width sourceSize.height: width color: control.enabled ? palette.text : disabledPalette.text source: UM.Theme.getIcon("arrow_bottom") @@ -209,6 +211,7 @@ UM.Dialog anchors.horizontalCenter: parent.horizontalCenter width: Math.round(control.width / 2.5) height: Math.round(control.height / 2.5) + sourceSize.width: width sourceSize.height: width color: control.enabled ? palette.text : disabledPalette.text source: UM.Theme.getIcon("arrow_top") @@ -495,6 +498,7 @@ UM.Dialog anchors.horizontalCenter: parent.horizontalCenter width: Math.round(parent.width / 2) height: Math.round(parent.height / 2) + sourceSize.width: width sourceSize.height: height color: !control.enabled ? UM.Theme.getColor("action_button_disabled_text") : control.pressed ? UM.Theme.getColor("action_button_active_text") : diff --git a/plugins/PrepareStage/PrepareMenu.qml b/plugins/PrepareStage/PrepareMenu.qml index fa94bc88b2..10b4262f01 100644 --- a/plugins/PrepareStage/PrepareMenu.qml +++ b/plugins/PrepareStage/PrepareMenu.qml @@ -61,7 +61,7 @@ Item color: UM.Theme.getColor("lining") } - Cura.ConfigurationMenu + Cura.QuickConfigurationSelector { Layout.fillHeight: true Layout.fillWidth: true @@ -107,6 +107,7 @@ Item height: UM.Theme.getSize("button_icon").height color: UM.Theme.getColor("toolbar_button_text") + sourceSize.width: width sourceSize.height: height } } diff --git a/plugins/SimulationView/SimulationViewMainComponent.qml b/plugins/SimulationView/SimulationViewMainComponent.qml index 16b049c921..16b9aeaae6 100644 --- a/plugins/SimulationView/SimulationViewMainComponent.qml +++ b/plugins/SimulationView/SimulationViewMainComponent.qml @@ -61,9 +61,10 @@ Item iconSource: !is_simulation_playing ? "./resources/simulation_resume.svg": "./resources/simulation_pause.svg" width: UM.Theme.getSize("small_button").width height: UM.Theme.getSize("small_button").height - hoverColor: UM.Theme.getColor("slider_handle_active") - color: UM.Theme.getColor("slider_handle") - iconMargin: UM.Theme.getSize("thick_lining").width + hoverBackgroundColor: UM.Theme.getColor("small_button_hover") + hoverColor: UM.Theme.getColor("small_button_text_hover") + color: UM.Theme.getColor("small_button_text") + iconMargin: 0.5 * UM.Theme.getSize("wide_lining").width visible: !UM.SimulationView.compatibilityMode Connections diff --git a/plugins/Toolbox/resources/qml/ToolboxAuthorPage.qml b/plugins/Toolbox/resources/qml/ToolboxAuthorPage.qml index 9c1df0c49e..4aaea20813 100644 --- a/plugins/Toolbox/resources/qml/ToolboxAuthorPage.qml +++ b/plugins/Toolbox/resources/qml/ToolboxAuthorPage.qml @@ -86,13 +86,13 @@ Item Label { text: catalog.i18nc("@label", "Website") + ":" - font: UM.Theme.getFont("default") + font: UM.Theme.getFont("very_small") color: UM.Theme.getColor("text_medium") } Label { text: catalog.i18nc("@label", "Email") + ":" - font: UM.Theme.getFont("default") + font: UM.Theme.getFont("very_small") color: UM.Theme.getColor("text_medium") } } @@ -118,7 +118,7 @@ Item } return "" } - font: UM.Theme.getFont("default") + font: UM.Theme.getFont("very_small") color: UM.Theme.getColor("text") linkColor: UM.Theme.getColor("text_link") onLinkActivated: Qt.openUrlExternally(link) @@ -134,7 +134,7 @@ Item } return "" } - font: UM.Theme.getFont("default") + font: UM.Theme.getFont("very_small") color: UM.Theme.getColor("text") linkColor: UM.Theme.getColor("text_link") onLinkActivated: Qt.openUrlExternally(link) diff --git a/plugins/Toolbox/resources/qml/ToolboxCompatibilityChart.qml b/plugins/Toolbox/resources/qml/ToolboxCompatibilityChart.qml index d4c0ae14eb..4a6268df42 100644 --- a/plugins/Toolbox/resources/qml/ToolboxCompatibilityChart.qml +++ b/plugins/Toolbox/resources/qml/ToolboxCompatibilityChart.qml @@ -228,7 +228,7 @@ Item return result } - font: UM.Theme.getFont("default") + font: UM.Theme.getFont("very_small") color: UM.Theme.getColor("text") linkColor: UM.Theme.getColor("text_link") onLinkActivated: Qt.openUrlExternally(link) diff --git a/plugins/Toolbox/resources/qml/ToolboxDetailPage.qml b/plugins/Toolbox/resources/qml/ToolboxDetailPage.qml index 9e2e178b71..c5e9bb0a49 100644 --- a/plugins/Toolbox/resources/qml/ToolboxDetailPage.qml +++ b/plugins/Toolbox/resources/qml/ToolboxDetailPage.qml @@ -82,25 +82,25 @@ Item Label { text: catalog.i18nc("@label", "Version") + ":" - font: UM.Theme.getFont("default") + font: UM.Theme.getFont("very_small") color: UM.Theme.getColor("text_medium") } Label { text: catalog.i18nc("@label", "Last updated") + ":" - font: UM.Theme.getFont("default") + font: UM.Theme.getFont("very_small") color: UM.Theme.getColor("text_medium") } Label { text: catalog.i18nc("@label", "Author") + ":" - font: UM.Theme.getFont("default") + font: UM.Theme.getFont("very_small") color: UM.Theme.getColor("text_medium") } Label { text: catalog.i18nc("@label", "Downloads") + ":" - font: UM.Theme.getFont("default") + font: UM.Theme.getFont("very_small") color: UM.Theme.getColor("text_medium") } } @@ -119,7 +119,7 @@ Item Label { text: details === null ? "" : (details.version || catalog.i18nc("@label", "Unknown")) - font: UM.Theme.getFont("default") + font: UM.Theme.getFont("very_small") color: UM.Theme.getColor("text") } Label @@ -133,7 +133,7 @@ Item var date = new Date(details.last_updated) return date.toLocaleString(UM.Preferences.getValue("general/language")) } - font: UM.Theme.getFont("default") + font: UM.Theme.getFont("very_small") color: UM.Theme.getColor("text") } Label @@ -149,7 +149,7 @@ Item return "" + details.author_name + "" } } - font: UM.Theme.getFont("default") + font: UM.Theme.getFont("very_small") color: UM.Theme.getColor("text") linkColor: UM.Theme.getColor("text_link") onLinkActivated: Qt.openUrlExternally(link) @@ -157,7 +157,7 @@ Item Label { text: details === null ? "" : (details.download_count || catalog.i18nc("@label", "Unknown")) - font: UM.Theme.getFont("default") + font: UM.Theme.getFont("very_small") color: UM.Theme.getColor("text") } } diff --git a/plugins/Toolbox/resources/qml/ToolboxDownloadsGridTile.qml b/plugins/Toolbox/resources/qml/ToolboxDownloadsGridTile.qml index 61374f9d99..887140bbfa 100644 --- a/plugins/Toolbox/resources/qml/ToolboxDownloadsGridTile.qml +++ b/plugins/Toolbox/resources/qml/ToolboxDownloadsGridTile.qml @@ -52,6 +52,7 @@ Item bottom: parent.bottom right: parent.right } + sourceSize.width: width sourceSize.height: height visible: installedPackages != 0 color: (installedPackages == packageCount) ? UM.Theme.getColor("primary") : UM.Theme.getColor("border") @@ -80,7 +81,7 @@ Item width: parent.width wrapMode: Text.WordWrap color: UM.Theme.getColor("text_medium") - font: UM.Theme.getFont("default") + font: UM.Theme.getFont("very_small") } } } diff --git a/plugins/Toolbox/resources/qml/ToolboxDownloadsShowcaseTile.qml b/plugins/Toolbox/resources/qml/ToolboxDownloadsShowcaseTile.qml index 8a2fdc8bc8..4fb70541d2 100644 --- a/plugins/Toolbox/resources/qml/ToolboxDownloadsShowcaseTile.qml +++ b/plugins/Toolbox/resources/qml/ToolboxDownloadsShowcaseTile.qml @@ -48,6 +48,8 @@ Rectangle right: parent.right bottomMargin: UM.Theme.getSize("default_lining").width } + sourceSize.width: width + sourceSize.height: height visible: installedPackages != 0 color: (installedPackages == packageCount) ? UM.Theme.getColor("primary") : UM.Theme.getColor("border") source: "../images/installed_check.svg" diff --git a/plugins/UM3NetworkPrinting/resources/qml/DiscoverUM3Action.qml b/plugins/UM3NetworkPrinting/resources/qml/DiscoverUM3Action.qml index bb710127fc..967adfc029 100644 --- a/plugins/UM3NetworkPrinting/resources/qml/DiscoverUM3Action.qml +++ b/plugins/UM3NetworkPrinting/resources/qml/DiscoverUM3Action.qml @@ -64,7 +64,6 @@ Cura.MachineAction width: parent.width text: catalog.i18nc("@title:window", "Connect to Networked Printer") wrapMode: Text.WordWrap - renderType: Text.NativeRendering font.pointSize: 18 } @@ -73,7 +72,6 @@ Cura.MachineAction id: pageDescription width: parent.width wrapMode: Text.WordWrap - renderType: Text.NativeRendering text: catalog.i18nc("@label", "To print directly to your printer over the network, please make sure your printer is connected to the network using a network cable or by connecting your printer to your WIFI network. If you don't connect Cura with your printer, you can still use a USB drive to transfer g-code files to your printer.\n\nSelect your printer from the list below:") } @@ -184,7 +182,6 @@ Cura.MachineAction text: listview.model[index].name color: parent.ListView.isCurrentItem ? palette.highlightedText : palette.text elide: Text.ElideRight - renderType: Text.NativeRendering } MouseArea @@ -207,7 +204,6 @@ Cura.MachineAction anchors.left: parent.left anchors.right: parent.right wrapMode: Text.WordWrap - renderType: Text.NativeRendering text: catalog.i18nc("@label", "If your printer is not listed, read the network printing troubleshooting guide").arg("https://ultimaker.com/en/troubleshooting"); onLinkActivated: Qt.openUrlExternally(link) } @@ -225,7 +221,6 @@ Cura.MachineAction text: base.selectedDevice ? base.selectedDevice.name : "" font: UM.Theme.getFont("large") elide: Text.ElideRight - renderType: Text.NativeRendering } Grid { @@ -236,14 +231,12 @@ Cura.MachineAction { width: Math.round(parent.width * 0.5) wrapMode: Text.WordWrap - renderType: Text.NativeRendering text: catalog.i18nc("@label", "Type") } Label { width: Math.round(parent.width * 0.5) wrapMode: Text.WordWrap - renderType: Text.NativeRendering text: { if(base.selectedDevice) @@ -275,28 +268,24 @@ Cura.MachineAction { width: Math.round(parent.width * 0.5) wrapMode: Text.WordWrap - renderType: Text.NativeRendering text: catalog.i18nc("@label", "Firmware version") } Label { width: Math.round(parent.width * 0.5) wrapMode: Text.WordWrap - renderType: Text.NativeRendering text: base.selectedDevice ? base.selectedDevice.firmwareVersion : "" } Label { width: Math.round(parent.width * 0.5) wrapMode: Text.WordWrap - renderType: Text.NativeRendering text: catalog.i18nc("@label", "Address") } Label { width: Math.round(parent.width * 0.5) wrapMode: Text.WordWrap - renderType: Text.NativeRendering text: base.selectedDevice ? base.selectedDevice.ipAddress : "" } } @@ -305,7 +294,6 @@ Cura.MachineAction { width: parent.width wrapMode: Text.WordWrap - renderType: Text.NativeRendering text:{ // The property cluster size does not exist for older UM3 devices. if(!base.selectedDevice || base.selectedDevice.clusterSize == null || base.selectedDevice.clusterSize == 1) @@ -327,7 +315,6 @@ Cura.MachineAction { width: parent.width wrapMode: Text.WordWrap - renderType: Text.NativeRendering visible: base.selectedDevice != null && !base.completeProperties text: catalog.i18nc("@label", "The printer at this address has not yet responded." ) } @@ -371,10 +358,9 @@ Cura.MachineAction Label { - text: catalog.i18nc("@alabel", "Enter the IP address or hostname of your printer on the network.") + text: catalog.i18nc("@alabel","Enter the IP address or hostname of your printer on the network.") width: parent.width wrapMode: Text.WordWrap - renderType: Text.NativeRendering } TextField diff --git a/plugins/UM3NetworkPrinting/resources/qml/MonitorBuildplateConfiguration.qml b/plugins/UM3NetworkPrinting/resources/qml/MonitorBuildplateConfiguration.qml index 7edeb81a96..9ffb1eabb4 100644 --- a/plugins/UM3NetworkPrinting/resources/qml/MonitorBuildplateConfiguration.qml +++ b/plugins/UM3NetworkPrinting/resources/qml/MonitorBuildplateConfiguration.qml @@ -52,7 +52,7 @@ Item id: buildplateLabel color: "#191919" // TODO: Theme! elide: Text.ElideRight - font: UM.Theme.getFont("default") // 12pt, regular + font: UM.Theme.getFont("very_small") // 12pt, regular text: "" // FIXED-LINE-HEIGHT: diff --git a/plugins/UM3NetworkPrinting/resources/qml/MonitorExtruderConfiguration.qml b/plugins/UM3NetworkPrinting/resources/qml/MonitorExtruderConfiguration.qml index 1e53191d8c..afbd4c3641 100644 --- a/plugins/UM3NetworkPrinting/resources/qml/MonitorExtruderConfiguration.qml +++ b/plugins/UM3NetworkPrinting/resources/qml/MonitorExtruderConfiguration.qml @@ -49,7 +49,7 @@ Item } color: "#191919" // TODO: Theme! elide: Text.ElideRight - font: UM.Theme.getFont("default") // 12pt, regular + font: UM.Theme.getFont("very_small") // 12pt, regular text: "" // FIXED-LINE-HEIGHT: @@ -66,7 +66,7 @@ Item } color: "#191919" // TODO: Theme! elide: Text.ElideRight - font: UM.Theme.getFont("default_bold") // 12pt, bold + font: UM.Theme.getFont("small") // 12pt, bold text: "" // FIXED-LINE-HEIGHT: diff --git a/plugins/UM3NetworkPrinting/resources/qml/MonitorPrinterCard.qml b/plugins/UM3NetworkPrinting/resources/qml/MonitorPrinterCard.qml index 567fff8489..8659037cb8 100644 --- a/plugins/UM3NetworkPrinting/resources/qml/MonitorPrinterCard.qml +++ b/plugins/UM3NetworkPrinting/resources/qml/MonitorPrinterCard.qml @@ -179,15 +179,13 @@ Item color: "#414054" // TODO: Theme! font: UM.Theme.getFont("large") // 16pt, bold text: { - if (printer && printer.state == "disabled") - { + if (printer && printer.state == "disabled"){ return catalog.i18nc("@label:status", "Unavailable") } - if (printer && printer.state == "unreachable") - { - return catalog.i18nc("@label:status", "Unreachable") + if (printer && printer.state == "unreachable"){ + return catalog.i18nc("@label:status", "Unavailable") } - if (printer && printer.state == "idle") + if (printer && !printer.activePrintJob) { return catalog.i18nc("@label:status", "Idle") } diff --git a/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py b/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py index a5ee3bc650..cc5b128479 100644 --- a/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py @@ -572,17 +572,7 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): def _createMaterialOutputModel(self, material_data: Dict[str, Any]) -> "MaterialOutputModel": material_manager = CuraApplication.getInstance().getMaterialManager() - material_group_list = material_manager.getMaterialGroupListByGUID(material_data["guid"]) - # This can happen if the connected machine has no material in one or more extruders (if GUID is empty), or the - # material is unknown to Cura, so we should return an "empty" or "unknown" material model. - if material_group_list is None: - material_name = "Empty" if len(material_data["guid"]) == 0 else "Unknown" - return MaterialOutputModel(guid = material_data["guid"], - type = material_data.get("type", ""), - color = material_data.get("color", ""), - brand = material_data.get("brand", ""), - name = material_data.get("name", material_name) - ) + material_group_list = material_manager.getMaterialGroupListByGUID(material_data["guid"]) or [] # Sort the material groups by "is_read_only = True" first, and then the name alphabetically. read_only_material_group_list = list(filter(lambda x: x.is_read_only, material_group_list)) diff --git a/resources/qml/ActionButton.qml b/resources/qml/ActionButton.qml index fc4a1c05f4..b9a04f3b46 100644 --- a/resources/qml/ActionButton.qml +++ b/resources/qml/ActionButton.qml @@ -7,17 +7,14 @@ import QtQuick.Controls 2.1 import QtGraphicalEffects 1.0 // For the dropshadow import UM 1.1 as UM -import Cura 1.0 as Cura Button { id: button - property alias iconSource: buttonIconLeft.source - property bool isIconOnRightSide: false + property alias iconSource: buttonIcon.source property alias textFont: buttonText.font property alias cornerRadius: backgroundRect.radius property alias tooltip: tooltip.text - property alias cornerSide: backgroundRect.cornerSide property color color: UM.Theme.getColor("primary") property color hoverColor: UM.Theme.getColor("primary_hover") @@ -39,21 +36,18 @@ Button // we elide the text to the right so the text will be cut off with the three dots at the end. property var fixedWidthMode: false - leftPadding: UM.Theme.getSize("default_margin").width - rightPadding: UM.Theme.getSize("default_margin").width - height: UM.Theme.getSize("action_button").height - contentItem: Row { - //Left side icon. Only displayed if !isIconOnRightSide. UM.RecolorImage { - id: buttonIconLeft + id: buttonIcon source: "" - height: buttonText.height - width: visible ? height : 0 + height: Math.round(0.6 * parent.height) + width: height + sourceSize.width: width + sourceSize.height: height color: button.hovered ? button.textHoverColor : button.textColor - visible: source != "" && !button.isIconOnRightSide + visible: source != "" anchors.verticalCenter: parent.verticalCenter } @@ -70,24 +64,11 @@ Button horizontalAlignment: Text.AlignHCenter elide: Text.ElideRight } - - //Right side icon. Only displayed if isIconOnRightSide. - UM.RecolorImage - { - id: buttonIconRight - source: buttonIconLeft.source - height: buttonText.height - width: visible ? height : 0 - color: buttonIconLeft.color - visible: source != "" && button.isIconOnRightSide - anchors.verticalCenter: buttonIconLeft.verticalCenter - } } - background: Cura.RoundedRectangle + background: Rectangle { id: backgroundRect - cornerSide: Cura.RoundedRectangle.Direction.All color: button.enabled ? (button.hovered ? button.hoverColor : button.color) : button.disabledColor radius: UM.Theme.getSize("action_button_radius").width border.width: UM.Theme.getSize("default_lining").width diff --git a/resources/qml/ActionPanel/OutputDevicesActionButton.qml b/resources/qml/ActionPanel/OutputDevicesActionButton.qml index 95750e6d11..2111038cfc 100644 --- a/resources/qml/ActionPanel/OutputDevicesActionButton.qml +++ b/resources/qml/ActionPanel/OutputDevicesActionButton.qml @@ -17,7 +17,6 @@ Item id: saveToButton height: parent.height fixedWidthMode: true - cornerSide: deviceSelectionMenu.visible ? Cura.RoundedRectangle.Direction.Left : Cura.RoundedRectangle.Direction.All anchors { @@ -45,7 +44,6 @@ Item shadowEnabled: true shadowColor: UM.Theme.getColor("primary_shadow") - cornerSide: Cura.RoundedRectangle.Direction.Right anchors { @@ -53,8 +51,6 @@ Item right: parent.right } - leftPadding: UM.Theme.getSize("narrow_margin").width //Need more space than usual here for wide text. - rightPadding: UM.Theme.getSize("narrow_margin").width tooltip: catalog.i18nc("@info:tooltip", "Select the active output device") iconSource: popup.opened ? UM.Theme.getIcon("arrow_top") : UM.Theme.getIcon("arrow_bottom") color: UM.Theme.getColor("action_panel_secondary") diff --git a/resources/qml/ActionPanel/OutputProcessWidget.qml b/resources/qml/ActionPanel/OutputProcessWidget.qml index 1d1a1e44e1..6ab8dc6fbb 100644 --- a/resources/qml/ActionPanel/OutputProcessWidget.qml +++ b/resources/qml/ActionPanel/OutputProcessWidget.qml @@ -51,7 +51,7 @@ Column text: preSlicedData ? catalog.i18nc("@label", "No time estimation available") : PrintInformation.currentPrintTime.getDisplayString(UM.DurationFormat.Long) source: UM.Theme.getIcon("clock") - font: UM.Theme.getFont("default_bold") + font: UM.Theme.getFont("small") } Cura.IconLabel @@ -84,7 +84,7 @@ Column return totalWeights + "g · " + totalLengths.toFixed(2) + "m" } source: UM.Theme.getIcon("spool") - font: UM.Theme.getFont("default") + font: UM.Theme.getFont("very_small") } } @@ -101,25 +101,17 @@ Column } } - Item + Row { id: buttonRow - anchors.right: parent.right - anchors.left: parent.left - height: UM.Theme.getSize("action_button").height + spacing: UM.Theme.getSize("default_margin").width + width: parent.width Cura.SecondaryButton { id: previewStageShortcut - anchors - { - left: parent.left - right: outputDevicesButton.left - rightMargin: UM.Theme.getSize("default_margin").width - } - - height: UM.Theme.getSize("action_button").height + height: UM.Theme.getSize("action_panel_button").height leftPadding: UM.Theme.getSize("default_margin").width rightPadding: UM.Theme.getSize("default_margin").width text: catalog.i18nc("@button", "Preview") @@ -133,11 +125,8 @@ Column Cura.OutputDevicesActionButton { - id: outputDevicesButton - - anchors.right: parent.right - width: previewStageShortcut.visible ? UM.Theme.getSize("action_button").width : parent.width - height: UM.Theme.getSize("action_button").height + width: previewStageShortcut.visible ? UM.Theme.getSize("action_panel_button").width : parent.width + height: UM.Theme.getSize("action_panel_button").height } } } \ No newline at end of file diff --git a/resources/qml/ActionPanel/PrintInformationWidget.qml b/resources/qml/ActionPanel/PrintInformationWidget.qml index 554273a818..25e380dea8 100644 --- a/resources/qml/ActionPanel/PrintInformationWidget.qml +++ b/resources/qml/ActionPanel/PrintInformationWidget.qml @@ -15,6 +15,9 @@ UM.RecolorImage width: UM.Theme.getSize("section_icon").width height: UM.Theme.getSize("section_icon").height + sourceSize.width: width + sourceSize.height: height + color: popup.opened ? UM.Theme.getColor("primary") : UM.Theme.getColor("text_medium") MouseArea diff --git a/resources/qml/ActionPanel/PrintJobInformation.qml b/resources/qml/ActionPanel/PrintJobInformation.qml index 8bd5d5a0d3..156111af4d 100644 --- a/resources/qml/ActionPanel/PrintJobInformation.qml +++ b/resources/qml/ActionPanel/PrintJobInformation.qml @@ -30,7 +30,7 @@ Column { text: catalog.i18nc("@label", "Time specification").toUpperCase() color: UM.Theme.getColor("primary") - font: UM.Theme.getFont("default_bold") + font: UM.Theme.getFont("small") renderType: Text.NativeRendering } @@ -61,7 +61,7 @@ Column } width: parent.width - 2 * UM.Theme.getSize("default_margin").width color: UM.Theme.getColor("text") - font: UM.Theme.getFont("default") + font: UM.Theme.getFont("very_small") renderType: Text.NativeRendering textFormat: Text.RichText } @@ -79,7 +79,7 @@ Column { text: catalog.i18nc("@label", "Material specification").toUpperCase() color: UM.Theme.getColor("primary") - font: UM.Theme.getFont("default_bold") + font: UM.Theme.getFont("small") renderType: Text.NativeRendering } @@ -151,7 +151,7 @@ Column } width: parent.width - 2 * UM.Theme.getSize("default_margin").width color: UM.Theme.getColor("text") - font: UM.Theme.getFont("default") + font: UM.Theme.getFont("very_small") renderType: Text.NativeRendering textFormat: Text.RichText } diff --git a/resources/qml/ActionPanel/SliceProcessWidget.qml b/resources/qml/ActionPanel/SliceProcessWidget.qml index 8f6608e15c..03d91db530 100644 --- a/resources/qml/ActionPanel/SliceProcessWidget.qml +++ b/resources/qml/ActionPanel/SliceProcessWidget.qml @@ -48,7 +48,7 @@ Column text: catalog.i18nc("@label:PrintjobStatus", "Auto slicing...") color: UM.Theme.getColor("text") - font: UM.Theme.getFont("default") + font: UM.Theme.getFont("very_small") renderType: Text.NativeRendering } @@ -61,7 +61,7 @@ Column text: catalog.i18nc("@label:PrintjobStatus", "Unable to Slice") source: UM.Theme.getIcon("warning") color: UM.Theme.getColor("warning") - font: UM.Theme.getFont("default") + font: UM.Theme.getFont("very_small") } // Progress bar, only visible when the backend is in the process of slice the printjob @@ -94,6 +94,7 @@ Column } } + Item { id: prepareButtons @@ -102,7 +103,7 @@ Column // Disable the slice process when width: parent.width - height: UM.Theme.getSize("action_button").height + height: UM.Theme.getSize("action_panel_button").height visible: !autoSlice Cura.PrimaryButton { diff --git a/resources/qml/Cura.qml b/resources/qml/Cura.qml index 4e8e9ce788..3578888886 100644 --- a/resources/qml/Cura.qml +++ b/resources/qml/Cura.qml @@ -88,54 +88,6 @@ UM.MainWindow window: base } - Rectangle - { - id: headerBackground - anchors - { - top: applicationMenu.bottom - left: parent.left - right: parent.right - } - height: stageMenu.source != "" ? Math.round(mainWindowHeader.height + stageMenu.height / 2) : mainWindowHeader.height - - LinearGradient - { - anchors.fill: parent - start: Qt.point(0, 0) - end: Qt.point(parent.width, 0) - gradient: Gradient - { - GradientStop - { - position: 0.0 - color: UM.Theme.getColor("main_window_header_background") - } - GradientStop - { - position: 0.5 - color: UM.Theme.getColor("main_window_header_background_gradient") - } - GradientStop - { - position: 1.0 - color: UM.Theme.getColor("main_window_header_background") - } - } - } - - // This is the new fancy pattern - Image - { - id: backgroundPattern - anchors.fill: parent - fillMode: Image.Tile - source: UM.Theme.getImage("header_pattern") - horizontalAlignment: Image.AlignLeft - verticalAlignment: Image.AlignTop - } - } - MainWindowHeader { id: mainWindowHeader @@ -192,6 +144,44 @@ UM.MainWindow } } + Rectangle + { + id: stageMenuBackground + anchors + { + left: parent.left + right: parent.right + top: parent.top + } + visible: stageMenu.source != "" + height: visible ? Math.round(UM.Theme.getSize("stage_menu").height / 2) : 0 + + LinearGradient + { + anchors.fill: parent + start: Qt.point(0, 0) + end: Qt.point(parent.width, 0) + gradient: Gradient + { + GradientStop + { + position: 0.0 + color: UM.Theme.getColor("main_window_header_background") + } + GradientStop + { + position: 0.5 + color: UM.Theme.getColor("main_window_header_background_gradient") + } + GradientStop + { + position: 1.0 + color: UM.Theme.getColor("main_window_header_background") + } + } + } + } + Connections { target: stageMenu.item @@ -267,8 +257,7 @@ UM.MainWindow anchors { - // Align to the top of the stageMenu since the stageMenu may not exist - top: parent.top + top: stageMenuBackground.bottom left: parent.left right: parent.right bottom: parent.bottom diff --git a/resources/qml/CustomConfigurationSelector.qml b/resources/qml/CustomConfigurationSelector.qml new file mode 100644 index 0000000000..c78ca700da --- /dev/null +++ b/resources/qml/CustomConfigurationSelector.qml @@ -0,0 +1,357 @@ +// Copyright (c) 2018 Ultimaker B.V. +// Cura is released under the terms of the LGPLv3 or higher. + +import QtQuick 2.7 +import QtQuick.Controls 1.1 +import QtQuick.Controls.Styles 1.1 + +import UM 1.2 as UM +import Cura 1.0 as Cura + +Rectangle +{ + implicitWidth: parent.width + implicitHeight: parent.height + + id: base + color: UM.Theme.getColor("main_background") + + // Height has an extra 2x margin for the top & bottom margin. + height: childrenRect.height + 2 * UM.Theme.getSize("default_margin").width + + Cura.ExtrudersModel { id: extrudersModel } + + ListView + { + // Horizontal list that shows the extruders + id: extrudersList + visible: extrudersModel.items.length > 1 + property var index: 0 + + height: UM.Theme.getSize("configuration_selector_mode_tabs").height + boundsBehavior: Flickable.StopAtBounds + + anchors + { + left: parent.left + right: parent.right + top: parent.top + margins: UM.Theme.getSize("thick_margin").width + } + + ExclusiveGroup { id: extruderMenuGroup } + + orientation: ListView.Horizontal + + model: extrudersModel + + Connections + { + target: Cura.MachineManager + onGlobalContainerChanged: forceActiveFocus() // Changing focus applies the currently-being-typed values so it can change the displayed setting values. + } + + delegate: Button + { + height: parent.height + width: Math.round(ListView.view.width / extrudersModel.rowCount()) + + text: model.name + tooltip: model.name + exclusiveGroup: extruderMenuGroup + checked: Cura.ExtruderManager.activeExtruderIndex == index + + property bool extruder_enabled: true + + MouseArea // TODO; This really should be fixed. It makes absolutely no sense to have a button AND a mouse area. + { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: + { + switch (mouse.button) + { + case Qt.LeftButton: + extruder_enabled = Cura.MachineManager.getExtruder(model.index).isEnabled + if (extruder_enabled) + { + forceActiveFocus() // Changing focus applies the currently-being-typed values so it can change the displayed setting values. + Cura.ExtruderManager.setActiveExtruderIndex(index) + } + break + case Qt.RightButton: + extruder_enabled = Cura.MachineManager.getExtruder(model.index).isEnabled + extruderMenu.popup() + break + } + } + } + + Menu + { + id: extruderMenu + + MenuItem + { + text: catalog.i18nc("@action:inmenu", "Enable Extruder") + onTriggered: Cura.MachineManager.setExtruderEnabled(model.index, true) + visible: !extruder_enabled // using an intermediate variable prevents an empty popup that occured now and then + } + + MenuItem + { + text: catalog.i18nc("@action:inmenu", "Disable Extruder") + onTriggered: Cura.MachineManager.setExtruderEnabled(model.index, false) + visible: extruder_enabled + enabled: Cura.MachineManager.numberExtrudersEnabled > 1 + } + } + + style: ButtonStyle + { + background: Rectangle + { + anchors.fill: parent + border.width: control.checked ? UM.Theme.getSize("default_lining").width * 2 : UM.Theme.getSize("default_lining").width + border.color: + { + if (Cura.MachineManager.getExtruder(index).isEnabled) + { + if(control.checked || control.pressed) + { + return UM.Theme.getColor("action_button_active_border") + } + else if (control.hovered) + { + return UM.Theme.getColor("action_button_hovered_border") + } + return UM.Theme.getColor("action_button_border") + } + return UM.Theme.getColor("action_button_disabled_border") + } + color: + { + if (Cura.MachineManager.getExtruder(index).isEnabled) + { + if(control.checked || control.pressed) + { + return UM.Theme.getColor("action_button_active"); + } + else if (control.hovered) + { + return UM.Theme.getColor("action_button_hovered") + } + return UM.Theme.getColor("action_button") + } + return UM.Theme.getColor("action_button_disabled") + } + Behavior on color { ColorAnimation { duration: 50; } } + + Item + { + id: extruderButtonFace + anchors.centerIn: parent + width: childrenRect.width + + Label + { + // Static text that holds the "Extruder" label + id: extruderStaticText + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + + color: + { + if (Cura.MachineManager.getExtruder(index).isEnabled) + { + if(control.checked || control.pressed) + { + return UM.Theme.getColor("action_button_active_text"); + } + else if (control.hovered) + { + return UM.Theme.getColor("action_button_hovered_text") + } + return UM.Theme.getColor("action_button_text") + } + return UM.Theme.getColor("action_button_disabled_text") + } + + font: UM.Theme.getFont("large_nonbold") + text: catalog.i18nc("@label", "Extruder") + visible: width < (control.width - extruderIcon.width - UM.Theme.getSize("default_margin").width) + elide: Text.ElideRight + } + + ExtruderIcon + { + // Round icon with the extruder number and material color indicator. + id: extruderIcon + + anchors.verticalCenter: parent.verticalCenter + anchors.left: extruderStaticText.right + anchors.leftMargin: UM.Theme.getSize("default_margin").width + width: control.height - Math.round(UM.Theme.getSize("default_margin").width / 2) + height: width + + checked: control.checked + materialColor: model.color + textColor: extruderStaticText.color + } + } + } + + label: Item {} + } + } + } + + Item + { + id: materialRow + height: UM.Theme.getSize("print_setup_item").height + visible: Cura.MachineManager.hasMaterials + + anchors + { + left: parent.left + right: parent.right + top: extrudersList.bottom + margins: UM.Theme.getSize("thick_margin").width + } + + Label + { + id: materialLabel + text: catalog.i18nc("@label", "Material"); + width: Math.round(parent.width * 0.45 - UM.Theme.getSize("default_margin").width) + height: parent.height + verticalAlignment: Text.AlignVCenter + font: UM.Theme.getFont("default"); + color: UM.Theme.getColor("text"); + } + + ToolButton + { + id: materialSelection + + property var activeExtruder: Cura.MachineManager.activeStack + property var hasActiveExtruder: activeExtruder != null + property var currentRootMaterialName: hasActiveExtruder ? activeExtruder.material.name : "" + + text: currentRootMaterialName + tooltip: currentRootMaterialName + visible: Cura.MachineManager.hasMaterials + + enabled: !extrudersList.visible || Cura.ExtruderManager.activeExtruderIndex > -1 + + height: UM.Theme.getSize("setting_control").height + width: Math.round(parent.width * 0.7) + UM.Theme.getSize("thick_margin").width + anchors.right: parent.right + style: UM.Theme.styles.sidebar_header_button + activeFocusOnPress: true; + menu: Cura.MaterialMenu + { + extruderIndex: Cura.ExtruderManager.activeExtruderIndex + } + + property var valueError: hasActiveExtruder ? Cura.ContainerManager.getContainerMetaDataEntry(activeExtruder.material.id, "compatible", "") != "True" : true + property var valueWarning: ! Cura.MachineManager.isActiveQualitySupported + } + } + + Item + { + id: variantRow + height: UM.Theme.getSize("print_setup_item").height + visible: Cura.MachineManager.hasVariants + + anchors + { + left: parent.left + right: parent.right + top: materialRow.bottom + margins: UM.Theme.getSize("thick_margin").width + } + + Label + { + id: variantLabel + text: Cura.MachineManager.activeDefinitionVariantsName; + width: Math.round(parent.width * 0.45 - UM.Theme.getSize("default_margin").width) + height: parent.height + verticalAlignment: Text.AlignVCenter + font: UM.Theme.getFont("default"); + color: UM.Theme.getColor("text"); + } + + ToolButton + { + id: variantSelection + text: Cura.MachineManager.activeVariantName + tooltip: Cura.MachineManager.activeVariantName; + visible: Cura.MachineManager.hasVariants + + height: UM.Theme.getSize("setting_control").height + width: Math.round(parent.width * 0.7 + UM.Theme.getSize("thick_margin").width) + anchors.right: parent.right + style: UM.Theme.styles.sidebar_header_button + activeFocusOnPress: true; + + menu: Cura.NozzleMenu { extruderIndex: Cura.ExtruderManager.activeExtruderIndex } + } + } + + Item + { + id: materialCompatibilityLink + height: UM.Theme.getSize("print_setup_item").height + + anchors.right: parent.right + anchors.top: variantRow.bottom + anchors.margins: UM.Theme.getSize("thick_margin").width + UM.RecolorImage + { + id: warningImage + + anchors.right: materialInfoLabel.left + anchors.rightMargin: UM.Theme.getSize("default_margin").width + + source: UM.Theme.getIcon("warning") + width: UM.Theme.getSize("section_icon").width + height: UM.Theme.getSize("section_icon").height + + sourceSize.width: width + sourceSize.height: height + + color: UM.Theme.getColor("material_compatibility_warning") + + visible: !Cura.MachineManager.isCurrentSetupSupported + } + + Label + { + id: materialInfoLabel + wrapMode: Text.WordWrap + text: "" + catalog.i18nc("@label", "Check compatibility") + "" + font: UM.Theme.getFont("default") + color: UM.Theme.getColor("text") + linkColor: UM.Theme.getColor("text_link") + + verticalAlignment: Text.AlignTop + + anchors.right: parent.right + + MouseArea + { + anchors.fill: parent + + onClicked: + { + // open the material URL with web browser + Qt.openUrlExternally("https://ultimaker.com/incoming-links/cura/material-compatibilty"); + } + } + } + } +} diff --git a/resources/qml/Dialogs/AboutDialog.qml b/resources/qml/Dialogs/AboutDialog.qml index add84614e0..25c9bbf74b 100644 --- a/resources/qml/Dialogs/AboutDialog.qml +++ b/resources/qml/Dialogs/AboutDialog.qml @@ -35,10 +35,12 @@ UM.Dialog { id: logo width: (base.minimumWidth * 0.85) | 0 - height: (width * (UM.Theme.getSize("logo").height / UM.Theme.getSize("logo").width)) | 0 + height: (width * (1/4.25)) | 0 - source: UM.Theme.getImage("logo_about") + source: UM.Theme.getImage("logo") + sourceSize.width: width + sourceSize.height: height anchors.top: parent.top anchors.topMargin: ((base.minimumWidth - width) / 2) | 0 anchors.horizontalCenter: parent.horizontalCenter diff --git a/resources/qml/Dialogs/AddMachineDialog.qml b/resources/qml/Dialogs/AddMachineDialog.qml index f00359869c..8e966c3df7 100644 --- a/resources/qml/Dialogs/AddMachineDialog.qml +++ b/resources/qml/Dialogs/AddMachineDialog.qml @@ -1,4 +1,4 @@ -// Copyright (c) 2018 Ultimaker B.V. +// Copyright (c) 2017 Ultimaker B.V. // Cura is released under the terms of the LGPLv3 or higher. import QtQuick 2.2 @@ -156,6 +156,7 @@ UM.Dialog anchors.rightMargin: UM.Theme.getSize("default_margin").width width: UM.Theme.getSize("standard_arrow").width height: UM.Theme.getSize("standard_arrow").height + sourceSize.width: width sourceSize.height: width color: palette.windowText source: base.activeCategory == section ? UM.Theme.getIcon("arrow_bottom") : UM.Theme.getIcon("arrow_right") @@ -169,7 +170,7 @@ UM.Dialog if (machineList.model.getItem(machineList.currentIndex).section != section) { // Find the first machine from this section - for(var i = 0; i < machineList.model.count; i++) + for(var i = 0; i < machineList.model.rowCount(); i++) { var item = machineList.model.getItem(i); if (item.section == section) diff --git a/resources/qml/ExpandableComponent.qml b/resources/qml/ExpandableComponent.qml index e42aa7e4a1..b438f0398c 100644 --- a/resources/qml/ExpandableComponent.qml +++ b/resources/qml/ExpandableComponent.qml @@ -32,8 +32,6 @@ Item property color headerBackgroundColor: UM.Theme.getColor("action_button") property color headerHoverColor: UM.Theme.getColor("action_button_hovered") - property alias enabled: mouseArea.enabled - // Defines the alignment of the popup with respect of the headerItem, by default to the right property int popupAlignment: ExpandableComponent.PopupAlignment.AlignRight @@ -141,7 +139,9 @@ Item verticalCenter: parent.verticalCenter margins: background.padding } - visible: source != "" && base.enabled + sourceSize.width: width + sourceSize.height: height + visible: source != "" width: height height: Math.round(0.2 * base.height) color: UM.Theme.getColor("text") diff --git a/resources/qml/ExtruderIcon.qml b/resources/qml/ExtruderIcon.qml index 49ad73a32e..c1a202050b 100644 --- a/resources/qml/ExtruderIcon.qml +++ b/resources/qml/ExtruderIcon.qml @@ -22,6 +22,8 @@ Item id: mainIcon anchors.fill: parent + sourceSize.width: parent.width + sourceSize.height: parent.height source: UM.Theme.getIcon("extruder_button") color: extruderEnabled ? materialColor: "gray" } @@ -48,9 +50,7 @@ Item id: extruderNumberText anchors.centerIn: parent text: index + 1 - font: UM.Theme.getFont("very_small") - width: contentWidth - height: contentHeight + font: UM.Theme.getFont("extruder_icon") visible: extruderEnabled renderType: Text.NativeRendering horizontalAlignment: Text.AlignHCenter @@ -62,6 +62,7 @@ Item id: disabledIcon anchors.fill: parent anchors.margins: UM.Theme.getSize("thick_lining").width + sourceSize.width: width sourceSize.height: width source: UM.Theme.getIcon("cross1") visible: !extruderEnabled diff --git a/resources/qml/IconLabel.qml b/resources/qml/IconLabel.qml index f925b6eab5..0941254e7b 100644 --- a/resources/qml/IconLabel.qml +++ b/resources/qml/IconLabel.qml @@ -31,6 +31,9 @@ Item width: UM.Theme.getSize("section_icon").width height: width + sourceSize.width: width + sourceSize.height: height + color: label.color visible: source != "" } @@ -45,7 +48,7 @@ Item text: "Empty label" elide: Text.ElideRight color: UM.Theme.getColor("text") - font: UM.Theme.getFont("default") + font: UM.Theme.getFont("very_small") renderType: Text.NativeRendering } } \ No newline at end of file diff --git a/resources/qml/IconWithText.qml b/resources/qml/IconWithText.qml index 22599b3aed..dcb3ef7851 100644 --- a/resources/qml/IconWithText.qml +++ b/resources/qml/IconWithText.qml @@ -37,6 +37,8 @@ Item width: UM.Theme.getSize("section_icon").width height: UM.Theme.getSize("section_icon").height + sourceSize.width: width + sourceSize.height: height color: "black" anchors diff --git a/resources/qml/JobSpecs.qml b/resources/qml/JobSpecs.qml index 935cb723de..45111992c1 100644 --- a/resources/qml/JobSpecs.qml +++ b/resources/qml/JobSpecs.qml @@ -60,6 +60,7 @@ Item { { width: UM.Theme.getSize("save_button_specs_icons").width; height: UM.Theme.getSize("save_button_specs_icons").height; + sourceSize.width: width; sourceSize.height: width; color: control.hovered ? UM.Theme.getColor("text_scene_hover") : UM.Theme.getColor("text_scene"); source: UM.Theme.getIcon("pencil"); @@ -123,7 +124,7 @@ Item { } height: UM.Theme.getSize("jobspecs_line").height verticalAlignment: Text.AlignVCenter - font: UM.Theme.getFont("default_bold") + font: UM.Theme.getFont("small") color: UM.Theme.getColor("text_scene") text: CuraApplication.getSceneBoundingBoxString } diff --git a/resources/qml/MainWindow/MainWindowHeader.qml b/resources/qml/MainWindow/MainWindowHeader.qml index ae1c13d9c3..34936e9b5a 100644 --- a/resources/qml/MainWindow/MainWindowHeader.qml +++ b/resources/qml/MainWindow/MainWindowHeader.qml @@ -12,13 +12,38 @@ import QtGraphicalEffects 1.0 import "../Account" -Item +Rectangle { id: base implicitHeight: UM.Theme.getSize("main_window_header").height implicitWidth: UM.Theme.getSize("main_window_header").width + LinearGradient + { + anchors.fill: parent + start: Qt.point(0, 0) + end: Qt.point(parent.width, 0) + gradient: Gradient + { + GradientStop + { + position: 0.0 + color: UM.Theme.getColor("main_window_header_background") + } + GradientStop + { + position: 0.5 + color: UM.Theme.getColor("main_window_header_background_gradient") + } + GradientStop + { + position: 1.0 + color: UM.Theme.getColor("main_window_header_background") + } + } + } + Image { id: logo @@ -29,6 +54,9 @@ Item source: UM.Theme.getImage("logo") width: UM.Theme.getSize("logo").width height: UM.Theme.getSize("logo").height + + sourceSize.width: width + sourceSize.height: height } Row diff --git a/resources/qml/Menus/ConfigurationMenu/AutoConfiguration.qml b/resources/qml/Menus/ConfigurationMenu/AutoConfiguration.qml deleted file mode 100644 index 68c56c7c4b..0000000000 --- a/resources/qml/Menus/ConfigurationMenu/AutoConfiguration.qml +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) 2018 Ultimaker B.V. -// Cura is released under the terms of the LGPLv3 or higher. - -import QtQuick 2.7 -import QtQuick.Controls 2.0 - -import UM 1.3 as UM -import Cura 1.0 as Cura - -Item -{ - width: parent.width - height: childrenRect.height - - Label - { - id: header - text: catalog.i18nc("@header", "Configurations") - font: UM.Theme.getFont("large") - color: UM.Theme.getColor("text") - height: contentHeight - renderType: Text.NativeRendering - - anchors - { - left: parent.left - right: parent.right - } - } - - ConfigurationListView - { - anchors.top: header.bottom - anchors.topMargin: UM.Theme.getSize("default_margin").width - width: parent.width - - outputDevice: Cura.MachineManager.printerOutputDevices.length >= 1 ? Cura.MachineManager.printerOutputDevices[0] : null - } -} \ No newline at end of file diff --git a/resources/qml/Menus/ConfigurationMenu/ConfigurationItem.qml b/resources/qml/Menus/ConfigurationMenu/ConfigurationItem.qml index 6ac1e6a2ad..7427b5ddff 100644 --- a/resources/qml/Menus/ConfigurationMenu/ConfigurationItem.qml +++ b/resources/qml/Menus/ConfigurationMenu/ConfigurationItem.qml @@ -7,129 +7,143 @@ import QtQuick.Controls 2.0 import UM 1.2 as UM import Cura 1.0 as Cura -Button +Rectangle { id: configurationItem property var configuration: null - hoverEnabled: true + property var selected: false + signal activateConfiguration() - height: background.height + height: childrenRect.height + border.width: UM.Theme.getSize("default_lining").width + border.color: updateBorderColor() + color: selected ? UM.Theme.getColor("configuration_item_active") : UM.Theme.getColor("configuration_item") + property var textColor: selected ? UM.Theme.getColor("configuration_item_text_active") : UM.Theme.getColor("configuration_item_text") - background: Rectangle + function updateBorderColor() { - height: childrenRect.height - color: parent.hovered ? UM.Theme.getColor("action_button_hovered") : UM.Theme.getColor("action_button") - border.color: (parent.checked || parent.hovered) ? UM.Theme.getColor("primary") : UM.Theme.getColor("lining") - border.width: parent.checked ? UM.Theme.getSize("thick_lining").width : UM.Theme.getSize("default_lining").width - radius: UM.Theme.getSize("default_radius").width + border.color = selected ? UM.Theme.getColor("configuration_item_border_active") : UM.Theme.getColor("configuration_item_border") + } - Column + Column + { + id: contentColumn + width: parent.width + padding: UM.Theme.getSize("default_margin").width + spacing: Math.round(UM.Theme.getSize("default_margin").height / 2) + + Row { - id: contentColumn - width: parent.width - padding: UM.Theme.getSize("wide_margin").width - spacing: Math.round(UM.Theme.getSize("default_margin").height / 2) + id: extruderRow - Row + width: parent.width - 2 * parent.padding + height: childrenRect.height + + spacing: UM.Theme.getSize("default_margin").width + + Repeater { - id: extruderRow - - anchors - { - left: parent.left - leftMargin: parent.padding - right: parent.right - rightMargin: parent.padding - } + id: repeater height: childrenRect.height - - spacing: UM.Theme.getSize("default_margin").width - - Repeater + model: configuration.extruderConfigurations + delegate: PrintCoreConfiguration { - id: repeater - height: childrenRect.height - model: configuration.extruderConfigurations - delegate: PrintCoreConfiguration - { - width: Math.round(parent.width / 2) - printCoreConfiguration: modelData - } - } - } - - //Buildplate row separator - Rectangle - { - id: separator - - visible: buildplateInformation.visible - anchors - { - left: parent.left - leftMargin: parent.padding - right: parent.right - rightMargin: parent.padding - } - height: visible ? Math.round(UM.Theme.getSize("thick_lining").height / 2) : 0 - color: UM.Theme.getColor("text") - } - - Item - { - id: buildplateInformation - - anchors - { - left: parent.left - leftMargin: parent.padding - right: parent.right - rightMargin: parent.padding - } - height: childrenRect.height - visible: configuration.buildplateConfiguration != "" - - UM.RecolorImage - { - id: buildplateIcon - anchors.left: parent.left - width: UM.Theme.getSize("main_window_header_button_icon").width - height: UM.Theme.getSize("main_window_header_button_icon").height - source: UM.Theme.getIcon("buildplate") - color: UM.Theme.getColor("text") - } - - Label - { - id: buildplateLabel - anchors.left: buildplateIcon.right - anchors.verticalCenter: buildplateIcon.verticalCenter - anchors.leftMargin: Math.round(UM.Theme.getSize("default_margin").height / 2) - text: configuration.buildplateConfiguration - renderType: Text.NativeRendering - color: UM.Theme.getColor("text") + width: Math.round(parent.width / 2) + printCoreConfiguration: modelData + mainColor: textColor } } } - Connections + //Buildplate row separator + Rectangle { - target: Cura.MachineManager - onCurrentConfigurationChanged: - { - configurationItem.checked = Cura.MachineManager.matchesConfiguration(configuration) - } + id: separator + + visible: buildplateInformation.visible + width: parent.width - 2 * parent.padding + height: visible ? Math.round(UM.Theme.getSize("thick_lining").height / 2) : 0 + color: textColor } - Component.onCompleted: + Item { - configurationItem.checked = Cura.MachineManager.matchesConfiguration(configuration) + id: buildplateInformation + width: parent.width - 2 * parent.padding + height: childrenRect.height + visible: configuration.buildplateConfiguration != "" + + UM.RecolorImage { + id: buildplateIcon + anchors.left: parent.left + width: UM.Theme.getSize("main_window_header_button_icon").width + height: UM.Theme.getSize("main_window_header_button_icon").height + sourceSize.width: width + sourceSize.height: height + source: UM.Theme.getIcon("buildplate") + color: textColor + } + + Label + { + id: buildplateLabel + anchors.left: buildplateIcon.right + anchors.verticalCenter: buildplateIcon.verticalCenter + anchors.leftMargin: Math.round(UM.Theme.getSize("default_margin").height / 2) + text: configuration.buildplateConfiguration + renderType: Text.NativeRendering + color: textColor + } } } - onClicked: + MouseArea { - Cura.MachineManager.applyRemoteConfiguration(configuration) + id: mouse + anchors.fill: parent + onClicked: activateConfiguration() + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + onEntered: + { + parent.border.color = UM.Theme.getColor("configuration_item_border_hover") + if (configurationItem.selected == false) + { + configurationItem.color = UM.Theme.getColor("wide_lining") + } + } + onExited: + { + updateBorderColor() + if (configurationItem.selected == false) + { + configurationItem.color = UM.Theme.getColor("configuration_item") + } + } + } + + Connections + { + target: Cura.MachineManager + onCurrentConfigurationChanged: { + configurationItem.selected = Cura.MachineManager.matchesConfiguration(configuration) + updateBorderColor() + } + } + + Component.onCompleted: + { + configurationItem.selected = Cura.MachineManager.matchesConfiguration(configuration) + updateBorderColor() + } + + onVisibleChanged: + { + if(visible) + { + // I cannot trigger function updateBorderColor() after visibility change + color = selected ? UM.Theme.getColor("configuration_item_active") : UM.Theme.getColor("configuration_item") + } } } \ No newline at end of file diff --git a/resources/qml/Menus/ConfigurationMenu/ConfigurationListView.qml b/resources/qml/Menus/ConfigurationMenu/ConfigurationListView.qml index e7936b69d2..210ff6057f 100644 --- a/resources/qml/Menus/ConfigurationMenu/ConfigurationListView.qml +++ b/resources/qml/Menus/ConfigurationMenu/ConfigurationListView.qml @@ -2,7 +2,8 @@ // Cura is released under the terms of the LGPLv3 or higher. import QtQuick 2.7 -import QtQuick.Controls 2.3 +import QtQuick.Controls 1.4 +import QtQuick.Controls.Styles 1.4 import UM 1.2 as UM import Cura 1.0 as Cura @@ -11,7 +12,9 @@ Column { id: base property var outputDevice: null + property var computedHeight: container.height + configurationListHeading.height + 3 * padding height: childrenRect.height + 2 * padding + padding: UM.Theme.getSize("default_margin").width spacing: Math.round(UM.Theme.getSize("default_margin").height / 2) function forceModelUpdate() @@ -24,60 +27,60 @@ Column } } + Label + { + id: configurationListHeading + text: catalog.i18nc("@label:header configurations", "Available configurations") + font: UM.Theme.getFont("large") + width: parent.width - 2 * parent.padding + color: UM.Theme.getColor("configuration_item_text") + } + + Component + { + id: sectionHeading + Rectangle + { + height: childrenRect.height + UM.Theme.getSize("default_margin").height + Label + { + text: section + font: UM.Theme.getFont("default_bold") + color: UM.Theme.getColor("configuration_item_text") + } + } + } + ScrollView { id: container - width: parent.width - readonly property int maximumHeight: 350 * screenScaleFactor - height: Math.round(Math.min(configurationList.height, maximumHeight)) - contentHeight: configurationList.height - clip: true + width: parent.width - parent.padding + height: Math.min(configurationList.contentHeight, 350 * screenScaleFactor) - ScrollBar.vertical.policy: (configurationList.height > maximumHeight) ? ScrollBar.AlwaysOn : ScrollBar.AlwaysOff //The AsNeeded policy also hides it when the cursor is away, and we don't want that. - ScrollBar.vertical.background: Rectangle - { - implicitWidth: UM.Theme.getSize("scrollbar").width - radius: width / 2 - color: UM.Theme.getColor("scrollbar_background") - } - ScrollBar.vertical.contentItem: Rectangle - { - implicitWidth: UM.Theme.getSize("scrollbar").width - radius: width / 2 - color: UM.Theme.getColor(parent.pressed ? "scrollbar_handle_down" : parent.hovered ? "scrollbar_handle_hover" : "scrollbar_handle") - } - - ButtonGroup - { - buttons: configurationList.children - } + style: UM.Theme.styles.scrollview + __wheelAreaScrollSpeed: 75 // Scroll three lines in one scroll event ListView { id: configurationList spacing: Math.round(UM.Theme.getSize("default_margin").height / 2) - width: container.width - ((height > container.maximumHeight) ? container.ScrollBar.vertical.background.width : 0) //Make room for scroll bar if there is any. + width: container.width contentHeight: childrenRect.height - height: childrenRect.height section.property: "modelData.printerType" section.criteria: ViewSection.FullString - section.delegate: Item - { - height: printerTypeLabel.height + UM.Theme.getSize("default_margin").height - Cura.PrinterTypeLabel - { - id: printerTypeLabel - text: Cura.MachineManager.getAbbreviatedMachineName(section) - } - } + section.delegate: sectionHeading model: (outputDevice != null) ? outputDevice.uniqueConfigurations : [] - delegate: ConfigurationItem { - width: parent.width + width: parent.width - UM.Theme.getSize("default_margin").width configuration: modelData + onActivateConfiguration: + { + switchPopupState() + Cura.MachineManager.applyRemoteConfiguration(configuration) + } } } } diff --git a/resources/qml/Menus/ConfigurationMenu/ConfigurationMenu.qml b/resources/qml/Menus/ConfigurationMenu/ConfigurationMenu.qml deleted file mode 100644 index 1d086acc67..0000000000 --- a/resources/qml/Menus/ConfigurationMenu/ConfigurationMenu.qml +++ /dev/null @@ -1,203 +0,0 @@ -// Copyright (c) 2018 Ultimaker B.V. -// Cura is released under the terms of the LGPLv3 or higher. - -import QtQuick 2.7 -import QtQuick.Controls 2.0 -import QtQuick.Controls.Styles 1.4 - -import UM 1.2 as UM -import Cura 1.0 as Cura - - -/** - * Menu that allows you to select the configuration of the current printer, such - * as the nozzle sizes and materials in each extruder. - */ -Cura.ExpandableComponent -{ - id: base - - Cura.ExtrudersModel - { - id: extrudersModel - } - - UM.I18nCatalog - { - id: catalog - name: "cura" - } - - enum ConfigurationMethod - { - AUTO, - CUSTOM - } - - iconSource: expanded ? UM.Theme.getIcon("arrow_bottom") : UM.Theme.getIcon("arrow_left") - headerItem: Item - { - // Horizontal list that shows the extruders - ListView - { - id: extrudersList - - orientation: ListView.Horizontal - anchors.fill: parent - model: extrudersModel - visible: base.enabled - - delegate: Item - { - height: parent.height - width: Math.round(ListView.view.width / extrudersModel.count) - - // Extruder icon. Shows extruder index and has the same color as the active material. - Cura.ExtruderIcon - { - id: extruderIcon - materialColor: model.color - extruderEnabled: model.enabled - height: parent.height - width: height - } - - // Label for the brand of the material - Label - { - id: brandNameLabel - - text: model.material_brand - elide: Text.ElideRight - font: UM.Theme.getFont("default") - color: UM.Theme.getColor("text_inactive") - renderType: Text.NativeRendering - - anchors - { - left: extruderIcon.right - leftMargin: UM.Theme.getSize("default_margin").width - right: parent.right - rightMargin: UM.Theme.getSize("default_margin").width - } - } - - // Label that shows the name of the material - Label - { - text: model.material - elide: Text.ElideRight - font: UM.Theme.getFont("default") - color: UM.Theme.getColor("text") - renderType: Text.NativeRendering - - anchors - { - left: extruderIcon.right - leftMargin: UM.Theme.getSize("default_margin").width - right: parent.right - rightMargin: UM.Theme.getSize("default_margin").width - top: brandNameLabel.bottom - } - } - } - } - } - - //Disable the menu if there are no materials, variants or build plates to change. - function updateEnabled() - { - var active_definition_id = Cura.MachineManager.activeMachine.definition.id; - var has_materials = Cura.ContainerManager.getContainerMetaDataEntry(active_definition_id, "has_materials"); - var has_variants = Cura.ContainerManager.getContainerMetaDataEntry(active_definition_id, "has_variants"); - var has_buildplates = Cura.ContainerManager.getContainerMetaDataEntry(active_definition_id, "has_variant_buildplates"); - base.enabled = has_materials || has_variants || has_buildplates; //Only let it drop down if there is any configuration that you could change. - } - - Connections - { - target: Cura.MachineManager - onGlobalContainerChanged: base.updateEnabled(); - } - Component.onCompleted: updateEnabled(); - - popupItem: Column - { - id: popupItem - width: base.width - 2 * UM.Theme.getSize("default_margin").width - height: implicitHeight //Required because ExpandableComponent will try to use this to determine the size of the background of the pop-up. - spacing: UM.Theme.getSize("default_margin").height - - property bool is_connected: false //If current machine is connected to a printer. Only evaluated upon making popup visible. - onVisibleChanged: - { - is_connected = Cura.MachineManager.activeMachineNetworkKey !== "" && Cura.MachineManager.printerConnected //Re-evaluate. - } - - property int configuration_method: is_connected ? ConfigurationMenu.ConfigurationMethod.AUTO : ConfigurationMenu.ConfigurationMethod.CUSTOM //Auto if connected to a printer at start-up, or Custom if not. - - Item - { - width: parent.width - height: childrenRect.height - AutoConfiguration - { - id: autoConfiguration - visible: popupItem.configuration_method == ConfigurationMenu.ConfigurationMethod.AUTO - } - - CustomConfiguration - { - id: customConfiguration - visible: popupItem.configuration_method == ConfigurationMenu.ConfigurationMethod.CUSTOM - } - } - - Rectangle - { - id: separator - visible: buttonBar.visible - x: -popupPadding - - width: base.width - height: UM.Theme.getSize("default_lining").height - - color: UM.Theme.getColor("lining") - } - - //Allow switching between custom and auto. - Item - { - id: buttonBar - visible: popupItem.is_connected //Switching only makes sense if the "auto" part is possible. - - width: parent.width - height: childrenRect.height - - Cura.SecondaryButton - { - id: goToCustom - visible: popupItem.configuration_method == ConfigurationMenu.ConfigurationMethod.AUTO - text: catalog.i18nc("@label", "Custom") - - anchors.right: parent.right - - iconSource: UM.Theme.getIcon("arrow_right") - isIconOnRightSide: true - - onClicked: popupItem.configuration_method = ConfigurationMenu.ConfigurationMethod.CUSTOM - } - - Cura.SecondaryButton - { - id: goToAuto - visible: popupItem.configuration_method == ConfigurationMenu.ConfigurationMethod.CUSTOM - text: catalog.i18nc("@label", "Configurations") - - iconSource: UM.Theme.getIcon("arrow_left") - - onClicked: popupItem.configuration_method = ConfigurationMenu.ConfigurationMethod.AUTO - } - } - } -} diff --git a/resources/qml/Menus/ConfigurationMenu/CustomConfiguration.qml b/resources/qml/Menus/ConfigurationMenu/CustomConfiguration.qml deleted file mode 100644 index 8d8f84155a..0000000000 --- a/resources/qml/Menus/ConfigurationMenu/CustomConfiguration.qml +++ /dev/null @@ -1,250 +0,0 @@ -// Copyright (c) 2018 Ultimaker B.V. -// Cura is released under the terms of the LGPLv3 or higher. - -import QtQuick 2.6 -import QtQuick.Controls 2.0 -import QtQuick.Controls 1.1 as OldControls - -import Cura 1.0 as Cura -import UM 1.3 as UM - -Item -{ - UM.I18nCatalog - { - id: catalog - name: "cura" - } - - width: parent.width - height: childrenRect.height - - Label - { - id: header - text: catalog.i18nc("@header", "Custom") - font: UM.Theme.getFont("large") - color: UM.Theme.getColor("text") - height: contentHeight - renderType: Text.NativeRendering - - anchors - { - top: parent.top - left: parent.left - right: parent.right - } - } - - UM.TabRow - { - id: tabBar - anchors.top: header.bottom - anchors.topMargin: UM.Theme.getSize("default_margin").height - visible: extrudersModel.count > 1 - - Repeater - { - id: repeater - model: extrudersModel - delegate: UM.TabRowButton - { - contentItem: Item - { - Cura.ExtruderIcon - { - anchors.horizontalCenter: parent.horizontalCenter - materialColor: model.color - extruderEnabled: model.enabled - width: parent.height - height: parent.height - } - } - onClicked: - { - Cura.ExtruderManager.setActiveExtruderIndex(tabBar.currentIndex) - } - } - } - - //When active extruder changes for some other reason, switch tabs. - //Don't directly link currentIndex to Cura.ExtruderManager.activeExtruderIndex! - //This causes a segfault in Qt 5.11. Something with VisualItemModel removing index -1. We have to use setCurrentIndex instead. - Connections - { - target: Cura.ExtruderManager - onActiveExtruderChanged: - { - tabBar.setCurrentIndex(Cura.ExtruderManager.activeExtruderIndex); - } - } - - //When the model of the extruders is rebuilt, the list of extruders is briefly emptied and rebuilt. - //This causes the currentIndex of the tab to be in an invalid position which resets it to 0. - //Therefore we need to change it back to what it was: The active extruder index. - Connections - { - target: repeater.model - onModelChanged: - { - tabBar.setCurrentIndex(Cura.ExtruderManager.activeExtruderIndex) - } - } - } - - Rectangle - { - width: parent.width - height: childrenRect.height - anchors.top: tabBar.bottom - - radius: tabBar.visible ? UM.Theme.getSize("default_radius").width : 0 - border.width: tabBar.visible ? UM.Theme.getSize("default_lining").width : 0 - border.color: UM.Theme.getColor("lining") - color: UM.Theme.getColor("main_background") - - //Remove rounding and lining at the top. - Rectangle - { - width: parent.width - height: parent.radius - anchors.top: parent.top - color: UM.Theme.getColor("lining") - visible: tabBar.visible - Rectangle - { - anchors - { - left: parent.left - leftMargin: parent.parent.border.width - right: parent.right - rightMargin: parent.parent.border.width - top: parent.top - } - height: parent.parent.radius - color: parent.parent.color - } - } - - Column - { - id: selectors - padding: UM.Theme.getSize("default_margin").width - spacing: UM.Theme.getSize("default_margin").height - - property var model: extrudersModel.items[tabBar.currentIndex] - - readonly property real paddedWidth: parent.width - padding * 2 - property real textWidth: Math.round(paddedWidth * 0.3) - property real controlWidth: paddedWidth - textWidth - - Row - { - height: UM.Theme.getSize("print_setup_item").height - - Label - { - text: catalog.i18nc("@label", "Enabled") - verticalAlignment: Text.AlignVCenter - font: UM.Theme.getFont("default") - color: UM.Theme.getColor("text") - height: parent.height - width: selectors.textWidth - visible: extrudersModel.count > 1 - renderType: Text.NativeRendering - } - - OldControls.CheckBox - { - checked: Cura.MachineManager.activeStack != null ? Cura.MachineManager.activeStack.isEnabled : false - enabled: !checked || Cura.MachineManager.numberExtrudersEnabled > 1 //Disable if it's the last enabled extruder. - height: UM.Theme.getSize("setting_control").height - style: UM.Theme.styles.checkbox - visible: extrudersModel.count > 1 - - /* Use a MouseArea to process the click on this checkbox. - This is necessary because actually clicking the checkbox - causes the "checked" property to be overwritten. After - it's been overwritten, the original link that made it - depend on the active extruder stack is broken. */ - MouseArea - { - anchors.fill: parent - onClicked: Cura.MachineManager.setExtruderEnabled(Cura.ExtruderManager.activeExtruderIndex, !parent.checked) - enabled: parent.enabled - } - } - } - - Row - { - height: UM.Theme.getSize("print_setup_item").height - Label - { - text: catalog.i18nc("@label", "Material") - verticalAlignment: Text.AlignVCenter - font: UM.Theme.getFont("default") - color: UM.Theme.getColor("text") - height: parent.height - width: selectors.textWidth - visible: materialSelection.visible - renderType: Text.NativeRendering - } - - OldControls.ToolButton - { - id: materialSelection - - property bool valueError: Cura.MachineManager.activeStack != null ? Cura.ContainerManager.getContainerMetaDataEntry(Cura.MachineManager.activeStack.material.id, "compatible", "") != "True" : true - property bool valueWarning: !Cura.MachineManager.isActiveQualitySupported - - text: Cura.MachineManager.activeStack != null ? Cura.MachineManager.activeStack.material.name : "" - tooltip: text - visible: Cura.MachineManager.hasMaterials - - height: UM.Theme.getSize("setting_control").height - width: selectors.controlWidth - - style: UM.Theme.styles.sidebar_header_button - activeFocusOnPress: true - menu: Cura.MaterialMenu - { - extruderIndex: Cura.ExtruderManager.activeExtruderIndex - } - } - } - - Row - { - height: UM.Theme.getSize("print_setup_item").height - - Label - { - text: Cura.MachineManager.activeDefinitionVariantsName - verticalAlignment: Text.AlignVCenter - font: UM.Theme.getFont("default") - color: UM.Theme.getColor("text") - height: parent.height - width: selectors.textWidth - visible: variantSelection.visible - renderType: Text.NativeRendering - } - - OldControls.ToolButton - { - id: variantSelection - text: Cura.MachineManager.activeVariantName - tooltip: Cura.MachineManager.activeVariantName; - visible: Cura.MachineManager.hasVariants - - height: UM.Theme.getSize("setting_control").height - width: selectors.controlWidth - style: UM.Theme.styles.sidebar_header_button - activeFocusOnPress: true; - - menu: Cura.NozzleMenu { extruderIndex: Cura.ExtruderManager.activeExtruderIndex } - } - } - } - } -} diff --git a/resources/qml/Menus/ConfigurationMenu/PrintCoreConfiguration.qml b/resources/qml/Menus/ConfigurationMenu/PrintCoreConfiguration.qml index 885f02d740..73fc342d66 100644 --- a/resources/qml/Menus/ConfigurationMenu/PrintCoreConfiguration.qml +++ b/resources/qml/Menus/ConfigurationMenu/PrintCoreConfiguration.qml @@ -5,50 +5,87 @@ import QtQuick 2.7 import QtQuick.Controls 2.0 import UM 1.2 as UM -import Cura 1.0 as Cura -Row + +Column { id: extruderInfo property var printCoreConfiguration + property var mainColor: "black" + spacing: Math.round(UM.Theme.getSize("default_margin").height / 2) - height: information.height - spacing: UM.Theme.getSize("default_margin").width + height: childrenRect.height - //Extruder icon. - Cura.ExtruderIcon + Item { - materialColor: printCoreConfiguration.material.color - anchors.verticalCenter: parent.verticalCenter - extruderEnabled: printCoreConfiguration.material.name !== "" && printCoreConfiguration.hotendID !== "" + id: extruder + width: parent.width + height: childrenRect.height + + Label + { + id: extruderLabel + text: catalog.i18nc("@label:extruder label", "Extruder") + renderType: Text.NativeRendering + elide: Text.ElideRight + anchors.left: parent.left + font: UM.Theme.getFont("default") + color: mainColor + } + + // Rounded item to show the extruder number + Item + { + id: extruderIconItem + anchors.verticalCenter: extruderLabel.verticalCenter + anchors.left: extruderLabel.right + anchors.leftMargin: Math.round(UM.Theme.getSize("default_margin").width / 2) + + width: UM.Theme.getSize("section_icon").width + height: UM.Theme.getSize("section_icon").height + + UM.RecolorImage { + id: mainCircle + anchors.fill: parent + + anchors.centerIn: parent + sourceSize.width: parent.width + sourceSize.height: parent.height + source: UM.Theme.getIcon("extruder_button") + color: mainColor + } + + Label + { + id: extruderNumberText + anchors.centerIn: parent + text: printCoreConfiguration.position + 1 + renderType: Text.NativeRendering + font: UM.Theme.getFont("default") + color: mainColor + } + } } - Column + Label { - id: information - Label - { - text: printCoreConfiguration.material.brand ? printCoreConfiguration.material.brand : " " //Use space so that the height is still correct. - renderType: Text.NativeRendering - elide: Text.ElideRight - font: UM.Theme.getFont("default") - color: UM.Theme.getColor("text_inactive") - } - Label - { - text: printCoreConfiguration.material.name ? printCoreConfiguration.material.name : " " //Use space so that the height is still correct. - renderType: Text.NativeRendering - elide: Text.ElideRight - font: UM.Theme.getFont("default") - color: UM.Theme.getColor("text") - } - Label - { - text: printCoreConfiguration.hotendID ? printCoreConfiguration.hotendID : " " //Use space so that the height is still correct. - renderType: Text.NativeRendering - elide: Text.ElideRight - font: UM.Theme.getFont("default") - color: UM.Theme.getColor("text_inactive") - } + id: materialLabel + text: printCoreConfiguration.material == null ? "" : printCoreConfiguration.material.name + renderType: Text.NativeRendering + elide: Text.ElideRight + width: parent.width + font: UM.Theme.getFont("default_bold") + color: mainColor + } + + Label + { + id: printCoreTypeLabel + text: printCoreConfiguration.hotendID + renderType: Text.NativeRendering + elide: Text.ElideRight + width: parent.width + font: UM.Theme.getFont("default") + color: mainColor } } diff --git a/resources/qml/Menus/ConfigurationMenu/QuickConfigurationSelector.qml b/resources/qml/Menus/ConfigurationMenu/QuickConfigurationSelector.qml new file mode 100644 index 0000000000..eb6800cb36 --- /dev/null +++ b/resources/qml/Menus/ConfigurationMenu/QuickConfigurationSelector.qml @@ -0,0 +1,243 @@ +// Copyright (c) 2018 Ultimaker B.V. +// Cura is released under the terms of the LGPLv3 or higher. + +import QtQuick 2.7 +import QtQuick.Controls 2.0 +import QtQuick.Controls.Styles 1.4 +import QtQuick.Layouts 1.3 + +import QtQuick.Controls 1.1 as OldControls + +import UM 1.2 as UM +import Cura 1.0 as Cura + + +Cura.ExpandableComponent +{ + id: base + + Cura.ExtrudersModel + { + id: extrudersModel + } + + UM.I18nCatalog + { + id: catalog + name: "cura" + } + + iconSource: expanded ? UM.Theme.getIcon("arrow_bottom") : UM.Theme.getIcon("arrow_left") + headerItem: Item + { + // Horizontal list that shows the extruders + ListView + { + id: extrudersList + + orientation: ListView.Horizontal + anchors.fill: parent + model: extrudersModel + + delegate: Item + { + height: parent.height + width: Math.round(ListView.view.width / extrudersModel.rowCount()) + + // Extruder icon. Shows extruder index and has the same color as the active material. + Cura.ExtruderIcon + { + id: extruderIcon + materialColor: model.color + extruderEnabled: model.enabled + anchors.verticalCenter: parent.verticalCenter + } + + // Label for the brand of the material + Label + { + id: brandNameLabel + + text: model.material_brand + elide: Text.ElideRight + font: UM.Theme.getFont("default") + color: UM.Theme.getColor("text") + + anchors + { + left: extruderIcon.right + leftMargin: UM.Theme.getSize("default_margin").width + right: parent.right + rightMargin: UM.Theme.getSize("default_margin").width + } + } + + // Label that shows the name of the material + Label + { + text: model.material + elide: Text.ElideRight + font: UM.Theme.getFont("default") + color: UM.Theme.getColor("text") + + anchors + { + left: extruderIcon.right + leftMargin: UM.Theme.getSize("default_margin").width + right: parent.right + rightMargin: UM.Theme.getSize("default_margin").width + top: brandNameLabel.bottom + } + } + } + } + } + + popupItem: Item + { + width: base.width - 2 * UM.Theme.getSize("default_margin").width + height: 200 + + TabBar + { + id: tabBar + onCurrentIndexChanged: Cura.ExtruderManager.setActiveExtruderIndex(currentIndex) + width: parent.width + height: 50 + Repeater + { + model: extrudersModel + + delegate: TabButton + { + width: ListView.view != null ? Math.round(ListView.view.width / extrudersModel.rowCount()): 0 + height: parent.height + contentItem: Item + { + Cura.ExtruderIcon + { + anchors.horizontalCenter: parent.horizontalCenter + materialColor: model.color + extruderEnabled: model.enabled + width: parent.height + height: parent.height + } + } + } + } + } + + Item + { + id: tabControl + width: parent.width + anchors.top: tabBar.bottom + anchors.bottom: parent.bottom + property var model: extrudersModel.items[tabBar.currentIndex] + property real textWidth: Math.round(width * 0.3) + property real controlWidth: width - textWidth + Column + { + spacing: UM.Theme.getSize("default_margin").height + Row + { + height: UM.Theme.getSize("print_setup_item").height + + Label + { + text: catalog.i18nc("@label", "Enabled") + verticalAlignment: Text.AlignVCenter + font: UM.Theme.getFont("default") + color: UM.Theme.getColor("text") + height: parent.height + width: tabControl.textWidth + renderType: Text.NativeRendering + } + + OldControls.CheckBox + { + checked: tabControl.model != null ? Cura.MachineManager.getExtruder(tabControl.model.index).isEnabled: false + onClicked: Cura.MachineManager.setExtruderEnabled(tabControl.model.index, checked) + height: UM.Theme.getSize("setting_control").height + style: UM.Theme.styles.checkbox + } + } + + Row + { + height: UM.Theme.getSize("print_setup_item").height + Label + { + text: catalog.i18nc("@label", "Material") + verticalAlignment: Text.AlignVCenter + font: UM.Theme.getFont("default") + color: UM.Theme.getColor("text") + height: parent.height + width: tabControl.textWidth + renderType: Text.NativeRendering + } + + OldControls.ToolButton + { + id: materialSelection + + property var activeExtruder: Cura.MachineManager.activeStack + property var hasActiveExtruder: activeExtruder != null + property var currentRootMaterialName: hasActiveExtruder ? activeExtruder.material.name : "" + property var valueError: hasActiveExtruder ? Cura.ContainerManager.getContainerMetaDataEntry(activeExtruder.material.id, "compatible", "") != "True" : true + property var valueWarning: ! Cura.MachineManager.isActiveQualitySupported + + text: currentRootMaterialName + tooltip: currentRootMaterialName + visible: Cura.MachineManager.hasMaterials + + enabled: Cura.ExtruderManager.activeExtruderIndex > -1 + + height: UM.Theme.getSize("setting_control").height + width: tabControl.controlWidth + + style: UM.Theme.styles.sidebar_header_button + activeFocusOnPress: true + menu: Cura.MaterialMenu + { + extruderIndex: Cura.ExtruderManager.activeExtruderIndex + } + + } + } + + Row + { + height: UM.Theme.getSize("print_setup_item").height + + Label + { + text: Cura.MachineManager.activeDefinitionVariantsName + verticalAlignment: Text.AlignVCenter + font: UM.Theme.getFont("default") + color: UM.Theme.getColor("text") + height: parent.height + width: tabControl.textWidth + renderType: Text.NativeRendering + } + + OldControls.ToolButton + { + id: variantSelection + text: Cura.MachineManager.activeVariantName + tooltip: Cura.MachineManager.activeVariantName; + visible: Cura.MachineManager.hasVariants + + height: UM.Theme.getSize("setting_control").height + width: tabControl.controlWidth + style: UM.Theme.styles.sidebar_header_button + activeFocusOnPress: true; + + menu: Cura.NozzleMenu { extruderIndex: Cura.ExtruderManager.activeExtruderIndex } + } + } + } + + } + } +} diff --git a/resources/qml/Menus/ConfigurationMenu/SyncButton.qml b/resources/qml/Menus/ConfigurationMenu/SyncButton.qml new file mode 100644 index 0000000000..558ae1e477 --- /dev/null +++ b/resources/qml/Menus/ConfigurationMenu/SyncButton.qml @@ -0,0 +1,102 @@ +// Copyright (c) 2018 Ultimaker B.V. +// Cura is released under the terms of the LGPLv3 or higher. + +import QtQuick 2.7 +import QtQuick.Controls 1.4 +import QtQuick.Controls.Styles 1.4 + +import UM 1.2 as UM +import Cura 1.0 as Cura + +Button +{ + id: base + property var outputDevice: null + property var matched: updateOnSync() + text: matched == true ? catalog.i18nc("@label:extruder label", "Yes") : catalog.i18nc("@label:extruder label", "No") + width: parent.width + height: parent.height + + function updateOnSync() + { + if (outputDevice != undefined) + { + for (var index in outputDevice.uniqueConfigurations) + { + var configuration = outputDevice.uniqueConfigurations[index] + if (Cura.MachineManager.matchesConfiguration(configuration)) + { + base.matched = true; + return; + } + } + } + base.matched = false; + } + + style: ButtonStyle + { + background: Rectangle + { + color: + { + if(control.pressed) + { + return UM.Theme.getColor("machine_selector_active"); + } + else if(control.hovered) + { + return UM.Theme.getColor("machine_selector_hover"); + } + else + { + return UM.Theme.getColor("machine_selector_bar"); + } + } + Behavior on color { ColorAnimation { duration: 50; } } + + UM.RecolorImage + { + id: downArrow + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + anchors.rightMargin: UM.Theme.getSize("default_margin").width + width: UM.Theme.getSize("standard_arrow").width + height: UM.Theme.getSize("standard_arrow").height + sourceSize.width: width + sourceSize.height: height + color: UM.Theme.getColor("text_emphasis") + source: UM.Theme.getIcon("arrow_bottom") + } + UM.RecolorImage + { + id: sidebarComboBoxLabel + anchors.left: parent.left + anchors.leftMargin: UM.Theme.getSize("default_margin").width + anchors.verticalCenter: parent.verticalCenter; + + width: UM.Theme.getSize("printer_sync_icon").width + height: UM.Theme.getSize("printer_sync_icon").height + + color: control.matched ? UM.Theme.getColor("printer_config_matched") : UM.Theme.getColor("printer_config_mismatch") + source: UM.Theme.getIcon("tab_status_connected") + sourceSize.width: width + sourceSize.height: height + } + } + label: Label {} + } + + Connections + { + target: outputDevice + onUniqueConfigurationsChanged: updateOnSync() + } + + Connections + { + target: Cura.MachineManager + onCurrentConfigurationChanged: updateOnSync() + onOutputDevicesChanged: updateOnSync() + } +} \ No newline at end of file diff --git a/resources/qml/Menus/ProfileMenu.qml b/resources/qml/Menus/ProfileMenu.qml index bf950aa409..fd46d2ef72 100644 --- a/resources/qml/Menus/ProfileMenu.qml +++ b/resources/qml/Menus/ProfileMenu.qml @@ -37,7 +37,7 @@ Menu MenuSeparator { id: customSeparator - visible: Cura.CustomQualityProfilesDropDownMenuModel.count > 0 + visible: Cura.CustomQualityProfilesDropDownMenuModel.rowCount > 0 } Instantiator @@ -48,7 +48,7 @@ Menu Connections { target: Cura.CustomQualityProfilesDropDownMenuModel - onModelReset: customSeparator.visible = Cura.CustomQualityProfilesDropDownMenuModel.count > 0 + onModelReset: customSeparator.visible = Cura.CustomQualityProfilesDropDownMenuModel.rowCount() > 0 } MenuItem @@ -62,12 +62,12 @@ Menu onObjectAdded: { - customSeparator.visible = model.count > 0; + customSeparator.visible = model.rowCount() > 0; menu.insertItem(index, object); } onObjectRemoved: { - customSeparator.visible = model.count > 0; + customSeparator.visible = model.rowCount() > 0; menu.removeItem(object); } } diff --git a/resources/qml/ObjectsList.qml b/resources/qml/ObjectsList.qml index 8f45b3744f..8c8eaa16ae 100644 --- a/resources/qml/ObjectsList.qml +++ b/resources/qml/ObjectsList.qml @@ -1,4 +1,4 @@ -// Copyright (c) 2018 Ultimaker B.V. +// Copyright (c) 2017 Ultimaker B.V. // Cura is released under the terms of the LGPLv3 or higher. import QtQuick 2.2 @@ -55,6 +55,7 @@ Rectangle { width: control.width height: control.height + sourceSize.width: width sourceSize.height: width color: UM.Theme.getColor("setting_control_text") source: collapsed ? UM.Theme.getIcon("arrow_left") : UM.Theme.getIcon("arrow_bottom") diff --git a/resources/qml/Preferences/MachinesPage.qml b/resources/qml/Preferences/MachinesPage.qml index bc75b9bc72..4dc5465dc6 100644 --- a/resources/qml/Preferences/MachinesPage.qml +++ b/resources/qml/Preferences/MachinesPage.qml @@ -21,10 +21,8 @@ UM.ManagementPage function activeMachineIndex() { - for(var i = 0; i < model.count; i++) - { - if (model.getItem(i).id == Cura.MachineManager.activeMachineId) - { + for(var i = 0; i < model.rowCount(); i++) { + if (model.getItem(i).id == Cura.MachineManager.activeMachineId) { return i; } } @@ -49,7 +47,7 @@ UM.ManagementPage { text: catalog.i18nc("@action:button", "Remove"); iconName: "list-remove"; - enabled: base.currentItem != null && model.count > 1 + enabled: base.currentItem != null && model.rowCount() > 1 onClicked: confirmDialog.open(); }, Button diff --git a/resources/qml/Preferences/Materials/MaterialsBrandSection.qml b/resources/qml/Preferences/Materials/MaterialsBrandSection.qml index a3a0e4708f..c8f391dfb0 100644 --- a/resources/qml/Preferences/Materials/MaterialsBrandSection.qml +++ b/resources/qml/Preferences/Materials/MaterialsBrandSection.qml @@ -55,8 +55,7 @@ Rectangle text: "" implicitWidth: UM.Theme.getSize("favorites_button").width implicitHeight: UM.Theme.getSize("favorites_button").height - UM.RecolorImage - { + UM.RecolorImage { anchors { verticalCenter: parent.verticalCenter @@ -64,6 +63,8 @@ Rectangle } width: UM.Theme.getSize("standard_arrow").width height: UM.Theme.getSize("standard_arrow").height + sourceSize.width: width + sourceSize.height: height color: "black" source: brand_section.expanded ? UM.Theme.getIcon("arrow_bottom") : UM.Theme.getIcon("arrow_left") } diff --git a/resources/qml/Preferences/Materials/MaterialsList.qml b/resources/qml/Preferences/Materials/MaterialsList.qml index 61f92db84c..00bead9650 100644 --- a/resources/qml/Preferences/Materials/MaterialsList.qml +++ b/resources/qml/Preferences/Materials/MaterialsList.qml @@ -57,7 +57,7 @@ Item var currentItemId = base.currentItem == null ? "" : base.currentItem.root_material_id search_root_id = currentItemId } - for (var material_idx = 0; material_idx < genericMaterialsModel.count; material_idx++) + for (var material_idx = 0; material_idx < genericMaterialsModel.rowCount(); material_idx++) { var material = genericMaterialsModel.getItem(material_idx) if (material.root_material_id == search_root_id) @@ -72,15 +72,15 @@ Item return true } } - for (var brand_idx = 0; brand_idx < materialsModel.count; brand_idx++) + for (var brand_idx = 0; brand_idx < materialsModel.rowCount(); brand_idx++) { var brand = materialsModel.getItem(brand_idx) var types_model = brand.material_types - for (var type_idx = 0; type_idx < types_model.count; type_idx++) + for (var type_idx = 0; type_idx < types_model.rowCount(); type_idx++) { var type = types_model.getItem(type_idx) var colors_model = type.colors - for (var material_idx = 0; material_idx < colors_model.count; material_idx++) + for (var material_idx = 0; material_idx < colors_model.rowCount(); material_idx++) { var material = colors_model.getItem(material_idx) if (material.root_material_id == search_root_id) diff --git a/resources/qml/Preferences/Materials/MaterialsSlot.qml b/resources/qml/Preferences/Materials/MaterialsSlot.qml index a706aaf2b9..a5af17f47a 100644 --- a/resources/qml/Preferences/Materials/MaterialsSlot.qml +++ b/resources/qml/Preferences/Materials/MaterialsSlot.qml @@ -95,6 +95,8 @@ Rectangle } width: UM.Theme.getSize("favorites_button_icon").width height: UM.Theme.getSize("favorites_button_icon").height + sourceSize.width: width + sourceSize.height: height color: { if (favorite_button.hovered) diff --git a/resources/qml/Preferences/Materials/MaterialsTypeSection.qml b/resources/qml/Preferences/Materials/MaterialsTypeSection.qml index f98c19e0b3..f62fc4ee16 100644 --- a/resources/qml/Preferences/Materials/MaterialsTypeSection.qml +++ b/resources/qml/Preferences/Materials/MaterialsTypeSection.qml @@ -74,6 +74,8 @@ Rectangle } width: UM.Theme.getSize("standard_arrow").width height: UM.Theme.getSize("standard_arrow").height + sourceSize.width: width + sourceSize.height: height color: "black" source: material_type_section.expanded ? UM.Theme.getIcon("arrow_bottom") : UM.Theme.getIcon("arrow_left") } diff --git a/resources/qml/Preferences/ProfilesPage.qml b/resources/qml/Preferences/ProfilesPage.qml index d7ffbb3152..ba0c2848a5 100644 --- a/resources/qml/Preferences/ProfilesPage.qml +++ b/resources/qml/Preferences/ProfilesPage.qml @@ -188,27 +188,21 @@ Item Connections { target: qualitiesModel - onItemsChanged: - { + onItemsChanged: { var toSelectItemName = base.currentItem == null ? "" : base.currentItem.name; - if (newQualityNameToSelect != "") - { + if (newQualityNameToSelect != "") { toSelectItemName = newQualityNameToSelect; } var newIdx = -1; // Default to nothing if nothing can be found - if (toSelectItemName != "") - { + if (toSelectItemName != "") { // Select the required quality name if given - for (var idx = 0; idx < qualitiesModel.count; ++idx) - { + for (var idx = 0; idx < qualitiesModel.rowCount(); ++idx) { var item = qualitiesModel.getItem(idx); - if (item.name == toSelectItemName) - { + if (item.name == toSelectItemName) { // Switch to the newly created profile if needed newIdx = idx; - if (base.toActivateNewQuality) - { + if (base.toActivateNewQuality) { // Activate this custom quality if required Cura.MachineManager.setQualityChangesGroup(item.quality_changes_group); } @@ -388,11 +382,9 @@ Item var selectedItemName = Cura.MachineManager.activeQualityOrQualityChangesName; // Select the required quality name if given - for (var idx = 0; idx < qualitiesModel.count; idx++) - { + for (var idx = 0; idx < qualitiesModel.rowCount(); idx++) { var item = qualitiesModel.getItem(idx); - if (item.name == selectedItemName) - { + if (item.name == selectedItemName) { currentIndex = idx; break; } diff --git a/resources/qml/Preferences/SettingVisibilityPage.qml b/resources/qml/Preferences/SettingVisibilityPage.qml index 3f7571a170..2edbeee960 100644 --- a/resources/qml/Preferences/SettingVisibilityPage.qml +++ b/resources/qml/Preferences/SettingVisibilityPage.qml @@ -50,7 +50,7 @@ UM.PreferencesPage { return Qt.Unchecked } - else if(definitionsModel.visibleCount == definitionsModel.count) + else if(definitionsModel.visibleCount == definitionsModel.rowCount(null)) { return Qt.Checked } diff --git a/resources/qml/PrinterOutput/ExtruderBox.qml b/resources/qml/PrinterOutput/ExtruderBox.qml index 247bb3a27d..f5a1bd75c4 100644 --- a/resources/qml/PrinterOutput/ExtruderBox.qml +++ b/resources/qml/PrinterOutput/ExtruderBox.qml @@ -47,7 +47,7 @@ Item { id: extruderTargetTemperature text: Math.round(extruderModel.targetHotendTemperature) + "°C" - font: UM.Theme.getFont("default_bold") + font: UM.Theme.getFont("small") color: UM.Theme.getColor("text_inactive") anchors.right: parent.right anchors.rightMargin: UM.Theme.getSize("default_margin").width diff --git a/resources/qml/PrinterOutput/HeatedBedBox.qml b/resources/qml/PrinterOutput/HeatedBedBox.qml index 33cf5cd1e2..8c99814e02 100644 --- a/resources/qml/PrinterOutput/HeatedBedBox.qml +++ b/resources/qml/PrinterOutput/HeatedBedBox.qml @@ -35,7 +35,7 @@ Item { id: bedTargetTemperature text: printerModel != null ? printerModel.targetBedTemperature + "°C" : "" - font: UM.Theme.getFont("default_bold") + font: UM.Theme.getFont("small") color: UM.Theme.getColor("text_inactive") anchors.right: parent.right anchors.rightMargin: UM.Theme.getSize("default_margin").width diff --git a/resources/qml/PrinterOutput/OutputDeviceHeader.qml b/resources/qml/PrinterOutput/OutputDeviceHeader.qml index 16280eab5f..e6328546ef 100644 --- a/resources/qml/PrinterOutput/OutputDeviceHeader.qml +++ b/resources/qml/PrinterOutput/OutputDeviceHeader.qml @@ -43,7 +43,7 @@ Item { id: outputDeviceAddressLabel text: (outputDevice != null && outputDevice.address != null) ? outputDevice.address : "" - font: UM.Theme.getFont("default_bold") + font: UM.Theme.getFont("small") color: UM.Theme.getColor("text_inactive") anchors.top: outputDeviceNameLabel.bottom anchors.left: parent.left @@ -54,7 +54,7 @@ Item { text: outputDevice != null ? "" : catalog.i18nc("@info:status", "The printer is not connected.") color: outputDevice != null && outputDevice.acceptsCommands ? UM.Theme.getColor("setting_control_text") : UM.Theme.getColor("setting_control_disabled_text") - font: UM.Theme.getFont("default") + font: UM.Theme.getFont("very_small") wrapMode: Text.WordWrap anchors.left: parent.left anchors.leftMargin: UM.Theme.getSize("default_margin").width diff --git a/resources/qml/PrinterSelector/MachineSelector.qml b/resources/qml/PrinterSelector/MachineSelector.qml index 95abfd6644..15cd773c90 100644 --- a/resources/qml/PrinterSelector/MachineSelector.qml +++ b/resources/qml/PrinterSelector/MachineSelector.qml @@ -25,52 +25,29 @@ Cura.ExpandableComponent name: "cura" } - headerItem: Item + headerItem: Cura.IconLabel { - implicitHeight: icon.height + text: isNetworkPrinter ? Cura.MachineManager.activeMachineNetworkGroupName : Cura.MachineManager.activeMachineName + source: + { + if (isNetworkPrinter) + { + if (machineSelector.outputDevice != null && machineSelector.outputDevice.clusterSize > 1) + { + return UM.Theme.getIcon("printer_group") + } + return UM.Theme.getIcon("printer_single") + } + return "" + } + font: UM.Theme.getFont("medium") + color: UM.Theme.getColor("text") + iconSize: UM.Theme.getSize("machine_selector_icon").width UM.RecolorImage { id: icon - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - - source: - { - if (isNetworkPrinter) - { - if (machineSelector.outputDevice != null && machineSelector.outputDevice.clusterSize > 1) - { - return UM.Theme.getIcon("printer_group") - } - return UM.Theme.getIcon("printer_single") - } - return "" - } - width: UM.Theme.getSize("machine_selector_icon").width - height: width - - color: UM.Theme.getColor("machine_selector_printer_icon") - visible: source != "" - } - - Label - { - id: label - anchors.left: icon.visible ? icon.right : parent.left - anchors.right: parent.right - anchors.leftMargin: UM.Theme.getSize("thin_margin").width - anchors.verticalCenter: icon.verticalCenter - text: isNetworkPrinter ? Cura.MachineManager.activeMachineNetworkGroupName : Cura.MachineManager.activeMachineName - elide: Text.ElideRight - color: UM.Theme.getColor("text") - font: UM.Theme.getFont("medium") - renderType: Text.NativeRendering - } - - UM.RecolorImage - { anchors { bottom: parent.bottom @@ -82,6 +59,9 @@ Cura.ExpandableComponent width: UM.Theme.getSize("printer_status_icon").width height: UM.Theme.getSize("printer_status_icon").height + sourceSize.width: width + sourceSize.height: height + color: UM.Theme.getColor("primary") visible: isNetworkPrinter && isPrinterConnected diff --git a/resources/qml/PrinterSelector/MachineSelectorList.qml b/resources/qml/PrinterSelector/MachineSelectorList.qml index d831f4eb5c..445940ab50 100644 --- a/resources/qml/PrinterSelector/MachineSelectorList.qml +++ b/resources/qml/PrinterSelector/MachineSelectorList.qml @@ -13,7 +13,7 @@ Column Label { - text: catalog.i18nc("@label", "Connected printers") + text: catalog.i18nc("@label", "Network connected printers") visible: networkedPrintersModel.items.length > 0 leftPadding: UM.Theme.getSize("default_margin").width height: visible ? contentHeight + 2 * UM.Theme.getSize("default_margin").height : 0 diff --git a/resources/qml/PrinterTypeLabel.qml b/resources/qml/PrinterSelector/PrinterTypeLabel.qml similarity index 95% rename from resources/qml/PrinterTypeLabel.qml rename to resources/qml/PrinterSelector/PrinterTypeLabel.qml index 7feae32e16..cd9f3b9743 100644 --- a/resources/qml/PrinterTypeLabel.qml +++ b/resources/qml/PrinterSelector/PrinterTypeLabel.qml @@ -28,7 +28,7 @@ Item anchors.verticalCenter: parent.verticalCenter anchors.horizontalCenter: parent.horizontalCenter renderType: Text.NativeRendering - font: UM.Theme.getFont("default") + font: UM.Theme.getFont("very_small") color: UM.Theme.getColor("text") } } \ No newline at end of file diff --git a/resources/qml/Settings/SettingCategory.qml b/resources/qml/Settings/SettingCategory.qml index 196b2d6b97..aafe36c546 100644 --- a/resources/qml/Settings/SettingCategory.qml +++ b/resources/qml/Settings/SettingCategory.qml @@ -129,26 +129,23 @@ Button anchors.rightMargin: UM.Theme.getSize("default_margin").width width: UM.Theme.getSize("standard_arrow").width height: UM.Theme.getSize("standard_arrow").height + sourceSize.width: width sourceSize.height: width color: { if (!base.enabled) { return UM.Theme.getColor("setting_category_disabled_text") - } - else if ((base.hovered || base.activeFocus) && base.checkable && base.checked) + } else if ((base.hovered || base.activeFocus) && base.checkable && base.checked) { return UM.Theme.getColor("setting_category_active_hover_text") - } - else if (base.pressed || (base.checkable && base.checked)) + } else if (base.pressed || (base.checkable && base.checked)) { return UM.Theme.getColor("setting_category_active_text") - } - else if (base.hovered || base.activeFocus) + } else if (base.hovered || base.activeFocus) { return UM.Theme.getColor("setting_category_hover_text") - } - else + } else { return UM.Theme.getColor("setting_category_text") } diff --git a/resources/qml/Settings/SettingCheckBox.qml b/resources/qml/Settings/SettingCheckBox.qml index fb2d5a2f4d..d37754d27c 100644 --- a/resources/qml/Settings/SettingCheckBox.qml +++ b/resources/qml/Settings/SettingCheckBox.qml @@ -115,12 +115,12 @@ SettingItem return UM.Theme.getColor("setting_control_border") } - UM.RecolorImage - { + UM.RecolorImage { anchors.verticalCenter: parent.verticalCenter anchors.horizontalCenter: parent.horizontalCenter width: Math.round(parent.width / 2.5) height: Math.round(parent.height / 2.5) + sourceSize.width: width sourceSize.height: width color: !enabled ? UM.Theme.getColor("setting_control_disabled_text") : UM.Theme.getColor("setting_control_text"); source: UM.Theme.getIcon("check") diff --git a/resources/qml/Settings/SettingOptionalExtruder.qml b/resources/qml/Settings/SettingOptionalExtruder.qml index 5f0d8327f8..a3c1422b30 100644 --- a/resources/qml/Settings/SettingOptionalExtruder.qml +++ b/resources/qml/Settings/SettingOptionalExtruder.qml @@ -1,5 +1,5 @@ -// Copyright (c) 2018 Ultimaker B.V. -// Cura is released under the terms of the LGPLv3 or higher. +// Copyright (c) 2016 Ultimaker B.V. +// Uranium is released under the terms of the LGPLv3 or higher. import QtQuick 2.7 import QtQuick.Controls 2.0 @@ -31,15 +31,12 @@ SettingItem { forceActiveFocus(); propertyProvider.setPropertyValue("value", model.getItem(index).index); - } - else + } else { if (propertyProvider.properties.value == -1) { - control.currentIndex = model.count - 1; // we know the last item is "Not overriden" - } - else - { + control.currentIndex = model.rowCount() - 1; // we know the last item is "Not overriden" + } else { control.currentIndex = propertyProvider.properties.value; // revert to the old value } } diff --git a/resources/qml/Settings/SettingView.qml b/resources/qml/Settings/SettingView.qml index bb624bcbde..ef1f123953 100644 --- a/resources/qml/Settings/SettingView.qml +++ b/resources/qml/Settings/SettingView.qml @@ -1,5 +1,5 @@ -// Copyright (c) 2018 Ultimaker B.V. -// Cura is released under the terms of the LGPLv3 or higher. +// Copyright (c) 2017 Ultimaker B.V. +// Uranium is released under the terms of the LGPLv3 or higher. import QtQuick 2.7 import QtQuick.Controls 1.1 @@ -129,14 +129,13 @@ Item } style: ButtonStyle { - background: Item - { - UM.RecolorImage - { + background: Item { + UM.RecolorImage { anchors.verticalCenter: parent.verticalCenter anchors.horizontalCenter: parent.horizontalCenter width: UM.Theme.getSize("standard_arrow").width height: UM.Theme.getSize("standard_arrow").height + sourceSize.width: width sourceSize.height: width color: control.enabled ? UM.Theme.getColor("setting_category_text") : UM.Theme.getColor("setting_category_disabled_text") source: UM.Theme.getIcon("menu") diff --git a/resources/qml/SidebarSimple.qml b/resources/qml/SidebarSimple.qml index fb4d52979d..5e723a3d70 100644 --- a/resources/qml/SidebarSimple.qml +++ b/resources/qml/SidebarSimple.qml @@ -106,7 +106,7 @@ Item var availableMin = -1 var availableMax = -1 - for (var i = 0; i < Cura.QualityProfilesDropDownMenuModel.count; i++) + for (var i = 0; i < Cura.QualityProfilesDropDownMenuModel.rowCount(); i++) { var qualityItem = Cura.QualityProfilesDropDownMenuModel.getItem(i) @@ -183,7 +183,7 @@ Item qualityModel.existingQualityProfile = 0 // check, the ticks count cannot be less than zero - qualityModel.totalTicks = Math.max(0, Cura.QualityProfilesDropDownMenuModel.count - 1) + qualityModel.totalTicks = Math.max(0, Cura.QualityProfilesDropDownMenuModel.rowCount() - 1) } } @@ -731,6 +731,7 @@ Item { anchors.fill: parent anchors.margins: 2 * screenScaleFactor + sourceSize.width: width sourceSize.height: width source: UM.Theme.getIcon(model.icon) color: UM.Theme.getColor("quality_slider_unavailable") @@ -1155,7 +1156,7 @@ Item function populateExtruderModel() { extruderModel.clear(); - for(var extruderNumber = 0; extruderNumber < extruders.count; extruderNumber++) + for(var extruderNumber = 0; extruderNumber < extruders.rowCount() ; extruderNumber++) { extruderModel.append({ text: extruders.getItem(extruderNumber).name, diff --git a/resources/qml/Toolbar.qml b/resources/qml/Toolbar.qml index 1e335472d4..5fbddea9ac 100644 --- a/resources/qml/Toolbar.qml +++ b/resources/qml/Toolbar.qml @@ -62,7 +62,7 @@ Item enabled: model.enabled && UM.Selection.hasSelection && UM.Controller.toolsEnabled isTopElement: toolsModel.getItem(0).id == model.id - isBottomElement: toolsModel.getItem(toolsModel.count - 1).id == model.id + isBottomElement: toolsModel.getItem(toolsModel.rowCount() - 1).id == model.id toolItem: UM.RecolorImage { diff --git a/resources/qml/ViewsSelector.qml b/resources/qml/ViewsSelector.qml index 1e42a0b3ba..e9fdd57177 100644 --- a/resources/qml/ViewsSelector.qml +++ b/resources/qml/ViewsSelector.qml @@ -19,7 +19,7 @@ Cura.ExpandableComponent property var activeView: { - for (var i = 0; i < viewModel.count; i++) + for (var i = 0; i < viewModel.rowCount(); i++) { if (viewModel.items[i].active) { @@ -74,8 +74,6 @@ Cura.ExpandableComponent { id: viewSelectorPopup width: viewSelector.width - 2 * viewSelector.popupPadding - leftPadding: UM.Theme.getSize("default_lining").width - rightPadding: UM.Theme.getSize("default_lining").width // For some reason the height/width of the column gets set to 0 if this is not set... Component.onCompleted: @@ -93,7 +91,7 @@ Cura.ExpandableComponent { id: viewsSelectorButton text: model.name - width: parent.width - viewSelectorPopup.leftPadding - viewSelectorPopup.rightPadding + width: parent.width height: UM.Theme.getSize("action_button").height leftPadding: UM.Theme.getSize("default_margin").width rightPadding: UM.Theme.getSize("default_margin").width diff --git a/resources/qml/qmldir b/resources/qml/qmldir index 7e57119bc6..2475f398f8 100644 --- a/resources/qml/qmldir +++ b/resources/qml/qmldir @@ -1,6 +1,7 @@ module Cura MachineSelector 1.0 MachineSelector.qml +QuickConfigurationSelector 1.0 QuickConfigurationSelector.qml CustomConfigurationSelector 1.0 CustomConfigurationSelector.qml PrintSetupSelector 1.0 PrintSetupSelector.qml ActionButton 1.0 ActionButton.qml diff --git a/resources/themes/cura-dark/theme.json b/resources/themes/cura-dark/theme.json index d9ef74ebb9..34b944b25b 100644 --- a/resources/themes/cura-dark/theme.json +++ b/resources/themes/cura-dark/theme.json @@ -15,7 +15,7 @@ "primary_hover": [48, 182, 231, 255], "primary_text": [255, 255, 255, 204], "border": [127, 127, 127, 255], - "secondary": [95, 95, 95, 255], + "secondary": [241, 242, 242, 255], "main_window_header_button_text_inactive": [128, 128, 128, 255], "main_window_header_button_text_hovered": [255, 255, 255, 255], @@ -196,6 +196,14 @@ "layerview_support_interface": [64, 192, 255, 255], "layerview_nozzle": [181, 166, 66, 120], + "configuration_item": [0, 0, 0, 0], + "configuration_item_active": [12, 169, 227, 179], + "configuration_item_text": [255, 255, 255, 255], + "configuration_item_text_active": [255, 255, 255, 255], + "configuration_item_border": [255, 255, 255, 255], + "configuration_item_border_active": [12, 169, 227, 179], + "configuration_item_border_hover": [12, 169, 227, 179], + "material_compatibility_warning": [255, 255, 255, 255], "quality_slider_unavailable": [179, 179, 179, 255], diff --git a/resources/themes/cura-light/images/header_pattern.svg b/resources/themes/cura-light/images/header_pattern.svg index eff5f01cfa..2a9de2f3e9 100644 --- a/resources/themes/cura-light/images/header_pattern.svg +++ b/resources/themes/cura-light/images/header_pattern.svg @@ -1,1901 +1 @@ - - - - Desktop HD - Created with Sketch. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file +Pattern \ No newline at end of file diff --git a/resources/themes/cura-light/images/logo_about.svg b/resources/themes/cura-light/images/logo_about.svg deleted file mode 100644 index 34301fd6c9..0000000000 --- a/resources/themes/cura-light/images/logo_about.svg +++ /dev/null @@ -1,172 +0,0 @@ - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/resources/themes/cura-light/styles.qml b/resources/themes/cura-light/styles.qml index 30cf42859a..f2ad2b6f4a 100755 --- a/resources/themes/cura-light/styles.qml +++ b/resources/themes/cura-light/styles.qml @@ -73,6 +73,7 @@ QtObject anchors.rightMargin: Theme.getSize("default_margin").width width: Theme.getSize("standard_arrow").width height: Theme.getSize("standard_arrow").height + sourceSize.width: width sourceSize.height: width color: control.enabled ? Theme.getColor("setting_category_text") : Theme.getColor("setting_category_disabled_text") source: Theme.getIcon("arrow_bottom") @@ -145,7 +146,7 @@ QtObject text: control.text anchors.verticalCenter: parent.verticalCenter anchors.horizontalCenter: parent.horizontalCenter - font: UM.Theme.getFont("medium") + font: UM.Theme.getFont("medium_bold") color: { if (control.checked) @@ -256,6 +257,7 @@ QtObject anchors.bottomMargin: Theme.getSize("button").height - Math.round(Theme.getSize("button_icon").height / 4) width: Theme.getSize("standard_arrow").width height: Theme.getSize("standard_arrow").height + sourceSize.width: width sourceSize.height: width visible: control.menu != null; color: @@ -527,7 +529,7 @@ QtObject implicitWidth: Theme.getSize("checkbox").width implicitHeight: Theme.getSize("checkbox").height - color: (control.hovered || control._hovered) ? Theme.getColor("checkbox_hover") : (control.enabled ? Theme.getColor("checkbox") : Theme.getColor("checkbox_disabled")) + color: (control.hovered || control._hovered) ? Theme.getColor("checkbox_hover") : Theme.getColor("checkbox") Behavior on color { ColorAnimation { duration: 50; } } radius: control.exclusiveGroup ? Math.round(Theme.getSize("checkbox").width / 2) : 0 @@ -541,6 +543,7 @@ QtObject anchors.horizontalCenter: parent.horizontalCenter width: Math.round(parent.width / 2.5) height: Math.round(parent.height / 2.5) + sourceSize.width: width sourceSize.height: width color: Theme.getColor("checkbox_mark") source: control.exclusiveGroup ? Theme.getIcon("dot") : Theme.getIcon("check") @@ -582,6 +585,7 @@ QtObject anchors.horizontalCenter: parent.horizontalCenter width: Math.round(parent.width / 2.5) height: Math.round(parent.height / 2.5) + sourceSize.width: width sourceSize.height: width color: Theme.getColor("checkbox_mark") source: @@ -832,6 +836,7 @@ QtObject anchors.horizontalCenter: parent.horizontalCenter width: Math.floor(control.width / 2) height: Math.floor(control.height / 2) + sourceSize.width: width sourceSize.height: width color: { diff --git a/resources/themes/cura-light/theme.json b/resources/themes/cura-light/theme.json index 2d7e92be4d..dfad5cfd17 100644 --- a/resources/themes/cura-light/theme.json +++ b/resources/themes/cura-light/theme.json @@ -41,12 +41,12 @@ "family": "Noto Sans" }, "small": { - "size": 0.85, - "weight": 50, + "size": 1.0, + "weight": 63, "family": "Noto Sans" }, "very_small": { - "size": 0.7, + "size": 1.0, "weight": 50, "family": "Noto Sans" }, @@ -64,6 +64,12 @@ "size": 1.15, "weight": 50, "family": "Noto Sans" + }, + "extruder_icon": + { + "size": 0.7, + "weight": 50, + "family": "Noto Sans" } }, @@ -93,14 +99,14 @@ "secondary_button_hover": [228, 228, 228, 255], "secondary_button_text": [30, 102, 215, 255], - "main_window_header_background": [8, 7, 63, 255], + "main_window_header_background": [10, 8, 80, 255], "main_window_header_background_gradient": [25, 23, 91, 255], - "main_window_header_button_text_active": [8, 7, 63, 255], + "main_window_header_button_text_active": [10, 8, 80, 255], "main_window_header_button_text_inactive": [255, 255, 255, 255], "main_window_header_button_text_hovered": [255, 255, 255, 255], "main_window_header_button_background_active": [255, 255, 255, 255], "main_window_header_button_background_inactive": [255, 255, 255, 0], - "main_window_header_button_background_hovered": [117, 114, 159, 255], + "main_window_header_button_background_hovered": [255, 255, 255, 102], "account_widget_outline_active": [70, 66, 126, 255], @@ -108,13 +114,12 @@ "machine_selector_active": [68, 72, 75, 255], "machine_selector_hover": [68, 72, 75, 255], "machine_selector_text_active": [255, 255, 255, 255], - "machine_selector_printer_icon": [8, 7, 63, 255], "action_panel_secondary": [27, 95, 202, 255], "toolbar_background": [255, 255, 255, 255], - "printer_type_label_background": [228, 228, 242, 255], + "printer_type_label_background": [171, 171, 191, 255], "text": [0, 0, 0, 255], "text_detail": [174, 174, 174, 128], @@ -128,9 +133,9 @@ "text_scene_hover": [70, 84, 113, 255], "error": [255, 140, 0, 255], - "warning": [245, 166, 35, 255], + "warning": [255, 190, 35, 255], - "toolbar_button_text": [8, 7, 63, 255], + "toolbar_button_text": [10, 8, 80, 255], "toolbar_button_hover": [232, 242, 252, 255], "toolbar_button_active": [232, 242, 252, 255], "toolbar_button_active_hover": [232, 242, 252, 255], @@ -145,9 +150,9 @@ "button_text_active_hover": [255, 255, 255, 255], "small_button": [0, 0, 0, 0], - "small_button_hover": [8, 7, 63, 255], - "small_button_active": [8, 7, 63, 255], - "small_button_active_hover": [8, 7, 63, 255], + "small_button_hover": [10, 8, 80, 255], + "small_button_active": [10, 8, 80, 255], + "small_button_active_hover": [10, 8, 80, 255], "small_button_text": [171, 171, 191, 255], "small_button_text_hover": [255, 255, 255, 255], "small_button_text_active": [255, 255, 255, 255], @@ -223,8 +228,8 @@ "progressbar_control": [50, 130, 255, 255], "slider_groove": [223, 223, 223, 255], - "slider_groove_fill": [8, 7, 63, 255], - "slider_handle": [8, 7, 63, 255], + "slider_groove_fill": [10, 8, 80, 255], + "slider_handle": [10, 8, 80, 255], "slider_handle_active": [50, 130, 255, 255], "slider_text_background": [255, 255, 255, 255], @@ -236,7 +241,6 @@ "checkbox_border": [64, 69, 72, 255], "checkbox_border_hover": [50, 130, 255, 255], "checkbox_mark": [119, 122, 124, 255], - "checkbox_disabled": [223, 223, 223, 255], "checkbox_text": [27, 27, 27, 255], "tooltip": [68, 192, 255, 255], @@ -306,6 +310,14 @@ "layerview_support_interface": [64, 192, 255, 255], "layerview_nozzle": [181, 166, 66, 50], + "configuration_item": [255, 255, 255, 0], + "configuration_item_active": [12, 169, 227, 32], + "configuration_item_text": [0, 0, 0, 255], + "configuration_item_text_active": [0, 0, 0, 255], + "configuration_item_border": [127, 127, 127, 255], + "configuration_item_border_active": [12, 169, 227, 32], + "configuration_item_border_hover": [50, 130, 255, 255], + "tab_status_connected": [50, 130, 255, 255], "tab_status_disconnected": [200, 200, 200, 255], @@ -365,6 +377,7 @@ "action_panel_widget": [25.0, 0.0], "action_panel_information_widget": [20.0, 0.0], + "action_panel_button": [15.0, 3.0], "machine_selector_widget": [20.0, 4.0], "machine_selector_widget_content": [25.0, 32.0], @@ -410,9 +423,6 @@ "button_icon": [2.5, 2.5], "button_lining": [0, 0], - "action_button": [15.0, 3.0], - "action_button_radius": [0.15, 0.15], - "small_button": [2, 2], "small_button_icon": [1.5, 1.5], @@ -501,6 +511,9 @@ "avatar_image": [6.8, 6.8], + "action_button": [15.0, 3.0], + "action_button_radius": [0.15, 0.15], + "monitor_config_override_box": [1.0, 14.0], "monitor_extruder_circle": [2.75, 2.75], "monitor_text_line": [1.16, 1.16], From a1c252d3a20de28ae5c3f81b660b260e28b9065f Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Fri, 7 Dec 2018 10:25:46 +0100 Subject: [PATCH 086/178] Revert "Revert "fix merge conflict"" This reverts commit 4bffa6d90f2e6e9784c7e5508b61e00a92ecb74f. --- cura/Settings/ExtruderManager.py | 4 +- cura/Settings/ExtruderStack.py | 4 +- cura/Settings/ExtrudersModel.py | 6 +- cura/Settings/MachineManager.py | 4 +- .../ProcessSlicedLayersJob.py | 2 +- .../MachineSettingsAction.qml | 4 +- plugins/ModelChecker/ModelChecker.qml | 1 - .../PerObjectSettingsPanel.qml | 1 - .../PostProcessingPlugin.qml | 4 - plugins/PrepareStage/PrepareMenu.qml | 3 +- .../SimulationViewMainComponent.qml | 7 +- .../resources/qml/ToolboxAuthorPage.qml | 8 +- .../qml/ToolboxCompatibilityChart.qml | 2 +- .../resources/qml/ToolboxDetailPage.qml | 16 +- .../qml/ToolboxDownloadsGridTile.qml | 3 +- .../qml/ToolboxDownloadsShowcaseTile.qml | 2 - .../resources/qml/DiscoverUM3Action.qml | 16 +- .../qml/MonitorBuildplateConfiguration.qml | 2 +- .../qml/MonitorExtruderConfiguration.qml | 4 +- .../resources/qml/MonitorPrinterCard.qml | 10 +- .../src/ClusterUM3OutputDevice.py | 12 +- resources/qml/ActionButton.qml | 35 +- .../ActionPanel/OutputDevicesActionButton.qml | 4 + .../qml/ActionPanel/OutputProcessWidget.qml | 27 +- .../ActionPanel/PrintInformationWidget.qml | 3 - .../qml/ActionPanel/PrintJobInformation.qml | 8 +- .../qml/ActionPanel/SliceProcessWidget.qml | 7 +- resources/qml/Cura.qml | 89 +- resources/qml/CustomConfigurationSelector.qml | 357 ---- resources/qml/Dialogs/AboutDialog.qml | 6 +- resources/qml/Dialogs/AddMachineDialog.qml | 5 +- resources/qml/ExpandableComponent.qml | 6 +- resources/qml/ExtruderIcon.qml | 7 +- resources/qml/IconLabel.qml | 5 +- resources/qml/IconWithText.qml | 2 - resources/qml/JobSpecs.qml | 3 +- resources/qml/MainWindow/MainWindowHeader.qml | 30 +- .../ConfigurationMenu/AutoConfiguration.qml | 39 + .../ConfigurationMenu/ConfigurationItem.qml | 210 +- .../ConfigurationListView.qml | 77 +- .../ConfigurationMenu/ConfigurationMenu.qml | 203 ++ .../ConfigurationMenu/CustomConfiguration.qml | 250 +++ .../PrintCoreConfiguration.qml | 95 +- .../QuickConfigurationSelector.qml | 243 --- .../Menus/ConfigurationMenu/SyncButton.qml | 102 - resources/qml/Menus/ProfileMenu.qml | 8 +- resources/qml/ObjectsList.qml | 3 +- resources/qml/Preferences/MachinesPage.qml | 8 +- .../Materials/MaterialsBrandSection.qml | 5 +- .../Preferences/Materials/MaterialsList.qml | 8 +- .../Preferences/Materials/MaterialsSlot.qml | 2 - .../Materials/MaterialsTypeSection.qml | 2 - resources/qml/Preferences/ProfilesPage.qml | 24 +- .../qml/Preferences/SettingVisibilityPage.qml | 2 +- resources/qml/PrinterOutput/ExtruderBox.qml | 2 +- resources/qml/PrinterOutput/HeatedBedBox.qml | 2 +- .../qml/PrinterOutput/OutputDeviceHeader.qml | 4 +- .../qml/PrinterSelector/MachineSelector.qml | 60 +- .../PrinterSelector/MachineSelectorList.qml | 2 +- .../PrinterTypeLabel.qml | 2 +- resources/qml/Settings/SettingCategory.qml | 13 +- resources/qml/Settings/SettingCheckBox.qml | 4 +- .../qml/Settings/SettingOptionalExtruder.qml | 13 +- resources/qml/Settings/SettingView.qml | 11 +- resources/qml/SidebarSimple.qml | 7 +- resources/qml/Toolbar.qml | 2 +- resources/qml/ViewsSelector.qml | 6 +- resources/qml/qmldir | 1 - resources/themes/cura-dark/theme.json | 10 +- .../cura-light/images/header_pattern.svg | 1902 ++++++++++++++++- .../themes/cura-light/images/logo_about.svg | 172 ++ resources/themes/cura-light/styles.qml | 9 +- resources/themes/cura-light/theme.json | 51 +- 73 files changed, 3047 insertions(+), 1216 deletions(-) delete mode 100644 resources/qml/CustomConfigurationSelector.qml create mode 100644 resources/qml/Menus/ConfigurationMenu/AutoConfiguration.qml create mode 100644 resources/qml/Menus/ConfigurationMenu/ConfigurationMenu.qml create mode 100644 resources/qml/Menus/ConfigurationMenu/CustomConfiguration.qml delete mode 100644 resources/qml/Menus/ConfigurationMenu/QuickConfigurationSelector.qml delete mode 100644 resources/qml/Menus/ConfigurationMenu/SyncButton.qml rename resources/qml/{PrinterSelector => }/PrinterTypeLabel.qml (95%) create mode 100644 resources/themes/cura-light/images/logo_about.svg diff --git a/cura/Settings/ExtruderManager.py b/cura/Settings/ExtruderManager.py index 9089ba96e9..b0bcf3b100 100755 --- a/cura/Settings/ExtruderManager.py +++ b/cura/Settings/ExtruderManager.py @@ -63,7 +63,7 @@ class ExtruderManager(QObject): if not self._application.getGlobalContainerStack(): return None # No active machine, so no active extruder. try: - return self._extruder_trains[self._application.getGlobalContainerStack().getId()][str(self._active_extruder_index)].getId() + return self._extruder_trains[self._application.getGlobalContainerStack().getId()][str(self.activeExtruderIndex)].getId() except KeyError: # Extruder index could be -1 if the global tab is selected, or the entry doesn't exist if the machine definition is wrong. return None @@ -144,7 +144,7 @@ class ExtruderManager(QObject): @pyqtSlot(result = QObject) def getActiveExtruderStack(self) -> Optional["ExtruderStack"]: - return self.getExtruderStack(self._active_extruder_index) + return self.getExtruderStack(self.activeExtruderIndex) ## Get an extruder stack by index def getExtruderStack(self, index) -> Optional["ExtruderStack"]: diff --git a/cura/Settings/ExtruderStack.py b/cura/Settings/ExtruderStack.py index d7faedb71c..d626ef06da 100644 --- a/cura/Settings/ExtruderStack.py +++ b/cura/Settings/ExtruderStack.py @@ -52,8 +52,8 @@ class ExtruderStack(CuraContainerStack): return super().getNextStack() def setEnabled(self, enabled: bool) -> None: - if "enabled" not in self._metadata: - self.setMetaDataEntry("enabled", "True") + if self.getMetaDataEntry("enabled", True) == enabled: #No change. + return #Don't emit a signal then. self.setMetaDataEntry("enabled", str(enabled)) self.enabledChanged.emit() diff --git a/cura/Settings/ExtrudersModel.py b/cura/Settings/ExtrudersModel.py index 14a8dadc69..5f10ac99d4 100644 --- a/cura/Settings/ExtrudersModel.py +++ b/cura/Settings/ExtrudersModel.py @@ -1,4 +1,4 @@ -# Copyright (c) 2017 Ultimaker B.V. +# Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from PyQt5.QtCore import Qt, pyqtSignal, pyqtSlot, pyqtProperty, QTimer @@ -165,7 +165,7 @@ class ExtrudersModel(UM.Qt.ListModel.ListModel): def __updateExtruders(self): extruders_changed = False - if self.rowCount() != 0: + if self.count != 0: extruders_changed = True items = [] @@ -177,7 +177,7 @@ class ExtrudersModel(UM.Qt.ListModel.ListModel): machine_extruder_count = global_container_stack.getProperty("machine_extruder_count", "value") for extruder in Application.getInstance().getExtruderManager().getActiveExtruderStacks(): - position = extruder.getMetaDataEntry("position", default = "0") # Get the position + position = extruder.getMetaDataEntry("position", default = "0") try: position = int(position) except ValueError: diff --git a/cura/Settings/MachineManager.py b/cura/Settings/MachineManager.py index 53390ca88d..a472cc7f09 100755 --- a/cura/Settings/MachineManager.py +++ b/cura/Settings/MachineManager.py @@ -874,7 +874,7 @@ class MachineManager(QObject): caution_message = Message(catalog.i18nc( "@info:generic", "Settings have been changed to match the current availability of extruders: [%s]" % ", ".join(add_user_changes)), - lifetime=0, + lifetime = 0, title = catalog.i18nc("@info:title", "Settings updated")) caution_message.show() @@ -1553,7 +1553,7 @@ class MachineManager(QObject): elif word.isdigit(): abbr_machine += word else: - stripped_word = ''.join(char for char in unicodedata.normalize('NFD', word.upper()) if unicodedata.category(char) != 'Mn') + stripped_word = "".join(char for char in unicodedata.normalize("NFD", word.upper()) if unicodedata.category(char) != "Mn") # - use only the first character if the word is too long (> 3 characters) # - use the whole word if it's not too long (<= 3 characters) if len(stripped_word) > 3: diff --git a/plugins/CuraEngineBackend/ProcessSlicedLayersJob.py b/plugins/CuraEngineBackend/ProcessSlicedLayersJob.py index 594bf3a43e..71c96880e8 100644 --- a/plugins/CuraEngineBackend/ProcessSlicedLayersJob.py +++ b/plugins/CuraEngineBackend/ProcessSlicedLayersJob.py @@ -195,7 +195,7 @@ class ProcessSlicedLayersJob(Job): if extruders: material_color_map = numpy.zeros((len(extruders), 4), dtype=numpy.float32) for extruder in extruders: - position = int(extruder.getMetaDataEntry("position", default="0")) # Get the position + position = int(extruder.getMetaDataEntry("position", default = "0")) try: default_color = ExtrudersModel.defaultColors[position] except IndexError: diff --git a/plugins/MachineSettingsAction/MachineSettingsAction.qml b/plugins/MachineSettingsAction/MachineSettingsAction.qml index 004b4e3cfc..c88a721a84 100644 --- a/plugins/MachineSettingsAction/MachineSettingsAction.qml +++ b/plugins/MachineSettingsAction/MachineSettingsAction.qml @@ -1,4 +1,4 @@ -// Copyright (c) 2016 Ultimaker B.V. +// Copyright (c) 2018 Ultimaker B.V. // Cura is released under the terms of the LGPLv3 or higher. import QtQuick 2.2 @@ -23,7 +23,7 @@ Cura.MachineAction target: base.extrudersModel onModelChanged: { - var extruderCount = base.extrudersModel.rowCount(); + var extruderCount = base.extrudersModel.count; base.extruderTabsCount = extruderCount; } } diff --git a/plugins/ModelChecker/ModelChecker.qml b/plugins/ModelChecker/ModelChecker.qml index 5e41591d6b..437df29516 100644 --- a/plugins/ModelChecker/ModelChecker.qml +++ b/plugins/ModelChecker/ModelChecker.qml @@ -33,7 +33,6 @@ Button { width: UM.Theme.getSize("save_button_specs_icons").width; height: UM.Theme.getSize("save_button_specs_icons").height; - sourceSize.width: width; sourceSize.height: width; color: control.hovered ? UM.Theme.getColor("text_scene_hover") : UM.Theme.getColor("text_scene"); source: "model_checker.svg" diff --git a/plugins/PerObjectSettingsTool/PerObjectSettingsPanel.qml b/plugins/PerObjectSettingsTool/PerObjectSettingsPanel.qml index 5d4e17a102..0e2bd88619 100644 --- a/plugins/PerObjectSettingsTool/PerObjectSettingsPanel.qml +++ b/plugins/PerObjectSettingsTool/PerObjectSettingsPanel.qml @@ -265,7 +265,6 @@ Item { anchors.verticalCenter: parent.verticalCenter width: parent.width height: width - sourceSize.width: width sourceSize.height: width color: control.hovered ? UM.Theme.getColor("setting_control_button_hover") : UM.Theme.getColor("setting_control_button") source: UM.Theme.getIcon("minus") diff --git a/plugins/PostProcessingPlugin/PostProcessingPlugin.qml b/plugins/PostProcessingPlugin/PostProcessingPlugin.qml index bd4d361d35..3fa10c23b9 100644 --- a/plugins/PostProcessingPlugin/PostProcessingPlugin.qml +++ b/plugins/PostProcessingPlugin/PostProcessingPlugin.qml @@ -141,7 +141,6 @@ UM.Dialog anchors.horizontalCenter: parent.horizontalCenter width: Math.round(control.width / 2.7) height: Math.round(control.height / 2.7) - sourceSize.width: width sourceSize.height: width color: palette.text source: UM.Theme.getIcon("cross1") @@ -176,7 +175,6 @@ UM.Dialog anchors.horizontalCenter: parent.horizontalCenter width: Math.round(control.width / 2.5) height: Math.round(control.height / 2.5) - sourceSize.width: width sourceSize.height: width color: control.enabled ? palette.text : disabledPalette.text source: UM.Theme.getIcon("arrow_bottom") @@ -211,7 +209,6 @@ UM.Dialog anchors.horizontalCenter: parent.horizontalCenter width: Math.round(control.width / 2.5) height: Math.round(control.height / 2.5) - sourceSize.width: width sourceSize.height: width color: control.enabled ? palette.text : disabledPalette.text source: UM.Theme.getIcon("arrow_top") @@ -498,7 +495,6 @@ UM.Dialog anchors.horizontalCenter: parent.horizontalCenter width: Math.round(parent.width / 2) height: Math.round(parent.height / 2) - sourceSize.width: width sourceSize.height: height color: !control.enabled ? UM.Theme.getColor("action_button_disabled_text") : control.pressed ? UM.Theme.getColor("action_button_active_text") : diff --git a/plugins/PrepareStage/PrepareMenu.qml b/plugins/PrepareStage/PrepareMenu.qml index 10b4262f01..fa94bc88b2 100644 --- a/plugins/PrepareStage/PrepareMenu.qml +++ b/plugins/PrepareStage/PrepareMenu.qml @@ -61,7 +61,7 @@ Item color: UM.Theme.getColor("lining") } - Cura.QuickConfigurationSelector + Cura.ConfigurationMenu { Layout.fillHeight: true Layout.fillWidth: true @@ -107,7 +107,6 @@ Item height: UM.Theme.getSize("button_icon").height color: UM.Theme.getColor("toolbar_button_text") - sourceSize.width: width sourceSize.height: height } } diff --git a/plugins/SimulationView/SimulationViewMainComponent.qml b/plugins/SimulationView/SimulationViewMainComponent.qml index 16b9aeaae6..16b049c921 100644 --- a/plugins/SimulationView/SimulationViewMainComponent.qml +++ b/plugins/SimulationView/SimulationViewMainComponent.qml @@ -61,10 +61,9 @@ Item iconSource: !is_simulation_playing ? "./resources/simulation_resume.svg": "./resources/simulation_pause.svg" width: UM.Theme.getSize("small_button").width height: UM.Theme.getSize("small_button").height - hoverBackgroundColor: UM.Theme.getColor("small_button_hover") - hoverColor: UM.Theme.getColor("small_button_text_hover") - color: UM.Theme.getColor("small_button_text") - iconMargin: 0.5 * UM.Theme.getSize("wide_lining").width + hoverColor: UM.Theme.getColor("slider_handle_active") + color: UM.Theme.getColor("slider_handle") + iconMargin: UM.Theme.getSize("thick_lining").width visible: !UM.SimulationView.compatibilityMode Connections diff --git a/plugins/Toolbox/resources/qml/ToolboxAuthorPage.qml b/plugins/Toolbox/resources/qml/ToolboxAuthorPage.qml index 4aaea20813..9c1df0c49e 100644 --- a/plugins/Toolbox/resources/qml/ToolboxAuthorPage.qml +++ b/plugins/Toolbox/resources/qml/ToolboxAuthorPage.qml @@ -86,13 +86,13 @@ Item Label { text: catalog.i18nc("@label", "Website") + ":" - font: UM.Theme.getFont("very_small") + font: UM.Theme.getFont("default") color: UM.Theme.getColor("text_medium") } Label { text: catalog.i18nc("@label", "Email") + ":" - font: UM.Theme.getFont("very_small") + font: UM.Theme.getFont("default") color: UM.Theme.getColor("text_medium") } } @@ -118,7 +118,7 @@ Item } return "" } - font: UM.Theme.getFont("very_small") + font: UM.Theme.getFont("default") color: UM.Theme.getColor("text") linkColor: UM.Theme.getColor("text_link") onLinkActivated: Qt.openUrlExternally(link) @@ -134,7 +134,7 @@ Item } return "" } - font: UM.Theme.getFont("very_small") + font: UM.Theme.getFont("default") color: UM.Theme.getColor("text") linkColor: UM.Theme.getColor("text_link") onLinkActivated: Qt.openUrlExternally(link) diff --git a/plugins/Toolbox/resources/qml/ToolboxCompatibilityChart.qml b/plugins/Toolbox/resources/qml/ToolboxCompatibilityChart.qml index 4a6268df42..d4c0ae14eb 100644 --- a/plugins/Toolbox/resources/qml/ToolboxCompatibilityChart.qml +++ b/plugins/Toolbox/resources/qml/ToolboxCompatibilityChart.qml @@ -228,7 +228,7 @@ Item return result } - font: UM.Theme.getFont("very_small") + font: UM.Theme.getFont("default") color: UM.Theme.getColor("text") linkColor: UM.Theme.getColor("text_link") onLinkActivated: Qt.openUrlExternally(link) diff --git a/plugins/Toolbox/resources/qml/ToolboxDetailPage.qml b/plugins/Toolbox/resources/qml/ToolboxDetailPage.qml index c5e9bb0a49..9e2e178b71 100644 --- a/plugins/Toolbox/resources/qml/ToolboxDetailPage.qml +++ b/plugins/Toolbox/resources/qml/ToolboxDetailPage.qml @@ -82,25 +82,25 @@ Item Label { text: catalog.i18nc("@label", "Version") + ":" - font: UM.Theme.getFont("very_small") + font: UM.Theme.getFont("default") color: UM.Theme.getColor("text_medium") } Label { text: catalog.i18nc("@label", "Last updated") + ":" - font: UM.Theme.getFont("very_small") + font: UM.Theme.getFont("default") color: UM.Theme.getColor("text_medium") } Label { text: catalog.i18nc("@label", "Author") + ":" - font: UM.Theme.getFont("very_small") + font: UM.Theme.getFont("default") color: UM.Theme.getColor("text_medium") } Label { text: catalog.i18nc("@label", "Downloads") + ":" - font: UM.Theme.getFont("very_small") + font: UM.Theme.getFont("default") color: UM.Theme.getColor("text_medium") } } @@ -119,7 +119,7 @@ Item Label { text: details === null ? "" : (details.version || catalog.i18nc("@label", "Unknown")) - font: UM.Theme.getFont("very_small") + font: UM.Theme.getFont("default") color: UM.Theme.getColor("text") } Label @@ -133,7 +133,7 @@ Item var date = new Date(details.last_updated) return date.toLocaleString(UM.Preferences.getValue("general/language")) } - font: UM.Theme.getFont("very_small") + font: UM.Theme.getFont("default") color: UM.Theme.getColor("text") } Label @@ -149,7 +149,7 @@ Item return "" + details.author_name + "" } } - font: UM.Theme.getFont("very_small") + font: UM.Theme.getFont("default") color: UM.Theme.getColor("text") linkColor: UM.Theme.getColor("text_link") onLinkActivated: Qt.openUrlExternally(link) @@ -157,7 +157,7 @@ Item Label { text: details === null ? "" : (details.download_count || catalog.i18nc("@label", "Unknown")) - font: UM.Theme.getFont("very_small") + font: UM.Theme.getFont("default") color: UM.Theme.getColor("text") } } diff --git a/plugins/Toolbox/resources/qml/ToolboxDownloadsGridTile.qml b/plugins/Toolbox/resources/qml/ToolboxDownloadsGridTile.qml index 887140bbfa..61374f9d99 100644 --- a/plugins/Toolbox/resources/qml/ToolboxDownloadsGridTile.qml +++ b/plugins/Toolbox/resources/qml/ToolboxDownloadsGridTile.qml @@ -52,7 +52,6 @@ Item bottom: parent.bottom right: parent.right } - sourceSize.width: width sourceSize.height: height visible: installedPackages != 0 color: (installedPackages == packageCount) ? UM.Theme.getColor("primary") : UM.Theme.getColor("border") @@ -81,7 +80,7 @@ Item width: parent.width wrapMode: Text.WordWrap color: UM.Theme.getColor("text_medium") - font: UM.Theme.getFont("very_small") + font: UM.Theme.getFont("default") } } } diff --git a/plugins/Toolbox/resources/qml/ToolboxDownloadsShowcaseTile.qml b/plugins/Toolbox/resources/qml/ToolboxDownloadsShowcaseTile.qml index 4fb70541d2..8a2fdc8bc8 100644 --- a/plugins/Toolbox/resources/qml/ToolboxDownloadsShowcaseTile.qml +++ b/plugins/Toolbox/resources/qml/ToolboxDownloadsShowcaseTile.qml @@ -48,8 +48,6 @@ Rectangle right: parent.right bottomMargin: UM.Theme.getSize("default_lining").width } - sourceSize.width: width - sourceSize.height: height visible: installedPackages != 0 color: (installedPackages == packageCount) ? UM.Theme.getColor("primary") : UM.Theme.getColor("border") source: "../images/installed_check.svg" diff --git a/plugins/UM3NetworkPrinting/resources/qml/DiscoverUM3Action.qml b/plugins/UM3NetworkPrinting/resources/qml/DiscoverUM3Action.qml index 967adfc029..bb710127fc 100644 --- a/plugins/UM3NetworkPrinting/resources/qml/DiscoverUM3Action.qml +++ b/plugins/UM3NetworkPrinting/resources/qml/DiscoverUM3Action.qml @@ -64,6 +64,7 @@ Cura.MachineAction width: parent.width text: catalog.i18nc("@title:window", "Connect to Networked Printer") wrapMode: Text.WordWrap + renderType: Text.NativeRendering font.pointSize: 18 } @@ -72,6 +73,7 @@ Cura.MachineAction id: pageDescription width: parent.width wrapMode: Text.WordWrap + renderType: Text.NativeRendering text: catalog.i18nc("@label", "To print directly to your printer over the network, please make sure your printer is connected to the network using a network cable or by connecting your printer to your WIFI network. If you don't connect Cura with your printer, you can still use a USB drive to transfer g-code files to your printer.\n\nSelect your printer from the list below:") } @@ -182,6 +184,7 @@ Cura.MachineAction text: listview.model[index].name color: parent.ListView.isCurrentItem ? palette.highlightedText : palette.text elide: Text.ElideRight + renderType: Text.NativeRendering } MouseArea @@ -204,6 +207,7 @@ Cura.MachineAction anchors.left: parent.left anchors.right: parent.right wrapMode: Text.WordWrap + renderType: Text.NativeRendering text: catalog.i18nc("@label", "If your printer is not listed, read the network printing troubleshooting guide").arg("https://ultimaker.com/en/troubleshooting"); onLinkActivated: Qt.openUrlExternally(link) } @@ -221,6 +225,7 @@ Cura.MachineAction text: base.selectedDevice ? base.selectedDevice.name : "" font: UM.Theme.getFont("large") elide: Text.ElideRight + renderType: Text.NativeRendering } Grid { @@ -231,12 +236,14 @@ Cura.MachineAction { width: Math.round(parent.width * 0.5) wrapMode: Text.WordWrap + renderType: Text.NativeRendering text: catalog.i18nc("@label", "Type") } Label { width: Math.round(parent.width * 0.5) wrapMode: Text.WordWrap + renderType: Text.NativeRendering text: { if(base.selectedDevice) @@ -268,24 +275,28 @@ Cura.MachineAction { width: Math.round(parent.width * 0.5) wrapMode: Text.WordWrap + renderType: Text.NativeRendering text: catalog.i18nc("@label", "Firmware version") } Label { width: Math.round(parent.width * 0.5) wrapMode: Text.WordWrap + renderType: Text.NativeRendering text: base.selectedDevice ? base.selectedDevice.firmwareVersion : "" } Label { width: Math.round(parent.width * 0.5) wrapMode: Text.WordWrap + renderType: Text.NativeRendering text: catalog.i18nc("@label", "Address") } Label { width: Math.round(parent.width * 0.5) wrapMode: Text.WordWrap + renderType: Text.NativeRendering text: base.selectedDevice ? base.selectedDevice.ipAddress : "" } } @@ -294,6 +305,7 @@ Cura.MachineAction { width: parent.width wrapMode: Text.WordWrap + renderType: Text.NativeRendering text:{ // The property cluster size does not exist for older UM3 devices. if(!base.selectedDevice || base.selectedDevice.clusterSize == null || base.selectedDevice.clusterSize == 1) @@ -315,6 +327,7 @@ Cura.MachineAction { width: parent.width wrapMode: Text.WordWrap + renderType: Text.NativeRendering visible: base.selectedDevice != null && !base.completeProperties text: catalog.i18nc("@label", "The printer at this address has not yet responded." ) } @@ -358,9 +371,10 @@ Cura.MachineAction Label { - text: catalog.i18nc("@alabel","Enter the IP address or hostname of your printer on the network.") + text: catalog.i18nc("@alabel", "Enter the IP address or hostname of your printer on the network.") width: parent.width wrapMode: Text.WordWrap + renderType: Text.NativeRendering } TextField diff --git a/plugins/UM3NetworkPrinting/resources/qml/MonitorBuildplateConfiguration.qml b/plugins/UM3NetworkPrinting/resources/qml/MonitorBuildplateConfiguration.qml index 9ffb1eabb4..7edeb81a96 100644 --- a/plugins/UM3NetworkPrinting/resources/qml/MonitorBuildplateConfiguration.qml +++ b/plugins/UM3NetworkPrinting/resources/qml/MonitorBuildplateConfiguration.qml @@ -52,7 +52,7 @@ Item id: buildplateLabel color: "#191919" // TODO: Theme! elide: Text.ElideRight - font: UM.Theme.getFont("very_small") // 12pt, regular + font: UM.Theme.getFont("default") // 12pt, regular text: "" // FIXED-LINE-HEIGHT: diff --git a/plugins/UM3NetworkPrinting/resources/qml/MonitorExtruderConfiguration.qml b/plugins/UM3NetworkPrinting/resources/qml/MonitorExtruderConfiguration.qml index afbd4c3641..1e53191d8c 100644 --- a/plugins/UM3NetworkPrinting/resources/qml/MonitorExtruderConfiguration.qml +++ b/plugins/UM3NetworkPrinting/resources/qml/MonitorExtruderConfiguration.qml @@ -49,7 +49,7 @@ Item } color: "#191919" // TODO: Theme! elide: Text.ElideRight - font: UM.Theme.getFont("very_small") // 12pt, regular + font: UM.Theme.getFont("default") // 12pt, regular text: "" // FIXED-LINE-HEIGHT: @@ -66,7 +66,7 @@ Item } color: "#191919" // TODO: Theme! elide: Text.ElideRight - font: UM.Theme.getFont("small") // 12pt, bold + font: UM.Theme.getFont("default_bold") // 12pt, bold text: "" // FIXED-LINE-HEIGHT: diff --git a/plugins/UM3NetworkPrinting/resources/qml/MonitorPrinterCard.qml b/plugins/UM3NetworkPrinting/resources/qml/MonitorPrinterCard.qml index 8659037cb8..567fff8489 100644 --- a/plugins/UM3NetworkPrinting/resources/qml/MonitorPrinterCard.qml +++ b/plugins/UM3NetworkPrinting/resources/qml/MonitorPrinterCard.qml @@ -179,13 +179,15 @@ Item color: "#414054" // TODO: Theme! font: UM.Theme.getFont("large") // 16pt, bold text: { - if (printer && printer.state == "disabled"){ + if (printer && printer.state == "disabled") + { return catalog.i18nc("@label:status", "Unavailable") } - if (printer && printer.state == "unreachable"){ - return catalog.i18nc("@label:status", "Unavailable") + if (printer && printer.state == "unreachable") + { + return catalog.i18nc("@label:status", "Unreachable") } - if (printer && !printer.activePrintJob) + if (printer && printer.state == "idle") { return catalog.i18nc("@label:status", "Idle") } diff --git a/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py b/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py index cc5b128479..a5ee3bc650 100644 --- a/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py @@ -572,7 +572,17 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): def _createMaterialOutputModel(self, material_data: Dict[str, Any]) -> "MaterialOutputModel": material_manager = CuraApplication.getInstance().getMaterialManager() - material_group_list = material_manager.getMaterialGroupListByGUID(material_data["guid"]) or [] + material_group_list = material_manager.getMaterialGroupListByGUID(material_data["guid"]) + # This can happen if the connected machine has no material in one or more extruders (if GUID is empty), or the + # material is unknown to Cura, so we should return an "empty" or "unknown" material model. + if material_group_list is None: + material_name = "Empty" if len(material_data["guid"]) == 0 else "Unknown" + return MaterialOutputModel(guid = material_data["guid"], + type = material_data.get("type", ""), + color = material_data.get("color", ""), + brand = material_data.get("brand", ""), + name = material_data.get("name", material_name) + ) # Sort the material groups by "is_read_only = True" first, and then the name alphabetically. read_only_material_group_list = list(filter(lambda x: x.is_read_only, material_group_list)) diff --git a/resources/qml/ActionButton.qml b/resources/qml/ActionButton.qml index b9a04f3b46..fc4a1c05f4 100644 --- a/resources/qml/ActionButton.qml +++ b/resources/qml/ActionButton.qml @@ -7,14 +7,17 @@ import QtQuick.Controls 2.1 import QtGraphicalEffects 1.0 // For the dropshadow import UM 1.1 as UM +import Cura 1.0 as Cura Button { id: button - property alias iconSource: buttonIcon.source + property alias iconSource: buttonIconLeft.source + property bool isIconOnRightSide: false property alias textFont: buttonText.font property alias cornerRadius: backgroundRect.radius property alias tooltip: tooltip.text + property alias cornerSide: backgroundRect.cornerSide property color color: UM.Theme.getColor("primary") property color hoverColor: UM.Theme.getColor("primary_hover") @@ -36,18 +39,21 @@ Button // we elide the text to the right so the text will be cut off with the three dots at the end. property var fixedWidthMode: false + leftPadding: UM.Theme.getSize("default_margin").width + rightPadding: UM.Theme.getSize("default_margin").width + height: UM.Theme.getSize("action_button").height + contentItem: Row { + //Left side icon. Only displayed if !isIconOnRightSide. UM.RecolorImage { - id: buttonIcon + id: buttonIconLeft source: "" - height: Math.round(0.6 * parent.height) - width: height - sourceSize.width: width - sourceSize.height: height + height: buttonText.height + width: visible ? height : 0 color: button.hovered ? button.textHoverColor : button.textColor - visible: source != "" + visible: source != "" && !button.isIconOnRightSide anchors.verticalCenter: parent.verticalCenter } @@ -64,11 +70,24 @@ Button horizontalAlignment: Text.AlignHCenter elide: Text.ElideRight } + + //Right side icon. Only displayed if isIconOnRightSide. + UM.RecolorImage + { + id: buttonIconRight + source: buttonIconLeft.source + height: buttonText.height + width: visible ? height : 0 + color: buttonIconLeft.color + visible: source != "" && button.isIconOnRightSide + anchors.verticalCenter: buttonIconLeft.verticalCenter + } } - background: Rectangle + background: Cura.RoundedRectangle { id: backgroundRect + cornerSide: Cura.RoundedRectangle.Direction.All color: button.enabled ? (button.hovered ? button.hoverColor : button.color) : button.disabledColor radius: UM.Theme.getSize("action_button_radius").width border.width: UM.Theme.getSize("default_lining").width diff --git a/resources/qml/ActionPanel/OutputDevicesActionButton.qml b/resources/qml/ActionPanel/OutputDevicesActionButton.qml index 2111038cfc..95750e6d11 100644 --- a/resources/qml/ActionPanel/OutputDevicesActionButton.qml +++ b/resources/qml/ActionPanel/OutputDevicesActionButton.qml @@ -17,6 +17,7 @@ Item id: saveToButton height: parent.height fixedWidthMode: true + cornerSide: deviceSelectionMenu.visible ? Cura.RoundedRectangle.Direction.Left : Cura.RoundedRectangle.Direction.All anchors { @@ -44,6 +45,7 @@ Item shadowEnabled: true shadowColor: UM.Theme.getColor("primary_shadow") + cornerSide: Cura.RoundedRectangle.Direction.Right anchors { @@ -51,6 +53,8 @@ Item right: parent.right } + leftPadding: UM.Theme.getSize("narrow_margin").width //Need more space than usual here for wide text. + rightPadding: UM.Theme.getSize("narrow_margin").width tooltip: catalog.i18nc("@info:tooltip", "Select the active output device") iconSource: popup.opened ? UM.Theme.getIcon("arrow_top") : UM.Theme.getIcon("arrow_bottom") color: UM.Theme.getColor("action_panel_secondary") diff --git a/resources/qml/ActionPanel/OutputProcessWidget.qml b/resources/qml/ActionPanel/OutputProcessWidget.qml index 6ab8dc6fbb..1d1a1e44e1 100644 --- a/resources/qml/ActionPanel/OutputProcessWidget.qml +++ b/resources/qml/ActionPanel/OutputProcessWidget.qml @@ -51,7 +51,7 @@ Column text: preSlicedData ? catalog.i18nc("@label", "No time estimation available") : PrintInformation.currentPrintTime.getDisplayString(UM.DurationFormat.Long) source: UM.Theme.getIcon("clock") - font: UM.Theme.getFont("small") + font: UM.Theme.getFont("default_bold") } Cura.IconLabel @@ -84,7 +84,7 @@ Column return totalWeights + "g · " + totalLengths.toFixed(2) + "m" } source: UM.Theme.getIcon("spool") - font: UM.Theme.getFont("very_small") + font: UM.Theme.getFont("default") } } @@ -101,17 +101,25 @@ Column } } - Row + Item { id: buttonRow - spacing: UM.Theme.getSize("default_margin").width - width: parent.width + anchors.right: parent.right + anchors.left: parent.left + height: UM.Theme.getSize("action_button").height Cura.SecondaryButton { id: previewStageShortcut - height: UM.Theme.getSize("action_panel_button").height + anchors + { + left: parent.left + right: outputDevicesButton.left + rightMargin: UM.Theme.getSize("default_margin").width + } + + height: UM.Theme.getSize("action_button").height leftPadding: UM.Theme.getSize("default_margin").width rightPadding: UM.Theme.getSize("default_margin").width text: catalog.i18nc("@button", "Preview") @@ -125,8 +133,11 @@ Column Cura.OutputDevicesActionButton { - width: previewStageShortcut.visible ? UM.Theme.getSize("action_panel_button").width : parent.width - height: UM.Theme.getSize("action_panel_button").height + id: outputDevicesButton + + anchors.right: parent.right + width: previewStageShortcut.visible ? UM.Theme.getSize("action_button").width : parent.width + height: UM.Theme.getSize("action_button").height } } } \ No newline at end of file diff --git a/resources/qml/ActionPanel/PrintInformationWidget.qml b/resources/qml/ActionPanel/PrintInformationWidget.qml index 25e380dea8..554273a818 100644 --- a/resources/qml/ActionPanel/PrintInformationWidget.qml +++ b/resources/qml/ActionPanel/PrintInformationWidget.qml @@ -15,9 +15,6 @@ UM.RecolorImage width: UM.Theme.getSize("section_icon").width height: UM.Theme.getSize("section_icon").height - sourceSize.width: width - sourceSize.height: height - color: popup.opened ? UM.Theme.getColor("primary") : UM.Theme.getColor("text_medium") MouseArea diff --git a/resources/qml/ActionPanel/PrintJobInformation.qml b/resources/qml/ActionPanel/PrintJobInformation.qml index 156111af4d..8bd5d5a0d3 100644 --- a/resources/qml/ActionPanel/PrintJobInformation.qml +++ b/resources/qml/ActionPanel/PrintJobInformation.qml @@ -30,7 +30,7 @@ Column { text: catalog.i18nc("@label", "Time specification").toUpperCase() color: UM.Theme.getColor("primary") - font: UM.Theme.getFont("small") + font: UM.Theme.getFont("default_bold") renderType: Text.NativeRendering } @@ -61,7 +61,7 @@ Column } width: parent.width - 2 * UM.Theme.getSize("default_margin").width color: UM.Theme.getColor("text") - font: UM.Theme.getFont("very_small") + font: UM.Theme.getFont("default") renderType: Text.NativeRendering textFormat: Text.RichText } @@ -79,7 +79,7 @@ Column { text: catalog.i18nc("@label", "Material specification").toUpperCase() color: UM.Theme.getColor("primary") - font: UM.Theme.getFont("small") + font: UM.Theme.getFont("default_bold") renderType: Text.NativeRendering } @@ -151,7 +151,7 @@ Column } width: parent.width - 2 * UM.Theme.getSize("default_margin").width color: UM.Theme.getColor("text") - font: UM.Theme.getFont("very_small") + font: UM.Theme.getFont("default") renderType: Text.NativeRendering textFormat: Text.RichText } diff --git a/resources/qml/ActionPanel/SliceProcessWidget.qml b/resources/qml/ActionPanel/SliceProcessWidget.qml index 03d91db530..8f6608e15c 100644 --- a/resources/qml/ActionPanel/SliceProcessWidget.qml +++ b/resources/qml/ActionPanel/SliceProcessWidget.qml @@ -48,7 +48,7 @@ Column text: catalog.i18nc("@label:PrintjobStatus", "Auto slicing...") color: UM.Theme.getColor("text") - font: UM.Theme.getFont("very_small") + font: UM.Theme.getFont("default") renderType: Text.NativeRendering } @@ -61,7 +61,7 @@ Column text: catalog.i18nc("@label:PrintjobStatus", "Unable to Slice") source: UM.Theme.getIcon("warning") color: UM.Theme.getColor("warning") - font: UM.Theme.getFont("very_small") + font: UM.Theme.getFont("default") } // Progress bar, only visible when the backend is in the process of slice the printjob @@ -94,7 +94,6 @@ Column } } - Item { id: prepareButtons @@ -103,7 +102,7 @@ Column // Disable the slice process when width: parent.width - height: UM.Theme.getSize("action_panel_button").height + height: UM.Theme.getSize("action_button").height visible: !autoSlice Cura.PrimaryButton { diff --git a/resources/qml/Cura.qml b/resources/qml/Cura.qml index 3578888886..4e8e9ce788 100644 --- a/resources/qml/Cura.qml +++ b/resources/qml/Cura.qml @@ -88,6 +88,54 @@ UM.MainWindow window: base } + Rectangle + { + id: headerBackground + anchors + { + top: applicationMenu.bottom + left: parent.left + right: parent.right + } + height: stageMenu.source != "" ? Math.round(mainWindowHeader.height + stageMenu.height / 2) : mainWindowHeader.height + + LinearGradient + { + anchors.fill: parent + start: Qt.point(0, 0) + end: Qt.point(parent.width, 0) + gradient: Gradient + { + GradientStop + { + position: 0.0 + color: UM.Theme.getColor("main_window_header_background") + } + GradientStop + { + position: 0.5 + color: UM.Theme.getColor("main_window_header_background_gradient") + } + GradientStop + { + position: 1.0 + color: UM.Theme.getColor("main_window_header_background") + } + } + } + + // This is the new fancy pattern + Image + { + id: backgroundPattern + anchors.fill: parent + fillMode: Image.Tile + source: UM.Theme.getImage("header_pattern") + horizontalAlignment: Image.AlignLeft + verticalAlignment: Image.AlignTop + } + } + MainWindowHeader { id: mainWindowHeader @@ -144,44 +192,6 @@ UM.MainWindow } } - Rectangle - { - id: stageMenuBackground - anchors - { - left: parent.left - right: parent.right - top: parent.top - } - visible: stageMenu.source != "" - height: visible ? Math.round(UM.Theme.getSize("stage_menu").height / 2) : 0 - - LinearGradient - { - anchors.fill: parent - start: Qt.point(0, 0) - end: Qt.point(parent.width, 0) - gradient: Gradient - { - GradientStop - { - position: 0.0 - color: UM.Theme.getColor("main_window_header_background") - } - GradientStop - { - position: 0.5 - color: UM.Theme.getColor("main_window_header_background_gradient") - } - GradientStop - { - position: 1.0 - color: UM.Theme.getColor("main_window_header_background") - } - } - } - } - Connections { target: stageMenu.item @@ -257,7 +267,8 @@ UM.MainWindow anchors { - top: stageMenuBackground.bottom + // Align to the top of the stageMenu since the stageMenu may not exist + top: parent.top left: parent.left right: parent.right bottom: parent.bottom diff --git a/resources/qml/CustomConfigurationSelector.qml b/resources/qml/CustomConfigurationSelector.qml deleted file mode 100644 index c78ca700da..0000000000 --- a/resources/qml/CustomConfigurationSelector.qml +++ /dev/null @@ -1,357 +0,0 @@ -// Copyright (c) 2018 Ultimaker B.V. -// Cura is released under the terms of the LGPLv3 or higher. - -import QtQuick 2.7 -import QtQuick.Controls 1.1 -import QtQuick.Controls.Styles 1.1 - -import UM 1.2 as UM -import Cura 1.0 as Cura - -Rectangle -{ - implicitWidth: parent.width - implicitHeight: parent.height - - id: base - color: UM.Theme.getColor("main_background") - - // Height has an extra 2x margin for the top & bottom margin. - height: childrenRect.height + 2 * UM.Theme.getSize("default_margin").width - - Cura.ExtrudersModel { id: extrudersModel } - - ListView - { - // Horizontal list that shows the extruders - id: extrudersList - visible: extrudersModel.items.length > 1 - property var index: 0 - - height: UM.Theme.getSize("configuration_selector_mode_tabs").height - boundsBehavior: Flickable.StopAtBounds - - anchors - { - left: parent.left - right: parent.right - top: parent.top - margins: UM.Theme.getSize("thick_margin").width - } - - ExclusiveGroup { id: extruderMenuGroup } - - orientation: ListView.Horizontal - - model: extrudersModel - - Connections - { - target: Cura.MachineManager - onGlobalContainerChanged: forceActiveFocus() // Changing focus applies the currently-being-typed values so it can change the displayed setting values. - } - - delegate: Button - { - height: parent.height - width: Math.round(ListView.view.width / extrudersModel.rowCount()) - - text: model.name - tooltip: model.name - exclusiveGroup: extruderMenuGroup - checked: Cura.ExtruderManager.activeExtruderIndex == index - - property bool extruder_enabled: true - - MouseArea // TODO; This really should be fixed. It makes absolutely no sense to have a button AND a mouse area. - { - anchors.fill: parent - acceptedButtons: Qt.LeftButton | Qt.RightButton - onClicked: - { - switch (mouse.button) - { - case Qt.LeftButton: - extruder_enabled = Cura.MachineManager.getExtruder(model.index).isEnabled - if (extruder_enabled) - { - forceActiveFocus() // Changing focus applies the currently-being-typed values so it can change the displayed setting values. - Cura.ExtruderManager.setActiveExtruderIndex(index) - } - break - case Qt.RightButton: - extruder_enabled = Cura.MachineManager.getExtruder(model.index).isEnabled - extruderMenu.popup() - break - } - } - } - - Menu - { - id: extruderMenu - - MenuItem - { - text: catalog.i18nc("@action:inmenu", "Enable Extruder") - onTriggered: Cura.MachineManager.setExtruderEnabled(model.index, true) - visible: !extruder_enabled // using an intermediate variable prevents an empty popup that occured now and then - } - - MenuItem - { - text: catalog.i18nc("@action:inmenu", "Disable Extruder") - onTriggered: Cura.MachineManager.setExtruderEnabled(model.index, false) - visible: extruder_enabled - enabled: Cura.MachineManager.numberExtrudersEnabled > 1 - } - } - - style: ButtonStyle - { - background: Rectangle - { - anchors.fill: parent - border.width: control.checked ? UM.Theme.getSize("default_lining").width * 2 : UM.Theme.getSize("default_lining").width - border.color: - { - if (Cura.MachineManager.getExtruder(index).isEnabled) - { - if(control.checked || control.pressed) - { - return UM.Theme.getColor("action_button_active_border") - } - else if (control.hovered) - { - return UM.Theme.getColor("action_button_hovered_border") - } - return UM.Theme.getColor("action_button_border") - } - return UM.Theme.getColor("action_button_disabled_border") - } - color: - { - if (Cura.MachineManager.getExtruder(index).isEnabled) - { - if(control.checked || control.pressed) - { - return UM.Theme.getColor("action_button_active"); - } - else if (control.hovered) - { - return UM.Theme.getColor("action_button_hovered") - } - return UM.Theme.getColor("action_button") - } - return UM.Theme.getColor("action_button_disabled") - } - Behavior on color { ColorAnimation { duration: 50; } } - - Item - { - id: extruderButtonFace - anchors.centerIn: parent - width: childrenRect.width - - Label - { - // Static text that holds the "Extruder" label - id: extruderStaticText - anchors.verticalCenter: parent.verticalCenter - anchors.left: parent.left - - color: - { - if (Cura.MachineManager.getExtruder(index).isEnabled) - { - if(control.checked || control.pressed) - { - return UM.Theme.getColor("action_button_active_text"); - } - else if (control.hovered) - { - return UM.Theme.getColor("action_button_hovered_text") - } - return UM.Theme.getColor("action_button_text") - } - return UM.Theme.getColor("action_button_disabled_text") - } - - font: UM.Theme.getFont("large_nonbold") - text: catalog.i18nc("@label", "Extruder") - visible: width < (control.width - extruderIcon.width - UM.Theme.getSize("default_margin").width) - elide: Text.ElideRight - } - - ExtruderIcon - { - // Round icon with the extruder number and material color indicator. - id: extruderIcon - - anchors.verticalCenter: parent.verticalCenter - anchors.left: extruderStaticText.right - anchors.leftMargin: UM.Theme.getSize("default_margin").width - width: control.height - Math.round(UM.Theme.getSize("default_margin").width / 2) - height: width - - checked: control.checked - materialColor: model.color - textColor: extruderStaticText.color - } - } - } - - label: Item {} - } - } - } - - Item - { - id: materialRow - height: UM.Theme.getSize("print_setup_item").height - visible: Cura.MachineManager.hasMaterials - - anchors - { - left: parent.left - right: parent.right - top: extrudersList.bottom - margins: UM.Theme.getSize("thick_margin").width - } - - Label - { - id: materialLabel - text: catalog.i18nc("@label", "Material"); - width: Math.round(parent.width * 0.45 - UM.Theme.getSize("default_margin").width) - height: parent.height - verticalAlignment: Text.AlignVCenter - font: UM.Theme.getFont("default"); - color: UM.Theme.getColor("text"); - } - - ToolButton - { - id: materialSelection - - property var activeExtruder: Cura.MachineManager.activeStack - property var hasActiveExtruder: activeExtruder != null - property var currentRootMaterialName: hasActiveExtruder ? activeExtruder.material.name : "" - - text: currentRootMaterialName - tooltip: currentRootMaterialName - visible: Cura.MachineManager.hasMaterials - - enabled: !extrudersList.visible || Cura.ExtruderManager.activeExtruderIndex > -1 - - height: UM.Theme.getSize("setting_control").height - width: Math.round(parent.width * 0.7) + UM.Theme.getSize("thick_margin").width - anchors.right: parent.right - style: UM.Theme.styles.sidebar_header_button - activeFocusOnPress: true; - menu: Cura.MaterialMenu - { - extruderIndex: Cura.ExtruderManager.activeExtruderIndex - } - - property var valueError: hasActiveExtruder ? Cura.ContainerManager.getContainerMetaDataEntry(activeExtruder.material.id, "compatible", "") != "True" : true - property var valueWarning: ! Cura.MachineManager.isActiveQualitySupported - } - } - - Item - { - id: variantRow - height: UM.Theme.getSize("print_setup_item").height - visible: Cura.MachineManager.hasVariants - - anchors - { - left: parent.left - right: parent.right - top: materialRow.bottom - margins: UM.Theme.getSize("thick_margin").width - } - - Label - { - id: variantLabel - text: Cura.MachineManager.activeDefinitionVariantsName; - width: Math.round(parent.width * 0.45 - UM.Theme.getSize("default_margin").width) - height: parent.height - verticalAlignment: Text.AlignVCenter - font: UM.Theme.getFont("default"); - color: UM.Theme.getColor("text"); - } - - ToolButton - { - id: variantSelection - text: Cura.MachineManager.activeVariantName - tooltip: Cura.MachineManager.activeVariantName; - visible: Cura.MachineManager.hasVariants - - height: UM.Theme.getSize("setting_control").height - width: Math.round(parent.width * 0.7 + UM.Theme.getSize("thick_margin").width) - anchors.right: parent.right - style: UM.Theme.styles.sidebar_header_button - activeFocusOnPress: true; - - menu: Cura.NozzleMenu { extruderIndex: Cura.ExtruderManager.activeExtruderIndex } - } - } - - Item - { - id: materialCompatibilityLink - height: UM.Theme.getSize("print_setup_item").height - - anchors.right: parent.right - anchors.top: variantRow.bottom - anchors.margins: UM.Theme.getSize("thick_margin").width - UM.RecolorImage - { - id: warningImage - - anchors.right: materialInfoLabel.left - anchors.rightMargin: UM.Theme.getSize("default_margin").width - - source: UM.Theme.getIcon("warning") - width: UM.Theme.getSize("section_icon").width - height: UM.Theme.getSize("section_icon").height - - sourceSize.width: width - sourceSize.height: height - - color: UM.Theme.getColor("material_compatibility_warning") - - visible: !Cura.MachineManager.isCurrentSetupSupported - } - - Label - { - id: materialInfoLabel - wrapMode: Text.WordWrap - text: "" + catalog.i18nc("@label", "Check compatibility") + "" - font: UM.Theme.getFont("default") - color: UM.Theme.getColor("text") - linkColor: UM.Theme.getColor("text_link") - - verticalAlignment: Text.AlignTop - - anchors.right: parent.right - - MouseArea - { - anchors.fill: parent - - onClicked: - { - // open the material URL with web browser - Qt.openUrlExternally("https://ultimaker.com/incoming-links/cura/material-compatibilty"); - } - } - } - } -} diff --git a/resources/qml/Dialogs/AboutDialog.qml b/resources/qml/Dialogs/AboutDialog.qml index 25c9bbf74b..add84614e0 100644 --- a/resources/qml/Dialogs/AboutDialog.qml +++ b/resources/qml/Dialogs/AboutDialog.qml @@ -35,12 +35,10 @@ UM.Dialog { id: logo width: (base.minimumWidth * 0.85) | 0 - height: (width * (1/4.25)) | 0 + height: (width * (UM.Theme.getSize("logo").height / UM.Theme.getSize("logo").width)) | 0 - source: UM.Theme.getImage("logo") + source: UM.Theme.getImage("logo_about") - sourceSize.width: width - sourceSize.height: height anchors.top: parent.top anchors.topMargin: ((base.minimumWidth - width) / 2) | 0 anchors.horizontalCenter: parent.horizontalCenter diff --git a/resources/qml/Dialogs/AddMachineDialog.qml b/resources/qml/Dialogs/AddMachineDialog.qml index 8e966c3df7..f00359869c 100644 --- a/resources/qml/Dialogs/AddMachineDialog.qml +++ b/resources/qml/Dialogs/AddMachineDialog.qml @@ -1,4 +1,4 @@ -// Copyright (c) 2017 Ultimaker B.V. +// Copyright (c) 2018 Ultimaker B.V. // Cura is released under the terms of the LGPLv3 or higher. import QtQuick 2.2 @@ -156,7 +156,6 @@ UM.Dialog anchors.rightMargin: UM.Theme.getSize("default_margin").width width: UM.Theme.getSize("standard_arrow").width height: UM.Theme.getSize("standard_arrow").height - sourceSize.width: width sourceSize.height: width color: palette.windowText source: base.activeCategory == section ? UM.Theme.getIcon("arrow_bottom") : UM.Theme.getIcon("arrow_right") @@ -170,7 +169,7 @@ UM.Dialog if (machineList.model.getItem(machineList.currentIndex).section != section) { // Find the first machine from this section - for(var i = 0; i < machineList.model.rowCount(); i++) + for(var i = 0; i < machineList.model.count; i++) { var item = machineList.model.getItem(i); if (item.section == section) diff --git a/resources/qml/ExpandableComponent.qml b/resources/qml/ExpandableComponent.qml index b438f0398c..e42aa7e4a1 100644 --- a/resources/qml/ExpandableComponent.qml +++ b/resources/qml/ExpandableComponent.qml @@ -32,6 +32,8 @@ Item property color headerBackgroundColor: UM.Theme.getColor("action_button") property color headerHoverColor: UM.Theme.getColor("action_button_hovered") + property alias enabled: mouseArea.enabled + // Defines the alignment of the popup with respect of the headerItem, by default to the right property int popupAlignment: ExpandableComponent.PopupAlignment.AlignRight @@ -139,9 +141,7 @@ Item verticalCenter: parent.verticalCenter margins: background.padding } - sourceSize.width: width - sourceSize.height: height - visible: source != "" + visible: source != "" && base.enabled width: height height: Math.round(0.2 * base.height) color: UM.Theme.getColor("text") diff --git a/resources/qml/ExtruderIcon.qml b/resources/qml/ExtruderIcon.qml index c1a202050b..49ad73a32e 100644 --- a/resources/qml/ExtruderIcon.qml +++ b/resources/qml/ExtruderIcon.qml @@ -22,8 +22,6 @@ Item id: mainIcon anchors.fill: parent - sourceSize.width: parent.width - sourceSize.height: parent.height source: UM.Theme.getIcon("extruder_button") color: extruderEnabled ? materialColor: "gray" } @@ -50,7 +48,9 @@ Item id: extruderNumberText anchors.centerIn: parent text: index + 1 - font: UM.Theme.getFont("extruder_icon") + font: UM.Theme.getFont("very_small") + width: contentWidth + height: contentHeight visible: extruderEnabled renderType: Text.NativeRendering horizontalAlignment: Text.AlignHCenter @@ -62,7 +62,6 @@ Item id: disabledIcon anchors.fill: parent anchors.margins: UM.Theme.getSize("thick_lining").width - sourceSize.width: width sourceSize.height: width source: UM.Theme.getIcon("cross1") visible: !extruderEnabled diff --git a/resources/qml/IconLabel.qml b/resources/qml/IconLabel.qml index 0941254e7b..f925b6eab5 100644 --- a/resources/qml/IconLabel.qml +++ b/resources/qml/IconLabel.qml @@ -31,9 +31,6 @@ Item width: UM.Theme.getSize("section_icon").width height: width - sourceSize.width: width - sourceSize.height: height - color: label.color visible: source != "" } @@ -48,7 +45,7 @@ Item text: "Empty label" elide: Text.ElideRight color: UM.Theme.getColor("text") - font: UM.Theme.getFont("very_small") + font: UM.Theme.getFont("default") renderType: Text.NativeRendering } } \ No newline at end of file diff --git a/resources/qml/IconWithText.qml b/resources/qml/IconWithText.qml index dcb3ef7851..22599b3aed 100644 --- a/resources/qml/IconWithText.qml +++ b/resources/qml/IconWithText.qml @@ -37,8 +37,6 @@ Item width: UM.Theme.getSize("section_icon").width height: UM.Theme.getSize("section_icon").height - sourceSize.width: width - sourceSize.height: height color: "black" anchors diff --git a/resources/qml/JobSpecs.qml b/resources/qml/JobSpecs.qml index 45111992c1..935cb723de 100644 --- a/resources/qml/JobSpecs.qml +++ b/resources/qml/JobSpecs.qml @@ -60,7 +60,6 @@ Item { { width: UM.Theme.getSize("save_button_specs_icons").width; height: UM.Theme.getSize("save_button_specs_icons").height; - sourceSize.width: width; sourceSize.height: width; color: control.hovered ? UM.Theme.getColor("text_scene_hover") : UM.Theme.getColor("text_scene"); source: UM.Theme.getIcon("pencil"); @@ -124,7 +123,7 @@ Item { } height: UM.Theme.getSize("jobspecs_line").height verticalAlignment: Text.AlignVCenter - font: UM.Theme.getFont("small") + font: UM.Theme.getFont("default_bold") color: UM.Theme.getColor("text_scene") text: CuraApplication.getSceneBoundingBoxString } diff --git a/resources/qml/MainWindow/MainWindowHeader.qml b/resources/qml/MainWindow/MainWindowHeader.qml index 34936e9b5a..ae1c13d9c3 100644 --- a/resources/qml/MainWindow/MainWindowHeader.qml +++ b/resources/qml/MainWindow/MainWindowHeader.qml @@ -12,38 +12,13 @@ import QtGraphicalEffects 1.0 import "../Account" -Rectangle +Item { id: base implicitHeight: UM.Theme.getSize("main_window_header").height implicitWidth: UM.Theme.getSize("main_window_header").width - LinearGradient - { - anchors.fill: parent - start: Qt.point(0, 0) - end: Qt.point(parent.width, 0) - gradient: Gradient - { - GradientStop - { - position: 0.0 - color: UM.Theme.getColor("main_window_header_background") - } - GradientStop - { - position: 0.5 - color: UM.Theme.getColor("main_window_header_background_gradient") - } - GradientStop - { - position: 1.0 - color: UM.Theme.getColor("main_window_header_background") - } - } - } - Image { id: logo @@ -54,9 +29,6 @@ Rectangle source: UM.Theme.getImage("logo") width: UM.Theme.getSize("logo").width height: UM.Theme.getSize("logo").height - - sourceSize.width: width - sourceSize.height: height } Row diff --git a/resources/qml/Menus/ConfigurationMenu/AutoConfiguration.qml b/resources/qml/Menus/ConfigurationMenu/AutoConfiguration.qml new file mode 100644 index 0000000000..68c56c7c4b --- /dev/null +++ b/resources/qml/Menus/ConfigurationMenu/AutoConfiguration.qml @@ -0,0 +1,39 @@ +// Copyright (c) 2018 Ultimaker B.V. +// Cura is released under the terms of the LGPLv3 or higher. + +import QtQuick 2.7 +import QtQuick.Controls 2.0 + +import UM 1.3 as UM +import Cura 1.0 as Cura + +Item +{ + width: parent.width + height: childrenRect.height + + Label + { + id: header + text: catalog.i18nc("@header", "Configurations") + font: UM.Theme.getFont("large") + color: UM.Theme.getColor("text") + height: contentHeight + renderType: Text.NativeRendering + + anchors + { + left: parent.left + right: parent.right + } + } + + ConfigurationListView + { + anchors.top: header.bottom + anchors.topMargin: UM.Theme.getSize("default_margin").width + width: parent.width + + outputDevice: Cura.MachineManager.printerOutputDevices.length >= 1 ? Cura.MachineManager.printerOutputDevices[0] : null + } +} \ No newline at end of file diff --git a/resources/qml/Menus/ConfigurationMenu/ConfigurationItem.qml b/resources/qml/Menus/ConfigurationMenu/ConfigurationItem.qml index 7427b5ddff..6ac1e6a2ad 100644 --- a/resources/qml/Menus/ConfigurationMenu/ConfigurationItem.qml +++ b/resources/qml/Menus/ConfigurationMenu/ConfigurationItem.qml @@ -7,143 +7,129 @@ import QtQuick.Controls 2.0 import UM 1.2 as UM import Cura 1.0 as Cura -Rectangle +Button { id: configurationItem property var configuration: null - property var selected: false - signal activateConfiguration() + hoverEnabled: true - height: childrenRect.height - border.width: UM.Theme.getSize("default_lining").width - border.color: updateBorderColor() - color: selected ? UM.Theme.getColor("configuration_item_active") : UM.Theme.getColor("configuration_item") - property var textColor: selected ? UM.Theme.getColor("configuration_item_text_active") : UM.Theme.getColor("configuration_item_text") + height: background.height - function updateBorderColor() + background: Rectangle { - border.color = selected ? UM.Theme.getColor("configuration_item_border_active") : UM.Theme.getColor("configuration_item_border") - } + height: childrenRect.height + color: parent.hovered ? UM.Theme.getColor("action_button_hovered") : UM.Theme.getColor("action_button") + border.color: (parent.checked || parent.hovered) ? UM.Theme.getColor("primary") : UM.Theme.getColor("lining") + border.width: parent.checked ? UM.Theme.getSize("thick_lining").width : UM.Theme.getSize("default_lining").width + radius: UM.Theme.getSize("default_radius").width - Column - { - id: contentColumn - width: parent.width - padding: UM.Theme.getSize("default_margin").width - spacing: Math.round(UM.Theme.getSize("default_margin").height / 2) - - Row + Column { - id: extruderRow + id: contentColumn + width: parent.width + padding: UM.Theme.getSize("wide_margin").width + spacing: Math.round(UM.Theme.getSize("default_margin").height / 2) - width: parent.width - 2 * parent.padding - height: childrenRect.height - - spacing: UM.Theme.getSize("default_margin").width - - Repeater + Row { - id: repeater - height: childrenRect.height - model: configuration.extruderConfigurations - delegate: PrintCoreConfiguration + id: extruderRow + + anchors { - width: Math.round(parent.width / 2) - printCoreConfiguration: modelData - mainColor: textColor + left: parent.left + leftMargin: parent.padding + right: parent.right + rightMargin: parent.padding + } + height: childrenRect.height + + spacing: UM.Theme.getSize("default_margin").width + + Repeater + { + id: repeater + height: childrenRect.height + model: configuration.extruderConfigurations + delegate: PrintCoreConfiguration + { + width: Math.round(parent.width / 2) + printCoreConfiguration: modelData + } + } + } + + //Buildplate row separator + Rectangle + { + id: separator + + visible: buildplateInformation.visible + anchors + { + left: parent.left + leftMargin: parent.padding + right: parent.right + rightMargin: parent.padding + } + height: visible ? Math.round(UM.Theme.getSize("thick_lining").height / 2) : 0 + color: UM.Theme.getColor("text") + } + + Item + { + id: buildplateInformation + + anchors + { + left: parent.left + leftMargin: parent.padding + right: parent.right + rightMargin: parent.padding + } + height: childrenRect.height + visible: configuration.buildplateConfiguration != "" + + UM.RecolorImage + { + id: buildplateIcon + anchors.left: parent.left + width: UM.Theme.getSize("main_window_header_button_icon").width + height: UM.Theme.getSize("main_window_header_button_icon").height + source: UM.Theme.getIcon("buildplate") + color: UM.Theme.getColor("text") + } + + Label + { + id: buildplateLabel + anchors.left: buildplateIcon.right + anchors.verticalCenter: buildplateIcon.verticalCenter + anchors.leftMargin: Math.round(UM.Theme.getSize("default_margin").height / 2) + text: configuration.buildplateConfiguration + renderType: Text.NativeRendering + color: UM.Theme.getColor("text") } } } - //Buildplate row separator - Rectangle + Connections { - id: separator - - visible: buildplateInformation.visible - width: parent.width - 2 * parent.padding - height: visible ? Math.round(UM.Theme.getSize("thick_lining").height / 2) : 0 - color: textColor - } - - Item - { - id: buildplateInformation - width: parent.width - 2 * parent.padding - height: childrenRect.height - visible: configuration.buildplateConfiguration != "" - - UM.RecolorImage { - id: buildplateIcon - anchors.left: parent.left - width: UM.Theme.getSize("main_window_header_button_icon").width - height: UM.Theme.getSize("main_window_header_button_icon").height - sourceSize.width: width - sourceSize.height: height - source: UM.Theme.getIcon("buildplate") - color: textColor - } - - Label + target: Cura.MachineManager + onCurrentConfigurationChanged: { - id: buildplateLabel - anchors.left: buildplateIcon.right - anchors.verticalCenter: buildplateIcon.verticalCenter - anchors.leftMargin: Math.round(UM.Theme.getSize("default_margin").height / 2) - text: configuration.buildplateConfiguration - renderType: Text.NativeRendering - color: textColor + configurationItem.checked = Cura.MachineManager.matchesConfiguration(configuration) } } - } - MouseArea - { - id: mouse - anchors.fill: parent - onClicked: activateConfiguration() - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - onEntered: + Component.onCompleted: { - parent.border.color = UM.Theme.getColor("configuration_item_border_hover") - if (configurationItem.selected == false) - { - configurationItem.color = UM.Theme.getColor("wide_lining") - } - } - onExited: - { - updateBorderColor() - if (configurationItem.selected == false) - { - configurationItem.color = UM.Theme.getColor("configuration_item") - } + configurationItem.checked = Cura.MachineManager.matchesConfiguration(configuration) } } - Connections + onClicked: { - target: Cura.MachineManager - onCurrentConfigurationChanged: { - configurationItem.selected = Cura.MachineManager.matchesConfiguration(configuration) - updateBorderColor() - } - } - - Component.onCompleted: - { - configurationItem.selected = Cura.MachineManager.matchesConfiguration(configuration) - updateBorderColor() - } - - onVisibleChanged: - { - if(visible) - { - // I cannot trigger function updateBorderColor() after visibility change - color = selected ? UM.Theme.getColor("configuration_item_active") : UM.Theme.getColor("configuration_item") - } + Cura.MachineManager.applyRemoteConfiguration(configuration) } } \ No newline at end of file diff --git a/resources/qml/Menus/ConfigurationMenu/ConfigurationListView.qml b/resources/qml/Menus/ConfigurationMenu/ConfigurationListView.qml index 210ff6057f..e7936b69d2 100644 --- a/resources/qml/Menus/ConfigurationMenu/ConfigurationListView.qml +++ b/resources/qml/Menus/ConfigurationMenu/ConfigurationListView.qml @@ -2,8 +2,7 @@ // Cura is released under the terms of the LGPLv3 or higher. import QtQuick 2.7 -import QtQuick.Controls 1.4 -import QtQuick.Controls.Styles 1.4 +import QtQuick.Controls 2.3 import UM 1.2 as UM import Cura 1.0 as Cura @@ -12,9 +11,7 @@ Column { id: base property var outputDevice: null - property var computedHeight: container.height + configurationListHeading.height + 3 * padding height: childrenRect.height + 2 * padding - padding: UM.Theme.getSize("default_margin").width spacing: Math.round(UM.Theme.getSize("default_margin").height / 2) function forceModelUpdate() @@ -27,60 +24,60 @@ Column } } - Label - { - id: configurationListHeading - text: catalog.i18nc("@label:header configurations", "Available configurations") - font: UM.Theme.getFont("large") - width: parent.width - 2 * parent.padding - color: UM.Theme.getColor("configuration_item_text") - } - - Component - { - id: sectionHeading - Rectangle - { - height: childrenRect.height + UM.Theme.getSize("default_margin").height - Label - { - text: section - font: UM.Theme.getFont("default_bold") - color: UM.Theme.getColor("configuration_item_text") - } - } - } - ScrollView { id: container - width: parent.width - parent.padding - height: Math.min(configurationList.contentHeight, 350 * screenScaleFactor) + width: parent.width + readonly property int maximumHeight: 350 * screenScaleFactor + height: Math.round(Math.min(configurationList.height, maximumHeight)) + contentHeight: configurationList.height + clip: true - style: UM.Theme.styles.scrollview - __wheelAreaScrollSpeed: 75 // Scroll three lines in one scroll event + ScrollBar.vertical.policy: (configurationList.height > maximumHeight) ? ScrollBar.AlwaysOn : ScrollBar.AlwaysOff //The AsNeeded policy also hides it when the cursor is away, and we don't want that. + ScrollBar.vertical.background: Rectangle + { + implicitWidth: UM.Theme.getSize("scrollbar").width + radius: width / 2 + color: UM.Theme.getColor("scrollbar_background") + } + ScrollBar.vertical.contentItem: Rectangle + { + implicitWidth: UM.Theme.getSize("scrollbar").width + radius: width / 2 + color: UM.Theme.getColor(parent.pressed ? "scrollbar_handle_down" : parent.hovered ? "scrollbar_handle_hover" : "scrollbar_handle") + } + + ButtonGroup + { + buttons: configurationList.children + } ListView { id: configurationList spacing: Math.round(UM.Theme.getSize("default_margin").height / 2) - width: container.width + width: container.width - ((height > container.maximumHeight) ? container.ScrollBar.vertical.background.width : 0) //Make room for scroll bar if there is any. contentHeight: childrenRect.height + height: childrenRect.height section.property: "modelData.printerType" section.criteria: ViewSection.FullString - section.delegate: sectionHeading + section.delegate: Item + { + height: printerTypeLabel.height + UM.Theme.getSize("default_margin").height + Cura.PrinterTypeLabel + { + id: printerTypeLabel + text: Cura.MachineManager.getAbbreviatedMachineName(section) + } + } model: (outputDevice != null) ? outputDevice.uniqueConfigurations : [] + delegate: ConfigurationItem { - width: parent.width - UM.Theme.getSize("default_margin").width + width: parent.width configuration: modelData - onActivateConfiguration: - { - switchPopupState() - Cura.MachineManager.applyRemoteConfiguration(configuration) - } } } } diff --git a/resources/qml/Menus/ConfigurationMenu/ConfigurationMenu.qml b/resources/qml/Menus/ConfigurationMenu/ConfigurationMenu.qml new file mode 100644 index 0000000000..1d086acc67 --- /dev/null +++ b/resources/qml/Menus/ConfigurationMenu/ConfigurationMenu.qml @@ -0,0 +1,203 @@ +// Copyright (c) 2018 Ultimaker B.V. +// Cura is released under the terms of the LGPLv3 or higher. + +import QtQuick 2.7 +import QtQuick.Controls 2.0 +import QtQuick.Controls.Styles 1.4 + +import UM 1.2 as UM +import Cura 1.0 as Cura + + +/** + * Menu that allows you to select the configuration of the current printer, such + * as the nozzle sizes and materials in each extruder. + */ +Cura.ExpandableComponent +{ + id: base + + Cura.ExtrudersModel + { + id: extrudersModel + } + + UM.I18nCatalog + { + id: catalog + name: "cura" + } + + enum ConfigurationMethod + { + AUTO, + CUSTOM + } + + iconSource: expanded ? UM.Theme.getIcon("arrow_bottom") : UM.Theme.getIcon("arrow_left") + headerItem: Item + { + // Horizontal list that shows the extruders + ListView + { + id: extrudersList + + orientation: ListView.Horizontal + anchors.fill: parent + model: extrudersModel + visible: base.enabled + + delegate: Item + { + height: parent.height + width: Math.round(ListView.view.width / extrudersModel.count) + + // Extruder icon. Shows extruder index and has the same color as the active material. + Cura.ExtruderIcon + { + id: extruderIcon + materialColor: model.color + extruderEnabled: model.enabled + height: parent.height + width: height + } + + // Label for the brand of the material + Label + { + id: brandNameLabel + + text: model.material_brand + elide: Text.ElideRight + font: UM.Theme.getFont("default") + color: UM.Theme.getColor("text_inactive") + renderType: Text.NativeRendering + + anchors + { + left: extruderIcon.right + leftMargin: UM.Theme.getSize("default_margin").width + right: parent.right + rightMargin: UM.Theme.getSize("default_margin").width + } + } + + // Label that shows the name of the material + Label + { + text: model.material + elide: Text.ElideRight + font: UM.Theme.getFont("default") + color: UM.Theme.getColor("text") + renderType: Text.NativeRendering + + anchors + { + left: extruderIcon.right + leftMargin: UM.Theme.getSize("default_margin").width + right: parent.right + rightMargin: UM.Theme.getSize("default_margin").width + top: brandNameLabel.bottom + } + } + } + } + } + + //Disable the menu if there are no materials, variants or build plates to change. + function updateEnabled() + { + var active_definition_id = Cura.MachineManager.activeMachine.definition.id; + var has_materials = Cura.ContainerManager.getContainerMetaDataEntry(active_definition_id, "has_materials"); + var has_variants = Cura.ContainerManager.getContainerMetaDataEntry(active_definition_id, "has_variants"); + var has_buildplates = Cura.ContainerManager.getContainerMetaDataEntry(active_definition_id, "has_variant_buildplates"); + base.enabled = has_materials || has_variants || has_buildplates; //Only let it drop down if there is any configuration that you could change. + } + + Connections + { + target: Cura.MachineManager + onGlobalContainerChanged: base.updateEnabled(); + } + Component.onCompleted: updateEnabled(); + + popupItem: Column + { + id: popupItem + width: base.width - 2 * UM.Theme.getSize("default_margin").width + height: implicitHeight //Required because ExpandableComponent will try to use this to determine the size of the background of the pop-up. + spacing: UM.Theme.getSize("default_margin").height + + property bool is_connected: false //If current machine is connected to a printer. Only evaluated upon making popup visible. + onVisibleChanged: + { + is_connected = Cura.MachineManager.activeMachineNetworkKey !== "" && Cura.MachineManager.printerConnected //Re-evaluate. + } + + property int configuration_method: is_connected ? ConfigurationMenu.ConfigurationMethod.AUTO : ConfigurationMenu.ConfigurationMethod.CUSTOM //Auto if connected to a printer at start-up, or Custom if not. + + Item + { + width: parent.width + height: childrenRect.height + AutoConfiguration + { + id: autoConfiguration + visible: popupItem.configuration_method == ConfigurationMenu.ConfigurationMethod.AUTO + } + + CustomConfiguration + { + id: customConfiguration + visible: popupItem.configuration_method == ConfigurationMenu.ConfigurationMethod.CUSTOM + } + } + + Rectangle + { + id: separator + visible: buttonBar.visible + x: -popupPadding + + width: base.width + height: UM.Theme.getSize("default_lining").height + + color: UM.Theme.getColor("lining") + } + + //Allow switching between custom and auto. + Item + { + id: buttonBar + visible: popupItem.is_connected //Switching only makes sense if the "auto" part is possible. + + width: parent.width + height: childrenRect.height + + Cura.SecondaryButton + { + id: goToCustom + visible: popupItem.configuration_method == ConfigurationMenu.ConfigurationMethod.AUTO + text: catalog.i18nc("@label", "Custom") + + anchors.right: parent.right + + iconSource: UM.Theme.getIcon("arrow_right") + isIconOnRightSide: true + + onClicked: popupItem.configuration_method = ConfigurationMenu.ConfigurationMethod.CUSTOM + } + + Cura.SecondaryButton + { + id: goToAuto + visible: popupItem.configuration_method == ConfigurationMenu.ConfigurationMethod.CUSTOM + text: catalog.i18nc("@label", "Configurations") + + iconSource: UM.Theme.getIcon("arrow_left") + + onClicked: popupItem.configuration_method = ConfigurationMenu.ConfigurationMethod.AUTO + } + } + } +} diff --git a/resources/qml/Menus/ConfigurationMenu/CustomConfiguration.qml b/resources/qml/Menus/ConfigurationMenu/CustomConfiguration.qml new file mode 100644 index 0000000000..8d8f84155a --- /dev/null +++ b/resources/qml/Menus/ConfigurationMenu/CustomConfiguration.qml @@ -0,0 +1,250 @@ +// Copyright (c) 2018 Ultimaker B.V. +// Cura is released under the terms of the LGPLv3 or higher. + +import QtQuick 2.6 +import QtQuick.Controls 2.0 +import QtQuick.Controls 1.1 as OldControls + +import Cura 1.0 as Cura +import UM 1.3 as UM + +Item +{ + UM.I18nCatalog + { + id: catalog + name: "cura" + } + + width: parent.width + height: childrenRect.height + + Label + { + id: header + text: catalog.i18nc("@header", "Custom") + font: UM.Theme.getFont("large") + color: UM.Theme.getColor("text") + height: contentHeight + renderType: Text.NativeRendering + + anchors + { + top: parent.top + left: parent.left + right: parent.right + } + } + + UM.TabRow + { + id: tabBar + anchors.top: header.bottom + anchors.topMargin: UM.Theme.getSize("default_margin").height + visible: extrudersModel.count > 1 + + Repeater + { + id: repeater + model: extrudersModel + delegate: UM.TabRowButton + { + contentItem: Item + { + Cura.ExtruderIcon + { + anchors.horizontalCenter: parent.horizontalCenter + materialColor: model.color + extruderEnabled: model.enabled + width: parent.height + height: parent.height + } + } + onClicked: + { + Cura.ExtruderManager.setActiveExtruderIndex(tabBar.currentIndex) + } + } + } + + //When active extruder changes for some other reason, switch tabs. + //Don't directly link currentIndex to Cura.ExtruderManager.activeExtruderIndex! + //This causes a segfault in Qt 5.11. Something with VisualItemModel removing index -1. We have to use setCurrentIndex instead. + Connections + { + target: Cura.ExtruderManager + onActiveExtruderChanged: + { + tabBar.setCurrentIndex(Cura.ExtruderManager.activeExtruderIndex); + } + } + + //When the model of the extruders is rebuilt, the list of extruders is briefly emptied and rebuilt. + //This causes the currentIndex of the tab to be in an invalid position which resets it to 0. + //Therefore we need to change it back to what it was: The active extruder index. + Connections + { + target: repeater.model + onModelChanged: + { + tabBar.setCurrentIndex(Cura.ExtruderManager.activeExtruderIndex) + } + } + } + + Rectangle + { + width: parent.width + height: childrenRect.height + anchors.top: tabBar.bottom + + radius: tabBar.visible ? UM.Theme.getSize("default_radius").width : 0 + border.width: tabBar.visible ? UM.Theme.getSize("default_lining").width : 0 + border.color: UM.Theme.getColor("lining") + color: UM.Theme.getColor("main_background") + + //Remove rounding and lining at the top. + Rectangle + { + width: parent.width + height: parent.radius + anchors.top: parent.top + color: UM.Theme.getColor("lining") + visible: tabBar.visible + Rectangle + { + anchors + { + left: parent.left + leftMargin: parent.parent.border.width + right: parent.right + rightMargin: parent.parent.border.width + top: parent.top + } + height: parent.parent.radius + color: parent.parent.color + } + } + + Column + { + id: selectors + padding: UM.Theme.getSize("default_margin").width + spacing: UM.Theme.getSize("default_margin").height + + property var model: extrudersModel.items[tabBar.currentIndex] + + readonly property real paddedWidth: parent.width - padding * 2 + property real textWidth: Math.round(paddedWidth * 0.3) + property real controlWidth: paddedWidth - textWidth + + Row + { + height: UM.Theme.getSize("print_setup_item").height + + Label + { + text: catalog.i18nc("@label", "Enabled") + verticalAlignment: Text.AlignVCenter + font: UM.Theme.getFont("default") + color: UM.Theme.getColor("text") + height: parent.height + width: selectors.textWidth + visible: extrudersModel.count > 1 + renderType: Text.NativeRendering + } + + OldControls.CheckBox + { + checked: Cura.MachineManager.activeStack != null ? Cura.MachineManager.activeStack.isEnabled : false + enabled: !checked || Cura.MachineManager.numberExtrudersEnabled > 1 //Disable if it's the last enabled extruder. + height: UM.Theme.getSize("setting_control").height + style: UM.Theme.styles.checkbox + visible: extrudersModel.count > 1 + + /* Use a MouseArea to process the click on this checkbox. + This is necessary because actually clicking the checkbox + causes the "checked" property to be overwritten. After + it's been overwritten, the original link that made it + depend on the active extruder stack is broken. */ + MouseArea + { + anchors.fill: parent + onClicked: Cura.MachineManager.setExtruderEnabled(Cura.ExtruderManager.activeExtruderIndex, !parent.checked) + enabled: parent.enabled + } + } + } + + Row + { + height: UM.Theme.getSize("print_setup_item").height + Label + { + text: catalog.i18nc("@label", "Material") + verticalAlignment: Text.AlignVCenter + font: UM.Theme.getFont("default") + color: UM.Theme.getColor("text") + height: parent.height + width: selectors.textWidth + visible: materialSelection.visible + renderType: Text.NativeRendering + } + + OldControls.ToolButton + { + id: materialSelection + + property bool valueError: Cura.MachineManager.activeStack != null ? Cura.ContainerManager.getContainerMetaDataEntry(Cura.MachineManager.activeStack.material.id, "compatible", "") != "True" : true + property bool valueWarning: !Cura.MachineManager.isActiveQualitySupported + + text: Cura.MachineManager.activeStack != null ? Cura.MachineManager.activeStack.material.name : "" + tooltip: text + visible: Cura.MachineManager.hasMaterials + + height: UM.Theme.getSize("setting_control").height + width: selectors.controlWidth + + style: UM.Theme.styles.sidebar_header_button + activeFocusOnPress: true + menu: Cura.MaterialMenu + { + extruderIndex: Cura.ExtruderManager.activeExtruderIndex + } + } + } + + Row + { + height: UM.Theme.getSize("print_setup_item").height + + Label + { + text: Cura.MachineManager.activeDefinitionVariantsName + verticalAlignment: Text.AlignVCenter + font: UM.Theme.getFont("default") + color: UM.Theme.getColor("text") + height: parent.height + width: selectors.textWidth + visible: variantSelection.visible + renderType: Text.NativeRendering + } + + OldControls.ToolButton + { + id: variantSelection + text: Cura.MachineManager.activeVariantName + tooltip: Cura.MachineManager.activeVariantName; + visible: Cura.MachineManager.hasVariants + + height: UM.Theme.getSize("setting_control").height + width: selectors.controlWidth + style: UM.Theme.styles.sidebar_header_button + activeFocusOnPress: true; + + menu: Cura.NozzleMenu { extruderIndex: Cura.ExtruderManager.activeExtruderIndex } + } + } + } + } +} diff --git a/resources/qml/Menus/ConfigurationMenu/PrintCoreConfiguration.qml b/resources/qml/Menus/ConfigurationMenu/PrintCoreConfiguration.qml index 73fc342d66..885f02d740 100644 --- a/resources/qml/Menus/ConfigurationMenu/PrintCoreConfiguration.qml +++ b/resources/qml/Menus/ConfigurationMenu/PrintCoreConfiguration.qml @@ -5,87 +5,50 @@ import QtQuick 2.7 import QtQuick.Controls 2.0 import UM 1.2 as UM +import Cura 1.0 as Cura - -Column +Row { id: extruderInfo property var printCoreConfiguration - property var mainColor: "black" - spacing: Math.round(UM.Theme.getSize("default_margin").height / 2) - height: childrenRect.height + height: information.height + spacing: UM.Theme.getSize("default_margin").width - Item + //Extruder icon. + Cura.ExtruderIcon { - id: extruder - width: parent.width - height: childrenRect.height + materialColor: printCoreConfiguration.material.color + anchors.verticalCenter: parent.verticalCenter + extruderEnabled: printCoreConfiguration.material.name !== "" && printCoreConfiguration.hotendID !== "" + } + Column + { + id: information Label { - id: extruderLabel - text: catalog.i18nc("@label:extruder label", "Extruder") + text: printCoreConfiguration.material.brand ? printCoreConfiguration.material.brand : " " //Use space so that the height is still correct. renderType: Text.NativeRendering elide: Text.ElideRight - anchors.left: parent.left font: UM.Theme.getFont("default") - color: mainColor + color: UM.Theme.getColor("text_inactive") } - - // Rounded item to show the extruder number - Item + Label { - id: extruderIconItem - anchors.verticalCenter: extruderLabel.verticalCenter - anchors.left: extruderLabel.right - anchors.leftMargin: Math.round(UM.Theme.getSize("default_margin").width / 2) - - width: UM.Theme.getSize("section_icon").width - height: UM.Theme.getSize("section_icon").height - - UM.RecolorImage { - id: mainCircle - anchors.fill: parent - - anchors.centerIn: parent - sourceSize.width: parent.width - sourceSize.height: parent.height - source: UM.Theme.getIcon("extruder_button") - color: mainColor - } - - Label - { - id: extruderNumberText - anchors.centerIn: parent - text: printCoreConfiguration.position + 1 - renderType: Text.NativeRendering - font: UM.Theme.getFont("default") - color: mainColor - } + text: printCoreConfiguration.material.name ? printCoreConfiguration.material.name : " " //Use space so that the height is still correct. + renderType: Text.NativeRendering + elide: Text.ElideRight + font: UM.Theme.getFont("default") + color: UM.Theme.getColor("text") + } + Label + { + text: printCoreConfiguration.hotendID ? printCoreConfiguration.hotendID : " " //Use space so that the height is still correct. + renderType: Text.NativeRendering + elide: Text.ElideRight + font: UM.Theme.getFont("default") + color: UM.Theme.getColor("text_inactive") } - } - - Label - { - id: materialLabel - text: printCoreConfiguration.material == null ? "" : printCoreConfiguration.material.name - renderType: Text.NativeRendering - elide: Text.ElideRight - width: parent.width - font: UM.Theme.getFont("default_bold") - color: mainColor - } - - Label - { - id: printCoreTypeLabel - text: printCoreConfiguration.hotendID - renderType: Text.NativeRendering - elide: Text.ElideRight - width: parent.width - font: UM.Theme.getFont("default") - color: mainColor } } diff --git a/resources/qml/Menus/ConfigurationMenu/QuickConfigurationSelector.qml b/resources/qml/Menus/ConfigurationMenu/QuickConfigurationSelector.qml deleted file mode 100644 index eb6800cb36..0000000000 --- a/resources/qml/Menus/ConfigurationMenu/QuickConfigurationSelector.qml +++ /dev/null @@ -1,243 +0,0 @@ -// Copyright (c) 2018 Ultimaker B.V. -// Cura is released under the terms of the LGPLv3 or higher. - -import QtQuick 2.7 -import QtQuick.Controls 2.0 -import QtQuick.Controls.Styles 1.4 -import QtQuick.Layouts 1.3 - -import QtQuick.Controls 1.1 as OldControls - -import UM 1.2 as UM -import Cura 1.0 as Cura - - -Cura.ExpandableComponent -{ - id: base - - Cura.ExtrudersModel - { - id: extrudersModel - } - - UM.I18nCatalog - { - id: catalog - name: "cura" - } - - iconSource: expanded ? UM.Theme.getIcon("arrow_bottom") : UM.Theme.getIcon("arrow_left") - headerItem: Item - { - // Horizontal list that shows the extruders - ListView - { - id: extrudersList - - orientation: ListView.Horizontal - anchors.fill: parent - model: extrudersModel - - delegate: Item - { - height: parent.height - width: Math.round(ListView.view.width / extrudersModel.rowCount()) - - // Extruder icon. Shows extruder index and has the same color as the active material. - Cura.ExtruderIcon - { - id: extruderIcon - materialColor: model.color - extruderEnabled: model.enabled - anchors.verticalCenter: parent.verticalCenter - } - - // Label for the brand of the material - Label - { - id: brandNameLabel - - text: model.material_brand - elide: Text.ElideRight - font: UM.Theme.getFont("default") - color: UM.Theme.getColor("text") - - anchors - { - left: extruderIcon.right - leftMargin: UM.Theme.getSize("default_margin").width - right: parent.right - rightMargin: UM.Theme.getSize("default_margin").width - } - } - - // Label that shows the name of the material - Label - { - text: model.material - elide: Text.ElideRight - font: UM.Theme.getFont("default") - color: UM.Theme.getColor("text") - - anchors - { - left: extruderIcon.right - leftMargin: UM.Theme.getSize("default_margin").width - right: parent.right - rightMargin: UM.Theme.getSize("default_margin").width - top: brandNameLabel.bottom - } - } - } - } - } - - popupItem: Item - { - width: base.width - 2 * UM.Theme.getSize("default_margin").width - height: 200 - - TabBar - { - id: tabBar - onCurrentIndexChanged: Cura.ExtruderManager.setActiveExtruderIndex(currentIndex) - width: parent.width - height: 50 - Repeater - { - model: extrudersModel - - delegate: TabButton - { - width: ListView.view != null ? Math.round(ListView.view.width / extrudersModel.rowCount()): 0 - height: parent.height - contentItem: Item - { - Cura.ExtruderIcon - { - anchors.horizontalCenter: parent.horizontalCenter - materialColor: model.color - extruderEnabled: model.enabled - width: parent.height - height: parent.height - } - } - } - } - } - - Item - { - id: tabControl - width: parent.width - anchors.top: tabBar.bottom - anchors.bottom: parent.bottom - property var model: extrudersModel.items[tabBar.currentIndex] - property real textWidth: Math.round(width * 0.3) - property real controlWidth: width - textWidth - Column - { - spacing: UM.Theme.getSize("default_margin").height - Row - { - height: UM.Theme.getSize("print_setup_item").height - - Label - { - text: catalog.i18nc("@label", "Enabled") - verticalAlignment: Text.AlignVCenter - font: UM.Theme.getFont("default") - color: UM.Theme.getColor("text") - height: parent.height - width: tabControl.textWidth - renderType: Text.NativeRendering - } - - OldControls.CheckBox - { - checked: tabControl.model != null ? Cura.MachineManager.getExtruder(tabControl.model.index).isEnabled: false - onClicked: Cura.MachineManager.setExtruderEnabled(tabControl.model.index, checked) - height: UM.Theme.getSize("setting_control").height - style: UM.Theme.styles.checkbox - } - } - - Row - { - height: UM.Theme.getSize("print_setup_item").height - Label - { - text: catalog.i18nc("@label", "Material") - verticalAlignment: Text.AlignVCenter - font: UM.Theme.getFont("default") - color: UM.Theme.getColor("text") - height: parent.height - width: tabControl.textWidth - renderType: Text.NativeRendering - } - - OldControls.ToolButton - { - id: materialSelection - - property var activeExtruder: Cura.MachineManager.activeStack - property var hasActiveExtruder: activeExtruder != null - property var currentRootMaterialName: hasActiveExtruder ? activeExtruder.material.name : "" - property var valueError: hasActiveExtruder ? Cura.ContainerManager.getContainerMetaDataEntry(activeExtruder.material.id, "compatible", "") != "True" : true - property var valueWarning: ! Cura.MachineManager.isActiveQualitySupported - - text: currentRootMaterialName - tooltip: currentRootMaterialName - visible: Cura.MachineManager.hasMaterials - - enabled: Cura.ExtruderManager.activeExtruderIndex > -1 - - height: UM.Theme.getSize("setting_control").height - width: tabControl.controlWidth - - style: UM.Theme.styles.sidebar_header_button - activeFocusOnPress: true - menu: Cura.MaterialMenu - { - extruderIndex: Cura.ExtruderManager.activeExtruderIndex - } - - } - } - - Row - { - height: UM.Theme.getSize("print_setup_item").height - - Label - { - text: Cura.MachineManager.activeDefinitionVariantsName - verticalAlignment: Text.AlignVCenter - font: UM.Theme.getFont("default") - color: UM.Theme.getColor("text") - height: parent.height - width: tabControl.textWidth - renderType: Text.NativeRendering - } - - OldControls.ToolButton - { - id: variantSelection - text: Cura.MachineManager.activeVariantName - tooltip: Cura.MachineManager.activeVariantName; - visible: Cura.MachineManager.hasVariants - - height: UM.Theme.getSize("setting_control").height - width: tabControl.controlWidth - style: UM.Theme.styles.sidebar_header_button - activeFocusOnPress: true; - - menu: Cura.NozzleMenu { extruderIndex: Cura.ExtruderManager.activeExtruderIndex } - } - } - } - - } - } -} diff --git a/resources/qml/Menus/ConfigurationMenu/SyncButton.qml b/resources/qml/Menus/ConfigurationMenu/SyncButton.qml deleted file mode 100644 index 558ae1e477..0000000000 --- a/resources/qml/Menus/ConfigurationMenu/SyncButton.qml +++ /dev/null @@ -1,102 +0,0 @@ -// Copyright (c) 2018 Ultimaker B.V. -// Cura is released under the terms of the LGPLv3 or higher. - -import QtQuick 2.7 -import QtQuick.Controls 1.4 -import QtQuick.Controls.Styles 1.4 - -import UM 1.2 as UM -import Cura 1.0 as Cura - -Button -{ - id: base - property var outputDevice: null - property var matched: updateOnSync() - text: matched == true ? catalog.i18nc("@label:extruder label", "Yes") : catalog.i18nc("@label:extruder label", "No") - width: parent.width - height: parent.height - - function updateOnSync() - { - if (outputDevice != undefined) - { - for (var index in outputDevice.uniqueConfigurations) - { - var configuration = outputDevice.uniqueConfigurations[index] - if (Cura.MachineManager.matchesConfiguration(configuration)) - { - base.matched = true; - return; - } - } - } - base.matched = false; - } - - style: ButtonStyle - { - background: Rectangle - { - color: - { - if(control.pressed) - { - return UM.Theme.getColor("machine_selector_active"); - } - else if(control.hovered) - { - return UM.Theme.getColor("machine_selector_hover"); - } - else - { - return UM.Theme.getColor("machine_selector_bar"); - } - } - Behavior on color { ColorAnimation { duration: 50; } } - - UM.RecolorImage - { - id: downArrow - anchors.verticalCenter: parent.verticalCenter - anchors.right: parent.right - anchors.rightMargin: UM.Theme.getSize("default_margin").width - width: UM.Theme.getSize("standard_arrow").width - height: UM.Theme.getSize("standard_arrow").height - sourceSize.width: width - sourceSize.height: height - color: UM.Theme.getColor("text_emphasis") - source: UM.Theme.getIcon("arrow_bottom") - } - UM.RecolorImage - { - id: sidebarComboBoxLabel - anchors.left: parent.left - anchors.leftMargin: UM.Theme.getSize("default_margin").width - anchors.verticalCenter: parent.verticalCenter; - - width: UM.Theme.getSize("printer_sync_icon").width - height: UM.Theme.getSize("printer_sync_icon").height - - color: control.matched ? UM.Theme.getColor("printer_config_matched") : UM.Theme.getColor("printer_config_mismatch") - source: UM.Theme.getIcon("tab_status_connected") - sourceSize.width: width - sourceSize.height: height - } - } - label: Label {} - } - - Connections - { - target: outputDevice - onUniqueConfigurationsChanged: updateOnSync() - } - - Connections - { - target: Cura.MachineManager - onCurrentConfigurationChanged: updateOnSync() - onOutputDevicesChanged: updateOnSync() - } -} \ No newline at end of file diff --git a/resources/qml/Menus/ProfileMenu.qml b/resources/qml/Menus/ProfileMenu.qml index fd46d2ef72..bf950aa409 100644 --- a/resources/qml/Menus/ProfileMenu.qml +++ b/resources/qml/Menus/ProfileMenu.qml @@ -37,7 +37,7 @@ Menu MenuSeparator { id: customSeparator - visible: Cura.CustomQualityProfilesDropDownMenuModel.rowCount > 0 + visible: Cura.CustomQualityProfilesDropDownMenuModel.count > 0 } Instantiator @@ -48,7 +48,7 @@ Menu Connections { target: Cura.CustomQualityProfilesDropDownMenuModel - onModelReset: customSeparator.visible = Cura.CustomQualityProfilesDropDownMenuModel.rowCount() > 0 + onModelReset: customSeparator.visible = Cura.CustomQualityProfilesDropDownMenuModel.count > 0 } MenuItem @@ -62,12 +62,12 @@ Menu onObjectAdded: { - customSeparator.visible = model.rowCount() > 0; + customSeparator.visible = model.count > 0; menu.insertItem(index, object); } onObjectRemoved: { - customSeparator.visible = model.rowCount() > 0; + customSeparator.visible = model.count > 0; menu.removeItem(object); } } diff --git a/resources/qml/ObjectsList.qml b/resources/qml/ObjectsList.qml index 8c8eaa16ae..8f45b3744f 100644 --- a/resources/qml/ObjectsList.qml +++ b/resources/qml/ObjectsList.qml @@ -1,4 +1,4 @@ -// Copyright (c) 2017 Ultimaker B.V. +// Copyright (c) 2018 Ultimaker B.V. // Cura is released under the terms of the LGPLv3 or higher. import QtQuick 2.2 @@ -55,7 +55,6 @@ Rectangle { width: control.width height: control.height - sourceSize.width: width sourceSize.height: width color: UM.Theme.getColor("setting_control_text") source: collapsed ? UM.Theme.getIcon("arrow_left") : UM.Theme.getIcon("arrow_bottom") diff --git a/resources/qml/Preferences/MachinesPage.qml b/resources/qml/Preferences/MachinesPage.qml index 4dc5465dc6..bc75b9bc72 100644 --- a/resources/qml/Preferences/MachinesPage.qml +++ b/resources/qml/Preferences/MachinesPage.qml @@ -21,8 +21,10 @@ UM.ManagementPage function activeMachineIndex() { - for(var i = 0; i < model.rowCount(); i++) { - if (model.getItem(i).id == Cura.MachineManager.activeMachineId) { + for(var i = 0; i < model.count; i++) + { + if (model.getItem(i).id == Cura.MachineManager.activeMachineId) + { return i; } } @@ -47,7 +49,7 @@ UM.ManagementPage { text: catalog.i18nc("@action:button", "Remove"); iconName: "list-remove"; - enabled: base.currentItem != null && model.rowCount() > 1 + enabled: base.currentItem != null && model.count > 1 onClicked: confirmDialog.open(); }, Button diff --git a/resources/qml/Preferences/Materials/MaterialsBrandSection.qml b/resources/qml/Preferences/Materials/MaterialsBrandSection.qml index c8f391dfb0..a3a0e4708f 100644 --- a/resources/qml/Preferences/Materials/MaterialsBrandSection.qml +++ b/resources/qml/Preferences/Materials/MaterialsBrandSection.qml @@ -55,7 +55,8 @@ Rectangle text: "" implicitWidth: UM.Theme.getSize("favorites_button").width implicitHeight: UM.Theme.getSize("favorites_button").height - UM.RecolorImage { + UM.RecolorImage + { anchors { verticalCenter: parent.verticalCenter @@ -63,8 +64,6 @@ Rectangle } width: UM.Theme.getSize("standard_arrow").width height: UM.Theme.getSize("standard_arrow").height - sourceSize.width: width - sourceSize.height: height color: "black" source: brand_section.expanded ? UM.Theme.getIcon("arrow_bottom") : UM.Theme.getIcon("arrow_left") } diff --git a/resources/qml/Preferences/Materials/MaterialsList.qml b/resources/qml/Preferences/Materials/MaterialsList.qml index 00bead9650..61f92db84c 100644 --- a/resources/qml/Preferences/Materials/MaterialsList.qml +++ b/resources/qml/Preferences/Materials/MaterialsList.qml @@ -57,7 +57,7 @@ Item var currentItemId = base.currentItem == null ? "" : base.currentItem.root_material_id search_root_id = currentItemId } - for (var material_idx = 0; material_idx < genericMaterialsModel.rowCount(); material_idx++) + for (var material_idx = 0; material_idx < genericMaterialsModel.count; material_idx++) { var material = genericMaterialsModel.getItem(material_idx) if (material.root_material_id == search_root_id) @@ -72,15 +72,15 @@ Item return true } } - for (var brand_idx = 0; brand_idx < materialsModel.rowCount(); brand_idx++) + for (var brand_idx = 0; brand_idx < materialsModel.count; brand_idx++) { var brand = materialsModel.getItem(brand_idx) var types_model = brand.material_types - for (var type_idx = 0; type_idx < types_model.rowCount(); type_idx++) + for (var type_idx = 0; type_idx < types_model.count; type_idx++) { var type = types_model.getItem(type_idx) var colors_model = type.colors - for (var material_idx = 0; material_idx < colors_model.rowCount(); material_idx++) + for (var material_idx = 0; material_idx < colors_model.count; material_idx++) { var material = colors_model.getItem(material_idx) if (material.root_material_id == search_root_id) diff --git a/resources/qml/Preferences/Materials/MaterialsSlot.qml b/resources/qml/Preferences/Materials/MaterialsSlot.qml index a5af17f47a..a706aaf2b9 100644 --- a/resources/qml/Preferences/Materials/MaterialsSlot.qml +++ b/resources/qml/Preferences/Materials/MaterialsSlot.qml @@ -95,8 +95,6 @@ Rectangle } width: UM.Theme.getSize("favorites_button_icon").width height: UM.Theme.getSize("favorites_button_icon").height - sourceSize.width: width - sourceSize.height: height color: { if (favorite_button.hovered) diff --git a/resources/qml/Preferences/Materials/MaterialsTypeSection.qml b/resources/qml/Preferences/Materials/MaterialsTypeSection.qml index f62fc4ee16..f98c19e0b3 100644 --- a/resources/qml/Preferences/Materials/MaterialsTypeSection.qml +++ b/resources/qml/Preferences/Materials/MaterialsTypeSection.qml @@ -74,8 +74,6 @@ Rectangle } width: UM.Theme.getSize("standard_arrow").width height: UM.Theme.getSize("standard_arrow").height - sourceSize.width: width - sourceSize.height: height color: "black" source: material_type_section.expanded ? UM.Theme.getIcon("arrow_bottom") : UM.Theme.getIcon("arrow_left") } diff --git a/resources/qml/Preferences/ProfilesPage.qml b/resources/qml/Preferences/ProfilesPage.qml index ba0c2848a5..d7ffbb3152 100644 --- a/resources/qml/Preferences/ProfilesPage.qml +++ b/resources/qml/Preferences/ProfilesPage.qml @@ -188,21 +188,27 @@ Item Connections { target: qualitiesModel - onItemsChanged: { + onItemsChanged: + { var toSelectItemName = base.currentItem == null ? "" : base.currentItem.name; - if (newQualityNameToSelect != "") { + if (newQualityNameToSelect != "") + { toSelectItemName = newQualityNameToSelect; } var newIdx = -1; // Default to nothing if nothing can be found - if (toSelectItemName != "") { + if (toSelectItemName != "") + { // Select the required quality name if given - for (var idx = 0; idx < qualitiesModel.rowCount(); ++idx) { + for (var idx = 0; idx < qualitiesModel.count; ++idx) + { var item = qualitiesModel.getItem(idx); - if (item.name == toSelectItemName) { + if (item.name == toSelectItemName) + { // Switch to the newly created profile if needed newIdx = idx; - if (base.toActivateNewQuality) { + if (base.toActivateNewQuality) + { // Activate this custom quality if required Cura.MachineManager.setQualityChangesGroup(item.quality_changes_group); } @@ -382,9 +388,11 @@ Item var selectedItemName = Cura.MachineManager.activeQualityOrQualityChangesName; // Select the required quality name if given - for (var idx = 0; idx < qualitiesModel.rowCount(); idx++) { + for (var idx = 0; idx < qualitiesModel.count; idx++) + { var item = qualitiesModel.getItem(idx); - if (item.name == selectedItemName) { + if (item.name == selectedItemName) + { currentIndex = idx; break; } diff --git a/resources/qml/Preferences/SettingVisibilityPage.qml b/resources/qml/Preferences/SettingVisibilityPage.qml index 2edbeee960..3f7571a170 100644 --- a/resources/qml/Preferences/SettingVisibilityPage.qml +++ b/resources/qml/Preferences/SettingVisibilityPage.qml @@ -50,7 +50,7 @@ UM.PreferencesPage { return Qt.Unchecked } - else if(definitionsModel.visibleCount == definitionsModel.rowCount(null)) + else if(definitionsModel.visibleCount == definitionsModel.count) { return Qt.Checked } diff --git a/resources/qml/PrinterOutput/ExtruderBox.qml b/resources/qml/PrinterOutput/ExtruderBox.qml index f5a1bd75c4..247bb3a27d 100644 --- a/resources/qml/PrinterOutput/ExtruderBox.qml +++ b/resources/qml/PrinterOutput/ExtruderBox.qml @@ -47,7 +47,7 @@ Item { id: extruderTargetTemperature text: Math.round(extruderModel.targetHotendTemperature) + "°C" - font: UM.Theme.getFont("small") + font: UM.Theme.getFont("default_bold") color: UM.Theme.getColor("text_inactive") anchors.right: parent.right anchors.rightMargin: UM.Theme.getSize("default_margin").width diff --git a/resources/qml/PrinterOutput/HeatedBedBox.qml b/resources/qml/PrinterOutput/HeatedBedBox.qml index 8c99814e02..33cf5cd1e2 100644 --- a/resources/qml/PrinterOutput/HeatedBedBox.qml +++ b/resources/qml/PrinterOutput/HeatedBedBox.qml @@ -35,7 +35,7 @@ Item { id: bedTargetTemperature text: printerModel != null ? printerModel.targetBedTemperature + "°C" : "" - font: UM.Theme.getFont("small") + font: UM.Theme.getFont("default_bold") color: UM.Theme.getColor("text_inactive") anchors.right: parent.right anchors.rightMargin: UM.Theme.getSize("default_margin").width diff --git a/resources/qml/PrinterOutput/OutputDeviceHeader.qml b/resources/qml/PrinterOutput/OutputDeviceHeader.qml index e6328546ef..16280eab5f 100644 --- a/resources/qml/PrinterOutput/OutputDeviceHeader.qml +++ b/resources/qml/PrinterOutput/OutputDeviceHeader.qml @@ -43,7 +43,7 @@ Item { id: outputDeviceAddressLabel text: (outputDevice != null && outputDevice.address != null) ? outputDevice.address : "" - font: UM.Theme.getFont("small") + font: UM.Theme.getFont("default_bold") color: UM.Theme.getColor("text_inactive") anchors.top: outputDeviceNameLabel.bottom anchors.left: parent.left @@ -54,7 +54,7 @@ Item { text: outputDevice != null ? "" : catalog.i18nc("@info:status", "The printer is not connected.") color: outputDevice != null && outputDevice.acceptsCommands ? UM.Theme.getColor("setting_control_text") : UM.Theme.getColor("setting_control_disabled_text") - font: UM.Theme.getFont("very_small") + font: UM.Theme.getFont("default") wrapMode: Text.WordWrap anchors.left: parent.left anchors.leftMargin: UM.Theme.getSize("default_margin").width diff --git a/resources/qml/PrinterSelector/MachineSelector.qml b/resources/qml/PrinterSelector/MachineSelector.qml index 15cd773c90..95abfd6644 100644 --- a/resources/qml/PrinterSelector/MachineSelector.qml +++ b/resources/qml/PrinterSelector/MachineSelector.qml @@ -25,29 +25,52 @@ Cura.ExpandableComponent name: "cura" } - headerItem: Cura.IconLabel + headerItem: Item { - text: isNetworkPrinter ? Cura.MachineManager.activeMachineNetworkGroupName : Cura.MachineManager.activeMachineName - source: - { - if (isNetworkPrinter) - { - if (machineSelector.outputDevice != null && machineSelector.outputDevice.clusterSize > 1) - { - return UM.Theme.getIcon("printer_group") - } - return UM.Theme.getIcon("printer_single") - } - return "" - } - font: UM.Theme.getFont("medium") - color: UM.Theme.getColor("text") - iconSize: UM.Theme.getSize("machine_selector_icon").width + implicitHeight: icon.height UM.RecolorImage { id: icon + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + + source: + { + if (isNetworkPrinter) + { + if (machineSelector.outputDevice != null && machineSelector.outputDevice.clusterSize > 1) + { + return UM.Theme.getIcon("printer_group") + } + return UM.Theme.getIcon("printer_single") + } + return "" + } + width: UM.Theme.getSize("machine_selector_icon").width + height: width + + color: UM.Theme.getColor("machine_selector_printer_icon") + visible: source != "" + } + + Label + { + id: label + anchors.left: icon.visible ? icon.right : parent.left + anchors.right: parent.right + anchors.leftMargin: UM.Theme.getSize("thin_margin").width + anchors.verticalCenter: icon.verticalCenter + text: isNetworkPrinter ? Cura.MachineManager.activeMachineNetworkGroupName : Cura.MachineManager.activeMachineName + elide: Text.ElideRight + color: UM.Theme.getColor("text") + font: UM.Theme.getFont("medium") + renderType: Text.NativeRendering + } + + UM.RecolorImage + { anchors { bottom: parent.bottom @@ -59,9 +82,6 @@ Cura.ExpandableComponent width: UM.Theme.getSize("printer_status_icon").width height: UM.Theme.getSize("printer_status_icon").height - sourceSize.width: width - sourceSize.height: height - color: UM.Theme.getColor("primary") visible: isNetworkPrinter && isPrinterConnected diff --git a/resources/qml/PrinterSelector/MachineSelectorList.qml b/resources/qml/PrinterSelector/MachineSelectorList.qml index 445940ab50..d831f4eb5c 100644 --- a/resources/qml/PrinterSelector/MachineSelectorList.qml +++ b/resources/qml/PrinterSelector/MachineSelectorList.qml @@ -13,7 +13,7 @@ Column Label { - text: catalog.i18nc("@label", "Network connected printers") + text: catalog.i18nc("@label", "Connected printers") visible: networkedPrintersModel.items.length > 0 leftPadding: UM.Theme.getSize("default_margin").width height: visible ? contentHeight + 2 * UM.Theme.getSize("default_margin").height : 0 diff --git a/resources/qml/PrinterSelector/PrinterTypeLabel.qml b/resources/qml/PrinterTypeLabel.qml similarity index 95% rename from resources/qml/PrinterSelector/PrinterTypeLabel.qml rename to resources/qml/PrinterTypeLabel.qml index cd9f3b9743..7feae32e16 100644 --- a/resources/qml/PrinterSelector/PrinterTypeLabel.qml +++ b/resources/qml/PrinterTypeLabel.qml @@ -28,7 +28,7 @@ Item anchors.verticalCenter: parent.verticalCenter anchors.horizontalCenter: parent.horizontalCenter renderType: Text.NativeRendering - font: UM.Theme.getFont("very_small") + font: UM.Theme.getFont("default") color: UM.Theme.getColor("text") } } \ No newline at end of file diff --git a/resources/qml/Settings/SettingCategory.qml b/resources/qml/Settings/SettingCategory.qml index aafe36c546..196b2d6b97 100644 --- a/resources/qml/Settings/SettingCategory.qml +++ b/resources/qml/Settings/SettingCategory.qml @@ -129,23 +129,26 @@ Button anchors.rightMargin: UM.Theme.getSize("default_margin").width width: UM.Theme.getSize("standard_arrow").width height: UM.Theme.getSize("standard_arrow").height - sourceSize.width: width sourceSize.height: width color: { if (!base.enabled) { return UM.Theme.getColor("setting_category_disabled_text") - } else if ((base.hovered || base.activeFocus) && base.checkable && base.checked) + } + else if ((base.hovered || base.activeFocus) && base.checkable && base.checked) { return UM.Theme.getColor("setting_category_active_hover_text") - } else if (base.pressed || (base.checkable && base.checked)) + } + else if (base.pressed || (base.checkable && base.checked)) { return UM.Theme.getColor("setting_category_active_text") - } else if (base.hovered || base.activeFocus) + } + else if (base.hovered || base.activeFocus) { return UM.Theme.getColor("setting_category_hover_text") - } else + } + else { return UM.Theme.getColor("setting_category_text") } diff --git a/resources/qml/Settings/SettingCheckBox.qml b/resources/qml/Settings/SettingCheckBox.qml index d37754d27c..fb2d5a2f4d 100644 --- a/resources/qml/Settings/SettingCheckBox.qml +++ b/resources/qml/Settings/SettingCheckBox.qml @@ -115,12 +115,12 @@ SettingItem return UM.Theme.getColor("setting_control_border") } - UM.RecolorImage { + UM.RecolorImage + { anchors.verticalCenter: parent.verticalCenter anchors.horizontalCenter: parent.horizontalCenter width: Math.round(parent.width / 2.5) height: Math.round(parent.height / 2.5) - sourceSize.width: width sourceSize.height: width color: !enabled ? UM.Theme.getColor("setting_control_disabled_text") : UM.Theme.getColor("setting_control_text"); source: UM.Theme.getIcon("check") diff --git a/resources/qml/Settings/SettingOptionalExtruder.qml b/resources/qml/Settings/SettingOptionalExtruder.qml index a3c1422b30..5f0d8327f8 100644 --- a/resources/qml/Settings/SettingOptionalExtruder.qml +++ b/resources/qml/Settings/SettingOptionalExtruder.qml @@ -1,5 +1,5 @@ -// Copyright (c) 2016 Ultimaker B.V. -// Uranium is released under the terms of the LGPLv3 or higher. +// Copyright (c) 2018 Ultimaker B.V. +// Cura is released under the terms of the LGPLv3 or higher. import QtQuick 2.7 import QtQuick.Controls 2.0 @@ -31,12 +31,15 @@ SettingItem { forceActiveFocus(); propertyProvider.setPropertyValue("value", model.getItem(index).index); - } else + } + else { if (propertyProvider.properties.value == -1) { - control.currentIndex = model.rowCount() - 1; // we know the last item is "Not overriden" - } else { + control.currentIndex = model.count - 1; // we know the last item is "Not overriden" + } + else + { control.currentIndex = propertyProvider.properties.value; // revert to the old value } } diff --git a/resources/qml/Settings/SettingView.qml b/resources/qml/Settings/SettingView.qml index ef1f123953..bb624bcbde 100644 --- a/resources/qml/Settings/SettingView.qml +++ b/resources/qml/Settings/SettingView.qml @@ -1,5 +1,5 @@ -// Copyright (c) 2017 Ultimaker B.V. -// Uranium is released under the terms of the LGPLv3 or higher. +// Copyright (c) 2018 Ultimaker B.V. +// Cura is released under the terms of the LGPLv3 or higher. import QtQuick 2.7 import QtQuick.Controls 1.1 @@ -129,13 +129,14 @@ Item } style: ButtonStyle { - background: Item { - UM.RecolorImage { + background: Item + { + UM.RecolorImage + { anchors.verticalCenter: parent.verticalCenter anchors.horizontalCenter: parent.horizontalCenter width: UM.Theme.getSize("standard_arrow").width height: UM.Theme.getSize("standard_arrow").height - sourceSize.width: width sourceSize.height: width color: control.enabled ? UM.Theme.getColor("setting_category_text") : UM.Theme.getColor("setting_category_disabled_text") source: UM.Theme.getIcon("menu") diff --git a/resources/qml/SidebarSimple.qml b/resources/qml/SidebarSimple.qml index 5e723a3d70..fb4d52979d 100644 --- a/resources/qml/SidebarSimple.qml +++ b/resources/qml/SidebarSimple.qml @@ -106,7 +106,7 @@ Item var availableMin = -1 var availableMax = -1 - for (var i = 0; i < Cura.QualityProfilesDropDownMenuModel.rowCount(); i++) + for (var i = 0; i < Cura.QualityProfilesDropDownMenuModel.count; i++) { var qualityItem = Cura.QualityProfilesDropDownMenuModel.getItem(i) @@ -183,7 +183,7 @@ Item qualityModel.existingQualityProfile = 0 // check, the ticks count cannot be less than zero - qualityModel.totalTicks = Math.max(0, Cura.QualityProfilesDropDownMenuModel.rowCount() - 1) + qualityModel.totalTicks = Math.max(0, Cura.QualityProfilesDropDownMenuModel.count - 1) } } @@ -731,7 +731,6 @@ Item { anchors.fill: parent anchors.margins: 2 * screenScaleFactor - sourceSize.width: width sourceSize.height: width source: UM.Theme.getIcon(model.icon) color: UM.Theme.getColor("quality_slider_unavailable") @@ -1156,7 +1155,7 @@ Item function populateExtruderModel() { extruderModel.clear(); - for(var extruderNumber = 0; extruderNumber < extruders.rowCount() ; extruderNumber++) + for(var extruderNumber = 0; extruderNumber < extruders.count; extruderNumber++) { extruderModel.append({ text: extruders.getItem(extruderNumber).name, diff --git a/resources/qml/Toolbar.qml b/resources/qml/Toolbar.qml index 5fbddea9ac..1e335472d4 100644 --- a/resources/qml/Toolbar.qml +++ b/resources/qml/Toolbar.qml @@ -62,7 +62,7 @@ Item enabled: model.enabled && UM.Selection.hasSelection && UM.Controller.toolsEnabled isTopElement: toolsModel.getItem(0).id == model.id - isBottomElement: toolsModel.getItem(toolsModel.rowCount() - 1).id == model.id + isBottomElement: toolsModel.getItem(toolsModel.count - 1).id == model.id toolItem: UM.RecolorImage { diff --git a/resources/qml/ViewsSelector.qml b/resources/qml/ViewsSelector.qml index e9fdd57177..1e42a0b3ba 100644 --- a/resources/qml/ViewsSelector.qml +++ b/resources/qml/ViewsSelector.qml @@ -19,7 +19,7 @@ Cura.ExpandableComponent property var activeView: { - for (var i = 0; i < viewModel.rowCount(); i++) + for (var i = 0; i < viewModel.count; i++) { if (viewModel.items[i].active) { @@ -74,6 +74,8 @@ Cura.ExpandableComponent { id: viewSelectorPopup width: viewSelector.width - 2 * viewSelector.popupPadding + leftPadding: UM.Theme.getSize("default_lining").width + rightPadding: UM.Theme.getSize("default_lining").width // For some reason the height/width of the column gets set to 0 if this is not set... Component.onCompleted: @@ -91,7 +93,7 @@ Cura.ExpandableComponent { id: viewsSelectorButton text: model.name - width: parent.width + width: parent.width - viewSelectorPopup.leftPadding - viewSelectorPopup.rightPadding height: UM.Theme.getSize("action_button").height leftPadding: UM.Theme.getSize("default_margin").width rightPadding: UM.Theme.getSize("default_margin").width diff --git a/resources/qml/qmldir b/resources/qml/qmldir index 2475f398f8..7e57119bc6 100644 --- a/resources/qml/qmldir +++ b/resources/qml/qmldir @@ -1,7 +1,6 @@ module Cura MachineSelector 1.0 MachineSelector.qml -QuickConfigurationSelector 1.0 QuickConfigurationSelector.qml CustomConfigurationSelector 1.0 CustomConfigurationSelector.qml PrintSetupSelector 1.0 PrintSetupSelector.qml ActionButton 1.0 ActionButton.qml diff --git a/resources/themes/cura-dark/theme.json b/resources/themes/cura-dark/theme.json index 34b944b25b..d9ef74ebb9 100644 --- a/resources/themes/cura-dark/theme.json +++ b/resources/themes/cura-dark/theme.json @@ -15,7 +15,7 @@ "primary_hover": [48, 182, 231, 255], "primary_text": [255, 255, 255, 204], "border": [127, 127, 127, 255], - "secondary": [241, 242, 242, 255], + "secondary": [95, 95, 95, 255], "main_window_header_button_text_inactive": [128, 128, 128, 255], "main_window_header_button_text_hovered": [255, 255, 255, 255], @@ -196,14 +196,6 @@ "layerview_support_interface": [64, 192, 255, 255], "layerview_nozzle": [181, 166, 66, 120], - "configuration_item": [0, 0, 0, 0], - "configuration_item_active": [12, 169, 227, 179], - "configuration_item_text": [255, 255, 255, 255], - "configuration_item_text_active": [255, 255, 255, 255], - "configuration_item_border": [255, 255, 255, 255], - "configuration_item_border_active": [12, 169, 227, 179], - "configuration_item_border_hover": [12, 169, 227, 179], - "material_compatibility_warning": [255, 255, 255, 255], "quality_slider_unavailable": [179, 179, 179, 255], diff --git a/resources/themes/cura-light/images/header_pattern.svg b/resources/themes/cura-light/images/header_pattern.svg index 2a9de2f3e9..eff5f01cfa 100644 --- a/resources/themes/cura-light/images/header_pattern.svg +++ b/resources/themes/cura-light/images/header_pattern.svg @@ -1 +1,1901 @@ -Pattern \ No newline at end of file + + + + Desktop HD + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/resources/themes/cura-light/images/logo_about.svg b/resources/themes/cura-light/images/logo_about.svg new file mode 100644 index 0000000000..34301fd6c9 --- /dev/null +++ b/resources/themes/cura-light/images/logo_about.svg @@ -0,0 +1,172 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/themes/cura-light/styles.qml b/resources/themes/cura-light/styles.qml index f2ad2b6f4a..30cf42859a 100755 --- a/resources/themes/cura-light/styles.qml +++ b/resources/themes/cura-light/styles.qml @@ -73,7 +73,6 @@ QtObject anchors.rightMargin: Theme.getSize("default_margin").width width: Theme.getSize("standard_arrow").width height: Theme.getSize("standard_arrow").height - sourceSize.width: width sourceSize.height: width color: control.enabled ? Theme.getColor("setting_category_text") : Theme.getColor("setting_category_disabled_text") source: Theme.getIcon("arrow_bottom") @@ -146,7 +145,7 @@ QtObject text: control.text anchors.verticalCenter: parent.verticalCenter anchors.horizontalCenter: parent.horizontalCenter - font: UM.Theme.getFont("medium_bold") + font: UM.Theme.getFont("medium") color: { if (control.checked) @@ -257,7 +256,6 @@ QtObject anchors.bottomMargin: Theme.getSize("button").height - Math.round(Theme.getSize("button_icon").height / 4) width: Theme.getSize("standard_arrow").width height: Theme.getSize("standard_arrow").height - sourceSize.width: width sourceSize.height: width visible: control.menu != null; color: @@ -529,7 +527,7 @@ QtObject implicitWidth: Theme.getSize("checkbox").width implicitHeight: Theme.getSize("checkbox").height - color: (control.hovered || control._hovered) ? Theme.getColor("checkbox_hover") : Theme.getColor("checkbox") + color: (control.hovered || control._hovered) ? Theme.getColor("checkbox_hover") : (control.enabled ? Theme.getColor("checkbox") : Theme.getColor("checkbox_disabled")) Behavior on color { ColorAnimation { duration: 50; } } radius: control.exclusiveGroup ? Math.round(Theme.getSize("checkbox").width / 2) : 0 @@ -543,7 +541,6 @@ QtObject anchors.horizontalCenter: parent.horizontalCenter width: Math.round(parent.width / 2.5) height: Math.round(parent.height / 2.5) - sourceSize.width: width sourceSize.height: width color: Theme.getColor("checkbox_mark") source: control.exclusiveGroup ? Theme.getIcon("dot") : Theme.getIcon("check") @@ -585,7 +582,6 @@ QtObject anchors.horizontalCenter: parent.horizontalCenter width: Math.round(parent.width / 2.5) height: Math.round(parent.height / 2.5) - sourceSize.width: width sourceSize.height: width color: Theme.getColor("checkbox_mark") source: @@ -836,7 +832,6 @@ QtObject anchors.horizontalCenter: parent.horizontalCenter width: Math.floor(control.width / 2) height: Math.floor(control.height / 2) - sourceSize.width: width sourceSize.height: width color: { diff --git a/resources/themes/cura-light/theme.json b/resources/themes/cura-light/theme.json index dfad5cfd17..2d7e92be4d 100644 --- a/resources/themes/cura-light/theme.json +++ b/resources/themes/cura-light/theme.json @@ -41,12 +41,12 @@ "family": "Noto Sans" }, "small": { - "size": 1.0, - "weight": 63, + "size": 0.85, + "weight": 50, "family": "Noto Sans" }, "very_small": { - "size": 1.0, + "size": 0.7, "weight": 50, "family": "Noto Sans" }, @@ -64,12 +64,6 @@ "size": 1.15, "weight": 50, "family": "Noto Sans" - }, - "extruder_icon": - { - "size": 0.7, - "weight": 50, - "family": "Noto Sans" } }, @@ -99,14 +93,14 @@ "secondary_button_hover": [228, 228, 228, 255], "secondary_button_text": [30, 102, 215, 255], - "main_window_header_background": [10, 8, 80, 255], + "main_window_header_background": [8, 7, 63, 255], "main_window_header_background_gradient": [25, 23, 91, 255], - "main_window_header_button_text_active": [10, 8, 80, 255], + "main_window_header_button_text_active": [8, 7, 63, 255], "main_window_header_button_text_inactive": [255, 255, 255, 255], "main_window_header_button_text_hovered": [255, 255, 255, 255], "main_window_header_button_background_active": [255, 255, 255, 255], "main_window_header_button_background_inactive": [255, 255, 255, 0], - "main_window_header_button_background_hovered": [255, 255, 255, 102], + "main_window_header_button_background_hovered": [117, 114, 159, 255], "account_widget_outline_active": [70, 66, 126, 255], @@ -114,12 +108,13 @@ "machine_selector_active": [68, 72, 75, 255], "machine_selector_hover": [68, 72, 75, 255], "machine_selector_text_active": [255, 255, 255, 255], + "machine_selector_printer_icon": [8, 7, 63, 255], "action_panel_secondary": [27, 95, 202, 255], "toolbar_background": [255, 255, 255, 255], - "printer_type_label_background": [171, 171, 191, 255], + "printer_type_label_background": [228, 228, 242, 255], "text": [0, 0, 0, 255], "text_detail": [174, 174, 174, 128], @@ -133,9 +128,9 @@ "text_scene_hover": [70, 84, 113, 255], "error": [255, 140, 0, 255], - "warning": [255, 190, 35, 255], + "warning": [245, 166, 35, 255], - "toolbar_button_text": [10, 8, 80, 255], + "toolbar_button_text": [8, 7, 63, 255], "toolbar_button_hover": [232, 242, 252, 255], "toolbar_button_active": [232, 242, 252, 255], "toolbar_button_active_hover": [232, 242, 252, 255], @@ -150,9 +145,9 @@ "button_text_active_hover": [255, 255, 255, 255], "small_button": [0, 0, 0, 0], - "small_button_hover": [10, 8, 80, 255], - "small_button_active": [10, 8, 80, 255], - "small_button_active_hover": [10, 8, 80, 255], + "small_button_hover": [8, 7, 63, 255], + "small_button_active": [8, 7, 63, 255], + "small_button_active_hover": [8, 7, 63, 255], "small_button_text": [171, 171, 191, 255], "small_button_text_hover": [255, 255, 255, 255], "small_button_text_active": [255, 255, 255, 255], @@ -228,8 +223,8 @@ "progressbar_control": [50, 130, 255, 255], "slider_groove": [223, 223, 223, 255], - "slider_groove_fill": [10, 8, 80, 255], - "slider_handle": [10, 8, 80, 255], + "slider_groove_fill": [8, 7, 63, 255], + "slider_handle": [8, 7, 63, 255], "slider_handle_active": [50, 130, 255, 255], "slider_text_background": [255, 255, 255, 255], @@ -241,6 +236,7 @@ "checkbox_border": [64, 69, 72, 255], "checkbox_border_hover": [50, 130, 255, 255], "checkbox_mark": [119, 122, 124, 255], + "checkbox_disabled": [223, 223, 223, 255], "checkbox_text": [27, 27, 27, 255], "tooltip": [68, 192, 255, 255], @@ -310,14 +306,6 @@ "layerview_support_interface": [64, 192, 255, 255], "layerview_nozzle": [181, 166, 66, 50], - "configuration_item": [255, 255, 255, 0], - "configuration_item_active": [12, 169, 227, 32], - "configuration_item_text": [0, 0, 0, 255], - "configuration_item_text_active": [0, 0, 0, 255], - "configuration_item_border": [127, 127, 127, 255], - "configuration_item_border_active": [12, 169, 227, 32], - "configuration_item_border_hover": [50, 130, 255, 255], - "tab_status_connected": [50, 130, 255, 255], "tab_status_disconnected": [200, 200, 200, 255], @@ -377,7 +365,6 @@ "action_panel_widget": [25.0, 0.0], "action_panel_information_widget": [20.0, 0.0], - "action_panel_button": [15.0, 3.0], "machine_selector_widget": [20.0, 4.0], "machine_selector_widget_content": [25.0, 32.0], @@ -423,6 +410,9 @@ "button_icon": [2.5, 2.5], "button_lining": [0, 0], + "action_button": [15.0, 3.0], + "action_button_radius": [0.15, 0.15], + "small_button": [2, 2], "small_button_icon": [1.5, 1.5], @@ -511,9 +501,6 @@ "avatar_image": [6.8, 6.8], - "action_button": [15.0, 3.0], - "action_button_radius": [0.15, 0.15], - "monitor_config_override_box": [1.0, 14.0], "monitor_extruder_circle": [2.75, 2.75], "monitor_text_line": [1.16, 1.16], From 4f82a2759ad78a00b60c5008e78877830c38f2d7 Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Fri, 7 Dec 2018 12:04:02 +0100 Subject: [PATCH 087/178] STAR-322: Improving configuration models interface --- .../NetworkedPrinterOutputDevice.py | 11 +- .../src/Cloud/CloudOutputDevice.py | 360 ++++++++++-------- .../src/Cloud/Models/CloudClusterPrintJob.py | 24 +- .../src/Cloud/Models/CloudClusterPrinter.py | 9 +- .../CloudClusterPrinterConfiguration.py | 19 +- plugins/UM3NetworkPrinting/src/Cloud/Utils.py | 27 ++ .../src/ClusterUM3OutputDevice.py | 11 +- 7 files changed, 278 insertions(+), 183 deletions(-) diff --git a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py index 300ed5194d..b0c8b54a67 100644 --- a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py +++ b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py @@ -4,6 +4,7 @@ from UM.FileHandler.FileHandler import FileHandler #For typing. from UM.Logger import Logger from UM.Scene.SceneNode import SceneNode #For typing. +from cura.API import Account from cura.CuraApplication import CuraApplication from cura.PrinterOutputDevice import PrinterOutputDevice, ConnectionState @@ -162,9 +163,15 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): part.setBody(data) return part - ## Convenience function to get the username from the OS. - # The code was copied from the getpass module, as we try to use as little dependencies as possible. + ## Convenience function to get the username, either from the cloud or from the OS. def _getUserName(self) -> str: + # check first if we are logged in with the Ultimaker Account + account = CuraApplication.getInstance().getCuraAPI().account # type: Account + if account and account.isLoggedIn: + return account.userName + + # Otherwise get the username from the US + # The code below was copied from the getpass module, as we try to use as little dependencies as possible. for name in ("LOGNAME", "USER", "LNAME", "USERNAME"): user = os.environ.get(name) if user: diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index 9f5857dff6..3bc16cbfb0 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -16,7 +16,6 @@ from UM.Qt.Duration import Duration, DurationFormat from UM.Scene.SceneNode import SceneNode from cura.CuraApplication import CuraApplication from cura.PrinterOutput.NetworkedPrinterOutputDevice import AuthState, NetworkedPrinterOutputDevice -from cura.PrinterOutput.PrinterOutputController import PrinterOutputController from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel from plugins.UM3NetworkPrinting.src.Cloud.CloudOutputController import CloudOutputController from ..MeshFormatHandler import MeshFormatHandler @@ -28,7 +27,7 @@ from .Models.CloudPrintResponse import CloudPrintResponse from .Models.CloudJobResponse import CloudJobResponse from .Models.CloudClusterPrinter import CloudClusterPrinter from .Models.CloudClusterPrintJob import CloudClusterPrintJob -from .Utils import findChanges +from .Utils import findChanges, formatDateCompleted, formatTimeCompleted ## Class that contains all the translations for this module. @@ -55,6 +54,12 @@ class T: UPLOAD_SUCCESS_TITLE = _I18N_CATALOG.i18nc("@info:title", "Data Sent") UPLOAD_SUCCESS_TEXT = _I18N_CATALOG.i18nc("@info:status", "Print job was successfully sent to the printer.") + JOB_COMPLETED_TITLE = _I18N_CATALOG.i18nc("@info:status", "Print finished") + JOB_COMPLETED_PRINTER = _I18N_CATALOG.i18nc("@info:status", + "Printer '{printer_name}' has finished printing '{job_name}'.") + + JOB_COMPLETED_NO_PRINTER = _I18N_CATALOG.i18nc("@info:status", "The print job '{job_name}' was finished.") + ## The cloud output device is a network output device that works remotely but has limited functionality. # Currently it only supports viewing the printer and print job status and adding a new job to the queue. @@ -65,7 +70,7 @@ class T: class CloudOutputDevice(NetworkedPrinterOutputDevice): # The interval with which the remote clusters are checked - CHECK_CLUSTER_INTERVAL = 2.0 # seconds + CHECK_CLUSTER_INTERVAL = 4.0 # seconds # Signal triggered when the print jobs in the queue were changed. printJobsChanged = pyqtSignal() @@ -109,6 +114,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): # We only allow a single upload at a time. self._sending_job = False + # TODO: handle progress messages in another class. self._progress_message = None # type: Optional[Message] ## Gets the host name of this device @@ -128,7 +134,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): return network_key.startswith(self._host_name) ## Set all the interface elements and texts for this output device. - def _setInterfaceElements(self): + def _setInterfaceElements(self) -> None: self.setPriority(2) # make sure we end up below the local networking and above 'save to file' self.setName(self._id) self.setShortDescription(T.PRINT_VIA_CLOUD_BUTTON) @@ -157,13 +163,192 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): # TODO: Remove extension from the file name, since we are using content types now request = CloudJobUploadRequest( - job_name = file_name + "." + mesh_format.file_extension, + job_name = file_name, ## + "." + mesh_format.file_extension, file_size = len(mesh_bytes), content_type = mesh_format.mime_type, ) self._api.requestUpload(request, lambda response: self._onPrintJobCreated(mesh_bytes, response)) - ## Get remote printers. + ## Called when the connection to the cluster changes. + def connect(self) -> None: + super().connect() + + ## Called when the network data should be updated. + def _update(self) -> None: + super()._update() + if self._last_response_time and time() - self._last_response_time < self.CHECK_CLUSTER_INTERVAL: + return # avoid calling the cloud too often + + if self._account.isLoggedIn: + self.setAuthenticationState(AuthState.Authenticated) + self._api.getClusterStatus(self._device_id, self._onStatusCallFinished) + else: + self.setAuthenticationState(AuthState.NotAuthenticated) + + ## Method called when HTTP request to status endpoint is finished. + # Contains both printers and print jobs statuses in a single response. + def _onStatusCallFinished(self, status: CloudClusterStatus) -> None: + # Update all data from the cluster. + self._updatePrinters(status.printers) + self._updatePrintJobs(status.print_jobs) + + ## Updates the local list of printers with the list received from the cloud. + # \param jobs: The printers received from the cloud. + def _updatePrinters(self, printers: List[CloudClusterPrinter]) -> None: + previous = {p.key: p for p in self._printers} # type: Dict[str, PrinterOutputModel] + received = {p.uuid: p for p in printers} # type: Dict[str, CloudClusterPrinter] + + removed_printers, added_printers, updated_printers = findChanges(previous, received) + + for removed_printer in removed_printers: + if self._active_printer == removed_printer: + self.setActivePrinter(None) + self._printers.remove(removed_printer) + + for added_printer in added_printers: + self._printers.append(added_printer.createOutputModel(CloudOutputController(self))) + + for model, printer in updated_printers: + printer.updateOutputModel(model) + + # Always have an active printer + if not self._active_printer: + self.setActivePrinter(self._printers[0]) + + if removed_printers or added_printers or updated_printers: + self._clusterPrintersChanged.emit() + + ## Updates the local list of print jobs with the list received from the cloud. + # \param jobs: The print jobs received from the cloud. + def _updatePrintJobs(self, jobs: List[CloudClusterPrintJob]) -> None: + received = {j.uuid: j for j in jobs} # type: Dict[str, CloudClusterPrintJob] + previous = {j.key: j for j in self._print_jobs} # type: Dict[str, UM3PrintJobOutputModel] + + removed_jobs, added_jobs, updated_jobs = findChanges(previous, received) + + # TODO: we see that not all data in the UI is correctly updated when the queue and active jobs change. + # TODO: we need to fix this here somehow by updating the correct output models. + # TODO: the configuration drop down in the slice window is not populated because we are missing some data. + # TODO: to fix this we need to implement more data as shown in ClusterUM3OutputDevice._createPrintJobModel + + for removed_job in removed_jobs: + self._print_jobs.remove(removed_job) + + for added_job in added_jobs: + self._addPrintJob(added_job) + + for model, job in updated_jobs: + job.updateOutputModel(model) + if job.printer_uuid: + self._updateAssignedPrinter(model, job.printer_uuid) + + # We only have to update when jobs are added or removed + # updated jobs push their changes via their output model + if added_jobs or removed_jobs or updated_jobs: + self.printJobsChanged.emit() + + ## Registers a new print job received via the cloud API. + # \param job: The print job received. + def _addPrintJob(self, job: CloudClusterPrintJob) -> None: + model = job.createOutputModel(CloudOutputController(self)) + model.stateChanged.connect(self._onPrintJobStateChanged) + if job.printer_uuid: + self._updateAssignedPrinter(model, job.printer_uuid) + self._print_jobs.append(model) + + ## Handles the event of a change in a print job state + def _onPrintJobStateChanged(self) -> None: + username = self._account.userName + finished_jobs = [job for job in self._print_jobs if job.state == "wait_cleanup"] + + newly_finished_jobs = [job for job in finished_jobs if job not in self._finished_jobs and job.owner == username] + for job in newly_finished_jobs: + if job.assignedPrinter: + job_completed_text = T.JOB_COMPLETED_PRINTER.format(printer_name=job.assignedPrinter.name, + job_name=job.name) + else: + job_completed_text = T.JOB_COMPLETED_NO_PRINTER.format(job_name=job.name) + job_completed_message = Message(text=job_completed_text, title = T.JOB_COMPLETED_TITLE) + job_completed_message.show() + + # Ensure UI gets updated + self.printJobsChanged.emit() + + ## Updates the printer assignment for the given print job model. + def _updateAssignedPrinter(self, model: UM3PrintJobOutputModel, printer_uuid: str) -> None: + printer = next((p for p in self._printers if printer_uuid == p.key), None) + + if not printer: + return Logger.log("w", "Missing printer %s for job %s in %s", model.assignedPrinter, model.key, + [p.key for p in self._printers]) + + printer.updateActivePrintJob(model) + model.updateAssignedPrinter(printer) + + ## Uploads the mesh when the print job was registered with the cloud API. + # \param mesh: The bytes to upload. + # \param job_response: The response received from the cloud API. + def _onPrintJobCreated(self, mesh: bytes, job_response: CloudJobResponse) -> None: + self._api.uploadMesh(job_response, mesh, self._onPrintJobUploaded, self._updateUploadProgress, + lambda _: self._onUploadError(T.UPLOAD_ERROR)) + + ## Requests the print to be sent to the printer when we finished uploading the mesh. + # \param job_id: The ID of the job. + def _onPrintJobUploaded(self, job_id: str) -> None: + self._api.requestPrint(self._device_id, job_id, self._onUploadSuccess) + + ## Updates the progress of the mesh upload. + # \param progress: The amount of percentage points uploaded until now (0-100). + def _updateUploadProgress(self, progress: int) -> None: + if not self._progress_message: + self._progress_message = Message( + text = T.SENDING_DATA_TEXT, + title = T.SENDING_DATA_TITLE, + progress = -1, + lifetime = 0, + dismissable = False, + use_inactivity_timer = False + ) + self._progress_message.setProgress(progress) + self._progress_message.show() + + ## Hides the upload progress bar + def _resetUploadProgress(self) -> None: + if self._progress_message: + self._progress_message.hide() + self._progress_message = None + + ## Displays the given message if uploading the mesh has failed + # \param message: The message to display. + def _onUploadError(self, message: str = None) -> None: + self._resetUploadProgress() + if message: + message = Message( + text = message, + title = T.ERROR, + lifetime = 10, + dismissable = True + ) + message.show() + self._sending_job = False # the upload has finished so we're not sending a job anymore + self.writeError.emit() + + ## Shows a message when the upload has succeeded + # \param response: The response from the cloud API. + def _onUploadSuccess(self, response: CloudPrintResponse) -> None: + Logger.log("i", "The cluster will be printing this print job with the ID %s", response.cluster_job_id) + self._resetUploadProgress() + message = Message( + text = T.UPLOAD_SUCCESS_TEXT, + title = T.UPLOAD_SUCCESS_TITLE, + lifetime = 5, + dismissable = True, + ) + message.show() + self._sending_job = False # the upload has finished so we're not sending a job anymore + self.writeFinished.emit() + + ## Gets the remote printers. @pyqtProperty("QVariantList", notify = _clusterPrintersChanged) def printers(self) -> List[PrinterOutputModel]: return self._printers @@ -209,170 +394,11 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): @pyqtSlot(int, result = str) def getTimeCompleted(self, time_remaining: int) -> str: # TODO: this really shouldn't be in this class - current_time = time() - datetime_completed = datetime.fromtimestamp(current_time + time_remaining) - return "{hour:02d}:{minute:02d}".format(hour = datetime_completed.hour, minute = datetime_completed.minute) + return formatTimeCompleted(time_remaining) @pyqtSlot(int, result = str) def getDateCompleted(self, time_remaining: int) -> str: - # TODO: this really shouldn't be in this class - current_time = time() - completed = datetime.fromtimestamp(current_time + time_remaining) - today = datetime.fromtimestamp(current_time) - # If finishing date is more than 7 days out, using "Mon Dec 3 at HH:MM" format - if completed.toordinal() > today.toordinal() + 7: - return completed.strftime("%a %b ") + "{day}".format(day = completed.day) - # If finishing date is within the next week, use "Monday at HH:MM" format - elif completed.toordinal() > today.toordinal() + 1: - return completed.strftime("%a") - # If finishing tomorrow, use "tomorrow at HH:MM" format - elif completed.toordinal() > today.toordinal(): - return "tomorrow" - # If finishing today, use "today at HH:MM" format - else: - return "today" - - ## Called when the connection to the cluster changes. - def connect(self) -> None: - super().connect() - - ## Called when the network data should be updated. - def _update(self) -> None: - super()._update() - if self._last_response_time and time() - self._last_response_time < self.CHECK_CLUSTER_INTERVAL: - return # avoid calling the cloud too often - - if self._account.isLoggedIn: - self.setAuthenticationState(AuthState.Authenticated) - self._api.getClusterStatus(self._device_id, self._onStatusCallFinished) - else: - self.setAuthenticationState(AuthState.NotAuthenticated) - - ## Method called when HTTP request to status endpoint is finished. - # Contains both printers and print jobs statuses in a single response. - def _onStatusCallFinished(self, status: CloudClusterStatus) -> None: - # Update all data from the cluster. - self._updatePrinters(status.printers) - self._updatePrintJobs(status.print_jobs) - - def _updatePrinters(self, printers: List[CloudClusterPrinter]) -> None: - previous = {p.key: p for p in self._printers} # type: Dict[str, PrinterOutputModel] - received = {p.uuid: p for p in printers} # type: Dict[str, CloudClusterPrinter] - - removed_printers, added_printers, updated_printers = findChanges(previous, received) - - for removed_printer in removed_printers: - if self._active_printer == removed_printer: - self.setActivePrinter(None) - self._printers.remove(removed_printer) - - for added_printer in added_printers: - self._printers.append(added_printer.createOutputModel(CloudOutputController(self))) - - for model, printer in updated_printers: - printer.updateOutputModel(model) - - # Always have an active printer - if not self._active_printer: - self.setActivePrinter(self._printers[0]) - - if removed_printers or added_printers or updated_printers: - self._clusterPrintersChanged.emit() - - def _updatePrintJobs(self, jobs: List[CloudClusterPrintJob]) -> None: - received = {j.uuid: j for j in jobs} # type: Dict[str, CloudClusterPrintJob] - previous = {j.key: j for j in self._print_jobs} # type: Dict[str, UM3PrintJobOutputModel] - - removed_jobs, added_jobs, updated_jobs = findChanges(previous, received) - - # TODO: we see that not all data in the UI is correctly updated when the queue and active jobs change. - # TODO: we need to fix this here somehow by updating the correct output models. - # TODO: also the configuration drop down in the slice window is not populated because we are missing some data. - # TODO: to fix this we need to implement more data as shown in ClusterUM3OutputDevice._createPrintJobModel - - for removed_job in removed_jobs: - self._print_jobs.remove(removed_job) - - for added_job in added_jobs: - self._addPrintJob(added_job) - - for model, job in updated_jobs: - job.updateOutputModel(model) - self._updatePrintJobDetails(model) - - # We only have to update when jobs are added or removed - # updated jobs push their changes via their output model - if added_jobs or removed_jobs or updated_jobs: - self.printJobsChanged.emit() - - def _addPrintJob(self, job: CloudClusterPrintJob) -> None: - print_job = job.createOutputModel(CloudOutputController(self)) - self._updatePrintJobDetails(print_job) - self._print_jobs.append(print_job) - - def _updatePrintJobDetails(self, print_job: UM3PrintJobOutputModel): - printer = None - try: - printer = next(p for p in self._printers if print_job.assignedPrinter == p.key) - except StopIteration: - Logger.log("w", "Missing printer %s for job %s in %s", print_job.assignedPrinter, print_job.key, - [p.key for p in self._printers]) - - if printer: - printer.updateActivePrintJob(print_job) - print_job.updateAssignedPrinter(printer) - - def _onPrintJobCreated(self, mesh: bytes, job_response: CloudJobResponse) -> None: - self._api.uploadMesh(job_response, mesh, self._onPrintJobUploaded, self._updateUploadProgress, - lambda _: self._onUploadError(T.UPLOAD_ERROR)) - - def _onPrintJobUploaded(self, job_id: str) -> None: - self._api.requestPrint(self._device_id, job_id, self._onUploadSuccess) - - def _updateUploadProgress(self, progress: int): - if not self._progress_message: - self._progress_message = Message( - text = T.SENDING_DATA_TEXT, - title = T.SENDING_DATA_TITLE, - progress = -1, - lifetime = 0, - dismissable = False, - use_inactivity_timer = False - ) - self._progress_message.setProgress(progress) - self._progress_message.show() - - def _resetUploadProgress(self): - if self._progress_message: - self._progress_message.hide() - self._progress_message = None - - def _onUploadError(self, message: str = None): - self._resetUploadProgress() - if message: - message = Message( - text = message, - title = T.ERROR, - lifetime = 10, - dismissable = True - ) - message.show() - self._sending_job = False # the upload has finished so we're not sending a job anymore - self.writeError.emit() - - # Shows a message when the upload has succeeded - def _onUploadSuccess(self, response: CloudPrintResponse): - Logger.log("i", "The cluster will be printing this print job with the ID %s", response.cluster_job_id) - self._resetUploadProgress() - message = Message( - text = T.UPLOAD_SUCCESS_TEXT, - title = T.UPLOAD_SUCCESS_TITLE, - lifetime = 5, - dismissable = True, - ) - message.show() - self._sending_job = False # the upload has finished so we're not sending a job anymore - self.writeFinished.emit() + return formatDateCompleted(time_remaining) ## TODO: The following methods are required by the monitor page QML, but are not actually available using cloud. # TODO: We fake the methods here to not break the monitor page. diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJob.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJob.py index c9255b8da8..22c66ddfab 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJob.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJob.py @@ -1,7 +1,8 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from typing import List, Optional +from typing import List +from cura.PrinterOutput.ConfigurationModel import ConfigurationModel from plugins.UM3NetworkPrinting.src.Cloud.CloudOutputController import CloudOutputController from .CloudClusterPrinterConfiguration import CloudClusterPrinterConfiguration from .CloudClusterPrintJobConstraint import CloudClusterPrintJobConstraint @@ -31,20 +32,33 @@ class CloudClusterPrintJob(BaseModel): self.time_total = None # type: str self.uuid = None # type: str super().__init__(**kwargs) - self.printers = [CloudClusterPrinterConfiguration(**c) if isinstance(c, dict) else c - for c in self.configuration] - self.print_jobs = [CloudClusterPrintJobConstraint(**p) if isinstance(p, dict) else p - for p in self.constraints] + self.configuration = [CloudClusterPrinterConfiguration(**c) if isinstance(c, dict) else c + for c in self.configuration] + self.constraints = [CloudClusterPrintJobConstraint(**p) if isinstance(p, dict) else p + for p in self.constraints] ## Creates an UM3 print job output model based on this cloud cluster print job. # \param printer: The output model of the printer def createOutputModel(self, controller: CloudOutputController) -> UM3PrintJobOutputModel: model = UM3PrintJobOutputModel(controller, self.uuid, self.name) + self.updateOutputModel(model) + return model + ## Creates a new configuration model + def _createConfigurationModel(self) -> ConfigurationModel: + extruders = [extruder.createConfigurationModel() for extruder in self.configuration or ()] + configuration = ConfigurationModel() + configuration.setExtruderConfigurations(extruders) + return configuration + ## Updates an UM3 print job output model based on this cloud cluster print job. # \param model: The model to update. def updateOutputModel(self, model: UM3PrintJobOutputModel) -> None: + # TODO: Add `compatible_machine_families` to the cloud, than add model.setCompatibleMachineFamilies() + # TODO: Add `impediments_to_printing` to the cloud, see ClusterUM3OutputDevice._updatePrintJob + # TODO: Use model.updateConfigurationChanges, see ClusterUM3OutputDevice#_createConfigurationChanges + model.updateConfiguration(self._createConfigurationModel()) model.updateTimeTotal(self.time_total) model.updateTimeElapsed(self.time_elapsed) model.updateOwner(self.owner) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinter.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinter.py index dd65dffa26..78aa8e3a31 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinter.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinter.py @@ -2,6 +2,7 @@ # Cura is released under the terms of the LGPLv3 or higher. from typing import List +from cura.PrinterOutput.ConfigurationModel import ConfigurationModel from cura.PrinterOutput.PrinterOutputController import PrinterOutputController from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel from .CloudClusterPrinterConfiguration import CloudClusterPrinterConfiguration @@ -40,5 +41,9 @@ class CloudClusterPrinter(BaseModel): model.updateType(self.machine_variant) model.updateState(self.status if self.enabled else "disabled") - for configuration, extruder in zip(self.configuration, model.extruders): - configuration.updateOutputModel(extruder) + for configuration, extruder_output, extruder_config in \ + zip(self.configuration, model.extruders, model.printerConfiguration.extruderConfigurations): + configuration.updateOutputModel(extruder_output) + configuration.updateConfigurationModel(extruder_config) + + pass diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterConfiguration.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterConfiguration.py index be92549015..d60395f6ab 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterConfiguration.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterConfiguration.py @@ -1,5 +1,9 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. +from typing import List, Optional + +from cura.PrinterOutput.ConfigurationModel import ConfigurationModel +from cura.PrinterOutput.ExtruderConfigurationModel import ExtruderConfigurationModel from cura.PrinterOutput.ExtruderOutputModel import ExtruderOutputModel from .CloudClusterPrinterConfigurationMaterial import CloudClusterPrinterConfigurationMaterial from ...Models import BaseModel @@ -8,7 +12,7 @@ from ...Models import BaseModel ## Class representing a cloud cluster printer configuration class CloudClusterPrinterConfiguration(BaseModel): def __init__(self, **kwargs) -> None: - self.extruder_index = None # type: str + self.extruder_index = None # type: int self.material = None # type: CloudClusterPrinterConfigurationMaterial self.nozzle_diameter = None # type: str self.print_core_id = None # type: str @@ -25,3 +29,16 @@ class CloudClusterPrinterConfiguration(BaseModel): if model.activeMaterial is None or model.activeMaterial.guid != self.material.guid: material = self.material.createOutputModel() model.updateActiveMaterial(material) + + ## Creates a configuration model + def createConfigurationModel(self) -> ExtruderConfigurationModel: + model = ExtruderConfigurationModel(position = self.extruder_index) + self.updateConfigurationModel(model) + return model + + ## Creates a configuration model + def updateConfigurationModel(self, model: ExtruderConfigurationModel) -> ExtruderConfigurationModel: + model.setHotendID(self.print_core_id) + if self.material: + model.setMaterial(self.material.createOutputModel()) + return model diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Utils.py b/plugins/UM3NetworkPrinting/src/Cloud/Utils.py index 58eaf5edb9..eb96e49dad 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Utils.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Utils.py @@ -1,5 +1,8 @@ +from datetime import datetime, timedelta from typing import TypeVar, Dict, Tuple, List +from UM import i18nCatalog + T = TypeVar("T") U = TypeVar("U") @@ -24,3 +27,27 @@ def findChanges(previous: Dict[str, T], received: Dict[str, U]) -> Tuple[List[T] updated = [(previous[updated_id], received[updated_id]) for updated_id in updated_ids] return removed, added, updated + + +def formatTimeCompleted(time_remaining: int) -> str: + completed = datetime.now() + timedelta(seconds=time_remaining) + return "{hour:02d}:{minute:02d}".format(hour = completed.hour, minute = completed.minute) + + +def formatDateCompleted(time_remaining: int) -> str: + remaining = timedelta(seconds=time_remaining) + completed = datetime.now() + remaining + i18n = i18nCatalog("cura") + + # If finishing date is more than 7 days out, using "Mon Dec 3 at HH:MM" format + if remaining.days >= 7: + return completed.strftime("%a %b ") + "{day}".format(day = completed.day) + # If finishing date is within the next week, use "Monday at HH:MM" format + elif remaining.days >= 2: + return completed.strftime("%a") + # If finishing tomorrow, use "tomorrow at HH:MM" format + elif remaining.days >= 1: + return i18n.i18nc("@info:status", "tomorrow") + # If finishing today, use "today at HH:MM" format + else: + return i18n.i18nc("@info:status", "today") diff --git a/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py b/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py index a5ee3bc650..93a53373dc 100644 --- a/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py @@ -25,6 +25,7 @@ from cura.PrinterOutput.ExtruderConfigurationModel import ExtruderConfigurationM from cura.PrinterOutput.NetworkedPrinterOutputDevice import AuthState, NetworkedPrinterOutputDevice from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel from cura.PrinterOutput.MaterialOutputModel import MaterialOutputModel +from plugins.UM3NetworkPrinting.src.Cloud.Utils import formatTimeCompleted, formatDateCompleted from .ClusterUM3PrinterOutputController import ClusterUM3PrinterOutputController from .ConfigurationChangeModel import ConfigurationChangeModel @@ -337,14 +338,12 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): return self._printers @pyqtSlot(int, result = str) - def formatDuration(self, seconds: int) -> str: - return Duration(seconds).getDisplayString(DurationFormat.Format.Short) + def getTimeCompleted(self, time_remaining: int) -> str: + return formatTimeCompleted(time_remaining) @pyqtSlot(int, result = str) - def getTimeCompleted(self, time_remaining: int) -> str: - current_time = time() - datetime_completed = datetime.fromtimestamp(current_time + time_remaining) - return "{hour:02d}:{minute:02d}".format(hour=datetime_completed.hour, minute=datetime_completed.minute) + def getDateCompleted(self, time_remaining: int) -> str: + return formatDateCompleted(time_remaining) @pyqtSlot(int, result = str) def getDateCompleted(self, time_remaining: int) -> str: From 2b8358fda855b2dd722f85dc7dcf525ecfb97461 Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Fri, 7 Dec 2018 12:39:37 +0100 Subject: [PATCH 088/178] STAR-322: Improvements to date calculation and signalling --- .../src/Cloud/CloudOutputDevice.py | 10 +++------- plugins/UM3NetworkPrinting/src/Cloud/Utils.py | 17 +++++++++-------- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index 3bc16cbfb0..108fb0040c 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -1,10 +1,9 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. import os -from datetime import datetime from time import time -from typing import Dict, List, Optional, Set +from typing import Dict, List, Optional from PyQt5.QtCore import QObject, QUrl, pyqtProperty, pyqtSignal, pyqtSlot @@ -215,8 +214,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): if not self._active_printer: self.setActivePrinter(self._printers[0]) - if removed_printers or added_printers or updated_printers: - self._clusterPrintersChanged.emit() + self.printersChanged.emit() ## Updates the local list of print jobs with the list received from the cloud. # \param jobs: The print jobs received from the cloud. @@ -388,12 +386,10 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): @pyqtSlot(int, result = str) def formatDuration(self, seconds: int) -> str: - # TODO: this really shouldn't be in this class return Duration(seconds).getDisplayString(DurationFormat.Format.Short) @pyqtSlot(int, result = str) def getTimeCompleted(self, time_remaining: int) -> str: - # TODO: this really shouldn't be in this class return formatTimeCompleted(time_remaining) @pyqtSlot(int, result = str) @@ -413,7 +409,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): @pyqtProperty(bool, notify = printJobsChanged) def receivedPrintJobs(self) -> bool: - return True + return bool(self._print_jobs) @pyqtSlot() def openPrintJobControlPanel(self) -> None: diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Utils.py b/plugins/UM3NetworkPrinting/src/Cloud/Utils.py index eb96e49dad..5136e0e7db 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Utils.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Utils.py @@ -29,24 +29,25 @@ def findChanges(previous: Dict[str, T], received: Dict[str, U]) -> Tuple[List[T] return removed, added, updated -def formatTimeCompleted(time_remaining: int) -> str: - completed = datetime.now() + timedelta(seconds=time_remaining) +def formatTimeCompleted(seconds_remaining: int) -> str: + completed = datetime.now() + timedelta(seconds=seconds_remaining) return "{hour:02d}:{minute:02d}".format(hour = completed.hour, minute = completed.minute) -def formatDateCompleted(time_remaining: int) -> str: - remaining = timedelta(seconds=time_remaining) - completed = datetime.now() + remaining +def formatDateCompleted(seconds_remaining: int) -> str: + now = datetime.now() + completed = now + timedelta(seconds=seconds_remaining) + days = (completed.date() - now.date()).days i18n = i18nCatalog("cura") # If finishing date is more than 7 days out, using "Mon Dec 3 at HH:MM" format - if remaining.days >= 7: + if days >= 7: return completed.strftime("%a %b ") + "{day}".format(day = completed.day) # If finishing date is within the next week, use "Monday at HH:MM" format - elif remaining.days >= 2: + elif days >= 2: return completed.strftime("%a") # If finishing tomorrow, use "tomorrow at HH:MM" format - elif remaining.days >= 1: + elif days >= 1: return i18n.i18nc("@info:status", "tomorrow") # If finishing today, use "today at HH:MM" format else: From 5b963de2ea64058e5a1ff04b24ef27a1e56e1873 Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Fri, 7 Dec 2018 13:19:45 +0100 Subject: [PATCH 089/178] STAR-322: Checking if response changed before updating cluster --- .../src/Cloud/CloudOutputDevice.py | 21 ++++++++++++------- .../src/Cloud/Models/BaseCloudModel.py | 17 +++++++++++++++ .../src/Cloud/Models/CloudCluster.py | 4 ++-- .../src/Cloud/Models/CloudClusterPrintJob.py | 4 ++-- .../Models/CloudClusterPrintJobConstraint.py | 4 ++-- .../src/Cloud/Models/CloudClusterPrinter.py | 4 ++-- .../CloudClusterPrinterConfiguration.py | 4 ++-- ...loudClusterPrinterConfigurationMaterial.py | 4 ++-- .../src/Cloud/Models/CloudClusterStatus.py | 10 +++++++-- .../src/Cloud/Models/CloudErrorObject.py | 4 ++-- .../src/Cloud/Models/CloudJobResponse.py | 4 ++-- .../src/Cloud/Models/CloudJobUploadRequest.py | 4 ++-- .../src/Cloud/Models/CloudPrintResponse.py | 4 ++-- 13 files changed, 58 insertions(+), 30 deletions(-) create mode 100644 plugins/UM3NetworkPrinting/src/Cloud/Models/BaseCloudModel.py diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index 108fb0040c..321c40bc74 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -1,6 +1,7 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. import os +from datetime import datetime from time import time from typing import Dict, List, Optional @@ -116,6 +117,10 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): # TODO: handle progress messages in another class. self._progress_message = None # type: Optional[Message] + # Keep server string of the last generated time to avoid updating models more than once for the same response + self._received_printers = None # type: Optional[List[CloudClusterPrinter]] + self._received_print_jobs = None # type: Optional[List[CloudClusterPrintJob]] + ## Gets the host name of this device @property def host_name(self) -> str: @@ -188,8 +193,13 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): # Contains both printers and print jobs statuses in a single response. def _onStatusCallFinished(self, status: CloudClusterStatus) -> None: # Update all data from the cluster. - self._updatePrinters(status.printers) - self._updatePrintJobs(status.print_jobs) + if self._received_printers != status.printers: + self._received_printers = status.printers + self._updatePrinters(status.printers) + + if status.print_jobs != self._received_print_jobs: + self._received_print_jobs = status.print_jobs + self._updatePrintJobs(status.print_jobs) ## Updates the local list of printers with the list received from the cloud. # \param jobs: The printers received from the cloud. @@ -214,7 +224,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): if not self._active_printer: self.setActivePrinter(self._printers[0]) - self.printersChanged.emit() + self.printersChanged.emit() # TODO: Make this more efficient by not updating every request ## Updates the local list of print jobs with the list received from the cloud. # \param jobs: The print jobs received from the cloud. @@ -224,11 +234,6 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): removed_jobs, added_jobs, updated_jobs = findChanges(previous, received) - # TODO: we see that not all data in the UI is correctly updated when the queue and active jobs change. - # TODO: we need to fix this here somehow by updating the correct output models. - # TODO: the configuration drop down in the slice window is not populated because we are missing some data. - # TODO: to fix this we need to implement more data as shown in ClusterUM3OutputDevice._createPrintJobModel - for removed_job in removed_jobs: self._print_jobs.remove(removed_job) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/BaseCloudModel.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/BaseCloudModel.py new file mode 100644 index 0000000000..1176c4374a --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/BaseCloudModel.py @@ -0,0 +1,17 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from datetime import datetime, timezone + +from ...Models import BaseModel + + +class BaseCloudModel(BaseModel): + def __eq__(self, other): + return type(self) == type(other) and self.__dict__ == other.__dict__ + + def __ne__(self, other): + return type(self) != type(other) or self.__dict__ != other.__dict__ + + @staticmethod + def parseDate(date_str: str) -> datetime: + return datetime.strptime(date_str, "%Y-%m-%dT%H:%M:%S.%fZ").replace(tzinfo=timezone.utc) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudCluster.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudCluster.py index 28e95a097a..e6e2af1466 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudCluster.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudCluster.py @@ -1,10 +1,10 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from ...Models import BaseModel +from .BaseCloudModel import BaseCloudModel ## Class representing a cloud connected cluster. -class CloudCluster(BaseModel): +class CloudCluster(BaseCloudModel): def __init__(self, **kwargs): self.cluster_id = None # type: str self.host_guid = None # type: str diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJob.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJob.py index 22c66ddfab..15d256e7d5 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJob.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJob.py @@ -6,14 +6,14 @@ from cura.PrinterOutput.ConfigurationModel import ConfigurationModel from plugins.UM3NetworkPrinting.src.Cloud.CloudOutputController import CloudOutputController from .CloudClusterPrinterConfiguration import CloudClusterPrinterConfiguration from .CloudClusterPrintJobConstraint import CloudClusterPrintJobConstraint -from ...Models import BaseModel +from .BaseCloudModel import BaseCloudModel ## Class representing a print job from plugins.UM3NetworkPrinting.src.UM3PrintJobOutputModel import UM3PrintJobOutputModel -class CloudClusterPrintJob(BaseModel): +class CloudClusterPrintJob(BaseCloudModel): def __init__(self, **kwargs) -> None: self.assigned_to = None # type: str self.configuration = [] # type: List[CloudClusterPrinterConfiguration] diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobConstraint.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobConstraint.py index 884ff8f0c2..f13e3098fc 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobConstraint.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobConstraint.py @@ -1,10 +1,10 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from ...Models import BaseModel +from .BaseCloudModel import BaseCloudModel ## Class representing a cloud cluster print job constraint -class CloudClusterPrintJobConstraint(BaseModel): +class CloudClusterPrintJobConstraint(BaseCloudModel): def __init__(self, **kwargs) -> None: self.require_printer_name = None # type: str super().__init__(**kwargs) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinter.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinter.py index 78aa8e3a31..9057743621 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinter.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinter.py @@ -6,11 +6,11 @@ from cura.PrinterOutput.ConfigurationModel import ConfigurationModel from cura.PrinterOutput.PrinterOutputController import PrinterOutputController from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel from .CloudClusterPrinterConfiguration import CloudClusterPrinterConfiguration -from ...Models import BaseModel +from .BaseCloudModel import BaseCloudModel ## Class representing a cluster printer -class CloudClusterPrinter(BaseModel): +class CloudClusterPrinter(BaseCloudModel): def __init__(self, **kwargs) -> None: self.configuration = [] # type: List[CloudClusterPrinterConfiguration] self.enabled = None # type: str diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterConfiguration.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterConfiguration.py index d60395f6ab..aa382136d0 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterConfiguration.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterConfiguration.py @@ -6,11 +6,11 @@ from cura.PrinterOutput.ConfigurationModel import ConfigurationModel from cura.PrinterOutput.ExtruderConfigurationModel import ExtruderConfigurationModel from cura.PrinterOutput.ExtruderOutputModel import ExtruderOutputModel from .CloudClusterPrinterConfigurationMaterial import CloudClusterPrinterConfigurationMaterial -from ...Models import BaseModel +from .BaseCloudModel import BaseCloudModel ## Class representing a cloud cluster printer configuration -class CloudClusterPrinterConfiguration(BaseModel): +class CloudClusterPrinterConfiguration(BaseCloudModel): def __init__(self, **kwargs) -> None: self.extruder_index = None # type: int self.material = None # type: CloudClusterPrinterConfigurationMaterial diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterConfigurationMaterial.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterConfigurationMaterial.py index 8023784925..e5f52ac630 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterConfigurationMaterial.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterConfigurationMaterial.py @@ -1,11 +1,11 @@ from UM.Logger import Logger from cura.CuraApplication import CuraApplication from cura.PrinterOutput.MaterialOutputModel import MaterialOutputModel -from ...Models import BaseModel +from .BaseCloudModel import BaseCloudModel ## Class representing a cloud cluster printer configuration -class CloudClusterPrinterConfigurationMaterial(BaseModel): +class CloudClusterPrinterConfigurationMaterial(BaseCloudModel): def __init__(self, **kwargs) -> None: self.guid = None # type: str self.brand = None # type: str diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterStatus.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterStatus.py index a44b665973..77ed979dbc 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterStatus.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterStatus.py @@ -1,15 +1,17 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. +from datetime import datetime from typing import List from .CloudClusterPrinter import CloudClusterPrinter from .CloudClusterPrintJob import CloudClusterPrintJob -from ...Models import BaseModel +from .BaseCloudModel import BaseCloudModel # Model that represents the status of the cluster for the cloud -class CloudClusterStatus(BaseModel): +class CloudClusterStatus(BaseCloudModel): def __init__(self, **kwargs) -> None: + self.generated_time = None # type: datetime # a list of the printers self.printers = [] # type: List[CloudClusterPrinter] # a list of the print jobs @@ -20,3 +22,7 @@ class CloudClusterStatus(BaseModel): # converting any dictionaries into models self.printers = [CloudClusterPrinter(**p) if isinstance(p, dict) else p for p in self.printers] self.print_jobs = [CloudClusterPrintJob(**j) if isinstance(j, dict) else j for j in self.print_jobs] + + # converting generated time into datetime + if isinstance(self.generated_time, str): + self.generated_time = self.parseDate(self.generated_time) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudErrorObject.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudErrorObject.py index 813ef957e4..9696cbcb7a 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudErrorObject.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudErrorObject.py @@ -2,11 +2,11 @@ # Cura is released under the terms of the LGPLv3 or higher. from typing import Dict -from ...Models import BaseModel +from .BaseCloudModel import BaseCloudModel ## Class representing errors generated by the cloud servers, according to the json-api standard. -class CloudErrorObject(BaseModel): +class CloudErrorObject(BaseCloudModel): def __init__(self, **kwargs) -> None: self.id = None # type: str self.code = None # type: str diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudJobResponse.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudJobResponse.py index 0b611dd2d3..e3161449a5 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudJobResponse.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudJobResponse.py @@ -1,10 +1,10 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from ...Models import BaseModel +from .BaseCloudModel import BaseCloudModel # Model that represents the response received from the cloud after requesting to upload a print job -class CloudJobResponse(BaseModel): +class CloudJobResponse(BaseCloudModel): def __init__(self, **kwargs) -> None: self.download_url = None # type: str self.job_id = None # type: str diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudJobUploadRequest.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudJobUploadRequest.py index 3e038b343e..07a781e2d6 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudJobUploadRequest.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudJobUploadRequest.py @@ -1,10 +1,10 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from ...Models import BaseModel +from .BaseCloudModel import BaseCloudModel # Model that represents the request to upload a print job to the cloud -class CloudJobUploadRequest(BaseModel): +class CloudJobUploadRequest(BaseCloudModel): def __init__(self, **kwargs) -> None: self.file_size = None # type: int self.job_name = None # type: str diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudPrintResponse.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudPrintResponse.py index ff05ad5b19..3e9ad584dc 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudPrintResponse.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudPrintResponse.py @@ -1,10 +1,10 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from ...Models import BaseModel +from .BaseCloudModel import BaseCloudModel # Model that represents the responses received from the cloud after requesting a job to be printed. -class CloudPrintResponse(BaseModel): +class CloudPrintResponse(BaseCloudModel): def __init__(self, **kwargs) -> None: self.cluster_job_id = None # type: str self.job_id = None # type: str From 07801b5394ae654a7fbd40c7f7a82b92baaedc1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marijn=20De=C3=A9?= Date: Fri, 7 Dec 2018 16:21:08 +0100 Subject: [PATCH 090/178] First test for CloudDevices --- .../tests/Cloud/TestCloudApiClient.py | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py new file mode 100644 index 0000000000..328bb053b7 --- /dev/null +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py @@ -0,0 +1,136 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +import json +from typing import Dict, Tuple +from unittest import TestCase, mock +from unittest.mock import patch, MagicMock + +from PyQt5.QtCore import QByteArray +from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkReply + +from UM.Application import Application +from UM.Signal import Signal +from cura.CuraApplication import CuraApplication +from plugins.UM3NetworkPrinting.src.Cloud.CloudApiClient import CloudApiClient +from plugins.UM3NetworkPrinting.src.Cloud.Models import CloudCluster, CloudErrorObject + +# This mock application must extend from Application and not QtApplication otherwise some QObjects are created and +# a segfault is raised. +class FixtureApplication(Application): + def __init__(self): + super().__init__(name = "test", version = "1.0", api_version = "5.0.0") + super().initialize() + Signal._signalQueue = self + + def functionEvent(self, event): + event.call() + + def parseCommandLine(self): + pass + + def processEvents(self): + pass + + def getRenderer(self): + return MagicMock() + +class ManagerMock: + finished = Signal() + authenticationRequired = Signal() + + def __init__(self, reply): + self.reply = reply + + def get(self, request): + self.reply.url.return_value = request.url() + + return self.reply + +class ManagerMock2: + finished = Signal() + authenticationRequired = Signal() + + def get(self, request): + reply_mock = MagicMock() + reply_mock.url = request.url + reply_mock.operation.return_value = QNetworkAccessManager.GetOperation + return reply_mock + + @staticmethod + def createReply(method: str, url: str, status_code: int, response: dict): + reply_mock = MagicMock() + reply_mock.url().toString.return_value = url + reply_mock.operation.return_value = { + "GET": QNetworkAccessManager.GetOperation, + "POST": QNetworkAccessManager.PostOperation, + "PUT": QNetworkAccessManager.PutOperation, + "DELETE": QNetworkAccessManager.DeleteOperation, + "HEAD": QNetworkAccessManager.HeadOperation, + }[method] + reply_mock.attribute.return_value = status_code + reply_mock.readAll.return_value = json.dumps(response).encode() + return reply_mock + + +class TestCloudApiClient(TestCase): + + app = CuraApplication.getInstance() or CuraApplication + + def _errorHandler(self, errors: [CloudErrorObject]): + pass + + @patch("cura.NetworkClient.QNetworkAccessManager") + @patch("cura.API.Account") + def test_GetClusters(self, account_mock, manager_mock): + reply_mock = MagicMock() + reply_mock.operation.return_value = 2 + reply_mock.attribute.return_value = 200 + reply_mock.readAll.return_value = b'{"data": [{"cluster_id": "RIZ6cZbWA_Ua7RZVJhrdVfVpf0z-MqaSHQE4v8aRTtYq", "host_guid": "e90ae0ac-1257-4403-91ee-a44c9b7e8050", "host_name": "ultimakersystem-ccbdd30044ec", "host_version": "5.1.2.20180807", "is_online": false, "status": "inactive"}, {"cluster_id": "R0YcLJwar1ugh0ikEZsZs8NWKV6vJP_LdYsXgXqAcaNC", "host_guid": "e90ae0ac-1257-4403-91ee-a44c9b7e8050", "host_name": "ultimakersystem-ccbdd30044ec", "host_version": "5.1.2.20180807", "is_online": true, "status": "active"}]}' + manager_mock.return_value = ManagerMock(reply_mock) + account_mock.isLoggedIn.return_value = True + + result = [] + + def _callback(clusters): + result.extend(clusters) + + with mock.patch.object(Application, "getInstance", new = lambda: FixtureApplication()): + api = CloudApiClient(account_mock, self._errorHandler) + api.getClusters(_callback) + + manager_mock.return_value.finished.emit(reply_mock) + + self.assertEqual(2, len(result)) + + @patch("cura.NetworkClient.QNetworkAccessManager") + @patch("cura.API.Account") + def test_GetClusters2(self, account_mock, manager_mock): + manager = ManagerMock2() + manager_mock.return_value = manager + account_mock.isLoggedIn.return_value = True + + result = [] + + # with mock.patch.object(Application, "getInstance", new = lambda: FixtureApplication()): + api = CloudApiClient(account_mock, self._errorHandler) + api.getClusters(lambda clusters: result.extend(clusters)) + + manager.finished.emit(ManagerMock2.createReply( + "GET", "https://api-staging.ultimaker.com/connect/v1/clusters", + 200, { + "data": [{ + "cluster_id": "RIZ6cZbWA_Ua7RZVJhrdVfVpf0z-MqaSHQE4v8aRTtYq", + "host_guid": "e90ae0ac-1257-4403-91ee-a44c9b7e8050", + "host_name": "ultimakersystem-ccbdd30044ec", "host_version": "5.1.2.20180807", + "is_online": False, "status": "inactive" + }, { + "cluster_id": "R0YcLJwar1ugh0ikEZsZs8NWKV6vJP_LdYsXgXqAcaNC", + "host_guid": "e90ae0ac-1257-4403-91ee-a44c9b7e8050", + "host_name": "ultimakersystem-ccbdd30044ec", "host_version": "5.1.2.20180807", + "is_online": True, "status": "active" + }] + } + )) + + self.assertEqual(2, len(result)) From 63c5b779595cb1488c44094eb431694430161625 Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Fri, 7 Dec 2018 16:43:53 +0100 Subject: [PATCH 091/178] STAR-322: Fixing finished jobs --- .../src/Cloud/CloudOutputDevice.py | 54 +++++++++---------- .../src/Cloud/CloudOutputDeviceManager.py | 9 ++-- 2 files changed, 30 insertions(+), 33 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index 321c40bc74..2800bc1c8c 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -1,10 +1,9 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. import os -from datetime import datetime from time import time -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Set from PyQt5.QtCore import QObject, QUrl, pyqtProperty, pyqtSignal, pyqtSlot @@ -92,7 +91,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): self._host_name = host_name self._setInterfaceElements() - + self._device_id = device_id self._account = CuraApplication.getInstance().getCuraAPI().account @@ -111,7 +110,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): # Properties to populate later on with received cloud data. self._print_jobs = [] # type: List[UM3PrintJobOutputModel] self._number_of_extruders = 2 # All networked printers are dual-extrusion Ultimaker machines. - + # We only allow a single upload at a time. self._sending_job = False # TODO: handle progress messages in another class. @@ -121,6 +120,9 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): self._received_printers = None # type: Optional[List[CloudClusterPrinter]] self._received_print_jobs = None # type: Optional[List[CloudClusterPrintJob]] + # A set of the user's job IDs that have finished + self._finished_jobs = set() # type: Set[str] + ## Gets the host name of this device @property def host_name(self) -> str: @@ -144,16 +146,16 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): self.setShortDescription(T.PRINT_VIA_CLOUD_BUTTON) self.setDescription(T.PRINT_VIA_CLOUD_TOOLTIP) self.setConnectionText(T.CONNECTED_VIA_CLOUD) - + ## Called when Cura requests an output device to receive a (G-code) file. def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mime_types: bool = False, file_handler: Optional[FileHandler] = None, **kwargs: str) -> None: - + # Show an error message if we're already sending a job. if self._sending_job: self._onUploadError(T.BLOCKED_UPLOADING) return - + # Indicate we have started sending a job. self._sending_job = True self.writeStarted.emit(self) @@ -165,9 +167,8 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): mesh_bytes = mesh_format.getBytes(nodes) - # TODO: Remove extension from the file name, since we are using content types now request = CloudJobUploadRequest( - job_name = file_name, ## + "." + mesh_format.file_extension, + job_name = file_name, file_size = len(mesh_bytes), content_type = mesh_format.mime_type, ) @@ -261,18 +262,15 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): ## Handles the event of a change in a print job state def _onPrintJobStateChanged(self) -> None: - username = self._account.userName - finished_jobs = [job for job in self._print_jobs if job.state == "wait_cleanup"] - - newly_finished_jobs = [job for job in finished_jobs if job not in self._finished_jobs and job.owner == username] - for job in newly_finished_jobs: - if job.assignedPrinter: - job_completed_text = T.JOB_COMPLETED_PRINTER.format(printer_name=job.assignedPrinter.name, - job_name=job.name) - else: - job_completed_text = T.JOB_COMPLETED_NO_PRINTER.format(job_name=job.name) - job_completed_message = Message(text=job_completed_text, title = T.JOB_COMPLETED_TITLE) - job_completed_message.show() + user_name = self._getUserName() + for job in self._print_jobs: + if job.state == "wait_cleanup" and job.key not in self._finished_jobs and job.owner == user_name: + self._finished_jobs.add(job.key) + Message( + title = T.JOB_COMPLETED_TITLE, + text = (T.JOB_COMPLETED_PRINTER.format(printer_name=job.assignedPrinter.name, job_name=job.name) + if job.assignedPrinter else T.JOB_COMPLETED_NO_PRINTER.format(job_name=job.name)), + ).show() # Ensure UI gets updated self.printJobsChanged.emit() @@ -326,13 +324,12 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): def _onUploadError(self, message: str = None) -> None: self._resetUploadProgress() if message: - message = Message( + Message( text = message, title = T.ERROR, lifetime = 10, dismissable = True - ) - message.show() + ).show() self._sending_job = False # the upload has finished so we're not sending a job anymore self.writeError.emit() @@ -341,18 +338,17 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): def _onUploadSuccess(self, response: CloudPrintResponse) -> None: Logger.log("i", "The cluster will be printing this print job with the ID %s", response.cluster_job_id) self._resetUploadProgress() - message = Message( + Message( text = T.UPLOAD_SUCCESS_TEXT, title = T.UPLOAD_SUCCESS_TITLE, lifetime = 5, dismissable = True, - ) - message.show() + ).show() self._sending_job = False # the upload has finished so we're not sending a job anymore self.writeFinished.emit() ## Gets the remote printers. - @pyqtProperty("QVariantList", notify = _clusterPrintersChanged) + @pyqtProperty("QVariantList", notify=_clusterPrintersChanged) def printers(self) -> List[PrinterOutputModel]: return self._printers @@ -374,7 +370,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): ## Get remote print jobs. @pyqtProperty("QVariantList", notify = printJobsChanged) - def printJobs(self)-> List[UM3PrintJobOutputModel]: + def printJobs(self) -> List[UM3PrintJobOutputModel]: return self._print_jobs ## Get remote print jobs that are still in the print queue. diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index bc410a4a1d..f06bbb305e 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -7,6 +7,7 @@ from PyQt5.QtCore import QTimer from UM import i18nCatalog from UM.Logger import Logger from UM.Message import Message +from cura.API import Account from cura.CuraApplication import CuraApplication from cura.Settings.GlobalStack import GlobalStack from .CloudApiClient import CloudApiClient @@ -40,7 +41,7 @@ class CloudOutputDeviceManager: application = CuraApplication.getInstance() self._output_device_manager = application.getOutputDeviceManager() - self._account = application.getCuraAPI().account + self._account = application.getCuraAPI().account # type: Account self._account.loginStateChanged.connect(self._onLoginStateChanged) self._api = CloudApiClient(self._account, self._onApiError) @@ -54,11 +55,11 @@ class CloudOutputDeviceManager: self._update_timer.timeout.connect(self._getRemoteClusters) # Make sure the timer is started in case we missed the loginChanged signal - self._onLoginStateChanged() + self._onLoginStateChanged(self._account.isLoggedIn) # Called when the uses logs in or out - def _onLoginStateChanged(self) -> None: - if self._account.isLoggedIn: + def _onLoginStateChanged(self, is_logged_in: bool) -> None: + if is_logged_in: if not self._update_timer.isActive(): self._update_timer.start() else: From e0b159e2ad78fe8110a5e959526b8c706ad45eb7 Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Fri, 7 Dec 2018 17:46:35 +0100 Subject: [PATCH 092/178] STAR-322: Creating a network manager mock --- .../tests/Cloud/NetworkManagerMock.py | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py b/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py new file mode 100644 index 0000000000..b14ee760d9 --- /dev/null +++ b/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py @@ -0,0 +1,42 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +import json +from unittest.mock import MagicMock + +from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest + +from UM.Signal import Signal + + +class NetworkManagerMock: + finished = Signal() + authenticationRequired = Signal() + + _OPERATIONS = { + "GET": QNetworkAccessManager.GetOperation, + "POST": QNetworkAccessManager.PostOperation, + "PUT": QNetworkAccessManager.PutOperation, + "DELETE": QNetworkAccessManager.DeleteOperation, + "HEAD": QNetworkAccessManager.HeadOperation, + } + + def __getattr__(self, item): + operation = self._OPERATIONS.get(item.upper()) + if operation: + return lambda request, *_: self._fakeReply(operation, request) + return super().__getattribute__(item) + + def _fakeReply(self, operation: QNetworkAccessManager.Operation, request: QNetworkRequest) -> QNetworkReply: + reply_mock = MagicMock() + reply_mock.url = request.url + reply_mock.operation.return_value = operation + return reply_mock + + def respond(self, method: str, url: str, status_code: int, response: dict) -> QNetworkReply: + reply_mock = MagicMock() + reply_mock.url().toString.return_value = url + reply_mock.operation.return_value = self._OPERATIONS[method] + reply_mock.attribute.return_value = status_code + reply_mock.readAll.return_value = json.dumps(response).encode() + self.finished.emit(reply_mock) + return reply_mock From 45f51c358866a09810dacfc9508c4c06cbc18ec4 Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Fri, 7 Dec 2018 18:09:20 +0100 Subject: [PATCH 093/178] STAR-322: First test cloud output device manager --- .../tests/Cloud/NetworkManagerMock.py | 32 +++++++----- .../Cloud/TestCloudOutputDeviceManager.py | 51 +++++++++++++++++++ .../tests/Cloud/__init__.py | 2 + 3 files changed, 71 insertions(+), 14 deletions(-) create mode 100644 plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py create mode 100644 plugins/UM3NetworkPrinting/tests/Cloud/__init__.py diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py b/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py index b14ee760d9..945c526814 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py @@ -1,9 +1,10 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. import json +from typing import Dict, Tuple from unittest.mock import MagicMock -from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest +from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkReply from UM.Signal import Signal @@ -20,23 +21,26 @@ class NetworkManagerMock: "HEAD": QNetworkAccessManager.HeadOperation, } - def __getattr__(self, item): - operation = self._OPERATIONS.get(item.upper()) + def __init__(self): + self.replies = {} # type: Dict[Tuple[str, str], QNetworkReply] + + def __getattr__(self, method): + operation = self._OPERATIONS.get(method.upper()) if operation: - return lambda request, *_: self._fakeReply(operation, request) - return super().__getattribute__(item) + return lambda request, *_: self.replies[method.upper(), request.url().toString()] + return super().__getattribute__(method) - def _fakeReply(self, operation: QNetworkAccessManager.Operation, request: QNetworkRequest) -> QNetworkReply: - reply_mock = MagicMock() - reply_mock.url = request.url - reply_mock.operation.return_value = operation - return reply_mock - - def respond(self, method: str, url: str, status_code: int, response: dict) -> QNetworkReply: + def prepareResponse(self, method: str, url: str, status_code: int, response: dict) -> None: reply_mock = MagicMock() reply_mock.url().toString.return_value = url reply_mock.operation.return_value = self._OPERATIONS[method] reply_mock.attribute.return_value = status_code reply_mock.readAll.return_value = json.dumps(response).encode() - self.finished.emit(reply_mock) - return reply_mock + self.replies[method, url] = reply_mock + + def flushReplies(self): + for reply in self.replies.values(): + self.finished.emit(reply) + + def reset(self): + self.replies.clear() diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py new file mode 100644 index 0000000000..cf86f713b2 --- /dev/null +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py @@ -0,0 +1,51 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from unittest import TestCase +from unittest.mock import patch + +from cura.CuraApplication import CuraApplication +from plugins.UM3NetworkPrinting.src.Cloud.CloudOutputDevice import CloudOutputDevice +from plugins.UM3NetworkPrinting.src.Cloud.CloudOutputDeviceManager import CloudOutputDeviceManager +from plugins.UM3NetworkPrinting.tests.Cloud.NetworkManagerMock import NetworkManagerMock + + +@patch("cura.NetworkClient.QNetworkAccessManager") +class TestCloudOutputDeviceManager(TestCase): + app = CuraApplication.getInstance() or CuraApplication() + + def setUp(self): + super().setUp() + self.app.initialize() + + self.network = NetworkManagerMock() + self.network.prepareResponse( + "GET", "https://api-staging.ultimaker.com/connect/v1/clusters", + 200, { + "data": [{ + "cluster_id": "RIZ6cZbWA_Ua7RZVJhrdVfVpf0z-MqaSHQE4v8aRTtYq", + "host_guid": "e90ae0ac-1257-4403-91ee-a44c9b7e8050", + "host_name": "ultimakersystem-ccbdd30044ec", "host_version": "5.1.2.20180807", + "is_online": False, "status": "inactive" + }, { + "cluster_id": "R0YcLJwar1ugh0ikEZsZs8NWKV6vJP_LdYsXgXqAcaNC", + "host_guid": "e90ae0ac-1257-4403-91ee-a44c9b7e8050", + "host_name": "ultimakersystem-ccbdd30044ec", "host_version": "5.1.2.20180807", + "is_online": True, "status": "active" + }] + } + ) + + def tearDown(self): + super().tearDown() + + def test_device(self, network_mock): + network_mock.return_value = self.network + + manager = CloudOutputDeviceManager() + manager._account.loginStateChanged.emit(True) + manager._update_timer.timeout.emit() + + self.network.flushReplies() + + devices = self.app.getOutputDeviceManager().getOutputDevices() + self.assertEqual([CloudOutputDevice], list(map(type, devices))) diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/__init__.py b/plugins/UM3NetworkPrinting/tests/Cloud/__init__.py new file mode 100644 index 0000000000..f3f6970c54 --- /dev/null +++ b/plugins/UM3NetworkPrinting/tests/Cloud/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. From c495ade2d363d9f19e3bb6a8444073e1c86fe3fc Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Mon, 10 Dec 2018 09:42:26 +0100 Subject: [PATCH 094/178] STAR-322: Documenting the network manager mock --- .../tests/Cloud/NetworkManagerMock.py | 46 +++++++++++++++++-- .../Cloud/TestCloudOutputDeviceManager.py | 21 ++------- 2 files changed, 47 insertions(+), 20 deletions(-) diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py b/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py index 945c526814..25107971c0 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py @@ -9,10 +9,15 @@ from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkReply from UM.Signal import Signal +## This class can be used to mock the QNetworkManager class and test the code using it. +# After patching the QNetworkManager class, requests are prepared before they can be executed. +# Any requests not prepared beforehand will cause KeyErrors. class NetworkManagerMock: + # signals used in the network manager. finished = Signal() authenticationRequired = Signal() + # an enumeration of the supported operations and their code for the network access manager. _OPERATIONS = { "GET": QNetworkAccessManager.GetOperation, "POST": QNetworkAccessManager.PostOperation, @@ -22,15 +27,29 @@ class NetworkManagerMock: } def __init__(self): + # a dict with the prepared replies, using the format {(http_method, url): reply} self.replies = {} # type: Dict[Tuple[str, str], QNetworkReply] - def __getattr__(self, method): + ## Mock implementation of the get, post, put, delete and head methods from the network manager. + # Since the methods are very simple and the same it didn't make sense to repeat the code. + # \param method: The method being called. + # \return The mocked function, if the method name is known. Defaults to the standard getattr function. + def __getattr__(self, method: str): operation = self._OPERATIONS.get(method.upper()) if operation: + # this mock implementation will simply return the reply from the prepared ones. + # it raises a KeyError if requests are done without being prepared. return lambda request, *_: self.replies[method.upper(), request.url().toString()] - return super().__getattribute__(method) - def prepareResponse(self, method: str, url: str, status_code: int, response: dict) -> None: + # the attribute is not one of the implemented methods, default to the standard implementation. + return getattr(super(), method) + + ## Prepares a server reply for the given parameters. + # \param method: The HTTP method. + # \param url: The URL being requested. + # \param status_code: The HTTP status code for the response. + # \param response: A dictionary with the response from the server (this is converted to JSON). + def prepareReply(self, method: str, url: str, status_code: int, response: dict) -> None: reply_mock = MagicMock() reply_mock.url().toString.return_value = url reply_mock.operation.return_value = self._OPERATIONS[method] @@ -38,9 +57,30 @@ class NetworkManagerMock: reply_mock.readAll.return_value = json.dumps(response).encode() self.replies[method, url] = reply_mock + ## Prepares a reply for the API call to get clusters. + def prepareGetClusters(self) -> None: + self.prepareReply( + "GET", "https://api-staging.ultimaker.com/connect/v1/clusters", + 200, { + "data": [{ + "cluster_id": "RIZ6cZbWA_Ua7RZVJhrdVfVpf0z-MqaSHQE4v8aRTtYq", + "host_guid": "e90ae0ac-1257-4403-91ee-a44c9b7e8050", + "host_name": "ultimakersystem-ccbdd30044ec", "host_version": "5.1.2.20180807", + "is_online": False, "status": "inactive" + }, { + "cluster_id": "R0YcLJwar1ugh0ikEZsZs8NWKV6vJP_LdYsXgXqAcaNC", + "host_guid": "e90ae0ac-1257-4403-91ee-a44c9b7e8050", + "host_name": "ultimakersystem-ccbdd30044ec", "host_version": "5.1.2.20180807", + "is_online": True, "status": "active" + }] + } + ) + + ## Emits the signal that the reply is ready to all prepared replies. def flushReplies(self): for reply in self.replies.values(): self.finished.emit(reply) + ## Deletes all prepared replies def reset(self): self.replies.clear() diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py index cf86f713b2..b22308ac1e 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py @@ -18,22 +18,7 @@ class TestCloudOutputDeviceManager(TestCase): self.app.initialize() self.network = NetworkManagerMock() - self.network.prepareResponse( - "GET", "https://api-staging.ultimaker.com/connect/v1/clusters", - 200, { - "data": [{ - "cluster_id": "RIZ6cZbWA_Ua7RZVJhrdVfVpf0z-MqaSHQE4v8aRTtYq", - "host_guid": "e90ae0ac-1257-4403-91ee-a44c9b7e8050", - "host_name": "ultimakersystem-ccbdd30044ec", "host_version": "5.1.2.20180807", - "is_online": False, "status": "inactive" - }, { - "cluster_id": "R0YcLJwar1ugh0ikEZsZs8NWKV6vJP_LdYsXgXqAcaNC", - "host_guid": "e90ae0ac-1257-4403-91ee-a44c9b7e8050", - "host_name": "ultimakersystem-ccbdd30044ec", "host_version": "5.1.2.20180807", - "is_online": True, "status": "active" - }] - } - ) + self.network.prepareGetClusters() def tearDown(self): super().tearDown() @@ -48,4 +33,6 @@ class TestCloudOutputDeviceManager(TestCase): self.network.flushReplies() devices = self.app.getOutputDeviceManager().getOutputDevices() - self.assertEqual([CloudOutputDevice], list(map(type, devices))) + self.assertEqual([CloudOutputDevice], [type(d) for d in devices]) + self.assertEqual(["R0YcLJwar1ugh0ikEZsZs8NWKV6vJP_LdYsXgXqAcaNC"], [d.key for d in devices]) + self.assertEqual(["ultimakersystem-ccbdd30044ec"], [d.host_name for d in devices]) From 75c2e25a012c6ae44e81be2c0747f9658a8ce930 Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Mon, 10 Dec 2018 10:43:22 +0100 Subject: [PATCH 095/178] STAR-322: Testing more of the device manager --- .../tests/Cloud/Fixtures/clusters.json | 17 ++++++ .../tests/Cloud/NetworkManagerMock.py | 49 +++++++++------- .../Cloud/TestCloudOutputDeviceManager.py | 58 ++++++++++++++----- 3 files changed, 88 insertions(+), 36 deletions(-) create mode 100644 plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/clusters.json diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/clusters.json b/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/clusters.json new file mode 100644 index 0000000000..79a4c30113 --- /dev/null +++ b/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/clusters.json @@ -0,0 +1,17 @@ +{ + "data": [{ + "cluster_id": "RIZ6cZbWA_Ua7RZVJhrdVfVpf0z-MqaSHQE4v8aRTtYq", + "host_guid": "e90ae0ac-1257-4403-91ee-a44c9b7e8050", + "host_name": "ultimakersystem-ccbdd30044ec", + "host_version": "5.0.0.20170101", + "is_online": true, + "status": "active" + }, { + "cluster_id": "NWKV6vJP_LdYsXgXqAcaNCR0YcLJwar1ugh0ikEZsZs8", + "host_guid": "e0ace90a-91ee-1257-4403-e8050a44c9b7", + "host_name": "ultimakersystem-ccbdd30044ec", + "host_version": "5.1.2.20180807", + "is_online": true, + "status": "active" + }] +} diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py b/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py index 25107971c0..fc84569fa4 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py @@ -1,11 +1,13 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. import json -from typing import Dict, Tuple +import os +from typing import Dict, Tuple, Optional from unittest.mock import MagicMock from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkReply +from UM.Logger import Logger from UM.Signal import Signal @@ -26,6 +28,7 @@ class NetworkManagerMock: "HEAD": QNetworkAccessManager.HeadOperation, } + ## Initializes the network manager mock. def __init__(self): # a dict with the prepared replies, using the format {(http_method, url): reply} self.replies = {} # type: Dict[Tuple[str, str], QNetworkReply] @@ -48,33 +51,37 @@ class NetworkManagerMock: # \param method: The HTTP method. # \param url: The URL being requested. # \param status_code: The HTTP status code for the response. - # \param response: A dictionary with the response from the server (this is converted to JSON). - def prepareReply(self, method: str, url: str, status_code: int, response: dict) -> None: + # \param response: The response body from the server (generally json-encoded). + def prepareReply(self, method: str, url: str, status_code: int, response: bytes) -> None: reply_mock = MagicMock() reply_mock.url().toString.return_value = url reply_mock.operation.return_value = self._OPERATIONS[method] reply_mock.attribute.return_value = status_code - reply_mock.readAll.return_value = json.dumps(response).encode() + reply_mock.readAll.return_value = response self.replies[method, url] = reply_mock + Logger.log("i", "Prepared mock {}-response to {} {}", status_code, method, url) ## Prepares a reply for the API call to get clusters. - def prepareGetClusters(self) -> None: - self.prepareReply( - "GET", "https://api-staging.ultimaker.com/connect/v1/clusters", - 200, { - "data": [{ - "cluster_id": "RIZ6cZbWA_Ua7RZVJhrdVfVpf0z-MqaSHQE4v8aRTtYq", - "host_guid": "e90ae0ac-1257-4403-91ee-a44c9b7e8050", - "host_name": "ultimakersystem-ccbdd30044ec", "host_version": "5.1.2.20180807", - "is_online": False, "status": "inactive" - }, { - "cluster_id": "R0YcLJwar1ugh0ikEZsZs8NWKV6vJP_LdYsXgXqAcaNC", - "host_guid": "e90ae0ac-1257-4403-91ee-a44c9b7e8050", - "host_name": "ultimakersystem-ccbdd30044ec", "host_version": "5.1.2.20180807", - "is_online": True, "status": "active" - }] - } - ) + # \param data: The data the server should return. If not given, a default response will be used. + # \return The data in the response. + def prepareGetClusters(self, data: Optional[dict] = None) -> dict: + data, response = self._getResponseData("clusters", data) + self.prepareReply("GET", "https://api-staging.ultimaker.com/connect/v1/clusters", 200, response) + return data + + ## Gets the data that should be in the server's response in both dictionary and JSON-encoded bytes format. + # \param fixture_name: The name of the fixture. + # \param data: The data that should be returned (optional) + # \return The server's response in both dictionary and JSON-encoded bytes format. + @staticmethod + def _getResponseData(fixture_name: str, data: Optional[dict] = None) -> Tuple[dict, bytes]: + if data is None: + with open("{}/Fixtures/{}.json".format(os.path.dirname(__file__), fixture_name), "rb") as f: + response = f.read() + data = json.loads(response.decode()) + else: + response = json.dumps(data).encode() + return data, response ## Emits the signal that the reply is ready to all prepared replies. def flushReplies(self): diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py index b22308ac1e..a043288e59 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py @@ -11,28 +11,56 @@ from plugins.UM3NetworkPrinting.tests.Cloud.NetworkManagerMock import NetworkMan @patch("cura.NetworkClient.QNetworkAccessManager") class TestCloudOutputDeviceManager(TestCase): - app = CuraApplication.getInstance() or CuraApplication() def setUp(self): super().setUp() - self.app.initialize() + self.app = CuraApplication.getInstance() + if not self.app: + self.app = CuraApplication() + self.app.initialize() self.network = NetworkManagerMock() - self.network.prepareGetClusters() + self.manager = CloudOutputDeviceManager() + self.clusters_response = self.network.prepareGetClusters() + ## In the tear down method we check whether the state of the output device manager is what we expect based on the + # mocked API response. def tearDown(self): super().tearDown() - - def test_device(self, network_mock): - network_mock.return_value = self.network - - manager = CloudOutputDeviceManager() - manager._account.loginStateChanged.emit(True) - manager._update_timer.timeout.emit() - + # let the network send replies self.network.flushReplies() - + # get the created devices devices = self.app.getOutputDeviceManager().getOutputDevices() - self.assertEqual([CloudOutputDevice], [type(d) for d in devices]) - self.assertEqual(["R0YcLJwar1ugh0ikEZsZs8NWKV6vJP_LdYsXgXqAcaNC"], [d.key for d in devices]) - self.assertEqual(["ultimakersystem-ccbdd30044ec"], [d.host_name for d in devices]) + # get the server data + clusters = self.clusters_response["data"] + self.assertEqual([CloudOutputDevice] * len(clusters), [type(d) for d in devices]) + self.assertEqual({cluster["cluster_id"] for cluster in clusters}, {device.key for device in devices}) + self.assertEqual({cluster["host_name"] for cluster in clusters}, {device.host_name for device in devices}) + + ## Runs the initial request to retrieve the clusters. + def _loadData(self, network_mock): + network_mock.return_value = self.network + self.manager._account.loginStateChanged.emit(True) + self.manager._update_timer.timeout.emit() + + def test_device_is_created(self, network_mock): + # just create the cluster, it is checked at tearDown + self._loadData(network_mock) + + def test_device_is_updated(self, network_mock): + self._loadData(network_mock) + + # update the cluster from member variable, which is checked at tearDown + self.clusters_response["data"][0]["host_name"] = "New host name" + self.network.prepareGetClusters(self.clusters_response) + + self.manager._update_timer.timeout.emit() + + def test_device_is_removed(self, network_mock): + self._loadData(network_mock) + + # delete the cluster from member variable, which is checked at tearDown + del self.clusters_response["data"][1] + self.network.prepareGetClusters(self.clusters_response) + + self.manager._update_timer.timeout.emit() From 134f97d5f15064703d9c1b59c7bb8162e7ee5e77 Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Mon, 10 Dec 2018 11:30:02 +0100 Subject: [PATCH 096/178] STAR-322: Finishing the output device manager tests --- .../src/Cloud/CloudOutputDeviceManager.py | 19 +++----- .../tests/Cloud/Fixtures/clusters.json | 2 +- .../Cloud/TestCloudOutputDeviceManager.py | 45 ++++++++++++++++++- 3 files changed, 51 insertions(+), 15 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index f06bbb305e..961f8d696d 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -22,7 +22,6 @@ from .Utils import findChanges # # API spec is available on https://api.ultimaker.com/docs/connect/spec/. # - class CloudOutputDeviceManager: META_CLUSTER_ID = "um_cloud_cluster_id" @@ -32,9 +31,7 @@ class CloudOutputDeviceManager: # The translation catalog for this device. I18N_CATALOG = i18nCatalog("cura") - def __init__(self): - super().__init__() - + def __init__(self) -> None: # Persistent dict containing the remote clusters for the authenticated user. self._remote_clusters = {} # type: Dict[str, CloudOutputDevice] @@ -86,6 +83,7 @@ class CloudOutputDeviceManager: for removed_cluster in removed_devices: if removed_cluster.isConnected(): removed_cluster.disconnect() + removed_cluster.close() self._output_device_manager.removeOutputDevice(removed_cluster.key) del self._remote_clusters[removed_cluster.key] @@ -124,20 +122,17 @@ class CloudOutputDeviceManager: return device = next((c for c in self._remote_clusters.values() if c.matchesNetworkKey(local_network_key)), None) - if not device: - return - - active_machine.setMetaDataEntry(self.META_CLUSTER_ID, device.key) - return device.connect() + if device: + active_machine.setMetaDataEntry(self.META_CLUSTER_ID, device.key) + device.connect() ## Handles an API error received from the cloud. # \param errors: The errors received def _onApiError(self, errors: List[CloudErrorObject]) -> None: message = ". ".join(e.title for e in errors) # TODO: translate errors - message = Message( + Message( text = message, title = self.I18N_CATALOG.i18nc("@info:title", "Error"), lifetime = 10, dismissable = True - ) - message.show() + ).show() diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/clusters.json b/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/clusters.json index 79a4c30113..5200e3b971 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/clusters.json +++ b/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/clusters.json @@ -9,7 +9,7 @@ }, { "cluster_id": "NWKV6vJP_LdYsXgXqAcaNCR0YcLJwar1ugh0ikEZsZs8", "host_guid": "e0ace90a-91ee-1257-4403-e8050a44c9b7", - "host_name": "ultimakersystem-ccbdd30044ec", + "host_name": "ultimakersystem-30044ecccbdd", "host_version": "5.1.2.20180807", "is_online": true, "status": "active" diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py index a043288e59..80ce54aeee 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py @@ -30,13 +30,18 @@ class TestCloudOutputDeviceManager(TestCase): # let the network send replies self.network.flushReplies() # get the created devices - devices = self.app.getOutputDeviceManager().getOutputDevices() + device_manager = self.app.getOutputDeviceManager() + devices = device_manager.getOutputDevices() # get the server data - clusters = self.clusters_response["data"] + clusters = self.clusters_response.get("data", []) self.assertEqual([CloudOutputDevice] * len(clusters), [type(d) for d in devices]) self.assertEqual({cluster["cluster_id"] for cluster in clusters}, {device.key for device in devices}) self.assertEqual({cluster["host_name"] for cluster in clusters}, {device.host_name for device in devices}) + for device in clusters: + device_manager.getOutputDevice(device["cluster_id"]).close() + device_manager.removeOutputDevice(device["cluster_id"]) + ## Runs the initial request to retrieve the clusters. def _loadData(self, network_mock): network_mock.return_value = self.network @@ -64,3 +69,39 @@ class TestCloudOutputDeviceManager(TestCase): self.network.prepareGetClusters(self.clusters_response) self.manager._update_timer.timeout.emit() + + @patch("cura.CuraApplication.CuraApplication.getGlobalContainerStack") + def test_device_connects_by_cluster_id(self, global_container_stack_mock, network_mock): + active_machine_mock = global_container_stack_mock.return_value + cluster1, cluster2 = self.clusters_response["data"] + cluster_id = cluster1["cluster_id"] + active_machine_mock.getMetaDataEntry.side_effect = {"um_cloud_cluster_id": cluster_id}.get + + self._loadData(network_mock) + self.network.flushReplies() + + self.assertTrue(self.app.getOutputDeviceManager().getOutputDevice(cluster1["cluster_id"]).isConnected()) + self.assertFalse(self.app.getOutputDeviceManager().getOutputDevice(cluster2["cluster_id"]).isConnected()) + + @patch("cura.CuraApplication.CuraApplication.getGlobalContainerStack") + def test_device_connects_by_network_key(self, global_container_stack_mock, network_mock): + active_machine_mock = global_container_stack_mock.return_value + + cluster1, cluster2 = self.clusters_response["data"] + network_key = cluster2["host_name"] + ".ultimaker.local" + active_machine_mock.getMetaDataEntry.side_effect = {"um_network_key": network_key}.get + + self._loadData(network_mock) + self.network.flushReplies() + + self.assertFalse(self.app.getOutputDeviceManager().getOutputDevice(cluster1["cluster_id"]).isConnected()) + self.assertTrue(self.app.getOutputDeviceManager().getOutputDevice(cluster2["cluster_id"]).isConnected()) + + active_machine_mock.setMetaDataEntry.assert_called_once_with("um_cloud_cluster_id", cluster2["cluster_id"]) + + @patch("UM.Message.Message.show") + def test_api_error(self, message_mock, network_mock): + self.clusters_response = {"errors": [{"id": "notFound"}]} + self.network.prepareGetClusters(self.clusters_response) + self._loadData(network_mock) + message_mock.assert_called_once_with() From f432d7c858862bfbc1e671c79718f57aee3a7c07 Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Mon, 10 Dec 2018 13:11:42 +0100 Subject: [PATCH 097/178] STAR-322: Using a test setup to run tests with Cura app --- .../tests/Cloud/NetworkManagerMock.py | 4 +- .../tests/Cloud/TestCloudApiClient.py | 5 +-- .../Cloud/TestCloudOutputDeviceManager.py | 27 ++++++++------ .../tests/TestSendMaterialJob.py | 2 +- plugins/UM3NetworkPrinting/tests/conftest.py | 37 +++++++++++++++++++ 5 files changed, 58 insertions(+), 17 deletions(-) create mode 100644 plugins/UM3NetworkPrinting/tests/conftest.py diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py b/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py index fc84569fa4..e8e4fc8de7 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py @@ -66,7 +66,8 @@ class NetworkManagerMock: # \return The data in the response. def prepareGetClusters(self, data: Optional[dict] = None) -> dict: data, response = self._getResponseData("clusters", data) - self.prepareReply("GET", "https://api-staging.ultimaker.com/connect/v1/clusters", 200, response) + status_code = 200 if "data" in data else int(data["errors"][0]["http_status"]) + self.prepareReply("GET", "https://api-staging.ultimaker.com/connect/v1/clusters", status_code, response) return data ## Gets the data that should be in the server's response in both dictionary and JSON-encoded bytes format. @@ -87,6 +88,7 @@ class NetworkManagerMock: def flushReplies(self): for reply in self.replies.values(): self.finished.emit(reply) + self.reset() ## Deletes all prepared replies def reset(self): diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py index 328bb053b7..91f367f9ad 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py @@ -95,9 +95,8 @@ class TestCloudApiClient(TestCase): def _callback(clusters): result.extend(clusters) - with mock.patch.object(Application, "getInstance", new = lambda: FixtureApplication()): - api = CloudApiClient(account_mock, self._errorHandler) - api.getClusters(_callback) + api = CloudApiClient(account_mock, self._errorHandler) + api.getClusters(_callback) manager_mock.return_value.finished.emit(reply_mock) diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py index 80ce54aeee..799e715f0d 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py @@ -4,9 +4,9 @@ from unittest import TestCase from unittest.mock import patch from cura.CuraApplication import CuraApplication -from plugins.UM3NetworkPrinting.src.Cloud.CloudOutputDevice import CloudOutputDevice -from plugins.UM3NetworkPrinting.src.Cloud.CloudOutputDeviceManager import CloudOutputDeviceManager -from plugins.UM3NetworkPrinting.tests.Cloud.NetworkManagerMock import NetworkManagerMock +from src.Cloud.CloudOutputDevice import CloudOutputDevice +from src.Cloud.CloudOutputDeviceManager import CloudOutputDeviceManager +from .NetworkManagerMock import NetworkManagerMock @patch("cura.NetworkClient.QNetworkAccessManager") @@ -15,18 +15,19 @@ class TestCloudOutputDeviceManager(TestCase): def setUp(self): super().setUp() self.app = CuraApplication.getInstance() - if not self.app: - self.app = CuraApplication() - self.app.initialize() - self.network = NetworkManagerMock() self.manager = CloudOutputDeviceManager() self.clusters_response = self.network.prepareGetClusters() - ## In the tear down method we check whether the state of the output device manager is what we expect based on the - # mocked API response. def tearDown(self): - super().tearDown() + try: + self._beforeTearDown() + finally: + super().tearDown() + + ## Before tear down method we check whether the state of the output device manager is what we expect based on the + # mocked API response. + def _beforeTearDown(self): # let the network send replies self.network.flushReplies() # get the created devices @@ -82,6 +83,7 @@ class TestCloudOutputDeviceManager(TestCase): self.assertTrue(self.app.getOutputDeviceManager().getOutputDevice(cluster1["cluster_id"]).isConnected()) self.assertFalse(self.app.getOutputDeviceManager().getOutputDevice(cluster2["cluster_id"]).isConnected()) + self.assertEquals([], active_machine_mock.setMetaDataEntry.mock_calls) @patch("cura.CuraApplication.CuraApplication.getGlobalContainerStack") def test_device_connects_by_network_key(self, global_container_stack_mock, network_mock): @@ -97,11 +99,12 @@ class TestCloudOutputDeviceManager(TestCase): self.assertFalse(self.app.getOutputDeviceManager().getOutputDevice(cluster1["cluster_id"]).isConnected()) self.assertTrue(self.app.getOutputDeviceManager().getOutputDevice(cluster2["cluster_id"]).isConnected()) - active_machine_mock.setMetaDataEntry.assert_called_once_with("um_cloud_cluster_id", cluster2["cluster_id"]) + active_machine_mock.setMetaDataEntry.assert_called_with("um_cloud_cluster_id", cluster2["cluster_id"]) @patch("UM.Message.Message.show") def test_api_error(self, message_mock, network_mock): - self.clusters_response = {"errors": [{"id": "notFound"}]} + self.clusters_response = {"errors": [{"id": "notFound", "title": "Not found!", "http_status": "404"}]} self.network.prepareGetClusters(self.clusters_response) self._loadData(network_mock) + self.network.flushReplies() message_mock.assert_called_once_with() diff --git a/plugins/UM3NetworkPrinting/tests/TestSendMaterialJob.py b/plugins/UM3NetworkPrinting/tests/TestSendMaterialJob.py index e3ec9faeaf..7db5ebdedf 100644 --- a/plugins/UM3NetworkPrinting/tests/TestSendMaterialJob.py +++ b/plugins/UM3NetworkPrinting/tests/TestSendMaterialJob.py @@ -10,7 +10,7 @@ from PyQt5.QtCore import QByteArray from UM.MimeTypeDatabase import MimeType from UM.Application import Application -from ..src.SendMaterialJob import SendMaterialJob +from src.SendMaterialJob import SendMaterialJob @patch("builtins.open", lambda _, __: io.StringIO("")) diff --git a/plugins/UM3NetworkPrinting/tests/conftest.py b/plugins/UM3NetworkPrinting/tests/conftest.py new file mode 100644 index 0000000000..6f245f8f2f --- /dev/null +++ b/plugins/UM3NetworkPrinting/tests/conftest.py @@ -0,0 +1,37 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Uranium is released under the terms of the LGPLv3 or higher. + +import pytest +import Arcus #Prevents error: "PyCapsule_GetPointer called with incorrect name" with conflicting SIP configurations between Arcus and PyQt: Import Arcus first! +from UM.Qt.QtApplication import QtApplication # QT application import is required, even though it isn't used. +from UM.Application import Application +from UM.Signal import Signal + +from cura.CuraApplication import CuraApplication + + +# This mock application must extend from Application and not QtApplication otherwise some QObjects are created and +# a segfault is raised. +class FixtureApplication(CuraApplication): + def __init__(self): + super().__init__() + super().initialize() + Signal._signalQueue = self + + def functionEvent(self, event): + event.call() + + def parseCommandLine(self): + pass + + def processEvents(self): + pass + + +@pytest.fixture(autouse=True) +def application(): + # Since we need to use it more that once, we create the application the first time and use its instance the second + application = FixtureApplication.getInstance() + if application is None: + application = FixtureApplication() + return application From e5124532f83ffb062ec6fba9a88fd0b258ce0136 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marijn=20De=C3=A9?= Date: Mon, 10 Dec 2018 14:03:43 +0100 Subject: [PATCH 098/178] getClusterStatusResponse as fixture --- .../Fixtures/getClusterStatusResponse.json | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/getClusterStatusResponse.json diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/getClusterStatusResponse.json b/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/getClusterStatusResponse.json new file mode 100644 index 0000000000..711e429a72 --- /dev/null +++ b/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/getClusterStatusResponse.json @@ -0,0 +1,97 @@ +{ + "data": [ + { + "generated_time": "2018-12-10T08:23:55.110Z", + "printers": [ + { + "configuration": [ + { + "extruder_index": 0, + "material": { + "material": "empty" + }, + "print_core_id": "AA 0.4" + }, + { + "extruder_index": 1, + "material": { + "material": "empty" + }, + "print_core_id": "AA 0.4" + } + ], + "enabled": true, + "firmware_version": "5.1.2.20180807", + "friendly_name": "Master-Luke", + "ip_address": "10.183.1.140", + "machine_variant": "Ultimaker 3", + "status": "maintenance", + "unique_name": "ultimakersystem-ccbdd30044ec", + "uuid": "b3a47ea3-1eeb-4323-9626-6f9c3c888f9e" + }, + { + "configuration": [ + { + "extruder_index": 0, + "material": { + "brand": "Generic", + "color": "Generic", + "guid": "506c9f0d-e3aa-4bd4-b2d2-23e2425b1aa9", + "material": "PLA" + }, + "print_core_id": "AA 0.4" + }, + { + "extruder_index": 1, + "material": { + "brand": "Ultimaker", + "color": "Red", + "guid": "9cfe5bf1-bdc5-4beb-871a-52c70777842d", + "material": "PLA" + }, + "print_core_id": "AA 0.4" + } + ], + "enabled": true, + "firmware_version": "4.3.3.20180529", + "friendly_name": "UM-Marijn", + "ip_address": "10.183.1.166", + "machine_variant": "Ultimaker 3", + "status": "idle", + "unique_name": "ultimakersystem-ccbdd30058ab", + "uuid": "6e62c40a-4601-4b0e-9fec-c7c02c59c30a" + } + ], + "print_jobs": [ + { + "assigned_to": "6e62c40a-4601-4b0e-9fec-c7c02c59c30a", + "configuration": [ + { + "extruder_index": 0, + "material": { + "brand": "Ultimaker", + "color": "Black", + "guid": "3ee70a86-77d8-4b87-8005-e4a1bc57d2ce", + "material": "PLA" + }, + "print_core_id": "AA 0.4" + } + ], + "constraints": {}, + "created_at": "2018-12-10T08:28:04.108Z", + "force": false, + "last_seen": 500165.109491861, + "machine_variant": "Ultimaker 3", + "name": "UM3_dragon", + "network_error_count": 0, + "owner": "Daniel Testing", + "started": false, + "status": "queued", + "time_elapsed": 0, + "time_total": 14145, + "uuid": "d1c8bd52-5e9f-486a-8c25-a123cc8c7702" + } + ] + } + ] +} From d482924ea299eed06939564483fd00789e9b9da6 Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Mon, 10 Dec 2018 14:43:02 +0100 Subject: [PATCH 099/178] STAR-322: First tests for cloud output device --- .../src/Cloud/CloudApiClient.py | 7 +- .../src/Cloud/CloudOutputDevice.py | 7 +- .../CloudClusterPrinterConfiguration.py | 3 - .../Fixtures/getClusterStatusResponse.json | 184 +++++++++--------- .../tests/Cloud/NetworkManagerMock.py | 30 +-- .../tests/Cloud/TestCloudOutputDevice.py | 73 +++++++ .../Cloud/TestCloudOutputDeviceManager.py | 14 +- plugins/UM3NetworkPrinting/tests/conftest.py | 11 +- 8 files changed, 191 insertions(+), 138 deletions(-) create mode 100644 plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py index b4c8774140..b3abc74ff4 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py @@ -36,6 +36,11 @@ class CloudApiClient(NetworkClient): self._account = account self._on_error = on_error + ## Gets the account used for the API. + @property + def account(self) -> Account: + return self._account + ## Retrieves all the clusters for the user that is currently logged in. # \param on_finished: The function to be called after the result is parsed. def getClusters(self, on_finished: Callable[[List[CloudCluster]], any]) -> None: @@ -46,7 +51,7 @@ class CloudApiClient(NetworkClient): # \param cluster_id: The ID of the cluster. # \param on_finished: The function to be called after the result is parsed. def getClusterStatus(self, cluster_id: str, on_finished: Callable[[CloudClusterStatus], any]) -> None: - url = "{}/cluster/{}/status".format(self.CLUSTER_API_ROOT, cluster_id) + url = "{}/clusters/{}/status".format(self.CLUSTER_API_ROOT, cluster_id) self.get(url, on_finished=self._wrapCallback(on_finished, CloudClusterStatus)) ## Requests the cloud to register the upload of a print job mesh. diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index 2800bc1c8c..a137e5261f 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -13,7 +13,6 @@ from UM.Logger import Logger from UM.Message import Message from UM.Qt.Duration import Duration, DurationFormat from UM.Scene.SceneNode import SceneNode -from cura.CuraApplication import CuraApplication from cura.PrinterOutput.NetworkedPrinterOutputDevice import AuthState, NetworkedPrinterOutputDevice from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel from plugins.UM3NetworkPrinting.src.Cloud.CloudOutputController import CloudOutputController @@ -93,7 +92,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): self._setInterfaceElements() self._device_id = device_id - self._account = CuraApplication.getInstance().getCuraAPI().account + self._account = api_client.account # We use the Cura Connect monitor tab to get most functionality right away. self._monitor_view_qml_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), @@ -174,10 +173,6 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): ) self._api.requestUpload(request, lambda response: self._onPrintJobCreated(mesh_bytes, response)) - ## Called when the connection to the cluster changes. - def connect(self) -> None: - super().connect() - ## Called when the network data should be updated. def _update(self) -> None: super()._update() diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterConfiguration.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterConfiguration.py index aa382136d0..c14a7f85c3 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterConfiguration.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterConfiguration.py @@ -1,8 +1,5 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from typing import List, Optional - -from cura.PrinterOutput.ConfigurationModel import ConfigurationModel from cura.PrinterOutput.ExtruderConfigurationModel import ExtruderConfigurationModel from cura.PrinterOutput.ExtruderOutputModel import ExtruderOutputModel from .CloudClusterPrinterConfigurationMaterial import CloudClusterPrinterConfigurationMaterial diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/getClusterStatusResponse.json b/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/getClusterStatusResponse.json index 711e429a72..4f9f47fc75 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/getClusterStatusResponse.json +++ b/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/getClusterStatusResponse.json @@ -1,97 +1,95 @@ { - "data": [ - { - "generated_time": "2018-12-10T08:23:55.110Z", - "printers": [ - { - "configuration": [ - { - "extruder_index": 0, - "material": { - "material": "empty" - }, - "print_core_id": "AA 0.4" + "data": { + "generated_time": "2018-12-10T08:23:55.110Z", + "printers": [ + { + "configuration": [ + { + "extruder_index": 0, + "material": { + "material": "empty" }, - { - "extruder_index": 1, - "material": { - "material": "empty" - }, - "print_core_id": "AA 0.4" - } - ], - "enabled": true, - "firmware_version": "5.1.2.20180807", - "friendly_name": "Master-Luke", - "ip_address": "10.183.1.140", - "machine_variant": "Ultimaker 3", - "status": "maintenance", - "unique_name": "ultimakersystem-ccbdd30044ec", - "uuid": "b3a47ea3-1eeb-4323-9626-6f9c3c888f9e" - }, - { - "configuration": [ - { - "extruder_index": 0, - "material": { - "brand": "Generic", - "color": "Generic", - "guid": "506c9f0d-e3aa-4bd4-b2d2-23e2425b1aa9", - "material": "PLA" - }, - "print_core_id": "AA 0.4" + "print_core_id": "AA 0.4" + }, + { + "extruder_index": 1, + "material": { + "material": "empty" }, - { - "extruder_index": 1, - "material": { - "brand": "Ultimaker", - "color": "Red", - "guid": "9cfe5bf1-bdc5-4beb-871a-52c70777842d", - "material": "PLA" - }, - "print_core_id": "AA 0.4" - } - ], - "enabled": true, - "firmware_version": "4.3.3.20180529", - "friendly_name": "UM-Marijn", - "ip_address": "10.183.1.166", - "machine_variant": "Ultimaker 3", - "status": "idle", - "unique_name": "ultimakersystem-ccbdd30058ab", - "uuid": "6e62c40a-4601-4b0e-9fec-c7c02c59c30a" - } - ], - "print_jobs": [ - { - "assigned_to": "6e62c40a-4601-4b0e-9fec-c7c02c59c30a", - "configuration": [ - { - "extruder_index": 0, - "material": { - "brand": "Ultimaker", - "color": "Black", - "guid": "3ee70a86-77d8-4b87-8005-e4a1bc57d2ce", - "material": "PLA" - }, - "print_core_id": "AA 0.4" - } - ], - "constraints": {}, - "created_at": "2018-12-10T08:28:04.108Z", - "force": false, - "last_seen": 500165.109491861, - "machine_variant": "Ultimaker 3", - "name": "UM3_dragon", - "network_error_count": 0, - "owner": "Daniel Testing", - "started": false, - "status": "queued", - "time_elapsed": 0, - "time_total": 14145, - "uuid": "d1c8bd52-5e9f-486a-8c25-a123cc8c7702" - } - ] - } - ] + "print_core_id": "AA 0.4" + } + ], + "enabled": true, + "firmware_version": "5.1.2.20180807", + "friendly_name": "Master-Luke", + "ip_address": "10.183.1.140", + "machine_variant": "Ultimaker 3", + "status": "maintenance", + "unique_name": "ultimakersystem-ccbdd30044ec", + "uuid": "b3a47ea3-1eeb-4323-9626-6f9c3c888f9e" + }, + { + "configuration": [ + { + "extruder_index": 0, + "material": { + "brand": "Generic", + "color": "Generic", + "guid": "506c9f0d-e3aa-4bd4-b2d2-23e2425b1aa9", + "material": "PLA" + }, + "print_core_id": "AA 0.4" + }, + { + "extruder_index": 1, + "material": { + "brand": "Ultimaker", + "color": "Red", + "guid": "9cfe5bf1-bdc5-4beb-871a-52c70777842d", + "material": "PLA" + }, + "print_core_id": "AA 0.4" + } + ], + "enabled": true, + "firmware_version": "4.3.3.20180529", + "friendly_name": "UM-Marijn", + "ip_address": "10.183.1.166", + "machine_variant": "Ultimaker 3", + "status": "idle", + "unique_name": "ultimakersystem-ccbdd30058ab", + "uuid": "6e62c40a-4601-4b0e-9fec-c7c02c59c30a" + } + ], + "print_jobs": [ + { + "assigned_to": "6e62c40a-4601-4b0e-9fec-c7c02c59c30a", + "configuration": [ + { + "extruder_index": 0, + "material": { + "brand": "Ultimaker", + "color": "Black", + "guid": "3ee70a86-77d8-4b87-8005-e4a1bc57d2ce", + "material": "PLA" + }, + "print_core_id": "AA 0.4" + } + ], + "constraints": {}, + "created_at": "2018-12-10T08:28:04.108Z", + "force": false, + "last_seen": 500165.109491861, + "machine_variant": "Ultimaker 3", + "name": "UM3_dragon", + "network_error_count": 0, + "owner": "Daniel Testing", + "started": false, + "status": "queued", + "time_elapsed": 0, + "time_total": 14145, + "uuid": "d1c8bd52-5e9f-486a-8c25-a123cc8c7702" + } + ] + } } diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py b/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py index e8e4fc8de7..c7dc1bac35 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py @@ -1,8 +1,7 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. import json -import os -from typing import Dict, Tuple, Optional +from typing import Dict, Tuple, Union from unittest.mock import MagicMock from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkReply @@ -52,38 +51,15 @@ class NetworkManagerMock: # \param url: The URL being requested. # \param status_code: The HTTP status code for the response. # \param response: The response body from the server (generally json-encoded). - def prepareReply(self, method: str, url: str, status_code: int, response: bytes) -> None: + def prepareReply(self, method: str, url: str, status_code: int, response: Union[bytes, dict]) -> None: reply_mock = MagicMock() reply_mock.url().toString.return_value = url reply_mock.operation.return_value = self._OPERATIONS[method] reply_mock.attribute.return_value = status_code - reply_mock.readAll.return_value = response + reply_mock.readAll.return_value = response if isinstance(response, bytes) else json.dumps(response).encode() self.replies[method, url] = reply_mock Logger.log("i", "Prepared mock {}-response to {} {}", status_code, method, url) - ## Prepares a reply for the API call to get clusters. - # \param data: The data the server should return. If not given, a default response will be used. - # \return The data in the response. - def prepareGetClusters(self, data: Optional[dict] = None) -> dict: - data, response = self._getResponseData("clusters", data) - status_code = 200 if "data" in data else int(data["errors"][0]["http_status"]) - self.prepareReply("GET", "https://api-staging.ultimaker.com/connect/v1/clusters", status_code, response) - return data - - ## Gets the data that should be in the server's response in both dictionary and JSON-encoded bytes format. - # \param fixture_name: The name of the fixture. - # \param data: The data that should be returned (optional) - # \return The server's response in both dictionary and JSON-encoded bytes format. - @staticmethod - def _getResponseData(fixture_name: str, data: Optional[dict] = None) -> Tuple[dict, bytes]: - if data is None: - with open("{}/Fixtures/{}.json".format(os.path.dirname(__file__), fixture_name), "rb") as f: - response = f.read() - data = json.loads(response.decode()) - else: - response = json.dumps(data).encode() - return data, response - ## Emits the signal that the reply is ready to all prepared replies. def flushReplies(self): for reply in self.replies.values(): diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py new file mode 100644 index 0000000000..4ed2767288 --- /dev/null +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py @@ -0,0 +1,73 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +import json +import os +from unittest import TestCase +from unittest.mock import patch, MagicMock + +from cura.CuraApplication import CuraApplication +from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel +from src.Cloud.CloudApiClient import CloudApiClient +from src.Cloud.CloudOutputController import CloudOutputController +from src.Cloud.CloudOutputDevice import CloudOutputDevice +from .NetworkManagerMock import NetworkManagerMock + + +@patch("cura.NetworkClient.QNetworkAccessManager") +class TestCloudOutputDevice(TestCase): + CLUSTER_ID = "RIZ6cZbWA_Ua7RZVJhrdVfVpf0z-MqaSHQE4v8aRTtYq" + HOST_NAME = "ultimakersystem-ccbdd30044ec" + URL = "https://api-staging.ultimaker.com/connect/v1/clusters/{}/status".format(CLUSTER_ID) + with open("{}/Fixtures/getClusterStatusResponse.json".format(os.path.dirname(__file__)), "rb") as f: + DEFAULT_RESPONSE = f.read() + + def setUp(self): + super().setUp() + self.app = CuraApplication.getInstance() + self.network = NetworkManagerMock() + self.account = MagicMock(isLoggedIn=True, accessToken="TestAccessToken") + self.onError = MagicMock() + self.device = CloudOutputDevice(CloudApiClient(self.account, self.onError), self.CLUSTER_ID, self.HOST_NAME) + self.cluster_status = json.loads(self.DEFAULT_RESPONSE.decode()) + self.network.prepareReply("GET", self.URL, 200, self.DEFAULT_RESPONSE) + + def tearDown(self): + try: + self._beforeTearDown() + finally: + super().tearDown() + + ## Before tear down method we check whether the state of the output device manager is what we expect based on the + # mocked API response. + def _beforeTearDown(self): + # let the network send replies + self.network.flushReplies() + # TODO + + def test_status(self, network_mock): + network_mock.return_value = self.network + self.device._update() + self.network.flushReplies() + + self.assertEqual([PrinterOutputModel, PrinterOutputModel], [type(printer) for printer in self.device.printers]) + + controller_fields = { + "_output_device": self.device, + "can_abort": False, + "can_control_manually": False, + "can_pause": False, + "can_pre_heat_bed": False, + "can_pre_heat_hotends": False, + "can_send_raw_gcode": False, + "can_update_firmware": False, + } + + self.assertEqual({printer["uuid"] for printer in self.cluster_status["data"]["printers"]}, + {printer.key for printer in self.device.printers}) + self.assertEqual([controller_fields, controller_fields], + [printer.getController().__dict__ for printer in self.device.printers]) + + self.assertEqual({job["uuid"] for job in self.cluster_status["data"]["print_jobs"]}, + {job.key for job in self.device.printJobs}) + self.assertEqual(["Daniel Testing"], [job.owner for job in self.device.printJobs]) + self.assertEqual(["UM3_dragon"], [job.name for job in self.device.printJobs]) diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py index 799e715f0d..9e980a8681 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py @@ -1,5 +1,7 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. +import json +import os from unittest import TestCase from unittest.mock import patch @@ -11,13 +13,17 @@ from .NetworkManagerMock import NetworkManagerMock @patch("cura.NetworkClient.QNetworkAccessManager") class TestCloudOutputDeviceManager(TestCase): + URL = "https://api-staging.ultimaker.com/connect/v1/clusters" + with open("{}/Fixtures/clusters.json".format(os.path.dirname(__file__)), "rb") as f: + DEFAULT_RESPONSE = f.read() def setUp(self): super().setUp() self.app = CuraApplication.getInstance() self.network = NetworkManagerMock() self.manager = CloudOutputDeviceManager() - self.clusters_response = self.network.prepareGetClusters() + self.clusters_response = json.loads(self.DEFAULT_RESPONSE.decode()) + self.network.prepareReply("GET", self.URL, 200, self.DEFAULT_RESPONSE) def tearDown(self): try: @@ -58,7 +64,7 @@ class TestCloudOutputDeviceManager(TestCase): # update the cluster from member variable, which is checked at tearDown self.clusters_response["data"][0]["host_name"] = "New host name" - self.network.prepareGetClusters(self.clusters_response) + self.network.prepareReply("GET", self.URL, 200, self.clusters_response) self.manager._update_timer.timeout.emit() @@ -67,7 +73,7 @@ class TestCloudOutputDeviceManager(TestCase): # delete the cluster from member variable, which is checked at tearDown del self.clusters_response["data"][1] - self.network.prepareGetClusters(self.clusters_response) + self.network.prepareReply("GET", self.URL, 200, self.clusters_response) self.manager._update_timer.timeout.emit() @@ -104,7 +110,7 @@ class TestCloudOutputDeviceManager(TestCase): @patch("UM.Message.Message.show") def test_api_error(self, message_mock, network_mock): self.clusters_response = {"errors": [{"id": "notFound", "title": "Not found!", "http_status": "404"}]} - self.network.prepareGetClusters(self.clusters_response) + self.network.prepareReply("GET", self.URL, 200, self.clusters_response) self._loadData(network_mock) self.network.flushReplies() message_mock.assert_called_once_with() diff --git a/plugins/UM3NetworkPrinting/tests/conftest.py b/plugins/UM3NetworkPrinting/tests/conftest.py index 6f245f8f2f..ce49bd3cb7 100644 --- a/plugins/UM3NetworkPrinting/tests/conftest.py +++ b/plugins/UM3NetworkPrinting/tests/conftest.py @@ -1,13 +1,11 @@ # Copyright (c) 2018 Ultimaker B.V. -# Uranium is released under the terms of the LGPLv3 or higher. +# Cura is released under the terms of the LGPLv3 or higher. import pytest -import Arcus #Prevents error: "PyCapsule_GetPointer called with incorrect name" with conflicting SIP configurations between Arcus and PyQt: Import Arcus first! -from UM.Qt.QtApplication import QtApplication # QT application import is required, even though it isn't used. -from UM.Application import Application from UM.Signal import Signal from cura.CuraApplication import CuraApplication +from cura.Machines.MaterialManager import MaterialManager # This mock application must extend from Application and not QtApplication otherwise some QObjects are created and @@ -18,6 +16,11 @@ class FixtureApplication(CuraApplication): super().initialize() Signal._signalQueue = self + self.getPreferences().addPreference("cura/favorite_materials", "") + + self._material_manager = MaterialManager(self._container_registry, parent = self) + self._material_manager.initialize() + def functionEvent(self, event): event.call() From bfd236dae417815c3f1308465f4587cf270ea449 Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Mon, 10 Dec 2018 16:11:43 +0100 Subject: [PATCH 100/178] STAR-322: Testing cloud printing --- .../src/Cloud/CloudApiClient.py | 2 +- .../src/Cloud/CloudOutputDevice.py | 2 +- .../tests/Cloud/Fixtures/__init__.py | 12 +++ .../{clusters.json => getClusters.json} | 0 .../Cloud/Fixtures/postJobPrintResponse.json | 7 ++ .../Cloud/Fixtures/putJobUploadResponse.json | 10 ++ .../tests/Cloud/NetworkManagerMock.py | 33 +++++-- .../tests/Cloud/TestCloudOutputDevice.py | 91 +++++++++++++++---- .../Cloud/TestCloudOutputDeviceManager.py | 9 +- 9 files changed, 129 insertions(+), 37 deletions(-) create mode 100644 plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/__init__.py rename plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/{clusters.json => getClusters.json} (100%) create mode 100644 plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/postJobPrintResponse.json create mode 100644 plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/putJobUploadResponse.json diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py index b3abc74ff4..448aa4d2e7 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py @@ -92,7 +92,7 @@ class CloudApiClient(NetworkClient): # \param job_id: The ID of the print job. # \param on_finished: The function to be called after the result is parsed. def requestPrint(self, cluster_id: str, job_id: str, on_finished: Callable[[CloudPrintResponse], any]) -> None: - url = "{}/cluster/{}/print/{}".format(self.CLUSTER_API_ROOT, cluster_id, job_id) + url = "{}/clusters/{}/print/{}".format(self.CLUSTER_API_ROOT, cluster_id, job_id) self.post(url, data = "", on_finished=self._wrapCallback(on_finished, CloudPrintResponse)) ## We override _createEmptyRequest in order to add the user credentials. diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index a137e5261f..17acbe2e3f 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -217,7 +217,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): printer.updateOutputModel(model) # Always have an active printer - if not self._active_printer: + if self._printers and not self._active_printer: self.setActivePrinter(self._printers[0]) self.printersChanged.emit() # TODO: Make this more efficient by not updating every request diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/__init__.py b/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/__init__.py new file mode 100644 index 0000000000..777afc92c2 --- /dev/null +++ b/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/__init__.py @@ -0,0 +1,12 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +import json +import os + + +def readFixture(fixture_name: str) -> bytes: + with open("{}/{}.json".format(os.path.dirname(__file__), fixture_name), "rb") as f: + return f.read() + +def parseFixture(fixture_name: str) -> dict: + return json.loads(readFixture(fixture_name).decode()) diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/clusters.json b/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/getClusters.json similarity index 100% rename from plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/clusters.json rename to plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/getClusters.json diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/postJobPrintResponse.json b/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/postJobPrintResponse.json new file mode 100644 index 0000000000..8b9574359f --- /dev/null +++ b/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/postJobPrintResponse.json @@ -0,0 +1,7 @@ +{ + "data": { + "cluster_job_id": "9a59d8e9-91d3-4ff6-b4cb-9db91c4094dd", + "job_id": "ABCDefGHIjKlMNOpQrSTUvYxWZ0-1234567890abcDE=", + "status": "queued" + } +} diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/putJobUploadResponse.json b/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/putJobUploadResponse.json new file mode 100644 index 0000000000..0474862720 --- /dev/null +++ b/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/putJobUploadResponse.json @@ -0,0 +1,10 @@ +{ + "data": { + "content_type": "text/plain", + "download_url": "https://api.ultimaker.com/print-job-download", + "job_id": "ABCDefGHIjKlMNOpQrSTUvYxWZ0-1234567890abcDE=", + "job_name": "Ultimaker Robot v3.0", + "status": "queued", + "upload_url": "https://api.ultimaker.com/print-job-upload" + } +} diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py b/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py index c7dc1bac35..5a76672b83 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py @@ -1,10 +1,10 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. import json -from typing import Dict, Tuple, Union +from typing import Dict, Tuple, Union, Optional from unittest.mock import MagicMock -from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkReply +from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest from UM.Logger import Logger from UM.Signal import Signal @@ -25,23 +25,30 @@ class NetworkManagerMock: "PUT": QNetworkAccessManager.PutOperation, "DELETE": QNetworkAccessManager.DeleteOperation, "HEAD": QNetworkAccessManager.HeadOperation, - } + } # type: Dict[str, int] ## Initializes the network manager mock. - def __init__(self): + def __init__(self) -> None: # a dict with the prepared replies, using the format {(http_method, url): reply} self.replies = {} # type: Dict[Tuple[str, str], QNetworkReply] + self.request_bodies = {} # type: Dict[Tuple[str, str], bytes] ## Mock implementation of the get, post, put, delete and head methods from the network manager. # Since the methods are very simple and the same it didn't make sense to repeat the code. # \param method: The method being called. # \return The mocked function, if the method name is known. Defaults to the standard getattr function. - def __getattr__(self, method: str): + def __getattr__(self, method: str) -> any: + ## This mock implementation will simply return the reply from the prepared ones. + # it raises a KeyError if requests are done without being prepared. + def doRequest(request: QNetworkRequest, body: Optional[bytes] = None, *_): + key = method.upper(), request.url().toString() + if body: + self.request_bodies[key] = body + return self.replies[key] + operation = self._OPERATIONS.get(method.upper()) if operation: - # this mock implementation will simply return the reply from the prepared ones. - # it raises a KeyError if requests are done without being prepared. - return lambda request, *_: self.replies[method.upper(), request.url().toString()] + return doRequest # the attribute is not one of the implemented methods, default to the standard implementation. return getattr(super(), method) @@ -60,12 +67,18 @@ class NetworkManagerMock: self.replies[method, url] = reply_mock Logger.log("i", "Prepared mock {}-response to {} {}", status_code, method, url) + ## Gets the request that was sent to the network manager for the given method and URL. + # \param method: The HTTP method. + # \param url: The URL. + def getRequestBody(self, method: str, url: str) -> Optional[bytes]: + return self.request_bodies.get((method.upper(), url)) + ## Emits the signal that the reply is ready to all prepared replies. - def flushReplies(self): + def flushReplies(self) -> None: for reply in self.replies.values(): self.finished.emit(reply) self.reset() ## Deletes all prepared replies - def reset(self): + def reset(self) -> None: self.replies.clear() diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py index 4ed2767288..6eca5d250d 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py @@ -1,25 +1,28 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. import json -import os from unittest import TestCase from unittest.mock import patch, MagicMock +from UM.Scene.SceneNode import SceneNode from cura.CuraApplication import CuraApplication from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel from src.Cloud.CloudApiClient import CloudApiClient -from src.Cloud.CloudOutputController import CloudOutputController from src.Cloud.CloudOutputDevice import CloudOutputDevice +from tests.Cloud.Fixtures import readFixture, parseFixture from .NetworkManagerMock import NetworkManagerMock @patch("cura.NetworkClient.QNetworkAccessManager") class TestCloudOutputDevice(TestCase): CLUSTER_ID = "RIZ6cZbWA_Ua7RZVJhrdVfVpf0z-MqaSHQE4v8aRTtYq" + JOB_ID = "ABCDefGHIjKlMNOpQrSTUvYxWZ0-1234567890abcDE=" HOST_NAME = "ultimakersystem-ccbdd30044ec" - URL = "https://api-staging.ultimaker.com/connect/v1/clusters/{}/status".format(CLUSTER_ID) - with open("{}/Fixtures/getClusterStatusResponse.json".format(os.path.dirname(__file__)), "rb") as f: - DEFAULT_RESPONSE = f.read() + + BASE_URL = "https://api-staging.ultimaker.com" + STATUS_URL = "{}/connect/v1/clusters/{}/status".format(BASE_URL, CLUSTER_ID) + PRINT_URL = "{}/connect/v1/clusters/{}/print/{}".format(BASE_URL, CLUSTER_ID, JOB_ID) + REQUEST_UPLOAD_URL = "{}/cura/v1/jobs/upload".format(BASE_URL) def setUp(self): super().setUp() @@ -28,21 +31,12 @@ class TestCloudOutputDevice(TestCase): self.account = MagicMock(isLoggedIn=True, accessToken="TestAccessToken") self.onError = MagicMock() self.device = CloudOutputDevice(CloudApiClient(self.account, self.onError), self.CLUSTER_ID, self.HOST_NAME) - self.cluster_status = json.loads(self.DEFAULT_RESPONSE.decode()) - self.network.prepareReply("GET", self.URL, 200, self.DEFAULT_RESPONSE) + self.cluster_status = parseFixture("getClusterStatusResponse") + self.network.prepareReply("GET", self.STATUS_URL, 200, readFixture("getClusterStatusResponse")) def tearDown(self): - try: - self._beforeTearDown() - finally: - super().tearDown() - - ## Before tear down method we check whether the state of the output device manager is what we expect based on the - # mocked API response. - def _beforeTearDown(self): - # let the network send replies + super().tearDown() self.network.flushReplies() - # TODO def test_status(self, network_mock): network_mock.return_value = self.network @@ -67,7 +61,66 @@ class TestCloudOutputDevice(TestCase): self.assertEqual([controller_fields, controller_fields], [printer.getController().__dict__ for printer in self.device.printers]) + self.assertEqual(["UM3PrintJobOutputModel"], [type(printer).__name__ for printer in self.device.printJobs]) self.assertEqual({job["uuid"] for job in self.cluster_status["data"]["print_jobs"]}, {job.key for job in self.device.printJobs}) - self.assertEqual(["Daniel Testing"], [job.owner for job in self.device.printJobs]) - self.assertEqual(["UM3_dragon"], [job.name for job in self.device.printJobs]) + self.assertEqual({job["owner"] for job in self.cluster_status["data"]["print_jobs"]}, + {job.owner for job in self.device.printJobs}) + self.assertEqual({job["name"] for job in self.cluster_status["data"]["print_jobs"]}, + {job.name for job in self.device.printJobs}) + + def test_remove_print_job(self, network_mock): + network_mock.return_value = self.network + self.device._update() + self.network.flushReplies() + self.assertEqual(1, len(self.device.printJobs)) + + self.cluster_status["data"]["print_jobs"].clear() + self.network.prepareReply("GET", self.STATUS_URL, 200, self.cluster_status) + self.device._update() + self.network.flushReplies() + self.assertEqual([], self.device.printJobs) + + def test_remove_printers(self, network_mock): + network_mock.return_value = self.network + self.device._update() + self.network.flushReplies() + self.assertEqual(2, len(self.device.printers)) + + self.cluster_status["data"]["printers"].clear() + self.network.prepareReply("GET", self.STATUS_URL, 200, self.cluster_status) + self.device._update() + self.network.flushReplies() + self.assertEqual([], self.device.printers) + + @patch("cura.CuraApplication.CuraApplication.getGlobalContainerStack") + def test_print_to_cloud(self, global_container_stack_mock, network_mock): + active_machine_mock = global_container_stack_mock.return_value + active_machine_mock.getMetaDataEntry.side_effect = {"file_formats": "application/gzip"}.get + + request_upload_response = parseFixture("putJobUploadResponse") + request_print_response = parseFixture("postJobPrintResponse") + self.network.prepareReply("PUT", self.REQUEST_UPLOAD_URL, 201, request_upload_response) + self.network.prepareReply("PUT", request_upload_response["data"]["upload_url"], 201, b"{}") + self.network.prepareReply("POST", self.PRINT_URL, 200, request_print_response) + + network_mock.return_value = self.network + file_handler = MagicMock() + file_handler.getSupportedFileTypesWrite.return_value = [{ + "extension": "gcode.gz", + "mime_type": "application/gzip", + "mode": 2, + }] + file_handler.getWriterByMimeType.return_value.write.side_effect = \ + lambda stream, nodes: stream.write(str(nodes).encode()) + + scene_nodes = [SceneNode()] + self.device.requestWrite(scene_nodes, file_handler=file_handler, file_name="FileName") + + self.network.flushReplies() + self.assertEqual({"data": {"content_type": "application/gzip", "file_size": 57, "job_name": "FileName"}}, + json.loads(self.network.getRequestBody("PUT", self.REQUEST_UPLOAD_URL).decode())) + self.assertEqual(str(scene_nodes).encode(), + self.network.getRequestBody("PUT", request_upload_response["data"]["upload_url"])) + + self.assertIsNone(self.network.getRequestBody("POST", self.PRINT_URL)) diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py index 9e980a8681..420d71d0fe 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py @@ -1,29 +1,26 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -import json -import os from unittest import TestCase from unittest.mock import patch from cura.CuraApplication import CuraApplication from src.Cloud.CloudOutputDevice import CloudOutputDevice from src.Cloud.CloudOutputDeviceManager import CloudOutputDeviceManager +from tests.Cloud.Fixtures import parseFixture, readFixture from .NetworkManagerMock import NetworkManagerMock @patch("cura.NetworkClient.QNetworkAccessManager") class TestCloudOutputDeviceManager(TestCase): URL = "https://api-staging.ultimaker.com/connect/v1/clusters" - with open("{}/Fixtures/clusters.json".format(os.path.dirname(__file__)), "rb") as f: - DEFAULT_RESPONSE = f.read() def setUp(self): super().setUp() self.app = CuraApplication.getInstance() self.network = NetworkManagerMock() self.manager = CloudOutputDeviceManager() - self.clusters_response = json.loads(self.DEFAULT_RESPONSE.decode()) - self.network.prepareReply("GET", self.URL, 200, self.DEFAULT_RESPONSE) + self.clusters_response = parseFixture("getClusters") + self.network.prepareReply("GET", self.URL, 200, readFixture("getClusters")) def tearDown(self): try: From 0357003e6991652c7b07762c2cca0a218b3cc5f6 Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Mon, 10 Dec 2018 21:47:06 +0100 Subject: [PATCH 101/178] STAR-322: Testing encode independent --- .../tests/Cloud/TestCloudOutputDevice.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py index 6eca5d250d..287f2dda98 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py @@ -115,12 +115,15 @@ class TestCloudOutputDevice(TestCase): lambda stream, nodes: stream.write(str(nodes).encode()) scene_nodes = [SceneNode()] + expected_mesh = str(scene_nodes).encode() self.device.requestWrite(scene_nodes, file_handler=file_handler, file_name="FileName") self.network.flushReplies() - self.assertEqual({"data": {"content_type": "application/gzip", "file_size": 57, "job_name": "FileName"}}, - json.loads(self.network.getRequestBody("PUT", self.REQUEST_UPLOAD_URL).decode())) - self.assertEqual(str(scene_nodes).encode(), + self.assertEqual( + {"data": {"content_type": "application/gzip", "file_size": len(expected_mesh), "job_name": "FileName"}}, + json.loads(self.network.getRequestBody("PUT", self.REQUEST_UPLOAD_URL).decode()) + ) + self.assertEqual(expected_mesh, self.network.getRequestBody("PUT", request_upload_response["data"]["upload_url"])) self.assertIsNone(self.network.getRequestBody("POST", self.PRINT_URL)) From 99cfef6cdc33d222178ee6f7418d40b5c1727525 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marijn=20De=C3=A9?= Date: Tue, 11 Dec 2018 09:25:26 +0100 Subject: [PATCH 102/178] Use the NetworkManagerMock, added tests for the other functions --- .../Cloud/Fixtures/requestPrintResponse.json | 7 + .../tests/Cloud/TestCloudApiClient.py | 212 +++++++++--------- 2 files changed, 109 insertions(+), 110 deletions(-) create mode 100644 plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/requestPrintResponse.json diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/requestPrintResponse.json b/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/requestPrintResponse.json new file mode 100644 index 0000000000..e69589784d --- /dev/null +++ b/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/requestPrintResponse.json @@ -0,0 +1,7 @@ +{ + "data": { + "cluster_job_id": "", + "job_id": "db34b096-c4d5-46f3-bea7-da6a19905e6c", + "status": "queued" + } +} diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py index 91f367f9ad..07c7733ac1 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py @@ -2,134 +2,126 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. import json -from typing import Dict, Tuple -from unittest import TestCase, mock +import os +from unittest import TestCase from unittest.mock import patch, MagicMock -from PyQt5.QtCore import QByteArray -from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkReply - -from UM.Application import Application -from UM.Signal import Signal from cura.CuraApplication import CuraApplication -from plugins.UM3NetworkPrinting.src.Cloud.CloudApiClient import CloudApiClient -from plugins.UM3NetworkPrinting.src.Cloud.Models import CloudCluster, CloudErrorObject - -# This mock application must extend from Application and not QtApplication otherwise some QObjects are created and -# a segfault is raised. -class FixtureApplication(Application): - def __init__(self): - super().__init__(name = "test", version = "1.0", api_version = "5.0.0") - super().initialize() - Signal._signalQueue = self - - def functionEvent(self, event): - event.call() - - def parseCommandLine(self): - pass - - def processEvents(self): - pass - - def getRenderer(self): - return MagicMock() - -class ManagerMock: - finished = Signal() - authenticationRequired = Signal() - - def __init__(self, reply): - self.reply = reply - - def get(self, request): - self.reply.url.return_value = request.url() - - return self.reply - -class ManagerMock2: - finished = Signal() - authenticationRequired = Signal() - - def get(self, request): - reply_mock = MagicMock() - reply_mock.url = request.url - reply_mock.operation.return_value = QNetworkAccessManager.GetOperation - return reply_mock - - @staticmethod - def createReply(method: str, url: str, status_code: int, response: dict): - reply_mock = MagicMock() - reply_mock.url().toString.return_value = url - reply_mock.operation.return_value = { - "GET": QNetworkAccessManager.GetOperation, - "POST": QNetworkAccessManager.PostOperation, - "PUT": QNetworkAccessManager.PutOperation, - "DELETE": QNetworkAccessManager.DeleteOperation, - "HEAD": QNetworkAccessManager.HeadOperation, - }[method] - reply_mock.attribute.return_value = status_code - reply_mock.readAll.return_value = json.dumps(response).encode() - return reply_mock +from src.Cloud.CloudApiClient import CloudApiClient +from src.Cloud.CloudOutputDeviceManager import CloudOutputDeviceManager +from src.Cloud.Models.CloudJobResponse import CloudJobResponse +from src.Cloud.Models.CloudJobUploadRequest import CloudJobUploadRequest +from .NetworkManagerMock import NetworkManagerMock +@patch("cura.NetworkClient.QNetworkAccessManager") class TestCloudApiClient(TestCase): - - app = CuraApplication.getInstance() or CuraApplication - - def _errorHandler(self, errors: [CloudErrorObject]): + def _errorHandler(self): pass - @patch("cura.NetworkClient.QNetworkAccessManager") - @patch("cura.API.Account") - def test_GetClusters(self, account_mock, manager_mock): - reply_mock = MagicMock() - reply_mock.operation.return_value = 2 - reply_mock.attribute.return_value = 200 - reply_mock.readAll.return_value = b'{"data": [{"cluster_id": "RIZ6cZbWA_Ua7RZVJhrdVfVpf0z-MqaSHQE4v8aRTtYq", "host_guid": "e90ae0ac-1257-4403-91ee-a44c9b7e8050", "host_name": "ultimakersystem-ccbdd30044ec", "host_version": "5.1.2.20180807", "is_online": false, "status": "inactive"}, {"cluster_id": "R0YcLJwar1ugh0ikEZsZs8NWKV6vJP_LdYsXgXqAcaNC", "host_guid": "e90ae0ac-1257-4403-91ee-a44c9b7e8050", "host_name": "ultimakersystem-ccbdd30044ec", "host_version": "5.1.2.20180807", "is_online": true, "status": "active"}]}' - manager_mock.return_value = ManagerMock(reply_mock) - account_mock.isLoggedIn.return_value = True + def setUp(self): + super().setUp() + self.account = MagicMock() + self.account.isLoggedIn.return_value = True + + self.app = CuraApplication.getInstance() + self.network = NetworkManagerMock() + self.manager = CloudOutputDeviceManager() + self.api = CloudApiClient(self.account, self._errorHandler) + + def test_GetClusters(self, network_mock): + network_mock.return_value = self.network result = [] - def _callback(clusters): - result.extend(clusters) + with open("{}/Fixtures/getClusters.json".format(os.path.dirname(__file__)), "rb") as f: + response = f.read() - api = CloudApiClient(account_mock, self._errorHandler) - api.getClusters(_callback) + self.network.prepareReply("GET", "https://api-staging.ultimaker.com/connect/v1/clusters", 200, response) + # the callback is a function that adds the result of the call to getClusters to the result list + self.api.getClusters(lambda clusters: result.extend(clusters)) - manager_mock.return_value.finished.emit(reply_mock) + self.network.flushReplies() self.assertEqual(2, len(result)) - @patch("cura.NetworkClient.QNetworkAccessManager") - @patch("cura.API.Account") - def test_GetClusters2(self, account_mock, manager_mock): - manager = ManagerMock2() - manager_mock.return_value = manager - account_mock.isLoggedIn.return_value = True + def test_getClusterStatus(self, network_mock): + network_mock.return_value = self.network result = [] - # with mock.patch.object(Application, "getInstance", new = lambda: FixtureApplication()): - api = CloudApiClient(account_mock, self._errorHandler) - api.getClusters(lambda clusters: result.extend(clusters)) + with open("{}/Fixtures/getClusterStatusResponse.json".format(os.path.dirname(__file__)), "rb") as f: + response = f.read() - manager.finished.emit(ManagerMock2.createReply( - "GET", "https://api-staging.ultimaker.com/connect/v1/clusters", - 200, { - "data": [{ - "cluster_id": "RIZ6cZbWA_Ua7RZVJhrdVfVpf0z-MqaSHQE4v8aRTtYq", - "host_guid": "e90ae0ac-1257-4403-91ee-a44c9b7e8050", - "host_name": "ultimakersystem-ccbdd30044ec", "host_version": "5.1.2.20180807", - "is_online": False, "status": "inactive" - }, { - "cluster_id": "R0YcLJwar1ugh0ikEZsZs8NWKV6vJP_LdYsXgXqAcaNC", - "host_guid": "e90ae0ac-1257-4403-91ee-a44c9b7e8050", - "host_name": "ultimakersystem-ccbdd30044ec", "host_version": "5.1.2.20180807", - "is_online": True, "status": "active" - }] - } - )) + self.network.prepareReply("GET", + "https://api-staging.ultimaker.com/connect/v1/clusters/R0YcLJwar1ugh0ikEZsZs8NWKV6vJP_LdYsXgXqAcaNC/status", + 200, response + ) + self.api.getClusterStatus("R0YcLJwar1ugh0ikEZsZs8NWKV6vJP_LdYsXgXqAcaNC", lambda status: result.append(status)) - self.assertEqual(2, len(result)) + self.network.flushReplies() + + self.assertEqual(len(result), 1) + status = result[0] + + self.assertEqual(len(status.printers), 2) + self.assertEqual(len(status.print_jobs), 1) + + def test_requestUpload(self, network_mock): + network_mock.return_value = self.network + results = [] + + with open("{}/Fixtures/requestUploadResponse.json".format(os.path.dirname(__file__)), "rb") as f: + response = f.read() + + self.network.prepareReply("PUT", "https://api-staging.ultimaker.com/cura/v1/jobs/upload", 200, response) + self.api.requestUpload(CloudJobUploadRequest(job_name = "job name", file_size = 143234, content_type = "text/plain"), + lambda r: results.append(r)) + self.network.flushReplies() + + self.assertEqual(results[0].content_type, "text/plain") + self.assertEqual(results[0].status, "uploading") + + def test_uploadMesh(self, network_mock): + network_mock.return_value = self.network + results = [] + progress = MagicMock() + + with open("{}/Fixtures/requestUploadResponse.json".format(os.path.dirname(__file__)), "rb") as f: + thedata = json.loads(f.read().decode("ascii")) + data = thedata["data"] + upload_response = CloudJobResponse(**data) + + self.network.prepareReply("PUT", upload_response.upload_url, 200, + '{ data : "" }') # Network client doesn't look into the reply + + self.api.uploadMesh(upload_response, b'', lambda job_id: results.append(job_id), + progress.advance, progress.error) + + self.network.flushReplies() + + self.assertEqual(len(results), 1) + self.assertEqual(results[0], upload_response.job_id) + + def test_requestPrint(self, network_mock): + network_mock.return_value = self.network + results = [] + + cluster_id = "NWKV6vJP_LdYsXgXqAcaNCR0YcLJwar1ugh0ikEZsZs8" + job_id = "db34b096-c4d5-46f3-bea7-da6a19905e6c" + + with open("{}/Fixtures/requestPrintResponse.json".format(os.path.dirname(__file__)), "rb") as f: + response = f.read() + + self.network.prepareReply("POST", + "https://api-staging.ultimaker.com/connect/v1/clusters/{}/print/{}" + .format(cluster_id, job_id), + 200, response) + + self.api.requestPrint(cluster_id, job_id, lambda r: results.append(r)) + + self.network.flushReplies() + + self.assertEqual(len(results), 1) + self.assertEqual(results[0].job_id, "db34b096-c4d5-46f3-bea7-da6a19905e6c") + self.assertEqual(results[0].status, "queued") From 3b54cb4b241187562341d7435c391dbefe0949b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marijn=20De=C3=A9?= Date: Tue, 11 Dec 2018 09:27:40 +0100 Subject: [PATCH 103/178] Use the NetworkManagerMock, added tests for the other functions --- .../tests/Cloud/Fixtures/requestUploadResponse.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/requestUploadResponse.json diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/requestUploadResponse.json b/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/requestUploadResponse.json new file mode 100644 index 0000000000..6168e5205f --- /dev/null +++ b/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/requestUploadResponse.json @@ -0,0 +1,11 @@ +{ + "data": { + "content_type": "text/plain", + "generated_time": "2018-12-10T09:33:00.009Z", + "job_id": "j9KUn4D6FRRRmdtbCo4OGAwUf6Ml3p3oU-Zv7RNRv92T", + "job_name": "job name", + "status": "uploading", + "status_description": "The print job has been created. Please upload the file.", + "upload_url": "https://www.googleapis.com/upload/storage/v1/b/ultimaker-storage-1/o?uploadType=resumable&upload_id=AEnB2Uqhg1H7BXQVeLJEWw6AheqMicydZVLuH9bnkh6Oge0e6i5X76MW3NZHWRmUTmjzulAF42mkczcC7rsAuPg1Nn8JeFpnNA" + } +} From 76688015645f6e3cce3fd66d7d6539ef870200a0 Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Tue, 11 Dec 2018 11:12:32 +0100 Subject: [PATCH 104/178] STAR-322: Adding documentation and fixing model types --- .../src/Cloud/CloudApiClient.py | 25 +++--- .../src/Cloud/CloudOutputDevice.py | 26 +++--- .../src/Cloud/CloudOutputDeviceManager.py | 6 +- .../src/Cloud/Models/BaseCloudModel.py | 48 ++++++++-- .../src/Cloud/Models/CloudCluster.py | 21 ----- .../src/Cloud/Models/CloudClusterPrintJob.py | 65 -------------- .../Models/CloudClusterPrintJobConstraint.py | 12 ++- .../Models/CloudClusterPrintJobStatus.py | 87 +++++++++++++++++++ .../src/Cloud/Models/CloudClusterPrinter.py | 49 ----------- .../CloudClusterPrinterConfiguration.py | 23 +++-- ...loudClusterPrinterConfigurationMaterial.py | 19 ++-- .../Cloud/Models/CloudClusterPrinterStatus.py | 60 +++++++++++++ .../src/Cloud/Models/CloudClusterResponse.py | 32 +++++++ .../src/Cloud/Models/CloudClusterStatus.py | 34 ++++---- .../src/Cloud/Models/CloudErrorObject.py | 29 +++++-- .../src/Cloud/Models/CloudJobResponse.py | 16 ---- .../src/Cloud/Models/CloudJobUploadRequest.py | 12 --- .../src/Cloud/Models/CloudPrintJobResponse.py | 33 +++++++ .../Models/CloudPrintJobUploadRequest.py | 17 ++++ .../src/Cloud/Models/CloudPrintResponse.py | 19 +++- .../Cloud/Fixtures/postJobPrintResponse.json | 3 +- .../Cloud/Fixtures/putJobUploadResponse.json | 3 +- .../Cloud/Fixtures/requestPrintResponse.json | 7 -- .../Cloud/Fixtures/requestUploadResponse.json | 11 --- .../tests/Cloud/TestCloudApiClient.py | 32 ++++--- .../Cloud/TestCloudOutputDeviceManager.py | 4 +- 26 files changed, 411 insertions(+), 282 deletions(-) delete mode 100644 plugins/UM3NetworkPrinting/src/Cloud/Models/CloudCluster.py delete mode 100644 plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJob.py create mode 100644 plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobStatus.py delete mode 100644 plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinter.py create mode 100644 plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterStatus.py create mode 100644 plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterResponse.py delete mode 100644 plugins/UM3NetworkPrinting/src/Cloud/Models/CloudJobResponse.py delete mode 100644 plugins/UM3NetworkPrinting/src/Cloud/Models/CloudJobUploadRequest.py create mode 100644 plugins/UM3NetworkPrinting/src/Cloud/Models/CloudPrintJobResponse.py create mode 100644 plugins/UM3NetworkPrinting/src/Cloud/Models/CloudPrintJobUploadRequest.py delete mode 100644 plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/requestPrintResponse.json delete mode 100644 plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/requestUploadResponse.json diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py index 448aa4d2e7..9cc70587a3 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py @@ -10,12 +10,12 @@ from UM.Logger import Logger from cura.API import Account from cura.NetworkClient import NetworkClient from ..Models import BaseModel -from .Models.CloudCluster import CloudCluster +from .Models.CloudClusterResponse import CloudClusterResponse from .Models.CloudErrorObject import CloudErrorObject from .Models.CloudClusterStatus import CloudClusterStatus -from .Models.CloudJobUploadRequest import CloudJobUploadRequest +from .Models.CloudPrintJobUploadRequest import CloudPrintJobUploadRequest from .Models.CloudPrintResponse import CloudPrintResponse -from .Models.CloudJobResponse import CloudJobResponse +from .Models.CloudPrintJobResponse import CloudPrintJobResponse ## The cloud API client is responsible for handling the requests and responses from the cloud. @@ -43,9 +43,9 @@ class CloudApiClient(NetworkClient): ## Retrieves all the clusters for the user that is currently logged in. # \param on_finished: The function to be called after the result is parsed. - def getClusters(self, on_finished: Callable[[List[CloudCluster]], any]) -> None: + def getClusters(self, on_finished: Callable[[List[CloudClusterResponse]], any]) -> None: url = "{}/clusters".format(self.CLUSTER_API_ROOT) - self.get(url, on_finished=self._wrapCallback(on_finished, CloudCluster)) + self.get(url, on_finished=self._wrapCallback(on_finished, CloudClusterResponse)) ## Retrieves the status of the given cluster. # \param cluster_id: The ID of the cluster. @@ -57,10 +57,11 @@ class CloudApiClient(NetworkClient): ## Requests the cloud to register the upload of a print job mesh. # \param request: The request object. # \param on_finished: The function to be called after the result is parsed. - def requestUpload(self, request: CloudJobUploadRequest, on_finished: Callable[[CloudJobResponse], any]) -> None: + def requestUpload(self, request: CloudPrintJobUploadRequest, on_finished: Callable[[CloudPrintJobResponse], any] + ) -> None: url = "{}/jobs/upload".format(self.CURA_API_ROOT) - body = json.dumps({"data": request.__dict__}) - self.put(url, body, on_finished=self._wrapCallback(on_finished, CloudJobResponse)) + body = json.dumps({"data": request.toDict()}) + self.put(url, body, on_finished=self._wrapCallback(on_finished, CloudPrintJobResponse)) ## Requests the cloud to register the upload of a print job mesh. # \param upload_response: The object received after requesting an upload with `self.requestUpload`. @@ -68,7 +69,7 @@ class CloudApiClient(NetworkClient): # \param on_finished: The function to be called after the result is parsed. It receives the print job ID. # \param on_progress: A function to be called during upload progress. It receives a percentage (0-100). # \param on_error: A function to be called if the upload fails. It receives a dict with the error. - def uploadMesh(self, upload_response: CloudJobResponse, mesh: bytes, on_finished: Callable[[str], any], + def uploadMesh(self, upload_response: CloudPrintJobResponse, mesh: bytes, on_finished: Callable[[str], any], on_progress: Callable[[int], any], on_error: Callable[[dict], any]): def progressCallback(bytes_sent: int, bytes_total: int) -> None: @@ -126,13 +127,13 @@ class CloudApiClient(NetworkClient): ## Parses the given models and calls the correct callback depending on the result. # \param response: The response from the server, after being converted to a dict. # \param on_finished: The callback in case the response is successful. - # \param model: The type of the model to convert the response to. It may either be a single record or a list. + # \param model_class: The type of the model to convert the response to. It may either be a single record or a list. def _parseModels(self, response: Dict[str, any], on_finished: Callable[[Union[Model, List[Model]]], any], - model: Type[Model]) -> None: + model_class: Type[Model]) -> None: if "data" in response: data = response["data"] - result = [model(**c) for c in data] if isinstance(data, list) else model(**data) + result = [model_class(**c) for c in data] if isinstance(data, list) else model_class(**data) on_finished(result) elif "errors" in response: self._on_error([CloudErrorObject(**error) for error in response["errors"]]) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index 17acbe2e3f..15eeed108d 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -20,11 +20,11 @@ from ..MeshFormatHandler import MeshFormatHandler from ..UM3PrintJobOutputModel import UM3PrintJobOutputModel from .CloudApiClient import CloudApiClient from .Models.CloudClusterStatus import CloudClusterStatus -from .Models.CloudJobUploadRequest import CloudJobUploadRequest +from .Models.CloudPrintJobUploadRequest import CloudPrintJobUploadRequest from .Models.CloudPrintResponse import CloudPrintResponse -from .Models.CloudJobResponse import CloudJobResponse -from .Models.CloudClusterPrinter import CloudClusterPrinter -from .Models.CloudClusterPrintJob import CloudClusterPrintJob +from .Models.CloudPrintJobResponse import CloudPrintJobResponse +from .Models.CloudClusterPrinterStatus import CloudClusterPrinterStatus +from .Models.CloudClusterPrintJobStatus import CloudClusterPrintJobStatus from .Utils import findChanges, formatDateCompleted, formatTimeCompleted @@ -116,8 +116,8 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): self._progress_message = None # type: Optional[Message] # Keep server string of the last generated time to avoid updating models more than once for the same response - self._received_printers = None # type: Optional[List[CloudClusterPrinter]] - self._received_print_jobs = None # type: Optional[List[CloudClusterPrintJob]] + self._received_printers = None # type: Optional[List[CloudClusterPrinterStatus]] + self._received_print_jobs = None # type: Optional[List[CloudClusterPrintJobStatus]] # A set of the user's job IDs that have finished self._finished_jobs = set() # type: Set[str] @@ -166,7 +166,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): mesh_bytes = mesh_format.getBytes(nodes) - request = CloudJobUploadRequest( + request = CloudPrintJobUploadRequest( job_name = file_name, file_size = len(mesh_bytes), content_type = mesh_format.mime_type, @@ -199,9 +199,9 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): ## Updates the local list of printers with the list received from the cloud. # \param jobs: The printers received from the cloud. - def _updatePrinters(self, printers: List[CloudClusterPrinter]) -> None: + def _updatePrinters(self, printers: List[CloudClusterPrinterStatus]) -> None: previous = {p.key: p for p in self._printers} # type: Dict[str, PrinterOutputModel] - received = {p.uuid: p for p in printers} # type: Dict[str, CloudClusterPrinter] + received = {p.uuid: p for p in printers} # type: Dict[str, CloudClusterPrinterStatus] removed_printers, added_printers, updated_printers = findChanges(previous, received) @@ -224,8 +224,8 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): ## Updates the local list of print jobs with the list received from the cloud. # \param jobs: The print jobs received from the cloud. - def _updatePrintJobs(self, jobs: List[CloudClusterPrintJob]) -> None: - received = {j.uuid: j for j in jobs} # type: Dict[str, CloudClusterPrintJob] + def _updatePrintJobs(self, jobs: List[CloudClusterPrintJobStatus]) -> None: + received = {j.uuid: j for j in jobs} # type: Dict[str, CloudClusterPrintJobStatus] previous = {j.key: j for j in self._print_jobs} # type: Dict[str, UM3PrintJobOutputModel] removed_jobs, added_jobs, updated_jobs = findChanges(previous, received) @@ -248,7 +248,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): ## Registers a new print job received via the cloud API. # \param job: The print job received. - def _addPrintJob(self, job: CloudClusterPrintJob) -> None: + def _addPrintJob(self, job: CloudClusterPrintJobStatus) -> None: model = job.createOutputModel(CloudOutputController(self)) model.stateChanged.connect(self._onPrintJobStateChanged) if job.printer_uuid: @@ -284,7 +284,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): ## Uploads the mesh when the print job was registered with the cloud API. # \param mesh: The bytes to upload. # \param job_response: The response received from the cloud API. - def _onPrintJobCreated(self, mesh: bytes, job_response: CloudJobResponse) -> None: + def _onPrintJobCreated(self, mesh: bytes, job_response: CloudPrintJobResponse) -> None: self._api.uploadMesh(job_response, mesh, self._onPrintJobUploaded, self._updateUploadProgress, lambda _: self._onUploadError(T.UPLOAD_ERROR)) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 961f8d696d..c9b30d7c79 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -12,7 +12,7 @@ from cura.CuraApplication import CuraApplication from cura.Settings.GlobalStack import GlobalStack from .CloudApiClient import CloudApiClient from .CloudOutputDevice import CloudOutputDevice -from .Models.CloudCluster import CloudCluster +from .Models.CloudClusterResponse import CloudClusterResponse from .Models.CloudErrorObject import CloudErrorObject from .Utils import findChanges @@ -72,8 +72,8 @@ class CloudOutputDeviceManager: self._api.getClusters(self._onGetRemoteClustersFinished) ## Callback for when the request for getting the clusters. is finished. - def _onGetRemoteClustersFinished(self, clusters: List[CloudCluster]) -> None: - online_clusters = {c.cluster_id: c for c in clusters if c.is_online} # type: Dict[str, CloudCluster] + def _onGetRemoteClustersFinished(self, clusters: List[CloudClusterResponse]) -> None: + online_clusters = {c.cluster_id: c for c in clusters if c.is_online} # type: Dict[str, CloudClusterResponse] removed_devices, added_clusters, updates = findChanges(self._remote_clusters, online_clusters) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/BaseCloudModel.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/BaseCloudModel.py index 1176c4374a..3a0e93e836 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/BaseCloudModel.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/BaseCloudModel.py @@ -1,17 +1,55 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from datetime import datetime, timezone +from typing import Dict, Union, TypeVar, Type, List from ...Models import BaseModel +## Base class for the models used in the interface with the Ultimaker cloud APIs. class BaseCloudModel(BaseModel): + ## Checks whether the two models are equal. + # \param other: The other model. + # \return True if they are equal, False if they are different. def __eq__(self, other): - return type(self) == type(other) and self.__dict__ == other.__dict__ + return type(self) == type(other) and self.toDict() == other.toDict() - def __ne__(self, other): - return type(self) != type(other) or self.__dict__ != other.__dict__ + ## Checks whether the two models are different. + # \param other: The other model. + # \return True if they are different, False if they are the same. + def __ne__(self, other) -> bool: + return type(self) != type(other) or self.toDict() != other.toDict() + ## Converts the model into a serializable dictionary + def toDict(self) -> Dict[str, any]: + return self.__dict__ + + # Type variable used in the parse methods below, which should be a subclass of BaseModel. + T = TypeVar("T", bound=BaseModel) + + ## Parses a single model. + # \param model_class: The model class. + # \param values: The value of the model, which is usually a dictionary, but may also be already parsed. + # \return An instance of the model_class given. @staticmethod - def parseDate(date_str: str) -> datetime: - return datetime.strptime(date_str, "%Y-%m-%dT%H:%M:%S.%fZ").replace(tzinfo=timezone.utc) + def parseModel(model_class: Type[T], values: Union[T, Dict[str, any]]) -> T: + if isinstance(values, dict): + return model_class(**values) + return values + + ## Parses a list of models. + # \param model_class: The model class. + # \param values: The value of the list. Each value is usually a dictionary, but may also be already parsed. + # \return A list of instances of the model_class given. + @classmethod + def parseModels(cls, model_class: Type[T], values: List[Union[T, Dict[str, any]]]) -> List[T]: + return [cls.parseModel(model_class, value) for value in values] + + ## Parses the given date string. + # \param date: The date to parse. + # \return The parsed date. + @staticmethod + def parseDate(date: Union[str, datetime]) -> datetime: + if isinstance(date, datetime): + return date + return datetime.strptime(date, "%Y-%m-%dT%H:%M:%S.%fZ").replace(tzinfo=timezone.utc) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudCluster.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudCluster.py deleted file mode 100644 index e6e2af1466..0000000000 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudCluster.py +++ /dev/null @@ -1,21 +0,0 @@ -# Copyright (c) 2018 Ultimaker B.V. -# Cura is released under the terms of the LGPLv3 or higher. -from .BaseCloudModel import BaseCloudModel - - -## Class representing a cloud connected cluster. -class CloudCluster(BaseCloudModel): - def __init__(self, **kwargs): - self.cluster_id = None # type: str - self.host_guid = None # type: str - self.host_name = None # type: str - self.host_version = None # type: str - self.status = None # type: str - self.is_online = False # type: bool - super().__init__(**kwargs) - - # Validates the model, raising an exception if the model is invalid. - def validate(self) -> None: - super().validate() - if not self.cluster_id: - raise ValueError("cluster_id is required on CloudCluster") diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJob.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJob.py deleted file mode 100644 index 15d256e7d5..0000000000 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJob.py +++ /dev/null @@ -1,65 +0,0 @@ -# Copyright (c) 2018 Ultimaker B.V. -# Cura is released under the terms of the LGPLv3 or higher. -from typing import List - -from cura.PrinterOutput.ConfigurationModel import ConfigurationModel -from plugins.UM3NetworkPrinting.src.Cloud.CloudOutputController import CloudOutputController -from .CloudClusterPrinterConfiguration import CloudClusterPrinterConfiguration -from .CloudClusterPrintJobConstraint import CloudClusterPrintJobConstraint -from .BaseCloudModel import BaseCloudModel - - -## Class representing a print job -from plugins.UM3NetworkPrinting.src.UM3PrintJobOutputModel import UM3PrintJobOutputModel - - -class CloudClusterPrintJob(BaseCloudModel): - def __init__(self, **kwargs) -> None: - self.assigned_to = None # type: str - self.configuration = [] # type: List[CloudClusterPrinterConfiguration] - self.constraints = [] # type: List[CloudClusterPrintJobConstraint] - self.created_at = None # type: str - self.force = None # type: str - self.last_seen = None # type: str - self.machine_variant = None # type: str - self.name = None # type: str - self.network_error_count = None # type: int - self.owner = None # type: str - self.printer_uuid = None # type: str - self.started = None # type: str - self.status = None # type: str - self.time_elapsed = None # type: str - self.time_total = None # type: str - self.uuid = None # type: str - super().__init__(**kwargs) - self.configuration = [CloudClusterPrinterConfiguration(**c) if isinstance(c, dict) else c - for c in self.configuration] - self.constraints = [CloudClusterPrintJobConstraint(**p) if isinstance(p, dict) else p - for p in self.constraints] - - ## Creates an UM3 print job output model based on this cloud cluster print job. - # \param printer: The output model of the printer - def createOutputModel(self, controller: CloudOutputController) -> UM3PrintJobOutputModel: - model = UM3PrintJobOutputModel(controller, self.uuid, self.name) - self.updateOutputModel(model) - - return model - - ## Creates a new configuration model - def _createConfigurationModel(self) -> ConfigurationModel: - extruders = [extruder.createConfigurationModel() for extruder in self.configuration or ()] - configuration = ConfigurationModel() - configuration.setExtruderConfigurations(extruders) - return configuration - - ## Updates an UM3 print job output model based on this cloud cluster print job. - # \param model: The model to update. - def updateOutputModel(self, model: UM3PrintJobOutputModel) -> None: - # TODO: Add `compatible_machine_families` to the cloud, than add model.setCompatibleMachineFamilies() - # TODO: Add `impediments_to_printing` to the cloud, see ClusterUM3OutputDevice._updatePrintJob - # TODO: Use model.updateConfigurationChanges, see ClusterUM3OutputDevice#_createConfigurationChanges - model.updateConfiguration(self._createConfigurationModel()) - model.updateTimeTotal(self.time_total) - model.updateTimeElapsed(self.time_elapsed) - model.updateOwner(self.owner) - model.updateState(self.status) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobConstraint.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobConstraint.py index f13e3098fc..8236ec06b9 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobConstraint.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobConstraint.py @@ -1,10 +1,16 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. +from typing import Optional + from .BaseCloudModel import BaseCloudModel ## Class representing a cloud cluster print job constraint -class CloudClusterPrintJobConstraint(BaseCloudModel): - def __init__(self, **kwargs) -> None: - self.require_printer_name = None # type: str +# Spec: https://api-staging.ultimaker.com/connect/v1/spec +class CloudClusterPrintJobConstraints(BaseCloudModel): + ## Creates a new print job constraint. + # \param require_printer_name: Unique name of the printer that this job should be printed on. + # Should be one of the unique_name field values in the cluster, e.g. 'ultimakersystem-ccbdd30044ec' + def __init__(self, require_printer_name: Optional[str] = None, **kwargs) -> None: + self.require_printer_name = require_printer_name super().__init__(**kwargs) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobStatus.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobStatus.py new file mode 100644 index 0000000000..24ef9078d6 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobStatus.py @@ -0,0 +1,87 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from typing import List, Optional, Union, Dict + +from cura.PrinterOutput.ConfigurationModel import ConfigurationModel +from plugins.UM3NetworkPrinting.src.Cloud.CloudOutputController import CloudOutputController +from .CloudClusterPrinterConfiguration import CloudClusterPrinterConfiguration +from .CloudClusterPrintJobConstraint import CloudClusterPrintJobConstraints +from .BaseCloudModel import BaseCloudModel + + +## Class representing a print job +from plugins.UM3NetworkPrinting.src.UM3PrintJobOutputModel import UM3PrintJobOutputModel + + +## Model for the status of a single print job in a cluster. +# Spec: https://api-staging.ultimaker.com/connect/v1/spec +class CloudClusterPrintJobStatus(BaseCloudModel): + ## Creates a new cloud print job status model. + # \param assigned_to: The name of the printer this job is assigned to while being queued. + # \param configuration: The required print core configurations of this print job. + # \param constraints: Print job constraints object. + # \param created_at: The timestamp when the job was created in Cura Connect. + # \param force: Allow this job to be printed despite of mismatching configurations. + # \param last_seen: The number of seconds since this job was checked. + # \param machine_variant: The machine type that this job should be printed on.Coincides with the machine_type field + # of the printer object. + # \param name: The name of the print job. Usually the name of the .gcode file. + # \param network_error_count: The number of errors encountered when requesting data for this print job. + # \param owner: The name of the user who added the print job to Cura Connect. + # \param printer_uuid: UUID of the printer that the job is currently printing on or assigned to. + # \param started: Whether the job has started printing or not. + # \param status: The status of the print job. + # \param time_elapsed: The remaining printing time in seconds. + # \param time_total: The total printing time in seconds. + # \param uuid: UUID of this print job. Should be used for identification purposes. + def __init__(self, created_at: str, force: bool, machine_variant: str, name: str, started: bool, status: str, + time_total: int, uuid: str, + configuration: List[Union[Dict[str, any], CloudClusterPrinterConfiguration]], + constraints: List[Union[Dict[str, any], CloudClusterPrintJobConstraints]], + last_seen: Optional[float] = None, network_error_count: Optional[int] = None, + owner: Optional[str] = None, printer_uuid: Optional[str] = None, time_elapsed: Optional[int] = None, + assigned_to: Optional[str] = None, **kwargs) -> None: + self.assigned_to = assigned_to # type: str + self.configuration = self.parseModels(CloudClusterPrinterConfiguration, configuration) + self.constraints = self.parseModels(CloudClusterPrintJobConstraints, constraints) + self.created_at = created_at + self.force = force + self.last_seen = last_seen + self.machine_variant = machine_variant + self.name = name + self.network_error_count = network_error_count + self.owner = owner + self.printer_uuid = printer_uuid + self.started = started + self.status = status + self.time_elapsed = time_elapsed + self.time_total = time_total + self.uuid = uuid + super().__init__(**kwargs) + + ## Creates an UM3 print job output model based on this cloud cluster print job. + # \param printer: The output model of the printer + def createOutputModel(self, controller: CloudOutputController) -> UM3PrintJobOutputModel: + model = UM3PrintJobOutputModel(controller, self.uuid, self.name) + self.updateOutputModel(model) + + return model + + ## Creates a new configuration model + def _createConfigurationModel(self) -> ConfigurationModel: + extruders = [extruder.createConfigurationModel() for extruder in self.configuration or ()] + configuration = ConfigurationModel() + configuration.setExtruderConfigurations(extruders) + return configuration + + ## Updates an UM3 print job output model based on this cloud cluster print job. + # \param model: The model to update. + def updateOutputModel(self, model: UM3PrintJobOutputModel) -> None: + # TODO: Add `compatible_machine_families` to the cloud, than add model.setCompatibleMachineFamilies() + # TODO: Add `impediments_to_printing` to the cloud, see ClusterUM3OutputDevice._updatePrintJob + # TODO: Use model.updateConfigurationChanges, see ClusterUM3OutputDevice#_createConfigurationChanges + model.updateConfiguration(self._createConfigurationModel()) + model.updateTimeTotal(self.time_total) + model.updateTimeElapsed(self.time_elapsed) + model.updateOwner(self.owner) + model.updateState(self.status) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinter.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinter.py deleted file mode 100644 index 9057743621..0000000000 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinter.py +++ /dev/null @@ -1,49 +0,0 @@ -# Copyright (c) 2018 Ultimaker B.V. -# Cura is released under the terms of the LGPLv3 or higher. -from typing import List - -from cura.PrinterOutput.ConfigurationModel import ConfigurationModel -from cura.PrinterOutput.PrinterOutputController import PrinterOutputController -from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel -from .CloudClusterPrinterConfiguration import CloudClusterPrinterConfiguration -from .BaseCloudModel import BaseCloudModel - - -## Class representing a cluster printer -class CloudClusterPrinter(BaseCloudModel): - def __init__(self, **kwargs) -> None: - self.configuration = [] # type: List[CloudClusterPrinterConfiguration] - self.enabled = None # type: str - self.firmware_version = None # type: str - self.friendly_name = None # type: str - self.ip_address = None # type: str - self.machine_variant = None # type: str - self.status = None # type: str - self.unique_name = None # type: str - self.uuid = None # type: str - super().__init__(**kwargs) - - self.configuration = [CloudClusterPrinterConfiguration(**c) - if isinstance(c, dict) else c for c in self.configuration] - - ## Creates a new output model. - # \param controller - The controller of the model. - def createOutputModel(self, controller: PrinterOutputController) -> PrinterOutputModel: - model = PrinterOutputModel(controller, len(self.configuration), firmware_version = self.firmware_version) - self.updateOutputModel(model) - return model - - ## Updates the given output model. - # \param model - The output model to update. - def updateOutputModel(self, model: PrinterOutputModel) -> None: - model.updateKey(self.uuid) - model.updateName(self.friendly_name) - model.updateType(self.machine_variant) - model.updateState(self.status if self.enabled else "disabled") - - for configuration, extruder_output, extruder_config in \ - zip(self.configuration, model.extruders, model.printerConfiguration.extruderConfigurations): - configuration.updateOutputModel(extruder_output) - configuration.updateConfigurationModel(extruder_config) - - pass diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterConfiguration.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterConfiguration.py index c14a7f85c3..a6319ed6bb 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterConfiguration.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterConfiguration.py @@ -1,5 +1,7 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. +from typing import Union, Dict, Optional + from cura.PrinterOutput.ExtruderConfigurationModel import ExtruderConfigurationModel from cura.PrinterOutput.ExtruderOutputModel import ExtruderOutputModel from .CloudClusterPrinterConfigurationMaterial import CloudClusterPrinterConfigurationMaterial @@ -7,17 +9,22 @@ from .BaseCloudModel import BaseCloudModel ## Class representing a cloud cluster printer configuration +# Spec: https://api-staging.ultimaker.com/connect/v1/spec class CloudClusterPrinterConfiguration(BaseCloudModel): - def __init__(self, **kwargs) -> None: - self.extruder_index = None # type: int - self.material = None # type: CloudClusterPrinterConfigurationMaterial - self.nozzle_diameter = None # type: str - self.print_core_id = None # type: str + ## Creates a new cloud cluster printer configuration object + # \param extruder_index: The position of the extruder on the machine as list index. Numbered from left to right. + # \param material: The material of a configuration object in a cluster printer. May be in a dict or an object. + # \param nozzle_diameter: The diameter of the print core at this position in millimeters, e.g. '0.4'. + # \param print_core_id: The type of print core inserted at this position, e.g. 'AA 0.4'. + def __init__(self, extruder_index: int, + material: Union[None, Dict[str, any], CloudClusterPrinterConfigurationMaterial], + nozzle_diameter: Optional[str] = None, print_core_id: Optional[str] = None, **kwargs) -> None: + self.extruder_index = extruder_index + self.material = self.parseModel(CloudClusterPrinterConfigurationMaterial, material) + self.nozzle_diameter = nozzle_diameter + self.print_core_id = print_core_id super().__init__(**kwargs) - if isinstance(self.material, dict): - self.material = CloudClusterPrinterConfigurationMaterial(**self.material) - ## Updates the given output model. # \param model - The output model to update. def updateOutputModel(self, model: ExtruderOutputModel) -> None: diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterConfigurationMaterial.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterConfigurationMaterial.py index e5f52ac630..652cbdabda 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterConfigurationMaterial.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterConfigurationMaterial.py @@ -1,3 +1,5 @@ +from typing import Optional + from UM.Logger import Logger from cura.CuraApplication import CuraApplication from cura.PrinterOutput.MaterialOutputModel import MaterialOutputModel @@ -5,12 +7,19 @@ from .BaseCloudModel import BaseCloudModel ## Class representing a cloud cluster printer configuration +# Spec: https://api-staging.ultimaker.com/connect/v1/spec class CloudClusterPrinterConfigurationMaterial(BaseCloudModel): - def __init__(self, **kwargs) -> None: - self.guid = None # type: str - self.brand = None # type: str - self.color = None # type: str - self.material = None # type: str + ## Creates a new material configuration model. + # \param brand: The brand of material in this print core, e.g. 'Ultimaker'. + # \param color: The color of material in this print core, e.g. 'Blue'. + # \param guid: he GUID of the material in this print core, e.g. '506c9f0d-e3aa-4bd4-b2d2-23e2425b1aa9'. + # \param material: The type of material in this print core, e.g. 'PLA'. + def __init__(self, brand: Optional[str] = None, color: Optional[str] = None, guid: Optional[str] = None, + material: Optional[str] = None, **kwargs) -> None: + self.guid = guid + self.brand = brand + self.color = color + self.material = material super().__init__(**kwargs) ## Creates a material output model based on this cloud printer material. diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterStatus.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterStatus.py new file mode 100644 index 0000000000..b25f21fde2 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterStatus.py @@ -0,0 +1,60 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from typing import List, Union, Dict, Optional + +from cura.PrinterOutput.PrinterOutputController import PrinterOutputController +from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel +from .CloudClusterPrinterConfiguration import CloudClusterPrinterConfiguration +from .BaseCloudModel import BaseCloudModel + + +## Class representing a cluster printer +# Spec: https://api-staging.ultimaker.com/connect/v1/spec +class CloudClusterPrinterStatus(BaseCloudModel): + ## Creates a new cluster printer status + # \param enabled: A printer can be disabled if it should not receive new jobs. By default every printer is enabled. + # \param firmware_version: Firmware version installed on the printer. Can differ for each printer in a cluster. + # \param friendly_name: Human readable name of the printer. Can be used for identification purposes. + # \param ip_address: The IP address of the printer in the local network. + # \param machine_variant: The type of printer. Can be 'Ultimaker 3' or 'Ultimaker 3ext'. + # \param status: The status of the printer. + # \param unique_name: The unique name of the printer in the network. + # \param uuid: The unique ID of the printer, also known as GUID. + # \param configuration: The active print core configurations of this printer. + # \param reserved_by: A printer can be claimed by a specific print job. + def __init__(self, enabled: bool, firmware_version: str, friendly_name: str, ip_address: str, machine_variant: str, + status: str, unique_name: str, uuid: str, + configuration: List[Union[Dict[str, any], CloudClusterPrinterConfiguration]], + reserved_by: Optional[str] = None, **kwargs) -> None: + + self.configuration = self.parseModels(CloudClusterPrinterConfiguration, configuration) + self.enabled = enabled + self.firmware_version = firmware_version + self.friendly_name = friendly_name + self.ip_address = ip_address + self.machine_variant = machine_variant + self.status = status + self.unique_name = unique_name + self.uuid = uuid + self.reserved_by = reserved_by + super().__init__(**kwargs) + + ## Creates a new output model. + # \param controller - The controller of the model. + def createOutputModel(self, controller: PrinterOutputController) -> PrinterOutputModel: + model = PrinterOutputModel(controller, len(self.configuration), firmware_version = self.firmware_version) + self.updateOutputModel(model) + return model + + ## Updates the given output model. + # \param model - The output model to update. + def updateOutputModel(self, model: PrinterOutputModel) -> None: + model.updateKey(self.uuid) + model.updateName(self.friendly_name) + model.updateType(self.machine_variant) + model.updateState(self.status if self.enabled else "disabled") + + for configuration, extruder_output, extruder_config in \ + zip(self.configuration, model.extruders, model.printerConfiguration.extruderConfigurations): + configuration.updateOutputModel(extruder_output) + configuration.updateConfigurationModel(extruder_config) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterResponse.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterResponse.py new file mode 100644 index 0000000000..a3eda54a76 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterResponse.py @@ -0,0 +1,32 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from typing import Optional + +from .BaseCloudModel import BaseCloudModel + + +## Class representing a cloud connected cluster. +# Spec: https://api-staging.ultimaker.com/connect/v1/spec +class CloudClusterResponse(BaseCloudModel): + ## Creates a new cluster response object. + # \param cluster_id: The secret unique ID, e.g. 'kBEeZWEifXbrXviO8mRYLx45P8k5lHVGs43XKvRniPg='. + # \param host_guid: The unique identifier of the print cluster host, e.g. 'e90ae0ac-1257-4403-91ee-a44c9b7e8050'. + # \param host_name: The name of the printer as configured during the Wi-Fi setup. Used as identifier for end users. + # \param is_online: Whether this cluster is currently connected to the cloud. + # \param status: The status of the cluster authentication (active or inactive). + # \param host_version: The firmware version of the cluster host. This is where the Stardust client is running on. + def __init__(self, cluster_id: str, host_guid: str, host_name: str, is_online: bool, status: str, + host_version: Optional[str] = None, **kwargs): + self.cluster_id = cluster_id + self.host_guid = host_guid + self.host_name = host_name + self.status = status + self.is_online = is_online + self.host_version = host_version + super().__init__(**kwargs) + + # Validates the model, raising an exception if the model is invalid. + def validate(self) -> None: + super().validate() + if not self.cluster_id: + raise ValueError("cluster_id is required on CloudCluster") diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterStatus.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterStatus.py index 77ed979dbc..2cebb1b592 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterStatus.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterStatus.py @@ -1,28 +1,26 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from datetime import datetime -from typing import List +from typing import List, Dict, Union -from .CloudClusterPrinter import CloudClusterPrinter -from .CloudClusterPrintJob import CloudClusterPrintJob +from .CloudClusterPrinterStatus import CloudClusterPrinterStatus +from .CloudClusterPrintJobStatus import CloudClusterPrintJobStatus from .BaseCloudModel import BaseCloudModel # Model that represents the status of the cluster for the cloud +# Spec: https://api-staging.ultimaker.com/connect/v1/spec class CloudClusterStatus(BaseCloudModel): - def __init__(self, **kwargs) -> None: - self.generated_time = None # type: datetime - # a list of the printers - self.printers = [] # type: List[CloudClusterPrinter] - # a list of the print jobs - self.print_jobs = [] # type: List[CloudClusterPrintJob] - + ## Creates a new cluster status model object. + # \param printers: The latest status of each printer in the cluster. + # \param print_jobs: The latest status of each print job in the cluster. + # \param generated_time: The datetime when the object was generated on the server-side. + def __init__(self, + printers: List[Union[CloudClusterPrinterStatus, Dict[str, any]]], + print_jobs: List[Union[CloudClusterPrintJobStatus, Dict[str, any]]], + generated_time: Union[str, datetime], + **kwargs) -> None: + self.generated_time = self.parseDate(generated_time) + self.printers = self.parseModels(CloudClusterPrinterStatus, printers) + self.print_jobs = self.parseModels(CloudClusterPrintJobStatus, print_jobs) super().__init__(**kwargs) - - # converting any dictionaries into models - self.printers = [CloudClusterPrinter(**p) if isinstance(p, dict) else p for p in self.printers] - self.print_jobs = [CloudClusterPrintJob(**j) if isinstance(j, dict) else j for j in self.print_jobs] - - # converting generated time into datetime - if isinstance(self.generated_time, str): - self.generated_time = self.parseDate(self.generated_time) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudErrorObject.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudErrorObject.py index 9696cbcb7a..c02a21d4da 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudErrorObject.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudErrorObject.py @@ -1,17 +1,28 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from typing import Dict +from typing import Dict, Optional from .BaseCloudModel import BaseCloudModel -## Class representing errors generated by the cloud servers, according to the json-api standard. +## Class representing errors generated by the cloud servers, according to the JSON-API standard. +# Spec: https://api-staging.ultimaker.com/connect/v1/spec class CloudErrorObject(BaseCloudModel): - def __init__(self, **kwargs) -> None: - self.id = None # type: str - self.code = None # type: str - self.http_status = None # type: str - self.title = None # type: str - self.detail = None # type: str - self.meta = None # type: Dict[str, any] + ## Creates a new error object. + # \param id: Unique identifier for this particular occurrence of the problem. + # \param title: A short, human-readable summary of the problem that SHOULD NOT change from occurrence to occurrence + # of the problem, except for purposes of localization. + # \param code: An application-specific error code, expressed as a string value. + # \param detail: A human-readable explanation specific to this occurrence of the problem. Like title, this field's + # value can be localized. + # \param http_status: The HTTP status code applicable to this problem, converted to string. + # \param meta: Non-standard meta-information about the error, depending on the error code. + def __init__(self, id: str, code: str, title: str, http_status: str, detail: Optional[str] = None, + meta: Optional[Dict[str, any]] = None, **kwargs) -> None: + self.id = id + self.code = code + self.http_status = http_status + self.title = title + self.detail = detail + self.meta = meta super().__init__(**kwargs) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudJobResponse.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudJobResponse.py deleted file mode 100644 index e3161449a5..0000000000 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudJobResponse.py +++ /dev/null @@ -1,16 +0,0 @@ -# Copyright (c) 2018 Ultimaker B.V. -# Cura is released under the terms of the LGPLv3 or higher. -from .BaseCloudModel import BaseCloudModel - - -# Model that represents the response received from the cloud after requesting to upload a print job -class CloudJobResponse(BaseCloudModel): - def __init__(self, **kwargs) -> None: - self.download_url = None # type: str - self.job_id = None # type: str - self.job_name = None # type: str - self.slicing_details = None # type: str - self.status = None # type: str - self.upload_url = None # type: str - self.content_type = None # type: str - super().__init__(**kwargs) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudJobUploadRequest.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudJobUploadRequest.py deleted file mode 100644 index 07a781e2d6..0000000000 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudJobUploadRequest.py +++ /dev/null @@ -1,12 +0,0 @@ -# Copyright (c) 2018 Ultimaker B.V. -# Cura is released under the terms of the LGPLv3 or higher. -from .BaseCloudModel import BaseCloudModel - - -# Model that represents the request to upload a print job to the cloud -class CloudJobUploadRequest(BaseCloudModel): - def __init__(self, **kwargs) -> None: - self.file_size = None # type: int - self.job_name = None # type: str - self.content_type = None # type: str - super().__init__(**kwargs) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudPrintJobResponse.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudPrintJobResponse.py new file mode 100644 index 0000000000..79196ee38c --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudPrintJobResponse.py @@ -0,0 +1,33 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from typing import Optional + +from .BaseCloudModel import BaseCloudModel + + +# Model that represents the response received from the cloud after requesting to upload a print job +# Spec: https://api-staging.ultimaker.com/cura/v1/spec +class CloudPrintJobResponse(BaseCloudModel): + ## Creates a new print job response model. + # \param job_id: The job unique ID, e.g. 'kBEeZWEifXbrXviO8mRYLx45P8k5lHVGs43XKvRniPg='. + # \param status: The status of the print job. + # \param status_description: Contains more details about the status, e.g. the cause of failures. + # \param download_url: A signed URL to download the resulting status. Only available when the job is finished. + # \param job_name: The name of the print job. + # \param slicing_details: Model for slice information. + # \param upload_url: The one-time use URL where the toolpath must be uploaded to (only if status is uploading). + # \param content_type: The content type of the print job (e.g. text/plain or application/gzip) + # \param generated_time: The datetime when the object was generated on the server-side. + def __init__(self, job_id: str, status: str, download_url: Optional[str] = None, job_name: Optional[str] = None, + upload_url: Optional[str] = None, content_type: Optional[str] = None, + status_description: Optional[str] = None, slicing_details: Optional[dict] = None, **kwargs) -> None: + self.job_id = job_id + self.status = status + self.download_url = download_url + self.job_name = job_name + self.upload_url = upload_url + self.content_type = content_type + self.status_description = status_description + # TODO: Implement slicing details + self.slicing_details = slicing_details + super().__init__(**kwargs) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudPrintJobUploadRequest.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudPrintJobUploadRequest.py new file mode 100644 index 0000000000..e59c571558 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudPrintJobUploadRequest.py @@ -0,0 +1,17 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from .BaseCloudModel import BaseCloudModel + + +# Model that represents the request to upload a print job to the cloud +# Spec: https://api-staging.ultimaker.com/cura/v1/spec +class CloudPrintJobUploadRequest(BaseCloudModel): + ## Creates a new print job upload request. + # \param job_name: The name of the print job. + # \param file_size: The size of the file in bytes. + # \param content_type: The content type of the print job (e.g. text/plain or application/gzip) + def __init__(self, job_name: str, file_size: int, content_type: str, **kwargs) -> None: + self.job_name = job_name + self.file_size = file_size + self.content_type = content_type + super().__init__(**kwargs) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudPrintResponse.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudPrintResponse.py index 3e9ad584dc..919d1b3c3a 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudPrintResponse.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudPrintResponse.py @@ -1,12 +1,23 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. +from datetime import datetime +from typing import Optional, Union + from .BaseCloudModel import BaseCloudModel # Model that represents the responses received from the cloud after requesting a job to be printed. +# Spec: https://api-staging.ultimaker.com/connect/v1/spec class CloudPrintResponse(BaseCloudModel): - def __init__(self, **kwargs) -> None: - self.cluster_job_id = None # type: str - self.job_id = None # type: str - self.status = None # type: str + ## Creates a new print response object. + # \param job_id: The unique ID of a print job inside of the cluster. This ID is generated by Cura Connect. + # \param status: The status of the print request (queued or failed). + # \param generated_time: The datetime when the object was generated on the server-side. + # \param cluster_job_id: The unique ID of a print job inside of the cluster. This ID is generated by Cura Connect. + def __init__(self, job_id: str, status: str, generated_time: Union[str, datetime], + cluster_job_id: Optional[str] = None, **kwargs) -> None: + self.job_id = job_id + self.status = status + self.cluster_job_id = cluster_job_id + self.generated_time = self.parseDate(generated_time) super().__init__(**kwargs) diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/postJobPrintResponse.json b/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/postJobPrintResponse.json index 8b9574359f..caedcd8732 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/postJobPrintResponse.json +++ b/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/postJobPrintResponse.json @@ -2,6 +2,7 @@ "data": { "cluster_job_id": "9a59d8e9-91d3-4ff6-b4cb-9db91c4094dd", "job_id": "ABCDefGHIjKlMNOpQrSTUvYxWZ0-1234567890abcDE=", - "status": "queued" + "status": "queued", + "generated_time": "2018-12-10T08:23:55.110Z" } } diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/putJobUploadResponse.json b/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/putJobUploadResponse.json index 0474862720..1304f3a9f6 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/putJobUploadResponse.json +++ b/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/putJobUploadResponse.json @@ -1,10 +1,9 @@ { "data": { "content_type": "text/plain", - "download_url": "https://api.ultimaker.com/print-job-download", "job_id": "ABCDefGHIjKlMNOpQrSTUvYxWZ0-1234567890abcDE=", "job_name": "Ultimaker Robot v3.0", - "status": "queued", + "status": "uploading", "upload_url": "https://api.ultimaker.com/print-job-upload" } } diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/requestPrintResponse.json b/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/requestPrintResponse.json deleted file mode 100644 index e69589784d..0000000000 --- a/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/requestPrintResponse.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "data": { - "cluster_job_id": "", - "job_id": "db34b096-c4d5-46f3-bea7-da6a19905e6c", - "status": "queued" - } -} diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/requestUploadResponse.json b/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/requestUploadResponse.json deleted file mode 100644 index 6168e5205f..0000000000 --- a/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/requestUploadResponse.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "data": { - "content_type": "text/plain", - "generated_time": "2018-12-10T09:33:00.009Z", - "job_id": "j9KUn4D6FRRRmdtbCo4OGAwUf6Ml3p3oU-Zv7RNRv92T", - "job_name": "job name", - "status": "uploading", - "status_description": "The print job has been created. Please upload the file.", - "upload_url": "https://www.googleapis.com/upload/storage/v1/b/ultimaker-storage-1/o?uploadType=resumable&upload_id=AEnB2Uqhg1H7BXQVeLJEWw6AheqMicydZVLuH9bnkh6Oge0e6i5X76MW3NZHWRmUTmjzulAF42mkczcC7rsAuPg1Nn8JeFpnNA" - } -} diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py index 07c7733ac1..84f0254b55 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py @@ -1,7 +1,6 @@ # Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -import json import os from unittest import TestCase from unittest.mock import patch, MagicMock @@ -9,8 +8,9 @@ from unittest.mock import patch, MagicMock from cura.CuraApplication import CuraApplication from src.Cloud.CloudApiClient import CloudApiClient from src.Cloud.CloudOutputDeviceManager import CloudOutputDeviceManager -from src.Cloud.Models.CloudJobResponse import CloudJobResponse -from src.Cloud.Models.CloudJobUploadRequest import CloudJobUploadRequest +from src.Cloud.Models.CloudPrintJobResponse import CloudPrintJobResponse +from src.Cloud.Models.CloudPrintJobUploadRequest import CloudPrintJobUploadRequest +from tests.Cloud.Fixtures import readFixture, parseFixture from .NetworkManagerMock import NetworkManagerMock @@ -71,12 +71,11 @@ class TestCloudApiClient(TestCase): network_mock.return_value = self.network results = [] - with open("{}/Fixtures/requestUploadResponse.json".format(os.path.dirname(__file__)), "rb") as f: - response = f.read() + response = readFixture("putJobUploadResponse") self.network.prepareReply("PUT", "https://api-staging.ultimaker.com/cura/v1/jobs/upload", 200, response) - self.api.requestUpload(CloudJobUploadRequest(job_name = "job name", file_size = 143234, content_type = "text/plain"), - lambda r: results.append(r)) + request = CloudPrintJobUploadRequest(job_name = "job name", file_size = 143234, content_type = "text/plain") + self.api.requestUpload(request, lambda r: results.append(r)) self.network.flushReplies() self.assertEqual(results[0].content_type, "text/plain") @@ -87,13 +86,11 @@ class TestCloudApiClient(TestCase): results = [] progress = MagicMock() - with open("{}/Fixtures/requestUploadResponse.json".format(os.path.dirname(__file__)), "rb") as f: - thedata = json.loads(f.read().decode("ascii")) - data = thedata["data"] - upload_response = CloudJobResponse(**data) + data = parseFixture("putJobUploadResponse")["data"] + upload_response = CloudPrintJobResponse(**data) self.network.prepareReply("PUT", upload_response.upload_url, 200, - '{ data : "" }') # Network client doesn't look into the reply + b'{ data : "" }') # Network client doesn't look into the reply self.api.uploadMesh(upload_response, b'', lambda job_id: results.append(job_id), progress.advance, progress.error) @@ -107,11 +104,11 @@ class TestCloudApiClient(TestCase): network_mock.return_value = self.network results = [] - cluster_id = "NWKV6vJP_LdYsXgXqAcaNCR0YcLJwar1ugh0ikEZsZs8" - job_id = "db34b096-c4d5-46f3-bea7-da6a19905e6c" + response = readFixture("postJobPrintResponse") - with open("{}/Fixtures/requestPrintResponse.json".format(os.path.dirname(__file__)), "rb") as f: - response = f.read() + cluster_id = "NWKV6vJP_LdYsXgXqAcaNCR0YcLJwar1ugh0ikEZsZs8" + cluster_job_id = "9a59d8e9-91d3-4ff6-b4cb-9db91c4094dd" + job_id = "ABCDefGHIjKlMNOpQrSTUvYxWZ0-1234567890abcDE=" self.network.prepareReply("POST", "https://api-staging.ultimaker.com/connect/v1/clusters/{}/print/{}" @@ -123,5 +120,6 @@ class TestCloudApiClient(TestCase): self.network.flushReplies() self.assertEqual(len(results), 1) - self.assertEqual(results[0].job_id, "db34b096-c4d5-46f3-bea7-da6a19905e6c") + self.assertEqual(results[0].job_id, job_id) + self.assertEqual(results[0].cluster_job_id, cluster_job_id) self.assertEqual(results[0].status, "queued") diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py index 420d71d0fe..b6bcde6e55 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py @@ -106,7 +106,9 @@ class TestCloudOutputDeviceManager(TestCase): @patch("UM.Message.Message.show") def test_api_error(self, message_mock, network_mock): - self.clusters_response = {"errors": [{"id": "notFound", "title": "Not found!", "http_status": "404"}]} + self.clusters_response = { + "errors": [{"id": "notFound", "title": "Not found!", "http_status": "404", "code": "notFound"}] + } self.network.prepareReply("GET", self.URL, 200, self.clusters_response) self._loadData(network_mock) self.network.flushReplies() From 816c6bd4ec56204b4654cd23ba71de188180e03d Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Tue, 11 Dec 2018 11:12:36 +0100 Subject: [PATCH 105/178] Fixes after merging UI changes --- plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index 17acbe2e3f..a77a25c375 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -96,9 +96,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): # We use the Cura Connect monitor tab to get most functionality right away. self._monitor_view_qml_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), - "../../resources/qml/ClusterMonitorItem.qml") - self._control_view_qml_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), - "../../resources/qml/ClusterControlItem.qml") + "../../resources/qml/MonitorStage.qml") # Trigger the printersChanged signal when the private signal is triggered. self.printersChanged.connect(self._clusterPrintersChanged) From d54fc4182b6355123a6ef3c51c51e974fcc188bb Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Tue, 11 Dec 2018 11:56:36 +0100 Subject: [PATCH 106/178] STAR-322: Fixing style errors --- .../src/Cloud/CloudApiClient.py | 2 +- .../src/Cloud/Models/BaseCloudModel.py | 8 +++--- .../Models/CloudClusterPrintJobStatus.py | 8 +++--- .../CloudClusterPrinterConfiguration.py | 19 ++++++++----- .../Cloud/Models/CloudClusterPrinterStatus.py | 4 +-- .../src/Cloud/Models/CloudClusterResponse.py | 2 +- .../src/Cloud/Models/CloudClusterStatus.py | 6 ++-- .../src/Cloud/Models/CloudErrorObject.py | 4 +-- .../src/ClusterUM3OutputDevice.py | 28 +++---------------- .../src/MeshFormatHandler.py | 26 +++++++++++------ .../UM3NetworkPrinting/src/SendMaterialJob.py | 8 ++++-- .../src/UM3PrintJobOutputModel.py | 9 +++--- .../tests/Cloud/Models/__init__.py | 2 ++ .../tests/Cloud/NetworkManagerMock.py | 7 +++-- .../tests/Cloud/TestCloudApiClient.py | 8 +++--- 15 files changed, 69 insertions(+), 72 deletions(-) create mode 100644 plugins/UM3NetworkPrinting/tests/Cloud/Models/__init__.py diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py index 9cc70587a3..474d76d85a 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py @@ -31,7 +31,7 @@ class CloudApiClient(NetworkClient): ## Initializes a new cloud API client. # \param account: The user's account object # \param on_error: The callback to be called whenever we receive errors from the server. - def __init__(self, account: Account, on_error: Callable[[List[CloudErrorObject]], None]): + def __init__(self, account: Account, on_error: Callable[[List[CloudErrorObject]], None]) -> None: super().__init__() self._account = account self._on_error = on_error diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/BaseCloudModel.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/BaseCloudModel.py index 3a0e93e836..18a8cb5cba 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/BaseCloudModel.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/BaseCloudModel.py @@ -1,7 +1,7 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from datetime import datetime, timezone -from typing import Dict, Union, TypeVar, Type, List +from typing import Dict, Union, TypeVar, Type, List, Any from ...Models import BaseModel @@ -21,7 +21,7 @@ class BaseCloudModel(BaseModel): return type(self) != type(other) or self.toDict() != other.toDict() ## Converts the model into a serializable dictionary - def toDict(self) -> Dict[str, any]: + def toDict(self) -> Dict[str, Any]: return self.__dict__ # Type variable used in the parse methods below, which should be a subclass of BaseModel. @@ -32,7 +32,7 @@ class BaseCloudModel(BaseModel): # \param values: The value of the model, which is usually a dictionary, but may also be already parsed. # \return An instance of the model_class given. @staticmethod - def parseModel(model_class: Type[T], values: Union[T, Dict[str, any]]) -> T: + def parseModel(model_class: Type[T], values: Union[T, Dict[str, Any]]) -> T: if isinstance(values, dict): return model_class(**values) return values @@ -42,7 +42,7 @@ class BaseCloudModel(BaseModel): # \param values: The value of the list. Each value is usually a dictionary, but may also be already parsed. # \return A list of instances of the model_class given. @classmethod - def parseModels(cls, model_class: Type[T], values: List[Union[T, Dict[str, any]]]) -> List[T]: + def parseModels(cls, model_class: Type[T], values: List[Union[T, Dict[str, Any]]]) -> List[T]: return [cls.parseModel(model_class, value) for value in values] ## Parses the given date string. diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobStatus.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobStatus.py index 24ef9078d6..f451665a4f 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobStatus.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobStatus.py @@ -1,6 +1,6 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from typing import List, Optional, Union, Dict +from typing import List, Optional, Union, Dict, Any from cura.PrinterOutput.ConfigurationModel import ConfigurationModel from plugins.UM3NetworkPrinting.src.Cloud.CloudOutputController import CloudOutputController @@ -36,12 +36,12 @@ class CloudClusterPrintJobStatus(BaseCloudModel): # \param uuid: UUID of this print job. Should be used for identification purposes. def __init__(self, created_at: str, force: bool, machine_variant: str, name: str, started: bool, status: str, time_total: int, uuid: str, - configuration: List[Union[Dict[str, any], CloudClusterPrinterConfiguration]], - constraints: List[Union[Dict[str, any], CloudClusterPrintJobConstraints]], + configuration: List[Union[Dict[str, Any], CloudClusterPrinterConfiguration]], + constraints: List[Union[Dict[str, Any], CloudClusterPrintJobConstraints]], last_seen: Optional[float] = None, network_error_count: Optional[int] = None, owner: Optional[str] = None, printer_uuid: Optional[str] = None, time_elapsed: Optional[int] = None, assigned_to: Optional[str] = None, **kwargs) -> None: - self.assigned_to = assigned_to # type: str + self.assigned_to = assigned_to self.configuration = self.parseModels(CloudClusterPrinterConfiguration, configuration) self.constraints = self.parseModels(CloudClusterPrintJobConstraints, constraints) self.created_at = created_at diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterConfiguration.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterConfiguration.py index a6319ed6bb..3e06d0e2e7 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterConfiguration.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterConfiguration.py @@ -1,6 +1,6 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from typing import Union, Dict, Optional +from typing import Union, Dict, Optional, Any from cura.PrinterOutput.ExtruderConfigurationModel import ExtruderConfigurationModel from cura.PrinterOutput.ExtruderOutputModel import ExtruderOutputModel @@ -17,10 +17,10 @@ class CloudClusterPrinterConfiguration(BaseCloudModel): # \param nozzle_diameter: The diameter of the print core at this position in millimeters, e.g. '0.4'. # \param print_core_id: The type of print core inserted at this position, e.g. 'AA 0.4'. def __init__(self, extruder_index: int, - material: Union[None, Dict[str, any], CloudClusterPrinterConfigurationMaterial], + material: Union[None, Dict[str, Any], CloudClusterPrinterConfigurationMaterial], nozzle_diameter: Optional[str] = None, print_core_id: Optional[str] = None, **kwargs) -> None: self.extruder_index = extruder_index - self.material = self.parseModel(CloudClusterPrinterConfigurationMaterial, material) + self.material = self.parseModel(CloudClusterPrinterConfigurationMaterial, material) if material else None self.nozzle_diameter = nozzle_diameter self.print_core_id = print_core_id super().__init__(**kwargs) @@ -28,11 +28,16 @@ class CloudClusterPrinterConfiguration(BaseCloudModel): ## Updates the given output model. # \param model - The output model to update. def updateOutputModel(self, model: ExtruderOutputModel) -> None: - model.updateHotendID(self.print_core_id) + if self.print_core_id is not None: + model.updateHotendID(self.print_core_id) - if model.activeMaterial is None or model.activeMaterial.guid != self.material.guid: - material = self.material.createOutputModel() - model.updateActiveMaterial(material) + if self.material: + active_material = model.activeMaterial + if active_material is None or active_material.guid != self.material.guid: + material = self.material.createOutputModel() + model.updateActiveMaterial(material) + else: + model.updateActiveMaterial(None) ## Creates a configuration model def createConfigurationModel(self) -> ExtruderConfigurationModel: diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterStatus.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterStatus.py index b25f21fde2..cd3b6bbdca 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterStatus.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterStatus.py @@ -1,6 +1,6 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from typing import List, Union, Dict, Optional +from typing import List, Union, Dict, Optional, Any from cura.PrinterOutput.PrinterOutputController import PrinterOutputController from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel @@ -24,7 +24,7 @@ class CloudClusterPrinterStatus(BaseCloudModel): # \param reserved_by: A printer can be claimed by a specific print job. def __init__(self, enabled: bool, firmware_version: str, friendly_name: str, ip_address: str, machine_variant: str, status: str, unique_name: str, uuid: str, - configuration: List[Union[Dict[str, any], CloudClusterPrinterConfiguration]], + configuration: List[Union[Dict[str, Any], CloudClusterPrinterConfiguration]], reserved_by: Optional[str] = None, **kwargs) -> None: self.configuration = self.parseModels(CloudClusterPrinterConfiguration, configuration) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterResponse.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterResponse.py index a3eda54a76..9c0853e7c9 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterResponse.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterResponse.py @@ -16,7 +16,7 @@ class CloudClusterResponse(BaseCloudModel): # \param status: The status of the cluster authentication (active or inactive). # \param host_version: The firmware version of the cluster host. This is where the Stardust client is running on. def __init__(self, cluster_id: str, host_guid: str, host_name: str, is_online: bool, status: str, - host_version: Optional[str] = None, **kwargs): + host_version: Optional[str] = None, **kwargs) -> None: self.cluster_id = cluster_id self.host_guid = host_guid self.host_name = host_name diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterStatus.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterStatus.py index 2cebb1b592..b0250c2ebb 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterStatus.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterStatus.py @@ -1,7 +1,7 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from datetime import datetime -from typing import List, Dict, Union +from typing import List, Dict, Union, Any from .CloudClusterPrinterStatus import CloudClusterPrinterStatus from .CloudClusterPrintJobStatus import CloudClusterPrintJobStatus @@ -16,8 +16,8 @@ class CloudClusterStatus(BaseCloudModel): # \param print_jobs: The latest status of each print job in the cluster. # \param generated_time: The datetime when the object was generated on the server-side. def __init__(self, - printers: List[Union[CloudClusterPrinterStatus, Dict[str, any]]], - print_jobs: List[Union[CloudClusterPrintJobStatus, Dict[str, any]]], + printers: List[Union[CloudClusterPrinterStatus, Dict[str, Any]]], + print_jobs: List[Union[CloudClusterPrintJobStatus, Dict[str, Any]]], generated_time: Union[str, datetime], **kwargs) -> None: self.generated_time = self.parseDate(generated_time) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudErrorObject.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudErrorObject.py index c02a21d4da..28b4d916a1 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudErrorObject.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudErrorObject.py @@ -1,6 +1,6 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from typing import Dict, Optional +from typing import Dict, Optional, Any from .BaseCloudModel import BaseCloudModel @@ -18,7 +18,7 @@ class CloudErrorObject(BaseCloudModel): # \param http_status: The HTTP status code applicable to this problem, converted to string. # \param meta: Non-standard meta-information about the error, depending on the error code. def __init__(self, id: str, code: str, title: str, http_status: str, detail: Optional[str] = None, - meta: Optional[Dict[str, any]] = None, **kwargs) -> None: + meta: Optional[Dict[str, Any]] = None, **kwargs) -> None: self.id = id self.code = code self.http_status = http_status diff --git a/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py b/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py index 93a53373dc..394c3d8552 100644 --- a/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py @@ -56,7 +56,8 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): self._number_of_extruders = 2 - self._dummy_lambdas = ("", {}, io.BytesIO()) #type: Tuple[str, Dict, Union[io.StringIO, io.BytesIO]] + self._dummy_lambdas = ("", {}, io.BytesIO() + ) # type: Tuple[str, Dict[str, Union[str, int, bool]], Union[io.StringIO, io.BytesIO]] self._print_jobs = [] # type: List[UM3PrintJobOutputModel] self._received_print_jobs = False # type: bool @@ -254,7 +255,8 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): # Treat upload progress as response. Uploading can take more than 10 seconds, so if we don't, we can get # timeout responses if this happens. self._last_response_time = time() - if self._progress_message and new_progress > self._progress_message.getProgress(): + old_progress = self._progress_message.getProgress() + if self._progress_message and (old_progress is None or new_progress > old_progress): self._progress_message.show() # Ensure that the message is visible. self._progress_message.setProgress(bytes_sent / bytes_total * 100) @@ -345,28 +347,6 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): def getDateCompleted(self, time_remaining: int) -> str: return formatDateCompleted(time_remaining) - @pyqtSlot(int, result = str) - def getDateCompleted(self, time_remaining: int) -> str: - current_time = time() - completed = datetime.fromtimestamp(current_time + time_remaining) - today = datetime.fromtimestamp(current_time) - - # If finishing date is more than 7 days out, using "Mon Dec 3 at HH:MM" format - if completed.toordinal() > today.toordinal() + 7: - return completed.strftime("%a %b ") + "{day}".format(day=completed.day) - - # If finishing date is within the next week, use "Monday at HH:MM" format - elif completed.toordinal() > today.toordinal() + 1: - return completed.strftime("%a") - - # If finishing tomorrow, use "tomorrow at HH:MM" format - elif completed.toordinal() > today.toordinal(): - return "tomorrow" - - # If finishing today, use "today at HH:MM" format - else: - return "today" - @pyqtSlot(str) def sendJobToTop(self, print_job_uuid: str) -> None: # This function is part of the output device (and not of the printjob output model) as this type of operation diff --git a/plugins/UM3NetworkPrinting/src/MeshFormatHandler.py b/plugins/UM3NetworkPrinting/src/MeshFormatHandler.py index d64861ea91..72da3c4e6b 100644 --- a/plugins/UM3NetworkPrinting/src/MeshFormatHandler.py +++ b/plugins/UM3NetworkPrinting/src/MeshFormatHandler.py @@ -1,7 +1,7 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. import io -from typing import Optional, Dict, Union, List +from typing import Optional, Dict, Union, List, cast from UM.FileHandler.FileHandler import FileHandler from UM.FileHandler.FileWriter import FileWriter @@ -26,7 +26,7 @@ class MeshFormatHandler: def __init__(self, file_handler: Optional[FileHandler], firmware_version: str) -> None: self._file_handler = file_handler or CuraApplication.getInstance().getMeshFileHandler() self._preferred_format = self._getPreferredFormat(firmware_version) - self._writer = self._getWriter(self._preferred_format["mime_type"]) if self._preferred_format else None + self._writer = self._getWriter(self.mime_type) if self._preferred_format else None @property def is_valid(self) -> bool: @@ -47,32 +47,40 @@ class MeshFormatHandler: @property def mime_type(self) -> str: - return self._preferred_format["mime_type"] + return cast(str, self._preferred_format["mime_type"]) ## Gets the file mode (FileWriter.OutputMode.TextMode or FileWriter.OutputMode.BinaryMode) @property def file_mode(self) -> int: - return self._preferred_format["mode"] + return cast(int, self._preferred_format["mode"]) ## Gets the file extension @property def file_extension(self) -> str: - return self._preferred_format["extension"] + return cast(str, self._preferred_format["extension"]) ## Creates the right kind of stream based on the preferred format. def createStream(self) -> Union[io.BytesIO, io.StringIO]: - return io.StringIO() if self.file_mode == FileWriter.OutputMode.TextMode else io.BytesIO() + if self.file_mode == FileWriter.OutputMode.TextMode: + return io.StringIO() + else: + return io.BytesIO() ## Writes the mesh and returns its value. def getBytes(self, nodes: List[SceneNode]) -> bytes: + if self.writer is None: + raise ValueError("There is no writer for the mesh format handler.") stream = self.createStream() self.writer.write(stream, nodes) - return stream.getvalue() + value = stream.getvalue() + if isinstance(value, str): + value = value.encode() + return value ## Chooses the preferred file format for the given file handler. # \param firmware_version: The version of the firmware. # \return A dict with the file format details. - def _getPreferredFormat(self, firmware_version: str) -> Optional[Dict[str, Union[str, int, bool]]]: + def _getPreferredFormat(self, firmware_version: str) -> Dict[str, Union[str, int, bool]]: # Formats supported by this application (file types that we can actually write). application = CuraApplication.getInstance() @@ -82,7 +90,7 @@ class MeshFormatHandler: # Create a list from the supported file formats string. if not global_stack: Logger.log("e", "Missing global stack!") - return + return {} machine_file_formats = global_stack.getMetaDataEntry("file_formats").split(";") machine_file_formats = [file_type.strip() for file_type in machine_file_formats] diff --git a/plugins/UM3NetworkPrinting/src/SendMaterialJob.py b/plugins/UM3NetworkPrinting/src/SendMaterialJob.py index f536fad49a..cbcfe73c71 100644 --- a/plugins/UM3NetworkPrinting/src/SendMaterialJob.py +++ b/plugins/UM3NetworkPrinting/src/SendMaterialJob.py @@ -3,7 +3,7 @@ import json import os import urllib.parse -from typing import Dict, TYPE_CHECKING, Set +from typing import Dict, TYPE_CHECKING, Set, Optional from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest @@ -151,7 +151,7 @@ class SendMaterialJob(Job): # \return a dictionary of ClusterMaterial objects by GUID # \throw KeyError Raised when on of the materials does not include a valid guid @classmethod - def _parseReply(cls, reply: QNetworkReply) -> Dict[str, ClusterMaterial]: + def _parseReply(cls, reply: QNetworkReply) -> Optional[Dict[str, ClusterMaterial]]: try: remote_materials = json.loads(reply.readAll().data().decode("utf-8")) return {material["guid"]: ClusterMaterial(**material) for material in remote_materials} @@ -163,6 +163,7 @@ class SendMaterialJob(Job): Logger.log("e", "Request material storage on printer: Printer's answer had an incorrect value.") except TypeError: Logger.log("e", "Request material storage on printer: Printer's answer was missing a required value.") + return None ## Retrieves a list of local materials # @@ -184,7 +185,8 @@ class SendMaterialJob(Job): local_material = LocalMaterial(**material) if local_material.GUID not in result or \ - local_material.version > result.get(local_material.GUID).version: + local_material.GUID not in result or \ + local_material.version > result[local_material.GUID].version: result[local_material.GUID] = local_material except KeyError: diff --git a/plugins/UM3NetworkPrinting/src/UM3PrintJobOutputModel.py b/plugins/UM3NetworkPrinting/src/UM3PrintJobOutputModel.py index 2ac3e6ba4f..4f44ca4af8 100644 --- a/plugins/UM3NetworkPrinting/src/UM3PrintJobOutputModel.py +++ b/plugins/UM3NetworkPrinting/src/UM3PrintJobOutputModel.py @@ -1,13 +1,12 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from PyQt5.QtCore import pyqtSignal, pyqtProperty, QObject, pyqtSlot -from typing import Optional, TYPE_CHECKING, List -from PyQt5.QtCore import QUrl -from PyQt5.QtGui import QImage +from typing import List + +from PyQt5.QtCore import pyqtProperty, pyqtSignal from cura.PrinterOutput.PrintJobOutputModel import PrintJobOutputModel - +from cura.PrinterOutput.PrinterOutputController import PrinterOutputController from .ConfigurationChangeModel import ConfigurationChangeModel diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/Models/__init__.py b/plugins/UM3NetworkPrinting/tests/Cloud/Models/__init__.py new file mode 100644 index 0000000000..f3f6970c54 --- /dev/null +++ b/plugins/UM3NetworkPrinting/tests/Cloud/Models/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py b/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py index 5a76672b83..94cc239c0a 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py @@ -14,9 +14,6 @@ from UM.Signal import Signal # After patching the QNetworkManager class, requests are prepared before they can be executed. # Any requests not prepared beforehand will cause KeyErrors. class NetworkManagerMock: - # signals used in the network manager. - finished = Signal() - authenticationRequired = Signal() # an enumeration of the supported operations and their code for the network access manager. _OPERATIONS = { @@ -33,6 +30,10 @@ class NetworkManagerMock: self.replies = {} # type: Dict[Tuple[str, str], QNetworkReply] self.request_bodies = {} # type: Dict[Tuple[str, str], bytes] + # signals used in the network manager. + self.finished = Signal() + self.authenticationRequired = Signal() + ## Mock implementation of the get, post, put, delete and head methods from the network manager. # Since the methods are very simple and the same it didn't make sense to repeat the code. # \param method: The method being called. diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py index 84f0254b55..d673554640 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py @@ -2,22 +2,23 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. import os +from typing import List from unittest import TestCase from unittest.mock import patch, MagicMock from cura.CuraApplication import CuraApplication from src.Cloud.CloudApiClient import CloudApiClient -from src.Cloud.CloudOutputDeviceManager import CloudOutputDeviceManager from src.Cloud.Models.CloudPrintJobResponse import CloudPrintJobResponse from src.Cloud.Models.CloudPrintJobUploadRequest import CloudPrintJobUploadRequest +from src.Cloud.Models.CloudErrorObject import CloudErrorObject from tests.Cloud.Fixtures import readFixture, parseFixture from .NetworkManagerMock import NetworkManagerMock @patch("cura.NetworkClient.QNetworkAccessManager") class TestCloudApiClient(TestCase): - def _errorHandler(self): - pass + def _errorHandler(self, errors: List[CloudErrorObject]): + raise Exception("Received unexpected error: {}".format(errors)) def setUp(self): super().setUp() @@ -26,7 +27,6 @@ class TestCloudApiClient(TestCase): self.app = CuraApplication.getInstance() self.network = NetworkManagerMock() - self.manager = CloudOutputDeviceManager() self.api = CloudApiClient(self.account, self._errorHandler) def test_GetClusters(self, network_mock): From a766effce8917337624b3d030400797fa73c2adb Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Tue, 11 Dec 2018 12:43:43 +0100 Subject: [PATCH 107/178] Fix typing for optional callback for on_progress --- cura/NetworkClient.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cura/NetworkClient.py b/cura/NetworkClient.py index 5294813fb7..6abf4f3d47 100644 --- a/cura/NetworkClient.py +++ b/cura/NetworkClient.py @@ -203,7 +203,7 @@ class NetworkClient: ## Does a POST request with form data to the given URL. def postForm(self, url: str, header_data: str, body_data: bytes, on_finished: Optional[Callable[[QNetworkReply], None]], - on_progress: Callable = None) -> None: + on_progress: Optional[Callable[[int, int], None]] = None) -> None: post_part = QHttpPart() post_part.setHeader(QNetworkRequest.ContentDispositionHeader, header_data) post_part.setBody(body_data) @@ -212,7 +212,7 @@ class NetworkClient: ## Does a POST request with form parts to the given URL. def postFormWithParts(self, target: str, parts: List[QHttpPart], on_finished: Optional[Callable[[QNetworkReply], None]], - on_progress: Callable = None) -> Optional[QNetworkReply]: + on_progress: Optional[Callable[[int, int], None]] = None) -> Optional[QNetworkReply]: self._validateManager() request = self._createEmptyRequest(target, content_type = None) From e52339a4248a5cf3a1aa423528e3c654defeb2e2 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Tue, 11 Dec 2018 12:44:37 +0100 Subject: [PATCH 108/178] Fix typing for output_device param in cloud output controller --- plugins/UM3NetworkPrinting/src/Cloud/CloudOutputController.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputController.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputController.py index d31d2bf486..b2a8d8649b 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputController.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputController.py @@ -1,10 +1,11 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. +from UM.OutputDevice.OutputDevice import OutputDevice from cura.PrinterOutput.PrinterOutputController import PrinterOutputController class CloudOutputController(PrinterOutputController): - def __init__(self, output_device): + def __init__(self, output_device: OutputDevice): super().__init__(output_device) # The cloud connection only supports fetching the printer and queue status and adding a job to the queue. From 7b7c687db7792634a178a2a6b001d6566b40f4c6 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Tue, 11 Dec 2018 12:46:05 +0100 Subject: [PATCH 109/178] Remove dismissable = False from messages as it's the default --- plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index e68eb47839..5e0d34e902 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -320,8 +320,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): Message( text = message, title = T.ERROR, - lifetime = 10, - dismissable = True + lifetime = 10 ).show() self._sending_job = False # the upload has finished so we're not sending a job anymore self.writeError.emit() @@ -334,8 +333,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): Message( text = T.UPLOAD_SUCCESS_TEXT, title = T.UPLOAD_SUCCESS_TITLE, - lifetime = 5, - dismissable = True, + lifetime = 5 ).show() self._sending_job = False # the upload has finished so we're not sending a job anymore self.writeFinished.emit() From e4939cf0051d4f679e8156df26b46e5a22f8b26d Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Tue, 11 Dec 2018 12:55:49 +0100 Subject: [PATCH 110/178] Remove outdated cloud icon, will be done in another PR --- .../cura-light/icons/printer_cloud_connected.svg | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 resources/themes/cura-light/icons/printer_cloud_connected.svg diff --git a/resources/themes/cura-light/icons/printer_cloud_connected.svg b/resources/themes/cura-light/icons/printer_cloud_connected.svg deleted file mode 100644 index 59ca67e93e..0000000000 --- a/resources/themes/cura-light/icons/printer_cloud_connected.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - noun_Cloud_377836 - Created with Sketch. - - - - - - - - \ No newline at end of file From a5d8e6ceb8da1e5df1d0a5bc72dd962457f1b216 Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Tue, 11 Dec 2018 14:28:00 +0100 Subject: [PATCH 111/178] STAR-322: Removing the print job after it was done --- .../src/Cloud/CloudApiClient.py | 24 +++++++++---------- .../src/Cloud/CloudOutputDevice.py | 3 +++ .../src/ClusterUM3OutputDevice.py | 24 +++++++++---------- 3 files changed, 27 insertions(+), 24 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py index 474d76d85a..b08bac6670 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py @@ -2,7 +2,7 @@ # Cura is released under the terms of the LGPLv3 or higher. import json from json import JSONDecodeError -from typing import Callable, List, Type, TypeVar, Union, Optional, Tuple, Dict +from typing import Callable, List, Type, TypeVar, Union, Optional, Tuple, Dict, Any from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply @@ -19,7 +19,7 @@ from .Models.CloudPrintJobResponse import CloudPrintJobResponse ## The cloud API client is responsible for handling the requests and responses from the cloud. -# Each method should only handle models instead of exposing any HTTP details. +# Each method should only handle models instead of exposing Any HTTP details. class CloudApiClient(NetworkClient): # The cloud URL to use for this remote cluster. @@ -43,21 +43,21 @@ class CloudApiClient(NetworkClient): ## Retrieves all the clusters for the user that is currently logged in. # \param on_finished: The function to be called after the result is parsed. - def getClusters(self, on_finished: Callable[[List[CloudClusterResponse]], any]) -> None: + def getClusters(self, on_finished: Callable[[List[CloudClusterResponse]], Any]) -> None: url = "{}/clusters".format(self.CLUSTER_API_ROOT) self.get(url, on_finished=self._wrapCallback(on_finished, CloudClusterResponse)) ## Retrieves the status of the given cluster. # \param cluster_id: The ID of the cluster. # \param on_finished: The function to be called after the result is parsed. - def getClusterStatus(self, cluster_id: str, on_finished: Callable[[CloudClusterStatus], any]) -> None: + def getClusterStatus(self, cluster_id: str, on_finished: Callable[[CloudClusterStatus], Any]) -> None: url = "{}/clusters/{}/status".format(self.CLUSTER_API_ROOT, cluster_id) self.get(url, on_finished=self._wrapCallback(on_finished, CloudClusterStatus)) ## Requests the cloud to register the upload of a print job mesh. # \param request: The request object. # \param on_finished: The function to be called after the result is parsed. - def requestUpload(self, request: CloudPrintJobUploadRequest, on_finished: Callable[[CloudPrintJobResponse], any] + def requestUpload(self, request: CloudPrintJobUploadRequest, on_finished: Callable[[CloudPrintJobResponse], Any] ) -> None: url = "{}/jobs/upload".format(self.CURA_API_ROOT) body = json.dumps({"data": request.toDict()}) @@ -69,8 +69,8 @@ class CloudApiClient(NetworkClient): # \param on_finished: The function to be called after the result is parsed. It receives the print job ID. # \param on_progress: A function to be called during upload progress. It receives a percentage (0-100). # \param on_error: A function to be called if the upload fails. It receives a dict with the error. - def uploadMesh(self, upload_response: CloudPrintJobResponse, mesh: bytes, on_finished: Callable[[str], any], - on_progress: Callable[[int], any], on_error: Callable[[dict], any]): + def uploadMesh(self, upload_response: CloudPrintJobResponse, mesh: bytes, on_finished: Callable[[str], Any], + on_progress: Callable[[int], Any], on_error: Callable[[dict], Any]): def progressCallback(bytes_sent: int, bytes_total: int) -> None: if bytes_total: @@ -92,7 +92,7 @@ class CloudApiClient(NetworkClient): # \param cluster_id: The ID of the cluster. # \param job_id: The ID of the print job. # \param on_finished: The function to be called after the result is parsed. - def requestPrint(self, cluster_id: str, job_id: str, on_finished: Callable[[CloudPrintResponse], any]) -> None: + def requestPrint(self, cluster_id: str, job_id: str, on_finished: Callable[[CloudPrintResponse], Any]) -> None: url = "{}/clusters/{}/print/{}".format(self.CLUSTER_API_ROOT, cluster_id, job_id) self.post(url, data = "", on_finished=self._wrapCallback(on_finished, CloudPrintResponse)) @@ -110,7 +110,7 @@ class CloudApiClient(NetworkClient): # \param reply: The reply from the server. # \return A tuple with a status code and a dictionary. @staticmethod - def _parseReply(reply: QNetworkReply) -> Tuple[int, Dict[str, any]]: + def _parseReply(reply: QNetworkReply) -> Tuple[int, Dict[str, Any]]: status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) try: response = bytes(reply.readAll()).decode() @@ -128,8 +128,8 @@ class CloudApiClient(NetworkClient): # \param response: The response from the server, after being converted to a dict. # \param on_finished: The callback in case the response is successful. # \param model_class: The type of the model to convert the response to. It may either be a single record or a list. - def _parseModels(self, response: Dict[str, any], - on_finished: Callable[[Union[Model, List[Model]]], any], + def _parseModels(self, response: Dict[str, Any], + on_finished: Callable[[Union[Model, List[Model]]], Any], model_class: Type[Model]) -> None: if "data" in response: data = response["data"] @@ -145,7 +145,7 @@ class CloudApiClient(NetworkClient): # \param model: The type of the model to convert the response to. It may either be a single record or a list. # \return: A function that can be passed to the def _wrapCallback(self, - on_finished: Callable[[Union[Model, List[Model]]], any], + on_finished: Callable[[Union[Model, List[Model]]], Any], model: Type[Model], ) -> Callable[[QNetworkReply], None]: def parse(reply: QNetworkReply) -> None: diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index e68eb47839..bf80e0f84c 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -229,6 +229,9 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): removed_jobs, added_jobs, updated_jobs = findChanges(previous, received) for removed_job in removed_jobs: + if removed_job.assignedPrinter: + removed_job.assignedPrinter.updateActivePrintJob(None) + removed_job.stateChanged.disconnect(self._onPrintJobStateChanged) self._print_jobs.remove(removed_job) for added_job in added_jobs: diff --git a/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py b/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py index 5e43b602cc..96fee0d96d 100644 --- a/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py @@ -56,8 +56,9 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): self._number_of_extruders = 2 - self._dummy_lambdas = ("", {}, io.BytesIO() - ) # type: Tuple[str, Dict[str, Union[str, int, bool]], Union[io.StringIO, io.BytesIO]] + self._dummy_lambdas = ( + "", {}, io.BytesIO() + ) # type: Tuple[Optional[str], Dict[str, Union[str, int, bool]], Union[io.StringIO, io.BytesIO]] self._print_jobs = [] # type: List[UM3PrintJobOutputModel] self._received_print_jobs = False # type: bool @@ -165,7 +166,8 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): self._sending_gcode = True - target_printer = yield #Potentially wait on the user to select a target printer. + # Potentially wait on the user to select a target printer. + target_printer = yield # type: Optional[str] # Using buffering greatly reduces the write time for many lines of gcode @@ -179,13 +181,12 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): use_inactivity_timer = False) self._write_job_progress_message.show() - self._dummy_lambdas = (target_printer, mesh_format.preferred_format, stream) - job.finished.connect(self._sendPrintJobWaitOnWriteJobFinished) - - job.start() - - yield True # Return that we had success! - yield # To prevent having to catch the StopIteration exception. + if mesh_format.preferred_format is not None: + self._dummy_lambdas = (target_printer, mesh_format.preferred_format, stream) + job.finished.connect(self._sendPrintJobWaitOnWriteJobFinished) + job.start() + yield True # Return that we had success! + yield # To prevent having to catch the StopIteration exception. def _sendPrintJobWaitOnWriteJobFinished(self, job: WriteFileJob) -> None: if self._write_job_progress_message: @@ -255,8 +256,7 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): # Treat upload progress as response. Uploading can take more than 10 seconds, so if we don't, we can get # timeout responses if this happens. self._last_response_time = time() - old_progress = self._progress_message.getProgress() - if self._progress_message and (old_progress is None or new_progress > old_progress): + if self._progress_message is not None and new_progress > self._progress_message.getProgress(): self._progress_message.show() # Ensure that the message is visible. self._progress_message.setProgress(bytes_sent / bytes_total * 100) From 64dc73aed3f6d043c5ab7c81c54f54495b951abe Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Tue, 11 Dec 2018 15:47:57 +0100 Subject: [PATCH 112/178] remove incorrect __init__.py --- __init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 __init__.py diff --git a/__init__.py b/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 From 0467756ed6ef4d4ae194cccede5465dd78a401c9 Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Tue, 11 Dec 2018 16:01:52 +0100 Subject: [PATCH 113/178] STAR-322: Adding return type to init method --- plugins/UM3NetworkPrinting/src/Cloud/CloudOutputController.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputController.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputController.py index b2a8d8649b..c139be0c38 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputController.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputController.py @@ -5,7 +5,7 @@ from cura.PrinterOutput.PrinterOutputController import PrinterOutputController class CloudOutputController(PrinterOutputController): - def __init__(self, output_device: OutputDevice): + def __init__(self, output_device: OutputDevice) -> None: super().__init__(output_device) # The cloud connection only supports fetching the printer and queue status and adding a job to the queue. From fed779d0d2cfd6a81fa5747a0a10debbcdb8c8ec Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Wed, 12 Dec 2018 17:31:08 +0100 Subject: [PATCH 114/178] STAR-322: Implementing multi-part upload (doesnt always work) --- .../src/Cloud/CloudApiClient.py | 23 +---- .../src/Cloud/CloudOutputDevice.py | 56 +++-------- .../src/Cloud/CloudProgressMessage.py | 37 ++++++++ .../src/Cloud/ResumableUpload.py | 94 +++++++++++++++++++ .../tests/Cloud/NetworkManagerMock.py | 3 +- .../tests/Cloud/TestCloudApiClient.py | 15 +-- 6 files changed, 161 insertions(+), 67 deletions(-) create mode 100644 plugins/UM3NetworkPrinting/src/Cloud/CloudProgressMessage.py create mode 100644 plugins/UM3NetworkPrinting/src/Cloud/ResumableUpload.py diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py index b08bac6670..2637f17010 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py @@ -9,6 +9,7 @@ from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply from UM.Logger import Logger from cura.API import Account from cura.NetworkClient import NetworkClient +from .ResumableUpload import ResumableUpload from ..Models import BaseModel from .Models.CloudClusterResponse import CloudClusterResponse from .Models.CloudErrorObject import CloudErrorObject @@ -69,24 +70,10 @@ class CloudApiClient(NetworkClient): # \param on_finished: The function to be called after the result is parsed. It receives the print job ID. # \param on_progress: A function to be called during upload progress. It receives a percentage (0-100). # \param on_error: A function to be called if the upload fails. It receives a dict with the error. - def uploadMesh(self, upload_response: CloudPrintJobResponse, mesh: bytes, on_finished: Callable[[str], Any], - on_progress: Callable[[int], Any], on_error: Callable[[dict], Any]): - - def progressCallback(bytes_sent: int, bytes_total: int) -> None: - if bytes_total: - on_progress(int((bytes_sent / bytes_total) * 100)) - - def finishedCallback(reply: QNetworkReply): - status_code, response = self._parseReply(reply) - if status_code < 300: - on_finished(upload_response.job_id) - else: - Logger.log("e", "Received unexpected response %s uploading mesh: %s", status_code, response) - on_error(response) - - # TODO: Multipart upload - self.put(upload_response.upload_url, data = mesh, content_type = upload_response.content_type, - on_finished = finishedCallback, on_progress = progressCallback) + def uploadMesh(self, upload_response: CloudPrintJobResponse, mesh: bytes, on_finished: Callable[[], Any], + on_progress: Callable[[int], Any], on_error: Callable[[], Any]): + ResumableUpload(upload_response.upload_url, upload_response.content_type, mesh, on_finished, + on_progress, on_error).start() # Requests a cluster to print the given print job. # \param cluster_id: The ID of the cluster. diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index c4ab752163..e75989a6a8 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -18,6 +18,7 @@ from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel from plugins.UM3NetworkPrinting.src.Cloud.CloudOutputController import CloudOutputController from ..MeshFormatHandler import MeshFormatHandler from ..UM3PrintJobOutputModel import UM3PrintJobOutputModel +from .CloudProgressMessage import CloudProgressMessage from .CloudApiClient import CloudApiClient from .Models.CloudClusterStatus import CloudClusterStatus from .Models.CloudPrintJobUploadRequest import CloudPrintJobUploadRequest @@ -43,9 +44,6 @@ class T: COULD_NOT_EXPORT = _I18N_CATALOG.i18nc("@info:status", "Could not export print job.") - SENDING_DATA_TEXT = _I18N_CATALOG.i18nc("@info:status", "Sending data to remote cluster") - SENDING_DATA_TITLE = _I18N_CATALOG.i18nc("@info:status", "Sending data to remote cluster") - ERROR = _I18N_CATALOG.i18nc("@info:title", "Error") UPLOAD_ERROR = _I18N_CATALOG.i18nc("@info:text", "Could not upload the data to the printer.") @@ -68,7 +66,7 @@ class T: class CloudOutputDevice(NetworkedPrinterOutputDevice): # The interval with which the remote clusters are checked - CHECK_CLUSTER_INTERVAL = 4.0 # seconds + CHECK_CLUSTER_INTERVAL = 5.0 # seconds # Signal triggered when the print jobs in the queue were changed. printJobsChanged = pyqtSignal() @@ -109,9 +107,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): self._number_of_extruders = 2 # All networked printers are dual-extrusion Ultimaker machines. # We only allow a single upload at a time. - self._sending_job = False - # TODO: handle progress messages in another class. - self._progress_message = None # type: Optional[Message] + self._progress = CloudProgressMessage() # Keep server string of the last generated time to avoid updating models more than once for the same response self._received_printers = None # type: Optional[List[CloudClusterPrinterStatus]] @@ -149,7 +145,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): file_handler: Optional[FileHandler] = None, **kwargs: str) -> None: # Show an error message if we're already sending a job. - if self._sending_job: + if self._progress.visible: self._onUploadError(T.BLOCKED_UPLOADING) return @@ -286,53 +282,31 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): # \param mesh: The bytes to upload. # \param job_response: The response received from the cloud API. def _onPrintJobCreated(self, mesh: bytes, job_response: CloudPrintJobResponse) -> None: - self._api.uploadMesh(job_response, mesh, self._onPrintJobUploaded, self._updateUploadProgress, - lambda _: self._onUploadError(T.UPLOAD_ERROR)) + self._progress.show() + self._api.uploadMesh(job_response, mesh, lambda: self._onPrintJobUploaded(job_response.job_id), + self._progress.update, self._onUploadError) ## Requests the print to be sent to the printer when we finished uploading the mesh. # \param job_id: The ID of the job. def _onPrintJobUploaded(self, job_id: str) -> None: self._api.requestPrint(self._device_id, job_id, self._onUploadSuccess) - ## Updates the progress of the mesh upload. - # \param progress: The amount of percentage points uploaded until now (0-100). - def _updateUploadProgress(self, progress: int) -> None: - if not self._progress_message: - self._progress_message = Message( - text = T.SENDING_DATA_TEXT, - title = T.SENDING_DATA_TITLE, - progress = -1, - lifetime = 0, - dismissable = False, - use_inactivity_timer = False - ) - self._progress_message.setProgress(progress) - self._progress_message.show() - - ## Hides the upload progress bar - def _resetUploadProgress(self) -> None: - if self._progress_message: - self._progress_message.hide() - self._progress_message = None - ## Displays the given message if uploading the mesh has failed # \param message: The message to display. - def _onUploadError(self, message: str = None) -> None: - self._resetUploadProgress() - if message: - Message( - text = message, - title = T.ERROR, - lifetime = 10 - ).show() - self._sending_job = False # the upload has finished so we're not sending a job anymore + def _onUploadError(self, message = None) -> None: + self._progress.hide() + Message( + text = message or T.UPLOAD_ERROR, + title = T.ERROR, + lifetime = 10 + ).show() self.writeError.emit() ## Shows a message when the upload has succeeded # \param response: The response from the cloud API. def _onUploadSuccess(self, response: CloudPrintResponse) -> None: Logger.log("i", "The cluster will be printing this print job with the ID %s", response.cluster_job_id) - self._resetUploadProgress() + self._progress.hide() Message( text = T.UPLOAD_SUCCESS_TEXT, title = T.UPLOAD_SUCCESS_TITLE, diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudProgressMessage.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudProgressMessage.py new file mode 100644 index 0000000000..e3e0cefc0c --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudProgressMessage.py @@ -0,0 +1,37 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from UM import i18nCatalog +from UM.Message import Message + + +## Class that contains all the translations for this module. +class T: + _I18N_CATALOG = i18nCatalog("cura") + + SENDING_DATA_TEXT = _I18N_CATALOG.i18nc("@info:status", "Sending data to remote cluster") + SENDING_DATA_TITLE = _I18N_CATALOG.i18nc("@info:status", "Sending data to remote cluster") + + +class CloudProgressMessage(Message): + def __init__(self): + super().__init__( + text = T.SENDING_DATA_TEXT, + title = T.SENDING_DATA_TITLE, + progress = -1, + lifetime = 0, + dismissable = False, + use_inactivity_timer = False + ) + + def show(self): + self.setProgress(0) + super().show() + + def update(self, percentage: int) -> None: + if not self._visible: + super().show() + self.setProgress(percentage) + + @property + def visible(self) -> bool: + return self._visible diff --git a/plugins/UM3NetworkPrinting/src/Cloud/ResumableUpload.py b/plugins/UM3NetworkPrinting/src/Cloud/ResumableUpload.py new file mode 100644 index 0000000000..52b8e5c2d7 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/ResumableUpload.py @@ -0,0 +1,94 @@ +# Copyright (c) 2018 Ultimaker B.V. +# !/usr/bin/env python +# -*- coding: utf-8 -*- +from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply +from typing import Optional, Callable, Any, Tuple + +from UM.Logger import Logger +from cura.NetworkClient import NetworkClient + + +class ResumableUpload(NetworkClient): + MAX_RETRIES = 10 + BYTES_PER_REQUEST = 256 * 1024 + RETRY_HTTP_CODES = {500, 502, 503, 504} + + ## Creates a resumable upload + # \param url: The URL to which we shall upload. + # \param content_length: The total content length of the file, in bytes. + # \param http_method: The HTTP method to be used, e.g. "POST" or "PUT". + # \param timeout: The timeout for each chunk upload. Important: If None, no timeout is applied at all. + def __init__(self, url: str, content_type: str, data: bytes, + on_finished: Callable[[], Any], on_progress: Callable[[int], Any], on_error: Callable[[], Any]): + super().__init__() + self._url = url + self._content_type = content_type + self._data = data + + self._on_finished = on_finished + self._on_progress = on_progress + self._on_error = on_error + + self._sent_bytes = 0 + self._retries = 0 + self._finished = False + + ## We override _createEmptyRequest in order to add the user credentials. + # \param url: The URL to request + # \param content_type: The type of the body contents. + def _createEmptyRequest(self, path: str, content_type: Optional[str] = "application/json") -> QNetworkRequest: + request = super()._createEmptyRequest(path, content_type = self._content_type) + + first_byte, last_byte = self._chunkRange() + content_range = "bytes {}-{}/{}".format(first_byte, last_byte - 1, len(self._data)) + request.setRawHeader(b"Content-Range", content_range.encode()) + Logger.log("i", "Uploading %s to %s", content_range, self._url) + + return request + + def _chunkRange(self) -> Tuple[int, int]: + last_byte = min(len(self._data), self._sent_bytes + self.BYTES_PER_REQUEST) + return self._sent_bytes, last_byte + + def start(self) -> None: + self._uploadChunk() + + def _uploadChunk(self) -> None: + if self._finished: + raise ValueError("The upload is already finished") + + first_byte, last_byte = self._chunkRange() + Logger.log("i", "PUT %s - %s", first_byte, last_byte) + self.put(self._url, data = self._data[first_byte:last_byte], content_type = self._content_type, + on_finished = self.finishedCallback, on_progress = self.progressCallback) + + def progressCallback(self, bytes_sent: int, bytes_total: int) -> None: + if bytes_total: + self._on_progress(int((self._sent_bytes + bytes_sent) / len(self._data) * 100)) + + def finishedCallback(self, reply: QNetworkReply) -> None: + status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) + + if self._retries < self.MAX_RETRIES and status_code in self.RETRY_HTTP_CODES: + self._retries += 1 + Logger.log("i", "Retrying %s/%s request %s", tries, self.MAX_RETRIES, request.url) + self._uploadChunk() + return + + body = bytes(reply.readAll()).decode() + Logger.log("w", "status_code: %s, Headers: %s, body: %s", status_code, + [bytes(header).decode() for header in reply.rawHeaderList()], body) + + if status_code > 308: + self._finished = True + Logger.log("e", "Received error while uploading: %s", body) + self._on_error() + return + + first_byte, last_byte = self._chunkRange() + self._sent_bytes += last_byte - first_byte + self._finished = self._sent_bytes >= len(self._data) + if self._finished: + self._on_finished() + else: + self._uploadChunk() diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py b/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py index 94cc239c0a..60627cbe7c 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py @@ -76,7 +76,8 @@ class NetworkManagerMock: ## Emits the signal that the reply is ready to all prepared replies. def flushReplies(self) -> None: - for reply in self.replies.values(): + for key, reply in self.replies.items(): + Logger.log("i", "Flushing reply to {} {}", *key) self.finished.emit(reply) self.reset() diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py index d673554640..e377627465 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py @@ -89,16 +89,17 @@ class TestCloudApiClient(TestCase): data = parseFixture("putJobUploadResponse")["data"] upload_response = CloudPrintJobResponse(**data) - self.network.prepareReply("PUT", upload_response.upload_url, 200, - b'{ data : "" }') # Network client doesn't look into the reply + # Network client doesn't look into the reply + self.network.prepareReply("PUT", upload_response.upload_url, 200, b'{}') - self.api.uploadMesh(upload_response, b'', lambda job_id: results.append(job_id), - progress.advance, progress.error) + mesh = ("1234" * 100000).encode() + self.api.uploadMesh(upload_response, mesh, lambda: results.append("sent"), progress.advance, progress.error) - self.network.flushReplies() + for _ in range(10): + self.network.flushReplies() + self.network.prepareReply("PUT", upload_response.upload_url, 200, b'{}') - self.assertEqual(len(results), 1) - self.assertEqual(results[0], upload_response.job_id) + self.assertEqual(["sent"], results) def test_requestPrint(self, network_mock): network_mock.return_value = self.network From 819f8531a21f68ebf47f18c347043f30117dbf35 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Thu, 13 Dec 2018 11:54:10 +0100 Subject: [PATCH 115/178] Use CuraConstants for Cloud printing API root --- plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py index b08bac6670..2dd0c84442 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py @@ -7,6 +7,7 @@ from typing import Callable, List, Type, TypeVar, Union, Optional, Tuple, Dict, from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply from UM.Logger import Logger +from cura import CuraConstants from cura.API import Account from cura.NetworkClient import NetworkClient from ..Models import BaseModel @@ -23,8 +24,7 @@ from .Models.CloudPrintJobResponse import CloudPrintJobResponse class CloudApiClient(NetworkClient): # The cloud URL to use for this remote cluster. - # TODO: Make sure that this URL goes to the live api before release - ROOT_PATH = "https://api-staging.ultimaker.com" + ROOT_PATH = CuraConstants.CuraCloudAPIRoot CLUSTER_API_ROOT = "{}/connect/v1".format(ROOT_PATH) CURA_API_ROOT = "{}/cura/v1".format(ROOT_PATH) From 4dc8edb99625de2c4b1efbe96eb3170e034b795b Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Fri, 14 Dec 2018 12:48:40 +0100 Subject: [PATCH 116/178] STAR-322: Fixing the multipart upload --- cura/NetworkClient.py | 82 +++++++------------ cura/OAuth2/AuthorizationService.py | 2 + .../src/Cloud/CloudOutputDevice.py | 12 +-- .../src/Cloud/CloudOutputDeviceManager.py | 34 ++++++-- .../src/Cloud/ResumableUpload.py | 53 ++++++++---- .../src/UM3OutputDevicePlugin.py | 2 + 6 files changed, 105 insertions(+), 80 deletions(-) diff --git a/cura/NetworkClient.py b/cura/NetworkClient.py index 6abf4f3d47..b455d03db0 100644 --- a/cura/NetworkClient.py +++ b/cura/NetworkClient.py @@ -1,7 +1,7 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from time import time -from typing import Optional, Dict, Callable, List, Union +from typing import Optional, Dict, Callable, List, Union, Tuple from PyQt5.QtCore import QUrl from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkReply, QHttpMultiPart, QNetworkRequest, QHttpPart, \ @@ -16,50 +16,63 @@ from UM.Logger import Logger class NetworkClient: def __init__(self) -> None: - + # Network manager instance to use for this client. self._manager = None # type: Optional[QNetworkAccessManager] - + # Timings. self._last_manager_create_time = None # type: Optional[float] self._last_response_time = None # type: Optional[float] self._last_request_time = None # type: Optional[float] - + # The user agent of Cura. application = Application.getInstance() self._user_agent = "%s/%s " % (application.getApplicationName(), application.getVersion()) # Uses to store callback methods for finished network requests. # This allows us to register network calls with a callback directly instead of having to dissect the reply. - self._on_finished_callbacks = {} # type: Dict[str, Callable[[QNetworkReply], None]] + # The key is created out of a tuple (operation, url) + self._on_finished_callbacks = {} # type: Dict[Tuple[int, str], Callable[[QNetworkReply], None]] # QHttpMultiPart objects need to be kept alive and not garbage collected during the # HTTP which uses them. We hold references to these QHttpMultiPart objects here. self._kept_alive_multiparts = {} # type: Dict[QNetworkReply, QHttpMultiPart] - ## Creates a network manager with all the required properties and event bindings. - def _createNetworkManager(self) -> None: + ## Creates a network manager if needed, with all the required properties and event bindings. + def start(self) -> None: if self._manager: - self._manager.finished.disconnect(self.__handleOnFinished) - self._manager.authenticationRequired.disconnect(self._onAuthenticationRequired) + return self._manager = QNetworkAccessManager() - self._manager.finished.connect(self.__handleOnFinished) + self._manager.finished.connect(self._handleOnFinished) self._last_manager_create_time = time() self._manager.authenticationRequired.connect(self._onAuthenticationRequired) + ## Destroys the network manager and event bindings. + def stop(self) -> None: + if not self._manager: + return + self._manager.finished.disconnect(self._handleOnFinished) + self._manager.authenticationRequired.disconnect(self._onAuthenticationRequired) + self._manager = None + ## Create a new empty network request. # Automatically adds the required HTTP headers. # \param url: The URL to request # \param content_type: The type of the body contents. def _createEmptyRequest(self, url: str, content_type: Optional[str] = "application/json") -> QNetworkRequest: + if not self._manager: + self.start() # make sure the manager is created request = QNetworkRequest(QUrl(url)) if content_type: request.setHeader(QNetworkRequest.ContentTypeHeader, content_type) request.setHeader(QNetworkRequest.UserAgentHeader, self._user_agent) + self._last_request_time = time() return request ## Executes the correct callback method when a network request finishes. - def __handleOnFinished(self, reply: QNetworkReply) -> None: + def _handleOnFinished(self, reply: QNetworkReply) -> None: + + Logger.log("i", "On finished %s %s", reply.attribute(QNetworkRequest.HttpStatusCodeAttribute), reply.url().toString()) # Due to garbage collection, we need to cache certain bits of post operations. # As we don't want to keep them around forever, delete them if we get a reply. @@ -76,7 +89,7 @@ class NetworkClient: # Find the right callback and execute it. # It always takes the full reply as single parameter. - callback_key = reply.url().toString() + str(reply.operation()) + callback_key = reply.operation(), reply.url().toString() if callback_key in self._on_finished_callbacks: self._on_finished_callbacks[callback_key](reply) else: @@ -87,12 +100,6 @@ class NetworkClient: if reply in self._kept_alive_multiparts: del self._kept_alive_multiparts[reply] - ## Makes sure the network manager is created. - def _validateManager(self) -> None: - if self._manager is None: - self._createNetworkManager() - assert self._manager is not None - ## Callback for when the network manager detects that authentication is required but was not given. @staticmethod def _onAuthenticationRequired(reply: QNetworkReply, authenticator: QAuthenticator) -> None: @@ -102,7 +109,7 @@ class NetworkClient: def _registerOnFinishedCallback(self, reply: QNetworkReply, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None: if on_finished is not None: - self._on_finished_callbacks[reply.url().toString() + str(reply.operation())] = on_finished + self._on_finished_callbacks[reply.operation(), reply.url().toString()] = on_finished ## Add a part to a Multi-Part form. @staticmethod @@ -133,33 +140,23 @@ class NetworkClient: def put(self, url: str, data: Union[str, bytes], content_type: Optional[str] = None, on_finished: Optional[Callable[[QNetworkReply], None]] = None, on_progress: Optional[Callable[[int, int], None]] = None) -> None: - self._validateManager() - request = self._createEmptyRequest(url, content_type = content_type) - self._last_request_time = time() - - if not self._manager: - return Logger.log("e", "No network manager was created to execute the PUT call with.") body = data if isinstance(data, bytes) else data.encode() # type: bytes reply = self._manager.put(request, body) self._registerOnFinishedCallback(reply, on_finished) if on_progress is not None: + # TODO: Do we need to disconnect() as well? reply.uploadProgress.connect(on_progress) + reply.finished.connect(lambda r: Logger.log("i", "On finished %s %s", url, r)) + reply.error.connect(lambda r: Logger.log("i", "On error %s %s", url, r)) ## Sends a delete request to the given path. # url: The path after the API prefix. # on_finished: The function to be call when the response is received. def delete(self, url: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None: - self._validateManager() - request = self._createEmptyRequest(url) - self._last_request_time = time() - - if not self._manager: - return Logger.log("e", "No network manager was created to execute the DELETE call with.") - reply = self._manager.deleteResource(request) self._registerOnFinishedCallback(reply, on_finished) @@ -167,14 +164,7 @@ class NetworkClient: # \param url: The path after the API prefix. # \param on_finished: The function to be call when the response is received. def get(self, url: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None: - self._validateManager() - request = self._createEmptyRequest(url) - self._last_request_time = time() - - if not self._manager: - return Logger.log("e", "No network manager was created to execute the GET call with.") - reply = self._manager.get(request) self._registerOnFinishedCallback(reply, on_finished) @@ -186,13 +176,7 @@ class NetworkClient: def post(self, url: str, data: Union[str, bytes], on_finished: Optional[Callable[[QNetworkReply], None]], on_progress: Optional[Callable[[int, int], None]] = None) -> None: - self._validateManager() - request = self._createEmptyRequest(url) - self._last_request_time = time() - - if not self._manager: - return Logger.log("e", "Could not find manager.") body = data if isinstance(data, bytes) else data.encode() # type: bytes reply = self._manager.post(request, body) @@ -213,20 +197,12 @@ class NetworkClient: def postFormWithParts(self, target: str, parts: List[QHttpPart], on_finished: Optional[Callable[[QNetworkReply], None]], on_progress: Optional[Callable[[int, int], None]] = None) -> Optional[QNetworkReply]: - self._validateManager() - request = self._createEmptyRequest(target, content_type = None) multi_post_part = QHttpMultiPart(QHttpMultiPart.FormDataType) for part in parts: multi_post_part.append(part) - self._last_request_time = time() - - if not self._manager: - Logger.log("e", "No network manager was created to execute the POST call with.") - return None - reply = self._manager.post(request, multi_post_part) self._kept_alive_multiparts[reply] = multi_post_part diff --git a/cura/OAuth2/AuthorizationService.py b/cura/OAuth2/AuthorizationService.py index 4355891139..21dbbe8248 100644 --- a/cura/OAuth2/AuthorizationService.py +++ b/cura/OAuth2/AuthorizationService.py @@ -54,6 +54,8 @@ class AuthorizationService: self._user_profile = self._parseJWT() if not self._user_profile: # If there is still no user profile from the JWT, we have to log in again. + Logger.log("w", "The user profile could not be loaded. The user must log in again!") + self.deleteAuthData() return None return self._user_profile diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index e75989a6a8..83b5bed16b 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -66,7 +66,7 @@ class T: class CloudOutputDevice(NetworkedPrinterOutputDevice): # The interval with which the remote clusters are checked - CHECK_CLUSTER_INTERVAL = 5.0 # seconds + CHECK_CLUSTER_INTERVAL = 50.0 # seconds # Signal triggered when the print jobs in the queue were changed. printJobsChanged = pyqtSignal() @@ -150,7 +150,6 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): return # Indicate we have started sending a job. - self._sending_job = True self.writeStarted.emit(self) mesh_format = MeshFormatHandler(file_handler, self.firmwareVersion) @@ -173,6 +172,8 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): if self._last_response_time and time() - self._last_response_time < self.CHECK_CLUSTER_INTERVAL: return # avoid calling the cloud too often + Logger.log("i", "Requesting update for %s after %s", self._device_id, + self._last_response_time and time() - self._last_response_time) if self._account.isLoggedIn: self.setAuthenticationState(AuthState.Authenticated) self._api.getClusterStatus(self._device_id, self._onStatusCallFinished) @@ -183,6 +184,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): # Contains both printers and print jobs statuses in a single response. def _onStatusCallFinished(self, status: CloudClusterStatus) -> None: # Update all data from the cluster. + self._last_response_time = time() if self._received_printers != status.printers: self._received_printers = status.printers self._updatePrinters(status.printers) @@ -289,7 +291,8 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): ## Requests the print to be sent to the printer when we finished uploading the mesh. # \param job_id: The ID of the job. def _onPrintJobUploaded(self, job_id: str) -> None: - self._api.requestPrint(self._device_id, job_id, self._onUploadSuccess) + self._progress.update(100) + self._api.requestPrint(self._device_id, job_id, self._onPrintRequested) ## Displays the given message if uploading the mesh has failed # \param message: The message to display. @@ -304,7 +307,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): ## Shows a message when the upload has succeeded # \param response: The response from the cloud API. - def _onUploadSuccess(self, response: CloudPrintResponse) -> None: + def _onPrintRequested(self, response: CloudPrintResponse) -> None: Logger.log("i", "The cluster will be printing this print job with the ID %s", response.cluster_job_id) self._progress.hide() Message( @@ -312,7 +315,6 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): title = T.UPLOAD_SUCCESS_TITLE, lifetime = 5 ).show() - self._sending_job = False # the upload has finished so we're not sending a job anymore self.writeFinished.emit() ## Gets the remote printers. diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index c9b30d7c79..68b5f99bba 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -26,7 +26,7 @@ class CloudOutputDeviceManager: META_CLUSTER_ID = "um_cloud_cluster_id" # The interval with which the remote clusters are checked - CHECK_CLUSTER_INTERVAL = 5.0 # seconds + CHECK_CLUSTER_INTERVAL = 50.0 # seconds # The translation catalog for this device. I18N_CATALOG = i18nCatalog("cura") @@ -39,26 +39,22 @@ class CloudOutputDeviceManager: self._output_device_manager = application.getOutputDeviceManager() self._account = application.getCuraAPI().account # type: Account - self._account.loginStateChanged.connect(self._onLoginStateChanged) self._api = CloudApiClient(self._account, self._onApiError) - # When switching machines we check if we have to activate a remote cluster. - application.globalContainerStackChanged.connect(self._connectToActiveMachine) - # create a timer to update the remote cluster list self._update_timer = QTimer(application) self._update_timer.setInterval(int(self.CHECK_CLUSTER_INTERVAL * 1000)) self._update_timer.setSingleShot(False) - self._update_timer.timeout.connect(self._getRemoteClusters) - # Make sure the timer is started in case we missed the loginChanged signal - self._onLoginStateChanged(self._account.isLoggedIn) + self._running = False # Called when the uses logs in or out def _onLoginStateChanged(self, is_logged_in: bool) -> None: + Logger.log("i", "Log in state changed to %s", is_logged_in) if is_logged_in: if not self._update_timer.isActive(): self._update_timer.start() + self._getRemoteClusters() else: if self._update_timer.isActive(): self._update_timer.stop() @@ -136,3 +132,25 @@ class CloudOutputDeviceManager: lifetime = 10, dismissable = True ).show() + + def start(self): + if self._running: + return + application = CuraApplication.getInstance() + self._account.loginStateChanged.connect(self._onLoginStateChanged) + # When switching machines we check if we have to activate a remote cluster. + application.globalContainerStackChanged.connect(self._connectToActiveMachine) + self._update_timer.timeout.connect(self._getRemoteClusters) + self._api.start() + self._onLoginStateChanged(is_logged_in = self._account.isLoggedIn) + + def stop(self): + if not self._running: + return + application = CuraApplication.getInstance() + self._account.loginStateChanged.disconnect(self._onLoginStateChanged) + # When switching machines we check if we have to activate a remote cluster. + application.globalContainerStackChanged.disconnect(self._connectToActiveMachine) + self._update_timer.timeout.disconnect(self._getRemoteClusters) + self._api.stop() + self._onLoginStateChanged(is_logged_in = False) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/ResumableUpload.py b/plugins/UM3NetworkPrinting/src/Cloud/ResumableUpload.py index 52b8e5c2d7..e2052c33c8 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/ResumableUpload.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/ResumableUpload.py @@ -51,44 +51,69 @@ class ResumableUpload(NetworkClient): return self._sent_bytes, last_byte def start(self) -> None: + super().start() + if self._finished: + self._sent_bytes = 0 + self._retries = 0 + self._finished = False self._uploadChunk() + def stop(self): + super().stop() + Logger.log("i", "Stopped uploading") + self._finished = True + def _uploadChunk(self) -> None: if self._finished: raise ValueError("The upload is already finished") first_byte, last_byte = self._chunkRange() - Logger.log("i", "PUT %s - %s", first_byte, last_byte) - self.put(self._url, data = self._data[first_byte:last_byte], content_type = self._content_type, - on_finished = self.finishedCallback, on_progress = self.progressCallback) + # self.put(self._url, data = self._data[first_byte:last_byte], content_type = self._content_type, + # on_finished = self.finishedCallback, on_progress = self._progressCallback) + request = self._createEmptyRequest(self._url, content_type=self._content_type) - def progressCallback(self, bytes_sent: int, bytes_total: int) -> None: + reply = self._manager.put(request, self._data[first_byte:last_byte]) + reply.finished.connect(lambda: self._finishedCallback(reply)) + reply.uploadProgress.connect(self._progressCallback) + reply.error.connect(self._errorCallback) + if reply.isFinished(): + self._finishedCallback(reply) + + def _progressCallback(self, bytes_sent: int, bytes_total: int) -> None: + Logger.log("i", "Progress callback %s / %s", bytes_sent, bytes_total) if bytes_total: self._on_progress(int((self._sent_bytes + bytes_sent) / len(self._data) * 100)) - def finishedCallback(self, reply: QNetworkReply) -> None: + def _errorCallback(self, reply: QNetworkReply) -> None: + body = bytes(reply.readAll()).decode() + Logger.log("e", "Received error while uploading: %s", body) + self.stop() + self._on_error() + + def _finishedCallback(self, reply: QNetworkReply) -> None: + Logger.log("i", "Finished callback %s %s", + reply.attribute(QNetworkRequest.HttpStatusCodeAttribute), reply.url().toString()) + status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) if self._retries < self.MAX_RETRIES and status_code in self.RETRY_HTTP_CODES: self._retries += 1 - Logger.log("i", "Retrying %s/%s request %s", tries, self.MAX_RETRIES, request.url) + Logger.log("i", "Retrying %s/%s request %s", self._retries, self.MAX_RETRIES, reply.url().toString()) self._uploadChunk() return + if status_code > 308: + self._errorCallback(reply) + return + body = bytes(reply.readAll()).decode() Logger.log("w", "status_code: %s, Headers: %s, body: %s", status_code, [bytes(header).decode() for header in reply.rawHeaderList()], body) - if status_code > 308: - self._finished = True - Logger.log("e", "Received error while uploading: %s", body) - self._on_error() - return - first_byte, last_byte = self._chunkRange() self._sent_bytes += last_byte - first_byte - self._finished = self._sent_bytes >= len(self._data) - if self._finished: + if self._sent_bytes >= len(self._data): + self.stop() self._on_finished() else: self._uploadChunk() diff --git a/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py b/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py index 6a80ae046e..52fbf31e3c 100644 --- a/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py +++ b/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py @@ -86,6 +86,7 @@ class UM3OutputDevicePlugin(OutputDevicePlugin): ## Start looking for devices on network. def start(self): self.startDiscovery() + self._cloud_output_device_manager.start() def startDiscovery(self): self.stop() @@ -142,6 +143,7 @@ class UM3OutputDevicePlugin(OutputDevicePlugin): if self._zero_conf is not None: Logger.log("d", "zeroconf close...") self._zero_conf.close() + self._cloud_output_device_manager.stop() def removeManualDevice(self, key, address = None): if key in self._discovered_devices: From 2f08854097dcf3895e2d320752c8afa6a67296f1 Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Fri, 14 Dec 2018 14:50:15 +0100 Subject: [PATCH 117/178] STAR-322: Using QNetworkReply.finished signal instead of QNetworkAccessManager.finished --- cura/NetworkClient.py | 73 ++++++------------- .../src/Cloud/CloudApiClient.py | 32 +++++--- .../src/Cloud/CloudOutputDevice.py | 2 - .../src/Cloud/CloudOutputDeviceManager.py | 11 ++- .../src/Cloud/ResumableUpload.py | 54 +++++++------- .../tests/Cloud/NetworkManagerMock.py | 23 +++++- .../tests/Cloud/TestCloudApiClient.py | 55 ++++++-------- .../tests/Cloud/TestCloudOutputDevice.py | 21 +++--- .../Cloud/TestCloudOutputDeviceManager.py | 45 ++++++------ 9 files changed, 152 insertions(+), 164 deletions(-) diff --git a/cura/NetworkClient.py b/cura/NetworkClient.py index b455d03db0..4c43e58c4f 100644 --- a/cura/NetworkClient.py +++ b/cura/NetworkClient.py @@ -1,9 +1,9 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from time import time -from typing import Optional, Dict, Callable, List, Union, Tuple +from typing import Optional, Dict, Callable, List, Union -from PyQt5.QtCore import QUrl +from PyQt5.QtCore import QUrl, QObject from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkReply, QHttpMultiPart, QNetworkRequest, QHttpPart, \ QAuthenticator @@ -13,9 +13,10 @@ from UM.Logger import Logger ## Abstraction of QNetworkAccessManager for easier networking in Cura. # This was originally part of NetworkedPrinterOutputDevice but was moved out for re-use in other classes. -class NetworkClient: +class NetworkClient(QObject): def __init__(self) -> None: + super().__init__() # Network manager instance to use for this client. self._manager = None # type: Optional[QNetworkAccessManager] @@ -29,11 +30,6 @@ class NetworkClient: application = Application.getInstance() self._user_agent = "%s/%s " % (application.getApplicationName(), application.getVersion()) - # Uses to store callback methods for finished network requests. - # This allows us to register network calls with a callback directly instead of having to dissect the reply. - # The key is created out of a tuple (operation, url) - self._on_finished_callbacks = {} # type: Dict[Tuple[int, str], Callable[[QNetworkReply], None]] - # QHttpMultiPart objects need to be kept alive and not garbage collected during the # HTTP which uses them. We hold references to these QHttpMultiPart objects here. self._kept_alive_multiparts = {} # type: Dict[QNetworkReply, QHttpMultiPart] @@ -43,7 +39,6 @@ class NetworkClient: if self._manager: return self._manager = QNetworkAccessManager() - self._manager.finished.connect(self._handleOnFinished) self._last_manager_create_time = time() self._manager.authenticationRequired.connect(self._onAuthenticationRequired) @@ -51,7 +46,6 @@ class NetworkClient: def stop(self) -> None: if not self._manager: return - self._manager.finished.disconnect(self._handleOnFinished) self._manager.authenticationRequired.disconnect(self._onAuthenticationRequired) self._manager = None @@ -69,32 +63,6 @@ class NetworkClient: self._last_request_time = time() return request - ## Executes the correct callback method when a network request finishes. - def _handleOnFinished(self, reply: QNetworkReply) -> None: - - Logger.log("i", "On finished %s %s", reply.attribute(QNetworkRequest.HttpStatusCodeAttribute), reply.url().toString()) - - # Due to garbage collection, we need to cache certain bits of post operations. - # As we don't want to keep them around forever, delete them if we get a reply. - if reply.operation() == QNetworkAccessManager.PostOperation: - self._clearCachedMultiPart(reply) - - # No status code means it never even reached remote. - if reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) is None: - return - - # Not used by this class itself, but children might need it for better network handling. - # An example of this is the _update method in the NetworkedPrinterOutputDevice. - self._last_response_time = time() - - # Find the right callback and execute it. - # It always takes the full reply as single parameter. - callback_key = reply.operation(), reply.url().toString() - if callback_key in self._on_finished_callbacks: - self._on_finished_callbacks[callback_key](reply) - else: - Logger.log("w", "Received reply to URL %s but no callbacks are registered", reply.url()) - ## Removes all cached Multi-Part items. def _clearCachedMultiPart(self, reply: QNetworkReply) -> None: if reply in self._kept_alive_multiparts: @@ -105,12 +73,6 @@ class NetworkClient: def _onAuthenticationRequired(reply: QNetworkReply, authenticator: QAuthenticator) -> None: Logger.log("w", "Request to {} required authentication but was not given".format(reply.url().toString())) - ## Register a method to be executed when the associated network request finishes. - def _registerOnFinishedCallback(self, reply: QNetworkReply, - on_finished: Optional[Callable[[QNetworkReply], None]]) -> None: - if on_finished is not None: - self._on_finished_callbacks[reply.operation(), reply.url().toString()] = on_finished - ## Add a part to a Multi-Part form. @staticmethod def _createFormPart(content_header: str, data: bytes, content_type: Optional[str] = None) -> QHttpPart: @@ -144,13 +106,10 @@ class NetworkClient: body = data if isinstance(data, bytes) else data.encode() # type: bytes reply = self._manager.put(request, body) - self._registerOnFinishedCallback(reply, on_finished) - + callback = self._createCallback(reply, on_finished) + reply.finished.connect(callback) if on_progress is not None: - # TODO: Do we need to disconnect() as well? reply.uploadProgress.connect(on_progress) - reply.finished.connect(lambda r: Logger.log("i", "On finished %s %s", url, r)) - reply.error.connect(lambda r: Logger.log("i", "On error %s %s", url, r)) ## Sends a delete request to the given path. # url: The path after the API prefix. @@ -158,7 +117,8 @@ class NetworkClient: def delete(self, url: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None: request = self._createEmptyRequest(url) reply = self._manager.deleteResource(request) - self._registerOnFinishedCallback(reply, on_finished) + callback = self._createCallback(reply, on_finished) + reply.finished.connect(callback) ## Sends a get request to the given path. # \param url: The path after the API prefix. @@ -166,7 +126,8 @@ class NetworkClient: def get(self, url: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None: request = self._createEmptyRequest(url) reply = self._manager.get(request) - self._registerOnFinishedCallback(reply, on_finished) + callback = self._createCallback(reply, on_finished) + reply.finished.connect(callback) ## Sends a post request to the given path. # \param url: The path after the API prefix. @@ -180,9 +141,10 @@ class NetworkClient: body = data if isinstance(data, bytes) else data.encode() # type: bytes reply = self._manager.post(request, body) + callback = self._createCallback(reply, on_finished) + reply.finished.connect(callback) if on_progress is not None: reply.uploadProgress.connect(on_progress) - self._registerOnFinishedCallback(reply, on_finished) ## Does a POST request with form data to the given URL. def postForm(self, url: str, header_data: str, body_data: bytes, @@ -205,10 +167,19 @@ class NetworkClient: reply = self._manager.post(request, multi_post_part) + def callback(): + on_finished(reply) + self._clearCachedMultiPart(reply) + + reply.finished.connect(callback) + self._kept_alive_multiparts[reply] = multi_post_part if on_progress is not None: reply.uploadProgress.connect(on_progress) - self._registerOnFinishedCallback(reply, on_finished) return reply + + @staticmethod + def _createCallback(reply: QNetworkReply, on_finished: Optional[Callable[[QNetworkReply], None]] = None): + return lambda: on_finished(reply) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py index 2637f17010..7c3c08e044 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py @@ -4,11 +4,11 @@ import json from json import JSONDecodeError from typing import Callable, List, Type, TypeVar, Union, Optional, Tuple, Dict, Any -from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply +from PyQt5.QtCore import QObject, QUrl +from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply, QNetworkAccessManager from UM.Logger import Logger from cura.API import Account -from cura.NetworkClient import NetworkClient from .ResumableUpload import ResumableUpload from ..Models import BaseModel from .Models.CloudClusterResponse import CloudClusterResponse @@ -21,7 +21,7 @@ from .Models.CloudPrintJobResponse import CloudPrintJobResponse ## The cloud API client is responsible for handling the requests and responses from the cloud. # Each method should only handle models instead of exposing Any HTTP details. -class CloudApiClient(NetworkClient): +class CloudApiClient: # The cloud URL to use for this remote cluster. # TODO: Make sure that this URL goes to the live api before release @@ -34,6 +34,7 @@ class CloudApiClient(NetworkClient): # \param on_error: The callback to be called whenever we receive errors from the server. def __init__(self, account: Account, on_error: Callable[[List[CloudErrorObject]], None]) -> None: super().__init__() + self._manager = QNetworkAccessManager() self._account = account self._on_error = on_error @@ -46,14 +47,18 @@ class CloudApiClient(NetworkClient): # \param on_finished: The function to be called after the result is parsed. def getClusters(self, on_finished: Callable[[List[CloudClusterResponse]], Any]) -> None: url = "{}/clusters".format(self.CLUSTER_API_ROOT) - self.get(url, on_finished=self._wrapCallback(on_finished, CloudClusterResponse)) + reply = self._manager.get(self._createEmptyRequest(url)) + callback = self._wrapCallback(reply, on_finished, CloudClusterResponse) + reply.finished.connect(callback) ## Retrieves the status of the given cluster. # \param cluster_id: The ID of the cluster. # \param on_finished: The function to be called after the result is parsed. def getClusterStatus(self, cluster_id: str, on_finished: Callable[[CloudClusterStatus], Any]) -> None: url = "{}/clusters/{}/status".format(self.CLUSTER_API_ROOT, cluster_id) - self.get(url, on_finished=self._wrapCallback(on_finished, CloudClusterStatus)) + reply = self._manager.get(self._createEmptyRequest(url)) + callback = self._wrapCallback(reply, on_finished, CloudClusterStatus) + reply.finished.connect(callback) ## Requests the cloud to register the upload of a print job mesh. # \param request: The request object. @@ -62,7 +67,9 @@ class CloudApiClient(NetworkClient): ) -> None: url = "{}/jobs/upload".format(self.CURA_API_ROOT) body = json.dumps({"data": request.toDict()}) - self.put(url, body, on_finished=self._wrapCallback(on_finished, CloudPrintJobResponse)) + reply = self._manager.put(self._createEmptyRequest(url), body.encode()) + callback = self._wrapCallback(reply, on_finished, CloudPrintJobResponse) + reply.finished.connect(callback) ## Requests the cloud to register the upload of a print job mesh. # \param upload_response: The object received after requesting an upload with `self.requestUpload`. @@ -72,7 +79,7 @@ class CloudApiClient(NetworkClient): # \param on_error: A function to be called if the upload fails. It receives a dict with the error. def uploadMesh(self, upload_response: CloudPrintJobResponse, mesh: bytes, on_finished: Callable[[], Any], on_progress: Callable[[int], Any], on_error: Callable[[], Any]): - ResumableUpload(upload_response.upload_url, upload_response.content_type, mesh, on_finished, + ResumableUpload(self._manager, upload_response.upload_url, upload_response.content_type, mesh, on_finished, on_progress, on_error).start() # Requests a cluster to print the given print job. @@ -81,13 +88,17 @@ class CloudApiClient(NetworkClient): # \param on_finished: The function to be called after the result is parsed. def requestPrint(self, cluster_id: str, job_id: str, on_finished: Callable[[CloudPrintResponse], Any]) -> None: url = "{}/clusters/{}/print/{}".format(self.CLUSTER_API_ROOT, cluster_id, job_id) - self.post(url, data = "", on_finished=self._wrapCallback(on_finished, CloudPrintResponse)) + reply = self._manager.post(self._createEmptyRequest(url), b"") + callback = self._wrapCallback(reply, on_finished, CloudPrintResponse) + reply.finished.connect(callback) ## We override _createEmptyRequest in order to add the user credentials. # \param url: The URL to request # \param content_type: The type of the body contents. def _createEmptyRequest(self, path: str, content_type: Optional[str] = "application/json") -> QNetworkRequest: - request = super()._createEmptyRequest(path, content_type) + request = QNetworkRequest(QUrl(path)) + if content_type: + request.setHeader(QNetworkRequest.ContentTypeHeader, content_type) if self._account.isLoggedIn: request.setRawHeader(b"Authorization", "Bearer {}".format(self._account.accessToken).encode()) Logger.log("i", "Created request for URL %s. Logged in = %s", path, self._account.isLoggedIn) @@ -132,10 +143,11 @@ class CloudApiClient(NetworkClient): # \param model: The type of the model to convert the response to. It may either be a single record or a list. # \return: A function that can be passed to the def _wrapCallback(self, + reply: QNetworkReply, on_finished: Callable[[Union[Model, List[Model]]], Any], model: Type[Model], ) -> Callable[[QNetworkReply], None]: - def parse(reply: QNetworkReply) -> None: + def parse() -> None: status_code, response = self._parseReply(reply) return self._parseModels(response, on_finished, model) return parse diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index 83b5bed16b..09677d5e48 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -172,8 +172,6 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): if self._last_response_time and time() - self._last_response_time < self.CHECK_CLUSTER_INTERVAL: return # avoid calling the cloud too often - Logger.log("i", "Requesting update for %s after %s", self._device_id, - self._last_response_time and time() - self._last_response_time) if self._account.isLoggedIn: self.setAuthenticationState(AuthState.Authenticated) self._api.getClusterStatus(self._device_id, self._onStatusCallFinished) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 68b5f99bba..af80907f01 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -125,13 +125,14 @@ class CloudOutputDeviceManager: ## Handles an API error received from the cloud. # \param errors: The errors received def _onApiError(self, errors: List[CloudErrorObject]) -> None: - message = ". ".join(e.title for e in errors) # TODO: translate errors - Message( - text = message, + text = ". ".join(e.title for e in errors) # TODO: translate errors + message = Message( + text = text, title = self.I18N_CATALOG.i18nc("@info:title", "Error"), lifetime = 10, dismissable = True - ).show() + ) + message.show() def start(self): if self._running: @@ -141,7 +142,6 @@ class CloudOutputDeviceManager: # When switching machines we check if we have to activate a remote cluster. application.globalContainerStackChanged.connect(self._connectToActiveMachine) self._update_timer.timeout.connect(self._getRemoteClusters) - self._api.start() self._onLoginStateChanged(is_logged_in = self._account.isLoggedIn) def stop(self): @@ -152,5 +152,4 @@ class CloudOutputDeviceManager: # When switching machines we check if we have to activate a remote cluster. application.globalContainerStackChanged.disconnect(self._connectToActiveMachine) self._update_timer.timeout.disconnect(self._getRemoteClusters) - self._api.stop() self._onLoginStateChanged(is_logged_in = False) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/ResumableUpload.py b/plugins/UM3NetworkPrinting/src/Cloud/ResumableUpload.py index e2052c33c8..5e3bc9545e 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/ResumableUpload.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/ResumableUpload.py @@ -1,14 +1,14 @@ # Copyright (c) 2018 Ultimaker B.V. # !/usr/bin/env python # -*- coding: utf-8 -*- -from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply +from PyQt5.QtCore import QUrl +from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply, QNetworkAccessManager from typing import Optional, Callable, Any, Tuple from UM.Logger import Logger -from cura.NetworkClient import NetworkClient -class ResumableUpload(NetworkClient): +class ResumableUpload: MAX_RETRIES = 10 BYTES_PER_REQUEST = 256 * 1024 RETRY_HTTP_CODES = {500, 502, 503, 504} @@ -18,9 +18,9 @@ class ResumableUpload(NetworkClient): # \param content_length: The total content length of the file, in bytes. # \param http_method: The HTTP method to be used, e.g. "POST" or "PUT". # \param timeout: The timeout for each chunk upload. Important: If None, no timeout is applied at all. - def __init__(self, url: str, content_type: str, data: bytes, + def __init__(self, manager: QNetworkAccessManager, url: str, content_type: str, data: bytes, on_finished: Callable[[], Any], on_progress: Callable[[int], Any], on_error: Callable[[], Any]): - super().__init__() + self._manager = manager self._url = url self._content_type = content_type self._data = data @@ -32,13 +32,15 @@ class ResumableUpload(NetworkClient): self._sent_bytes = 0 self._retries = 0 self._finished = False + self._reply = None # type: Optional[QNetworkReply] - ## We override _createEmptyRequest in order to add the user credentials. + ## We override _createRequest in order to add the user credentials. # \param url: The URL to request # \param content_type: The type of the body contents. - def _createEmptyRequest(self, path: str, content_type: Optional[str] = "application/json") -> QNetworkRequest: - request = super()._createEmptyRequest(path, content_type = self._content_type) - + def _createRequest(self) -> QNetworkRequest: + request = QNetworkRequest(QUrl(self._url)) + request.setHeader(QNetworkRequest.ContentTypeHeader, self._content_type) + first_byte, last_byte = self._chunkRange() content_range = "bytes {}-{}/{}".format(first_byte, last_byte - 1, len(self._data)) request.setRawHeader(b"Content-Range", content_range.encode()) @@ -51,7 +53,6 @@ class ResumableUpload(NetworkClient): return self._sent_bytes, last_byte def start(self) -> None: - super().start() if self._finished: self._sent_bytes = 0 self._retries = 0 @@ -59,7 +60,6 @@ class ResumableUpload(NetworkClient): self._uploadChunk() def stop(self): - super().stop() Logger.log("i", "Stopped uploading") self._finished = True @@ -68,47 +68,43 @@ class ResumableUpload(NetworkClient): raise ValueError("The upload is already finished") first_byte, last_byte = self._chunkRange() - # self.put(self._url, data = self._data[first_byte:last_byte], content_type = self._content_type, - # on_finished = self.finishedCallback, on_progress = self._progressCallback) - request = self._createEmptyRequest(self._url, content_type=self._content_type) + request = self._createRequest() - reply = self._manager.put(request, self._data[first_byte:last_byte]) - reply.finished.connect(lambda: self._finishedCallback(reply)) - reply.uploadProgress.connect(self._progressCallback) - reply.error.connect(self._errorCallback) - if reply.isFinished(): - self._finishedCallback(reply) + self._reply = self._manager.put(request, self._data[first_byte:last_byte]) + self._reply.finished.connect(self._finishedCallback) + self._reply.uploadProgress.connect(self._progressCallback) + self._reply.error.connect(self._errorCallback) def _progressCallback(self, bytes_sent: int, bytes_total: int) -> None: Logger.log("i", "Progress callback %s / %s", bytes_sent, bytes_total) if bytes_total: self._on_progress(int((self._sent_bytes + bytes_sent) / len(self._data) * 100)) - def _errorCallback(self, reply: QNetworkReply) -> None: - body = bytes(reply.readAll()).decode() + def _errorCallback(self) -> None: + body = bytes(self._reply.readAll()).decode() Logger.log("e", "Received error while uploading: %s", body) self.stop() self._on_error() - def _finishedCallback(self, reply: QNetworkReply) -> None: + def _finishedCallback(self) -> None: Logger.log("i", "Finished callback %s %s", - reply.attribute(QNetworkRequest.HttpStatusCodeAttribute), reply.url().toString()) + self._reply.attribute(QNetworkRequest.HttpStatusCodeAttribute), self._reply.url().toString()) - status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) + status_code = self._reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) if self._retries < self.MAX_RETRIES and status_code in self.RETRY_HTTP_CODES: self._retries += 1 - Logger.log("i", "Retrying %s/%s request %s", self._retries, self.MAX_RETRIES, reply.url().toString()) + Logger.log("i", "Retrying %s/%s request %s", self._retries, self.MAX_RETRIES, self._reply.url().toString()) self._uploadChunk() return if status_code > 308: - self._errorCallback(reply) + self._errorCallback() return - body = bytes(reply.readAll()).decode() + body = bytes(self._reply.readAll()).decode() Logger.log("w", "status_code: %s, Headers: %s, body: %s", status_code, - [bytes(header).decode() for header in reply.rawHeaderList()], body) + [bytes(header).decode() for header in self._reply.rawHeaderList()], body) first_byte, last_byte = self._chunkRange() self._sent_bytes += last_byte - first_byte diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py b/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py index 60627cbe7c..59b79fdfa6 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py @@ -4,12 +4,27 @@ import json from typing import Dict, Tuple, Union, Optional from unittest.mock import MagicMock -from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest +from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest from UM.Logger import Logger from UM.Signal import Signal +class FakeSignal: + def __init__(self): + self._callbacks = [] + + def connect(self, callback): + self._callbacks.append(callback) + + def disconnect(self, callback): + self._callbacks.remove(callback) + + def emit(self, *args, **kwargs): + for callback in self._callbacks: + callback(*args, **kwargs) + + ## This class can be used to mock the QNetworkManager class and test the code using it. # After patching the QNetworkManager class, requests are prepared before they can be executed. # Any requests not prepared beforehand will cause KeyErrors. @@ -27,7 +42,7 @@ class NetworkManagerMock: ## Initializes the network manager mock. def __init__(self) -> None: # a dict with the prepared replies, using the format {(http_method, url): reply} - self.replies = {} # type: Dict[Tuple[str, str], QNetworkReply] + self.replies = {} # type: Dict[Tuple[str, str], MagicMock] self.request_bodies = {} # type: Dict[Tuple[str, str], bytes] # signals used in the network manager. @@ -64,6 +79,8 @@ class NetworkManagerMock: reply_mock.url().toString.return_value = url reply_mock.operation.return_value = self._OPERATIONS[method] reply_mock.attribute.return_value = status_code + reply_mock.finished = FakeSignal() + reply_mock.isFinished.return_value = False reply_mock.readAll.return_value = response if isinstance(response, bytes) else json.dumps(response).encode() self.replies[method, url] = reply_mock Logger.log("i", "Prepared mock {}-response to {} {}", status_code, method, url) @@ -78,6 +95,8 @@ class NetworkManagerMock: def flushReplies(self) -> None: for key, reply in self.replies.items(): Logger.log("i", "Flushing reply to {} {}", *key) + reply.isFinished.return_value = True + reply.finished.emit() self.finished.emit(reply) self.reset() diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py index e377627465..d4044726a3 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py @@ -8,6 +8,8 @@ from unittest.mock import patch, MagicMock from cura.CuraApplication import CuraApplication from src.Cloud.CloudApiClient import CloudApiClient +from src.Cloud.Models.CloudClusterResponse import CloudClusterResponse +from src.Cloud.Models.CloudClusterStatus import CloudClusterStatus from src.Cloud.Models.CloudPrintJobResponse import CloudPrintJobResponse from src.Cloud.Models.CloudPrintJobUploadRequest import CloudPrintJobUploadRequest from src.Cloud.Models.CloudErrorObject import CloudErrorObject @@ -15,7 +17,6 @@ from tests.Cloud.Fixtures import readFixture, parseFixture from .NetworkManagerMock import NetworkManagerMock -@patch("cura.NetworkClient.QNetworkAccessManager") class TestCloudApiClient(TestCase): def _errorHandler(self, errors: List[CloudErrorObject]): raise Exception("Received unexpected error: {}".format(errors)) @@ -27,15 +28,14 @@ class TestCloudApiClient(TestCase): self.app = CuraApplication.getInstance() self.network = NetworkManagerMock() - self.api = CloudApiClient(self.account, self._errorHandler) - - def test_GetClusters(self, network_mock): - network_mock.return_value = self.network + with patch("src.Cloud.CloudApiClient.QNetworkAccessManager", return_value = self.network): + self.api = CloudApiClient(self.account, self._errorHandler) + def test_getClusters(self): result = [] - with open("{}/Fixtures/getClusters.json".format(os.path.dirname(__file__)), "rb") as f: - response = f.read() + response = readFixture("getClusters") + data = parseFixture("getClusters")["data"] self.network.prepareReply("GET", "https://api-staging.ultimaker.com/connect/v1/clusters", 200, response) # the callback is a function that adds the result of the call to getClusters to the result list @@ -43,32 +43,26 @@ class TestCloudApiClient(TestCase): self.network.flushReplies() - self.assertEqual(2, len(result)) - - def test_getClusterStatus(self, network_mock): - network_mock.return_value = self.network + self.assertEqual([CloudClusterResponse(**data[0]), CloudClusterResponse(**data[1])], result) + def test_getClusterStatus(self): result = [] - with open("{}/Fixtures/getClusterStatusResponse.json".format(os.path.dirname(__file__)), "rb") as f: - response = f.read() + response = readFixture("getClusterStatusResponse") + data = parseFixture("getClusterStatusResponse")["data"] self.network.prepareReply("GET", "https://api-staging.ultimaker.com/connect/v1/clusters/R0YcLJwar1ugh0ikEZsZs8NWKV6vJP_LdYsXgXqAcaNC/status", 200, response ) - self.api.getClusterStatus("R0YcLJwar1ugh0ikEZsZs8NWKV6vJP_LdYsXgXqAcaNC", lambda status: result.append(status)) + self.api.getClusterStatus("R0YcLJwar1ugh0ikEZsZs8NWKV6vJP_LdYsXgXqAcaNC", lambda s: result.append(s)) self.network.flushReplies() - self.assertEqual(len(result), 1) - status = result[0] + self.assertEqual([CloudClusterStatus(**data)], result) - self.assertEqual(len(status.printers), 2) - self.assertEqual(len(status.print_jobs), 1) - - def test_requestUpload(self, network_mock): - network_mock.return_value = self.network + def test_requestUpload(self): + results = [] response = readFixture("putJobUploadResponse") @@ -78,11 +72,11 @@ class TestCloudApiClient(TestCase): self.api.requestUpload(request, lambda r: results.append(r)) self.network.flushReplies() - self.assertEqual(results[0].content_type, "text/plain") - self.assertEqual(results[0].status, "uploading") + self.assertEqual(["text/plain"], [r.content_type for r in results]) + self.assertEqual(["uploading"], [r.status for r in results]) - def test_uploadMesh(self, network_mock): - network_mock.return_value = self.network + def test_uploadMesh(self): + results = [] progress = MagicMock() @@ -101,8 +95,8 @@ class TestCloudApiClient(TestCase): self.assertEqual(["sent"], results) - def test_requestPrint(self, network_mock): - network_mock.return_value = self.network + def test_requestPrint(self): + results = [] response = readFixture("postJobPrintResponse") @@ -120,7 +114,6 @@ class TestCloudApiClient(TestCase): self.network.flushReplies() - self.assertEqual(len(results), 1) - self.assertEqual(results[0].job_id, job_id) - self.assertEqual(results[0].cluster_job_id, cluster_job_id) - self.assertEqual(results[0].status, "queued") + self.assertEqual([job_id], [r.job_id for r in results]) + self.assertEqual([cluster_job_id], [r.cluster_job_id for r in results]) + self.assertEqual(["queued"], [r.status for r in results]) diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py index 287f2dda98..c391dc75dd 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py @@ -13,7 +13,6 @@ from tests.Cloud.Fixtures import readFixture, parseFixture from .NetworkManagerMock import NetworkManagerMock -@patch("cura.NetworkClient.QNetworkAccessManager") class TestCloudOutputDevice(TestCase): CLUSTER_ID = "RIZ6cZbWA_Ua7RZVJhrdVfVpf0z-MqaSHQE4v8aRTtYq" JOB_ID = "ABCDefGHIjKlMNOpQrSTUvYxWZ0-1234567890abcDE=" @@ -30,7 +29,9 @@ class TestCloudOutputDevice(TestCase): self.network = NetworkManagerMock() self.account = MagicMock(isLoggedIn=True, accessToken="TestAccessToken") self.onError = MagicMock() - self.device = CloudOutputDevice(CloudApiClient(self.account, self.onError), self.CLUSTER_ID, self.HOST_NAME) + with patch("src.Cloud.CloudApiClient.QNetworkAccessManager", return_value = self.network): + self._api = CloudApiClient(self.account, self.onError) + self.device = CloudOutputDevice(self._api, self.CLUSTER_ID, self.HOST_NAME) self.cluster_status = parseFixture("getClusterStatusResponse") self.network.prepareReply("GET", self.STATUS_URL, 200, readFixture("getClusterStatusResponse")) @@ -38,8 +39,7 @@ class TestCloudOutputDevice(TestCase): super().tearDown() self.network.flushReplies() - def test_status(self, network_mock): - network_mock.return_value = self.network + def test_status(self): self.device._update() self.network.flushReplies() @@ -69,32 +69,34 @@ class TestCloudOutputDevice(TestCase): self.assertEqual({job["name"] for job in self.cluster_status["data"]["print_jobs"]}, {job.name for job in self.device.printJobs}) - def test_remove_print_job(self, network_mock): - network_mock.return_value = self.network + def test_remove_print_job(self): self.device._update() self.network.flushReplies() self.assertEqual(1, len(self.device.printJobs)) self.cluster_status["data"]["print_jobs"].clear() self.network.prepareReply("GET", self.STATUS_URL, 200, self.cluster_status) + + self.device._last_response_time = None self.device._update() self.network.flushReplies() self.assertEqual([], self.device.printJobs) - def test_remove_printers(self, network_mock): - network_mock.return_value = self.network + def test_remove_printers(self): self.device._update() self.network.flushReplies() self.assertEqual(2, len(self.device.printers)) self.cluster_status["data"]["printers"].clear() self.network.prepareReply("GET", self.STATUS_URL, 200, self.cluster_status) + + self.device._last_response_time = None self.device._update() self.network.flushReplies() self.assertEqual([], self.device.printers) @patch("cura.CuraApplication.CuraApplication.getGlobalContainerStack") - def test_print_to_cloud(self, global_container_stack_mock, network_mock): + def test_print_to_cloud(self, global_container_stack_mock): active_machine_mock = global_container_stack_mock.return_value active_machine_mock.getMetaDataEntry.side_effect = {"file_formats": "application/gzip"}.get @@ -104,7 +106,6 @@ class TestCloudOutputDevice(TestCase): self.network.prepareReply("PUT", request_upload_response["data"]["upload_url"], 201, b"{}") self.network.prepareReply("POST", self.PRINT_URL, 200, request_print_response) - network_mock.return_value = self.network file_handler = MagicMock() file_handler.getSupportedFileTypesWrite.return_value = [{ "extension": "gcode.gz", diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py index b6bcde6e55..80dd2c7990 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py @@ -10,7 +10,6 @@ from tests.Cloud.Fixtures import parseFixture, readFixture from .NetworkManagerMock import NetworkManagerMock -@patch("cura.NetworkClient.QNetworkAccessManager") class TestCloudOutputDeviceManager(TestCase): URL = "https://api-staging.ultimaker.com/connect/v1/clusters" @@ -18,13 +17,15 @@ class TestCloudOutputDeviceManager(TestCase): super().setUp() self.app = CuraApplication.getInstance() self.network = NetworkManagerMock() - self.manager = CloudOutputDeviceManager() + with patch("src.Cloud.CloudApiClient.QNetworkAccessManager", return_value = self.network): + self.manager = CloudOutputDeviceManager() self.clusters_response = parseFixture("getClusters") self.network.prepareReply("GET", self.URL, 200, readFixture("getClusters")) def tearDown(self): try: self._beforeTearDown() + self.manager.stop() finally: super().tearDown() @@ -47,17 +48,17 @@ class TestCloudOutputDeviceManager(TestCase): device_manager.removeOutputDevice(device["cluster_id"]) ## Runs the initial request to retrieve the clusters. - def _loadData(self, network_mock): - network_mock.return_value = self.network - self.manager._account.loginStateChanged.emit(True) - self.manager._update_timer.timeout.emit() + def _loadData(self): + self.manager.start() + self.manager._onLoginStateChanged(is_logged_in = True) + self.network.flushReplies() - def test_device_is_created(self, network_mock): + def test_device_is_created(self): # just create the cluster, it is checked at tearDown - self._loadData(network_mock) + self._loadData() - def test_device_is_updated(self, network_mock): - self._loadData(network_mock) + def test_device_is_updated(self): + self._loadData() # update the cluster from member variable, which is checked at tearDown self.clusters_response["data"][0]["host_name"] = "New host name" @@ -65,8 +66,8 @@ class TestCloudOutputDeviceManager(TestCase): self.manager._update_timer.timeout.emit() - def test_device_is_removed(self, network_mock): - self._loadData(network_mock) + def test_device_is_removed(self): + self._loadData() # delete the cluster from member variable, which is checked at tearDown del self.clusters_response["data"][1] @@ -75,41 +76,39 @@ class TestCloudOutputDeviceManager(TestCase): self.manager._update_timer.timeout.emit() @patch("cura.CuraApplication.CuraApplication.getGlobalContainerStack") - def test_device_connects_by_cluster_id(self, global_container_stack_mock, network_mock): + def test_device_connects_by_cluster_id(self, global_container_stack_mock): active_machine_mock = global_container_stack_mock.return_value cluster1, cluster2 = self.clusters_response["data"] cluster_id = cluster1["cluster_id"] active_machine_mock.getMetaDataEntry.side_effect = {"um_cloud_cluster_id": cluster_id}.get - self._loadData(network_mock) - self.network.flushReplies() + self._loadData() self.assertTrue(self.app.getOutputDeviceManager().getOutputDevice(cluster1["cluster_id"]).isConnected()) self.assertFalse(self.app.getOutputDeviceManager().getOutputDevice(cluster2["cluster_id"]).isConnected()) self.assertEquals([], active_machine_mock.setMetaDataEntry.mock_calls) @patch("cura.CuraApplication.CuraApplication.getGlobalContainerStack") - def test_device_connects_by_network_key(self, global_container_stack_mock, network_mock): + def test_device_connects_by_network_key(self, global_container_stack_mock): active_machine_mock = global_container_stack_mock.return_value cluster1, cluster2 = self.clusters_response["data"] network_key = cluster2["host_name"] + ".ultimaker.local" active_machine_mock.getMetaDataEntry.side_effect = {"um_network_key": network_key}.get - self._loadData(network_mock) - self.network.flushReplies() + self._loadData() self.assertFalse(self.app.getOutputDeviceManager().getOutputDevice(cluster1["cluster_id"]).isConnected()) self.assertTrue(self.app.getOutputDeviceManager().getOutputDevice(cluster2["cluster_id"]).isConnected()) active_machine_mock.setMetaDataEntry.assert_called_with("um_cloud_cluster_id", cluster2["cluster_id"]) - @patch("UM.Message.Message.show") - def test_api_error(self, message_mock, network_mock): + @patch("src.Cloud.CloudOutputDeviceManager.Message") + def test_api_error(self, message_mock): self.clusters_response = { "errors": [{"id": "notFound", "title": "Not found!", "http_status": "404", "code": "notFound"}] } self.network.prepareReply("GET", self.URL, 200, self.clusters_response) - self._loadData(network_mock) - self.network.flushReplies() - message_mock.assert_called_once_with() + self._loadData() + message_mock.assert_called_once_with(text='Not found!', title='Error', lifetime=10, dismissable=True) + message_mock.return_value.show.assert_called_once_with() From e815d5da8f73a939cd4a1e028fea44c65f987cc8 Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Fri, 14 Dec 2018 16:02:28 +0100 Subject: [PATCH 118/178] STAR-322: Avoiding lambdas and direct callbacks to avoid gc --- cura/NetworkClient.py | 4 +- .../src/Cloud/CloudApiClient.py | 11 +++-- .../src/Cloud/CloudOutputDevice.py | 46 +++++++++++++++---- .../{ResumableUpload.py => MeshUploader.py} | 18 +++++--- .../tests/Cloud/TestCloudOutputDevice.py | 4 ++ 5 files changed, 59 insertions(+), 24 deletions(-) rename plugins/UM3NetworkPrinting/src/Cloud/{ResumableUpload.py => MeshUploader.py} (91%) diff --git a/cura/NetworkClient.py b/cura/NetworkClient.py index 4c43e58c4f..878158542a 100644 --- a/cura/NetworkClient.py +++ b/cura/NetworkClient.py @@ -3,7 +3,7 @@ from time import time from typing import Optional, Dict, Callable, List, Union -from PyQt5.QtCore import QUrl, QObject +from PyQt5.QtCore import QUrl from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkReply, QHttpMultiPart, QNetworkRequest, QHttpPart, \ QAuthenticator @@ -13,7 +13,7 @@ from UM.Logger import Logger ## Abstraction of QNetworkAccessManager for easier networking in Cura. # This was originally part of NetworkedPrinterOutputDevice but was moved out for re-use in other classes. -class NetworkClient(QObject): +class NetworkClient: def __init__(self) -> None: super().__init__() diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py index 7c3c08e044..8cdedd1229 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py @@ -4,12 +4,12 @@ import json from json import JSONDecodeError from typing import Callable, List, Type, TypeVar, Union, Optional, Tuple, Dict, Any -from PyQt5.QtCore import QObject, QUrl +from PyQt5.QtCore import QUrl from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply, QNetworkAccessManager from UM.Logger import Logger from cura.API import Account -from .ResumableUpload import ResumableUpload +from .MeshUploader import MeshUploader from ..Models import BaseModel from .Models.CloudClusterResponse import CloudClusterResponse from .Models.CloudErrorObject import CloudErrorObject @@ -37,6 +37,7 @@ class CloudApiClient: self._manager = QNetworkAccessManager() self._account = account self._on_error = on_error + self._upload = None # type: Optional[MeshUploader] ## Gets the account used for the API. @property @@ -77,10 +78,10 @@ class CloudApiClient: # \param on_finished: The function to be called after the result is parsed. It receives the print job ID. # \param on_progress: A function to be called during upload progress. It receives a percentage (0-100). # \param on_error: A function to be called if the upload fails. It receives a dict with the error. - def uploadMesh(self, upload_response: CloudPrintJobResponse, mesh: bytes, on_finished: Callable[[], Any], + def uploadMesh(self, print_job: CloudPrintJobResponse, mesh: bytes, on_finished: Callable[[], Any], on_progress: Callable[[int], Any], on_error: Callable[[], Any]): - ResumableUpload(self._manager, upload_response.upload_url, upload_response.content_type, mesh, on_finished, - on_progress, on_error).start() + self._upload = MeshUploader(self._manager, print_job, mesh, on_finished, on_progress, on_error) + self._upload.start() # Requests a cluster to print the given print job. # \param cluster_id: The ID of the cluster. diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index 09677d5e48..88c2f8da1d 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -8,11 +8,13 @@ from typing import Dict, List, Optional, Set from PyQt5.QtCore import QObject, QUrl, pyqtProperty, pyqtSignal, pyqtSlot from UM import i18nCatalog +from UM.Backend.Backend import BackendState from UM.FileHandler.FileHandler import FileHandler from UM.Logger import Logger from UM.Message import Message from UM.Qt.Duration import Duration, DurationFormat from UM.Scene.SceneNode import SceneNode +from cura.CuraApplication import CuraApplication from cura.PrinterOutput.NetworkedPrinterOutputDevice import AuthState, NetworkedPrinterOutputDevice from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel from plugins.UM3NetworkPrinting.src.Cloud.CloudOutputController import CloudOutputController @@ -92,6 +94,8 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): self._device_id = device_id self._account = api_client.account + CuraApplication.getInstance().getBackend().backendStateChange.connect(self._onBackendStateChange) + # We use the Cura Connect monitor tab to get most functionality right away. self._monitor_view_qml_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "../../resources/qml/MonitorStage.qml") @@ -116,6 +120,17 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): # A set of the user's job IDs that have finished self._finished_jobs = set() # type: Set[str] + # Reference to the uploaded print job + self._mesh = None # type: Optional[bytes] + self._uploaded_print_job = None # type: Optional[CloudPrintJobResponse] + + def disconnect(self) -> None: + CuraApplication.getInstance().getBackend().backendStateChange.disconnect(self._onBackendStateChange) + + def _onBackendStateChange(self, _: BackendState) -> None: + self._mesh = None + self._uploaded_print_job = None + ## Gets the host name of this device @property def host_name(self) -> str: @@ -146,7 +161,16 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): # Show an error message if we're already sending a job. if self._progress.visible: - self._onUploadError(T.BLOCKED_UPLOADING) + Message( + text = T.BLOCKED_UPLOADING, + title = T.ERROR, + lifetime = 10, + ).show() + return + + if self._uploaded_print_job: + # the mesh didn't change, let's not upload it again + self._api.requestPrint(self._device_id, self._uploaded_print_job.job_id, self._onPrintRequested) return # Indicate we have started sending a job. @@ -157,14 +181,15 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): Logger.log("e", "Missing file or mesh writer!") return self._onUploadError(T.COULD_NOT_EXPORT) - mesh_bytes = mesh_format.getBytes(nodes) + mesh = mesh_format.getBytes(nodes) + self._mesh = mesh request = CloudPrintJobUploadRequest( job_name = file_name, - file_size = len(mesh_bytes), + file_size = len(mesh), content_type = mesh_format.mime_type, ) - self._api.requestUpload(request, lambda response: self._onPrintJobCreated(mesh_bytes, response)) + self._api.requestUpload(request, self._onPrintJobCreated) ## Called when the network data should be updated. def _update(self) -> None: @@ -281,21 +306,22 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): ## Uploads the mesh when the print job was registered with the cloud API. # \param mesh: The bytes to upload. # \param job_response: The response received from the cloud API. - def _onPrintJobCreated(self, mesh: bytes, job_response: CloudPrintJobResponse) -> None: + def _onPrintJobCreated(self, job_response: CloudPrintJobResponse) -> None: self._progress.show() - self._api.uploadMesh(job_response, mesh, lambda: self._onPrintJobUploaded(job_response.job_id), - self._progress.update, self._onUploadError) + self._uploaded_print_job = job_response + self._api.uploadMesh(job_response, self._mesh, self._onPrintJobUploaded, self._progress.update, + self._onUploadError) ## Requests the print to be sent to the printer when we finished uploading the mesh. - # \param job_id: The ID of the job. - def _onPrintJobUploaded(self, job_id: str) -> None: + def _onPrintJobUploaded(self) -> None: self._progress.update(100) - self._api.requestPrint(self._device_id, job_id, self._onPrintRequested) + self._api.requestPrint(self._device_id, self._uploaded_print_job.job_id, self._onPrintRequested) ## Displays the given message if uploading the mesh has failed # \param message: The message to display. def _onUploadError(self, message = None) -> None: self._progress.hide() + self._uploaded_print_job = None Message( text = message or T.UPLOAD_ERROR, title = T.ERROR, diff --git a/plugins/UM3NetworkPrinting/src/Cloud/ResumableUpload.py b/plugins/UM3NetworkPrinting/src/Cloud/MeshUploader.py similarity index 91% rename from plugins/UM3NetworkPrinting/src/Cloud/ResumableUpload.py rename to plugins/UM3NetworkPrinting/src/Cloud/MeshUploader.py index 5e3bc9545e..4f0d6f2e81 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/ResumableUpload.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/MeshUploader.py @@ -6,9 +6,10 @@ from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply, QNetworkAccessManage from typing import Optional, Callable, Any, Tuple from UM.Logger import Logger +from src.Cloud.Models.CloudPrintJobResponse import CloudPrintJobResponse -class ResumableUpload: +class MeshUploader: MAX_RETRIES = 10 BYTES_PER_REQUEST = 256 * 1024 RETRY_HTTP_CODES = {500, 502, 503, 504} @@ -18,11 +19,10 @@ class ResumableUpload: # \param content_length: The total content length of the file, in bytes. # \param http_method: The HTTP method to be used, e.g. "POST" or "PUT". # \param timeout: The timeout for each chunk upload. Important: If None, no timeout is applied at all. - def __init__(self, manager: QNetworkAccessManager, url: str, content_type: str, data: bytes, + def __init__(self, manager: QNetworkAccessManager, print_job: CloudPrintJobResponse, data: bytes, on_finished: Callable[[], Any], on_progress: Callable[[int], Any], on_error: Callable[[], Any]): self._manager = manager - self._url = url - self._content_type = content_type + self._print_job = print_job self._data = data self._on_finished = on_finished @@ -34,17 +34,21 @@ class ResumableUpload: self._finished = False self._reply = None # type: Optional[QNetworkReply] + @property + def printJob(self): + return self._print_job + ## We override _createRequest in order to add the user credentials. # \param url: The URL to request # \param content_type: The type of the body contents. def _createRequest(self) -> QNetworkRequest: - request = QNetworkRequest(QUrl(self._url)) - request.setHeader(QNetworkRequest.ContentTypeHeader, self._content_type) + request = QNetworkRequest(QUrl(self._print_job.upload_url)) + request.setHeader(QNetworkRequest.ContentTypeHeader, self._print_job.content_type) first_byte, last_byte = self._chunkRange() content_range = "bytes {}-{}/{}".format(first_byte, last_byte - 1, len(self._data)) request.setRawHeader(b"Content-Range", content_range.encode()) - Logger.log("i", "Uploading %s to %s", content_range, self._url) + Logger.log("i", "Uploading %s to %s", content_range, self._print_job.upload_url) return request diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py index c391dc75dd..d31f59f85a 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py @@ -5,6 +5,7 @@ from unittest import TestCase from unittest.mock import patch, MagicMock from UM.Scene.SceneNode import SceneNode +from UM.Signal import Signal from cura.CuraApplication import CuraApplication from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel from src.Cloud.CloudApiClient import CloudApiClient @@ -26,6 +27,9 @@ class TestCloudOutputDevice(TestCase): def setUp(self): super().setUp() self.app = CuraApplication.getInstance() + self.backend = MagicMock(backendStateChange = Signal()) + self.app.setBackend(self.backend) + self.network = NetworkManagerMock() self.account = MagicMock(isLoggedIn=True, accessToken="TestAccessToken") self.onError = MagicMock() From 9f4b7bd70362a3f7c77ebda0ee77d52ee68ca945 Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Mon, 17 Dec 2018 11:28:16 +0100 Subject: [PATCH 119/178] STAR-322: Improving logging and cluster connection --- cura/OAuth2/AuthorizationService.py | 3 +- .../src/Cloud/CloudApiClient.py | 29 ++++++++------ .../src/Cloud/CloudOutputDevice.py | 40 +++++++++++-------- .../src/Cloud/CloudOutputDeviceManager.py | 11 +++-- .../src/ClusterUM3OutputDevice.py | 2 +- .../src/ClusterUM3PrinterOutputController.py | 1 - 6 files changed, 51 insertions(+), 35 deletions(-) diff --git a/cura/OAuth2/AuthorizationService.py b/cura/OAuth2/AuthorizationService.py index 21dbbe8248..0c3074f3d5 100644 --- a/cura/OAuth2/AuthorizationService.py +++ b/cura/OAuth2/AuthorizationService.py @@ -52,7 +52,8 @@ class AuthorizationService: if not self._user_profile: # If no user profile was stored locally, we try to get it from JWT. self._user_profile = self._parseJWT() - if not self._user_profile: + + if not self._user_profile and self._auth_data: # If there is still no user profile from the JWT, we have to log in again. Logger.log("w", "The user profile could not be loaded. The user must log in again!") self.deleteAuthData() diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py index b54c9e97d6..5fd14efc9c 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py @@ -2,6 +2,7 @@ # Cura is released under the terms of the LGPLv3 or higher. import json from json import JSONDecodeError +from time import time from typing import Callable, List, Type, TypeVar, Union, Optional, Tuple, Dict, Any from PyQt5.QtCore import QUrl @@ -38,6 +39,8 @@ class CloudApiClient: self._account = account self._on_error = on_error self._upload = None # type: Optional[MeshUploader] + # in order to avoid garbage collection we keep the callbacks in this list. + self._anti_gc_callbacks = [] # type: List[Callable[[QNetworkReply], None]] ## Gets the account used for the API. @property @@ -49,8 +52,7 @@ class CloudApiClient: def getClusters(self, on_finished: Callable[[List[CloudClusterResponse]], Any]) -> None: url = "{}/clusters".format(self.CLUSTER_API_ROOT) reply = self._manager.get(self._createEmptyRequest(url)) - callback = self._wrapCallback(reply, on_finished, CloudClusterResponse) - reply.finished.connect(callback) + self._addCallbacks(reply, on_finished, CloudClusterResponse) ## Retrieves the status of the given cluster. # \param cluster_id: The ID of the cluster. @@ -58,8 +60,7 @@ class CloudApiClient: def getClusterStatus(self, cluster_id: str, on_finished: Callable[[CloudClusterStatus], Any]) -> None: url = "{}/clusters/{}/status".format(self.CLUSTER_API_ROOT, cluster_id) reply = self._manager.get(self._createEmptyRequest(url)) - callback = self._wrapCallback(reply, on_finished, CloudClusterStatus) - reply.finished.connect(callback) + self._addCallbacks(reply, on_finished, CloudClusterStatus) ## Requests the cloud to register the upload of a print job mesh. # \param request: The request object. @@ -69,8 +70,7 @@ class CloudApiClient: url = "{}/jobs/upload".format(self.CURA_API_ROOT) body = json.dumps({"data": request.toDict()}) reply = self._manager.put(self._createEmptyRequest(url), body.encode()) - callback = self._wrapCallback(reply, on_finished, CloudPrintJobResponse) - reply.finished.connect(callback) + self._addCallbacks(reply, on_finished, CloudPrintJobResponse) ## Requests the cloud to register the upload of a print job mesh. # \param upload_response: The object received after requesting an upload with `self.requestUpload`. @@ -90,8 +90,7 @@ class CloudApiClient: def requestPrint(self, cluster_id: str, job_id: str, on_finished: Callable[[CloudPrintResponse], Any]) -> None: url = "{}/clusters/{}/print/{}".format(self.CLUSTER_API_ROOT, cluster_id, job_id) reply = self._manager.post(self._createEmptyRequest(url), b"") - callback = self._wrapCallback(reply, on_finished, CloudPrintResponse) - reply.finished.connect(callback) + self._addCallbacks(reply, on_finished, CloudPrintResponse) ## We override _createEmptyRequest in order to add the user credentials. # \param url: The URL to request @@ -116,9 +115,10 @@ class CloudApiClient: Logger.log("i", "Received a reply %s from %s with %s", status_code, reply.url().toString(), response) return status_code, json.loads(response) except (UnicodeDecodeError, JSONDecodeError, ValueError) as err: - error = {"code": type(err).__name__, "title": str(err), "http_code": str(status_code)} + error = CloudErrorObject(code=type(err).__name__, title=str(err), http_code=str(status_code), + id=str(time()), http_status="500") Logger.logException("e", "Could not parse the stardust response: %s", error) - return status_code, {"errors": [error]} + return status_code, {"errors": [error.toDict()]} ## The generic type variable used to document the methods below. Model = TypeVar("Model", bound=BaseModel) @@ -143,12 +143,15 @@ class CloudApiClient: # \param on_finished: The callback in case the response is successful. # \param model: The type of the model to convert the response to. It may either be a single record or a list. # \return: A function that can be passed to the - def _wrapCallback(self, + def _addCallbacks(self, reply: QNetworkReply, on_finished: Callable[[Union[Model, List[Model]]], Any], model: Type[Model], - ) -> Callable[[QNetworkReply], None]: + ) -> None: def parse() -> None: status_code, response = self._parseReply(reply) + self._anti_gc_callbacks.remove(parse) return self._parseModels(response, on_finished, model) - return parse + + self._anti_gc_callbacks.append(parse) + reply.finished.connect(parse) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index 88c2f8da1d..3890e7cee2 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -18,6 +18,7 @@ from cura.CuraApplication import CuraApplication from cura.PrinterOutput.NetworkedPrinterOutputDevice import AuthState, NetworkedPrinterOutputDevice from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel from plugins.UM3NetworkPrinting.src.Cloud.CloudOutputController import CloudOutputController +from src.Cloud.Models.CloudClusterResponse import CloudClusterResponse from ..MeshFormatHandler import MeshFormatHandler from ..UM3PrintJobOutputModel import UM3PrintJobOutputModel from .CloudProgressMessage import CloudProgressMessage @@ -84,18 +85,15 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): # \param api_client: The client that will run the API calls # \param device_id: The ID of the device (i.e. the cluster_id for the cloud API) # \param parent: The optional parent of this output device. - def __init__(self, api_client: CloudApiClient, device_id: str, host_name: str, parent: QObject = None) -> None: - super().__init__(device_id = device_id, address = "", properties = {}, parent = parent) + def __init__(self, api_client: CloudApiClient, cluster: CloudClusterResponse, parent: QObject = None) -> None: + super().__init__(device_id = cluster.cluster_id, address = "", properties = {}, parent = parent) self._api = api_client - self._host_name = host_name + self._cluster = cluster self._setInterfaceElements() - self._device_id = device_id self._account = api_client.account - CuraApplication.getInstance().getBackend().backendStateChange.connect(self._onBackendStateChange) - # We use the Cura Connect monitor tab to get most functionality right away. self._monitor_view_qml_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "../../resources/qml/MonitorStage.qml") @@ -124,7 +122,14 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): self._mesh = None # type: Optional[bytes] self._uploaded_print_job = None # type: Optional[CloudPrintJobResponse] + def connect(self) -> None: + super().connect() + Logger.log("i", "Connected to cluster %s", self.key) + CuraApplication.getInstance().getBackend().backendStateChange.connect(self._onBackendStateChange) + def disconnect(self) -> None: + super().disconnect() + Logger.log("i", "Disconnected to cluster %s", self.key) CuraApplication.getInstance().getBackend().backendStateChange.disconnect(self._onBackendStateChange) def _onBackendStateChange(self, _: BackendState) -> None: @@ -133,19 +138,19 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): ## Gets the host name of this device @property - def host_name(self) -> str: - return self._host_name + def clusterData(self) -> CloudClusterResponse: + return self._cluster ## Updates the host name of the output device - @host_name.setter - def host_name(self, value: str) -> None: - self._host_name = value + @clusterData.setter + def clusterData(self, value: CloudClusterResponse) -> None: + self._cluster = value ## Checks whether the given network key is found in the cloud's host name def matchesNetworkKey(self, network_key: str) -> bool: # A network key looks like "ultimakersystem-aabbccdd0011._ultimaker._tcp.local." # the host name should then be "ultimakersystem-aabbccdd0011" - return network_key.startswith(self._host_name) + return network_key.startswith(self.clusterData.host_name) ## Set all the interface elements and texts for this output device. def _setInterfaceElements(self) -> None: @@ -170,7 +175,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): if self._uploaded_print_job: # the mesh didn't change, let's not upload it again - self._api.requestPrint(self._device_id, self._uploaded_print_job.job_id, self._onPrintRequested) + self._api.requestPrint(self.key, self._uploaded_print_job.job_id, self._onPrintRequested) return # Indicate we have started sending a job. @@ -194,12 +199,15 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): ## Called when the network data should be updated. def _update(self) -> None: super()._update() - if self._last_response_time and time() - self._last_response_time < self.CHECK_CLUSTER_INTERVAL: + if self._last_request_time and time() - self._last_request_time < self.CHECK_CLUSTER_INTERVAL: + Logger.log("i", "Not updating: %s - %s < %s", time(), self._last_request_time, self.CHECK_CLUSTER_INTERVAL) return # avoid calling the cloud too often + Logger.log("i", "Updating: %s - %s >= %s", time(), self._last_request_time, self.CHECK_CLUSTER_INTERVAL) if self._account.isLoggedIn: self.setAuthenticationState(AuthState.Authenticated) - self._api.getClusterStatus(self._device_id, self._onStatusCallFinished) + self._last_request_time = time() + self._api.getClusterStatus(self.key, self._onStatusCallFinished) else: self.setAuthenticationState(AuthState.NotAuthenticated) @@ -315,7 +323,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): ## Requests the print to be sent to the printer when we finished uploading the mesh. def _onPrintJobUploaded(self) -> None: self._progress.update(100) - self._api.requestPrint(self._device_id, self._uploaded_print_job.job_id, self._onPrintRequested) + self._api.requestPrint(self.key, self._uploaded_print_job.job_id, self._onPrintRequested) ## Displays the given message if uploading the mesh has failed # \param message: The message to display. diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index af80907f01..29c60fd14a 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -73,7 +73,8 @@ class CloudOutputDeviceManager: removed_devices, added_clusters, updates = findChanges(self._remote_clusters, online_clusters) - Logger.log("i", "Parsed remote clusters to %s", online_clusters) + Logger.log("i", "Parsed remote clusters to %s", [cluster.toDict() for cluster in online_clusters.values()]) + Logger.log("i", "Removed: %s, added: %s, updates: %s", len(removed_devices), len(added_clusters), len(updates)) # Remove output devices that are gone for removed_cluster in removed_devices: @@ -86,12 +87,12 @@ class CloudOutputDeviceManager: # Add an output device for each new remote cluster. # We only add when is_online as we don't want the option in the drop down if the cluster is not online. for added_cluster in added_clusters: - device = CloudOutputDevice(self._api, added_cluster.cluster_id, added_cluster.host_name) + device = CloudOutputDevice(self._api, added_cluster) self._output_device_manager.addOutputDevice(device) self._remote_clusters[added_cluster.cluster_id] = device for device, cluster in updates: - device.host_name = cluster.host_name + device.clusterData = cluster self._connectToActiveMachine() @@ -99,6 +100,7 @@ class CloudOutputDeviceManager: def _connectToActiveMachine(self) -> None: active_machine = CuraApplication.getInstance().getGlobalContainerStack() if not active_machine: + Logger.log("i", "no active machine") return # Check if the stored cluster_id for the active machine is in our list of remote clusters. @@ -107,6 +109,7 @@ class CloudOutputDeviceManager: device = self._remote_clusters[stored_cluster_id] if not device.isConnected(): device.connect() + Logger.log("i", "Device connected by metadata %s", stored_cluster_id) else: self._connectByNetworkKey(active_machine) @@ -122,6 +125,8 @@ class CloudOutputDeviceManager: active_machine.setMetaDataEntry(self.META_CLUSTER_ID, device.key) device.connect() + Logger.log("i", "Found cluster %s with network key %s", device, local_network_key) + ## Handles an API error received from the cloud. # \param errors: The errors received def _onApiError(self, errors: List[CloudErrorObject]) -> None: diff --git a/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py b/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py index 96fee0d96d..1896ffac9c 100644 --- a/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py @@ -390,10 +390,10 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): ## Called when the connection to the cluster changes. def connect(self) -> None: - pass # TODO: uncomment this once cloud implementation works for testing # super().connect() # self.sendMaterialProfiles() + pass def _onGetPreviewImageFinished(self, reply: QNetworkReply) -> None: reply_url = reply.url().toString() diff --git a/plugins/UM3NetworkPrinting/src/ClusterUM3PrinterOutputController.py b/plugins/UM3NetworkPrinting/src/ClusterUM3PrinterOutputController.py index fcced0b883..fc6798386a 100644 --- a/plugins/UM3NetworkPrinting/src/ClusterUM3PrinterOutputController.py +++ b/plugins/UM3NetworkPrinting/src/ClusterUM3PrinterOutputController.py @@ -18,4 +18,3 @@ class ClusterUM3PrinterOutputController(PrinterOutputController): def setJobState(self, job: "PrintJobOutputModel", state: str): data = "{\"action\": \"%s\"}" % state self._output_device.put("print_jobs/%s/action" % job.key, data, on_finished=None) - From 9eb743bcb8b1619769f0c6679bb3378b9c813231 Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Mon, 17 Dec 2018 12:01:10 +0100 Subject: [PATCH 120/178] STAR-322: Making mypy happy --- cura/NetworkClient.py | 57 +++++++++++-------- cura/Settings/SimpleModeSettingsManager.py | 5 +- .../src/Cloud/CloudApiClient.py | 16 ++++-- .../src/Cloud/CloudOutputDevice.py | 13 +++-- .../src/Cloud/MeshUploader.py | 19 ++++--- 5 files changed, 64 insertions(+), 46 deletions(-) diff --git a/cura/NetworkClient.py b/cura/NetworkClient.py index 878158542a..fabaaed95e 100644 --- a/cura/NetworkClient.py +++ b/cura/NetworkClient.py @@ -1,7 +1,7 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from time import time -from typing import Optional, Dict, Callable, List, Union +from typing import Optional, Dict, Callable, List, Union, cast from PyQt5.QtCore import QUrl from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkReply, QHttpMultiPart, QNetworkRequest, QHttpPart, \ @@ -19,7 +19,7 @@ class NetworkClient: super().__init__() # Network manager instance to use for this client. - self._manager = None # type: Optional[QNetworkAccessManager] + self.__manager = None # type: Optional[QNetworkAccessManager] # Timings. self._last_manager_create_time = None # type: Optional[float] @@ -34,20 +34,26 @@ class NetworkClient: # HTTP which uses them. We hold references to these QHttpMultiPart objects here. self._kept_alive_multiparts = {} # type: Dict[QNetworkReply, QHttpMultiPart] + # in order to avoid garbage collection we keep the callbacks in this list. + self._anti_gc_callbacks = [] # type: List[Callable[[], None]] + ## Creates a network manager if needed, with all the required properties and event bindings. def start(self) -> None: - if self._manager: - return - self._manager = QNetworkAccessManager() - self._last_manager_create_time = time() - self._manager.authenticationRequired.connect(self._onAuthenticationRequired) + if not self.__manager: + self.__manager = QNetworkAccessManager() + self._last_manager_create_time = time() + self.__manager.authenticationRequired.connect(self._onAuthenticationRequired) ## Destroys the network manager and event bindings. def stop(self) -> None: - if not self._manager: - return - self._manager.authenticationRequired.disconnect(self._onAuthenticationRequired) - self._manager = None + if self.__manager: + self.__manager.authenticationRequired.disconnect(self._onAuthenticationRequired) + self.__manager = None + + @property + def _manager(self) -> QNetworkAccessManager: + self.start() + return cast(QNetworkAccessManager, self.__manager) ## Create a new empty network request. # Automatically adds the required HTTP headers. @@ -94,13 +100,13 @@ class NetworkClient: return self._createFormPart(content_header, data, content_type) ## Sends a put request to the given path. - # url: The path after the API prefix. - # data: The data to be sent in the body - # content_type: The content type of the body data. - # on_finished: The function to call when the response is received. - # on_progress: The function to call when the progress changes. Parameters are bytes_sent / bytes_total. - def put(self, url: str, data: Union[str, bytes], content_type: Optional[str] = None, - on_finished: Optional[Callable[[QNetworkReply], None]] = None, + # \param url: The path after the API prefix. + # \param data: The data to be sent in the body + # \param content_type: The content type of the body data. + # \param on_finished: The function to call when the response is received. + # \param on_progress: The function to call when the progress changes. Parameters are bytes_sent / bytes_total. + def put(self, url: str, data: Union[str, bytes], content_type: str, + on_finished: Callable[[QNetworkReply], None], on_progress: Optional[Callable[[int, int], None]] = None) -> None: request = self._createEmptyRequest(url, content_type = content_type) @@ -114,7 +120,7 @@ class NetworkClient: ## Sends a delete request to the given path. # url: The path after the API prefix. # on_finished: The function to be call when the response is received. - def delete(self, url: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None: + def delete(self, url: str, on_finished: Callable[[QNetworkReply], None]) -> None: request = self._createEmptyRequest(url) reply = self._manager.deleteResource(request) callback = self._createCallback(reply, on_finished) @@ -123,7 +129,7 @@ class NetworkClient: ## Sends a get request to the given path. # \param url: The path after the API prefix. # \param on_finished: The function to be call when the response is received. - def get(self, url: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None: + def get(self, url: str, on_finished: Callable[[QNetworkReply], None]) -> None: request = self._createEmptyRequest(url) reply = self._manager.get(request) callback = self._createCallback(reply, on_finished) @@ -135,7 +141,7 @@ class NetworkClient: # \param on_finished: The function to call when the response is received. # \param on_progress: The function to call when the progress changes. Parameters are bytes_sent / bytes_total. def post(self, url: str, data: Union[str, bytes], - on_finished: Optional[Callable[[QNetworkReply], None]], + on_finished: Callable[[QNetworkReply], None], on_progress: Optional[Callable[[int, int], None]] = None) -> None: request = self._createEmptyRequest(url) @@ -180,6 +186,9 @@ class NetworkClient: return reply - @staticmethod - def _createCallback(reply: QNetworkReply, on_finished: Optional[Callable[[QNetworkReply], None]] = None): - return lambda: on_finished(reply) + def _createCallback(self, reply: QNetworkReply, on_finished: Callable[[QNetworkReply], None]) -> Callable[[], None]: + def callback(): + on_finished(reply) + self._anti_gc_callbacks.remove(callback) + self._anti_gc_callbacks.append(callback) + return callback diff --git a/cura/Settings/SimpleModeSettingsManager.py b/cura/Settings/SimpleModeSettingsManager.py index 210a5794d4..b22aea15ea 100644 --- a/cura/Settings/SimpleModeSettingsManager.py +++ b/cura/Settings/SimpleModeSettingsManager.py @@ -1,5 +1,6 @@ # Copyright (c) 2017 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. +from typing import Set from PyQt5.QtCore import QObject, pyqtSignal, pyqtProperty, pyqtSlot @@ -63,10 +64,10 @@ class SimpleModeSettingsManager(QObject): @pyqtSlot() def updateIsProfileUserCreated(self) -> None: - quality_changes_keys = set() + quality_changes_keys = set() # type: Set[str] if not self._machine_manager.activeMachine: - return False + return global_stack = self._machine_manager.activeMachine diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py index 5fd14efc9c..d58def4545 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py @@ -3,7 +3,7 @@ import json from json import JSONDecodeError from time import time -from typing import Callable, List, Type, TypeVar, Union, Optional, Tuple, Dict, Any +from typing import Callable, List, Type, TypeVar, Union, Optional, Tuple, Dict, Any, cast from PyQt5.QtCore import QUrl from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply, QNetworkAccessManager @@ -40,7 +40,7 @@ class CloudApiClient: self._on_error = on_error self._upload = None # type: Optional[MeshUploader] # in order to avoid garbage collection we keep the callbacks in this list. - self._anti_gc_callbacks = [] # type: List[Callable[[QNetworkReply], None]] + self._anti_gc_callbacks = [] # type: List[Callable[[], None]] ## Gets the account used for the API. @property @@ -128,12 +128,16 @@ class CloudApiClient: # \param on_finished: The callback in case the response is successful. # \param model_class: The type of the model to convert the response to. It may either be a single record or a list. def _parseModels(self, response: Dict[str, Any], - on_finished: Callable[[Union[Model, List[Model]]], Any], + on_finished: Union[Callable[[Model], Any], Callable[[List[Model]], Any]], model_class: Type[Model]) -> None: if "data" in response: data = response["data"] - result = [model_class(**c) for c in data] if isinstance(data, list) else model_class(**data) - on_finished(result) + if isinstance(data, list): + results = [model_class(**c) for c in data] # type: List[CloudApiClient.Model] + cast(Callable[[List[CloudApiClient.Model]], Any], on_finished)(results) + else: + result = model_class(**data) # type: CloudApiClient.Model + cast(Callable[[CloudApiClient.Model], Any], on_finished)(result) elif "errors" in response: self._on_error([CloudErrorObject(**error) for error in response["errors"]]) else: @@ -145,7 +149,7 @@ class CloudApiClient: # \return: A function that can be passed to the def _addCallbacks(self, reply: QNetworkReply, - on_finished: Callable[[Union[Model, List[Model]]], Any], + on_finished: Union[Callable[[Model], Any], Callable[[List[Model]], Any]], model: Type[Model], ) -> None: def parse() -> None: diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index 3890e7cee2..54f0dc20b6 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -3,7 +3,7 @@ import os from time import time -from typing import Dict, List, Optional, Set +from typing import Dict, List, Optional, Set, cast from PyQt5.QtCore import QObject, QUrl, pyqtProperty, pyqtSignal, pyqtSlot @@ -161,7 +161,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): self.setConnectionText(T.CONNECTED_VIA_CLOUD) ## Called when Cura requests an output device to receive a (G-code) file. - def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mime_types: bool = False, + def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mimetypes: bool = False, file_handler: Optional[FileHandler] = None, **kwargs: str) -> None: # Show an error message if we're already sending a job. @@ -190,7 +190,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): self._mesh = mesh request = CloudPrintJobUploadRequest( - job_name = file_name, + job_name = file_name or mesh_format.file_extension, file_size = len(mesh), content_type = mesh_format.mime_type, ) @@ -317,13 +317,14 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): def _onPrintJobCreated(self, job_response: CloudPrintJobResponse) -> None: self._progress.show() self._uploaded_print_job = job_response - self._api.uploadMesh(job_response, self._mesh, self._onPrintJobUploaded, self._progress.update, - self._onUploadError) + mesh = cast(bytes, self._mesh) + self._api.uploadMesh(job_response, mesh, self._onPrintJobUploaded, self._progress.update, self._onUploadError) ## Requests the print to be sent to the printer when we finished uploading the mesh. def _onPrintJobUploaded(self) -> None: self._progress.update(100) - self._api.requestPrint(self.key, self._uploaded_print_job.job_id, self._onPrintRequested) + print_job = cast(CloudPrintJobResponse, self._uploaded_print_job) + self._api.requestPrint(self.key, print_job.job_id, self._onPrintRequested) ## Displays the given message if uploading the mesh has failed # \param message: The message to display. diff --git a/plugins/UM3NetworkPrinting/src/Cloud/MeshUploader.py b/plugins/UM3NetworkPrinting/src/Cloud/MeshUploader.py index 4f0d6f2e81..f0360b83e3 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/MeshUploader.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/MeshUploader.py @@ -3,7 +3,7 @@ # -*- coding: utf-8 -*- from PyQt5.QtCore import QUrl from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply, QNetworkAccessManager -from typing import Optional, Callable, Any, Tuple +from typing import Optional, Callable, Any, Tuple, cast from UM.Logger import Logger from src.Cloud.Models.CloudPrintJobResponse import CloudPrintJobResponse @@ -20,7 +20,8 @@ class MeshUploader: # \param http_method: The HTTP method to be used, e.g. "POST" or "PUT". # \param timeout: The timeout for each chunk upload. Important: If None, no timeout is applied at all. def __init__(self, manager: QNetworkAccessManager, print_job: CloudPrintJobResponse, data: bytes, - on_finished: Callable[[], Any], on_progress: Callable[[int], Any], on_error: Callable[[], Any]): + on_finished: Callable[[], Any], on_progress: Callable[[int], Any], on_error: Callable[[], Any] + ) -> None: self._manager = manager self._print_job = print_job self._data = data @@ -85,20 +86,22 @@ class MeshUploader: self._on_progress(int((self._sent_bytes + bytes_sent) / len(self._data) * 100)) def _errorCallback(self) -> None: - body = bytes(self._reply.readAll()).decode() + reply = cast(QNetworkReply, self._reply) + body = bytes(reply.readAll()).decode() Logger.log("e", "Received error while uploading: %s", body) self.stop() self._on_error() def _finishedCallback(self) -> None: + reply = cast(QNetworkReply, self._reply) Logger.log("i", "Finished callback %s %s", - self._reply.attribute(QNetworkRequest.HttpStatusCodeAttribute), self._reply.url().toString()) + reply.attribute(QNetworkRequest.HttpStatusCodeAttribute), reply.url().toString()) - status_code = self._reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) + status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) if self._retries < self.MAX_RETRIES and status_code in self.RETRY_HTTP_CODES: self._retries += 1 - Logger.log("i", "Retrying %s/%s request %s", self._retries, self.MAX_RETRIES, self._reply.url().toString()) + Logger.log("i", "Retrying %s/%s request %s", self._retries, self.MAX_RETRIES, reply.url().toString()) self._uploadChunk() return @@ -106,9 +109,9 @@ class MeshUploader: self._errorCallback() return - body = bytes(self._reply.readAll()).decode() + body = bytes(reply.readAll()).decode() Logger.log("w", "status_code: %s, Headers: %s, body: %s", status_code, - [bytes(header).decode() for header in self._reply.rawHeaderList()], body) + [bytes(header).decode() for header in reply.rawHeaderList()], body) first_byte, last_byte = self._chunkRange() self._sent_bytes += last_byte - first_byte From 87c1392173ce05785f9e4c981bb1cae86dcbacce Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Mon, 17 Dec 2018 12:05:25 +0100 Subject: [PATCH 121/178] STAR-322: Fixing tests --- .../tests/Cloud/TestCloudOutputDevice.py | 10 +++++++--- .../tests/Cloud/TestCloudOutputDeviceManager.py | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py index d31f59f85a..c42a208ece 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py @@ -10,6 +10,7 @@ from cura.CuraApplication import CuraApplication from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel from src.Cloud.CloudApiClient import CloudApiClient from src.Cloud.CloudOutputDevice import CloudOutputDevice +from src.Cloud.Models.CloudClusterResponse import CloudClusterResponse from tests.Cloud.Fixtures import readFixture, parseFixture from .NetworkManagerMock import NetworkManagerMock @@ -18,6 +19,7 @@ class TestCloudOutputDevice(TestCase): CLUSTER_ID = "RIZ6cZbWA_Ua7RZVJhrdVfVpf0z-MqaSHQE4v8aRTtYq" JOB_ID = "ABCDefGHIjKlMNOpQrSTUvYxWZ0-1234567890abcDE=" HOST_NAME = "ultimakersystem-ccbdd30044ec" + HOST_GUID = "e90ae0ac-1257-4403-91ee-a44c9b7e8050" BASE_URL = "https://api-staging.ultimaker.com" STATUS_URL = "{}/connect/v1/clusters/{}/status".format(BASE_URL, CLUSTER_ID) @@ -29,13 +31,15 @@ class TestCloudOutputDevice(TestCase): self.app = CuraApplication.getInstance() self.backend = MagicMock(backendStateChange = Signal()) self.app.setBackend(self.backend) + self.cluster = CloudClusterResponse(self.CLUSTER_ID, self.HOST_GUID, self.HOST_NAME, is_online=True, + status="active") self.network = NetworkManagerMock() self.account = MagicMock(isLoggedIn=True, accessToken="TestAccessToken") self.onError = MagicMock() with patch("src.Cloud.CloudApiClient.QNetworkAccessManager", return_value = self.network): self._api = CloudApiClient(self.account, self.onError) - self.device = CloudOutputDevice(self._api, self.CLUSTER_ID, self.HOST_NAME) + self.device = CloudOutputDevice(self._api, self.cluster) self.cluster_status = parseFixture("getClusterStatusResponse") self.network.prepareReply("GET", self.STATUS_URL, 200, readFixture("getClusterStatusResponse")) @@ -81,7 +85,7 @@ class TestCloudOutputDevice(TestCase): self.cluster_status["data"]["print_jobs"].clear() self.network.prepareReply("GET", self.STATUS_URL, 200, self.cluster_status) - self.device._last_response_time = None + self.device._last_request_time = None self.device._update() self.network.flushReplies() self.assertEqual([], self.device.printJobs) @@ -94,7 +98,7 @@ class TestCloudOutputDevice(TestCase): self.cluster_status["data"]["printers"].clear() self.network.prepareReply("GET", self.STATUS_URL, 200, self.cluster_status) - self.device._last_response_time = None + self.device._last_request_time = None self.device._update() self.network.flushReplies() self.assertEqual([], self.device.printers) diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py index 80dd2c7990..f98e954274 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py @@ -41,7 +41,7 @@ class TestCloudOutputDeviceManager(TestCase): clusters = self.clusters_response.get("data", []) self.assertEqual([CloudOutputDevice] * len(clusters), [type(d) for d in devices]) self.assertEqual({cluster["cluster_id"] for cluster in clusters}, {device.key for device in devices}) - self.assertEqual({cluster["host_name"] for cluster in clusters}, {device.host_name for device in devices}) + self.assertEqual(clusters, [device.clusterData.toDict() for device in devices]) for device in clusters: device_manager.getOutputDevice(device["cluster_id"]).close() From 34f56b046aeb562c8d8e8d52a1dd89d35474c176 Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Mon, 17 Dec 2018 13:13:44 +0100 Subject: [PATCH 122/178] STAR-322: Review comments --- cura/NetworkClient.py | 194 ------------------ .../src/Cloud/CloudOutputDevice.py | 2 +- 2 files changed, 1 insertion(+), 195 deletions(-) delete mode 100644 cura/NetworkClient.py diff --git a/cura/NetworkClient.py b/cura/NetworkClient.py deleted file mode 100644 index fabaaed95e..0000000000 --- a/cura/NetworkClient.py +++ /dev/null @@ -1,194 +0,0 @@ -# Copyright (c) 2018 Ultimaker B.V. -# Cura is released under the terms of the LGPLv3 or higher. -from time import time -from typing import Optional, Dict, Callable, List, Union, cast - -from PyQt5.QtCore import QUrl -from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkReply, QHttpMultiPart, QNetworkRequest, QHttpPart, \ - QAuthenticator - -from UM.Application import Application -from UM.Logger import Logger - - -## Abstraction of QNetworkAccessManager for easier networking in Cura. -# This was originally part of NetworkedPrinterOutputDevice but was moved out for re-use in other classes. -class NetworkClient: - - def __init__(self) -> None: - super().__init__() - - # Network manager instance to use for this client. - self.__manager = None # type: Optional[QNetworkAccessManager] - - # Timings. - self._last_manager_create_time = None # type: Optional[float] - self._last_response_time = None # type: Optional[float] - self._last_request_time = None # type: Optional[float] - - # The user agent of Cura. - application = Application.getInstance() - self._user_agent = "%s/%s " % (application.getApplicationName(), application.getVersion()) - - # QHttpMultiPart objects need to be kept alive and not garbage collected during the - # HTTP which uses them. We hold references to these QHttpMultiPart objects here. - self._kept_alive_multiparts = {} # type: Dict[QNetworkReply, QHttpMultiPart] - - # in order to avoid garbage collection we keep the callbacks in this list. - self._anti_gc_callbacks = [] # type: List[Callable[[], None]] - - ## Creates a network manager if needed, with all the required properties and event bindings. - def start(self) -> None: - if not self.__manager: - self.__manager = QNetworkAccessManager() - self._last_manager_create_time = time() - self.__manager.authenticationRequired.connect(self._onAuthenticationRequired) - - ## Destroys the network manager and event bindings. - def stop(self) -> None: - if self.__manager: - self.__manager.authenticationRequired.disconnect(self._onAuthenticationRequired) - self.__manager = None - - @property - def _manager(self) -> QNetworkAccessManager: - self.start() - return cast(QNetworkAccessManager, self.__manager) - - ## Create a new empty network request. - # Automatically adds the required HTTP headers. - # \param url: The URL to request - # \param content_type: The type of the body contents. - def _createEmptyRequest(self, url: str, content_type: Optional[str] = "application/json") -> QNetworkRequest: - if not self._manager: - self.start() # make sure the manager is created - request = QNetworkRequest(QUrl(url)) - if content_type: - request.setHeader(QNetworkRequest.ContentTypeHeader, content_type) - request.setHeader(QNetworkRequest.UserAgentHeader, self._user_agent) - self._last_request_time = time() - return request - - ## Removes all cached Multi-Part items. - def _clearCachedMultiPart(self, reply: QNetworkReply) -> None: - if reply in self._kept_alive_multiparts: - del self._kept_alive_multiparts[reply] - - ## Callback for when the network manager detects that authentication is required but was not given. - @staticmethod - def _onAuthenticationRequired(reply: QNetworkReply, authenticator: QAuthenticator) -> None: - Logger.log("w", "Request to {} required authentication but was not given".format(reply.url().toString())) - - ## Add a part to a Multi-Part form. - @staticmethod - def _createFormPart(content_header: str, data: bytes, content_type: Optional[str] = None) -> QHttpPart: - part = QHttpPart() - - if not content_header.startswith("form-data;"): - content_header = "form_data; " + content_header - - part.setHeader(QNetworkRequest.ContentDispositionHeader, content_header) - - if content_type is not None: - part.setHeader(QNetworkRequest.ContentTypeHeader, content_type) - - part.setBody(data) - return part - - ## Public version of _createFormPart. Both are needed for backward compatibility with 3rd party plugins. - def createFormPart(self, content_header: str, data: bytes, content_type: Optional[str] = None) -> QHttpPart: - return self._createFormPart(content_header, data, content_type) - - ## Sends a put request to the given path. - # \param url: The path after the API prefix. - # \param data: The data to be sent in the body - # \param content_type: The content type of the body data. - # \param on_finished: The function to call when the response is received. - # \param on_progress: The function to call when the progress changes. Parameters are bytes_sent / bytes_total. - def put(self, url: str, data: Union[str, bytes], content_type: str, - on_finished: Callable[[QNetworkReply], None], - on_progress: Optional[Callable[[int, int], None]] = None) -> None: - request = self._createEmptyRequest(url, content_type = content_type) - - body = data if isinstance(data, bytes) else data.encode() # type: bytes - reply = self._manager.put(request, body) - callback = self._createCallback(reply, on_finished) - reply.finished.connect(callback) - if on_progress is not None: - reply.uploadProgress.connect(on_progress) - - ## Sends a delete request to the given path. - # url: The path after the API prefix. - # on_finished: The function to be call when the response is received. - def delete(self, url: str, on_finished: Callable[[QNetworkReply], None]) -> None: - request = self._createEmptyRequest(url) - reply = self._manager.deleteResource(request) - callback = self._createCallback(reply, on_finished) - reply.finished.connect(callback) - - ## Sends a get request to the given path. - # \param url: The path after the API prefix. - # \param on_finished: The function to be call when the response is received. - def get(self, url: str, on_finished: Callable[[QNetworkReply], None]) -> None: - request = self._createEmptyRequest(url) - reply = self._manager.get(request) - callback = self._createCallback(reply, on_finished) - reply.finished.connect(callback) - - ## Sends a post request to the given path. - # \param url: The path after the API prefix. - # \param data: The data to be sent in the body - # \param on_finished: The function to call when the response is received. - # \param on_progress: The function to call when the progress changes. Parameters are bytes_sent / bytes_total. - def post(self, url: str, data: Union[str, bytes], - on_finished: Callable[[QNetworkReply], None], - on_progress: Optional[Callable[[int, int], None]] = None) -> None: - request = self._createEmptyRequest(url) - - body = data if isinstance(data, bytes) else data.encode() # type: bytes - reply = self._manager.post(request, body) - callback = self._createCallback(reply, on_finished) - reply.finished.connect(callback) - if on_progress is not None: - reply.uploadProgress.connect(on_progress) - - ## Does a POST request with form data to the given URL. - def postForm(self, url: str, header_data: str, body_data: bytes, - on_finished: Optional[Callable[[QNetworkReply], None]], - on_progress: Optional[Callable[[int, int], None]] = None) -> None: - post_part = QHttpPart() - post_part.setHeader(QNetworkRequest.ContentDispositionHeader, header_data) - post_part.setBody(body_data) - self.postFormWithParts(url, [post_part], on_finished, on_progress) - - ## Does a POST request with form parts to the given URL. - def postFormWithParts(self, target: str, parts: List[QHttpPart], - on_finished: Optional[Callable[[QNetworkReply], None]], - on_progress: Optional[Callable[[int, int], None]] = None) -> Optional[QNetworkReply]: - request = self._createEmptyRequest(target, content_type = None) - multi_post_part = QHttpMultiPart(QHttpMultiPart.FormDataType) - - for part in parts: - multi_post_part.append(part) - - reply = self._manager.post(request, multi_post_part) - - def callback(): - on_finished(reply) - self._clearCachedMultiPart(reply) - - reply.finished.connect(callback) - - self._kept_alive_multiparts[reply] = multi_post_part - - if on_progress is not None: - reply.uploadProgress.connect(on_progress) - - return reply - - def _createCallback(self, reply: QNetworkReply, on_finished: Callable[[QNetworkReply], None]) -> Callable[[], None]: - def callback(): - on_finished(reply) - self._anti_gc_callbacks.remove(callback) - self._anti_gc_callbacks.append(callback) - return callback diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index 54f0dc20b6..f29e29c40b 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -18,11 +18,11 @@ from cura.CuraApplication import CuraApplication from cura.PrinterOutput.NetworkedPrinterOutputDevice import AuthState, NetworkedPrinterOutputDevice from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel from plugins.UM3NetworkPrinting.src.Cloud.CloudOutputController import CloudOutputController -from src.Cloud.Models.CloudClusterResponse import CloudClusterResponse from ..MeshFormatHandler import MeshFormatHandler from ..UM3PrintJobOutputModel import UM3PrintJobOutputModel from .CloudProgressMessage import CloudProgressMessage from .CloudApiClient import CloudApiClient +from .Models.CloudClusterResponse import CloudClusterResponse from .Models.CloudClusterStatus import CloudClusterStatus from .Models.CloudPrintJobUploadRequest import CloudPrintJobUploadRequest from .Models.CloudPrintResponse import CloudPrintResponse From a1cb09f73fe4403090c4857c35910ba92e74e411 Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Mon, 17 Dec 2018 13:24:07 +0100 Subject: [PATCH 123/178] STAR-322: Sorting clusters --- plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py | 2 ++ .../UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py | 2 ++ .../tests/Cloud/TestCloudOutputDeviceManager.py | 5 ++++- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py index d4044726a3..c7d58cea78 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py @@ -18,6 +18,8 @@ from .NetworkManagerMock import NetworkManagerMock class TestCloudApiClient(TestCase): + maxDiff = None + def _errorHandler(self, errors: List[CloudErrorObject]): raise Exception("Received unexpected error: {}".format(errors)) diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py index c42a208ece..fded79e15b 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py @@ -16,6 +16,8 @@ from .NetworkManagerMock import NetworkManagerMock class TestCloudOutputDevice(TestCase): + maxDiff = None + CLUSTER_ID = "RIZ6cZbWA_Ua7RZVJhrdVfVpf0z-MqaSHQE4v8aRTtYq" JOB_ID = "ABCDefGHIjKlMNOpQrSTUvYxWZ0-1234567890abcDE=" HOST_NAME = "ultimakersystem-ccbdd30044ec" diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py index f98e954274..f62d92e9db 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py @@ -11,6 +11,8 @@ from .NetworkManagerMock import NetworkManagerMock class TestCloudOutputDeviceManager(TestCase): + maxDiff = None + URL = "https://api-staging.ultimaker.com/connect/v1/clusters" def setUp(self): @@ -41,7 +43,8 @@ class TestCloudOutputDeviceManager(TestCase): clusters = self.clusters_response.get("data", []) self.assertEqual([CloudOutputDevice] * len(clusters), [type(d) for d in devices]) self.assertEqual({cluster["cluster_id"] for cluster in clusters}, {device.key for device in devices}) - self.assertEqual(clusters, [device.clusterData.toDict() for device in devices]) + self.assertEqual(clusters, sorted((device.clusterData.toDict() for device in devices), + key=lambda device_dict: device_dict["host_version"])) for device in clusters: device_manager.getOutputDevice(device["cluster_id"]).close() From b6f90f1ab2caa1be9a1974f680970997dd8e72b3 Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Mon, 17 Dec 2018 13:29:20 +0100 Subject: [PATCH 124/178] STAR-322: Using cura constants --- .../UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py | 9 +++++---- .../tests/Cloud/TestCloudOutputDevice.py | 8 ++++---- .../tests/Cloud/TestCloudOutputDeviceManager.py | 3 ++- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py index c7d58cea78..a09894deb0 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py @@ -7,6 +7,7 @@ from unittest import TestCase from unittest.mock import patch, MagicMock from cura.CuraApplication import CuraApplication +from cura.CuraConstants import CuraCloudAPIRoot from src.Cloud.CloudApiClient import CloudApiClient from src.Cloud.Models.CloudClusterResponse import CloudClusterResponse from src.Cloud.Models.CloudClusterStatus import CloudClusterStatus @@ -39,7 +40,7 @@ class TestCloudApiClient(TestCase): response = readFixture("getClusters") data = parseFixture("getClusters")["data"] - self.network.prepareReply("GET", "https://api-staging.ultimaker.com/connect/v1/clusters", 200, response) + self.network.prepareReply("GET", CuraCloudAPIRoot + "/connect/v1/clusters", 200, response) # the callback is a function that adds the result of the call to getClusters to the result list self.api.getClusters(lambda clusters: result.extend(clusters)) @@ -54,7 +55,7 @@ class TestCloudApiClient(TestCase): data = parseFixture("getClusterStatusResponse")["data"] self.network.prepareReply("GET", - "https://api-staging.ultimaker.com/connect/v1/clusters/R0YcLJwar1ugh0ikEZsZs8NWKV6vJP_LdYsXgXqAcaNC/status", + CuraCloudAPIRoot + "/connect/v1/clusters/R0YcLJwar1ugh0ikEZsZs8NWKV6vJP_LdYsXgXqAcaNC/status", 200, response ) self.api.getClusterStatus("R0YcLJwar1ugh0ikEZsZs8NWKV6vJP_LdYsXgXqAcaNC", lambda s: result.append(s)) @@ -69,7 +70,7 @@ class TestCloudApiClient(TestCase): response = readFixture("putJobUploadResponse") - self.network.prepareReply("PUT", "https://api-staging.ultimaker.com/cura/v1/jobs/upload", 200, response) + self.network.prepareReply("PUT", CuraCloudAPIRoot + "/cura/v1/jobs/upload", 200, response) request = CloudPrintJobUploadRequest(job_name = "job name", file_size = 143234, content_type = "text/plain") self.api.requestUpload(request, lambda r: results.append(r)) self.network.flushReplies() @@ -108,7 +109,7 @@ class TestCloudApiClient(TestCase): job_id = "ABCDefGHIjKlMNOpQrSTUvYxWZ0-1234567890abcDE=" self.network.prepareReply("POST", - "https://api-staging.ultimaker.com/connect/v1/clusters/{}/print/{}" + CuraCloudAPIRoot + "/connect/v1/clusters/{}/print/{}" .format(cluster_id, job_id), 200, response) diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py index fded79e15b..90a1b2fa96 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py @@ -7,6 +7,7 @@ from unittest.mock import patch, MagicMock from UM.Scene.SceneNode import SceneNode from UM.Signal import Signal from cura.CuraApplication import CuraApplication +from cura.CuraConstants import CuraCloudAPIRoot from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel from src.Cloud.CloudApiClient import CloudApiClient from src.Cloud.CloudOutputDevice import CloudOutputDevice @@ -23,10 +24,9 @@ class TestCloudOutputDevice(TestCase): HOST_NAME = "ultimakersystem-ccbdd30044ec" HOST_GUID = "e90ae0ac-1257-4403-91ee-a44c9b7e8050" - BASE_URL = "https://api-staging.ultimaker.com" - STATUS_URL = "{}/connect/v1/clusters/{}/status".format(BASE_URL, CLUSTER_ID) - PRINT_URL = "{}/connect/v1/clusters/{}/print/{}".format(BASE_URL, CLUSTER_ID, JOB_ID) - REQUEST_UPLOAD_URL = "{}/cura/v1/jobs/upload".format(BASE_URL) + STATUS_URL = "{}/connect/v1/clusters/{}/status".format(CuraCloudAPIRoot, CLUSTER_ID) + PRINT_URL = "{}/connect/v1/clusters/{}/print/{}".format(CuraCloudAPIRoot, CLUSTER_ID, JOB_ID) + REQUEST_UPLOAD_URL = "{}/cura/v1/jobs/upload".format(CuraCloudAPIRoot) def setUp(self): super().setUp() diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py index f62d92e9db..01b1b18ff1 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py @@ -4,6 +4,7 @@ from unittest import TestCase from unittest.mock import patch from cura.CuraApplication import CuraApplication +from cura.CuraConstants import CuraCloudAPIRoot from src.Cloud.CloudOutputDevice import CloudOutputDevice from src.Cloud.CloudOutputDeviceManager import CloudOutputDeviceManager from tests.Cloud.Fixtures import parseFixture, readFixture @@ -13,7 +14,7 @@ from .NetworkManagerMock import NetworkManagerMock class TestCloudOutputDeviceManager(TestCase): maxDiff = None - URL = "https://api-staging.ultimaker.com/connect/v1/clusters" + URL = CuraCloudAPIRoot + "/connect/v1/clusters" def setUp(self): super().setUp() From 3b367938de9600a94829bfaa8ac29e069cb49552 Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Mon, 17 Dec 2018 13:53:31 +0100 Subject: [PATCH 125/178] STAR-322: Removing TestSendMaterialJob temporarily --- .../tests/Cloud/TestCloudApiClient.py | 6 +- .../tests/TestSendMaterialJob.py | 190 ------------------ plugins/UM3NetworkPrinting/tests/__init__.py | 2 + run_mypy.py | 1 + 4 files changed, 5 insertions(+), 194 deletions(-) delete mode 100644 plugins/UM3NetworkPrinting/tests/TestSendMaterialJob.py create mode 100644 plugins/UM3NetworkPrinting/tests/__init__.py diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py index a09894deb0..49d2b1b34b 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py @@ -54,10 +54,8 @@ class TestCloudApiClient(TestCase): response = readFixture("getClusterStatusResponse") data = parseFixture("getClusterStatusResponse")["data"] - self.network.prepareReply("GET", - CuraCloudAPIRoot + "/connect/v1/clusters/R0YcLJwar1ugh0ikEZsZs8NWKV6vJP_LdYsXgXqAcaNC/status", - 200, response - ) + url = CuraCloudAPIRoot + "/connect/v1/clusters/R0YcLJwar1ugh0ikEZsZs8NWKV6vJP_LdYsXgXqAcaNC/status" + self.network.prepareReply("GET", url, 200, response) self.api.getClusterStatus("R0YcLJwar1ugh0ikEZsZs8NWKV6vJP_LdYsXgXqAcaNC", lambda s: result.append(s)) self.network.flushReplies() diff --git a/plugins/UM3NetworkPrinting/tests/TestSendMaterialJob.py b/plugins/UM3NetworkPrinting/tests/TestSendMaterialJob.py deleted file mode 100644 index 7db5ebdedf..0000000000 --- a/plugins/UM3NetworkPrinting/tests/TestSendMaterialJob.py +++ /dev/null @@ -1,190 +0,0 @@ -# Copyright (c) 2018 Ultimaker B.V. -# Copyright (c) 2018 Ultimaker B.V. -# Cura is released under the terms of the LGPLv3 or higher. -import io -import json -from unittest import TestCase, mock -from unittest.mock import patch, call - -from PyQt5.QtCore import QByteArray - -from UM.MimeTypeDatabase import MimeType -from UM.Application import Application -from src.SendMaterialJob import SendMaterialJob - - -@patch("builtins.open", lambda _, __: io.StringIO("")) -@patch("UM.MimeTypeDatabase.MimeTypeDatabase.getMimeTypeForFile", - lambda _: MimeType(name = "application/x-ultimaker-material-profile", comment = "Ultimaker Material Profile", - suffixes = ["xml.fdm_material"])) -@patch("UM.Resources.Resources.getAllResourcesOfType", lambda _: ["/materials/generic_pla_white.xml.fdm_material"]) -@patch("plugins.UM3NetworkPrinting.src.ClusterUM3OutputDevice") -@patch("PyQt5.QtNetwork.QNetworkReply") -class TestSendMaterialJob(TestCase): - _LOCAL_MATERIAL_WHITE = {"type": "material", "status": "unknown", "id": "generic_pla_white", - "base_file": "generic_pla_white", "setting_version": "5", "name": "White PLA", - "brand": "Generic", "material": "PLA", "color_name": "White", - "GUID": "badb0ee7-87c8-4f3f-9398-938587b67dce", "version": "1", "color_code": "#ffffff", - "description": "Test PLA White", "adhesion_info": "Use glue.", "approximate_diameter": "3", - "properties": {"density": "1.00", "diameter": "2.85", "weight": "750"}, - "definition": "fdmprinter", "compatible": True} - - _LOCAL_MATERIAL_BLACK = {"type": "material", "status": "unknown", "id": "generic_pla_black", - "base_file": "generic_pla_black", "setting_version": "5", "name": "Yellow CPE", - "brand": "Ultimaker", "material": "CPE", "color_name": "Black", - "GUID": "5fbb362a-41f9-4818-bb43-15ea6df34aa4", "version": "1", "color_code": "#000000", - "description": "Test PLA Black", "adhesion_info": "Use glue.", "approximate_diameter": "3", - "properties": {"density": "1.01", "diameter": "2.85", "weight": "750"}, - "definition": "fdmprinter", "compatible": True} - - _REMOTE_MATERIAL_WHITE = { - "guid": "badb0ee7-87c8-4f3f-9398-938587b67dce", - "material": "PLA", - "brand": "Generic", - "version": 1, - "color": "White", - "density": 1.00 - } - - _REMOTE_MATERIAL_BLACK = { - "guid": "5fbb362a-41f9-4818-bb43-15ea6df34aa4", - "material": "PLA", - "brand": "Generic", - "version": 2, - "color": "Black", - "density": 1.00 - } - - def test_run(self, device_mock, reply_mock): - job = SendMaterialJob(device_mock) - job.run() - - # We expect the materials endpoint to be called when the job runs. - device_mock.get.assert_called_with("materials/", on_finished = job._onGetRemoteMaterials) - - def test__onGetRemoteMaterials_withFailedRequest(self, reply_mock, device_mock): - reply_mock.attribute.return_value = 404 - job = SendMaterialJob(device_mock) - job._onGetRemoteMaterials(reply_mock) - - # We expect the device not to be called for any follow up. - self.assertEqual(0, device_mock.createFormPart.call_count) - - def test__onGetRemoteMaterials_withWrongEncoding(self, reply_mock, device_mock): - reply_mock.attribute.return_value = 200 - reply_mock.readAll.return_value = QByteArray(json.dumps([self._REMOTE_MATERIAL_WHITE]).encode("cp500")) - job = SendMaterialJob(device_mock) - job._onGetRemoteMaterials(reply_mock) - - # Given that the parsing fails we do no expect the device to be called for any follow up. - self.assertEqual(0, device_mock.createFormPart.call_count) - - def test__onGetRemoteMaterials_withBadJsonAnswer(self, reply_mock, device_mock): - reply_mock.attribute.return_value = 200 - reply_mock.readAll.return_value = QByteArray(b"Six sick hicks nick six slick bricks with picks and sticks.") - job = SendMaterialJob(device_mock) - job._onGetRemoteMaterials(reply_mock) - - # Given that the parsing fails we do no expect the device to be called for any follow up. - self.assertEqual(0, device_mock.createFormPart.call_count) - - def test__onGetRemoteMaterials_withMissingGuidInRemoteMaterial(self, reply_mock, device_mock): - reply_mock.attribute.return_value = 200 - remote_material_without_guid = self._REMOTE_MATERIAL_WHITE.copy() - del remote_material_without_guid["guid"] - reply_mock.readAll.return_value = QByteArray(json.dumps([remote_material_without_guid]).encode("ascii")) - job = SendMaterialJob(device_mock) - job._onGetRemoteMaterials(reply_mock) - - # Given that parsing fails we do not expect the device to be called for any follow up. - self.assertEqual(0, device_mock.createFormPart.call_count) - - @patch("cura.Settings.CuraContainerRegistry") - @patch("UM.Application") - def test__onGetRemoteMaterials_withInvalidVersionInLocalMaterial(self, application_mock, container_registry_mock, - reply_mock, device_mock): - reply_mock.attribute.return_value = 200 - reply_mock.readAll.return_value = QByteArray(json.dumps([self._REMOTE_MATERIAL_WHITE]).encode("ascii")) - - localMaterialWhiteWithInvalidVersion = self._LOCAL_MATERIAL_WHITE.copy() - localMaterialWhiteWithInvalidVersion["version"] = "one" - container_registry_mock.findContainersMetadata.return_value = [localMaterialWhiteWithInvalidVersion] - - application_mock.getContainerRegistry.return_value = container_registry_mock - - with mock.patch.object(Application, "getInstance", new = lambda: application_mock): - job = SendMaterialJob(device_mock) - job._onGetRemoteMaterials(reply_mock) - - self.assertEqual(0, device_mock.createFormPart.call_count) - - @patch("cura.Settings.CuraContainerRegistry") - @patch("UM.Application") - def test__onGetRemoteMaterials_withNoUpdate(self, application_mock, container_registry_mock, reply_mock, - device_mock): - application_mock.getContainerRegistry.return_value = container_registry_mock - - device_mock.createFormPart.return_value = "_xXx_" - - container_registry_mock.findContainersMetadata.return_value = [self._LOCAL_MATERIAL_WHITE] - - reply_mock.attribute.return_value = 200 - reply_mock.readAll.return_value = QByteArray(json.dumps([self._REMOTE_MATERIAL_WHITE]).encode("ascii")) - - with mock.patch.object(Application, "getInstance", new = lambda: application_mock): - job = SendMaterialJob(device_mock) - job._onGetRemoteMaterials(reply_mock) - - self.assertEqual(0, device_mock.createFormPart.call_count) - self.assertEqual(0, device_mock.postFormWithParts.call_count) - - @patch("cura.Settings.CuraContainerRegistry") - @patch("UM.Application") - def test__onGetRemoteMaterials_withUpdatedMaterial(self, application_mock, container_registry_mock, reply_mock, - device_mock): - application_mock.getContainerRegistry.return_value = container_registry_mock - - device_mock.createFormPart.return_value = "_xXx_" - - localMaterialWhiteWithHigherVersion = self._LOCAL_MATERIAL_WHITE.copy() - localMaterialWhiteWithHigherVersion["version"] = "2" - container_registry_mock.findContainersMetadata.return_value = [localMaterialWhiteWithHigherVersion] - - reply_mock.attribute.return_value = 200 - reply_mock.readAll.return_value = QByteArray(json.dumps([self._REMOTE_MATERIAL_WHITE]).encode("ascii")) - - with mock.patch.object(Application, "getInstance", new = lambda: application_mock): - job = SendMaterialJob(device_mock) - job._onGetRemoteMaterials(reply_mock) - - self.assertEqual(1, device_mock.createFormPart.call_count) - self.assertEqual(1, device_mock.postFormWithParts.call_count) - self.assertEquals( - [call.createFormPart("name=\"file\"; filename=\"generic_pla_white.xml.fdm_material\"", ""), - call.postFormWithParts(target = "materials/", parts = ["_xXx_"], on_finished = job.sendingFinished)], - device_mock.method_calls) - - @patch("cura.Settings.CuraContainerRegistry") - @patch("UM.Application") - def test__onGetRemoteMaterials_withNewMaterial(self, application_mock, container_registry_mock, reply_mock, - device_mock): - application_mock.getContainerRegistry.return_value = container_registry_mock - - device_mock.createFormPart.return_value = "_xXx_" - - container_registry_mock.findContainersMetadata.return_value = [self._LOCAL_MATERIAL_WHITE, - self._LOCAL_MATERIAL_BLACK] - - reply_mock.attribute.return_value = 200 - reply_mock.readAll.return_value = QByteArray(json.dumps([self._REMOTE_MATERIAL_BLACK]).encode("ascii")) - - with mock.patch.object(Application, "getInstance", new = lambda: application_mock): - job = SendMaterialJob(device_mock) - job._onGetRemoteMaterials(reply_mock) - - self.assertEqual(1, device_mock.createFormPart.call_count) - self.assertEqual(1, device_mock.postFormWithParts.call_count) - self.assertEquals( - [call.createFormPart("name=\"file\"; filename=\"generic_pla_white.xml.fdm_material\"", ""), - call.postFormWithParts(target = "materials/", parts = ["_xXx_"], on_finished = job.sendingFinished)], - device_mock.method_calls) diff --git a/plugins/UM3NetworkPrinting/tests/__init__.py b/plugins/UM3NetworkPrinting/tests/__init__.py new file mode 100644 index 0000000000..f3f6970c54 --- /dev/null +++ b/plugins/UM3NetworkPrinting/tests/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. diff --git a/run_mypy.py b/run_mypy.py index 27f07cd281..2073f0e9a7 100644 --- a/run_mypy.py +++ b/run_mypy.py @@ -29,6 +29,7 @@ def where(exe_name: str, search_path: str = os.getenv("PATH")) -> str: def findModules(path): + return ["UM3NetworkPrinting"] result = [] for entry in os.scandir(path): if entry.is_dir() and os.path.exists(os.path.join(path, entry.name, "__init__.py")): From 75d7d493499b4594166c8a7adf9d4be719065ff4 Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Mon, 17 Dec 2018 14:41:28 +0100 Subject: [PATCH 126/178] STAR-322: Mocking the CuraApp --- .../src/Cloud/CloudApiClient.py | 10 +- .../tests/Cloud/TestCloudApiClient.py | 2 - .../tests/Cloud/TestCloudOutputDevice.py | 18 +- .../Cloud/TestCloudOutputDeviceManager.py | 51 +++-- .../tests/TestSendMaterialJob.py | 190 ++++++++++++++++++ plugins/UM3NetworkPrinting/tests/conftest.py | 40 ---- 6 files changed, 238 insertions(+), 73 deletions(-) create mode 100644 plugins/UM3NetworkPrinting/tests/TestSendMaterialJob.py delete mode 100644 plugins/UM3NetworkPrinting/tests/conftest.py diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py index d58def4545..c6fb02753f 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py @@ -52,7 +52,7 @@ class CloudApiClient: def getClusters(self, on_finished: Callable[[List[CloudClusterResponse]], Any]) -> None: url = "{}/clusters".format(self.CLUSTER_API_ROOT) reply = self._manager.get(self._createEmptyRequest(url)) - self._addCallbacks(reply, on_finished, CloudClusterResponse) + self._addCallback(reply, on_finished, CloudClusterResponse) ## Retrieves the status of the given cluster. # \param cluster_id: The ID of the cluster. @@ -60,7 +60,7 @@ class CloudApiClient: def getClusterStatus(self, cluster_id: str, on_finished: Callable[[CloudClusterStatus], Any]) -> None: url = "{}/clusters/{}/status".format(self.CLUSTER_API_ROOT, cluster_id) reply = self._manager.get(self._createEmptyRequest(url)) - self._addCallbacks(reply, on_finished, CloudClusterStatus) + self._addCallback(reply, on_finished, CloudClusterStatus) ## Requests the cloud to register the upload of a print job mesh. # \param request: The request object. @@ -70,7 +70,7 @@ class CloudApiClient: url = "{}/jobs/upload".format(self.CURA_API_ROOT) body = json.dumps({"data": request.toDict()}) reply = self._manager.put(self._createEmptyRequest(url), body.encode()) - self._addCallbacks(reply, on_finished, CloudPrintJobResponse) + self._addCallback(reply, on_finished, CloudPrintJobResponse) ## Requests the cloud to register the upload of a print job mesh. # \param upload_response: The object received after requesting an upload with `self.requestUpload`. @@ -90,7 +90,7 @@ class CloudApiClient: def requestPrint(self, cluster_id: str, job_id: str, on_finished: Callable[[CloudPrintResponse], Any]) -> None: url = "{}/clusters/{}/print/{}".format(self.CLUSTER_API_ROOT, cluster_id, job_id) reply = self._manager.post(self._createEmptyRequest(url), b"") - self._addCallbacks(reply, on_finished, CloudPrintResponse) + self._addCallback(reply, on_finished, CloudPrintResponse) ## We override _createEmptyRequest in order to add the user credentials. # \param url: The URL to request @@ -147,7 +147,7 @@ class CloudApiClient: # \param on_finished: The callback in case the response is successful. # \param model: The type of the model to convert the response to. It may either be a single record or a list. # \return: A function that can be passed to the - def _addCallbacks(self, + def _addCallback(self, reply: QNetworkReply, on_finished: Union[Callable[[Model], Any], Callable[[List[Model]], Any]], model: Type[Model], diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py index 49d2b1b34b..0c0c8cffdf 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py @@ -6,7 +6,6 @@ from typing import List from unittest import TestCase from unittest.mock import patch, MagicMock -from cura.CuraApplication import CuraApplication from cura.CuraConstants import CuraCloudAPIRoot from src.Cloud.CloudApiClient import CloudApiClient from src.Cloud.Models.CloudClusterResponse import CloudClusterResponse @@ -29,7 +28,6 @@ class TestCloudApiClient(TestCase): self.account = MagicMock() self.account.isLoggedIn.return_value = True - self.app = CuraApplication.getInstance() self.network = NetworkManagerMock() with patch("src.Cloud.CloudApiClient.QNetworkAccessManager", return_value = self.network): self.api = CloudApiClient(self.account, self._errorHandler) diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py index 90a1b2fa96..34e04689c2 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py @@ -30,9 +30,13 @@ class TestCloudOutputDevice(TestCase): def setUp(self): super().setUp() - self.app = CuraApplication.getInstance() - self.backend = MagicMock(backendStateChange = Signal()) - self.app.setBackend(self.backend) + self.app = MagicMock() + + self.patches = [patch("UM.Qt.QtApplication.QtApplication.getInstance", return_value=self.app), + patch("UM.Application.Application.getInstance", return_value=self.app)] + for patched_method in self.patches: + patched_method.start() + self.cluster = CloudClusterResponse(self.CLUSTER_ID, self.HOST_GUID, self.HOST_NAME, is_online=True, status="active") @@ -41,6 +45,7 @@ class TestCloudOutputDevice(TestCase): self.onError = MagicMock() with patch("src.Cloud.CloudApiClient.QNetworkAccessManager", return_value = self.network): self._api = CloudApiClient(self.account, self.onError) + self.device = CloudOutputDevice(self._api, self.cluster) self.cluster_status = parseFixture("getClusterStatusResponse") self.network.prepareReply("GET", self.STATUS_URL, 200, readFixture("getClusterStatusResponse")) @@ -48,6 +53,8 @@ class TestCloudOutputDevice(TestCase): def tearDown(self): super().tearDown() self.network.flushReplies() + for patched_method in self.patches: + patched_method.stop() def test_status(self): self.device._update() @@ -105,9 +112,8 @@ class TestCloudOutputDevice(TestCase): self.network.flushReplies() self.assertEqual([], self.device.printers) - @patch("cura.CuraApplication.CuraApplication.getGlobalContainerStack") - def test_print_to_cloud(self, global_container_stack_mock): - active_machine_mock = global_container_stack_mock.return_value + def test_print_to_cloud(self): + active_machine_mock = self.app.getGlobalContainerStack.return_value active_machine_mock.getMetaDataEntry.side_effect = {"file_formats": "application/gzip"}.get request_upload_response = parseFixture("putJobUploadResponse") diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py index 01b1b18ff1..96137a3edb 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py @@ -1,14 +1,14 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from unittest import TestCase -from unittest.mock import patch +from unittest.mock import patch, MagicMock -from cura.CuraApplication import CuraApplication +from UM.OutputDevice.OutputDeviceManager import OutputDeviceManager from cura.CuraConstants import CuraCloudAPIRoot from src.Cloud.CloudOutputDevice import CloudOutputDevice from src.Cloud.CloudOutputDeviceManager import CloudOutputDeviceManager from tests.Cloud.Fixtures import parseFixture, readFixture -from .NetworkManagerMock import NetworkManagerMock +from .NetworkManagerMock import NetworkManagerMock, FakeSignal class TestCloudOutputDeviceManager(TestCase): @@ -18,9 +18,19 @@ class TestCloudOutputDeviceManager(TestCase): def setUp(self): super().setUp() - self.app = CuraApplication.getInstance() + self.app = MagicMock() + self.device_manager = OutputDeviceManager() + self.app.getOutputDeviceManager.return_value = self.device_manager + + self.patches = [patch("UM.Qt.QtApplication.QtApplication.getInstance", return_value=self.app), + patch("UM.Application.Application.getInstance", return_value=self.app)] + for patched_method in self.patches: + patched_method.start() + self.network = NetworkManagerMock() - with patch("src.Cloud.CloudApiClient.QNetworkAccessManager", return_value = self.network): + self.timer = MagicMock(timeout = FakeSignal()) + with patch("src.Cloud.CloudApiClient.QNetworkAccessManager", return_value = self.network), \ + patch("src.Cloud.CloudOutputDeviceManager.QTimer", return_value = self.timer): self.manager = CloudOutputDeviceManager() self.clusters_response = parseFixture("getClusters") self.network.prepareReply("GET", self.URL, 200, readFixture("getClusters")) @@ -28,7 +38,11 @@ class TestCloudOutputDeviceManager(TestCase): def tearDown(self): try: self._beforeTearDown() + + self.network.flushReplies() self.manager.stop() + for patched_method in self.patches: + patched_method.stop() finally: super().tearDown() @@ -38,8 +52,7 @@ class TestCloudOutputDeviceManager(TestCase): # let the network send replies self.network.flushReplies() # get the created devices - device_manager = self.app.getOutputDeviceManager() - devices = device_manager.getOutputDevices() + devices = self.device_manager.getOutputDevices() # get the server data clusters = self.clusters_response.get("data", []) self.assertEqual([CloudOutputDevice] * len(clusters), [type(d) for d in devices]) @@ -48,13 +61,12 @@ class TestCloudOutputDeviceManager(TestCase): key=lambda device_dict: device_dict["host_version"])) for device in clusters: - device_manager.getOutputDevice(device["cluster_id"]).close() - device_manager.removeOutputDevice(device["cluster_id"]) + self.device_manager.getOutputDevice(device["cluster_id"]).close() + self.device_manager.removeOutputDevice(device["cluster_id"]) ## Runs the initial request to retrieve the clusters. def _loadData(self): self.manager.start() - self.manager._onLoginStateChanged(is_logged_in = True) self.network.flushReplies() def test_device_is_created(self): @@ -79,22 +91,20 @@ class TestCloudOutputDeviceManager(TestCase): self.manager._update_timer.timeout.emit() - @patch("cura.CuraApplication.CuraApplication.getGlobalContainerStack") - def test_device_connects_by_cluster_id(self, global_container_stack_mock): - active_machine_mock = global_container_stack_mock.return_value + def test_device_connects_by_cluster_id(self): + active_machine_mock = self.app.getGlobalContainerStack.return_value cluster1, cluster2 = self.clusters_response["data"] cluster_id = cluster1["cluster_id"] active_machine_mock.getMetaDataEntry.side_effect = {"um_cloud_cluster_id": cluster_id}.get self._loadData() - self.assertTrue(self.app.getOutputDeviceManager().getOutputDevice(cluster1["cluster_id"]).isConnected()) - self.assertFalse(self.app.getOutputDeviceManager().getOutputDevice(cluster2["cluster_id"]).isConnected()) + self.assertTrue(self.device_manager.getOutputDevice(cluster1["cluster_id"]).isConnected()) + self.assertFalse(self.device_manager.getOutputDevice(cluster2["cluster_id"]).isConnected()) self.assertEquals([], active_machine_mock.setMetaDataEntry.mock_calls) - @patch("cura.CuraApplication.CuraApplication.getGlobalContainerStack") - def test_device_connects_by_network_key(self, global_container_stack_mock): - active_machine_mock = global_container_stack_mock.return_value + def test_device_connects_by_network_key(self): + active_machine_mock = self.app.getGlobalContainerStack.return_value cluster1, cluster2 = self.clusters_response["data"] network_key = cluster2["host_name"] + ".ultimaker.local" @@ -102,8 +112,9 @@ class TestCloudOutputDeviceManager(TestCase): self._loadData() - self.assertFalse(self.app.getOutputDeviceManager().getOutputDevice(cluster1["cluster_id"]).isConnected()) - self.assertTrue(self.app.getOutputDeviceManager().getOutputDevice(cluster2["cluster_id"]).isConnected()) + self.assertEqual([False, True], + [self.device_manager.getOutputDevice(cluster["cluster_id"]).isConnected() + for cluster in (cluster1, cluster2)]) active_machine_mock.setMetaDataEntry.assert_called_with("um_cloud_cluster_id", cluster2["cluster_id"]) diff --git a/plugins/UM3NetworkPrinting/tests/TestSendMaterialJob.py b/plugins/UM3NetworkPrinting/tests/TestSendMaterialJob.py new file mode 100644 index 0000000000..b669eb192a --- /dev/null +++ b/plugins/UM3NetworkPrinting/tests/TestSendMaterialJob.py @@ -0,0 +1,190 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +import io +import json +from unittest import TestCase, mock +from unittest.mock import patch, call + +from PyQt5.QtCore import QByteArray + +from UM.MimeTypeDatabase import MimeType +from UM.Application import Application +from plugins.UM3NetworkPrinting.src.SendMaterialJob import SendMaterialJob + + +@patch("builtins.open", lambda _, __: io.StringIO("")) +@patch("UM.MimeTypeDatabase.MimeTypeDatabase.getMimeTypeForFile", + lambda _: MimeType(name = "application/x-ultimaker-material-profile", comment = "Ultimaker Material Profile", + suffixes = ["xml.fdm_material"])) +@patch("UM.Resources.Resources.getAllResourcesOfType", lambda _: ["/materials/generic_pla_white.xml.fdm_material"]) +@patch("plugins.UM3NetworkPrinting.src.ClusterUM3OutputDevice") +@patch("PyQt5.QtNetwork.QNetworkReply") +class TestSendMaterialJob(TestCase): + _LOCAL_MATERIAL_WHITE = {"type": "material", "status": "unknown", "id": "generic_pla_white", + "base_file": "generic_pla_white", "setting_version": "5", "name": "White PLA", + "brand": "Generic", "material": "PLA", "color_name": "White", + "GUID": "badb0ee7-87c8-4f3f-9398-938587b67dce", "version": "1", "color_code": "#ffffff", + "description": "Test PLA White", "adhesion_info": "Use glue.", "approximate_diameter": "3", + "properties": {"density": "1.00", "diameter": "2.85", "weight": "750"}, + "definition": "fdmprinter", "compatible": True} + + _LOCAL_MATERIAL_BLACK = {"type": "material", "status": "unknown", "id": "generic_pla_black", + "base_file": "generic_pla_black", "setting_version": "5", "name": "Yellow CPE", + "brand": "Ultimaker", "material": "CPE", "color_name": "Black", + "GUID": "5fbb362a-41f9-4818-bb43-15ea6df34aa4", "version": "1", "color_code": "#000000", + "description": "Test PLA Black", "adhesion_info": "Use glue.", "approximate_diameter": "3", + "properties": {"density": "1.01", "diameter": "2.85", "weight": "750"}, + "definition": "fdmprinter", "compatible": True} + + _REMOTE_MATERIAL_WHITE = { + "guid": "badb0ee7-87c8-4f3f-9398-938587b67dce", + "material": "PLA", + "brand": "Generic", + "version": 1, + "color": "White", + "density": 1.00 + } + + _REMOTE_MATERIAL_BLACK = { + "guid": "5fbb362a-41f9-4818-bb43-15ea6df34aa4", + "material": "PLA", + "brand": "Generic", + "version": 2, + "color": "Black", + "density": 1.00 + } + + def test_run(self, device_mock, reply_mock): + job = SendMaterialJob(device_mock) + job.run() + + # We expect the materials endpoint to be called when the job runs. + device_mock.get.assert_called_with("materials/", on_finished = job._onGetRemoteMaterials) + + def test__onGetRemoteMaterials_withFailedRequest(self, reply_mock, device_mock): + reply_mock.attribute.return_value = 404 + job = SendMaterialJob(device_mock) + job._onGetRemoteMaterials(reply_mock) + + # We expect the device not to be called for any follow up. + self.assertEqual(0, device_mock.createFormPart.call_count) + + def test__onGetRemoteMaterials_withWrongEncoding(self, reply_mock, device_mock): + reply_mock.attribute.return_value = 200 + reply_mock.readAll.return_value = QByteArray(json.dumps([self._REMOTE_MATERIAL_WHITE]).encode("cp500")) + job = SendMaterialJob(device_mock) + job._onGetRemoteMaterials(reply_mock) + + # Given that the parsing fails we do no expect the device to be called for any follow up. + self.assertEqual(0, device_mock.createFormPart.call_count) + + def test__onGetRemoteMaterials_withBadJsonAnswer(self, reply_mock, device_mock): + reply_mock.attribute.return_value = 200 + reply_mock.readAll.return_value = QByteArray(b"Six sick hicks nick six slick bricks with picks and sticks.") + job = SendMaterialJob(device_mock) + job._onGetRemoteMaterials(reply_mock) + + # Given that the parsing fails we do no expect the device to be called for any follow up. + self.assertEqual(0, device_mock.createFormPart.call_count) + + def test__onGetRemoteMaterials_withMissingGuidInRemoteMaterial(self, reply_mock, device_mock): + reply_mock.attribute.return_value = 200 + remote_material_without_guid = self._REMOTE_MATERIAL_WHITE.copy() + del remote_material_without_guid["guid"] + reply_mock.readAll.return_value = QByteArray(json.dumps([remote_material_without_guid]).encode("ascii")) + job = SendMaterialJob(device_mock) + job._onGetRemoteMaterials(reply_mock) + + # Given that parsing fails we do not expect the device to be called for any follow up. + self.assertEqual(0, device_mock.createFormPart.call_count) + + @patch("cura.Settings.CuraContainerRegistry") + @patch("UM.Application") + def test__onGetRemoteMaterials_withInvalidVersionInLocalMaterial(self, application_mock, container_registry_mock, + reply_mock, device_mock): + reply_mock.attribute.return_value = 200 + reply_mock.readAll.return_value = QByteArray(json.dumps([self._REMOTE_MATERIAL_WHITE]).encode("ascii")) + + localMaterialWhiteWithInvalidVersion = self._LOCAL_MATERIAL_WHITE.copy() + localMaterialWhiteWithInvalidVersion["version"] = "one" + container_registry_mock.findContainersMetadata.return_value = [localMaterialWhiteWithInvalidVersion] + + application_mock.getContainerRegistry.return_value = container_registry_mock + + with mock.patch.object(Application, "getInstance", new = lambda: application_mock): + job = SendMaterialJob(device_mock) + job._onGetRemoteMaterials(reply_mock) + + self.assertEqual(0, device_mock.createFormPart.call_count) + + @patch("cura.Settings.CuraContainerRegistry") + @patch("UM.Application") + def test__onGetRemoteMaterials_withNoUpdate(self, application_mock, container_registry_mock, reply_mock, + device_mock): + application_mock.getContainerRegistry.return_value = container_registry_mock + + device_mock.createFormPart.return_value = "_xXx_" + + container_registry_mock.findContainersMetadata.return_value = [self._LOCAL_MATERIAL_WHITE] + + reply_mock.attribute.return_value = 200 + reply_mock.readAll.return_value = QByteArray(json.dumps([self._REMOTE_MATERIAL_WHITE]).encode("ascii")) + + with mock.patch.object(Application, "getInstance", new = lambda: application_mock): + job = SendMaterialJob(device_mock) + job._onGetRemoteMaterials(reply_mock) + + self.assertEqual(0, device_mock.createFormPart.call_count) + self.assertEqual(0, device_mock.postFormWithParts.call_count) + + @patch("cura.Settings.CuraContainerRegistry") + @patch("UM.Application") + def test__onGetRemoteMaterials_withUpdatedMaterial(self, application_mock, container_registry_mock, reply_mock, + device_mock): + application_mock.getContainerRegistry.return_value = container_registry_mock + + device_mock.createFormPart.return_value = "_xXx_" + + localMaterialWhiteWithHigherVersion = self._LOCAL_MATERIAL_WHITE.copy() + localMaterialWhiteWithHigherVersion["version"] = "2" + container_registry_mock.findContainersMetadata.return_value = [localMaterialWhiteWithHigherVersion] + + reply_mock.attribute.return_value = 200 + reply_mock.readAll.return_value = QByteArray(json.dumps([self._REMOTE_MATERIAL_WHITE]).encode("ascii")) + + with mock.patch.object(Application, "getInstance", new = lambda: application_mock): + job = SendMaterialJob(device_mock) + job._onGetRemoteMaterials(reply_mock) + + self.assertEqual(1, device_mock.createFormPart.call_count) + self.assertEqual(1, device_mock.postFormWithParts.call_count) + self.assertEquals( + [call.createFormPart("name=\"file\"; filename=\"generic_pla_white.xml.fdm_material\"", ""), + call.postFormWithParts(target = "materials/", parts = ["_xXx_"], on_finished = job.sendingFinished)], + device_mock.method_calls) + + @patch("cura.Settings.CuraContainerRegistry") + @patch("UM.Application") + def test__onGetRemoteMaterials_withNewMaterial(self, application_mock, container_registry_mock, reply_mock, + device_mock): + application_mock.getContainerRegistry.return_value = container_registry_mock + + device_mock.createFormPart.return_value = "_xXx_" + + container_registry_mock.findContainersMetadata.return_value = [self._LOCAL_MATERIAL_WHITE, + self._LOCAL_MATERIAL_BLACK] + + reply_mock.attribute.return_value = 200 + reply_mock.readAll.return_value = QByteArray(json.dumps([self._REMOTE_MATERIAL_BLACK]).encode("ascii")) + + with mock.patch.object(Application, "getInstance", new = lambda: application_mock): + job = SendMaterialJob(device_mock) + job._onGetRemoteMaterials(reply_mock) + + self.assertEqual(1, device_mock.createFormPart.call_count) + self.assertEqual(1, device_mock.postFormWithParts.call_count) + self.assertEquals( + [call.createFormPart("name=\"file\"; filename=\"generic_pla_white.xml.fdm_material\"", ""), + call.postFormWithParts(target = "materials/", parts = ["_xXx_"], on_finished = job.sendingFinished)], + device_mock.method_calls) diff --git a/plugins/UM3NetworkPrinting/tests/conftest.py b/plugins/UM3NetworkPrinting/tests/conftest.py deleted file mode 100644 index ce49bd3cb7..0000000000 --- a/plugins/UM3NetworkPrinting/tests/conftest.py +++ /dev/null @@ -1,40 +0,0 @@ -# Copyright (c) 2018 Ultimaker B.V. -# Cura is released under the terms of the LGPLv3 or higher. - -import pytest -from UM.Signal import Signal - -from cura.CuraApplication import CuraApplication -from cura.Machines.MaterialManager import MaterialManager - - -# This mock application must extend from Application and not QtApplication otherwise some QObjects are created and -# a segfault is raised. -class FixtureApplication(CuraApplication): - def __init__(self): - super().__init__() - super().initialize() - Signal._signalQueue = self - - self.getPreferences().addPreference("cura/favorite_materials", "") - - self._material_manager = MaterialManager(self._container_registry, parent = self) - self._material_manager.initialize() - - def functionEvent(self, event): - event.call() - - def parseCommandLine(self): - pass - - def processEvents(self): - pass - - -@pytest.fixture(autouse=True) -def application(): - # Since we need to use it more that once, we create the application the first time and use its instance the second - application = FixtureApplication.getInstance() - if application is None: - application = FixtureApplication() - return application From 42ae9faeb4df483be003b6f45c7f91605402d54f Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Mon, 17 Dec 2018 14:42:16 +0100 Subject: [PATCH 127/178] STAR-322: Reverting change to run mypy --- run_mypy.py | 1 - 1 file changed, 1 deletion(-) diff --git a/run_mypy.py b/run_mypy.py index 2073f0e9a7..27f07cd281 100644 --- a/run_mypy.py +++ b/run_mypy.py @@ -29,7 +29,6 @@ def where(exe_name: str, search_path: str = os.getenv("PATH")) -> str: def findModules(path): - return ["UM3NetworkPrinting"] result = [] for entry in os.scandir(path): if entry.is_dir() and os.path.exists(os.path.join(path, entry.name, "__init__.py")): From da974d98686356eee9a39335334841d02fe68555 Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Mon, 17 Dec 2018 14:51:57 +0100 Subject: [PATCH 128/178] STAR-322: Improving documentation --- .../src/Cloud/CloudApiClient.py | 32 +++++++++++-------- .../tests/Cloud/NetworkManagerMock.py | 4 +-- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py index c6fb02753f..29a9f48c3f 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py @@ -72,12 +72,12 @@ class CloudApiClient: reply = self._manager.put(self._createEmptyRequest(url), body.encode()) self._addCallback(reply, on_finished, CloudPrintJobResponse) - ## Requests the cloud to register the upload of a print job mesh. - # \param upload_response: The object received after requesting an upload with `self.requestUpload`. + ## Uploads a print job mesh to the cloud. + # \param print_job: The object received after requesting an upload with `self.requestUpload`. # \param mesh: The mesh data to be uploaded. - # \param on_finished: The function to be called after the result is parsed. It receives the print job ID. + # \param on_finished: The function to be called after the upload is successful. # \param on_progress: A function to be called during upload progress. It receives a percentage (0-100). - # \param on_error: A function to be called if the upload fails. It receives a dict with the error. + # \param on_error: A function to be called if the upload fails. def uploadMesh(self, print_job: CloudPrintJobResponse, mesh: bytes, on_finished: Callable[[], Any], on_progress: Callable[[int], Any], on_error: Callable[[], Any]): self._upload = MeshUploader(self._manager, print_job, mesh, on_finished, on_progress, on_error) @@ -134,24 +134,28 @@ class CloudApiClient: data = response["data"] if isinstance(data, list): results = [model_class(**c) for c in data] # type: List[CloudApiClient.Model] - cast(Callable[[List[CloudApiClient.Model]], Any], on_finished)(results) + on_finished_list = cast(Callable[[List[CloudApiClient.Model]], Any], on_finished) + on_finished_list(results) else: result = model_class(**data) # type: CloudApiClient.Model - cast(Callable[[CloudApiClient.Model], Any], on_finished)(result) + on_finished_item = cast(Callable[[CloudApiClient.Model], Any], on_finished) + on_finished_item(result) elif "errors" in response: self._on_error([CloudErrorObject(**error) for error in response["errors"]]) else: Logger.log("e", "Cannot find data or errors in the cloud response: %s", response) - ## Wraps a callback function so that it includes the parsing of the response into the correct model. - # \param on_finished: The callback in case the response is successful. - # \param model: The type of the model to convert the response to. It may either be a single record or a list. - # \return: A function that can be passed to the + ## Creates a callback function so that it includes the parsing of the response into the correct model. + # The callback is added to the 'finished' signal of the reply. + # \param reply: The reply that should be listened to. + # \param on_finished: The callback in case the response is successful. Depending on the endpoint it will be either + # a list or a single item. + # \param model: The type of the model to convert the response to. def _addCallback(self, - reply: QNetworkReply, - on_finished: Union[Callable[[Model], Any], Callable[[List[Model]], Any]], - model: Type[Model], - ) -> None: + reply: QNetworkReply, + on_finished: Union[Callable[[Model], Any], Callable[[List[Model]], Any]], + model: Type[Model], + ) -> None: def parse() -> None: status_code, response = self._parseReply(reply) self._anti_gc_callbacks.remove(parse) diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py b/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py index 59b79fdfa6..5b5d89ca54 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py @@ -1,7 +1,7 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. import json -from typing import Dict, Tuple, Union, Optional +from typing import Dict, Tuple, Union, Optional, Any from unittest.mock import MagicMock from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest @@ -53,7 +53,7 @@ class NetworkManagerMock: # Since the methods are very simple and the same it didn't make sense to repeat the code. # \param method: The method being called. # \return The mocked function, if the method name is known. Defaults to the standard getattr function. - def __getattr__(self, method: str) -> any: + def __getattr__(self, method: str) -> Any: ## This mock implementation will simply return the reply from the prepared ones. # it raises a KeyError if requests are done without being prepared. def doRequest(request: QNetworkRequest, body: Optional[bytes] = None, *_): From d28ac5e1205f8b5d3083807c2975925c78052e9b Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Mon, 17 Dec 2018 14:57:57 +0100 Subject: [PATCH 129/178] STAR-322: Improving documentation --- .../src/Cloud/CloudOutputDevice.py | 19 +++++++++---------- .../src/Cloud/CloudOutputDeviceManager.py | 2 ++ 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index f29e29c40b..fed8cb040a 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -83,7 +83,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): ## Creates a new cloud output device # \param api_client: The client that will run the API calls - # \param device_id: The ID of the device (i.e. the cluster_id for the cloud API) + # \param cluster: The device response received from the cloud API. # \param parent: The optional parent of this output device. def __init__(self, api_client: CloudApiClient, cluster: CloudClusterResponse, parent: QObject = None) -> None: super().__init__(device_id = cluster.cluster_id, address = "", properties = {}, parent = parent) @@ -118,30 +118,33 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): # A set of the user's job IDs that have finished self._finished_jobs = set() # type: Set[str] - # Reference to the uploaded print job + # Reference to the uploaded print job / mesh self._mesh = None # type: Optional[bytes] self._uploaded_print_job = None # type: Optional[CloudPrintJobResponse] + ## Connects this device. def connect(self) -> None: super().connect() Logger.log("i", "Connected to cluster %s", self.key) CuraApplication.getInstance().getBackend().backendStateChange.connect(self._onBackendStateChange) + ## Disconnects the device def disconnect(self) -> None: super().disconnect() Logger.log("i", "Disconnected to cluster %s", self.key) CuraApplication.getInstance().getBackend().backendStateChange.disconnect(self._onBackendStateChange) + ## Resets the print job that was uploaded to force a new upload, runs whenever the user re-slices. def _onBackendStateChange(self, _: BackendState) -> None: self._mesh = None self._uploaded_print_job = None - ## Gets the host name of this device + ## Gets the cluster response from which this device was created. @property def clusterData(self) -> CloudClusterResponse: return self._cluster - ## Updates the host name of the output device + ## Updates the cluster data from the cloud. @clusterData.setter def clusterData(self, value: CloudClusterResponse) -> None: self._cluster = value @@ -166,11 +169,8 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): # Show an error message if we're already sending a job. if self._progress.visible: - Message( - text = T.BLOCKED_UPLOADING, - title = T.ERROR, - lifetime = 10, - ).show() + message = Message(text = T.BLOCKED_UPLOADING, title = T.ERROR, lifetime = 10) + message.show() return if self._uploaded_print_job: @@ -312,7 +312,6 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): model.updateAssignedPrinter(printer) ## Uploads the mesh when the print job was registered with the cloud API. - # \param mesh: The bytes to upload. # \param job_response: The response received from the cloud API. def _onPrintJobCreated(self, job_response: CloudPrintJobResponse) -> None: self._progress.show() diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 29c60fd14a..07051f15fd 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -139,6 +139,7 @@ class CloudOutputDeviceManager: ) message.show() + ## Starts running the cloud output device manager, thus periodically requesting cloud data. def start(self): if self._running: return @@ -149,6 +150,7 @@ class CloudOutputDeviceManager: self._update_timer.timeout.connect(self._getRemoteClusters) self._onLoginStateChanged(is_logged_in = self._account.isLoggedIn) + ## Stops running the cloud output device manager. def stop(self): if not self._running: return From c5f438819a60c547b9d416696a6fc4665b539aa3 Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Mon, 17 Dec 2018 15:11:01 +0100 Subject: [PATCH 130/178] STAR-322: Improving documentation --- .../src/Cloud/CloudProgressMessage.py | 5 ++ .../src/Cloud/MeshUploader.py | 54 ++++++++++++++----- 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudProgressMessage.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudProgressMessage.py index e3e0cefc0c..aefe59cc85 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudProgressMessage.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudProgressMessage.py @@ -12,6 +12,7 @@ class T: SENDING_DATA_TITLE = _I18N_CATALOG.i18nc("@info:status", "Sending data to remote cluster") +## Class responsible for showing a progress message while a mesh is being uploaded to the cloud. class CloudProgressMessage(Message): def __init__(self): super().__init__( @@ -23,15 +24,19 @@ class CloudProgressMessage(Message): use_inactivity_timer = False ) + ## Shows the progress message. def show(self): self.setProgress(0) super().show() + ## Updates the percentage of the uploaded. + # \param percentage: The percentage amount (0-100). def update(self, percentage: int) -> None: if not self._visible: super().show() self.setProgress(percentage) + ## Returns a boolean indicating whether the message is currently visible. @property def visible(self) -> bool: return self._visible diff --git a/plugins/UM3NetworkPrinting/src/Cloud/MeshUploader.py b/plugins/UM3NetworkPrinting/src/Cloud/MeshUploader.py index f0360b83e3..9d9662a82a 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/MeshUploader.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/MeshUploader.py @@ -9,16 +9,25 @@ from UM.Logger import Logger from src.Cloud.Models.CloudPrintJobResponse import CloudPrintJobResponse +## Class responsible for uploading meshes to the cloud in separate requests. class MeshUploader: + + # The maximum amount of times to retry if the server returns one of the RETRY_HTTP_CODES MAX_RETRIES = 10 - BYTES_PER_REQUEST = 256 * 1024 + + # The HTTP codes that should trigger a retry. RETRY_HTTP_CODES = {500, 502, 503, 504} - ## Creates a resumable upload - # \param url: The URL to which we shall upload. - # \param content_length: The total content length of the file, in bytes. - # \param http_method: The HTTP method to be used, e.g. "POST" or "PUT". - # \param timeout: The timeout for each chunk upload. Important: If None, no timeout is applied at all. + # The amount of bytes to send per request + BYTES_PER_REQUEST = 256 * 1024 + + ## Creates a mesh upload object. + # \param manager: The network access manager that will handle the HTTP requests. + # \param print_job: The print job response that was returned by the cloud after registering the upload. + # \param data: The mesh bytes to be uploaded. + # \param on_finished: The method to be called when done. + # \param on_progress: The method to be called when the progress changes (receives a percentage 0-100). + # \param on_error: The method to be called when an error occurs. def __init__(self, manager: QNetworkAccessManager, print_job: CloudPrintJobResponse, data: bytes, on_finished: Callable[[], Any], on_progress: Callable[[int], Any], on_error: Callable[[], Any] ) -> None: @@ -35,13 +44,12 @@ class MeshUploader: self._finished = False self._reply = None # type: Optional[QNetworkReply] + ## Returns the print job for which this object was created. @property def printJob(self): return self._print_job - ## We override _createRequest in order to add the user credentials. - # \param url: The URL to request - # \param content_type: The type of the body contents. + ## Creates a network request to the print job upload URL, adding the needed content range header. def _createRequest(self) -> QNetworkRequest: request = QNetworkRequest(QUrl(self._print_job.upload_url)) request.setHeader(QNetworkRequest.ContentTypeHeader, self._print_job.content_type) @@ -53,21 +61,27 @@ class MeshUploader: return request + ## Determines the bytes that should be uploaded next. + # \return: A tuple with the first and the last byte to upload. def _chunkRange(self) -> Tuple[int, int]: last_byte = min(len(self._data), self._sent_bytes + self.BYTES_PER_REQUEST) return self._sent_bytes, last_byte + ## Starts uploading the mesh. def start(self) -> None: if self._finished: + # reset state. self._sent_bytes = 0 self._retries = 0 self._finished = False self._uploadChunk() + ## Stops uploading the mesh, marking it as finished. def stop(self): Logger.log("i", "Stopped uploading") self._finished = True + ## Uploads a chunk of the mesh to the cloud. def _uploadChunk(self) -> None: if self._finished: raise ValueError("The upload is already finished") @@ -75,16 +89,22 @@ class MeshUploader: first_byte, last_byte = self._chunkRange() request = self._createRequest() + # now send the reply and subscribe to the results self._reply = self._manager.put(request, self._data[first_byte:last_byte]) self._reply.finished.connect(self._finishedCallback) self._reply.uploadProgress.connect(self._progressCallback) self._reply.error.connect(self._errorCallback) + ## Handles an update to the upload progress + # \param bytes_sent: The amount of bytes sent in the current request. + # \param bytes_total: The amount of bytes to send in the current request. def _progressCallback(self, bytes_sent: int, bytes_total: int) -> None: Logger.log("i", "Progress callback %s / %s", bytes_sent, bytes_total) if bytes_total: - self._on_progress(int((self._sent_bytes + bytes_sent) / len(self._data) * 100)) + total_sent = self._sent_bytes + bytes_sent + self._on_progress(int(total_sent / len(self._data) * 100)) + ## Handles an error uploading. def _errorCallback(self) -> None: reply = cast(QNetworkReply, self._reply) body = bytes(reply.readAll()).decode() @@ -92,27 +112,33 @@ class MeshUploader: self.stop() self._on_error() + ## Checks whether a chunk of data was uploaded successfully, starting the next chunk if needed. def _finishedCallback(self) -> None: reply = cast(QNetworkReply, self._reply) Logger.log("i", "Finished callback %s %s", reply.attribute(QNetworkRequest.HttpStatusCodeAttribute), reply.url().toString()) - status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) + status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) # type: int + # check if we should retry the last chunk if self._retries < self.MAX_RETRIES and status_code in self.RETRY_HTTP_CODES: self._retries += 1 Logger.log("i", "Retrying %s/%s request %s", self._retries, self.MAX_RETRIES, reply.url().toString()) self._uploadChunk() return + # Http codes that are not to be retried are assumed to be errors. if status_code > 308: self._errorCallback() return - body = bytes(reply.readAll()).decode() - Logger.log("w", "status_code: %s, Headers: %s, body: %s", status_code, - [bytes(header).decode() for header in reply.rawHeaderList()], body) + Logger.log("d", "status_code: %s, Headers: %s, body: %s", status_code, + [bytes(header).decode() for header in reply.rawHeaderList()], bytes(reply.readAll()).decode()) + self._chunkUploaded() + ## Handles a chunk of data being uploaded, starting the next chunk if needed. + def _chunkUploaded(self) -> None: + # We got a successful response. Let's start the next chunk or report the upload is finished. first_byte, last_byte = self._chunkRange() self._sent_bytes += last_byte - first_byte if self._sent_bytes >= len(self._data): From 74bec96ce810a569b02161dd0b14f7032194341f Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Mon, 17 Dec 2018 17:41:02 +0100 Subject: [PATCH 131/178] Fix a relative import --- plugins/UM3NetworkPrinting/src/Cloud/MeshUploader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/MeshUploader.py b/plugins/UM3NetworkPrinting/src/Cloud/MeshUploader.py index 9d9662a82a..cb721b872e 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/MeshUploader.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/MeshUploader.py @@ -6,7 +6,7 @@ from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply, QNetworkAccessManage from typing import Optional, Callable, Any, Tuple, cast from UM.Logger import Logger -from src.Cloud.Models.CloudPrintJobResponse import CloudPrintJobResponse +from .Models.CloudPrintJobResponse import CloudPrintJobResponse ## Class responsible for uploading meshes to the cloud in separate requests. From 0588c54035441d536f6afce236563e957a5b5114 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Tue, 18 Dec 2018 16:29:13 +0100 Subject: [PATCH 132/178] Start work on cloud icon --- cura/PrinterOutputDevice.py | 7 ++++++- .../UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py | 4 +++- resources/qml/PrinterSelector/MachineSelector.qml | 7 ++++--- .../cura-light/icons/printer_cloud_connected.svg | 10 ++++++++++ 4 files changed, 23 insertions(+), 5 deletions(-) create mode 100644 resources/themes/cura-light/icons/printer_cloud_connected.svg diff --git a/cura/PrinterOutputDevice.py b/cura/PrinterOutputDevice.py index d16242d9c2..44c564d03a 100644 --- a/cura/PrinterOutputDevice.py +++ b/cura/PrinterOutputDevice.py @@ -56,6 +56,7 @@ class PrinterOutputDevice(QObject, OutputDevice): # Put ConnectionType here with Q_ENUMS() so it can be registered as a QML type and accessible via QML, and there is # no need to remember what those Enum integer values mean. + ConnectionType = ConnectionType Q_ENUMS(ConnectionType) printersChanged = pyqtSignal() @@ -133,7 +134,11 @@ class PrinterOutputDevice(QObject, OutputDevice): def getConnectionType(self) -> "ConnectionType": return self._connection_type - @pyqtProperty(str, notify = connectionStateChanged) + @pyqtProperty(int, constant = True) + def connectionType(self) -> "ConnectionType": + return self._connection_type + + @pyqtProperty(int, notify = connectionStateChanged) def connectionState(self) -> "ConnectionState": return self._connection_state diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index fed8cb040a..7e9608dc8b 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -17,6 +17,7 @@ from UM.Scene.SceneNode import SceneNode from cura.CuraApplication import CuraApplication from cura.PrinterOutput.NetworkedPrinterOutputDevice import AuthState, NetworkedPrinterOutputDevice from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel +from cura.PrinterOutputDevice import ConnectionType from plugins.UM3NetworkPrinting.src.Cloud.CloudOutputController import CloudOutputController from ..MeshFormatHandler import MeshFormatHandler from ..UM3PrintJobOutputModel import UM3PrintJobOutputModel @@ -86,7 +87,8 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): # \param cluster: The device response received from the cloud API. # \param parent: The optional parent of this output device. def __init__(self, api_client: CloudApiClient, cluster: CloudClusterResponse, parent: QObject = None) -> None: - super().__init__(device_id = cluster.cluster_id, address = "", properties = {}, parent = parent) + super().__init__(device_id = cluster.cluster_id, address = "", + connection_type = ConnectionType.CloudConnection, properties = {}, parent = parent) self._api = api_client self._cluster = cluster diff --git a/resources/qml/PrinterSelector/MachineSelector.qml b/resources/qml/PrinterSelector/MachineSelector.qml index 28e01c7ae9..3804c04025 100644 --- a/resources/qml/PrinterSelector/MachineSelector.qml +++ b/resources/qml/PrinterSelector/MachineSelector.qml @@ -11,9 +11,10 @@ Cura.ExpandablePopup { id: machineSelector - property bool isNetworkPrinter: Cura.MachineManager.activeMachineHasRemoteConnection - property bool isPrinterConnected: Cura.MachineManager.printerConnected property var outputDevice: Cura.MachineManager.printerOutputDevices.length >= 1 ? Cura.MachineManager.printerOutputDevices[0] : null + property bool isNetworkPrinter: Cura.MachineManager.activeMachineHasRemoteConnection + property bool isCloudPrinter: machineSelector.outputDevice.connectionType == Cura.PrinterOutputDevice.CloudConnection + property bool isPrinterConnected: Cura.MachineManager.printerConnected contentPadding: UM.Theme.getSize("default_lining").width contentAlignment: Cura.ExpandablePopup.ContentAlignment.AlignLeft @@ -52,7 +53,7 @@ Cura.ExpandablePopup leftMargin: UM.Theme.getSize("thick_margin").width } - source: UM.Theme.getIcon("printer_connected") + source: machineSelector.isCloudPrinter ? UM.Theme.getIcon("printer_connected") : UM.Theme.getIcon("printer_connected") width: UM.Theme.getSize("printer_status_icon").width height: UM.Theme.getSize("printer_status_icon").height diff --git a/resources/themes/cura-light/icons/printer_cloud_connected.svg b/resources/themes/cura-light/icons/printer_cloud_connected.svg new file mode 100644 index 0000000000..6b2c814786 --- /dev/null +++ b/resources/themes/cura-light/icons/printer_cloud_connected.svg @@ -0,0 +1,10 @@ + + + + Artboard Copy 2 + Created with Sketch. + + + + + \ No newline at end of file From 4bf24671a21d3b17ed55548200198180eb71e52c Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Tue, 18 Dec 2018 16:33:39 +0100 Subject: [PATCH 133/178] Use the (not final) cloud icon when cloud connected --- resources/qml/PrinterSelector/MachineSelector.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/qml/PrinterSelector/MachineSelector.qml b/resources/qml/PrinterSelector/MachineSelector.qml index 3804c04025..13ebae4ac5 100644 --- a/resources/qml/PrinterSelector/MachineSelector.qml +++ b/resources/qml/PrinterSelector/MachineSelector.qml @@ -53,7 +53,7 @@ Cura.ExpandablePopup leftMargin: UM.Theme.getSize("thick_margin").width } - source: machineSelector.isCloudPrinter ? UM.Theme.getIcon("printer_connected") : UM.Theme.getIcon("printer_connected") + source: machineSelector.isCloudPrinter ? UM.Theme.getIcon("printer_cloud_connected") : UM.Theme.getIcon("printer_connected") width: UM.Theme.getSize("printer_status_icon").width height: UM.Theme.getSize("printer_status_icon").height From 5e9fe3fe500f7dca38139672fb58cdb6a28014d2 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Wed, 19 Dec 2018 11:21:50 +0100 Subject: [PATCH 134/178] Fix some codestyle, make connectionType a property as it's needed in QML --- cura/PrinterOutputDevice.py | 31 +++++++++---------- .../src/DiscoverUM3Action.py | 6 ++-- .../src/UM3OutputDevicePlugin.py | 2 +- 3 files changed, 18 insertions(+), 21 deletions(-) diff --git a/cura/PrinterOutputDevice.py b/cura/PrinterOutputDevice.py index 44c564d03a..54fbf3a7f2 100644 --- a/cura/PrinterOutputDevice.py +++ b/cura/PrinterOutputDevice.py @@ -81,28 +81,28 @@ class PrinterOutputDevice(QObject, OutputDevice): self._printers = [] # type: List[PrinterOutputModel] self._unique_configurations = [] # type: List[ConfigurationModel] - self._monitor_view_qml_path = "" #type: str - self._monitor_component = None #type: Optional[QObject] - self._monitor_item = None #type: Optional[QObject] + self._monitor_view_qml_path = "" # type: str + self._monitor_component = None # type: Optional[QObject] + self._monitor_item = None # type: Optional[QObject] - self._control_view_qml_path = "" #type: str - self._control_component = None #type: Optional[QObject] - self._control_item = None #type: Optional[QObject] + self._control_view_qml_path = "" # type: str + self._control_component = None # type: Optional[QObject] + self._control_item = None # type: Optional[QObject] - self._accepts_commands = False #type: bool + self._accepts_commands = False # type: bool - self._update_timer = QTimer() #type: QTimer + self._update_timer = QTimer() # type: QTimer self._update_timer.setInterval(2000) # TODO; Add preference for update interval self._update_timer.setSingleShot(False) self._update_timer.timeout.connect(self._update) - self._connection_state = ConnectionState.Closed #type: ConnectionState - self._connection_type = connection_type + self._connection_state = ConnectionState.Closed # type: ConnectionState + self._connection_type = connection_type # type: ConnectionType - self._firmware_updater = None #type: Optional[FirmwareUpdater] - self._firmware_name = None #type: Optional[str] - self._address = "" #type: str - self._connection_text = "" #type: str + self._firmware_updater = None # type: Optional[FirmwareUpdater] + self._firmware_name = None # type: Optional[str] + self._address = "" # type: str + self._connection_text = "" # type: str self.printersChanged.connect(self._onPrintersChanged) QtApplication.getInstance().getOutputDeviceManager().outputDevicesChanged.connect(self._updateUniqueConfigurations) @@ -131,9 +131,6 @@ class PrinterOutputDevice(QObject, OutputDevice): self._connection_state = connection_state self.connectionStateChanged.emit(self._id) - def getConnectionType(self) -> "ConnectionType": - return self._connection_type - @pyqtProperty(int, constant = True) def connectionType(self) -> "ConnectionType": return self._connection_type diff --git a/plugins/UM3NetworkPrinting/src/DiscoverUM3Action.py b/plugins/UM3NetworkPrinting/src/DiscoverUM3Action.py index 6ce99e4891..6b016c4f42 100644 --- a/plugins/UM3NetworkPrinting/src/DiscoverUM3Action.py +++ b/plugins/UM3NetworkPrinting/src/DiscoverUM3Action.py @@ -138,11 +138,11 @@ class DiscoverUM3Action(MachineAction): if "connection_type" in meta_data: previous_connection_type = meta_data["connection_type"] - global_container_stack.setMetaDataEntry("connection_type", printer_device.getConnectionType().value) - CuraApplication.getInstance().getMachineManager().replaceContainersMetadata(key = "connection_type", value = previous_connection_type, new_value = printer_device.getConnectionType().value) + global_container_stack.setMetaDataEntry("connection_type", printer_device.connectionType.value) + CuraApplication.getInstance().getMachineManager().replaceContainersMetadata(key = "connection_type", value = previous_connection_type, new_value = printer_device.connectionType.value) else: global_container_stack.setMetaDataEntry("um_network_key", printer_device.key) - global_container_stack.setMetaDataEntry("connection_type", printer_device.getConnectionType().value) + global_container_stack.setMetaDataEntry("connection_type", printer_device.connectionType.value) if self._network_plugin: # Ensure that the connection states are refreshed. diff --git a/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py b/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py index 4642811f39..e7e6b35978 100644 --- a/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py +++ b/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py @@ -288,7 +288,7 @@ class UM3OutputDevicePlugin(OutputDevicePlugin): global_container_stack = Application.getInstance().getGlobalContainerStack() if global_container_stack and device.getId() == global_container_stack.getMetaDataEntry("um_network_key"): - global_container_stack.setMetaDataEntry("connection_type", device.getConnectionType().value) + global_container_stack.setMetaDataEntry("connection_type", device.connectionType.value) device.connect() device.connectionStateChanged.connect(self._onDeviceConnectionStateChanged) From 1b356a321958437c2781a475800ee9bb12f67c44 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Wed, 19 Dec 2018 11:41:22 +0100 Subject: [PATCH 135/178] Move cloud connection check logic to Python --- cura/Settings/MachineManager.py | 9 +++++++++ resources/qml/PrinterSelector/MachineSelector.qml | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/cura/Settings/MachineManager.py b/cura/Settings/MachineManager.py index cd8ca09447..669a5a7cf7 100755 --- a/cura/Settings/MachineManager.py +++ b/cura/Settings/MachineManager.py @@ -530,6 +530,15 @@ class MachineManager(QObject): return connection_type in [ConnectionType.NetworkConnection.value, ConnectionType.CloudConnection.value] return False + @pyqtProperty(bool, notify = printerConnectedStatusChanged) + def activeMachineHasCloudConnection(self) -> bool: + if not self.activeMachineHasRemoteConnection: + return False + output_device = next(iter(self.printerOutputDevices), None) # type: PrinterOutputDevice + if not output_device: + return False + return output_device.connectionType == ConnectionType.CloudConnection + def activeMachineNetworkKey(self) -> str: if self._global_container_stack: return self._global_container_stack.getMetaDataEntry("um_network_key", "") diff --git a/resources/qml/PrinterSelector/MachineSelector.qml b/resources/qml/PrinterSelector/MachineSelector.qml index 13ebae4ac5..bb8934f620 100644 --- a/resources/qml/PrinterSelector/MachineSelector.qml +++ b/resources/qml/PrinterSelector/MachineSelector.qml @@ -13,7 +13,7 @@ Cura.ExpandablePopup property var outputDevice: Cura.MachineManager.printerOutputDevices.length >= 1 ? Cura.MachineManager.printerOutputDevices[0] : null property bool isNetworkPrinter: Cura.MachineManager.activeMachineHasRemoteConnection - property bool isCloudPrinter: machineSelector.outputDevice.connectionType == Cura.PrinterOutputDevice.CloudConnection + property bool isCloudPrinter: Cura.MachineManager.activeMachineHasCloudConnection property bool isPrinterConnected: Cura.MachineManager.printerConnected contentPadding: UM.Theme.getSize("default_lining").width From eb1bde05166a64ac860e4c07ca056448826bab82 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Wed, 19 Dec 2018 11:42:17 +0100 Subject: [PATCH 136/178] Remove Q_ENUMS for connection type, not used in QML anymore --- cura/PrinterOutputDevice.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/cura/PrinterOutputDevice.py b/cura/PrinterOutputDevice.py index 54fbf3a7f2..2d95b21c8a 100644 --- a/cura/PrinterOutputDevice.py +++ b/cura/PrinterOutputDevice.py @@ -54,11 +54,6 @@ class ConnectionType(IntEnum): @signalemitter class PrinterOutputDevice(QObject, OutputDevice): - # Put ConnectionType here with Q_ENUMS() so it can be registered as a QML type and accessible via QML, and there is - # no need to remember what those Enum integer values mean. - ConnectionType = ConnectionType - Q_ENUMS(ConnectionType) - printersChanged = pyqtSignal() connectionStateChanged = pyqtSignal(str) acceptsCommandsChanged = pyqtSignal() From 112950f00319b69c09f0d532b4edc556178ee212 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Wed, 19 Dec 2018 11:45:36 +0100 Subject: [PATCH 137/178] Remove unused import --- cura/PrinterOutputDevice.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/cura/PrinterOutputDevice.py b/cura/PrinterOutputDevice.py index 2d95b21c8a..723a255133 100644 --- a/cura/PrinterOutputDevice.py +++ b/cura/PrinterOutputDevice.py @@ -1,10 +1,12 @@ # Copyright (c) 2018 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 UM.Decorators import deprecated from UM.i18n import i18nCatalog from UM.OutputDevice.OutputDevice import OutputDevice -from PyQt5.QtCore import pyqtProperty, pyqtSignal, QObject, QTimer, QUrl, Q_ENUMS +from PyQt5.QtCore import pyqtProperty, pyqtSignal, QObject, QTimer, QUrl from PyQt5.QtWidgets import QMessageBox from UM.Logger import Logger @@ -12,9 +14,6 @@ from UM.Signal import signalemitter from UM.Qt.QtApplication import QtApplication from UM.FlameProfiler import pyqtSlot -from enum import IntEnum # For the connection state tracking. -from typing import Callable, List, Optional, Union - MYPY = False if MYPY: from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel From 80d2a784634f74039594fdf17be92ee7263f6e2b Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Wed, 19 Dec 2018 11:47:41 +0100 Subject: [PATCH 138/178] Fix optional type for output device --- cura/Settings/MachineManager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cura/Settings/MachineManager.py b/cura/Settings/MachineManager.py index 669a5a7cf7..f47724372f 100755 --- a/cura/Settings/MachineManager.py +++ b/cura/Settings/MachineManager.py @@ -534,7 +534,7 @@ class MachineManager(QObject): def activeMachineHasCloudConnection(self) -> bool: if not self.activeMachineHasRemoteConnection: return False - output_device = next(iter(self.printerOutputDevices), None) # type: PrinterOutputDevice + output_device = next(iter(self.printerOutputDevices), None) # type: Optional[PrinterOutputDevice] if not output_device: return False return output_device.connectionType == ConnectionType.CloudConnection From d2746d03c1a5786b0d1fef1d41d2abfff79939c7 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Wed, 19 Dec 2018 11:56:43 +0100 Subject: [PATCH 139/178] Fix type checking for DiscoverUM3Action --- .../src/DiscoverUM3Action.py | 40 +++++++++++-------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/DiscoverUM3Action.py b/plugins/UM3NetworkPrinting/src/DiscoverUM3Action.py index 6b016c4f42..c02c18e942 100644 --- a/plugins/UM3NetworkPrinting/src/DiscoverUM3Action.py +++ b/plugins/UM3NetworkPrinting/src/DiscoverUM3Action.py @@ -124,25 +124,31 @@ class DiscoverUM3Action(MachineAction): @pyqtSlot(QObject) def associateActiveMachineWithPrinterDevice(self, printer_device: Optional["PrinterOutputDevice"]) -> None: Logger.log("d", "Attempting to set the network key of the active machine to %s", printer_device.key) - global_container_stack = CuraApplication.getInstance().getGlobalContainerStack() - if global_container_stack: - meta_data = global_container_stack.getMetaData() - if "um_network_key" in meta_data: - previous_network_key= meta_data["um_network_key"] - global_container_stack.setMetaDataEntry("um_network_key", printer_device.key) - # Delete old authentication data. - Logger.log("d", "Removing old authentication id %s for device %s", global_container_stack.getMetaDataEntry("network_authentication_id", None), printer_device.key) - global_container_stack.removeMetaDataEntry("network_authentication_id") - global_container_stack.removeMetaDataEntry("network_authentication_key") - CuraApplication.getInstance().getMachineManager().replaceContainersMetadata(key = "um_network_key", value = previous_network_key, new_value = printer_device.key) + if not printer_device: + return - if "connection_type" in meta_data: - previous_connection_type = meta_data["connection_type"] - global_container_stack.setMetaDataEntry("connection_type", printer_device.connectionType.value) - CuraApplication.getInstance().getMachineManager().replaceContainersMetadata(key = "connection_type", value = previous_connection_type, new_value = printer_device.connectionType.value) - else: - global_container_stack.setMetaDataEntry("um_network_key", printer_device.key) + global_container_stack = CuraApplication.getInstance().getGlobalContainerStack() + if not global_container_stack: + return + + meta_data = global_container_stack.getMetaData() + if "um_network_key" in meta_data: + previous_network_key = meta_data["um_network_key"] + global_container_stack.setMetaDataEntry("um_network_key", printer_device.key) + # Delete old authentication data. + Logger.log("d", "Removing old authentication id %s for device %s", + global_container_stack.getMetaDataEntry("network_authentication_id", None), printer_device.key) + global_container_stack.removeMetaDataEntry("network_authentication_id") + global_container_stack.removeMetaDataEntry("network_authentication_key") + CuraApplication.getInstance().getMachineManager().replaceContainersMetadata(key = "um_network_key", value = previous_network_key, new_value = printer_device.key) + + if "connection_type" in meta_data: + previous_connection_type = meta_data["connection_type"] global_container_stack.setMetaDataEntry("connection_type", printer_device.connectionType.value) + CuraApplication.getInstance().getMachineManager().replaceContainersMetadata(key = "connection_type", value = previous_connection_type, new_value = printer_device.connectionType.value) + else: + global_container_stack.setMetaDataEntry("um_network_key", printer_device.key) + global_container_stack.setMetaDataEntry("connection_type", printer_device.connectionType.value) if self._network_plugin: # Ensure that the connection states are refreshed. From c4a8545c45717547449c0c03c39c3cd25acb9bc2 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Wed, 19 Dec 2018 12:05:07 +0100 Subject: [PATCH 140/178] Add final cloud connected icon to printer selector --- .../themes/cura-light/icons/printer_cloud_connected.svg | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/resources/themes/cura-light/icons/printer_cloud_connected.svg b/resources/themes/cura-light/icons/printer_cloud_connected.svg index 6b2c814786..3bc94a05e7 100644 --- a/resources/themes/cura-light/icons/printer_cloud_connected.svg +++ b/resources/themes/cura-light/icons/printer_cloud_connected.svg @@ -1,10 +1,11 @@ - + Artboard Copy 2 Created with Sketch. - - + + + \ No newline at end of file From cf06cb5351f7ec3c08ac1bd811334822406bfb1a Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Wed, 19 Dec 2018 12:10:24 +0100 Subject: [PATCH 141/178] Do not call printer_device.key before checking if it exists --- plugins/UM3NetworkPrinting/src/DiscoverUM3Action.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/UM3NetworkPrinting/src/DiscoverUM3Action.py b/plugins/UM3NetworkPrinting/src/DiscoverUM3Action.py index c02c18e942..c88848317a 100644 --- a/plugins/UM3NetworkPrinting/src/DiscoverUM3Action.py +++ b/plugins/UM3NetworkPrinting/src/DiscoverUM3Action.py @@ -123,10 +123,11 @@ class DiscoverUM3Action(MachineAction): # stored into the metadata of the currently active machine. @pyqtSlot(QObject) def associateActiveMachineWithPrinterDevice(self, printer_device: Optional["PrinterOutputDevice"]) -> None: - Logger.log("d", "Attempting to set the network key of the active machine to %s", printer_device.key) if not printer_device: return + Logger.log("d", "Attempting to set the network key of the active machine to %s", printer_device.key) + global_container_stack = CuraApplication.getInstance().getGlobalContainerStack() if not global_container_stack: return From beb68213f403eb6f2f3318ce2fb058d3aa83bdc5 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Wed, 19 Dec 2018 14:14:44 +0100 Subject: [PATCH 142/178] Remove much logging or use debug level, fix cloud icon not appearing right away --- cura/Settings/MachineManager.py | 10 ++++++---- .../UM3NetworkPrinting/src/Cloud/CloudApiClient.py | 4 ++-- .../src/Cloud/CloudOutputDevice.py | 7 ++----- .../src/Cloud/CloudOutputDeviceManager.py | 12 ++++++------ 4 files changed, 16 insertions(+), 17 deletions(-) diff --git a/cura/Settings/MachineManager.py b/cura/Settings/MachineManager.py index f47724372f..bdb0f10082 100755 --- a/cura/Settings/MachineManager.py +++ b/cura/Settings/MachineManager.py @@ -178,6 +178,7 @@ class MachineManager(QObject): self._printer_output_devices.append(printer_output_device) self.outputDevicesChanged.emit() + self.printerConnectedStatusChanged.emit() @pyqtProperty(QObject, notify = currentConfigurationChanged) def currentConfiguration(self) -> ConfigurationModel: @@ -520,7 +521,7 @@ class MachineManager(QObject): return "" @pyqtProperty(bool, notify = printerConnectedStatusChanged) - def printerConnected(self): + def printerConnected(self) -> bool: return bool(self._printer_output_devices) @pyqtProperty(bool, notify = printerConnectedStatusChanged) @@ -532,9 +533,10 @@ class MachineManager(QObject): @pyqtProperty(bool, notify = printerConnectedStatusChanged) def activeMachineHasCloudConnection(self) -> bool: - if not self.activeMachineHasRemoteConnection: - return False - output_device = next(iter(self.printerOutputDevices), None) # type: Optional[PrinterOutputDevice] + # A cloud connection is only available if the active output device actually is a cloud connected device. + # We cannot simply use the connection_type metadata entry as that's always set to 'NetworkConnection' + # if there was a network connection during setup, which is always the case. + output_device = next(iter(self._printer_output_devices), None) # type: Optional[PrinterOutputDevice] if not output_device: return False return output_device.connectionType == ConnectionType.CloudConnection diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py index 29a9f48c3f..302ca86d32 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py @@ -101,7 +101,7 @@ class CloudApiClient: request.setHeader(QNetworkRequest.ContentTypeHeader, content_type) if self._account.isLoggedIn: request.setRawHeader(b"Authorization", "Bearer {}".format(self._account.accessToken).encode()) - Logger.log("i", "Created request for URL %s. Logged in = %s", path, self._account.isLoggedIn) + # Logger.log("i", "Created request for URL %s. Logged in = %s", path, self._account.isLoggedIn) return request ## Parses the given JSON network reply into a status code and a dictionary, handling unexpected errors as well. @@ -112,7 +112,7 @@ class CloudApiClient: status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) try: response = bytes(reply.readAll()).decode() - Logger.log("i", "Received a reply %s from %s with %s", status_code, reply.url().toString(), response) + # Logger.log("i", "Received a reply %s from %s with %s", status_code, reply.url().toString(), response) return status_code, json.loads(response) except (UnicodeDecodeError, JSONDecodeError, ValueError) as err: error = CloudErrorObject(code=type(err).__name__, title=str(err), http_code=str(status_code), diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index 7e9608dc8b..c436418f5e 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -65,8 +65,6 @@ class T: # Currently it only supports viewing the printer and print job status and adding a new job to the queue. # As such, those methods have been implemented here. # Note that this device represents a single remote cluster, not a list of multiple clusters. -# -# TODO: figure our how the QML interface for the cluster networking should operate with this limited functionality. class CloudOutputDevice(NetworkedPrinterOutputDevice): # The interval with which the remote clusters are checked @@ -202,10 +200,9 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): def _update(self) -> None: super()._update() if self._last_request_time and time() - self._last_request_time < self.CHECK_CLUSTER_INTERVAL: - Logger.log("i", "Not updating: %s - %s < %s", time(), self._last_request_time, self.CHECK_CLUSTER_INTERVAL) return # avoid calling the cloud too often - Logger.log("i", "Updating: %s - %s >= %s", time(), self._last_request_time, self.CHECK_CLUSTER_INTERVAL) + Logger.log("d", "Updating: %s - %s >= %s", time(), self._last_request_time, self.CHECK_CLUSTER_INTERVAL) if self._account.isLoggedIn: self.setAuthenticationState(AuthState.Authenticated) self._last_request_time = time() @@ -342,7 +339,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): ## Shows a message when the upload has succeeded # \param response: The response from the cloud API. def _onPrintRequested(self, response: CloudPrintResponse) -> None: - Logger.log("i", "The cluster will be printing this print job with the ID %s", response.cluster_job_id) + Logger.log("d", "The cluster will be printing this print job with the ID %s", response.cluster_job_id) self._progress.hide() Message( text = T.UPLOAD_SUCCESS_TEXT, diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 07051f15fd..af45f06394 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -50,7 +50,7 @@ class CloudOutputDeviceManager: # Called when the uses logs in or out def _onLoginStateChanged(self, is_logged_in: bool) -> None: - Logger.log("i", "Log in state changed to %s", is_logged_in) + Logger.log("d", "Log in state changed to %s", is_logged_in) if is_logged_in: if not self._update_timer.isActive(): self._update_timer.start() @@ -64,7 +64,7 @@ class CloudOutputDeviceManager: ## Gets all remote clusters from the API. def _getRemoteClusters(self) -> None: - Logger.log("i", "Retrieving remote clusters") + Logger.log("d", "Retrieving remote clusters") self._api.getClusters(self._onGetRemoteClustersFinished) ## Callback for when the request for getting the clusters. is finished. @@ -73,8 +73,8 @@ class CloudOutputDeviceManager: removed_devices, added_clusters, updates = findChanges(self._remote_clusters, online_clusters) - Logger.log("i", "Parsed remote clusters to %s", [cluster.toDict() for cluster in online_clusters.values()]) - Logger.log("i", "Removed: %s, added: %s, updates: %s", len(removed_devices), len(added_clusters), len(updates)) + Logger.log("d", "Parsed remote clusters to %s", [cluster.toDict() for cluster in online_clusters.values()]) + Logger.log("d", "Removed: %s, added: %s, updates: %s", len(removed_devices), len(added_clusters), len(updates)) # Remove output devices that are gone for removed_cluster in removed_devices: @@ -100,7 +100,7 @@ class CloudOutputDeviceManager: def _connectToActiveMachine(self) -> None: active_machine = CuraApplication.getInstance().getGlobalContainerStack() if not active_machine: - Logger.log("i", "no active machine") + Logger.log("d", "no active machine") return # Check if the stored cluster_id for the active machine is in our list of remote clusters. @@ -109,7 +109,7 @@ class CloudOutputDeviceManager: device = self._remote_clusters[stored_cluster_id] if not device.isConnected(): device.connect() - Logger.log("i", "Device connected by metadata %s", stored_cluster_id) + Logger.log("d", "Device connected by metadata %s", stored_cluster_id) else: self._connectByNetworkKey(active_machine) From 2f92f6ef50faba4b8c3875481b60e446bc6bc83b Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Thu, 20 Dec 2018 13:45:59 +0100 Subject: [PATCH 143/178] Simplify checking if cloud or network printer, small fixes --- .../NetworkedPrinterOutputDevice.py | 5 +++++ cura/Settings/MachineManager.py | 16 +++++++------- .../src/Cloud/CloudOutputDevice.py | 21 +++++++------------ .../src/ClusterUM3OutputDevice.py | 6 ++---- .../qml/PrinterSelector/MachineSelector.qml | 4 ++-- 5 files changed, 24 insertions(+), 28 deletions(-) diff --git a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py index d0a0b5076a..3dcc43dd00 100644 --- a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py +++ b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py @@ -150,6 +150,11 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): request.setHeader(QNetworkRequest.UserAgentHeader, self._user_agent) return request + ## This method was only available privately before, but it was actually called from SendMaterialJob.py. + # We now have a public equivalent as well. We did not remove the private one as plugins might be using that. + def createFormPart(self, content_header: str, data: bytes, content_type: Optional[str] = None) -> QHttpPart: + return self._createFormPart(content_header, data, content_type) + def _createFormPart(self, content_header: str, data: bytes, content_type: Optional[str] = None) -> QHttpPart: part = QHttpPart() diff --git a/cura/Settings/MachineManager.py b/cura/Settings/MachineManager.py index 8d42ee59a2..66ee7f9543 100755 --- a/cura/Settings/MachineManager.py +++ b/cura/Settings/MachineManager.py @@ -533,14 +533,14 @@ class MachineManager(QObject): return False @pyqtProperty(bool, notify = printerConnectedStatusChanged) - def activeMachineHasCloudConnection(self) -> bool: - # A cloud connection is only available if the active output device actually is a cloud connected device. - # We cannot simply use the connection_type metadata entry as that's always set to 'NetworkConnection' - # if there was a network connection during setup, which is always the case. - output_device = next(iter(self._printer_output_devices), None) # type: Optional[PrinterOutputDevice] - if not output_device: - return False - return output_device.connectionType == ConnectionType.CloudConnection + def activeMachineHasActiveNetworkConnection(self) -> bool: + # A network connection is only available if any output device is actually a network connected device. + return any(d.connectionType == ConnectionType.NetworkConnection for d in self._printer_output_devices) + + @pyqtProperty(bool, notify = printerConnectedStatusChanged) + def activeMachineHasActiveCloudConnection(self) -> bool: + # A cloud connection is only available if any output device actually is a cloud connected device. + return any(d.connectionType == ConnectionType.CloudConnection for d in self._printer_output_devices) def activeMachineNetworkKey(self) -> str: if self._global_container_stack: diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index c436418f5e..093aa05ea9 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -246,7 +246,8 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): if self._printers and not self._active_printer: self.setActivePrinter(self._printers[0]) - self.printersChanged.emit() # TODO: Make this more efficient by not updating every request + if added_printers or removed_printers or updated_printers: + self.printersChanged.emit() ## Updates the local list of print jobs with the list received from the cloud. # \param jobs: The print jobs received from the cloud. @@ -302,10 +303,10 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): ## Updates the printer assignment for the given print job model. def _updateAssignedPrinter(self, model: UM3PrintJobOutputModel, printer_uuid: str) -> None: printer = next((p for p in self._printers if printer_uuid == p.key), None) - if not printer: - return Logger.log("w", "Missing printer %s for job %s in %s", model.assignedPrinter, model.key, - [p.key for p in self._printers]) + Logger.log("w", "Missing printer %s for job %s in %s", model.assignedPrinter, model.key, + [p.key for p in self._printers]) + return printer.updateActivePrintJob(model) model.updateAssignedPrinter(printer) @@ -329,11 +330,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): def _onUploadError(self, message = None) -> None: self._progress.hide() self._uploaded_print_job = None - Message( - text = message or T.UPLOAD_ERROR, - title = T.ERROR, - lifetime = 10 - ).show() + Message(text = message or T.UPLOAD_ERROR, title = T.ERROR, lifetime = 10).show() self.writeError.emit() ## Shows a message when the upload has succeeded @@ -341,11 +338,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): def _onPrintRequested(self, response: CloudPrintResponse) -> None: Logger.log("d", "The cluster will be printing this print job with the ID %s", response.cluster_job_id) self._progress.hide() - Message( - text = T.UPLOAD_SUCCESS_TEXT, - title = T.UPLOAD_SUCCESS_TITLE, - lifetime = 5 - ).show() + Message(text = T.UPLOAD_SUCCESS_TEXT, title = T.UPLOAD_SUCCESS_TITLE, lifetime = 5).show() self.writeFinished.emit() ## Gets the remote printers. diff --git a/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py b/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py index a9a002aed7..75fd1e0f9e 100644 --- a/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py @@ -389,10 +389,8 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): ## Called when the connection to the cluster changes. def connect(self) -> None: - # TODO: uncomment this once cloud implementation works for testing - # super().connect() - # self.sendMaterialProfiles() - pass + super().connect() + self.sendMaterialProfiles() def _onGetPreviewImageFinished(self, reply: QNetworkReply) -> None: reply_url = reply.url().toString() diff --git a/resources/qml/PrinterSelector/MachineSelector.qml b/resources/qml/PrinterSelector/MachineSelector.qml index bb8934f620..64cf3e6005 100644 --- a/resources/qml/PrinterSelector/MachineSelector.qml +++ b/resources/qml/PrinterSelector/MachineSelector.qml @@ -12,9 +12,9 @@ Cura.ExpandablePopup id: machineSelector property var outputDevice: Cura.MachineManager.printerOutputDevices.length >= 1 ? Cura.MachineManager.printerOutputDevices[0] : null - property bool isNetworkPrinter: Cura.MachineManager.activeMachineHasRemoteConnection - property bool isCloudPrinter: Cura.MachineManager.activeMachineHasCloudConnection property bool isPrinterConnected: Cura.MachineManager.printerConnected + property bool isNetworkPrinter: Cura.MachineManager.activeMachineHasActiveNetworkConnection + property bool isCloudPrinter: Cura.MachineManager.activeMachineHasActiveCloudConnection contentPadding: UM.Theme.getSize("default_lining").width contentAlignment: Cura.ExpandablePopup.ContentAlignment.AlignLeft From af2061cd52ec05cd9f25f07c6f858e2b81194fc8 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Thu, 20 Dec 2018 14:26:30 +0100 Subject: [PATCH 144/178] Simplify some checks for connection types and group size --- cura/Settings/MachineManager.py | 13 ++++++--- .../src/Cloud/CloudOutputDeviceManager.py | 7 +++-- .../qml/PrinterSelector/MachineSelector.qml | 29 ++++++++++++------- 3 files changed, 31 insertions(+), 18 deletions(-) diff --git a/cura/Settings/MachineManager.py b/cura/Settings/MachineManager.py index 66ee7f9543..b5f8420b97 100755 --- a/cura/Settings/MachineManager.py +++ b/cura/Settings/MachineManager.py @@ -527,10 +527,15 @@ class MachineManager(QObject): @pyqtProperty(bool, notify = printerConnectedStatusChanged) def activeMachineHasRemoteConnection(self) -> bool: - if self._global_container_stack: - connection_type = self._global_container_stack.getMetaDataEntry("connection_type") - return connection_type in [ConnectionType.NetworkConnection.value, ConnectionType.CloudConnection.value] - return False + return self.activeMachineHasActiveNetworkConnection or self.activeMachineHasActiveCloudConnection + # if self._global_container_stack: + # connection_type = self._global_container_stack.getMetaDataEntry("connection_type") + # return connection_type in [ConnectionType.NetworkConnection.value, ConnectionType.CloudConnection.value] + # return False + + @pyqtProperty(bool, notify = printerConnectedStatusChanged) + def activeMachineIsGroup(self) -> bool: + return self._printer_output_devices and self._printer_output_devices[0].clusterSize > 1 @pyqtProperty(bool, notify = printerConnectedStatusChanged) def activeMachineHasActiveNetworkConnection(self) -> bool: diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index af45f06394..c849ebfe4a 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -121,10 +121,11 @@ class CloudOutputDeviceManager: return device = next((c for c in self._remote_clusters.values() if c.matchesNetworkKey(local_network_key)), None) - if device: - active_machine.setMetaDataEntry(self.META_CLUSTER_ID, device.key) - device.connect() + if not device: + return + active_machine.setMetaDataEntry(self.META_CLUSTER_ID, device.key) + device.connect() Logger.log("i", "Found cluster %s with network key %s", device, local_network_key) ## Handles an API error received from the cloud. diff --git a/resources/qml/PrinterSelector/MachineSelector.qml b/resources/qml/PrinterSelector/MachineSelector.qml index 64cf3e6005..c39acb53e7 100644 --- a/resources/qml/PrinterSelector/MachineSelector.qml +++ b/resources/qml/PrinterSelector/MachineSelector.qml @@ -11,10 +11,9 @@ Cura.ExpandablePopup { id: machineSelector - property var outputDevice: Cura.MachineManager.printerOutputDevices.length >= 1 ? Cura.MachineManager.printerOutputDevices[0] : null - property bool isPrinterConnected: Cura.MachineManager.printerConnected property bool isNetworkPrinter: Cura.MachineManager.activeMachineHasActiveNetworkConnection property bool isCloudPrinter: Cura.MachineManager.activeMachineHasActiveCloudConnection + property bool isGroup: Cura.MachineManager.activeMachineIsGroup contentPadding: UM.Theme.getSize("default_lining").width contentAlignment: Cura.ExpandablePopup.ContentAlignment.AlignLeft @@ -30,15 +29,13 @@ Cura.ExpandablePopup text: isNetworkPrinter ? Cura.MachineManager.activeMachineNetworkGroupName : Cura.MachineManager.activeMachineName source: { - if (isNetworkPrinter) - { - if (machineSelector.outputDevice != null && machineSelector.outputDevice.clusterSize > 1) - { - return UM.Theme.getIcon("printer_group") - } + if (isGroup) { + return UM.Theme.getIcon("printer_group") + } else if (isNetworkPrinter || isCloudPrinter) { return UM.Theme.getIcon("printer_single") + } else { + return "" } - return "" } font: UM.Theme.getFont("medium") iconColor: UM.Theme.getColor("machine_selector_printer_icon") @@ -53,12 +50,22 @@ Cura.ExpandablePopup leftMargin: UM.Theme.getSize("thick_margin").width } - source: machineSelector.isCloudPrinter ? UM.Theme.getIcon("printer_cloud_connected") : UM.Theme.getIcon("printer_connected") + source: + { + if (isNetworkPrinter) { + return UM.Theme.getIcon("printer_connected") + } else if (isCloudPrinter) { + return UM.Theme.getIcon("printer_cloud_connected") + } else { + return "" + } + } + width: UM.Theme.getSize("printer_status_icon").width height: UM.Theme.getSize("printer_status_icon").height color: UM.Theme.getColor("primary") - visible: isNetworkPrinter && isPrinterConnected + visible: isNetworkPrinter || isCloudPrinter // Make a themable circle in the background so we can change it in other themes Rectangle From bbddbcde9a780660c603d080ca903af1a9949654 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Thu, 20 Dec 2018 14:29:39 +0100 Subject: [PATCH 145/178] cleanup --- cura/Settings/MachineManager.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/cura/Settings/MachineManager.py b/cura/Settings/MachineManager.py index b5f8420b97..1cb9af02d1 100755 --- a/cura/Settings/MachineManager.py +++ b/cura/Settings/MachineManager.py @@ -528,10 +528,6 @@ class MachineManager(QObject): @pyqtProperty(bool, notify = printerConnectedStatusChanged) def activeMachineHasRemoteConnection(self) -> bool: return self.activeMachineHasActiveNetworkConnection or self.activeMachineHasActiveCloudConnection - # if self._global_container_stack: - # connection_type = self._global_container_stack.getMetaDataEntry("connection_type") - # return connection_type in [ConnectionType.NetworkConnection.value, ConnectionType.CloudConnection.value] - # return False @pyqtProperty(bool, notify = printerConnectedStatusChanged) def activeMachineIsGroup(self) -> bool: From 1012eb7553db8f2246c0a94c21faa1012a9d2555 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Thu, 20 Dec 2018 14:48:56 +0100 Subject: [PATCH 146/178] Assure bool --- cura/Settings/MachineManager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cura/Settings/MachineManager.py b/cura/Settings/MachineManager.py index 1cb9af02d1..c87ddf2f80 100755 --- a/cura/Settings/MachineManager.py +++ b/cura/Settings/MachineManager.py @@ -531,7 +531,7 @@ class MachineManager(QObject): @pyqtProperty(bool, notify = printerConnectedStatusChanged) def activeMachineIsGroup(self) -> bool: - return self._printer_output_devices and self._printer_output_devices[0].clusterSize > 1 + return bool(self._printer_output_devices) and self._printer_output_devices[0].clusterSize > 1 @pyqtProperty(bool, notify = printerConnectedStatusChanged) def activeMachineHasActiveNetworkConnection(self) -> bool: From 119d3e9974780708516f88e760b4ea3d1dd3e665 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Thu, 20 Dec 2018 15:47:48 +0100 Subject: [PATCH 147/178] Fix printer selector sections --- cura/PrintersModel.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/cura/PrintersModel.py b/cura/PrintersModel.py index 8b5d2f6cc9..77df73505d 100644 --- a/cura/PrintersModel.py +++ b/cura/PrintersModel.py @@ -53,9 +53,8 @@ class PrintersModel(ListModel): container_stacks = ContainerRegistry.getInstance().findContainerStacks(type = "machine") for container_stack in container_stacks: - connection_type = container_stack.getMetaDataEntry("connection_type") - has_remote_connection = connection_type in [ConnectionType.NetworkConnection.value, ConnectionType.CloudConnection.value] - + connection_type = container_stack.getMetaDataEntry("connection_type", 0) + has_remote_connection = int(connection_type) in [ConnectionType.NetworkConnection.value, ConnectionType.CloudConnection.value] if container_stack.getMetaDataEntry("hidden", False) in ["True", True]: continue From 3f82cd491697588c8ffcd58cf611fa9c845439ae Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Thu, 20 Dec 2018 15:55:04 +0100 Subject: [PATCH 148/178] Add missing new-line --- cura/PrintersModel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cura/PrintersModel.py b/cura/PrintersModel.py index 77df73505d..f05697d2b5 100644 --- a/cura/PrintersModel.py +++ b/cura/PrintersModel.py @@ -65,4 +65,4 @@ class PrintersModel(ListModel): "connectionType": connection_type, "metadata": container_stack.getMetaData().copy()}) items.sort(key=lambda i: not i["hasRemoteConnection"]) - self.setItems(items) \ No newline at end of file + self.setItems(items) From 403010aa90184a7977648d0d0701791ef5d33a1b Mon Sep 17 00:00:00 2001 From: Marijn Dee Date: Thu, 20 Dec 2018 16:32:00 +0100 Subject: [PATCH 149/178] Mirrored the changes made to the models in Commons --- .../Cloud/Models/CloudClusterBuildPlate.py | 13 +++++ ... => CloudClusterPrintCoreConfiguration.py} | 5 +- ...CloudClusterPrintJobConfigurationChange.py | 28 +++++++++ .../Models/CloudClusterPrintJobImpediment.py | 15 +++++ .../Models/CloudClusterPrintJobStatus.py | 58 ++++++++++++++++--- .../Cloud/Models/CloudClusterPrinterStatus.py | 20 +++++-- 6 files changed, 124 insertions(+), 15 deletions(-) create mode 100644 plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterBuildPlate.py rename plugins/UM3NetworkPrinting/src/Cloud/Models/{CloudClusterPrinterConfiguration.py => CloudClusterPrintCoreConfiguration.py} (92%) create mode 100644 plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobConfigurationChange.py create mode 100644 plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobImpediment.py diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterBuildPlate.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterBuildPlate.py new file mode 100644 index 0000000000..4386bbb435 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterBuildPlate.py @@ -0,0 +1,13 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from .BaseCloudModel import BaseCloudModel + + +## Class representing a cluster printer +# Spec: https://api-staging.ultimaker.com/connect/v1/spec +class CloudClusterBuildPlate(BaseCloudModel): + ## Create a new build plate + # \param type: The type of buildplate glass or aluminium + def __init__(self, type: str = "glass", **kwargs) -> None: + self.type = type + super().__init__(**kwargs) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterConfiguration.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintCoreConfiguration.py similarity index 92% rename from plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterConfiguration.py rename to plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintCoreConfiguration.py index 3e06d0e2e7..7454401d09 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterConfiguration.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintCoreConfiguration.py @@ -10,7 +10,7 @@ from .BaseCloudModel import BaseCloudModel ## Class representing a cloud cluster printer configuration # Spec: https://api-staging.ultimaker.com/connect/v1/spec -class CloudClusterPrinterConfiguration(BaseCloudModel): +class CloudClusterPrintCoreConfiguration(BaseCloudModel): ## Creates a new cloud cluster printer configuration object # \param extruder_index: The position of the extruder on the machine as list index. Numbered from left to right. # \param material: The material of a configuration object in a cluster printer. May be in a dict or an object. @@ -18,10 +18,9 @@ class CloudClusterPrinterConfiguration(BaseCloudModel): # \param print_core_id: The type of print core inserted at this position, e.g. 'AA 0.4'. def __init__(self, extruder_index: int, material: Union[None, Dict[str, Any], CloudClusterPrinterConfigurationMaterial], - nozzle_diameter: Optional[str] = None, print_core_id: Optional[str] = None, **kwargs) -> None: + print_core_id: Optional[str] = None, **kwargs) -> None: self.extruder_index = extruder_index self.material = self.parseModel(CloudClusterPrinterConfigurationMaterial, material) if material else None - self.nozzle_diameter = nozzle_diameter self.print_core_id = print_core_id super().__init__(**kwargs) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobConfigurationChange.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobConfigurationChange.py new file mode 100644 index 0000000000..6c02972757 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobConfigurationChange.py @@ -0,0 +1,28 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from typing import Optional + +from .BaseCloudModel import BaseCloudModel + + +## Model for the types of changes that are needed before a print job can start +# Spec: https://api-staging.ultimaker.com/connect/v1/spec +class CloudClusterPrintJobConfigurationChange(BaseCloudModel): + ## Creates a new print job constraint. + # \param type_of_change: The type of configuration change, one of: "material", "print_core_change" + # \param index: The hotend slot or extruder index to change + # \param target_id: Target material guid or hotend id + # \param origin_id: Original/current material guid or hotend id + # \param target_name: Target material name or hotend id + # \param origin_name: Original/current material name or hotend id + def __init__(self, type_of_change: Optional[str] = None, index: Optional[int] = None, + target_id: Optional[str] = None,origin_id: Optional[str] = None, + target_name: Optional[str] = None,origin_name: Optional[str] = None, + **kwargs) -> None: + self.type_of_change = type_of_change + self.index = index + self.target_id = target_id + self.origin_id = origin_id + self.target_name = target_name + self.origin_name = origin_name + super().__init__(**kwargs) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobImpediment.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobImpediment.py new file mode 100644 index 0000000000..12b67996c1 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobImpediment.py @@ -0,0 +1,15 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from .BaseCloudModel import BaseCloudModel + + +## Class representing the reasons that prevent this job from being printed on the associated printer +# Spec: https://api-staging.ultimaker.com/connect/v1/spec +class CloudClusterPrintJobImpediment(BaseCloudModel): + ## Creates a new print job constraint. + # \param translation_key: A string indicating a reason the print cannot be printed, such as 'does_not_fit_in_build_volume' + # \param severity: A number indicating the severity of the problem, with higher being more severe + def __init__(self, translation_key: str, severity: int, **kwargs) -> None: + self.translation_key = translation_key + self.severity = severity + super().__init__(**kwargs) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobStatus.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobStatus.py index f451665a4f..4b70d191e4 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobStatus.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobStatus.py @@ -4,11 +4,14 @@ from typing import List, Optional, Union, Dict, Any from cura.PrinterOutput.ConfigurationModel import ConfigurationModel from plugins.UM3NetworkPrinting.src.Cloud.CloudOutputController import CloudOutputController -from .CloudClusterPrinterConfiguration import CloudClusterPrinterConfiguration +from src.ConfigurationChangeModel import ConfigurationChangeModel +from .CloudClusterBuildPlate import CloudClusterBuildPlate +from .CloudClusterPrintJobConfigurationChange import CloudClusterPrintJobConfigurationChange +from .CloudClusterPrintJobImpediment import CloudClusterPrintJobImpediment +from .CloudClusterPrintCoreConfiguration import CloudClusterPrintCoreConfiguration from .CloudClusterPrintJobConstraint import CloudClusterPrintJobConstraints from .BaseCloudModel import BaseCloudModel - ## Class representing a print job from plugins.UM3NetworkPrinting.src.UM3PrintJobOutputModel import UM3PrintJobOutputModel @@ -34,15 +37,29 @@ class CloudClusterPrintJobStatus(BaseCloudModel): # \param time_elapsed: The remaining printing time in seconds. # \param time_total: The total printing time in seconds. # \param uuid: UUID of this print job. Should be used for identification purposes. + # \param deleted_at: The time when this print job was deleted. + # \param printed_on_uuid: UUID of the printer used to print this job. + # \param configuration_changes_required: List of configuration changes the printer this job is associated with + # needs to make in order to be able to print this job + # \param build_plate: The build plate (type) this job needs to be printed on. + # \param compatible_machine_families: Family names of machines suitable for this print job + # \param impediments_to_printing: A list of reasons that prevent this job from being printed on the associated + # printer def __init__(self, created_at: str, force: bool, machine_variant: str, name: str, started: bool, status: str, time_total: int, uuid: str, - configuration: List[Union[Dict[str, Any], CloudClusterPrinterConfiguration]], + configuration: List[Union[Dict[str, Any], CloudClusterPrintCoreConfiguration]], constraints: List[Union[Dict[str, Any], CloudClusterPrintJobConstraints]], last_seen: Optional[float] = None, network_error_count: Optional[int] = None, owner: Optional[str] = None, printer_uuid: Optional[str] = None, time_elapsed: Optional[int] = None, - assigned_to: Optional[str] = None, **kwargs) -> None: + assigned_to: Optional[str] = None, deleted_at: Optional[str] = None, + printed_on_uuid: Optional[str] = None, + configuration_changes_required: List[ + Union[Dict[str, Any], CloudClusterPrintJobConfigurationChange]] = None, + build_plate: Optional[str] = None, compatible_machine_families: List[str] = None, + impediments_to_printing: List[Union[Dict[str, Any], CloudClusterPrintJobImpediment]] = None, + **kwargs) -> None: self.assigned_to = assigned_to - self.configuration = self.parseModels(CloudClusterPrinterConfiguration, configuration) + self.configuration = self.parseModels(CloudClusterPrintCoreConfiguration, configuration) self.constraints = self.parseModels(CloudClusterPrintJobConstraints, constraints) self.created_at = created_at self.force = force @@ -57,6 +74,14 @@ class CloudClusterPrintJobStatus(BaseCloudModel): self.time_elapsed = time_elapsed self.time_total = time_total self.uuid = uuid + self.deleted_at = deleted_at + self.printed_on_uuid = printed_on_uuid + self.configuration_changes_required = self.parseModels(CloudClusterPrintJobConfigurationChange, + configuration_changes_required) + self.build_plate = self.parseModel(CloudClusterBuildPlate, build_plate) + self.compatible_machine_families = compatible_machine_families + self.impediments_to_printing = self.parseModels(CloudClusterPrintJobImpediment, impediments_to_printing) + super().__init__(**kwargs) ## Creates an UM3 print job output model based on this cloud cluster print job. @@ -77,11 +102,28 @@ class CloudClusterPrintJobStatus(BaseCloudModel): ## Updates an UM3 print job output model based on this cloud cluster print job. # \param model: The model to update. def updateOutputModel(self, model: UM3PrintJobOutputModel) -> None: - # TODO: Add `compatible_machine_families` to the cloud, than add model.setCompatibleMachineFamilies() - # TODO: Add `impediments_to_printing` to the cloud, see ClusterUM3OutputDevice._updatePrintJob - # TODO: Use model.updateConfigurationChanges, see ClusterUM3OutputDevice#_createConfigurationChanges model.updateConfiguration(self._createConfigurationModel()) model.updateTimeTotal(self.time_total) model.updateTimeElapsed(self.time_elapsed) model.updateOwner(self.owner) model.updateState(self.status) + model.setCompatibleMachineFamilies(self.compatible_machine_families) + model.updateTimeTotal(self.time_total) + model.updateTimeElapsed(self.time_elapsed) + model.updateOwner(self.owner) + + status_set_by_impediment = False + for impediment in self.impediments_to_printing: + if impediment.severity == "UNFIXABLE": # TODO: impediment.severity is defined as int, this will not work, is there a translation? + status_set_by_impediment = True + model.updateState("error") + break + + if not status_set_by_impediment: + model.updateState(self.status) + + model.updateConfigurationChanges([ConfigurationChangeModel(type_of_change=change.type_of_change, + index=change.index, + target_name=change.target_name, + origin_name=change.origin_name) + for change in self.configuration_changes_required]) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterStatus.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterStatus.py index cd3b6bbdca..23409a761d 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterStatus.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterStatus.py @@ -4,7 +4,8 @@ from typing import List, Union, Dict, Optional, Any from cura.PrinterOutput.PrinterOutputController import PrinterOutputController from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel -from .CloudClusterPrinterConfiguration import CloudClusterPrinterConfiguration +from .CloudClusterBuildPlate import CloudClusterBuildPlate +from .CloudClusterPrintCoreConfiguration import CloudClusterPrintCoreConfiguration from .BaseCloudModel import BaseCloudModel @@ -22,12 +23,19 @@ class CloudClusterPrinterStatus(BaseCloudModel): # \param uuid: The unique ID of the printer, also known as GUID. # \param configuration: The active print core configurations of this printer. # \param reserved_by: A printer can be claimed by a specific print job. + # \param maintenance_required: Indicates if maintenance is necessary + # \param firmware_update_status: Whether the printer's firmware is up-to-date, value is one of: "up_to_date", + # "pending_update", "update_available", "update_in_progress", "update_failed", "update_impossible" + # \param latest_available_firmware: The version of the latest firmware that is available + # \param build_plate: The build plate that is on the printer def __init__(self, enabled: bool, firmware_version: str, friendly_name: str, ip_address: str, machine_variant: str, status: str, unique_name: str, uuid: str, - configuration: List[Union[Dict[str, Any], CloudClusterPrinterConfiguration]], - reserved_by: Optional[str] = None, **kwargs) -> None: + configuration: List[Union[Dict[str, Any], CloudClusterPrintCoreConfiguration]], + reserved_by: Optional[str] = None, maintenance_required: Optional[bool] = None, + firmware_update_status: Optional[str] = None, latest_available_firmware: Optional[str] = None, + build_plate: Optional[str] = None, **kwargs) -> None: - self.configuration = self.parseModels(CloudClusterPrinterConfiguration, configuration) + self.configuration = self.parseModels(CloudClusterPrintCoreConfiguration, configuration) self.enabled = enabled self.firmware_version = firmware_version self.friendly_name = friendly_name @@ -37,6 +45,10 @@ class CloudClusterPrinterStatus(BaseCloudModel): self.unique_name = unique_name self.uuid = uuid self.reserved_by = reserved_by + self.maintenance_required = maintenance_required + self.firmware_update_status = firmware_update_status + self.latest_available_firmware = latest_available_firmware + self.build_plate = self.parseModel(CloudClusterBuildPlate, build_plate) super().__init__(**kwargs) ## Creates a new output model. From 9d27c29c8ccd5bc09d54dd9f0a90f474cf643c18 Mon Sep 17 00:00:00 2001 From: Marijn Dee Date: Thu, 20 Dec 2018 17:04:17 +0100 Subject: [PATCH 150/178] Fixed indentation and relative import, tests still fail though --- .../src/Cloud/Models/CloudClusterPrintJobStatus.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobStatus.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobStatus.py index 4b70d191e4..5b7ef7c18a 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobStatus.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobStatus.py @@ -4,7 +4,7 @@ from typing import List, Optional, Union, Dict, Any from cura.PrinterOutput.ConfigurationModel import ConfigurationModel from plugins.UM3NetworkPrinting.src.Cloud.CloudOutputController import CloudOutputController -from src.ConfigurationChangeModel import ConfigurationChangeModel +from plugins.UM3NetworkPrinting.src.ConfigurationChangeModel import ConfigurationChangeModel from .CloudClusterBuildPlate import CloudClusterBuildPlate from .CloudClusterPrintJobConfigurationChange import CloudClusterPrintJobConfigurationChange from .CloudClusterPrintJobImpediment import CloudClusterPrintJobImpediment @@ -122,8 +122,10 @@ class CloudClusterPrintJobStatus(BaseCloudModel): if not status_set_by_impediment: model.updateState(self.status) - model.updateConfigurationChanges([ConfigurationChangeModel(type_of_change=change.type_of_change, - index=change.index, - target_name=change.target_name, - origin_name=change.origin_name) - for change in self.configuration_changes_required]) + model.updateConfigurationChanges( + [ConfigurationChangeModel( + type_of_change=change.type_of_change, + index=change.index, + target_name=change.target_name, + origin_name=change.origin_name) + for change in self.configuration_changes_required]) From 5403f5241b44684ec6b857fa86c97f8d593a7527 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Thu, 20 Dec 2018 17:21:07 +0100 Subject: [PATCH 151/178] Fix not registering cloud output device for every machine --- .../src/Cloud/CloudOutputDeviceManager.py | 22 +++++++++++++------ .../src/ClusterUM3OutputDevice.py | 5 +++-- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index c849ebfe4a..b1dc13e34f 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -88,7 +88,6 @@ class CloudOutputDeviceManager: # We only add when is_online as we don't want the option in the drop down if the cluster is not online. for added_cluster in added_clusters: device = CloudOutputDevice(self._api, added_cluster) - self._output_device_manager.addOutputDevice(device) self._remote_clusters[added_cluster.cluster_id] = device for device, cluster in updates: @@ -103,17 +102,20 @@ class CloudOutputDeviceManager: Logger.log("d", "no active machine") return + # Remove all output devices that we have registered. + for stored_cluster_id in self._remote_clusters: + self._output_device_manager.removeOutputDevice(stored_cluster_id) + # Check if the stored cluster_id for the active machine is in our list of remote clusters. stored_cluster_id = active_machine.getMetaDataEntry(self.META_CLUSTER_ID) if stored_cluster_id in self._remote_clusters: device = self._remote_clusters[stored_cluster_id] - if not device.isConnected(): - device.connect() - Logger.log("d", "Device connected by metadata %s", stored_cluster_id) + self._connectToOutputDevice(device) + Logger.log("d", "Device connected by metadata cluster ID %s", stored_cluster_id) else: self._connectByNetworkKey(active_machine) - ## Tries to match the + ## Tries to match the local network key to the cloud cluster host name. def _connectByNetworkKey(self, active_machine: GlobalStack) -> None: # Check if the active printer has a local network connection and match this key to the remote cluster. local_network_key = active_machine.getMetaDataEntry("um_network_key") @@ -124,9 +126,15 @@ class CloudOutputDeviceManager: if not device: return - active_machine.setMetaDataEntry(self.META_CLUSTER_ID, device.key) - device.connect() Logger.log("i", "Found cluster %s with network key %s", device, local_network_key) + active_machine.setMetaDataEntry(self.META_CLUSTER_ID, device.key) + self._connectToOutputDevice(device) + + ## Connects to an output device and makes sure it is registered in the output device manager. + def _connectToOutputDevice(self, device: CloudOutputDevice) -> None: + if not device.isConnected(): + device.connect() + self._output_device_manager.addOutputDevice(device) ## Handles an API error received from the cloud. # \param errors: The errors received diff --git a/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py b/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py index 75fd1e0f9e..a1931c428a 100644 --- a/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py @@ -389,8 +389,9 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): ## Called when the connection to the cluster changes. def connect(self) -> None: - super().connect() - self.sendMaterialProfiles() + # super().connect() + # self.sendMaterialProfiles() + pass def _onGetPreviewImageFinished(self, reply: QNetworkReply) -> None: reply_url = reply.url().toString() From 131211e604bed58b719f6f017d44d0fbb68d15b8 Mon Sep 17 00:00:00 2001 From: Marijn Dee Date: Fri, 21 Dec 2018 09:42:28 +0100 Subject: [PATCH 152/178] Fixed the unit tests --- .../src/Cloud/Models/CloudClusterPrintJobStatus.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobStatus.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobStatus.py index 5b7ef7c18a..b1672f362e 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobStatus.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobStatus.py @@ -76,11 +76,17 @@ class CloudClusterPrintJobStatus(BaseCloudModel): self.uuid = uuid self.deleted_at = deleted_at self.printed_on_uuid = printed_on_uuid - self.configuration_changes_required = self.parseModels(CloudClusterPrintJobConfigurationChange, - configuration_changes_required) + if configuration_changes_required: + self.configuration_changes_required = self.parseModels(CloudClusterPrintJobConfigurationChange, + configuration_changes_required) + else: + self.configuration_changes_required = [] self.build_plate = self.parseModel(CloudClusterBuildPlate, build_plate) self.compatible_machine_families = compatible_machine_families - self.impediments_to_printing = self.parseModels(CloudClusterPrintJobImpediment, impediments_to_printing) + if impediments_to_printing: + self.impediments_to_printing = self.parseModels(CloudClusterPrintJobImpediment, impediments_to_printing) + else: + self.impediments_to_printing = [] super().__init__(**kwargs) From 12a4a5e9f54ba62b6c2eb1f6df8e1a28c8441506 Mon Sep 17 00:00:00 2001 From: Marijn Dee Date: Fri, 21 Dec 2018 15:06:02 +0100 Subject: [PATCH 153/178] Fixing the rest of the automatic test failures --- ...CloudClusterPrintJobConfigurationChange.py | 5 ++- .../Models/CloudClusterPrintJobStatus.py | 35 +++++++++---------- .../Cloud/Models/CloudClusterPrinterStatus.py | 4 +-- 3 files changed, 21 insertions(+), 23 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobConfigurationChange.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobConfigurationChange.py index 6c02972757..9ff4154666 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobConfigurationChange.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobConfigurationChange.py @@ -15,9 +15,8 @@ class CloudClusterPrintJobConfigurationChange(BaseCloudModel): # \param origin_id: Original/current material guid or hotend id # \param target_name: Target material name or hotend id # \param origin_name: Original/current material name or hotend id - def __init__(self, type_of_change: Optional[str] = None, index: Optional[int] = None, - target_id: Optional[str] = None,origin_id: Optional[str] = None, - target_name: Optional[str] = None,origin_name: Optional[str] = None, + def __init__(self, type_of_change: str, target_id: str, origin_id: str, + index: Optional[int] = None, target_name: Optional[str] = None, origin_name: Optional[str] = None, **kwargs) -> None: self.type_of_change = type_of_change self.index = index diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobStatus.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobStatus.py index b1672f362e..5d62471710 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobStatus.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobStatus.py @@ -55,7 +55,8 @@ class CloudClusterPrintJobStatus(BaseCloudModel): printed_on_uuid: Optional[str] = None, configuration_changes_required: List[ Union[Dict[str, Any], CloudClusterPrintJobConfigurationChange]] = None, - build_plate: Optional[str] = None, compatible_machine_families: List[str] = None, + build_plate: Union[Dict[str, Any], CloudClusterBuildPlate] = None, + compatible_machine_families: List[str] = None, impediments_to_printing: List[Union[Dict[str, Any], CloudClusterPrintJobImpediment]] = None, **kwargs) -> None: self.assigned_to = assigned_to @@ -76,17 +77,14 @@ class CloudClusterPrintJobStatus(BaseCloudModel): self.uuid = uuid self.deleted_at = deleted_at self.printed_on_uuid = printed_on_uuid - if configuration_changes_required: - self.configuration_changes_required = self.parseModels(CloudClusterPrintJobConfigurationChange, - configuration_changes_required) - else: - self.configuration_changes_required = [] - self.build_plate = self.parseModel(CloudClusterBuildPlate, build_plate) - self.compatible_machine_families = compatible_machine_families - if impediments_to_printing: - self.impediments_to_printing = self.parseModels(CloudClusterPrintJobImpediment, impediments_to_printing) - else: - self.impediments_to_printing = [] + + self.configuration_changes_required = self.parseModels(CloudClusterPrintJobConfigurationChange, + configuration_changes_required) \ + if configuration_changes_required else [] + self.build_plate = self.parseModel(CloudClusterBuildPlate, build_plate) if build_plate else None + self.compatible_machine_families = compatible_machine_families if compatible_machine_families else [] + self.impediments_to_printing = self.parseModels(CloudClusterPrintJobImpediment, impediments_to_printing) \ + if impediments_to_printing else [] super().__init__(**kwargs) @@ -120,7 +118,8 @@ class CloudClusterPrintJobStatus(BaseCloudModel): status_set_by_impediment = False for impediment in self.impediments_to_printing: - if impediment.severity == "UNFIXABLE": # TODO: impediment.severity is defined as int, this will not work, is there a translation? + # TODO: impediment.severity is defined as int, this will not work, is there a translation? + if impediment.severity == "UNFIXABLE": status_set_by_impediment = True model.updateState("error") break @@ -130,8 +129,8 @@ class CloudClusterPrintJobStatus(BaseCloudModel): model.updateConfigurationChanges( [ConfigurationChangeModel( - type_of_change=change.type_of_change, - index=change.index, - target_name=change.target_name, - origin_name=change.origin_name) - for change in self.configuration_changes_required]) + type_of_change = change.type_of_change, + index = change.index if change.index else 0, + target_name = change.target_name if change.target_name else "", + origin_name = change.origin_name if change.origin_name else "") + for change in self.configuration_changes_required]) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterStatus.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterStatus.py index 23409a761d..a8165ff69c 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterStatus.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterStatus.py @@ -33,7 +33,7 @@ class CloudClusterPrinterStatus(BaseCloudModel): configuration: List[Union[Dict[str, Any], CloudClusterPrintCoreConfiguration]], reserved_by: Optional[str] = None, maintenance_required: Optional[bool] = None, firmware_update_status: Optional[str] = None, latest_available_firmware: Optional[str] = None, - build_plate: Optional[str] = None, **kwargs) -> None: + build_plate: Union[Dict[str, Any], CloudClusterBuildPlate] = None, **kwargs) -> None: self.configuration = self.parseModels(CloudClusterPrintCoreConfiguration, configuration) self.enabled = enabled @@ -48,7 +48,7 @@ class CloudClusterPrinterStatus(BaseCloudModel): self.maintenance_required = maintenance_required self.firmware_update_status = firmware_update_status self.latest_available_firmware = latest_available_firmware - self.build_plate = self.parseModel(CloudClusterBuildPlate, build_plate) + self.build_plate = self.parseModel(CloudClusterBuildPlate, build_plate) if build_plate else None super().__init__(**kwargs) ## Creates a new output model. From 7c7bca31c75432dca58f14c2e1db8a6672753931 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Tue, 8 Jan 2019 11:52:27 +0100 Subject: [PATCH 154/178] Fix renamed property connectionType --- plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py b/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py index 5f6b6b15bf..4a510903dd 100644 --- a/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py +++ b/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py @@ -118,7 +118,7 @@ class UM3OutputDevicePlugin(OutputDevicePlugin): if key == um_network_key: if not self._discovered_devices[key].isConnected(): Logger.log("d", "Attempting to connect with [%s]" % key) - active_machine.setMetaDataEntry("connection_type", self._discovered_devices[key].getConnectionType().value) + active_machine.setMetaDataEntry("connection_type", self._discovered_devices[key].connectionType.value) self._discovered_devices[key].connect() self._discovered_devices[key].connectionStateChanged.connect(self._onDeviceConnectionStateChanged) else: From 485b471522968e1099cee3aa1e0055f01cd5dbec Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Tue, 8 Jan 2019 12:19:00 +0100 Subject: [PATCH 155/178] Fix some review comments --- .../NetworkedPrinterOutputDevice.py | 4 +- .../src/Cloud/CloudApiClient.py | 35 ++++++------ .../src/Cloud/CloudOutputDevice.py | 55 +++++-------------- .../src/Cloud/CloudOutputDeviceManager.py | 4 +- .../{CloudErrorObject.py => CloudError.py} | 2 +- .../src/Cloud/Translations.py | 31 +++++++++++ .../tests/Cloud/TestCloudApiClient.py | 4 +- 7 files changed, 73 insertions(+), 62 deletions(-) rename plugins/UM3NetworkPrinting/src/Cloud/Models/{CloudErrorObject.py => CloudError.py} (97%) create mode 100644 plugins/UM3NetworkPrinting/src/Cloud/Translations.py diff --git a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py index 3dcc43dd00..4a8aa0a6b2 100644 --- a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py +++ b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py @@ -18,6 +18,7 @@ from enum import IntEnum import os # To get the username import gzip + class AuthState(IntEnum): NotAuthenticated = 1 AuthenticationRequested = 2 @@ -207,7 +208,8 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): self._last_request_time = time() if not self._manager: - return Logger.log("e", "No network manager was created to execute the PUT call with.") + Logger.log("e", "No network manager was created to execute the PUT call with.") + return body = data if isinstance(data, bytes) else data.encode() # type: bytes reply = self._manager.put(request, body) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py index 302ca86d32..8f10d02802 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py @@ -14,13 +14,17 @@ from cura.API import Account from .MeshUploader import MeshUploader from ..Models import BaseModel from .Models.CloudClusterResponse import CloudClusterResponse -from .Models.CloudErrorObject import CloudErrorObject +from .Models.CloudError import CloudError from .Models.CloudClusterStatus import CloudClusterStatus from .Models.CloudPrintJobUploadRequest import CloudPrintJobUploadRequest from .Models.CloudPrintResponse import CloudPrintResponse from .Models.CloudPrintJobResponse import CloudPrintJobResponse +## The generic type variable used to document the methods below. +CloudApiClientModel = TypeVar("Model", bound = BaseModel) + + ## The cloud API client is responsible for handling the requests and responses from the cloud. # Each method should only handle models instead of exposing Any HTTP details. class CloudApiClient: @@ -33,7 +37,7 @@ class CloudApiClient: ## Initializes a new cloud API client. # \param account: The user's account object # \param on_error: The callback to be called whenever we receive errors from the server. - def __init__(self, account: Account, on_error: Callable[[List[CloudErrorObject]], None]) -> None: + def __init__(self, account: Account, on_error: Callable[[List[CloudError]], None]) -> None: super().__init__() self._manager = QNetworkAccessManager() self._account = account @@ -115,33 +119,31 @@ class CloudApiClient: # Logger.log("i", "Received a reply %s from %s with %s", status_code, reply.url().toString(), response) return status_code, json.loads(response) except (UnicodeDecodeError, JSONDecodeError, ValueError) as err: - error = CloudErrorObject(code=type(err).__name__, title=str(err), http_code=str(status_code), - id=str(time()), http_status="500") + error = CloudError(code=type(err).__name__, title=str(err), http_code=str(status_code), + id=str(time()), http_status="500") Logger.logException("e", "Could not parse the stardust response: %s", error) return status_code, {"errors": [error.toDict()]} - ## The generic type variable used to document the methods below. - Model = TypeVar("Model", bound=BaseModel) - ## Parses the given models and calls the correct callback depending on the result. # \param response: The response from the server, after being converted to a dict. # \param on_finished: The callback in case the response is successful. # \param model_class: The type of the model to convert the response to. It may either be a single record or a list. def _parseModels(self, response: Dict[str, Any], - on_finished: Union[Callable[[Model], Any], Callable[[List[Model]], Any]], - model_class: Type[Model]) -> None: + on_finished: Union[Callable[[CloudApiClientModel], Any], + Callable[[List[CloudApiClientModel]], Any]], + model_class: Type[CloudApiClientModel]) -> None: if "data" in response: data = response["data"] if isinstance(data, list): - results = [model_class(**c) for c in data] # type: List[CloudApiClient.Model] - on_finished_list = cast(Callable[[List[CloudApiClient.Model]], Any], on_finished) + results = [model_class(**c) for c in data] # type: List[CloudApiClientModel] + on_finished_list = cast(Callable[[List[CloudApiClientModel]], Any], on_finished) on_finished_list(results) else: - result = model_class(**data) # type: CloudApiClient.Model - on_finished_item = cast(Callable[[CloudApiClient.Model], Any], on_finished) + result = model_class(**data) # type: CloudApiClientModel + on_finished_item = cast(Callable[[CloudApiClientModel], Any], on_finished) on_finished_item(result) elif "errors" in response: - self._on_error([CloudErrorObject(**error) for error in response["errors"]]) + self._on_error([CloudError(**error) for error in response["errors"]]) else: Logger.log("e", "Cannot find data or errors in the cloud response: %s", response) @@ -153,8 +155,9 @@ class CloudApiClient: # \param model: The type of the model to convert the response to. def _addCallback(self, reply: QNetworkReply, - on_finished: Union[Callable[[Model], Any], Callable[[List[Model]], Any]], - model: Type[Model], + on_finished: Union[Callable[[CloudApiClientModel], Any], + Callable[[List[CloudApiClientModel]], Any]], + model: Type[CloudApiClientModel], ) -> None: def parse() -> None: status_code, response = self._parseReply(reply) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index 093aa05ea9..e866303d27 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -7,7 +7,6 @@ from typing import Dict, List, Optional, Set, cast from PyQt5.QtCore import QObject, QUrl, pyqtProperty, pyqtSignal, pyqtSlot -from UM import i18nCatalog from UM.Backend.Backend import BackendState from UM.FileHandler.FileHandler import FileHandler from UM.Logger import Logger @@ -18,7 +17,8 @@ from cura.CuraApplication import CuraApplication from cura.PrinterOutput.NetworkedPrinterOutputDevice import AuthState, NetworkedPrinterOutputDevice from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel from cura.PrinterOutputDevice import ConnectionType -from plugins.UM3NetworkPrinting.src.Cloud.CloudOutputController import CloudOutputController + +from .CloudOutputController import CloudOutputController from ..MeshFormatHandler import MeshFormatHandler from ..UM3PrintJobOutputModel import UM3PrintJobOutputModel from .CloudProgressMessage import CloudProgressMessage @@ -30,37 +30,10 @@ from .Models.CloudPrintResponse import CloudPrintResponse from .Models.CloudPrintJobResponse import CloudPrintJobResponse from .Models.CloudClusterPrinterStatus import CloudClusterPrinterStatus from .Models.CloudClusterPrintJobStatus import CloudClusterPrintJobStatus +from .Translations import Translations from .Utils import findChanges, formatDateCompleted, formatTimeCompleted -## Class that contains all the translations for this module. -class T: - # The translation catalog for this device. - - _I18N_CATALOG = i18nCatalog("cura") - - PRINT_VIA_CLOUD_BUTTON = _I18N_CATALOG.i18nc("@action:button", "Print via Cloud") - PRINT_VIA_CLOUD_TOOLTIP = _I18N_CATALOG.i18nc("@properties:tooltip", "Print via Cloud") - - CONNECTED_VIA_CLOUD = _I18N_CATALOG.i18nc("@info:status", "Connected via Cloud") - BLOCKED_UPLOADING = _I18N_CATALOG.i18nc("@info:status", "Sending new jobs (temporarily) blocked, still sending " - "the previous print job.") - - COULD_NOT_EXPORT = _I18N_CATALOG.i18nc("@info:status", "Could not export print job.") - - ERROR = _I18N_CATALOG.i18nc("@info:title", "Error") - UPLOAD_ERROR = _I18N_CATALOG.i18nc("@info:text", "Could not upload the data to the printer.") - - UPLOAD_SUCCESS_TITLE = _I18N_CATALOG.i18nc("@info:title", "Data Sent") - UPLOAD_SUCCESS_TEXT = _I18N_CATALOG.i18nc("@info:status", "Print job was successfully sent to the printer.") - - JOB_COMPLETED_TITLE = _I18N_CATALOG.i18nc("@info:status", "Print finished") - JOB_COMPLETED_PRINTER = _I18N_CATALOG.i18nc("@info:status", - "Printer '{printer_name}' has finished printing '{job_name}'.") - - JOB_COMPLETED_NO_PRINTER = _I18N_CATALOG.i18nc("@info:status", "The print job '{job_name}' was finished.") - - ## The cloud output device is a network output device that works remotely but has limited functionality. # Currently it only supports viewing the printer and print job status and adding a new job to the queue. # As such, those methods have been implemented here. @@ -159,9 +132,9 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): def _setInterfaceElements(self) -> None: self.setPriority(2) # make sure we end up below the local networking and above 'save to file' self.setName(self._id) - self.setShortDescription(T.PRINT_VIA_CLOUD_BUTTON) - self.setDescription(T.PRINT_VIA_CLOUD_TOOLTIP) - self.setConnectionText(T.CONNECTED_VIA_CLOUD) + self.setShortDescription(Translations.PRINT_VIA_CLOUD_BUTTON) + self.setDescription(Translations.PRINT_VIA_CLOUD_TOOLTIP) + self.setConnectionText(Translations.CONNECTED_VIA_CLOUD) ## Called when Cura requests an output device to receive a (G-code) file. def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mimetypes: bool = False, @@ -169,7 +142,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): # Show an error message if we're already sending a job. if self._progress.visible: - message = Message(text = T.BLOCKED_UPLOADING, title = T.ERROR, lifetime = 10) + message = Message(text = Translations.BLOCKED_UPLOADING, title = Translations.ERROR, lifetime = 10) message.show() return @@ -184,7 +157,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): mesh_format = MeshFormatHandler(file_handler, self.firmwareVersion) if not mesh_format.is_valid: Logger.log("e", "Missing file or mesh writer!") - return self._onUploadError(T.COULD_NOT_EXPORT) + return self._onUploadError(Translations.COULD_NOT_EXPORT) mesh = mesh_format.getBytes(nodes) @@ -292,9 +265,11 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): if job.state == "wait_cleanup" and job.key not in self._finished_jobs and job.owner == user_name: self._finished_jobs.add(job.key) Message( - title = T.JOB_COMPLETED_TITLE, - text = (T.JOB_COMPLETED_PRINTER.format(printer_name=job.assignedPrinter.name, job_name=job.name) - if job.assignedPrinter else T.JOB_COMPLETED_NO_PRINTER.format(job_name=job.name)), + title = Translations.JOB_COMPLETED_TITLE, + text = (Translations.JOB_COMPLETED_PRINTER.format(printer_name=job.assignedPrinter.name, + job_name=job.name) + if job.assignedPrinter else + Translations.JOB_COMPLETED_NO_PRINTER.format(job_name=job.name)), ).show() # Ensure UI gets updated @@ -330,7 +305,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): def _onUploadError(self, message = None) -> None: self._progress.hide() self._uploaded_print_job = None - Message(text = message or T.UPLOAD_ERROR, title = T.ERROR, lifetime = 10).show() + Message(text = message or Translations.UPLOAD_ERROR, title = Translations.ERROR, lifetime = 10).show() self.writeError.emit() ## Shows a message when the upload has succeeded @@ -338,7 +313,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): def _onPrintRequested(self, response: CloudPrintResponse) -> None: Logger.log("d", "The cluster will be printing this print job with the ID %s", response.cluster_job_id) self._progress.hide() - Message(text = T.UPLOAD_SUCCESS_TEXT, title = T.UPLOAD_SUCCESS_TITLE, lifetime = 5).show() + Message(text = Translations.UPLOAD_SUCCESS_TEXT, title = Translations.UPLOAD_SUCCESS_TITLE, lifetime = 5).show() self.writeFinished.emit() ## Gets the remote printers. diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index b1dc13e34f..72ac34ff34 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -13,7 +13,7 @@ from cura.Settings.GlobalStack import GlobalStack from .CloudApiClient import CloudApiClient from .CloudOutputDevice import CloudOutputDevice from .Models.CloudClusterResponse import CloudClusterResponse -from .Models.CloudErrorObject import CloudErrorObject +from .Models.CloudError import CloudError from .Utils import findChanges @@ -138,7 +138,7 @@ class CloudOutputDeviceManager: ## Handles an API error received from the cloud. # \param errors: The errors received - def _onApiError(self, errors: List[CloudErrorObject]) -> None: + def _onApiError(self, errors: List[CloudError]) -> None: text = ". ".join(e.title for e in errors) # TODO: translate errors message = Message( text = text, diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudErrorObject.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudError.py similarity index 97% rename from plugins/UM3NetworkPrinting/src/Cloud/Models/CloudErrorObject.py rename to plugins/UM3NetworkPrinting/src/Cloud/Models/CloudError.py index 28b4d916a1..b53361022e 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudErrorObject.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudError.py @@ -7,7 +7,7 @@ from .BaseCloudModel import BaseCloudModel ## Class representing errors generated by the cloud servers, according to the JSON-API standard. # Spec: https://api-staging.ultimaker.com/connect/v1/spec -class CloudErrorObject(BaseCloudModel): +class CloudError(BaseCloudModel): ## Creates a new error object. # \param id: Unique identifier for this particular occurrence of the problem. # \param title: A short, human-readable summary of the problem that SHOULD NOT change from occurrence to occurrence diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Translations.py b/plugins/UM3NetworkPrinting/src/Cloud/Translations.py new file mode 100644 index 0000000000..278bf91c37 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/Translations.py @@ -0,0 +1,31 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from UM import i18nCatalog + + +## Class that contains all the translations for this module. +class Translations: + # The translation catalog for this device. + + _I18N_CATALOG = i18nCatalog("cura") + + PRINT_VIA_CLOUD_BUTTON = _I18N_CATALOG.i18nc("@action:button", "Print via Cloud") + PRINT_VIA_CLOUD_TOOLTIP = _I18N_CATALOG.i18nc("@properties:tooltip", "Print via Cloud") + + CONNECTED_VIA_CLOUD = _I18N_CATALOG.i18nc("@info:status", "Connected via Cloud") + BLOCKED_UPLOADING = _I18N_CATALOG.i18nc("@info:status", "Sending new jobs (temporarily) blocked, still sending " + "the previous print job.") + + COULD_NOT_EXPORT = _I18N_CATALOG.i18nc("@info:status", "Could not export print job.") + + ERROR = _I18N_CATALOG.i18nc("@info:title", "Error") + UPLOAD_ERROR = _I18N_CATALOG.i18nc("@info:text", "Could not upload the data to the printer.") + + UPLOAD_SUCCESS_TITLE = _I18N_CATALOG.i18nc("@info:title", "Data Sent") + UPLOAD_SUCCESS_TEXT = _I18N_CATALOG.i18nc("@info:status", "Print job was successfully sent to the printer.") + + JOB_COMPLETED_TITLE = _I18N_CATALOG.i18nc("@info:status", "Print finished") + JOB_COMPLETED_PRINTER = _I18N_CATALOG.i18nc("@info:status", + "Printer '{printer_name}' has finished printing '{job_name}'.") + + JOB_COMPLETED_NO_PRINTER = _I18N_CATALOG.i18nc("@info:status", "The print job '{job_name}' was finished.") \ No newline at end of file diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py index 0c0c8cffdf..b57334b2da 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py @@ -12,7 +12,7 @@ from src.Cloud.Models.CloudClusterResponse import CloudClusterResponse from src.Cloud.Models.CloudClusterStatus import CloudClusterStatus from src.Cloud.Models.CloudPrintJobResponse import CloudPrintJobResponse from src.Cloud.Models.CloudPrintJobUploadRequest import CloudPrintJobUploadRequest -from src.Cloud.Models.CloudErrorObject import CloudErrorObject +from src.Cloud.Models.CloudError import CloudError from tests.Cloud.Fixtures import readFixture, parseFixture from .NetworkManagerMock import NetworkManagerMock @@ -20,7 +20,7 @@ from .NetworkManagerMock import NetworkManagerMock class TestCloudApiClient(TestCase): maxDiff = None - def _errorHandler(self, errors: List[CloudErrorObject]): + def _errorHandler(self, errors: List[CloudError]): raise Exception("Received unexpected error: {}".format(errors)) def setUp(self): From b8da720c1d528b3a7becfda00cf006543bc162c8 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Tue, 8 Jan 2019 13:10:42 +0100 Subject: [PATCH 156/178] Move translations in-line --- .../src/Cloud/CloudOutputDevice.py | 44 +++++++++++++------ .../src/Cloud/Translations.py | 31 ------------- 2 files changed, 31 insertions(+), 44 deletions(-) delete mode 100644 plugins/UM3NetworkPrinting/src/Cloud/Translations.py diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index e866303d27..f3c4830e24 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -7,6 +7,7 @@ from typing import Dict, List, Optional, Set, cast from PyQt5.QtCore import QObject, QUrl, pyqtProperty, pyqtSignal, pyqtSlot +from UM import i18nCatalog from UM.Backend.Backend import BackendState from UM.FileHandler.FileHandler import FileHandler from UM.Logger import Logger @@ -30,10 +31,12 @@ from .Models.CloudPrintResponse import CloudPrintResponse from .Models.CloudPrintJobResponse import CloudPrintJobResponse from .Models.CloudClusterPrinterStatus import CloudClusterPrinterStatus from .Models.CloudClusterPrintJobStatus import CloudClusterPrintJobStatus -from .Translations import Translations from .Utils import findChanges, formatDateCompleted, formatTimeCompleted +I18N_CATALOG = i18nCatalog("cura") + + ## The cloud output device is a network output device that works remotely but has limited functionality. # Currently it only supports viewing the printer and print job status and adding a new job to the queue. # As such, those methods have been implemented here. @@ -132,9 +135,9 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): def _setInterfaceElements(self) -> None: self.setPriority(2) # make sure we end up below the local networking and above 'save to file' self.setName(self._id) - self.setShortDescription(Translations.PRINT_VIA_CLOUD_BUTTON) - self.setDescription(Translations.PRINT_VIA_CLOUD_TOOLTIP) - self.setConnectionText(Translations.CONNECTED_VIA_CLOUD) + self.setShortDescription(I18N_CATALOG.i18nc("@action:button", "Print via Cloud")) + self.setDescription(I18N_CATALOG.i18nc("@properties:tooltip", "Print via Cloud")) + self.setConnectionText(I18N_CATALOG.i18nc("@info:status", "Connected via Cloud")) ## Called when Cura requests an output device to receive a (G-code) file. def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mimetypes: bool = False, @@ -142,7 +145,11 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): # Show an error message if we're already sending a job. if self._progress.visible: - message = Message(text = Translations.BLOCKED_UPLOADING, title = Translations.ERROR, lifetime = 10) + message = Message( + text = I18N_CATALOG.i18nc("@info:status", "Sending new jobs (temporarily) blocked, still sending the previous print job."), + title = I18N_CATALOG.i18nc("@info:title", "Cloud error"), + lifetime = 10 + ) message.show() return @@ -157,7 +164,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): mesh_format = MeshFormatHandler(file_handler, self.firmwareVersion) if not mesh_format.is_valid: Logger.log("e", "Missing file or mesh writer!") - return self._onUploadError(Translations.COULD_NOT_EXPORT) + return self._onUploadError(I18N_CATALOG.i18nc("@info:status", "Could not export print job.")) mesh = mesh_format.getBytes(nodes) @@ -265,11 +272,14 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): if job.state == "wait_cleanup" and job.key not in self._finished_jobs and job.owner == user_name: self._finished_jobs.add(job.key) Message( - title = Translations.JOB_COMPLETED_TITLE, - text = (Translations.JOB_COMPLETED_PRINTER.format(printer_name=job.assignedPrinter.name, - job_name=job.name) - if job.assignedPrinter else - Translations.JOB_COMPLETED_NO_PRINTER.format(job_name=job.name)), + title = I18N_CATALOG.i18nc("@info:status", "Print finished"), + text = (I18N_CATALOG.i18nc("@info:status", "Printer '{printer_name}' has finished printing '{job_name}'.").format( + printer_name = job.assignedPrinter.name, + job_name = job.name + ) if job.assignedPrinter else + I18N_CATALOG.i18nc("@info:status", "The print job '{job_name}' was finished.").format( + job_name = job.name + )), ).show() # Ensure UI gets updated @@ -305,7 +315,11 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): def _onUploadError(self, message = None) -> None: self._progress.hide() self._uploaded_print_job = None - Message(text = message or Translations.UPLOAD_ERROR, title = Translations.ERROR, lifetime = 10).show() + Message( + text = message or I18N_CATALOG.i18nc("@info:text", "Could not upload the data to the printer."), + title = I18N_CATALOG.i18nc("@info:title", "Cloud error"), + lifetime = 10 + ).show() self.writeError.emit() ## Shows a message when the upload has succeeded @@ -313,7 +327,11 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): def _onPrintRequested(self, response: CloudPrintResponse) -> None: Logger.log("d", "The cluster will be printing this print job with the ID %s", response.cluster_job_id) self._progress.hide() - Message(text = Translations.UPLOAD_SUCCESS_TEXT, title = Translations.UPLOAD_SUCCESS_TITLE, lifetime = 5).show() + Message( + text = I18N_CATALOG.i18nc("@info:status", "Print job was successfully sent to the printer."), + title = I18N_CATALOG.i18nc("@info:title", "Data Sent"), + lifetime = 5 + ).show() self.writeFinished.emit() ## Gets the remote printers. diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Translations.py b/plugins/UM3NetworkPrinting/src/Cloud/Translations.py deleted file mode 100644 index 278bf91c37..0000000000 --- a/plugins/UM3NetworkPrinting/src/Cloud/Translations.py +++ /dev/null @@ -1,31 +0,0 @@ -# Copyright (c) 2018 Ultimaker B.V. -# Cura is released under the terms of the LGPLv3 or higher. -from UM import i18nCatalog - - -## Class that contains all the translations for this module. -class Translations: - # The translation catalog for this device. - - _I18N_CATALOG = i18nCatalog("cura") - - PRINT_VIA_CLOUD_BUTTON = _I18N_CATALOG.i18nc("@action:button", "Print via Cloud") - PRINT_VIA_CLOUD_TOOLTIP = _I18N_CATALOG.i18nc("@properties:tooltip", "Print via Cloud") - - CONNECTED_VIA_CLOUD = _I18N_CATALOG.i18nc("@info:status", "Connected via Cloud") - BLOCKED_UPLOADING = _I18N_CATALOG.i18nc("@info:status", "Sending new jobs (temporarily) blocked, still sending " - "the previous print job.") - - COULD_NOT_EXPORT = _I18N_CATALOG.i18nc("@info:status", "Could not export print job.") - - ERROR = _I18N_CATALOG.i18nc("@info:title", "Error") - UPLOAD_ERROR = _I18N_CATALOG.i18nc("@info:text", "Could not upload the data to the printer.") - - UPLOAD_SUCCESS_TITLE = _I18N_CATALOG.i18nc("@info:title", "Data Sent") - UPLOAD_SUCCESS_TEXT = _I18N_CATALOG.i18nc("@info:status", "Print job was successfully sent to the printer.") - - JOB_COMPLETED_TITLE = _I18N_CATALOG.i18nc("@info:status", "Print finished") - JOB_COMPLETED_PRINTER = _I18N_CATALOG.i18nc("@info:status", - "Printer '{printer_name}' has finished printing '{job_name}'.") - - JOB_COMPLETED_NO_PRINTER = _I18N_CATALOG.i18nc("@info:status", "The print job '{job_name}' was finished.") \ No newline at end of file From 36191fbe0f57aa2a3c88b358e7de408b9b94fa83 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Thu, 10 Jan 2019 15:36:12 +0100 Subject: [PATCH 157/178] Fix typevar typing issue --- plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py | 2 +- plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py index 2ff323555a..1b60ee7aae 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py @@ -22,7 +22,7 @@ from .Models.CloudPrintJobResponse import CloudPrintJobResponse ## The generic type variable used to document the methods below. -CloudApiClientModel = TypeVar("Model", bound = BaseModel) +CloudApiClientModel = TypeVar("CloudApiClientModel", bound = BaseModel) ## The cloud API client is responsible for handling the requests and responses from the cloud. diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index f3c4830e24..e8356cb897 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -44,7 +44,7 @@ I18N_CATALOG = i18nCatalog("cura") class CloudOutputDevice(NetworkedPrinterOutputDevice): # The interval with which the remote clusters are checked - CHECK_CLUSTER_INTERVAL = 50.0 # seconds + CHECK_CLUSTER_INTERVAL = 20.0 # seconds # Signal triggered when the print jobs in the queue were changed. printJobsChanged = pyqtSignal() From b78ac0664f1ebd869a6487f64f9e9b6859481dac Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Fri, 11 Jan 2019 16:07:08 +0100 Subject: [PATCH 158/178] Fix return types from review --- cura/PrinterOutput/NetworkedPrinterOutputDevice.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py index 4a8aa0a6b2..985d742728 100644 --- a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py +++ b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py @@ -228,7 +228,8 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): self._last_request_time = time() if not self._manager: - return Logger.log("e", "No network manager was created to execute the DELETE call with.") + Logger.log("e", "No network manager was created to execute the DELETE call with.") + return reply = self._manager.deleteResource(request) self._registerOnFinishedCallback(reply, on_finished) @@ -243,7 +244,8 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): self._last_request_time = time() if not self._manager: - return Logger.log("e", "No network manager was created to execute the GET call with.") + Logger.log("e", "No network manager was created to execute the GET call with.") + return reply = self._manager.get(request) self._registerOnFinishedCallback(reply, on_finished) @@ -262,7 +264,8 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): self._last_request_time = time() if not self._manager: - return Logger.log("e", "Could not find manager.") + Logger.log("e", "Could not find manager.") + return body = data if isinstance(data, bytes) else data.encode() # type: bytes reply = self._manager.post(request, body) From 36e49ee6bb0ee7261268135a3ab212f036fe2fc7 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Fri, 11 Jan 2019 16:08:46 +0100 Subject: [PATCH 159/178] Make activeMachineIsGroup more robust --- cura/Settings/MachineManager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cura/Settings/MachineManager.py b/cura/Settings/MachineManager.py index 2f9cb106fb..5f33be1c54 100755 --- a/cura/Settings/MachineManager.py +++ b/cura/Settings/MachineManager.py @@ -534,7 +534,7 @@ class MachineManager(QObject): @pyqtProperty(bool, notify = printerConnectedStatusChanged) def activeMachineIsGroup(self) -> bool: - return bool(self._printer_output_devices) and self._printer_output_devices[0].clusterSize > 1 + return bool(self._printer_output_devices) and len(self._printer_output_devices[0].printers) > 1 @pyqtProperty(bool, notify = printerConnectedStatusChanged) def activeMachineHasActiveNetworkConnection(self) -> bool: From d8b5f75e2a297c9ce269bc7b0208f2d65f90239e Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Fri, 11 Jan 2019 16:14:55 +0100 Subject: [PATCH 160/178] Solve some review comments --- cura/PrinterOutput/NetworkedPrinterOutputDevice.py | 14 +++++++------- .../UM3NetworkPrinting/src/Cloud/CloudApiClient.py | 4 +--- .../src/Cloud/CloudOutputDevice.py | 2 +- .../src/Cloud/CloudProgressMessage.py | 11 +++-------- .../UM3NetworkPrinting/src/MeshFormatHandler.py | 10 ++++------ 5 files changed, 16 insertions(+), 25 deletions(-) diff --git a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py index 985d742728..6b19ca9564 100644 --- a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py +++ b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py @@ -194,11 +194,11 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): assert (self._manager is not None) ## Sends a put request to the given path. - # url: The path after the API prefix. - # data: The data to be sent in the body - # content_type: The content type of the body data. - # on_finished: The function to call when the response is received. - # on_progress: The function to call when the progress changes. Parameters are bytes_sent / bytes_total. + # \param url: The path after the API prefix. + # \param data: The data to be sent in the body + # \param content_type: The content type of the body data. + # \param on_finished: The function to call when the response is received. + # \param on_progress: The function to call when the progress changes. Parameters are bytes_sent / bytes_total. def put(self, url: str, data: Union[str, bytes], content_type: Optional[str] = None, on_finished: Optional[Callable[[QNetworkReply], None]] = None, on_progress: Optional[Callable[[int, int], None]] = None) -> None: @@ -219,8 +219,8 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): reply.uploadProgress.connect(on_progress) ## Sends a delete request to the given path. - # url: The path after the API prefix. - # on_finished: The function to be call when the response is received. + # \param url: The path after the API prefix. + # \param on_finished: The function to be call when the response is received. def delete(self, url: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None: self._validateManager() diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py index 1b60ee7aae..836a0eb393 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py @@ -43,7 +43,7 @@ class CloudApiClient: self._account = account self._on_error = on_error self._upload = None # type: Optional[MeshUploader] - # in order to avoid garbage collection we keep the callbacks in this list. + # In order to avoid garbage collection we keep the callbacks in this list. self._anti_gc_callbacks = [] # type: List[Callable[[], None]] ## Gets the account used for the API. @@ -105,7 +105,6 @@ class CloudApiClient: request.setHeader(QNetworkRequest.ContentTypeHeader, content_type) if self._account.isLoggedIn: request.setRawHeader(b"Authorization", "Bearer {}".format(self._account.accessToken).encode()) - # Logger.log("i", "Created request for URL %s. Logged in = %s", path, self._account.isLoggedIn) return request ## Parses the given JSON network reply into a status code and a dictionary, handling unexpected errors as well. @@ -116,7 +115,6 @@ class CloudApiClient: status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) try: response = bytes(reply.readAll()).decode() - # Logger.log("i", "Received a reply %s from %s with %s", status_code, reply.url().toString(), response) return status_code, json.loads(response) except (UnicodeDecodeError, JSONDecodeError, ValueError) as err: error = CloudError(code=type(err).__name__, title=str(err), http_code=str(status_code), diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index e8356cb897..0b832fc8b4 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -107,7 +107,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): ## Disconnects the device def disconnect(self) -> None: super().disconnect() - Logger.log("i", "Disconnected to cluster %s", self.key) + Logger.log("i", "Disconnected from cluster %s", self.key) CuraApplication.getInstance().getBackend().backendStateChange.disconnect(self._onBackendStateChange) ## Resets the print job that was uploaded to force a new upload, runs whenever the user re-slices. diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudProgressMessage.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudProgressMessage.py index aefe59cc85..c4618c1d50 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudProgressMessage.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudProgressMessage.py @@ -4,20 +4,15 @@ from UM import i18nCatalog from UM.Message import Message -## Class that contains all the translations for this module. -class T: - _I18N_CATALOG = i18nCatalog("cura") - - SENDING_DATA_TEXT = _I18N_CATALOG.i18nc("@info:status", "Sending data to remote cluster") - SENDING_DATA_TITLE = _I18N_CATALOG.i18nc("@info:status", "Sending data to remote cluster") +I18N_CATALOG = i18nCatalog("cura") ## Class responsible for showing a progress message while a mesh is being uploaded to the cloud. class CloudProgressMessage(Message): def __init__(self): super().__init__( - text = T.SENDING_DATA_TEXT, - title = T.SENDING_DATA_TITLE, + text = I18N_CATALOG.i18nc("@info:status", "Sending data to remote cluster"), + title = I18N_CATALOG.i18nc("@info:status", "Sending data to remote cluster"), progress = -1, lifetime = 0, dismissable = False, diff --git a/plugins/UM3NetworkPrinting/src/MeshFormatHandler.py b/plugins/UM3NetworkPrinting/src/MeshFormatHandler.py index 72da3c4e6b..c3cd82a86d 100644 --- a/plugins/UM3NetworkPrinting/src/MeshFormatHandler.py +++ b/plugins/UM3NetworkPrinting/src/MeshFormatHandler.py @@ -13,11 +13,7 @@ from UM.i18n import i18nCatalog from cura.CuraApplication import CuraApplication -## Class that contains all the translations for this module. -class T: - # The translation catalog for this module. - _I18N_CATALOG = i18nCatalog("cura") - NO_FORMATS_AVAILABLE = _I18N_CATALOG.i18nc("@info:status", "There are no file formats available to write with!") +I18N_CATALOG = i18nCatalog("cura") ## This class is responsible for choosing the formats used by the connected clusters. @@ -106,7 +102,9 @@ class MeshFormatHandler: if len(file_formats) == 0: Logger.log("e", "There are no file formats available to write with!") - raise OutputDeviceError.WriteRequestFailedError(T.NO_FORMATS_AVAILABLE) + raise OutputDeviceError.WriteRequestFailedError( + I18N_CATALOG.i18nc("@info:status", "There are no file formats available to write with!") + ) return file_formats[0] ## Gets the file writer for the given file handler and mime type. From e465bd771ae8a5261980e53203e3b76178c78203 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Fri, 11 Jan 2019 16:18:47 +0100 Subject: [PATCH 161/178] Use toolpath instead of mesh, some review fixes --- cura/PrinterOutput/NetworkedPrinterOutputDevice.py | 2 +- plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py | 8 ++++---- plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py | 2 +- .../UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py index 6b19ca9564..47a6caf3e5 100644 --- a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py +++ b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py @@ -275,7 +275,7 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): def postFormWithParts(self, target: str, parts: List[QHttpPart], on_finished: Optional[Callable[[QNetworkReply], None]], - on_progress: Callable = None) -> QNetworkReply: + on_progress: Optional[Callable[[int, int], None]] = None) -> QNetworkReply: self._validateManager() request = self._createEmptyRequest(target, content_type=None) multi_post_part = QHttpMultiPart(QHttpMultiPart.FormDataType) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py index 836a0eb393..f82d244fa9 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py @@ -76,14 +76,14 @@ class CloudApiClient: reply = self._manager.put(self._createEmptyRequest(url), body.encode()) self._addCallback(reply, on_finished, CloudPrintJobResponse) - ## Uploads a print job mesh to the cloud. + ## Uploads a print job tool path to the cloud. # \param print_job: The object received after requesting an upload with `self.requestUpload`. - # \param mesh: The mesh data to be uploaded. + # \param mesh: The tool path data to be uploaded. # \param on_finished: The function to be called after the upload is successful. # \param on_progress: A function to be called during upload progress. It receives a percentage (0-100). # \param on_error: A function to be called if the upload fails. - def uploadMesh(self, print_job: CloudPrintJobResponse, mesh: bytes, on_finished: Callable[[], Any], - on_progress: Callable[[int], Any], on_error: Callable[[], Any]): + def uploadToolPath(self, print_job: CloudPrintJobResponse, mesh: bytes, on_finished: Callable[[], Any], + on_progress: Callable[[int], Any], on_error: Callable[[], Any]): self._upload = MeshUploader(self._manager, print_job, mesh, on_finished, on_progress, on_error) self._upload.start() diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index 0b832fc8b4..054c465ef2 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -302,7 +302,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): self._progress.show() self._uploaded_print_job = job_response mesh = cast(bytes, self._mesh) - self._api.uploadMesh(job_response, mesh, self._onPrintJobUploaded, self._progress.update, self._onUploadError) + self._api.uploadToolPath(job_response, mesh, self._onPrintJobUploaded, self._progress.update, self._onUploadError) ## Requests the print to be sent to the printer when we finished uploading the mesh. def _onPrintJobUploaded(self) -> None: diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py index b57334b2da..3c0617a290 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py @@ -74,7 +74,7 @@ class TestCloudApiClient(TestCase): self.assertEqual(["text/plain"], [r.content_type for r in results]) self.assertEqual(["uploading"], [r.status for r in results]) - def test_uploadMesh(self): + def test_uploadToolPath(self): results = [] progress = MagicMock() @@ -86,7 +86,7 @@ class TestCloudApiClient(TestCase): self.network.prepareReply("PUT", upload_response.upload_url, 200, b'{}') mesh = ("1234" * 100000).encode() - self.api.uploadMesh(upload_response, mesh, lambda: results.append("sent"), progress.advance, progress.error) + self.api.uploadToolPath(upload_response, mesh, lambda: results.append("sent"), progress.advance, progress.error) for _ in range(10): self.network.flushReplies() From 3c10cca0dee6c4a0fc0599bc33e51aaff7f4e5d9 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Fri, 11 Jan 2019 16:33:49 +0100 Subject: [PATCH 162/178] Fixed all codestyle and nitpicks from review --- .../src/Cloud/CloudOutputDevice.py | 32 +++++++++---------- .../src/Cloud/CloudOutputDeviceManager.py | 6 ++-- .../src/Cloud/CloudProgressMessage.py | 5 --- .../src/ClusterUM3OutputDevice.py | 2 +- .../tests/Cloud/NetworkManagerMock.py | 8 ++--- .../tests/Cloud/TestCloudApiClient.py | 2 +- .../qml/PrinterSelector/MachineSelector.qml | 22 +++++++++---- 7 files changed, 39 insertions(+), 38 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index 054c465ef2..2cf6a3c236 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -95,7 +95,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): self._finished_jobs = set() # type: Set[str] # Reference to the uploaded print job / mesh - self._mesh = None # type: Optional[bytes] + self._tool_path = None # type: Optional[bytes] self._uploaded_print_job = None # type: Optional[CloudPrintJobResponse] ## Connects this device. @@ -112,7 +112,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): ## Resets the print job that was uploaded to force a new upload, runs whenever the user re-slices. def _onBackendStateChange(self, _: BackendState) -> None: - self._mesh = None + self._tool_path = None self._uploaded_print_job = None ## Gets the cluster response from which this device was created. @@ -133,7 +133,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): ## Set all the interface elements and texts for this output device. def _setInterfaceElements(self) -> None: - self.setPriority(2) # make sure we end up below the local networking and above 'save to file' + self.setPriority(2) # Make sure we end up below the local networking and above 'save to file' self.setName(self._id) self.setShortDescription(I18N_CATALOG.i18nc("@action:button", "Print via Cloud")) self.setDescription(I18N_CATALOG.i18nc("@properties:tooltip", "Print via Cloud")) @@ -154,8 +154,8 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): return if self._uploaded_print_job: - # the mesh didn't change, let's not upload it again - self._api.requestPrint(self.key, self._uploaded_print_job.job_id, self._onPrintRequested) + # The mesh didn't change, let's not upload it again + self._api.requestPrint(self.key, self._uploaded_print_job.job_id, self._onPrintUploadCompleted) return # Indicate we have started sending a job. @@ -168,7 +168,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): mesh = mesh_format.getBytes(nodes) - self._mesh = mesh + self._tool_path = mesh request = CloudPrintJobUploadRequest( job_name = file_name or mesh_format.file_extension, file_size = len(mesh), @@ -180,7 +180,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): def _update(self) -> None: super()._update() if self._last_request_time and time() - self._last_request_time < self.CHECK_CLUSTER_INTERVAL: - return # avoid calling the cloud too often + return # Avoid calling the cloud too often Logger.log("d", "Updating: %s - %s >= %s", time(), self._last_request_time, self.CHECK_CLUSTER_INTERVAL) if self._account.isLoggedIn: @@ -226,7 +226,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): if self._printers and not self._active_printer: self.setActivePrinter(self._printers[0]) - if added_printers or removed_printers or updated_printers: + if added_printers or removed_printers: self.printersChanged.emit() ## Updates the local list of print jobs with the list received from the cloud. @@ -253,7 +253,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): # We only have to update when jobs are added or removed # updated jobs push their changes via their output model - if added_jobs or removed_jobs or updated_jobs: + if added_jobs or removed_jobs: self.printJobsChanged.emit() ## Registers a new print job received via the cloud API. @@ -268,6 +268,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): ## Handles the event of a change in a print job state def _onPrintJobStateChanged(self) -> None: user_name = self._getUserName() + # TODO: confirm that notifications in Cura are still required for job in self._print_jobs: if job.state == "wait_cleanup" and job.key not in self._finished_jobs and job.owner == user_name: self._finished_jobs.add(job.key) @@ -282,9 +283,6 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): )), ).show() - # Ensure UI gets updated - self.printJobsChanged.emit() - ## Updates the printer assignment for the given print job model. def _updateAssignedPrinter(self, model: UM3PrintJobOutputModel, printer_uuid: str) -> None: printer = next((p for p in self._printers if printer_uuid == p.key), None) @@ -301,18 +299,18 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): def _onPrintJobCreated(self, job_response: CloudPrintJobResponse) -> None: self._progress.show() self._uploaded_print_job = job_response - mesh = cast(bytes, self._mesh) - self._api.uploadToolPath(job_response, mesh, self._onPrintJobUploaded, self._progress.update, self._onUploadError) + tool_path = cast(bytes, self._tool_path) + self._api.uploadToolPath(job_response, tool_path, self._onPrintJobUploaded, self._progress.update, self._onUploadError) ## Requests the print to be sent to the printer when we finished uploading the mesh. def _onPrintJobUploaded(self) -> None: self._progress.update(100) print_job = cast(CloudPrintJobResponse, self._uploaded_print_job) - self._api.requestPrint(self.key, print_job.job_id, self._onPrintRequested) + self._api.requestPrint(self.key, print_job.job_id, self._onPrintUploadCompleted) ## Displays the given message if uploading the mesh has failed # \param message: The message to display. - def _onUploadError(self, message = None) -> None: + def _onUploadError(self, message: str = None) -> None: self._progress.hide() self._uploaded_print_job = None Message( @@ -324,7 +322,7 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): ## Shows a message when the upload has succeeded # \param response: The response from the cloud API. - def _onPrintRequested(self, response: CloudPrintResponse) -> None: + def _onPrintUploadCompleted(self, response: CloudPrintResponse) -> None: Logger.log("d", "The cluster will be printing this print job with the ID %s", response.cluster_job_id) self._progress.hide() Message( diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 72ac34ff34..ba852e36c0 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -41,7 +41,7 @@ class CloudOutputDeviceManager: self._account = application.getCuraAPI().account # type: Account self._api = CloudApiClient(self._account, self._onApiError) - # create a timer to update the remote cluster list + # Create a timer to update the remote cluster list self._update_timer = QTimer(application) self._update_timer.setInterval(int(self.CHECK_CLUSTER_INTERVAL * 1000)) self._update_timer.setSingleShot(False) @@ -99,7 +99,6 @@ class CloudOutputDeviceManager: def _connectToActiveMachine(self) -> None: active_machine = CuraApplication.getInstance().getGlobalContainerStack() if not active_machine: - Logger.log("d", "no active machine") return # Remove all output devices that we have registered. @@ -143,8 +142,7 @@ class CloudOutputDeviceManager: message = Message( text = text, title = self.I18N_CATALOG.i18nc("@info:title", "Error"), - lifetime = 10, - dismissable = True + lifetime = 10 ) message.show() diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudProgressMessage.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudProgressMessage.py index c4618c1d50..d85f49c1a0 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudProgressMessage.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudProgressMessage.py @@ -30,8 +30,3 @@ class CloudProgressMessage(Message): if not self._visible: super().show() self.setProgress(percentage) - - ## Returns a boolean indicating whether the message is currently visible. - @property - def visible(self) -> bool: - return self._visible diff --git a/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py b/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py index e40cad10f8..84740ae856 100644 --- a/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py @@ -64,7 +64,7 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): self._monitor_view_qml_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "../resources/qml/MonitorStage.qml") - # trigger the printersChanged signal when the private signal is triggered + # Trigger the printersChanged signal when the private signal is triggered self.printersChanged.connect(self._clusterPrintersChanged) self._accepts_commands = True # type: bool diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py b/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py index 5b5d89ca54..e504509d67 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py @@ -30,7 +30,7 @@ class FakeSignal: # Any requests not prepared beforehand will cause KeyErrors. class NetworkManagerMock: - # an enumeration of the supported operations and their code for the network access manager. + # An enumeration of the supported operations and their code for the network access manager. _OPERATIONS = { "GET": QNetworkAccessManager.GetOperation, "POST": QNetworkAccessManager.PostOperation, @@ -41,11 +41,11 @@ class NetworkManagerMock: ## Initializes the network manager mock. def __init__(self) -> None: - # a dict with the prepared replies, using the format {(http_method, url): reply} + # A dict with the prepared replies, using the format {(http_method, url): reply} self.replies = {} # type: Dict[Tuple[str, str], MagicMock] self.request_bodies = {} # type: Dict[Tuple[str, str], bytes] - # signals used in the network manager. + # Signals used in the network manager. self.finished = Signal() self.authenticationRequired = Signal() @@ -55,7 +55,7 @@ class NetworkManagerMock: # \return The mocked function, if the method name is known. Defaults to the standard getattr function. def __getattr__(self, method: str) -> Any: ## This mock implementation will simply return the reply from the prepared ones. - # it raises a KeyError if requests are done without being prepared. + # it raises a KeyError if requests are done without being prepared. def doRequest(request: QNetworkRequest, body: Optional[bytes] = None, *_): key = method.upper(), request.url().toString() if body: diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py index 3c0617a290..acce55751c 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py @@ -39,7 +39,7 @@ class TestCloudApiClient(TestCase): data = parseFixture("getClusters")["data"] self.network.prepareReply("GET", CuraCloudAPIRoot + "/connect/v1/clusters", 200, response) - # the callback is a function that adds the result of the call to getClusters to the result list + # The callback is a function that adds the result of the call to getClusters to the result list self.api.getClusters(lambda clusters: result.extend(clusters)) self.network.flushReplies() diff --git a/resources/qml/PrinterSelector/MachineSelector.qml b/resources/qml/PrinterSelector/MachineSelector.qml index 7018131f45..84f63a854a 100644 --- a/resources/qml/PrinterSelector/MachineSelector.qml +++ b/resources/qml/PrinterSelector/MachineSelector.qml @@ -29,11 +29,16 @@ Cura.ExpandablePopup text: isNetworkPrinter ? Cura.MachineManager.activeMachineNetworkGroupName : Cura.MachineManager.activeMachineName source: { - if (isGroup) { + if (isGroup) + { return UM.Theme.getIcon("printer_group") - } else if (isNetworkPrinter || isCloudPrinter) { + } + else if (isNetworkPrinter || isCloudPrinter) + { return UM.Theme.getIcon("printer_single") - } else { + } + else + { return "" } } @@ -52,11 +57,16 @@ Cura.ExpandablePopup source: { - if (isNetworkPrinter) { + if (isNetworkPrinter) + { return UM.Theme.getIcon("printer_connected") - } else if (isCloudPrinter) { + } + else if (isCloudPrinter) + { return UM.Theme.getIcon("printer_cloud_connected") - } else { + } + else + { return "" } } From 7bbd43928a4d8d3adcc7d8119995add058c57bea Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Fri, 11 Jan 2019 16:38:25 +0100 Subject: [PATCH 163/178] Fix more review comments --- plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py | 6 +++--- plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py | 2 ++ .../src/Cloud/CloudOutputDeviceManager.py | 3 +-- .../src/Cloud/{MeshUploader.py => ToolPathUploader.py} | 2 +- .../tests/Cloud/TestCloudOutputDeviceManager.py | 1 - 5 files changed, 7 insertions(+), 7 deletions(-) rename plugins/UM3NetworkPrinting/src/Cloud/{MeshUploader.py => ToolPathUploader.py} (99%) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py index f82d244fa9..9d6c29c0a4 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py @@ -11,7 +11,7 @@ from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply, QNetworkAccessManage from UM.Logger import Logger from cura import UltimakerCloudAuthentication from cura.API import Account -from .MeshUploader import MeshUploader +from .ToolPathUploader import ToolPathUploader from ..Models import BaseModel from .Models.CloudClusterResponse import CloudClusterResponse from .Models.CloudError import CloudError @@ -42,7 +42,7 @@ class CloudApiClient: self._manager = QNetworkAccessManager() self._account = account self._on_error = on_error - self._upload = None # type: Optional[MeshUploader] + self._upload = None # type: Optional[ToolPathUploader] # In order to avoid garbage collection we keep the callbacks in this list. self._anti_gc_callbacks = [] # type: List[Callable[[], None]] @@ -84,7 +84,7 @@ class CloudApiClient: # \param on_error: A function to be called if the upload fails. def uploadToolPath(self, print_job: CloudPrintJobResponse, mesh: bytes, on_finished: Callable[[], Any], on_progress: Callable[[int], Any], on_error: Callable[[], Any]): - self._upload = MeshUploader(self._manager, print_job, mesh, on_finished, on_progress, on_error) + self._upload = ToolPathUploader(self._manager, print_job, mesh, on_finished, on_progress, on_error) self._upload.start() # Requests a cluster to print the given print job. diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index 2cf6a3c236..2947cd421c 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -100,6 +100,8 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): ## Connects this device. def connect(self) -> None: + if self.isConnected(): + return super().connect() Logger.log("i", "Connected to cluster %s", self.key) CuraApplication.getInstance().getBackend().backendStateChange.connect(self._onBackendStateChange) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index ba852e36c0..f9bd635bbd 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -131,8 +131,7 @@ class CloudOutputDeviceManager: ## Connects to an output device and makes sure it is registered in the output device manager. def _connectToOutputDevice(self, device: CloudOutputDevice) -> None: - if not device.isConnected(): - device.connect() + device.connect() self._output_device_manager.addOutputDevice(device) ## Handles an API error received from the cloud. diff --git a/plugins/UM3NetworkPrinting/src/Cloud/MeshUploader.py b/plugins/UM3NetworkPrinting/src/Cloud/ToolPathUploader.py similarity index 99% rename from plugins/UM3NetworkPrinting/src/Cloud/MeshUploader.py rename to plugins/UM3NetworkPrinting/src/Cloud/ToolPathUploader.py index cb721b872e..176b7e6ab7 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/MeshUploader.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/ToolPathUploader.py @@ -10,7 +10,7 @@ from .Models.CloudPrintJobResponse import CloudPrintJobResponse ## Class responsible for uploading meshes to the cloud in separate requests. -class MeshUploader: +class ToolPathUploader: # The maximum amount of times to retry if the server returns one of the RETRY_HTTP_CODES MAX_RETRIES = 10 diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py index 96137a3edb..8b72c9d62d 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py @@ -125,5 +125,4 @@ class TestCloudOutputDeviceManager(TestCase): } self.network.prepareReply("GET", self.URL, 200, self.clusters_response) self._loadData() - message_mock.assert_called_once_with(text='Not found!', title='Error', lifetime=10, dismissable=True) message_mock.return_value.show.assert_called_once_with() From 81abc84741c161ead9c8a7b7825e7b714ad54292 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Mon, 14 Jan 2019 10:41:10 +0100 Subject: [PATCH 164/178] Fix typing of CloudOutputDevice in controller --- plugins/UM3NetworkPrinting/src/Cloud/CloudOutputController.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputController.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputController.py index c139be0c38..af98b55587 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputController.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputController.py @@ -1,11 +1,11 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from UM.OutputDevice.OutputDevice import OutputDevice from cura.PrinterOutput.PrinterOutputController import PrinterOutputController +from .CloudOutputDevice import CloudOutputDevice class CloudOutputController(PrinterOutputController): - def __init__(self, output_device: OutputDevice) -> None: + def __init__(self, output_device: CloudOutputDevice) -> None: super().__init__(output_device) # The cloud connection only supports fetching the printer and queue status and adding a job to the queue. From 13d390a7d9dc0949262107fd445e1a10cce24e3e Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Mon, 14 Jan 2019 10:43:32 +0100 Subject: [PATCH 165/178] Fix imports in tests, some cleanup --- .../tests/Cloud/TestCloudApiClient.py | 17 ++++++++--------- .../tests/Cloud/TestCloudOutputDevice.py | 12 +++++------- .../tests/Cloud/TestCloudOutputDeviceManager.py | 8 ++++---- 3 files changed, 17 insertions(+), 20 deletions(-) diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py index acce55751c..fcaa14b055 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py @@ -1,19 +1,18 @@ # Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -import os from typing import List from unittest import TestCase from unittest.mock import patch, MagicMock -from cura.CuraConstants import CuraCloudAPIRoot -from src.Cloud.CloudApiClient import CloudApiClient -from src.Cloud.Models.CloudClusterResponse import CloudClusterResponse -from src.Cloud.Models.CloudClusterStatus import CloudClusterStatus -from src.Cloud.Models.CloudPrintJobResponse import CloudPrintJobResponse -from src.Cloud.Models.CloudPrintJobUploadRequest import CloudPrintJobUploadRequest -from src.Cloud.Models.CloudError import CloudError -from tests.Cloud.Fixtures import readFixture, parseFixture +from cura.UltimakerCloudAuthentication import CuraCloudAPIRoot +from ...src.Cloud.CloudApiClient import CloudApiClient +from ...src.Cloud.Models.CloudClusterResponse import CloudClusterResponse +from ...src.Cloud.Models.CloudClusterStatus import CloudClusterStatus +from ...src.Cloud.Models.CloudPrintJobResponse import CloudPrintJobResponse +from ...src.Cloud.Models.CloudPrintJobUploadRequest import CloudPrintJobUploadRequest +from ...src.Cloud.Models.CloudError import CloudError +from .Fixtures import readFixture, parseFixture from .NetworkManagerMock import NetworkManagerMock diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py index 34e04689c2..0e2220ee04 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py @@ -5,14 +5,12 @@ from unittest import TestCase from unittest.mock import patch, MagicMock from UM.Scene.SceneNode import SceneNode -from UM.Signal import Signal -from cura.CuraApplication import CuraApplication -from cura.CuraConstants import CuraCloudAPIRoot +from cura.UltimakerCloudAuthentication import CuraCloudAPIRoot from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel -from src.Cloud.CloudApiClient import CloudApiClient -from src.Cloud.CloudOutputDevice import CloudOutputDevice -from src.Cloud.Models.CloudClusterResponse import CloudClusterResponse -from tests.Cloud.Fixtures import readFixture, parseFixture +from ...src.Cloud.CloudApiClient import CloudApiClient +from ...src.Cloud.CloudOutputDevice import CloudOutputDevice +from ...src.Cloud.Models.CloudClusterResponse import CloudClusterResponse +from .Fixtures import readFixture, parseFixture from .NetworkManagerMock import NetworkManagerMock diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py index 8b72c9d62d..5388cf152d 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py @@ -4,10 +4,10 @@ from unittest import TestCase from unittest.mock import patch, MagicMock from UM.OutputDevice.OutputDeviceManager import OutputDeviceManager -from cura.CuraConstants import CuraCloudAPIRoot -from src.Cloud.CloudOutputDevice import CloudOutputDevice -from src.Cloud.CloudOutputDeviceManager import CloudOutputDeviceManager -from tests.Cloud.Fixtures import parseFixture, readFixture +from cura.UltimakerCloudAuthentication import CuraCloudAPIRoot +from ...src.Cloud.CloudOutputDevice import CloudOutputDevice +from ...src.Cloud.CloudOutputDeviceManager import CloudOutputDeviceManager +from .Fixtures import parseFixture, readFixture from .NetworkManagerMock import NetworkManagerMock, FakeSignal From 1e2a8aa23e366491bbe0597d3eeca204e27f6b8d Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Mon, 14 Jan 2019 10:52:50 +0100 Subject: [PATCH 166/178] revert to prevent circular dependency --- plugins/UM3NetworkPrinting/src/Cloud/CloudOutputController.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputController.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputController.py index af98b55587..ec89b1d6c4 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputController.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputController.py @@ -1,11 +1,11 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. +from UM.OutputDevice.OutputDevice import OutputDevice from cura.PrinterOutput.PrinterOutputController import PrinterOutputController -from .CloudOutputDevice import CloudOutputDevice class CloudOutputController(PrinterOutputController): - def __init__(self, output_device: CloudOutputDevice) -> None: + def __init__(self, output_device: "OutputDevice") -> None: super().__init__(output_device) # The cloud connection only supports fetching the printer and queue status and adding a job to the queue. From 402097f4d087867906ed2b4e67f484fe552900ed Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Mon, 14 Jan 2019 10:57:50 +0100 Subject: [PATCH 167/178] Fix imports --- .../src/Cloud/Models/CloudClusterPrintJobStatus.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobStatus.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobStatus.py index 5d62471710..45b7d838a5 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobStatus.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobStatus.py @@ -3,17 +3,15 @@ from typing import List, Optional, Union, Dict, Any from cura.PrinterOutput.ConfigurationModel import ConfigurationModel -from plugins.UM3NetworkPrinting.src.Cloud.CloudOutputController import CloudOutputController -from plugins.UM3NetworkPrinting.src.ConfigurationChangeModel import ConfigurationChangeModel +from ...UM3PrintJobOutputModel import UM3PrintJobOutputModel +from ...ConfigurationChangeModel import ConfigurationChangeModel +from ..CloudOutputController import CloudOutputController +from .BaseCloudModel import BaseCloudModel from .CloudClusterBuildPlate import CloudClusterBuildPlate from .CloudClusterPrintJobConfigurationChange import CloudClusterPrintJobConfigurationChange from .CloudClusterPrintJobImpediment import CloudClusterPrintJobImpediment from .CloudClusterPrintCoreConfiguration import CloudClusterPrintCoreConfiguration from .CloudClusterPrintJobConstraint import CloudClusterPrintJobConstraints -from .BaseCloudModel import BaseCloudModel - -## Class representing a print job -from plugins.UM3NetworkPrinting.src.UM3PrintJobOutputModel import UM3PrintJobOutputModel ## Model for the status of a single print job in a cluster. From cd19eec98a8e69f0f454276ae7be8e6d8e62b95c Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Mon, 14 Jan 2019 11:06:16 +0100 Subject: [PATCH 168/178] Remove weird argument for timer --- .../UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index f9bd635bbd..c282463e55 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -42,7 +42,7 @@ class CloudOutputDeviceManager: self._api = CloudApiClient(self._account, self._onApiError) # Create a timer to update the remote cluster list - self._update_timer = QTimer(application) + self._update_timer = QTimer() self._update_timer.setInterval(int(self.CHECK_CLUSTER_INTERVAL * 1000)) self._update_timer.setSingleShot(False) From 7bf319dfd12ce82385ff2c290dfdceb64921b778 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Mon, 14 Jan 2019 14:17:36 +0100 Subject: [PATCH 169/178] Better fix for circular dependency --- .../UM3NetworkPrinting/src/Cloud/CloudOutputController.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputController.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputController.py index ec89b1d6c4..bd56ef3185 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputController.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputController.py @@ -1,11 +1,14 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from UM.OutputDevice.OutputDevice import OutputDevice from cura.PrinterOutput.PrinterOutputController import PrinterOutputController +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from .CloudOutputDevice import CloudOutputDevice + class CloudOutputController(PrinterOutputController): - def __init__(self, output_device: "OutputDevice") -> None: + def __init__(self, output_device: "CloudOutputDevice") -> None: super().__init__(output_device) # The cloud connection only supports fetching the printer and queue status and adding a job to the queue. From f478653c371ee9a97fc1f44bdb78421f108d1afe Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Mon, 14 Jan 2019 14:56:14 +0100 Subject: [PATCH 170/178] Uncomment code that was needed for testing --- plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py b/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py index 84740ae856..b48f9380e1 100644 --- a/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py @@ -389,9 +389,8 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): ## Called when the connection to the cluster changes. def connect(self) -> None: - # super().connect() - # self.sendMaterialProfiles() - pass + super().connect() + self.sendMaterialProfiles() def _onGetPreviewImageFinished(self, reply: QNetworkReply) -> None: reply_url = reply.url().toString() From 77c30c891f92d68e00dde62044317c90b978a706 Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Tue, 15 Jan 2019 08:20:39 +0100 Subject: [PATCH 171/178] Fix tests --- cura/UltimakerCloudAuthentication.py | 2 +- .../src/Cloud/CloudOutputDeviceManager.py | 2 +- .../src/Cloud/CloudProgressMessage.py | 5 ++++ .../tests/Cloud/TestCloudApiClient.py | 2 +- .../tests/Cloud/TestCloudOutputDevice.py | 3 +- .../Cloud/TestCloudOutputDeviceManager.py | 30 ++++++++----------- 6 files changed, 23 insertions(+), 21 deletions(-) diff --git a/cura/UltimakerCloudAuthentication.py b/cura/UltimakerCloudAuthentication.py index 5f69329dbb..69bb577354 100644 --- a/cura/UltimakerCloudAuthentication.py +++ b/cura/UltimakerCloudAuthentication.py @@ -5,7 +5,7 @@ # Constants used for the Cloud API # --------- DEFAULT_CLOUD_API_ROOT = "https://api.ultimaker.com" # type: str -DEFAULT_CLOUD_API_VERSION = 1 # type: int +DEFAULT_CLOUD_API_VERSION = "1" # type: str DEFAULT_CLOUD_ACCOUNT_API_ROOT = "https://account.ultimaker.com" # type: str try: diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index c282463e55..237947b21e 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -101,7 +101,7 @@ class CloudOutputDeviceManager: if not active_machine: return - # Remove all output devices that we have registered. + # Remove all output devices that we have registered. TODO: Why?? for stored_cluster_id in self._remote_clusters: self._output_device_manager.removeOutputDevice(stored_cluster_id) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudProgressMessage.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudProgressMessage.py index d85f49c1a0..32aa6044d3 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudProgressMessage.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudProgressMessage.py @@ -19,6 +19,11 @@ class CloudProgressMessage(Message): use_inactivity_timer = False ) + ## Returns a boolean indicating whether this message is currently visible + @property + def visible(self) -> bool: + return self._visible + ## Shows the progress message. def show(self): self.setProgress(0) diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py index fcaa14b055..0be1d82141 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py @@ -28,7 +28,7 @@ class TestCloudApiClient(TestCase): self.account.isLoggedIn.return_value = True self.network = NetworkManagerMock() - with patch("src.Cloud.CloudApiClient.QNetworkAccessManager", return_value = self.network): + with patch("plugins.UM3NetworkPrinting.src.Cloud.CloudApiClient.QNetworkAccessManager", return_value = self.network): self.api = CloudApiClient(self.account, self._errorHandler) def test_getClusters(self): diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py index 0e2220ee04..191b92bdd5 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py @@ -41,7 +41,8 @@ class TestCloudOutputDevice(TestCase): self.network = NetworkManagerMock() self.account = MagicMock(isLoggedIn=True, accessToken="TestAccessToken") self.onError = MagicMock() - with patch("src.Cloud.CloudApiClient.QNetworkAccessManager", return_value = self.network): + with patch("plugins.UM3NetworkPrinting.src.Cloud.CloudApiClient.QNetworkAccessManager", + return_value = self.network): self._api = CloudApiClient(self.account, self.onError) self.device = CloudOutputDevice(self._api, self.cluster) diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py index 5388cf152d..c5006f35a1 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py @@ -5,7 +5,6 @@ from unittest.mock import patch, MagicMock from UM.OutputDevice.OutputDeviceManager import OutputDeviceManager from cura.UltimakerCloudAuthentication import CuraCloudAPIRoot -from ...src.Cloud.CloudOutputDevice import CloudOutputDevice from ...src.Cloud.CloudOutputDeviceManager import CloudOutputDeviceManager from .Fixtures import parseFixture, readFixture from .NetworkManagerMock import NetworkManagerMock, FakeSignal @@ -29,8 +28,10 @@ class TestCloudOutputDeviceManager(TestCase): self.network = NetworkManagerMock() self.timer = MagicMock(timeout = FakeSignal()) - with patch("src.Cloud.CloudApiClient.QNetworkAccessManager", return_value = self.network), \ - patch("src.Cloud.CloudOutputDeviceManager.QTimer", return_value = self.timer): + with patch("plugins.UM3NetworkPrinting.src.Cloud.CloudApiClient.QNetworkAccessManager", + return_value = self.network), \ + patch("plugins.UM3NetworkPrinting.src.Cloud.CloudOutputDeviceManager.QTimer", + return_value = self.timer): self.manager = CloudOutputDeviceManager() self.clusters_response = parseFixture("getClusters") self.network.prepareReply("GET", self.URL, 200, readFixture("getClusters")) @@ -53,16 +54,12 @@ class TestCloudOutputDeviceManager(TestCase): self.network.flushReplies() # get the created devices devices = self.device_manager.getOutputDevices() - # get the server data - clusters = self.clusters_response.get("data", []) - self.assertEqual([CloudOutputDevice] * len(clusters), [type(d) for d in devices]) - self.assertEqual({cluster["cluster_id"] for cluster in clusters}, {device.key for device in devices}) - self.assertEqual(clusters, sorted((device.clusterData.toDict() for device in devices), - key=lambda device_dict: device_dict["host_version"])) + # TODO: Check active device - for device in clusters: - self.device_manager.getOutputDevice(device["cluster_id"]).close() - self.device_manager.removeOutputDevice(device["cluster_id"]) + response_clusters = self.clusters_response.get("data", []) + manager_clusters = sorted([device.clusterData.toDict() for device in self.manager._remote_clusters.values()], + key=lambda cluster: cluster['cluster_id'], reverse=True) + self.assertEqual(response_clusters, manager_clusters) ## Runs the initial request to retrieve the clusters. def _loadData(self): @@ -100,7 +97,7 @@ class TestCloudOutputDeviceManager(TestCase): self._loadData() self.assertTrue(self.device_manager.getOutputDevice(cluster1["cluster_id"]).isConnected()) - self.assertFalse(self.device_manager.getOutputDevice(cluster2["cluster_id"]).isConnected()) + self.assertIsNone(self.device_manager.getOutputDevice(cluster2["cluster_id"])) self.assertEquals([], active_machine_mock.setMetaDataEntry.mock_calls) def test_device_connects_by_network_key(self): @@ -112,13 +109,12 @@ class TestCloudOutputDeviceManager(TestCase): self._loadData() - self.assertEqual([False, True], - [self.device_manager.getOutputDevice(cluster["cluster_id"]).isConnected() - for cluster in (cluster1, cluster2)]) + self.assertIsNone(self.device_manager.getOutputDevice(cluster1["cluster_id"])) + self.assertTrue(self.device_manager.getOutputDevice(cluster2["cluster_id"]).isConnected()) active_machine_mock.setMetaDataEntry.assert_called_with("um_cloud_cluster_id", cluster2["cluster_id"]) - @patch("src.Cloud.CloudOutputDeviceManager.Message") + @patch("plugins.UM3NetworkPrinting.src.Cloud.CloudOutputDeviceManager.Message") def test_api_error(self, message_mock): self.clusters_response = { "errors": [{"id": "notFound", "title": "Not found!", "http_status": "404", "code": "notFound"}] From 7785555a943b600636809ae5c5e50cabecdd79fd Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Tue, 15 Jan 2019 08:21:17 +0100 Subject: [PATCH 172/178] Remove mock changes used locally --- .../UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py | 2 +- .../UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py | 2 +- .../tests/Cloud/TestCloudOutputDeviceManager.py | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py index 0be1d82141..fcaa14b055 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py @@ -28,7 +28,7 @@ class TestCloudApiClient(TestCase): self.account.isLoggedIn.return_value = True self.network = NetworkManagerMock() - with patch("plugins.UM3NetworkPrinting.src.Cloud.CloudApiClient.QNetworkAccessManager", return_value = self.network): + with patch("src.Cloud.CloudApiClient.QNetworkAccessManager", return_value = self.network): self.api = CloudApiClient(self.account, self._errorHandler) def test_getClusters(self): diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py index 191b92bdd5..1a72c170d3 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py @@ -41,7 +41,7 @@ class TestCloudOutputDevice(TestCase): self.network = NetworkManagerMock() self.account = MagicMock(isLoggedIn=True, accessToken="TestAccessToken") self.onError = MagicMock() - with patch("plugins.UM3NetworkPrinting.src.Cloud.CloudApiClient.QNetworkAccessManager", + with patch("src.Cloud.CloudApiClient.QNetworkAccessManager", return_value = self.network): self._api = CloudApiClient(self.account, self.onError) diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py index c5006f35a1..6fe17f7759 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py @@ -28,9 +28,9 @@ class TestCloudOutputDeviceManager(TestCase): self.network = NetworkManagerMock() self.timer = MagicMock(timeout = FakeSignal()) - with patch("plugins.UM3NetworkPrinting.src.Cloud.CloudApiClient.QNetworkAccessManager", + with patch("src.Cloud.CloudApiClient.QNetworkAccessManager", return_value = self.network), \ - patch("plugins.UM3NetworkPrinting.src.Cloud.CloudOutputDeviceManager.QTimer", + patch("src.Cloud.CloudOutputDeviceManager.QTimer", return_value = self.timer): self.manager = CloudOutputDeviceManager() self.clusters_response = parseFixture("getClusters") @@ -114,7 +114,7 @@ class TestCloudOutputDeviceManager(TestCase): active_machine_mock.setMetaDataEntry.assert_called_with("um_cloud_cluster_id", cluster2["cluster_id"]) - @patch("plugins.UM3NetworkPrinting.src.Cloud.CloudOutputDeviceManager.Message") + @patch("src.Cloud.CloudOutputDeviceManager.Message") def test_api_error(self, message_mock): self.clusters_response = { "errors": [{"id": "notFound", "title": "Not found!", "http_status": "404", "code": "notFound"}] From de24c7d9c34e5bd80e6311e4d1c7ea1e1eaaea98 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Tue, 15 Jan 2019 09:35:28 +0100 Subject: [PATCH 173/178] Revert "Remove mock changes used locally" This reverts commit 7785555a943b600636809ae5c5e50cabecdd79fd. --- .../UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py | 2 +- .../UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py | 2 +- .../tests/Cloud/TestCloudOutputDeviceManager.py | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py index fcaa14b055..0be1d82141 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py @@ -28,7 +28,7 @@ class TestCloudApiClient(TestCase): self.account.isLoggedIn.return_value = True self.network = NetworkManagerMock() - with patch("src.Cloud.CloudApiClient.QNetworkAccessManager", return_value = self.network): + with patch("plugins.UM3NetworkPrinting.src.Cloud.CloudApiClient.QNetworkAccessManager", return_value = self.network): self.api = CloudApiClient(self.account, self._errorHandler) def test_getClusters(self): diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py index 1a72c170d3..191b92bdd5 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py @@ -41,7 +41,7 @@ class TestCloudOutputDevice(TestCase): self.network = NetworkManagerMock() self.account = MagicMock(isLoggedIn=True, accessToken="TestAccessToken") self.onError = MagicMock() - with patch("src.Cloud.CloudApiClient.QNetworkAccessManager", + with patch("plugins.UM3NetworkPrinting.src.Cloud.CloudApiClient.QNetworkAccessManager", return_value = self.network): self._api = CloudApiClient(self.account, self.onError) diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py index 6fe17f7759..c5006f35a1 100644 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py @@ -28,9 +28,9 @@ class TestCloudOutputDeviceManager(TestCase): self.network = NetworkManagerMock() self.timer = MagicMock(timeout = FakeSignal()) - with patch("src.Cloud.CloudApiClient.QNetworkAccessManager", + with patch("plugins.UM3NetworkPrinting.src.Cloud.CloudApiClient.QNetworkAccessManager", return_value = self.network), \ - patch("src.Cloud.CloudOutputDeviceManager.QTimer", + patch("plugins.UM3NetworkPrinting.src.Cloud.CloudOutputDeviceManager.QTimer", return_value = self.timer): self.manager = CloudOutputDeviceManager() self.clusters_response = parseFixture("getClusters") @@ -114,7 +114,7 @@ class TestCloudOutputDeviceManager(TestCase): active_machine_mock.setMetaDataEntry.assert_called_with("um_cloud_cluster_id", cluster2["cluster_id"]) - @patch("src.Cloud.CloudOutputDeviceManager.Message") + @patch("plugins.UM3NetworkPrinting.src.Cloud.CloudOutputDeviceManager.Message") def test_api_error(self, message_mock): self.clusters_response = { "errors": [{"id": "notFound", "title": "Not found!", "http_status": "404", "code": "notFound"}] From b7fb969524fc8dbb3ebbe66b26a2598c9791cfba Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Tue, 15 Jan 2019 09:37:38 +0100 Subject: [PATCH 174/178] Fixes for review --- .../UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py | 4 +++- plugins/UM3NetworkPrinting/src/Cloud/CloudProgressMessage.py | 5 ----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 237947b21e..541b30b05d 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -101,7 +101,9 @@ class CloudOutputDeviceManager: if not active_machine: return - # Remove all output devices that we have registered. TODO: Why?? + # Remove all output devices that we have registered. + # This is needed because when we switch machines we can only leave + # output devices that are meant for that machine. for stored_cluster_id in self._remote_clusters: self._output_device_manager.removeOutputDevice(stored_cluster_id) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudProgressMessage.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudProgressMessage.py index 32aa6044d3..d85f49c1a0 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudProgressMessage.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudProgressMessage.py @@ -19,11 +19,6 @@ class CloudProgressMessage(Message): use_inactivity_timer = False ) - ## Returns a boolean indicating whether this message is currently visible - @property - def visible(self) -> bool: - return self._visible - ## Shows the progress message. def show(self): self.setProgress(0) From ed96d32aca2a84c05ddfc63705e43212891b0741 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Tue, 15 Jan 2019 09:59:17 +0100 Subject: [PATCH 175/178] trigger re-build From a702661b5de80bbd1d0ef3063b5c0f16608992f2 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Tue, 15 Jan 2019 14:17:38 +0100 Subject: [PATCH 176/178] Decrease the API call intervals to get quicker UI updates --- plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py | 2 +- .../UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index 2947cd421c..33968beb6d 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -44,7 +44,7 @@ I18N_CATALOG = i18nCatalog("cura") class CloudOutputDevice(NetworkedPrinterOutputDevice): # The interval with which the remote clusters are checked - CHECK_CLUSTER_INTERVAL = 20.0 # seconds + CHECK_CLUSTER_INTERVAL = 10.0 # seconds # Signal triggered when the print jobs in the queue were changed. printJobsChanged = pyqtSignal() diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 541b30b05d..f9a0a59c81 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -26,7 +26,7 @@ class CloudOutputDeviceManager: META_CLUSTER_ID = "um_cloud_cluster_id" # The interval with which the remote clusters are checked - CHECK_CLUSTER_INTERVAL = 50.0 # seconds + CHECK_CLUSTER_INTERVAL = 30.0 # seconds # The translation catalog for this device. I18N_CATALOG = i18nCatalog("cura") From d9a8bb0eb7940b4c606627275a6572b8ca4ccdfa Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Wed, 16 Jan 2019 15:18:04 +0100 Subject: [PATCH 177/178] Remove empty init py in plugins dir --- plugins/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 plugins/__init__.py diff --git a/plugins/__init__.py b/plugins/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 From c28aefac4ed0eeca2d96a0459c168cb89df87c3b Mon Sep 17 00:00:00 2001 From: Diego Prado Gesto Date: Wed, 16 Jan 2019 17:33:14 +0100 Subject: [PATCH 178/178] Add the maximum backups message to the backups plugin It didn't work after previous changes, so now it will show again when the user already has 5 backups. Contributes to CURA-6005 --- plugins/CuraDrive/src/qml/components/BackupListFooter.qml | 2 +- plugins/CuraDrive/src/qml/pages/BackupsPage.qml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/CuraDrive/src/qml/components/BackupListFooter.qml b/plugins/CuraDrive/src/qml/components/BackupListFooter.qml index 56706b9990..8decdc5c27 100644 --- a/plugins/CuraDrive/src/qml/components/BackupListFooter.qml +++ b/plugins/CuraDrive/src/qml/components/BackupListFooter.qml @@ -30,7 +30,7 @@ RowLayout id: createBackupButton text: catalog.i18nc("@button", "Backup Now") iconSource: UM.Theme.getIcon("plus") - enabled: !CuraDrive.isCreatingBackup && !CuraDrive.isRestoringBackup && !backupListFooter.showInfoButton + enabled: !CuraDrive.isCreatingBackup && !CuraDrive.isRestoringBackup onClicked: CuraDrive.createBackup() busy: CuraDrive.isCreatingBackup } diff --git a/plugins/CuraDrive/src/qml/pages/BackupsPage.qml b/plugins/CuraDrive/src/qml/pages/BackupsPage.qml index 0ba0cae09b..c337294744 100644 --- a/plugins/CuraDrive/src/qml/pages/BackupsPage.qml +++ b/plugins/CuraDrive/src/qml/pages/BackupsPage.qml @@ -40,7 +40,7 @@ Item font: UM.Theme.getFont("default") color: UM.Theme.getColor("text") wrapMode: Label.WordWrap - visible: backupList.count == 0 + visible: backupList.model.length == 0 Layout.fillWidth: true Layout.fillHeight: true renderType: Text.NativeRendering @@ -62,14 +62,14 @@ Item font: UM.Theme.getFont("default") color: UM.Theme.getColor("text") wrapMode: Label.WordWrap - visible: backupList.count > 4 + visible: backupList.model.length > 4 renderType: Text.NativeRendering } BackupListFooter { id: backupListFooter - showInfoButton: backupList.count > 4 + showInfoButton: backupList.model.length > 4 } } }