Merge pull request #7551 from Ultimaker/doxygen_to_restructuredtext_comments

Converted doxygen style comments to reStructuredText style
This commit is contained in:
Nino van Hooff 2020-05-29 16:46:25 +02:00 committed by GitHub
commit 98587a9008
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
224 changed files with 5521 additions and 3874 deletions

View file

@ -32,8 +32,9 @@ except ImportError:
import xml.etree.ElementTree as ET
## Base implementation for reading 3MF files. Has no support for textures. Only loads meshes!
class ThreeMFReader(MeshReader):
"""Base implementation for reading 3MF files. Has no support for textures. Only loads meshes!"""
def __init__(self) -> None:
super().__init__()
@ -55,13 +56,17 @@ class ThreeMFReader(MeshReader):
return Matrix()
split_transformation = transformation.split()
## Transformation is saved as:
## M00 M01 M02 0.0
## M10 M11 M12 0.0
## M20 M21 M22 0.0
## M30 M31 M32 1.0
## We switch the row & cols as that is how everyone else uses matrices!
temp_mat = Matrix()
"""Transformation is saved as:
M00 M01 M02 0.0
M10 M11 M12 0.0
M20 M21 M22 0.0
M30 M31 M32 1.0
We switch the row & cols as that is how everyone else uses matrices!
"""
# Rotation & Scale
temp_mat._data[0, 0] = split_transformation[0]
temp_mat._data[1, 0] = split_transformation[1]
@ -80,9 +85,11 @@ class ThreeMFReader(MeshReader):
return temp_mat
## Convenience function that converts a SceneNode object (as obtained from libSavitar) to a scene node.
# \returns Scene node.
def _convertSavitarNodeToUMNode(self, savitar_node: Savitar.SceneNode, file_name: str = "") -> Optional[SceneNode]:
"""Convenience function that converts a SceneNode object (as obtained from libSavitar) to a scene node.
:returns: Scene node.
"""
try:
node_name = savitar_node.getName()
node_id = savitar_node.getId()
@ -243,15 +250,17 @@ class ThreeMFReader(MeshReader):
return result
## Create a scale vector based on a unit string.
# The core spec defines the following:
# * micron
# * millimeter (default)
# * centimeter
# * inch
# * foot
# * meter
def _getScaleFromUnit(self, unit: Optional[str]) -> Vector:
"""Create a scale vector based on a unit string.
.. The core spec defines the following:
* micron
* millimeter (default)
* centimeter
* inch
* foot
* meter
"""
conversion_to_mm = {
"micron": 0.001,
"millimeter": 1,

View file

@ -89,8 +89,9 @@ class ExtruderInfo:
self.intent_info = None
## Base implementation for reading 3MF workspace files.
class ThreeMFWorkspaceReader(WorkspaceReader):
"""Base implementation for reading 3MF workspace files."""
def __init__(self) -> None:
super().__init__()
@ -130,18 +131,21 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
self._old_new_materials = {}
self._machine_info = None
## Get a unique name based on the old_id. This is different from directly calling the registry in that it caches results.
# This has nothing to do with speed, but with getting consistent new naming for instances & objects.
def getNewId(self, old_id: str):
"""Get a unique name based on the old_id. This is different from directly calling the registry in that it caches results.
This has nothing to do with speed, but with getting consistent new naming for instances & objects.
"""
if old_id not in self._id_mapping:
self._id_mapping[old_id] = self._container_registry.uniqueName(old_id)
return self._id_mapping[old_id]
## Separates the given file list into a list of GlobalStack files and a list of ExtruderStack files.
#
# In old versions, extruder stack files have the same suffix as container stack files ".stack.cfg".
#
def _determineGlobalAndExtruderStackFiles(self, project_file_name: str, file_list: List[str]) -> Tuple[str, List[str]]:
"""Separates the given file list into a list of GlobalStack files and a list of ExtruderStack files.
In old versions, extruder stack files have the same suffix as container stack files ".stack.cfg".
"""
archive = zipfile.ZipFile(project_file_name, "r")
global_stack_file_list = [name for name in file_list if name.endswith(self._global_stack_suffix)]
@ -181,10 +185,13 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
return global_stack_file_list[0], extruder_stack_file_list
## read some info so we can make decisions
# \param file_name
# \param show_dialog In case we use preRead() to check if a file is a valid project file, we don't want to show a dialog.
def preRead(self, file_name, show_dialog=True, *args, **kwargs):
"""Read some info so we can make decisions
:param file_name:
:param show_dialog: In case we use preRead() to check if a file is a valid project file,
we don't want to show a dialog.
"""
self._clearState()
self._3mf_mesh_reader = Application.getInstance().getMeshFileHandler().getReaderForFile(file_name)
@ -578,15 +585,17 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
return WorkspaceReader.PreReadResult.accepted
## Read the project file
# Add all the definitions / materials / quality changes that do not exist yet. Then it loads
# all the stacks into the container registry. In some cases it will reuse the container for the global stack.
# It handles old style project files containing .stack.cfg as well as new style project files
# containing global.cfg / extruder.cfg
#
# \param file_name
@call_on_qt_thread
def read(self, file_name):
"""Read the project file
Add all the definitions / materials / quality changes that do not exist yet. Then it loads
all the stacks into the container registry. In some cases it will reuse the container for the global stack.
It handles old style project files containing .stack.cfg as well as new style project files
containing global.cfg / extruder.cfg
:param file_name:
"""
application = CuraApplication.getInstance()
try:
@ -868,19 +877,22 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
self._machine_info.quality_changes_info.name = quality_changes_name
## Helper class to create a new quality changes profile.
#
# This will then later be filled with the appropriate data.
# \param quality_type The quality type of the new profile.
# \param intent_category The intent category of the new profile.
# \param name The name for the profile. This will later be made unique so
# it doesn't need to be unique yet.
# \param global_stack The global stack showing the configuration that the
# profile should be created for.
# \param extruder_stack The extruder stack showing the configuration that
# the profile should be created for. If this is None, it will be created
# for the global stack.
def _createNewQualityChanges(self, quality_type: str, intent_category: Optional[str], name: str, global_stack: GlobalStack, extruder_stack: Optional[ExtruderStack]) -> InstanceContainer:
"""Helper class to create a new quality changes profile.
This will then later be filled with the appropriate data.
:param quality_type: The quality type of the new profile.
:param intent_category: The intent category of the new profile.
:param name: The name for the profile. This will later be made unique so
it doesn't need to be unique yet.
:param global_stack: The global stack showing the configuration that the
profile should be created for.
:param extruder_stack: The extruder stack showing the configuration that
the profile should be created for. If this is None, it will be created
for the global stack.
"""
container_registry = CuraApplication.getInstance().getContainerRegistry()
base_id = global_stack.definition.getId() if extruder_stack is None else extruder_stack.getId()
new_id = base_id + "_" + name
@ -1089,9 +1101,10 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
def _getXmlProfileClass(self):
return self._container_registry.getContainerForMimeType(MimeTypeDatabase.getMimeType("application/x-ultimaker-material-profile"))
## Get the list of ID's of all containers in a container stack by partially parsing it's serialized data.
@staticmethod
def _getContainerIdListFromSerialized(serialized):
"""Get the list of ID's of all containers in a container stack by partially parsing it's serialized data."""
parser = ConfigParser(interpolation = None, empty_lines_in_values = False)
parser.read_string(serialized)

View file

@ -229,9 +229,10 @@ class WorkspaceDialog(QObject):
if key in self._result:
self._result[key] = strategy
## Close the backend: otherwise one could end up with "Slicing..."
@pyqtSlot()
def closeBackend(self):
"""Close the backend: otherwise one could end up with "Slicing..."""
Application.getInstance().getBackend().close()
def setMaterialConflict(self, material_conflict):
@ -283,8 +284,9 @@ class WorkspaceDialog(QObject):
self.showDialogSignal.emit()
@pyqtSlot()
## Used to notify the dialog so the lock can be released.
def notifyClosed(self):
"""Used to notify the dialog so the lock can be released."""
self._result = {} # The result should be cleared before hide, because after it is released the main thread lock
self._visible = False
try:
@ -319,8 +321,9 @@ class WorkspaceDialog(QObject):
self._view.hide()
self.hide()
## Block thread until the dialog is closed.
def waitForClose(self):
"""Block thread until the dialog is closed."""
if self._visible:
if threading.current_thread() != threading.main_thread():
self._lock.acquire()

View file

@ -33,7 +33,7 @@ def getMetaData() -> Dict:
"description": catalog.i18nc("@item:inlistbox", "3MF File")
}
]
return metaData

View file

@ -107,11 +107,13 @@ class ThreeMFWorkspaceWriter(WorkspaceWriter):
import json
archive.writestr(file_in_archive, json.dumps(metadata, separators = (", ", ": "), indent = 4, skipkeys = True))
## Helper function that writes ContainerStacks, InstanceContainers and DefinitionContainers to the archive.
# \param container That follows the \type{ContainerInterface} to archive.
# \param archive The archive to write to.
@staticmethod
def _writeContainerToArchive(container, archive):
"""Helper function that writes ContainerStacks, InstanceContainers and DefinitionContainers to the archive.
:param container: That follows the :type{ContainerInterface} to archive.
:param archive: The archive to write to.
"""
if isinstance(container, type(ContainerRegistry.getInstance().getEmptyInstanceContainer())):
return # Empty file, do nothing.

View file

@ -60,15 +60,19 @@ class ThreeMFWriter(MeshWriter):
result += str(matrix._data[2, 3])
return result
## Should we store the archive
# Note that if this is true, the archive will not be closed.
# The object that set this parameter is then responsible for closing it correctly!
def setStoreArchive(self, store_archive):
"""Should we store the archive
Note that if this is true, the archive will not be closed.
The object that set this parameter is then responsible for closing it correctly!
"""
self._store_archive = store_archive
## Convenience function that converts an Uranium SceneNode object to a SavitarSceneNode
# \returns Uranium Scene node.
def _convertUMNodeToSavitarNode(self, um_node, transformation = Matrix()):
"""Convenience function that converts an Uranium SceneNode object to a SavitarSceneNode
:returns: Uranium Scene node.
"""
if not isinstance(um_node, SceneNode):
return None

View file

@ -147,13 +147,13 @@ class AMFReader(MeshReader):
return group_node
## Converts a Trimesh to Uranium's MeshData.
# \param tri_node A Trimesh containing the contents of a file that was
# just read.
# \param file_name The full original filename used to watch for changes
# \return Mesh data from the Trimesh in a way that Uranium can understand
# it.
def _toMeshData(self, tri_node: trimesh.base.Trimesh, file_name: str = "") -> MeshData:
"""Converts a Trimesh to Uranium's MeshData.
:param tri_node: A Trimesh containing the contents of a file that was just read.
:param file_name: The full original filename used to watch for changes
:return: Mesh data from the Trimesh in a way that Uranium can understand it.
"""
tri_faces = tri_node.faces
tri_vertices = tri_node.vertices

View file

@ -42,12 +42,14 @@ catalog = i18nCatalog("cura")
class CuraEngineBackend(QObject, Backend):
backendError = Signal()
## Starts the back-end plug-in.
#
# This registers all the signal listeners and prepares for communication
# with the back-end in general.
# CuraEngineBackend is exposed to qml as well.
def __init__(self) -> None:
"""Starts the back-end plug-in.
This registers all the signal listeners and prepares for communication
with the back-end in general.
CuraEngineBackend is exposed to qml as well.
"""
super().__init__()
# Find out where the engine is located, and how it is called.
# This depends on how Cura is packaged and which OS we are running on.
@ -177,18 +179,22 @@ class CuraEngineBackend(QObject, Backend):
self._machine_error_checker = self._application.getMachineErrorChecker()
self._machine_error_checker.errorCheckFinished.connect(self._onStackErrorCheckFinished)
## Terminate the engine process.
#
# This function should terminate the engine process.
# Called when closing the application.
def close(self) -> None:
"""Terminate the engine process.
This function should terminate the engine process.
Called when closing the application.
"""
# Terminate CuraEngine if it is still running at this point
self._terminate()
## Get the command that is used to call the engine.
# This is useful for debugging and used to actually start the engine.
# \return list of commands and args / parameters.
def getEngineCommand(self) -> List[str]:
"""Get the command that is used to call the engine.
This is useful for debugging and used to actually start the engine.
:return: list of commands and args / parameters.
"""
command = [self._application.getPreferences().getValue("backend/location"), "connect", "127.0.0.1:{0}".format(self._port), ""]
parser = argparse.ArgumentParser(prog = "cura", add_help = False)
@ -199,17 +205,18 @@ class CuraEngineBackend(QObject, Backend):
return command
## Emitted when we get a message containing print duration and material amount.
# This also implies the slicing has finished.
# \param time The amount of time the print will take.
# \param material_amount The amount of material the print will use.
printDurationMessage = Signal()
"""Emitted when we get a message containing print duration and material amount.
## Emitted when the slicing process starts.
This also implies the slicing has finished.
:param time: The amount of time the print will take.
:param material_amount: The amount of material the print will use.
"""
slicingStarted = Signal()
"""Emitted when the slicing process starts."""
## Emitted when the slicing process is aborted forcefully.
slicingCancelled = Signal()
"""Emitted when the slicing process is aborted forcefully."""
@pyqtSlot()
def stopSlicing(self) -> None:
@ -226,14 +233,16 @@ class CuraEngineBackend(QObject, Backend):
if self._error_message:
self._error_message.hide()
## Manually triggers a reslice
@pyqtSlot()
def forceSlice(self) -> None:
"""Manually triggers a reslice"""
self.markSliceAll()
self.slice()
## Perform a slice of the scene.
def slice(self) -> None:
"""Perform a slice of the scene."""
Logger.log("i", "Starting to slice...")
self._slice_start_time = time()
if not self._build_plates_to_be_sliced:
@ -289,9 +298,11 @@ class CuraEngineBackend(QObject, Backend):
self._start_slice_job.start()
self._start_slice_job.finished.connect(self._onStartSliceCompleted)
## Terminate the engine process.
# Start the engine process by calling _createSocket()
def _terminate(self) -> None:
"""Terminate the engine process.
Start the engine process by calling _createSocket()
"""
self._slicing = False
self._stored_layer_data = []
if self._start_slice_job_build_plate in self._stored_optimized_layer_data:
@ -316,15 +327,17 @@ class CuraEngineBackend(QObject, Backend):
except Exception as e: # terminating a process that is already terminating causes an exception, silently ignore this.
Logger.log("d", "Exception occurred while trying to kill the engine %s", str(e))
## Event handler to call when the job to initiate the slicing process is
# completed.
#
# When the start slice job is successfully completed, it will be happily
# slicing. This function handles any errors that may occur during the
# bootstrapping of a slice job.
#
# \param job The start slice job that was just finished.
def _onStartSliceCompleted(self, job: StartSliceJob) -> None:
"""Event handler to call when the job to initiate the slicing process is
completed.
When the start slice job is successfully completed, it will be happily
slicing. This function handles any errors that may occur during the
bootstrapping of a slice job.
:param job: The start slice job that was just finished.
"""
if self._error_message:
self._error_message.hide()
@ -443,11 +456,13 @@ class CuraEngineBackend(QObject, Backend):
if self._slice_start_time:
Logger.log("d", "Sending slice message took %s seconds", time() - self._slice_start_time )
## Determine enable or disable auto slicing. Return True for enable timer and False otherwise.
# It disables when
# - preference auto slice is off
# - decorator isBlockSlicing is found (used in g-code reader)
def determineAutoSlicing(self) -> bool:
"""Determine enable or disable auto slicing. Return True for enable timer and False otherwise.
It disables when:
- preference auto slice is off
- decorator isBlockSlicing is found (used in g-code reader)
"""
enable_timer = True
self._is_disabled = False
@ -472,8 +487,9 @@ class CuraEngineBackend(QObject, Backend):
self.disableTimer()
return False
## Return a dict with number of objects per build plate
def _numObjectsPerBuildPlate(self) -> Dict[int, int]:
"""Return a dict with number of objects per build plate"""
num_objects = defaultdict(int) #type: Dict[int, int]
for node in DepthFirstIterator(self._scene.getRoot()):
# Only count sliceable objects
@ -483,12 +499,14 @@ class CuraEngineBackend(QObject, Backend):
num_objects[build_plate_number] += 1
return num_objects
## Listener for when the scene has changed.
#
# This should start a slice if the scene is now ready to slice.
#
# \param source The scene node that was changed.
def _onSceneChanged(self, source: SceneNode) -> None:
"""Listener for when the scene has changed.
This should start a slice if the scene is now ready to slice.
:param source: The scene node that was changed.
"""
if not source.callDecoration("isSliceable") and source != self._scene.getRoot():
return
@ -536,10 +554,12 @@ class CuraEngineBackend(QObject, Backend):
self._invokeSlice()
## Called when an error occurs in the socket connection towards the engine.
#
# \param error The exception that occurred.
def _onSocketError(self, error: Arcus.Error) -> None:
"""Called when an error occurs in the socket connection towards the engine.
:param error: The exception that occurred.
"""
if self._application.isShuttingDown():
return
@ -567,8 +587,9 @@ class CuraEngineBackend(QObject, Backend):
break
return has_slicable
## Remove old layer data (if any)
def _clearLayerData(self, build_plate_numbers: Set = None) -> None:
"""Remove old layer data (if any)"""
# Clear out any old gcode
self._scene.gcode_dict = {} # type: ignore
@ -583,8 +604,9 @@ class CuraEngineBackend(QObject, Backend):
if build_plate_number not in self._build_plates_to_be_sliced:
self._build_plates_to_be_sliced.append(build_plate_number)
## Convenient function: mark everything to slice, emit state and clear layer data
def needsSlicing(self) -> None:
"""Convenient function: mark everything to slice, emit state and clear layer data"""
# CURA-6604: If there's no slicable object, do not (try to) trigger slice, which will clear all the current
# gcode. This can break Gcode file loading if it tries to remove it afterwards.
if not self.hasSlicableObject():
@ -597,10 +619,12 @@ class CuraEngineBackend(QObject, Backend):
# With manually having to slice, we want to clear the old invalid layer data.
self._clearLayerData()
## A setting has changed, so check if we must reslice.
# \param instance The setting instance that has changed.
# \param property The property of the setting instance that has changed.
def _onSettingChanged(self, instance: SettingInstance, property: str) -> None:
"""A setting has changed, so check if we must reslice.
:param instance: The setting instance that has changed.
:param property: The property of the setting instance that has changed.
"""
if property == "value": # Only reslice if the value has changed.
self.needsSlicing()
self._onChanged()
@ -618,25 +642,31 @@ class CuraEngineBackend(QObject, Backend):
self.needsSlicing()
self._onChanged()
## Called when a sliced layer data message is received from the engine.
#
# \param message The protobuf message containing sliced layer data.
def _onLayerMessage(self, message: Arcus.PythonMessage) -> None:
"""Called when a sliced layer data message is received from the engine.
:param message: The protobuf message containing sliced layer data.
"""
self._stored_layer_data.append(message)
## Called when an optimized sliced layer data message is received from the engine.
#
# \param message The protobuf message containing sliced layer data.
def _onOptimizedLayerMessage(self, message: Arcus.PythonMessage) -> None:
"""Called when an optimized sliced layer data message is received from the engine.
:param message: The protobuf message containing sliced layer data.
"""
if self._start_slice_job_build_plate is not None:
if self._start_slice_job_build_plate not in self._stored_optimized_layer_data:
self._stored_optimized_layer_data[self._start_slice_job_build_plate] = []
self._stored_optimized_layer_data[self._start_slice_job_build_plate].append(message)
## Called when a progress message is received from the engine.
#
# \param message The protobuf message containing the slicing progress.
def _onProgressMessage(self, message: Arcus.PythonMessage) -> None:
"""Called when a progress message is received from the engine.
:param message: The protobuf message containing the slicing progress.
"""
self.processingProgress.emit(message.amount)
self.setState(BackendState.Processing)
@ -653,10 +683,12 @@ class CuraEngineBackend(QObject, Backend):
else:
self._change_timer.start()
## Called when the engine sends a message that slicing is finished.
#
# \param message The protobuf message signalling that slicing is finished.
def _onSlicingFinishedMessage(self, message: Arcus.PythonMessage) -> None:
"""Called when the engine sends a message that slicing is finished.
:param message: The protobuf message signalling that slicing is finished.
"""
self.setState(BackendState.Done)
self.processingProgress.emit(1.0)
@ -698,27 +730,32 @@ class CuraEngineBackend(QObject, Backend):
self.enableTimer() # manually enable timer to be able to invoke slice, also when in manual slice mode
self._invokeSlice()
## Called when a g-code message is received from the engine.
#
# \param message The protobuf message containing g-code, encoded as UTF-8.
def _onGCodeLayerMessage(self, message: Arcus.PythonMessage) -> None:
"""Called when a g-code message is received from the engine.
:param message: The protobuf message containing g-code, encoded as UTF-8.
"""
try:
self._scene.gcode_dict[self._start_slice_job_build_plate].append(message.data.decode("utf-8", "replace")) #type: ignore #Because we generate this attribute dynamically.
except KeyError: # Can occur if the g-code has been cleared while a slice message is still arriving from the other end.
pass # Throw the message away.
## Called when a g-code prefix message is received from the engine.
#
# \param message The protobuf message containing the g-code prefix,
# encoded as UTF-8.
def _onGCodePrefixMessage(self, message: Arcus.PythonMessage) -> None:
"""Called when a g-code prefix message is received from the engine.
:param message: The protobuf message containing the g-code prefix,
encoded as UTF-8.
"""
try:
self._scene.gcode_dict[self._start_slice_job_build_plate].insert(0, message.data.decode("utf-8", "replace")) #type: ignore #Because we generate this attribute dynamically.
except KeyError: # Can occur if the g-code has been cleared while a slice message is still arriving from the other end.
pass # Throw the message away.
## Creates a new socket connection.
def _createSocket(self, protocol_file: str = None) -> None:
"""Creates a new socket connection."""
if not protocol_file:
if not self.getPluginId():
Logger.error("Can't create socket before CuraEngineBackend plug-in is registered.")
@ -731,10 +768,12 @@ class CuraEngineBackend(QObject, Backend):
super()._createSocket(protocol_file)
self._engine_is_fresh = True
## Called when anything has changed to the stuff that needs to be sliced.
#
# This indicates that we should probably re-slice soon.
def _onChanged(self, *args: Any, **kwargs: Any) -> None:
"""Called when anything has changed to the stuff that needs to be sliced.
This indicates that we should probably re-slice soon.
"""
self.needsSlicing()
if self._use_timer:
# if the error check is scheduled, wait for the error check finish signal to trigger auto-slice,
@ -748,11 +787,13 @@ class CuraEngineBackend(QObject, Backend):
else:
self._change_timer.start()
## Called when a print time message is received from the engine.
#
# \param message The protobuf message containing the print time per feature and
# material amount per extruder
def _onPrintTimeMaterialEstimates(self, message: Arcus.PythonMessage) -> None:
"""Called when a print time message is received from the engine.
:param message: The protobuf message containing the print time per feature and
material amount per extruder
"""
material_amounts = []
for index in range(message.repeatedMessageCount("materialEstimates")):
material_amounts.append(message.getRepeatedMessage("materialEstimates", index).material_amount)
@ -760,10 +801,12 @@ class CuraEngineBackend(QObject, Backend):
times = self._parseMessagePrintTimes(message)
self.printDurationMessage.emit(self._start_slice_job_build_plate, times, material_amounts)
## Called for parsing message to retrieve estimated time per feature
#
# \param message The protobuf message containing the print time per feature
def _parseMessagePrintTimes(self, message: Arcus.PythonMessage) -> Dict[str, float]:
"""Called for parsing message to retrieve estimated time per feature
:param message: The protobuf message containing the print time per feature
"""
result = {
"inset_0": message.time_inset_0,
"inset_x": message.time_inset_x,
@ -780,19 +823,22 @@ class CuraEngineBackend(QObject, Backend):
}
return result
## Called when the back-end connects to the front-end.
def _onBackendConnected(self) -> None:
"""Called when the back-end connects to the front-end."""
if self._restart:
self._restart = False
self._onChanged()
## Called when the user starts using some tool.
#
# When the user starts using a tool, we should pause slicing to prevent
# continuously slicing while the user is dragging some tool handle.
#
# \param tool The tool that the user is using.
def _onToolOperationStarted(self, tool: Tool) -> None:
"""Called when the user starts using some tool.
When the user starts using a tool, we should pause slicing to prevent
continuously slicing while the user is dragging some tool handle.
:param tool: The tool that the user is using.
"""
self._tool_active = True # Do not react on scene change
self.disableTimer()
# Restart engine as soon as possible, we know we want to slice afterwards
@ -800,12 +846,14 @@ class CuraEngineBackend(QObject, Backend):
self._terminate()
self._createSocket()
## Called when the user stops using some tool.
#
# This indicates that we can safely start slicing again.
#
# \param tool The tool that the user was using.
def _onToolOperationStopped(self, tool: Tool) -> None:
"""Called when the user stops using some tool.
This indicates that we can safely start slicing again.
:param tool: The tool that the user was using.
"""
self._tool_active = False # React on scene change again
self.determineAutoSlicing() # Switch timer on if appropriate
# Process all the postponed scene changes
@ -819,8 +867,9 @@ class CuraEngineBackend(QObject, Backend):
self._process_layers_job.finished.connect(self._onProcessLayersFinished)
self._process_layers_job.start()
## Called when the user changes the active view mode.
def _onActiveViewChanged(self) -> None:
"""Called when the user changes the active view mode."""
view = self._application.getController().getActiveView()
if view:
active_build_plate = self._application.getMultiBuildPlateModel().activeBuildPlate
@ -838,17 +887,20 @@ class CuraEngineBackend(QObject, Backend):
else:
self._layer_view_active = False
## Called when the back-end self-terminates.
#
# We should reset our state and start listening for new connections.
def _onBackendQuit(self) -> None:
"""Called when the back-end self-terminates.
We should reset our state and start listening for new connections.
"""
if not self._restart:
if self._process: # type: ignore
Logger.log("d", "Backend quit with return code %s. Resetting process and socket.", self._process.wait()) # type: ignore
self._process = None # type: ignore
## Called when the global container stack changes
def _onGlobalStackChanged(self) -> None:
"""Called when the global container stack changes"""
if self._global_container_stack:
self._global_container_stack.propertyChanged.disconnect(self._onSettingChanged)
self._global_container_stack.containersChanged.disconnect(self._onChanged)
@ -877,15 +929,18 @@ class CuraEngineBackend(QObject, Backend):
Logger.log("d", "See if there is more to slice(2)...")
self._invokeSlice()
## Connect slice function to timer.
def enableTimer(self) -> None:
"""Connect slice function to timer."""
if not self._use_timer:
self._change_timer.timeout.connect(self.slice)
self._use_timer = True
## Disconnect slice function from timer.
# This means that slicing will not be triggered automatically
def disableTimer(self) -> None:
"""Disconnect slice function from timer.
This means that slicing will not be triggered automatically
"""
if self._use_timer:
self._use_timer = False
self._change_timer.timeout.disconnect(self.slice)
@ -897,8 +952,9 @@ class CuraEngineBackend(QObject, Backend):
if auto_slice:
self._change_timer.start()
## Tickle the backend so in case of auto slicing, it starts the timer.
def tickle(self) -> None:
"""Tickle the backend so in case of auto slicing, it starts the timer."""
if self._use_timer:
self._change_timer.start()

