Merge branch '4.0' of github.com:Ultimaker/Cura

This commit is contained in:
Jaime van Kessel 2019-01-16 17:39:43 +01:00
commit 24fbb1007d
60 changed files with 2681 additions and 249 deletions

View File

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

View File

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

View File

@ -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)
self.setItems(items)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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()}
return {
"output_device": UM3OutputDevicePlugin.UM3OutputDevicePlugin(),
"machine_action": DiscoverUM3Action.DiscoverUM3Action()
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,2 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,2 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,2 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.

View File

@ -0,0 +1,2 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.

View File

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

View File

@ -43,4 +43,4 @@ ListView
return result
}
}
}
}

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="14px" height="14px" viewBox="0 0 14 14" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 52.2 (67145) - http://www.bohemiancoding.com/sketch -->
<title>Artboard Copy 2</title>
<desc>Created with Sketch.</desc>
<g id="Artboard-Copy-2" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Group" fill="#3282FF">
<path d="M7,14 C3.13400675,14 0,10.8659932 0,7 C0,3.13400675 3.13400675,0 7,0 C10.8659932,0 14,3.13400675 14,7 C14,10.8659932 10.8659932,14 7,14 Z M9.8,6.08333333 C9.72,5.375 9.12,4.83333333 8.4,4.83333333 C8.2,4.83333333 8.04,4.875 7.88,4.95833333 C7.52,4.375 6.88,4 6.2,4 C5.08,4 4.2,4.91666667 4.2,6.08333333 C4.2,6.08333333 4.2,6.08333333 4.2,6.125 C3.52,6.20833333 3,6.83333333 3,7.54166667 C3,8.33333333 3.64,9 4.4,9 C5,9 8.88,9 9.6,9 C10.36,9 11,8.33333333 11,7.54166667 C11,6.79166667 10.48,6.20833333 9.8,6.08333333 Z" id="Combined-Shape"></path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB