Merge pull request #20680 from Ultimaker/CURA-12557_handle-deactivated-things
Some checks failed
conan-package / conan-package (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
unit-test / Run unit tests (push) Has been cancelled

CURA-12557 handle deactivated things
This commit is contained in:
HellAholic 2025-06-30 15:00:07 +02:00 committed by GitHub
commit 4e04d06db0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 207 additions and 48 deletions

View file

@ -33,8 +33,8 @@ class AuthState(IntEnum):
class NetworkedPrinterOutputDevice(PrinterOutputDevice):
authenticationStateChanged = pyqtSignal()
def __init__(self, device_id, address: str, properties: Dict[bytes, bytes], connection_type: ConnectionType = ConnectionType.NetworkConnection, parent: QObject = None) -> None:
super().__init__(device_id = device_id, connection_type = connection_type, parent = parent)
def __init__(self, device_id, address: str, properties: Dict[bytes, bytes], connection_type: ConnectionType = ConnectionType.NetworkConnection, parent: QObject = None, active: bool = True) -> None:
super().__init__(device_id = device_id, connection_type = connection_type, parent = parent, active = active)
self._manager = None # type: Optional[QNetworkAccessManager]
self._timeout_time = 10 # After how many seconds of no response should a timeout occur?

View file

@ -72,7 +72,10 @@ class PrinterOutputDevice(QObject, OutputDevice):
# Signal to indicate that the configuration of one of the printers has changed.
uniqueConfigurationsChanged = pyqtSignal()
def __init__(self, device_id: str, connection_type: "ConnectionType" = ConnectionType.NotConnected, parent: QObject = None) -> None:
# Signal to indicate that the printer has become active or inactive
activeChanged = pyqtSignal()
def __init__(self, device_id: str, connection_type: "ConnectionType" = ConnectionType.NotConnected, parent: QObject = None, active: bool = True) -> None:
super().__init__(device_id = device_id, parent = parent) # type: ignore # MyPy complains with the multiple inheritance
self._printers = [] # type: List[PrinterOutputModel]
@ -88,6 +91,8 @@ class PrinterOutputDevice(QObject, OutputDevice):
self._accepts_commands = False # type: bool
self._active: bool = active
self._update_timer = QTimer() # type: QTimer
self._update_timer.setInterval(2000) # TODO; Add preference for update interval
self._update_timer.setSingleShot(False)
@ -295,3 +300,17 @@ class PrinterOutputDevice(QObject, OutputDevice):
return
self._firmware_updater.updateFirmware(firmware_file)
@pyqtProperty(bool, notify = activeChanged)
def active(self) -> bool:
"""
Indicates whether the printer is active, which is not the same as "being the active printer". In this case,
active means that the printer can be used. An example of an inactive printer is one that cannot be used because
the user doesn't have enough seats on Digital Factory.
"""
return self._active
def _setActive(self, active: bool) -> None:
if active != self._active:
self._active = active
self.activeChanged.emit()

View file

@ -183,10 +183,14 @@ class MachineManager(QObject):
self.setActiveMachine(active_machine_id)
def _onOutputDevicesChanged(self) -> None:
for printer_output_device in self._printer_output_devices:
printer_output_device.activeChanged.disconnect(self.printerConnectedStatusChanged)
self._printer_output_devices = []
for printer_output_device in self._application.getOutputDeviceManager().getOutputDevices():
if isinstance(printer_output_device, PrinterOutputDevice):
self._printer_output_devices.append(printer_output_device)
printer_output_device.activeChanged.connect(self.printerConnectedStatusChanged)
self.outputDevicesChanged.emit()
@ -569,6 +573,13 @@ class MachineManager(QObject):
def activeMachineIsUsingCloudConnection(self) -> bool:
return self.activeMachineHasCloudConnection and not self.activeMachineHasNetworkConnection
@pyqtProperty(bool, notify = printerConnectedStatusChanged)
def activeMachineIsActive(self) -> bool:
if not self._printer_output_devices:
return True
return self._printer_output_devices[0].active
def activeMachineNetworkKey(self) -> str:
if self._global_container_stack:
return self._global_container_stack.getMetaDataEntry("um_network_key", "")

View file

@ -11,10 +11,10 @@ Cura.RoundedRectangle
width: parent.width
height: projectImage.height + 2 * UM.Theme.getSize("default_margin").width
cornerSide: Cura.RoundedRectangle.Direction.All
border.color: UM.Theme.getColor("lining")
border.color: enabled ? UM.Theme.getColor("lining") : UM.Theme.getColor("action_button_disabled_border")
border.width: UM.Theme.getSize("default_lining").width
radius: UM.Theme.getSize("default_radius").width
color: UM.Theme.getColor("main_background")
color: getBackgroundColor()
signal clicked()
property alias imageSource: projectImage.source
property alias projectNameText: displayNameLabel.text
@ -22,17 +22,18 @@ Cura.RoundedRectangle
property alias projectLastUpdatedText: lastUpdatedLabel.text
property alias cardMouseAreaEnabled: cardMouseArea.enabled
onVisibleChanged: color = UM.Theme.getColor("main_background")
onVisibleChanged: color = getBackgroundColor()
MouseArea
{
id: cardMouseArea
anchors.fill: parent
hoverEnabled: true
onEntered: base.color = UM.Theme.getColor("action_button_hovered")
onExited: base.color = UM.Theme.getColor("main_background")
hoverEnabled: base.enabled
onEntered: color = getBackgroundColor()
onExited: color = getBackgroundColor()
onClicked: base.clicked()
}
Row
{
id: projectInformationRow
@ -73,7 +74,7 @@ Cura.RoundedRectangle
width: parent.width
height: Math.round(parent.height / 3)
elide: Text.ElideRight
color: UM.Theme.getColor("small_button_text")
color: base.enabled ? UM.Theme.getColor("small_button_text") : UM.Theme.getColor("text_disabled")
}
UM.Label
@ -82,8 +83,27 @@ Cura.RoundedRectangle
width: parent.width
height: Math.round(parent.height / 3)
elide: Text.ElideRight
color: UM.Theme.getColor("small_button_text")
color: base.enabled ? UM.Theme.getColor("small_button_text") : UM.Theme.getColor("text_disabled")
}
}
}
function getBackgroundColor()
{
if(enabled)
{
if(cardMouseArea.containsMouse)
{
return UM.Theme.getColor("action_button_hovered")
}
else
{
return UM.Theme.getColor("main_background")
}
}
else
{
return UM.Theme.getColor("action_button_disabled")
}
}
}