View file

@ -28,10 +28,12 @@ from cura.Machines.Models.ExtrudersModel import ExtrudersModel
catalog = i18nCatalog("cura")
## Return a 4-tuple with floats 0-1 representing the html color code
#
# \param color_code html color code, i.e. "#FF0000" -> red
def colorCodeToRGBA(color_code):
"""Return a 4-tuple with floats 0-1 representing the html color code
:param color_code: html color code, i.e. "#FF0000" -> red
"""
if color_code is None:
Logger.log("w", "Unable to convert color code, returning default")
return [0, 0, 0, 1]
@ -51,13 +53,15 @@ class ProcessSlicedLayersJob(Job):
self._abort_requested = False
self._build_plate_number = None
## Aborts the processing of layers.
#
# This abort is made on a best-effort basis, meaning that the actual
# job thread will check once in a while to see whether an abort is
# requested and then stop processing by itself. There is no guarantee
# that the abort will stop the job any time soon or even at all.
def abort(self):
"""Aborts the processing of layers.
This abort is made on a best-effort basis, meaning that the actual
job thread will check once in a while to see whether an abort is
requested and then stop processing by itself. There is no guarantee
that the abort will stop the job any time soon or even at all.
"""
self._abort_requested = True
def setBuildPlate(self, new_value):

View file

@ -42,8 +42,9 @@ class StartJobResult(IntEnum):
ObjectsWithDisabledExtruder = 8
## Formatter class that handles token expansion in start/end gcode
class GcodeStartEndFormatter(Formatter):
"""Formatter class that handles token expansion in start/end gcode"""
def __init__(self, default_extruder_nr: int = -1) -> None:
super().__init__()
self._default_extruder_nr = default_extruder_nr
@ -84,8 +85,9 @@ class GcodeStartEndFormatter(Formatter):
return value
## Job class that builds up the message of scene data to send to CuraEngine.
class StartSliceJob(Job):
"""Job class that builds up the message of scene data to send to CuraEngine."""
def __init__(self, slice_message: Arcus.PythonMessage) -> None:
super().__init__()
@ -102,9 +104,10 @@ class StartSliceJob(Job):
def setBuildPlate(self, build_plate_number: int) -> None:
self._build_plate_number = build_plate_number
## Check if a stack has any errors.
## returns true if it has errors, false otherwise.
def _checkStackForErrors(self, stack: ContainerStack) -> bool:
"""Check if a stack has any errors."""
"""returns true if it has errors, false otherwise."""
top_of_stack = cast(InstanceContainer, stack.getTop()) # Cache for efficiency.
changed_setting_keys = top_of_stack.getAllKeys()
@ -134,8 +137,9 @@ class StartSliceJob(Job):
return False
## Runs the job that initiates the slicing.
def run(self) -> None:
"""Runs the job that initiates the slicing."""
if self._build_plate_number is None:
self.setResult(StartJobResult.Error)
return
@ -338,14 +342,14 @@ class StartSliceJob(Job):
def setIsCancelled(self, value: bool):
self._is_cancelled = value
## Creates a dictionary of tokens to replace in g-code pieces.
#
# This indicates what should be replaced in the start and end g-codes.
# \param stack The stack to get the settings from to replace the tokens
# with.
# \return A dictionary of replacement tokens to the values they should be
# replaced with.
def _buildReplacementTokens(self, stack: ContainerStack) -> Dict[str, Any]:
"""Creates a dictionary of tokens to replace in g-code pieces.
This indicates what should be replaced in the start and end g-codes.
:param stack: The stack to get the settings from to replace the tokens with.
:return: A dictionary of replacement tokens to the values they should be replaced with.
"""
result = {}
for key in stack.getAllKeys():
value = stack.getProperty(key, "value")
@ -373,10 +377,12 @@ class StartSliceJob(Job):
extruder_nr = extruder_stack.getProperty("extruder_nr", "value")
self._all_extruders_settings[str(extruder_nr)] = self._buildReplacementTokens(extruder_stack)
## Replace setting tokens in a piece of g-code.
# \param value A piece of g-code to replace tokens in.
# \param default_extruder_nr Stack nr to use when no stack nr is specified, defaults to the global stack
def _expandGcodeTokens(self, value: str, default_extruder_nr: int = -1) -> str:
"""Replace setting tokens in a piece of g-code.
:param value: A piece of g-code to replace tokens in.
:param default_extruder_nr: Stack nr to use when no stack nr is specified, defaults to the global stack
"""
if not self._all_extruders_settings:
self._cacheAllExtruderSettings()
@ -392,8 +398,9 @@ class StartSliceJob(Job):
Logger.logException("w", "Unable to do token replacement on start/end g-code")
return str(value)
## Create extruder message from stack
def _buildExtruderMessage(self, stack: ContainerStack) -> None:
"""Create extruder message from stack"""
message = self._slice_message.addRepeatedMessage("extruders")
message.id = int(stack.getMetaDataEntry("position"))
if not self._all_extruders_settings:
@ -422,11 +429,13 @@ class StartSliceJob(Job):
setting.value = str(value).encode("utf-8")
Job.yieldThread()
## Sends all global settings to the engine.
#
# The settings are taken from the global stack. This does not include any
# per-extruder settings or per-object settings.
def _buildGlobalSettingsMessage(self, stack: ContainerStack) -> None:
"""Sends all global settings to the engine.
The settings are taken from the global stack. This does not include any
per-extruder settings or per-object settings.
"""
if not self._all_extruders_settings:
self._cacheAllExtruderSettings()
@ -460,15 +469,16 @@ class StartSliceJob(Job):
setting_message.value = str(value).encode("utf-8")
Job.yieldThread()
## Sends for some settings which extruder they should fallback to if not
# set.
#
# This is only set for settings that have the limit_to_extruder
# property.
#
# \param stack The global stack with all settings, from which to read the
# limit_to_extruder property.
def _buildGlobalInheritsStackMessage(self, stack: ContainerStack) -> None:
"""Sends for some settings which extruder they should fallback to if not set.
This is only set for settings that have the limit_to_extruder
property.
:param stack: The global stack with all settings, from which to read the
limit_to_extruder property.
"""
for key in stack.getAllKeys():
extruder_position = int(round(float(stack.getProperty(key, "limit_to_extruder"))))
if extruder_position >= 0: # Set to a specific extruder.
@ -477,10 +487,13 @@ class StartSliceJob(Job):
setting_extruder.extruder = extruder_position
Job.yieldThread()
## Check if a node has per object settings and ensure that they are set correctly in the message
# \param node Node to check.
# \param message object_lists message to put the per object settings in
def _handlePerObjectSettings(self, node: CuraSceneNode, message: Arcus.PythonMessage):
"""Check if a node has per object settings and ensure that they are set correctly in the message
:param node: Node to check.
:param message: object_lists message to put the per object settings in
"""
stack = node.callDecoration("getStack")
# Check if the node has a stack attached to it and the stack has any settings in the top container.
@ -516,10 +529,13 @@ class StartSliceJob(Job):
Job.yieldThread()
## Recursive function to put all settings that require each other for value changes in a list
# \param relations_set Set of keys of settings that are influenced
# \param relations list of relation objects that need to be checked.
def _addRelations(self, relations_set: Set[str], relations: List[SettingRelation]):
"""Recursive function to put all settings that require each other for value changes in a list
:param relations_set: Set of keys of settings that are influenced
:param relations: list of relation objects that need to be checked.
"""
for relation in filter(lambda r: r.role == "value" or r.role == "limit_to_extruder", relations):
if relation.type == RelationType.RequiresTarget:
continue

View file

@ -13,23 +13,30 @@ from cura.ReaderWriters.ProfileReader import ProfileReader
import zipfile
## A plugin that reads profile data from Cura profile files.
#
# It reads a profile from a .curaprofile file, and returns it as a profile
# instance.
class CuraProfileReader(ProfileReader):
## Initialises the cura profile reader.
# This does nothing since the only other function is basically stateless.
"""A plugin that reads profile data from Cura profile files.
It reads a profile from a .curaprofile file, and returns it as a profile
instance.
"""
def __init__(self) -> None:
"""Initialises the cura profile reader.
This does nothing since the only other function is basically stateless.
"""
super().__init__()
## Reads a cura profile from a file and returns it.
#
# \param file_name The file to read the cura profile from.
# \return The cura profiles that were in the file, if any. If the file
# could not be read or didn't contain a valid profile, ``None`` is
# returned.
def read(self, file_name: str) -> List[Optional[InstanceContainer]]:
"""Reads a cura profile from a file and returns it.
:param file_name: The file to read the cura profile from.
:return: The cura profiles that were in the file, if any. If the file
could not be read or didn't contain a valid profile, ``None`` is
returned.
"""
try:
with zipfile.ZipFile(file_name, "r") as archive:
results = [] # type: List[Optional[InstanceContainer]]
@ -50,13 +57,14 @@ class CuraProfileReader(ProfileReader):
serialized_bytes = fhandle.read()
return [self._loadProfile(serialized, profile_id) for serialized, profile_id in self._upgradeProfile(serialized_bytes, file_name)]
## Convert a profile from an old Cura to this Cura if needed.
#
# \param serialized The profile data to convert in the serialized on-disk
# format.
# \param profile_id The name of the profile.
# \return List of serialized profile strings and matching profile names.
def _upgradeProfile(self, serialized: str, profile_id: str) -> List[Tuple[str, str]]:
"""Convert a profile from an old Cura to this Cura if needed.
:param serialized: The profile data to convert in the serialized on-disk format.
:param profile_id: The name of the profile.
:return: List of serialized profile strings and matching profile names.
"""
parser = configparser.ConfigParser(interpolation = None)
parser.read_string(serialized)
@ -75,12 +83,14 @@ class CuraProfileReader(ProfileReader):
else:
return [(serialized, profile_id)]
## Load a profile from a serialized string.
#
# \param serialized The profile data to read.
# \param profile_id The name of the profile.
# \return The profile that was stored in the string.
def _loadProfile(self, serialized: str, profile_id: str) -> Optional[InstanceContainer]:
"""Load a profile from a serialized string.
:param serialized: The profile data to read.
:param profile_id: The name of the profile.
:return: The profile that was stored in the string.
"""
# Create an empty profile.
profile = InstanceContainer(profile_id)
profile.setMetaDataEntry("type", "quality_changes")
@ -102,13 +112,15 @@ class CuraProfileReader(ProfileReader):
profile.setMetaDataEntry("definition", active_quality_definition)
return profile
## Upgrade a serialized profile to the current profile format.
#
# \param serialized The profile data to convert.
# \param profile_id The name of the profile.
# \param source_version The profile version of 'serialized'.
# \return List of serialized profile strings and matching profile names.
def _upgradeProfileVersion(self, serialized: str, profile_id: str, main_version: int, setting_version: int) -> List[Tuple[str, str]]:
"""Upgrade a serialized profile to the current profile format.
:param serialized: The profile data to convert.
:param profile_id: The name of the profile.
:param source_version: The profile version of 'serialized'.
:return: List of serialized profile strings and matching profile names.
"""
source_version = main_version * 1000000 + setting_version
from UM.VersionUpgradeManager import VersionUpgradeManager

View file

@ -6,15 +6,18 @@ from UM.Logger import Logger
from cura.ReaderWriters.ProfileWriter import ProfileWriter
import zipfile
## Writes profiles to Cura's own profile format with config files.
class CuraProfileWriter(ProfileWriter):
## Writes a profile to the specified file path.
#
# \param path \type{string} The file to output to.
# \param profiles \type{Profile} \type{List} The profile(s) to write to that file.
# \return \code True \endcode if the writing was successful, or \code
# False \endcode if it wasn't.
"""Writes profiles to Cura's own profile format with config files."""
def write(self, path, profiles):
"""Writes a profile to the specified file path.
:param path: :type{string} The file to output to.
:param profiles: :type{Profile} :type{List} The profile(s) to write to that file.
:return: True if the writing was successful, or
False if it wasn't.
"""
if type(profiles) != list:
profiles = [profiles]

View file

@ -18,10 +18,12 @@ from .FirmwareUpdateCheckerMessage import FirmwareUpdateCheckerMessage
i18n_catalog = i18nCatalog("cura")
## This Extension checks for new versions of the firmware based on the latest checked version number.
# The plugin is currently only usable for applications maintained by Ultimaker. But it should be relatively easy
# to change it to work for other applications.
class FirmwareUpdateChecker(Extension):
"""This Extension checks for new versions of the firmware based on the latest checked version number.
The plugin is currently only usable for applications maintained by Ultimaker. But it should be relatively easy
to change it to work for other applications.
"""
def __init__(self) -> None:
super().__init__()
@ -35,8 +37,9 @@ class FirmwareUpdateChecker(Extension):
self._check_job = None
self._checked_printer_names = set() # type: Set[str]
## Callback for the message that is spawned when there is a new version.
def _onActionTriggered(self, message, action):
"""Callback for the message that is spawned when there is a new version."""
if action == FirmwareUpdateCheckerMessage.STR_ACTION_DOWNLOAD:
machine_id = message.getMachineId()
download_url = message.getDownloadUrl()
@ -57,13 +60,15 @@ class FirmwareUpdateChecker(Extension):
def _onJobFinished(self, *args, **kwargs):
self._check_job = None
## Connect with software.ultimaker.com, load latest.version and check version info.
# If the version info is different from the current version, spawn a message to
# allow the user to download it.
#
# \param silent type(boolean) Suppresses messages other than "new version found" messages.
# This is used when checking for a new firmware version at startup.
def checkFirmwareVersion(self, container = None, silent = False):
"""Connect with software.ultimaker.com, load latest.version and check version info.
If the version info is different from the current version, spawn a message to
allow the user to download it.
:param silent: type(boolean) Suppresses messages other than "new version found" messages.
This is used when checking for a new firmware version at startup.
"""
container_name = container.definition.getName()
if container_name in self._checked_printer_names:
return

View file

@ -21,8 +21,9 @@ from UM.i18n import i18nCatalog
i18n_catalog = i18nCatalog("cura")
## This job checks if there is an update available on the provided URL.
class FirmwareUpdateCheckerJob(Job):
"""This job checks if there is an update available on the provided URL."""
STRING_ZERO_VERSION = "0.0.0"
STRING_EPSILON_VERSION = "0.0.1"
ZERO_VERSION = Version(STRING_ZERO_VERSION)

View file

@ -19,8 +19,10 @@ if MYPY:
catalog = i18nCatalog("cura")
## Upgrade the firmware of a machine by USB with this action.
class FirmwareUpdaterMachineAction(MachineAction):
"""Upgrade the firmware of a machine by USB with this action."""
def __init__(self) -> None:
super().__init__("UpgradeFirmware", catalog.i18nc("@action", "Update Firmware"))
self._qml_url = "FirmwareUpdaterMachineAction.qml"

View file

