Beispiel #1
0
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.")
    }
Beispiel #2
0
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.")
    }
Beispiel #3
0
    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
Beispiel #4
0
    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"""
Beispiel #5
0
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")
Beispiel #6
0
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")
Beispiel #7
0
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()
Beispiel #8
0
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()
Beispiel #9
0
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()
Beispiel #13
0
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()
Beispiel #14
0
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)
Beispiel #15
0
    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)
Beispiel #21
0
"""
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)
Beispiel #23
0
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)