View file

@ -159,20 +159,33 @@ Item
Repeater
{
model: manager.digitalFactoryProjectModel
delegate: ProjectSummaryCard
delegate: Item
{
width: parent.width
height: projectSummaryCard.height
UM.TooltipArea
{
anchors.fill: parent
text: "This project is inactive and cannot be used."
enabled: !model.active
}
ProjectSummaryCard
{
id: projectSummaryCard
imageSource: model.thumbnailUrl || "../images/placeholder.svg"
projectNameText: model.displayName
projectUsernameText: model.username
projectLastUpdatedText: "Last updated: " + model.lastUpdated
enabled: model.active
onClicked:
{
onClicked: {
manager.selectedProjectIndex = index
}
}
}
}
LoadMoreProjectsCard
{

View file

@ -17,6 +17,7 @@ class DigitalFactoryProjectModel(ListModel):
ThumbnailUrlRole = Qt.ItemDataRole.UserRole + 5
UsernameRole = Qt.ItemDataRole.UserRole + 6
LastUpdatedRole = Qt.ItemDataRole.UserRole + 7
ActiveRole = Qt.ItemDataRole.UserRole + 8
dfProjectModelChanged = pyqtSignal()
@ -28,6 +29,7 @@ class DigitalFactoryProjectModel(ListModel):
self.addRoleName(self.ThumbnailUrlRole, "thumbnailUrl")
self.addRoleName(self.UsernameRole, "username")
self.addRoleName(self.LastUpdatedRole, "lastUpdated")
self.addRoleName(self.ActiveRole, "active")
self._projects = [] # type: List[DigitalFactoryProjectResponse]
def setProjects(self, df_projects: List[DigitalFactoryProjectResponse]) -> None:
@ -59,5 +61,6 @@ class DigitalFactoryProjectModel(ListModel):
"thumbnailUrl": project.thumbnail_url,
"username": project.username,
"lastUpdated": project.last_updated.strftime(PROJECT_UPDATED_AT_DATETIME_FORMAT) if project.last_updated else "",
"active": project.active,
})
self.dfProjectModelChanged.emit()

