diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index ccc64f8073..0b65f55cfd 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -1,26 +1,29 @@ # Copyright (c) 2019 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. - +import os from typing import Dict, List, Optional + from PyQt5.QtCore import QTimer from UM import i18nCatalog from UM.Logger import Logger # To log errors talking to the API. +from UM.Message import Message from UM.Signal import Signal from cura.API import Account from cura.CuraApplication import CuraApplication from cura.Settings.CuraStackBuilder import CuraStackBuilder from cura.Settings.GlobalStack import GlobalStack - from .CloudApiClient import CloudApiClient from .CloudOutputDevice import CloudOutputDevice from ..Models.Http.CloudClusterResponse import CloudClusterResponse -## 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/. 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/. + """ META_CLUSTER_ID = "um_cloud_cluster_id" META_NETWORK_KEY = "um_network_key" @@ -44,14 +47,16 @@ class CloudOutputDeviceManager: # Create a timer to update the remote cluster list self._update_timer = QTimer() self._update_timer.setInterval(int(self.CHECK_CLUSTER_INTERVAL * 1000)) - self._update_timer.setSingleShot(False) + # The timer is restarted explicitly after an update was processed. This prevents 2 concurrent updates + self._update_timer.setSingleShot(True) self._update_timer.timeout.connect(self._getRemoteClusters) # Ensure we don't start twice. self._running = False - ## Starts running the cloud output device manager, thus periodically requesting cloud data. def start(self): + """Starts running the cloud output device manager, thus periodically requesting cloud data.""" + if self._running: return if not self._account.isLoggedIn: @@ -61,8 +66,9 @@ class CloudOutputDeviceManager: self._update_timer.start() self._getRemoteClusters() - ## Stops running the cloud output device manager. def stop(self): + """Stops running the cloud output device manager.""" + if not self._running: return self._running = False @@ -70,47 +76,121 @@ class CloudOutputDeviceManager: self._update_timer.stop() self._onGetRemoteClustersFinished([]) # Make sure we remove all cloud output devices. - ## Force refreshing connections. def refreshConnections(self) -> None: + """Force refreshing connections.""" + self._connectToActiveMachine() - ## Called when the uses logs in or out def _onLoginStateChanged(self, is_logged_in: bool) -> None: + """Called when the uses logs in or out""" + if is_logged_in: self.start() else: self.stop() - ## Gets all remote clusters from the API. def _getRemoteClusters(self) -> None: + """Gets all remote clusters from the API.""" + self._api.getClusters(self._onGetRemoteClustersFinished) - ## Callback for when the request for getting the clusters is finished. def _onGetRemoteClustersFinished(self, clusters: List[CloudClusterResponse]) -> None: + """Callback for when the request for getting the clusters is finished.""" + + new_clusters = [] online_clusters = {c.cluster_id: c for c in clusters if c.is_online} # type: Dict[str, CloudClusterResponse] for device_id, cluster_data in online_clusters.items(): if device_id not in self._remote_clusters: - self._onDeviceDiscovered(cluster_data) + new_clusters.append(cluster_data) else: self._onDiscoveredDeviceUpdated(cluster_data) + self._onDevicesDiscovered(new_clusters) + removed_device_keys = set(self._remote_clusters.keys()) - set(online_clusters.keys()) for device_id in removed_device_keys: self._onDiscoveredDeviceRemoved(device_id) - def _onDeviceDiscovered(self, cluster_data: CloudClusterResponse) -> None: - device = CloudOutputDevice(self._api, cluster_data) - CuraApplication.getInstance().getDiscoveredPrintersModel().addDiscoveredPrinter( - ip_address=device.key, - key=device.getId(), - name=device.getName(), - create_callback=self._createMachineFromDiscoveredDevice, - machine_type=device.printerType, - device=device + if new_clusters or removed_device_keys: + self.discoveredDevicesChanged.emit() + if removed_device_keys: + # If the removed device was active we should connect to the new active device + self._connectToActiveMachine() + # Schedule a new update + self._update_timer.start() + + def _onDevicesDiscovered(self, clusters: List[CloudClusterResponse]) -> None: + """**Synchronously** create machines for discovered devices + + Any new machines are made available to the user. + May take a long time to complete. As this code needs access to the Application + and blocks the GIL, creating a Job for this would not make sense. + Shows a Message informing the user of progress. + """ + new_devices = [] + for cluster_data in clusters: + device = CloudOutputDevice(self._api, cluster_data) + # Create a machine if we don't already have it. Do not make it the active machine. + machine_manager = CuraApplication.getInstance().getMachineManager() + + # We only need to add it if it wasn't already added by "local" network or by cloud. + if machine_manager.getMachine(device.printerType, {self.META_CLUSTER_ID: device.key}) is None \ + and machine_manager.getMachine(device.printerType, {self.META_NETWORK_KEY: cluster_data.host_name + "*"}) is None: # The host name is part of the network key. + new_devices.append(device) + + if not new_devices: + return + + new_devices.sort(key = lambda x: x.name.lower()) + + image_path = os.path.join( + CuraApplication.getInstance().getPluginRegistry().getPluginPath("UM3NetworkPrinting") or "", + "resources", "svg", "cloud-flow-completed.svg" ) - self._remote_clusters[device.getId()] = device - self.discoveredDevicesChanged.emit() - self._connectToActiveMachine() + + message = Message( + title = self.I18N_CATALOG.i18ncp( + "info:status", + "New printer detected from your Ultimaker account", + "New printers detected from your Ultimaker account", + len(new_devices) + ), + progress = 0, + lifetime = 0, + image_source = image_path + ) + message.show() + + for idx, device in enumerate(new_devices): + message_text = self.I18N_CATALOG.i18nc( + "info:status", "Adding printer {} ({}) from your account", + device.name, + device.printerTypeName + ) + message.setText(message_text) + if len(new_devices) > 1: + message.setProgress((idx / len(new_devices)) * 100) + CuraApplication.getInstance().processEvents() + self._remote_clusters[device.getId()] = device + self._createMachineFromDiscoveredDevice(device.getId(), activate = False) + + message.setProgress(None) + + max_disp_devices = 3 + if len(new_devices) > max_disp_devices: + num_hidden = len(new_devices) - max_disp_devices + 1 + device_name_list = ["- {} ({})".format(device.name, device.printerTypeName) for device in new_devices[0:num_hidden]] + device_name_list.append(self.I18N_CATALOG.i18nc("info:hidden list items", "- and {} others", num_hidden)) + device_names = "\n".join(device_name_list) + else: + device_names = "\n".join(["- {} ({})".format(device.name, device.printerTypeName) for device in new_devices]) + + message_text = self.I18N_CATALOG.i18nc( + "info:status", + "Cloud printers added from your account:\n{}", + device_names + ) + message.setText(message_text) def _onDiscoveredDeviceUpdated(self, cluster_data: CloudClusterResponse) -> None: device = self._remote_clusters.get(cluster_data.cluster_id) @@ -128,29 +208,31 @@ class CloudOutputDeviceManager: if not device: return device.close() - CuraApplication.getInstance().getDiscoveredPrintersModel().removeDiscoveredPrinter(device.key) output_device_manager = CuraApplication.getInstance().getOutputDeviceManager() if device.key in output_device_manager.getOutputDeviceIds(): output_device_manager.removeOutputDevice(device.key) - self.discoveredDevicesChanged.emit() - def _createMachineFromDiscoveredDevice(self, key: str) -> None: + def _createMachineFromDiscoveredDevice(self, key: str, activate: bool = True) -> None: device = self._remote_clusters[key] if not device: return - # Create a new machine and activate it. + # Create a new machine. # We do not use use MachineManager.addMachine here because we need to set the cluster ID before activating it. new_machine = CuraStackBuilder.createMachine(device.name, device.printerType) if not new_machine: Logger.log("e", "Failed creating a new machine") return new_machine.setMetaDataEntry(self.META_CLUSTER_ID, device.key) - CuraApplication.getInstance().getMachineManager().setActiveMachine(new_machine.getId()) + + if activate: + CuraApplication.getInstance().getMachineManager().setActiveMachine(new_machine.getId()) + self._connectToOutputDevice(device, new_machine) - ## 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""" + active_machine = CuraApplication.getInstance().getGlobalContainerStack() if not active_machine: return @@ -169,8 +251,9 @@ class CloudOutputDeviceManager: # Remove device if it is not meant for the active machine. output_device_manager.removeOutputDevice(device.key) - ## Connects to an output device and makes sure it is registered in the output device manager. def _connectToOutputDevice(self, device: CloudOutputDevice, machine: GlobalStack) -> None: + """Connects to an output device and makes sure it is registered in the output device manager.""" + machine.setName(device.name) machine.setMetaDataEntry(self.META_CLUSTER_ID, device.key) machine.setMetaDataEntry("group_name", device.name)