STAR-322: Creating a Cloud API client to handle the interaction

This commit is contained in:
Daniel Schiavini 2018-12-04 16:14:08 +01:00
parent 97535ffa24
commit 9046b39b43
7 changed files with 367 additions and 235 deletions

View file

@ -61,6 +61,11 @@ class Account(QObject):
self._authorization_service.onAuthenticationError.connect(self._onLoginStateChanged)
self._authorization_service.loadAuthDataFromPreferences()
## Returns a boolean indicating whether the given authentication is applied against staging or not.
@property
def is_staging(self) -> bool:
return "staging" in self._oauth_root
@pyqtProperty(bool, notify=loginStateChanged)
def isLoggedIn(self) -> bool:
return self._logged_in

View file

@ -1,7 +1,7 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from time import time
from typing import Optional, Dict, Callable, List
from typing import Optional, Dict, Callable, List, Union
from PyQt5.QtCore import QUrl
from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkReply, QHttpMultiPart, QNetworkRequest, QHttpPart, \
@ -49,6 +49,8 @@ class NetworkClient:
## Create a new empty network request.
# Automatically adds the required HTTP headers.
# \param url: The URL to request
# \param content_type: The type of the body contents.
def _createEmptyRequest(self, url: str, content_type: Optional[str] = "application/json") -> QNetworkRequest:
request = QNetworkRequest(QUrl(url))
if content_type:
@ -120,67 +122,82 @@ class NetworkClient:
def createFormPart(self, content_header: str, data: bytes, content_type: Optional[str] = None) -> QHttpPart:
return self._createFormPart(content_header, data, content_type)
## Does a PUT request to the given URL.
def put(self, url: str, data: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None:
## Sends a put request to the given path.
# url: The path after the API prefix.
# data: The data to be sent in the body
# content_type: The content type of the body data.
# on_finished: The function to call when the response is received.
# on_progress: The function to call when the progress changes. Parameters are bytes_sent / bytes_total.
def put(self, url: str, data: Union[str, bytes], content_type: Optional[str] = None,
on_finished: Optional[Callable[[QNetworkReply], None]] = None,
on_progress: Optional[Callable[[int, int], None]] = None) -> None:
self._validateManager()
request = self._createEmptyRequest(url)
self._last_request_time = time()
if not self._manager:
Logger.log("e", "No network manager was created to execute the PUT call with.")
return
reply = self._manager.put(request, data.encode())
request = self._createEmptyRequest(url, content_type = content_type)
self._last_request_time = time()
if not self._manager:
return Logger.log("e", "No network manager was created to execute the PUT call with.")
body = data if isinstance(data, bytes) else data.encode() # type: bytes
reply = self._manager.put(request, body)
self._registerOnFinishedCallback(reply, on_finished)
## Does a DELETE request to the given URL.
if on_progress is not None:
reply.uploadProgress.connect(on_progress)
## Sends a delete request to the given path.
# url: The path after the API prefix.
# on_finished: The function to be call when the response is received.
def delete(self, url: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None:
self._validateManager()
request = self._createEmptyRequest(url)
self._last_request_time = time()
if not self._manager:
Logger.log("e", "No network manager was created to execute the DELETE call with.")
return
return Logger.log("e", "No network manager was created to execute the DELETE call with.")
reply = self._manager.deleteResource(request)
self._registerOnFinishedCallback(reply, on_finished)
## Does a GET request to the given URL.
## Sends a get request to the given path.
# \param url: The path after the API prefix.
# \param on_finished: The function to be call when the response is received.
def get(self, url: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None:
self._validateManager()
request = self._createEmptyRequest(url)
self._last_request_time = time()
if not self._manager:
Logger.log("e", "No network manager was created to execute the GET call with.")
return
return Logger.log("e", "No network manager was created to execute the GET call with.")
reply = self._manager.get(request)
self._registerOnFinishedCallback(reply, on_finished)
## Does a POST request to the given URL.
def post(self, url: str, data: str, on_finished: Optional[Callable[[QNetworkReply], None]],
on_progress: Callable = None) -> None:
## Sends a post request to the given path.
# \param url: The path after the API prefix.
# \param data: The data to be sent in the body
# \param on_finished: The function to call when the response is received.
# \param on_progress: The function to call when the progress changes. Parameters are bytes_sent / bytes_total.
def post(self, url: str, data: Union[str, bytes],
on_finished: Optional[Callable[[QNetworkReply], None]],
on_progress: Optional[Callable[[int, int], None]] = None) -> None:
self._validateManager()
request = self._createEmptyRequest(url)
self._last_request_time = time()
if not self._manager:
Logger.log("e", "No network manager was created to execute the GET call with.")
return
reply = self._manager.post(request, data.encode())
return Logger.log("e", "Could not find manager.")
body = data if isinstance(data, bytes) else data.encode() # type: bytes
reply = self._manager.post(request, body)
if on_progress is not None:
reply.uploadProgress.connect(on_progress)
self._registerOnFinishedCallback(reply, on_finished)
## Does a POST request with form data to the given URL.
def postForm(self, url: str, header_data: str, body_data: bytes,
on_finished: Optional[Callable[[QNetworkReply], None]],

View file

@ -145,7 +145,7 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
url = QUrl("http://" + self._address + self._api_prefix + target)
request = QNetworkRequest(url)
if content_type is not None:
request.setHeader(QNetworkRequest.ContentTypeHeader, "application/json")
request.setHeader(QNetworkRequest.ContentTypeHeader, content_type)
request.setHeader(QNetworkRequest.UserAgentHeader, self._user_agent)
return request
@ -180,54 +180,85 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
self._createNetworkManager()
assert (self._manager is not None)
def put(self, target: str, data: Union[str, bytes], content_type: str = None,
## Sends a put request to the given path.
# url: The path after the API prefix.
# data: The data to be sent in the body
# content_type: The content type of the body data.
# on_finished: The function to call when the response is received.
# on_progress: The function to call when the progress changes. Parameters are bytes_sent / bytes_total.
def put(self, url: str, data: Union[str, bytes], content_type: Optional[str] = None,
on_finished: Optional[Callable[[QNetworkReply], None]] = None,
on_progress: Optional[Callable] = None) -> None:
on_progress: Optional[Callable[[int, int], None]] = None) -> None:
self._validateManager()
request = self._createEmptyRequest(target, content_type = content_type)
self._last_request_time = time()
if self._manager is not None:
reply = self._manager.put(request, data if isinstance(data, bytes) else data.encode())
self._registerOnFinishedCallback(reply, on_finished)
if on_progress is not None:
reply.uploadProgress.connect(on_progress)
else:
Logger.log("e", "Could not find manager.")
def delete(self, target: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None:
request = self._createEmptyRequest(url, content_type = content_type)
self._last_request_time = time()
if not self._manager:
return Logger.log("e", "No network manager was created to execute the PUT call with.")
body = data if isinstance(data, bytes) else data.encode() # type: bytes
reply = self._manager.put(request, body)
self._registerOnFinishedCallback(reply, on_finished)
if on_progress is not None:
reply.uploadProgress.connect(on_progress)
## Sends a delete request to the given path.
# url: The path after the API prefix.
# on_finished: The function to be call when the response is received.
def delete(self, url: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None:
self._validateManager()
request = self._createEmptyRequest(target)
self._last_request_time = time()
if self._manager is not None:
reply = self._manager.deleteResource(request)
self._registerOnFinishedCallback(reply, on_finished)
else:
Logger.log("e", "Could not find manager.")
def get(self, target: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None:
request = self._createEmptyRequest(url)
self._last_request_time = time()
if not self._manager:
return Logger.log("e", "No network manager was created to execute the DELETE call with.")
reply = self._manager.deleteResource(request)
self._registerOnFinishedCallback(reply, on_finished)
## Sends a get request to the given path.
# \param url: The path after the API prefix.
# \param on_finished: The function to be call when the response is received.
def get(self, url: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None:
self._validateManager()
request = self._createEmptyRequest(target)
self._last_request_time = time()
if self._manager is not None:
reply = self._manager.get(request)
self._registerOnFinishedCallback(reply, on_finished)
else:
Logger.log("e", "Could not find manager.")
def post(self, target: str, data: Union[str, bytes], on_finished: Optional[Callable[[QNetworkReply], None]],
on_progress: Callable = None) -> None:
request = self._createEmptyRequest(url)
self._last_request_time = time()
if not self._manager:
return Logger.log("e", "No network manager was created to execute the GET call with.")
reply = self._manager.get(request)
self._registerOnFinishedCallback(reply, on_finished)
## Sends a post request to the given path.
# \param url: The path after the API prefix.
# \param data: The data to be sent in the body
# \param on_finished: The function to call when the response is received.
# \param on_progress: The function to call when the progress changes. Parameters are bytes_sent / bytes_total.
def post(self, url: str, data: Union[str, bytes],
on_finished: Optional[Callable[[QNetworkReply], None]],
on_progress: Optional[Callable[[int, int], None]] = None) -> None:
self._validateManager()
request = self._createEmptyRequest(target)
self._last_request_time = time()
if self._manager is not None:
reply = self._manager.post(request, data if isinstance(data, bytes) else data.encode())
if on_progress is not None:
reply.uploadProgress.connect(on_progress)
self._registerOnFinishedCallback(reply, on_finished)
else:
Logger.log("e", "Could not find manager.")
def postFormWithParts(self, target: str, parts: List[QHttpPart], on_finished: Optional[Callable[[QNetworkReply], None]], on_progress: Callable = None) -> QNetworkReply:
request = self._createEmptyRequest(url)
self._last_request_time = time()
if not self._manager:
return Logger.log("e", "Could not find manager.")
body = data if isinstance(data, bytes) else data.encode() # type: bytes
reply = self._manager.post(request, body)
if on_progress is not None:
reply.uploadProgress.connect(on_progress)
self._registerOnFinishedCallback(reply, on_finished)
def postFormWithParts(self, target: str, parts: List[QHttpPart],
on_finished: Optional[Callable[[QNetworkReply], None]],
on_progress: Callable = None) -> QNetworkReply:
self._validateManager()
request = self._createEmptyRequest(target, content_type=None)
multi_post_part = QHttpMultiPart(QHttpMultiPart.FormDataType)

View file

@ -0,0 +1,155 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import json
from json import JSONDecodeError
from typing import Callable, List, Type, TypeVar, Union, Optional, Tuple, Dict
from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply
from UM.Logger import Logger
from cura.API import Account
from cura.NetworkClient import NetworkClient
from plugins.UM3NetworkPrinting.src.Models import BaseModel
from plugins.UM3NetworkPrinting.src.Cloud.Models import (
CloudCluster, CloudErrorObject, CloudClusterStatus, CloudJobUploadRequest,
CloudJobResponse,
CloudPrintResponse
)
## The cloud API client is responsible for handling the requests and responses from the cloud.
# Each method should only handle models instead of exposing any HTTP details.
class CloudApiClient(NetworkClient):
# The cloud URL to use for this remote cluster.
# TODO: Make sure that this URL goes to the live api before release
ROOT_PATH = "https://api-staging.ultimaker.com"
CLUSTER_API_ROOT = "{}/connect/v1/".format(ROOT_PATH)
CURA_API_ROOT = "{}/cura/v1/".format(ROOT_PATH)
## Initializes a new cloud API client.
# \param account: The user's account object
# \param on_error: The callback to be called whenever we receive errors from the server.
def __init__(self, account: Account, on_error: Callable[[List[CloudErrorObject]], None]):
super().__init__()
self._account = account
self._on_error = on_error
## Retrieves all the clusters for the user that is currently logged in.
# \param on_finished: The function to be called after the result is parsed.
def getClusters(self, on_finished: Callable[[List[CloudCluster]], any]) -> None:
url = "/clusters"
self.get(url, on_finished=self._createCallback(on_finished, CloudCluster))
## Retrieves the status of the given cluster.
# \param cluster_id: The ID of the cluster.
# \param on_finished: The function to be called after the result is parsed.
def getClusterStatus(self, cluster_id: str, on_finished: Callable[[CloudClusterStatus], any]) -> None:
url = "{}/cluster/{}/status".format(self.CLUSTER_API_ROOT, cluster_id)
self.get(url, on_finished=self._createCallback(on_finished, CloudClusterStatus))
## Requests the cloud to register the upload of a print job mesh.
# \param request: The request object.
# \param on_finished: The function to be called after the result is parsed.
def requestUpload(self, request: CloudJobUploadRequest, on_finished: Callable[[CloudJobResponse], any]) -> None:
url = "{}/jobs/upload".format(self.CURA_API_ROOT)
body = json.dumps({"data": request.__dict__})
self.put(url, body, on_finished=self._createCallback(on_finished, CloudJobResponse))
## Requests the cloud to register the upload of a print job mesh.
# \param upload_response: The object received after requesting an upload with `self.requestUpload`.
# \param on_finished: The function to be called after the result is parsed. It receives the print job ID.
def uploadMesh(self, upload_response: CloudJobResponse, mesh: bytes, on_finished: Callable[[str], any],
on_progress: Callable[[int], any]):
def progressCallback(bytes_sent: int, bytes_total: int) -> None:
if bytes_total:
on_progress(int((bytes_sent / bytes_total) * 100))
def finishedCallback(reply: QNetworkReply):
status_code, response = self._parseReply(reply)
if status_code < 300:
on_finished(upload_response.job_id)
else:
self._uploadMeshError(status_code, response)
# TODO: Multipart upload
self.put(upload_response.upload_url, data = mesh, content_type = upload_response.content_type,
on_finished = finishedCallback, on_progress = progressCallback)
# Requests a cluster to print the given print job.
# \param cluster_id: The ID of the cluster.
# \param job_id: The ID of the print job.
# \param on_finished: The function to be called after the result is parsed.
def requestPrint(self, cluster_id: str, job_id: str, on_finished: Callable[[], any]) -> None:
url = "{}/cluster/{}/print/{}".format(self.CLUSTER_API_ROOT, cluster_id, job_id)
self.post(url, data = "", on_finished=self._createCallback(on_finished, CloudPrintResponse))
## We override _createEmptyRequest in order to add the user credentials.
# \param url: The URL to request
# \param content_type: The type of the body contents.
def _createEmptyRequest(self, path: str, content_type: Optional[str] = "application/json") -> QNetworkRequest:
request = super()._createEmptyRequest(path, content_type)
if self._account.isLoggedIn:
request.setRawHeader(b"Authorization", "Bearer {}".format(self._account.accessToken).encode())
return request
## Parses the given JSON network reply into a status code and a dictionary, handling unexpected errors as well.
# \param reply: The reply from the server.
# \return A tuple with a status code and a dictionary.
@staticmethod
def _parseReply(reply: QNetworkReply) -> Tuple[int, Dict[str, any]]:
status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute)
try:
response = bytes(reply.readAll()).decode()
Logger.log("i", "Received an HTTP %s from %s with %s", status_code, reply.url, response)
return status_code, json.loads(response)
except (UnicodeDecodeError, JSONDecodeError, ValueError) as err:
error = {"code": type(err).__name__, "title": str(err), "http_code": str(status_code)}
Logger.logException("e", "Could not parse the stardust response: %s", error)
return status_code, {"errors": [error]}
## Calls the error handler that is responsible for handling errors uploading meshes.
# \param http_status - The status of the HTTP request.
# \param response - The response received from the upload endpoint. This is not formatted according to the standard
# JSON-api response.
def _uploadMeshError(self, http_status: int, response: Dict[str, any]) -> None:
error = CloudErrorObject(
code = "uploadError",
http_status = str(http_status),
title = "Could not upload the mesh",
meta = response
)
self._on_error([error])
## The generic type variable used to document the methods below.
Model = TypeVar("Model", bound=BaseModel)
## Parses the given models and calls the correct callback depending on the result.
# \param response: The response from the server, after being converted to a dict.
# \param on_finished: The callback in case the response is successful.
# \param model: The type of the model to convert the response to. It may either be a single record or a list.
def _parseModels(self, response: Dict[str, any],
on_finished: Callable[[Union[Model, List[Model]]], any],
model: Type[Model]) -> None:
if "data" in response:
data = response["data"]
result = [model(**c) for c in data] if isinstance(data, list) else model(**data)
on_finished(result)
elif "error" in response:
self._on_error([CloudErrorObject(**error) for error in response["errors"]])
else:
Logger.log("e", "Cannot find data or errors in the cloud response: %s", response)
## Creates a callback function that includes the parsing of the response into the correct model.
# \param on_finished: The callback in case the response is successful.
# \param model: The type of the model to convert the response to. It may either be a single record or a list.
# \return: A function that can be passed to the
def _createCallback(self,
on_finished: Callable[[Union[Model, List[Model]]], any],
model: Type[Model],
) -> Callable[[QNetworkReply], None]:
def parse(reply: QNetworkReply) -> None:
status_code, response = self._parseReply(reply)
return self._parseModels(response, on_finished, model)
return parse

View file

@ -1,13 +1,10 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import io
import json
import os
from json import JSONDecodeError
from typing import List, Optional, Dict, cast, Union, Tuple
from typing import List, Optional, Dict, cast, Union
from PyQt5.QtCore import QObject, pyqtSignal, QUrl, pyqtProperty, pyqtSlot
from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest
from UM import i18nCatalog
from UM.FileHandler.FileWriter import FileWriter
@ -22,10 +19,11 @@ from cura.PrinterOutput.PrinterOutputController import PrinterOutputController
from cura.PrinterOutput.MaterialOutputModel import MaterialOutputModel
from cura.PrinterOutput.NetworkedPrinterOutputDevice import NetworkedPrinterOutputDevice, AuthState
from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel
from plugins.UM3NetworkPrinting.src.Cloud.CloudApiClient import CloudApiClient
from plugins.UM3NetworkPrinting.src.UM3PrintJobOutputModel import UM3PrintJobOutputModel
from .Models import (
CloudClusterPrinter, CloudClusterPrintJob, JobUploadRequest, JobUploadResponse, PrintResponse, CloudClusterStatus,
CloudClusterPrinterConfigurationMaterial
CloudClusterPrinter, CloudClusterPrintJob, CloudJobUploadRequest, CloudJobResponse, CloudClusterStatus,
CloudClusterPrinterConfigurationMaterial, CloudErrorObject
)
@ -40,20 +38,16 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice):
# The translation catalog for this device.
I18N_CATALOG = i18nCatalog("cura")
# The cloud URL to use for this remote cluster.
# TODO: Make sure that this URL goes to the live api before release
ROOT_PATH = "https://api-staging.ultimaker.com"
CLUSTER_API_ROOT = "{}/connect/v1/".format(ROOT_PATH)
CURA_API_ROOT = "{}/cura/v1/".format(ROOT_PATH)
# Signal triggered when the printers in the remote cluster were changed.
printersChanged = pyqtSignal()
# Signal triggered when the print jobs in the queue were changed.
printJobsChanged = pyqtSignal()
def __init__(self, device_id: str, parent: QObject = None):
def __init__(self, api_client: CloudApiClient, device_id: str, parent: QObject = None):
super().__init__(device_id = device_id, address = "", properties = {}, parent = parent)
self._api = api_client
self._setInterfaceElements()
self._device_id = device_id
@ -76,40 +70,6 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice):
self._sending_job = False
self._progress_message = None # type: Optional[Message]
@staticmethod
def _parseReply(reply: QNetworkReply) -> Tuple[int, Union[None, str, bytes]]:
"""
Parses a reply from the stardust server.
:param reply: The reply received from the server.
:return: The status code and the response dict.
"""
status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute)
response = None
try:
response = bytes(reply.readAll()).decode("utf-8")
response = json.loads(response)
except JSONDecodeError:
Logger.logException("w", "Unable to decode JSON from reply.")
return status_code, response
## We need to override _createEmptyRequest to work for the cloud.
def _createEmptyRequest(self, path: str, content_type: Optional[str] = "application/json") -> QNetworkRequest:
# noinspection PyArgumentList
url = QUrl(path)
request = QNetworkRequest(url)
request.setHeader(QNetworkRequest.ContentTypeHeader, content_type)
request.setHeader(QNetworkRequest.UserAgentHeader, self._user_agent)
if not self._account.isLoggedIn:
# TODO: show message to user to sign in
self.setAuthenticationState(AuthState.NotAuthenticated)
else:
# TODO: not execute call at all when not signed in?
self.setAuthenticationState(AuthState.Authenticated)
request.setRawHeader(b"Authorization", "Bearer {}".format(self._account.accessToken).encode())
return request
## Set all the interface elements and texts for this output device.
def _setInterfaceElements(self):
self.setPriority(2) # make sure we end up below the local networking and above 'save to file'
@ -223,22 +183,16 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice):
def _update(self) -> None:
super()._update()
Logger.log("i", "Calling the cloud cluster")
self.get("{root}/cluster/{cluster_id}/status".format(root = self.CLUSTER_API_ROOT,
cluster_id = self._device_id),
on_finished = self._onStatusCallFinished)
if self._account.isLoggedIn:
self.setAuthenticationState(AuthState.Authenticated)
self._api.getClusterStatus(self._device_id, self._onStatusCallFinished)
else:
self.setAuthenticationState(AuthState.NotAuthenticated)
## Method called when HTTP request to status endpoint is finished.
# Contains both printers and print jobs statuses in a single response.
def _onStatusCallFinished(self, reply: QNetworkReply) -> None:
status_code, response = self._parseReply(reply)
if status_code > 204 or not isinstance(response, dict) or "data" not in response:
Logger.log("w", "Got unexpected response while trying to get cloud cluster data: %s, %s",
status_code, response)
return
Logger.log("d", "Got response form the cloud cluster %s, %s", status_code, response)
status = CloudClusterStatus(**response["data"])
def _onStatusCallFinished(self, status: CloudClusterStatus) -> None:
Logger.log("d", "Got response form the cloud cluster: %s", status.__dict__)
# Update all data from the cluster.
self._updatePrinters(status.printers)
self._updatePrintJobs(status.print_jobs)
@ -325,18 +279,14 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice):
remote_jobs = {j.uuid: j for j in jobs} # type: Dict[str, CloudClusterPrintJob]
current_jobs = {j.key: j for j in self._print_jobs} # type: Dict[str, UM3PrintJobOutputModel]
removed_job_ids = set(current_jobs).difference(set(remote_jobs))
new_job_ids = set(remote_jobs.keys()).difference(set(current_jobs))
updated_job_ids = set(current_jobs).intersection(set(remote_jobs))
for removed_job_id in set(current_jobs).difference(remote_jobs):
self._print_jobs.remove(current_jobs[removed_job_id])
for job_id in removed_job_ids:
self._print_jobs.remove(current_jobs[job_id])
for new_job_id in set(remote_jobs.keys()).difference(current_jobs):
self._addPrintJob(remote_jobs[new_job_id])
for job_id in new_job_ids:
self._addPrintJob(remote_jobs[job_id])
for job_id in updated_job_ids:
self._updateUM3PrintJobOutputModel(current_jobs[job_id], remote_jobs[job_id])
for updated_job_id in set(current_jobs).intersection(remote_jobs):
self._updateUM3PrintJobOutputModel(current_jobs[updated_job_id], remote_jobs[updated_job_id])
# TODO: properly handle removed and updated printers
self.printJobsChanged.emit()
@ -362,56 +312,25 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice):
def _sendPrintJob(self, file_name: str, content_type: str, stream: Union[io.StringIO, io.BytesIO]) -> None:
mesh = stream.getvalue()
request = JobUploadRequest()
request = CloudJobUploadRequest()
request.job_name = file_name
request.file_size = len(mesh)
request.content_type = content_type
Logger.log("i", "Creating new cloud print job: %s", request.__dict__)
self.put("{}/jobs/upload".format(self.CURA_API_ROOT), data = json.dumps({"data": request.__dict__}),
on_finished = lambda reply: self._onPrintJobCreated(mesh, reply))
self._api.requestUpload(request, lambda response: self._onPrintJobCreated(mesh, response))
def _onPrintJobCreated(self, mesh: bytes, reply: QNetworkReply) -> None:
status_code, response = self._parseReply(reply)
if status_code > 204 or not isinstance(response, dict) or "data" not in response:
Logger.log("w", "Unexpected response while adding to queue: {}, {}".format(status_code, response))
self._onUploadError(self.I18N_CATALOG.i18nc("@info:status", "Could not add print job to queue."))
return
# TODO: Multipart upload
job_response = JobUploadResponse(**response.get("data"))
def _onPrintJobCreated(self, mesh: bytes, job_response: CloudJobResponse) -> None:
Logger.log("i", "Print job created successfully: %s", job_response.__dict__)
self.put(job_response.upload_url, data = mesh, content_type = job_response.content_type,
on_finished = lambda r: self._onPrintJobUploaded(job_response.job_id, r),
on_progress = self._onUploadPrintJobProgress)
self._api.uploadMesh(job_response, mesh, self._onPrintJobUploaded, self._onUploadPrintJobProgress)
def _onPrintJobUploaded(self, job_id: str) -> None:
self._api.requestPrint(self._device_id, job_id, self._onUploadSuccess)
def _onUploadPrintJobProgress(self, bytes_sent: int, bytes_total: int) -> None:
if bytes_total > 0:
self._updateUploadProgress(int((bytes_sent / bytes_total) * 100))
def _onPrintJobUploaded(self, job_id: str, reply: QNetworkReply) -> None:
status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute)
if status_code > 204:
Logger.log("w", "Received unexpected response from the job upload: %s, %s.", status_code,
bytes(reply.readAll()).decode())
self._onUploadError(self.I18N_CATALOG.i18nc("@info:status", "Could not add print job to queue."))
return
Logger.log("i", "Print job uploaded successfully: %s", reply.readAll())
url = "{}/cluster/{}/print/{}".format(self.CLUSTER_API_ROOT, self._device_id, job_id)
self.post(url, data = "", on_finished = self._onPrintJobRequested)
def _onPrintJobRequested(self, reply: QNetworkReply) -> None:
status_code, response = self._parseReply(reply)
if status_code > 204 or not isinstance(response, dict) or "data" not in response:
Logger.log("w", "Got unexpected response while trying to request printing: %s, %s", status_code, response)
self._onUploadError(self.I18N_CATALOG.i18nc("@info:status", "Could not add print job to queue."))
return
print_response = PrintResponse(**response["data"])
Logger.log("i", "Print job requested successfully: %s", print_response.__dict__)
self._onUploadSuccess()
def _updateUploadProgress(self, progress: int):
if not self._progress_message:
self._progress_message = Message(
@ -479,3 +398,6 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice):
@pyqtProperty(bool, notify = printJobsChanged)
def receivedPrintJobs(self) -> bool:
return True
def _onApiError(self, errors: List[CloudErrorObject]) -> None:
pass # TODO: Show errors...

