Merge pull request #6276 from Ultimaker/network-plugin-material-sync-improvements

Network plugin material and configuration sync improvements
This commit is contained in:
ChrisTerBeke 2019-08-28 23:32:36 +02:00 committed by GitHub
commit 243d51eb23
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 114 additions and 338 deletions

View File

@ -2,7 +2,7 @@
"name": "Ultimaker Network Connection", "name": "Ultimaker Network Connection",
"author": "Ultimaker B.V.", "author": "Ultimaker B.V.",
"description": "Manages network connections to Ultimaker networked printers.", "description": "Manages network connections to Ultimaker networked printers.",
"version": "1.0.1", "version": "2.0.0",
"api": "6.0", "api": "6.0",
"i18n-catalog": "cura" "i18n-catalog": "cura"
} }

View File

@ -0,0 +1,39 @@
# Copyright (c) 2019 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import TYPE_CHECKING
from UM import i18nCatalog
from UM.Message import Message
if TYPE_CHECKING:
from ..UltimakerNetworkedPrinterOutputDevice import UltimakerNetworkedPrinterOutputDevice
I18N_CATALOG = i18nCatalog("cura")
## Message shown when sending material files to cluster host.
class MaterialSyncMessage(Message):
# Singleton used to prevent duplicate messages of this type at the same time.
__is_visible = False
def __init__(self, device: "UltimakerNetworkedPrinterOutputDevice") -> None:
super().__init__(
text = I18N_CATALOG.i18nc("@info:status", "Cura has detected material profiles that were not yet installed "
"on the host printer of group {0}.", device.name),
title = I18N_CATALOG.i18nc("@info:title", "Sending materials to printer"),
lifetime = 10,
dismissable = True
)
def show(self) -> None:
if MaterialSyncMessage.__is_visible:
return
super().show()
MaterialSyncMessage.__is_visible = True
def hide(self, send_signal = True) -> None:
super().hide(send_signal)
MaterialSyncMessage.__is_visible = False

View File

@ -1,6 +1,6 @@
# Copyright (c) 2019 Ultimaker B.V. # Copyright (c) 2019 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 .BaseModel import BaseModel from ..BaseModel import BaseModel
class ClusterMaterial(BaseModel): class ClusterMaterial(BaseModel):

View File

@ -14,7 +14,7 @@ class ClusterPrinterMaterialStation(BaseModel):
# \param: supported: Whether the material station is supported on this machine or not. # \param: supported: Whether the material station is supported on this machine or not.
# \param material_slots: The active slots configurations of this material station. # \param material_slots: The active slots configurations of this material station.
def __init__(self, status: str, supported: bool = False, def __init__(self, status: str, supported: bool = False,
material_slots: Union[None, Dict[str, Any], ClusterPrinterMaterialStationSlot] = None, material_slots: List[Union[ClusterPrinterMaterialStationSlot, Dict[str, Any]]] = None,
**kwargs) -> None: **kwargs) -> None:
self.status = status self.status = status
self.supported = supported self.supported = supported

View File

