diff --git a/cura/Machines/Models/MachineListModel.py b/cura/Machines/Models/MachineListModel.py index a72d6ebbc9..2dbf088088 100644 --- a/cura/Machines/Models/MachineListModel.py +++ b/cura/Machines/Models/MachineListModel.py @@ -99,7 +99,8 @@ class MachineListModel(ListModel): if self._show_cloud_printers: self.addItem(stack) # Remove this machine from the other stack list - other_machine_stacks.remove(stack) + if stack in other_machine_stacks: + other_machine_stacks.remove(stack) if len(abstract_machine_stacks) > 0: if self._show_cloud_printers: diff --git a/cura/PrinterOutput/PrinterOutputDevice.py b/cura/PrinterOutput/PrinterOutputDevice.py index d3a5e252d3..add561fcb1 100644 --- a/cura/PrinterOutput/PrinterOutputDevice.py +++ b/cura/PrinterOutput/PrinterOutputDevice.py @@ -50,13 +50,12 @@ class PrinterOutputDevice(QObject, OutputDevice): The assumption is made the printer is a FDM printer. Note that a number of settings are marked as "final". This is because decorators - are not inherited by children. To fix this we use the private counter part of those + are not inherited by children. To fix this we use the private counterpart of those functions to actually have the implementation. For all other uses it should be used in the same way as a "regular" OutputDevice. """ - printersChanged = pyqtSignal() connectionStateChanged = pyqtSignal(str) acceptsCommandsChanged = pyqtSignal() @@ -183,8 +182,8 @@ class PrinterOutputDevice(QObject, OutputDevice): @pyqtProperty(QObject, constant = True) def monitorItem(self) -> QObject: # Note that we specifically only check if the monitor component is created. - # It could be that it failed to actually create the qml item! If we check if the item was created, it will try to - # create the item (and fail) every time. + # It could be that it failed to actually create the qml item! If we check if the item was created, it will try + # to create the item (and fail) every time. if not self._monitor_component: self._createMonitorViewFromQML() return self._monitor_item @@ -237,9 +236,9 @@ class PrinterOutputDevice(QObject, OutputDevice): self.acceptsCommandsChanged.emit() - # Returns the unique configurations of the printers within this output device @pyqtProperty("QVariantList", notify = uniqueConfigurationsChanged) def uniqueConfigurations(self) -> List["PrinterConfigurationModel"]: + """ Returns the unique configurations of the printers within this output device """ return self._unique_configurations def _updateUniqueConfigurations(self) -> None: @@ -248,7 +247,9 @@ class PrinterOutputDevice(QObject, OutputDevice): if printer.printerConfiguration is not None and printer.printerConfiguration.hasAnyMaterialLoaded(): all_configurations.add(printer.printerConfiguration) all_configurations.update(printer.availableConfigurations) - if None in all_configurations: # Shouldn't happen, but it does. I don't see how it could ever happen. Skip adding that configuration. List could end up empty! + if None in all_configurations: + # Shouldn't happen, but it does. I don't see how it could ever happen. Skip adding that configuration. + # List could end up empty! Logger.log("e", "Found a broken configuration in the synced list!") all_configurations.remove(None) new_configurations = sorted(all_configurations, key = lambda config: config.printerType or "") @@ -256,9 +257,9 @@ class PrinterOutputDevice(QObject, OutputDevice): self._unique_configurations = new_configurations self.uniqueConfigurationsChanged.emit() - # Returns the unique configurations of the printers within this output device @pyqtProperty("QStringList", notify = uniqueConfigurationsChanged) def uniquePrinterTypes(self) -> List[str]: + """ Returns the unique configurations of the printers within this output device """ return list(sorted(set([configuration.printerType or "" for configuration in self._unique_configurations]))) def _onPrintersChanged(self) -> None: diff --git a/cura/Settings/CuraStackBuilder.py b/cura/Settings/CuraStackBuilder.py index 5a745f8f0a..813b3f7d2e 100644 --- a/cura/Settings/CuraStackBuilder.py +++ b/cura/Settings/CuraStackBuilder.py @@ -1,7 +1,7 @@ # Copyright (c) 2019 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from typing import Optional +from typing import Optional, cast from UM.ConfigurationErrorMessage import ConfigurationErrorMessage from UM.Logger import Logger @@ -275,41 +275,26 @@ class CuraStackBuilder: :return: The new Abstract Machine or None if an error occurred. """ abstract_machine_id = f"{definition_id}_abstract_machine" - from cura.CuraApplication import CuraApplication application = CuraApplication.getInstance() registry = application.getContainerRegistry() - container_tree = ContainerTree.getInstance() - if registry.findContainerStacks(is_abstract_machine = "True", id = abstract_machine_id): - # This abstract machine already exists + abstract_machines = registry.findContainerStacks(id = abstract_machine_id) + if abstract_machines: + return cast(GlobalStack, abstract_machines[0]) + definitions = registry.findDefinitionContainers(id=definition_id) + + name = "" + + if definitions: + name = definitions[0].getName() + stack = cls.createMachine(abstract_machine_id, definition_id) + if not stack: return None - match registry.findDefinitionContainers(type = "machine", id = definition_id): - case []: - # It should not be possible for the definition to be missing since an abstract machine will only - # be created as a result of a machine with definition_id being created. - Logger.error(f"Definition {definition_id} was not found!") - return None - case [machine_definition, *_definitions]: - machine_node = container_tree.machines[machine_definition.getId()] - name = machine_definition.getName() + stack.setName(name) - stack = GlobalStack(abstract_machine_id) - stack.setMetaDataEntry("is_abstract_machine", True) - stack.setMetaDataEntry("is_online", True) - stack.setDefinition(machine_definition) - cls.createUserContainer( - name, - machine_definition, - stack, - application.empty_variant_container, - application.empty_material_container, - machine_node.preferredGlobalQuality().container, - ) + stack.setMetaDataEntry("is_abstract_machine", True) + stack.setMetaDataEntry("is_online", True) - stack.setName(name) - - registry.addContainer(stack) - - return stack + return stack \ No newline at end of file diff --git a/cura/Settings/GlobalStack.py b/cura/Settings/GlobalStack.py index 3c13f236ab..43232da725 100755 --- a/cura/Settings/GlobalStack.py +++ b/cura/Settings/GlobalStack.py @@ -292,7 +292,6 @@ class GlobalStack(CuraContainerStack): for extruder_train in extruder_trains: extruder_position = extruder_train.getMetaDataEntry("position") extruder_check_position.add(extruder_position) - for check_position in range(machine_extruder_count): if str(check_position) not in extruder_check_position: return False diff --git a/cura/Settings/MachineManager.py b/cura/Settings/MachineManager.py index a4988be49d..fc11beb2c8 100755 --- a/cura/Settings/MachineManager.py +++ b/cura/Settings/MachineManager.py @@ -359,6 +359,7 @@ class MachineManager(QObject): extruder_manager = ExtruderManager.getInstance() extruder_manager.fixSingleExtrusionMachineExtruderDefinition(global_stack) if not global_stack.isValid(): + Logger.warning("Global stack isn't valid, adding it to faulty container list") # Mark global stack as invalid ConfigurationErrorMessage.getInstance().addFaultyContainers(global_stack.getId()) return # We're done here diff --git a/plugins/UM3NetworkPrinting/src/Cloud/AbstractCloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/AbstractCloudOutputDevice.py new file mode 100644 index 0000000000..8448c095c8 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/AbstractCloudOutputDevice.py @@ -0,0 +1,87 @@ +from time import time +from typing import List + +from PyQt6.QtCore import QObject +from PyQt6.QtNetwork import QNetworkReply + +from UM import i18nCatalog +from UM.Logger import Logger +from cura.PrinterOutput.NetworkedPrinterOutputDevice import AuthState +from cura.PrinterOutput.PrinterOutputDevice import ConnectionType +from .CloudApiClient import CloudApiClient +from ..Models.Http.CloudClusterResponse import CloudClusterResponse +from ..Models.Http.CloudClusterWithConfigResponse import CloudClusterWithConfigResponse +from ..UltimakerNetworkedPrinterOutputDevice import UltimakerNetworkedPrinterOutputDevice + +I18N_CATALOG = i18nCatalog("cura") + + +class AbstractCloudOutputDevice(UltimakerNetworkedPrinterOutputDevice): + API_CHECK_INTERVAL = 10.0 # seconds + + def __init__(self, api_client: CloudApiClient, printer_type: str, parent: QObject = None) -> None: + + self._api = api_client + properties = {b"printer_type": printer_type.encode()} + super().__init__( + device_id=f"ABSTRACT_{printer_type}", + address="", + connection_type=ConnectionType.CloudConnection, + properties=properties, + parent=parent + ) + + self._setInterfaceElements() + + def connect(self) -> None: + """Connects this device.""" + + if self.isConnected(): + return + Logger.log("i", "Attempting to connect AbstractCloudOutputDevice %s", self.key) + super().connect() + + #CuraApplication.getInstance().getBackend().backendStateChange.connect(self._onBackendStateChange) + self._update() + + def disconnect(self) -> None: + """Disconnects the device""" + + if not self.isConnected(): + return + super().disconnect() + + def _update(self) -> None: + """Called when the network data should be updated.""" + + super()._update() + if time() - self._time_of_last_request < self.API_CHECK_INTERVAL: + return # avoid calling the cloud too often + self._time_of_last_request = time() + if self._api.account.isLoggedIn: + self.setAuthenticationState(AuthState.Authenticated) + self._last_request_time = time() + self._api.getClustersByMachineType(self.printerType, self._onCompleted, self._onError) + else: + self.setAuthenticationState(AuthState.NotAuthenticated) + + def _setInterfaceElements(self) -> None: + """Set all the interface elements and texts for this output device.""" + + self.setPriority(2) # Make sure we end up below the local networking and above 'save to file'. + self.setShortDescription(I18N_CATALOG.i18nc("@action:button", "Print via cloud")) + self.setDescription(I18N_CATALOG.i18nc("@properties:tooltip", "Print via cloud")) + self.setConnectionText(I18N_CATALOG.i18nc("@info:status", "Connected via cloud")) + + def _onCompleted(self, clusters: List[CloudClusterWithConfigResponse]) -> None: + self._responseReceived() + + all_configurations = [] + for resp in clusters: + if resp.configuration is not None: + # Usually when the printer is offline, it doesn't have a configuration... + all_configurations.append(resp.configuration) + self._updatePrinters(all_configurations) + + def _onError(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None: + pass diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py index 1fc926fe90..318fceeb40 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py @@ -1,6 +1,7 @@ # Copyright (c) 2019 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. import json +import urllib.parse from json import JSONDecodeError from time import time from typing import Callable, List, Type, TypeVar, Union, Optional, Tuple, Dict, Any, cast @@ -17,6 +18,7 @@ from cura.UltimakerCloud import UltimakerCloudConstants from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope from .ToolPathUploader import ToolPathUploader from ..Models.BaseModel import BaseModel +from ..Models.Http.CloudClusterWithConfigResponse import CloudClusterWithConfigResponse from ..Models.Http.CloudClusterResponse import CloudClusterResponse from ..Models.Http.CloudClusterStatus import CloudClusterStatus from ..Models.Http.CloudError import CloudError @@ -48,7 +50,6 @@ class CloudApiClient: """Initializes a new cloud API client. :param app: - :param account: The user's account object :param on_error: The callback to be called whenever we receive errors from the server. """ super().__init__() @@ -57,12 +58,11 @@ class CloudApiClient: self._scope = JsonDecoratorScope(UltimakerCloudScope(app)) self._http = HttpRequestManager.getInstance() self._on_error = on_error - self._upload = None # type: Optional[ToolPathUploader] + self._upload: Optional[ToolPathUploader] = None @property def account(self) -> Account: """Gets the account used for the API.""" - return self._account def getClusters(self, on_finished: Callable[[List[CloudClusterResponse]], Any], failed: Callable) -> None: @@ -71,13 +71,31 @@ class CloudApiClient: :param on_finished: The function to be called after the result is parsed. """ - url = "{}/clusters?status=active".format(self.CLUSTER_API_ROOT) + url = f"{self.CLUSTER_API_ROOT}/clusters?status=active" self._http.get(url, scope = self._scope, callback = self._parseCallback(on_finished, CloudClusterResponse, failed), error_callback = failed, timeout = self.DEFAULT_REQUEST_TIMEOUT) + def getClustersByMachineType(self, machine_type, on_finished: Callable[[List[CloudClusterWithConfigResponse]], Any], failed: Callable) -> None: + # HACK: There is something weird going on with the API, as it reports printer types in formats like + # "ultimaker_s3", but wants "Ultimaker S3" when using the machine_variant filter query. So we need to do some + # conversion! + + machine_type = machine_type.replace("_plus", "+") + machine_type = machine_type.replace("_", " ") + machine_type = machine_type.replace("ultimaker", "ultimaker ") + machine_type = machine_type.replace(" ", " ") + machine_type = machine_type.title() + machine_type = urllib.parse.quote_plus(machine_type) + url = f"{self.CLUSTER_API_ROOT}/clusters?machine_variant={machine_type}" + self._http.get(url, + scope=self._scope, + callback=self._parseCallback(on_finished, CloudClusterWithConfigResponse, failed), + error_callback=failed, + timeout=self.DEFAULT_REQUEST_TIMEOUT) + def getClusterStatus(self, cluster_id: str, on_finished: Callable[[CloudClusterStatus], Any]) -> None: """Retrieves the status of the given cluster. @@ -85,7 +103,7 @@ class CloudApiClient: :param on_finished: The function to be called after the result is parsed. """ - url = "{}/clusters/{}/status".format(self.CLUSTER_API_ROOT, cluster_id) + url = f"{self.CLUSTER_API_ROOT}/clusters/{cluster_id}/status" self._http.get(url, scope = self._scope, callback = self._parseCallback(on_finished, CloudClusterStatus), @@ -100,7 +118,7 @@ class CloudApiClient: :param on_finished: The function to be called after the result is parsed. """ - url = "{}/jobs/upload".format(self.CURA_API_ROOT) + url = f"{self.CURA_API_ROOT}/jobs/upload" data = json.dumps({"data": request.toDict()}).encode() self._http.put(url, @@ -131,7 +149,7 @@ class CloudApiClient: # specific to sending print jobs) such as lost connection, unparsable responses, etc. are not returned here, but # handled in a generic way by the CloudApiClient. def requestPrint(self, cluster_id: str, job_id: str, on_finished: Callable[[CloudPrintResponse], Any], on_error) -> None: - url = "{}/clusters/{}/print/{}".format(self.CLUSTER_API_ROOT, cluster_id, job_id) + url = f"{self.CLUSTER_API_ROOT}/clusters/{cluster_id}/print/{job_id}" self._http.post(url, scope = self._scope, data = b"", @@ -150,7 +168,7 @@ class CloudApiClient: """ body = json.dumps({"data": data}).encode() if data else b"" - url = "{}/clusters/{}/print_jobs/{}/action/{}".format(self.CLUSTER_API_ROOT, cluster_id, cluster_job_id, action) + url = f"{self.CLUSTER_API_ROOT}/clusters/{cluster_id}/print_jobs/{cluster_job_id}/action/{action}" self._http.post(url, scope = self._scope, data = body, @@ -159,7 +177,7 @@ class CloudApiClient: def _createEmptyRequest(self, path: str, content_type: Optional[str] = "application/json") -> QNetworkRequest: """We override _createEmptyRequest in order to add the user credentials. - :param url: The URL to request + :param path: The URL to request :param content_type: The type of the body contents. """ @@ -168,7 +186,7 @@ class CloudApiClient: request.setHeader(QNetworkRequest.KnownHeaders.ContentTypeHeader, content_type) access_token = self._account.accessToken if access_token: - request.setRawHeader(b"Authorization", "Bearer {}".format(access_token).encode()) + request.setRawHeader(b"Authorization", f"Bearer {access_token}".encode()) return request @staticmethod diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index 6426f01b76..4c58c82350 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -42,7 +42,7 @@ I18N_CATALOG = i18nCatalog("cura") class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice): """The cloud output device is a network output device that works remotely but has limited functionality. - Currently it only supports viewing the printer and print job status and adding a new job to the queue. + Currently, it only supports viewing the printer and print job status and adding a new job to the queue. As such, those methods have been implemented here. Note that this device represents a single remote cluster, not a list of multiple clusters. """ @@ -59,7 +59,7 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice): PRINT_JOB_ACTIONS_MIN_VERSION = Version("5.2.12") # Notify can only use signals that are defined by the class that they are in, not inherited ones. - # Therefore we create a private signal used to trigger the printersChanged signal. + # Therefore, we create a private signal used to trigger the printersChanged signal. _cloudClusterPrintersChanged = pyqtSignal() def __init__(self, api_client: CloudApiClient, cluster: CloudClusterResponse, parent: QObject = None) -> None: @@ -203,7 +203,8 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice): # Note that self.writeFinished is called in _onPrintUploadCompleted as well. if self._uploaded_print_job: Logger.log("i", "Current mesh is already attached to a print-job, immediately request reprint.") - self._api.requestPrint(self.key, self._uploaded_print_job.job_id, self._onPrintUploadCompleted, self._onPrintUploadSpecificError) + self._api.requestPrint(self.key, self._uploaded_print_job.job_id, self._onPrintUploadCompleted, + self._onPrintUploadSpecificError) return # Export the scene to the correct file type. @@ -246,12 +247,15 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice): self._progress.update(100) print_job = cast(CloudPrintJobResponse, self._pre_upload_print_job) - if not print_job: # It's possible that another print job is requested in the meanwhile, which then fails to upload with an error, which sets self._pre_uploaded_print_job to `None`. + if not print_job: + # It's possible that another print job is requested in the meanwhile, which then fails to upload with an + # error, which sets self._pre_uploaded_print_job to `None`. self._pre_upload_print_job = None self._uploaded_print_job = None Logger.log("w", "Interference from another job uploaded at roughly the same time, not uploading print!") return # Prevent a crash. - self._api.requestPrint(self.key, print_job.job_id, self._onPrintUploadCompleted, self._onPrintUploadSpecificError) + self._api.requestPrint(self.key, print_job.job_id, self._onPrintUploadCompleted, + self._onPrintUploadSpecificError) def _onPrintUploadCompleted(self, response: CloudPrintResponse) -> None: """Shows a message when the upload has succeeded @@ -285,7 +289,9 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice): if error_code == 409: PrintJobUploadQueueFullMessage().show() else: - PrintJobUploadErrorMessage(I18N_CATALOG.i18nc("@error:send", "Unknown error code when uploading print job: {0}", error_code)).show() + PrintJobUploadErrorMessage(I18N_CATALOG.i18nc("@error:send", + "Unknown error code when uploading print job: {0}", + error_code)).show() Logger.log("w", "Upload of print job failed specifically with error code {}".format(error_code)) @@ -343,11 +349,13 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice): @pyqtSlot(name="openPrintJobControlPanel") def openPrintJobControlPanel(self) -> None: - QDesktopServices.openUrl(QUrl(self.clusterCloudUrl + "?utm_source=cura&utm_medium=software&utm_campaign=monitor-manage-browser")) + QDesktopServices.openUrl(QUrl(f"{self.clusterCloudUrl}?utm_source=cura&utm_medium=software&" + f"utm_campaign=monitor-manage-browser")) @pyqtSlot(name="openPrinterControlPanel") def openPrinterControlPanel(self) -> None: - QDesktopServices.openUrl(QUrl(self.clusterCloudUrl + "?utm_source=cura&utm_medium=software&utm_campaign=monitor-manage-printer")) + QDesktopServices.openUrl(QUrl(f"{self.clusterCloudUrl}?utm_source=cura&utm_medium=software" + f"&utm_campaign=monitor-manage-printer")) permissionsChanged = pyqtSignal() @@ -369,7 +377,7 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice): @pyqtProperty(bool, notify = permissionsChanged) def canWriteOwnPrintJobs(self) -> bool: """ - Whether this user can change things about print jobs made by themself. + Whether this user can change things about print jobs made by them. """ return "digital-factory.print-job.write.own" in self._account.permissions @@ -397,4 +405,4 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice): """Gets the URL on which to monitor the cluster via the cloud.""" root_url_prefix = "-staging" if self._account.is_staging else "" - return "https://digitalfactory{}.ultimaker.com/app/jobs/{}".format(root_url_prefix, self.clusterData.cluster_id) + return f"https://digitalfactory{root_url_prefix}.ultimaker.com/app/jobs/{self.clusterData.cluster_id}" diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 30bbf68f6c..4082431cd9 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -9,7 +9,6 @@ from PyQt6.QtWidgets import QMessageBox from UM import i18nCatalog from UM.Logger import Logger # To log errors talking to the API. -from UM.Message import Message from UM.Settings.Interfaces import ContainerInterface from UM.Signal import Signal from UM.Util import parseBool @@ -20,16 +19,19 @@ from cura.Settings.CuraContainerRegistry import CuraContainerRegistry # To upda from cura.Settings.CuraStackBuilder import CuraStackBuilder from cura.Settings.GlobalStack import GlobalStack from cura.UltimakerCloud.UltimakerCloudConstants import META_CAPABILITIES, META_UM_LINKED_TO_ACCOUNT +from .AbstractCloudOutputDevice import AbstractCloudOutputDevice from .CloudApiClient import CloudApiClient from .CloudOutputDevice import CloudOutputDevice +from ..Messages.RemovedPrintersMessage import RemovedPrintersMessage from ..Models.Http.CloudClusterResponse import CloudClusterResponse +from ..Messages.NewPrinterDetectedMessage import NewPrinterDetectedMessage 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/. + API spec is available on https://docs.api.ultimaker.com/connect/index.html. """ META_CLUSTER_ID = "um_cloud_cluster_id" @@ -46,21 +48,22 @@ class CloudOutputDeviceManager: def __init__(self) -> None: # Persistent dict containing the remote clusters for the authenticated user. - self._remote_clusters = {} # type: Dict[str, CloudOutputDevice] + self._remote_clusters: Dict[str, CloudOutputDevice] = {} + + self._abstract_clusters: Dict[str, AbstractCloudOutputDevice] = {} # Dictionary containing all the cloud printers loaded in Cura - self._um_cloud_printers = {} # type: Dict[str, GlobalStack] + self._um_cloud_printers: Dict[str, GlobalStack] = {} - self._account = CuraApplication.getInstance().getCuraAPI().account # type: Account + self._account: Account = CuraApplication.getInstance().getCuraAPI().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] + self._removed_printers_message: Optional[RemovedPrintersMessage] = None # Ensure we don't start twice. self._running = False self._syncing = False - CuraApplication.getInstance().getContainerRegistry().containerRemoved.connect(self._printerRemoved) def start(self): @@ -113,8 +116,8 @@ class CloudOutputDeviceManager: 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] + all_clusters: Dict[str, CloudClusterResponse] = {c.cluster_id: c for c in clusters} + online_clusters: Dict[str, CloudClusterResponse] = {c.cluster_id: c for c in clusters if c.is_online} # Add the new printers in Cura. for device_id, cluster_data in all_clusters.items(): @@ -130,8 +133,11 @@ class CloudOutputDeviceManager: self._um_cloud_printers[device_id].setMetaDataEntry(META_UM_LINKED_TO_ACCOUNT, True) if not self._um_cloud_printers[device_id].getMetaDataEntry(META_CAPABILITIES, None): self._um_cloud_printers[device_id].setMetaDataEntry(META_CAPABILITIES, ",".join(cluster_data.capabilities)) - self._onDevicesDiscovered(new_clusters) + # We want a machine stack per remote printer that we discovered. Create them now! + self._createMachineStacksForDiscoveredClusters(new_clusters) + + # Update the online vs offline status for all found devices self._updateOnlinePrinters(all_clusters) # Hide the current removed_printers_message, if there is any @@ -152,6 +158,7 @@ class CloudOutputDeviceManager: 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() @@ -165,54 +172,62 @@ class CloudOutputDeviceManager: self._syncing = False self._account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.ERROR) - def _onDevicesDiscovered(self, clusters: List[CloudClusterResponse]) -> None: + def _createMachineStacksForDiscoveredClusters(self, discovered_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. + May take a long time to complete. This currently forcefully calls the "processEvents", which isn't + the nicest solution out there. We might need to consider moving this into a job later! """ - new_devices = [] + new_output_devices: List[CloudOutputDevice] = [] 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)} + + # Create a map that maps the HOST_GUID to the DEVICE_CLUSTER_ID + host_guid_map: Dict[str, str] = {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) + for cluster_data in discovered_clusters: + output_device = CloudOutputDevice(self._api, cluster_data) + + if cluster_data.printer_type not in self._abstract_clusters: + self._abstract_clusters[cluster_data.printer_type] = AbstractCloudOutputDevice(self._api, cluster_data.printer_type) + # 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: + machine = machine_manager.getMachine(output_device.printerType, {self.META_HOST_GUID: cluster_data.host_guid}) + if machine and machine.getMetaDataEntry(self.META_CLUSTER_ID) != output_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) + self._updateOutdatedMachine(outdated_machine = machine, new_cloud_output_device = output_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 + if machine_manager.getMachine(output_device.printerType, {self.META_CLUSTER_ID: output_device.key}) is None \ + and machine_manager.getMachine(output_device.printerType, {self.META_NETWORK_KEY: cluster_data.host_name + "*"}) is None: # The host name is part of the network key. + new_output_devices.append(output_device) + elif output_device.getId() not in self._remote_clusters: + self._remote_clusters[output_device.getId()] = output_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) + elif not parseBool(self._um_cloud_printers[output_device.key].getMetaDataEntry(META_UM_LINKED_TO_ACCOUNT, "true")): + self._um_cloud_printers[output_device.key].setMetaDataEntry(META_UM_LINKED_TO_ACCOUNT, True) + # As adding a lot of machines might take some time, ensure that the GUI (and progress message) is updated + CuraApplication.getInstance().processEvents() # 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] + "firmware_version": d.firmwareVersion} for d in new_output_devices] discovered_cloud_printers_model = CuraApplication.getInstance().getDiscoveredCloudPrintersModel() discovered_cloud_printers_model.addDiscoveredCloudPrinters(new_devices_list_of_dicts) - if not new_devices: + if not new_output_devices: if remote_clusters_added: self._connectToActiveMachine() return @@ -220,55 +235,29 @@ class CloudOutputDeviceManager: # 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())) + online_cluster_names = {c.friendly_name.lower() for c in discovered_clusters if c.is_online and not c.friendly_name is None} + new_output_devices.sort(key = lambda x: ("a{}" if x.name.lower() in online_cluster_names else "b{}").format(x.name.lower())) - 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, - message_type = Message.MessageType.POSITIVE - ) + message = NewPrinterDetectedMessage(num_printers_found = len(new_output_devices)) message.show() new_devices_added = [] - for idx, device in enumerate(new_devices): - message_text = self.i18n_catalog.i18nc("info:status Filled in with printer name and printer model.", "Adding printer {name} ({model}) from your account").format(name = device.name, model = 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 + for idx, output_device in enumerate(new_output_devices): + message.updateProgressText(output_device) + + self._remote_clusters[output_device.getId()] = output_device # If there is no active machine, activate the first available cloud printer activate = not CuraApplication.getInstance().getMachineManager().activeMachine - if self._createMachineFromDiscoveredDevice(device.getId(), activate = activate): - new_devices_added.append(device) + if self._createMachineFromDiscoveredDevice(output_device.getId(), activate = activate): + new_devices_added.append(output_device) - message.setProgress(None) + message.finalize(new_devices_added, new_output_devices) - max_disp_devices = 3 - if len(new_devices_added) > max_disp_devices: - num_hidden = len(new_devices_added) - max_disp_devices - device_name_list = ["