View file

@ -28,6 +28,7 @@ class DigitalFactoryProjectResponse(BaseModel):
team_ids: Optional[List[str]] = None,
status: Optional[str] = None,
technical_requirements: Optional[Dict[str, Any]] = None,
is_inactive: bool = False,
**kwargs) -> None:
"""
Creates a new digital factory project response object
@ -56,6 +57,7 @@ class DigitalFactoryProjectResponse(BaseModel):
self.last_updated = datetime.strptime(last_updated, DIGITAL_FACTORY_RESPONSE_DATETIME_FORMAT) if last_updated else None
self.status = status
self.technical_requirements = technical_requirements
self.active = not is_inactive
super().__init__(**kwargs)
def __str__(self) -> str:

View file

@ -163,7 +163,7 @@ class CloudApiClient:
scope=self._scope,
data=b"",
callback=self._parseCallback(on_finished, CloudPrintResponse),
error_callback=on_error,
error_callback=self._parseError(on_error),
timeout=self.DEFAULT_REQUEST_TIMEOUT)
def doPrintJobAction(self, cluster_id: str, cluster_job_id: str, action: str,
@ -256,7 +256,6 @@ class CloudApiClient:
"""Creates a callback function so that it includes the parsing of the response into the correct model.
The callback is added to the 'finished' signal of the reply.
:param reply: The reply that should be listened to.
:param on_finished: The callback in case the response is successful. Depending on the endpoint it will be either
a list or a single item.
:param model: The type of the model to convert the response to.
@ -281,6 +280,25 @@ class CloudApiClient:
self._anti_gc_callbacks.append(parse)
return parse
def _parseError(self,
on_error: Callable[[CloudError, "QNetworkReply.NetworkError", int], None]) -> Callable[[QNetworkReply, "QNetworkReply.NetworkError"], None]:
"""Creates a callback function so that it includes the parsing of an explicit error response into the correct model.
:param on_error: The callback in case the response gives an explicit error
"""
def parse(reply: QNetworkReply, error: "QNetworkReply.NetworkError") -> None:
self._anti_gc_callbacks.remove(parse)
http_code, response = self._parseReply(reply)
result = CloudError(**response["errors"][0])
on_error(result, error, http_code)
self._anti_gc_callbacks.append(parse)
return parse
@classmethod
def getMachineIDMap(cls) -> Dict[str, str]:
if cls._machine_id_to_name is None:

View file

@ -27,9 +27,11 @@ from ..UltimakerNetworkedPrinterOutputDevice import UltimakerNetworkedPrinterOut
from ..Messages.PrintJobUploadBlockedMessage import PrintJobUploadBlockedMessage
from ..Messages.PrintJobUploadErrorMessage import PrintJobUploadErrorMessage
from ..Messages.PrintJobUploadQueueFullMessage import PrintJobUploadQueueFullMessage
from ..Messages.PrintJobUploadPrinterInactiveMessage import PrintJobUploadPrinterInactiveMessage
from ..Messages.PrintJobUploadSuccessMessage import PrintJobUploadSuccessMessage
from ..Models.Http.CloudClusterResponse import CloudClusterResponse
from ..Models.Http.CloudClusterStatus import CloudClusterStatus
from ..Models.Http.CloudError import CloudError
from ..Models.Http.CloudPrintJobUploadRequest import CloudPrintJobUploadRequest
from ..Models.Http.CloudPrintResponse import CloudPrintResponse
from ..Models.Http.CloudPrintJobResponse import CloudPrintJobResponse
@ -87,7 +89,8 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice):
address="",
connection_type=ConnectionType.CloudConnection,
properties=properties,
parent=parent
parent=parent,
active=cluster.display_status != "inactive"
)
self._api = api_client
@ -190,6 +193,8 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice):
self._received_print_jobs = status.print_jobs
self._updatePrintJobs(status.print_jobs)
self._setActive(status.active)
def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mimetypes: bool = False,
file_handler: Optional[FileHandler] = None, filter_by_machine: bool = False, **kwargs) -> None:
@ -291,19 +296,21 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice):
self.writeFinished.emit()
def _onPrintUploadSpecificError(self, reply: "QNetworkReply", _: "QNetworkReply.NetworkError"):
def _onPrintUploadSpecificError(self, error: CloudError, _: "QNetworkReply.NetworkError", http_error: int):
"""
Displays a message when an error occurs specific to uploading print job (i.e. queue is full).
"""
error_code = reply.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute)
if error_code == 409:
if http_error == 409:
if error.code == "printerInactive":
PrintJobUploadPrinterInactiveMessage().show()
else:
PrintJobUploadQueueFullMessage().show()
else:
PrintJobUploadErrorMessage(I18N_CATALOG.i18nc("@error:send",
"Unknown error code when uploading print job: {0}",
error_code)).show()
http_error)).show()
Logger.log("w", "Upload of print job failed specifically with error code {}".format(error_code))
Logger.log("w", "Upload of print job failed specifically with error code {}".format(http_error))
self._progress.hide()
self._pre_upload_print_job = None

View file

@ -0,0 +1,20 @@
# Copyright (c) 2020 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from UM import i18nCatalog
from UM.Message import Message
I18N_CATALOG = i18nCatalog("cura")
class PrintJobUploadPrinterInactiveMessage(Message):
"""Message shown when uploading a print job to a cluster and the printer is inactive."""
def __init__(self) -> None:
super().__init__(
text = I18N_CATALOG.i18nc("@info:status", "The printer is inactive and cannot accept a new print job."),
title = I18N_CATALOG.i18nc("@info:title", "Printer inactive"),
lifetime = 10,
message_type=Message.MessageType.ERROR
)

View file

@ -10,7 +10,7 @@ class CloudClusterResponse(BaseModel):
"""Class representing a cloud connected cluster."""
def __init__(self, cluster_id: str, host_guid: str, host_name: str, is_online: bool, status: str,
host_internal_ip: Optional[str] = None, host_version: Optional[str] = None,
display_status: str, host_internal_ip: Optional[str] = None, host_version: Optional[str] = None,
friendly_name: Optional[str] = None, printer_type: str = "ultimaker3", printer_count: int = 1,
capabilities: Optional[List[str]] = None, **kwargs) -> None:
"""Creates a new cluster response object.
@ -20,6 +20,7 @@ class CloudClusterResponse(BaseModel):
:param host_name: The name of the printer as configured during the Wi-Fi setup. Used as identifier for end users.
:param is_online: Whether this cluster is currently connected to the cloud.
:param status: The status of the cluster authentication (active or inactive).
:param display_status: The display status of the cluster.
:param host_version: The firmware version of the cluster host. This is where the Stardust client is running on.
:param host_internal_ip: The internal IP address of the host printer.
:param friendly_name: The human readable name of the host printer.
@ -31,6 +32,7 @@ class CloudClusterResponse(BaseModel):
self.host_guid = host_guid
self.host_name = host_name
self.status = status
self.display_status = display_status
self.is_online = is_online
self.host_version = host_version
self.host_internal_ip = host_internal_ip
@ -51,5 +53,5 @@ class CloudClusterResponse(BaseModel):
Convenience function for printing when debugging.
:return: A human-readable representation of the data in this object.
"""
return str({k: v for k, v in self.__dict__.items() if k in {"cluster_id", "host_guid", "host_name", "status", "is_online", "host_version", "host_internal_ip", "friendly_name", "printer_type", "printer_count", "capabilities"}})
return str({k: v for k, v in self.__dict__.items() if k in {"cluster_id", "host_guid", "host_name", "status", "display_status", "is_online", "host_version", "host_internal_ip", "friendly_name", "printer_type", "printer_count", "capabilities"}})

