mirror of
https://github.com/Ultimaker/Cura.git
synced 2025-07-07 15:07:28 -06:00
STAR-322: Creating a Cloud API client to handle the interaction
This commit is contained in:
parent
97535ffa24
commit
9046b39b43
7 changed files with 367 additions and 235 deletions
|
@ -61,6 +61,11 @@ class Account(QObject):
|
||||||
self._authorization_service.onAuthenticationError.connect(self._onLoginStateChanged)
|
self._authorization_service.onAuthenticationError.connect(self._onLoginStateChanged)
|
||||||
self._authorization_service.loadAuthDataFromPreferences()
|
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)
|
@pyqtProperty(bool, notify=loginStateChanged)
|
||||||
def isLoggedIn(self) -> bool:
|
def isLoggedIn(self) -> bool:
|
||||||
return self._logged_in
|
return self._logged_in
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
# Copyright (c) 2018 Ultimaker B.V.
|
# Copyright (c) 2018 Ultimaker B.V.
|
||||||
# Cura is released under the terms of the LGPLv3 or higher.
|
# Cura is released under the terms of the LGPLv3 or higher.
|
||||||
from time import time
|
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.QtCore import QUrl
|
||||||
from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkReply, QHttpMultiPart, QNetworkRequest, QHttpPart, \
|
from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkReply, QHttpMultiPart, QNetworkRequest, QHttpPart, \
|
||||||
|
@ -49,6 +49,8 @@ class NetworkClient:
|
||||||
|
|
||||||
## Create a new empty network request.
|
## Create a new empty network request.
|
||||||
# Automatically adds the required HTTP headers.
|
# 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:
|
def _createEmptyRequest(self, url: str, content_type: Optional[str] = "application/json") -> QNetworkRequest:
|
||||||
request = QNetworkRequest(QUrl(url))
|
request = QNetworkRequest(QUrl(url))
|
||||||
if content_type:
|
if content_type:
|
||||||
|
@ -120,21 +122,33 @@ class NetworkClient:
|
||||||
def createFormPart(self, content_header: str, data: bytes, content_type: Optional[str] = None) -> QHttpPart:
|
def createFormPart(self, content_header: str, data: bytes, content_type: Optional[str] = None) -> QHttpPart:
|
||||||
return self._createFormPart(content_header, data, content_type)
|
return self._createFormPart(content_header, data, content_type)
|
||||||
|
|
||||||
## Does a PUT request to the given URL.
|
## Sends a put request to the given path.
|
||||||
def put(self, url: str, data: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None:
|
# 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()
|
self._validateManager()
|
||||||
|
|
||||||
request = self._createEmptyRequest(url)
|
request = self._createEmptyRequest(url, content_type = content_type)
|
||||||
self._last_request_time = time()
|
self._last_request_time = time()
|
||||||
|
|
||||||
if not self._manager:
|
if not self._manager:
|
||||||
Logger.log("e", "No network manager was created to execute the PUT call with.")
|
return Logger.log("e", "No network manager was created to execute the PUT call with.")
|
||||||
return
|
|
||||||
|
|
||||||
reply = self._manager.put(request, data.encode())
|
body = data if isinstance(data, bytes) else data.encode() # type: bytes
|
||||||
|
reply = self._manager.put(request, body)
|
||||||
self._registerOnFinishedCallback(reply, on_finished)
|
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:
|
def delete(self, url: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None:
|
||||||
self._validateManager()
|
self._validateManager()
|
||||||
|
|
||||||
|
@ -142,13 +156,14 @@ class NetworkClient:
|
||||||
self._last_request_time = time()
|
self._last_request_time = time()
|
||||||
|
|
||||||
if not self._manager:
|
if not self._manager:
|
||||||
Logger.log("e", "No network manager was created to execute the DELETE call with.")
|
return Logger.log("e", "No network manager was created to execute the DELETE call with.")
|
||||||
return
|
|
||||||
|
|
||||||
reply = self._manager.deleteResource(request)
|
reply = self._manager.deleteResource(request)
|
||||||
self._registerOnFinishedCallback(reply, on_finished)
|
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:
|
def get(self, url: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None:
|
||||||
self._validateManager()
|
self._validateManager()
|
||||||
|
|
||||||
|
@ -156,29 +171,31 @@ class NetworkClient:
|
||||||
self._last_request_time = time()
|
self._last_request_time = time()
|
||||||
|
|
||||||
if not self._manager:
|
if not self._manager:
|
||||||
Logger.log("e", "No network manager was created to execute the GET call with.")
|
return Logger.log("e", "No network manager was created to execute the GET call with.")
|
||||||
return
|
|
||||||
|
|
||||||
reply = self._manager.get(request)
|
reply = self._manager.get(request)
|
||||||
self._registerOnFinishedCallback(reply, on_finished)
|
self._registerOnFinishedCallback(reply, on_finished)
|
||||||
|
|
||||||
## Does a POST request to the given URL.
|
## Sends a post request to the given path.
|
||||||
def post(self, url: str, data: str, on_finished: Optional[Callable[[QNetworkReply], None]],
|
# \param url: The path after the API prefix.
|
||||||
on_progress: Callable = None) -> None:
|
# \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()
|
self._validateManager()
|
||||||
|
|
||||||
request = self._createEmptyRequest(url)
|
request = self._createEmptyRequest(url)
|
||||||
self._last_request_time = time()
|
self._last_request_time = time()
|
||||||
|
|
||||||
if not self._manager:
|
if not self._manager:
|
||||||
Logger.log("e", "No network manager was created to execute the GET call with.")
|
return Logger.log("e", "Could not find manager.")
|
||||||
return
|
|
||||||
|
|
||||||
reply = self._manager.post(request, data.encode())
|
|
||||||
|
|
||||||
|
body = data if isinstance(data, bytes) else data.encode() # type: bytes
|
||||||
|
reply = self._manager.post(request, body)
|
||||||
if on_progress is not None:
|
if on_progress is not None:
|
||||||
reply.uploadProgress.connect(on_progress)
|
reply.uploadProgress.connect(on_progress)
|
||||||
|
|
||||||
self._registerOnFinishedCallback(reply, on_finished)
|
self._registerOnFinishedCallback(reply, on_finished)
|
||||||
|
|
||||||
## Does a POST request with form data to the given URL.
|
## Does a POST request with form data to the given URL.
|
||||||
|
|
|
@ -145,7 +145,7 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
|
||||||
url = QUrl("http://" + self._address + self._api_prefix + target)
|
url = QUrl("http://" + self._address + self._api_prefix + target)
|
||||||
request = QNetworkRequest(url)
|
request = QNetworkRequest(url)
|
||||||
if content_type is not None:
|
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)
|
request.setHeader(QNetworkRequest.UserAgentHeader, self._user_agent)
|
||||||
return request
|
return request
|
||||||
|
|
||||||
|
@ -180,54 +180,85 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice):
|
||||||
self._createNetworkManager()
|
self._createNetworkManager()
|
||||||
assert (self._manager is not None)
|
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_finished: Optional[Callable[[QNetworkReply], None]] = None,
|
||||||
on_progress: Optional[Callable] = None) -> None:
|
on_progress: Optional[Callable[[int, int], None]] = None) -> None:
|
||||||
self._validateManager()
|
self._validateManager()
|
||||||
request = self._createEmptyRequest(target, content_type = content_type)
|
|
||||||
|
request = self._createEmptyRequest(url, content_type = content_type)
|
||||||
self._last_request_time = time()
|
self._last_request_time = time()
|
||||||
if self._manager is not None:
|
|
||||||
reply = self._manager.put(request, data if isinstance(data, bytes) else data.encode())
|
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)
|
self._registerOnFinishedCallback(reply, on_finished)
|
||||||
|
|
||||||
if on_progress is not None:
|
if on_progress is not None:
|
||||||
reply.uploadProgress.connect(on_progress)
|
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:
|
## 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()
|
self._validateManager()
|
||||||
request = self._createEmptyRequest(target)
|
|
||||||
|
request = self._createEmptyRequest(url)
|
||||||
self._last_request_time = time()
|
self._last_request_time = time()
|
||||||
if self._manager is not None:
|
|
||||||
|
if not self._manager:
|
||||||
|
return Logger.log("e", "No network manager was created to execute the DELETE call with.")
|
||||||
|
|
||||||
reply = self._manager.deleteResource(request)
|
reply = self._manager.deleteResource(request)
|
||||||
self._registerOnFinishedCallback(reply, on_finished)
|
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:
|
## 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()
|
self._validateManager()
|
||||||
request = self._createEmptyRequest(target)
|
|
||||||
|
request = self._createEmptyRequest(url)
|
||||||
self._last_request_time = time()
|
self._last_request_time = time()
|
||||||
if self._manager is not None:
|
|
||||||
|
if not self._manager:
|
||||||
|
return Logger.log("e", "No network manager was created to execute the GET call with.")
|
||||||
|
|
||||||
reply = self._manager.get(request)
|
reply = self._manager.get(request)
|
||||||
self._registerOnFinishedCallback(reply, on_finished)
|
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]],
|
## Sends a post request to the given path.
|
||||||
on_progress: Callable = None) -> None:
|
# \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()
|
self._validateManager()
|
||||||
request = self._createEmptyRequest(target)
|
|
||||||
|
request = self._createEmptyRequest(url)
|
||||||
self._last_request_time = time()
|
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 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:
|
if on_progress is not None:
|
||||||
reply.uploadProgress.connect(on_progress)
|
reply.uploadProgress.connect(on_progress)
|
||||||
self._registerOnFinishedCallback(reply, on_finished)
|
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:
|
def postFormWithParts(self, target: str, parts: List[QHttpPart],
|
||||||
|
on_finished: Optional[Callable[[QNetworkReply], None]],
|
||||||
|
on_progress: Callable = None) -> QNetworkReply:
|
||||||
self._validateManager()
|
self._validateManager()
|
||||||
request = self._createEmptyRequest(target, content_type=None)
|
request = self._createEmptyRequest(target, content_type=None)
|
||||||
multi_post_part = QHttpMultiPart(QHttpMultiPart.FormDataType)
|
multi_post_part = QHttpMultiPart(QHttpMultiPart.FormDataType)
|
||||||
|
|
155
plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py
Normal file
155
plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py
Normal 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
|
|
@ -1,13 +1,10 @@
|
||||||
# Copyright (c) 2018 Ultimaker B.V.
|
# Copyright (c) 2018 Ultimaker B.V.
|
||||||
# Cura is released under the terms of the LGPLv3 or higher.
|
# Cura is released under the terms of the LGPLv3 or higher.
|
||||||
import io
|
import io
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
from json import JSONDecodeError
|
from typing import List, Optional, Dict, cast, Union
|
||||||
from typing import List, Optional, Dict, cast, Union, Tuple
|
|
||||||
|
|
||||||
from PyQt5.QtCore import QObject, pyqtSignal, QUrl, pyqtProperty, pyqtSlot
|
from PyQt5.QtCore import QObject, pyqtSignal, QUrl, pyqtProperty, pyqtSlot
|
||||||
from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest
|
|
||||||
|
|
||||||
from UM import i18nCatalog
|
from UM import i18nCatalog
|
||||||
from UM.FileHandler.FileWriter import FileWriter
|
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.MaterialOutputModel import MaterialOutputModel
|
||||||
from cura.PrinterOutput.NetworkedPrinterOutputDevice import NetworkedPrinterOutputDevice, AuthState
|
from cura.PrinterOutput.NetworkedPrinterOutputDevice import NetworkedPrinterOutputDevice, AuthState
|
||||||
from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel
|
from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel
|
||||||
|
from plugins.UM3NetworkPrinting.src.Cloud.CloudApiClient import CloudApiClient
|
||||||
from plugins.UM3NetworkPrinting.src.UM3PrintJobOutputModel import UM3PrintJobOutputModel
|
from plugins.UM3NetworkPrinting.src.UM3PrintJobOutputModel import UM3PrintJobOutputModel
|
||||||
from .Models import (
|
from .Models import (
|
||||||
CloudClusterPrinter, CloudClusterPrintJob, JobUploadRequest, JobUploadResponse, PrintResponse, CloudClusterStatus,
|
CloudClusterPrinter, CloudClusterPrintJob, CloudJobUploadRequest, CloudJobResponse, CloudClusterStatus,
|
||||||
CloudClusterPrinterConfigurationMaterial
|
CloudClusterPrinterConfigurationMaterial, CloudErrorObject
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -40,20 +38,16 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice):
|
||||||
# The translation catalog for this device.
|
# The translation catalog for this device.
|
||||||
I18N_CATALOG = i18nCatalog("cura")
|
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.
|
# Signal triggered when the printers in the remote cluster were changed.
|
||||||
printersChanged = pyqtSignal()
|
printersChanged = pyqtSignal()
|
||||||
|
|
||||||
# Signal triggered when the print jobs in the queue were changed.
|
# Signal triggered when the print jobs in the queue were changed.
|
||||||
printJobsChanged = pyqtSignal()
|
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)
|
super().__init__(device_id = device_id, address = "", properties = {}, parent = parent)
|
||||||
|
self._api = api_client
|
||||||
|
|
||||||
self._setInterfaceElements()
|
self._setInterfaceElements()
|
||||||
|
|
||||||
self._device_id = device_id
|
self._device_id = device_id
|
||||||
|
@ -76,40 +70,6 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice):
|
||||||
self._sending_job = False
|
self._sending_job = False
|
||||||
self._progress_message = None # type: Optional[Message]
|
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.
|
## Set all the interface elements and texts for this output device.
|
||||||
def _setInterfaceElements(self):
|
def _setInterfaceElements(self):
|
||||||
self.setPriority(2) # make sure we end up below the local networking and above 'save to file'
|
self.setPriority(2) # make sure we end up below the local networking and above 'save to file'
|
||||||
|
@ -223,22 +183,16 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice):
|
||||||
def _update(self) -> None:
|
def _update(self) -> None:
|
||||||
super()._update()
|
super()._update()
|
||||||
Logger.log("i", "Calling the cloud cluster")
|
Logger.log("i", "Calling the cloud cluster")
|
||||||
self.get("{root}/cluster/{cluster_id}/status".format(root = self.CLUSTER_API_ROOT,
|
if self._account.isLoggedIn:
|
||||||
cluster_id = self._device_id),
|
self.setAuthenticationState(AuthState.Authenticated)
|
||||||
on_finished = self._onStatusCallFinished)
|
self._api.getClusterStatus(self._device_id, self._onStatusCallFinished)
|
||||||
|
else:
|
||||||
|
self.setAuthenticationState(AuthState.NotAuthenticated)
|
||||||
|
|
||||||
## Method called when HTTP request to status endpoint is finished.
|
## Method called when HTTP request to status endpoint is finished.
|
||||||
# Contains both printers and print jobs statuses in a single response.
|
# Contains both printers and print jobs statuses in a single response.
|
||||||
def _onStatusCallFinished(self, reply: QNetworkReply) -> None:
|
def _onStatusCallFinished(self, status: CloudClusterStatus) -> None:
|
||||||
status_code, response = self._parseReply(reply)
|
Logger.log("d", "Got response form the cloud cluster: %s", status.__dict__)
|
||||||
if status_code > 204 or not isinstance(response, dict) or "data" not in response:
|
|
||||||
Logger.log("w", "Got unexpected response while trying to get cloud cluster data: %s, %s",
|
|
||||||
status_code, response)
|
|
||||||
return
|
|
||||||
|
|
||||||
Logger.log("d", "Got response form the cloud cluster %s, %s", status_code, response)
|
|
||||||
status = CloudClusterStatus(**response["data"])
|
|
||||||
|
|
||||||
# Update all data from the cluster.
|
# Update all data from the cluster.
|
||||||
self._updatePrinters(status.printers)
|
self._updatePrinters(status.printers)
|
||||||
self._updatePrintJobs(status.print_jobs)
|
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]
|
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]
|
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))
|
for removed_job_id in set(current_jobs).difference(remote_jobs):
|
||||||
new_job_ids = set(remote_jobs.keys()).difference(set(current_jobs))
|
self._print_jobs.remove(current_jobs[removed_job_id])
|
||||||
updated_job_ids = set(current_jobs).intersection(set(remote_jobs))
|
|
||||||
|
|
||||||
for job_id in removed_job_ids:
|
for new_job_id in set(remote_jobs.keys()).difference(current_jobs):
|
||||||
self._print_jobs.remove(current_jobs[job_id])
|
self._addPrintJob(remote_jobs[new_job_id])
|
||||||
|
|
||||||
for job_id in new_job_ids:
|
for updated_job_id in set(current_jobs).intersection(remote_jobs):
|
||||||
self._addPrintJob(remote_jobs[job_id])
|
self._updateUM3PrintJobOutputModel(current_jobs[updated_job_id], remote_jobs[updated_job_id])
|
||||||
|
|
||||||
for job_id in updated_job_ids:
|
|
||||||
self._updateUM3PrintJobOutputModel(current_jobs[job_id], remote_jobs[job_id])
|
|
||||||
|
|
||||||
# TODO: properly handle removed and updated printers
|
# TODO: properly handle removed and updated printers
|
||||||
self.printJobsChanged.emit()
|
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:
|
def _sendPrintJob(self, file_name: str, content_type: str, stream: Union[io.StringIO, io.BytesIO]) -> None:
|
||||||
mesh = stream.getvalue()
|
mesh = stream.getvalue()
|
||||||
|
|
||||||
request = JobUploadRequest()
|
request = CloudJobUploadRequest()
|
||||||
request.job_name = file_name
|
request.job_name = file_name
|
||||||
request.file_size = len(mesh)
|
request.file_size = len(mesh)
|
||||||
request.content_type = content_type
|
request.content_type = content_type
|
||||||
|
|
||||||
Logger.log("i", "Creating new cloud print job: %s", request.__dict__)
|
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__}),
|
self._api.requestUpload(request, lambda response: self._onPrintJobCreated(mesh, response))
|
||||||
on_finished = lambda reply: self._onPrintJobCreated(mesh, reply))
|
|
||||||
|
|
||||||
def _onPrintJobCreated(self, mesh: bytes, reply: QNetworkReply) -> None:
|
def _onPrintJobCreated(self, mesh: bytes, job_response: CloudJobResponse) -> 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"))
|
|
||||||
Logger.log("i", "Print job created successfully: %s", job_response.__dict__)
|
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,
|
self._api.uploadMesh(job_response, mesh, self._onPrintJobUploaded, self._onUploadPrintJobProgress)
|
||||||
on_finished = lambda r: self._onPrintJobUploaded(job_response.job_id, r),
|
|
||||||
on_progress = 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:
|
def _onUploadPrintJobProgress(self, bytes_sent: int, bytes_total: int) -> None:
|
||||||
if bytes_total > 0:
|
if bytes_total > 0:
|
||||||
self._updateUploadProgress(int((bytes_sent / bytes_total) * 100))
|
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):
|
def _updateUploadProgress(self, progress: int):
|
||||||
if not self._progress_message:
|
if not self._progress_message:
|
||||||
self._progress_message = Message(
|
self._progress_message = Message(
|
||||||
|
@ -479,3 +398,6 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice):
|
||||||
@pyqtProperty(bool, notify = printJobsChanged)
|
@pyqtProperty(bool, notify = printJobsChanged)
|
||||||
def receivedPrintJobs(self) -> bool:
|
def receivedPrintJobs(self) -> bool:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def _onApiError(self, errors: List[CloudErrorObject]) -> None:
|
||||||
|
pass # TODO: Show errors...
|
||||||
|
|
|
@ -1,19 +1,17 @@
|
||||||
# Copyright (c) 2018 Ultimaker B.V.
|
# Copyright (c) 2018 Ultimaker B.V.
|
||||||
# Cura is released under the terms of the LGPLv3 or higher.
|
# Cura is released under the terms of the LGPLv3 or higher.
|
||||||
import json
|
|
||||||
from time import sleep
|
|
||||||
from threading import Timer
|
from threading import Timer
|
||||||
from typing import Dict, Optional
|
from typing import Dict, List
|
||||||
|
|
||||||
from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply
|
|
||||||
|
|
||||||
|
from UM import i18nCatalog
|
||||||
from UM.Logger import Logger
|
from UM.Logger import Logger
|
||||||
|
from UM.Message import Message
|
||||||
from UM.Signal import Signal
|
from UM.Signal import Signal
|
||||||
from cura.CuraApplication import CuraApplication
|
from cura.CuraApplication import CuraApplication
|
||||||
from cura.NetworkClient import NetworkClient
|
from plugins.UM3NetworkPrinting.src.Cloud.CloudApiClient import CloudApiClient
|
||||||
|
|
||||||
from .CloudOutputDevice import CloudOutputDevice
|
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.
|
## 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/.
|
# API spec is available on https://api.ultimaker.com/docs/connect/spec/.
|
||||||
#
|
#
|
||||||
class CloudOutputDeviceManager(NetworkClient):
|
class CloudOutputDeviceManager:
|
||||||
|
|
||||||
# The cloud URL to use for remote clusters.
|
|
||||||
API_ROOT_PATH = "https://api-staging.ultimaker.com/connect/v1"
|
|
||||||
|
|
||||||
# The interval with which the remote clusters are checked
|
# The interval with which the remote clusters are checked
|
||||||
CHECK_CLUSTER_INTERVAL = 5 # seconds
|
CHECK_CLUSTER_INTERVAL = 5 # seconds
|
||||||
|
|
||||||
|
# The translation catalog for this device.
|
||||||
|
I18N_CATALOG = i18nCatalog("cura")
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
|
@ -37,8 +35,10 @@ class CloudOutputDeviceManager(NetworkClient):
|
||||||
|
|
||||||
application = CuraApplication.getInstance()
|
application = CuraApplication.getInstance()
|
||||||
self._output_device_manager = application.getOutputDeviceManager()
|
self._output_device_manager = application.getOutputDeviceManager()
|
||||||
|
|
||||||
self._account = application.getCuraAPI().account
|
self._account = application.getCuraAPI().account
|
||||||
self._account.loginStateChanged.connect(self._getRemoteClusters)
|
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.
|
# When switching machines we check if we have to activate a remote cluster.
|
||||||
application.globalContainerStackChanged.connect(self._connectToActiveMachine)
|
application.globalContainerStackChanged.connect(self._connectToActiveMachine)
|
||||||
|
@ -46,40 +46,21 @@ class CloudOutputDeviceManager(NetworkClient):
|
||||||
self._on_cluster_received = Signal()
|
self._on_cluster_received = Signal()
|
||||||
self._on_cluster_received.connect(self._getRemoteClusters)
|
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.
|
## Gets all remote clusters from the API.
|
||||||
def _getRemoteClusters(self) -> None:
|
def _getRemoteClusters(self) -> None:
|
||||||
Logger.log("i", "Retrieving remote clusters")
|
Logger.log("i", "Retrieving remote clusters")
|
||||||
if self._account.isLoggedIn:
|
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
|
# Only start the polling thread after the user is authenticated
|
||||||
# The first call to _getRemoteClusters comes from self._account.loginStateChanged
|
# The first call to _getRemoteClusters comes from self._account.loginStateChanged
|
||||||
timer = Timer(5.0, self._on_cluster_received.emit)
|
timer = Timer(5.0, self._on_cluster_received.emit)
|
||||||
timer.start()
|
timer.start()
|
||||||
|
|
||||||
|
|
||||||
## Callback for when the request for getting the clusters. is finished.
|
## Callback for when the request for getting the clusters. is finished.
|
||||||
def _onGetRemoteClustersFinished(self, reply: QNetworkReply) -> None:
|
def _onGetRemoteClustersFinished(self, clusters: List[CloudCluster]) -> None:
|
||||||
Logger.log("i", "Received remote clusters")
|
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)
|
Logger.log("i", "Parsed remote clusters to %s", found_clusters)
|
||||||
if not found_clusters:
|
if not found_clusters:
|
||||||
return
|
return
|
||||||
|
@ -97,28 +78,17 @@ class CloudOutputDeviceManager(NetworkClient):
|
||||||
for cluster_id in known_cluster_ids.difference(found_cluster_ids):
|
for cluster_id in known_cluster_ids.difference(found_cluster_ids):
|
||||||
self._removeCloudOutputDevice(found_clusters[cluster_id])
|
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.
|
## 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):
|
def _addCloudOutputDevice(self, cluster: CloudCluster):
|
||||||
device = CloudOutputDevice(cluster.cluster_id)
|
device = CloudOutputDevice(self._api, cluster.cluster_id)
|
||||||
self._output_device_manager.addOutputDevice(device)
|
self._output_device_manager.addOutputDevice(device)
|
||||||
self._remote_clusters[cluster.cluster_id] = device
|
self._remote_clusters[cluster.cluster_id] = device
|
||||||
device.connect() # TODO: remove this
|
device.connect() # TODO: remove this
|
||||||
self._connectToActiveMachine()
|
self._connectToActiveMachine()
|
||||||
|
|
||||||
## Remove a CloudOutputDevice
|
## Remove a CloudOutputDevice
|
||||||
|
# \param cluster: The cluster that was removed
|
||||||
def _removeCloudOutputDevice(self, cluster: CloudCluster):
|
def _removeCloudOutputDevice(self, cluster: CloudCluster):
|
||||||
self._output_device_manager.removeOutputDevice(cluster.cluster_id)
|
self._output_device_manager.removeOutputDevice(cluster.cluster_id)
|
||||||
del self._remote_clusters[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.
|
# TODO: If so, we can also immediate connect to it.
|
||||||
# active_machine.setMetaDataEntry("um_cloud_cluster_id", "")
|
# active_machine.setMetaDataEntry("um_cloud_cluster_id", "")
|
||||||
# self._remote_clusters.get(stored_cluster_id).connect()
|
# 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()
|
||||||
|
|
|
@ -1,10 +1,22 @@
|
||||||
# Copyright (c) 2018 Ultimaker B.V.
|
# Copyright (c) 2018 Ultimaker B.V.
|
||||||
# Cura is released under the terms of the LGPLv3 or higher.
|
# Cura is released under the terms of the LGPLv3 or higher.
|
||||||
from typing import List
|
from typing import List, Dict
|
||||||
|
|
||||||
from ..Models import BaseModel
|
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 representing a cloud connected cluster.
|
||||||
class CloudCluster(BaseModel):
|
class CloudCluster(BaseModel):
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
|
@ -95,17 +107,23 @@ class CloudClusterPrintJob(BaseModel):
|
||||||
for p in self.constraints]
|
for p in self.constraints]
|
||||||
|
|
||||||
|
|
||||||
|
# Model that represents the status of the cluster for the cloud
|
||||||
class CloudClusterStatus(BaseModel):
|
class CloudClusterStatus(BaseModel):
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
|
# a list of the printers
|
||||||
self.printers = [] # type: List[CloudClusterPrinter]
|
self.printers = [] # type: List[CloudClusterPrinter]
|
||||||
|
# a list of the print jobs
|
||||||
self.print_jobs = [] # type: List[CloudClusterPrintJob]
|
self.print_jobs = [] # type: List[CloudClusterPrintJob]
|
||||||
|
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
|
# converting any dictionaries into models
|
||||||
self.printers = [CloudClusterPrinter(**p) if isinstance(p, dict) else p for p in self.printers]
|
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]
|
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):
|
def __init__(self, **kwargs):
|
||||||
self.file_size = None # type: int
|
self.file_size = None # type: int
|
||||||
self.job_name = None # type: str
|
self.job_name = None # type: str
|
||||||
|
@ -113,7 +131,8 @@ class JobUploadRequest(BaseModel):
|
||||||
super().__init__(**kwargs)
|
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):
|
def __init__(self, **kwargs):
|
||||||
self.download_url = None # type: str
|
self.download_url = None # type: str
|
||||||
self.job_id = None # type: str
|
self.job_id = None # type: str
|
||||||
|
@ -125,7 +144,8 @@ class JobUploadResponse(BaseModel):
|
||||||
super().__init__(**kwargs)
|
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):
|
def __init__(self, **kwargs):
|
||||||
self.cluster_job_id = None # type: str
|
self.cluster_job_id = None # type: str
|
||||||
self.job_id = None # type: str
|
self.job_id = None # type: str
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue