diff --git a/plugins/UM3NetworkPrinting/ClusterControlItem.qml b/plugins/UM3NetworkPrinting/ClusterControlItem.qml
new file mode 100644
index 0000000000..6558720943
--- /dev/null
+++ b/plugins/UM3NetworkPrinting/ClusterControlItem.qml
@@ -0,0 +1,243 @@
+import QtQuick 2.2
+import QtQuick.Controls 1.4
+
+import UM 1.3 as UM
+import Cura 1.0 as Cura
+
+Component
+{
+ Item
+ {
+ id: base
+ property var manager: Cura.MachineManager.printerOutputDevices[0]
+ anchors.fill: parent
+ property var lineColor: "#DCDCDC" // TODO: Should be linked to theme.
+ property var cornerRadius: 4 // TODO: Should be linked to theme.
+
+ visible: manager != null
+
+ UM.I18nCatalog
+ {
+ id: catalog
+ name: "cura"
+ }
+
+ Label
+ {
+ id: activePrintersLabel
+ font: UM.Theme.getFont("large")
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: Cura.MachineManager.printerOutputDevices[0].name
+ }
+ Label
+ {
+ id: printerGroupLabel
+ anchors.top: activePrintersLabel.bottom
+ text: catalog.i18nc("@label", "PRINTER GROUP")
+ anchors.horizontalCenter: parent.horizontalCenter
+ font: UM.Theme.getFont("very_small")
+ opacity: 0.65
+ }
+
+ Rectangle
+ {
+ id: printJobArea
+ border.width: UM.Theme.getSize("default_lining").width
+ border.color: lineColor
+ anchors.top: printerGroupLabel.bottom
+ anchors.topMargin: UM.Theme.getSize("default_margin").height
+ anchors.left: parent.left
+ anchors.leftMargin: UM.Theme.getSize("default_margin").width
+ anchors.right: parent.right
+ anchors.rightMargin:UM.Theme.getSize("default_margin").width
+ radius: cornerRadius
+ height: childrenRect.height
+
+ Item
+ {
+ id: printJobTitleBar
+ width: parent.width
+ height: printJobTitleLabel.height + 2 * UM.Theme.getSize("default_margin").height
+
+ Label
+ {
+ id: printJobTitleLabel
+ anchors.left: parent.left
+ anchors.leftMargin: UM.Theme.getSize("default_margin").width
+ anchors.top: parent.top
+ anchors.topMargin: UM.Theme.getSize("default_margin").height
+ text: catalog.i18nc("@title", "Print jobs")
+ font: UM.Theme.getFont("default")
+ opacity: 0.75
+ }
+ Rectangle
+ {
+ anchors.bottom: parent.bottom
+ height: UM.Theme.getSize("default_lining").width
+ color: lineColor
+ width: parent.width
+ }
+ }
+
+ Column
+ {
+ id: printJobColumn
+ anchors.top: printJobTitleBar.bottom
+ anchors.topMargin: UM.Theme.getSize("default_margin").height
+ anchors.left: parent.left
+ anchors.leftMargin: UM.Theme.getSize("default_margin").width
+ anchors.right: parent.right
+ anchors.rightMargin: UM.Theme.getSize("default_margin").width
+ //TODO; It's probably nicer to do this with a dynamic data model instead of hardcoding this.
+ //But you know the drill; time constraints don't result in elegant code.
+ Item
+ {
+ width: parent.width
+ height: childrenRect.height
+ opacity: 0.65
+ Label
+ {
+ text: catalog.i18nc("@label", "Printing")
+ font: UM.Theme.getFont("very_small")
+
+ }
+ Label
+ {
+ text: manager.numJobsPrinting
+ font: UM.Theme.getFont("small")
+ anchors.right: parent.right
+ }
+ }
+ Item
+ {
+ width: parent.width
+ height: childrenRect.height
+ opacity: 0.65
+ Label
+ {
+ text: catalog.i18nc("@label", "Queued")
+ font: UM.Theme.getFont("very_small")
+ }
+ Label
+ {
+ text: manager.numJobsQueued
+ font: UM.Theme.getFont("small")
+ anchors.right: parent.right
+ }
+ }
+ }
+ OpenPanelButton
+ {
+ anchors.top: printJobColumn.bottom
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.margins: UM.Theme.getSize("default_margin").height
+ id: configButton
+ onClicked: base.manager.openPrintJobControlPanel()
+ text: catalog.i18nc("@action:button", "View print jobs")
+ }
+
+ Item
+ {
+ // spacer
+ anchors.top: configButton.bottom
+ width: UM.Theme.getSize("default_margin").width
+ height: UM.Theme.getSize("default_margin").height
+ }
+ }
+
+
+ Rectangle
+ {
+ id: printersArea
+ border.width: UM.Theme.getSize("default_lining").width
+ border.color: lineColor
+ anchors.top: printJobArea.bottom
+ anchors.topMargin: UM.Theme.getSize("default_margin").height
+ anchors.left: parent.left
+ anchors.leftMargin: UM.Theme.getSize("default_margin").width
+ anchors.right: parent.right
+ anchors.rightMargin:UM.Theme.getSize("default_margin").width
+ radius: cornerRadius
+ height: childrenRect.height
+
+ Item
+ {
+ id: printersTitleBar
+ width: parent.width
+ height: printJobTitleLabel.height + 2 * UM.Theme.getSize("default_margin").height
+
+ Label
+ {
+ id: printersTitleLabel
+ anchors.left: parent.left
+ anchors.leftMargin: UM.Theme.getSize("default_margin").width
+ anchors.top: parent.top
+ anchors.topMargin: UM.Theme.getSize("default_margin").height
+ text: catalog.i18nc("@label:title", "Printers")
+ font: UM.Theme.getFont("default")
+ opacity: 0.75
+ }
+ Rectangle
+ {
+ anchors.bottom: parent.bottom
+ height: UM.Theme.getSize("default_lining").width
+ color: lineColor
+ width: parent.width
+ }
+ }
+ Column
+ {
+ id: printersColumn
+ anchors.top: printersTitleBar.bottom
+ anchors.topMargin: UM.Theme.getSize("default_margin").height
+ anchors.left: parent.left
+ anchors.leftMargin: UM.Theme.getSize("default_margin").width
+ anchors.right: parent.right
+ anchors.rightMargin: UM.Theme.getSize("default_margin").width
+
+ Repeater
+ {
+ model: manager.connectedPrintersTypeCount
+ Item
+ {
+ width: parent.width
+ height: childrenRect.height
+ opacity: 0.65
+ Label
+ {
+ text: modelData.machine_type
+ font: UM.Theme.getFont("very_small")
+ }
+
+ Label
+ {
+ text: modelData.count
+ font: UM.Theme.getFont("small")
+ anchors.right: parent.right
+ }
+ }
+ }
+ }
+ OpenPanelButton
+ {
+ anchors.top: printersColumn.bottom
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.margins: UM.Theme.getSize("default_margin").height
+ id: printerConfigButton
+ onClicked: base.manager.openPrinterControlPanel()
+
+ text: catalog.i18nc("@action:button", "View printers")
+ }
+
+ Item
+ {
+ // spacer
+ anchors.top: printerConfigButton.bottom
+ width: UM.Theme.getSize("default_margin").width
+ height: UM.Theme.getSize("default_margin").height
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/plugins/UM3NetworkPrinting/ClusterMonitorItem.qml b/plugins/UM3NetworkPrinting/ClusterMonitorItem.qml
new file mode 100644
index 0000000000..d39cdab81e
--- /dev/null
+++ b/plugins/UM3NetworkPrinting/ClusterMonitorItem.qml
@@ -0,0 +1,108 @@
+import QtQuick 2.2
+import QtQuick.Controls 1.4
+import QtQuick.Controls.Styles 1.4
+
+import UM 1.3 as UM
+import Cura 1.0 as Cura
+
+Component
+{
+ Rectangle
+ {
+ width: maximumWidth
+ height: maximumHeight
+ color: "#FFFFFF" // TODO; Should not be hardcoded.
+
+ property var emphasisColor: "#44c0ff" //TODO: should be linked to theme.
+ property var lineColor: "#DCDCDC" // TODO: Should be linked to theme.
+ property var cornerRadius: 4 // TODO: Should be linked to theme.
+ UM.I18nCatalog
+ {
+ id: catalog
+ name: "cura"
+ }
+
+ Label
+ {
+ id: activePrintersLabel
+ font: UM.Theme.getFont("large")
+
+ text:
+ {
+ if (OutputDevice.connectedPrinters.length == 0){
+ return catalog.i18nc("@label: arg 1 is group name", "%1 is not set up to host a group of connected Ultimaker 3 printers").arg(Cura.MachineManager.printerOutputDevices[0].name)
+ } else {
+ return ""
+ }
+ }
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.topMargin: UM.Theme.getSize("default_margin").height
+
+ visible: OutputDevice.connectedPrinters.length == 0
+ }
+
+ Item
+ {
+ anchors.topMargin: UM.Theme.getSize("default_margin").height
+ anchors.top: parent.top
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ width: Math.min(800, maximumWidth)
+ height: children.height
+ visible: OutputDevice.connectedPrinters.length != 0
+
+ Label
+ {
+ id: addRemovePrintersLabel
+ anchors.right: parent.right
+ text: "Add / remove printers"
+ }
+
+ MouseArea
+ {
+ anchors.fill: addRemovePrintersLabel
+ onClicked: Cura.MachineManager.printerOutputDevices[0].openPrinterControlPanel()
+ }
+ }
+
+
+ ScrollView
+ {
+ id: printerScrollView
+ anchors.margins: UM.Theme.getSize("default_margin").width
+ anchors.top: activePrintersLabel.bottom
+ anchors.bottom: parent.bottom
+ anchors.left: parent.left
+ anchors.leftMargin: UM.Theme.getSize("default_lining").width // To ensure border can be drawn.
+ anchors.rightMargin: UM.Theme.getSize("default_lining").width
+ anchors.right: parent.right
+
+ ListView
+ {
+ anchors.fill: parent
+ spacing: -UM.Theme.getSize("default_lining").height
+
+ model: OutputDevice.connectedPrinters
+
+ delegate: PrinterInfoBlock
+ {
+ printer: modelData
+ width: Math.min(800, maximumWidth)
+ height: 125
+
+ // Add a 1 pix margin, as the border is sometimes cut off otherwise.
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+ }
+ }
+
+ PrinterVideoStream
+ {
+ visible: OutputDevice.selectedPrinterName != ""
+ anchors.fill:parent
+ }
+ }
+}
diff --git a/plugins/UM3NetworkPrinting/DiscoverUM3Action.qml b/plugins/UM3NetworkPrinting/DiscoverUM3Action.qml
index 58f155533f..3f51ff9dda 100644
--- a/plugins/UM3NetworkPrinting/DiscoverUM3Action.qml
+++ b/plugins/UM3NetworkPrinting/DiscoverUM3Action.qml
@@ -272,6 +272,28 @@ Cura.MachineAction
text: base.selectedPrinter ? base.selectedPrinter.ipAddress : ""
}
}
+
+ Label
+ {
+ width: parent.width
+ wrapMode: Text.WordWrap
+ text:{
+ // The property cluster size does not exist for older UM3 devices.
+ if(base.selectedPrinter.clusterSize == null || base.selectedPrinter.clusterSize == 1)
+ {
+ return "";
+ }
+ else if (base.selectedPrinter.clusterSize === 0)
+ {
+ return catalog.i18nc("@label", "Cura Connect: This printer is not set up to host a group of connected Ultimaker 3 printers.");
+ }
+ else
+ {
+ return catalog.i18nc("@label", "Cura Connect: This printer is set up to host a group of %1 connected Ultimaker 3 printers".arg(base.selectedPrinter.clusterSize));
+ }
+ }
+
+ }
Label
{
width: parent.width
diff --git a/plugins/UM3NetworkPrinting/NetworkClusterPrinterOutputDevice.py b/plugins/UM3NetworkPrinting/NetworkClusterPrinterOutputDevice.py
new file mode 100644
index 0000000000..55f9d1247b
--- /dev/null
+++ b/plugins/UM3NetworkPrinting/NetworkClusterPrinterOutputDevice.py
@@ -0,0 +1,638 @@
+import datetime
+import getpass
+import gzip
+import json
+import os
+import os.path
+import time
+
+from enum import Enum
+from PyQt5.QtNetwork import QNetworkRequest, QHttpPart, QHttpMultiPart
+from PyQt5.QtCore import QUrl, QByteArray, pyqtSlot, pyqtProperty, QCoreApplication, QTimer, pyqtSignal, QObject
+from PyQt5.QtGui import QDesktopServices
+from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkReply
+from PyQt5.QtQml import QQmlComponent, QQmlContext
+from UM.Application import Application
+from UM.Logger import Logger
+from UM.Message import Message
+from UM.OutputDevice import OutputDeviceError
+from UM.i18n import i18nCatalog
+
+from . import NetworkPrinterOutputDevice
+
+
+i18n_catalog = i18nCatalog("cura")
+
+
+class OutputStage(Enum):
+ ready = 0
+ uploading = 2
+
+
+class NetworkClusterPrinterOutputDevice(NetworkPrinterOutputDevice.NetworkPrinterOutputDevice):
+ printJobsChanged = pyqtSignal()
+ printersChanged = pyqtSignal()
+ selectedPrinterChanged = pyqtSignal()
+
+ def __init__(self, key, address, properties, api_prefix, plugin_path):
+ super().__init__(key, address, properties, api_prefix)
+ # Store the address of the master.
+ self._master_address = address
+ name_property = properties.get(b"name", b"")
+ if name_property:
+ name = name_property.decode("utf-8")
+ else:
+ name = key
+
+ self._plugin_path = plugin_path
+
+ self.setName(name)
+ description = i18n_catalog.i18nc("@action:button Preceded by 'Ready to'.", "Print over network")
+ self.setShortDescription(description)
+ self.setDescription(description)
+
+ self._stage = OutputStage.ready
+ host_override = os.environ.get("CLUSTER_OVERRIDE_HOST", "")
+ if host_override:
+ Logger.log(
+ "w",
+ "Environment variable CLUSTER_OVERRIDE_HOST is set to [%s], cluster hosts are now set to this host",
+ host_override)
+ self._host = "http://" + host_override
+ else:
+ self._host = "http://" + address
+
+ # is the same as in NetworkPrinterOutputDevicePlugin
+ self._cluster_api_version = "1"
+ self._cluster_api_prefix = "/cluster-api/v" + self._cluster_api_version + "/"
+ self._api_base_uri = self._host + self._cluster_api_prefix
+
+ self._file_name = None
+ self._progress_message = None
+ self._request = None
+ self._reply = None
+
+ # The main reason to keep the 'multipart' form data on the object
+ # is to prevent the Python GC from claiming it too early.
+ self._multipart = None
+
+ self._print_view = None
+ self._request_job = []
+
+ self._monitor_view_qml_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ClusterMonitorItem.qml")
+ self._control_view_qml_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ClusterControlItem.qml")
+
+ self._print_jobs = []
+ self._print_job_by_printer_uuid = {}
+ self._print_job_by_uuid = {} # Print jobs by their own uuid
+ self._printers = []
+ self._printers_dict = {} # by unique_name
+
+ self._connected_printers_type_count = []
+ self._automatic_printer = {"unique_name": "", "friendly_name": "Automatic"} # empty unique_name IS automatic selection
+ self._selected_printer = self._automatic_printer
+
+ self._cluster_status_update_timer = QTimer()
+ self._cluster_status_update_timer.setInterval(5000)
+ self._cluster_status_update_timer.setSingleShot(False)
+ self._cluster_status_update_timer.timeout.connect(self._requestClusterStatus)
+
+ self._can_pause = False
+ self._can_abort = False
+ self._can_pre_heat_bed = False
+ self._cluster_size = int(properties.get(b"cluster_size", 0))
+
+ self._cleanupRequest()
+
+ #These are texts that are to be translated for future features.
+ temporary_translation = i18n_catalog.i18n("This printer is not set up to host a group of connected Ultimaker 3 printers.")
+ temporary_translation2 = i18n_catalog.i18nc("Count is number of printers.", "This printer is the host for a group of {count} connected Ultimaker 3 printers.").format(count = 3)
+ temporary_translation3 = i18n_catalog.i18n("{printer_name} has finished printing '{job_name}'. Please collect the print and confirm clearing the build plate.") #When finished.
+ temporary_translation4 = i18n_catalog.i18n("{printer_name} is reserved to print '{job_name}'. Please change the printer's configuration to match the job, for it to start printing.") #When configuration changed.
+
+ @pyqtProperty(QObject, notify=selectedPrinterChanged)
+ def controlItem(self):
+ # TODO: Probably not the nicest way to do this. This needs to be done better at some point in time.
+ if not self._control_component:
+ self._createControlViewFromQML()
+ name = self._selected_printer.get("friendly_name")
+ if name == self._automatic_printer.get("friendly_name") or name == "":
+ return self._control_item
+ # Let cura use the default.
+ return None
+
+ @pyqtSlot(int, result = str)
+ def getTimeCompleted(self, time_remaining):
+ current_time = time.time()
+ datetime_completed = datetime.datetime.fromtimestamp(current_time + time_remaining)
+ return "{hour:02d}:{minute:02d}".format(hour = datetime_completed.hour, minute = datetime_completed.minute)
+
+ @pyqtSlot(int, result = str)
+ def getDateCompleted(self, time_remaining):
+ current_time = time.time()
+ datetime_completed = datetime.datetime.fromtimestamp(current_time + time_remaining)
+ return (datetime_completed.strftime("%a %b ") + "{day}".format(day=datetime_completed.day)).upper()
+
+ @pyqtProperty(int, constant = True)
+ def clusterSize(self):
+ return self._cluster_size
+
+ @pyqtProperty(str, notify=selectedPrinterChanged)
+ def name(self):
+ # Show the name of the selected printer.
+ # This is not the nicest way to do this, but changes to the Cura UI are required otherwise.
+ name = self._selected_printer.get("friendly_name")
+ if name != self._automatic_printer.get("friendly_name"):
+ return name
+ # Return name of cluster master.
+ return self._properties.get(b"name", b"").decode("utf-8")
+
+ def connect(self):
+ super().connect()
+ self._cluster_status_update_timer.start()
+
+ def close(self):
+ super().close()
+ self._cluster_status_update_timer.stop()
+
+ def _requestClusterStatus(self):
+ # TODO: Handle timeout. We probably want to know if the cluster is still reachable or not.
+ url = QUrl(self._api_base_uri + "print_jobs/")
+ print_jobs_request = QNetworkRequest(url)
+ self._addUserAgentHeader(print_jobs_request)
+ self._manager.get(print_jobs_request)
+ # See _finishedPrintJobsRequest()
+
+ url = QUrl(self._api_base_uri + "printers/")
+ printers_request = QNetworkRequest(url)
+ self._addUserAgentHeader(printers_request)
+ self._manager.get(printers_request)
+ # See _finishedPrintersRequest()
+
+ def _finishedPrintJobsRequest(self, reply):
+ try:
+ json_data = json.loads(bytes(reply.readAll()).decode("utf-8"))
+ except json.decoder.JSONDecodeError:
+ Logger.log("w", "Received an invalid print job state message: Not valid JSON.")
+ return
+ self.setPrintJobs(json_data)
+
+ def _finishedPrintersRequest(self, reply):
+ try:
+ json_data = json.loads(bytes(reply.readAll()).decode("utf-8"))
+ except json.decoder.JSONDecodeError:
+ Logger.log("w", "Received an invalid print job state message: Not valid JSON.")
+ return
+ self.setPrinters(json_data)
+
+ def materialHotendChangedMessage(self, callback):
+ pass # Do nothing.
+
+ def _startCameraStream(self):
+ ## Request new image
+ url = QUrl("http://" + self._printers_dict[self._selected_printer["unique_name"]]["ip_address"] + ":8080/?action=stream")
+ self._image_request = QNetworkRequest(url)
+ self._addUserAgentHeader(self._image_request)
+ self._image_reply = self._manager.get(self._image_request)
+ self._image_reply.downloadProgress.connect(self._onStreamDownloadProgress)
+
+ def spawnPrintView(self):
+ if self._print_view is None:
+ path = QUrl.fromLocalFile(os.path.join(self._plugin_path, "PrintWindow.qml"))
+ component = QQmlComponent(Application.getInstance()._engine, path)
+
+ self._print_context = QQmlContext(Application.getInstance()._engine.rootContext())
+ self._print_context.setContextProperty("OutputDevice", self)
+ self._print_view = component.create(self._print_context)
+
+ if component.isError():
+ Logger.log("e", " Errors creating component: \n%s", "\n".join(
+ [e.toString() for e in component.errors()]))
+
+ if self._print_view is not None:
+ self._print_view.show()
+
+ ## Store job info, show Print view for settings
+ def requestWrite(self, nodes, file_name=None, filter_by_machine=False, file_handler=None, **kwargs):
+ self._selected_printer = self._automatic_printer # reset to default option
+ self._request_job = [nodes, file_name, filter_by_machine, file_handler, kwargs]
+
+ if self._stage != OutputStage.ready:
+ if self._error_message:
+ self._error_message.hide()
+ self._error_message = Message(
+ i18n_catalog.i18nc("@info:status",
+ "Sending new jobs (temporarily) blocked, still sending the previous print job."))
+ self._error_message.show()
+ return
+
+ if len(self._printers) > 1:
+ self.spawnPrintView() # Ask user how to print it.
+ elif len(self._printers) == 1:
+ # If there is only one printer, don't bother asking.
+ self.selectAutomaticPrinter()
+ self.sendPrintJob()
+ else:
+ # Cluster has no printers, warn the user of this.
+ if self._error_message:
+ self._error_message.hide()
+ self._error_message = Message(
+ i18n_catalog.i18nc("@info:status",
+ "Unable to send new print job: this 3D printer is not (yet) set up to host a group of connected Ultimaker 3 printers."))
+ self._error_message.show()
+
+ ## Actually send the print job, called from the dialog
+ # :param: require_printer_name: name of printer, or ""
+ @pyqtSlot()
+ def sendPrintJob(self):
+ nodes, file_name, filter_by_machine, file_handler, kwargs = self._request_job
+ require_printer_name = self._selected_printer["unique_name"]
+
+ self._send_gcode_start = time.time()
+ Logger.log("d", "Sending print job [%s] to host..." % file_name)
+
+ if self._stage != OutputStage.ready:
+ Logger.log("d", "Unable to send print job as the state is %s", self._stage)
+ raise OutputDeviceError.DeviceBusyError()
+ self._stage = OutputStage.uploading
+
+ self._file_name = "%s.gcode.gz" % file_name
+ self._showProgressMessage()
+
+ self._request = self._buildSendPrintJobHttpRequest(require_printer_name)
+ self._reply = self._manager.post(self._request, self._multipart)
+ self._reply.uploadProgress.connect(self._onUploadProgress)
+ # See _finishedPostPrintJobRequest()
+
+ def _buildSendPrintJobHttpRequest(self, require_printer_name):
+ api_url = QUrl(self._api_base_uri + "print_jobs/")
+ request = QNetworkRequest(api_url)
+ # Create multipart request and add the g-code.
+ self._multipart = QHttpMultiPart(QHttpMultiPart.FormDataType)
+
+ # Add gcode
+ part = QHttpPart()
+ part.setHeader(QNetworkRequest.ContentDispositionHeader,
+ 'form-data; name="file"; filename="%s"' % self._file_name)
+
+ gcode = getattr(Application.getInstance().getController().getScene(), "gcode_list")
+ compressed_gcode = self._compressGcode(gcode)
+ if compressed_gcode is None:
+ return # User aborted print, so stop trying.
+
+ part.setBody(compressed_gcode)
+ self._multipart.append(part)
+
+ # require_printer_name "" means automatic
+ if require_printer_name:
+ self._multipart.append(self.__createKeyValueHttpPart("require_printer_name", require_printer_name))
+ user_name = self.__get_username()
+ if user_name is None:
+ user_name = "unknown"
+ self._multipart.append(self.__createKeyValueHttpPart("owner", user_name))
+
+ self._addUserAgentHeader(request)
+ return request
+
+ def _compressGcode(self, gcode):
+ self._compressing_print = True
+ batched_line = ""
+ max_chars_per_line = int(1024 * 1024 / 4) # 1 / 4 MB
+
+ byte_array_file_data = b""
+
+ def _compressDataAndNotifyQt(data_to_append):
+ compressed_data = gzip.compress(data_to_append.encode("utf-8"))
+ QCoreApplication.processEvents() # Ensure that the GUI does not freeze.
+ # Pretend that this is a response, as zipping might take a bit of time.
+ self._last_response_time = time.time()
+ return compressed_data
+
+ if gcode is None:
+ Logger.log("e", "Unable to find sliced gcode, returning empty.")
+ return byte_array_file_data
+
+ for line in gcode:
+ if not self._compressing_print:
+ self._progress_message.hide()
+ return # Stop trying to zip, abort was called.
+ batched_line += line
+ # if the gcode was read from a gcode file, self._gcode will be a list of all lines in that file.
+ # Compressing line by line in this case is extremely slow, so we need to batch them.
+ if len(batched_line) < max_chars_per_line:
+ continue
+ byte_array_file_data += _compressDataAndNotifyQt(batched_line)
+ batched_line = ""
+
+ # Also compress the leftovers.
+ if batched_line:
+ byte_array_file_data += _compressDataAndNotifyQt(batched_line)
+
+ return byte_array_file_data
+
+ def __createKeyValueHttpPart(self, key, value):
+ metadata_part = QHttpPart()
+ metadata_part.setHeader(QNetworkRequest.ContentTypeHeader, 'text/plain')
+ metadata_part.setHeader(QNetworkRequest.ContentDispositionHeader, 'form-data; name="%s"' % (key))
+ metadata_part.setBody(bytearray(value, "utf8"))
+ return metadata_part
+
+ def __get_username(self):
+ try:
+ return getpass.getuser()
+ except:
+ Logger.log("d", "Could not get the system user name, returning 'unknown' instead.")
+ return None
+
+ def _finishedPrintJobPostRequest(self, reply):
+ self._stage = OutputStage.ready
+ if self._progress_message:
+ self._progress_message.hide()
+ self._progress_message = None
+ self.writeFinished.emit(self)
+
+ if reply.error():
+ self._showRequestFailedMessage(reply)
+ self.writeError.emit(self)
+ else:
+ self._showRequestSucceededMessage()
+ self.writeSuccess.emit(self)
+
+ self._cleanupRequest()
+
+ def _showRequestFailedMessage(self, reply):
+ if reply is not None:
+ Logger.log("w", "Unable to send print job to group {cluster_name}: {error_string} ({error})".format(
+ cluster_name = self.getName(),
+ error_string = str(reply.errorString()),
+ error = str(reply.error())))
+ error_message_template = i18n_catalog.i18nc("@info:status", "Unable to send print job to group {cluster_name}.")
+ message = Message(text=error_message_template.format(
+ cluster_name = self.getName()))
+ message.show()
+
+ def _showRequestSucceededMessage(self):
+ confirmation_message_template = i18n_catalog.i18nc(
+ "@info:status",
+ "Sent {file_name} to group {cluster_name}."
+ )
+ file_name = os.path.basename(self._file_name).split(".")[0]
+ message_text = confirmation_message_template.format(cluster_name = self.getName(), file_name = file_name)
+ message = Message(text=message_text)
+ button_text = i18n_catalog.i18nc("@action:button", "Show print jobs")
+ button_tooltip = i18n_catalog.i18nc("@info:tooltip", "Opens the print jobs interface in your browser.")
+ message.addAction("open_browser", button_text, "globe", button_tooltip)
+ message.actionTriggered.connect(self._onMessageActionTriggered)
+ message.show()
+
+ def setPrintJobs(self, print_jobs):
+ #TODO: hack, last seen messes up the check, so drop it.
+ for job in print_jobs:
+ del job["last_seen"]
+ # Strip any extensions
+ job["name"] = self._removeGcodeExtension(job["name"])
+
+ if self._print_jobs != print_jobs:
+ old_print_jobs = self._print_jobs
+ self._print_jobs = print_jobs
+
+ self._notifyFinishedPrintJobs(old_print_jobs, print_jobs)
+
+ # Yes, this is a hacky way of doing it, but it's quick and the API doesn't give the print job per printer
+ # for some reason. ugh.
+ self._print_job_by_printer_uuid = {}
+ self._print_job_by_uuid = {}
+ for print_job in print_jobs:
+ if "printer_uuid" in print_job and print_job["printer_uuid"] is not None:
+ self._print_job_by_printer_uuid[print_job["printer_uuid"]] = print_job
+ self._print_job_by_uuid[print_job["uuid"]] = print_job
+ self.printJobsChanged.emit()
+
+ def _removeGcodeExtension(self, name):
+ parts = name.split(".")
+ if parts[-1].upper() == "GZ":
+ parts = parts[:-1]
+ if parts[-1].upper() == "GCODE":
+ parts = parts[:-1]
+ return ".".join(parts)
+
+ def _notifyFinishedPrintJobs(self, old_print_jobs, new_print_jobs):
+ """Notify the user when any of their print jobs have just completed.
+
+ Arguments:
+
+ old_print_jobs -- the previous list of print job status information as returned by the cluster REST API.
+ new_print_jobs -- the current list of print job status information as returned by the cluster REST API.
+ """
+ if old_print_jobs is None:
+ return
+
+ username = self.__get_username()
+ if username is None:
+ return
+
+ our_old_print_jobs = self.__filterOurPrintJobs(old_print_jobs)
+ our_old_not_finished_print_jobs = [pj for pj in our_old_print_jobs if pj["status"] != "wait_cleanup"]
+
+ our_new_print_jobs = self.__filterOurPrintJobs(new_print_jobs)
+ our_new_finished_print_jobs = [pj for pj in our_new_print_jobs if pj["status"] == "wait_cleanup"]
+
+ old_not_finished_print_job_uuids = set([pj["uuid"] for pj in our_old_not_finished_print_jobs])
+
+ for print_job in our_new_finished_print_jobs:
+ if print_job["uuid"] in old_not_finished_print_job_uuids:
+
+ printer_name = self.__getPrinterNameFromUuid(print_job["printer_uuid"])
+ if printer_name is None:
+ printer_name = i18n_catalog.i18nc("@info:status", "Unknown printer")
+
+ message_text = (i18n_catalog.i18nc("@info:status",
+ "Printer '{printer_name}' has finished printing '{job_name}'.")
+ .format(printer_name=printer_name, job_name=print_job["name"]))
+ message = Message(text=message_text, title=i18n_catalog.i18nc("@info:status", "Print finished"))
+ Application.getInstance().showMessage(message)
+ Application.getInstance().showToastMessage(
+ i18n_catalog.i18nc("@info:status", "Print finished"),
+ message_text)
+
+ def __filterOurPrintJobs(self, print_jobs):
+ username = self.__get_username()
+ return [print_job for print_job in print_jobs if print_job["owner"] == username]
+
+ def __getPrinterNameFromUuid(self, printer_uuid):
+ for printer in self._printers:
+ if printer["uuid"] == printer_uuid:
+ return printer["friendly_name"]
+ return None
+
+ def setPrinters(self, printers):
+ if self._printers != printers:
+ self._connected_printers_type_count = []
+ printers_count = {}
+ self._printers = printers
+ self._printers_dict = dict((p["unique_name"], p) for p in printers) # for easy lookup by unique_name
+
+ for printer in printers:
+ variant = printer["machine_variant"]
+ if variant in printers_count:
+ printers_count[variant] += 1
+ else:
+ printers_count[variant] = 1
+ for type in printers_count:
+ self._connected_printers_type_count.append({"machine_type": type, "count": printers_count[type]})
+ self.printersChanged.emit()
+
+ @pyqtProperty("QVariantList", notify=printersChanged)
+ def connectedPrintersTypeCount(self):
+ return self._connected_printers_type_count
+
+ @pyqtProperty("QVariantList", notify=printersChanged)
+ def connectedPrinters(self):
+ return self._printers
+
+ @pyqtProperty(int, notify=printJobsChanged)
+ def numJobsPrinting(self):
+ num_jobs_printing = 0
+ for job in self._print_jobs:
+ if job["status"] == "printing":
+ num_jobs_printing += 1
+ return num_jobs_printing
+
+ @pyqtProperty(int, notify=printJobsChanged)
+ def numJobsQueued(self):
+ num_jobs_queued = 0
+ for job in self._print_jobs:
+ if job["status"] == "queued":
+ num_jobs_queued += 1
+ return num_jobs_queued
+
+ @pyqtProperty("QVariantMap", notify=printJobsChanged)
+ def printJobsByUUID(self):
+ return self._print_job_by_uuid
+
+ @pyqtProperty("QVariantMap", notify=printJobsChanged)
+ def printJobsByPrinterUUID(self):
+ return self._print_job_by_printer_uuid
+
+ @pyqtProperty("QVariantList", notify=printJobsChanged)
+ def printJobs(self):
+ return self._print_jobs
+
+ @pyqtProperty("QVariantList", notify=printersChanged)
+ def printers(self):
+ return [self._automatic_printer, ] + self._printers
+
+ @pyqtSlot(str, str)
+ def selectPrinter(self, unique_name, friendly_name):
+ self.stopCamera()
+ self._selected_printer = {"unique_name": unique_name, "friendly_name": friendly_name}
+ Logger.log("d", "Selected printer: %s %s", friendly_name, unique_name)
+ # TODO: Probably not the nicest way to do this. This needs to be done better at some point in time.
+ if unique_name == "":
+ self._address = self._master_address
+ else:
+ self._address = self._printers_dict[self._selected_printer["unique_name"]]["ip_address"]
+
+ self.selectedPrinterChanged.emit()
+
+ def _updateJobState(self, job_state):
+ name = self._selected_printer.get("friendly_name")
+ if name == "" or name == "Automatic":
+ # TODO: This is now a bit hacked; If no printer is selected, don't show job state.
+ if self._job_state != "":
+ self._job_state = ""
+ self.jobStateChanged.emit()
+ else:
+ if self._job_state != job_state:
+ self._job_state = job_state
+ self.jobStateChanged.emit()
+
+ @pyqtSlot()
+ def selectAutomaticPrinter(self):
+ self.stopCamera()
+ self._selected_printer = self._automatic_printer
+ self.selectedPrinterChanged.emit()
+
+ @pyqtProperty("QVariant", notify=selectedPrinterChanged)
+ def selectedPrinterName(self):
+ return self._selected_printer.get("unique_name", "")
+
+ def getPrintJobsUrl(self):
+ return self._host + "/print_jobs"
+
+ def getPrintersUrl(self):
+ return self._host + "/printers"
+
+ def _showProgressMessage(self):
+ progress_message_template = i18n_catalog.i18nc("@info:progress",
+ "Sending {file_name} to group {cluster_name}")
+ file_name = os.path.basename(self._file_name).split(".")[0]
+ self._progress_message = Message(progress_message_template.format(file_name = file_name, cluster_name = self.getName()), 0, False, -1)
+ self._progress_message.addAction("Abort", i18n_catalog.i18nc("@action:button", "Cancel"), None, "")
+ self._progress_message.actionTriggered.connect(self._onMessageActionTriggered)
+ self._progress_message.show()
+
+ def _addUserAgentHeader(self, request):
+ request.setRawHeader(b"User-agent", b"CuraPrintClusterOutputDevice Plugin")
+
+ def _cleanupRequest(self):
+ self._reply = None
+ self._request = None
+ self._multipart = None
+ self._stage = OutputStage.ready
+ self._file_name = None
+
+ def _onFinished(self, reply):
+ super()._onFinished(reply)
+ reply_url = reply.url().toString()
+ status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute)
+ if status_code == 500:
+ Logger.log("w", "Request to {url} returned a 500.".format(url = reply_url))
+ return
+ if reply.error() == QNetworkReply.ContentOperationNotPermittedError:
+ # It was probably "/api/v1/materials" for legacy UM3
+ return
+ if reply.error() == QNetworkReply.ContentNotFoundError:
+ # It was probably "/api/v1/print_job" for legacy UM3
+ return
+
+ if reply.operation() == QNetworkAccessManager.PostOperation:
+ if self._cluster_api_prefix + "print_jobs" in reply_url:
+ self._finishedPrintJobPostRequest(reply)
+ return
+
+ # We need to do this check *after* we process the post operation!
+ # If the sending of g-code is cancelled by the user it will result in an error, but we do need to handle this.
+ if reply.error() != QNetworkReply.NoError:
+ Logger.log("e", "After requesting [%s] we got a network error [%s]. Not processing anything...", reply_url, reply.error())
+ return
+
+ elif reply.operation() == QNetworkAccessManager.GetOperation:
+ if self._cluster_api_prefix + "print_jobs" in reply_url:
+ self._finishedPrintJobsRequest(reply)
+ elif self._cluster_api_prefix + "printers" in reply_url:
+ self._finishedPrintersRequest(reply)
+
+ @pyqtSlot()
+ def openPrintJobControlPanel(self):
+ Logger.log("d", "Opening print job control panel...")
+ QDesktopServices.openUrl(QUrl(self.getPrintJobsUrl()))
+
+ @pyqtSlot()
+ def openPrinterControlPanel(self):
+ Logger.log("d", "Opening printer control panel...")
+ QDesktopServices.openUrl(QUrl(self.getPrintersUrl()))
+
+ def _onMessageActionTriggered(self, message, action):
+ if action == "open_browser":
+ QDesktopServices.openUrl(QUrl(self.getPrintJobsUrl()))
+
+ if action == "Abort":
+ Logger.log("d", "User aborted sending print to remote.")
+ self._progress_message.hide()
+ self._compressing_print = False
+ self._stage = OutputStage.ready
+ if self._reply:
+ self._reply.abort()
+ self._reply = None
+ Application.getInstance().showPrintMonitor.emit(False)
diff --git a/plugins/UM3NetworkPrinting/NetworkPrinterOutputDevice.py b/plugins/UM3NetworkPrinting/NetworkPrinterOutputDevice.py
index 8c7a07ef4b..6c81ff6d82 100755
--- a/plugins/UM3NetworkPrinting/NetworkPrinterOutputDevice.py
+++ b/plugins/UM3NetworkPrinting/NetworkPrinterOutputDevice.py
@@ -17,7 +17,7 @@ import cura.Settings.ExtruderManager
from PyQt5.QtNetwork import QHttpMultiPart, QHttpPart, QNetworkRequest, QNetworkAccessManager, QNetworkReply
from PyQt5.QtCore import QUrl, QTimer, pyqtSignal, pyqtProperty, pyqtSlot, QCoreApplication
-from PyQt5.QtGui import QImage
+from PyQt5.QtGui import QImage, QColor
from PyQt5.QtWidgets import QMessageBox
import json
@@ -102,7 +102,7 @@ class NetworkPrinterOutputDevice(PrinterOutputDevice):
self._target_bed_temperature = 0
self._processing_preheat_requests = True
- self.setPriority(2) # Make sure the output device gets selected above local file output
+ self.setPriority(3) # Make sure the output device gets selected above local file output
self.setName(key)
self.setShortDescription(i18n_catalog.i18nc("@action:button Preceded by 'Ready to'.", "Print over network"))
self.setDescription(i18n_catalog.i18nc("@properties:tooltip", "Print over network"))
@@ -340,6 +340,10 @@ class NetworkPrinterOutputDevice(PrinterOutputDevice):
pass # It can happen that the wrapped c++ object is already deleted.
self._image_reply = None
self._image_request = None
+ if self._use_stream:
+ # Reset image (To prevent old images from being displayed)
+ self._camera_image.fill(QColor(0, 0, 0))
+ self.newImage.emit()
def _startCamera(self):
if self._use_stream:
@@ -1007,7 +1011,8 @@ class NetworkPrinterOutputDevice(PrinterOutputDevice):
reply_url = reply.url().toString()
if reply.operation() == QNetworkAccessManager.GetOperation:
- if "printer" in reply_url: # Status update from printer.
+ # "printer" is also in "printers", therefore _api_prefix is added.
+ if self._api_prefix + "printer" in reply_url: # Status update from printer.
if status_code == 200:
if self._connection_state == ConnectionState.connecting:
self.setConnectionState(ConnectionState.connected)
@@ -1025,7 +1030,7 @@ class NetworkPrinterOutputDevice(PrinterOutputDevice):
else:
Logger.log("w", "We got an unexpected status (%s) while requesting printer state", status_code)
pass # TODO: Handle errors
- elif "print_job" in reply_url: # Status update from print_job:
+ elif self._api_prefix + "print_job" in reply_url: # Status update from print_job:
if status_code == 200:
try:
json_data = json.loads(bytes(reply.readAll()).decode("utf-8"))
diff --git a/plugins/UM3NetworkPrinting/NetworkPrinterOutputDevicePlugin.py b/plugins/UM3NetworkPrinting/NetworkPrinterOutputDevicePlugin.py
index 5f2ed1badc..39e5faf938 100644
--- a/plugins/UM3NetworkPrinting/NetworkPrinterOutputDevicePlugin.py
+++ b/plugins/UM3NetworkPrinting/NetworkPrinterOutputDevicePlugin.py
@@ -1,26 +1,31 @@
# Copyright (c) 2017 Ultimaker B.V.
# Cura is released under the terms of the AGPLv3 or higher.
-from UM.OutputDevice.OutputDevicePlugin import OutputDevicePlugin
-from . import NetworkPrinterOutputDevice
-
-from zeroconf import Zeroconf, ServiceBrowser, ServiceStateChange, ServiceInfo # type: ignore
-from UM.Logger import Logger
-from UM.Signal import Signal, signalemitter
-from UM.Application import Application
-from UM.Preferences import Preferences
-
-from PyQt5.QtNetwork import QNetworkRequest, QNetworkAccessManager, QNetworkReply
-from PyQt5.QtCore import QUrl
-
+import os
import time
import json
+from PyQt5.QtCore import QObject, pyqtProperty, pyqtSignal, pyqtSlot
+from PyQt5.QtCore import QUrl
+from PyQt5.QtGui import QDesktopServices
+from PyQt5.QtNetwork import QNetworkRequest, QNetworkAccessManager, QNetworkReply
+from PyQt5.QtQml import QQmlComponent, QQmlContext
+from UM.Application import Application
+from UM.Logger import Logger
+from UM.OutputDevice.OutputDevicePlugin import OutputDevicePlugin
+from UM.PluginRegistry import PluginRegistry
+from UM.Preferences import Preferences
+from UM.Signal import Signal, signalemitter
+from zeroconf import Zeroconf, ServiceBrowser, ServiceStateChange, ServiceInfo # type: ignore
+
+from . import NetworkPrinterOutputDevice, NetworkClusterPrinterOutputDevice
+
+
## This plugin handles the connection detection & creation of output device objects for the UM3 printer.
# Zero-Conf is used to detect printers, which are saved in a dict.
# If we discover a printer that has the same key as the active machine instance a connection is made.
@signalemitter
-class NetworkPrinterOutputDevicePlugin(OutputDevicePlugin):
+class NetworkPrinterOutputDevicePlugin(QObject, OutputDevicePlugin):
def __init__(self):
super().__init__()
self._zero_conf = None
@@ -29,6 +34,8 @@ class NetworkPrinterOutputDevicePlugin(OutputDevicePlugin):
self._api_version = "1"
self._api_prefix = "/api/v" + self._api_version + "/"
+ self._cluster_api_version = "1"
+ self._cluster_api_prefix = "/cluster-api/v" + self._cluster_api_version + "/"
self._network_manager = QNetworkAccessManager()
self._network_manager.finished.connect(self._onNetworkRequestFinished)
@@ -47,6 +54,8 @@ class NetworkPrinterOutputDevicePlugin(OutputDevicePlugin):
self._preferences.addPreference("um3networkprinting/manual_instances", "") # A comma-separated list of ip adresses or hostnames
self._manual_instances = self._preferences.getValue("um3networkprinting/manual_instances").split(",")
+ self._network_requests_buffer = {} # store api responses until data is complete
+
addPrinterSignal = Signal()
removePrinterSignal = Signal()
printerListChanged = Signal()
@@ -91,6 +100,7 @@ class NetworkPrinterOutputDevicePlugin(OutputDevicePlugin):
self.addPrinter(instance_name, address, properties)
self.checkManualPrinter(address)
+ self.checkClusterPrinter(address)
def removeManualPrinter(self, key, address = None):
if key in self._printers:
@@ -105,18 +115,26 @@ class NetworkPrinterOutputDevicePlugin(OutputDevicePlugin):
def checkManualPrinter(self, address):
# Check if a printer exists at this address
# If a printer responds, it will replace the preliminary printer created above
- url = QUrl("http://" + address + self._api_prefix + "system")
+ # origin=manual is for tracking back the origin of the call
+ url = QUrl("http://" + address + self._api_prefix + "system?origin=manual_name")
name_request = QNetworkRequest(url)
self._network_manager.get(name_request)
+ def checkClusterPrinter(self, address):
+ cluster_url = QUrl("http://" + address + self._cluster_api_prefix + "printers/?origin=check_cluster")
+ cluster_request = QNetworkRequest(cluster_url)
+ self._network_manager.get(cluster_request)
+
## Handler for all requests that have finished.
def _onNetworkRequestFinished(self, reply):
reply_url = reply.url().toString()
status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute)
if reply.operation() == QNetworkAccessManager.GetOperation:
- if "system" in reply_url: # Name returned from printer.
+ address = reply.url().host()
+ if "origin=manual_name" in reply_url: # Name returned from printer.
if status_code == 200:
+
try:
system_info = json.loads(bytes(reply.readAll()).decode("utf-8"))
except json.JSONDecodeError:
@@ -125,28 +143,51 @@ class NetworkPrinterOutputDevicePlugin(OutputDevicePlugin):
except UnicodeDecodeError:
Logger.log("e", "Printer returned incorrect UTF-8.")
return
- address = reply.url().host()
- instance_name = "manual:%s" % address
- machine = "unknown"
- if "variant" in system_info:
- variant = system_info["variant"]
- if variant == "Ultimaker 3":
- machine = "9066"
- elif variant == "Ultimaker 3 Extended":
- machine = "9511"
- properties = {
- b"name": system_info["name"].encode("utf-8"),
- b"address": address.encode("utf-8"),
- b"firmware_version": system_info["firmware"].encode("utf-8"),
- b"manual": b"true",
- b"machine": machine.encode("utf-8")
- }
- if instance_name in self._printers:
- # Only replace the printer if it is still in the list of (manual) printers
- self.removePrinter(instance_name)
- self.addPrinter(instance_name, address, properties)
+ if address not in self._network_requests_buffer:
+ self._network_requests_buffer[address] = {}
+ self._network_requests_buffer[address]["system"] = system_info
+ elif "origin=check_cluster" in reply_url:
+ if address not in self._network_requests_buffer:
+ self._network_requests_buffer[address] = {}
+ if status_code == 200:
+ # We know it's a cluster printer
+ Logger.log("d", "Cluster printer detected: [%s]", reply.url())
+ self._network_requests_buffer[address]["cluster"] = True
+ else:
+ Logger.log("d", "This url is not from a cluster printer: [%s]", reply.url())
+ self._network_requests_buffer[address]["cluster"] = False
+
+ # Both the system call and cluster call are finished
+ if (address in self._network_requests_buffer and
+ "system" in self._network_requests_buffer[address] and
+ "cluster" in self._network_requests_buffer[address]):
+
+ instance_name = "manual:%s" % address
+ system_info = self._network_requests_buffer[address]["system"]
+ is_cluster = self._network_requests_buffer[address]["cluster"]
+ machine = "unknown"
+ if "variant" in system_info:
+ variant = system_info["variant"]
+ if variant == "Ultimaker 3":
+ machine = "9066"
+ elif variant == "Ultimaker 3 Extended":
+ machine = "9511"
+
+ properties = {
+ b"name": system_info["name"].encode("utf-8"),
+ b"address": address.encode("utf-8"),
+ b"firmware_version": system_info["firmware"].encode("utf-8"),
+ b"manual": b"true",
+ b"machine": machine.encode("utf-8")
+ }
+ if instance_name in self._printers:
+ # Only replace the printer if it is still in the list of (manual) printers
+ self.removePrinter(instance_name)
+ self.addPrinter(instance_name, address, properties, force_cluster=is_cluster)
+
+ del self._network_requests_buffer[address]
## Stop looking for devices on network.
def stop(self):
@@ -175,8 +216,13 @@ class NetworkPrinterOutputDevicePlugin(OutputDevicePlugin):
self._printers[key].connectionStateChanged.disconnect(self._onPrinterConnectionStateChanged)
## Because the model needs to be created in the same thread as the QMLEngine, we use a signal.
- def addPrinter(self, name, address, properties):
- printer = NetworkPrinterOutputDevice.NetworkPrinterOutputDevice(name, address, properties, self._api_prefix)
+ def addPrinter(self, name, address, properties, force_cluster=False):
+ cluster_size = int(properties.get(b"cluster_size", -1))
+ if force_cluster or cluster_size >= 0:
+ printer = NetworkClusterPrinterOutputDevice.NetworkClusterPrinterOutputDevice(
+ name, address, properties, self._api_prefix, self._plugin_path)
+ else:
+ printer = NetworkPrinterOutputDevice.NetworkPrinterOutputDevice(name, address, properties, self._api_prefix)
self._printers[printer.getKey()] = printer
global_container_stack = Application.getInstance().getGlobalContainerStack()
if global_container_stack and printer.getKey() == global_container_stack.getMetaDataEntry("um_network_key"):
@@ -237,4 +283,22 @@ class NetworkPrinterOutputDevicePlugin(OutputDevicePlugin):
elif state_change == ServiceStateChange.Removed:
Logger.log("d", "Bonjour service removed: %s" % name)
- self.removePrinterSignal.emit(str(name))
\ No newline at end of file
+ self.removePrinterSignal.emit(str(name))
+
+ ## For cluster below
+ def _get_plugin_directory_name(self):
+ current_file_absolute_path = os.path.realpath(__file__)
+ directory_path = os.path.dirname(current_file_absolute_path)
+ _, directory_name = os.path.split(directory_path)
+ return directory_name
+
+ @property
+ def _plugin_path(self):
+ return PluginRegistry.getInstance().getPluginPath(self._get_plugin_directory_name())
+
+ @pyqtSlot()
+ def openControlPanel(self):
+ Logger.log("d", "Opening print jobs web UI...")
+ selected_device = self.getOutputDeviceManager().getActiveDevice()
+ if isinstance(selected_device, NetworkClusterPrinterOutputDevice.NetworkClusterPrinterOutputDevice):
+ QDesktopServices.openUrl(QUrl(selected_device.getPrintJobsUrl()))
diff --git a/plugins/UM3NetworkPrinting/OpenPanelButton.qml b/plugins/UM3NetworkPrinting/OpenPanelButton.qml
new file mode 100644
index 0000000000..3915c1f9eb
--- /dev/null
+++ b/plugins/UM3NetworkPrinting/OpenPanelButton.qml
@@ -0,0 +1,18 @@
+import QtQuick 2.2
+import QtQuick.Controls 1.1
+import QtQuick.Controls.Styles 1.1
+
+import UM 1.1 as UM
+
+Button {
+ objectName: "openPanelSaveAreaButton"
+ id: openPanelSaveAreaButton
+
+ UM.I18nCatalog { id: catalog; name: "cura"; }
+
+ height: UM.Theme.getSize("save_button_save_to_button").height
+ tooltip: catalog.i18nc("@info:tooltip", "Opens the print jobs page with your default web browser.")
+ text: catalog.i18nc("@action:button", "View print jobs")
+
+ style: UM.Theme.styles.sidebar_action_button
+}
diff --git a/plugins/UM3NetworkPrinting/PrintCoreConfiguration.qml b/plugins/UM3NetworkPrinting/PrintCoreConfiguration.qml
new file mode 100644
index 0000000000..624c02f735
--- /dev/null
+++ b/plugins/UM3NetworkPrinting/PrintCoreConfiguration.qml
@@ -0,0 +1,33 @@
+import QtQuick 2.2
+import QtQuick.Controls 1.4
+import QtQuick.Controls.Styles 1.4
+
+import UM 1.2 as UM
+
+
+Item
+{
+ id: extruderInfo
+ property var printCoreConfiguration
+
+ width: parent.width / 2
+ height: childrenRect.height
+ Label
+ {
+ id: materialLabel
+ text: printCoreConfiguration.material.material + " (" + printCoreConfiguration.material.color + ")"
+ elide: Text.ElideRight
+ width: parent.width
+ font: UM.Theme.getFont("very_small")
+ }
+ Label
+ {
+ id: printCoreLabel
+ text: printCoreConfiguration.print_core_id
+ anchors.top: materialLabel.bottom
+ elide: Text.ElideRight
+ width: parent.width
+ font: UM.Theme.getFont("very_small")
+ opacity: 0.5
+ }
+}
diff --git a/plugins/UM3NetworkPrinting/PrintWindow.qml b/plugins/UM3NetworkPrinting/PrintWindow.qml
new file mode 100644
index 0000000000..28e8a72160
--- /dev/null
+++ b/plugins/UM3NetworkPrinting/PrintWindow.qml
@@ -0,0 +1,103 @@
+// Copyright (c) 2015 Ultimaker B.V.
+// Cura is released under the terms of the AGPLv3 or higher.
+
+import QtQuick 2.2
+import QtQuick.Window 2.2
+import QtQuick.Controls 1.2
+
+import UM 1.1 as UM
+
+UM.Dialog
+{
+ id: base;
+
+ minimumWidth: 500
+ minimumHeight: 140
+ maximumWidth: minimumWidth
+ maximumHeight: minimumHeight
+ width: minimumWidth
+ height: minimumHeight
+
+ visible: true
+ modality: Qt.ApplicationModal
+
+ title: catalog.i18nc("@title:window","Print over network")
+
+ Column
+ {
+ id: printerSelection
+ anchors.fill: parent
+ anchors.top: parent.top
+ anchors.topMargin: UM.Theme.getSize("default_margin").height
+ anchors.leftMargin: UM.Theme.getSize("default_margin").width
+ anchors.rightMargin: UM.Theme.getSize("default_margin").width
+ height: 50
+
+ Label
+ {
+ id: manualPrinterSelectionLabel
+ anchors
+ {
+ left: parent.left
+ topMargin: UM.Theme.getSize("default_margin").height
+ right: parent.right
+ }
+ text: "Printer selection"
+ wrapMode: Text.Wrap
+ height: 20
+ }
+
+ ComboBox
+ {
+ id: printerSelectionCombobox
+ model: OutputDevice.printers
+ textRole: "friendly_name"
+
+ width: parent.width
+ height: 40
+ Behavior on height { NumberAnimation { duration: 100 } }
+
+ onActivated:
+ {
+ var printerData = OutputDevice.printers[index];
+ OutputDevice.selectPrinter(printerData.unique_name, printerData.friendly_name);
+ }
+ }
+
+ SystemPalette
+ {
+ id: palette
+ }
+
+ UM.I18nCatalog { id: catalog; name: "cura"; }
+ }
+
+ leftButtons: [
+ Button
+ {
+ text: catalog.i18nc("@action:button","Cancel")
+ enabled: true
+ onClicked: {
+ base.visible = false;
+ // reset to defaults
+ OutputDevice.selectAutomaticPrinter()
+ printerSelectionCombobox.currentIndex = 0
+ }
+ }
+ ]
+
+ rightButtons: [
+ Button
+ {
+ text: catalog.i18nc("@action:button","Print")
+ enabled: true
+ onClicked: {
+ base.visible = false;
+ OutputDevice.sendPrintJob();
+ // reset to defaults
+ OutputDevice.selectAutomaticPrinter()
+ printerSelectionCombobox.currentIndex = 0
+ }
+ }
+ ]
+}
diff --git a/plugins/UM3NetworkPrinting/PrinterInfoBlock.qml b/plugins/UM3NetworkPrinting/PrinterInfoBlock.qml
new file mode 100644
index 0000000000..bab7db41d4
--- /dev/null
+++ b/plugins/UM3NetworkPrinting/PrinterInfoBlock.qml
@@ -0,0 +1,345 @@
+import QtQuick 2.2
+import QtQuick.Controls 1.4
+import QtQuick.Controls.Styles 1.4
+
+import UM 1.3 as UM
+
+
+Rectangle
+{
+ function strPadLeft(string, pad, length)
+ {
+ return (new Array(length + 1).join(pad) + string).slice(-length);
+ }
+
+ function getPrettyTime(time)
+ {
+ var hours = Math.floor(time / 3600)
+ time -= hours * 3600
+ var minutes = Math.floor(time / 60);
+ time -= minutes * 60
+ var seconds = Math.floor(time);
+
+ var finalTime = strPadLeft(hours, "0", 2) + ':' + strPadLeft(minutes,'0',2)+ ':' + strPadLeft(seconds,'0',2);
+ return finalTime;
+ }
+
+ function formatPrintJobPercent(printJob)
+ {
+ if (printJob == null)
+ {
+ return "";
+ }
+ if (printJob.time_total === 0)
+ {
+ return "";
+ }
+ return Math.min(100, Math.round(printJob.time_elapsed / printJob.time_total * 100)) + "%";
+ }
+
+
+ id: printerDelegate
+ property var printer
+
+ border.width: UM.Theme.getSize("default_lining").width
+ border.color: mouse.containsMouse ? UM.Theme.getColor("setting_control_border_highlight") : lineColor
+ z: mouse.containsMouse ? 1 : 0 // Push this item up a bit on mouse over to ensure that the highlighted bottom border is visible.
+
+ property var printJob:
+ {
+ if (printer.reserved_by != null)
+ {
+ // Look in another list.
+ return OutputDevice.printJobsByUUID[printer.reserved_by]
+ }
+ return OutputDevice.printJobsByPrinterUUID[printer.uuid]
+ }
+
+ MouseArea
+ {
+ id: mouse
+ anchors.fill:parent
+ onClicked: OutputDevice.selectPrinter(printer.unique_name, printer.friendly_name)
+ hoverEnabled: true;
+
+ // Only clickable if no printer is selected
+ enabled: OutputDevice.selectedPrinterName == ""
+ }
+
+ Row
+ {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ anchors.margins: UM.Theme.getSize("default_margin").width
+
+ Rectangle
+ {
+ width: parent.width / 3
+ height: parent.height
+
+ Label // Print job name
+ {
+ id: jobNameLabel
+ anchors.top: parent.top
+ anchors.left: parent.left
+ text: printJob != null ? printJob.name : ""
+ font: UM.Theme.getFont("default_bold")
+ }
+
+ Label
+ {
+ id: jobOwnerLabel
+ anchors.top: jobNameLabel.bottom
+ text: printJob != null ? printJob.owner : ""
+ opacity: 0.50
+ }
+
+ Label
+ {
+ id: totalTimeLabel
+ anchors.bottom: parent.bottom
+ text: printJob != null ? getPrettyTime(printJob.time_total) : ""
+ opacity: 0.65
+ font: UM.Theme.getFont("default")
+ }
+ }
+
+ Rectangle
+ {
+ width: parent.width / 3 * 2
+ height: parent.height
+
+ Label // Friendly machine name
+ {
+ id: printerNameLabel
+ anchors.top: parent.top
+ anchors.left: parent.left
+ width: parent.width / 2 - UM.Theme.getSize("default_margin").width - showCameraIcon.width
+ text: printer.friendly_name
+ font: UM.Theme.getFont("default_bold")
+ elide: Text.ElideRight
+ }
+
+ Label // Machine variant
+ {
+ id: printerTypeLabel
+ anchors.top: printerNameLabel.bottom
+ width: parent.width / 2 - UM.Theme.getSize("default_margin").width
+ text: printer.machine_variant
+ anchors.left: parent.left
+ elide: Text.ElideRight
+ font: UM.Theme.getFont("very_small")
+ opacity: 0.50
+ }
+
+ Rectangle // Camera icon
+ {
+ id: showCameraIcon
+ width: 40
+ height: width
+ radius: width
+ anchors.right: printProgressArea.left
+ anchors.rightMargin: UM.Theme.getSize("default_margin").width
+ color: emphasisColor
+ UM.RecolorImage
+ {
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.horizontalCenter: parent.horizontalCenter
+ source: "camera-icon.svg"
+ width: sourceSize.width
+ height: sourceSize.height * width / sourceSize.width
+ color: "white"
+ }
+ }
+
+ Row // PrintCode config
+ {
+ id: extruderInfo
+ anchors.bottom: parent.bottom
+
+ width: parent.width / 2 - UM.Theme.getSize("default_margin").width
+ height: childrenRect.height
+ spacing: 10
+
+ PrintCoreConfiguration
+ {
+ id: leftExtruderInfo
+ width: (parent.width-1) / 2
+ printCoreConfiguration: printer.configuration[0]
+ }
+
+ Rectangle
+ {
+ id: extruderSeperator
+ width: 1
+ height: parent.height
+ color: lineColor
+ }
+
+ PrintCoreConfiguration
+ {
+ id: rightExtruderInfo
+ width: (parent.width-1) / 2
+ printCoreConfiguration: printer.configuration[1]
+ }
+ }
+
+ Rectangle // Print progress
+ {
+ id: printProgressArea
+ anchors.right: parent.right
+ anchors.top: parent.top
+ height: showExtended ? parent.height: printProgressTitleBar.height
+ width: parent.width / 2 - UM.Theme.getSize("default_margin").width
+ border.width: UM.Theme.getSize("default_lining").width
+ border.color: lineColor
+ radius: cornerRadius
+ property var showExtended: {
+ if(printJob!= null)
+ {
+ var extendStates = ["sent_to_printer", "wait_for_configuration", "printing", "pre_print", "post_print", "wait_cleanup"];
+ return extendStates.indexOf(printJob.status) !== -1;
+ }
+ return false
+ }
+ visible:
+ {
+ return true
+ }
+
+ Item // Status and Percent
+ {
+ id: printProgressTitleBar
+ width: parent.width
+ //border.width: UM.Theme.getSize("default_lining").width
+ //border.color: lineColor
+ height: 40
+ anchors.left: parent.left
+
+ Label
+ {
+ id: statusText
+ anchors.left: parent.left
+ anchors.leftMargin: UM.Theme.getSize("default_margin").width
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.right: progressText.left
+ anchors.rightMargin: UM.Theme.getSize("default_margin").width
+ text: {
+ if(printJob != null)
+ {
+ if(printJob.status == "printing" || printJob.status == "post_print")
+ {
+ return catalog.i18nc("@label:status", "Printing")
+ }
+ else if(printJob.status == "wait_for_configuration")
+ {
+ return catalog.i18nc("@label:status", "Reserved")
+ }
+ else if(printJob.status == "wait_cleanup")
+ {
+ return catalog.i18nc("@label:status", "Finished")
+ }
+ else if (printJob.status == "pre_print" || printJob.status == "sent_to_printer")
+ {
+ return catalog.i18nc("@label:status", "Preparing")
+ }
+ else
+ {
+ return ""
+ }
+ }
+ return catalog.i18nc("@label:status", "Available")
+ }
+
+ elide: Text.ElideRight
+
+ font: UM.Theme.getFont("small")
+ }
+ Label
+ {
+ id: progressText
+ anchors.right: parent.right
+ anchors.rightMargin: UM.Theme.getSize("default_margin").width
+
+ anchors.top: statusText.top
+
+ text: formatPrintJobPercent(printJob)
+ visible: printJob != null && (["printing", "post_print", "pre_print", "sent_to_printer"].indexOf(printJob.status) !== -1)
+ opacity: 0.65
+ font: UM.Theme.getFont("very_small")
+ }
+ Rectangle
+ {
+ //TODO: This will become a progress bar in the future
+ width: parent.width
+ height: UM.Theme.getSize("default_lining").height
+ anchors.bottom: parent.bottom
+ anchors.left: parent.left
+ visible: printProgressArea.showExtended
+ color: lineColor
+ }
+ }
+
+ Column
+ {
+ anchors.left: parent.left
+ anchors.leftMargin: UM.Theme.getSize("default_margin").width
+
+ anchors.top: printProgressTitleBar.bottom
+ anchors.topMargin: UM.Theme.getSize("default_margin").height
+
+ width: parent.width - 2 * UM.Theme.getSize("default_margin").width
+
+ visible: printJob != null && (["wait_cleanup", "printing", "pre_print", "wait_for_configuration"].indexOf(printJob.status) !== -1)
+
+ Label // Status detail
+ {
+ text:
+ {
+ if(printJob != null)
+ {
+ if(printJob.status == "printing" )
+ {
+ return catalog.i18nc("@label", "Finishes at: ") + OutputDevice.getTimeCompleted(printJob.time_total - printJob.time_elapsed)
+ }
+ if(printJob.status == "wait_cleanup")
+ {
+ return catalog.i18nc("@label", "Clear build plate")
+ }
+ if(printJob.status == "sent_to_printer" || printJob.status == "pre_print")
+ {
+ return catalog.i18nc("@label", "Preparing to print")
+ }
+ if(printJob.status == "wait_for_configuration")
+ {
+ return catalog.i18nc("@label", "Not accepting print jobs")
+ }
+ }
+ return ""
+ }
+ elide: Text.ElideRight
+ font: UM.Theme.getFont("default")
+ }
+
+ Label // Status 2nd row
+ {
+ text: {
+ if(printJob != null) {
+ if(printJob.status == "printing" )
+ {
+ return OutputDevice.getDateCompleted(printJob.time_total - printJob.time_elapsed)
+ }
+ }
+ return "";
+ }
+
+ elide: Text.ElideRight
+ font: UM.Theme.getFont("default")
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/plugins/UM3NetworkPrinting/PrinterTile.qml b/plugins/UM3NetworkPrinting/PrinterTile.qml
new file mode 100644
index 0000000000..f240f3034f
--- /dev/null
+++ b/plugins/UM3NetworkPrinting/PrinterTile.qml
@@ -0,0 +1,54 @@
+import QtQuick 2.2
+import QtQuick.Controls 1.4
+import QtQuick.Controls.Styles 1.4
+
+import UM 1.3 as UM
+import Cura 1.0 as Cura
+
+Rectangle
+{
+ id: base
+ width: 250
+ height: 250
+ signal clicked()
+ MouseArea
+ {
+ anchors.fill:parent
+ onClicked: base.clicked()
+ }
+ Rectangle
+ {
+ // TODO: Actually add UM icon / picture
+ width: 100
+ height: 100
+ border.width: UM.Theme.getSize("default_lining").width
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.top: parent.top
+ anchors.topMargin: UM.Theme.getSize("default_margin").height
+ }
+ Label
+ {
+ id: nameLabel
+ anchors.bottom: ipLabel.top
+ anchors.bottomMargin: UM.Theme.getSize("default_margin").height
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.leftMargin: UM.Theme.getSize("default_margin").width
+ anchors.rightMargin: UM.Theme.getSize("default_margin").width
+ text: modelData.friendly_name.toString()
+ font: UM.Theme.getFont("large")
+ elide: Text.ElideMiddle;
+ height: UM.Theme.getSize("section").height;
+ }
+ Label
+ {
+ id: ipLabel
+ text: modelData.ip_address.toString()
+ anchors.bottom: parent.bottom
+ anchors.bottomMargin: UM.Theme.getSize("default_margin").height
+ font: UM.Theme.getFont("default")
+ height:10
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+}
+
diff --git a/plugins/UM3NetworkPrinting/PrinterVideoStream.qml b/plugins/UM3NetworkPrinting/PrinterVideoStream.qml
new file mode 100644
index 0000000000..4f138ee8d1
--- /dev/null
+++ b/plugins/UM3NetworkPrinting/PrinterVideoStream.qml
@@ -0,0 +1,91 @@
+import QtQuick 2.2
+import QtQuick.Controls 1.4
+import QtQuick.Controls.Styles 1.4
+
+import UM 1.3 as UM
+
+
+Item
+{
+ Rectangle
+ {
+ anchors.fill:parent
+ color: UM.Theme.getColor("viewport_overlay")
+ opacity: 0.5
+ }
+
+ MouseArea
+ {
+ anchors.fill: parent
+ onClicked: OutputDevice.selectAutomaticPrinter()
+ z: 0
+ }
+
+ Button
+ {
+ id: backButton
+ anchors.bottom: cameraImage.top
+ anchors.bottomMargin: UM.Theme.getSize("default_margin").width
+ anchors.right: cameraImage.right
+
+ // TODO: Harcoded sizes
+ width: 20
+ height: 20
+
+ onClicked: OutputDevice.selectAutomaticPrinter()
+
+ style: ButtonStyle
+ {
+ label: Item
+ {
+ UM.RecolorImage
+ {
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: control.width
+ height: control.height
+ sourceSize.width: width
+ sourceSize.height: width
+ source: UM.Theme.getIcon("cross1")
+ }
+ }
+ background: Item {}
+ }
+ }
+
+ Image
+ {
+ id: cameraImage
+ width: Math.min(sourceSize.width === 0 ? 800 : sourceSize.width, maximumWidth)
+ height: (sourceSize.height === 0 ? 600 : sourceSize.height) * width / sourceSize.width
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.verticalCenter: parent.verticalCenter
+ z: 1
+ onVisibleChanged:
+ {
+ if(visible)
+ {
+ OutputDevice.startCamera()
+ } else
+ {
+ OutputDevice.stopCamera()
+ }
+ }
+ source:
+ {
+ if(OutputDevice.cameraImage)
+ {
+ return OutputDevice.cameraImage;
+ }
+ return "";
+ }
+ }
+
+ MouseArea
+ {
+ anchors.fill: cameraImage
+ onClicked: { /* no-op */ }
+ z: 1
+ }
+
+}
diff --git a/plugins/UM3NetworkPrinting/camera-icon.svg b/plugins/UM3NetworkPrinting/camera-icon.svg
new file mode 100644
index 0000000000..2aafc4b6f4
--- /dev/null
+++ b/plugins/UM3NetworkPrinting/camera-icon.svg
@@ -0,0 +1,3 @@
+