View file

@ -14,6 +14,7 @@ class CloudClusterStatus(BaseModel):
def __init__(self, printers: List[Union[ClusterPrinterStatus, Dict[str, Any]]],
print_jobs: List[Union[ClusterPrintJobStatus, Dict[str, Any]]],
generated_time: Union[str, datetime],
unavailable: bool = False,
**kwargs) -> None:
"""Creates a new cluster status model object.
@ -23,6 +24,7 @@ class CloudClusterStatus(BaseModel):
"""
self.generated_time = self.parseDate(generated_time)
self.active = not unavailable
self.printers = self.parseModels(ClusterPrinterStatus, printers)
self.print_jobs = self.parseModels(ClusterPrintJobStatus, print_jobs)
super().__init__(**kwargs)

View file

@ -20,13 +20,23 @@ from ..BaseModel import BaseModel
class ClusterPrinterStatus(BaseModel):
"""Class representing a cluster printer"""
def __init__(self, enabled: bool, firmware_version: str, friendly_name: str, ip_address: str, machine_variant: str,
status: str, unique_name: str, uuid: str,
configuration: List[Union[Dict[str, Any], ClusterPrintCoreConfiguration]],
reserved_by: Optional[str] = None, maintenance_required: Optional[bool] = None,
firmware_update_status: Optional[str] = None, latest_available_firmware: Optional[str] = None,
build_plate: Union[Dict[str, Any], ClusterBuildPlate] = None,
material_station: Union[Dict[str, Any], ClusterPrinterMaterialStation] = None, **kwargs) -> None:
def __init__(self,
enabled: Optional[bool] = True,
friendly_name: Optional[str] = "",
machine_variant: Optional[str] = "",
status: Optional[str] = "unknown",
unique_name: Optional[str] = "",
uuid: Optional[str] = "",
configuration: Optional[List[Union[Dict[str, Any], ClusterPrintCoreConfiguration]]] = None,
firmware_version: Optional[str] = None,
ip_address: Optional[str] = None,
reserved_by: Optional[str] = "",
maintenance_required: Optional[bool] = False,
firmware_update_status: Optional[str] = "",
latest_available_firmware: Optional[str] = "",
build_plate: Optional[Union[Dict[str, Any], ClusterBuildPlate]] = None,
material_station: Optional[Union[Dict[str, Any], ClusterPrinterMaterialStation]] = None,
**kwargs) -> None:
"""
Creates a new cluster printer status
:param enabled: A printer can be disabled if it should not receive new jobs. By default, every printer is enabled.
@ -47,7 +57,7 @@ class ClusterPrinterStatus(BaseModel):
:param material_station: The material station that is on the printer.
"""
self.configuration = self.parseModels(ClusterPrintCoreConfiguration, configuration)
self.configuration = self.parseModels(ClusterPrintCoreConfiguration, configuration) if configuration else []
self.enabled = enabled
self.firmware_version = firmware_version
self.friendly_name = friendly_name
@ -70,7 +80,7 @@ class ClusterPrinterStatus(BaseModel):
:param controller: - The controller of the model.
"""
model = PrinterOutputModel(controller, len(self.configuration), firmware_version = self.firmware_version)
model = PrinterOutputModel(controller, len(self.configuration), firmware_version = self.firmware_version or "")
self.updateOutputModel(model)
return model
@ -86,6 +96,7 @@ class ClusterPrinterStatus(BaseModel):
model.updateType(self.machine_variant)
model.updateState(self.status if self.enabled else "disabled")
model.updateBuildplate(self.build_plate.type if self.build_plate else "glass")
if self.ip_address:
model.setCameraUrl(QUrl("http://{}:8080/?action=stream".format(self.ip_address)))
if not model.printerConfiguration:

View file

@ -46,10 +46,10 @@ class UltimakerNetworkedPrinterOutputDevice(NetworkedPrinterOutputDevice):
QUEUED_PRINT_JOBS_STATES = {"queued", "error"}
def __init__(self, device_id: str, address: str, properties: Dict[bytes, bytes], connection_type: ConnectionType,
parent=None) -> None:
parent=None, active: bool = True) -> None:
super().__init__(device_id=device_id, address=address, properties=properties, connection_type=connection_type,
parent=parent)
parent=parent, active=active)
# Trigger the printersChanged signal when the private signal is triggered.
self.printersChanged.connect(self._clusterPrintersChanged)

View file

@ -16,6 +16,7 @@ Cura.ExpandablePopup
property bool isConnectedCloudPrinter: machineManager.activeMachineHasCloudConnection
property bool isCloudRegistered: machineManager.activeMachineHasCloudRegistration
property bool isGroup: machineManager.activeMachineIsGroup
property bool isActive: machineManager.activeMachineIsActive
property string machineName: {
if (isNetworkPrinter && machineManager.activeMachineNetworkGroupName != "")
{
@ -39,9 +40,16 @@ Cura.ExpandablePopup
return "printer_connected"
}
else if (isConnectedCloudPrinter && Cura.API.connectionStatus.isInternetReachable)
{
if (isActive)
{
return "printer_cloud_connected"
}
else
{
return "printer_cloud_inactive"
}
}
else if (isCloudRegistered)
{
return "printer_cloud_not_available"
@ -53,7 +61,7 @@ Cura.ExpandablePopup
}
function getConnectionStatusMessage() {
if (connectionStatus == "printer_cloud_not_available")
if (connectionStatus === "printer_cloud_not_available")
{
if(Cura.API.connectionStatus.isInternetReachable)
{
@ -78,6 +86,10 @@ Cura.ExpandablePopup
return catalog.i18nc("@status", "The cloud connection is currently unavailable. Please check your internet connection.")
}
}
else if(connectionStatus === "printer_cloud_inactive")
{
return catalog.i18nc("@status", "This printer is deactivated and can not accept commands or jobs.")
}
else
{
return ""
@ -130,14 +142,18 @@ Cura.ExpandablePopup
source:
{
if (connectionStatus == "printer_connected")
if (connectionStatus === "printer_connected")
{
return UM.Theme.getIcon("CheckBlueBG", "low")
}
else if (connectionStatus == "printer_cloud_connected" || connectionStatus == "printer_cloud_not_available")
else if (connectionStatus === "printer_cloud_connected" || connectionStatus === "printer_cloud_not_available")
{
return UM.Theme.getIcon("CloudBadge", "low")
}
else if (connectionStatus === "printer_cloud_inactive")
{
return UM.Theme.getIcon("WarningBadge", "low")
}
else
{
return ""
@ -147,7 +163,21 @@ Cura.ExpandablePopup
width: UM.Theme.getSize("printer_status_icon").width
height: UM.Theme.getSize("printer_status_icon").height
color: connectionStatus == "printer_cloud_not_available" ? UM.Theme.getColor("cloud_unavailable") : UM.Theme.getColor("primary")
color:
{
if (connectionStatus === "printer_cloud_not_available")
{
return UM.Theme.getColor("cloud_unavailable")
}
else if(connectionStatus === "printer_cloud_inactive")
{
return UM.Theme.getColor("cloud_inactive")
}
else
{
return UM.Theme.getColor("primary")
}
}
visible: (isNetworkPrinter || isCloudRegistered) && source != ""

View file

@ -498,6 +498,7 @@
"monitor_carousel_dot_current": [119, 119, 119, 255],
"cloud_unavailable": [153, 153, 153, 255],
"cloud_inactive": [253, 209, 58, 255],
"connection_badge_background": [255, 255, 255, 255],
"warning_badge_background": [0, 0, 0, 255],
"error_badge_background": [255, 255, 255, 255],