diff --git a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py index 017fd0f0ed..676157181b 100644 --- a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py +++ b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py @@ -214,7 +214,8 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): 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: + def postFormWithParts(self, target: str, parts: List[QHttpPart], on_finished: Optional[Callable[[QNetworkReply], None]], on_progress: Callable = None) -> QNetworkReply: + if self._manager is None: self._createNetworkManager() request = self._createEmptyRequest(target, content_type=None) diff --git a/cura/Settings/MachineManager.py b/cura/Settings/MachineManager.py index a905ecdb42..a088592b88 100755 --- a/cura/Settings/MachineManager.py +++ b/cura/Settings/MachineManager.py @@ -1288,7 +1288,7 @@ class MachineManager(QObject): @pyqtSlot(str) def switchPrinterType(self, machine_name: str) -> None: # Don't switch if the user tries to change to the same type of printer - if self._global_container_stack is None or self.self.activeMachineDefinitionName == machine_name: + if self._global_container_stack is None or self.activeMachineDefinitionName == machine_name: return # Get the definition id corresponding to this machine name machine_definition_id = CuraContainerRegistry.getInstance().findDefinitionContainers(name = machine_name)[0].getId() diff --git a/plugins/UM3NetworkPrinting/ClusterUM3OutputDevice.py b/plugins/UM3NetworkPrinting/ClusterUM3OutputDevice.py index 495bbe1315..8b3ceb7809 100644 --- a/plugins/UM3NetworkPrinting/ClusterUM3OutputDevice.py +++ b/plugins/UM3NetworkPrinting/ClusterUM3OutputDevice.py @@ -23,6 +23,7 @@ from cura.PrinterOutput.MaterialOutputModel import MaterialOutputModel from cura.PrinterOutput.NetworkCamera import NetworkCamera from .ClusterUM3PrinterOutputController import ClusterUM3PrinterOutputController +from .SendMaterialJob import SendMaterialJob from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply from PyQt5.QtGui import QDesktopServices @@ -30,7 +31,7 @@ from PyQt5.QtCore import pyqtSlot, QUrl, pyqtSignal, pyqtProperty, QObject from time import time from datetime import datetime -from typing import Optional, Dict, List +from typing import Optional, Dict, List, Set import io #To create the correct buffers for sending data to the printer. import json @@ -94,6 +95,8 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mimetypes: bool = False, file_handler: Optional[FileHandler] = None, **kwargs: str) -> None: self.writeStarted.emit(self) + self.sendMaterialProfiles() + #Formats supported by this application (file types that we can actually write). if file_handler: file_formats = file_handler.getSupportedFileTypesWrite() @@ -370,6 +373,11 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): # Keep a list of all completed jobs so we know if something changed next time. self._finished_jobs = finished_jobs + ## Called when the connection to the cluster changes. + def connect(self) -> None: + super().connect() + self.sendMaterialProfiles() + def _update(self) -> None: super()._update() self.get("printers/", on_finished = self._onGetPrintersDataFinished) @@ -538,6 +546,13 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): self._active_printer = None self.activePrinterChanged.emit() + ## Sync the material profiles in Cura with the printer. + # + # This gets called when connecting to a printer as well as when sending a + # print. + def sendMaterialProfiles(self) -> None: + job = SendMaterialJob(device = self) + job.run() def loadJsonFromReply(reply: QNetworkReply) -> Optional[List[Dict[str, Any]]]: try: diff --git a/plugins/UM3NetworkPrinting/SendMaterialJob.py b/plugins/UM3NetworkPrinting/SendMaterialJob.py new file mode 100644 index 0000000000..135b2c421e --- /dev/null +++ b/plugins/UM3NetworkPrinting/SendMaterialJob.py @@ -0,0 +1,99 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + +import json #To understand the list of materials from the printer reply. +import os #To walk over material files. +import os.path #To filter on material files. +from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest #To listen to the reply from the printer. +from typing import Any, Dict, Set, TYPE_CHECKING +import urllib.parse #For getting material IDs from their file names. + +from UM.Job import Job #The interface we're implementing. +from UM.Logger import Logger +from UM.MimeTypeDatabase import MimeTypeDatabase #To strip the extensions of the material profile files. +from UM.Resources import Resources +from UM.Settings.ContainerRegistry import ContainerRegistry #To find the GUIDs of materials. + +from cura.CuraApplication import CuraApplication #For the resource types. + +if TYPE_CHECKING: + from .ClusterUM3OutputDevice import ClusterUM3OutputDevice + +## Asynchronous job to send material profiles to the printer. +# +# This way it won't freeze up the interface while sending those materials. +class SendMaterialJob(Job): + def __init__(self, device: "ClusterUM3OutputDevice"): + super().__init__() + self.device = device #type: ClusterUM3OutputDevice + + def run(self) -> None: + self.device.get("materials/", on_finished = self.sendMissingMaterials) + + def sendMissingMaterials(self, reply: QNetworkReply) -> None: + if reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) != 200: #Got an error from the HTTP request. + Logger.log("e", "Couldn't request current material storage on printer. Not syncing materials.") + return + + remote_materials_list = reply.readAll().data().decode("utf-8") + try: + remote_materials_list = json.loads(remote_materials_list) + except json.JSONDecodeError: + Logger.log("e", "Current material storage on printer was a corrupted reply.") + return + try: + remote_materials_by_guid = {material["guid"]: material for material in remote_materials_list} #Index by GUID. + except KeyError: + Logger.log("e", "Current material storage on printer was an invalid reply (missing GUIDs).") + return + + container_registry = ContainerRegistry.getInstance() + local_materials_list = filter(lambda material: ("GUID" in material and "version" in material and "id" in material), container_registry.findContainersMetadata(type = "material")) + local_materials_by_guid = {material["GUID"]: material for material in local_materials_list if material["id"] == material["base_file"]} + for material in local_materials_list: #For each GUID get the material with the highest version number. + try: + if int(material["version"]) > local_materials_by_guid[material["GUID"]]["version"]: + local_materials_by_guid[material["GUID"]] = material + except ValueError: + Logger.log("e", "Material {material_id} has invalid version number {number}.".format(material_id = material["id"], number = material["version"])) + continue + + materials_to_send = set() #type: Set[Dict[str, Any]] + for guid, material in local_materials_by_guid.items(): + if guid not in remote_materials_by_guid: + materials_to_send.add(material["id"]) + continue + try: + if int(material["version"]) > remote_materials_by_guid[guid]["version"]: + materials_to_send.add(material["id"]) + continue + except KeyError: + Logger.log("e", "Current material storage on printer was an invalid reply (missing version).") + return + + for file_path in Resources.getAllResourcesOfType(CuraApplication.ResourceTypes.MaterialInstanceContainer): + try: + mime_type = MimeTypeDatabase.getMimeTypeForFile(file_path) + except MimeTypeDatabase.MimeTypeNotFoundError: + continue #Not the sort of file we'd like to send then. + _, file_name = os.path.split(file_path) + material_id = urllib.parse.unquote_plus(mime_type.stripExtension(file_name)) + if material_id not in materials_to_send: + continue + + parts = [] + with open(file_path, "rb") as f: + parts.append(self.device.createFormPart("name=\"file\"; filename=\"{file_name}\"".format(file_name = file_name), f.read())) + signature_file_path = file_path + ".sig" + if os.path.exists(signature_file_path): + _, signature_file_name = os.path.split(signature_file_path) + with open(signature_file_path, "rb") as f: + parts.append(self.device.createFormPart("name=\"signature_file\"; filename=\"{file_name}\"".format(file_name = signature_file_name), f.read())) + + Logger.log("d", "Syncing material {material_id} with cluster.".format(material_id = material_id)) + self.device.postFormWithParts(target = "materials/", parts = parts, onFinished = self.sendingFinished) + + def sendingFinished(self, reply: QNetworkReply): + if reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) != 200: + Logger.log("e", "Received error code from printer when syncing material: {code}".format(code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute))) + Logger.log("e", reply.readAll().data().decode("utf-8")) \ No newline at end of file diff --git a/plugins/XmlMaterialProfile/XmlMaterialProfile.py b/plugins/XmlMaterialProfile/XmlMaterialProfile.py index eafb504deb..eac6646197 100644 --- a/plugins/XmlMaterialProfile/XmlMaterialProfile.py +++ b/plugins/XmlMaterialProfile/XmlMaterialProfile.py @@ -333,9 +333,9 @@ class XmlMaterialProfile(InstanceContainer): stream = io.BytesIO() tree = ET.ElementTree(root) # this makes sure that the XML header states encoding="utf-8" - tree.write(stream, encoding="utf-8", xml_declaration=True) + tree.write(stream, encoding = "utf-8", xml_declaration=True) - return stream.getvalue().decode('utf-8') + return stream.getvalue().decode("utf-8") # Recursively resolve loading inherited files def _resolveInheritance(self, file_name): @@ -351,7 +351,7 @@ class XmlMaterialProfile(InstanceContainer): def _loadFile(self, file_name): path = Resources.getPath(CuraApplication.getInstance().ResourceTypes.MaterialInstanceContainer, file_name + ".xml.fdm_material") - with open(path, encoding="utf-8") as f: + with open(path, encoding = "utf-8") as f: contents = f.read() self._inherited_files.append(path) @@ -565,7 +565,16 @@ class XmlMaterialProfile(InstanceContainer): for entry in settings: key = entry.get("key") if key in self.__material_settings_setting_map: - common_setting_values[self.__material_settings_setting_map[key]] = entry.text + if key == "processing temperature graph": #This setting has no setting text but subtags. + graph_nodes = entry.iterfind("./um:point", self.__namespaces) + graph_points = [] + for graph_node in graph_nodes: + flow = float(graph_node.get("flow")) + temperature = float(graph_node.get("temperature")) + graph_points.append([flow, temperature]) + common_setting_values[self.__material_settings_setting_map[key]] = str(graph_points) + else: + common_setting_values[self.__material_settings_setting_map[key]] = entry.text elif key in self.__unmapped_settings: if key == "hardware compatible": common_compatibility = self._parseCompatibleValue(entry.text) @@ -598,7 +607,16 @@ class XmlMaterialProfile(InstanceContainer): for entry in settings: key = entry.get("key") if key in self.__material_settings_setting_map: - machine_setting_values[self.__material_settings_setting_map[key]] = entry.text + if key == "processing temperature graph": #This setting has no setting text but subtags. + graph_nodes = entry.iterfind("./um:point", self.__namespaces) + graph_points = [] + for graph_node in graph_nodes: + flow = float(graph_node.get("flow")) + temperature = float(graph_node.get("temperature")) + graph_points.append([flow, temperature]) + machine_setting_values[self.__material_settings_setting_map[key]] = str(graph_points) + else: + machine_setting_values[self.__material_settings_setting_map[key]] = entry.text elif key in self.__unmapped_settings: if key == "hardware compatible": machine_compatibility = self._parseCompatibleValue(entry.text) @@ -716,7 +734,16 @@ class XmlMaterialProfile(InstanceContainer): for entry in settings: key = entry.get("key") if key in self.__material_settings_setting_map: - hotend_setting_values[self.__material_settings_setting_map[key]] = entry.text + if key == "processing temperature graph": #This setting has no setting text but subtags. + graph_nodes = entry.iterfind("./um:point", self.__namespaces) + graph_points = [] + for graph_node in graph_nodes: + flow = float(graph_node.get("flow")) + temperature = float(graph_node.get("temperature")) + graph_points.append([flow, temperature]) + hotend_setting_values[self.__material_settings_setting_map[key]] = str(graph_points) + else: + hotend_setting_values[self.__material_settings_setting_map[key]] = entry.text elif key in self.__unmapped_settings: if key == "hardware compatible": hotend_compatibility = self._parseCompatibleValue(entry.text) @@ -965,9 +992,21 @@ class XmlMaterialProfile(InstanceContainer): def _addSettingElement(self, builder, instance): key = instance.definition.key if key in self.__material_settings_setting_map.values(): - # Setting has a key in the stabndard namespace + # Setting has a key in the standard namespace key = UM.Dictionary.findKey(self.__material_settings_setting_map, instance.definition.key) tag_name = "setting" + + if key == "processing temperature graph": #The Processing Temperature Graph has its own little structure that we need to implement separately. + builder.start(tag_name, {"key": key}) + graph_str = str(instance.value) + graph = graph_str.replace("[", "").replace("]", "").split(", ") #Crude parsing of this list: Flatten the list by removing all brackets, then split on ", ". Safe to eval attacks though! + graph = [graph[i:i + 2] for i in range(0, len(graph) - 1, 2)] #Convert to 2D array. + for point in graph: + builder.start("point", {"flow": point[0], "temperature": point[1]}) + builder.end("point") + builder.end(tag_name) + return + elif key not in self.__material_properties_setting_map.values() and key not in self.__material_metadata_setting_map.values(): # Setting is not in the standard namespace, and not a material property (eg diameter) or metadata (eg GUID) tag_name = "cura:setting" diff --git a/plugins/XmlMaterialProfile/XmlMaterialValidator.py b/plugins/XmlMaterialProfile/XmlMaterialValidator.py index f11c8bea4b..a23022854b 100644 --- a/plugins/XmlMaterialProfile/XmlMaterialValidator.py +++ b/plugins/XmlMaterialProfile/XmlMaterialValidator.py @@ -1,12 +1,13 @@ -# Copyright (c) 2017 Ultimaker B.V. +# Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. +from typing import Any, Dict - -class XmlMaterialValidator(): - +## Makes sure that the required metadata is present for a material. +class XmlMaterialValidator: + ## Makes sure that the required metadata is present for a material. @classmethod - def validateMaterialMetaData(cls, validation_metadata): + def validateMaterialMetaData(cls, validation_metadata: Dict[str, Any]): if validation_metadata.get("GUID") is None: return "Missing GUID"