@ -7,10 +7,13 @@ from UM.Mesh.MeshReader import MeshReader #The class we're extending/implementin
from UM.MimeTypeDatabase import MimeTypeDatabase, MimeType #To add the .gcode.gz files to the MIME type database.
from UM.PluginRegistry import PluginRegistry
## A file reader that reads gzipped g-code.
#
# If you're zipping g-code, you might as well use gzip!
class GCodeGzReader(MeshReader):
"""A file reader that reads gzipped g-code.
If you're zipping g-code, you might as well use gzip!
"""
def __init__(self) -> None:
super().__init__()
MimeTypeDatabase.addMimeType(

View file

@ -13,26 +13,31 @@ from UM.Scene.SceneNode import SceneNode #For typing.
from UM.i18n import i18nCatalog
catalog = i18nCatalog("cura")
## A file writer that writes gzipped g-code.
#
# If you're zipping g-code, you might as well use gzip!
class GCodeGzWriter(MeshWriter):
"""A file writer that writes gzipped g-code.
If you're zipping g-code, you might as well use gzip!
"""
def __init__(self) -> None:
super().__init__(add_to_recent_files = False)
## Writes the gzipped g-code to a stream.
#
# Note that even though the function accepts a collection of nodes, the
# entire scene is always written to the file since it is not possible to
# separate the g-code for just specific nodes.
#
# \param stream The stream to write the gzipped g-code to.
# \param nodes This is ignored.
# \param mode Additional information on what type of stream to use. This
# must always be binary mode.
# \return Whether the write was successful.
def write(self, stream: BufferedIOBase, nodes: List[SceneNode], mode = MeshWriter.OutputMode.BinaryMode) -> bool:
"""Writes the gzipped g-code to a stream.
Note that even though the function accepts a collection of nodes, the
entire scene is always written to the file since it is not possible to
separate the g-code for just specific nodes.
:param stream: The stream to write the gzipped g-code to.
:param nodes: This is ignored.
:param mode: Additional information on what type of stream to use. This
must always be binary mode.
:return: Whether the write was successful.
"""
if mode != MeshWriter.OutputMode.BinaryMode:
Logger.log("e", "GCodeGzWriter does not support text mode.")
self.setInformation(catalog.i18nc("@error:not supported", "GCodeGzWriter does not support text mode."))

View file

@ -3,6 +3,7 @@
import re #Regular expressions for parsing escape characters in the settings.
import json
from typing import Optional
from UM.Settings.ContainerFormatError import ContainerFormatError
from UM.Settings.InstanceContainer import InstanceContainer
@ -12,40 +13,48 @@ catalog = i18nCatalog("cura")
from cura.ReaderWriters.ProfileReader import ProfileReader, NoProfileException
## A class that reads profile data from g-code files.
#
# It reads the profile data from g-code files and stores it in a new profile.
# This class currently does not process the rest of the g-code in any way.
class GCodeProfileReader(ProfileReader):
## The file format version of the serialized g-code.
#
# It can only read settings with the same version as the version it was
# written with. If the file format is changed in a way that breaks reverse
# compatibility, increment this version number!
version = 3
"""A class that reads profile data from g-code files.
It reads the profile data from g-code files and stores it in a new profile.
This class currently does not process the rest of the g-code in any way.
"""
version = 3
"""The file format version of the serialized g-code.
It can only read settings with the same version as the version it was
written with. If the file format is changed in a way that breaks reverse
compatibility, increment this version number!
"""
## Dictionary that defines how characters are escaped when embedded in
# g-code.
#
# Note that the keys of this dictionary are regex strings. The values are
# not.
escape_characters = {
re.escape("\\\\"): "\\", #The escape character.
re.escape("\\n"): "\n", #Newlines. They break off the comment.
re.escape("\\r"): "\r" #Carriage return. Windows users may need this for visualisation in their editors.
}
"""Dictionary that defines how characters are escaped when embedded in
g-code.
Note that the keys of this dictionary are regex strings. The values are
not.
"""
## Initialises the g-code reader as a profile reader.
def __init__(self):
"""Initialises the g-code reader as a profile reader."""
super().__init__()
## Reads a g-code file, loading the profile from it.
#
# \param file_name The name of the file to read the profile from.
# \return The profile that was in the specified file, if any. If the
# specified file was no g-code or contained no parsable profile, \code
# None \endcode is returned.
def read(self, file_name):
"""Reads a g-code file, loading the profile from it.
:param file_name: The name of the file to read the profile from.
:return: The profile that was in the specified file, if any. If the
specified file was no g-code or contained no parsable profile,
None is returned.
"""
if file_name.split(".")[-1] != "gcode":
return None
@ -94,22 +103,28 @@ class GCodeProfileReader(ProfileReader):
profiles.append(readQualityProfileFromString(profile_string))
return profiles
## Unescape a string which has been escaped for use in a gcode comment.
#
# \param string The string to unescape.
# \return \type{str} The unscaped string.
def unescapeGcodeComment(string):
def unescapeGcodeComment(string: str) -> str:
"""Unescape a string which has been escaped for use in a gcode comment.
:param string: The string to unescape.
:return: The unescaped string.
"""
# Un-escape the serialized profile.
pattern = re.compile("|".join(GCodeProfileReader.escape_characters.keys()))
# Perform the replacement with a regular expression.
return pattern.sub(lambda m: GCodeProfileReader.escape_characters[re.escape(m.group(0))], string)
## Read in a profile from a serialized string.
#
# \param profile_string The profile data in serialized form.
# \return \type{Profile} the resulting Profile object or None if it could not be read.
def readQualityProfileFromString(profile_string):
def readQualityProfileFromString(profile_string) -> Optional[InstanceContainer]:
"""Read in a profile from a serialized string.
:param profile_string: The profile data in serialized form.
:return: The resulting Profile object or None if it could not be read.
"""
# Create an empty profile - the id and name will be changed by the ContainerRegistry
profile = InstanceContainer("")
try:

View file

@ -28,9 +28,8 @@ PositionOptional = NamedTuple("Position", [("x", Optional[float]), ("y", Optiona
Position = NamedTuple("Position", [("x", float), ("y", float), ("z", float), ("f", float), ("e", List[float])])
## This parser is intended to interpret the common firmware codes among all the
# different flavors
class FlavorParser:
"""This parser is intended to interpret the common firmware codes among all the different flavors"""
def __init__(self) -> None:
CuraApplication.getInstance().hideMessageSignal.connect(self._onHideMessage)
@ -212,8 +211,9 @@ class FlavorParser:
# G0 and G1 should be handled exactly the same.
_gCode1 = _gCode0
## Home the head.
def _gCode28(self, position: Position, params: PositionOptional, path: List[List[Union[float, int]]]) -> Position:
"""Home the head."""
return self._position(
params.x if params.x is not None else position.x,
params.y if params.y is not None else position.y,
@ -221,21 +221,26 @@ class FlavorParser:
position.f,
position.e)
## Set the absolute positioning
def _gCode90(self, position: Position, params: PositionOptional, path: List[List[Union[float, int]]]) -> Position:
"""Set the absolute positioning"""
self._is_absolute_positioning = True
self._is_absolute_extrusion = True
return position
## Set the relative positioning
def _gCode91(self, position: Position, params: PositionOptional, path: List[List[Union[float, int]]]) -> Position:
"""Set the relative positioning"""
self._is_absolute_positioning = False
self._is_absolute_extrusion = False
return position
## Reset the current position to the values specified.
# For example: G92 X10 will set the X to 10 without any physical motion.
def _gCode92(self, position: Position, params: PositionOptional, path: List[List[Union[float, int]]]) -> Position:
"""Reset the current position to the values specified.
For example: G92 X10 will set the X to 10 without any physical motion.
"""
if params.e is not None:
# Sometimes a G92 E0 is introduced in the middle of the GCode so we need to keep those offsets for calculate the line_width
self._extrusion_length_offset[self._extruder_number] = position.e[self._extruder_number] - params.e
@ -291,8 +296,9 @@ class FlavorParser:
_type_keyword = ";TYPE:"
_layer_keyword = ";LAYER:"
## For showing correct x, y offsets for each extruder
def _extruderOffsets(self) -> Dict[int, List[float]]:
"""For showing correct x, y offsets for each extruder"""
result = {}
for extruder in ExtruderManager.getInstance().getActiveExtruderStacks():
result[int(extruder.getMetaData().get("position", "0"))] = [

View file

@ -3,8 +3,10 @@
from . import FlavorParser
## This parser is intended to interpret the RepRap Firmware g-code flavor.
class RepRapFlavorParser(FlavorParser.FlavorParser):
"""This parser is intended to interpret the RepRap Firmware g-code flavor."""
def __init__(self):
super().__init__()
@ -17,16 +19,20 @@ class RepRapFlavorParser(FlavorParser.FlavorParser):
# Set relative extrusion mode
self._is_absolute_extrusion = False
## Set the absolute positioning
# RepRapFlavor code G90 sets position of X, Y, Z to absolute
# For absolute E, M82 is used
def _gCode90(self, position, params, path):
"""Set the absolute positioning
RepRapFlavor code G90 sets position of X, Y, Z to absolute
For absolute E, M82 is used
"""
self._is_absolute_positioning = True
return position
## Set the relative positioning
# RepRapFlavor code G91 sets position of X, Y, Z to relative
# For relative E, M83 is used
def _gCode91(self, position, params, path):
"""Set the relative positioning
RepRapFlavor code G91 sets position of X, Y, Z to relative
For relative E, M83 is used
"""
self._is_absolute_positioning = False
return position

View file

@ -14,34 +14,40 @@ from cura.Machines.ContainerTree import ContainerTree
from UM.i18n import i18nCatalog
catalog = i18nCatalog("cura")
## Writes g-code to a file.
#
# While this poses as a mesh writer, what this really does is take the g-code
# in the entire scene and write it to an output device. Since the g-code of a
# single mesh isn't separable from the rest what with rafts and travel moves
# and all, it doesn't make sense to write just a single mesh.
#
# So this plug-in takes the g-code that is stored in the root of the scene
# node tree, adds a bit of extra information about the profiles and writes
# that to the output device.
class GCodeWriter(MeshWriter):
## The file format version of the serialised g-code.
#
# It can only read settings with the same version as the version it was
# written with. If the file format is changed in a way that breaks reverse
# compatibility, increment this version number!
version = 3
## Dictionary that defines how characters are escaped when embedded in
# g-code.
#
# Note that the keys of this dictionary are regex strings. The values are
# not.
class GCodeWriter(MeshWriter):
"""Writes g-code to a file.
While this poses as a mesh writer, what this really does is take the g-code
in the entire scene and write it to an output device. Since the g-code of a
single mesh isn't separable from the rest what with rafts and travel moves
and all, it doesn't make sense to write just a single mesh.
So this plug-in takes the g-code that is stored in the root of the scene
node tree, adds a bit of extra information about the profiles and writes
that to the output device.
"""
version = 3
"""The file format version of the serialised g-code.
It can only read settings with the same version as the version it was
written with. If the file format is changed in a way that breaks reverse
compatibility, increment this version number!
"""
escape_characters = {
re.escape("\\"): "\\\\", # The escape character.
re.escape("\n"): "\\n", # Newlines. They break off the comment.
re.escape("\r"): "\\r" # Carriage return. Windows users may need this for visualisation in their editors.
}
"""Dictionary that defines how characters are escaped when embedded in
g-code.
Note that the keys of this dictionary are regex strings. The values are
not.
"""
_setting_keyword = ";SETTING_"
@ -50,17 +56,19 @@ class GCodeWriter(MeshWriter):
self._application = Application.getInstance()
## Writes the g-code for the entire scene to a stream.
#
# Note that even though the function accepts a collection of nodes, the
# entire scene is always written to the file since it is not possible to
# separate the g-code for just specific nodes.
#
# \param stream The stream to write the g-code to.
# \param nodes This is ignored.
# \param mode Additional information on how to format the g-code in the
# file. This must always be text mode.
def write(self, stream, nodes, mode = MeshWriter.OutputMode.TextMode):
"""Writes the g-code for the entire scene to a stream.
Note that even though the function accepts a collection of nodes, the
entire scene is always written to the file since it is not possible to
separate the g-code for just specific nodes.
:param stream: The stream to write the g-code to.
:param nodes: This is ignored.
:param mode: Additional information on how to format the g-code in the
file. This must always be text mode.
"""
if mode != MeshWriter.OutputMode.TextMode:
Logger.log("e", "GCodeWriter does not support non-text mode.")
self.setInformation(catalog.i18nc("@error:not supported", "GCodeWriter does not support non-text mode."))
@ -88,8 +96,9 @@ class GCodeWriter(MeshWriter):
self.setInformation(catalog.i18nc("@warning:status", "Please prepare G-code before exporting."))
return False
## Create a new container with container 2 as base and container 1 written over it.
def _createFlattenedContainerInstance(self, instance_container1, instance_container2):
"""Create a new container with container 2 as base and container 1 written over it."""
flat_container = InstanceContainer(instance_container2.getName())
# The metadata includes id, name and definition
@ -106,15 +115,15 @@ class GCodeWriter(MeshWriter):
return flat_container
## Serialises a container stack to prepare it for writing at the end of the
# g-code.
#
# The settings are serialised, and special characters (including newline)
# are escaped.
#
# \param settings A container stack to serialise.
# \return A serialised string of the settings.
def _serialiseSettings(self, stack):
"""Serialises a container stack to prepare it for writing at the end of the g-code.
The settings are serialised, and special characters (including newline)
are escaped.
:param stack: A container stack to serialise.
:return: A serialised string of the settings.
"""
container_registry = self._application.getContainerRegistry()
prefix = self._setting_keyword + str(GCodeWriter.version) + " " # The prefix to put before each line.

View file

@ -16,58 +16,67 @@ from UM.Settings.InstanceContainer import InstanceContainer # The new profile t
from cura.ReaderWriters.ProfileReader import ProfileReader # The plug-in type to implement.
## A plugin that reads profile data from legacy Cura versions.
#
# It reads a profile from an .ini file, and performs some translations on it.
# Not all translations are correct, mind you, but it is a best effort.
class LegacyProfileReader(ProfileReader):
## Initialises the legacy profile reader.
#
# This does nothing since the only other function is basically stateless.
"""A plugin that reads profile data from legacy Cura versions.
It reads a profile from an .ini file, and performs some translations on it.
Not all translations are correct, mind you, but it is a best effort.
"""
def __init__(self):
"""Initialises the legacy profile reader.
This does nothing since the only other function is basically stateless.
"""
super().__init__()
## Prepares the default values of all legacy settings.
#
# These are loaded from the Dictionary of Doom.
#
# \param json The JSON file to load the default setting values from. This
# should not be a URL but a pre-loaded JSON handle.
# \return A dictionary of the default values of the legacy Cura version.
def prepareDefaults(self, json: Dict[str, Dict[str, str]]) -> Dict[str, str]:
"""Prepares the default values of all legacy settings.
These are loaded from the Dictionary of Doom.
:param json: The JSON file to load the default setting values from. This
should not be a URL but a pre-loaded JSON handle.
:return: A dictionary of the default values of the legacy Cura version.
"""
defaults = {}
if "defaults" in json:
for key in json["defaults"]: # We have to copy over all defaults from the JSON handle to a normal dict.
defaults[key] = json["defaults"][key]
return defaults
## Prepares the local variables that can be used in evaluation of computing
# new setting values from the old ones.
#
# This fills a dictionary with all settings from the legacy Cura version
# and their values, so that they can be used in evaluating the new setting
# values as Python code.
#
# \param config_parser The ConfigParser that finds the settings in the
# legacy profile.
# \param config_section The section in the profile where the settings
# should be found.
# \param defaults The default values for all settings in the legacy Cura.
# \return A set of local variables, one for each setting in the legacy
# profile.
def prepareLocals(self, config_parser, config_section, defaults):
"""Prepares the local variables that can be used in evaluation of computing
new setting values from the old ones.
This fills a dictionary with all settings from the legacy Cura version
and their values, so that they can be used in evaluating the new setting
values as Python code.
:param config_parser: The ConfigParser that finds the settings in the
legacy profile.
:param config_section: The section in the profile where the settings
should be found.
:param defaults: The default values for all settings in the legacy Cura.
:return: A set of local variables, one for each setting in the legacy
profile.
"""
copied_locals = defaults.copy() # Don't edit the original!
for option in config_parser.options(config_section):
copied_locals[option] = config_parser.get(config_section, option)
return copied_locals
## Reads a legacy Cura profile from a file and returns it.
#
# \param file_name The file to read the legacy Cura profile from.
# \return The legacy Cura profile that was in the file, if any. If the
# file could not be read or didn't contain a valid profile, \code None
# \endcode is returned.
def read(self, file_name):
"""Reads a legacy Cura profile from a file and returns it.
:param file_name: The file to read the legacy Cura profile from.
:return: The legacy Cura profile that was in the file, if any. If the
file could not be read or didn't contain a valid profile, None is returned.
"""
if file_name.split(".")[-1] != "ini":
return None
global_container_stack = Application.getInstance().getGlobalContainerStack()

View file

@ -13,7 +13,7 @@ import UM.PluginRegistry # To mock the plug-in registry out.
import UM.Settings.ContainerRegistry # To mock the container registry out.
import UM.Settings.InstanceContainer # To intercept the serialised data from the read() function.
import LegacyProfileReader as LegacyProfileReaderModule # To get the directory of the module.
import LegacyProfileReader as LegacyProfileReaderModule # To get the directory of the module.
@pytest.fixture
@ -126,9 +126,11 @@ test_prepareLocalsNoSectionErrorData = [
)
]
## Test cases where a key error is expected.
@pytest.mark.parametrize("parser_data, defaults", test_prepareLocalsNoSectionErrorData)
def test_prepareLocalsNoSectionError(legacy_profile_reader, parser_data, defaults):
"""Test cases where a key error is expected."""
parser = configparser.ConfigParser()
parser.read_dict(parser_data)

View file

@ -23,9 +23,11 @@ if TYPE_CHECKING:
catalog = UM.i18n.i18nCatalog("cura")
## This action allows for certain settings that are "machine only") to be modified.
# It automatically detects machine definitions that it knows how to change and attaches itself to those.
class MachineSettingsAction(MachineAction):
"""This action allows for certain settings that are "machine only") to be modified.
It automatically detects machine definitions that it knows how to change and attaches itself to those.
"""
def __init__(self, parent: Optional["QObject"] = None) -> None:
super().__init__("MachineSettingsAction", catalog.i18nc("@action", "Machine Settings"))
self._qml_url = "MachineSettingsAction.qml"
@ -56,9 +58,11 @@ class MachineSettingsAction(MachineAction):
if isinstance(container, DefinitionContainer) and container.getMetaDataEntry("type") == "machine":
self._application.getMachineActionManager().addSupportedAction(container.getId(), self.getKey())
## Triggered when the global container stack changes or when the g-code
# flavour setting is changed.
def _updateHasMaterialsInContainerTree(self) -> None:
"""Triggered when the global container stack changes or when the g-code
flavour setting is changed.
"""
global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack()
if global_stack is None:
return

View file

@ -18,8 +18,8 @@ catalog = i18nCatalog("cura")
class ModelChecker(QObject, Extension):
## Signal that gets emitted when anything changed that we need to check.
onChanged = pyqtSignal()
"""Signal that gets emitted when anything changed that we need to check."""
def __init__(self):
super().__init__()
@ -47,11 +47,13 @@ class ModelChecker(QObject, Extension):
if not isinstance(args[0], Camera):
self._change_timer.start()
## Called when plug-ins are initialized.
#
# This makes sure that we listen to changes of the material and that the
# button is created that indicates warnings with the current set-up.
def _pluginsInitialized(self):
"""Called when plug-ins are initialized.
This makes sure that we listen to changes of the material and that the
button is created that indicates warnings with the current set-up.
"""
Application.getInstance().getMachineManager().rootMaterialChanged.connect(self.onChanged)
self._createView()
@ -106,8 +108,12 @@ class ModelChecker(QObject, Extension):
if node.callDecoration("isSliceable"):
yield node
## Creates the view used by show popup. The view is saved because of the fairly aggressive garbage collection.
def _createView(self):
"""Creates the view used by show popup.
The view is saved because of the fairly aggressive garbage collection.
"""
Logger.log("d", "Creating model checker view.")
# Create the plugin dialog component

View file

@ -1,72 +1,72 @@
# Copyright (c) 2017 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import os.path
from UM.Application import Application
from cura.Stages.CuraStage import CuraStage
## Stage for monitoring a 3D printing while it's printing.
class MonitorStage(CuraStage):
def __init__(self, parent = None):
super().__init__(parent)
# Wait until QML engine is created, otherwise creating the new QML components will fail
Application.getInstance().engineCreatedSignal.connect(self._onEngineCreated)
self._printer_output_device = None
self._active_print_job = None
self._active_printer = None
def _setActivePrintJob(self, print_job):
if self._active_print_job != print_job:
self._active_print_job = print_job
def _setActivePrinter(self, printer):
if self._active_printer != printer:
if self._active_printer:
self._active_printer.activePrintJobChanged.disconnect(self._onActivePrintJobChanged)
self._active_printer = printer
if self._active_printer:
self._setActivePrintJob(self._active_printer.activePrintJob)
# Jobs might change, so we need to listen to it's changes.
self._active_printer.activePrintJobChanged.connect(self._onActivePrintJobChanged)
else:
self._setActivePrintJob(None)
def _onActivePrintJobChanged(self):
self._setActivePrintJob(self._active_printer.activePrintJob)
def _onActivePrinterChanged(self):
self._setActivePrinter(self._printer_output_device.activePrinter)
def _onOutputDevicesChanged(self):
try:
# We assume that you are monitoring the device with the highest priority.
new_output_device = Application.getInstance().getMachineManager().printerOutputDevices[0]
if new_output_device != self._printer_output_device:
if self._printer_output_device:
try:
self._printer_output_device.printersChanged.disconnect(self._onActivePrinterChanged)
except TypeError:
# Ignore stupid "Not connected" errors.
pass
self._printer_output_device = new_output_device
self._printer_output_device.printersChanged.connect(self._onActivePrinterChanged)
self._setActivePrinter(self._printer_output_device.activePrinter)
except IndexError:
pass
def _onEngineCreated(self):
# We can only connect now, as we need to be sure that everything is loaded (plugins get created quite early)
Application.getInstance().getMachineManager().outputDevicesChanged.connect(self._onOutputDevicesChanged)
self._onOutputDevicesChanged()
plugin_path = Application.getInstance().getPluginRegistry().getPluginPath(self.getPluginId())
if plugin_path is not None:
menu_component_path = os.path.join(plugin_path, "MonitorMenu.qml")
main_component_path = os.path.join(plugin_path, "MonitorMain.qml")
self.addDisplayComponent("menu", menu_component_path)
self.addDisplayComponent("main", main_component_path)
# Copyright (c) 2017 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import os.path
from UM.Application import Application
from cura.Stages.CuraStage import CuraStage
class MonitorStage(CuraStage):
"""Stage for monitoring a 3D printing while it's printing."""
def __init__(self, parent = None):
super().__init__(parent)
# Wait until QML engine is created, otherwise creating the new QML components will fail
Application.getInstance().engineCreatedSignal.connect(self._onEngineCreated)
self._printer_output_device = None
self._active_print_job = None
self._active_printer = None
def _setActivePrintJob(self, print_job):
if self._active_print_job != print_job:
self._active_print_job = print_job
def _setActivePrinter(self, printer):
if self._active_printer != printer:
if self._active_printer:
self._active_printer.activePrintJobChanged.disconnect(self._onActivePrintJobChanged)
self._active_printer = printer
if self._active_printer:
self._setActivePrintJob(self._active_printer.activePrintJob)
# Jobs might change, so we need to listen to it's changes.
self._active_printer.activePrintJobChanged.connect(self._onActivePrintJobChanged)
else:
self._setActivePrintJob(None)
def _onActivePrintJobChanged(self):
self._setActivePrintJob(self._active_printer.activePrintJob)
def _onActivePrinterChanged(self):
self._setActivePrinter(self._printer_output_device.activePrinter)
def _onOutputDevicesChanged(self):
try:
# We assume that you are monitoring the device with the highest priority.
new_output_device = Application.getInstance().getMachineManager().printerOutputDevices[0]
if new_output_device != self._printer_output_device:
if self._printer_output_device:
try:
self._printer_output_device.printersChanged.disconnect(self._onActivePrinterChanged)
except TypeError:
# Ignore stupid "Not connected" errors.
pass
self._printer_output_device = new_output_device
self._printer_output_device.printersChanged.connect(self._onActivePrinterChanged)
self._setActivePrinter(self._printer_output_device.activePrinter)
except IndexError:
pass
def _onEngineCreated(self):
# We can only connect now, as we need to be sure that everything is loaded (plugins get created quite early)
Application.getInstance().getMachineManager().outputDevicesChanged.connect(self._onOutputDevicesChanged)
self._onOutputDevicesChanged()
plugin_path = Application.getInstance().getPluginRegistry().getPluginPath(self.getPluginId())
if plugin_path is not None:
menu_component_path = os.path.join(plugin_path, "MonitorMenu.qml")
main_component_path = os.path.join(plugin_path, "MonitorMain.qml")
self.addDisplayComponent("menu", menu_component_path)
self.addDisplayComponent("main", main_component_path)

View file

@ -15,9 +15,11 @@ from cura.Settings.ExtruderManager import ExtruderManager #To get global-inherit
from cura.Settings.SettingOverrideDecorator import SettingOverrideDecorator
## The per object setting visibility handler ensures that only setting
# definitions that have a matching instance Container are returned as visible.
class PerObjectSettingVisibilityHandler(UM.Settings.Models.SettingVisibilityHandler.SettingVisibilityHandler):
"""The per object setting visibility handler ensures that only setting
definitions that have a matching instance Container are returned as visible.
"""
def __init__(self, parent = None, *args, **kwargs):
super().__init__(parent = parent, *args, **kwargs)

View file

@ -12,9 +12,11 @@ from UM.Settings.SettingInstance import SettingInstance
from UM.Event import Event
## This tool allows the user to add & change settings per node in the scene.
# The settings per object are kept in a ContainerStack, which is linked to a node by decorator.
class PerObjectSettingsTool(Tool):
"""This tool allows the user to add & change settings per node in the scene.
The settings per object are kept in a ContainerStack, which is linked to a node by decorator.
"""
def __init__(self):
super().__init__()
self._model = None
@ -48,26 +50,31 @@ class PerObjectSettingsTool(Tool):
except AttributeError:
return ""
## Gets the active extruder of the currently selected object.
#
# \return The active extruder of the currently selected object.
def getSelectedActiveExtruder(self):
"""Gets the active extruder of the currently selected object.
:return: The active extruder of the currently selected object.
"""
selected_object = Selection.getSelectedObject(0)
return selected_object.callDecoration("getActiveExtruder")
## Changes the active extruder of the currently selected object.
#
# \param extruder_stack_id The ID of the extruder to print the currently
# selected object with.
def setSelectedActiveExtruder(self, extruder_stack_id):
"""Changes the active extruder of the currently selected object.
:param extruder_stack_id: The ID of the extruder to print the currently
selected object with.
"""
selected_object = Selection.getSelectedObject(0)
stack = selected_object.callDecoration("getStack") #Don't try to get the active extruder since it may be None anyway.
if not stack:
selected_object.addDecorator(SettingOverrideDecorator())
selected_object.callDecoration("setActiveExtruder", extruder_stack_id)
## Returns True when the mesh_type was changed, False when current mesh_type == mesh_type
def setMeshType(self, mesh_type: str) -> bool:
"""Returns True when the mesh_type was changed, False when current mesh_type == mesh_type"""
old_mesh_type = self.getMeshType()
if old_mesh_type == mesh_type:
return False

View file

@ -27,9 +27,8 @@ if TYPE_CHECKING:
from .Script import Script
## The post processing plugin is an Extension type plugin that enables pre-written scripts to post process generated
# g-code files.
class PostProcessingPlugin(QObject, Extension):
"""Extension type plugin that enables pre-written scripts to post process g-code files."""
def __init__(self, parent = None) -> None:
QObject.__init__(self, parent)
Extension.__init__(self)
@ -69,8 +68,9 @@ class PostProcessingPlugin(QObject, Extension):
except IndexError:
return ""
## Execute all post-processing scripts on the gcode.
def execute(self, output_device) -> None:
"""Execute all post-processing scripts on the gcode."""
scene = Application.getInstance().getController().getScene()
# If the scene does not have a gcode, do nothing
if not hasattr(scene, "gcode_dict"):
@ -119,9 +119,10 @@ class PostProcessingPlugin(QObject, Extension):
self.selectedIndexChanged.emit() #Ensure that settings are updated
self._propertyChanged()
## Remove a script from the active script list by index.
@pyqtSlot(int)
def removeScriptByIndex(self, index: int) -> None:
"""Remove a script from the active script list by index."""
self._script_list.pop(index)
if len(self._script_list) - 1 < self._selected_script_index:
self._selected_script_index = len(self._script_list) - 1
@ -129,10 +130,12 @@ class PostProcessingPlugin(QObject, Extension):
self.selectedIndexChanged.emit() # Ensure that settings are updated
self._propertyChanged()
## Load all scripts from all paths where scripts can be found.
#
# This should probably only be done on init.
def loadAllScripts(self) -> None:
"""Load all scripts from all paths where scripts can be found.
This should probably only be done on init.
"""
if self._loaded_scripts: # Already loaded.
return
@ -152,10 +155,12 @@ class PostProcessingPlugin(QObject, Extension):
self.loadScripts(path)
## Load all scripts from provided path.
# This should probably only be done on init.
# \param path Path to check for scripts.
def loadScripts(self, path: str) -> None:
"""Load all scripts from provided path.
This should probably only be done on init.
:param path: Path to check for scripts.
"""
if ApplicationMetadata.IsEnterpriseVersion:
# Delete all __pycache__ not in installation folder, as it may present a security risk.
@ -173,8 +178,8 @@ class PostProcessingPlugin(QObject, Extension):
if not is_in_installation_path:
TrustBasics.removeCached(path)
## Load all scripts in the scripts folders
scripts = pkgutil.iter_modules(path = [path])
"""Load all scripts in the scripts folders"""
for loader, script_name, ispkg in scripts:
# Iterate over all scripts.
if script_name not in sys.modules:
@ -278,9 +283,8 @@ class PostProcessingPlugin(QObject, Extension):
self.scriptListChanged.emit()
self._propertyChanged()
## When the global container stack is changed, swap out the list of active
# scripts.
def _onGlobalContainerStackChanged(self) -> None:
"""When the global container stack is changed, swap out the list of active scripts."""
if self._global_container_stack:
self._global_container_stack.metaDataChanged.disconnect(self._restoreScriptInforFromMetadata)
@ -323,8 +327,12 @@ class PostProcessingPlugin(QObject, Extension):
# We do want to listen to other events.
self._global_container_stack.metaDataChanged.connect(self._restoreScriptInforFromMetadata)
## Creates the view used by show popup. The view is saved because of the fairly aggressive garbage collection.
def _createView(self) -> None:
"""Creates the view used by show popup.
The view is saved because of the fairly aggressive garbage collection.
"""
Logger.log("d", "Creating post processing plugin view.")
self.loadAllScripts()
@ -340,8 +348,9 @@ class PostProcessingPlugin(QObject, Extension):
# Create the save button component
CuraApplication.getInstance().addAdditionalComponent("saveButton", self._view.findChild(QObject, "postProcessingSaveAreaButton"))
## Show the (GUI) popup of the post processing plugin.
def showPopup(self) -> None:
"""Show the (GUI) popup of the post processing plugin."""
if self._view is None:
self._createView()
if self._view is None:
@ -349,11 +358,13 @@ class PostProcessingPlugin(QObject, Extension):
return
self._view.show()
## Property changed: trigger re-slice
# To do this we use the global container stack propertyChanged.
# Re-slicing is necessary for setting changes in this plugin, because the changes
# are applied only once per "fresh" gcode
def _propertyChanged(self) -> None:
"""Property changed: trigger re-slice
To do this we use the global container stack propertyChanged.
Re-slicing is necessary for setting changes in this plugin, because the changes
are applied only once per "fresh" gcode
"""
global_container_stack = Application.getInstance().getGlobalContainerStack()
if global_container_stack is not None:
global_container_stack.propertyChanged.emit("post_processing_plugin", "value")

View file

@ -23,9 +23,10 @@ if TYPE_CHECKING:
from UM.Settings.Interfaces import DefinitionContainerInterface
## Base class for scripts. All scripts should inherit the script class.
@signalemitter
class Script:
"""Base class for scripts. All scripts should inherit the script class."""
def __init__(self) -> None:
super().__init__()
self._stack = None # type: Optional[ContainerStack]
@ -78,13 +79,15 @@ class Script:
if global_container_stack is not None:
global_container_stack.propertyChanged.emit(key, property_name)
## Needs to return a dict that can be used to construct a settingcategory file.
# See the example script for an example.
# It follows the same style / guides as the Uranium settings.
# Scripts can either override getSettingData directly, or use getSettingDataString
# to return a string that will be parsed as json. The latter has the benefit over
# returning a dict in that the order of settings is maintained.
def getSettingData(self) -> Dict[str, Any]:
"""Needs to return a dict that can be used to construct a settingcategory file.
See the example script for an example.
It follows the same style / guides as the Uranium settings.
Scripts can either override getSettingData directly, or use getSettingDataString
to return a string that will be parsed as json. The latter has the benefit over
returning a dict in that the order of settings is maintained.
"""
setting_data_as_string = self.getSettingDataString()
setting_data = json.loads(setting_data_as_string, object_pairs_hook = collections.OrderedDict)
return setting_data
@ -104,15 +107,18 @@ class Script:
return self._stack.getId()
return None
## Convenience function that retrieves value of a setting from the stack.
def getSettingValueByKey(self, key: str) -> Any:
"""Convenience function that retrieves value of a setting from the stack."""
if self._stack is not None:
return self._stack.getProperty(key, "value")
return None
## Convenience function that finds the value in a line of g-code.
# When requesting key = x from line "G1 X100" the value 100 is returned.
def getValue(self, line: str, key: str, default = None) -> Any:
"""Convenience function that finds the value in a line of g-code.
When requesting key = x from line "G1 X100" the value 100 is returned.
"""
if not key in line or (';' in line and line.find(key) > line.find(';')):
return default
sub_part = line[line.find(key) + 1:]
@ -127,20 +133,23 @@ class Script:
except ValueError: #Not a number at all.
return default
## Convenience function to produce a line of g-code.
#
# You can put in an original g-code line and it'll re-use all the values
# in that line.
# All other keyword parameters are put in the result in g-code's format.
# For instance, if you put ``G=1`` in the parameters, it will output
# ``G1``. If you put ``G=1, X=100`` in the parameters, it will output
# ``G1 X100``. The parameters G and M will always be put first. The
# parameters T and S will be put second (or first if there is no G or M).
# The rest of the parameters will be put in arbitrary order.
# \param line The original g-code line that must be modified. If not
# provided, an entirely new g-code line will be produced.
# \return A line of g-code with the desired parameters filled in.
def putValue(self, line: str = "", **kwargs) -> str:
"""Convenience function to produce a line of g-code.
You can put in an original g-code line and it'll re-use all the values
in that line.
All other keyword parameters are put in the result in g-code's format.
For instance, if you put ``G=1`` in the parameters, it will output
``G1``. If you put ``G=1, X=100`` in the parameters, it will output
``G1 X100``. The parameters G and M will always be put first. The
parameters T and S will be put second (or first if there is no G or M).
The rest of the parameters will be put in arbitrary order.
:param line: The original g-code line that must be modified. If not
provided, an entirely new g-code line will be produced.
:return: A line of g-code with the desired parameters filled in.
"""
#Strip the comment.
comment = ""
if ";" in line:
@ -179,7 +188,9 @@ class Script:
return result
## This is called when the script is executed.
# It gets a list of g-code strings and needs to return a (modified) list.
def execute(self, data: List[str]) -> List[str]:
"""This is called when the script is executed.
It gets a list of g-code strings and needs to return a (modified) list.
"""
raise NotImplementedError()

View file

@ -20,7 +20,7 @@
# Uses -
# M163 - Set Mix Factor
# M164 - Save Mix - saves to T2 as a unique mix
import re #To perform the search and replace.
from ..Script import Script
@ -127,7 +127,7 @@ class ColorMix(Script):
firstMix = self.getSettingValueByKey("mix_start")
secondMix = self.getSettingValueByKey("mix_finish")
modelOfInterest = self.getSettingValueByKey("object_number")
#get layer height
layerHeight = 0
for active_layer in data:
@ -138,11 +138,11 @@ class ColorMix(Script):
break
if layerHeight != 0:
break
#default layerHeight if not found
if layerHeight == 0:
layerHeight = .2
#get layers to use
startLayer = 0
endLayer = 0

View file

@ -56,7 +56,7 @@ class DisplayFilenameAndLayerOnLCD(Script):
}
}
}"""
def execute(self, data):
max_layer = 0
if self.getSettingValueByKey("name") != "":
@ -96,5 +96,5 @@ class DisplayFilenameAndLayerOnLCD(Script):
i += 1
final_lines = "\n".join(lines)
data[layer_index] = final_lines
return data

View file

@ -63,10 +63,12 @@ class FilamentChange(Script):
}
}"""
## Inserts the filament change g-code at specific layer numbers.
# \param data A list of layers of g-code.
# \return A similar list, with filament change commands inserted.
def execute(self, data: List[str]):
"""Inserts the filament change g-code at specific layer numbers.
:param data: A list of layers of g-code.
:return: A similar list, with filament change commands inserted.
"""
layer_nums = self.getSettingValueByKey("layer_number")
initial_retract = self.getSettingValueByKey("initial_retract")
later_retract = self.getSettingValueByKey("later_retract")

View file

@ -201,6 +201,7 @@ class PauseAtHeight(Script):
## Get the X and Y values for a layer (will be used to get X and Y of the
# layer after the pause).
def getNextXY(self, layer: str) -> Tuple[float, float]:
"""Get the X and Y values for a layer (will be used to get X and Y of the layer after the pause)."""
lines = layer.split("\n")
for line in lines:
if self.getValue(line, "X") is not None and self.getValue(line, "Y") is not None:
@ -209,10 +210,12 @@ class PauseAtHeight(Script):
return x, y
return 0, 0
## Inserts the pause commands.
# \param data: List of layers.
# \return New list of layers.
def execute(self, data: List[str]) -> List[str]:
"""Inserts the pause commands.
:param data: List of layers.
:return: New list of layers.
"""
pause_at = self.getSettingValueByKey("pause_at")
pause_height = self.getSettingValueByKey("pause_height")
pause_layer = self.getSettingValueByKey("pause_layer")

View file

@ -5,8 +5,10 @@ import math
from ..Script import Script
## Continues retracting during all travel moves.
class RetractContinue(Script):
"""Continues retracting during all travel moves."""
def getSettingDataString(self):
return """{
"name": "Retract Continue",

View file

@ -5,11 +5,14 @@ import re #To perform the search and replace.
from ..Script import Script
## Performs a search-and-replace on all g-code.
#
# Due to technical limitations, the search can't cross the border between
# layers.
class SearchAndReplace(Script):
"""Performs a search-and-replace on all g-code.
Due to technical limitations, the search can't cross the border between
layers.
"""
def getSettingDataString(self):
return """{
"name": "Search and Replace",

View file

@ -30,7 +30,7 @@ class UsePreviousProbeMeasurements(Script):
}
}
}"""
def execute(self, data):
text = "M501 ;load bed level data\nM420 S1 ;enable bed leveling"
if self.getSettingValueByKey("use_previous_measurements"):

View file

@ -1,19 +1,21 @@
# Copyright (c) 2019 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import os.path
from UM.Application import Application
from UM.PluginRegistry import PluginRegistry
from cura.Stages.CuraStage import CuraStage
## Stage for preparing model (slicing).
class PrepareStage(CuraStage):
def __init__(self, parent = None):
super().__init__(parent)
Application.getInstance().engineCreatedSignal.connect(self._engineCreated)
def _engineCreated(self):
menu_component_path = os.path.join(PluginRegistry.getInstance().getPluginPath("PrepareStage"), "PrepareMenu.qml")
main_component_path = os.path.join(PluginRegistry.getInstance().getPluginPath("PrepareStage"), "PrepareMain.qml")
self.addDisplayComponent("menu", menu_component_path)
# Copyright (c) 2019 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import os.path
from UM.Application import Application
from UM.PluginRegistry import PluginRegistry
from cura.Stages.CuraStage import CuraStage
class PrepareStage(CuraStage):
"""Stage for preparing model (slicing)."""
def __init__(self, parent = None):
super().__init__(parent)
Application.getInstance().engineCreatedSignal.connect(self._engineCreated)
def _engineCreated(self):
menu_component_path = os.path.join(PluginRegistry.getInstance().getPluginPath("PrepareStage"), "PrepareMenu.qml")
main_component_path = os.path.join(PluginRegistry.getInstance().getPluginPath("PrepareStage"), "PrepareMain.qml")
self.addDisplayComponent("menu", menu_component_path)
self.addDisplayComponent("main", main_component_path)

View file

@ -12,37 +12,45 @@ if TYPE_CHECKING:
from UM.View.View import View
## Displays a preview of what you're about to print.
#
# The Python component of this stage just loads PreviewMain.qml for display
# when the stage is selected, and makes sure that it reverts to the previous
# view when the previous stage is activated.
class PreviewStage(CuraStage):
"""Displays a preview of what you're about to print.
The Python component of this stage just loads PreviewMain.qml for display
when the stage is selected, and makes sure that it reverts to the previous
view when the previous stage is activated.
"""
def __init__(self, application: QtApplication, parent = None) -> None:
super().__init__(parent)
self._application = application
self._application.engineCreatedSignal.connect(self._engineCreated)
self._previously_active_view = None # type: Optional[View]
## When selecting the stage, remember which was the previous view so that
# we can revert to that view when we go out of the stage later.
def onStageSelected(self) -> None:
"""When selecting the stage, remember which was the previous view so that
we can revert to that view when we go out of the stage later.
"""
self._previously_active_view = self._application.getController().getActiveView()
## Called when going to a different stage (away from the Preview Stage).
#
# When going to a different stage, the view should be reverted to what it
# was before. Normally, that just reverts it to solid view.
def onStageDeselected(self) -> None:
"""Called when going to a different stage (away from the Preview Stage).
When going to a different stage, the view should be reverted to what it
was before. Normally, that just reverts it to solid view.
"""
if self._previously_active_view is not None:
self._application.getController().setActiveView(self._previously_active_view.getPluginId())
self._previously_active_view = None
## Delayed load of the QML files.
#
# We need to make sure that the QML engine is running before we can load
# these.
def _engineCreated(self) -> None:
"""Delayed load of the QML files.
We need to make sure that the QML engine is running before we can load
these.
"""
plugin_path = self._application.getPluginRegistry().getPluginPath(self.getPluginId())
if plugin_path is not None:
menu_component_path = os.path.join(plugin_path, "PreviewMenu.qml")

View file

@ -10,12 +10,14 @@ import glob
import os
import subprocess
## Support for removable devices on Linux.
#
# TODO: This code uses the most basic interfaces for handling this.
# We should instead use UDisks2 to handle mount/unmount and hotplugging events.
#
class LinuxRemovableDrivePlugin(RemovableDrivePlugin.RemovableDrivePlugin):
"""Support for removable devices on Linux.
TODO: This code uses the most basic interfaces for handling this.
We should instead use UDisks2 to handle mount/unmount and hotplugging events.
"""
def checkRemovableDrives(self):
drives = {}
for volume in glob.glob("/media/*"):

View file

@ -9,8 +9,10 @@ import os
import plistlib
## Support for removable devices on Mac OSX
class OSXRemovableDrivePlugin(RemovableDrivePlugin.RemovableDrivePlugin):
"""Support for removable devices on Mac OSX"""
def checkRemovableDrives(self):
drives = {}
p = subprocess.Popen(["system_profiler", "SPUSBDataType", "-xml"], stdout = subprocess.PIPE, stderr = subprocess.PIPE)

View file

@ -28,17 +28,19 @@ class RemovableDriveOutputDevice(OutputDevice):
self._writing = False
self._stream = None
## Request the specified nodes to be written to the removable drive.
#
# \param nodes A collection of scene nodes that should be written to the
# removable drive.
# \param file_name \type{string} A suggestion for the file name to write
# to. If none is provided, a file name will be made from the names of the
# meshes.
# \param limit_mimetypes Should we limit the available MIME types to the
# MIME types available to the currently active machine?
#
def requestWrite(self, nodes, file_name = None, filter_by_machine = False, file_handler = None, **kwargs):
"""Request the specified nodes to be written to the removable drive.
:param nodes: A collection of scene nodes that should be written to the
removable drive.
:param file_name: :type{string} A suggestion for the file name to write to.
If none is provided, a file name will be made from the names of the
meshes.
:param limit_mimetypes: Should we limit the available MIME types to the
MIME types available to the currently active machine?
"""
filter_by_machine = True # This plugin is intended to be used by machine (regardless of what it was told to do)
if self._writing:
raise OutputDeviceError.DeviceBusyError()
@ -106,14 +108,14 @@ class RemovableDriveOutputDevice(OutputDevice):
Logger.log("e", "Operating system would not let us write to %s: %s", file_name, str(e))
raise OutputDeviceError.WriteRequestFailedError(catalog.i18nc("@info:status Don't translate the XML tags <filename> or <message>!", "Could not save to <filename>{0}</filename>: <message>{1}</message>").format(file_name, str(e))) from e
## Generate a file name automatically for the specified nodes to be saved
# in.
#
# The name generated will be the name of one of the nodes. Which node that
# is can not be guaranteed.
#
# \param nodes A collection of nodes for which to generate a file name.
def _automaticFileName(self, nodes):
"""Generate a file name automatically for the specified nodes to be saved in.
The name generated will be the name of one of the nodes. Which node that
is can not be guaranteed.
:param nodes: A collection of nodes for which to generate a file name.
"""
for root in nodes:
for child in BreadthFirstIterator(root):
if child.getMeshData():

View file

@ -42,8 +42,9 @@ ctypes.windll.kernel32.DeviceIoControl.argtypes = [ #type: ignore
ctypes.windll.kernel32.DeviceIoControl.restype = wintypes.BOOL #type: ignore
## Removable drive support for windows
class WindowsRemovableDrivePlugin(RemovableDrivePlugin.RemovableDrivePlugin):
"""Removable drive support for windows"""
def checkRemovableDrives(self):
drives = {}

View file

@ -20,7 +20,7 @@ class SentryLogger(LogOutput):
# processed and ready for sending.
# Note that this only prepares them for sending. It only sends them when the user actually agrees to sending the
# information.
_levels = {
"w": "warning",
"i": "info",
@ -32,11 +32,13 @@ class SentryLogger(LogOutput):
def __init__(self) -> None:
super().__init__()
self._show_once = set() # type: Set[str]
## Log the message to the sentry hub as a breadcrumb
# \param log_type "e" (error), "i"(info), "d"(debug), "w"(warning) or "c"(critical) (can postfix with "_once")
# \param message String containing message to be logged
def log(self, log_type: str, message: str) -> None:
"""Log the message to the sentry hub as a breadcrumb
:param log_type: "e" (error), "i"(info), "d"(debug), "w"(warning) or "c"(critical) (can postfix with "_once")
:param message: String containing message to be logged
"""
level = self._translateLogType(log_type)
message = CrashHandler.pruneSensitiveData(message)
if level is None:

View file

@ -49,8 +49,9 @@ if TYPE_CHECKING:
catalog = i18nCatalog("cura")
## The preview layer view. It is used to display g-code paths.
class SimulationView(CuraView):
"""The preview layer view. It is used to display g-code paths."""
# Must match SimulationViewMenuComponent.qml
LAYER_VIEW_TYPE_MATERIAL_TYPE = 0
LAYER_VIEW_TYPE_LINE_TYPE = 1
@ -295,23 +296,28 @@ class SimulationView(CuraView):
self.currentPathNumChanged.emit()
## Set the layer view type
#
# \param layer_view_type integer as in SimulationView.qml and this class
def setSimulationViewType(self, layer_view_type: int) -> None:
"""Set the layer view type
:param layer_view_type: integer as in SimulationView.qml and this class
"""
if layer_view_type != self._layer_view_type:
self._layer_view_type = layer_view_type
self.currentLayerNumChanged.emit()
## Return the layer view type, integer as in SimulationView.qml and this class
def getSimulationViewType(self) -> int:
"""Return the layer view type, integer as in SimulationView.qml and this class"""
return self._layer_view_type
## Set the extruder opacity
#
# \param extruder_nr 0..15
# \param opacity 0.0 .. 1.0
def setExtruderOpacity(self, extruder_nr: int, opacity: float) -> None:
"""Set the extruder opacity
:param extruder_nr: 0..15
:param opacity: 0.0 .. 1.0
"""
if 0 <= extruder_nr <= 15:
self._extruder_opacity[extruder_nr // 4][extruder_nr % 4] = opacity
self.currentLayerNumChanged.emit()
@ -375,8 +381,8 @@ class SimulationView(CuraView):
scene = self.getController().getScene()
self._old_max_layers = self._max_layers
## Recalculate num max layers
new_max_layers = -1
"""Recalculate num max layers"""
for node in DepthFirstIterator(scene.getRoot()): # type: ignore
layer_data = node.callDecoration("getLayerData")
if not layer_data:
@ -452,9 +458,11 @@ class SimulationView(CuraView):
busyChanged = Signal()
activityChanged = Signal()
## Hackish way to ensure the proxy is already created, which ensures that the layerview.qml is already created
# as this caused some issues.
def getProxy(self, engine, script_engine):
"""Hackish way to ensure the proxy is already created
which ensures that the layerview.qml is already created as this caused some issues.
"""
if self._proxy is None:
self._proxy = SimulationViewProxy(self)
return self._proxy

View file

@ -26,10 +26,13 @@ if TYPE_CHECKING:
catalog = i18nCatalog("cura")
## This Extension runs in the background and sends several bits of information to the Ultimaker servers.
# The data is only sent when the user in question gave permission to do so. All data is anonymous and
# no model files are being sent (Just a SHA256 hash of the model).
class SliceInfo(QObject, Extension):
"""This Extension runs in the background and sends several bits of information to the Ultimaker servers.
The data is only sent when the user in question gave permission to do so. All data is anonymous and
no model files are being sent (Just a SHA256 hash of the model).
"""
info_url = "https://stats.ultimaker.com/api/cura"
def __init__(self, parent = None):
@ -54,9 +57,11 @@ class SliceInfo(QObject, Extension):
if self._more_info_dialog is None:
self._more_info_dialog = self._createDialog("MoreInfoWindow.qml")
## Perform action based on user input.
# Note that clicking "Disable" won't actually disable the data sending, but rather take the user to preferences where they can disable it.
def messageActionTriggered(self, message_id, action_id):
"""Perform action based on user input.
Note that clicking "Disable" won't actually disable the data sending, but rather take the user to preferences where they can disable it.
"""
self._application.getPreferences().setValue("info/asked_send_slice_info", True)
if action_id == "MoreInfo":
self.showMoreInfoDialog()

View file

@ -33,9 +33,10 @@ import math
catalog = i18nCatalog("cura")
## Standard view for mesh models.
class SolidView(View):
"""Standard view for mesh models."""
_show_xray_warning_preference = "view/show_xray_warning"
def __init__(self):

View file

@ -9,8 +9,12 @@ from PyQt5.QtCore import Qt, pyqtProperty
from UM.Qt.ListModel import ListModel
## Model that holds cura packages. By setting the filter property the instances held by this model can be changed.
class AuthorsModel(ListModel):
"""Model that holds cura packages.
By setting the filter property the instances held by this model can be changed.
"""
def __init__(self, parent = None) -> None:
super().__init__(parent)
@ -67,9 +71,11 @@ class AuthorsModel(ListModel):
filtered_items.sort(key = lambda k: k["name"])
self.setItems(filtered_items)
## Set the filter of this model based on a string.
# \param filter_dict \type{Dict} Dictionary to do the filtering by.
def setFilter(self, filter_dict: Dict[str, str]) -> None:
"""Set the filter of this model based on a string.
:param filter_dict: Dictionary to do the filtering by.
"""
if filter_dict != self._filter:
self._filter = filter_dict
self._update()

View file

@ -20,9 +20,9 @@ class CloudApiModel:
cloud_api_version=cloud_api_version,
)
## https://api.ultimaker.com/cura-packages/v1/user/packages/{package_id}
@classmethod
def userPackageUrl(cls, package_id: str) -> str:
"""https://api.ultimaker.com/cura-packages/v1/user/packages/{package_id}"""
return (CloudApiModel.api_url_user_packages + "/{package_id}").format(
package_id=package_id

View file

@ -8,9 +8,11 @@ from UM.Signal import Signal
from .SubscribedPackagesModel import SubscribedPackagesModel
## Shows a list of packages to be added or removed. The user can select which packages to (un)install. The user's
# choices are emitted on the `packageMutations` Signal.
class DiscrepanciesPresenter(QObject):
"""Shows a list of packages to be added or removed. The user can select which packages to (un)install. The user's
choices are emitted on the `packageMutations` Signal.
"""
def __init__(self, app: QtApplication) -> None:
super().__init__(app)

View file

@ -16,9 +16,11 @@ from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope
from .SubscribedPackagesModel import SubscribedPackagesModel
## Downloads a set of packages from the Ultimaker Cloud Marketplace
# use download() exactly once: should not be used for multiple sets of downloads since this class contains state
class DownloadPresenter:
"""Downloads a set of packages from the Ultimaker Cloud Marketplace
use download() exactly once: should not be used for multiple sets of downloads since this class contains state
"""
DISK_WRITE_BUFFER_SIZE = 256 * 1024 # 256 KB

View file

@ -43,10 +43,12 @@ class LicensePresenter(QObject):
self._compatibility_dialog_path = "resources/qml/dialogs/ToolboxLicenseDialog.qml"
## Show a license dialog for multiple packages where users can read a license and accept or decline them
# \param plugin_path: Root directory of the Toolbox plugin
# \param packages: Dict[package id, file path]
def present(self, plugin_path: str, packages: Dict[str, Dict[str, str]]) -> None:
"""Show a license dialog for multiple packages where users can read a license and accept or decline them
:param plugin_path: Root directory of the Toolbox plugin
:param packages: Dict[package id, file path]
"""
if self._presented:
Logger.error("{clazz} is single-use. Create a new {clazz} instead", clazz=self.__class__.__name__)
return

View file

@ -3,9 +3,11 @@ from UM.Message import Message
from cura.CuraApplication import CuraApplication
## Presents a dialog telling the user that a restart is required to apply changes
# Since we cannot restart Cura, the app is closed instead when the button is clicked
class RestartApplicationPresenter:
"""Presents a dialog telling the user that a restart is required to apply changes
Since we cannot restart Cura, the app is closed instead when the button is clicked
"""
def __init__(self, app: CuraApplication) -> None:
self._app = app
self._i18n_catalog = i18nCatalog("cura")

View file

@ -16,20 +16,23 @@ from .RestartApplicationPresenter import RestartApplicationPresenter
from .SubscribedPackagesModel import SubscribedPackagesModel
## Orchestrates the synchronizing of packages from the user account to the installed packages
# Example flow:
# - CloudPackageChecker compares a list of packages the user `subscribed` to in their account
# If there are `discrepancies` between the account and locally installed packages, they are emitted
# - DiscrepanciesPresenter shows a list of packages to be added or removed to the user. It emits the `packageMutations`
# the user selected to be performed
# - The SyncOrchestrator uses PackageManager to remove local packages the users wants to see removed
# - The DownloadPresenter shows a download progress dialog. It emits A tuple of succeeded and failed downloads
# - The LicensePresenter extracts licenses from the downloaded packages and presents a license for each package to
# be installed. It emits the `licenseAnswers` signal for accept or declines
# - The CloudApiClient removes the declined packages from the account
# - The SyncOrchestrator uses PackageManager to install the downloaded packages and delete temp files.
# - The RestartApplicationPresenter notifies the user that a restart is required for changes to take effect
class SyncOrchestrator(Extension):
"""Orchestrates the synchronizing of packages from the user account to the installed packages
Example flow:
- CloudPackageChecker compares a list of packages the user `subscribed` to in their account
If there are `discrepancies` between the account and locally installed packages, they are emitted
- DiscrepanciesPresenter shows a list of packages to be added or removed to the user. It emits the `packageMutations`
the user selected to be performed
- The SyncOrchestrator uses PackageManager to remove local packages the users wants to see removed
- The DownloadPresenter shows a download progress dialog. It emits A tuple of succeeded and failed downloads
- The LicensePresenter extracts licenses from the downloaded packages and presents a license for each package to
be installed. It emits the `licenseAnswers` signal for accept or declines
- The CloudApiClient removes the declined packages from the account
- The SyncOrchestrator uses PackageManager to install the downloaded packages and delete temp files.
- The RestartApplicationPresenter notifies the user that a restart is required for changes to take effect
"""
def __init__(self, app: CuraApplication) -> None:
super().__init__()
@ -63,10 +66,12 @@ class SyncOrchestrator(Extension):
self._download_presenter.done.connect(self._onDownloadFinished)
self._download_presenter.download(mutations)
## Called when a set of packages have finished downloading
# \param success_items: Dict[package_id, Dict[str, str]]
# \param error_items: List[package_id]
def _onDownloadFinished(self, success_items: Dict[str, Dict[str, str]], error_items: List[str]) -> None:
"""Called when a set of packages have finished downloading
:param success_items:: Dict[package_id, Dict[str, str]]
:param error_items:: List[package_id]
"""
if error_items:
message = i18n_catalog.i18nc("@info:generic", "{} plugins failed to download".format(len(error_items)))
self._showErrorMessage(message)
@ -96,7 +101,8 @@ class SyncOrchestrator(Extension):
if has_changes:
self._restart_presenter.present()
## Logs an error and shows it to the user
def _showErrorMessage(self, text: str):
"""Logs an error and shows it to the user"""
Logger.error(text)
Message(text, lifetime=0).show()

View file

@ -6,8 +6,9 @@ from PyQt5.QtCore import Qt
from UM.Qt.ListModel import ListModel
## Model that holds supported configurations (for material/quality packages).
class ConfigsModel(ListModel):
"""Model that holds supported configurations (for material/quality packages)."""
def __init__(self, parent = None):
super().__init__(parent)

View file

@ -12,8 +12,12 @@ from UM.Qt.ListModel import ListModel
from .ConfigsModel import ConfigsModel
## Model that holds Cura packages. By setting the filter property the instances held by this model can be changed.
class PackagesModel(ListModel):
"""Model that holds Cura packages.
By setting the filter property the instances held by this model can be changed.
"""
def __init__(self, parent = None):
super().__init__(parent)
@ -131,9 +135,11 @@ class PackagesModel(ListModel):
filtered_items.sort(key = lambda k: k["name"])
self.setItems(filtered_items)
## Set the filter of this model based on a string.
# \param filter_dict \type{Dict} Dictionary to do the filtering by.
def setFilter(self, filter_dict: Dict[str, str]) -> None:
"""Set the filter of this model based on a string.
:param filter_dict: Dictionary to do the filtering by.
"""
if filter_dict != self._filter:
self._filter = filter_dict
self._update()

View file

@ -37,10 +37,10 @@ try:
except ImportError:
CuraMarketplaceRoot = DEFAULT_MARKETPLACE_ROOT
# todo Remove license and download dialog, use SyncOrchestrator instead
## Provides a marketplace for users to download plugins an materials
class Toolbox(QObject, Extension):
"""Provides a marketplace for users to download plugins an materials"""
def __init__(self, application: CuraApplication) -> None:
super().__init__()
@ -135,8 +135,9 @@ class Toolbox(QObject, Extension):
closeLicenseDialog = pyqtSignal()
uninstallVariablesChanged = pyqtSignal()
## Go back to the start state (welcome screen or loading if no login required)
def _restart(self):
"""Go back to the start state (welcome screen or loading if no login required)"""
# For an Essentials build, login is mandatory
if not self._application.getCuraAPI().account.isLoggedIn and ApplicationMetadata.IsEnterpriseVersion:
self.setViewPage("welcome")
@ -311,10 +312,13 @@ class Toolbox(QObject, Extension):
self.restartRequiredChanged.emit()
return package_id
## Check package usage and uninstall
# If the package is in use, you'll get a confirmation dialog to set everything to default
@pyqtSlot(str)
def checkPackageUsageAndUninstall(self, package_id: str) -> None:
"""Check package usage and uninstall
If the package is in use, you'll get a confirmation dialog to set everything to default
"""
package_used_materials, package_used_qualities = self._package_manager.getMachinesUsingPackage(package_id)
if package_used_materials or package_used_qualities:
# Set up "uninstall variables" for resetMaterialsQualitiesAndUninstall
@ -352,10 +356,13 @@ class Toolbox(QObject, Extension):
if self._confirm_reset_dialog is not None:
self._confirm_reset_dialog.close()
## Uses "uninstall variables" to reset qualities and materials, then uninstall
# It's used as an action on Confirm reset on Uninstall
@pyqtSlot()
def resetMaterialsQualitiesAndUninstall(self) -> None:
"""Uses "uninstall variables" to reset qualities and materials, then uninstall
It's used as an action on Confirm reset on Uninstall
"""
application = CuraApplication.getInstance()
machine_manager = application.getMachineManager()
container_tree = ContainerTree.getInstance()
@ -418,8 +425,9 @@ class Toolbox(QObject, Extension):
self._restart_required = True
self.restartRequiredChanged.emit()
## Actual update packages that are in self._to_update
def _update(self) -> None:
"""Actual update packages that are in self._to_update"""
if self._to_update:
plugin_id = self._to_update.pop(0)
remote_package = self.getRemotePackage(plugin_id)
@ -433,9 +441,10 @@ class Toolbox(QObject, Extension):
if self._to_update:
self._application.callLater(self._update)
## Update a plugin by plugin_id
@pyqtSlot(str)
def update(self, plugin_id: str) -> None:
"""Update a plugin by plugin_id"""
self._to_update.append(plugin_id)
self._application.callLater(self._update)
@ -714,9 +723,10 @@ class Toolbox(QObject, Extension):
self._active_package = package
self.activePackageChanged.emit()
## The active package is the package that is currently being downloaded
@pyqtProperty(QObject, fset = setActivePackage, notify = activePackageChanged)
def activePackage(self) -> Optional[QObject]:
"""The active package is the package that is currently being downloaded"""
return self._active_package
def setViewCategory(self, category: str = "plugin") -> None:
@ -724,7 +734,7 @@ class Toolbox(QObject, Extension):
self._view_category = category
self.viewChanged.emit()
## Function explicitly defined so that it can be called through the callExtensionsMethod
# Function explicitly defined so that it can be called through the callExtensionsMethod
# which cannot receive arguments.
def setViewCategoryToMaterials(self) -> None:
self.setViewCategory("material")

View file

@ -22,8 +22,10 @@ from cura.Scene.SliceableObjectDecorator import SliceableObjectDecorator # Adde
if TYPE_CHECKING:
from UM.Scene.SceneNode import SceneNode
## Class that leverages Trimesh to import files.
class TrimeshReader(MeshReader):
"""Class that leverages Trimesh to import files."""
def __init__(self) -> None:
super().__init__()
@ -79,11 +81,13 @@ class TrimeshReader(MeshReader):
)
)
## Reads a file using Trimesh.
# \param file_name The file path. This is assumed to be one of the file
# types that Trimesh can read. It will not be checked again.
# \return A scene node that contains the file's contents.
def _read(self, file_name: str) -> Union["SceneNode", List["SceneNode"]]:
"""Reads a file using Trimesh.
:param file_name: The file path. This is assumed to be one of the file
types that Trimesh can read. It will not be checked again.
:return: A scene node that contains the file's contents.
"""
# CURA-6739
# GLTF files are essentially JSON files. If you directly give a file name to trimesh.load(), it will
# try to figure out the format, but for GLTF, it loads it as a binary file with flags "rb", and the json.load()
@ -130,13 +134,14 @@ class TrimeshReader(MeshReader):
node.setParent(group_node)
return group_node
## Converts a Trimesh to Uranium's MeshData.
# \param tri_node A Trimesh containing the contents of a file that was
# just read.
# \param file_name The full original filename used to watch for changes
# \return Mesh data from the Trimesh in a way that Uranium can understand
# it.
def _toMeshData(self, tri_node: trimesh.base.Trimesh, file_name: str = "") -> MeshData:
"""Converts a Trimesh to Uranium's MeshData.
:param tri_node: A Trimesh containing the contents of a file that was just read.
:param file_name: The full original filename used to watch for changes
:return: Mesh data from the Trimesh in a way that Uranium can understand it.
"""
tri_faces = tri_node.faces
tri_vertices = tri_node.vertices

