diff --git a/cura/Settings/MachineManager.py b/cura/Settings/MachineManager.py index 0aafbd82c7..b250b27cd3 100755 --- a/cura/Settings/MachineManager.py +++ b/cura/Settings/MachineManager.py @@ -517,6 +517,18 @@ class MachineManager(QObject): return self._global_container_stack.getId() return "" + @pyqtProperty(str, notify = globalContainerChanged) + def activeMachineFirmwareVersion(self) -> str: + if not self._printer_output_devices[0]: + return "" + return self._printer_output_devices[0].firmwareVersion + + @pyqtProperty(str, notify = globalContainerChanged) + def activeMachineAddress(self) -> str: + if not self._printer_output_devices[0]: + return "" + return self._printer_output_devices[0].address + @pyqtProperty(bool, notify = printerConnectedStatusChanged) def printerConnected(self) -> bool: return bool(self._printer_output_devices) diff --git a/plugins/UM3NetworkPrinting/resources/svg/cloud-flow-completed.svg b/plugins/UM3NetworkPrinting/resources/svg/cloud-flow-completed.svg new file mode 100644 index 0000000000..8eba62ecc8 --- /dev/null +++ b/plugins/UM3NetworkPrinting/resources/svg/cloud-flow-completed.svg @@ -0,0 +1,27 @@ + + + + Group 2 + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plugins/UM3NetworkPrinting/resources/svg/cloud-flow-start.svg b/plugins/UM3NetworkPrinting/resources/svg/cloud-flow-start.svg new file mode 100644 index 0000000000..746dc269fd --- /dev/null +++ b/plugins/UM3NetworkPrinting/resources/svg/cloud-flow-start.svg @@ -0,0 +1,13 @@ + + + + Cloud_connection-icon + Created with Sketch. + + + + + + + + \ No newline at end of file diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 78944d954b..e081beb99c 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -7,6 +7,7 @@ from PyQt5.QtCore import QTimer from UM import i18nCatalog from UM.Logger import Logger from UM.Message import Message +from UM.Signal import Signal, signalemitter from cura.API import Account from cura.CuraApplication import CuraApplication from cura.Settings.GlobalStack import GlobalStack @@ -31,14 +32,17 @@ class CloudOutputDeviceManager: # The translation catalog for this device. I18N_CATALOG = i18nCatalog("cura") + addedCloudCluster = Signal() + removedCloudCluster = Signal() + def __init__(self) -> None: # Persistent dict containing the remote clusters for the authenticated user. self._remote_clusters = {} # type: Dict[str, CloudOutputDevice] - application = CuraApplication.getInstance() - self._output_device_manager = application.getOutputDeviceManager() + self._application = CuraApplication.getInstance() + self._output_device_manager = self._application.getOutputDeviceManager() - self._account = application.getCuraAPI().account # type: Account + self._account = self._application.getCuraAPI().account # type: Account self._api = CloudApiClient(self._account, self._onApiError) # Create a timer to update the remote cluster list @@ -82,6 +86,7 @@ class CloudOutputDeviceManager: removed_cluster.disconnect() removed_cluster.close() self._output_device_manager.removeOutputDevice(removed_cluster.key) + self.removedCloudCluster.emit() del self._remote_clusters[removed_cluster.key] # Add an output device for each new remote cluster. @@ -89,6 +94,7 @@ class CloudOutputDeviceManager: for added_cluster in added_clusters: device = CloudOutputDevice(self._api, added_cluster) self._remote_clusters[added_cluster.cluster_id] = device + self.addedCloudCluster.emit() for device, cluster in updates: device.clusterData = cluster @@ -152,10 +158,9 @@ class CloudOutputDeviceManager: def start(self): if self._running: return - application = CuraApplication.getInstance() self._account.loginStateChanged.connect(self._onLoginStateChanged) # When switching machines we check if we have to activate a remote cluster. - application.globalContainerStackChanged.connect(self._connectToActiveMachine) + self._application.globalContainerStackChanged.connect(self._connectToActiveMachine) self._update_timer.timeout.connect(self._getRemoteClusters) self._onLoginStateChanged(is_logged_in = self._account.isLoggedIn) @@ -163,9 +168,8 @@ class CloudOutputDeviceManager: def stop(self): if not self._running: return - application = CuraApplication.getInstance() self._account.loginStateChanged.disconnect(self._onLoginStateChanged) # When switching machines we check if we have to activate a remote cluster. - application.globalContainerStackChanged.disconnect(self._connectToActiveMachine) + self._application.globalContainerStackChanged.disconnect(self._connectToActiveMachine) self._update_timer.timeout.disconnect(self._getRemoteClusters) self._onLoginStateChanged(is_logged_in = False) diff --git a/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py b/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py index 052dd0b979..790d0c430b 100644 --- a/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py @@ -54,6 +54,8 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): super().__init__(device_id = device_id, address = address, properties=properties, connection_type = ConnectionType.NetworkConnection, parent = parent) self._api_prefix = "/cluster-api/v1/" + self._application = CuraApplication.getInstance() + self._number_of_extruders = 2 self._dummy_lambdas = ( @@ -125,7 +127,7 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): def _spawnPrinterSelectionDialog(self): if self._printer_selection_dialog is None: path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "../resources/qml/PrintWindow.qml") - self._printer_selection_dialog = CuraApplication.getInstance().createQmlComponent(path, {"OutputDevice": self}) + self._printer_selection_dialog = self._application.createQmlComponent(path, {"OutputDevice": self}) if self._printer_selection_dialog is not None: self._printer_selection_dialog.show() @@ -211,7 +213,7 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): # Add user name to the print_job parts.append(self._createFormPart("name=owner", bytes(self._getUserName(), "utf-8"), "text/plain")) - file_name = CuraApplication.getInstance().getPrintInformation().jobName + "." + preferred_format["extension"] + 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): @@ -250,6 +252,11 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): self._compressing_gcode = False self._sending_gcode = False + ## The IP address of the printer. + @pyqtProperty(str, constant = True) + def address(self) -> str: + return self._address + def _onUploadPrintJobProgress(self, bytes_sent: int, bytes_total: int) -> None: if bytes_total > 0: new_progress = bytes_sent / bytes_total * 100 @@ -284,7 +291,7 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): self._progress_message.hide() self._compressing_gcode = False self._sending_gcode = False - CuraApplication.getInstance().getController().setActiveStage("PrepareStage") + 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 @@ -294,7 +301,7 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): def _successMessageActionTriggered(self, message_id: Optional[str] = None, action_id: Optional[str] = None) -> None: if action_id == "View": - CuraApplication.getInstance().getController().setActiveStage("MonitorStage") + self._application.getController().setActiveStage("MonitorStage") @pyqtSlot() def openPrintJobControlPanel(self) -> None: @@ -552,7 +559,7 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): return result def _createMaterialOutputModel(self, material_data: Dict[str, Any]) -> "MaterialOutputModel": - material_manager = CuraApplication.getInstance().getMaterialManager() + material_manager = self._application.getMaterialManager() material_group_list = None # Avoid crashing if there is no "guid" field in the metadata @@ -665,7 +672,6 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): job = SendMaterialJob(device = self) job.run() - def loadJsonFromReply(reply: QNetworkReply) -> Optional[List[Dict[str, Any]]]: try: result = json.loads(bytes(reply.readAll()).decode("utf-8")) diff --git a/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py b/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py index 7a7670d64c..891810f2f8 100644 --- a/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py +++ b/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py @@ -7,17 +7,25 @@ from time import time from zeroconf import Zeroconf, ServiceBrowser, ServiceStateChange, ServiceInfo from PyQt5.QtNetwork import QNetworkRequest, QNetworkAccessManager -from PyQt5.QtCore import QUrl +from PyQt5.QtCore import pyqtSlot, QUrl, pyqtSignal, pyqtProperty, QObject +from PyQt5.QtGui import QDesktopServices from cura.CuraApplication import CuraApplication +from cura.PrinterOutputDevice import ConnectionType +from cura.Settings.GlobalStack import GlobalStack # typing from UM.OutputDevice.OutputDevicePlugin import OutputDevicePlugin from UM.Logger import Logger from UM.Signal import Signal, signalemitter from UM.Version import Version +from UM.Message import Message +from UM.i18n import i18nCatalog from . import ClusterUM3OutputDevice, LegacyUM3OutputDevice from .Cloud.CloudOutputDeviceManager import CloudOutputDeviceManager +from typing import Optional + +i18n_catalog = i18nCatalog("cura") ## This plugin handles the connection detection & creation of output device objects for the UM3 printer. # Zero-Conf is used to detect printers, which are saved in a dict. @@ -27,6 +35,7 @@ class UM3OutputDevicePlugin(OutputDevicePlugin): addDeviceSignal = Signal() removeDeviceSignal = Signal() discoveredDevicesChanged = Signal() + cloudFlowIsPossible = Signal() def __init__(self): super().__init__() @@ -34,6 +43,8 @@ class UM3OutputDevicePlugin(OutputDevicePlugin): self._zero_conf = None self._zero_conf_browser = None + self._application = CuraApplication.getInstance() + # Create a cloud output device manager that abstracts all cloud connection logic away. self._cloud_output_device_manager = CloudOutputDeviceManager() @@ -41,7 +52,7 @@ class UM3OutputDevicePlugin(OutputDevicePlugin): self.addDeviceSignal.connect(self._onAddDevice) self.removeDeviceSignal.connect(self._onRemoveDevice) - CuraApplication.getInstance().globalContainerStackChanged.connect(self.reCheckConnections) + self._application.globalContainerStackChanged.connect(self.reCheckConnections) self._discovered_devices = {} @@ -49,6 +60,7 @@ class UM3OutputDevicePlugin(OutputDevicePlugin): self._network_manager.finished.connect(self._onNetworkRequestFinished) self._min_cluster_version = Version("4.0.0") + self._min_cloud_version = Version("5.2.0") self._api_version = "1" self._api_prefix = "/api/v" + self._api_version + "/" @@ -74,6 +86,26 @@ class UM3OutputDevicePlugin(OutputDevicePlugin): self._service_changed_request_thread = Thread(target=self._handleOnServiceChangedRequests, daemon=True) self._service_changed_request_thread.start() + 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) + + # Listen if cloud cluster was added + self._cloud_output_device_manager.addedCloudCluster.connect(self._onCloudPrintingConfigured) + + # Listen if cloud cluster was removed + self._cloud_output_device_manager.removedCloudCluster.connect(self.checkCloudFlowIsPossible) + + self._start_cloud_flow_message = None # type: Optional[Message] + self._cloud_flow_complete_message = None # type: Optional[Message] + def getDiscoveredDevices(self): return self._discovered_devices @@ -138,6 +170,7 @@ class UM3OutputDevicePlugin(OutputDevicePlugin): um_network_key = CuraApplication.getInstance().getGlobalContainerStack().getMetaDataEntry("um_network_key") if key == um_network_key: self.getOutputDeviceManager().addOutputDevice(self._discovered_devices[key]) + self.checkCloudFlowIsPossible() else: self.getOutputDeviceManager().removeOutputDevice(key) @@ -370,3 +403,113 @@ class UM3OutputDevicePlugin(OutputDevicePlugin): self.removeDeviceSignal.emit(str(name)) return True + + ## Check if the prerequsites are in place to start the cloud flow + def checkCloudFlowIsPossible(self) -> None: + Logger.log("d", "Checking if cloud connection is possible...") + + # Pre-Check: Skip if active machine already has been cloud connected or you said don't ask again + active_machine = self._application.getMachineManager().activeMachine # type: Optional["GlobalStack"] + if active_machine: + + # Check 1: Printer isn't already configured for cloud + if ConnectionType.CloudConnection.value in active_machine.configuredConnectionTypes: + Logger.log("d", "Active machine was already configured for cloud.") + return + + # Check 2: User did not already say "Don't ask me again" + if active_machine.getMetaDataEntry("show_cloud_message", "value") is False: + Logger.log("d", "Active machine shouldn't ask about cloud anymore.") + return + + # Check 3: User is logged in with an Ultimaker account + if not self._account.isLoggedIn: + Logger.log("d", "Cloud Flow not possible: User not logged in!") + return + + # Check 4: Machine is configured for network connectivity + if not self._application.getMachineManager().activeMachineHasActiveNetworkConnection: + Logger.log("d", "Cloud Flow not possible: Machine is not connected!") + return + + # Check 5: Machine has correct firmware version + firmware_version = self._application.getMachineManager().activeMachineFirmwareVersion # type: str + if not Version(firmware_version) > self._min_cloud_version: + Logger.log("d", "Cloud Flow not possible: Machine firmware (%s) is too low! (Requires version %s)", + firmware_version, + self._min_cloud_version) + return + + Logger.log("d", "Cloud flow is possible!") + self.cloudFlowIsPossible.emit() + + def _onCloudFlowPossible(self) -> None: + # Cloud flow is possible, so show the message + if not self._start_cloud_flow_message: + self._start_cloud_flow_message = Message( + text = i18n_catalog.i18nc("@info:status", "Pair your printer to your Ultimaker account and start print jobs from anywhere."), + image_source = "../../../../../Cura/plugins/UM3NetworkPrinting/resources/svg/cloud-flow-start.svg", + image_caption = i18n_catalog.i18nc("@info:status", "Connect to cloud"), + option_text = i18n_catalog.i18nc("@action", "Don't ask me again for this printer."), + option_state = False + ) + self._start_cloud_flow_message.addAction("", i18n_catalog.i18nc("@action", "Get started"), "", "") + self._start_cloud_flow_message.optionToggled.connect(self._onDontAskMeAgain) + self._start_cloud_flow_message.actionTriggered.connect(self._onCloudFlowStarted) + self._start_cloud_flow_message.show() + return + + def _onCloudPrintingConfigured(self) -> None: + if self._start_cloud_flow_message: + self._start_cloud_flow_message.hide() + self._start_cloud_flow_message = None + + # Show the successful pop-up + if not self._start_cloud_flow_message: + self._cloud_flow_complete_message = Message( + text = i18n_catalog.i18nc("@info:status", "You can now send and monitor print jobs from anywhere using your Ultimaker account."), + lifetime = 30, + image_source = "../../../../../Cura/plugins/UM3NetworkPrinting/resources/svg/cloud-flow-completed.svg", + image_caption = i18n_catalog.i18nc("@info:status", "Connected!") + ) + self._cloud_flow_complete_message.addAction("", i18n_catalog.i18nc("@action", "Review your connection"), "", "", 1) # TODO: Icon + self._cloud_flow_complete_message.actionTriggered.connect(self._onReviewCloudConnection) + self._cloud_flow_complete_message.show() + + # Set the machine's cloud flow as complete so we don't ask the user again and again for cloud connected printers + active_machine = self._application.getMachineManager().activeMachine + if active_machine: + active_machine.setMetaDataEntry("cloud_flow_complete", True) + return + + def _onDontAskMeAgain(self, messageId: str, checked: bool) -> None: + active_machine = self._application.getMachineManager().activeMachine # type: Optional["GlobalStack"] + if active_machine: + active_machine.setMetaDataEntry("show_cloud_message", False) + Logger.log("d", "Will not ask the user again to cloud connect for current printer.") + return + + def _onCloudFlowStarted(self, messageId: str, actionId: str) -> None: + address = self._application.getMachineManager().activeMachineAddress # type: str + if address: + QDesktopServices.openUrl(QUrl("http://" + address + "/cloud_connect")) + if self._start_cloud_flow_message: + self._start_cloud_flow_message.hide() + self._start_cloud_flow_message = None + return + + def _onReviewCloudConnection(self, messageId: str, actionId: str) -> None: + address = self._application.getMachineManager().activeMachineAddress # type: str + if address: + QDesktopServices.openUrl(QUrl("http://" + address + "/settings")) + return + + def _onMachineSwitched(self) -> None: + if self._start_cloud_flow_message is not None: + self._start_cloud_flow_message.hide() + self._start_cloud_flow_message = None + if self._cloud_flow_complete_message is not None: + self._cloud_flow_complete_message.hide() + self._cloud_flow_complete_message = None + + self.checkCloudFlowIsPossible() \ No newline at end of file