View file

@ -1,19 +1,17 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import json
from time import sleep
from threading import Timer
from typing import Dict, Optional
from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply
from typing import Dict, List
from UM import i18nCatalog
from UM.Logger import Logger
from UM.Message import Message
from UM.Signal import Signal
from cura.CuraApplication import CuraApplication
from cura.NetworkClient import NetworkClient
from plugins.UM3NetworkPrinting.src.Cloud.CloudApiClient import CloudApiClient
from .CloudOutputDevice import CloudOutputDevice
from .Models import CloudCluster
from .Models import CloudCluster, CloudErrorObject
## The cloud output device manager is responsible for using the Ultimaker Cloud APIs to manage remote clusters.
@ -21,14 +19,14 @@ from .Models import CloudCluster
#
# API spec is available on https://api.ultimaker.com/docs/connect/spec/.
#
class CloudOutputDeviceManager(NetworkClient):
# The cloud URL to use for remote clusters.
API_ROOT_PATH = "https://api-staging.ultimaker.com/connect/v1"
class CloudOutputDeviceManager:
# The interval with which the remote clusters are checked
CHECK_CLUSTER_INTERVAL = 5 # seconds
# The translation catalog for this device.
I18N_CATALOG = i18nCatalog("cura")
def __init__(self):
super().__init__()
@ -37,8 +35,10 @@ class CloudOutputDeviceManager(NetworkClient):
application = CuraApplication.getInstance()
self._output_device_manager = application.getOutputDeviceManager()
self._account = application.getCuraAPI().account
self._account.loginStateChanged.connect(self._getRemoteClusters)
self._api = CloudApiClient(self._account, self._onApiError)
# When switching machines we check if we have to activate a remote cluster.
application.globalContainerStackChanged.connect(self._connectToActiveMachine)
@ -46,40 +46,21 @@ class CloudOutputDeviceManager(NetworkClient):
self._on_cluster_received = Signal()
self._on_cluster_received.connect(self._getRemoteClusters)
## Override _createEmptyRequest to add the needed authentication header for talking to the Ultimaker Cloud API.
def _createEmptyRequest(self, path: str, content_type: Optional[str] = "application/json") -> QNetworkRequest:
request = super()._createEmptyRequest(self.API_ROOT_PATH + path, content_type = content_type)
if self._account.isLoggedIn:
# TODO: add correct scopes to OAuth2 client to use remote connect API.
# TODO: don't create the client when not signed in?
request.setRawHeader(b"Authorization", "Bearer {}".format(self._account.accessToken).encode())
return request
## Gets all remote clusters from the API.
def _getRemoteClusters(self) -> None:
Logger.log("i", "Retrieving remote clusters")
if self._account.isLoggedIn:
self.get("/clusters", on_finished = self._onGetRemoteClustersFinished)
self._api.getClusters(self._onGetRemoteClustersFinished)
# Only start the polling thread after the user is authenticated
# The first call to _getRemoteClusters comes from self._account.loginStateChanged
timer = Timer(5.0, self._on_cluster_received.emit)
timer.start()
## Callback for when the request for getting the clusters. is finished.
def _onGetRemoteClustersFinished(self, reply: QNetworkReply) -> None:
Logger.log("i", "Received remote clusters")
def _onGetRemoteClustersFinished(self, clusters: List[CloudCluster]) -> None:
found_clusters = {c.cluster_id: c for c in clusters}
status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute)
if status_code > 204:
Logger.log("w", "Got unexpected response while trying to get cloud cluster data: {}, {}"
.format(status_code, reply.readAll()))
return
# Parse the response (returns the "data" field from the body).
found_clusters = self._parseStatusResponse(reply)
Logger.log("i", "Parsed remote clusters to %s", found_clusters)
if not found_clusters:
return
@ -97,28 +78,17 @@ class CloudOutputDeviceManager(NetworkClient):
for cluster_id in known_cluster_ids.difference(found_cluster_ids):
self._removeCloudOutputDevice(found_clusters[cluster_id])
@staticmethod
def _parseStatusResponse(reply: QNetworkReply) -> Dict[str, CloudCluster]:
try:
response = bytes(reply.readAll()).decode()
return {c["cluster_id"]: CloudCluster(**c) for c in json.loads(response)["data"]}
except UnicodeDecodeError:
Logger.log("w", "Unable to read server response")
except json.decoder.JSONDecodeError:
Logger.logException("w", "Unable to decode JSON from reply.")
except ValueError:
Logger.logException("w", "Response was missing values.")
return {}
## Adds a CloudOutputDevice for each entry in the remote cluster list from the API.
# \param cluster: The cluster that was added.
def _addCloudOutputDevice(self, cluster: CloudCluster):
device = CloudOutputDevice(cluster.cluster_id)
device = CloudOutputDevice(self._api, cluster.cluster_id)
self._output_device_manager.addOutputDevice(device)
self._remote_clusters[cluster.cluster_id] = device
device.connect() # TODO: remove this
self._connectToActiveMachine()
## Remove a CloudOutputDevice
# \param cluster: The cluster that was removed
def _removeCloudOutputDevice(self, cluster: CloudCluster):
self._output_device_manager.removeOutputDevice(cluster.cluster_id)
del self._remote_clusters[cluster.cluster_id]
@ -141,3 +111,15 @@ class CloudOutputDeviceManager(NetworkClient):
# TODO: If so, we can also immediate connect to it.
# active_machine.setMetaDataEntry("um_cloud_cluster_id", "")
# self._remote_clusters.get(stored_cluster_id).connect()
## Handles an API error received from the cloud.
# \param errors: The errors received
def _onApiError(self, errors: List[CloudErrorObject]) -> None:
message = ". ".join(e.title for e in errors) # TODO: translate errors
message = Message(
text = message,
title = self.I18N_CATALOG.i18nc("@info:title", "Error"),
lifetime = 10,
dismissable = True
)
message.show()

View file

@ -1,10 +1,22 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import List
from typing import List, Dict
from ..Models import BaseModel
## Class representing errors generated by the cloud servers, according to the json-api standard.
class CloudErrorObject(BaseModel):
def __init__(self, **kwargs):
self.id = None # type: str
self.code = None # type: str
self.http_status = None # type: str
self.title = None # type: str
self.detail = None # type: str
self.meta = None # type: Dict[str, any]
super().__init__(**kwargs)
## Class representing a cloud connected cluster.
class CloudCluster(BaseModel):
def __init__(self, **kwargs):
@ -95,17 +107,23 @@ class CloudClusterPrintJob(BaseModel):
for p in self.constraints]
# Model that represents the status of the cluster for the cloud
class CloudClusterStatus(BaseModel):
def __init__(self, **kwargs):
# a list of the printers
self.printers = [] # type: List[CloudClusterPrinter]
# a list of the print jobs
self.print_jobs = [] # type: List[CloudClusterPrintJob]
super().__init__(**kwargs)
# converting any dictionaries into models
self.printers = [CloudClusterPrinter(**p) if isinstance(p, dict) else p for p in self.printers]
self.print_jobs = [CloudClusterPrintJob(**j) if isinstance(j, dict) else j for j in self.print_jobs]
class JobUploadRequest(BaseModel):
# Model that represents the request to upload a print job to the cloud
class CloudJobUploadRequest(BaseModel):
def __init__(self, **kwargs):
self.file_size = None # type: int
self.job_name = None # type: str
@ -113,7 +131,8 @@ class JobUploadRequest(BaseModel):
super().__init__(**kwargs)
class JobUploadResponse(BaseModel):
# Model that represents the response received from the cloud after requesting to upload a print job
class CloudJobResponse(BaseModel):
def __init__(self, **kwargs):
self.download_url = None # type: str
self.job_id = None # type: str
@ -125,7 +144,8 @@ class JobUploadResponse(BaseModel):
super().__init__(**kwargs)
class PrintResponse(BaseModel):
# Model that represents the responses received from the cloud after requesting a job to be printed.
class CloudPrintResponse(BaseModel):
def __init__(self, **kwargs):
self.cluster_job_id = None # type: str
self.job_id = None # type: str