diff --git a/cura/API/Account.py b/cura/API/Account.py index 47f67af8ce..a04f97ef1c 100644 --- a/cura/API/Account.py +++ b/cura/API/Account.py @@ -62,6 +62,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/CuraApplication.py b/cura/CuraApplication.py index 0d71dff106..d3de15690d 100755 --- a/cura/CuraApplication.py +++ b/cura/CuraApplication.py @@ -131,6 +131,7 @@ if TYPE_CHECKING: numpy.seterr(all = "ignore") + try: from cura.CuraVersion import CuraAppDisplayName, CuraVersion, CuraBuildType, CuraDebugMode, CuraSDKVersion # type: ignore except ImportError: diff --git a/cura/GlobalStacksModel.py b/cura/GlobalStacksModel.py index 939809151d..289a03d1c4 100644 --- a/cura/GlobalStacksModel.py +++ b/cura/GlobalStacksModel.py @@ -60,4 +60,4 @@ class GlobalStacksModel(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) diff --git a/cura/OAuth2/AuthorizationService.py b/cura/OAuth2/AuthorizationService.py index a055254891..1e98dc9cee 100644 --- a/cura/OAuth2/AuthorizationService.py +++ b/cura/OAuth2/AuthorizationService.py @@ -52,8 +52,11 @@ 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() return None return self._user_profile diff --git a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py index 14f1364601..47a6caf3e5 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, ConnectionType @@ -11,12 +12,13 @@ from cura.PrinterOutputDevice import PrinterOutputDevice, ConnectionState, Conne 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 import gzip + class AuthState(IntEnum): NotAuthenticated = 1 AuthenticationRequested = 2 @@ -41,7 +43,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 +58,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: @@ -143,10 +147,12 @@ 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 + ## 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) @@ -163,9 +169,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: @@ -181,49 +193,89 @@ 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: + ## 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: Optional[str] = None, + on_finished: Optional[Callable[[QNetworkReply], None]] = 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.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: + request = self._createEmptyRequest(url, content_type = content_type) + self._last_request_time = time() + + if not self._manager: + 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) + self._registerOnFinishedCallback(reply, on_finished) + + if on_progress is not None: + reply.uploadProgress.connect(on_progress) + + ## Sends a delete 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 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: + 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) + + ## 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: str, on_finished: Optional[Callable[[QNetworkReply], None]], on_progress: Callable = None) -> None: + 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) + + ## 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.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: + 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) + 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: 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/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: diff --git a/cura/PrinterOutputDevice.py b/cura/PrinterOutputDevice.py index 99e8835c2f..dbdf8c986c 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 @@ -54,10 +53,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. - Q_ENUMS(ConnectionType) - printersChanged = pyqtSignal() connectionStateChanged = pyqtSignal(str) acceptsCommandsChanged = pyqtSignal() @@ -80,28 +75,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) @@ -130,10 +125,11 @@ class PrinterOutputDevice(QObject, OutputDevice): self._connection_state = connection_state self.connectionStateChanged.emit(self._id) - def getConnectionType(self) -> "ConnectionType": + @pyqtProperty(int, constant = True) + def connectionType(self) -> "ConnectionType": return self._connection_type - @pyqtProperty(str, notify = connectionStateChanged) + @pyqtProperty(int, notify = connectionStateChanged) def connectionState(self) -> "ConnectionState": return self._connection_state @@ -147,7 +143,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) @@ -223,8 +220,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/cura/Settings/MachineManager.py b/cura/Settings/MachineManager.py index dec2524e6b..5763d2bcab 100755 --- a/cura/Settings/MachineManager.py +++ b/cura/Settings/MachineManager.py @@ -176,6 +176,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: @@ -514,7 +515,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) @@ -524,6 +525,20 @@ class MachineManager(QObject): return connection_type in [ConnectionType.NetworkConnection.value, ConnectionType.CloudConnection.value] return False + @pyqtProperty(bool, notify = printerConnectedStatusChanged) + def activeMachineIsGroup(self) -> bool: + return bool(self._printer_output_devices) and len(self._printer_output_devices[0].printers) > 1 + + @pyqtProperty(bool, notify = printerConnectedStatusChanged) + 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: return self._global_container_stack.getMetaDataEntry("um_network_key", "") 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/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 } } } diff --git a/plugins/UM3NetworkPrinting/__init__.py b/plugins/UM3NetworkPrinting/__init__.py index e2ad5a2b12..3da7795589 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(), + "machine_action": DiscoverUM3Action.DiscoverUM3Action() + } diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py new file mode 100644 index 0000000000..9d6c29c0a4 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py @@ -0,0 +1,166 @@ +# Copyright (c) 2018 Ultimaker B.V. +# 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, cast + +from PyQt5.QtCore import QUrl +from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply, QNetworkAccessManager + +from UM.Logger import Logger +from cura import UltimakerCloudAuthentication +from cura.API import Account +from .ToolPathUploader import ToolPathUploader +from ..Models import BaseModel +from .Models.CloudClusterResponse import CloudClusterResponse +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("CloudApiClientModel", 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: + + # The cloud URL to use for this remote cluster. + ROOT_PATH = UltimakerCloudAuthentication.CuraCloudAPIRoot + 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[CloudError]], None]) -> None: + super().__init__() + self._manager = QNetworkAccessManager() + self._account = account + self._on_error = on_error + 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]] + + ## 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[CloudClusterResponse]], Any]) -> None: + url = "{}/clusters".format(self.CLUSTER_API_ROOT) + reply = self._manager.get(self._createEmptyRequest(url)) + self._addCallback(reply, 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: + url = "{}/clusters/{}/status".format(self.CLUSTER_API_ROOT, cluster_id) + reply = self._manager.get(self._createEmptyRequest(url)) + self._addCallback(reply, 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] + ) -> None: + url = "{}/jobs/upload".format(self.CURA_API_ROOT) + body = json.dumps({"data": request.toDict()}) + reply = self._manager.put(self._createEmptyRequest(url), body.encode()) + self._addCallback(reply, on_finished, CloudPrintJobResponse) + + ## 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 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 uploadToolPath(self, print_job: CloudPrintJobResponse, mesh: bytes, on_finished: Callable[[], Any], + on_progress: Callable[[int], Any], on_error: Callable[[], Any]): + 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. + # \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: + url = "{}/clusters/{}/print/{}".format(self.CLUSTER_API_ROOT, cluster_id, job_id) + reply = self._manager.post(self._createEmptyRequest(url), b"") + self._addCallback(reply, 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 = 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()) + 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() + 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), + id=str(time()), http_status="500") + Logger.logException("e", "Could not parse the stardust response: %s", error) + return status_code, {"errors": [error.toDict()]} + + ## 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[[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[CloudApiClientModel] + on_finished_list = cast(Callable[[List[CloudApiClientModel]], Any], on_finished) + on_finished_list(results) + else: + 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([CloudError(**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 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[[CloudApiClientModel], Any], + Callable[[List[CloudApiClientModel]], Any]], + model: Type[CloudApiClientModel], + ) -> None: + def parse() -> None: + status_code, response = self._parseReply(reply) + self._anti_gc_callbacks.remove(parse) + return self._parseModels(response, on_finished, model) + + self._anti_gc_callbacks.append(parse) + reply.finished.connect(parse) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputController.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputController.py new file mode 100644 index 0000000000..bd56ef3185 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputController.py @@ -0,0 +1,22 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +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: "CloudOutputDevice") -> None: + 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..33968beb6d --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -0,0 +1,424 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +import os + +from time import time +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 +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 cura.PrinterOutputDevice import ConnectionType + +from .CloudOutputController import CloudOutputController +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 .Models.CloudPrintJobResponse import CloudPrintJobResponse +from .Models.CloudClusterPrinterStatus import CloudClusterPrinterStatus +from .Models.CloudClusterPrintJobStatus import CloudClusterPrintJobStatus +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. +# Note that this device represents a single remote cluster, not a list of multiple clusters. +class CloudOutputDevice(NetworkedPrinterOutputDevice): + + # The interval with which the remote clusters are checked + CHECK_CLUSTER_INTERVAL = 10.0 # seconds + + # 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() + + ## Creates a new cloud output device + # \param api_client: The client that will run the API calls + # \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 = "", + connection_type = ConnectionType.CloudConnection, properties = {}, parent = parent) + self._api = api_client + self._cluster = cluster + + self._setInterfaceElements() + + 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__)), + "../../resources/qml/MonitorStage.qml") + + # 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. + + # We only allow a single upload at a time. + 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]] + 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] + + # Reference to the uploaded print job / mesh + self._tool_path = None # type: Optional[bytes] + self._uploaded_print_job = None # type: Optional[CloudPrintJobResponse] + + ## 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) + + ## Disconnects the device + def disconnect(self) -> None: + super().disconnect() + 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. + def _onBackendStateChange(self, _: BackendState) -> None: + self._tool_path = None + self._uploaded_print_job = None + + ## Gets the cluster response from which this device was created. + @property + def clusterData(self) -> CloudClusterResponse: + return self._cluster + + ## Updates the cluster data from the cloud. + @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.clusterData.host_name) + + ## 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.setName(self._id) + 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, + file_handler: Optional[FileHandler] = None, **kwargs: str) -> None: + + # Show an error message if we're already sending a job. + if self._progress.visible: + 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 + + 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._onPrintUploadCompleted) + return + + # Indicate we have started sending a job. + self.writeStarted.emit(self) + + mesh_format = MeshFormatHandler(file_handler, self.firmwareVersion) + if not mesh_format.is_valid: + Logger.log("e", "Missing file or mesh writer!") + return self._onUploadError(I18N_CATALOG.i18nc("@info:status", "Could not export print job.")) + + mesh = mesh_format.getBytes(nodes) + + self._tool_path = mesh + request = CloudPrintJobUploadRequest( + job_name = file_name or mesh_format.file_extension, + file_size = len(mesh), + content_type = mesh_format.mime_type, + ) + self._api.requestUpload(request, self._onPrintJobCreated) + + ## Called when the network data should be updated. + 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 + + 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() + self._api.getClusterStatus(self.key, 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._last_response_time = time() + 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. + 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, CloudClusterPrinterStatus] + + 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 self._printers and not self._active_printer: + self.setActivePrinter(self._printers[0]) + + if added_printers or removed_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. + 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) + + 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: + 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: + self.printJobsChanged.emit() + + ## Registers a new print job received via the cloud API. + # \param job: The print job received. + def _addPrintJob(self, job: CloudClusterPrintJobStatus) -> 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: + 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) + Message( + 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() + + ## 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: + 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) + + ## Uploads the mesh when the print job was registered with the cloud API. + # \param job_response: The response received from the cloud API. + def _onPrintJobCreated(self, job_response: CloudPrintJobResponse) -> None: + self._progress.show() + self._uploaded_print_job = job_response + 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._onPrintUploadCompleted) + + ## Displays the given message if uploading the mesh has failed + # \param message: The message to display. + def _onUploadError(self, message: str = None) -> None: + self._progress.hide() + self._uploaded_print_job = None + 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 + # \param response: The response from the cloud API. + 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( + 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. + @pyqtProperty("QVariantList", notify=_clusterPrintersChanged) + 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) + + ## Get remote print jobs. + @pyqtProperty("QVariantList", notify = printJobsChanged) + def printJobs(self) -> List[UM3PrintJobOutputModel]: + return self._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 + 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"] + + @pyqtSlot(int, result = str) + def formatDuration(self, seconds: int) -> str: + return Duration(seconds).getDisplayString(DurationFormat.Format.Short) + + @pyqtSlot(int, result = str) + def getTimeCompleted(self, time_remaining: int) -> str: + return formatTimeCompleted(time_remaining) + + @pyqtSlot(int, result = str) + def getDateCompleted(self, time_remaining: int) -> str: + 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. + + @pyqtProperty(QUrl, notify = _clusterPrintersChanged) + def activeCameraUrl(self) -> "QUrl": + return QUrl() + + @pyqtSlot(QUrl) + def setActiveCameraUrl(self, camera_url: "QUrl") -> None: + pass + + @pyqtProperty(bool, notify = printJobsChanged) + def receivedPrintJobs(self) -> bool: + return bool(self._print_jobs) + + @pyqtSlot() + def openPrintJobControlPanel(self) -> None: + pass + + @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 [] diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py new file mode 100644 index 0000000000..f9a0a59c81 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -0,0 +1,170 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from typing import Dict, List + +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 +from .CloudOutputDevice import CloudOutputDevice +from .Models.CloudClusterResponse import CloudClusterResponse +from .Models.CloudError import CloudError +from .Utils import findChanges + + +## 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/. +# +class CloudOutputDeviceManager: + META_CLUSTER_ID = "um_cloud_cluster_id" + + # The interval with which the remote clusters are checked + CHECK_CLUSTER_INTERVAL = 30.0 # seconds + + # The translation catalog for this device. + I18N_CATALOG = i18nCatalog("cura") + + def __init__(self) -> None: + # 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 # type: Account + self._api = CloudApiClient(self._account, self._onApiError) + + # Create a timer to update the remote cluster list + self._update_timer = QTimer() + self._update_timer.setInterval(int(self.CHECK_CLUSTER_INTERVAL * 1000)) + self._update_timer.setSingleShot(False) + + self._running = False + + # Called when the uses logs in or out + def _onLoginStateChanged(self, is_logged_in: bool) -> None: + 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() + self._getRemoteClusters() + else: + if self._update_timer.isActive(): + 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("d", "Retrieving remote clusters") + self._api.getClusters(self._onGetRemoteClustersFinished) + + ## Callback for when the request for getting the clusters. is finished. + 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) + + 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: + 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] + + # 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) + self._remote_clusters[added_cluster.cluster_id] = device + + for device, cluster in updates: + device.clusterData = cluster + + self._connectToActiveMachine() + + ## 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() + if not active_machine: + return + + # 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) + + # 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] + 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 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") + if not local_network_key: + return + + device = next((c for c in self._remote_clusters.values() if c.matchesNetworkKey(local_network_key)), None) + if not device: + return + + 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: + device.connect() + self._output_device_manager.addOutputDevice(device) + + ## Handles an API error received from the cloud. + # \param errors: The errors received + def _onApiError(self, errors: List[CloudError]) -> None: + 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 + ) + message.show() + + ## Starts running the cloud output device manager, thus periodically requesting cloud data. + 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._onLoginStateChanged(is_logged_in = self._account.isLoggedIn) + + ## Stops running the cloud output device manager. + 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._onLoginStateChanged(is_logged_in = False) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudProgressMessage.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudProgressMessage.py new file mode 100644 index 0000000000..d85f49c1a0 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudProgressMessage.py @@ -0,0 +1,32 @@ +# 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 + + +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 = 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, + 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) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/BaseCloudModel.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/BaseCloudModel.py new file mode 100644 index 0000000000..18a8cb5cba --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/BaseCloudModel.py @@ -0,0 +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, Any + +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.toDict() == other.toDict() + + ## 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 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/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/CloudClusterPrintCoreConfiguration.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintCoreConfiguration.py new file mode 100644 index 0000000000..7454401d09 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintCoreConfiguration.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 Union, Dict, Optional, Any + +from cura.PrinterOutput.ExtruderConfigurationModel import ExtruderConfigurationModel +from cura.PrinterOutput.ExtruderOutputModel import ExtruderOutputModel +from .CloudClusterPrinterConfigurationMaterial import CloudClusterPrinterConfigurationMaterial +from .BaseCloudModel import BaseCloudModel + + +## Class representing a cloud cluster printer configuration +# Spec: https://api-staging.ultimaker.com/connect/v1/spec +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. + # \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], + print_core_id: Optional[str] = None, **kwargs) -> None: + self.extruder_index = extruder_index + self.material = self.parseModel(CloudClusterPrinterConfigurationMaterial, material) if material else None + self.print_core_id = print_core_id + super().__init__(**kwargs) + + ## Updates the given output model. + # \param model - The output model to update. + def updateOutputModel(self, model: ExtruderOutputModel) -> None: + if self.print_core_id is not None: + model.updateHotendID(self.print_core_id) + + 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: + 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/Models/CloudClusterPrintJobConfigurationChange.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobConfigurationChange.py new file mode 100644 index 0000000000..9ff4154666 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobConfigurationChange.py @@ -0,0 +1,27 @@ +# 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: 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 + 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/CloudClusterPrintJobConstraint.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobConstraint.py new file mode 100644 index 0000000000..8236ec06b9 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobConstraint.py @@ -0,0 +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 +# 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/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 new file mode 100644 index 0000000000..45b7d838a5 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobStatus.py @@ -0,0 +1,134 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from typing import List, Optional, Union, Dict, Any + +from cura.PrinterOutput.ConfigurationModel import ConfigurationModel +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 + + +## 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. + # \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], 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, deleted_at: Optional[str] = None, + printed_on_uuid: Optional[str] = None, + configuration_changes_required: List[ + Union[Dict[str, Any], CloudClusterPrintJobConfigurationChange]] = 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 + self.configuration = self.parseModels(CloudClusterPrintCoreConfiguration, 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 + 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 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) + + ## 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: + 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: + # 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 + + if not status_set_by_impediment: + model.updateState(self.status) + + model.updateConfigurationChanges( + [ConfigurationChangeModel( + 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/CloudClusterPrinterConfigurationMaterial.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterConfigurationMaterial.py new file mode 100644 index 0000000000..652cbdabda --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterConfigurationMaterial.py @@ -0,0 +1,55 @@ +from typing import Optional + +from UM.Logger import Logger +from cura.CuraApplication import CuraApplication +from cura.PrinterOutput.MaterialOutputModel import MaterialOutputModel +from .BaseCloudModel import BaseCloudModel + + +## Class representing a cloud cluster printer configuration +# Spec: https://api-staging.ultimaker.com/connect/v1/spec +class CloudClusterPrinterConfigurationMaterial(BaseCloudModel): + ## 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. + 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/CloudClusterPrinterStatus.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterStatus.py new file mode 100644 index 0000000000..a8165ff69c --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterStatus.py @@ -0,0 +1,72 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from typing import List, Union, Dict, Optional, Any + +from cura.PrinterOutput.PrinterOutputController import PrinterOutputController +from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel +from .CloudClusterBuildPlate import CloudClusterBuildPlate +from .CloudClusterPrintCoreConfiguration import CloudClusterPrintCoreConfiguration +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. + # \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], 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: Union[Dict[str, Any], CloudClusterBuildPlate] = None, **kwargs) -> None: + + self.configuration = self.parseModels(CloudClusterPrintCoreConfiguration, 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 + 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) if build_plate else None + 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..9c0853e7c9 --- /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) -> None: + 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 new file mode 100644 index 0000000000..b0250c2ebb --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterStatus.py @@ -0,0 +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, Dict, Union, Any + +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): + ## 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) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudError.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudError.py new file mode 100644 index 0000000000..b53361022e --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudError.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 Dict, Optional, Any + +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 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 + # 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/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 new file mode 100644 index 0000000000..919d1b3c3a --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudPrintResponse.py @@ -0,0 +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): + ## 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/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/Cloud/ToolPathUploader.py b/plugins/UM3NetworkPrinting/src/Cloud/ToolPathUploader.py new file mode 100644 index 0000000000..176b7e6ab7 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/ToolPathUploader.py @@ -0,0 +1,148 @@ +# Copyright (c) 2018 Ultimaker B.V. +# !/usr/bin/env python +# -*- coding: utf-8 -*- +from PyQt5.QtCore import QUrl +from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply, QNetworkAccessManager +from typing import Optional, Callable, Any, Tuple, cast + +from UM.Logger import Logger +from .Models.CloudPrintJobResponse import CloudPrintJobResponse + + +## Class responsible for uploading meshes to the cloud in separate requests. +class ToolPathUploader: + + # The maximum amount of times to retry if the server returns one of the RETRY_HTTP_CODES + MAX_RETRIES = 10 + + # The HTTP codes that should trigger a retry. + RETRY_HTTP_CODES = {500, 502, 503, 504} + + # 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: + self._manager = manager + self._print_job = print_job + 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 + self._reply = None # type: Optional[QNetworkReply] + + ## Returns the print job for which this object was created. + @property + def printJob(self): + return self._print_job + + ## 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) + + 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._print_job.upload_url) + + 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") + + 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: + 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() + Logger.log("e", "Received error while uploading: %s", body) + 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) # 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 + + 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): + self.stop() + self._on_finished() + else: + self._uploadChunk() diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Utils.py b/plugins/UM3NetworkPrinting/src/Cloud/Utils.py new file mode 100644 index 0000000000..5136e0e7db --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/Utils.py @@ -0,0 +1,54 @@ +from datetime import datetime, timedelta +from typing import TypeVar, Dict, Tuple, List + +from UM import i18nCatalog + +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) + + 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 + + +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(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 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 days >= 2: + return completed.strftime("%a") + # If finishing tomorrow, use "tomorrow at HH:MM" format + elif 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/Cloud/__init__.py b/plugins/UM3NetworkPrinting/src/Cloud/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py b/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py index 5e8aaa9fa9..b48f9380e1 100644 --- a/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py @@ -1,46 +1,41 @@ # 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 + +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). from UM.FileHandler.WriteFileJob import WriteFileJob # To call the file writer asynchronously. from UM.Logger import Logger from UM.Settings.ContainerRegistry import ContainerRegistry 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, NetworkedPrinterOutputDevice from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel from cura.PrinterOutput.MaterialOutputModel import MaterialOutputModel from cura.PrinterOutputDevice import ConnectionType +from .Cloud.Utils import formatTimeCompleted, formatDateCompleted 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 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") @@ -50,9 +45,9 @@ 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. - clusterPrintersChanged = 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() def __init__(self, device_id, address, properties, parent = None) -> None: super().__init__(device_id = device_id, address = address, properties=properties, connection_type = ConnectionType.NetworkConnection, parent = parent) @@ -60,15 +55,17 @@ 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[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 self._monitor_view_qml_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "../resources/qml/MonitorStage.qml") - # See comments about this hack with the clusterPrintersChanged signal - self.printersChanged.connect(self.clusterPrintersChanged) + # Trigger the printersChanged signal when the private signal is triggered + self.printersChanged.connect(self._clusterPrintersChanged) self._accepts_commands = True # type: bool @@ -101,53 +98,19 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): 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 + 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. @@ -187,11 +150,8 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): # 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( @@ -205,35 +165,37 @@ 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 - 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) - 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: 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 = [] @@ -257,7 +219,9 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): 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]: @@ -291,7 +255,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() - if self._progress_message and new_progress > self._progress_message.getProgress(): + 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) @@ -357,7 +321,7 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): 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: @@ -370,41 +334,17 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): 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 - @pyqtSlot(int, result = str) - def formatDuration(self, seconds: int) -> str: - return Duration(seconds).getDisplayString(DurationFormat.Format.Short) - @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) + return formatTimeCompleted(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" + return formatDateCompleted(time_remaining) @pyqtSlot(str) def sendJobToTop(self, print_job_uuid: str) -> None: 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) - diff --git a/plugins/UM3NetworkPrinting/src/DiscoverUM3Action.py b/plugins/UM3NetworkPrinting/src/DiscoverUM3Action.py index 68af2bd575..b688ee9d7d 100644 --- a/plugins/UM3NetworkPrinting/src/DiscoverUM3Action.py +++ b/plugins/UM3NetworkPrinting/src/DiscoverUM3Action.py @@ -123,26 +123,33 @@ 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) - 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.getConnectionType().value) - CuraApplication.getInstance().getMachineManager().replaceContainersMetadata(key = "connection_type", value = previous_connection_type, new_value = printer_device.getConnectionType().value) - else: - global_container_stack.setMetaDataEntry("um_network_key", printer_device.key) - global_container_stack.setMetaDataEntry("connection_type", printer_device.getConnectionType().value) + 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 + + 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. diff --git a/plugins/UM3NetworkPrinting/src/MeshFormatHandler.py b/plugins/UM3NetworkPrinting/src/MeshFormatHandler.py new file mode 100644 index 0000000000..c3cd82a86d --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/MeshFormatHandler.py @@ -0,0 +1,115 @@ +# 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, cast + +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 + + +I18N_CATALOG = i18nCatalog("cura") + + +## 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.mime_type) if self._preferred_format else None + + @property + 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 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 cast(int, self._preferred_format["mode"]) + + ## Gets the file extension + @property + def file_extension(self) -> str: + 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]: + 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) + 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) -> Dict[str, Union[str, int, bool]]: + # Formats supported by this application (file types that we can actually write). + application = CuraApplication.getInstance() + + file_formats = self._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(firmware_version) >= 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( + 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. + # \param mime_type: The mine type. + # \return A file writer. + def _getWriter(self, mime_type: str) -> Optional[FileWriter]: + # Just take the first file format available. + return self._file_handler.getWriterByMimeType(mime_type) 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/src/SendMaterialJob.py b/plugins/UM3NetworkPrinting/src/SendMaterialJob.py index 9d0d3dbbad..8cdd647a25 100644 --- a/plugins/UM3NetworkPrinting/src/SendMaterialJob.py +++ b/plugins/UM3NetworkPrinting/src/SendMaterialJob.py @@ -2,7 +2,7 @@ # Cura is released under the terms of the LGPLv3 or higher. import json import os -from typing import Dict, TYPE_CHECKING, Set +from typing import Dict, TYPE_CHECKING, Set, Optional from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest @@ -145,7 +145,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} @@ -157,6 +157,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 # @@ -182,7 +183,8 @@ class SendMaterialJob(Job): local_material.id = root_material_id 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/UM3OutputDevicePlugin.py b/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py index 57bc96b0e0..4a510903dd 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. - -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 . import ClusterUM3OutputDevice, LegacyUM3OutputDevice - -from PyQt5.QtNetwork import QNetworkRequest, QNetworkAccessManager -from PyQt5.QtCore import QUrl - -from zeroconf import Zeroconf, ServiceBrowser, ServiceStateChange, ServiceInfo +import json from queue import Queue from threading import Event, Thread from time import time -import json +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 . import ClusterUM3OutputDevice, LegacyUM3OutputDevice +from .Cloud.CloudOutputDeviceManager import CloudOutputDeviceManager ## This plugin handles the connection detection & creation of output device objects for the UM3 printer. @@ -31,9 +30,13 @@ class UM3OutputDevicePlugin(OutputDevicePlugin): def __init__(self): super().__init__() + self._zero_conf = None self._zero_conf_browser = None + # 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. self.addDeviceSignal.connect(self._onAddDevice) self.removeDeviceSignal.connect(self._onRemoveDevice) @@ -83,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() @@ -114,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: @@ -140,6 +144,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: @@ -284,7 +289,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) @@ -362,4 +367,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 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/src/__init__.py b/plugins/UM3NetworkPrinting/src/__init__.py new file mode 100644 index 0000000000..e69de29bb2 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/getClusterStatusResponse.json b/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/getClusterStatusResponse.json new file mode 100644 index 0000000000..4f9f47fc75 --- /dev/null +++ b/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/getClusterStatusResponse.json @@ -0,0 +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" + }, + { + "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" + } + ] + } +} diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/getClusters.json b/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/getClusters.json new file mode 100644 index 0000000000..5200e3b971 --- /dev/null +++ b/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/getClusters.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-30044ecccbdd", + "host_version": "5.1.2.20180807", + "is_online": true, + "status": "active" + }] +} diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/postJobPrintResponse.json b/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/postJobPrintResponse.json new file mode 100644 index 0000000000..caedcd8732 --- /dev/null +++ b/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/postJobPrintResponse.json @@ -0,0 +1,8 @@ +{ + "data": { + "cluster_job_id": "9a59d8e9-91d3-4ff6-b4cb-9db91c4094dd", + "job_id": "ABCDefGHIjKlMNOpQrSTUvYxWZ0-1234567890abcDE=", + "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 new file mode 100644 index 0000000000..1304f3a9f6 --- /dev/null +++ b/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/putJobUploadResponse.json @@ -0,0 +1,9 @@ +{ + "data": { + "content_type": "text/plain", + "job_id": "ABCDefGHIjKlMNOpQrSTUvYxWZ0-1234567890abcDE=", + "job_name": "Ultimaker Robot v3.0", + "status": "uploading", + "upload_url": "https://api.ultimaker.com/print-job-upload" + } +} 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 new file mode 100644 index 0000000000..e504509d67 --- /dev/null +++ b/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py @@ -0,0 +1,105 @@ +# 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, Any +from unittest.mock import MagicMock + +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. +class NetworkManagerMock: + + # An enumeration of the supported operations and their code for the network access manager. + _OPERATIONS = { + "GET": QNetworkAccessManager.GetOperation, + "POST": QNetworkAccessManager.PostOperation, + "PUT": QNetworkAccessManager.PutOperation, + "DELETE": QNetworkAccessManager.DeleteOperation, + "HEAD": QNetworkAccessManager.HeadOperation, + } # type: Dict[str, int] + + ## 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], MagicMock] + 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. + # \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. + 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: + return doRequest + + # 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: The response body from the server (generally json-encoded). + 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.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) + + ## 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) -> 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() + + ## Deletes all prepared replies + def reset(self) -> None: + self.replies.clear() diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py new file mode 100644 index 0000000000..0be1d82141 --- /dev/null +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py @@ -0,0 +1,117 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from typing import List +from unittest import TestCase +from unittest.mock import patch, MagicMock + +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 + + +class TestCloudApiClient(TestCase): + maxDiff = None + + def _errorHandler(self, errors: List[CloudError]): + raise Exception("Received unexpected error: {}".format(errors)) + + def setUp(self): + super().setUp() + self.account = MagicMock() + self.account.isLoggedIn.return_value = True + + self.network = NetworkManagerMock() + with patch("plugins.UM3NetworkPrinting.src.Cloud.CloudApiClient.QNetworkAccessManager", return_value = self.network): + self.api = CloudApiClient(self.account, self._errorHandler) + + def test_getClusters(self): + result = [] + + response = readFixture("getClusters") + 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 + self.api.getClusters(lambda clusters: result.extend(clusters)) + + self.network.flushReplies() + + self.assertEqual([CloudClusterResponse(**data[0]), CloudClusterResponse(**data[1])], result) + + def test_getClusterStatus(self): + result = [] + + response = readFixture("getClusterStatusResponse") + data = parseFixture("getClusterStatusResponse")["data"] + + 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() + + self.assertEqual([CloudClusterStatus(**data)], result) + + def test_requestUpload(self): + + results = [] + + response = readFixture("putJobUploadResponse") + + 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() + + self.assertEqual(["text/plain"], [r.content_type for r in results]) + self.assertEqual(["uploading"], [r.status for r in results]) + + def test_uploadToolPath(self): + + results = [] + progress = MagicMock() + + data = parseFixture("putJobUploadResponse")["data"] + upload_response = CloudPrintJobResponse(**data) + + # Network client doesn't look into the reply + self.network.prepareReply("PUT", upload_response.upload_url, 200, b'{}') + + mesh = ("1234" * 100000).encode() + self.api.uploadToolPath(upload_response, mesh, lambda: results.append("sent"), progress.advance, progress.error) + + for _ in range(10): + self.network.flushReplies() + self.network.prepareReply("PUT", upload_response.upload_url, 200, b'{}') + + self.assertEqual(["sent"], results) + + def test_requestPrint(self): + + results = [] + + response = readFixture("postJobPrintResponse") + + cluster_id = "NWKV6vJP_LdYsXgXqAcaNCR0YcLJwar1ugh0ikEZsZs8" + cluster_job_id = "9a59d8e9-91d3-4ff6-b4cb-9db91c4094dd" + job_id = "ABCDefGHIjKlMNOpQrSTUvYxWZ0-1234567890abcDE=" + + self.network.prepareReply("POST", + CuraCloudAPIRoot + "/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([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 new file mode 100644 index 0000000000..191b92bdd5 --- /dev/null +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py @@ -0,0 +1,145 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +import json +from unittest import TestCase +from unittest.mock import patch, MagicMock + +from UM.Scene.SceneNode import SceneNode +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 .Fixtures import readFixture, parseFixture +from .NetworkManagerMock import NetworkManagerMock + + +class TestCloudOutputDevice(TestCase): + maxDiff = None + + CLUSTER_ID = "RIZ6cZbWA_Ua7RZVJhrdVfVpf0z-MqaSHQE4v8aRTtYq" + JOB_ID = "ABCDefGHIjKlMNOpQrSTUvYxWZ0-1234567890abcDE=" + HOST_NAME = "ultimakersystem-ccbdd30044ec" + HOST_GUID = "e90ae0ac-1257-4403-91ee-a44c9b7e8050" + + 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() + 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") + + self.network = NetworkManagerMock() + self.account = MagicMock(isLoggedIn=True, accessToken="TestAccessToken") + self.onError = MagicMock() + 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) + self.cluster_status = parseFixture("getClusterStatusResponse") + self.network.prepareReply("GET", self.STATUS_URL, 200, readFixture("getClusterStatusResponse")) + + def tearDown(self): + super().tearDown() + self.network.flushReplies() + for patched_method in self.patches: + patched_method.stop() + + def test_status(self): + 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(["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({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): + 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_request_time = None + self.device._update() + self.network.flushReplies() + self.assertEqual([], self.device.printJobs) + + 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_request_time = None + self.device._update() + self.network.flushReplies() + self.assertEqual([], self.device.printers) + + 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") + 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) + + 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()] + 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": 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)) diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py new file mode 100644 index 0000000000..c5006f35a1 --- /dev/null +++ b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py @@ -0,0 +1,124 @@ +# 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, MagicMock + +from UM.OutputDevice.OutputDeviceManager import OutputDeviceManager +from cura.UltimakerCloudAuthentication import CuraCloudAPIRoot +from ...src.Cloud.CloudOutputDeviceManager import CloudOutputDeviceManager +from .Fixtures import parseFixture, readFixture +from .NetworkManagerMock import NetworkManagerMock, FakeSignal + + +class TestCloudOutputDeviceManager(TestCase): + maxDiff = None + + URL = CuraCloudAPIRoot + "/connect/v1/clusters" + + def setUp(self): + super().setUp() + 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() + self.timer = MagicMock(timeout = FakeSignal()) + 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")) + + def tearDown(self): + try: + self._beforeTearDown() + + self.network.flushReplies() + self.manager.stop() + for patched_method in self.patches: + patched_method.stop() + 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 + devices = self.device_manager.getOutputDevices() + # TODO: Check active device + + 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): + self.manager.start() + self.network.flushReplies() + + def test_device_is_created(self): + # just create the cluster, it is checked at tearDown + self._loadData() + + 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" + self.network.prepareReply("GET", self.URL, 200, self.clusters_response) + + self.manager._update_timer.timeout.emit() + + 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] + self.network.prepareReply("GET", self.URL, 200, self.clusters_response) + + self.manager._update_timer.timeout.emit() + + 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.device_manager.getOutputDevice(cluster1["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): + active_machine_mock = self.app.getGlobalContainerStack.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() + + 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("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"}] + } + self.network.prepareReply("GET", self.URL, 200, self.clusters_response) + self._loadData() + message_mock.return_value.show.assert_called_once_with() 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. 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/resources/qml/PrinterSelector/MachineSelector.qml b/resources/qml/PrinterSelector/MachineSelector.qml index 4bee917751..cd5e041606 100644 --- a/resources/qml/PrinterSelector/MachineSelector.qml +++ b/resources/qml/PrinterSelector/MachineSelector.qml @@ -11,9 +11,9 @@ 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.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 @@ -36,15 +36,18 @@ Cura.ExpandablePopup } source: { - if (isNetworkPrinter) + if (isGroup) + { + return UM.Theme.getIcon("printer_group") + } + else if (isNetworkPrinter || isCloudPrinter) { - if (machineSelector.outputDevice != null && machineSelector.outputDevice.clusterSize > 1) - { - return UM.Theme.getIcon("printer_group") - } return UM.Theme.getIcon("printer_single") } - return "" + else + { + return "" + } } font: UM.Theme.getFont("medium") iconColor: UM.Theme.getColor("machine_selector_printer_icon") @@ -59,12 +62,27 @@ Cura.ExpandablePopup leftMargin: UM.Theme.getSize("thick_margin").width } - source: 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 diff --git a/resources/qml/PrinterSelector/MachineSelectorList.qml b/resources/qml/PrinterSelector/MachineSelectorList.qml index b9c20d0de1..5fd3515cd3 100644 --- a/resources/qml/PrinterSelector/MachineSelectorList.qml +++ b/resources/qml/PrinterSelector/MachineSelectorList.qml @@ -43,4 +43,4 @@ ListView return result } } -} \ No newline at end of file +} 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..3bc94a05e7 --- /dev/null +++ b/resources/themes/cura-light/icons/printer_cloud_connected.svg @@ -0,0 +1,11 @@ + + + + Artboard Copy 2 + Created with Sketch. + + + + + + \ No newline at end of file