diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index 79a3d46949..06e5656392 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -1,18 +1,23 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. import json +import os from typing import List, Optional, Dict -from PyQt5.QtCore import QObject, pyqtProperty, pyqtSignal, QUrl +from PyQt5.QtCore import QObject, pyqtSignal, QUrl, pyqtProperty from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest from UM import i18nCatalog from UM.FileHandler.FileHandler import FileHandler from UM.Logger import Logger from UM.Scene.SceneNode import SceneNode +from UM.Settings import ContainerRegistry from cura.CuraApplication import CuraApplication +from cura.PrinterOutput import PrinterOutputController, PrintJobOutputModel +from cura.PrinterOutput.MaterialOutputModel import MaterialOutputModel from cura.PrinterOutput.NetworkedPrinterOutputDevice import NetworkedPrinterOutputDevice, AuthState from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel +from .Models import CloudClusterPrinter, CloudClusterPrinterConfiguration, CloudClusterPrinterConfigurationMaterial, CloudClusterPrintJob, CloudClusterPrintJobConstraint from .CloudOutputController import CloudOutputController from ..UM3PrintJobOutputModel import UM3PrintJobOutputModel @@ -38,17 +43,23 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): # 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 + + # 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._printers = [] - self._print_jobs = [] + self._printers = {} # type: Dict[str, PrinterOutputModel] + self._print_jobs = {} # type: Dict[str, PrintJobOutputModel] self._number_of_extruders = 2 # All networked printers are dual-extrusion Ultimaker machines. ## We need to override _createEmptyRequest to work for the cloud. @@ -90,8 +101,8 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): ## Get remote print jobs. @pyqtProperty("QVariantList", notify = printJobsChanged) - def printJobs(self) -> List[UM3PrintJobOutputModel]: - return self._print_jobs + 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: @@ -111,41 +122,182 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): .format(status_code, reply.readAll())) return - data = self._parseStatusResponse(reply) - if data is None: + printers, print_jobs = self._parseStatusResponse(reply) + if not printers and not print_jobs: return # Update all data from the cluster. - self._updatePrinters(data.get("printers", [])) - self._updatePrintJobs(data.get("print_jobs", [])) + self._updatePrinters(printers) + self._updatePrintJobs(print_jobs) @staticmethod - def _parseStatusResponse(reply: QNetworkReply) -> Optional[dict]: + def _parseStatusResponse(reply: QNetworkReply): # Optional[(CloudClusterPrinter, CloudClusterPrintJob)] doesn't work + + printers = [] + print_jobs = [] + s = '' try: - result = json.loads(bytes(reply.readAll()).decode("utf-8")) - # TODO: use model or named tuple here. - return result + s = json.loads(bytes(reply.readAll()).decode("utf-8")) + + for p in s["printers"]: + printer = CloudClusterPrinter(**p) + configuration = printer.configuration + printer.configuration = [] + for c in configuration: + extruder = CloudClusterPrinterConfiguration(**c) + extruder.material = CloudClusterPrinterConfigurationMaterial(extruder.material) + printer.configuration.append(extruder) + + printers.append(printer) + + for j in s["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(configuration.material) + job.configuration.append(configuration) + + print_jobs.append(job) + except json.decoder.JSONDecodeError: Logger.logException("w", "Unable to decode JSON from reply.") return None - def _updatePrinters(self, remote_printers: List[Dict[str, any]]) -> None: - # TODO: use model or tuple for remote_printers data - for printer in remote_printers: - - # If the printer does not exist yet, create it. - if not self._getPrinterByKey(printer["uuid"]): - self._printers.append(PrinterOutputModel( - output_controller = CloudOutputController(self), - number_of_extruders = self._number_of_extruders - )) - + return printers, print_jobs + + def _updatePrinters(self, printers: List[CloudClusterPrinter]) -> None: + remote_printers = {p.uuid: p for p in printers} + + removed_printers = set(self._printers.keys()).difference(set(remote_printers.keys())) + new_printers = set(remote_printers.keys()).difference(set(self._printers.keys())) + updated_printers = set(self._printers.keys()).intersection(set(remote_printers.keys())) + + for p in removed_printers: + self._removePrinter(p) + + for p in new_printers: + self._addPrinter(printers[p]) + self._updatePrinter(printers[p]) + + for p in updated_printers: + self._updatePrinter(printers[p]) + # TODO: properly handle removed and updated printers self.printersChanged.emit() - def _updatePrintJobs(self, remote_print_jobs: List[Dict[str, any]]) -> None: - # TODO: use model or tuple for remote_print_jobs data - pass + + def _addPrinter(self, printer): + self._printers[printer.uuid] = self._createPrinterOutputModel(self, printer) + + def _createPrinterOutputModel(self, printer: CloudClusterPrinter) -> PrinterOutputModel: + return PrinterOutputModel(PrinterOutputController(self), len(printer.configuration), + firmware_version=printer.firmware_version) + + def _updatePrinter(self, guid : str, printer : CloudClusterPrinter): + model = self._printers[guid] + self._printers[guid] = self._updatePrinterOutputModel(self, printer) + + def _updatePrinterOutputModel(self, printer: CloudClusterPrinter, model : PrinterOutputModel) -> PrinterOutputModel: + 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) + + material_data = extruder_data.material + if extruder.activeMaterial is None or extruder.activeMaterial.guid != material.guid: + material = self._createMaterialOutputModel(material_data) + extruder.updateActiveMaterial(material) + + def _createMaterialOutputModel(self, 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 _removePrinter(self, guid): + del self._printers[guid] + + def _updatePrintJobs(self, jobs: List[CloudClusterPrintJob]) -> None: + remote_jobs = {j.uuid: j for j in jobs} + + removed_jobs = set(self._print_jobs.keys()).difference(set(remote_jobs.keys())) + new_jobs = set(remote_jobs.keys()).difference(set(self._print_jobs.keys())) + updated_jobs = set(self._print_jobs.keys()).intersection(set(remote_jobs.keys())) + + for j in removed_jobs: + self._removePrintJob(j) + + for j in new_jobs: + self._addPrintJob(jobs[j]) + + for j in updated_jobs: + self._updatePrintJob(jobs[j]) + + # TODO: properly handle removed and updated printers + self.printJobsChanged() + + def _addPrintJob(self, job: CloudClusterPrintJob): + self._print_jobs[job.uuid] = self._createPrintJobOutputModel(job) + + def _createPrintJobOutputModel(self, job:CloudClusterPrintJob) -> PrintJobOutputModel: + controller = self._printers[job.printer_uuid]._controller # TODO: Can we access this property? + model = PrintJobOutputModel(controller, job.uuid, job.name) + assigned_printer = self._printes[job.printer_uuid] # TODO: Or do we have to use the assigned_to field? + model.updateAssignedPrinter(assigned_printer) + + def _updatePrintJobOutputModel(self, guid: str, job:CloudClusterPrintJob): + model =self._print_jobs[guid] + + model.updateTimeTotal(job.time_total) + model.updateTimeElapsed(job.time_elapsed) + model.updateOwner(job.owner) + model.updateState(job.status) + + def _removePrintJob(self, guid:str): + del self._print_jobs[guid] def _addPrintJobToQueue(self): # TODO: implement this diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 4de7263df1..f6542e3c76 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -124,9 +124,9 @@ class CloudOutputDeviceManager(NetworkClient): local_device_id = active_machine.getMetaDataEntry("um_network_key") if local_device_id: - active_output_device = CuraApplication.getInstance().getOutputDeviceManager().getActiveDevice() - active_output_device.id - + active_output_device = self._output_device_manager.getActiveDevice() + # We must find a match for the active machine and a cloud device + stored_cluster_id = active_machine.getMetaDataEntry("um_cloud_cluster_id") if stored_cluster_id not in self._remote_clusters.keys(): # Currently authenticated user does not have access to stored cluster or no user is signed in. diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models.py b/plugins/UM3NetworkPrinting/src/Cloud/Models.py index e98d848d51..7d6db9c8c0 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models.py @@ -16,3 +16,66 @@ class CloudCluster(BaseModel): def validate(self): if not self.cluster_id: raise ValueError("cluster_id is required on CloudCluster") + + +## Class representing a cloud cluster printer configuration +class CloudClusterPrinterConfigurationMaterial(BaseModel): + def __init__(self, **kwargs): + self.guid = None # type: str + self.brand = None # type: str + self.color = None # type: str + self.material = None # type: str + super().__init__(**kwargs) + + +## Class representing a cloud cluster printer configuration +class CloudClusterPrinterConfiguration(BaseModel): + def __init__(self, **kwargs): + self.extruder_index = None # type: str + self.material = None # type: CloudClusterPrinterConfigurationMaterial + self.nozzle_diameter = None # type: str + self.printer_core_id = None # type: str + super().__init__(**kwargs) + + +## Class representing a cluster printer +class CloudClusterPrinter(BaseModel): + def __init__(self, **kwargs): + self.configuration = None # type: CloudClusterPrinterConfiguration + self.enabled = None # type: str + self.firmware_version = None # type: str + self.friendly_name = None # type: str + self.ip_address = None # type: str + self.machine_variant = None # type: str + self.status = None # type: str + self.unique_name = None # type: str + self.uuid = None # type: str + super().__init__(**kwargs) + + +## Class representing a cloud cluster print job constraint +class CloudClusterPrintJobConstraint(BaseModel): + def __init__(self, **kwargs): + self.require_printer_name: None # type: str + super().__init__(**kwargs) + +## Class representing a print job +class CloudClusterPrintJob(BaseModel): + def __init__(self, **kwargs): + self.assigned_to = None # type: str + self.configuration = None # type: str + self.constraints = None # type: str + self.created_at = None # type: str + self.force = None # type: str + self.last_seen = None # type: str + self.machine_variant = None # type: str + self.name = None # type: str + self.network_error_count = None # type: str + self.owner = None # type: str + self.printer_uuid = None # type: str + self.started = None # type: str + self.status = None # type: str + self.time_elapsed = None # type: str + self.time_total = None # type: str + self.uuid = None # type: str + super().__init__(**kwargs)