@ -13,6 +13,7 @@ from .ClusterBuildPlate import ClusterBuildPlate
from .ClusterPrintCoreConfiguration import ClusterPrintCoreConfiguration from .ClusterPrintCoreConfiguration import ClusterPrintCoreConfiguration
from .ClusterPrinterMaterialStation import ClusterPrinterMaterialStation from .ClusterPrinterMaterialStation import ClusterPrinterMaterialStation
from .ClusterPrinterMaterialStationSlot import ClusterPrinterMaterialStationSlot from .ClusterPrinterMaterialStationSlot import ClusterPrinterMaterialStationSlot
from .ClusterPrinterConfigurationMaterial import ClusterPrinterConfigurationMaterial
from ..BaseModel import BaseModel from ..BaseModel import BaseModel
@ -80,9 +81,9 @@ class ClusterPrinterStatus(BaseModel):
model.setCameraUrl(QUrl("http://{}:8080/?action=stream".format(self.ip_address))) model.setCameraUrl(QUrl("http://{}:8080/?action=stream".format(self.ip_address)))
# Set the possible configurations based on whether a Material Station is present or not. # Set the possible configurations based on whether a Material Station is present or not.
if self.material_station is not None and len(self.material_station.material_slots): if self.material_station and self.material_station.material_slots:
self._updateAvailableConfigurations(model) self._updateAvailableConfigurations(model)
if self.configuration is not None: if self.configuration:
self._updateActiveConfiguration(model) self._updateActiveConfiguration(model)
def _updateActiveConfiguration(self, model: PrinterOutputModel) -> None: def _updateActiveConfiguration(self, model: PrinterOutputModel) -> None:
@ -92,31 +93,36 @@ class ClusterPrinterStatus(BaseModel):
configuration.updateConfigurationModel(extruder_config) configuration.updateConfigurationModel(extruder_config)
def _updateAvailableConfigurations(self, model: PrinterOutputModel) -> None: def _updateAvailableConfigurations(self, model: PrinterOutputModel) -> None:
# Generate a list of configurations for the left extruder.
left_configurations = [slot for slot in self.material_station.material_slots if self._isSupportedConfiguration(
slot = slot,
extruder_index = 0
)]
# Generate a list of configurations for the right extruder.
right_configurations = [slot for slot in self.material_station.material_slots if self._isSupportedConfiguration(
slot = slot,
extruder_index = 1
)]
# Create a list of all available combinations between both print cores.
available_configurations = [self._createAvailableConfigurationFromPrinterConfiguration( available_configurations = [self._createAvailableConfigurationFromPrinterConfiguration(
left_slot = left_slot, left_slot = left_slot,
right_slot = right_slot, right_slot = right_slot,
printer_configuration = model.printerConfiguration printer_configuration = model.printerConfiguration
) for left_slot, right_slot in product(left_configurations, right_configurations)] ) for left_slot, right_slot in product(self._getSlotsForExtruder(0), self._getSlotsForExtruder(1))]
# Let Cura know which available configurations there are.
model.setAvailableConfigurations(available_configurations) model.setAvailableConfigurations(available_configurations)
## Create a list of Material Station slots for the given extruder index.
# Returns a list with a single empty material slot if none are found to ensure we don't miss configurations.
def _getSlotsForExtruder(self, extruder_index: int) -> List[ClusterPrinterMaterialStationSlot]:
if not self.material_station: # typing guard
return []
slots = [slot for slot in self.material_station.material_slots if self._isSupportedConfiguration(
slot = slot,
extruder_index = extruder_index
)]
return slots or [self._createEmptyMaterialSlot(extruder_index)]
## Check if a configuration is supported in order to make it selectable by the user. ## Check if a configuration is supported in order to make it selectable by the user.
# We filter out any slot that is not supported by the extruder index, print core type or if the material is empty. # We filter out any slot that is not supported by the extruder index, print core type or if the material is empty.
@staticmethod @staticmethod
def _isSupportedConfiguration(slot: ClusterPrinterMaterialStationSlot, extruder_index: int) -> bool: def _isSupportedConfiguration(slot: ClusterPrinterMaterialStationSlot, extruder_index: int) -> bool:
return slot.extruder_index == extruder_index and slot.compatible and slot.material and \ return slot.extruder_index == extruder_index and slot.compatible
slot.material_remaining != 0
## Create an empty material slot with a fake empty material.
@staticmethod
def _createEmptyMaterialSlot(extruder_index: int) -> ClusterPrinterMaterialStationSlot:
empty_material = ClusterPrinterConfigurationMaterial(guid = "", material = "empty", brand = "", color = "")
return ClusterPrinterMaterialStationSlot(slot_index = 0, extruder_index = extruder_index,
compatible = True, material_remaining = 0, material = empty_material)
@staticmethod @staticmethod
def _createAvailableConfigurationFromPrinterConfiguration(left_slot: ClusterPrinterMaterialStationSlot, def _createAvailableConfigurationFromPrinterConfiguration(left_slot: ClusterPrinterMaterialStationSlot,

View File

@ -13,6 +13,7 @@ from ..Models.BaseModel import BaseModel
from ..Models.Http.ClusterPrintJobStatus import ClusterPrintJobStatus from ..Models.Http.ClusterPrintJobStatus import ClusterPrintJobStatus
from ..Models.Http.ClusterPrinterStatus import ClusterPrinterStatus from ..Models.Http.ClusterPrinterStatus import ClusterPrinterStatus
from ..Models.Http.PrinterSystemStatus import PrinterSystemStatus from ..Models.Http.PrinterSystemStatus import PrinterSystemStatus
from ..Models.Http.ClusterMaterial import ClusterMaterial
## The generic type variable used to document the methods below. ## The generic type variable used to document the methods below.
@ -44,6 +45,13 @@ class ClusterApiClient:
reply = self._manager.get(self._createEmptyRequest(url)) reply = self._manager.get(self._createEmptyRequest(url))
self._addCallback(reply, on_finished, PrinterSystemStatus) self._addCallback(reply, on_finished, PrinterSystemStatus)
## Get the installed materials on the printer.
# \param on_finished: The callback in case the response is successful.
def getMaterials(self, on_finished: Callable[[List[ClusterMaterial]], Any]) -> None:
url = "{}/materials".format(self.CLUSTER_API_PREFIX)
reply = self._manager.get(self._createEmptyRequest(url))
self._addCallback(reply, on_finished, ClusterMaterial)
## Get the printers in the cluster. ## Get the printers in the cluster.
# \param on_finished: The callback in case the response is successful. # \param on_finished: The callback in case the response is successful.
def getPrinters(self, on_finished: Callable[[List[ClusterPrinterStatus]], Any]) -> None: def getPrinters(self, on_finished: Callable[[List[ClusterPrinterStatus]], Any]) -> None:

View File

@ -1,6 +1,6 @@
# Copyright (c) 2019 Ultimaker B.V. # Copyright (c) 2019 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 Optional, Dict, List from typing import Optional, Dict, List, Callable, Any
from PyQt5.QtGui import QDesktopServices from PyQt5.QtGui import QDesktopServices
from PyQt5.QtCore import pyqtSlot, QUrl, pyqtSignal, pyqtProperty from PyQt5.QtCore import pyqtSlot, QUrl, pyqtSignal, pyqtProperty
@ -13,12 +13,13 @@ from cura.PrinterOutput.NetworkedPrinterOutputDevice import AuthState
from cura.PrinterOutput.PrinterOutputDevice import ConnectionType from cura.PrinterOutput.PrinterOutputDevice import ConnectionType
from .ClusterApiClient import ClusterApiClient from .ClusterApiClient import ClusterApiClient
from .SendMaterialJob import SendMaterialJob
from ..ExportFileJob import ExportFileJob from ..ExportFileJob import ExportFileJob
from ..SendMaterialJob import SendMaterialJob
from ..UltimakerNetworkedPrinterOutputDevice import UltimakerNetworkedPrinterOutputDevice from ..UltimakerNetworkedPrinterOutputDevice import UltimakerNetworkedPrinterOutputDevice
from ..Messages.PrintJobUploadBlockedMessage import PrintJobUploadBlockedMessage from ..Messages.PrintJobUploadBlockedMessage import PrintJobUploadBlockedMessage
from ..Messages.PrintJobUploadErrorMessage import PrintJobUploadErrorMessage from ..Messages.PrintJobUploadErrorMessage import PrintJobUploadErrorMessage
from ..Messages.PrintJobUploadSuccessMessage import PrintJobUploadSuccessMessage from ..Messages.PrintJobUploadSuccessMessage import PrintJobUploadSuccessMessage
from ..Models.Http.ClusterMaterial import ClusterMaterial
I18N_CATALOG = i18nCatalog("cura") I18N_CATALOG = i18nCatalog("cura")
@ -100,10 +101,14 @@ class LocalClusterOutputDevice(UltimakerNetworkedPrinterOutputDevice):
self._getApiClient().getPrintJobs(self._updatePrintJobs) self._getApiClient().getPrintJobs(self._updatePrintJobs)
self._updatePrintJobPreviewImages() self._updatePrintJobPreviewImages()
## Get a list of materials that are installed on the cluster host.
def getMaterials(self, on_finished: Callable[[List[ClusterMaterial]], Any]) -> None:
self._getApiClient().getMaterials(on_finished = on_finished)
## Sync the material profiles in Cura with the printer. ## Sync the material profiles in Cura with the printer.
# This gets called when connecting to a printer as well as when sending a print. # This gets called when connecting to a printer as well as when sending a print.
def sendMaterialProfiles(self) -> None: def sendMaterialProfiles(self) -> None:
job = SendMaterialJob(device=self) job = SendMaterialJob(device = self)
job.run() job.run()
## Send a print job to the cluster. ## Send a print job to the cluster.
@ -133,6 +138,7 @@ class LocalClusterOutputDevice(UltimakerNetworkedPrinterOutputDevice):
self._createFormPart("name=owner", bytes(self._getUserName(), "utf-8"), "text/plain"), self._createFormPart("name=owner", bytes(self._getUserName(), "utf-8"), "text/plain"),
self._createFormPart("name=\"file\"; filename=\"%s\"" % job.getFileName(), job.getOutput()) self._createFormPart("name=\"file\"; filename=\"%s\"" % job.getFileName(), job.getOutput())
] ]
# FIXME: move form posting to API client
self.postFormWithParts("/cluster-api/v1/print_jobs/", parts, on_finished=self._onPrintUploadCompleted, self.postFormWithParts("/cluster-api/v1/print_jobs/", parts, on_finished=self._onPrintUploadCompleted,
on_progress=self._onPrintJobUploadProgress) on_progress=self._onPrintJobUploadProgress)

View File

@ -1,19 +1,19 @@
# Copyright (c) 2019 Ultimaker B.V. # Copyright (c) 2019 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
import os import os
from typing import Dict, TYPE_CHECKING, Set, Optional from typing import Dict, TYPE_CHECKING, Set, List
from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest
from UM.Job import Job from UM.Job import Job
from UM.Logger import Logger from UM.Logger import Logger
from cura.CuraApplication import CuraApplication from cura.CuraApplication import CuraApplication
from .Models.ClusterMaterial import ClusterMaterial from ..Models.Http.ClusterMaterial import ClusterMaterial
from .Models.LocalMaterial import LocalMaterial from ..Models.LocalMaterial import LocalMaterial
from ..Messages.MaterialSyncMessage import MaterialSyncMessage
if TYPE_CHECKING: if TYPE_CHECKING:
from .Network.LocalClusterOutputDevice import LocalClusterOutputDevice from .LocalClusterOutputDevice import LocalClusterOutputDevice
## Asynchronous job to send material profiles to the printer. ## Asynchronous job to send material profiles to the printer.
@ -27,62 +27,43 @@ class SendMaterialJob(Job):
## Send the request to the printer and register a callback ## Send the request to the printer and register a callback
def run(self) -> None: def run(self) -> None:
self.device.get("materials/", on_finished = self._onGetRemoteMaterials) self.device.getMaterials(on_finished = self._onGetMaterials)
## Process the materials reply from the printer. ## Callback for when the remote materials were returned.
# def _onGetMaterials(self, materials: List[ClusterMaterial]) -> None:
# \param reply The reply from the printer, a json file. remote_materials_by_guid = {material.guid: material for material in materials}
def _onGetRemoteMaterials(self, reply: QNetworkReply) -> None:
# Got an error from the HTTP request. If we did not receive a 200 something happened.
if reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) != 200:
Logger.log("e", "Error fetching materials from printer: %s", reply.errorString())
return
# Collect materials from the printer's reply and send the missing ones if needed.
remote_materials_by_guid = self._parseReply(reply)
if remote_materials_by_guid:
self._sendMissingMaterials(remote_materials_by_guid) self._sendMissingMaterials(remote_materials_by_guid)
## Determine which materials should be updated and send them to the printer. ## Determine which materials should be updated and send them to the printer.
#
# \param remote_materials_by_guid The remote materials by GUID. # \param remote_materials_by_guid The remote materials by GUID.
def _sendMissingMaterials(self, remote_materials_by_guid: Dict[str, ClusterMaterial]) -> None: def _sendMissingMaterials(self, remote_materials_by_guid: Dict[str, ClusterMaterial]) -> None:
# Collect local materials
local_materials_by_guid = self._getLocalMaterials() local_materials_by_guid = self._getLocalMaterials()
if len(local_materials_by_guid) == 0: if len(local_materials_by_guid) == 0:
Logger.log("d", "There are no local materials to synchronize with the printer.") Logger.log("d", "There are no local materials to synchronize with the printer.")
return return
# Find out what materials are new or updated and must be sent to the printer
material_ids_to_send = self._determineMaterialsToSend(local_materials_by_guid, remote_materials_by_guid) material_ids_to_send = self._determineMaterialsToSend(local_materials_by_guid, remote_materials_by_guid)
if len(material_ids_to_send) == 0: if len(material_ids_to_send) == 0:
Logger.log("d", "There are no remote materials to update.") Logger.log("d", "There are no remote materials to update.")
return return
# Send materials to the printer
self._sendMaterials(material_ids_to_send) self._sendMaterials(material_ids_to_send)
## From the local and remote materials, determine which ones should be synchronized. ## From the local and remote materials, determine which ones should be synchronized.
#
# Makes a Set of id's containing only the id's of the materials that are not on the printer yet or the ones that # Makes a Set of id's containing only the id's of the materials that are not on the printer yet or the ones that
# are newer in Cura. # are newer in Cura.
#
# \param local_materials The local materials by GUID. # \param local_materials The local materials by GUID.
# \param remote_materials The remote materials by GUID. # \param remote_materials The remote materials by GUID.
@staticmethod @staticmethod
def _determineMaterialsToSend(local_materials: Dict[str, LocalMaterial], def _determineMaterialsToSend(local_materials: Dict[str, LocalMaterial],
remote_materials: Dict[str, ClusterMaterial]) -> Set[str]: remote_materials: Dict[str, ClusterMaterial]) -> Set[str]:
return { return {
material.id local_material.id
for guid, material in local_materials.items() for guid, local_material in local_materials.items()
if guid not in remote_materials or material.version > remote_materials[guid].version if guid not in remote_materials.keys() or local_material.version > remote_materials[guid].version
} }
## Send the materials to the printer. ## Send the materials to the printer.
#
# The given materials will be loaded from disk en sent to to printer. # The given materials will be loaded from disk en sent to to printer.
# The given id's will be matched with filenames of the locally stored materials. # The given id's will be matched with filenames of the locally stored materials.
#
# \param materials_to_send A set with id's of materials that must be sent. # \param materials_to_send A set with id's of materials that must be sent.
def _sendMaterials(self, materials_to_send: Set[str]) -> None: def _sendMaterials(self, materials_to_send: Set[str]) -> None:
container_registry = CuraApplication.getInstance().getContainerRegistry() container_registry = CuraApplication.getInstance().getContainerRegistry()
@ -103,9 +84,7 @@ class SendMaterialJob(Job):
self._sendMaterialFile(file_path, file_name, root_material_id) self._sendMaterialFile(file_path, file_name, root_material_id)
## Send a single material file to the printer. ## Send a single material file to the printer.
#
# Also add the material signature file if that is available. # Also add the material signature file if that is available.
#
# \param file_path The path of the material file. # \param file_path The path of the material file.
# \param file_name The name of the material file. # \param file_name The name of the material file.
# \param material_id The ID of the material in the file. # \param material_id The ID of the material in the file.
@ -125,48 +104,32 @@ class SendMaterialJob(Job):
parts.append(self.device.createFormPart("name=\"signature_file\"; filename=\"{file_name}\"" parts.append(self.device.createFormPart("name=\"signature_file\"; filename=\"{file_name}\""
.format(file_name = signature_file_name), f.read())) .format(file_name = signature_file_name), f.read()))
Logger.log("d", "Syncing material {material_id} with cluster.".format(material_id = material_id)) Logger.log("d", "Syncing material %s with cluster.", material_id)
self.device.postFormWithParts(target = "/materials/", parts = parts, on_finished = self.sendingFinished) # FIXME: move form posting to API client
self.device.postFormWithParts(target = "/cluster-api/v1/materials/", parts = parts,
on_finished = self._sendingFinished)
## Check a reply from an upload to the printer and log an error when the call failed ## Check a reply from an upload to the printer and log an error when the call failed
@staticmethod def _sendingFinished(self, reply: QNetworkReply) -> None:
def sendingFinished(reply: QNetworkReply) -> None:
if reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) != 200: if reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) != 200:
Logger.log("e", "Received error code from printer when syncing material: {code}, {text}".format( Logger.log("w", "Error while syncing material: %s", reply.errorString())
code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute), return
text = reply.errorString() body = reply.readAll().data().decode('utf8')
)) if "not added" in body:
# For some reason the cluster returns a 200 sometimes even when syncing failed.
## Parse the reply from the printer Logger.log("w", "Error while syncing material: %s", body)
# return
# Parses the reply to a "/materials" request to the printer # Inform the user that materials have been synced. This message only shows itself when not already visible.
# # Because of the guards above it is not shown when syncing failed (which is not always an actual problem).
# \return a dictionary of ClusterMaterial objects by GUID MaterialSyncMessage(self.device).show()
# \throw KeyError Raised when on of the materials does not include a valid guid
@classmethod
def _parseReply(cls, reply: QNetworkReply) -> Optional[Dict[str, ClusterMaterial]]:
try:
remote_materials = json.loads(reply.readAll().data().decode("utf-8"))
return {material["guid"]: ClusterMaterial(**material) for material in remote_materials}
except UnicodeDecodeError:
Logger.log("e", "Request material storage on printer: I didn't understand the printer's answer.")
except json.JSONDecodeError:
Logger.log("e", "Request material storage on printer: I didn't understand the printer's answer.")
except ValueError:
Logger.log("e", "Request material storage on printer: Printer's answer had an incorrect value.")
except TypeError:
Logger.log("e", "Request material storage on printer: Printer's answer was missing a required value.")
return None
## Retrieves a list of local materials ## Retrieves a list of local materials
#
# Only the new newest version of the local materials is returned # Only the new newest version of the local materials is returned
#
# \return a dictionary of LocalMaterial objects by GUID # \return a dictionary of LocalMaterial objects by GUID
def _getLocalMaterials(self) -> Dict[str, LocalMaterial]: @staticmethod
def _getLocalMaterials() -> Dict[str, LocalMaterial]:
result = {} # type: Dict[str, LocalMaterial] result = {} # type: Dict[str, LocalMaterial]
material_manager = CuraApplication.getInstance().getMaterialManager() material_manager = CuraApplication.getInstance().getMaterialManager()
material_group_dict = material_manager.getAllMaterialGroups() material_group_dict = material_manager.getAllMaterialGroups()
# Find the latest version of all material containers in the registry. # Find the latest version of all material containers in the registry.

View File

@ -1,244 +0,0 @@
# Copyright (c) 2019 Ultimaker B.V.
# Copyright (c) 2019 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import io
import json
from unittest import TestCase, mock
from unittest.mock import patch, call, MagicMock
from PyQt5.QtCore import QByteArray
from UM.Application import Application
from cura.Machines.MaterialGroup import MaterialGroup
from cura.Machines.MaterialNode import MaterialNode
from ..src.SendMaterialJob import SendMaterialJob
_FILES_MAP = {"generic_pla_white": "/materials/generic_pla_white.xml.fdm_material",
"generic_pla_black": "/materials/generic_pla_black.xml.fdm_material",
}
@patch("builtins.open", lambda _, __: io.StringIO("<xml></xml>"))
class TestSendMaterialJob(TestCase):
# version 1
_LOCAL_MATERIAL_WHITE = {"type": "material", "status": "unknown", "id": "generic_pla_white",
"base_file": "generic_pla_white", "setting_version": "5", "name": "White PLA",
"brand": "Generic", "material": "PLA", "color_name": "White",
"GUID": "badb0ee7-87c8-4f3f-9398-938587b67dce", "version": "1", "color_code": "#ffffff",
"description": "Test PLA White", "adhesion_info": "Use glue.", "approximate_diameter": "3",
"properties": {"density": "1.00", "diameter": "2.85", "weight": "750"},
"definition": "fdmprinter", "compatible": True}
# version 2
_LOCAL_MATERIAL_WHITE_NEWER = {"type": "material", "status": "unknown", "id": "generic_pla_white",
"base_file": "generic_pla_white", "setting_version": "5", "name": "White PLA",
"brand": "Generic", "material": "PLA", "color_name": "White",
"GUID": "badb0ee7-87c8-4f3f-9398-938587b67dce", "version": "2",
"color_code": "#ffffff",
"description": "Test PLA White", "adhesion_info": "Use glue.",
"approximate_diameter": "3",
"properties": {"density": "1.00", "diameter": "2.85", "weight": "750"},
"definition": "fdmprinter", "compatible": True}
# invalid version: "one"
_LOCAL_MATERIAL_WHITE_INVALID_VERSION = {"type": "material", "status": "unknown", "id": "generic_pla_white",
"base_file": "generic_pla_white", "setting_version": "5", "name": "White PLA",
"brand": "Generic", "material": "PLA", "color_name": "White",
"GUID": "badb0ee7-87c8-4f3f-9398-938587b67dce", "version": "one",
"color_code": "#ffffff",
"description": "Test PLA White", "adhesion_info": "Use glue.",
"approximate_diameter": "3",
"properties": {"density": "1.00", "diameter": "2.85", "weight": "750"},
"definition": "fdmprinter", "compatible": True}
_LOCAL_MATERIAL_WHITE_ALL_RESULT = {"generic_pla_white": MaterialGroup("generic_pla_white",
MaterialNode(_LOCAL_MATERIAL_WHITE))}
_LOCAL_MATERIAL_WHITE_NEWER_ALL_RESULT = {"generic_pla_white": MaterialGroup("generic_pla_white",
MaterialNode(_LOCAL_MATERIAL_WHITE_NEWER))}
_LOCAL_MATERIAL_WHITE_INVALID_VERSION_ALL_RESULT = {"generic_pla_white": MaterialGroup("generic_pla_white",
MaterialNode(_LOCAL_MATERIAL_WHITE_INVALID_VERSION))}
_LOCAL_MATERIAL_BLACK = {"type": "material", "status": "unknown", "id": "generic_pla_black",
"base_file": "generic_pla_black", "setting_version": "5", "name": "Yellow CPE",
"brand": "Ultimaker", "material": "CPE", "color_name": "Black",
"GUID": "5fbb362a-41f9-4818-bb43-15ea6df34aa4", "version": "1", "color_code": "#000000",
"description": "Test PLA Black", "adhesion_info": "Use glue.", "approximate_diameter": "3",
"properties": {"density": "1.01", "diameter": "2.85", "weight": "750"},
"definition": "fdmprinter", "compatible": True}
_LOCAL_MATERIAL_BLACK_ALL_RESULT = {"generic_pla_black": MaterialGroup("generic_pla_black",
MaterialNode(_LOCAL_MATERIAL_BLACK))}
_REMOTE_MATERIAL_WHITE = {
"guid": "badb0ee7-87c8-4f3f-9398-938587b67dce",
"material": "PLA",
"brand": "Generic",
"version": 1,
"color": "White",
"density": 1.00
}
_REMOTE_MATERIAL_BLACK = {
"guid": "5fbb362a-41f9-4818-bb43-15ea6df34aa4",
"material": "PLA",
"brand": "Generic",
"version": 2,
"color": "Black",
"density": 1.00
}
def test_run(self):
device_mock = MagicMock()
job = SendMaterialJob(device_mock)
job.run()
# We expect the materials endpoint to be called when the job runs.
device_mock.get.assert_called_with("materials/", on_finished = job._onGetRemoteMaterials)
def test__onGetRemoteMaterials_withFailedRequest(self):
reply_mock = MagicMock()
device_mock = MagicMock()
reply_mock.attribute.return_value = 404
job = SendMaterialJob(device_mock)
job._onGetRemoteMaterials(reply_mock)
# We expect the device not to be called for any follow up.
self.assertEqual(0, device_mock.createFormPart.call_count)
def test__onGetRemoteMaterials_withWrongEncoding(self):
reply_mock = MagicMock()
device_mock = MagicMock()
reply_mock.attribute.return_value = 200
reply_mock.readAll.return_value = QByteArray(json.dumps([self._REMOTE_MATERIAL_WHITE]).encode("cp500"))
job = SendMaterialJob(device_mock)
job._onGetRemoteMaterials(reply_mock)
# Given that the parsing fails we do no expect the device to be called for any follow up.
self.assertEqual(0, device_mock.createFormPart.call_count)
def test__onGetRemoteMaterials_withBadJsonAnswer(self):
reply_mock = MagicMock()
device_mock = MagicMock()
reply_mock.attribute.return_value = 200
reply_mock.readAll.return_value = QByteArray(b"Six sick hicks nick six slick bricks with picks and sticks.")
job = SendMaterialJob(device_mock)
job._onGetRemoteMaterials(reply_mock)
# Given that the parsing fails we do no expect the device to be called for any follow up.
self.assertEqual(0, device_mock.createFormPart.call_count)
def test__onGetRemoteMaterials_withMissingGuidInRemoteMaterial(self):
reply_mock = MagicMock()
device_mock = MagicMock()
reply_mock.attribute.return_value = 200
remote_material_without_guid = self._REMOTE_MATERIAL_WHITE.copy()
del remote_material_without_guid["guid"]
reply_mock.readAll.return_value = QByteArray(json.dumps([remote_material_without_guid]).encode("ascii"))
job = SendMaterialJob(device_mock)
job._onGetRemoteMaterials(reply_mock)
# Given that parsing fails we do not expect the device to be called for any follow up.
self.assertEqual(0, device_mock.createFormPart.call_count)
@patch("cura.Machines.MaterialManager.MaterialManager")
@patch("cura.Settings.CuraContainerRegistry")
@patch("UM.Application")
def test__onGetRemoteMaterials_withInvalidVersionInLocalMaterial(self, application_mock, container_registry_mock,
material_manager_mock):
reply_mock = MagicMock()
device_mock = MagicMock()
application_mock.getContainerRegistry.return_value = container_registry_mock
application_mock.getMaterialManager.return_value = material_manager_mock
reply_mock.attribute.return_value = 200
reply_mock.readAll.return_value = QByteArray(json.dumps([self._REMOTE_MATERIAL_WHITE]).encode("ascii"))
material_manager_mock.getAllMaterialGroups.return_value = self._LOCAL_MATERIAL_WHITE_INVALID_VERSION_ALL_RESULT.copy()
with mock.patch.object(Application, "getInstance", new = lambda: application_mock):
job = SendMaterialJob(device_mock)
job._onGetRemoteMaterials(reply_mock)
self.assertEqual(0, device_mock.createFormPart.call_count)
@patch("UM.Application.Application.getInstance")
def test__onGetRemoteMaterials_withNoUpdate(self, application_mock):
reply_mock = MagicMock()
device_mock = MagicMock()
container_registry_mock = application_mock.getContainerRegistry.return_value
material_manager_mock = application_mock.getMaterialManager.return_value
device_mock.createFormPart.return_value = "_xXx_"
material_manager_mock.getAllMaterialGroups.return_value = self._LOCAL_MATERIAL_WHITE_ALL_RESULT.copy()
reply_mock.attribute.return_value = 200
reply_mock.readAll.return_value = QByteArray(json.dumps([self._REMOTE_MATERIAL_WHITE]).encode("ascii"))
with mock.patch.object(Application, "getInstance", new = lambda: application_mock):
job = SendMaterialJob(device_mock)
job._onGetRemoteMaterials(reply_mock)
self.assertEqual(0, device_mock.createFormPart.call_count)
self.assertEqual(0, device_mock.postFormWithParts.call_count)
@patch("UM.Application.Application.getInstance")
def test__onGetRemoteMaterials_withUpdatedMaterial(self, get_instance_mock):
reply_mock = MagicMock()
device_mock = MagicMock()
application_mock = get_instance_mock.return_value
container_registry_mock = application_mock.getContainerRegistry.return_value
material_manager_mock = application_mock.getMaterialManager.return_value
container_registry_mock.getContainerFilePathById = lambda x: _FILES_MAP.get(x)
device_mock.createFormPart.return_value = "_xXx_"
material_manager_mock.getAllMaterialGroups.return_value = self._LOCAL_MATERIAL_WHITE_NEWER_ALL_RESULT.copy()
reply_mock.attribute.return_value = 200
reply_mock.readAll.return_value = QByteArray(json.dumps([self._REMOTE_MATERIAL_WHITE]).encode("ascii"))
job = SendMaterialJob(device_mock)
job._onGetRemoteMaterials(reply_mock)
self.assertEqual(1, device_mock.createFormPart.call_count)
self.assertEqual(1, device_mock.postFormWithParts.call_count)
self.assertEqual(
[call.createFormPart("name=\"file\"; filename=\"generic_pla_white.xml.fdm_material\"", "<xml></xml>"),
call.postFormWithParts(target = "/materials/", parts = ["_xXx_"], on_finished = job.sendingFinished)],
device_mock.method_calls)
@patch("UM.Application.Application.getInstance")
def test__onGetRemoteMaterials_withNewMaterial(self, application_mock):
reply_mock = MagicMock()
device_mock = MagicMock()
container_registry_mock = application_mock.getContainerRegistry.return_value
material_manager_mock = application_mock.getMaterialManager.return_value
container_registry_mock.getContainerFilePathById = lambda x: _FILES_MAP.get(x)
device_mock.createFormPart.return_value = "_xXx_"
all_results = self._LOCAL_MATERIAL_WHITE_ALL_RESULT.copy()
for key, value in self._LOCAL_MATERIAL_BLACK_ALL_RESULT.items():
all_results[key] = value
material_manager_mock.getAllMaterialGroups.return_value = all_results
reply_mock.attribute.return_value = 200
reply_mock.readAll.return_value = QByteArray(json.dumps([self._REMOTE_MATERIAL_BLACK]).encode("ascii"))
with mock.patch.object(Application, "getInstance", new = lambda: application_mock):
job = SendMaterialJob(device_mock)
job._onGetRemoteMaterials(reply_mock)
self.assertEqual(1, device_mock.createFormPart.call_count)
self.assertEqual(1, device_mock.postFormWithParts.call_count)
self.assertEqual(
[call.createFormPart("name=\"file\"; filename=\"generic_pla_white.xml.fdm_material\"", "<xml></xml>"),
call.postFormWithParts(target = "/materials/", parts = ["_xXx_"], on_finished = job.sendingFinished)],
device_mock.method_calls)

View File

@ -1,2 +0,0 @@
# Copyright (c) 2019 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.