mirror of
https://github.com/Ultimaker/Cura.git
synced 2025-07-07 15:07:28 -06:00
420 lines
20 KiB
Python
420 lines
20 KiB
Python
# 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 PyQt5.QtCore import QObject, pyqtSignal, QUrl, pyqtProperty
|
|
from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest
|
|
|
|
from UM import i18nCatalog
|
|
from UM.FileHandler.FileWriter import FileWriter
|
|
from UM.FileHandler.FileHandler import FileHandler
|
|
from UM.Logger import Logger
|
|
from UM.OutputDevice import OutputDeviceError
|
|
from UM.Scene.SceneNode import SceneNode
|
|
from UM.Version import Version
|
|
from cura.CuraApplication import CuraApplication
|
|
from cura.PrinterOutput.PrinterOutputController import PrinterOutputController
|
|
from cura.PrinterOutput.MaterialOutputModel import MaterialOutputModel
|
|
from cura.PrinterOutput.NetworkedPrinterOutputDevice import NetworkedPrinterOutputDevice, AuthState
|
|
from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel
|
|
from plugins.UM3NetworkPrinting.src.UM3PrintJobOutputModel import UM3PrintJobOutputModel
|
|
from .Models import CloudClusterPrinter, CloudClusterPrinterConfiguration, CloudClusterPrinterConfigurationMaterial, \
|
|
CloudClusterPrintJob, CloudClusterPrintJobConstraint, JobUploadRequest, JobUploadResponse, PrintResponse
|
|
|
|
|
|
## The cloud output device is a network output device that works remotely but has limited functionality.
|
|
# Currently it only supports viewing the printer and print job status and adding a new job to the queue.
|
|
# As such, those methods have been implemented here.
|
|
# Note that this device represents a single remote cluster, not a list of multiple clusters.
|
|
#
|
|
# TODO: figure our how the QML interface for the cluster networking should operate with this limited functionality.
|
|
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):
|
|
super().__init__(device_id = device_id, address = "", properties = {}, parent = parent)
|
|
self._setInterfaceElements()
|
|
|
|
self._device_id = device_id
|
|
self._account = CuraApplication.getInstance().getCuraAPI().account
|
|
|
|
# Cluster does not have authentication, so default to authenticated
|
|
self._authentication_state = AuthState.Authenticated
|
|
|
|
# We re-use the Cura Connect monitor tab to get the most functionality right away.
|
|
self._monitor_view_qml_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|
"../../resources/qml/ClusterMonitorItem.qml")
|
|
self._control_view_qml_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|
"../../resources/qml/ClusterControlItem.qml")
|
|
|
|
# Properties to populate later on with received cloud data.
|
|
self._print_jobs = [] # type: List[UM3PrintJobOutputModel]
|
|
self._number_of_extruders = 2 # All networked printers are dual-extrusion Ultimaker machines.
|
|
|
|
@staticmethod
|
|
def _parseReply(reply: QNetworkReply) -> Tuple[int, Union[None, str, bytes]]:
|
|
"""
|
|
Parses a reply from the stardust server.
|
|
:param reply: The reply received from the server.
|
|
:return: The status code and the response dict.
|
|
"""
|
|
status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute)
|
|
response = None
|
|
try:
|
|
response = bytes(reply.readAll()).decode("utf-8")
|
|
response = json.loads(response)
|
|
except JSONDecodeError:
|
|
Logger.logException("w", "Unable to decode JSON from reply.")
|
|
return status_code, response
|
|
|
|
## We need to override _createEmptyRequest to work for the cloud.
|
|
def _createEmptyRequest(self, path: str, content_type: Optional[str] = "application/json") -> QNetworkRequest:
|
|
# 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(3)
|
|
self.setName(self._id)
|
|
# TODO: how to name these?
|
|
self.setShortDescription(self.I18N_CATALOG.i18nc("@action:button", "Print via Cloud"))
|
|
self.setDescription(self.I18N_CATALOG.i18nc("@properties:tooltip", "Print via Cloud"))
|
|
self.setConnectionText(self.I18N_CATALOG.i18nc("@info:status", "Connected via Cloud"))
|
|
|
|
## Called when Cura requests an output device to receive a (G-code) file.
|
|
def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mime_types: bool = False,
|
|
file_handler: Optional[FileHandler] = None, **kwargs: str) -> None:
|
|
self.writeStarted.emit(self)
|
|
|
|
file_format = self._determineFileFormat(file_handler)
|
|
writer = self._determineWriter(file_handler, file_format)
|
|
|
|
# This function pauses with the yield, waiting on instructions on which printer it needs to print with.
|
|
if not writer:
|
|
Logger.log("e", "Missing file or mesh writer!")
|
|
return
|
|
|
|
stream = io.StringIO() if file_format["mode"] == FileWriter.OutputMode.TextMode else io.BytesIO()
|
|
writer.write(stream, nodes)
|
|
self._sendPrintJob(file_name + "." + file_format["extension"], stream)
|
|
|
|
# TODO: This is yanked right out of ClusterUM3OutputDevice, great candidate for a utility or base class
|
|
def _determineFileFormat(self, file_handler) -> Optional[Dict[str, Union[str, int]]]:
|
|
# Formats supported by this application (file types that we can actually write).
|
|
if file_handler:
|
|
file_formats = file_handler.getSupportedFileTypesWrite()
|
|
else:
|
|
file_formats = CuraApplication.getInstance().getMeshFileHandler().getSupportedFileTypesWrite()
|
|
|
|
global_stack = CuraApplication.getInstance().getGlobalContainerStack()
|
|
# Create a list from the supported file formats string.
|
|
if not global_stack:
|
|
Logger.log("e", "Missing global stack!")
|
|
return
|
|
|
|
machine_file_formats = global_stack.getMetaDataEntry("file_formats").split(";")
|
|
machine_file_formats = [file_type.strip() for file_type in machine_file_formats]
|
|
# Exception for UM3 firmware version >=4.4: UFP is now supported and should be the preferred file format.
|
|
if "application/x-ufp" not in machine_file_formats and Version(self.firmwareVersion) >= Version("4.4"):
|
|
machine_file_formats = ["application/x-ufp"] + machine_file_formats
|
|
|
|
# Take the intersection between file_formats and machine_file_formats.
|
|
format_by_mimetype = {f["mime_type"]: f for f in file_formats}
|
|
|
|
# Keep them ordered according to the preference in machine_file_formats.
|
|
file_formats = [format_by_mimetype[mimetype] for mimetype in machine_file_formats]
|
|
|
|
if len(file_formats) == 0:
|
|
Logger.log("e", "There are no file formats available to write with!")
|
|
raise OutputDeviceError.WriteRequestFailedError(
|
|
self.I18N_CATALOG.i18nc("@info:status", "There are no file formats available to write with!")
|
|
)
|
|
return file_formats[0]
|
|
|
|
# TODO: This is yanked right out of ClusterUM3OutputDevice, great candidate for a utility or base class
|
|
@staticmethod
|
|
def _determineWriter(file_handler, file_format) -> Optional[FileWriter]:
|
|
# Just take the first file format available.
|
|
if file_handler is not None:
|
|
writer = file_handler.getWriterByMimeType(cast(str, file_format["mime_type"]))
|
|
else:
|
|
writer = CuraApplication.getInstance().getMeshFileHandler().getWriterByMimeType(
|
|
cast(str, file_format["mime_type"])
|
|
)
|
|
|
|
if not writer:
|
|
Logger.log("e", "Unexpected error when trying to get the FileWriter")
|
|
return
|
|
|
|
return writer
|
|
|
|
## Get remote printers.
|
|
@pyqtProperty("QVariantList", notify = printersChanged)
|
|
def printers(self):
|
|
return self._printers
|
|
|
|
@pyqtProperty("QVariantList", notify = printJobsChanged)
|
|
def printJobs(self)-> List[UM3PrintJobOutputModel]:
|
|
return self._print_jobs
|
|
|
|
## Get remote print jobs.
|
|
@pyqtProperty("QVariantList", notify = printJobsChanged)
|
|
def queuedPrintJobs(self) -> List[UM3PrintJobOutputModel]:
|
|
return [print_job for print_job in self._print_jobs
|
|
if print_job.state == "queued" or print_job.state == "error"]
|
|
|
|
## Called when the connection to the cluster changes.
|
|
def connect(self) -> None:
|
|
super().connect()
|
|
|
|
## Called when the network data should be updated.
|
|
def _update(self) -> None:
|
|
super()._update()
|
|
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)
|
|
|
|
## 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):
|
|
Logger.log("w", "Got unexpected response while trying to get cloud cluster data: %s, %s",
|
|
status_code, response)
|
|
return
|
|
|
|
Logger.log("d", "Got response form the cloud cluster %s, %s", status_code, response)
|
|
printers, print_jobs = self._parseStatusResponse(response)
|
|
if not printers and not print_jobs:
|
|
return
|
|
|
|
# Update all data from the cluster.
|
|
self._updatePrinters(printers)
|
|
self._updatePrintJobs(print_jobs)
|
|
|
|
@staticmethod
|
|
def _parseStatusResponse(response: dict) -> Tuple[List[CloudClusterPrinter], List[CloudClusterPrintJob]]:
|
|
printers = []
|
|
print_jobs = []
|
|
|
|
data = response["data"]
|
|
for p in data["printers"]:
|
|
printer = CloudClusterPrinter(**p)
|
|
configuration = printer.configuration
|
|
printer.configuration = []
|
|
for c in configuration:
|
|
extruder = CloudClusterPrinterConfiguration(**c)
|
|
extruder.material = CloudClusterPrinterConfigurationMaterial(material=extruder.material)
|
|
printer.configuration.append(extruder)
|
|
|
|
printers.append(printer)
|
|
|
|
for j in data["print_jobs"]:
|
|
job = CloudClusterPrintJob(**j)
|
|
constraints = job.constraints
|
|
job.constraints = []
|
|
for c in constraints:
|
|
job.constraints.append(CloudClusterPrintJobConstraint(**c))
|
|
|
|
configuration = job.configuration
|
|
job.configuration = []
|
|
for c in configuration:
|
|
configuration = CloudClusterPrinterConfiguration(**c)
|
|
configuration.material = CloudClusterPrinterConfigurationMaterial(material=configuration.material)
|
|
job.configuration.append(configuration)
|
|
|
|
print_jobs.append(job)
|
|
|
|
return printers, print_jobs
|
|
|
|
def _updatePrinters(self, printers: List[CloudClusterPrinter]) -> None:
|
|
remote_printers = {p.uuid: p for p in printers} # type: Dict[str, CloudClusterPrinter]
|
|
current_printers = {p.key: p for p in self._printers}
|
|
|
|
removed_printer_ids = set(current_printers).difference(remote_printers)
|
|
new_printer_ids = set(remote_printers).difference(current_printers)
|
|
updated_printer_ids = set(current_printers).intersection(remote_printers)
|
|
|
|
for printer_guid in removed_printer_ids:
|
|
self._printers.remove(current_printers[printer_guid])
|
|
|
|
for printer_guid in new_printer_ids:
|
|
self._addPrinter(remote_printers[printer_guid])
|
|
|
|
for printer_guid in updated_printer_ids:
|
|
self._updatePrinter(current_printers[printer_guid], remote_printers[printer_guid])
|
|
|
|
self.printersChanged.emit()
|
|
|
|
def _addPrinter(self, printer: CloudClusterPrinter) -> None:
|
|
model = PrinterOutputModel(
|
|
PrinterOutputController(self), len(printer.configuration), firmware_version=printer.firmware_version
|
|
)
|
|
self._printers.append(model)
|
|
self._updatePrinter(model, printer)
|
|
|
|
def _updatePrinter(self, model: PrinterOutputModel, printer: CloudClusterPrinter) -> None:
|
|
model.updateKey(printer.uuid)
|
|
model.updateName(printer.friendly_name)
|
|
model.updateType(printer.machine_variant)
|
|
model.updateState(printer.status if printer.enabled else "disabled")
|
|
|
|
for index in range(0, len(printer.configuration)):
|
|
try:
|
|
extruder = model.extruders[index]
|
|
extruder_data = printer.configuration[index]
|
|
except IndexError:
|
|
break
|
|
|
|
extruder.updateHotendID(extruder_data.print_core_id)
|
|
|
|
if extruder.activeMaterial is None or extruder.activeMaterial.guid != extruder_data.material.guid:
|
|
material = self._createMaterialOutputModel(extruder_data.material)
|
|
extruder.updateActiveMaterial(material)
|
|
|
|
@staticmethod
|
|
def _createMaterialOutputModel(material: CloudClusterPrinterConfigurationMaterial) -> MaterialOutputModel:
|
|
material_manager = CuraApplication.getInstance().getMaterialManager()
|
|
material_group_list = material_manager.getMaterialGroupListByGUID(material.guid) or []
|
|
|
|
# Sort the material groups by "is_read_only = True" first, and then the name alphabetically.
|
|
read_only_material_group_list = list(filter(lambda x: x.is_read_only, material_group_list))
|
|
non_read_only_material_group_list = list(filter(lambda x: not x.is_read_only, material_group_list))
|
|
material_group = None
|
|
if read_only_material_group_list:
|
|
read_only_material_group_list = sorted(read_only_material_group_list, key=lambda x: x.name)
|
|
material_group = read_only_material_group_list[0]
|
|
elif non_read_only_material_group_list:
|
|
non_read_only_material_group_list = sorted(non_read_only_material_group_list, key=lambda x: x.name)
|
|
material_group = non_read_only_material_group_list[0]
|
|
|
|
if material_group:
|
|
container = material_group.root_material_node.getContainer()
|
|
color = container.getMetaDataEntry("color_code")
|
|
brand = container.getMetaDataEntry("brand")
|
|
material_type = container.getMetaDataEntry("material")
|
|
name = container.getName()
|
|
else:
|
|
Logger.log("w",
|
|
"Unable to find material with guid {guid}. Using data as provided by cluster".format(
|
|
guid=material.guid))
|
|
color = material.color
|
|
brand = material.brand
|
|
material_type = material.material
|
|
name = "Empty" if material.material == "empty" else "Unknown"
|
|
|
|
return MaterialOutputModel(guid=material.guid, type=material_type, brand=brand, color=color, name=name)
|
|
|
|
def _updatePrintJobs(self, jobs: List[CloudClusterPrintJob]) -> None:
|
|
remote_jobs = {j.uuid: j for j in jobs}
|
|
current_jobs = {j.key: j for j in self._print_jobs}
|
|
|
|
removed_job_ids = set(current_jobs).difference(set(remote_jobs))
|
|
new_job_ids = set(remote_jobs.keys()).difference(set(current_jobs))
|
|
updated_job_ids = set(current_jobs).intersection(set(remote_jobs))
|
|
|
|
for job_id in removed_job_ids:
|
|
self._print_jobs.remove(current_jobs[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])
|
|
|
|
# TODO: properly handle removed and updated printers
|
|
self.printJobsChanged.emit()
|
|
|
|
def _addPrintJob(self, job: CloudClusterPrintJob) -> None:
|
|
try:
|
|
printer = next(p for p in self._printers if job.printer_uuid == p.key)
|
|
except StopIteration:
|
|
return Logger.log("w", "Missing printer %s for job %s in %s", job.printer_uuid, job.uuid,
|
|
[p.key for p in self._printers])
|
|
|
|
model = UM3PrintJobOutputModel(printer.getController(), job.uuid, job.name)
|
|
model.updateAssignedPrinter(printer)
|
|
self._print_jobs.append(model)
|
|
|
|
@staticmethod
|
|
def _updateUM3PrintJobOutputModel(model: PrinterOutputModel, job: CloudClusterPrintJob) -> None:
|
|
model.updateTimeTotal(job.time_total)
|
|
model.updateTimeElapsed(job.time_elapsed)
|
|
model.updateOwner(job.owner)
|
|
model.updateState(job.status)
|
|
|
|
def _sendPrintJob(self, file_name: str, stream: Union[io.StringIO, io.BytesIO]) -> None:
|
|
mesh = stream.getvalue()
|
|
|
|
request = JobUploadRequest()
|
|
request.job_name = file_name
|
|
request.file_size = len(mesh)
|
|
|
|
Logger.log("i", "Creating new cloud print job: %s", request.__dict__)
|
|
self.put("{}/jobs/upload".format(self.CURA_API_ROOT), data = json.dumps({"data": request.__dict__}),
|
|
on_finished = lambda reply: self._onPrintJobCreated(mesh, reply))
|
|
|
|
def _onPrintJobCreated(self, mesh: bytes, reply: QNetworkReply) -> None:
|
|
status_code, response = self._parseReply(reply)
|
|
if status_code > 204 or not isinstance(response, dict) or "data" not in response:
|
|
Logger.log("w", "Got unexpected response while trying to add print job to cluster: {}, {}"
|
|
.format(status_code, response))
|
|
return
|
|
|
|
# TODO: Multipart upload
|
|
job_response = JobUploadResponse(**response.get("data"))
|
|
Logger.log("i", "Print job created successfully: %s", job_response.__dict__)
|
|
self.put(job_response.upload_url, data=mesh,
|
|
on_finished=lambda r: self._onPrintJobUploaded(job_response.job_id, r))
|
|
|
|
def _onPrintJobUploaded(self, job_id: str, reply: QNetworkReply) -> None:
|
|
status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute)
|
|
if status_code > 204:
|
|
Logger.logException("w", "Received unexpected response from the job upload: %s, %s.", status_code,
|
|
bytes(reply.readAll()).decode())
|
|
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):
|
|
Logger.log("w", "Got unexpected response while trying to request printing: %s, %s",
|
|
status_code, response)
|
|
return
|
|
|
|
print_response = PrintResponse(**response.get("data"))
|
|
Logger.log("i", "Print job requested successfully: %s", print_response.__dict__)
|