mirror of
https://github.com/Ultimaker/Cura.git
synced 2025-07-24 15:13:56 -06:00

This makes the cluster size also available when the machine is offline. Also fixes an issue where the cluster size is improperly restored once the internet connection comes back online, resulting in the printer showing as a single printer until next sync CURA-7347
309 lines
14 KiB
Python
309 lines
14 KiB
Python
# Copyright (c) 2019 Ultimaker B.V.
|
|
# Cura is released under the terms of the LGPLv3 or higher.
|
|
from time import time
|
|
import os
|
|
from typing import List, Optional, cast
|
|
|
|
from PyQt5.QtCore import QObject, QUrl, pyqtProperty, pyqtSignal, pyqtSlot
|
|
from PyQt5.QtGui import QDesktopServices
|
|
|
|
from UM import i18nCatalog
|
|
from UM.Backend.Backend import BackendState
|
|
from UM.FileHandler.FileHandler import FileHandler
|
|
from UM.Logger import Logger
|
|
from UM.Scene.SceneNode import SceneNode
|
|
from UM.Version import Version
|
|
from cura.CuraApplication import CuraApplication
|
|
from cura.PrinterOutput.NetworkedPrinterOutputDevice import AuthState
|
|
from cura.PrinterOutput.PrinterOutputDevice import ConnectionType
|
|
|
|
from .CloudApiClient import CloudApiClient
|
|
from ..ExportFileJob import ExportFileJob
|
|
from ..UltimakerNetworkedPrinterOutputDevice import UltimakerNetworkedPrinterOutputDevice
|
|
from ..Messages.PrintJobUploadBlockedMessage import PrintJobUploadBlockedMessage
|
|
from ..Messages.PrintJobUploadErrorMessage import PrintJobUploadErrorMessage
|
|
from ..Messages.PrintJobUploadSuccessMessage import PrintJobUploadSuccessMessage
|
|
from ..Models.Http.CloudClusterResponse import CloudClusterResponse
|
|
from ..Models.Http.CloudClusterStatus import CloudClusterStatus
|
|
from ..Models.Http.CloudPrintJobUploadRequest import CloudPrintJobUploadRequest
|
|
from ..Models.Http.CloudPrintResponse import CloudPrintResponse
|
|
from ..Models.Http.CloudPrintJobResponse import CloudPrintJobResponse
|
|
from ..Models.Http.ClusterPrinterStatus import ClusterPrinterStatus
|
|
from ..Models.Http.ClusterPrintJobStatus import ClusterPrintJobStatus
|
|
|
|
|
|
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.
|
|
As such, those methods have been implemented here.
|
|
Note that this device represents a single remote cluster, not a list of multiple clusters.
|
|
"""
|
|
|
|
# The interval with which the remote cluster is checked.
|
|
# We can do this relatively often as this API call is quite fast.
|
|
CHECK_CLUSTER_INTERVAL = 10.0 # seconds
|
|
|
|
# Override the network response timeout in seconds after which we consider the device offline.
|
|
# For cloud this needs to be higher because the interval at which we check the status is higher as well.
|
|
NETWORK_RESPONSE_CONSIDER_OFFLINE = 15.0 # seconds
|
|
|
|
# The minimum version of firmware that support print job actions over cloud.
|
|
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.
|
|
_cloudClusterPrintersChanged = pyqtSignal()
|
|
|
|
def __init__(self, api_client: CloudApiClient, cluster: CloudClusterResponse, parent: QObject = None) -> None:
|
|
"""Creates a new cloud output device
|
|
|
|
:param api_client: The client that will run the API calls
|
|
:param cluster: The device response received from the cloud API.
|
|
:param parent: The optional parent of this output device.
|
|
"""
|
|
|
|
# The following properties are expected on each networked output device.
|
|
# Because the cloud connection does not off all of these, we manually construct this version here.
|
|
# An example of why this is needed is the selection of the compatible file type when exporting the tool path.
|
|
properties = {
|
|
b"address": cluster.host_internal_ip.encode() if cluster.host_internal_ip else b"",
|
|
b"name": cluster.friendly_name.encode() if cluster.friendly_name else b"",
|
|
b"firmware_version": cluster.host_version.encode() if cluster.host_version else b"",
|
|
b"printer_type": cluster.printer_type.encode() if cluster.printer_type else b"",
|
|
b"cluster_size": str(cluster.printer_count).encode() if cluster.printer_count else b"1"
|
|
}
|
|
|
|
super().__init__(
|
|
device_id=cluster.cluster_id,
|
|
address="",
|
|
connection_type=ConnectionType.CloudConnection,
|
|
properties=properties,
|
|
parent=parent
|
|
)
|
|
|
|
self._api = api_client
|
|
self._account = api_client.account
|
|
self._cluster = cluster
|
|
self.setAuthenticationState(AuthState.NotAuthenticated)
|
|
self._setInterfaceElements()
|
|
|
|
# Trigger the printersChanged signal when the private signal is triggered.
|
|
self.printersChanged.connect(self._cloudClusterPrintersChanged)
|
|
|
|
# Keep server string of the last generated time to avoid updating models more than once for the same response
|
|
self._received_printers = None # type: Optional[List[ClusterPrinterStatus]]
|
|
self._received_print_jobs = None # type: Optional[List[ClusterPrintJobStatus]]
|
|
|
|
# Reference to the uploaded print job / mesh
|
|
# We do this to prevent re-uploading the same file multiple times.
|
|
self._tool_path = None # type: Optional[bytes]
|
|
self._uploaded_print_job = None # type: Optional[CloudPrintJobResponse]
|
|
|
|
def connect(self) -> None:
|
|
"""Connects this device."""
|
|
|
|
if self.isConnected():
|
|
return
|
|
super().connect()
|
|
Logger.log("i", "Connected to cluster %s", self.key)
|
|
CuraApplication.getInstance().getBackend().backendStateChange.connect(self._onBackendStateChange)
|
|
self._update()
|
|
|
|
def disconnect(self) -> None:
|
|
"""Disconnects the device"""
|
|
|
|
if not self.isConnected():
|
|
return
|
|
super().disconnect()
|
|
Logger.log("i", "Disconnected from cluster %s", self.key)
|
|
CuraApplication.getInstance().getBackend().backendStateChange.disconnect(self._onBackendStateChange)
|
|
|
|
def _onBackendStateChange(self, _: BackendState) -> None:
|
|
"""Resets the print job that was uploaded to force a new upload, runs whenever the user re-slices."""
|
|
|
|
self._tool_path = None
|
|
self._uploaded_print_job = None
|
|
|
|
def matchesNetworkKey(self, network_key: str) -> bool:
|
|
"""Checks whether the given network key is found in the cloud's host name"""
|
|
|
|
# Typically, a network key looks like "ultimakersystem-aabbccdd0011._ultimaker._tcp.local."
|
|
# the host name should then be "ultimakersystem-aabbccdd0011"
|
|
if network_key.startswith(str(self.clusterData.host_name or "")):
|
|
return True
|
|
# However, for manually added printers, the local IP address is used in lieu of a proper
|
|
# network key, so check for that as well. It is in the format "manual:10.1.10.1".
|
|
if network_key.endswith(str(self.clusterData.host_internal_ip or "")):
|
|
return True
|
|
return False
|
|
|
|
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 _update(self) -> None:
|
|
"""Called when the network data should be updated."""
|
|
|
|
super()._update()
|
|
if time() - self._time_of_last_request < self.CHECK_CLUSTER_INTERVAL:
|
|
return # avoid calling the cloud too often
|
|
self._time_of_last_request = time()
|
|
if self._account.isLoggedIn:
|
|
self.setAuthenticationState(AuthState.Authenticated)
|
|
self._last_request_time = time()
|
|
self._api.getClusterStatus(self.key, self._onStatusCallFinished)
|
|
else:
|
|
self.setAuthenticationState(AuthState.NotAuthenticated)
|
|
|
|
def _onStatusCallFinished(self, status: CloudClusterStatus) -> None:
|
|
"""Method called when HTTP request to status endpoint is finished.
|
|
|
|
Contains both printers and print jobs statuses in a single response.
|
|
"""
|
|
self._responseReceived()
|
|
if status.printers != self._received_printers:
|
|
self._received_printers = status.printers
|
|
self._updatePrinters(status.printers)
|
|
if status.print_jobs != self._received_print_jobs:
|
|
self._received_print_jobs = status.print_jobs
|
|
self._updatePrintJobs(status.print_jobs)
|
|
|
|
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:
|
|
|
|
"""Called when Cura requests an output device to receive a (G-code) file."""
|
|
|
|
# Show an error message if we're already sending a job.
|
|
if self._progress.visible:
|
|
PrintJobUploadBlockedMessage().show()
|
|
return
|
|
|
|
# Indicate we have started sending a job.
|
|
self.writeStarted.emit(self)
|
|
|
|
# The mesh didn't change, let's not upload it to the cloud again.
|
|
# Note that self.writeFinished is called in _onPrintUploadCompleted as well.
|
|
if self._uploaded_print_job:
|
|
self._api.requestPrint(self.key, self._uploaded_print_job.job_id, self._onPrintUploadCompleted)
|
|
return
|
|
|
|
# Export the scene to the correct file type.
|
|
job = ExportFileJob(file_handler=file_handler, nodes=nodes, firmware_version=self.firmwareVersion)
|
|
job.finished.connect(self._onPrintJobCreated)
|
|
job.start()
|
|
|
|
def _onPrintJobCreated(self, job: ExportFileJob) -> None:
|
|
"""Handler for when the print job was created locally.
|
|
|
|
It can now be sent over the cloud.
|
|
"""
|
|
output = job.getOutput()
|
|
self._tool_path = output # store the tool path to prevent re-uploading when printing the same file again
|
|
file_name = job.getFileName()
|
|
request = CloudPrintJobUploadRequest(
|
|
job_name=os.path.splitext(file_name)[0],
|
|
file_size=len(output),
|
|
content_type=job.getMimeType(),
|
|
)
|
|
self._api.requestUpload(request, self._uploadPrintJob)
|
|
|
|
def _uploadPrintJob(self, job_response: CloudPrintJobResponse) -> None:
|
|
"""Uploads the mesh when the print job was registered with the cloud API.
|
|
|
|
:param job_response: The response received from the cloud API.
|
|
"""
|
|
if not self._tool_path:
|
|
return self._onUploadError()
|
|
self._progress.show()
|
|
self._uploaded_print_job = job_response # store the last uploaded job to prevent re-upload of the same file
|
|
self._api.uploadToolPath(job_response, self._tool_path, self._onPrintJobUploaded, self._progress.update,
|
|
self._onUploadError)
|
|
|
|
def _onPrintJobUploaded(self) -> None:
|
|
"""Requests the print to be sent to the printer when we finished uploading the mesh."""
|
|
|
|
self._progress.update(100)
|
|
print_job = cast(CloudPrintJobResponse, self._uploaded_print_job)
|
|
self._api.requestPrint(self.key, print_job.job_id, self._onPrintUploadCompleted)
|
|
|
|
def _onPrintUploadCompleted(self, response: CloudPrintResponse) -> None:
|
|
"""Shows a message when the upload has succeeded
|
|
|
|
:param response: The response from the cloud API.
|
|
"""
|
|
self._progress.hide()
|
|
PrintJobUploadSuccessMessage().show()
|
|
self.writeFinished.emit()
|
|
|
|
def _onUploadError(self, message: str = None) -> None:
|
|
"""Displays the given message if uploading the mesh has failed
|
|
|
|
:param message: The message to display.
|
|
"""
|
|
self._progress.hide()
|
|
self._uploaded_print_job = None
|
|
PrintJobUploadErrorMessage(message).show()
|
|
self.writeError.emit()
|
|
|
|
@pyqtProperty(bool, notify=_cloudClusterPrintersChanged)
|
|
def supportsPrintJobActions(self) -> bool:
|
|
"""Whether the printer that this output device represents supports print job actions via the cloud."""
|
|
|
|
if not self._printers:
|
|
return False
|
|
version_number = self.printers[0].firmwareVersion.split(".")
|
|
firmware_version = Version([version_number[0], version_number[1], version_number[2]])
|
|
return firmware_version >= self.PRINT_JOB_ACTIONS_MIN_VERSION
|
|
|
|
def setJobState(self, print_job_uuid: str, state: str) -> None:
|
|
"""Set the remote print job state."""
|
|
|
|
self._api.doPrintJobAction(self._cluster.cluster_id, print_job_uuid, state)
|
|
|
|
@pyqtSlot(str, name="sendJobToTop")
|
|
def sendJobToTop(self, print_job_uuid: str) -> None:
|
|
self._api.doPrintJobAction(self._cluster.cluster_id, print_job_uuid, "move",
|
|
{"list": "queued", "to_position": 0})
|
|
|
|
@pyqtSlot(str, name="deleteJobFromQueue")
|
|
def deleteJobFromQueue(self, print_job_uuid: str) -> None:
|
|
self._api.doPrintJobAction(self._cluster.cluster_id, print_job_uuid, "remove")
|
|
|
|
@pyqtSlot(str, name="forceSendJob")
|
|
def forceSendJob(self, print_job_uuid: str) -> None:
|
|
self._api.doPrintJobAction(self._cluster.cluster_id, print_job_uuid, "force")
|
|
|
|
@pyqtSlot(name="openPrintJobControlPanel")
|
|
def openPrintJobControlPanel(self) -> None:
|
|
QDesktopServices.openUrl(QUrl(self.clusterCloudUrl))
|
|
|
|
@pyqtSlot(name="openPrinterControlPanel")
|
|
def openPrinterControlPanel(self) -> None:
|
|
QDesktopServices.openUrl(QUrl(self.clusterCloudUrl))
|
|
|
|
@property
|
|
def clusterData(self) -> CloudClusterResponse:
|
|
"""Gets the cluster response from which this device was created."""
|
|
|
|
return self._cluster
|
|
|
|
@clusterData.setter
|
|
def clusterData(self, value: CloudClusterResponse) -> None:
|
|
"""Updates the cluster data from the cloud."""
|
|
|
|
self._cluster = value
|
|
|
|
@property
|
|
def clusterCloudUrl(self) -> str:
|
|
"""Gets the URL on which to monitor the cluster via the cloud."""
|
|
|
|
root_url_prefix = "-staging" if self._account.is_staging else ""
|
|
return "https://mycloud{}.ultimaker.com/app/jobs/{}".format(root_url_prefix, self.clusterData.cluster_id)
|