diff --git a/.github/workflows/conan-package-create.yml b/.github/workflows/conan-package-create.yml
index 4af608b7ac..f753b76e71 100644
--- a/.github/workflows/conan-package-create.yml
+++ b/.github/workflows/conan-package-create.yml
@@ -53,6 +53,7 @@ env:
jobs:
conan-package-create:
+ if: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
runs-on: ${{ inputs.runs_on }}
steps:
diff --git a/.github/workflows/conan-recipe-version.yml b/.github/workflows/conan-recipe-version.yml
index ddadfe1781..3e86c880a6 100644
--- a/.github/workflows/conan-recipe-version.yml
+++ b/.github/workflows/conan-recipe-version.yml
@@ -53,9 +53,18 @@ jobs:
steps:
- name: Checkout repo
uses: actions/checkout@v3
+ if: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
with:
- ref: ${{ github.head_ref }}
fetch-depth: 0
+ ref: ${{ github.head_ref }}
+
+ - name: Checkout repo PR
+ uses: actions/checkout@v3
+ if: ${{ github.event.pull_request.head.repo.full_name != github.repository }}
+ with:
+ fetch-depth: 0
+ ref: ${{ github.event.pull_request.head.ref }}
+ repository: ${{ github.event.pull_request.head.repo.full_name }}
- name: Setup Python and pip
uses: actions/setup-python@v4
@@ -106,7 +115,7 @@ jobs:
else:
channel = repo.active_branch.name.split("_")[0].replace("-", "_").lower()
- if event_name == "pull_request":
+ if "pull_request" in event_name:
channel = f"pr_{issue_number}"
# %% Get the actual version
@@ -121,36 +130,40 @@ jobs:
latest_branch_version = version
latest_branch_tag = repo.tag(tag)
- # %% Get the actual version
- no_commits = 0
- for commit in repo.iter_commits("HEAD"):
- if commit == latest_branch_tag.commit:
- break
- no_commits += 1
-
- if no_commits == 0:
- # This is a release
- actual_version = f"{latest_branch_version.major}.{latest_branch_version.minor}.{latest_branch_version.patch}"
- if channel == "stable":
- user = "_"
- channel = "_"
- else:
- if latest_branch_version.prerelease and not "." in latest_branch_version.prerelease:
- # The prerealese did not contain a version number, default it to 1
- latest_branch_version.prerelease += ".1"
- if event_name == "pull_request":
- actual_version = f"{latest_branch_version.major}.{latest_branch_version.minor}.{latest_branch_version.patch}-{latest_branch_version.prerelease.lower()}+{buildmetadata}pr_{issue_number}_{no_commits}"
+ if latest_branch_tag:
+ # %% Get the actual version
+ no_commits = 0
+ for commit in repo.iter_commits("HEAD"):
+ if commit == latest_branch_tag.commit:
+ break
+ no_commits += 1
+
+ if no_commits == 0:
+ # This is a release
+ actual_version = f"{latest_branch_version.major}.{latest_branch_version.minor}.{latest_branch_version.patch}"
+ if channel == "stable":
+ user = "_"
+ channel = "_"
else:
- if channel in ("stable", "_", ""):
- channel_metadata = f"{no_commits}"
+ if latest_branch_version.prerelease and not "." in latest_branch_version.prerelease:
+ # The prerealese did not contain a version number, default it to 1
+ latest_branch_version.prerelease += ".1"
+ if event_name == "pull_request":
+ actual_version = f"{latest_branch_version.major}.{latest_branch_version.minor}.{latest_branch_version.patch}-{latest_branch_version.prerelease.lower()}+{buildmetadata}pr_{issue_number}_{no_commits}"
else:
- channel_metadata = f"{channel}_{no_commits}"
- # FIXME: for when we create a new release branch
- if latest_branch_version.prerelease == "":
- bump_up_minor = int(latest_branch_version.minor) + 1
- actual_version = f"{latest_branch_version.major}.{bump_up_minor}.{latest_branch_version.patch}-alpha+{buildmetadata}{channel_metadata}"
- else:
- actual_version = f"{latest_branch_version.major}.{latest_branch_version.minor}.{latest_branch_version.patch}-{latest_branch_version.prerelease.lower()}+{buildmetadata}{channel_metadata}"
+ if channel in ("stable", "_", ""):
+ channel_metadata = f"{no_commits}"
+ else:
+ channel_metadata = f"{channel}_{no_commits}"
+ # FIXME: for when we create a new release branch
+ if latest_branch_version.prerelease == "":
+ bump_up_minor = int(latest_branch_version.minor) + 1
+ actual_version = f"{latest_branch_version.major}.{bump_up_minor}.{latest_branch_version.patch}-alpha+{buildmetadata}{channel_metadata}"
+ else:
+ actual_version = f"{latest_branch_version.major}.{latest_branch_version.minor}.{latest_branch_version.patch}-{latest_branch_version.prerelease.lower()}+{buildmetadata}{channel_metadata}"
+ else:
+ # FIXME: for external PR's
+ actual_version = f"5.2.0-alpha+{buildmetadata}pr_{issue_number}"
# %% print to output
cmd_name = ["echo", f"::set-output name=name::{project_name}"]
diff --git a/cura/CuraApplication.py b/cura/CuraApplication.py
index 3cd0ecbf97..f690456913 100755
--- a/cura/CuraApplication.py
+++ b/cura/CuraApplication.py
@@ -146,8 +146,6 @@ class CuraApplication(QtApplication):
DefinitionChangesContainer = Resources.UserType + 10
SettingVisibilityPreset = Resources.UserType + 11
IntentInstanceContainer = Resources.UserType + 12
- AbstractMachineStack = Resources.UserType + 13
-
pyqtEnum(ResourceTypes)
@@ -426,7 +424,6 @@ class CuraApplication(QtApplication):
Resources.addStorageType(self.ResourceTypes.DefinitionChangesContainer, "definition_changes")
Resources.addStorageType(self.ResourceTypes.SettingVisibilityPreset, "setting_visibility")
Resources.addStorageType(self.ResourceTypes.IntentInstanceContainer, "intent")
- Resources.addStorageType(self.ResourceTypes.AbstractMachineStack, "abstract_machine_instances")
self._container_registry.addResourceType(self.ResourceTypes.QualityInstanceContainer, "quality")
self._container_registry.addResourceType(self.ResourceTypes.QualityChangesInstanceContainer, "quality_changes")
@@ -437,7 +434,6 @@ class CuraApplication(QtApplication):
self._container_registry.addResourceType(self.ResourceTypes.MachineStack, "machine")
self._container_registry.addResourceType(self.ResourceTypes.DefinitionChangesContainer, "definition_changes")
self._container_registry.addResourceType(self.ResourceTypes.IntentInstanceContainer, "intent")
- self._container_registry.addResourceType(self.ResourceTypes.AbstractMachineStack, "abstract_machine")
Resources.addType(self.ResourceTypes.QmlFiles, "qml")
Resources.addType(self.ResourceTypes.Firmware, "firmware")
@@ -486,7 +482,6 @@ class CuraApplication(QtApplication):
("variant", InstanceContainer.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.VariantInstanceContainer, "application/x-uranium-instancecontainer"),
("setting_visibility", SettingVisibilityPresetsModel.Version * 1000000 + self.SettingVersion): (self.ResourceTypes.SettingVisibilityPreset, "application/x-uranium-preferences"),
("machine", 2): (Resources.DefinitionContainers, "application/x-uranium-definitioncontainer"),
- ("abstract_machine", 1): (Resources.DefinitionContainers, "application/x-uranium-definitioncontainer"),
("extruder", 2): (Resources.DefinitionContainers, "application/x-uranium-definitioncontainer")
}
)
diff --git a/cura/MachineAction.py b/cura/MachineAction.py
index 15d9ab1ca1..c38be5261f 100644
--- a/cura/MachineAction.py
+++ b/cura/MachineAction.py
@@ -94,7 +94,7 @@ class MachineAction(QObject, PluginObject):
plugin_path = PluginRegistry.getInstance().getPluginPath(self.getPluginId())
if plugin_path is None:
- Logger.log("e", "Cannot create QML view: cannot find plugin path for plugin [%s]", self.getPluginId())
+ Logger.error(f"Cannot create QML view: cannot find plugin path for plugin {self.getPluginId()}")
return None
path = os.path.join(plugin_path, self._qml_url)
@@ -106,7 +106,7 @@ class MachineAction(QObject, PluginObject):
def qmlPath(self) -> "QUrl":
plugin_path = PluginRegistry.getInstance().getPluginPath(self.getPluginId())
if plugin_path is None:
- Logger.log("e", "Cannot create QML view: cannot find plugin path for plugin [%s]", self.getPluginId())
+ Logger.error(f"Cannot create QML view: cannot find plugin path for plugin {self.getPluginId()}")
return QUrl("")
path = os.path.join(plugin_path, self._qml_url)
return QUrl.fromLocalFile(path)
diff --git a/cura/Machines/Models/MachineListModel.py b/cura/Machines/Models/MachineListModel.py
index f3781cfd60..55db072180 100644
--- a/cura/Machines/Models/MachineListModel.py
+++ b/cura/Machines/Models/MachineListModel.py
@@ -1,14 +1,18 @@
# Copyright (c) 2022 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
-from PyQt6.QtCore import Qt, QTimer
+# The MachineListModel is used to display the connected printers in the interface. Both the abstract machines and all
+# online cloud connected printers are represented within this ListModel. Additional information such as the number of
+# connected printers for each printer type is gathered.
+
+from PyQt6.QtCore import Qt, QTimer, pyqtSlot, pyqtProperty, pyqtSignal
from UM.Qt.ListModel import ListModel
from UM.Settings.ContainerStack import ContainerStack
from UM.i18n import i18nCatalog
from UM.Util import parseBool
+from cura.PrinterOutput.PrinterOutputDevice import ConnectionType
-from cura.Settings.AbstractMachine import AbstractMachine
from cura.Settings.CuraContainerRegistry import CuraContainerRegistry
from cura.Settings.GlobalStack import GlobalStack
@@ -19,12 +23,15 @@ class MachineListModel(ListModel):
HasRemoteConnectionRole = Qt.ItemDataRole.UserRole + 3
MetaDataRole = Qt.ItemDataRole.UserRole + 4
IsOnlineRole = Qt.ItemDataRole.UserRole + 5
- MachineTypeRole = Qt.ItemDataRole.UserRole + 6
- MachineCountRole = Qt.ItemDataRole.UserRole + 7
+ MachineCountRole = Qt.ItemDataRole.UserRole + 6
+ IsAbstractMachineRole = Qt.ItemDataRole.UserRole + 7
+ ComponentTypeRole = Qt.ItemDataRole.UserRole + 8
def __init__(self, parent=None) -> None:
super().__init__(parent)
+ self._show_cloud_printers = False
+
self._catalog = i18nCatalog("cura")
self.addRoleName(self.NameRole, "name")
@@ -32,8 +39,9 @@ class MachineListModel(ListModel):
self.addRoleName(self.HasRemoteConnectionRole, "hasRemoteConnection")
self.addRoleName(self.MetaDataRole, "metadata")
self.addRoleName(self.IsOnlineRole, "isOnline")
- self.addRoleName(self.MachineTypeRole, "machineType")
self.addRoleName(self.MachineCountRole, "machineCount")
+ self.addRoleName(self.IsAbstractMachineRole, "isAbstractMachine")
+ self.addRoleName(self.ComponentTypeRole, "componentType")
self._change_timer = QTimer()
self._change_timer.setInterval(200)
@@ -46,6 +54,18 @@ class MachineListModel(ListModel):
CuraContainerRegistry.getInstance().containerRemoved.connect(self._onContainerChanged)
self._updateDelayed()
+ showCloudPrintersChanged = pyqtSignal(bool)
+
+ @pyqtProperty(bool, notify=showCloudPrintersChanged)
+ def showCloudPrinters(self) -> bool:
+ return self._show_cloud_printers
+
+ @pyqtSlot(bool)
+ def setShowCloudPrinters(self, show_cloud_printers: bool) -> None:
+ self._show_cloud_printers = show_cloud_printers
+ self._updateDelayed()
+ self.showCloudPrintersChanged.emit(show_cloud_printers)
+
def _onContainerChanged(self, container) -> None:
"""Handler for container added/removed events from registry"""
@@ -57,24 +77,45 @@ class MachineListModel(ListModel):
self._change_timer.start()
def _update(self) -> None:
- self.setItems([]) # Clear items
+ self.clear()
other_machine_stacks = CuraContainerRegistry.getInstance().findContainerStacks(type="machine")
- abstract_machine_stacks = CuraContainerRegistry.getInstance().findContainerStacks(type = "abstract_machine")
+ abstract_machine_stacks = CuraContainerRegistry.getInstance().findContainerStacks(is_abstract_machine = "True")
abstract_machine_stacks.sort(key = lambda machine: machine.getName(), reverse = True)
-
for abstract_machine in abstract_machine_stacks:
- online_machine_stacks = AbstractMachine.getMachines(abstract_machine, online_only = True)
+ definition_id = abstract_machine.definition.getId()
+ from cura.CuraApplication import CuraApplication
+ machines_manager = CuraApplication.getInstance().getMachineManager()
+ online_machine_stacks = machines_manager.getMachinesWithDefinition(definition_id, online_only = True)
# Create a list item for abstract machine
self.addItem(abstract_machine, len(online_machine_stacks))
+ other_machine_stacks.remove(abstract_machine)
+ if abstract_machine in online_machine_stacks:
+ online_machine_stacks.remove(abstract_machine)
# Create list of machines that are children of the abstract machine
for stack in online_machine_stacks:
- self.addItem(stack)
+ 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:
+ self.appendItem({"componentType": "HIDE_BUTTON",
+ "isOnline": True,
+ "isAbstractMachine": False,
+ "machineCount": 0
+ })
+ else:
+ self.appendItem({"componentType": "SHOW_BUTTON",
+ "isOnline": True,
+ "isAbstractMachine": False,
+ "machineCount": 0
+ })
for stack in other_machine_stacks:
self.addItem(stack)
@@ -83,10 +124,19 @@ class MachineListModel(ListModel):
if parseBool(container_stack.getMetaDataEntry("hidden", False)):
return
- self.appendItem({"name": container_stack.getName(),
+ # This is required because machines loaded from projects have the is_online="True" but no connection type.
+ # We want to display them the same way as unconnected printers in this case.
+ has_connection = False
+ has_connection |= parseBool(container_stack.getMetaDataEntry("is_abstract_machine", False))
+ for connection_type in [ConnectionType.NetworkConnection.value, ConnectionType.CloudConnection.value]:
+ has_connection |= connection_type in container_stack.configuredConnectionTypes
+
+ self.appendItem({
+ "componentType": "MACHINE",
+ "name": container_stack.getName(),
"id": container_stack.getId(),
"metadata": container_stack.getMetaData().copy(),
- "isOnline": parseBool(container_stack.getMetaDataEntry("is_online", False)),
- "machineType": container_stack.getMetaDataEntry("type"),
+ "isOnline": parseBool(container_stack.getMetaDataEntry("is_online", False)) and has_connection,
+ "isAbstractMachine": parseBool(container_stack.getMetaDataEntry("is_abstract_machine", False)),
"machineCount": machine_count,
})
diff --git a/cura/PrinterOutput/Models/PrinterOutputModel.py b/cura/PrinterOutput/Models/PrinterOutputModel.py
index 2f7091e014..5605ce0de9 100644
--- a/cura/PrinterOutput/Models/PrinterOutputModel.py
+++ b/cura/PrinterOutput/Models/PrinterOutputModel.py
@@ -350,5 +350,6 @@ class PrinterOutputModel(QObject):
self.availableConfigurationsChanged.emit()
def setAvailableConfigurations(self, new_configurations: List[PrinterConfigurationModel]) -> None:
- self._available_printer_configurations = new_configurations
- self.availableConfigurationsChanged.emit()
+ if self._available_printer_configurations != new_configurations:
+ self._available_printer_configurations = new_configurations
+ self.availableConfigurationsChanged.emit()
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/AbstractMachine.py b/cura/Settings/AbstractMachine.py
deleted file mode 100644
index 86909b6e29..0000000000
--- a/cura/Settings/AbstractMachine.py
+++ /dev/null
@@ -1,52 +0,0 @@
-from typing import List
-
-from UM.Settings.ContainerStack import ContainerStack
-from UM.Util import parseBool
-from cura.PrinterOutput.PrinterOutputDevice import ConnectionType
-from cura.Settings.GlobalStack import GlobalStack
-from UM.MimeTypeDatabase import MimeType, MimeTypeDatabase
-from UM.Settings.ContainerRegistry import ContainerRegistry
-
-
-class AbstractMachine(GlobalStack):
- """ Represents a group of machines of the same type. This allows the user to select settings before selecting a printer. """
-
- def __init__(self, container_id: str) -> None:
- super().__init__(container_id)
- self.setMetaDataEntry("type", "abstract_machine")
-
- @classmethod
- def getMachines(cls, abstract_machine: ContainerStack, online_only = False) -> List[ContainerStack]:
- """ Fetches all container stacks that match definition_id with an abstract machine.
-
- :param abstractMachine: The abstract machine stack.
- :return: A list of Containers or an empty list if abstract_machine is not an "abstract_machine"
- """
- if not abstract_machine.getMetaDataEntry("type") == "abstract_machine":
- return []
-
- from cura.CuraApplication import CuraApplication # In function to avoid circular import
- application = CuraApplication.getInstance()
- registry = application.getContainerRegistry()
-
- machines = registry.findContainerStacks(type="machine")
- # Filter machines that match definition
- machines = filter(lambda machine: machine.definition.id == abstract_machine.definition.getId(), machines)
- # Filter only LAN and Cloud printers
- machines = filter(lambda machine: ConnectionType.CloudConnection in machine.configuredConnectionTypes or ConnectionType.NetworkConnection in machine.configuredConnectionTypes, machines)
- if online_only:
- # LAN printers have is_online = False but should still be included
- machines = filter(lambda machine: parseBool(machine.getMetaDataEntry("is_online", False) or ConnectionType.NetworkConnection in machine.configuredConnectionTypes), machines)
-
- return list(machines)
-
-
-## private:
-_abstract_machine_mime = MimeType(
- name = "application/x-cura-abstract-machine",
- comment = "Cura Abstract Machine",
- suffixes = ["global.cfg"]
-)
-
-MimeTypeDatabase.addMimeType(_abstract_machine_mime)
-ContainerRegistry.addContainerTypeByName(AbstractMachine, "abstract_machine", _abstract_machine_mime.name)
diff --git a/cura/Settings/CuraStackBuilder.py b/cura/Settings/CuraStackBuilder.py
index d711a61243..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
@@ -9,7 +9,6 @@ from UM.Settings.Interfaces import DefinitionContainerInterface
from UM.Settings.InstanceContainer import InstanceContainer
from cura.Machines.ContainerTree import ContainerTree
-from .AbstractMachine import AbstractMachine
from .GlobalStack import GlobalStack
from .ExtruderStack import ExtruderStack
@@ -268,48 +267,34 @@ class CuraStackBuilder:
return definition_changes_container
@classmethod
- def createAbstractMachine(cls, definition_id: str) -> Optional[AbstractMachine]:
+ def createAbstractMachine(cls, definition_id: str) -> Optional[GlobalStack]:
"""Create a new instance of an abstract machine.
:param definition_id: The ID of the machine definition to use.
:return: The new Abstract Machine or None if an error occurred.
"""
- abstract_machine_id = definition_id + "_abstract_machine"
-
+ 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(type = "abstract_machine", 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 = AbstractMachine(abstract_machine_id)
- 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 f0a6946f88..b94ca45763 100755
--- a/cura/Settings/GlobalStack.py
+++ b/cura/Settings/GlobalStack.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2019 Ultimaker B.V.
+# Copyright (c) 2022 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from collections import defaultdict
@@ -8,10 +8,9 @@ import uuid
from PyQt6.QtCore import pyqtProperty, pyqtSlot, pyqtSignal
-from UM.Decorators import deprecated, override
+from UM.Decorators import override
from UM.MimeTypeDatabase import MimeType, MimeTypeDatabase
from UM.Settings.ContainerStack import ContainerStack
-from UM.Settings.SettingInstance import InstanceState
from UM.Settings.ContainerRegistry import ContainerRegistry
from UM.Settings.Interfaces import PropertyEvaluationContext
from UM.Logger import Logger
@@ -91,7 +90,6 @@ class GlobalStack(CuraContainerStack):
@pyqtProperty("QVariantList", notify=configuredConnectionTypesChanged)
def configuredConnectionTypes(self) -> List[int]:
"""The configured connection types can be used to find out if the global
-
stack is configured to be connected with a printer, without having to
know all the details as to how this is exactly done (and without
actually setting the stack to be active).
@@ -293,7 +291,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
@@ -344,14 +341,12 @@ class GlobalStack(CuraContainerStack):
def getName(self) -> str:
return self._metadata.get("group_name", self._metadata.get("name", ""))
- def setName(self, name: "str") -> None:
+ def setName(self, name: str) -> None:
super().setName(name)
nameChanged = pyqtSignal()
name = pyqtProperty(str, fget=getName, fset=setName, notify=nameChanged)
-
-
## private:
global_stack_mime = MimeType(
name = "application/x-cura-globalstack",
diff --git a/cura/Settings/MachineManager.py b/cura/Settings/MachineManager.py
index 64d34d6c3e..d091ab9f6f 100755
--- a/cura/Settings/MachineManager.py
+++ b/cura/Settings/MachineManager.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2021 Ultimaker B.V.
+# Copyright (c) 2022 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import time
@@ -19,6 +19,7 @@ from UM.Logger import Logger
from UM.Message import Message
from UM.Settings.SettingFunction import SettingFunction
+from UM.Settings.ContainerStack import ContainerStack
from UM.Signal import postponeSignals, CompressTechnique
import cura.CuraApplication # Imported like this to prevent circular references.
@@ -186,6 +187,32 @@ class MachineManager(QObject):
self.outputDevicesChanged.emit()
+ def getMachinesWithDefinition(self, definition_id: str, online_only=False) -> List[ContainerStack]:
+ """ Fetches all container stacks that match definition_id.
+
+ :param definition_id: The id of the machine definition.
+ :return: A list of Containers that match definition_id
+ """
+ from cura.CuraApplication import CuraApplication # In function to avoid circular import
+ application = CuraApplication.getInstance()
+ registry = application.getContainerRegistry()
+
+ machines = registry.findContainerStacks(type="machine")
+ # Filter machines that match definition
+ machines = filter(lambda machine: machine.definition.id == definition_id, machines)
+ # Filter only LAN and Cloud printers
+ machines = filter(lambda machine: ConnectionType.CloudConnection in machine.configuredConnectionTypes or
+ ConnectionType.NetworkConnection in machine.configuredConnectionTypes,
+ machines)
+ if online_only:
+ # LAN printers can have is_online = False but should still be included,
+ # their online status is only checked when they are the active printer.
+ machines = filter(lambda machine: parseBool(machine.getMetaDataEntry("is_online", False) or
+ ConnectionType.NetworkConnection in machine.configuredConnectionTypes),
+ machines)
+
+ return list(machines)
+
@pyqtProperty(QObject, notify = currentConfigurationChanged)
def currentConfiguration(self) -> PrinterConfigurationModel:
return self._current_printer_configuration
@@ -332,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
@@ -503,6 +531,10 @@ class MachineManager(QObject):
def printerConnected(self) -> bool:
return bool(self._printer_output_devices)
+ @pyqtProperty(bool, notify = globalContainerChanged)
+ def activeMachineIsAbstractCloudPrinter(self) -> bool:
+ return len(self._printer_output_devices) == 1 and self._printer_output_devices[0].__class__.__name__ == "AbstractCloudOutputDevice"
+
@pyqtProperty(bool, notify = printerConnectedStatusChanged)
def activeMachineIsGroup(self) -> bool:
if self.activeMachine is None:
diff --git a/plugins/3MFWriter/ThreeMFWorkspaceWriter.py b/plugins/3MFWriter/ThreeMFWorkspaceWriter.py
index d6cc6ea159..1bc1432b67 100644
--- a/plugins/3MFWriter/ThreeMFWorkspaceWriter.py
+++ b/plugins/3MFWriter/ThreeMFWorkspaceWriter.py
@@ -156,6 +156,7 @@ class ThreeMFWorkspaceWriter(WorkspaceWriter):
"connection_type",
"capabilities",
"octoprint_api_key",
+ "is_online",
}
serialized_data = container.serialize(ignored_metadata_keys = ignore_keys)
diff --git a/plugins/MonitorStage/MonitorMain.qml b/plugins/MonitorStage/MonitorMain.qml
index 5d63ac5b83..a89938530c 100644
--- a/plugins/MonitorStage/MonitorMain.qml
+++ b/plugins/MonitorStage/MonitorMain.qml
@@ -1,4 +1,4 @@
-// Copyright (c) 2018 Ultimaker B.V.
+// Copyright (c) 2022 Ultimaker B.V.
// Cura is released under the terms of the LGPLv3 or higher.
import QtQuick 2.10
@@ -114,6 +114,7 @@ Rectangle
font: UM.Theme.getFont("medium")
width: contentWidth
}
+
Item
{
anchors
diff --git a/plugins/UM3NetworkPrinting/resources/qml/MonitorPrintJobCard.qml b/plugins/UM3NetworkPrinting/resources/qml/MonitorPrintJobCard.qml
index 64aa4e7a9c..c0662cfc82 100644
--- a/plugins/UM3NetworkPrinting/resources/qml/MonitorPrintJobCard.qml
+++ b/plugins/UM3NetworkPrinting/resources/qml/MonitorPrintJobCard.qml
@@ -153,7 +153,7 @@ Item
MonitorPrinterPill
{
- text: printJob.configuration.printerType
+ text: printJob ? printJob.configuration.printerType : ""
}
}
}
@@ -173,7 +173,7 @@ Item
id: printerConfiguration
anchors.verticalCenter: parent.verticalCenter
buildplate: catalog.i18nc("@label", "Glass")
- configurations: base.printJob.configuration.extruderConfigurations
+ configurations: base.printJob ? base.printJob.configuration.extruderConfigurations : null
height: Math.round(72 * screenScaleFactor) // TODO: Theme!
}
diff --git a/plugins/UM3NetworkPrinting/resources/qml/MonitorStage.qml b/plugins/UM3NetworkPrinting/resources/qml/MonitorStage.qml
index 6e8f6b4ebd..e508f70cbb 100644
--- a/plugins/UM3NetworkPrinting/resources/qml/MonitorStage.qml
+++ b/plugins/UM3NetworkPrinting/resources/qml/MonitorStage.qml
@@ -1,8 +1,8 @@
-// Copyright (c) 2019 Ultimaker B.V.
+// Copyright (c) 2022 Ultimaker B.V.
// Cura is released under the terms of the LGPLv3 or higher.
-import QtQuick 2.2
-import UM 1.3 as UM
+import QtQuick 2.15
+import UM 1.5 as UM
import Cura 1.0 as Cura
// This is the root component for the monitor stage.
@@ -37,6 +37,7 @@ Component
Item
{
id: printers
+ visible: !Cura.MachineManager.activeMachineIsAbstractCloudPrinter
anchors
{
top: parent.top
@@ -69,14 +70,66 @@ Component
top: printers.bottom
topMargin: 48 * screenScaleFactor // TODO: Theme!
}
- visible: OutputDevice.supportsPrintJobQueue && OutputDevice.canReadPrintJobs
+ visible: OutputDevice.supportsPrintJobQueue && OutputDevice.canReadPrintJobs && !Cura.MachineManager.activeMachineIsAbstractCloudPrinter
}
PrinterVideoStream
{
anchors.fill: parent
cameraUrl: OutputDevice.activeCameraUrl
- visible: OutputDevice.activeCameraUrl != ""
+ visible: OutputDevice.activeCameraUrl != "" && !Cura.MachineManager.activeMachineIsAbstractCloudPrinter
+ }
+
+ Rectangle
+ {
+ id: sendToFactoryCard
+
+ visible: Cura.MachineManager.activeMachineIsAbstractCloudPrinter
+
+ color: UM.Theme.getColor("monitor_stage_background")
+ height: childrenRect.height + UM.Theme.getSize("default_margin").height * 2
+ width: childrenRect.width + UM.Theme.getSize("wide_margin").width * 2
+ anchors
+ {
+ horizontalCenter: parent.horizontalCenter
+ top: parent.top
+ topMargin: UM.Theme.getSize("wide_margin").height * screenScaleFactor * 2
+ }
+
+ Column
+ {
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: UM.Theme.getSize("wide_margin").height
+ padding: UM.Theme.getSize("default_margin").width
+ topPadding: 0
+
+ Image
+ {
+ id: sendToFactoryImage
+ anchors.horizontalCenter: parent.horizontalCenter
+ source: UM.Theme.getImage("cura_connected_printers")
+ }
+
+ UM.Label
+ {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: catalog.i18nc("@info", "Monitor your printers from everywhere using Ultimaker Digital Factory")
+ font: UM.Theme.getFont("medium")
+ width: sendToFactoryImage.width
+ wrapMode: Text.WordWrap
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ Cura.PrimaryButton
+ {
+ id: sendToFactoryButton
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: catalog.i18nc("@button", "View printers in Digital Factory")
+ onClicked: Qt.openUrlExternally("https://digitalfactory.ultimaker.com/app/print-jobs?utm_source=cura&utm_medium=software&utm_campaign=monitor-view-cloud-printer-type")
+ }
+ }
}
}
}
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 470e57947e..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
@@ -189,9 +207,9 @@ class CloudApiClient:
Logger.logException("e", "Could not parse the stardust response: %s", error.toDict())
return status_code, {"errors": [error.toDict()]}
- def _parseModels(self, response: Dict[str, Any], on_finished: Union[Callable[[CloudApiClientModel], Any],
- Callable[[List[CloudApiClientModel]], Any]], model_class: Type[CloudApiClientModel]) -> None:
- """Parses the given models and calls the correct callback depending on the result.
+ def _parseResponse(self, response: Dict[str, Any], on_finished: Union[Callable[[CloudApiClientModel], Any],
+ Callable[[List[CloudApiClientModel]], Any]], model_class: Type[CloudApiClientModel]) -> None:
+ """Parses the given response and calls the correct callback depending on the result.
:param response: The response from the server, after being converted to a dict.
:param on_finished: The callback in case the response is successful.
@@ -200,7 +218,10 @@ class CloudApiClient:
if "data" in response:
data = response["data"]
- if isinstance(data, list):
+ if "status" in data and data["status"] == "wait_approval":
+ on_finished_empty = cast(Callable[[List], Any], on_finished)
+ on_finished_empty([])
+ elif isinstance(data, list):
results = [model_class(**c) for c in data] # type: List[CloudApiClientModel]
on_finished_list = cast(Callable[[List[CloudApiClientModel]], Any], on_finished)
on_finished_list(results)
@@ -242,7 +263,7 @@ class CloudApiClient:
if status_code >= 300 and on_error is not None:
on_error()
else:
- self._parseModels(response, on_finished, model)
+ self._parseResponse(response, on_finished, model)
self._anti_gc_callbacks.append(parse)
return parse
diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py
index 6431d09b7b..4c58c82350 100644
--- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py
+++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py
@@ -3,7 +3,7 @@
from time import time
import os
-from typing import cast, List, Optional, TYPE_CHECKING
+from typing import cast, List, Optional
from PyQt6.QtCore import QObject, QUrl, pyqtProperty, pyqtSignal, pyqtSlot
from PyQt6.QtGui import QDesktopServices
@@ -21,6 +21,7 @@ from cura.PrinterOutput.PrinterOutputDevice import ConnectionType
from .CloudApiClient import CloudApiClient
from ..ExportFileJob import ExportFileJob
+from ..Messages.PrintJobAwaitingApprovalMessage import PrintJobPendingApprovalMessage
from ..UltimakerNetworkedPrinterOutputDevice import UltimakerNetworkedPrinterOutputDevice
from ..Messages.PrintJobUploadBlockedMessage import PrintJobUploadBlockedMessage
from ..Messages.PrintJobUploadErrorMessage import PrintJobUploadErrorMessage
@@ -41,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.
"""
@@ -58,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:
@@ -202,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.
@@ -230,6 +232,7 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice):
:param job_response: The response received from the cloud API.
"""
+
if not self._tool_path:
return self._onUploadError()
self._pre_upload_print_job = job_response # store the last uploaded job to prevent re-upload of the same file
@@ -244,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
@@ -258,16 +264,21 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice):
"""
self._uploaded_print_job = self._pre_upload_print_job
self._progress.hide()
- message = PrintJobUploadSuccessMessage()
- message.addAction("monitor print",
- name=I18N_CATALOG.i18nc("@action:button", "Monitor print"),
- icon="",
- description=I18N_CATALOG.i18nc("@action:tooltip", "Track the print in Ultimaker Digital Factory"),
- button_align=message.ActionButtonAlignment.ALIGN_RIGHT)
- df_url = f"https://digitalfactory.ultimaker.com/app/jobs/{self._cluster.cluster_id}?utm_source=cura&utm_medium=software&utm_campaign=message-printjob-sent"
- message.pyQtActionTriggered.connect(lambda message, action: (QDesktopServices.openUrl(QUrl(df_url)), message.hide()))
- message.show()
+ if response:
+ message = PrintJobUploadSuccessMessage()
+ message.addAction("monitor print",
+ name=I18N_CATALOG.i18nc("@action:button", "Monitor print"),
+ icon="",
+ description=I18N_CATALOG.i18nc("@action:tooltip", "Track the print in Ultimaker Digital Factory"),
+ button_align=message.ActionButtonAlignment.ALIGN_RIGHT)
+ df_url = f"https://digitalfactory.ultimaker.com/app/jobs/{self._cluster.cluster_id}?utm_source=cura&utm_medium=software&utm_campaign=message-printjob-sent"
+ message.pyQtActionTriggered.connect(lambda message, action: (QDesktopServices.openUrl(QUrl(df_url)), message.hide()))
+
+ message.show()
+ else:
+ PrintJobPendingApprovalMessage(self._cluster.cluster_id).show()
+
self.writeFinished.emit()
def _onPrintUploadSpecificError(self, reply: "QNetworkReply", _: "QNetworkReply.NetworkError"):
@@ -278,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))
@@ -336,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()
@@ -362,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
@@ -390,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..bdae34a860 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,65 @@ 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)
+ # Ensure that the abstract machine is added (either because it was never added, or it somehow got
+ # removed)
+ _abstract_machine = CuraStackBuilder.createAbstractMachine(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 +238,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 = ["
{} ({})
".format(device.name, device.printerTypeName) for device in new_devices[0:max_disp_devices]]
- device_name_list.append("
" + self.i18n_catalog.i18ncp("info:{0} gets replaced by a number of printers", "... and {0} other", "... and {0} others", num_hidden) + "
".format(device.name, device.printerTypeName) for device in new_devices_added])
- if new_devices_added:
- message_text = self.i18n_catalog.i18nc("info:status", "Printers added from Digital Factory:") + "
" + device_names + "
"
- message.setText(message_text)
- else:
- message.hide()
-
- def _updateOnlinePrinters(self, printer_responses: Dict[str, CloudClusterResponse]) -> None:
+ @staticmethod
+ def _updateOnlinePrinters(printer_responses: Dict[str, CloudClusterResponse]) -> None:
"""
Update the metadata of the printers to store whether they are online or not.
:param printer_responses: The responses received from the API about the printer statuses.
@@ -291,7 +283,8 @@ class CloudOutputDeviceManager:
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)
+
+ # Cleanup the remains 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():
@@ -321,56 +314,19 @@ class CloudOutputDeviceManager:
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",
- "A cloud connection is not available for a printer",
- "A cloud connection is not available for some printers",
- len(self.reported_device_ids)
- ),
- message_type = Message.MessageType.WARNING
- )
- device_names = "".join(["
{} ({})
".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",
- "This printer is not linked to the Digital Factory:",
- "These printers are not linked to the Digital Factory:",
- len(self.reported_device_ids)
- )
- message_text += "
{}
".format(device_names)
- digital_factory_string = self.i18n_catalog.i18nc("info:name", "Ultimaker Digital Factory")
-
- message_text += self.i18n_catalog.i18nc(
- "info:status",
- "To establish a connection, please visit the {website_link}".format(website_link = "{}.".format(digital_factory_string))
- )
- 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 cloud printers in Ultimaker Cura when not connected 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 cloud printer(s) which aren't 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:
+ global_stack: Optional[GlobalStack] = self._um_cloud_printers.get(device_id, None)
+ if not global_stack:
continue
if device_id in output_device_manager.getOutputDeviceIds():
output_device_manager.removeOutputDevice(device_id)
@@ -378,12 +334,19 @@ class CloudOutputDeviceManager:
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)
+ global_stack.setMetaDataEntry(META_UM_LINKED_TO_ACCOUNT, False)
+ # Generate message to show
+ device_names = "".join(["
{} ({})
".format(self._um_cloud_printers[device].name,
+ self._um_cloud_printers[device].definition.name) for device in
+ self.reported_device_ids])
+ self._removed_printers_message = RemovedPrintersMessage(self.reported_device_ids, device_names)
+ self._removed_printers_message.actionTriggered.connect(self._onRemovedPrintersMessageActionTriggered)
self._removed_printers_message.show()
def _onDiscoveredDeviceRemoved(self, device_id: str) -> None:
- device = self._remote_clusters.pop(device_id, None) # type: Optional[CloudOutputDevice]
+ """ Remove the CloudOutputDevices for printers that are offline"""
+ device: Optional[CloudOutputDevice] = self._remote_clusters.pop(device_id, None)
if not device:
return
device.close()
@@ -392,12 +355,12 @@ class CloudOutputDeviceManager:
output_device_manager.removeOutputDevice(device.key)
def _createMachineFromDiscoveredDevice(self, key: str, activate: bool = True) -> bool:
- device = self._remote_clusters[key]
+ device = self._remote_clusters.get(key)
if not device:
return False
# Create a new machine.
- # We do not use use MachineManager.addMachine here because we need to set the cluster ID before activating it.
+ # We do not use MachineManager.addMachine here because we need to set the cluster ID before activating it.
new_machine = CuraStackBuilder.createMachine(device.name, device.printerType, show_warning_message=False)
if not new_machine:
Logger.error(f"Failed creating a new machine for {device.name}")
@@ -405,8 +368,6 @@ class CloudOutputDeviceManager:
self._setOutputDeviceMetadata(device, new_machine)
- _abstract_machine = CuraStackBuilder.createAbstractMachine(device.printerType)
-
if activate:
CuraApplication.getInstance().getMachineManager().setActiveMachine(new_machine.getId())
@@ -414,15 +375,19 @@ class CloudOutputDeviceManager:
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
+ # Check if we should directly connect with a "normal" CloudOutputDevice or that we should connect to an
+ # 'abstract' one
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 list(self._remote_clusters.values()): # Make a copy of the remote devices list, to prevent modifying the list while iterating, if a device gets added asynchronously.
+
+ # Copy of the device list, to prevent modifying the list while iterating, if a device gets added asynchronously.
+ remote_cluster_copy: List[CloudOutputDevice] = list(self._remote_clusters.values())
+ for device in remote_cluster_copy:
if device.key == stored_cluster_id:
# Connect to it if the stored ID matches.
self._connectToOutputDevice(device, active_machine)
@@ -433,6 +398,14 @@ class CloudOutputDeviceManager:
# Remove device if it is not meant for the active machine.
output_device_manager.removeOutputDevice(device.key)
+ # Update state of all abstract output devices
+ remote_abstract_cluster_copy: List[CloudOutputDevice] = list(self._abstract_clusters.values())
+ for device in remote_abstract_cluster_copy:
+ if device.printerType == active_machine.definition.getId() and parseBool(active_machine.getMetaDataEntry("is_abstract_machine", False)):
+ self._connectToAbstractOutputDevice(device, active_machine)
+ elif device.key in output_device_manager.getOutputDeviceIds():
+ 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)
@@ -440,13 +413,24 @@ class CloudOutputDeviceManager:
machine.setMetaDataEntry("group_name", device.name)
machine.setMetaDataEntry("group_size", device.clusterSize)
digital_factory_string = self.i18n_catalog.i18nc("info:name", "Ultimaker Digital Factory")
- digital_factory_link = "{digital_factory_string}".format(digital_factory_string = digital_factory_string)
+ digital_factory_link = f"{digital_factory_string}"
removal_warning_string = self.i18n_catalog.i18nc("@message {printer_name} is replaced with the name of the printer", "{printer_name} will be removed until the next account sync.").format(printer_name = device.name) \
+ " " + self.i18n_catalog.i18nc("@message {printer_name} is replaced with the name of the printer", "To remove {printer_name} permanently, visit {digital_factory_link}").format(printer_name = device.name, digital_factory_link = digital_factory_link) \
+ "
" + self.i18n_catalog.i18nc("@message {printer_name} is replaced with the name of the printer", "Are you sure you want to remove {printer_name} temporarily?").format(printer_name = device.name)
machine.setMetaDataEntry("removal_warning", removal_warning_string)
machine.addConfiguredConnectionType(device.connectionType.value)
+ def _connectToAbstractOutputDevice(self, device: AbstractCloudOutputDevice, machine: GlobalStack) -> None:
+ Logger.debug(f"Attempting to connect to abstract machine {machine.id}")
+ if not device.isConnected():
+ device.connect()
+ machine.addConfiguredConnectionType(device.connectionType.value)
+
+ output_device_manager = CuraApplication.getInstance().getOutputDeviceManager()
+ if device.key not in output_device_manager.getOutputDeviceIds():
+ output_device_manager.addOutputDevice(device)
+
def _connectToOutputDevice(self, device: CloudOutputDevice, machine: GlobalStack) -> None:
"""Connects to an output device and makes sure it is registered in the output device manager."""
@@ -472,7 +456,7 @@ class CloudOutputDeviceManager:
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:
+ def _onRemovedPrintersMessageActionTriggered(self, removed_printers_message: RemovedPrintersMessage, action: str) -> None:
if action == "keep_printer_configurations_action":
removed_printers_message.hide()
elif action == "remove_printers_action":
@@ -483,12 +467,16 @@ class CloudOutputDeviceManager:
question_title = self.i18n_catalog.i18nc("@title:window", "Remove printers?")
question_content = self.i18n_catalog.i18ncp(
"@label",
- "You are about to remove {0} printer from Cura. This action cannot be undone.\nAre you sure you want to continue?",
- "You are about to remove {0} printers from Cura. This action cannot be undone.\nAre you sure you want to continue?",
+ "You are about to remove {0} printer from Cura. This action cannot be undone.\n"
+ "Are you sure you want to continue?",
+ "You are about to remove {0} printers from Cura. This action cannot be undone.\n"
+ "Are you sure you want to continue?",
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?")
+ question_content = self.i18n_catalog.i18nc("@label", "You are about to remove all printers from Cura. "
+ "This action cannot be undone.\n"
+ "Are you sure you want to continue?")
result = QMessageBox.question(None, question_title, question_content)
if result == QMessageBox.StandardButton.No:
return
diff --git a/plugins/UM3NetworkPrinting/src/Messages/NewPrinterDetectedMessage.py b/plugins/UM3NetworkPrinting/src/Messages/NewPrinterDetectedMessage.py
new file mode 100644
index 0000000000..d85ade9dce
--- /dev/null
+++ b/plugins/UM3NetworkPrinting/src/Messages/NewPrinterDetectedMessage.py
@@ -0,0 +1,60 @@
+# Copyright (c) 2022 Ultimaker B.V.
+# Cura is released under the terms of the LGPLv3 or higher.
+from UM import i18nCatalog
+from UM.Message import Message
+from cura.CuraApplication import CuraApplication
+
+
+class NewPrinterDetectedMessage(Message):
+ i18n_catalog = i18nCatalog("cura")
+
+ def __init__(self, num_printers_found: int) -> None:
+ super().__init__(title = self.i18n_catalog.i18ncp("info:status",
+ "New printer detected from your Ultimaker account",
+ "New printers detected from your Ultimaker account",
+ num_printers_found),
+ progress = 0,
+ lifetime = 0,
+ message_type = Message.MessageType.POSITIVE)
+ self._printers_added = 0
+ self._num_printers_found = num_printers_found
+
+ def updateProgressText(self, output_device):
+ """
+ While the progress of adding printers is running, update the text displayed.
+ :param output_device: The output device that is being added.
+ :return:
+ """
+ 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=output_device.name, model=output_device.printerTypeName)
+ self.setText(message_text)
+ if self._num_printers_found > 1:
+ self.setProgress((self._printers_added / self._num_printers_found) * 100)
+ self._printers_added += 1
+
+ CuraApplication.getInstance().processEvents()
+
+ def finalize(self, new_devices_added, new_output_devices):
+ self.setProgress(None)
+ num_devices_added = len(new_devices_added)
+ max_disp_devices = 3
+
+ if num_devices_added > max_disp_devices:
+ num_hidden = num_devices_added - max_disp_devices
+ device_name_list = ["
{} ({})
".format(device.name, device.printerTypeName) for device in
+ new_output_devices[0: max_disp_devices]]
+ device_name_list.append(
+ "
" + self.i18n_catalog.i18ncp("info:{0} gets replaced by a number of printers", "... and {0} other",
+ "... and {0} others", num_hidden) + "
".format(device.name, device.printerTypeName) for device in new_devices_added])
+
+ if new_devices_added:
+ message_text = self.i18n_catalog.i18nc("info:status",
+ "Printers added from Digital Factory:") + f"
{device_names}
"
+ self.setText(message_text)
+ else:
+ self.hide()
diff --git a/plugins/UM3NetworkPrinting/src/Messages/PrintJobAwaitingApprovalMessage.py b/plugins/UM3NetworkPrinting/src/Messages/PrintJobAwaitingApprovalMessage.py
new file mode 100644
index 0000000000..c60d596da9
--- /dev/null
+++ b/plugins/UM3NetworkPrinting/src/Messages/PrintJobAwaitingApprovalMessage.py
@@ -0,0 +1,38 @@
+# Copyright (c) 2022 Ultimaker B.V.
+# Cura is released under the terms of the LGPLv3 or higher.
+from PyQt6.QtCore import QUrl
+from PyQt6.QtGui import QDesktopServices
+
+from UM import i18nCatalog
+from UM.Message import Message
+
+
+I18N_CATALOG = i18nCatalog("cura")
+
+
+class PrintJobPendingApprovalMessage(Message):
+ """Message shown when waiting for approval on an uploaded print job."""
+
+ def __init__(self, cluster_id: str) -> None:
+ super().__init__(
+ text = I18N_CATALOG.i18nc("@info:status", "You will receive a confirmation via email when the print job is approved"),
+ title=I18N_CATALOG.i18nc("@info:title", "The print job was successfully submitted"),
+ message_type=Message.MessageType.POSITIVE
+ )
+ self.addAction("manage_print_jobs", I18N_CATALOG.i18nc("@action", "Manage print jobs"), "", "")
+
+ self.addAction("learn_more", I18N_CATALOG.i18nc("@action", "Learn more"), "", "",
+ button_style = Message.ActionButtonStyle.LINK,
+ button_align = Message.ActionButtonAlignment.ALIGN_LEFT)
+
+ self.actionTriggered.connect(self._onActionTriggered)
+
+ self.cluster_id = cluster_id
+
+ def _onActionTriggered(self, message: Message, action: str) -> None:
+ """ Callback function for the "Manage print jobs" button on the pending approval notification. """
+ match action:
+ case "manage_print_jobs":
+ QDesktopServices.openUrl(QUrl(f"https://digitalfactory.ultimaker.com/app/jobs/{self._cluster.cluster_id}?utm_source=cura&utm_medium=software&utm_campaign=message-printjob-sent"))
+ case "learn_more":
+ QDesktopServices.openUrl(QUrl("https://support.ultimaker.com/hc/en-us/articles/5329940078620?utm_source=cura&utm_medium=software&utm_campaign=message-printjob-sent"))
diff --git a/plugins/UM3NetworkPrinting/src/Messages/RemovedPrintersMessage.py b/plugins/UM3NetworkPrinting/src/Messages/RemovedPrintersMessage.py
new file mode 100644
index 0000000000..c875eb183a
--- /dev/null
+++ b/plugins/UM3NetworkPrinting/src/Messages/RemovedPrintersMessage.py
@@ -0,0 +1,52 @@
+# Copyright (c) 2022 Ultimaker B.V.
+# Cura is released under the terms of the LGPLv3 or higher.
+from UM import i18nCatalog
+from UM.Message import Message
+from cura.CuraApplication import CuraApplication
+
+
+class RemovedPrintersMessage(Message):
+ i18n_catalog = i18nCatalog("cura")
+
+ def __init__(self, removed_devices, device_names) -> None:
+ self._removed_devices = removed_devices
+
+ message_text = self.i18n_catalog.i18ncp(
+ "info:status",
+ "This printer is not linked to the Digital Factory:",
+ "These printers are not linked to the Digital Factory:",
+ len(self._removed_devices)
+ )
+ message_text += "