diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index f3e51c5f4e..446a8bb73b 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -9,6 +9,7 @@ from PyQt5.QtGui import QDesktopServices from UM import i18nCatalog from UM.Backend.Backend import BackendState from UM.FileHandler.FileHandler import FileHandler +from UM.FileHandler.WriteFileJob import WriteFileJob from UM.Logger import Logger from UM.Message import Message from UM.Scene.SceneNode import SceneNode @@ -16,6 +17,7 @@ from UM.Version import Version from cura.CuraApplication import CuraApplication from cura.PrinterOutput.NetworkedPrinterOutputDevice import AuthState from cura.PrinterOutput.PrinterOutputDevice import ConnectionType +from plugins.UM3NetworkPrinting.src.ExportFileJob import ExportFileJob from .CloudApiClient import CloudApiClient from ..UltimakerNetworkedPrinterOutputDevice import UltimakerNetworkedPrinterOutputDevice @@ -93,6 +95,7 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice): self._received_print_jobs = None # type: Optional[List[ClusterPrintJobStatus]] # Reference to the uploaded print job / mesh + # We do this to prevent re-uploading the same file multiple times. self._tool_path = None # type: Optional[bytes] self._uploaded_print_job = None # type: Optional[CloudPrintJobResponse] @@ -129,7 +132,7 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice): return True return False - ## Set all the interface elements and texts for this output device. + ## Set all the interface elements and texts for this output device. def _setInterfaceElements(self) -> None: self.setPriority(2) # Make sure we end up below the local networking and above 'save to file'. self.setName(self._id) @@ -137,6 +140,32 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice): 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: + super()._update() + if self._last_request_time and time() - self._last_request_time < self.CHECK_CLUSTER_INTERVAL: + return # Avoid calling the cloud too often + + Logger.log("d", "Updating: %s - %s >= %s", time(), self._last_request_time, self.CHECK_CLUSTER_INTERVAL) + if self._account.isLoggedIn: + self.setAuthenticationState(AuthState.Authenticated) + self._last_request_time = time() + self._api.getClusterStatus(self.key, self._onStatusCallFinished) + 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: + # Update all data from the cluster. + self._last_response_time = time() + if self._received_printers != status.printers: + self._received_printers = status.printers + self._updatePrinters(status.printers) + if status.print_jobs != self._received_print_jobs: + 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: @@ -159,54 +188,29 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice): # Indicate we have started sending a job. self.writeStarted.emit(self) - 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.")) + # Export the scene to the correct file type. + job = ExportFileJob(file_handler=file_handler, nodes=nodes, firmware_version=self.firmwareVersion) + job.finished.connect(self._onPrintJobCreated) + job.start() - # TODO: use stream just like the network output device - mesh = mesh_format.getBytes(nodes) - self._tool_path = mesh + ## Handler for when the print job was created locally. + # It can now be sent over the cloud. + def _onPrintJobCreated(self, job: WriteFileJob) -> None: + self._progress.show() + self._tool_path = job.getOutput() request = CloudPrintJobUploadRequest( - job_name=file_name or mesh_format.file_extension, - file_size=len(mesh), - content_type=mesh_format.mime_type, + job_name=job.getFileName(), + file_size=len(self._tool_path), + content_type=job.getMimeType(), ) - self._api.requestUpload(request, self._onPrintJobCreated) - - ## Called when the network data should be updated. - def _update(self) -> None: - super()._update() - if self._last_request_time and time() - self._last_request_time < self.CHECK_CLUSTER_INTERVAL: - return # Avoid calling the cloud too often - - Logger.log("d", "Updating: %s - %s >= %s", time(), self._last_request_time, self.CHECK_CLUSTER_INTERVAL) - if self._account.isLoggedIn: - self.setAuthenticationState(AuthState.Authenticated) - self._last_request_time = time() - self._api.getClusterStatus(self.key, self._onStatusCallFinished) - 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: - # Update all data from the cluster. - self._last_response_time = time() - if self._received_printers != status.printers: - self._received_printers = status.printers - self._updatePrinters(status.printers) - if status.print_jobs != self._received_print_jobs: - self._received_print_jobs = status.print_jobs - self._updatePrintJobs(status.print_jobs) + 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 _onPrintJobCreated(self, job_response: CloudPrintJobResponse) -> None: + def _uploadPrintJob(self, job_response: CloudPrintJobResponse) -> None: self._progress.show() - self._uploaded_print_job = job_response - tool_path = cast(bytes, self._tool_path) - self._api.uploadToolPath(job_response, tool_path, self._onPrintJobUploaded, self._progress.update, + self._uploaded_print_job = job_response # store the last uploaded job to prevent re-upload of the same file + 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. diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 3ebe8ac90f..6e8700b2e0 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -40,6 +40,7 @@ class CloudOutputDeviceManager: self._remote_clusters = {} # type: Dict[str, CloudOutputDevice] self._account = CuraApplication.getInstance().getCuraAPI().account # type: Account self._api = CloudApiClient(self._account, self._onApiError) + self._account.loginStateChanged.connect(self._onLoginStateChanged) # Create a timer to update the remote cluster list self._update_timer = QTimer() @@ -53,17 +54,22 @@ class CloudOutputDeviceManager: def start(self): if self._running: return - self._account.loginStateChanged.connect(self._onLoginStateChanged) + if not self._account.isLoggedIn: + return + self._running = True + if not self._update_timer.isActive(): + self._update_timer.start() self._update_timer.timeout.connect(self._getRemoteClusters) - self._onLoginStateChanged(is_logged_in=self._account.isLoggedIn) ## Stops running the cloud output device manager. def stop(self): if not self._running: return - self._account.loginStateChanged.disconnect(self._onLoginStateChanged) + self._running = False + if self._update_timer.isActive(): + self._update_timer.stop() + self._onGetRemoteClustersFinished([]) # Make sure we remove all cloud output devices. self._update_timer.timeout.disconnect(self._getRemoteClusters) - self._onLoginStateChanged(is_logged_in=False) ## Force refreshing connections. def refreshConnections(self) -> None: @@ -72,17 +78,13 @@ class CloudOutputDeviceManager: ## Called when the uses logs in or out def _onLoginStateChanged(self, is_logged_in: bool) -> None: if is_logged_in: - if not self._update_timer.isActive(): - self._update_timer.start() - self._getRemoteClusters() + self.start() else: - if self._update_timer.isActive(): - self._update_timer.stop() - # Notify that all clusters have disappeared - self._onGetRemoteClustersFinished([]) + self.stop() ## Gets all remote clusters from the API. def _getRemoteClusters(self) -> None: + print("getRemoteClusters") self._api.getClusters(self._onGetRemoteClustersFinished) ## Callback for when the request for getting the clusters is finished. @@ -113,6 +115,8 @@ class CloudOutputDeviceManager: keys = self._remote_clusters.keys() removed_devices = [cluster for cluster in self._remote_clusters.values() if cluster.key not in keys] for device in removed_devices: + device.disconnect() + device.close() CuraApplication.getInstance().getOutputDeviceManager().removeOutputDevice(device.key) discovery.removeDiscoveredPrinter(device.key) @@ -127,11 +131,13 @@ class CloudOutputDeviceManager: return # The newly added machine is automatically activated. - CuraApplication.getInstance().getMachineManager().addMachine(device.printerType, - device.clusterData.friendly_name) + machine_manager = CuraApplication.getInstance().getMachineManager() + machine_manager.addMachine(device.printerType, device.clusterData.friendly_name) active_machine = CuraApplication.getInstance().getGlobalContainerStack() - if active_machine: - self._connectToOutputDevice(device, active_machine) + if not active_machine: + return + active_machine.setMetaDataEntry(self.META_CLUSTER_ID, device.key) + self._connectToOutputDevice(device, active_machine) ## Callback for when the active machine was changed by the user or a new remote cluster was found. def _connectToActiveMachine(self) -> None: @@ -150,28 +156,25 @@ class CloudOutputDeviceManager: device = self._remote_clusters[stored_cluster_id] self._connectToOutputDevice(device, active_machine) Logger.log("d", "Device connected by metadata cluster ID %s", stored_cluster_id) - else: - self._connectByNetworkKey(active_machine) + # else: + # self._connectByNetworkKey(active_machine) ## Tries to match the local network key to the cloud cluster host name. def _connectByNetworkKey(self, active_machine: GlobalStack) -> None: - # Check if the active printer has a local network connection and match this key to the remote cluster. local_network_key = active_machine.getMetaDataEntry("um_network_key") if not local_network_key: return - device = next((c for c in self._remote_clusters.values() if c.matchesNetworkKey(local_network_key)), None) if not device: return - Logger.log("i", "Found cluster %s with network key %s", device, local_network_key) active_machine.setMetaDataEntry(self.META_CLUSTER_ID, device.key) self._connectToOutputDevice(device, active_machine) ## Connects to an output device and makes sure it is registered in the output device manager. - def _connectToOutputDevice(self, device: CloudOutputDevice, active_machine: GlobalStack) -> None: + @staticmethod + def _connectToOutputDevice(device: CloudOutputDevice, active_machine: GlobalStack) -> None: device.connect() - active_machine.setMetaDataEntry(self.META_CLUSTER_ID, device.key) active_machine.addConfiguredConnectionType(device.connectionType.value) CuraApplication.getInstance().getOutputDeviceManager().addOutputDevice(device) diff --git a/plugins/UM3NetworkPrinting/src/ExportFileJob.py b/plugins/UM3NetworkPrinting/src/ExportFileJob.py new file mode 100644 index 0000000000..605fa054bd --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/ExportFileJob.py @@ -0,0 +1,39 @@ +from typing import List + +from UM.FileHandler.FileHandler import FileHandler +from UM.FileHandler.WriteFileJob import WriteFileJob +from UM.Logger import Logger +from UM.Scene.SceneNode import SceneNode +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): + + def __init__(self, file_handler: FileHandler, nodes: List[SceneNode], firmware_version: str) -> None: + + self._mesh_format_handler = MeshFormatHandler(file_handler, firmware_version) + if not self._mesh_format_handler.is_valid: + Logger.log("e", "Missing file or mesh writer!") + return + + # Determine the filename. + job_name = CuraApplication.getInstance().getPrintInformation().jobName + extension = self._mesh_format_handler.preferred_format.get("extension", "") + self.setFileName(f"{job_name}.{extension}") + + super().__init__(self._mesh_format_handler.writer, self._mesh_format_handler.createStream(), nodes, + self._mesh_format_handler.file_mode) + + ## Get the mime type of the selected export file type. + def getMimeType(self) -> str: + 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: + output = self.getStream().getvalue() + if isinstance(output, str): + output = output.encode("utf-8") + return output diff --git a/plugins/UM3NetworkPrinting/src/Network/NetworkOutputDevice.py b/plugins/UM3NetworkPrinting/src/Network/NetworkOutputDevice.py index 7249683f4b..96cfab6888 100644 --- a/plugins/UM3NetworkPrinting/src/Network/NetworkOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Network/NetworkOutputDevice.py @@ -15,6 +15,7 @@ from UM.Scene.SceneNode import SceneNode from cura.CuraApplication import CuraApplication from cura.PrinterOutput.NetworkedPrinterOutputDevice import AuthState from cura.PrinterOutput.PrinterOutputDevice import ConnectionType +from plugins.UM3NetworkPrinting.src.ExportFileJob import ExportFileJob from .ClusterApiClient import ClusterApiClient from ..MeshFormatHandler import MeshFormatHandler @@ -46,7 +47,6 @@ class NetworkOutputDevice(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: self.setPriority(3) # Make sure the output device gets selected above local file output @@ -146,21 +146,8 @@ class NetworkOutputDevice(UltimakerNetworkedPrinterOutputDevice): # 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(writer=mesh_format.writer, stream=stream, data=nodes, mode=mesh_format.file_mode) - job.setFileName(file_name) + # Export the scene to the correct file type. + job = ExportFileJob(file_handler=file_handler, nodes=nodes, firmware_version=self.firmwareVersion) job.finished.connect(self._onPrintJobCreated) job.start() @@ -168,14 +155,10 @@ class NetworkOutputDevice(UltimakerNetworkedPrinterOutputDevice): # It can now be sent over the network. def _onPrintJobCreated(self, job: WriteFileJob) -> None: self._progress.show() - # TODO: extract multi-part stuff - parts = [] - parts.append(self._createFormPart("name=owner", bytes(self._getUserName(), "utf-8"), "text/plain")) - output = job.getStream().getvalue() - if isinstance(output, str): - # Ensure that our output is bytes - output = output.encode("utf-8") - parts.append(self._createFormPart("name=\"file\"; filename=\"%s\"" % job.getFileName(), output)) + parts = [ + self._createFormPart("name=owner", bytes(self._getUserName(), "utf-8"), "text/plain"), + self._createFormPart("name=\"file\"; filename=\"%s\"" % job.getFileName(), job.getOutput()) + ] self.postFormWithParts("/cluster-api/v1/print_jobs/", parts, on_finished=self._onPrintUploadCompleted, on_progress=self._onPrintJobUploadProgress) diff --git a/plugins/UM3NetworkPrinting/src/Network/NetworkOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Network/NetworkOutputDeviceManager.py index 6bed02549f..9544435185 100644 --- a/plugins/UM3NetworkPrinting/src/Network/NetworkOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Network/NetworkOutputDeviceManager.py @@ -118,8 +118,6 @@ class NetworkOutputDeviceManager: @staticmethod def _connectToOutputDevice(device: PrinterOutputDevice, active_machine: GlobalStack) -> None: device.connect() - active_machine.setMetaDataEntry("um_network_key", device.key) - active_machine.setMetaDataEntry("group_name", device.name) active_machine.addConfiguredConnectionType(device.connectionType.value) CuraApplication.getInstance().getOutputDeviceManager().addOutputDevice(device) @@ -199,6 +197,8 @@ class NetworkOutputDeviceManager: # The newly added machine is automatically activated. CuraApplication.getInstance().getMachineManager().addMachine(device.printerType, device.name) active_machine = CuraApplication.getInstance().getGlobalContainerStack() + active_machine.setMetaDataEntry("um_network_key", device.key) + active_machine.setMetaDataEntry("group_name", device.name) if active_machine: self._connectToOutputDevice(device, active_machine) diff --git a/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py b/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py index 18ee65b66f..42529c1df7 100644 --- a/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py +++ b/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py @@ -14,8 +14,6 @@ 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): - # cloudFlowIsPossible = Signal() - def __init__(self) -> None: super().__init__() @@ -29,24 +27,6 @@ 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) - # TODO: re-write cloud messaging - # self._account = self._application.getCuraAPI().account - - # Check if cloud flow is possible when user logs in - # self._account.loginStateChanged.connect(self.checkCloudFlowIsPossible) - - # Check if cloud flow is possible when user switches machines - # self._application.globalContainerStackChanged.connect(self._onMachineSwitched) - - # Listen for when cloud flow is possible - # self.cloudFlowIsPossible.connect(self._onCloudFlowPossible) - - # self._start_cloud_flow_message = None # type: Optional[Message] - # self._cloud_flow_complete_message = None # type: Optional[Message] - - # self._cloud_output_device_manager.addedCloudCluster.connect(self._onCloudPrintingConfigured) - # self._cloud_output_device_manager.removedCloudCluster.connect(self.checkCloudFlowIsPossible) - ## Start looking for devices in the network and cloud. def start(self): self._network_output_device_manager.start()