class Settings: """ Keeps the application settings. """ UM_CLOUD_API_ROOT = "https://api.ultimaker.com" DRIVE_API_VERSION = 1 DRIVE_API_URL = "{}/cura-drive/v{}".format(UM_CLOUD_API_ROOT, str(DRIVE_API_VERSION)) AUTO_BACKUP_ENABLED_PREFERENCE_KEY = "cura_drive/auto_backup_enabled" AUTO_BACKUP_LAST_DATE_PREFERENCE_KEY = "cura_drive/auto_backup_date" I18N_CATALOG_ID = "cura_drive" I18N_CATALOG = i18nCatalog(I18N_CATALOG_ID) MESSAGE_TITLE = I18N_CATALOG.i18nc("@info:title", "Backups"), # Translatable messages for the entire plugin. translatable_messages = { # Menu items. "extension_menu_entry": I18N_CATALOG.i18nc("@item:inmenu", "Manage backups"), # Notification messages. "backup_failed": I18N_CATALOG.i18nc("@info:backup_status", "There was an error while creating your backup."), "uploading_backup": I18N_CATALOG.i18nc("@info:backup_status", "Uploading your backup..."), "uploading_backup_success": I18N_CATALOG.i18nc("@info:backup_status", "Your backup has finished uploading."), "uploading_backup_error": I18N_CATALOG.i18nc("@info:backup_status", "There was an error while uploading your backup."), "get_backups_error": I18N_CATALOG.i18nc("@info:backup_status", "There was an error listing your backups."), "backup_restore_error_message": I18N_CATALOG.i18nc("@info:backup_status", "There was an error trying to restore your backup.") }
class Settings: """ Keeps the application settings. """ UM_OAUTH_ROOT = "https://account.ultimaker.com" UM_CLOUD_API_ROOT = "https://api.ultimaker.com" CALLBACK_PORT = 32118 OAUTH_SETTINGS = OAuth2Settings( OAUTH_SERVER_URL=UM_OAUTH_ROOT, CALLBACK_PORT=CALLBACK_PORT, CALLBACK_URL="http://*****:*****@info:title", "Backups"), # Translatable messages for the entire plugin. translatable_messages = { # Menu items. "extension_menu_entry": I18N_CATALOG.i18nc("@item:inmenu", "Manage backups"), # Notification messages. "backup_failed": I18N_CATALOG.i18nc("@info:backup_status", "There was an error while creating your backup."), "uploading_backup": I18N_CATALOG.i18nc("@info:backup_status", "Uploading your backup..."), "uploading_backup_success": I18N_CATALOG.i18nc("@info:backup_status", "Your backup has finished uploading."), "uploading_backup_error": I18N_CATALOG.i18nc("@info:backup_status", "There was an error while uploading your backup."), "get_backups_error": I18N_CATALOG.i18nc("@info:backup_status", "There was an error listing your backups."), "backup_restore_error_message": I18N_CATALOG.i18nc( "@info:backup_status", "There was an error trying to restore your backup.") }
def __init__(self, application: CuraApplication) -> None: super().__init__() self.discrepancies = Signal() # Emits SubscribedPackagesModel self._application = application # type: CuraApplication self._scope = UltimakerCloudScope(application) self._model = SubscribedPackagesModel() self._application.initializationFinished.connect(self._onAppInitialized) self._i18n_catalog = i18nCatalog("cura") self._sdk_version = ApplicationMetadata.CuraSDKVersion
def __init__(self, application: CuraApplication) -> None: super().__init__() self.discrepancies = Signal() # Emits SubscribedPackagesModel self._application = application # type: CuraApplication self._scope = JsonDecoratorScope(UltimakerCloudScope(application)) self._model = SubscribedPackagesModel() self._message = None # type: Optional[Message] self._application.initializationFinished.connect(self._onAppInitialized) self._i18n_catalog = i18nCatalog("cura") self._sdk_version = ApplicationMetadata.CuraSDKVersion self._last_notified_packages = set() # type: Set[str] """Packages for which a notification has been shown. No need to bother the user twice fo equal content"""
def formatDateCompleted(seconds_remaining: int) -> str: now = datetime.now() completed = now + timedelta(seconds=seconds_remaining) days = (completed.date() - now.date()).days i18n = i18nCatalog("cura") # If finishing date is more than 7 days out, using "Mon Dec 3 at HH:MM" format if days >= 7: return completed.strftime("%a %b ") + "{day}".format(day=completed.day) # If finishing date is within the next week, use "Monday at HH:MM" format elif days >= 2: return completed.strftime("%a") # If finishing tomorrow, use "tomorrow at HH:MM" format elif days >= 1: return i18n.i18nc("@info:status", "tomorrow") # If finishing today, use "today at HH:MM" format else: return i18n.i18nc("@info:status", "today")
def formatDateCompleted(seconds_remaining: int) -> str: now = datetime.now() completed = now + timedelta(seconds=seconds_remaining) days = (completed.date() - now.date()).days i18n = i18nCatalog("cura") # If finishing date is more than 7 days out, using "Mon Dec 3 at HH:MM" format if days >= 7: return completed.strftime("%a %b ") + "{day}".format(day = completed.day) # If finishing date is within the next week, use "Monday at HH:MM" format elif days >= 2: return completed.strftime("%a") # If finishing tomorrow, use "tomorrow at HH:MM" format elif days >= 1: return i18n.i18nc("@info:status", "tomorrow") # If finishing today, use "today at HH:MM" format else: return i18n.i18nc("@info:status", "today")
import urllib.parse # For interpreting escape characters using unquote_plus. import zipfile from json import JSONDecodeError from typing import Any, Dict, List, Optional, Set, Tuple, cast, TYPE_CHECKING from PyQt5.QtCore import pyqtSlot, QObject, pyqtSignal, QUrl, pyqtProperty from UM import i18nCatalog from UM.Logger import Logger from UM.Message import Message from UM.MimeTypeDatabase import MimeTypeDatabase # To get the type of container we're loading. from UM.Resources import Resources from UM.Signal import Signal from UM.Version import Version as UMVersion catalog = i18nCatalog("uranium") if TYPE_CHECKING: from UM.Qt.QtApplication import QtApplication class PackageManager(QObject): Version = 1 def __init__(self, application: "QtApplication", parent: Optional[QObject] = None) -> None: super().__init__(parent) self._application = application self._container_registry = self._application.getContainerRegistry()
class Backup: """The back-up class holds all data about a back-up. It is also responsible for reading and writing the zip file to the user data folder. """ IGNORED_FILES = [ r"cura\.log", r"plugins\.json", r"cache", r"__pycache__", r"\.qmlc", r"\.pyc" ] """These files should be ignored when making a backup.""" SECRETS_SETTINGS = ["general/ultimaker_auth_data"] """Secret preferences that need to obfuscated when making a backup of Cura""" catalog = i18nCatalog("cura") """Re-use translation catalog""" def __init__(self, application: "CuraApplication", zip_file: bytes = None, meta_data: Dict[str, str] = None) -> None: self._application = application self.zip_file = zip_file # type: Optional[bytes] self.meta_data = meta_data # type: Optional[Dict[str, str]] def makeFromCurrent(self) -> None: """Create a back-up from the current user config folder.""" cura_release = self._application.getVersion() version_data_dir = Resources.getDataStoragePath() Logger.log("d", "Creating backup for Cura %s, using folder %s", cura_release, version_data_dir) # obfuscate sensitive secrets secrets = self._obfuscate() # Ensure all current settings are saved. self._application.saveSettings() # We copy the preferences file to the user data directory in Linux as it's in a different location there. # When restoring a backup on Linux, we move it back. if Platform.isLinux( ): #TODO: This should check for the config directory not being the same as the data directory, rather than hard-coding that to Linux systems. preferences_file_name = self._application.getApplicationName() preferences_file = Resources.getPath( Resources.Preferences, "{}.cfg".format(preferences_file_name)) backup_preferences_file = os.path.join( version_data_dir, "{}.cfg".format(preferences_file_name)) if os.path.exists(preferences_file) and ( not os.path.exists(backup_preferences_file) or not os.path.samefile(preferences_file, backup_preferences_file)): Logger.log("d", "Copying preferences file from %s to %s", preferences_file, backup_preferences_file) shutil.copyfile(preferences_file, backup_preferences_file) # Create an empty buffer and write the archive to it. buffer = io.BytesIO() archive = self._makeArchive(buffer, version_data_dir) if archive is None: return files = archive.namelist() # Count the metadata items. We do this in a rather naive way at the moment. machine_count = max( len([s for s in files if "machine_instances/" in s]) - 1, 0 ) # If people delete their profiles but not their preferences, it can still make a backup, and report -1 profiles. Server crashes on this. material_count = max( len([s for s in files if "materials/" in s]) - 1, 0) profile_count = max( len([s for s in files if "quality_changes/" in s]) - 1, 0) plugin_count = len([s for s in files if "plugin.json" in s]) # Store the archive and metadata so the BackupManager can fetch them when needed. self.zip_file = buffer.getvalue() self.meta_data = { "cura_release": cura_release, "machine_count": str(machine_count), "material_count": str(material_count), "profile_count": str(profile_count), "plugin_count": str(plugin_count) } # Restore the obfuscated settings self._illuminate(**secrets) def _makeArchive(self, buffer: "io.BytesIO", root_path: str) -> Optional[ZipFile]: """Make a full archive from the given root path with the given name. :param root_path: The root directory to archive recursively. :return: The archive as bytes. """ ignore_string = re.compile("|".join(self.IGNORED_FILES)) try: archive = ZipFile(buffer, "w", ZIP_DEFLATED) for root, folders, files in os.walk(root_path): for item_name in folders + files: absolute_path = os.path.join(root, item_name) if ignore_string.search(absolute_path): continue archive.write(absolute_path, absolute_path[len(root_path) + len(os.sep):]) archive.close() return archive except (IOError, OSError, BadZipfile) as error: Logger.log( "e", "Could not create archive from user data directory: %s", error) self._showMessage( self.catalog.i18nc( "@info:backup_failed", "Could not create archive from user data directory: {}". format(error))) return None def _showMessage(self, message: str) -> None: """Show a UI message.""" Message(message, title=self.catalog.i18nc("@info:title", "Backup"), lifetime=30).show() def restore(self) -> bool: """Restore this back-up. :return: Whether we had success or not. """ if not self.zip_file or not self.meta_data or not self.meta_data.get( "cura_release", None): # We can restore without the minimum required information. Logger.log( "w", "Tried to restore a Cura backup without having proper data or meta data." ) self._showMessage( self.catalog.i18nc( "@info:backup_failed", "Tried to restore a Cura backup without having proper data or meta data." )) return False current_version = self._application.getVersion() version_to_restore = self.meta_data.get("cura_release", "master") if current_version < version_to_restore: # Cannot restore version newer than current because settings might have changed. Logger.log( "d", "Tried to restore a Cura backup of version {version_to_restore} with cura version {current_version}" .format(version_to_restore=version_to_restore, current_version=current_version)) self._showMessage( self.catalog.i18nc( "@info:backup_failed", "Tried to restore a Cura backup that is higher than the current version." )) return False # Get the current secrets and store since the back-up doesn't contain those secrets = self._obfuscate() version_data_dir = Resources.getDataStoragePath() try: archive = ZipFile(io.BytesIO(self.zip_file), "r") except LookupError as e: Logger.log( "d", f"The following error occurred while trying to restore a Cura backup: {str(e)}" ) self._showMessage( self.catalog.i18nc( "@info:backup_failed", "The following error occurred while trying to restore a Cura backup:" ) + str(e)) return False extracted = self._extractArchive(archive, version_data_dir) # Under Linux, preferences are stored elsewhere, so we copy the file to there. if Platform.isLinux(): preferences_file_name = self._application.getApplicationName() preferences_file = Resources.getPath( Resources.Preferences, "{}.cfg".format(preferences_file_name)) backup_preferences_file = os.path.join( version_data_dir, "{}.cfg".format(preferences_file_name)) Logger.log("d", "Moving preferences file from %s to %s", backup_preferences_file, preferences_file) shutil.move(backup_preferences_file, preferences_file) # Restore the obfuscated settings self._illuminate(**secrets) return extracted @staticmethod def _extractArchive(archive: "ZipFile", target_path: str) -> bool: """Extract the whole archive to the given target path. :param archive: The archive as ZipFile. :param target_path: The target path. :return: Whether we had success or not. """ # Implement security recommendations: Sanity check on zip files will make it harder to spoof. from cura.CuraApplication import CuraApplication config_filename = CuraApplication.getInstance().getApplicationName( ) + ".cfg" # Should be there if valid. if config_filename not in [file.filename for file in archive.filelist]: Logger.logException( "e", "Unable to extract the backup due to corruption of compressed file(s)." ) return False Logger.log("d", "Removing current data in location: %s", target_path) Resources.factoryReset() Logger.log("d", "Extracting backup to location: %s", target_path) try: archive.extractall(target_path) except (PermissionError, EnvironmentError): Logger.logException( "e", "Unable to extract the backup due to permission or file system errors." ) return False return True def _obfuscate(self) -> Dict[str, str]: """ Obfuscate and remove the secret preferences that are specified in SECRETS_SETTINGS :return: a dictionary of the removed secrets. Note: the '/' is replaced by '__' """ preferences = self._application.getPreferences() secrets = {} for secret in self.SECRETS_SETTINGS: secrets[secret.replace("/", "__")] = deepcopy( preferences.getValue(secret)) preferences.setValue(secret, None) self._application.savePreferences() return secrets def _illuminate(self, **kwargs) -> None: """ Restore the obfuscated settings :param kwargs: a dict of obscured preferences. Note: the '__' of the keys will be replaced by '/' """ preferences = self._application.getPreferences() for key, value in kwargs.items(): preferences.setValue(key.replace("__", "/"), value) self._application.savePreferences()
class Backup: # These files should be ignored when making a backup. IGNORED_FILES = [ r"cura\.log", r"plugins\.json", r"cache", r"__pycache__", r"\.qmlc", r"\.pyc" ] # Re-use translation catalog. catalog = i18nCatalog("cura") def __init__(self, zip_file: bytes = None, meta_data: Dict[str, str] = None) -> None: self.zip_file = zip_file # type: Optional[bytes] self.meta_data = meta_data # type: Optional[Dict[str, str]] ## Create a back-up from the current user config folder. def makeFromCurrent(self) -> None: cura_release = CuraApplication.getInstance().getVersion() version_data_dir = Resources.getDataStoragePath() Logger.log("d", "Creating backup for Cura %s, using folder %s", cura_release, version_data_dir) # Ensure all current settings are saved. CuraApplication.getInstance().saveSettings() # We copy the preferences file to the user data directory in Linux as it's in a different location there. # When restoring a backup on Linux, we move it back. if Platform.isLinux(): preferences_file_name = CuraApplication.getInstance( ).getApplicationName() preferences_file = Resources.getPath( Resources.Preferences, "{}.cfg".format(preferences_file_name)) backup_preferences_file = os.path.join( version_data_dir, "{}.cfg".format(preferences_file_name)) Logger.log("d", "Copying preferences file from %s to %s", preferences_file, backup_preferences_file) shutil.copyfile(preferences_file, backup_preferences_file) # Create an empty buffer and write the archive to it. buffer = io.BytesIO() archive = self._makeArchive(buffer, version_data_dir) if archive is None: return files = archive.namelist() # Count the metadata items. We do this in a rather naive way at the moment. machine_count = len([s for s in files if "machine_instances/" in s]) - 1 material_count = len([s for s in files if "materials/" in s]) - 1 profile_count = len([s for s in files if "quality_changes/" in s]) - 1 plugin_count = len([s for s in files if "plugin.json" in s]) # Store the archive and metadata so the BackupManager can fetch them when needed. self.zip_file = buffer.getvalue() self.meta_data = { "cura_release": cura_release, "machine_count": str(machine_count), "material_count": str(material_count), "profile_count": str(profile_count), "plugin_count": str(plugin_count) } ## Make a full archive from the given root path with the given name. # \param root_path The root directory to archive recursively. # \return The archive as bytes. def _makeArchive(self, buffer: "io.BytesIO", root_path: str) -> Optional[ZipFile]: ignore_string = re.compile("|".join(self.IGNORED_FILES)) try: archive = ZipFile(buffer, "w", ZIP_DEFLATED) for root, folders, files in os.walk(root_path): for item_name in folders + files: absolute_path = os.path.join(root, item_name) if ignore_string.search(absolute_path): continue archive.write(absolute_path, absolute_path[len(root_path) + len(os.sep):]) archive.close() return archive except (IOError, OSError, BadZipfile) as error: Logger.log( "e", "Could not create archive from user data directory: %s", error) self._showMessage( self.catalog.i18nc( "@info:backup_failed", "Could not create archive from user data directory: {}". format(error))) return None ## Show a UI message. def _showMessage(self, message: str) -> None: Message(message, title=self.catalog.i18nc("@info:title", "Backup"), lifetime=30).show() ## Restore this back-up. # \return Whether we had success or not. def restore(self) -> bool: if not self.zip_file or not self.meta_data or not self.meta_data.get( "cura_release", None): # We can restore without the minimum required information. Logger.log( "w", "Tried to restore a Cura backup without having proper data or meta data." ) self._showMessage( self.catalog.i18nc( "@info:backup_failed", "Tried to restore a Cura backup without having proper data or meta data." )) return False current_version = CuraApplication.getInstance().getVersion() version_to_restore = self.meta_data.get("cura_release", "master") if current_version != version_to_restore: # Cannot restore version older or newer than current because settings might have changed. # Restoring this will cause a lot of issues so we don't allow this for now. self._showMessage( self.catalog.i18nc( "@info:backup_failed", "Tried to restore a Cura backup that does not match your current version." )) return False version_data_dir = Resources.getDataStoragePath() archive = ZipFile(io.BytesIO(self.zip_file), "r") extracted = self._extractArchive(archive, version_data_dir) # Under Linux, preferences are stored elsewhere, so we copy the file to there. if Platform.isLinux(): preferences_file_name = CuraApplication.getInstance( ).getApplicationName() preferences_file = Resources.getPath( Resources.Preferences, "{}.cfg".format(preferences_file_name)) backup_preferences_file = os.path.join( version_data_dir, "{}.cfg".format(preferences_file_name)) Logger.log("d", "Moving preferences file from %s to %s", backup_preferences_file, preferences_file) shutil.move(backup_preferences_file, preferences_file) return extracted ## Extract the whole archive to the given target path. # \param archive The archive as ZipFile. # \param target_path The target path. # \return Whether we had success or not. @staticmethod def _extractArchive(archive: "ZipFile", target_path: str) -> bool: Logger.log("d", "Removing current data in location: %s", target_path) Resources.factoryReset() Logger.log("d", "Extracting backup to location: %s", target_path) archive.extractall(target_path) return True
class LocalClusterOutputDeviceManager: """The LocalClusterOutputDeviceManager is responsible for discovering and managing local networked clusters.""" META_NETWORK_KEY = "um_network_key" MANUAL_DEVICES_PREFERENCE_KEY = "um3networkprinting/manual_instances" MIN_SUPPORTED_CLUSTER_VERSION = Version("4.0.0") # 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 networked clusters. self._discovered_devices = { } # type: Dict[str, LocalClusterOutputDevice] self._output_device_manager = CuraApplication.getInstance( ).getOutputDeviceManager() # Hook up ZeroConf client. self._zero_conf_client = ZeroConfClient() self._zero_conf_client.addedNetworkCluster.connect( self._onDeviceDiscovered) self._zero_conf_client.removedNetworkCluster.connect( self._onDiscoveredDeviceRemoved) def start(self) -> None: """Start the network discovery.""" self._zero_conf_client.start() for address in self._getStoredManualAddresses(): self.addManualDevice(address) def stop(self) -> None: """Stop network discovery and clean up discovered devices.""" self._zero_conf_client.stop() for instance_name in list(self._discovered_devices): self._onDiscoveredDeviceRemoved(instance_name) def startDiscovery(self): """Restart discovery on the local network.""" self.stop() self.start() def addManualDevice( self, address: str, callback: Optional[Callable[[bool, str], None]] = None) -> None: """Add a networked printer manually by address.""" api_client = ClusterApiClient( address, lambda error: Logger.log("e", str(error))) api_client.getSystem(lambda status: self._onCheckManualDeviceResponse( address, status, callback)) def removeManualDevice(self, device_id: str, address: Optional[str] = None) -> None: """Remove a manually added networked printer.""" if device_id not in self._discovered_devices and address is not None: device_id = "manual:{}".format(address) if device_id in self._discovered_devices: address = address or self._discovered_devices[device_id].ipAddress self._onDiscoveredDeviceRemoved(device_id) if address in self._getStoredManualAddresses(): self._removeStoredManualAddress(address) def refreshConnections(self) -> None: """Force reset all network device connections.""" self._connectToActiveMachine() def getDiscoveredDevices(self) -> Dict[str, LocalClusterOutputDevice]: """Get the discovered devices.""" return self._discovered_devices def associateActiveMachineWithPrinterDevice( self, device: LocalClusterOutputDevice) -> None: """Connect the active machine to a given device.""" active_machine = CuraApplication.getInstance().getGlobalContainerStack( ) if not active_machine: return self._connectToOutputDevice(device, active_machine) self._connectToActiveMachine() # Pre-select the correct machine type of the group host. # We first need to find the correct definition because the machine manager only takes name as input, not ID. definitions = CuraApplication.getInstance().getContainerRegistry( ).findContainers(id=device.printerType) if not definitions: return CuraApplication.getInstance().getMachineManager().switchPrinterType( definitions[0].getName()) def _connectToActiveMachine(self) -> None: """Callback for when the active machine was changed by the user or a new remote cluster was found.""" active_machine = CuraApplication.getInstance().getGlobalContainerStack( ) if not active_machine: return output_device_manager = CuraApplication.getInstance( ).getOutputDeviceManager() stored_device_id = active_machine.getMetaDataEntry( self.META_NETWORK_KEY) for device in self._discovered_devices.values(): if device.key == stored_device_id: # Connect to it if the stored key matches. self._connectToOutputDevice(device, active_machine) elif device.key in output_device_manager.getOutputDeviceIds(): # Remove device if it is not meant for the active machine. output_device_manager.removeOutputDevice(device.key) def _onCheckManualDeviceResponse( self, address: str, status: PrinterSystemStatus, callback: Optional[Callable[[bool, str], None]] = None) -> None: """Callback for when a manual device check request was responded to.""" self._onDeviceDiscovered( "manual:{}".format(address), address, { b"name": status.name.encode("utf-8"), b"address": address.encode("utf-8"), b"machine": str(status.hardware.get("typeid", "")).encode("utf-8"), b"manual": b"true", b"firmware_version": status.firmware.encode("utf-8"), b"cluster_size": b"1" }) self._storeManualAddress(address) if callback is not None: CuraApplication.getInstance().callLater(callback, True, address) @staticmethod def _getPrinterTypeIdentifiers() -> Dict[str, str]: """Returns a dict of printer BOM numbers to machine types. These numbers are available in the machine definition already so we just search for them here. """ container_registry = CuraApplication.getInstance( ).getContainerRegistry() ultimaker_machines = container_registry.findContainersMetadata( type="machine", manufacturer="Ultimaker B.V.") found_machine_type_identifiers = {} # type: Dict[str, str] for machine in ultimaker_machines: machine_type = machine.get("id", None) machine_bom_numbers = machine.get("bom_numbers", []) if machine_type and machine_bom_numbers: for bom_number in machine_bom_numbers: # This produces a n:1 mapping of bom numbers to machine types # allowing the S5R1 and S5R2 hardware to use a single S5 definition. found_machine_type_identifiers[str( bom_number)] = machine_type return found_machine_type_identifiers def _onDeviceDiscovered(self, key: str, address: str, properties: Dict[bytes, bytes]) -> None: """Add a new device.""" machine_identifier = properties.get(b"machine", b"").decode("utf-8") printer_type_identifiers = self._getPrinterTypeIdentifiers() # Detect the machine type based on the BOM number that is sent over the network. properties[b"printer_type"] = b"Unknown" for bom, p_type in printer_type_identifiers.items(): if machine_identifier.startswith(bom): properties[b"printer_type"] = bytes(p_type, encoding="utf8") break device = LocalClusterOutputDevice(key, address, properties) discovered_printers_model = CuraApplication.getInstance( ).getDiscoveredPrintersModel() if address in list( discovered_printers_model.discoveredPrintersByAddress.keys()): # The printer was already added, we just update the available data. discovered_printers_model.updateDiscoveredPrinter( ip_address=address, name=device.getName(), machine_type=device.printerType) else: # The printer was not added yet so let's do that. discovered_printers_model.addDiscoveredPrinter( ip_address=address, key=device.getId(), name=device.getName(), create_callback=self._createMachineFromDiscoveredDevice, machine_type=device.printerType, device=device) self._discovered_devices[device.getId()] = device self.discoveredDevicesChanged.emit() self._connectToActiveMachine() def _onDiscoveredDeviceRemoved(self, device_id: str) -> None: """Remove a device.""" device = self._discovered_devices.pop( device_id, None) # type: Optional[LocalClusterOutputDevice] if not device: return device.close() CuraApplication.getInstance().getDiscoveredPrintersModel( ).removeDiscoveredPrinter(device.address) self.discoveredDevicesChanged.emit() def _createMachineFromDiscoveredDevice(self, device_id: str) -> None: """Create a machine instance based on the discovered network printer.""" device = self._discovered_devices.get(device_id) if device is None: return # Create a new machine and activate it. # We do not use use MachineManager.addMachine here because we need to set the network key before activating it. # If we do not do this the auto-pairing with the cloud-equivalent device will not work. 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_NETWORK_KEY, device.key) CuraApplication.getInstance().getMachineManager().setActiveMachine( new_machine.getId()) self._connectToOutputDevice(device, new_machine) self._showCloudFlowMessage(device) def _storeManualAddress(self, address: str) -> None: """Add an address to the stored preferences.""" stored_addresses = self._getStoredManualAddresses() if address in stored_addresses: return # Prevent duplicates. stored_addresses.append(address) new_value = ",".join(stored_addresses) CuraApplication.getInstance().getPreferences().setValue( self.MANUAL_DEVICES_PREFERENCE_KEY, new_value) def _removeStoredManualAddress(self, address: str) -> None: """Remove an address from the stored preferences.""" stored_addresses = self._getStoredManualAddresses() try: stored_addresses.remove(address) # Can throw a ValueError new_value = ",".join(stored_addresses) CuraApplication.getInstance().getPreferences().setValue( self.MANUAL_DEVICES_PREFERENCE_KEY, new_value) except ValueError: Logger.log( "w", "Could not remove address from stored_addresses, it was not there" ) def _getStoredManualAddresses(self) -> List[str]: """Load the user-configured manual devices from Cura preferences.""" preferences = CuraApplication.getInstance().getPreferences() preferences.addPreference(self.MANUAL_DEVICES_PREFERENCE_KEY, "") manual_instances = preferences.getValue( self.MANUAL_DEVICES_PREFERENCE_KEY).split(",") return manual_instances def _connectToOutputDevice(self, device: UltimakerNetworkedPrinterOutputDevice, machine: GlobalStack) -> None: """Add a device to the current active machine.""" # Make sure users know that we no longer support legacy devices. if Version( device.firmwareVersion) < self.MIN_SUPPORTED_CLUSTER_VERSION: LegacyDeviceNoLongerSupportedMessage().show() return machine.setName(device.name) machine.setMetaDataEntry(self.META_NETWORK_KEY, device.key) machine.setMetaDataEntry("group_name", device.name) machine.addConfiguredConnectionType(device.connectionType.value) if not device.isConnected(): device.connect() output_device_manager = CuraApplication.getInstance( ).getOutputDeviceManager() if device.key not in output_device_manager.getOutputDeviceIds(): output_device_manager.addOutputDevice(device) @staticmethod def _showCloudFlowMessage(device: LocalClusterOutputDevice) -> None: """Nudge the user to start using Ultimaker Cloud.""" if CuraApplication.getInstance().getMachineManager( ).activeMachineHasCloudRegistration: # This printer is already cloud connected, so we do not bother the user anymore. return if not CuraApplication.getInstance().getCuraAPI().account.isLoggedIn: # Do not show the message if the user is not signed in. return CloudFlowMessage(device.ipAddress).show()
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_HOST_GUID = "host_guid" 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) self._removed_printers_message = None # type: Optional[Message] # 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 successful and 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. 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: # Existing cloud printers may not have the host_guid meta-data entry. If that's the case, add it. if not self._um_cloud_printers[device_id].getMetaDataEntry( self.META_HOST_GUID, None): self._um_cloud_printers[device_id].setMetaDataEntry( self.META_HOST_GUID, cluster_data.host_guid) # If a printer was previously not linked to the account and is rediscovered, mark the printer as linked # to the current account if 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) # Hide the current removed_printers_message, if there is any if self._removed_printers_message: self._removed_printers_message.actionTriggered.disconnect( self._onRemovedPrintersMessageActionTriggered) self._removed_printers_message.hide() # 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 host_guid_map = { machine.getMetaDataEntry(self.META_HOST_GUID): device_cluster_id for device_cluster_id, machine in self._um_cloud_printers.items() if machine.getMetaDataEntry(self.META_HOST_GUID) } machine_manager = CuraApplication.getInstance().getMachineManager() for cluster_data in clusters: device = CloudOutputDevice(self._api, cluster_data) # If the machine already existed before, it will be present in the host_guid_map if cluster_data.host_guid in host_guid_map: machine = machine_manager.getMachine( device.printerType, {self.META_HOST_GUID: cluster_data.host_guid}) if machine and machine.getMetaDataEntry( self.META_CLUSTER_ID) != device.key: # If the retrieved device has a different cluster_id than the existing machine, bring the existing # machine up-to-date. self._updateOutdatedMachine(outdated_machine=machine, new_cloud_output_device=device) # Create a machine if we don't already have it. Do not make it the active machine. # 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 device_name_list = [ "<li>{} ({})</li>".format(device.name, device.printerTypeName) for device in new_devices[0:max_disp_devices] ] device_name_list.append( self.I18N_CATALOG.i18nc("info:hidden list items", "<li>... and {} others</li>", num_hidden)) device_names = "".join(device_name_list) else: device_names = "".join([ "<li>{} ({})</li>".format(device.name, device.printerTypeName) for device in new_devices ]) message_text = self.I18N_CATALOG.i18nc( "info:status", "Cloud printers added from your account:<ul>{}</ul>", device_names) message.setText(message_text) def _updateOutdatedMachine( self, outdated_machine: GlobalStack, new_cloud_output_device: CloudOutputDevice) -> None: """ Update the cloud metadata of a pre-existing machine that is rediscovered (e.g. if the printer was removed and re-added to the account) and delete the old CloudOutputDevice related to this machine. :param outdated_machine: The cloud machine that needs to be brought up-to-date with the new data received from the account :param new_cloud_output_device: The new CloudOutputDevice that should be linked to the pre-existing machine :return: None """ old_cluster_id = outdated_machine.getMetaDataEntry( self.META_CLUSTER_ID) outdated_machine.setMetaDataEntry(self.META_CLUSTER_ID, new_cloud_output_device.key) outdated_machine.setMetaDataEntry(META_UM_LINKED_TO_ACCOUNT, True) # Cleanup the remainings of the old CloudOutputDevice(old_cluster_id) self._um_cloud_printers[ new_cloud_output_device.key] = self._um_cloud_printers.pop( old_cluster_id) output_device_manager = CuraApplication.getInstance( ).getOutputDeviceManager() if old_cluster_id in output_device_manager.getOutputDeviceIds(): output_device_manager.removeOutputDevice(old_cluster_id) if old_cluster_id in self._remote_clusters: # We need to close the device so that it stops checking for its status self._remote_clusters[old_cluster_id].close() del self._remote_clusters[old_cluster_id] self._remote_clusters[ new_cloud_output_device.key] = new_cloud_output_device def _devicesRemovedFromAccount(self, removed_device_ids: Set[str]) -> None: """ Removes the CloudOutputDevice from the received device ids and marks the specific printers as "removed from account". In addition, it generates a message to inform the user about the printers that are no longer linked to his/her account. The message is not generated if all the printers have been previously reported as not linked to the account. :param removed_device_ids: Set of device ids, whose CloudOutputDevice needs to be removed :return: None """ if not CuraApplication.getInstance().getCuraAPI().account.isLoggedIn: return # Do not report device ids which have been previously marked as non-linked to the account ignored_device_ids = set() for device_id in removed_device_ids: if not parseBool( self._um_cloud_printers[device_id].getMetaDataEntry( META_UM_LINKED_TO_ACCOUNT, "true")): ignored_device_ids.add(device_id) # Keep the reported_device_ids list in a class variable, so that the message button actions can access it and # take the necessary steps to fulfill their purpose. self.reported_device_ids = removed_device_ids - ignored_device_ids if not self.reported_device_ids: return # Generate message self._removed_printers_message = Message( title=self.I18N_CATALOG.i18ncp( "info:status", "Cloud connection is not available for a printer", "Cloud connection is not available for some printers", len(self.reported_device_ids))) device_names = "\n".join([ "<li>{} ({})</li>".format( self._um_cloud_printers[device].name, self._um_cloud_printers[device].definition.name) for device in self.reported_device_ids ]) message_text = self.I18N_CATALOG.i18ncp( "info:status", "The following cloud printer is not linked to your account:\n", "The following cloud printers are not linked to your account:\n", len(self.reported_device_ids)) message_text += self.I18N_CATALOG.i18nc( "info:status", "<ul>{}</ul>\nTo establish a connection, please visit the " "<a href='https://mycloud.ultimaker.com/'>Ultimaker Digital Factory</a>.", device_names) self._removed_printers_message.setText(message_text) self._removed_printers_message.addAction( "keep_printer_configurations_action", name=self.I18N_CATALOG.i18nc("@action:button", "Keep printer configurations"), icon="", description= "Keep the configuration of the cloud printer(s) synced with Cura which are not linked to your account.", button_align=Message.ActionButtonAlignment.ALIGN_RIGHT) self._removed_printers_message.addAction( "remove_printers_action", name=self.I18N_CATALOG.i18nc("@action:button", "Remove printers"), icon="", description= "Remove the cloud printer(s) which are not linked to your account.", button_style=Message.ActionButtonStyle.SECONDARY, button_align=Message.ActionButtonAlignment.ALIGN_LEFT) self._removed_printers_message.actionTriggered.connect( self._onRemovedPrintersMessageActionTriggered) output_device_manager = CuraApplication.getInstance( ).getOutputDeviceManager() # Remove the output device from the printers for device_id in removed_device_ids: device = self._um_cloud_printers.get( device_id, None) # type: Optional[GlobalStack] if not device: continue if device_id in output_device_manager.getOutputDeviceIds(): output_device_manager.removeOutputDevice(device_id) if device_id in self._remote_clusters: del self._remote_clusters[device_id] # Update the printer's metadata to mark it as not linked to the account device.setMetaDataEntry(META_UM_LINKED_TO_ACCOUNT, False) self._removed_printers_message.show() def _onDiscoveredDeviceRemoved(self, device_id: str) -> None: device = self._remote_clusters.pop( device_id, None) # type: Optional[CloudOutputDevice] if not device: return device.close() output_device_manager = CuraApplication.getInstance( ).getOutputDeviceManager() if device.key in output_device_manager.getOutputDeviceIds(): output_device_manager.removeOutputDevice(device.key) def _createMachineFromDiscoveredDevice(self, key: str, activate: bool = True) -> None: device = self._remote_clusters[key] if not device: return # 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 self._setOutputDeviceMetadata(device, new_machine) if activate: CuraApplication.getInstance().getMachineManager().setActiveMachine( new_machine.getId()) 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 output_device_manager = CuraApplication.getInstance( ).getOutputDeviceManager() stored_cluster_id = active_machine.getMetaDataEntry( self.META_CLUSTER_ID) local_network_key = active_machine.getMetaDataEntry( self.META_NETWORK_KEY) for device in self._remote_clusters.values(): if device.key == stored_cluster_id: # Connect to it if the stored ID matches. self._connectToOutputDevice(device, active_machine) elif local_network_key and device.matchesNetworkKey( local_network_key): # Connect to it if we can match the local network key that was already present. self._connectToOutputDevice(device, active_machine) elif device.key in output_device_manager.getOutputDeviceIds(): # Remove device if it is not meant for the active machine. output_device_manager.removeOutputDevice(device.key) def _setOutputDeviceMetadata(self, device: CloudOutputDevice, machine: GlobalStack): machine.setName(device.name) machine.setMetaDataEntry(self.META_CLUSTER_ID, device.key) machine.setMetaDataEntry(self.META_HOST_GUID, device.clusterData.host_guid) machine.setMetaDataEntry("group_name", device.name) machine.setMetaDataEntry("group_size", device.clusterSize) machine.setMetaDataEntry( "removal_warning", self.I18N_CATALOG.i18nc( "@label ({} is printer name)", "{} will be removed until the next account sync. <br> To remove {} permanently, " "visit <a href='https://mycloud.ultimaker.com/'>Ultimaker Digital Factory</a>. " "<br><br>Are you sure you want to remove {} temporarily?", device.name, device.name, device.name)) machine.addConfiguredConnectionType(device.connectionType.value) def _connectToOutputDevice(self, device: CloudOutputDevice, machine: GlobalStack) -> None: """Connects to an output device and makes sure it is registered in the output device manager.""" self._setOutputDeviceMetadata(device, machine) if not device.isConnected(): device.connect() output_device_manager = CuraApplication.getInstance( ).getOutputDeviceManager() if device.key not in output_device_manager.getOutputDeviceIds(): output_device_manager.addOutputDevice(device) def _printerRemoved(self, container: ContainerInterface) -> None: """ Callback connected to the containerRemoved signal. Invoked when a cloud printer is removed from Cura to remove the printer's reference from the _remote_clusters. :param container: The ContainerInterface passed to this function whenever the ContainerRemoved signal is emitted :return: None """ if isinstance(container, GlobalStack): container_cluster_id = container.getMetaDataEntry( self.META_CLUSTER_ID, None) if container_cluster_id in self._remote_clusters.keys(): del self._remote_clusters[container_cluster_id] def _onRemovedPrintersMessageActionTriggered( self, removed_printers_message: Message, action: str) -> None: if action == "keep_printer_configurations_action": removed_printers_message.hide() elif action == "remove_printers_action": machine_manager = CuraApplication.getInstance().getMachineManager() remove_printers_ids = { self._um_cloud_printers[i].getId() for i in self.reported_device_ids } all_ids = { m.getId() for m in CuraApplication.getInstance().getContainerRegistry(). findContainerStacks(type="machine") } question_title = self.I18N_CATALOG.i18nc("@title:window", "Remove printers?") question_content = self.I18N_CATALOG.i18nc( "@label", "You are about to remove {} printer(s) from Cura. This action cannot be undone. \nAre you sure you want to continue?" .format(len(remove_printers_ids))) if remove_printers_ids == all_ids: question_content = self.I18N_CATALOG.i18nc( "@label", "You are about to remove all printers from Cura. This action cannot be undone. \nAre you sure you want to continue?" ) result = QMessageBox.question(None, question_title, question_content) if result == QMessageBox.No: return for machine_cloud_id in self.reported_device_ids: machine_manager.setActiveMachine( self._um_cloud_printers[machine_cloud_id].getId()) machine_manager.removeMachine( self._um_cloud_printers[machine_cloud_id].getId()) removed_printers_message.hide()
from UM.Version import Version from cura.CuraApplication import CuraApplication from cura.Settings.GlobalStack import GlobalStack from .ZeroConfClient import ZeroConfClient from .ClusterApiClient import ClusterApiClient from .LocalClusterOutputDevice import LocalClusterOutputDevice from ..UltimakerNetworkedPrinterOutputDevice import UltimakerNetworkedPrinterOutputDevice from ..CloudFlowMessage import CloudFlowMessage from ..Messages.LegacyDeviceNoLongerSupportedMessage import LegacyDeviceNoLongerSupportedMessage from ..Messages.NotClusterHostMessage import NotClusterHostMessage from ..Models.Http.PrinterSystemStatus import PrinterSystemStatus I18N_CATALOG = i18nCatalog("cura") ## The LocalClusterOutputDeviceManager is responsible for discovering and managing local networked clusters. class LocalClusterOutputDeviceManager: META_NETWORK_KEY = "um_network_key" MANUAL_DEVICES_PREFERENCE_KEY = "um3networkprinting/manual_instances" MIN_SUPPORTED_CLUSTER_VERSION = Version("4.0.0") # The translation catalog for this device. I18N_CATALOG = i18nCatalog("cura") # Signal emitted when the list of discovered devices changed. discoveredDevicesChanged = Signal()
import os import shutil import zipfile import tempfile import urllib.parse # For interpreting escape characters using unquote_plus. from PyQt5.QtCore import pyqtSlot, QObject, pyqtSignal, QUrl, pyqtProperty from UM import i18nCatalog from UM.Logger import Logger from UM.Message import Message from UM.MimeTypeDatabase import MimeTypeDatabase # To get the type of container we're loading. from UM.Resources import Resources from UM.Version import Version as UMVersion catalog = i18nCatalog("uranium") if TYPE_CHECKING: from UM.Qt.QtApplication import QtApplication class PackageManager(QObject): Version = 1 def __init__(self, application: "QtApplication", parent: Optional[QObject] = None) -> None: super().__init__(parent) self._application = application self._container_registry = self._application.getContainerRegistry() self._plugin_registry = self._application.getPluginRegistry()
class DeviceManager(QObject): """Discovers and manages Monoprice Select Mini V2 printers over the network.""" I18N_CATALOG = i18nCatalog('cura') discoveredDevicesChanged = Signal() onPrinterUpload = pyqtSignal(bool) def __init__(self) -> None: super().__init__() self._discovered_devices = {} self._background_threads = {} self._output_device_manager = ( CuraApplication.getInstance().getOutputDeviceManager()) ContainerRegistry.getInstance().containerRemoved.connect( self._on_printer_container_removed) self._add_manual_device_in_progress = False def start(self) -> None: Logger.log('d', 'Starting Device Manager.') for address in _get_stored_manual_addresses(): self._create_heartbeat_thread(address) def stop(self) -> None: Logger.log('d', 'Stopping Device Manager.') for instance_name in list(self._discovered_devices): self._on_discovered_device_removed(instance_name) def start_discovery(self) -> None: Logger.log('d', 'Start discovery.') self.stop() self.start() def connect_to_active_machine(self) -> None: """Connects to the active machine. If the active machine is not a networked Monoprice Select Mini V2 printer, it removes them as Output Device. """ Logger.log('d', 'Connecting to active machine.') active_machine = CuraApplication.getInstance().getGlobalContainerStack() if not active_machine: return # Should only occur on fresh installations of Cura. output_device_manager = ( CuraApplication.getInstance().getOutputDeviceManager()) stored_device_id = active_machine.getMetaDataEntry(_METADATA_MPSM2_KEY) for device in self._discovered_devices.values(): if device.key == stored_device_id: _connect_to_output_device(device, active_machine) elif device.key in output_device_manager.getOutputDeviceIds(): output_device_manager.removeOutputDevice(device.key) def add_device( self, address: str, callback: Optional[Callable[[bool, str], None]] = None) -> None: """Handles user-request to add a device by IP address. Args: address: Printer's IP address. callback: Called after requests completes. """ Logger.log('d', 'Requesting to add device with address: %s.', address) self._add_manual_device_in_progress = True api_client = ApiClient(address) api_client.get_printer_status( lambda response: self._on_printer_status_response( response, address, callback), self._on_printer_status_error) def remove_device(self, device_id: Optional[str], address: Optional[str] = None) -> None: """Handles user-request to delete a device. Args: device_id: Device identifier 'manual:<ip_address>'. address: Printer's IP address. """ Logger.log('d', 'Removing manual device with device_id: %s and address: %s', device_id, address) if device_id not in self._discovered_devices and address is not None: device_id = _get_device_id(address) if device_id in self._discovered_devices: address = address or self._discovered_devices[device_id].ipAddress self._on_discovered_device_removed(device_id) if address in _get_stored_manual_addresses(): _remove_stored_manual_address(address) if address in self._background_threads: Logger.log('d', 'Stopping background thread for address %s.', address) self._background_threads[address].stopBeat() self._background_threads[address].quit() del self._background_threads[address] def _create_heartbeat_thread(self, address: str) -> None: """Creates and starts a background thread to ping the printer status. Args address: printer's IP address. """ Logger.log('d', 'Creating heartbeat thread for stored address: %s', address) heartbeat_thread = PrinterHeartbeat(address) heartbeat_thread.heartbeatSignal.connect(self._on_printer_heartbeat) self.onPrinterUpload.connect(heartbeat_thread.handle_printer_busy) heartbeat_thread.start() self._background_threads[address] = heartbeat_thread def _on_printer_container_removed(self, container: ContainerInterface) -> None: """Removes device if it is managed by this plugin. Called when the user deletes a printer. Args: container: deleted container. """ device_id = container.getMetaDataEntry(_METADATA_MPSM2_KEY) self.remove_device(device_id, _get_address(device_id)) def _on_printer_status_error(self) -> None: """Called when the printer status has error.""" self._add_manual_device_in_progress = False def _on_printer_status_response( self, response, address: str, callback: Optional[Callable[[bool, str], None]] = None) -> None: """Called when the printer status requests completes. Args: response: Response to the status request. Can be 'timeout'. address: Printer's IP address. callback: Called after this function finishes. """ self._add_manual_device_in_progress = False if response is None and callback is not None: CuraApplication.getInstance().callLater(callback, False, address) return Logger.log('d', 'Received response from printer on address %s: %s.', address, response) device = MPSM2NetworkedPrinterOutputDevice(_get_device_id(address), address) device.onPrinterUpload.connect(self.onPrinterUpload) device.update_printer_status(response) discovered_printers_model = ( CuraApplication.getInstance().getDiscoveredPrintersModel()) discovered_printers_model.addDiscoveredPrinter( ip_address=address, key=device.getId(), name=device.getName(), create_callback=self._create_machine, machine_type=device.printerType, device=device) _store_manual_address(address) self._discovered_devices[device.getId()] = device self.discoveredDevicesChanged.emit() self.connect_to_active_machine() if callback is not None: CuraApplication.getInstance().callLater(callback, True, address) def _on_discovered_device_removed(self, device_id: str) -> None: """Called when a discovered device by this plugin is removed. Args: device_id: device identifier. """ Logger.log('d', 'Removing discovered device with device_id: %s', device_id) device = self._discovered_devices.pop(device_id, None) if not device: return device.close() (CuraApplication.getInstance() .getDiscoveredPrintersModel().removeDiscoveredPrinter(device.address)) self.discoveredDevicesChanged.emit() def _create_machine(self, device_id: str) -> None: """Creates a machine. Called when user adds a discovered machine. Args: device_id: device identifier. """ Logger.log('d', 'Creating machine with device id %s.', device_id) device = cast(MPSM2NetworkedPrinterOutputDevice, self._discovered_devices.get(device_id)) if device is None: return machine_manager = CuraApplication.getInstance().getMachineManager() machine = machine_manager.getMachine( 'monoprice_select_mini_v2', {_METADATA_MPSM2_KEY: device_id}) if machine is None: new_machine = CuraStackBuilder.createMachine( device.name, device.printerType) if not new_machine: Logger.log('e', 'Failed to create a new machine.') return new_machine.setMetaDataEntry('group_name', device.name) new_machine.setMetaDataEntry(_METADATA_MPSM2_KEY, device.key) CuraApplication.getInstance().getMachineManager().setActiveMachine( new_machine.getId()) _connect_to_output_device(device, new_machine) self._create_heartbeat_thread(device.ipAddress) def _on_printer_heartbeat(self, address: str, response: str) -> None: """Called when background heartbeat was received. Includes timeout. Args: address: IP address response: HTTP body response to inquiry request. """ device = cast( MPSM2NetworkedPrinterOutputDevice, self._discovered_devices.get(_get_device_id(address))) if response == 'timeout': if (device and device.isConnected() and not device.is_uploading() and not self._add_manual_device_in_progress): # Request timeout is expected during job upload. Logger.log('d', 'Discovered device timed out. Stopping device.') device.close() return if not device: self._on_printer_status_response(response, address) return device = cast( MPSM2NetworkedPrinterOutputDevice, self._discovered_devices.get(_get_device_id(address))) if not device.isConnected(): Logger.log('d', 'Printer at %s is up again. Reconnecting.', address) self.connect_to_active_machine() self.discoveredDevicesChanged.emit() device.update_printer_status(response)
def _update(self) -> None: Logger.log("d", "Updating {model_class_name}.".format(model_class_name = self.__class__.__name__)) if not self._selected_quality_item: self.setItems([]) return items = [] global_container_stack = self._application.getGlobalContainerStack() definition_container = global_container_stack.definition # Try and find a translation catalog for the definition for file_name in definition_container.getInheritedFiles(): catalog = i18nCatalog(os.path.basename(file_name)) if catalog.hasTranslationLoaded(): self._i18n_catalog = catalog quality_group = self._selected_quality_item["quality_group"] quality_changes_group = self._selected_quality_item["quality_changes_group"] quality_node = None settings_keys = set() # type: Set[str] if quality_group: if self._selected_position == self.GLOBAL_STACK_POSITION: quality_node = quality_group.node_for_global else: quality_node = quality_group.nodes_for_extruders.get(self._selected_position) settings_keys = quality_group.getAllKeys() quality_containers = [] if quality_node is not None and quality_node.container is not None: quality_containers.append(quality_node.container) # Here, if the user has selected a quality changes, then "quality_changes_group" will not be None, and we fetch # the settings in that quality_changes_group. if quality_changes_group is not None: container_registry = ContainerRegistry.getInstance() metadata_for_global = quality_changes_group.metadata_for_global global_containers = container_registry.findContainers(id = metadata_for_global["id"]) global_container = None if len(global_containers) == 0 else global_containers[0] extruders_containers = {pos: container_registry.findContainers(id = quality_changes_group.metadata_per_extruder[pos]["id"]) for pos in quality_changes_group.metadata_per_extruder} extruders_container = {pos: None if not containers else containers[0] for pos, containers in extruders_containers.items()} quality_changes_metadata = None if self._selected_position == self.GLOBAL_STACK_POSITION and global_container: quality_changes_metadata = global_container.getMetaData() else: extruder = extruders_container.get(self._selected_position) if extruder: quality_changes_metadata = extruder.getMetaData() if quality_changes_metadata is not None: # It can be None if number of extruders are changed during runtime. container = container_registry.findContainers(id = quality_changes_metadata["id"]) if container: quality_containers.insert(0, container[0]) if global_container: settings_keys.update(global_container.getAllKeys()) for container in extruders_container.values(): if container: settings_keys.update(container.getAllKeys()) # We iterate over all definitions instead of settings in a quality/quality_changes group is because in the GUI, # the settings are grouped together by categories, and we had to go over all the definitions to figure out # which setting belongs in which category. current_category = "" for definition in definition_container.findDefinitions(): if definition.type == "category": current_category = definition.label if self._i18n_catalog: current_category = self._i18n_catalog.i18nc(definition.key + " label", definition.label) continue profile_value = None profile_value_source = "" for quality_container in quality_containers: new_value = quality_container.getProperty(definition.key, "value") if new_value is not None: profile_value_source = quality_container.getMetaDataEntry("type") profile_value = new_value # Global tab should use resolve (if there is one) if self._selected_position == self.GLOBAL_STACK_POSITION: resolve_value = global_container_stack.getProperty(definition.key, "resolve") if resolve_value is not None and definition.key in settings_keys: profile_value = resolve_value if profile_value is not None: break if self._selected_position == self.GLOBAL_STACK_POSITION: user_value = global_container_stack.userChanges.getProperty(definition.key, "value") else: extruder_stack = global_container_stack.extruderList[self._selected_position] user_value = extruder_stack.userChanges.getProperty(definition.key, "value") if profile_value is None and user_value is None: continue label = definition.label if self._i18n_catalog: label = self._i18n_catalog.i18nc(definition.key + " label", label) if profile_value_source == "quality_changes": label = f"<i>{label}</i>" # Make setting name italic if it's derived from the quality-changes profile. if isinstance(profile_value, SettingFunction): if self._i18n_catalog: profile_value_display = self._i18n_catalog.i18nc("@info:status", "Calculated") else: profile_value_display = "Calculated" else: profile_value_display = "" if profile_value is None else str(profile_value) items.append({ "key": definition.key, "label": label, "unit": definition.unit, "profile_value": profile_value_display, "profile_value_source": profile_value_source, "user_value": "" if user_value is None else str(user_value), "category": current_category }) self.setItems(items)
# Copyright (c) 2021 Ultimaker B.V. # Uranium is released under the terms of the LGPLv3 or higher. from UM import i18nCatalog from UM.Application import Application from UM.Message import Message from UM.Version import Version from .AnnotatedUpdateMessage import AnnotatedUpdateMessage I18N_CATALOG = i18nCatalog("uranium") class NewBetaVersionMessage(AnnotatedUpdateMessage): def __init__(self, application_display_name: str, newest_version: Version) -> None: super().__init__( title = I18N_CATALOG.i18nc("@info:status", "{application_name} {version_number}-BETA is available!").format( application_name = application_display_name, version_number = newest_version), text = I18N_CATALOG.i18nc("@info:status", "Try out the latest BETA version and help us improve {application_name}.").format( application_name = application_display_name) ) self.change_log_url = Application.getInstance().beta_change_log_url self.addAction("download", I18N_CATALOG.i18nc("@action:button", "Download"), "[no_icon]", "[no_description]") self.addAction("new_features", I18N_CATALOG.i18nc("@action:button", "Learn more"),
# Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from UM import i18nCatalog from UM.Message import Message I18N_CATALOG = i18nCatalog("cura") ## Class responsible for showing a progress message while a mesh is being uploaded to the cloud. class CloudProgressMessage(Message): def __init__(self): super().__init__( text = I18N_CATALOG.i18nc("@info:status", "Sending data to remote cluster"), title = I18N_CATALOG.i18nc("@info:status", "Sending data to remote cluster"), progress = -1, lifetime = 0, dismissable = False, use_inactivity_timer = False ) ## Shows the progress message. def show(self): self.setProgress(0) super().show() ## Updates the percentage of the uploaded. # \param percentage: The percentage amount (0-100). def update(self, percentage: int) -> None: if not self._visible: super().show()
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" # The interval with which the remote clusters are checked CHECK_CLUSTER_INTERVAL = 30.0 # seconds # 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] self._account = CuraApplication.getInstance().getCuraAPI( ).account # type: Account self._api = CloudApiClient( self._account, on_error=lambda error: Logger.log("e", str(error))) self._account.loginStateChanged.connect(self._onLoginStateChanged) # Create a timer to update the remote cluster list self._update_timer = QTimer() self._update_timer.setInterval(int(self.CHECK_CLUSTER_INTERVAL * 1000)) # 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 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 if not self._update_timer.isActive(): self._update_timer.start() self._getRemoteClusters() def stop(self): """Stops running the cloud output device manager.""" if not self._running: return self._running = False if self._update_timer.isActive(): self._update_timer.stop() 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.""" self._api.getClusters(self._onGetRemoteClustersFinished) 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: new_clusters.append(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) 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 = [] 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 # 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 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") 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 = [ "- {} ({})".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 _onDiscoveredDeviceRemoved(self, device_id: str) -> None: device = self._remote_clusters.pop( device_id, None) # type: Optional[CloudOutputDevice] if not device: return device.close() output_device_manager = CuraApplication.getInstance( ).getOutputDeviceManager() if device.key in output_device_manager.getOutputDeviceIds(): output_device_manager.removeOutputDevice(device.key) def _createMachineFromDiscoveredDevice(self, key: str, activate: bool = True) -> None: device = self._remote_clusters[key] if not device: return # 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 self._setOutputDeviceMetadata(device, new_machine) if activate: CuraApplication.getInstance().getMachineManager().setActiveMachine( new_machine.getId()) 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 output_device_manager = CuraApplication.getInstance( ).getOutputDeviceManager() stored_cluster_id = active_machine.getMetaDataEntry( self.META_CLUSTER_ID) local_network_key = active_machine.getMetaDataEntry( self.META_NETWORK_KEY) for device in self._remote_clusters.values(): if device.key == stored_cluster_id: # Connect to it if the stored ID matches. self._connectToOutputDevice(device, active_machine) elif local_network_key and device.matchesNetworkKey( local_network_key): # Connect to it if we can match the local network key that was already present. self._connectToOutputDevice(device, active_machine) elif device.key in output_device_manager.getOutputDeviceIds(): # Remove device if it is not meant for the active machine. output_device_manager.removeOutputDevice(device.key) def _setOutputDeviceMetadata(self, device: CloudOutputDevice, machine: GlobalStack): machine.setName(device.name) machine.setMetaDataEntry(self.META_CLUSTER_ID, device.key) machine.setMetaDataEntry("group_name", device.name) machine.addConfiguredConnectionType(device.connectionType.value) def _connectToOutputDevice(self, device: CloudOutputDevice, machine: GlobalStack) -> None: """Connects to an output device and makes sure it is registered in the output device manager.""" self._setOutputDeviceMetadata(device, machine) if not device.isConnected(): device.connect() output_device_manager = CuraApplication.getInstance( ).getOutputDeviceManager() if device.key not in output_device_manager.getOutputDeviceIds(): output_device_manager.addOutputDevice(device)
def __init__(self, app: CuraApplication) -> None: self._app = app self._i18n_catalog = i18nCatalog("cura")
class CloudOutputDeviceManager: META_CLUSTER_ID = "um_cloud_cluster_id" META_NETWORK_KEY = "um_network_key" # The interval with which the remote clusters are checked CHECK_CLUSTER_INTERVAL = 30.0 # seconds # 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] self._account = CuraApplication.getInstance().getCuraAPI().account # type: Account self._api = CloudApiClient(self._account, on_error=lambda error: print(error)) self._account.loginStateChanged.connect(self._onLoginStateChanged) # 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) # Ensure we don't start twice. self._running = False ## Starts running the cloud output device manager, thus periodically requesting cloud data. def start(self): if self._running: return if not self._account.isLoggedIn: return self._running = True if not self._update_timer.isActive(): self._update_timer.start() self._getRemoteClusters() self._update_timer.timeout.connect(self._getRemoteClusters) ## Stops running the cloud output device manager. def stop(self): if not self._running: return 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) ## Force refreshing connections. def refreshConnections(self) -> None: self._connectToActiveMachine() ## Called when the uses logs in or out def _onLoginStateChanged(self, is_logged_in: bool) -> None: if is_logged_in: self.start() else: self.stop() ## Gets all remote clusters from the API. def _getRemoteClusters(self) -> None: self._api.getClusters(self._onGetRemoteClustersFinished) ## Callback for when the request for getting the clusters is finished. def _onGetRemoteClustersFinished(self, clusters: List[CloudClusterResponse]) -> None: 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) else: self._onDiscoveredDeviceUpdated(cluster_data) 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 ) self._remote_clusters[device.getId()] = device self.discoveredDevicesChanged.emit() self._connectToActiveMachine() def _onDiscoveredDeviceUpdated(self, cluster_data: CloudClusterResponse) -> None: device = self._remote_clusters.get(cluster_data.cluster_id) if not device: return CuraApplication.getInstance().getDiscoveredPrintersModel().updateDiscoveredPrinter( ip_address=device.key, name=cluster_data.friendly_name, machine_type=device.printerType ) self.discoveredDevicesChanged.emit() def _onDiscoveredDeviceRemoved(self, device_id: str) -> None: device = self._remote_clusters.pop(device_id, None) # type: Optional[CloudOutputDevice] if not device: return device.disconnect() device.close() CuraApplication.getInstance().getDiscoveredPrintersModel().removeDiscoveredPrinter(device.key) self.discoveredDevicesChanged.emit() def _createMachineFromDiscoveredDevice(self, key: str) -> None: device = self._remote_clusters[key] if not device: return # The newly added machine is automatically activated. machine_manager = CuraApplication.getInstance().getMachineManager() machine_manager.addMachine(device.printerType, device.clusterData.friendly_name) active_machine = CuraApplication.getInstance().getGlobalContainerStack() 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: active_machine = CuraApplication.getInstance().getGlobalContainerStack() if not active_machine: return output_device_manager = CuraApplication.getInstance().getOutputDeviceManager() stored_cluster_id = active_machine.getMetaDataEntry(self.META_CLUSTER_ID) local_network_key = active_machine.getMetaDataEntry(self.META_NETWORK_KEY) for device in self._remote_clusters.values(): if device.key == stored_cluster_id: # Connect to it if the stored ID matches. self._connectToOutputDevice(device, active_machine) elif local_network_key and device.matchesNetworkKey(local_network_key): # Connect to it if we can match the local network key that was already present. active_machine.setMetaDataEntry(self.META_CLUSTER_ID, device.key) self._connectToOutputDevice(device, active_machine) elif device.key in output_device_manager.getOutputDeviceIds(): # 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. @staticmethod def _connectToOutputDevice(device: CloudOutputDevice, active_machine: GlobalStack) -> None: device.connect() active_machine.addConfiguredConnectionType(device.connectionType.value) CuraApplication.getInstance().getOutputDeviceManager().addOutputDevice(device)
""" Copyright 2020 Luc Rubio <*****@*****.**> Plugin is licensed under the GNU Lesser General Public License v3.0. """ from UM import i18nCatalog from UM.Message import Message I18N_CATALOG = i18nCatalog('cura') class PrintJobUploadCancelMessage(Message): """Message displayed when the user cancels the print.""" def __init__(self) -> None: super().__init__(title=I18N_CATALOG.i18nc('@info:title', 'Print Cancelled'), text=I18N_CATALOG.i18nc('@info:status', 'Print job was cancelled.'), lifetime=0)
class LocalClusterOutputDeviceManager: META_NETWORK_KEY = "um_network_key" MANUAL_DEVICES_PREFERENCE_KEY = "um3networkprinting/manual_instances" MIN_SUPPORTED_CLUSTER_VERSION = Version("4.0.0") # 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 networked clusters. self._discovered_devices = {} # type: Dict[str, LocalClusterOutputDevice] self._output_device_manager = CuraApplication.getInstance().getOutputDeviceManager() # Hook up ZeroConf client. self._zero_conf_client = ZeroConfClient() self._zero_conf_client.addedNetworkCluster.connect(self._onDeviceDiscovered) self._zero_conf_client.removedNetworkCluster.connect(self._onDiscoveredDeviceRemoved) ## Start the network discovery. def start(self) -> None: self._zero_conf_client.start() for address in self._getStoredManualAddresses(): self.addManualDevice(address) ## Stop network discovery and clean up discovered devices. def stop(self) -> None: self._zero_conf_client.stop() # Cleanup all manual devices. for instance_name in list(self._discovered_devices): self._onDiscoveredDeviceRemoved(instance_name) ## Add a networked printer manually by address. def addManualDevice(self, address: str, callback: Optional[Callable[[bool, str], None]] = None) -> None: api_client = ClusterApiClient(address, lambda error: print(error)) api_client.getSystem(lambda status: self._onCheckManualDeviceResponse(address, status, callback)) ## Remove a manually added networked printer. def removeManualDevice(self, device_id: str, address: Optional[str] = None) -> None: if device_id not in self._discovered_devices and address is not None: device_id = "manual:{}".format(address) if device_id in self._discovered_devices: address = address or self._discovered_devices[device_id].ipAddress self._onDiscoveredDeviceRemoved(device_id) if address in self._getStoredManualAddresses(): self._removeStoredManualAddress(address) ## Force reset all network device connections. def refreshConnections(self) -> None: self._connectToActiveMachine() ## Callback for when the active machine was changed by the user or a new remote cluster was found. def _connectToActiveMachine(self) -> None: active_machine = CuraApplication.getInstance().getGlobalContainerStack() if not active_machine: return output_device_manager = CuraApplication.getInstance().getOutputDeviceManager() stored_device_id = active_machine.getMetaDataEntry(self.META_NETWORK_KEY) for device in self._discovered_devices.values(): if device.key == stored_device_id: # Connect to it if the stored key matches. self._connectToOutputDevice(device, active_machine) elif device.key in output_device_manager.getOutputDeviceIds(): # Remove device if it is not meant for the active machine. CuraApplication.getInstance().getOutputDeviceManager().removeOutputDevice(device.key) ## Callback for when a manual device check request was responded to. def _onCheckManualDeviceResponse(self, address: str, status: PrinterSystemStatus, callback: Optional[Callable[[bool, str], None]] = None) -> None: self._onDeviceDiscovered("manual:{}".format(address), address, { b"name": status.name.encode("utf-8"), b"address": address.encode("utf-8"), b"machine": str(status.hardware.get("typeid", "")).encode("utf-8"), b"manual": b"true", b"firmware_version": status.firmware.encode("utf-8"), b"cluster_size": b"1" }) self._storeManualAddress(address) if callback is not None: CuraApplication.getInstance().callLater(callback, True, address) ## Returns a dict of printer BOM numbers to machine types. # These numbers are available in the machine definition already so we just search for them here. @staticmethod def _getPrinterTypeIdentifiers() -> Dict[str, str]: container_registry = CuraApplication.getInstance().getContainerRegistry() ultimaker_machines = container_registry.findContainersMetadata(type="machine", manufacturer="Ultimaker B.V.") found_machine_type_identifiers = {} # type: Dict[str, str] for machine in ultimaker_machines: machine_bom_number = machine.get("firmware_update_info", {}).get("id", None) machine_type = machine.get("id", None) if machine_bom_number and machine_type: found_machine_type_identifiers[str(machine_bom_number)] = machine_type return found_machine_type_identifiers ## Add a new device. def _onDeviceDiscovered(self, key: str, address: str, properties: Dict[bytes, bytes]) -> None: machine_identifier = properties.get(b"machine", b"").decode("utf-8") printer_type_identifiers = self._getPrinterTypeIdentifiers() # Detect the machine type based on the BOM number that is sent over the network. properties[b"printer_type"] = b"Unknown" for bom, p_type in printer_type_identifiers.items(): if machine_identifier.startswith(bom): properties[b"printer_type"] = bytes(p_type, encoding="utf8") break device = LocalClusterOutputDevice(key, address, properties) CuraApplication.getInstance().getDiscoveredPrintersModel().addDiscoveredPrinter( ip_address=address, key=device.getId(), name=device.getName(), create_callback=self._createMachineFromDiscoveredDevice, machine_type=device.printerType, device=device ) self._discovered_devices[device.getId()] = device self.discoveredDevicesChanged.emit() self._connectToActiveMachine() ## Remove a device. def _onDiscoveredDeviceRemoved(self, device_id: str) -> None: device = self._discovered_devices.pop(device_id, None) # type: Optional[LocalClusterOutputDevice] if not device: return device.close() CuraApplication.getInstance().getDiscoveredPrintersModel().removeDiscoveredPrinter(device.address) self.discoveredDevicesChanged.emit() ## Create a machine instance based on the discovered network printer. def _createMachineFromDiscoveredDevice(self, device_id: str) -> None: device = self._discovered_devices.get(device_id) if device is None: return # The newly added machine is automatically activated. CuraApplication.getInstance().getMachineManager().addMachine(device.printerType, device.name) active_machine = CuraApplication.getInstance().getGlobalContainerStack() if not active_machine: return active_machine.setMetaDataEntry(self.META_NETWORK_KEY, device.key) active_machine.setMetaDataEntry("group_name", device.name) self._connectToOutputDevice(device, active_machine) CloudFlowMessage(device.ipAddress).show() # Nudge the user to start using Ultimaker Cloud. ## Add an address to the stored preferences. def _storeManualAddress(self, address: str) -> None: stored_addresses = self._getStoredManualAddresses() if address in stored_addresses: return # Prevent duplicates. stored_addresses.append(address) new_value = ",".join(stored_addresses) CuraApplication.getInstance().getPreferences().setValue(self.MANUAL_DEVICES_PREFERENCE_KEY, new_value) ## Remove an address from the stored preferences. def _removeStoredManualAddress(self, address: str) -> None: stored_addresses = self._getStoredManualAddresses() try: stored_addresses.remove(address) # Can throw a ValueError new_value = ",".join(stored_addresses) CuraApplication.getInstance().getPreferences().setValue(self.MANUAL_DEVICES_PREFERENCE_KEY, new_value) except ValueError: Logger.log("w", "Could not remove address from stored_addresses, it was not there") ## Load the user-configured manual devices from Cura preferences. def _getStoredManualAddresses(self) -> List[str]: preferences = CuraApplication.getInstance().getPreferences() preferences.addPreference(self.MANUAL_DEVICES_PREFERENCE_KEY, "") manual_instances = preferences.getValue(self.MANUAL_DEVICES_PREFERENCE_KEY).split(",") return manual_instances ## Add a device to the current active machine. def _connectToOutputDevice(self, device: UltimakerNetworkedPrinterOutputDevice, machine: GlobalStack) -> None: # Make sure users know that we no longer support legacy devices. if Version(device.firmwareVersion) < self.MIN_SUPPORTED_CLUSTER_VERSION: LegacyDeviceNoLongerSupportedMessage().show() return # Tell the user that they cannot connect to a non-host printer. if device.clusterSize < 1: NotClusterHostMessage().show() return device.connect() machine.addConfiguredConnectionType(device.connectionType.value) CuraApplication.getInstance().getOutputDeviceManager().addOutputDevice(device)
class CloudOutputDeviceManager: META_CLUSTER_ID = "um_cloud_cluster_id" # The interval with which the remote clusters are checked CHECK_CLUSTER_INTERVAL = 30.0 # seconds # 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] self._application = CuraApplication.getInstance() self._output_device_manager = self._application.getOutputDeviceManager( ) self._account = self._application.getCuraAPI().account # type: Account self._api = CloudApiClient(self._account, self._onApiError) # 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) self._running = False # Called when the uses logs in or out def _onLoginStateChanged(self, is_logged_in: bool) -> None: Logger.log("d", "Log in state changed to %s", is_logged_in) if is_logged_in: if not self._update_timer.isActive(): self._update_timer.start() self._getRemoteClusters() else: if self._update_timer.isActive(): self._update_timer.stop() # Notify that all clusters have disappeared self._onGetRemoteClustersFinished([]) ## Gets all remote clusters from the API. def _getRemoteClusters(self) -> None: Logger.log("d", "Retrieving remote clusters") self._api.getClusters(self._onGetRemoteClustersFinished) ## Callback for when the request for getting the clusters. is finished. def _onGetRemoteClustersFinished( self, clusters: List[CloudClusterResponse]) -> None: online_clusters = {c.cluster_id: c for c in clusters if c.is_online } # type: Dict[str, CloudClusterResponse] removed_devices, added_clusters, updates = findChanges( self._remote_clusters, online_clusters) Logger.log("d", "Parsed remote clusters to %s", [cluster.toDict() for cluster in online_clusters.values()]) Logger.log("d", "Removed: %s, added: %s, updates: %s", len(removed_devices), len(added_clusters), len(updates)) # Remove output devices that are gone for device in removed_devices: if device.isConnected(): device.disconnect() device.close() self._output_device_manager.removeOutputDevice(device.key) self._application.getDiscoveredPrintersModel( ).removeDiscoveredPrinter(device.key) self.removedCloudCluster.emit(device) del self._remote_clusters[device.key] # Add an output device for each new remote cluster. # We only add when is_online as we don't want the option in the drop down if the cluster is not online. for cluster in added_clusters: device = CloudOutputDevice(self._api, cluster) self._remote_clusters[cluster.cluster_id] = device self._application.getDiscoveredPrintersModel( ).addDiscoveredPrinter(cluster.cluster_id, device.key, cluster.friendly_name, self._createMachineFromDiscoveredPrinter, device.printerType, device) self.addedCloudCluster.emit(cluster) # Update the output devices for device, cluster in updates: device.clusterData = cluster self._application.getDiscoveredPrintersModel( ).updateDiscoveredPrinter( cluster.cluster_id, cluster.friendly_name, device.printerType, ) self._connectToActiveMachine() def _createMachineFromDiscoveredPrinter(self, key: str) -> None: device = self._remote_clusters[key] # type: CloudOutputDevice if not device: Logger.log("e", "Could not find discovered device with key [%s]", key) return group_name = device.clusterData.friendly_name machine_type_id = device.printerType Logger.log( "i", "Creating machine from cloud device with key = [%s], group name = [%s], printer type = [%s]", key, group_name, machine_type_id) # The newly added machine is automatically activated. self._application.getMachineManager().addMachine( machine_type_id, group_name) active_machine = CuraApplication.getInstance().getGlobalContainerStack( ) 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: active_machine = CuraApplication.getInstance().getGlobalContainerStack( ) if not active_machine: return # Remove all output devices that we have registered. # This is needed because when we switch machines we can only leave # output devices that are meant for that machine. for stored_cluster_id in self._remote_clusters: self._output_device_manager.removeOutputDevice(stored_cluster_id) # Check if the stored cluster_id for the active machine is in our list of remote clusters. stored_cluster_id = active_machine.getMetaDataEntry( self.META_CLUSTER_ID) if stored_cluster_id in self._remote_clusters: 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) ## 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: device.connect() self._output_device_manager.addOutputDevice(device) active_machine.addConfiguredConnectionType(device.connectionType.value) ## Handles an API error received from the cloud. # \param errors: The errors received def _onApiError(self, errors: List[CloudError] = None) -> None: Logger.log("w", str(errors)) message = Message(text=self.I18N_CATALOG.i18nc( "@info:description", "There was an error connecting to the cloud."), title=self.I18N_CATALOG.i18nc( "@info:title", "Error"), lifetime=10) message.show() ## Starts running the cloud output device manager, thus periodically requesting cloud data. def start(self): if self._running: return self._account.loginStateChanged.connect(self._onLoginStateChanged) # When switching machines we check if we have to activate a remote cluster. self._application.globalContainerStackChanged.connect( self._connectToActiveMachine) 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) # When switching machines we check if we have to activate a remote cluster. self._application.globalContainerStackChanged.disconnect( self._connectToActiveMachine) self._update_timer.timeout.disconnect(self._getRemoteClusters) self._onLoginStateChanged(is_logged_in=False)