View file

@ -24,13 +24,15 @@ from ..Models.Http.CloudPrintJobResponse import CloudPrintJobResponse
from ..Models.Http.CloudPrintJobUploadRequest import CloudPrintJobUploadRequest
from ..Models.Http.CloudPrintResponse import CloudPrintResponse
## The generic type variable used to document the methods below.
CloudApiClientModel = TypeVar("CloudApiClientModel", bound=BaseModel)
"""The generic type variable used to document the methods below."""
## 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:
"""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.
"""
# The cloud URL to use for this remote cluster.
ROOT_PATH = UltimakerCloudAuthentication.CuraCloudAPIRoot
@ -42,10 +44,13 @@ class CloudApiClient:
# In order to avoid garbage collection we keep the callbacks in this list.
_anti_gc_callbacks = [] # type: List[Callable[[Any], None]]
## 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, app: CuraApplication, on_error: Callable[[List[CloudError]], None]) -> None:
"""Initializes a new cloud API client.
:param app:
:param account: The user's account object
:param on_error: The callback to be called whenever we receive errors from the server.
"""
super().__init__()
self._app = app
self._account = app.getCuraAPI().account
@ -54,14 +59,18 @@ class CloudApiClient:
self._on_error = on_error
self._upload = None # type: Optional[ToolPathUploader]
## Gets the account used for the API.
@property
def account(self) -> Account:
"""Gets the account used for the API."""
return self._account
## 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[CloudClusterResponse]], Any], failed: Callable) -> None:
"""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.
"""
url = "{}/clusters?status=active".format(self.CLUSTER_API_ROOT)
self._http.get(url,
scope = self._scope,
@ -69,21 +78,28 @@ class CloudApiClient:
error_callback = failed,
timeout = self.DEFAULT_REQUEST_TIMEOUT)
## 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:
"""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.
"""
url = "{}/clusters/{}/status".format(self.CLUSTER_API_ROOT, cluster_id)
self._http.get(url,
scope = self._scope,
callback = self._parseCallback(on_finished, CloudClusterStatus),
timeout = self.DEFAULT_REQUEST_TIMEOUT)
## 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: CloudPrintJobUploadRequest,
on_finished: Callable[[CloudPrintJobResponse], Any]) -> None:
"""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.
"""
url = "{}/jobs/upload".format(self.CURA_API_ROOT)
data = json.dumps({"data": request.toDict()}).encode()
@ -93,14 +109,17 @@ class CloudApiClient:
callback = self._parseCallback(on_finished, CloudPrintJobResponse),
timeout = self.DEFAULT_REQUEST_TIMEOUT)
## Uploads a print job tool path to the cloud.
# \param print_job: The object received after requesting an upload with `self.requestUpload`.
# \param mesh: The tool path data to be uploaded.
# \param on_finished: The function to be called after the upload is successful.
# \param on_progress: A function to be called during upload progress. It receives a percentage (0-100).
# \param on_error: A function to be called if the upload fails.
def uploadToolPath(self, print_job: CloudPrintJobResponse, mesh: bytes, on_finished: Callable[[], Any],
on_progress: Callable[[int], Any], on_error: Callable[[], Any]):
"""Uploads a print job tool path to the cloud.
:param print_job: The object received after requesting an upload with `self.requestUpload`.
:param mesh: The tool path data to be uploaded.
:param on_finished: The function to be called after the upload is successful.
:param on_progress: A function to be called during upload progress. It receives a percentage (0-100).
:param on_error: A function to be called if the upload fails.
"""
self._upload = ToolPathUploader(self._http, print_job, mesh, on_finished, on_progress, on_error)
self._upload.start()
@ -116,12 +135,16 @@ class CloudApiClient:
callback = self._parseCallback(on_finished, CloudPrintResponse),
timeout = self.DEFAULT_REQUEST_TIMEOUT)
## Send a print job action to the cluster for the given print job.
# \param cluster_id: The ID of the cluster.
# \param cluster_job_id: The ID of the print job within the cluster.
# \param action: The name of the action to execute.
def doPrintJobAction(self, cluster_id: str, cluster_job_id: str, action: str,
data: Optional[Dict[str, Any]] = None) -> None:
"""Send a print job action to the cluster for the given print job.
:param cluster_id: The ID of the cluster.
:param cluster_job_id: The ID of the print job within the cluster.
:param action: The name of the action to execute.
"""
body = json.dumps({"data": data}).encode() if data else b""
url = "{}/clusters/{}/print_jobs/{}/action/{}".format(self.CLUSTER_API_ROOT, cluster_id, cluster_job_id, action)
self._http.post(url,
@ -129,10 +152,13 @@ class CloudApiClient:
data = body,
timeout = self.DEFAULT_REQUEST_TIMEOUT)
## 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:
"""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.
"""
request = QNetworkRequest(QUrl(path))
if content_type:
request.setHeader(QNetworkRequest.ContentTypeHeader, content_type)
@ -141,11 +167,14 @@ class CloudApiClient:
request.setRawHeader(b"Authorization", "Bearer {}".format(access_token).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]]:
"""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.
"""
status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute)
try:
response = bytes(reply.readAll()).decode()
@ -156,14 +185,15 @@ class CloudApiClient:
Logger.logException("e", "Could not parse the stardust response: %s", error.toDict())
return status_code, {"errors": [error.toDict()]}
## 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_class: 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: Union[Callable[[CloudApiClientModel], Any],
Callable[[List[CloudApiClientModel]], Any]],
model_class: Type[CloudApiClientModel]) -> None:
def _parseModels(self, response: Dict[str, Any], on_finished: Union[Callable[[CloudApiClientModel], Any],
Callable[[List[CloudApiClientModel]], Any]], model_class: Type[CloudApiClientModel]) -> None:
"""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_class: The type of the model to convert the response to. It may either be a single record or a list.
"""
if "data" in response:
data = response["data"]
if isinstance(data, list):
@ -179,18 +209,23 @@ class CloudApiClient:
else:
Logger.log("e", "Cannot find data or errors in the cloud response: %s", response)
## Creates a callback function so that it includes the parsing of the response into the correct model.
# The callback is added to the 'finished' signal of the reply.
# \param reply: The reply that should be listened to.
# \param on_finished: The callback in case the response is successful. Depending on the endpoint it will be either
# a list or a single item.
# \param model: The type of the model to convert the response to.
def _parseCallback(self,
on_finished: Union[Callable[[CloudApiClientModel], Any],
Callable[[List[CloudApiClientModel]], Any]],
model: Type[CloudApiClientModel],
on_error: Optional[Callable] = None) -> Callable[[QNetworkReply], None]:
"""Creates a callback function so that it includes the parsing of the response into the correct model.
The callback is added to the 'finished' signal of the reply.
:param reply: The reply that should be listened to.
:param on_finished: The callback in case the response is successful. Depending on the endpoint it will be either
a list or a single item.
:param model: The type of the model to convert the response to.
"""
def parse(reply: QNetworkReply) -> None:
self._anti_gc_callbacks.remove(parse)
# Don't try to parse the reply if we didn't get one

