diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index 6e8ce73bf5..f3e51c5f4e 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -17,7 +17,6 @@ from cura.CuraApplication import CuraApplication from cura.PrinterOutput.NetworkedPrinterOutputDevice import AuthState from cura.PrinterOutput.PrinterOutputDevice import ConnectionType -from .CloudProgressMessage import CloudProgressMessage from .CloudApiClient import CloudApiClient from ..UltimakerNetworkedPrinterOutputDevice import UltimakerNetworkedPrinterOutputDevice from ..MeshFormatHandler import MeshFormatHandler @@ -84,15 +83,11 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice): self._account = api_client.account self._cluster = cluster self.setAuthenticationState(AuthState.NotAuthenticated) - self._setInterfaceElements() # Trigger the printersChanged signal when the private signal is triggered. self.printersChanged.connect(self._clusterPrintersChanged) - # We only allow a single upload at a time. - self._progress = CloudProgressMessage() - # Keep server string of the last generated time to avoid updating models more than once for the same response self._received_printers = None # type: Optional[List[ClusterPrinterStatus]] self._received_print_jobs = None # type: Optional[List[ClusterPrintJobStatus]] @@ -143,15 +138,14 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice): self.setConnectionText(I18N_CATALOG.i18nc("@info:status", "Connected via Cloud")) ## Called when Cura requests an output device to receive a (G-code) file. - def requestWrite(self, nodes: List["SceneNode"], file_name: Optional[str] = None, limit_mimetypes: bool = False, - file_handler: Optional["FileHandler"] = None, filter_by_machine: bool = False, **kwargs) -> None: + 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: # Show an error message if we're already sending a job. if self._progress.visible: message = Message( - text=I18N_CATALOG.i18nc("@info:status", - "Sending new jobs (temporarily) blocked, still sending the previous print job."), - title=I18N_CATALOG.i18nc("@info:title", "Cloud error"), + text=I18N_CATALOG.i18nc("@info:status", "Please wait until the current job has been sent."), + title=I18N_CATALOG.i18nc("@info:title", "Print error"), lifetime=10 ) message.show() @@ -170,8 +164,8 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice): Logger.log("e", "Missing file or mesh writer!") return self._onUploadError(I18N_CATALOG.i18nc("@info:status", "Could not export print job.")) + # TODO: use stream just like the network output device mesh = mesh_format.getBytes(nodes) - self._tool_path = mesh request = CloudPrintJobUploadRequest( job_name=file_name or mesh_format.file_extension, @@ -236,7 +230,6 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice): ## Shows a message when the upload has succeeded # \param response: The response from the cloud API. def _onPrintUploadCompleted(self, response: CloudPrintResponse) -> None: - Logger.log("d", "The cluster will be printing this print job with the ID %s", response.cluster_job_id) self._progress.hide() Message( text=I18N_CATALOG.i18nc("@info:status", "Print job was successfully sent to the printer."), diff --git a/plugins/UM3NetworkPrinting/src/Network/NetworkOutputDevice.py b/plugins/UM3NetworkPrinting/src/Network/NetworkOutputDevice.py index 0f516a8aeb..25e7b467f8 100644 --- a/plugins/UM3NetworkPrinting/src/Network/NetworkOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Network/NetworkOutputDevice.py @@ -6,13 +6,17 @@ from PyQt5.QtGui import QDesktopServices from PyQt5.QtCore import pyqtSlot, QUrl, pyqtSignal, pyqtProperty from UM.FileHandler.FileHandler import FileHandler +from UM.FileHandler.WriteFileJob import WriteFileJob from UM.Logger import Logger +from UM.Message import Message from UM.i18n import i18nCatalog from UM.Scene.SceneNode import SceneNode +from cura.CuraApplication import CuraApplication from cura.PrinterOutput.NetworkedPrinterOutputDevice import AuthState from cura.PrinterOutput.PrinterOutputDevice import ConnectionType from .ClusterApiClient import ClusterApiClient +from ..MeshFormatHandler import MeshFormatHandler from ..SendMaterialJob import SendMaterialJob from ..UltimakerNetworkedPrinterOutputDevice import UltimakerNetworkedPrinterOutputDevice @@ -38,22 +42,9 @@ class NetworkOutputDevice(UltimakerNetworkedPrinterOutputDevice): self._cluster_api = ClusterApiClient(address, on_error=self._onNetworkError) # We don't have authentication over local networking, so we're always authenticated. self.setAuthenticationState(AuthState.Authenticated) - self._setInterfaceElements() - self._active_camera_url = QUrl() # type: QUrl - # self._number_of_extruders = 2 - # self._dummy_lambdas = ( - # "", {}, io.BytesIO() - # ) # type: Tuple[Optional[str], Dict[str, Union[str, int, bool]], Union[io.StringIO, io.BytesIO]] - # self._error_message = None # type: Optional[Message] - # self._write_job_progress_message = None # type: Optional[Message] - # self._progress_message = None # type: Optional[Message] - # self._printer_selection_dialog = None # type: QObject - # self._printer_uuid_to_unique_name_mapping = {} # type: Dict[str, str] - # self._finished_jobs = [] # type: List[UM3PrintJobOutputModel] - # self._sending_job = None ## Set all the interface elements and texts for this output device. def _setInterfaceElements(self) -> None: @@ -117,195 +108,13 @@ class NetworkOutputDevice(UltimakerNetworkedPrinterOutputDevice): # if print_job.getPreviewImage() is None: # self.get("print_jobs/{uuid}/preview_image".format(uuid=print_job.key), on_finished=self._onGetPreviewImageFinished) - ## Sync the material profiles in Cura with the printer. - # This gets called when connecting to a printer as well as when sending a print. + ## Sync the material profiles in Cura with the printer. + # This gets called when connecting to a printer as well as when sending a print. def sendMaterialProfiles(self) -> None: job = SendMaterialJob(device=self) job.run() - - - # TODO FROM HERE - - - - 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: - pass - # self.writeStarted.emit(self) - # - # self.sendMaterialProfiles() - # - # mesh_format = MeshFormatHandler(file_handler, self.firmwareVersion) - # - # # This function pauses with the yield, waiting on instructions on which printer it needs to print with. - # if not mesh_format.is_valid: - # Logger.log("e", "Missing file or mesh writer!") - # return - # self._sending_job = self._sendPrintJob(mesh_format, nodes) - # if self._sending_job is not None: - # self._sending_job.send(None) # Start the generator. - # - # if len(self._printers) > 1: # We need to ask the user. - # self._spawnPrinterSelectionDialog() - # else: # Just immediately continue. - # self._sending_job.send("") # No specifically selected printer. - # self._sending_job.send(None) - # - # def _spawnPrinterSelectionDialog(self): - # if self._printer_selection_dialog is None: - # if PluginRegistry.getInstance() is not None: - # path = os.path.join( - # PluginRegistry.getInstance().getPluginPath("UM3NetworkPrinting"), - # "resources", "qml", "PrintWindow.qml" - # ) - # self._printer_selection_dialog = self._application.createQmlComponent(path, {"OutputDevice": self}) - # if self._printer_selection_dialog is not None: - # self._printer_selection_dialog.show() - - # ## Allows the user to choose a printer to print with from the printer - # # selection dialogue. - # # \param target_printer The name of the printer to target. - # @pyqtSlot(str) - # def selectPrinter(self, target_printer: str = "") -> None: - # if self._sending_job is not None: - # self._sending_job.send(target_printer) - - # @pyqtSlot() - # def cancelPrintSelection(self) -> None: - # self._sending_gcode = False - - # ## Greenlet to send a job to the printer over the network. - # # - # # This greenlet gets called asynchronously in requestWrite. It is a - # # greenlet in order to optionally wait for selectPrinter() to select a - # # printer. - # # The greenlet yields exactly three times: First time None, - # # \param mesh_format Object responsible for choosing the right kind of format to write with. - # def _sendPrintJob(self, mesh_format: MeshFormatHandler, nodes: List[SceneNode]): - # Logger.log("i", "Sending print job to printer.") - # if self._sending_gcode: - # self._error_message = Message( - # I18N_CATALOG.i18nc("@info:status", - # "Sending new jobs (temporarily) blocked, still sending the previous print job.")) - # self._error_message.show() - # yield #Wait on the user to select a target printer. - # yield #Wait for the write job to be finished. - # yield False #Return whether this was a success or not. - # yield #Prevent StopIteration. - # - # self._sending_gcode = True - # - # # Potentially wait on the user to select a target printer. - # target_printer = yield # type: Optional[str] - # - # # Using buffering greatly reduces the write time for many lines of gcode - # - # stream = mesh_format.createStream() - # - # job = WriteFileJob(mesh_format.writer, stream, nodes, mesh_format.file_mode) - # - # self._write_job_progress_message = Message(I18N_CATALOG.i18nc("@info:status", "Sending data to printer"), - # lifetime = 0, dismissable = False, progress = -1, - # title = I18N_CATALOG.i18nc("@info:title", "Sending Data"), - # use_inactivity_timer = False) - # self._write_job_progress_message.show() - # - # if mesh_format.preferred_format is not None: - # self._dummy_lambdas = (target_printer, mesh_format.preferred_format, stream) - # job.finished.connect(self._sendPrintJobWaitOnWriteJobFinished) - # job.start() - # yield True # Return that we had success! - # yield # To prevent having to catch the StopIteration exception. - - # def _sendPrintJobWaitOnWriteJobFinished(self, job: WriteFileJob) -> None: - # if self._write_job_progress_message: - # self._write_job_progress_message.hide() - # - # self._progress_message = Message(I18N_CATALOG.i18nc("@info:status", "Sending data to printer"), lifetime = 0, - # dismissable = False, progress = -1, - # title = I18N_CATALOG.i18nc("@info:title", "Sending Data")) - # self._progress_message.addAction("Abort", I18N_CATALOG.i18nc("@action:button", "Cancel"), icon = "", - # description = "") - # self._progress_message.actionTriggered.connect(self._progressMessageActionTriggered) - # self._progress_message.show() - # parts = [] - # - # target_printer, preferred_format, stream = self._dummy_lambdas - # - # # If a specific printer was selected, it should be printed with that machine. - # if target_printer: - # target_printer = self._printer_uuid_to_unique_name_mapping[target_printer] - # parts.append(self._createFormPart("name=require_printer_name", bytes(target_printer, "utf-8"), "text/plain")) - # - # # Add user name to the print_job - # parts.append(self._createFormPart("name=owner", bytes(self._getUserName(), "utf-8"), "text/plain")) - # - # file_name = self._application.getPrintInformation().jobName + "." + preferred_format["extension"] - # - # output = stream.getvalue() # Either str or bytes depending on the output mode. - # if isinstance(stream, io.StringIO): - # output = cast(str, output).encode("utf-8") - # output = cast(bytes, output) - # - # parts.append(self._createFormPart("name=\"file\"; filename=\"%s\"" % file_name, output)) - # - # self._latest_reply_handler = self.postFormWithParts("print_jobs/", parts, - # on_finished = self._onPostPrintJobFinished, - # on_progress = self._onUploadPrintJobProgress) - - # def _onPostPrintJobFinished(self, reply: QNetworkReply) -> None: - # if self._progress_message: - # self._progress_message.hide() - # self._compressing_gcode = False - # self._sending_gcode = False - - # def _onUploadPrintJobProgress(self, bytes_sent: int, bytes_total: int) -> None: - # if bytes_total > 0: - # new_progress = bytes_sent / bytes_total * 100 - # # Treat upload progress as response. Uploading can take more than 10 seconds, so if we don't, we can get - # # timeout responses if this happens. - # self._last_response_time = time() - # if self._progress_message is not None and new_progress != self._progress_message.getProgress(): - # self._progress_message.show() # Ensure that the message is visible. - # self._progress_message.setProgress(bytes_sent / bytes_total * 100) - # - # # If successfully sent: - # if bytes_sent == bytes_total: - # # Show a confirmation to the user so they know the job was sucessful and provide the option to switch to - # # the monitor tab. - # self._success_message = Message( - # I18N_CATALOG.i18nc("@info:status", "Print job was successfully sent to the printer."), - # lifetime=5, dismissable=True, - # title=I18N_CATALOG.i18nc("@info:title", "Data Sent")) - # self._success_message.addAction("View", I18N_CATALOG.i18nc("@action:button", "View in Monitor"), icon = "", - # description="") - # self._success_message.actionTriggered.connect(self._successMessageActionTriggered) - # self._success_message.show() - # else: - # if self._progress_message is not None: - # self._progress_message.setProgress(0) - # self._progress_message.hide() - - # def _progressMessageActionTriggered(self, message_id: Optional[str] = None, action_id: Optional[str] = None) -> None: - # if action_id == "Abort": - # Logger.log("d", "User aborted sending print to remote.") - # if self._progress_message is not None: - # self._progress_message.hide() - # self._compressing_gcode = False - # self._sending_gcode = False - # self._application.getController().setActiveStage("PrepareStage") - # - # # After compressing the sliced model Cura sends data to printer, to stop receiving updates from the request - # # the "reply" should be disconnected - # if self._latest_reply_handler: - # self._latest_reply_handler.disconnect() - # self._latest_reply_handler = None - - # def _successMessageActionTriggered(self, message_id: Optional[str] = None, action_id: Optional[str] = None) -> None: - # if action_id == "View": - # self._application.getController().setActiveStage("MonitorStage") - + # ## Callback for when preview image was downloaded from cluster. # def _onGetPreviewImageFinished(self, reply: QNetworkReply) -> None: # reply_url = reply.url().toString() # @@ -317,53 +126,77 @@ class NetworkOutputDevice(UltimakerNetworkedPrinterOutputDevice): # image.loadFromData(reply.readAll()) # print_job.updatePreviewImage(image) - # def _createMaterialOutputModel(self, material_data: Dict[str, Any]) -> "MaterialOutputModel": - # material_manager = self._application.getMaterialManager() - # material_group_list = None - # - # # Avoid crashing if there is no "guid" field in the metadata - # material_guid = material_data.get("guid") - # if material_guid: - # material_group_list = material_manager.getMaterialGroupListByGUID(material_guid) - # - # # This can happen if the connected machine has no material in one or more extruders (if GUID is empty), or the - # # material is unknown to Cura, so we should return an "empty" or "unknown" material model. - # if material_group_list is None: - # material_name = I18N_CATALOG.i18nc("@label:material", "Empty") if len(material_data.get("guid", "")) == 0 \ - # else I18N_CATALOG.i18nc("@label:material", "Unknown") - # - # return MaterialOutputModel(guid = material_data.get("guid", ""), - # type = material_data.get("material", ""), - # color = material_data.get("color", ""), - # brand = material_data.get("brand", ""), - # name = material_data.get("name", material_name) - # ) - # - # # Sort the material groups by "is_read_only = True" first, and then the name alphabetically. - # read_only_material_group_list = list(filter(lambda x: x.is_read_only, material_group_list)) - # non_read_only_material_group_list = list(filter(lambda x: not x.is_read_only, material_group_list)) - # material_group = None - # if read_only_material_group_list: - # read_only_material_group_list = sorted(read_only_material_group_list, key = lambda x: x.name) - # material_group = read_only_material_group_list[0] - # elif non_read_only_material_group_list: - # non_read_only_material_group_list = sorted(non_read_only_material_group_list, key = lambda x: x.name) - # material_group = non_read_only_material_group_list[0] - # - # if material_group: - # container = material_group.root_material_node.getContainer() - # color = container.getMetaDataEntry("color_code") - # brand = container.getMetaDataEntry("brand") - # material_type = container.getMetaDataEntry("material") - # name = container.getName() - # else: - # Logger.log("w", - # "Unable to find material with guid {guid}. Using data as provided by cluster".format( - # guid=material_data["guid"])) - # color = material_data["color"] - # brand = material_data["brand"] - # material_type = material_data["material"] - # name = I18N_CATALOG.i18nc("@label:material", "Empty") if material_data["material"] == "empty" \ - # else I18N_CATALOG.i18nc("@label:material", "Unknown") - # return MaterialOutputModel(guid = material_data["guid"], type = material_type, - # brand = brand, color = color, name = name) + ## 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: + + # Show an error message if we're already sending a job. + if self._progress.visible: + message = Message( + text=I18N_CATALOG.i18nc("@info:status", "Please wait until the current job has been sent."), + title=I18N_CATALOG.i18nc("@info:title", "Print error"), + lifetime=10 + ) + message.show() + return + + self.writeStarted.emit(self) + + # Make sure the printer is aware of all new materials as the new print job might contain one. + self.sendMaterialProfiles() + + # Detect the correct export format depending on printer type and firmware version. + mesh_format = MeshFormatHandler(file_handler, self.firmwareVersion) + if not mesh_format.is_valid: + Logger.log("e", "Missing file or mesh writer!") + return self._onUploadError(I18N_CATALOG.i18nc("@info:status", "Could not export print job.")) + + # Determine the filename. + job_name = CuraApplication.getInstance().getPrintInformation().jobName + extension = mesh_format.preferred_format.get("extension", "") + file_name = f"{job_name}.{extension}" + + # Export the file. + stream = mesh_format.createStream() + job = WriteFileJob(mesh_format.writer, stream, nodes, mesh_format.file_mode) + job.setFileName(file_name) + job.finished.connect(self._onPrintJobCreated) + job.start() + + ## Handler for when the print job was created locally. + # It can now be sent over the network. + def _onPrintJobCreated(self, job: WriteFileJob) -> None: + self._progress.show() + parts = [] + parts.append(self._createFormPart("name=owner", bytes(self._getUserName(), "utf-8"), "text/plain")) + output = job.getStream().getvalue() + parts.append(self._createFormPart("name=\"file\"; filename=\"%s\"" % job.getFileName(), output)) + self.postFormWithParts("print_jobs/", parts, on_finished=self._onPrintUploadCompleted, + on_progress=self._onPrintJobUploadProgress) + + ## Handler for print job upload progress. + def _onPrintJobUploadProgress(self, bytes_sent: int, bytes_total: int) -> None: + 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) -> None: + self._progress.hide() + Message( + text=I18N_CATALOG.i18nc("@info:status", "Print job was successfully sent to the printer."), + title=I18N_CATALOG.i18nc("@info:title", "Data Sent"), + lifetime=5 + ).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: + self._progress.hide() + Message( + text=message or I18N_CATALOG.i18nc("@info:text", "Could not upload the data to the printer."), + title=I18N_CATALOG.i18nc("@info:title", "Network error"), + lifetime=10 + ).show() + self.writeError.emit() diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudProgressMessage.py b/plugins/UM3NetworkPrinting/src/PrintJobUploadProgressMessage.py similarity index 93% rename from plugins/UM3NetworkPrinting/src/Cloud/CloudProgressMessage.py rename to plugins/UM3NetworkPrinting/src/PrintJobUploadProgressMessage.py index 943bef2bc1..9862c9ec72 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudProgressMessage.py +++ b/plugins/UM3NetworkPrinting/src/PrintJobUploadProgressMessage.py @@ -8,11 +8,11 @@ I18N_CATALOG = i18nCatalog("cura") ## Class responsible for showing a progress message while a mesh is being uploaded to the cloud. -class CloudProgressMessage(Message): +class PrintJobUploadProgressMessage(Message): def __init__(self): super().__init__( title = I18N_CATALOG.i18nc("@info:status", "Sending Print Job"), - text = I18N_CATALOG.i18nc("@info:status", "Uploading via Ultimaker Cloud"), + text = I18N_CATALOG.i18nc("@info:status", "Uploading print job to printer."), progress = -1, lifetime = 0, dismissable = False, diff --git a/plugins/UM3NetworkPrinting/src/UltimakerNetworkedPrinterOutputDevice.py b/plugins/UM3NetworkPrinting/src/UltimakerNetworkedPrinterOutputDevice.py index b098be629e..ee6a67992f 100644 --- a/plugins/UM3NetworkPrinting/src/UltimakerNetworkedPrinterOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/UltimakerNetworkedPrinterOutputDevice.py @@ -14,6 +14,7 @@ from cura.PrinterOutput.PrinterOutputDevice import ConnectionType from .Utils import formatTimeCompleted, formatDateCompleted from .ClusterOutputController import ClusterOutputController +from .PrintJobUploadProgressMessage import PrintJobUploadProgressMessage from .Models.UM3PrintJobOutputModel import UM3PrintJobOutputModel from .Models.Http.ClusterPrinterStatus import ClusterPrinterStatus from .Models.Http.ClusterPrintJobStatus import ClusterPrintJobStatus @@ -60,6 +61,9 @@ class UltimakerNetworkedPrinterOutputDevice(NetworkedPrinterOutputDevice): # Load the Monitor UI elements. self._loadMonitorTab() + # The job upload progress message modal. + self._progress = PrintJobUploadProgressMessage() + ## The IP address of the printer. @pyqtProperty(str, constant=True) def address(self) -> str: