# 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, Set from PyQt5.QtNetwork import QNetworkReply from UM import i18nCatalog from UM.Logger import Logger # To log errors talking to the API. from UM.Message import Message from UM.Settings.Interfaces import ContainerInterface from UM.Signal import Signal from UM.Util import parseBool from cura.API import Account from cura.API.Account import SyncState from cura.CuraApplication import CuraApplication from cura.Settings.CuraStackBuilder import CuraStackBuilder from cura.Settings.GlobalStack import GlobalStack from cura.UltimakerCloud.UltimakerCloudConstants import META_UM_LINKED_TO_ACCOUNT from .CloudApiClient import CloudApiClient from .CloudOutputDevice import CloudOutputDevice 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/. """ META_CLUSTER_ID = "um_cloud_cluster_id" META_NETWORK_KEY = "um_network_key" SYNC_SERVICE_NAME = "CloudOutputDeviceManager" # The translation catalog for this device. I18N_CATALOG = i18nCatalog("cura") # Signal emitted when the list of discovered devices changed. discoveredDevicesChanged = Signal() def __init__(self) -> None: # Persistent dict containing the remote clusters for the authenticated user. self._remote_clusters = {} # type: Dict[str, CloudOutputDevice] # Dictionary containing all the cloud printers loaded in Cura self._um_cloud_printers = {} # type: Dict[str, GlobalStack] self._account = CuraApplication.getInstance().getCuraAPI().account # type: Account self._api = CloudApiClient(CuraApplication.getInstance(), on_error = lambda error: Logger.log("e", str(error))) self._account.loginStateChanged.connect(self._onLoginStateChanged) # Ensure we don't start twice. self._running = False self._syncing = False CuraApplication.getInstance().getContainerRegistry().containerRemoved.connect(self._printerRemoved) def start(self): """Starts running the cloud output device manager, thus periodically requesting cloud data.""" if self._running: return if not self._account.isLoggedIn: return self._running = True self._getRemoteClusters() self._account.syncRequested.connect(self._getRemoteClusters) def stop(self): """Stops running the cloud output device manager.""" if not self._running: return self._running = False self._onGetRemoteClustersFinished([]) # Make sure we remove all cloud output devices. def refreshConnections(self) -> None: """Force refreshing connections.""" self._connectToActiveMachine() 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() def _getRemoteClusters(self) -> None: """Gets all remote clusters from the API.""" if self._syncing: return self._syncing = True self._account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.SYNCING) self._api.getClusters(self._onGetRemoteClustersFinished, self._onGetRemoteClusterFailed) def _onGetRemoteClustersFinished(self, clusters: List[CloudClusterResponse]) -> None: """Callback for when the request for getting the clusters is finished.""" self._um_cloud_printers = {m.getMetaDataEntry(self.META_CLUSTER_ID): m for m in CuraApplication.getInstance().getContainerRegistry().findContainerStacks( type = "machine") if m.getMetaDataEntry(self.META_CLUSTER_ID, None)} new_clusters = [] all_clusters = {c.cluster_id: c for c in clusters} # type: Dict[str, CloudClusterResponse] online_clusters = {c.cluster_id: c for c in clusters if c.is_online} # type: Dict[str, CloudClusterResponse] # Add the new printers in Cura. If a printer was previously added and is rediscovered, set its metadata to # reflect that and mark the printer not removed from the account for device_id, cluster_data in all_clusters.items(): if device_id not in self._remote_clusters: new_clusters.append(cluster_data) if device_id in self._um_cloud_printers and not parseBool(self._um_cloud_printers[device_id].getMetaDataEntry(META_UM_LINKED_TO_ACCOUNT, "true")): self._um_cloud_printers[device_id].setMetaDataEntry(META_UM_LINKED_TO_ACCOUNT, True) self._onDevicesDiscovered(new_clusters) # Remove the CloudOutput device for offline printers offline_device_keys = set(self._remote_clusters.keys()) - set(online_clusters.keys()) for device_id in offline_device_keys: self._onDiscoveredDeviceRemoved(device_id) # Handle devices that were previously added in Cura but do not exist in the account anymore (i.e. they were # removed from the account) removed_device_keys = set(self._um_cloud_printers.keys()) - set(all_clusters.keys()) if removed_device_keys: self._devicesRemovedFromAccount(removed_device_keys) if new_clusters or offline_device_keys or removed_device_keys: self.discoveredDevicesChanged.emit() if offline_device_keys: # If the removed device was active we should connect to the new active device self._connectToActiveMachine() self._syncing = False self._account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.SUCCESS) def _onGetRemoteClusterFailed(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None: self._syncing = False self._account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.ERROR) 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 = [] remote_clusters_added = False 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) elif device.getId() not in self._remote_clusters: self._remote_clusters[device.getId()] = device remote_clusters_added = True # If a printer that was removed from the account is re-added, change its metadata to mark it not removed # from the account elif not parseBool(self._um_cloud_printers[device.key].getMetaDataEntry(META_UM_LINKED_TO_ACCOUNT, "true")): self._um_cloud_printers[device.key].setMetaDataEntry(META_UM_LINKED_TO_ACCOUNT, True) # Inform the Cloud printers model about new devices. new_devices_list_of_dicts = [{ "key": d.getId(), "name": d.name, "machine_type": d.printerTypeName, "firmware_version": d.firmwareVersion} for d in new_devices] discovered_cloud_printers_model = CuraApplication.getInstance().getDiscoveredCloudPrintersModel() discovered_cloud_printers_model.addDiscoveredCloudPrinters(new_devices_list_of_dicts) if not new_devices: if remote_clusters_added: self._connectToActiveMachine() return # Sort new_devices on online status first, alphabetical second. # Since the first device might be activated in case there is no active printer yet, # it would be nice to prioritize online devices online_cluster_names = {c.friendly_name.lower() for c in clusters if c.is_online and not c.friendly_name is None} new_devices.sort(key = lambda x: ("a{}" if x.name.lower() in online_cluster_names else "b{}").format(x.name.lower())) image_path = os.path.join( CuraApplication.getInstance().getPluginRegistry().getPluginPath("UM3NetworkPrinting") or "", "resources", "svg", "cloud-flow-completed.svg" ) 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 # If there is no active machine, activate the first available cloud printer activate = not CuraApplication.getInstance().getMachineManager().activeMachine self._createMachineFromDiscoveredDevice(device.getId(), activate = activate) 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 = ["