View file

@ -35,11 +35,13 @@ from ..Models.Http.ClusterPrintJobStatus import ClusterPrintJobStatus
I18N_CATALOG = i18nCatalog("cura")
## 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.
class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice):
"""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.
"""
# The interval with which the remote cluster is checked.
# We can do this relatively often as this API call is quite fast.
@ -56,11 +58,13 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice):
# Therefore we create a private signal used to trigger the printersChanged signal.
_cloudClusterPrintersChanged = pyqtSignal()
## Creates a new cloud output device
# \param api_client: The client that will run the API calls
# \param cluster: The device response received from the cloud API.
# \param parent: The optional parent of this output device.
def __init__(self, api_client: CloudApiClient, cluster: CloudClusterResponse, parent: QObject = None) -> None:
"""Creates a new cloud output device
:param api_client: The client that will run the API calls
:param cluster: The device response received from the cloud API.
:param parent: The optional parent of this output device.
"""
# The following properties are expected on each networked output device.
# Because the cloud connection does not off all of these, we manually construct this version here.
@ -99,8 +103,9 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice):
self._tool_path = None # type: Optional[bytes]
self._uploaded_print_job = None # type: Optional[CloudPrintJobResponse]
## Connects this device.
def connect(self) -> None:
"""Connects this device."""
if self.isConnected():
return
super().connect()
@ -108,21 +113,24 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice):
CuraApplication.getInstance().getBackend().backendStateChange.connect(self._onBackendStateChange)
self._update()
## Disconnects the device
def disconnect(self) -> None:
"""Disconnects the device"""
if not self.isConnected():
return
super().disconnect()
Logger.log("i", "Disconnected from cluster %s", self.key)
CuraApplication.getInstance().getBackend().backendStateChange.disconnect(self._onBackendStateChange)
## Resets the print job that was uploaded to force a new upload, runs whenever the user re-slices.
def _onBackendStateChange(self, _: BackendState) -> None:
"""Resets the print job that was uploaded to force a new upload, runs whenever the user re-slices."""
self._tool_path = None
self._uploaded_print_job = None
## Checks whether the given network key is found in the cloud's host name
def matchesNetworkKey(self, network_key: str) -> bool:
"""Checks whether the given network key is found in the cloud's host name"""
# Typically, a network key looks like "ultimakersystem-aabbccdd0011._ultimaker._tcp.local."
# the host name should then be "ultimakersystem-aabbccdd0011"
if network_key.startswith(str(self.clusterData.host_name or "")):
@ -133,15 +141,17 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice):
return True
return False
## Set all the interface elements and texts for this output device.
def _setInterfaceElements(self) -> None:
"""Set all the interface elements and texts for this output device."""
self.setPriority(2) # Make sure we end up below the local networking and above 'save to file'.
self.setShortDescription(I18N_CATALOG.i18nc("@action:button", "Print via Cloud"))
self.setDescription(I18N_CATALOG.i18nc("@properties:tooltip", "Print via Cloud"))
self.setConnectionText(I18N_CATALOG.i18nc("@info:status", "Connected via Cloud"))
## Called when the network data should be updated.
def _update(self) -> None:
"""Called when the network data should be updated."""
super()._update()
if time() - self._time_of_last_request < self.CHECK_CLUSTER_INTERVAL:
return # avoid calling the cloud too often
@ -153,9 +163,11 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice):
else:
self.setAuthenticationState(AuthState.NotAuthenticated)
## Method called when HTTP request to status endpoint is finished.
# Contains both printers and print jobs statuses in a single response.
def _onStatusCallFinished(self, status: CloudClusterStatus) -> None:
"""Method called when HTTP request to status endpoint is finished.
Contains both printers and print jobs statuses in a single response.
"""
self._responseReceived()
if status.printers != self._received_printers:
self._received_printers = status.printers
@ -164,10 +176,11 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice):
self._received_print_jobs = status.print_jobs
self._updatePrintJobs(status.print_jobs)
## 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_mimetypes: bool = False,
file_handler: Optional[FileHandler] = None, filter_by_machine: bool = False, **kwargs) -> None:
"""Called when Cura requests an output device to receive a (G-code) file."""
# Show an error message if we're already sending a job.
if self._progress.visible:
PrintJobUploadBlockedMessage().show()
@ -187,9 +200,11 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice):
job.finished.connect(self._onPrintJobCreated)
job.start()
## Handler for when the print job was created locally.
# It can now be sent over the cloud.
def _onPrintJobCreated(self, job: ExportFileJob) -> None:
"""Handler for when the print job was created locally.
It can now be sent over the cloud.
"""
output = job.getOutput()
self._tool_path = output # store the tool path to prevent re-uploading when printing the same file again
file_name = job.getFileName()
@ -200,9 +215,11 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice):
)
self._api.requestUpload(request, self._uploadPrintJob)
## Uploads the mesh when the print job was registered with the cloud API.
# \param job_response: The response received from the cloud API.
def _uploadPrintJob(self, job_response: CloudPrintJobResponse) -> None:
"""Uploads the mesh when the print job was registered with the cloud API.
:param job_response: The response received from the cloud API.
"""
if not self._tool_path:
return self._onUploadError()
self._progress.show()
@ -210,38 +227,45 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice):
self._api.uploadToolPath(job_response, self._tool_path, self._onPrintJobUploaded, self._progress.update,
self._onUploadError)
## Requests the print to be sent to the printer when we finished uploading the mesh.
def _onPrintJobUploaded(self) -> None:
"""Requests the print to be sent to the printer when we finished uploading the mesh."""
self._progress.update(100)
print_job = cast(CloudPrintJobResponse, self._uploaded_print_job)
self._api.requestPrint(self.key, print_job.job_id, self._onPrintUploadCompleted)
## Shows a message when the upload has succeeded
# \param response: The response from the cloud API.
def _onPrintUploadCompleted(self, response: CloudPrintResponse) -> None:
"""Shows a message when the upload has succeeded
:param response: The response from the cloud API.
"""
self._progress.hide()
PrintJobUploadSuccessMessage().show()
self.writeFinished.emit()
## Displays the given message if uploading the mesh has failed
# \param message: The message to display.
def _onUploadError(self, message: str = None) -> None:
"""Displays the given message if uploading the mesh has failed
:param message: The message to display.
"""
self._progress.hide()
self._uploaded_print_job = None
PrintJobUploadErrorMessage(message).show()
self.writeError.emit()
## Whether the printer that this output device represents supports print job actions via the cloud.
@pyqtProperty(bool, notify=_cloudClusterPrintersChanged)
def supportsPrintJobActions(self) -> bool:
"""Whether the printer that this output device represents supports print job actions via the cloud."""
if not self._printers:
return False
version_number = self.printers[0].firmwareVersion.split(".")
firmware_version = Version([version_number[0], version_number[1], version_number[2]])
return firmware_version >= self.PRINT_JOB_ACTIONS_MIN_VERSION
## Set the remote print job state.
def setJobState(self, print_job_uuid: str, state: str) -> None:
"""Set the remote print job state."""
self._api.doPrintJobAction(self._cluster.cluster_id, print_job_uuid, state)
@pyqtSlot(str, name="sendJobToTop")
@ -265,18 +289,21 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice):
def openPrinterControlPanel(self) -> None:
QDesktopServices.openUrl(QUrl(self.clusterCloudUrl))
## Gets the cluster response from which this device was created.
@property
def clusterData(self) -> CloudClusterResponse:
"""Gets the cluster response from which this device was created."""
return self._cluster
## Updates the cluster data from the cloud.
@clusterData.setter
def clusterData(self, value: CloudClusterResponse) -> None:
"""Updates the cluster data from the cloud."""
self._cluster = value
## Gets the URL on which to monitor the cluster via the cloud.
@property
def clusterCloudUrl(self) -> str:
"""Gets the URL on which to monitor the cluster via the cloud."""
root_url_prefix = "-staging" if self._account.is_staging else ""
return "https://mycloud{}.ultimaker.com/app/jobs/{}".format(root_url_prefix, self.clusterData.cluster_id)

View file

@ -22,7 +22,7 @@ from ..Models.Http.CloudClusterResponse import CloudClusterResponse
class CloudOutputDeviceManager:
"""The cloud output device manager is responsible for using the Ultimaker Cloud APIs to manage remote clusters.
Keeping all cloud related logic in this class instead of the UM3OutputDevicePlugin results in more readable code.
API spec is available on https://api.ultimaker.com/docs/connect/spec/.
"""

View file

@ -10,8 +10,9 @@ from UM.TaskManagement.HttpRequestManager import HttpRequestManager
from ..Models.Http.CloudPrintJobResponse import CloudPrintJobResponse
## Class responsible for uploading meshes to the cloud in separate requests.
class ToolPathUploader:
"""Class responsible for uploading meshes to the cloud in separate requests."""
# The maximum amount of times to retry if the server returns one of the RETRY_HTTP_CODES
MAX_RETRIES = 10
@ -22,16 +23,19 @@ class ToolPathUploader:
# The amount of bytes to send per request
BYTES_PER_REQUEST = 256 * 1024
## Creates a mesh upload object.
# \param http: The HttpRequestManager that will handle the HTTP requests.
# \param print_job: The print job response that was returned by the cloud after registering the upload.
# \param data: The mesh bytes to be uploaded.
# \param on_finished: The method to be called when done.
# \param on_progress: The method to be called when the progress changes (receives a percentage 0-100).
# \param on_error: The method to be called when an error occurs.
def __init__(self, http: HttpRequestManager, print_job: CloudPrintJobResponse, data: bytes,
on_finished: Callable[[], Any], on_progress: Callable[[int], Any], on_error: Callable[[], Any]
) -> None:
"""Creates a mesh upload object.
:param manager: The network access manager that will handle the HTTP requests.
:param print_job: The print job response that was returned by the cloud after registering the upload.
:param data: The mesh bytes to be uploaded.
:param on_finished: The method to be called when done.
:param on_progress: The method to be called when the progress changes (receives a percentage 0-100).
:param on_error: The method to be called when an error occurs.
"""
self._http = http
self._print_job = print_job
self._data = data
@ -44,19 +48,23 @@ class ToolPathUploader:
self._retries = 0
self._finished = False
## Returns the print job for which this object was created.
@property
def printJob(self):
"""Returns the print job for which this object was created."""
return self._print_job
## Determines the bytes that should be uploaded next.
# \return: A tuple with the first and the last byte to upload.
def _chunkRange(self) -> Tuple[int, int]:
"""Determines the bytes that should be uploaded next.
:return: A tuple with the first and the last byte to upload.
"""
last_byte = min(len(self._data), self._sent_bytes + self.BYTES_PER_REQUEST)
return self._sent_bytes, last_byte
## Starts uploading the mesh.
def start(self) -> None:
"""Starts uploading the mesh."""
if self._finished:
# reset state.
self._sent_bytes = 0
@ -64,13 +72,15 @@ class ToolPathUploader:
self._finished = False
self._uploadChunk()
## Stops uploading the mesh, marking it as finished.
def stop(self):
"""Stops uploading the mesh, marking it as finished."""
Logger.log("i", "Stopped uploading")
self._finished = True
## Uploads a chunk of the mesh to the cloud.
def _uploadChunk(self) -> None:
"""Uploads a chunk of the mesh to the cloud."""
if self._finished:
raise ValueError("The upload is already finished")
@ -93,10 +103,12 @@ class ToolPathUploader:
upload_progress_callback = self._progressCallback
)
## Handles an update to the upload progress
# \param bytes_sent: The amount of bytes sent in the current request.
# \param bytes_total: The amount of bytes to send in the current request.
def _progressCallback(self, bytes_sent: int, bytes_total: int) -> None:
"""Handles an update to the upload progress
:param bytes_sent: The amount of bytes sent in the current request.
:param bytes_total: The amount of bytes to send in the current request.
"""
Logger.log("i", "Progress callback %s / %s", bytes_sent, bytes_total)
if bytes_total:
total_sent = self._sent_bytes + bytes_sent
@ -104,13 +116,16 @@ class ToolPathUploader:
## Handles an error uploading.
def _errorCallback(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None:
"""Handles an error uploading."""
body = bytes(reply.readAll()).decode()
Logger.log("e", "Received error while uploading: %s", body)
self.stop()
self._on_error()
## Checks whether a chunk of data was uploaded successfully, starting the next chunk if needed.
def _finishedCallback(self, reply: QNetworkReply) -> None:
"""Checks whether a chunk of data was uploaded successfully, starting the next chunk if needed."""
Logger.log("i", "Finished callback %s %s",
reply.attribute(QNetworkRequest.HttpStatusCodeAttribute), reply.url().toString())
@ -135,8 +150,9 @@ class ToolPathUploader:
[bytes(header).decode() for header in reply.rawHeaderList()], bytes(reply.readAll()).decode())
self._chunkUploaded()
## Handles a chunk of data being uploaded, starting the next chunk if needed.
def _chunkUploaded(self) -> None:
"""Handles a chunk of data being uploaded, starting the next chunk if needed."""
# We got a successful response. Let's start the next chunk or report the upload is finished.
first_byte, last_byte = self._chunkRange()
self._sent_bytes += last_byte - first_byte

View file

@ -9,8 +9,8 @@ from cura.CuraApplication import CuraApplication
from .MeshFormatHandler import MeshFormatHandler
## Job that exports the build plate to the correct file format for the target cluster.
class ExportFileJob(WriteFileJob):
"""Job that exports the build plate to the correct file format for the target cluster."""
def __init__(self, file_handler: Optional[FileHandler], nodes: List[SceneNode], firmware_version: str) -> None:
@ -27,12 +27,14 @@ class ExportFileJob(WriteFileJob):
extension = self._mesh_format_handler.preferred_format.get("extension", "")
self.setFileName("{}.{}".format(job_name, extension))
## Get the mime type of the selected export file type.
def getMimeType(self) -> str:
"""Get the mime type of the selected export file type."""
return self._mesh_format_handler.mime_type
## Get the job result as bytes as that is what we need to upload to the cluster.
def getOutput(self) -> bytes:
"""Get the job result as bytes as that is what we need to upload to the cluster."""
output = self.getStream().getvalue()
if isinstance(output, str):
output = output.encode("utf-8")

View file

@ -16,8 +16,9 @@ from cura.CuraApplication import CuraApplication
I18N_CATALOG = i18nCatalog("cura")
## This class is responsible for choosing the formats used by the connected clusters.
class MeshFormatHandler:
"""This class is responsible for choosing the formats used by the connected clusters."""
def __init__(self, file_handler: Optional[FileHandler], firmware_version: str) -> None:
self._file_handler = file_handler or CuraApplication.getInstance().getMeshFileHandler()
@ -28,42 +29,50 @@ class MeshFormatHandler:
def is_valid(self) -> bool:
return bool(self._writer)
## Chooses the preferred file format.
# \return A dict with the file format details, with the following keys:
# {id: str, extension: str, description: str, mime_type: str, mode: int, hide_in_file_dialog: bool}
@property
def preferred_format(self) -> Dict[str, Union[str, int, bool]]:
"""Chooses the preferred file format.
:return: A dict with the file format details, with the following keys:
{id: str, extension: str, description: str, mime_type: str, mode: int, hide_in_file_dialog: bool}
"""
return self._preferred_format
## Gets the file writer for the given file handler and mime type.
# \return A file writer.
@property
def writer(self) -> Optional[FileWriter]:
"""Gets the file writer for the given file handler and mime type.
:return: A file writer.
"""
return self._writer
@property
def mime_type(self) -> str:
return cast(str, self._preferred_format["mime_type"])
## Gets the file mode (FileWriter.OutputMode.TextMode or FileWriter.OutputMode.BinaryMode)
@property
def file_mode(self) -> int:
"""Gets the file mode (FileWriter.OutputMode.TextMode or FileWriter.OutputMode.BinaryMode)"""
return cast(int, self._preferred_format["mode"])
## Gets the file extension
@property
def file_extension(self) -> str:
"""Gets the file extension"""
return cast(str, self._preferred_format["extension"])
## Creates the right kind of stream based on the preferred format.
def createStream(self) -> Union[io.BytesIO, io.StringIO]:
"""Creates the right kind of stream based on the preferred format."""
if self.file_mode == FileWriter.OutputMode.TextMode:
return io.StringIO()
else:
return io.BytesIO()
## Writes the mesh and returns its value.
def getBytes(self, nodes: List[SceneNode]) -> bytes:
"""Writes the mesh and returns its value."""
if self.writer is None:
raise ValueError("There is no writer for the mesh format handler.")
stream = self.createStream()
@ -73,10 +82,12 @@ class MeshFormatHandler:
value = value.encode()
return value
## Chooses the preferred file format for the given file handler.
# \param firmware_version: The version of the firmware.
# \return A dict with the file format details.
def _getPreferredFormat(self, firmware_version: str) -> Dict[str, Union[str, int, bool]]:
"""Chooses the preferred file format for the given file handler.
:param firmware_version: The version of the firmware.
:return: A dict with the file format details.
"""
# Formats supported by this application (file types that we can actually write).
application = CuraApplication.getInstance()
@ -108,9 +119,11 @@ class MeshFormatHandler:
)
return file_formats[0]
## Gets the file writer for the given file handler and mime type.
# \param mime_type: The mine type.
# \return A file writer.
def _getWriter(self, mime_type: str) -> Optional[FileWriter]:
"""Gets the file writer for the given file handler and mime type.
:param mime_type: The mine type.
:return: A file writer.
"""
# Just take the first file format available.
return self._file_handler.getWriterByMimeType(mime_type)

View file

@ -7,12 +7,12 @@ from UM.Message import Message
I18N_CATALOG = i18nCatalog("cura")
## Message shown when trying to connect to a legacy printer device.
class LegacyDeviceNoLongerSupportedMessage(Message):
# Singleton used to prevent duplicate messages of this type at the same time.
"""Message shown when trying to connect to a legacy printer device."""
__is_visible = False
"""Singleton used to prevent duplicate messages of this type at the same time."""
def __init__(self) -> None:
super().__init__(
text = I18N_CATALOG.i18nc("@info:status", "You are attempting to connect to a printer that is not "

View file

@ -13,11 +13,11 @@ if TYPE_CHECKING:
I18N_CATALOG = i18nCatalog("cura")
## Message shown when sending material files to cluster host.
class MaterialSyncMessage(Message):
"""Message shown when sending material files to cluster host."""
# Singleton used to prevent duplicate messages of this type at the same time.
__is_visible = False
"""Singleton used to prevent duplicate messages of this type at the same time."""
def __init__(self, device: "UltimakerNetworkedPrinterOutputDevice") -> None:
super().__init__(

View file

@ -16,11 +16,11 @@ if TYPE_CHECKING:
I18N_CATALOG = i18nCatalog("cura")
## Message shown when trying to connect to a printer that is not a host.
class NotClusterHostMessage(Message):
"""Message shown when trying to connect to a printer that is not a host."""
# Singleton used to prevent duplicate messages of this type at the same time.
__is_visible = False
"""Singleton used to prevent duplicate messages of this type at the same time."""
def __init__(self, device: "UltimakerNetworkedPrinterOutputDevice") -> None:
super().__init__(

View file

@ -7,9 +7,9 @@ from UM.Message import Message
I18N_CATALOG = i18nCatalog("cura")
## Message shown when uploading a print job to a cluster is blocked because another upload is already in progress.
class PrintJobUploadBlockedMessage(Message):
"""Message shown when uploading a print job to a cluster is blocked because another upload is already in progress."""
def __init__(self) -> None:
super().__init__(
text = I18N_CATALOG.i18nc("@info:status", "Please wait until the current job has been sent."),

View file

@ -7,9 +7,9 @@ from UM.Message import Message
I18N_CATALOG = i18nCatalog("cura")
## Message shown when uploading a print job to a cluster failed.
class PrintJobUploadErrorMessage(Message):
"""Message shown when uploading a print job to a cluster failed."""
def __init__(self, message: str = None) -> None:
super().__init__(
text = message or I18N_CATALOG.i18nc("@info:text", "Could not upload the data to the printer."),

View file

@ -7,8 +7,9 @@ from UM.Message import Message
I18N_CATALOG = i18nCatalog("cura")
## Class responsible for showing a progress message while a mesh is being uploaded to the cloud.
class PrintJobUploadProgressMessage(Message):
"""Class responsible for showing a progress message while a mesh is being uploaded to the cloud."""
def __init__(self):
super().__init__(
title = I18N_CATALOG.i18nc("@info:status", "Sending Print Job"),
@ -19,14 +20,17 @@ class PrintJobUploadProgressMessage(Message):
use_inactivity_timer = False
)
## Shows the progress message.
def show(self):
"""Shows the progress message."""
self.setProgress(0)
super().show()
## Updates the percentage of the uploaded.
# \param percentage: The percentage amount (0-100).
def update(self, percentage: int) -> None:
"""Updates the percentage of the uploaded.
:param percentage: The percentage amount (0-100).
"""
if not self._visible:
super().show()
self.setProgress(percentage)

View file

@ -7,9 +7,9 @@ from UM.Message import Message
I18N_CATALOG = i18nCatalog("cura")
## Message shown when uploading a print job to a cluster succeeded.
class PrintJobUploadSuccessMessage(Message):
"""Message shown when uploading a print job to a cluster succeeded."""
def __init__(self) -> None:
super().__init__(
text = I18N_CATALOG.i18nc("@info:status", "Print job was successfully sent to the printer."),

View file

@ -18,45 +18,56 @@ class BaseModel:
def validate(self) -> None:
pass
## Checks whether the two models are equal.
# \param other: The other model.
# \return True if they are equal, False if they are different.
def __eq__(self, other):
"""Checks whether the two models are equal.
:param other: The other model.
:return: True if they are equal, False if they are different.
"""
return type(self) == type(other) and self.toDict() == other.toDict()
## Checks whether the two models are different.
# \param other: The other model.
# \return True if they are different, False if they are the same.
def __ne__(self, other) -> bool:
"""Checks whether the two models are different.
:param other: The other model.
:return: True if they are different, False if they are the same.
"""
return type(self) != type(other) or self.toDict() != other.toDict()
## Converts the model into a serializable dictionary
def toDict(self) -> Dict[str, Any]:
"""Converts the model into a serializable dictionary"""
return self.__dict__
## Parses a single model.
# \param model_class: The model class.
# \param values: The value of the model, which is usually a dictionary, but may also be already parsed.
# \return An instance of the model_class given.
@staticmethod
def parseModel(model_class: Type[T], values: Union[T, Dict[str, Any]]) -> T:
"""Parses a single model.
:param model_class: The model class.
:param values: The value of the model, which is usually a dictionary, but may also be already parsed.
:return: An instance of the model_class given.
"""
if isinstance(values, dict):
return model_class(**values)
return values
## Parses a list of models.
# \param model_class: The model class.
# \param values: The value of the list. Each value is usually a dictionary, but may also be already parsed.
# \return A list of instances of the model_class given.
@classmethod
def parseModels(cls, model_class: Type[T], values: List[Union[T, Dict[str, Any]]]) -> List[T]:
"""Parses a list of models.
:param model_class: The model class.
:param values: The value of the list. Each value is usually a dictionary, but may also be already parsed.
:return: A list of instances of the model_class given.
"""
return [cls.parseModel(model_class, value) for value in values]
## Parses the given date string.
# \param date: The date to parse.
# \return The parsed date.
@staticmethod
def parseDate(date: Union[str, datetime]) -> datetime:
"""Parses the given date string.
:param date: The date to parse.
:return: The parsed date.
"""
if isinstance(date, datetime):
return date
return datetime.strptime(date, "%Y-%m-%dT%H:%M:%S.%fZ").replace(tzinfo=timezone.utc)

View file

@ -5,22 +5,26 @@ from typing import Optional
from ..BaseModel import BaseModel
## Class representing a cloud connected cluster.
class CloudClusterResponse(BaseModel):
"""Class representing a cloud connected cluster."""
## Creates a new cluster response object.
# \param cluster_id: The secret unique ID, e.g. 'kBEeZWEifXbrXviO8mRYLx45P8k5lHVGs43XKvRniPg='.
# \param host_guid: The unique identifier of the print cluster host, e.g. 'e90ae0ac-1257-4403-91ee-a44c9b7e8050'.
# \param host_name: The name of the printer as configured during the Wi-Fi setup. Used as identifier for end users.
# \param is_online: Whether this cluster is currently connected to the cloud.
# \param status: The status of the cluster authentication (active or inactive).
# \param host_version: The firmware version of the cluster host. This is where the Stardust client is running on.
# \param host_internal_ip: The internal IP address of the host printer.
# \param friendly_name: The human readable name of the host printer.
# \param printer_type: The machine type of the host printer.
def __init__(self, cluster_id: str, host_guid: str, host_name: str, is_online: bool, status: str,
host_internal_ip: Optional[str] = None, host_version: Optional[str] = None,
friendly_name: Optional[str] = None, printer_type: str = "ultimaker3", **kwargs) -> None:
"""Creates a new cluster response object.
:param cluster_id: The secret unique ID, e.g. 'kBEeZWEifXbrXviO8mRYLx45P8k5lHVGs43XKvRniPg='.
:param host_guid: The unique identifier of the print cluster host, e.g. 'e90ae0ac-1257-4403-91ee-a44c9b7e8050'.
:param host_name: The name of the printer as configured during the Wi-Fi setup. Used as identifier for end users.
:param is_online: Whether this cluster is currently connected to the cloud.
:param status: The status of the cluster authentication (active or inactive).
:param host_version: The firmware version of the cluster host. This is where the Stardust client is running on.
:param host_internal_ip: The internal IP address of the host printer.
:param friendly_name: The human readable name of the host printer.
:param printer_type: The machine type of the host printer.
"""
self.cluster_id = cluster_id
self.host_guid = host_guid
self.host_name = host_name

View file

@ -11,15 +11,17 @@ from .ClusterPrintJobStatus import ClusterPrintJobStatus
# Model that represents the status of the cluster for the cloud
class CloudClusterStatus(BaseModel):
## Creates a new cluster status model object.
# \param printers: The latest status of each printer in the cluster.
# \param print_jobs: The latest status of each print job in the cluster.
# \param generated_time: The datetime when the object was generated on the server-side.
def __init__(self,
printers: List[Union[ClusterPrinterStatus, Dict[str, Any]]],
def __init__(self, printers: List[Union[ClusterPrinterStatus, Dict[str, Any]]],
print_jobs: List[Union[ClusterPrintJobStatus, Dict[str, Any]]],
generated_time: Union[str, datetime],
**kwargs) -> None:
"""Creates a new cluster status model object.
:param printers: The latest status of each printer in the cluster.
:param print_jobs: The latest status of each print job in the cluster.
:param generated_time: The datetime when the object was generated on the server-side.
"""
self.generated_time = self.parseDate(generated_time)
self.printers = self.parseModels(ClusterPrinterStatus, printers)
self.print_jobs = self.parseModels(ClusterPrintJobStatus, print_jobs)

View file

@ -5,20 +5,23 @@ from typing import Dict, Optional, Any
from ..BaseModel import BaseModel
## Class representing errors generated by the cloud servers, according to the JSON-API standard.
class CloudError(BaseModel):
"""Class representing errors generated by the cloud servers, according to the JSON-API standard."""
## Creates a new error object.
# \param id: Unique identifier for this particular occurrence of the problem.
# \param title: A short, human-readable summary of the problem that SHOULD NOT change from occurrence to occurrence
# of the problem, except for purposes of localization.
# \param code: An application-specific error code, expressed as a string value.
# \param detail: A human-readable explanation specific to this occurrence of the problem. Like title, this field's
# value can be localized.
# \param http_status: The HTTP status code applicable to this problem, converted to string.
# \param meta: Non-standard meta-information about the error, depending on the error code.
def __init__(self, id: str, code: str, title: str, http_status: str, detail: Optional[str] = None,
meta: Optional[Dict[str, Any]] = None, **kwargs) -> None:
"""Creates a new error object.
:param id: Unique identifier for this particular occurrence of the problem.
:param title: A short, human-readable summary of the problem that SHOULD NOT change from occurrence to occurrence
of the problem, except for purposes of localization.
:param code: An application-specific error code, expressed as a string value.
:param detail: A human-readable explanation specific to this occurrence of the problem. Like title, this field's
value can be localized.
:param http_status: The HTTP status code applicable to this problem, converted to string.
:param meta: Non-standard meta-information about the error, depending on the error code.
"""
self.id = id
self.code = code
self.http_status = http_status

View file

@ -8,19 +8,22 @@ from ..BaseModel import BaseModel
# Model that represents the response received from the cloud after requesting to upload a print job
class CloudPrintJobResponse(BaseModel):
## Creates a new print job response model.
# \param job_id: The job unique ID, e.g. 'kBEeZWEifXbrXviO8mRYLx45P8k5lHVGs43XKvRniPg='.
# \param status: The status of the print job.
# \param status_description: Contains more details about the status, e.g. the cause of failures.
# \param download_url: A signed URL to download the resulting status. Only available when the job is finished.
# \param job_name: The name of the print job.
# \param slicing_details: Model for slice information.
# \param upload_url: The one-time use URL where the toolpath must be uploaded to (only if status is uploading).
# \param content_type: The content type of the print job (e.g. text/plain or application/gzip)
# \param generated_time: The datetime when the object was generated on the server-side.
def __init__(self, job_id: str, status: str, download_url: Optional[str] = None, job_name: Optional[str] = None,
upload_url: Optional[str] = None, content_type: Optional[str] = None,
status_description: Optional[str] = None, slicing_details: Optional[dict] = None, **kwargs) -> None:
"""Creates a new print job response model.
:param job_id: The job unique ID, e.g. 'kBEeZWEifXbrXviO8mRYLx45P8k5lHVGs43XKvRniPg='.
:param status: The status of the print job.
:param status_description: Contains more details about the status, e.g. the cause of failures.
:param download_url: A signed URL to download the resulting status. Only available when the job is finished.
:param job_name: The name of the print job.
:param slicing_details: Model for slice information.
:param upload_url: The one-time use URL where the toolpath must be uploaded to (only if status is uploading).
:param content_type: The content type of the print job (e.g. text/plain or application/gzip)
:param generated_time: The datetime when the object was generated on the server-side.
"""
self.job_id = job_id
self.status = status
self.download_url = download_url

View file

@ -6,11 +6,14 @@ from ..BaseModel import BaseModel
# Model that represents the request to upload a print job to the cloud
class CloudPrintJobUploadRequest(BaseModel):
## Creates a new print job upload request.
# \param job_name: The name of the print job.
# \param file_size: The size of the file in bytes.
# \param content_type: The content type of the print job (e.g. text/plain or application/gzip)
def __init__(self, job_name: str, file_size: int, content_type: str, **kwargs) -> None:
"""Creates a new print job upload request.
:param job_name: The name of the print job.
:param file_size: The size of the file in bytes.
:param content_type: The content type of the print job (e.g. text/plain or application/gzip)
"""
self.job_name = job_name
self.file_size = file_size
self.content_type = content_type

View file

@ -9,13 +9,16 @@ from ..BaseModel import BaseModel
# Model that represents the responses received from the cloud after requesting a job to be printed.
class CloudPrintResponse(BaseModel):
## Creates a new print response object.
# \param job_id: The unique ID of a print job inside of the cluster. This ID is generated by Cura Connect.
# \param status: The status of the print request (queued or failed).
# \param generated_time: The datetime when the object was generated on the server-side.
# \param cluster_job_id: The unique ID of a print job inside of the cluster. This ID is generated by Cura Connect.
def __init__(self, job_id: str, status: str, generated_time: Union[str, datetime],
cluster_job_id: Optional[str] = None, **kwargs) -> None:
"""Creates a new print response object.
:param job_id: The unique ID of a print job inside of the cluster. This ID is generated by Cura Connect.
:param status: The status of the print request (queued or failed).
:param generated_time: The datetime when the object was generated on the server-side.
:param cluster_job_id: The unique ID of a print job inside of the cluster. This ID is generated by Cura Connect.
"""
self.job_id = job_id
self.status = status
self.cluster_job_id = cluster_job_id

View file

@ -3,11 +3,13 @@
from ..BaseModel import BaseModel
## Class representing a cluster printer
class ClusterBuildPlate(BaseModel):
"""Class representing a cluster printer"""
## Create a new build plate
# \param type: The type of build plate glass or aluminium
def __init__(self, type: str = "glass", **kwargs) -> None:
"""Create a new build plate
:param type: The type of build plate glass or aluminium
"""
self.type = type
super().__init__(**kwargs)

View file

@ -9,26 +9,33 @@ from .ClusterPrinterConfigurationMaterial import ClusterPrinterConfigurationMate
from ..BaseModel import BaseModel
## Class representing a cloud cluster printer configuration
# Also used for representing slots in a Material Station (as from Cura's perspective these are the same).
class ClusterPrintCoreConfiguration(BaseModel):
"""Class representing a cloud cluster printer configuration
Also used for representing slots in a Material Station (as from Cura's perspective these are the same).
"""
def __init__(self, extruder_index: int, material: Union[None, Dict[str, Any],
ClusterPrinterConfigurationMaterial] = None, print_core_id: Optional[str] = None, **kwargs) -> None:
"""Creates a new cloud cluster printer configuration object
:param extruder_index: The position of the extruder on the machine as list index. Numbered from left to right.
:param material: The material of a configuration object in a cluster printer. May be in a dict or an object.
:param nozzle_diameter: The diameter of the print core at this position in millimeters, e.g. '0.4'.
:param print_core_id: The type of print core inserted at this position, e.g. 'AA 0.4'.
"""
## Creates a new cloud cluster printer configuration object
# \param extruder_index: The position of the extruder on the machine as list index. Numbered from left to right.
# \param material: The material of a configuration object in a cluster printer. May be in a dict or an object.
# \param nozzle_diameter: The diameter of the print core at this position in millimeters, e.g. '0.4'.
# \param print_core_id: The type of print core inserted at this position, e.g. 'AA 0.4'.
def __init__(self, extruder_index: int,
material: Union[None, Dict[str, Any], ClusterPrinterConfigurationMaterial] = None,
print_core_id: Optional[str] = None, **kwargs) -> None:
self.extruder_index = extruder_index
self.material = self.parseModel(ClusterPrinterConfigurationMaterial, material) if material else None
self.print_core_id = print_core_id
super().__init__(**kwargs)
## Updates the given output model.
# \param model - The output model to update.
def updateOutputModel(self, model: ExtruderOutputModel) -> None:
"""Updates the given output model.
:param model: The output model to update.
"""
if self.print_core_id is not None:
model.updateHotendID(self.print_core_id)
@ -40,14 +47,16 @@ class ClusterPrintCoreConfiguration(BaseModel):
else:
model.updateActiveMaterial(None)
## Creates a configuration model
def createConfigurationModel(self) -> ExtruderConfigurationModel:
"""Creates a configuration model"""
model = ExtruderConfigurationModel(position = self.extruder_index)
self.updateConfigurationModel(model)
return model
## Creates a configuration model
def updateConfigurationModel(self, model: ExtruderConfigurationModel) -> ExtruderConfigurationModel:
"""Creates a configuration model"""
model.setHotendID(self.print_core_id)
if self.material:
model.setMaterial(self.material.createOutputModel())

View file

@ -5,19 +5,22 @@ from typing import Optional
from ..BaseModel import BaseModel
## Model for the types of changes that are needed before a print job can start
class ClusterPrintJobConfigurationChange(BaseModel):
"""Model for the types of changes that are needed before a print job can start"""
def __init__(self, type_of_change: str, target_id: str, origin_id: str, index: Optional[int] = None,
target_name: Optional[str] = None, origin_name: Optional[str] = None, **kwargs) -> None:
"""Creates a new print job constraint.
:param type_of_change: The type of configuration change, one of: "material", "print_core_change"
:param index: The hotend slot or extruder index to change
:param target_id: Target material guid or hotend id
:param origin_id: Original/current material guid or hotend id
:param target_name: Target material name or hotend id
:param origin_name: Original/current material name or hotend id
"""
## Creates a new print job constraint.
# \param type_of_change: The type of configuration change, one of: "material", "print_core_change"
# \param index: The hotend slot or extruder index to change
# \param target_id: Target material guid or hotend id
# \param origin_id: Original/current material guid or hotend id
# \param target_name: Target material name or hotend id
# \param origin_name: Original/current material name or hotend id
def __init__(self, type_of_change: str, target_id: str, origin_id: str,
index: Optional[int] = None, target_name: Optional[str] = None, origin_name: Optional[str] = None,
**kwargs) -> None:
self.type_of_change = type_of_change
self.index = index
self.target_id = target_id

View file

@ -5,12 +5,14 @@ from typing import Optional
from ..BaseModel import BaseModel
## Class representing a cloud cluster print job constraint
class ClusterPrintJobConstraints(BaseModel):
"""Class representing a cloud cluster print job constraint"""
## Creates a new print job constraint.
# \param require_printer_name: Unique name of the printer that this job should be printed on.
# Should be one of the unique_name field values in the cluster, e.g. 'ultimakersystem-ccbdd30044ec'
def __init__(self, require_printer_name: Optional[str] = None, **kwargs) -> None:
"""Creates a new print job constraint.
:param require_printer_name: Unique name of the printer that this job should be printed on.
Should be one of the unique_name field values in the cluster, e.g. 'ultimakersystem-ccbdd30044ec'
"""
self.require_printer_name = require_printer_name
super().__init__(**kwargs)

View file

@ -3,14 +3,17 @@
from ..BaseModel import BaseModel
## Class representing the reasons that prevent this job from being printed on the associated printer
class ClusterPrintJobImpediment(BaseModel):
"""Class representing the reasons that prevent this job from being printed on the associated printer"""
## Creates a new print job constraint.
# \param translation_key: A string indicating a reason the print cannot be printed,
# such as 'does_not_fit_in_build_volume'
# \param severity: A number indicating the severity of the problem, with higher being more severe
def __init__(self, translation_key: str, severity: int, **kwargs) -> None:
"""Creates a new print job constraint.
:param translation_key: A string indicating a reason the print cannot be printed,
such as 'does_not_fit_in_build_volume'
:param severity: A number indicating the severity of the problem, with higher being more severe
"""
self.translation_key = translation_key
self.severity = severity
super().__init__(**kwargs)

View file

@ -15,36 +15,9 @@ from ..BaseModel import BaseModel
from ...ClusterOutputController import ClusterOutputController
## Model for the status of a single print job in a cluster.
class ClusterPrintJobStatus(BaseModel):
"""Model for the status of a single print job in a cluster."""
## Creates a new cloud print job status model.
# \param assigned_to: The name of the printer this job is assigned to while being queued.
# \param configuration: The required print core configurations of this print job.
# \param constraints: Print job constraints object.
# \param created_at: The timestamp when the job was created in Cura Connect.
# \param force: Allow this job to be printed despite of mismatching configurations.
# \param last_seen: The number of seconds since this job was checked.
# \param machine_variant: The machine type that this job should be printed on.Coincides with the machine_type field
# of the printer object.
# \param name: The name of the print job. Usually the name of the .gcode file.
# \param network_error_count: The number of errors encountered when requesting data for this print job.
# \param owner: The name of the user who added the print job to Cura Connect.
# \param printer_uuid: UUID of the printer that the job is currently printing on or assigned to.
# \param started: Whether the job has started printing or not.
# \param status: The status of the print job.
# \param time_elapsed: The remaining printing time in seconds.
# \param time_total: The total printing time in seconds.
# \param uuid: UUID of this print job. Should be used for identification purposes.
# \param deleted_at: The time when this print job was deleted.
# \param printed_on_uuid: UUID of the printer used to print this job.
# \param configuration_changes_required: List of configuration changes the printer this job is associated with
# needs to make in order to be able to print this job
# \param build_plate: The build plate (type) this job needs to be printed on.
# \param compatible_machine_families: Family names of machines suitable for this print job
# \param impediments_to_printing: A list of reasons that prevent this job from being printed on the associated
# printer
# \param preview_url: URL to the preview image (same as wou;d've been included in the ufp).
def __init__(self, created_at: str, force: bool, machine_variant: str, name: str, started: bool, status: str,
time_total: int, uuid: str,
configuration: List[Union[Dict[str, Any], ClusterPrintCoreConfiguration]],
@ -60,6 +33,37 @@ class ClusterPrintJobStatus(BaseModel):
impediments_to_printing: List[Union[Dict[str, Any], ClusterPrintJobImpediment]] = None,
preview_url: Optional[str] = None,
**kwargs) -> None:
"""Creates a new cloud print job status model.
:param assigned_to: The name of the printer this job is assigned to while being queued.
:param configuration: The required print core configurations of this print job.
:param constraints: Print job constraints object.
:param created_at: The timestamp when the job was created in Cura Connect.
:param force: Allow this job to be printed despite of mismatching configurations.
:param last_seen: The number of seconds since this job was checked.
:param machine_variant: The machine type that this job should be printed on.Coincides with the machine_type field
of the printer object.
:param name: The name of the print job. Usually the name of the .gcode file.
:param network_error_count: The number of errors encountered when requesting data for this print job.
:param owner: The name of the user who added the print job to Cura Connect.
:param printer_uuid: UUID of the printer that the job is currently printing on or assigned to.
:param started: Whether the job has started printing or not.
:param status: The status of the print job.
:param time_elapsed: The remaining printing time in seconds.
:param time_total: The total printing time in seconds.
:param uuid: UUID of this print job. Should be used for identification purposes.
:param deleted_at: The time when this print job was deleted.
:param printed_on_uuid: UUID of the printer used to print this job.
:param configuration_changes_required: List of configuration changes the printer this job is associated with
needs to make in order to be able to print this job
:param build_plate: The build plate (type) this job needs to be printed on.
:param compatible_machine_families: Family names of machines suitable for this print job
:param impediments_to_printing: A list of reasons that prevent this job from being printed on the associated
printer
:param preview_url: URL to the preview image (same as wou;d've been included in the ufp).
"""
self.assigned_to = assigned_to
self.configuration = self.parseModels(ClusterPrintCoreConfiguration, configuration)
self.constraints = self.parseModels(ClusterPrintJobConstraints, constraints)
@ -90,24 +94,31 @@ class ClusterPrintJobStatus(BaseModel):
super().__init__(**kwargs)
## Creates an UM3 print job output model based on this cloud cluster print job.
# \param printer: The output model of the printer
def createOutputModel(self, controller: ClusterOutputController) -> UM3PrintJobOutputModel:
"""Creates an UM3 print job output model based on this cloud cluster print job.
:param printer: The output model of the printer
"""
model = UM3PrintJobOutputModel(controller, self.uuid, self.name)
self.updateOutputModel(model)
return model
## Creates a new configuration model
def _createConfigurationModel(self) -> PrinterConfigurationModel:
"""Creates a new configuration model"""
extruders = [extruder.createConfigurationModel() for extruder in self.configuration or ()]
configuration = PrinterConfigurationModel()
configuration.setExtruderConfigurations(extruders)
configuration.setPrinterType(self.machine_variant)
return configuration
## Updates an UM3 print job output model based on this cloud cluster print job.
# \param model: The model to update.
def updateOutputModel(self, model: UM3PrintJobOutputModel) -> None:
"""Updates an UM3 print job output model based on this cloud cluster print job.
:param model: The model to update.
"""
model.updateConfiguration(self._createConfigurationModel())
model.updateTimeTotal(self.time_total)
model.updateTimeElapsed(self.time_elapsed)

View file

@ -9,29 +9,35 @@ from cura.PrinterOutput.Models.MaterialOutputModel import MaterialOutputModel
from ..BaseModel import BaseModel
## Class representing a cloud cluster printer configuration
class ClusterPrinterConfigurationMaterial(BaseModel):
"""Class representing a cloud cluster printer configuration"""
## Creates a new material configuration model.
# \param brand: The brand of material in this print core, e.g. 'Ultimaker'.
# \param color: The color of material in this print core, e.g. 'Blue'.
# \param guid: he GUID of the material in this print core, e.g. '506c9f0d-e3aa-4bd4-b2d2-23e2425b1aa9'.
# \param material: The type of material in this print core, e.g. 'PLA'.
def __init__(self, brand: Optional[str] = None, color: Optional[str] = None, guid: Optional[str] = None,
material: Optional[str] = None, **kwargs) -> None:
"""Creates a new material configuration model.
:param brand: The brand of material in this print core, e.g. 'Ultimaker'.
:param color: The color of material in this print core, e.g. 'Blue'.
:param guid: he GUID of the material in this print core, e.g. '506c9f0d-e3aa-4bd4-b2d2-23e2425b1aa9'.
:param material: The type of material in this print core, e.g. 'PLA'.
"""
self.guid = guid
self.brand = brand
self.color = color
self.material = material
super().__init__(**kwargs)
## Creates a material output model based on this cloud printer material.
#
# A material is chosen that matches the current GUID. If multiple such
# materials are available, read-only materials are preferred and the
# material with the earliest alphabetical name will be selected.
# \return A material output model that matches the current GUID.
def createOutputModel(self) -> MaterialOutputModel:
"""Creates a material output model based on this cloud printer material.
A material is chosen that matches the current GUID. If multiple such
materials are available, read-only materials are preferred and the
material with the earliest alphabetical name will be selected.
:return: A material output model that matches the current GUID.
"""
container_registry = ContainerRegistry.getInstance()
same_guid = container_registry.findInstanceContainersMetadata(GUID = self.guid)
if same_guid:

View file

@ -6,16 +6,19 @@ from ..BaseModel import BaseModel
from .ClusterPrinterMaterialStationSlot import ClusterPrinterMaterialStationSlot
## Class representing the data of a Material Station in the cluster.
class ClusterPrinterMaterialStation(BaseModel):
"""Class representing the data of a Material Station in the cluster."""
## Creates a new Material Station status.
# \param status: The status of the material station.
# \param: supported: Whether the material station is supported on this machine or not.
# \param material_slots: The active slots configurations of this material station.
def __init__(self, status: str, supported: bool = False,
material_slots: List[Union[ClusterPrinterMaterialStationSlot, Dict[str, Any]]] = None,
**kwargs) -> None:
"""Creates a new Material Station status.
:param status: The status of the material station.
:param: supported: Whether the material station is supported on this machine or not.
:param material_slots: The active slots configurations of this material station.
"""
self.status = status
self.supported = supported
self.material_slots = self.parseModels(ClusterPrinterMaterialStationSlot, material_slots)\

View file

@ -5,16 +5,19 @@ from typing import Optional
from .ClusterPrintCoreConfiguration import ClusterPrintCoreConfiguration
## Class representing the data of a single slot in the material station.
class ClusterPrinterMaterialStationSlot(ClusterPrintCoreConfiguration):
## Create a new material station slot object.
# \param slot_index: The index of the slot in the material station (ranging 0 to 5).
# \param compatible: Whether the configuration is compatible with the print core.
# \param material_remaining: How much material is remaining on the spool (between 0 and 1, or -1 for missing data).
# \param material_empty: Whether the material spool is too empty to be used.
"""Class representing the data of a single slot in the material station."""
def __init__(self, slot_index: int, compatible: bool, material_remaining: float,
material_empty: Optional[bool] = False, **kwargs) -> None:
"""Create a new material station slot object.
:param slot_index: The index of the slot in the material station (ranging 0 to 5).
:param compatible: Whether the configuration is compatible with the print core.
:param material_remaining: How much material is remaining on the spool (between 0 and 1, or -1 for missing data).
:param material_empty: Whether the material spool is too empty to be used.
"""
self.slot_index = slot_index
self.compatible = compatible
self.material_remaining = material_remaining

View file

@ -17,26 +17,10 @@ from .ClusterPrinterConfigurationMaterial import ClusterPrinterConfigurationMate
from ..BaseModel import BaseModel
## Class representing a cluster printer
class ClusterPrinterStatus(BaseModel):
"""Class representing a cluster printer"""
## Creates a new cluster printer status
# \param enabled: A printer can be disabled if it should not receive new jobs. By default every printer is enabled.
# \param firmware_version: Firmware version installed on the printer. Can differ for each printer in a cluster.
# \param friendly_name: Human readable name of the printer. Can be used for identification purposes.
# \param ip_address: The IP address of the printer in the local network.
# \param machine_variant: The type of printer. Can be 'Ultimaker 3' or 'Ultimaker 3ext'.
# \param status: The status of the printer.
# \param unique_name: The unique name of the printer in the network.
# \param uuid: The unique ID of the printer, also known as GUID.
# \param configuration: The active print core configurations of this printer.
# \param reserved_by: A printer can be claimed by a specific print job.
# \param maintenance_required: Indicates if maintenance is necessary.
# \param firmware_update_status: Whether the printer's firmware is up-to-date, value is one of: "up_to_date",
# "pending_update", "update_available", "update_in_progress", "update_failed", "update_impossible".
# \param latest_available_firmware: The version of the latest firmware that is available.
# \param build_plate: The build plate that is on the printer.
# \param material_station: The material station that is on the printer.
def __init__(self, enabled: bool, firmware_version: str, friendly_name: str, ip_address: str, machine_variant: str,
status: str, unique_name: str, uuid: str,
configuration: List[Union[Dict[str, Any], ClusterPrintCoreConfiguration]],
@ -44,6 +28,25 @@ class ClusterPrinterStatus(BaseModel):
firmware_update_status: Optional[str] = None, latest_available_firmware: Optional[str] = None,
build_plate: Union[Dict[str, Any], ClusterBuildPlate] = None,
material_station: Union[Dict[str, Any], ClusterPrinterMaterialStation] = None, **kwargs) -> None:
"""Creates a new cluster printer status
:param enabled: A printer can be disabled if it should not receive new jobs. By default every printer is enabled.
:param firmware_version: Firmware version installed on the printer. Can differ for each printer in a cluster.
:param friendly_name: Human readable name of the printer. Can be used for identification purposes.
:param ip_address: The IP address of the printer in the local network.
:param machine_variant: The type of printer. Can be 'Ultimaker 3' or 'Ultimaker 3ext'.
:param status: The status of the printer.
:param unique_name: The unique name of the printer in the network.
:param uuid: The unique ID of the printer, also known as GUID.
:param configuration: The active print core configurations of this printer.
:param reserved_by: A printer can be claimed by a specific print job.
:param maintenance_required: Indicates if maintenance is necessary.
:param firmware_update_status: Whether the printer's firmware is up-to-date, value is one of: "up_to_date",
"pending_update", "update_available", "update_in_progress", "update_failed", "update_impossible".
:param latest_available_firmware: The version of the latest firmware that is available.
:param build_plate: The build plate that is on the printer.
:param material_station: The material station that is on the printer.
"""
self.configuration = self.parseModels(ClusterPrintCoreConfiguration, configuration)
self.enabled = enabled
@ -63,9 +66,12 @@ class ClusterPrinterStatus(BaseModel):
material_station) if material_station else None
super().__init__(**kwargs)
## Creates a new output model.
# \param controller - The controller of the model.
def createOutputModel(self, controller: PrinterOutputController) -> PrinterOutputModel:
"""Creates a new output model.
:param controller: - The controller of the model.
"""
# FIXME
# Note that we're using '2' here as extruder count. We have hardcoded this for now to prevent issues where the
# amount of extruders coming back from the API is actually lower (which it can be if a printer was just added
@ -74,9 +80,12 @@ class ClusterPrinterStatus(BaseModel):
self.updateOutputModel(model)
return model
## Updates the given output model.
# \param model - The output model to update.
def updateOutputModel(self, model: PrinterOutputModel) -> None:
"""Updates the given output model.
:param model: - The output model to update.
"""
model.updateKey(self.uuid)
model.updateName(self.friendly_name)
model.updateUniqueName(self.unique_name)
@ -110,9 +119,12 @@ class ClusterPrinterStatus(BaseModel):
) for left_slot, right_slot in product(self._getSlotsForExtruder(0), self._getSlotsForExtruder(1))]
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]:
"""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.
"""
if not self.material_station: # typing guard
return []
slots = [slot for slot in self.material_station.material_slots if self._isSupportedConfiguration(
@ -121,15 +133,19 @@ class ClusterPrinterStatus(BaseModel):
)]
return slots or [self._createEmptyMaterialSlot(extruder_index)]
## 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.
@staticmethod
def _isSupportedConfiguration(slot: ClusterPrinterMaterialStationSlot, extruder_index: int) -> bool:
"""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.
"""
return slot.extruder_index == extruder_index and slot.compatible and not slot.material_empty
## Create an empty material slot with a fake empty material.
@staticmethod
def _createEmptyMaterialSlot(extruder_index: int) -> ClusterPrinterMaterialStationSlot:
"""Create an empty material slot with a fake empty material."""
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)

View file

@ -5,12 +5,11 @@ from typing import Dict, Any
from ..BaseModel import BaseModel
## Class representing the system status of a printer.
class PrinterSystemStatus(BaseModel):
"""Class representing the system status of a printer."""
def __init__(self, guid: str, firmware: str, hostname: str, name: str, platform: str, variant: str,
hardware: Dict[str, Any], **kwargs
) -> None:
hardware: Dict[str, Any], **kwargs) -> None:
self.guid = guid
self.firmware = firmware
self.hostname = hostname

View file

@ -16,12 +16,13 @@ from ..Models.Http.PrinterSystemStatus import PrinterSystemStatus
from ..Models.Http.ClusterMaterial import ClusterMaterial
## The generic type variable used to document the methods below.
ClusterApiClientModel = TypeVar("ClusterApiClientModel", bound=BaseModel)
"""The generic type variable used to document the methods below."""
## The ClusterApiClient is responsible for all network calls to local network clusters.
class ClusterApiClient:
"""The ClusterApiClient is responsible for all network calls to local network clusters."""
PRINTER_API_PREFIX = "/api/v1"
CLUSTER_API_PREFIX = "/cluster-api/v1"
@ -29,75 +30,92 @@ class ClusterApiClient:
# In order to avoid garbage collection we keep the callbacks in this list.
_anti_gc_callbacks = [] # type: List[Callable[[], None]]
## Initializes a new cluster API client.
# \param address: The network address of the cluster to call.
# \param on_error: The callback to be called whenever we receive errors from the server.
def __init__(self, address: str, on_error: Callable) -> None:
"""Initializes a new cluster API client.
:param address: The network address of the cluster to call.
:param on_error: The callback to be called whenever we receive errors from the server.
"""
super().__init__()
self._manager = QNetworkAccessManager()
self._address = address
self._on_error = on_error
## Get printer system information.
# \param on_finished: The callback in case the response is successful.
def getSystem(self, on_finished: Callable) -> None:
"""Get printer system information.
:param on_finished: The callback in case the response is successful.
"""
url = "{}/system".format(self.PRINTER_API_PREFIX)
reply = self._manager.get(self._createEmptyRequest(url))
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:
"""Get the installed materials on the printer.
:param on_finished: The callback in case the response is successful.
"""
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.
# \param on_finished: The callback in case the response is successful.
def getPrinters(self, on_finished: Callable[[List[ClusterPrinterStatus]], Any]) -> None:
"""Get the printers in the cluster.
:param on_finished: The callback in case the response is successful.
"""
url = "{}/printers".format(self.CLUSTER_API_PREFIX)
reply = self._manager.get(self._createEmptyRequest(url))
self._addCallback(reply, on_finished, ClusterPrinterStatus)
## Get the print jobs in the cluster.
# \param on_finished: The callback in case the response is successful.
def getPrintJobs(self, on_finished: Callable[[List[ClusterPrintJobStatus]], Any]) -> None:
"""Get the print jobs in the cluster.
:param on_finished: The callback in case the response is successful.
"""
url = "{}/print_jobs".format(self.CLUSTER_API_PREFIX)
reply = self._manager.get(self._createEmptyRequest(url))
self._addCallback(reply, on_finished, ClusterPrintJobStatus)
## Move a print job to the top of the queue.
def movePrintJobToTop(self, print_job_uuid: str) -> None:
"""Move a print job to the top of the queue."""
url = "{}/print_jobs/{}/action/move".format(self.CLUSTER_API_PREFIX, print_job_uuid)
self._manager.post(self._createEmptyRequest(url), json.dumps({"to_position": 0, "list": "queued"}).encode())
## Override print job configuration and force it to be printed.
def forcePrintJob(self, print_job_uuid: str) -> None:
"""Override print job configuration and force it to be printed."""
url = "{}/print_jobs/{}".format(self.CLUSTER_API_PREFIX, print_job_uuid)
self._manager.put(self._createEmptyRequest(url), json.dumps({"force": True}).encode())
## Delete a print job from the queue.
def deletePrintJob(self, print_job_uuid: str) -> None:
"""Delete a print job from the queue."""
url = "{}/print_jobs/{}".format(self.CLUSTER_API_PREFIX, print_job_uuid)
self._manager.deleteResource(self._createEmptyRequest(url))
## Set the state of a print job.
def setPrintJobState(self, print_job_uuid: str, state: str) -> None:
"""Set the state of a print job."""
url = "{}/print_jobs/{}/action".format(self.CLUSTER_API_PREFIX, print_job_uuid)
# We rewrite 'resume' to 'print' here because we are using the old print job action endpoints.
action = "print" if state == "resume" else state
self._manager.put(self._createEmptyRequest(url), json.dumps({"action": action}).encode())
## Get the preview image data of a print job.
def getPrintJobPreviewImage(self, print_job_uuid: str, on_finished: Callable) -> None:
"""Get the preview image data of a print job."""
url = "{}/print_jobs/{}/preview_image".format(self.CLUSTER_API_PREFIX, print_job_uuid)
reply = self._manager.get(self._createEmptyRequest(url))
self._addCallback(reply, on_finished)
## 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:
"""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.
"""
url = QUrl("http://" + self._address + path)
request = QNetworkRequest(url)
request.setAttribute(QNetworkRequest.FollowRedirectsAttribute, True)
@ -105,11 +123,13 @@ class ClusterApiClient:
request.setHeader(QNetworkRequest.ContentTypeHeader, content_type)
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]]:
"""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.
"""
status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute)
try:
response = bytes(reply.readAll()).decode()
@ -118,14 +138,15 @@ class ClusterApiClient:
Logger.logException("e", "Could not parse the cluster response: %s", err)
return status_code, {"errors": [err]}
## 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_class: 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: Union[Callable[[ClusterApiClientModel], Any],
Callable[[List[ClusterApiClientModel]], Any]],
model_class: Type[ClusterApiClientModel]) -> None:
def _parseModels(self, response: Dict[str, Any], on_finished: Union[Callable[[ClusterApiClientModel], Any],
Callable[[List[ClusterApiClientModel]], Any]], model_class: Type[ClusterApiClientModel]) -> None:
"""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_class: The type of the model to convert the response to. It may either be a single record or a list.
"""
try:
if isinstance(response, list):
results = [model_class(**c) for c in response] # type: List[ClusterApiClientModel]
@ -138,16 +159,15 @@ class ClusterApiClient:
except (JSONDecodeError, TypeError, ValueError):
Logger.log("e", "Could not parse response from network: %s", str(response))
## Creates a callback function so that it includes the parsing of the response into the correct model.
# The callback is added to the 'finished' signal of the reply.
# \param reply: The reply that should be listened to.
# \param on_finished: The callback in case the response is successful.
def _addCallback(self,
reply: QNetworkReply,
on_finished: Union[Callable[[ClusterApiClientModel], Any],
Callable[[List[ClusterApiClientModel]], Any]],
model: Type[ClusterApiClientModel] = None,
def _addCallback(self, reply: QNetworkReply, on_finished: Union[Callable[[ClusterApiClientModel], Any],
Callable[[List[ClusterApiClientModel]], Any]], model: Type[ClusterApiClientModel] = None,
) -> None:
"""Creates a callback function so that it includes the parsing of the response into the correct model.
The callback is added to the 'finished' signal of the reply.
:param reply: The reply that should be listened to.
:param on_finished: The callback in case the response is successful.
"""
def parse() -> None:
self._anti_gc_callbacks.remove(parse)

View file

@ -51,15 +51,17 @@ class LocalClusterOutputDevice(UltimakerNetworkedPrinterOutputDevice):
self._setInterfaceElements()
self._active_camera_url = QUrl() # type: QUrl
## Set all the interface elements and texts for this output device.
def _setInterfaceElements(self) -> None:
"""Set all the interface elements and texts for this output device."""
self.setPriority(3) # Make sure the output device gets selected above local file output
self.setShortDescription(I18N_CATALOG.i18nc("@action:button Preceded by 'Ready to'.", "Print over network"))
self.setDescription(I18N_CATALOG.i18nc("@properties:tooltip", "Print over network"))
self.setConnectionText(I18N_CATALOG.i18nc("@info:status", "Connected over the network"))
## Called when the connection to the cluster changes.
def connect(self) -> None:
"""Called when the connection to the cluster changes."""
super().connect()
self._update()
self.sendMaterialProfiles()
@ -94,10 +96,13 @@ class LocalClusterOutputDevice(UltimakerNetworkedPrinterOutputDevice):
def forceSendJob(self, print_job_uuid: str) -> None:
self._getApiClient().forcePrintJob(print_job_uuid)
## Set the remote print job state.
# \param print_job_uuid: The UUID of the print job to set the state for.
# \param action: The action to undertake ('pause', 'resume', 'abort').
def setJobState(self, print_job_uuid: str, action: str) -> None:
"""Set the remote print job state.
:param print_job_uuid: The UUID of the print job to set the state for.
:param action: The action to undertake ('pause', 'resume', 'abort').
"""
self._getApiClient().setPrintJobState(print_job_uuid, action)
def _update(self) -> None:
@ -106,19 +111,22 @@ class LocalClusterOutputDevice(UltimakerNetworkedPrinterOutputDevice):
self._getApiClient().getPrintJobs(self._updatePrintJobs)
self._updatePrintJobPreviewImages()
## Get a list of materials that are installed on the cluster host.
def getMaterials(self, on_finished: Callable[[List[ClusterMaterial]], Any]) -> None:
"""Get a list of materials that are installed on the cluster host."""
self._getApiClient().getMaterials(on_finished = on_finished)
## 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:
"""Sync the material profiles in Cura with the printer.
This gets called when connecting to a printer as well as when sending a print.
"""
job = SendMaterialJob(device = self)
job.run()
## Send a print job to the cluster.
def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mimetypes: bool = False,
file_handler: Optional[FileHandler] = None, filter_by_machine: bool = False, **kwargs) -> None:
"""Send a print job to the cluster."""
# Show an error message if we're already sending a job.
if self._progress.visible:
@ -132,15 +140,20 @@ class LocalClusterOutputDevice(UltimakerNetworkedPrinterOutputDevice):
job.finished.connect(self._onPrintJobCreated)
job.start()
## Allows the user to choose a printer to print with from the printer selection dialogue.
# \param unique_name: The unique name of the printer to target.
@pyqtSlot(str, name="selectTargetPrinter")
def selectTargetPrinter(self, unique_name: str = "") -> None:
"""Allows the user to choose a printer to print with from the printer selection dialogue.
:param unique_name: The unique name of the printer to target.
"""
self._startPrintJobUpload(unique_name if unique_name != "" else None)
## Handler for when the print job was created locally.
# It can now be sent over the network.
def _onPrintJobCreated(self, job: ExportFileJob) -> None:
"""Handler for when the print job was created locally.
It can now be sent over the network.
"""
self._active_exported_job = job
# TODO: add preference to enable/disable this feature?
if self.clusterSize > 1:
@ -148,8 +161,9 @@ class LocalClusterOutputDevice(UltimakerNetworkedPrinterOutputDevice):
return
self._startPrintJobUpload()
## Shows a dialog allowing the user to select which printer in a group to send a job to.
def _showPrinterSelectionDialog(self) -> None:
"""Shows a dialog allowing the user to select which printer in a group to send a job to."""
if not self._printer_select_dialog:
plugin_path = CuraApplication.getInstance().getPluginRegistry().getPluginPath("UM3NetworkPrinting") or ""
path = os.path.join(plugin_path, "resources", "qml", "PrintWindow.qml")
@ -157,8 +171,9 @@ class LocalClusterOutputDevice(UltimakerNetworkedPrinterOutputDevice):
if self._printer_select_dialog is not None:
self._printer_select_dialog.show()
## Upload the print job to the group.
def _startPrintJobUpload(self, unique_name: str = None) -> None:
"""Upload the print job to the group."""
if not self._active_exported_job:
Logger.log("e", "No active exported job to upload!")
return
@ -177,33 +192,40 @@ class LocalClusterOutputDevice(UltimakerNetworkedPrinterOutputDevice):
on_progress=self._onPrintJobUploadProgress)
self._active_exported_job = None
## Handler for print job upload progress.
def _onPrintJobUploadProgress(self, bytes_sent: int, bytes_total: int) -> None:
"""Handler for print job upload progress."""
percentage = (bytes_sent / bytes_total) if bytes_total else 0
self._progress.setProgress(percentage * 100)
self.writeProgress.emit()
## Handler for when the print job was fully uploaded to the cluster.
def _onPrintUploadCompleted(self, _: QNetworkReply) -> None:
"""Handler for when the print job was fully uploaded to the cluster."""
self._progress.hide()
PrintJobUploadSuccessMessage().show()
self.writeFinished.emit()
## Displays the given message if uploading the mesh has failed
# \param message: The message to display.
def _onUploadError(self, message: str = None) -> None:
"""Displays the given message if uploading the mesh has failed
:param message: The message to display.
"""
self._progress.hide()
PrintJobUploadErrorMessage(message).show()
self.writeError.emit()
## Download all the images from the cluster and load their data in the print job models.
def _updatePrintJobPreviewImages(self):
"""Download all the images from the cluster and load their data in the print job models."""
for print_job in self._print_jobs:
if print_job.getPreviewImage() is None:
self._getApiClient().getPrintJobPreviewImage(print_job.key, print_job.updatePreviewImageData)
## Get the API client instance.
def _getApiClient(self) -> ClusterApiClient:
"""Get the API client instance."""
if not self._cluster_api:
self._cluster_api = ClusterApiClient(self.address, on_error = lambda error: Logger.log("e", str(error)))
return self._cluster_api

