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 @@ + + +