View file

@ -24,8 +24,9 @@ from ..Models.Http.PrinterSystemStatus import PrinterSystemStatus
I18N_CATALOG = i18nCatalog("cura")
## The LocalClusterOutputDeviceManager is responsible for discovering and managing local networked clusters.
class LocalClusterOutputDeviceManager:
"""The LocalClusterOutputDeviceManager is responsible for discovering and managing local networked clusters."""
META_NETWORK_KEY = "um_network_key"
@ -49,30 +50,35 @@ class LocalClusterOutputDeviceManager:
self._zero_conf_client.addedNetworkCluster.connect(self._onDeviceDiscovered)
self._zero_conf_client.removedNetworkCluster.connect(self._onDiscoveredDeviceRemoved)
## Start the network discovery.
def start(self) -> None:
"""Start the network discovery."""
self._zero_conf_client.start()
for address in self._getStoredManualAddresses():
self.addManualDevice(address)
## Stop network discovery and clean up discovered devices.
def stop(self) -> None:
"""Stop network discovery and clean up discovered devices."""
self._zero_conf_client.stop()
for instance_name in list(self._discovered_devices):
self._onDiscoveredDeviceRemoved(instance_name)
## Restart discovery on the local network.
def startDiscovery(self):
"""Restart discovery on the local network."""
self.stop()
self.start()
## Add a networked printer manually by address.
def addManualDevice(self, address: str, callback: Optional[Callable[[bool, str], None]] = None) -> None:
"""Add a networked printer manually by address."""
api_client = ClusterApiClient(address, lambda error: Logger.log("e", str(error)))
api_client.getSystem(lambda status: self._onCheckManualDeviceResponse(address, status, callback))
## Remove a manually added networked printer.
def removeManualDevice(self, device_id: str, address: Optional[str] = None) -> None:
"""Remove a manually added networked printer."""
if device_id not in self._discovered_devices and address is not None:
device_id = "manual:{}".format(address)
@ -83,16 +89,19 @@ class LocalClusterOutputDeviceManager:
if address in self._getStoredManualAddresses():
self._removeStoredManualAddress(address)
## Force reset all network device connections.
def refreshConnections(self) -> None:
"""Force reset all network device connections."""
self._connectToActiveMachine()
## Get the discovered devices.
def getDiscoveredDevices(self) -> Dict[str, LocalClusterOutputDevice]:
"""Get the discovered devices."""
return self._discovered_devices
## Connect the active machine to a given device.
def associateActiveMachineWithPrinterDevice(self, device: LocalClusterOutputDevice) -> None:
"""Connect the active machine to a given device."""
active_machine = CuraApplication.getInstance().getGlobalContainerStack()
if not active_machine:
return
@ -106,8 +115,9 @@ class LocalClusterOutputDeviceManager:
return
CuraApplication.getInstance().getMachineManager().switchPrinterType(definitions[0].getName())
## Callback for when the active machine was changed by the user or a new remote cluster was found.
def _connectToActiveMachine(self) -> None:
"""Callback for when the active machine was changed by the user or a new remote cluster was found."""
active_machine = CuraApplication.getInstance().getGlobalContainerStack()
if not active_machine:
return
@ -122,9 +132,10 @@ class LocalClusterOutputDeviceManager:
# Remove device if it is not meant for the active machine.
output_device_manager.removeOutputDevice(device.key)
## Callback for when a manual device check request was responded to.
def _onCheckManualDeviceResponse(self, address: str, status: PrinterSystemStatus,
callback: Optional[Callable[[bool, str], None]] = None) -> None:
"""Callback for when a manual device check request was responded to."""
self._onDeviceDiscovered("manual:{}".format(address), address, {
b"name": status.name.encode("utf-8"),
b"address": address.encode("utf-8"),
@ -137,10 +148,13 @@ class LocalClusterOutputDeviceManager:
if callback is not None:
CuraApplication.getInstance().callLater(callback, True, address)
## Returns a dict of printer BOM numbers to machine types.
# These numbers are available in the machine definition already so we just search for them here.
@staticmethod
def _getPrinterTypeIdentifiers() -> Dict[str, str]:
"""Returns a dict of printer BOM numbers to machine types.
These numbers are available in the machine definition already so we just search for them here.
"""
container_registry = CuraApplication.getInstance().getContainerRegistry()
ultimaker_machines = container_registry.findContainersMetadata(type="machine", manufacturer="Ultimaker B.V.")
found_machine_type_identifiers = {} # type: Dict[str, str]
@ -154,8 +168,9 @@ class LocalClusterOutputDeviceManager:
found_machine_type_identifiers[str(bom_number)] = machine_type
return found_machine_type_identifiers
## Add a new device.
def _onDeviceDiscovered(self, key: str, address: str, properties: Dict[bytes, bytes]) -> None:
"""Add a new device."""
machine_identifier = properties.get(b"machine", b"").decode("utf-8")
printer_type_identifiers = self._getPrinterTypeIdentifiers()
@ -189,8 +204,9 @@ class LocalClusterOutputDeviceManager:
self.discoveredDevicesChanged.emit()
self._connectToActiveMachine()
## Remove a device.
def _onDiscoveredDeviceRemoved(self, device_id: str) -> None:
"""Remove a device."""
device = self._discovered_devices.pop(device_id, None) # type: Optional[LocalClusterOutputDevice]
if not device:
return
@ -198,8 +214,9 @@ class LocalClusterOutputDeviceManager:
CuraApplication.getInstance().getDiscoveredPrintersModel().removeDiscoveredPrinter(device.address)
self.discoveredDevicesChanged.emit()
## Create a machine instance based on the discovered network printer.
def _createMachineFromDiscoveredDevice(self, device_id: str) -> None:
"""Create a machine instance based on the discovered network printer."""
device = self._discovered_devices.get(device_id)
if device is None:
return
@ -216,8 +233,9 @@ class LocalClusterOutputDeviceManager:
self._connectToOutputDevice(device, new_machine)
self._showCloudFlowMessage(device)
## Add an address to the stored preferences.
def _storeManualAddress(self, address: str) -> None:
"""Add an address to the stored preferences."""
stored_addresses = self._getStoredManualAddresses()
if address in stored_addresses:
return # Prevent duplicates.
@ -225,8 +243,9 @@ class LocalClusterOutputDeviceManager:
new_value = ",".join(stored_addresses)
CuraApplication.getInstance().getPreferences().setValue(self.MANUAL_DEVICES_PREFERENCE_KEY, new_value)
## Remove an address from the stored preferences.
def _removeStoredManualAddress(self, address: str) -> None:
"""Remove an address from the stored preferences."""
stored_addresses = self._getStoredManualAddresses()
try:
stored_addresses.remove(address) # Can throw a ValueError
@ -235,15 +254,16 @@ class LocalClusterOutputDeviceManager:
except ValueError:
Logger.log("w", "Could not remove address from stored_addresses, it was not there")
## Load the user-configured manual devices from Cura preferences.
def _getStoredManualAddresses(self) -> List[str]:
"""Load the user-configured manual devices from Cura preferences."""
preferences = CuraApplication.getInstance().getPreferences()
preferences.addPreference(self.MANUAL_DEVICES_PREFERENCE_KEY, "")
manual_instances = preferences.getValue(self.MANUAL_DEVICES_PREFERENCE_KEY).split(",")
return manual_instances
## Add a device to the current active machine.
def _connectToOutputDevice(self, device: UltimakerNetworkedPrinterOutputDevice, machine: GlobalStack) -> None:
"""Add a device to the current active machine."""
# Make sure users know that we no longer support legacy devices.
if Version(device.firmwareVersion) < self.MIN_SUPPORTED_CLUSTER_VERSION:
@ -262,9 +282,10 @@ class LocalClusterOutputDeviceManager:
if device.key not in output_device_manager.getOutputDeviceIds():
output_device_manager.addOutputDevice(device)
## Nudge the user to start using Ultimaker Cloud.
@staticmethod
def _showCloudFlowMessage(device: LocalClusterOutputDevice) -> None:
"""Nudge the user to start using Ultimaker Cloud."""
if CuraApplication.getInstance().getMachineManager().activeMachineHasCloudRegistration:
# This printer is already cloud connected, so we do not bother the user anymore.
return

View file

@ -16,27 +16,33 @@ if TYPE_CHECKING:
from .LocalClusterOutputDevice import LocalClusterOutputDevice
## 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):
"""Asynchronous job to send material profiles to the printer.
This way it won't freeze up the interface while sending those materials.
"""
def __init__(self, device: "LocalClusterOutputDevice") -> None:
super().__init__()
self.device = device # type: LocalClusterOutputDevice
## Send the request to the printer and register a callback
def run(self) -> None:
"""Send the request to the printer and register a callback"""
self.device.getMaterials(on_finished = self._onGetMaterials)
## Callback for when the remote materials were returned.
def _onGetMaterials(self, materials: List[ClusterMaterial]) -> None:
"""Callback for when the remote materials were returned."""
remote_materials_by_guid = {material.guid: material for material in materials}
self._sendMissingMaterials(remote_materials_by_guid)
## Determine which materials should be updated and send them to the printer.
# \param remote_materials_by_guid The remote materials by GUID.
def _sendMissingMaterials(self, remote_materials_by_guid: Dict[str, ClusterMaterial]) -> None:
"""Determine which materials should be updated and send them to the printer.
:param remote_materials_by_guid: The remote materials by GUID.
"""
local_materials_by_guid = self._getLocalMaterials()
if len(local_materials_by_guid) == 0:
Logger.log("d", "There are no local materials to synchronize with the printer.")
@ -47,25 +53,31 @@ class SendMaterialJob(Job):
return
self._sendMaterials(material_ids_to_send)
## 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
# are newer in Cura.
# \param local_materials The local materials by GUID.
# \param remote_materials The remote materials by GUID.
@staticmethod
def _determineMaterialsToSend(local_materials: Dict[str, LocalMaterial],
remote_materials: Dict[str, ClusterMaterial]) -> Set[str]:
"""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
are newer in Cura.
:param local_materials: The local materials by GUID.
:param remote_materials: The remote materials by GUID.
"""
return {
local_material.id
for guid, local_material in local_materials.items()
if guid not in remote_materials.keys() or local_material.version > remote_materials[guid].version
}
## Send the materials to the 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.
# \param materials_to_send A set with id's of materials that must be sent.
def _sendMaterials(self, materials_to_send: Set[str]) -> None:
"""Send the materials to the 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.
:param materials_to_send: A set with id's of materials that must be sent.
"""
container_registry = CuraApplication.getInstance().getContainerRegistry()
all_materials = container_registry.findInstanceContainersMetadata(type = "material")
all_base_files = {material["base_file"] for material in all_materials if "base_file" in material} # Filters out uniques by making it a set. Don't include files without base file (i.e. empty material).
@ -83,12 +95,14 @@ class SendMaterialJob(Job):
file_name = os.path.basename(file_path)
self._sendMaterialFile(file_path, file_name, root_material_id)
## Send a single material file to the printer.
# Also add the material signature file if that is available.
# \param file_path The path of the material file.
# \param file_name The name of the material file.
# \param material_id The ID of the material in the file.
def _sendMaterialFile(self, file_path: str, file_name: str, material_id: str) -> None:
"""Send a single material file to the printer.
Also add the material signature file if that is available.
:param file_path: The path of the material file.
:param file_name: The name of the material file.
:param material_id: The ID of the material in the file.
"""
parts = []
# Add the material file.
@ -112,8 +126,9 @@ class SendMaterialJob(Job):
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
def _sendingFinished(self, reply: QNetworkReply) -> None:
"""Check a reply from an upload to the printer and log an error when the call failed"""
if reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) != 200:
Logger.log("w", "Error while syncing material: %s", reply.errorString())
return
@ -125,11 +140,14 @@ class SendMaterialJob(Job):
# Because of the guards above it is not shown when syncing failed (which is not always an actual problem).
MaterialSyncMessage(self.device).show()
## Retrieves a list of local materials
# Only the new newest version of the local materials is returned
# \return a dictionary of LocalMaterial objects by GUID
@staticmethod
def _getLocalMaterials() -> Dict[str, LocalMaterial]:
"""Retrieves a list of local materials
Only the new newest version of the local materials is returned
:return: a dictionary of LocalMaterial objects by GUID
"""
result = {} # type: Dict[str, LocalMaterial]
all_materials = CuraApplication.getInstance().getContainerRegistry().findInstanceContainersMetadata(type = "material")
all_base_files = [material for material in all_materials if material["id"] == material.get("base_file")] # Don't send materials without base_file: The empty material doesn't need to be sent.

View file

@ -12,9 +12,11 @@ from UM.Signal import Signal
from cura.CuraApplication import CuraApplication
## The ZeroConfClient handles all network discovery logic.
# It emits signals when new network services were found or disappeared.
class ZeroConfClient:
"""The ZeroConfClient handles all network discovery logic.
It emits signals when new network services were found or disappeared.
"""
# The discovery protocol name for Ultimaker printers.
ZERO_CONF_NAME = u"_ultimaker._tcp.local."
@ -30,10 +32,13 @@ class ZeroConfClient:
self._service_changed_request_event = None # type: Optional[Event]
self._service_changed_request_thread = None # type: Optional[Thread]
## The ZeroConf service changed requests are handled in a separate thread so we don't block the UI.
# We can also re-schedule the requests when they fail to get detailed service info.
# Any new or re-reschedule requests will be appended to the request queue and the thread will process them.
def start(self) -> None:
"""The ZeroConf service changed requests are handled in a separate thread so we don't block the UI.
We can also re-schedule the requests when they fail to get detailed service info.
Any new or re-reschedule requests will be appended to the request queue and the thread will process them.
"""
self._service_changed_request_queue = Queue()
self._service_changed_request_event = Event()
try:
@ -56,16 +61,18 @@ class ZeroConfClient:
self._zero_conf_browser.cancel()
self._zero_conf_browser = None
## Handles a change is discovered network services.
def _queueService(self, zeroconf: Zeroconf, service_type, name: str, state_change: ServiceStateChange) -> None:
"""Handles a change is discovered network services."""
item = (zeroconf, service_type, name, state_change)
if not self._service_changed_request_queue or not self._service_changed_request_event:
return
self._service_changed_request_queue.put(item)
self._service_changed_request_event.set()
## Callback for when a ZeroConf service has changes.
def _handleOnServiceChangedRequests(self) -> None:
"""Callback for when a ZeroConf service has changes."""
if not self._service_changed_request_queue or not self._service_changed_request_event:
return
@ -98,19 +105,23 @@ class ZeroConfClient:
for request in reschedule_requests:
self._service_changed_request_queue.put(request)
## Handler for zeroConf detection.
# Return True or False indicating if the process succeeded.
# Note that this function can take over 3 seconds to complete. Be careful calling it from the main thread.
def _onServiceChanged(self, zero_conf: Zeroconf, service_type: str, name: str, state_change: ServiceStateChange
) -> bool:
def _onServiceChanged(self, zero_conf: Zeroconf, service_type: str, name: str,
state_change: ServiceStateChange) -> bool:
"""Handler for zeroConf detection.
Return True or False indicating if the process succeeded.
Note that this function can take over 3 seconds to complete. Be careful calling it from the main thread.
"""
if state_change == ServiceStateChange.Added:
return self._onServiceAdded(zero_conf, service_type, name)
elif state_change == ServiceStateChange.Removed:
return self._onServiceRemoved(name)
return True
## Handler for when a ZeroConf service was added.
def _onServiceAdded(self, zero_conf: Zeroconf, service_type: str, name: str) -> bool:
"""Handler for when a ZeroConf service was added."""
# First try getting info from zero-conf cache
info = ServiceInfo(service_type, name, properties={})
for record in zero_conf.cache.entries_with_name(name.lower()):
@ -141,8 +152,9 @@ class ZeroConfClient:
return True
## Handler for when a ZeroConf service was removed.
def _onServiceRemoved(self, name: str) -> bool:
"""Handler for when a ZeroConf service was removed."""
Logger.log("d", "ZeroConf service removed: %s" % name)
self.removedNetworkCluster.emit(str(name))
return True

View file

@ -13,11 +13,11 @@ from .Network.LocalClusterOutputDeviceManager import LocalClusterOutputDeviceMan
from .Cloud.CloudOutputDeviceManager import CloudOutputDeviceManager
## This plugin handles the discovery and networking for Ultimaker 3D printers that support network and cloud printing.
class UM3OutputDevicePlugin(OutputDevicePlugin):
# Signal emitted when the list of discovered devices changed. Used by printer action in this plugin.
"""This plugin handles the discovery and networking for Ultimaker 3D printers"""
discoveredDevicesChanged = Signal()
"""Signal emitted when the list of discovered devices changed. Used by printer action in this plugin."""
def __init__(self) -> None:
super().__init__()
@ -33,8 +33,9 @@ class UM3OutputDevicePlugin(OutputDevicePlugin):
# This ensures no output devices are still connected that do not belong to the new active machine.
CuraApplication.getInstance().globalContainerStackChanged.connect(self.refreshConnections)
## Start looking for devices in the network and cloud.
def start(self):
"""Start looking for devices in the network and cloud."""
self._network_output_device_manager.start()
self._cloud_output_device_manager.start()
@ -43,31 +44,38 @@ class UM3OutputDevicePlugin(OutputDevicePlugin):
self._network_output_device_manager.stop()
self._cloud_output_device_manager.stop()
## Restart network discovery.
def startDiscovery(self) -> None:
"""Restart network discovery."""
self._network_output_device_manager.startDiscovery()
## Force refreshing the network connections.
def refreshConnections(self) -> None:
"""Force refreshing the network connections."""
self._network_output_device_manager.refreshConnections()
self._cloud_output_device_manager.refreshConnections()
## Indicate that this plugin supports adding networked printers manually.
def canAddManualDevice(self, address: str = "") -> ManualDeviceAdditionAttempt:
"""Indicate that this plugin supports adding networked printers manually."""
return ManualDeviceAdditionAttempt.PRIORITY
## Add a networked printer manually based on its network address.
def addManualDevice(self, address: str, callback: Optional[Callable[[bool, str], None]] = None) -> None:
"""Add a networked printer manually based on its network address."""
self._network_output_device_manager.addManualDevice(address, callback)
## Remove a manually connected networked printer.
def removeManualDevice(self, key: str, address: Optional[str] = None) -> None:
"""Remove a manually connected networked printer."""
self._network_output_device_manager.removeManualDevice(key, address)
## Get the discovered devices from the local network.
def getDiscoveredDevices(self) -> Dict[str, LocalClusterOutputDevice]:
"""Get the discovered devices from the local network."""
return self._network_output_device_manager.getDiscoveredDevices()
## Connect the active machine to a device.
def associateActiveMachineWithPrinterDevice(self, device: LocalClusterOutputDevice) -> None:
"""Connect the active machine to a device."""
self._network_output_device_manager.associateActiveMachineWithPrinterDevice(device)

View file

@ -15,9 +15,11 @@ from .Network.LocalClusterOutputDevice import LocalClusterOutputDevice
I18N_CATALOG = i18nCatalog("cura")
## Machine action that allows to connect the active machine to a networked devices.
# TODO: in the future this should be part of the new discovery workflow baked into Cura.
class UltimakerNetworkedPrinterAction(MachineAction):
"""Machine action that allows to connect the active machine to a networked devices.
TODO: in the future this should be part of the new discovery workflow baked into Cura.
"""
# Signal emitted when discovered devices have changed.
discoveredDevicesChanged = pyqtSignal()
@ -27,59 +29,69 @@ class UltimakerNetworkedPrinterAction(MachineAction):
self._qml_url = "resources/qml/DiscoverUM3Action.qml"
self._network_plugin = None # type: Optional[UM3OutputDevicePlugin]
## Override the default value.
def needsUserInteraction(self) -> bool:
"""Override the default value."""
return False
## Start listening to network discovery events via the plugin.
@pyqtSlot(name = "startDiscovery")
def startDiscovery(self) -> None:
"""Start listening to network discovery events via the plugin."""
self._networkPlugin.discoveredDevicesChanged.connect(self._onDeviceDiscoveryChanged)
self.discoveredDevicesChanged.emit() # trigger at least once to populate the list
## Reset the discovered devices.
@pyqtSlot(name = "reset")
def reset(self) -> None:
"""Reset the discovered devices."""
self.discoveredDevicesChanged.emit() # trigger to reset the list
## Reset the discovered devices.
@pyqtSlot(name = "restartDiscovery")
def restartDiscovery(self) -> None:
"""Reset the discovered devices."""
self._networkPlugin.startDiscovery()
self.discoveredDevicesChanged.emit() # trigger to reset the list
## Remove a manually added device.
@pyqtSlot(str, str, name = "removeManualDevice")
def removeManualDevice(self, key: str, address: str) -> None:
"""Remove a manually added device."""
self._networkPlugin.removeManualDevice(key, address)
## Add a new manual device. Can replace an existing one by key.
@pyqtSlot(str, str, name = "setManualDevice")
def setManualDevice(self, key: str, address: str) -> None:
"""Add a new manual device. Can replace an existing one by key."""
if key != "":
self._networkPlugin.removeManualDevice(key)
if address != "":
self._networkPlugin.addManualDevice(address)
## Get the devices discovered in the local network sorted by name.
@pyqtProperty("QVariantList", notify = discoveredDevicesChanged)
def foundDevices(self):
"""Get the devices discovered in the local network sorted by name."""
discovered_devices = list(self._networkPlugin.getDiscoveredDevices().values())
discovered_devices.sort(key = lambda d: d.name)
return discovered_devices
## Connect a device selected in the list with the active machine.
@pyqtSlot(QObject, name = "associateActiveMachineWithPrinterDevice")
def associateActiveMachineWithPrinterDevice(self, device: LocalClusterOutputDevice) -> None:
"""Connect a device selected in the list with the active machine."""
self._networkPlugin.associateActiveMachineWithPrinterDevice(device)
## Callback for when the list of discovered devices in the plugin was changed.
def _onDeviceDiscoveryChanged(self) -> None:
"""Callback for when the list of discovered devices in the plugin was changed."""
self.discoveredDevicesChanged.emit()
## Get the network manager from the plugin.
@property
def _networkPlugin(self) -> UM3OutputDevicePlugin:
"""Get the network manager from the plugin."""
if not self._network_plugin:
output_device_manager = CuraApplication.getInstance().getOutputDeviceManager()
network_plugin = output_device_manager.getOutputDevicePlugin("UM3NetworkPrinting")

View file

@ -22,10 +22,12 @@ from .Models.Http.ClusterPrinterStatus import ClusterPrinterStatus
from .Models.Http.ClusterPrintJobStatus import ClusterPrintJobStatus
## Output device class that forms the basis of Ultimaker networked printer output devices.
# Currently used for local networking and cloud printing using Ultimaker Connect.
# This base class primarily contains all the Qt properties and slots needed for the monitor page to work.
class UltimakerNetworkedPrinterOutputDevice(NetworkedPrinterOutputDevice):
"""Output device class that forms the basis of Ultimaker networked printer output devices.
Currently used for local networking and cloud printing using Ultimaker Connect.
This base class primarily contains all the Qt properties and slots needed for the monitor page to work.
"""
META_NETWORK_KEY = "um_network_key"
META_CLUSTER_ID = "um_cloud_cluster_id"
@ -85,14 +87,16 @@ class UltimakerNetworkedPrinterOutputDevice(NetworkedPrinterOutputDevice):
# The job upload progress message modal.
self._progress = PrintJobUploadProgressMessage()
## The IP address of the printer.
@pyqtProperty(str, constant=True)
def address(self) -> str:
"""The IP address of the printer."""
return self._address
## The display name of the printer.
@pyqtProperty(str, constant=True)
def printerTypeName(self) -> str:
"""The display name of the printer."""
return self._printer_type_name
# Get all print jobs for this cluster.
@ -157,13 +161,15 @@ class UltimakerNetworkedPrinterOutputDevice(NetworkedPrinterOutputDevice):
self._active_printer = printer
self.activePrinterChanged.emit()
## Whether the printer that this output device represents supports print job actions via the local network.
@pyqtProperty(bool, constant=True)
def supportsPrintJobActions(self) -> bool:
"""Whether the printer that this output device represents supports print job actions via the local network."""
return True
## Set the remote print job state.
def setJobState(self, print_job_uuid: str, state: str) -> None:
"""Set the remote print job state."""
raise NotImplementedError("setJobState must be implemented")
@pyqtSlot(str, name="sendJobToTop")
@ -210,11 +216,13 @@ class UltimakerNetworkedPrinterOutputDevice(NetworkedPrinterOutputDevice):
self._checkStillConnected()
super()._update()
## Check if we're still connected by comparing the last timestamps for network response and the current time.
# This implementation is similar to the base NetworkedPrinterOutputDevice, but is tweaked slightly.
# Re-connecting is handled automatically by the output device managers in this plugin.
# TODO: it would be nice to have this logic in the managers, but connecting those with signals causes crashes.
def _checkStillConnected(self) -> None:
"""Check if we're still connected by comparing the last timestamps for network response and the current time.
This implementation is similar to the base NetworkedPrinterOutputDevice, but is tweaked slightly.
Re-connecting is handled automatically by the output device managers in this plugin.
TODO: it would be nice to have this logic in the managers, but connecting those with signals causes crashes.
"""
time_since_last_response = time() - self._time_of_last_response
if time_since_last_response > self.NETWORK_RESPONSE_CONSIDER_OFFLINE:
self.setConnectionState(ConnectionState.Closed)
@ -223,9 +231,11 @@ class UltimakerNetworkedPrinterOutputDevice(NetworkedPrinterOutputDevice):
elif self.connectionState == ConnectionState.Closed:
self._reconnectForActiveMachine()
## Reconnect for the active output device.
# Does nothing if the device is not meant for the active machine.
def _reconnectForActiveMachine(self) -> None:
"""Reconnect for the active output device.
Does nothing if the device is not meant for the active machine.
"""
active_machine = CuraApplication.getInstance().getGlobalContainerStack()
if not active_machine:
return
@ -281,16 +291,19 @@ class UltimakerNetworkedPrinterOutputDevice(NetworkedPrinterOutputDevice):
self.printersChanged.emit()
self._checkIfClusterHost()
## Check is this device is a cluster host and takes the needed actions when it is not.
def _checkIfClusterHost(self):
"""Check is this device is a cluster host and takes the needed actions when it is not."""
if len(self._printers) < 1 and self.isConnected():
NotClusterHostMessage(self).show()
self.close()
CuraApplication.getInstance().getOutputDeviceManager().removeOutputDevice(self.key)
## Updates the local list of print jobs with the list received from the cluster.
# \param remote_jobs: The print jobs received from the cluster.
def _updatePrintJobs(self, remote_jobs: List[ClusterPrintJobStatus]) -> None:
"""Updates the local list of print jobs with the list received from the cluster.
:param remote_jobs: The print jobs received from the cluster.
"""
self._responseReceived()
# Keep track of the new print jobs to show.
@ -321,9 +334,11 @@ class UltimakerNetworkedPrinterOutputDevice(NetworkedPrinterOutputDevice):
self._print_jobs = new_print_jobs
self.printJobsChanged.emit()
## Create a new print job model based on the remote status of the job.
# \param remote_job: The remote print job data.
def _createPrintJobModel(self, remote_job: ClusterPrintJobStatus) -> UM3PrintJobOutputModel:
"""Create a new print job model based on the remote status of the job.
:param remote_job: The remote print job data.
"""
model = remote_job.createOutputModel(ClusterOutputController(self))
if remote_job.printer_uuid:
self._updateAssignedPrinter(model, remote_job.printer_uuid)
@ -333,16 +348,18 @@ class UltimakerNetworkedPrinterOutputDevice(NetworkedPrinterOutputDevice):
model.loadPreviewImageFromUrl(remote_job.preview_url)
return model
## Updates the printer assignment for the given print job model.
def _updateAssignedPrinter(self, model: UM3PrintJobOutputModel, printer_uuid: str) -> None:
"""Updates the printer assignment for the given print job model."""
printer = next((p for p in self._printers if printer_uuid == p.key), None)
if not printer:
return
printer.updateActivePrintJob(model)
model.updateAssignedPrinter(printer)
## Load Monitor tab QML.
def _loadMonitorTab(self) -> None:
"""Load Monitor tab QML."""
plugin_registry = CuraApplication.getInstance().getPluginRegistry()
if not plugin_registry:
Logger.log("e", "Could not get plugin registry")

View file

@ -110,20 +110,22 @@ class USBPrinterOutputDevice(PrinterOutputDevice):
application = CuraApplication.getInstance()
application.triggerNextExitCheck()
## Reset USB device settings
#
def resetDeviceSettings(self) -> None:
"""Reset USB device settings"""
self._firmware_name = None
## Request the current scene to be sent to a USB-connected printer.
#
# \param nodes A collection of scene nodes to send. This is ignored.
# \param file_name A suggestion for a file name to write.
# \param filter_by_machine Whether to filter MIME types by machine. This
# is ignored.
# \param kwargs Keyword arguments.
def requestWrite(self, nodes: List["SceneNode"], file_name: Optional[str] = None, limit_mimetypes: bool = False,
file_handler: Optional["FileHandler"] = None, filter_by_machine: bool = False, **kwargs) -> None:
"""Request the current scene to be sent to a USB-connected printer.
:param nodes: A collection of scene nodes to send. This is ignored.
:param file_name: A suggestion for a file name to write.
:param filter_by_machine: Whether to filter MIME types by machine. This
is ignored.
:param kwargs: Keyword arguments.
"""
if self._is_printing:
message = Message(text = catalog.i18nc("@message", "A print is still in progress. Cura cannot start another print via USB until the previous print has completed."), title = catalog.i18nc("@message", "Print in Progress"))
message.show()
@ -144,9 +146,11 @@ class USBPrinterOutputDevice(PrinterOutputDevice):
self._printGCode(gcode_textio.getvalue())
## Start a print based on a g-code.
# \param gcode The g-code to print.
def _printGCode(self, gcode: str):
"""Start a print based on a g-code.
:param gcode: The g-code to print.
"""
self._gcode.clear()
self._paused = False
@ -219,8 +223,9 @@ class USBPrinterOutputDevice(PrinterOutputDevice):
self._update_thread = Thread(target=self._update, daemon=True, name = "USBPrinterUpdate")
self._serial = None
## Send a command to printer.
def sendCommand(self, command: Union[str, bytes]):
"""Send a command to printer."""
if not self._command_received.is_set():
self._command_queue.put(command)
else:

View file

@ -20,9 +20,10 @@ from . import USBPrinterOutputDevice
i18n_catalog = i18nCatalog("cura")
## Manager class that ensures that an USBPrinterOutput device is created for every connected USB printer.
@signalemitter
class USBPrinterOutputDeviceManager(QObject, OutputDevicePlugin):
"""Manager class that ensures that an USBPrinterOutput device is created for every connected USB printer."""
addUSBOutputDeviceSignal = Signal()
progressChanged = pyqtSignal()
@ -85,8 +86,9 @@ class USBPrinterOutputDeviceManager(QObject, OutputDevicePlugin):
self._addRemovePorts(port_list)
time.sleep(5)
## Helper to identify serial ports (and scan for them)
def _addRemovePorts(self, serial_ports):
"""Helper to identify serial ports (and scan for them)"""
# First, find and add all new or changed keys
for serial_port in list(serial_ports):
if serial_port not in self._serial_port_list:
@ -98,16 +100,19 @@ class USBPrinterOutputDeviceManager(QObject, OutputDevicePlugin):
if port not in self._serial_port_list:
device.close()
## Because the model needs to be created in the same thread as the QMLEngine, we use a signal.
def addOutputDevice(self, serial_port):
"""Because the model needs to be created in the same thread as the QMLEngine, we use a signal."""
device = USBPrinterOutputDevice.USBPrinterOutputDevice(serial_port)
device.connectionStateChanged.connect(self._onConnectionStateChanged)
self._usb_output_devices[serial_port] = device
device.connect()
## Create a list of serial ports on the system.
# \param only_list_usb If true, only usb ports are listed
def getSerialPortList(self, only_list_usb = False):
"""Create a list of serial ports on the system.
:param only_list_usb: If true, only usb ports are listed
"""
base_list = []
for port in serial.tools.list_ports.comports():
if not isinstance(port, tuple):

View file

@ -31,7 +31,7 @@ def readHex(filename):
check_sum &= 0xFF
if check_sum != 0:
raise Exception("Checksum error in hex file: " + line)
if rec_type == 0:#Data record
while len(data) < addr + rec_len:
data.append(0)

Some files were not shown because too many files have changed in this diff Show more