diff --git a/.gitignore b/.gitignore index fd01d5ce76..6c4ee96d3a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -# Compiled and generated things. +#Compiled and generated things. build *.pyc __pycache__ @@ -32,4 +32,5 @@ plugins/Doodle3D-cura-plugin plugins/GodMode plugins/PostProcessingPlugin plugins/UM3NetworkPrinting -plugins/X3GWriter \ No newline at end of file +plugins/X3GWriter + diff --git a/CMakeLists.txt b/CMakeLists.txt index c591ffa625..bf93166e2e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,4 +1,3 @@ - project(cura NONE) cmake_minimum_required(VERSION 2.8.12) diff --git a/DiscoverUM3Action.py b/DiscoverUM3Action.py new file mode 100644 index 0000000000..c4ffdb8472 --- /dev/null +++ b/DiscoverUM3Action.py @@ -0,0 +1,148 @@ +from cura.MachineAction import MachineAction + +from UM.Application import Application +from UM.PluginRegistry import PluginRegistry +from UM.Logger import Logger + +from PyQt5.QtCore import pyqtSignal, pyqtProperty, pyqtSlot, QUrl, QObject +from PyQt5.QtQml import QQmlComponent, QQmlContext + +import os.path + +import time + +from UM.i18n import i18nCatalog +catalog = i18nCatalog("cura") + +class DiscoverUM3Action(MachineAction): + def __init__(self): + super().__init__("DiscoverUM3Action", catalog.i18nc("@action","Connect via Network")) + self._qml_url = "DiscoverUM3Action.qml" + + self._network_plugin = None + + self.__additional_components_context = None + self.__additional_component = None + self.__additional_components_view = None + + Application.getInstance().engineCreatedSignal.connect(self._createAdditionalComponentsView) + + self._last_zeroconf_event_time = time.time() + self._zeroconf_change_grace_period = 0.25 # Time to wait after a zeroconf service change before allowing a zeroconf reset + + printersChanged = pyqtSignal() + + @pyqtSlot() + def startDiscovery(self): + if not self._network_plugin: + self._network_plugin = Application.getInstance().getOutputDeviceManager().getOutputDevicePlugin("UM3NetworkPrinting") + self._network_plugin.printerListChanged.connect(self._onPrinterDiscoveryChanged) + self.printersChanged.emit() + + ## Re-filters the list of printers. + @pyqtSlot() + def reset(self): + self.printersChanged.emit() + + @pyqtSlot() + def restartDiscovery(self): + # Ensure that there is a bit of time after a printer has been discovered. + # This is a work around for an issue with Qt 5.5.1 up to Qt 5.7 which can segfault if we do this too often. + # It's most likely that the QML engine is still creating delegates, where the python side already deleted or + # garbage collected the data. + # Whatever the case, waiting a bit ensures that it doesn't crash. + if time.time() - self._last_zeroconf_event_time > self._zeroconf_change_grace_period: + if not self._network_plugin: + self.startDiscovery() + else: + self._network_plugin.startDiscovery() + + @pyqtSlot(str, str) + def removeManualPrinter(self, key, address): + if not self._network_plugin: + return + + self._network_plugin.removeManualPrinter(key, address) + + @pyqtSlot(str, str) + def setManualPrinter(self, key, address): + if key != "": + # This manual printer replaces a current manual printer + self._network_plugin.removeManualPrinter(key) + + if address != "": + self._network_plugin.addManualPrinter(address) + + def _onPrinterDiscoveryChanged(self, *args): + self._last_zeroconf_event_time = time.time() + self.printersChanged.emit() + + @pyqtProperty("QVariantList", notify = printersChanged) + def foundDevices(self): + if self._network_plugin: + if Application.getInstance().getGlobalContainerStack(): + global_printer_type = Application.getInstance().getGlobalContainerStack().getBottom().getId() + else: + global_printer_type = "unknown" + + printers = list(self._network_plugin.getPrinters().values()) + # TODO; There are still some testing printers that don't have a correct printer type, so don't filter out unkown ones just yet. + printers = [printer for printer in printers if printer.printerType == global_printer_type or printer.printerType == "unknown"] + printers.sort(key = lambda k: k.name) + return printers + else: + return [] + + @pyqtSlot(str) + def setKey(self, key): + global_container_stack = Application.getInstance().getGlobalContainerStack() + if global_container_stack: + meta_data = global_container_stack.getMetaData() + if "um_network_key" in meta_data: + global_container_stack.setMetaDataEntry("um_network_key", key) + # Delete old authentication data. + global_container_stack.removeMetaDataEntry("network_authentication_id") + global_container_stack.removeMetaDataEntry("network_authentication_key") + else: + global_container_stack.addMetaDataEntry("um_network_key", key) + + if self._network_plugin: + # Ensure that the connection states are refreshed. + self._network_plugin.reCheckConnections() + + @pyqtSlot(result = str) + def getStoredKey(self): + global_container_stack = Application.getInstance().getGlobalContainerStack() + if global_container_stack: + meta_data = global_container_stack.getMetaData() + if "um_network_key" in meta_data: + return global_container_stack.getMetaDataEntry("um_network_key") + + return "" + + @pyqtSlot() + def loadConfigurationFromPrinter(self): + machine_manager = Application.getInstance().getMachineManager() + hotend_ids = machine_manager.printerOutputDevices[0].hotendIds + for index in range(len(hotend_ids)): + machine_manager.printerOutputDevices[0].hotendIdChanged.emit(index, hotend_ids[index]) + material_ids = machine_manager.printerOutputDevices[0].materialIds + for index in range(len(material_ids)): + machine_manager.printerOutputDevices[0].materialIdChanged.emit(index, material_ids[index]) + + def _createAdditionalComponentsView(self): + Logger.log("d", "Creating additional ui components for UM3.") + path = QUrl.fromLocalFile(os.path.join(PluginRegistry.getInstance().getPluginPath("UM3NetworkPrinting"), "UM3InfoComponents.qml")) + self.__additional_component = QQmlComponent(Application.getInstance()._engine, path) + + # We need access to engine (although technically we can't) + self.__additional_components_context = QQmlContext(Application.getInstance()._engine.rootContext()) + self.__additional_components_context.setContextProperty("manager", self) + + self.__additional_components_view = self.__additional_component.create(self.__additional_components_context) + if not self.__additional_components_view: + Logger.log("w", "Could not create ui components for UM3.") + return + + Application.getInstance().addAdditionalComponent("monitorButtons", self.__additional_components_view.findChild(QObject, "networkPrinterConnectButton")) + Application.getInstance().addAdditionalComponent("machinesDetailPane", self.__additional_components_view.findChild(QObject, "networkPrinterConnectionInfo")) diff --git a/DiscoverUM3Action.qml b/DiscoverUM3Action.qml new file mode 100644 index 0000000000..a80ed1d179 --- /dev/null +++ b/DiscoverUM3Action.qml @@ -0,0 +1,369 @@ +import UM 1.2 as UM +import Cura 1.0 as Cura + +import QtQuick 2.2 +import QtQuick.Controls 1.1 +import QtQuick.Layouts 1.1 +import QtQuick.Window 2.1 + +Cura.MachineAction +{ + id: base + anchors.fill: parent; + property var selectedPrinter: null + property bool completeProperties: true + property var connectingToPrinter: null + + Connections + { + target: dialog ? dialog : null + ignoreUnknownSignals: true + onNextClicked: + { + // Connect to the printer if the MachineAction is currently shown + if(base.parent.wizard == dialog) + { + connectToPrinter(); + } + } + } + + function connectToPrinter() + { + if(base.selectedPrinter && base.completeProperties) + { + var printerKey = base.selectedPrinter.getKey() + if(connectingToPrinter != printerKey) { + // prevent an infinite loop + connectingToPrinter = printerKey; + manager.setKey(printerKey); + completed(); + } + } + } + + Column + { + anchors.fill: parent; + id: discoverUM3Action + spacing: UM.Theme.getSize("default_margin").height + + SystemPalette { id: palette } + UM.I18nCatalog { id: catalog; name:"cura" } + Label + { + id: pageTitle + width: parent.width + text: catalog.i18nc("@title:window", "Connect to Networked Printer") + wrapMode: Text.WordWrap + font.pointSize: 18 + } + + Label + { + id: pageDescription + width: parent.width + wrapMode: Text.WordWrap + text: catalog.i18nc("@label", "To print directly to your printer over the network, please make sure your printer is connected to the network using a network cable or by connecting your printer to your WIFI network. If you don't connect Cura with your printer, you can still use a USB drive to transfer g-code files to your printer.\n\nSelect your printer from the list below:") + } + + Row + { + spacing: UM.Theme.getSize("default_lining").width + + Button + { + id: addButton + text: catalog.i18nc("@action:button", "Add"); + onClicked: + { + manualPrinterDialog.showDialog("", ""); + } + } + + Button + { + id: editButton + text: catalog.i18nc("@action:button", "Edit") + enabled: base.selectedPrinter != null && base.selectedPrinter.getProperty("manual") == "true" + onClicked: + { + manualPrinterDialog.showDialog(base.selectedPrinter.getKey(), base.selectedPrinter.ipAddress); + } + } + + Button + { + id: removeButton + text: catalog.i18nc("@action:button", "Remove") + enabled: base.selectedPrinter != null && base.selectedPrinter.getProperty("manual") == "true" + onClicked: manager.removeManualPrinter(base.selectedPrinter.getKey(), base.selectedPrinter.ipAddress) + } + + Button + { + id: rediscoverButton + text: catalog.i18nc("@action:button", "Refresh") + onClicked: manager.restartDiscovery() + } + } + + Row + { + id: contentRow + width: parent.width + spacing: UM.Theme.getSize("default_margin").width + + Column + { + width: parent.width * 0.5 + spacing: UM.Theme.getSize("default_margin").height + + ScrollView + { + id: objectListContainer + frameVisible: true + width: parent.width + height: base.height - contentRow.y - discoveryTip.height + + Rectangle + { + parent: viewport + anchors.fill: parent + color: palette.light + } + + ListView + { + id: listview + model: manager.foundDevices + onModelChanged: + { + var selectedKey = manager.getStoredKey(); + for(var i = 0; i < model.length; i++) { + if(model[i].getKey() == selectedKey) + { + currentIndex = i; + return + } + } + currentIndex = -1; + } + width: parent.width + currentIndex: -1 + onCurrentIndexChanged: + { + base.selectedPrinter = listview.model[currentIndex]; + // Only allow connecting if the printer has responded to API query since the last refresh + base.completeProperties = base.selectedPrinter != null && base.selectedPrinter.getProperty("incomplete") != "true"; + } + Component.onCompleted: manager.startDiscovery() + delegate: Rectangle + { + height: childrenRect.height + color: ListView.isCurrentItem ? palette.highlight : index % 2 ? palette.base : palette.alternateBase + width: parent.width + Label + { + anchors.left: parent.left + anchors.leftMargin: UM.Theme.getSize("default_margin").width + anchors.right: parent.right + text: listview.model[index].name + color: parent.ListView.isCurrentItem ? palette.highlightedText : palette.text + elide: Text.ElideRight + } + + MouseArea + { + anchors.fill: parent; + onClicked: + { + if(!parent.ListView.isCurrentItem) + { + parent.ListView.view.currentIndex = index; + } + } + } + } + } + } + Label + { + id: discoveryTip + anchors.left: parent.left + anchors.right: parent.right + wrapMode: Text.WordWrap + //: Tips label + //TODO: get actual link from webteam + text: catalog.i18nc("@label", "If your printer is not listed, read the network-printing troubleshooting guide").arg("https://ultimaker.com/en/troubleshooting"); + onLinkActivated: Qt.openUrlExternally(link) + } + + } + Column + { + width: parent.width * 0.5 + visible: base.selectedPrinter ? true : false + spacing: UM.Theme.getSize("default_margin").height + Label + { + width: parent.width + wrapMode: Text.WordWrap + text: base.selectedPrinter ? base.selectedPrinter.name : "" + font: UM.Theme.getFont("large") + elide: Text.ElideRight + } + Grid + { + visible: base.completeProperties + width: parent.width + columns: 2 + Label + { + width: parent.width * 0.5 + wrapMode: Text.WordWrap + text: catalog.i18nc("@label", "Type") + } + Label + { + width: parent.width * 0.5 + wrapMode: Text.WordWrap + text: + { + if(base.selectedPrinter) + { + if(base.selectedPrinter.printerType == "ultimaker3") + { + return catalog.i18nc("@label", "Ultimaker 3") + } else if(base.selectedPrinter.printerType == "ultimaker3_extended") + { + return catalog.i18nc("@label", "Ultimaker 3 Extended") + } else + { + return catalog.i18nc("@label", "Unknown") // We have no idea what type it is. Should not happen 'in the field' + } + } + else + { + return "" + } + } + } + Label + { + width: parent.width * 0.5 + wrapMode: Text.WordWrap + text: catalog.i18nc("@label", "Firmware version") + } + Label + { + width: parent.width * 0.5 + wrapMode: Text.WordWrap + text: base.selectedPrinter ? base.selectedPrinter.firmwareVersion : "" + } + Label + { + width: parent.width * 0.5 + wrapMode: Text.WordWrap + text: catalog.i18nc("@label", "Address") + } + Label + { + width: parent.width * 0.5 + wrapMode: Text.WordWrap + text: base.selectedPrinter ? base.selectedPrinter.ipAddress : "" + } + } + Label + { + width: parent.width + wrapMode: Text.WordWrap + visible: base.selectedPrinter != null && !base.completeProperties + text: catalog.i18nc("@label", "The printer at this address has not yet responded." ) + } + + Button + { + text: catalog.i18nc("@action:button", "Connect") + enabled: (base.selectedPrinter && base.completeProperties) ? true : false + onClicked: connectToPrinter() + } + } + } + } + + UM.Dialog + { + id: manualPrinterDialog + property string printerKey + property alias addressText: addressField.text + + title: catalog.i18nc("@title:window", "Printer Address") + + minimumWidth: 400 * Screen.devicePixelRatio + minimumHeight: 120 * Screen.devicePixelRatio + width: minimumWidth + height: minimumHeight + + signal showDialog(string key, string address) + onShowDialog: + { + printerKey = key; + + addressText = address; + addressField.selectAll(); + addressField.focus = true; + + manualPrinterDialog.show(); + } + + onAccepted: + { + manager.setManualPrinter(printerKey, addressText) + } + + Column { + anchors.fill: parent + spacing: UM.Theme.getSize("default_margin").height + + Label + { + text: catalog.i18nc("@alabel","Enter the IP address or hostname of your printer on the network.") + width: parent.width + wrapMode: Text.WordWrap + } + + TextField + { + id: addressField + width: parent.width + maximumLength: 40 + validator: RegExpValidator + { + regExp: /[a-zA-Z0-9\.\-\_]*/ + } + } + } + + rightButtons: [ + Button { + text: catalog.i18nc("@action:button","Cancel") + onClicked: + { + manualPrinterDialog.reject() + manualPrinterDialog.hide() + } + }, + Button { + text: catalog.i18nc("@action:button", "Ok") + onClicked: + { + manualPrinterDialog.accept() + manualPrinterDialog.hide() + } + enabled: manualPrinterDialog.addressText.trim() != "" + isDefault: true + } + ] + } +} \ No newline at end of file diff --git a/LICENSE b/LICENSE index 3ffc567893..58777e31af 100644 --- a/LICENSE +++ b/LICENSE @@ -658,4 +658,4 @@ specific requirements. You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see -. \ No newline at end of file +. diff --git a/NetworkPrinterOutputDevice.py b/NetworkPrinterOutputDevice.py new file mode 100644 index 0000000000..356f61b172 --- /dev/null +++ b/NetworkPrinterOutputDevice.py @@ -0,0 +1,1019 @@ +# Copyright (c) 2016 Ultimaker B.V. +# Cura is released under the terms of the AGPLv3 or higher. + +from UM.i18n import i18nCatalog +from UM.Application import Application +from UM.Logger import Logger +from UM.Signal import signalemitter + +from UM.Message import Message + +import UM.Settings + +from cura.PrinterOutputDevice import PrinterOutputDevice, ConnectionState +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.QtWidgets import QMessageBox + +import json +import os +import gzip +import zlib + +from time import time +from time import sleep + +i18n_catalog = i18nCatalog("cura") + +from enum import IntEnum + +class AuthState(IntEnum): + NotAuthenticated = 1 + AuthenticationRequested = 2 + Authenticated = 3 + AuthenticationDenied = 4 + +## Network connected (wifi / lan) printer that uses the Ultimaker API +@signalemitter +class NetworkPrinterOutputDevice(PrinterOutputDevice): + def __init__(self, key, address, properties, api_prefix): + super().__init__(key) + self._address = address + self._key = key + self._properties = properties # Properties dict as provided by zero conf + self._api_prefix = api_prefix + + self._gcode = None + self._print_finished = True # _print_finsihed == False means we're halfway in a print + + self._use_gzip = True # Should we use g-zip compression before sending the data? + + # This holds the full JSON file that was received from the last request. + # The JSON looks like: + #{ + # "led": {"saturation": 0.0, "brightness": 100.0, "hue": 0.0}, + # "beep": {}, + # "network": { + # "wifi_networks": [], + # "ethernet": {"connected": true, "enabled": true}, + # "wifi": {"ssid": "xxxx", "connected": False, "enabled": False} + # }, + # "diagnostics": {}, + # "bed": {"temperature": {"target": 60.0, "current": 44.4}}, + # "heads": [{ + # "max_speed": {"z": 40.0, "y": 300.0, "x": 300.0}, + # "position": {"z": 20.0, "y": 6.0, "x": 180.0}, + # "fan": 0.0, + # "jerk": {"z": 0.4, "y": 20.0, "x": 20.0}, + # "extruders": [ + # { + # "feeder": {"max_speed": 45.0, "jerk": 5.0, "acceleration": 3000.0}, + # "active_material": {"guid": "xxxxxxx", "length_remaining": -1.0}, + # "hotend": {"temperature": {"target": 0.0, "current": 22.8}, "id": "AA 0.4"} + # }, + # { + # "feeder": {"max_speed": 45.0, "jerk": 5.0, "acceleration": 3000.0}, + # "active_material": {"guid": "xxxx", "length_remaining": -1.0}, + # "hotend": {"temperature": {"target": 0.0, "current": 22.8}, "id": "BB 0.4"} + # } + # ], + # "acceleration": 3000.0 + # }], + # "status": "printing" + #} + + self._json_printer_state = {} + + ## Todo: Hardcoded value now; we should probably read this from the machine file. + ## It's okay to leave this for now, as this plugin is um3 only (and has 2 extruders by definition) + self._num_extruders = 2 + + # These are reinitialised here (from PrinterOutputDevice) to match the new _num_extruders + self._hotend_temperatures = [0] * self._num_extruders + self._target_hotend_temperatures = [0] * self._num_extruders + + self._material_ids = [""] * self._num_extruders + self._hotend_ids = [""] * self._num_extruders + + self.setPriority(2) # 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")) + self.setIconName("print") + + self._manager = None + + self._post_request = None + self._post_reply = None + self._post_multi_part = None + self._post_part = None + + self._material_multi_part = None + self._material_part = None + + self._progress_message = None + self._error_message = None + self._connection_message = None + + self._update_timer = QTimer() + self._update_timer.setInterval(2000) # TODO; Add preference for update interval + self._update_timer.setSingleShot(False) + self._update_timer.timeout.connect(self._update) + + self._camera_timer = QTimer() + self._camera_timer.setInterval(500) # Todo: Add preference for camera update interval + self._camera_timer.setSingleShot(False) + self._camera_timer.timeout.connect(self._updateCamera) + + self._image_request = None + self._image_reply = None + + self._use_stream = True + self._stream_buffer = b"" + self._stream_buffer_start_index = -1 + + self._camera_image_id = 0 + + self._authentication_counter = 0 + self._max_authentication_counter = 5 * 60 # Number of attempts before authentication timed out (5 min) + + self._authentication_timer = QTimer() + self._authentication_timer.setInterval(1000) # TODO; Add preference for update interval + self._authentication_timer.setSingleShot(False) + self._authentication_timer.timeout.connect(self._onAuthenticationTimer) + self._authentication_request_active = False + + self._authentication_state = AuthState.NotAuthenticated + self._authentication_id = None + self._authentication_key = None + + self._authentication_requested_message = Message(i18n_catalog.i18nc("@info:status", "Access to the printer requested. Please approve the request on the printer"), lifetime = 0, dismissable = False, progress = 0) + self._authentication_failed_message = Message(i18n_catalog.i18nc("@info:status", "")) + self._authentication_failed_message.addAction("Retry", i18n_catalog.i18nc("@action:button", "Retry"), None, i18n_catalog.i18nc("@info:tooltip", "Re-send the access request")) + self._authentication_failed_message.actionTriggered.connect(self.requestAuthentication) + self._authentication_succeeded_message = Message(i18n_catalog.i18nc("@info:status", "Access to the printer accepted")) + self._not_authenticated_message = Message(i18n_catalog.i18nc("@info:status", "No access to print with this printer. Unable to send print job.")) + self._not_authenticated_message.addAction("Request", i18n_catalog.i18nc("@action:button", "Request Access"), None, i18n_catalog.i18nc("@info:tooltip", "Send access request to the printer")) + self._not_authenticated_message.actionTriggered.connect(self.requestAuthentication) + + self._camera_image = QImage() + + self._material_post_objects = {} + self._connection_state_before_timeout = None + + self._last_response_time = time() + self._last_request_time = None + self._response_timeout_time = 10 + self._recreate_network_manager_time = 30 # If we have no connection, re-create network manager every 30 sec. + self._recreate_network_manager_count = 1 + + self._send_gcode_start = time() # Time when the sending of the g-code started. + + self._last_command = "" + + self._compressing_print = False + + printer_type = self._properties.get(b"machine", b"").decode("utf-8") + if printer_type.startswith("9511"): + self._updatePrinterType("ultimaker3_extended") + elif printer_type.startswith("9066"): + self._updatePrinterType("ultimaker3") + else: + self._updatePrinterType("unknown") + + def _onNetworkAccesibleChanged(self, accessible): + Logger.log("d", "Network accessible state changed to: %s", accessible) + + def _onAuthenticationTimer(self): + self._authentication_counter += 1 + self._authentication_requested_message.setProgress(self._authentication_counter / self._max_authentication_counter * 100) + if self._authentication_counter > self._max_authentication_counter: + self._authentication_timer.stop() + Logger.log("i", "Authentication timer ended. Setting authentication to denied") + self.setAuthenticationState(AuthState.AuthenticationDenied) + + def _onAuthenticationRequired(self, reply, authenticator): + if self._authentication_id is not None and self._authentication_key is not None: + Logger.log("d", "Authentication was required. Setting up authenticator.") + authenticator.setUser(self._authentication_id) + authenticator.setPassword(self._authentication_key) + + def getProperties(self): + return self._properties + + @pyqtSlot(str, result = str) + def getProperty(self, key): + key = key.encode("utf-8") + if key in self._properties: + return self._properties.get(key, b"").decode("utf-8") + else: + return "" + + ## Get the unique key of this machine + # \return key String containing the key of the machine. + @pyqtSlot(result = str) + def getKey(self): + return self._key + + ## Name of the printer (as returned from the zeroConf properties) + @pyqtProperty(str, constant = True) + def name(self): + return self._properties.get(b"name", b"").decode("utf-8") + + ## Firmware version (as returned from the zeroConf properties) + @pyqtProperty(str, constant=True) + def firmwareVersion(self): + return self._properties.get(b"firmware_version", b"").decode("utf-8") + + ## IPadress of this printer + @pyqtProperty(str, constant=True) + def ipAddress(self): + return self._address + + def _stopCamera(self): + self._camera_timer.stop() + if self._image_reply: + self._image_reply.abort() + self._image_reply.downloadProgress.disconnect(self._onStreamDownloadProgress) + self._image_reply = None + self._image_request = None + + def _startCamera(self): + if self._use_stream: + self._startCameraStream() + else: + self._camera_timer.start() + + def _startCameraStream(self): + ## Request new image + url = QUrl("http://" + self._address + ":8080/?action=stream") + self._image_request = QNetworkRequest(url) + self._image_reply = self._manager.get(self._image_request) + self._image_reply.downloadProgress.connect(self._onStreamDownloadProgress) + + def _updateCamera(self): + if not self._manager.networkAccessible(): + return + ## Request new image + url = QUrl("http://" + self._address + ":8080/?action=snapshot") + image_request = QNetworkRequest(url) + self._manager.get(image_request) + self._last_request_time = time() + + ## Set the authentication state. + # \param auth_state \type{AuthState} Enum value representing the new auth state + def setAuthenticationState(self, auth_state): + if auth_state == AuthState.AuthenticationRequested: + Logger.log("d", "Authentication state changed to authentication requested.") + self.setAcceptsCommands(False) + self.setConnectionText(i18n_catalog.i18nc("@info:status", "Connected over the network to {0}. Please approve the access request on the printer.").format(self.name)) + self._authentication_requested_message.show() + self._authentication_request_active = True + self._authentication_timer.start() # Start timer so auth will fail after a while. + elif auth_state == AuthState.Authenticated: + Logger.log("d", "Authentication state changed to authenticated") + self.setAcceptsCommands(True) + self.setConnectionText(i18n_catalog.i18nc("@info:status", "Connected over the network to {0}.").format(self.name)) + self._authentication_requested_message.hide() + if self._authentication_request_active: + self._authentication_succeeded_message.show() + + # Stop waiting for a response + self._authentication_timer.stop() + self._authentication_counter = 0 + + # Once we are authenticated we need to send all material profiles. + self.sendMaterialProfiles() + elif auth_state == AuthState.AuthenticationDenied: + self.setAcceptsCommands(False) + self.setConnectionText(i18n_catalog.i18nc("@info:status", "Connected over the network to {0}. No access to control the printer.").format(self.name)) + self._authentication_requested_message.hide() + if self._authentication_request_active: + if self._authentication_timer.remainingTime() > 0: + Logger.log("d", "Authentication state changed to authentication denied before the request timeout.") + self._authentication_failed_message.setText(i18n_catalog.i18nc("@info:status", "Access request was denied on the printer.")) + else: + Logger.log("d", "Authentication state changed to authentication denied due to a timeout") + self._authentication_failed_message.setText(i18n_catalog.i18nc("@info:status", "Access request failed due to a timeout.")) + + self._authentication_failed_message.show() + self._authentication_request_active = False + + # Stop waiting for a response + self._authentication_timer.stop() + self._authentication_counter = 0 + + if auth_state != self._authentication_state: + self._authentication_state = auth_state + self.authenticationStateChanged.emit() + + authenticationStateChanged = pyqtSignal() + + @pyqtProperty(int, notify = authenticationStateChanged) + def authenticationState(self): + return self._authentication_state + + @pyqtSlot() + def requestAuthentication(self, message_id = None, action_id = "Retry"): + if action_id == "Request" or action_id == "Retry": + self._authentication_failed_message.hide() + self._not_authenticated_message.hide() + self._authentication_state = AuthState.NotAuthenticated + self._authentication_counter = 0 + self._authentication_requested_message.setProgress(0) + self._authentication_id = None + self._authentication_key = None + self._createNetworkManager() # Re-create network manager to force re-authentication. + + ## Request data from the connected device. + def _update(self): + if self._last_response_time: + time_since_last_response = time() - self._last_response_time + else: + time_since_last_response = 0 + if self._last_request_time: + time_since_last_request = time() - self._last_request_time + else: + time_since_last_request = float("inf") # An irrelevantly large number of seconds + + # Connection is in timeout, check if we need to re-start the connection. + # Sometimes the qNetwork manager incorrectly reports the network status on Mac & Windows. + # Re-creating the QNetworkManager seems to fix this issue. + if self._last_response_time and self._connection_state_before_timeout: + if time_since_last_response > self._recreate_network_manager_time * self._recreate_network_manager_count: + self._recreate_network_manager_count += 1 + counter = 0 # Counter to prevent possible indefinite while loop. + # It can happen that we had a very long timeout (multiple times the recreate time). + # In that case we should jump through the point that the next update won't be right away. + while time_since_last_response - self._recreate_network_manager_time * self._recreate_network_manager_count > self._recreate_network_manager_time and counter < 10: + counter += 1 + self._recreate_network_manager_count += 1 + Logger.log("d", "Timeout lasted over %.0f seconds (%.1fs), re-checking connection.", self._recreate_network_manager_time, time_since_last_response) + self._createNetworkManager() + return + + # Check if we have an connection in the first place. + if not self._manager.networkAccessible(): + if not self._connection_state_before_timeout: + Logger.log("d", "The network connection seems to be disabled. Going into timeout mode") + self._connection_state_before_timeout = self._connection_state + self.setConnectionState(ConnectionState.error) + self._connection_message = Message(i18n_catalog.i18nc("@info:status", + "The connection with the network was lost.")) + self._connection_message.show() + + if self._progress_message: + self._progress_message.hide() + + # Check if we were uploading something. Abort if this is the case. + # Some operating systems handle this themselves, others give weird issues. + try: + if self._post_reply: + Logger.log("d", "Stopping post upload because the connection was lost.") + try: + self._post_reply.uploadProgress.disconnect(self._onUploadProgress) + except TypeError: + pass # The disconnection can fail on mac in some cases. Ignore that. + + self._post_reply.abort() + self._post_reply = None + except RuntimeError: + self._post_reply = None # It can happen that the wrapped c++ object is already deleted. + return + else: + if not self._connection_state_before_timeout: + self._recreate_network_manager_count = 1 + + # Check that we aren't in a timeout state + if self._last_response_time and self._last_request_time and not self._connection_state_before_timeout: + if time_since_last_response > self._response_timeout_time and time_since_last_request <= self._response_timeout_time: + # Go into timeout state. + Logger.log("d", "We did not receive a response for %0.1f seconds, so it seems the printer is no longer accessible.", time_since_last_response) + self._connection_state_before_timeout = self._connection_state + self._connection_message = Message(i18n_catalog.i18nc("@info:status", "The connection with the printer was lost. Check your printer to see if it is connected.")) + self._connection_message.show() + + if self._progress_message: + self._progress_message.hide() + + # Check if we were uploading something. Abort if this is the case. + # Some operating systems handle this themselves, others give weird issues. + try: + if self._post_reply: + Logger.log("d", "Stopping post upload because the connection was lost.") + try: + self._post_reply.uploadProgress.disconnect(self._onUploadProgress) + except TypeError: + pass # The disconnection can fail on mac in some cases. Ignore that. + + self._post_reply.abort() + self._post_reply = None + except RuntimeError: + self._post_reply = None # It can happen that the wrapped c++ object is already deleted. + self.setConnectionState(ConnectionState.error) + return + + if self._authentication_state == AuthState.NotAuthenticated: + self._verifyAuthentication() # We don't know if we are authenticated; check if we have correct auth. + elif self._authentication_state == AuthState.AuthenticationRequested: + self._checkAuthentication() # We requested authentication at some point. Check if we got permission. + + ## Request 'general' printer data + url = QUrl("http://" + self._address + self._api_prefix + "printer") + printer_request = QNetworkRequest(url) + self._manager.get(printer_request) + + ## Request print_job data + url = QUrl("http://" + self._address + self._api_prefix + "print_job") + print_job_request = QNetworkRequest(url) + self._manager.get(print_job_request) + + self._last_request_time = time() + + def _createNetworkManager(self): + if self._manager: + self._manager.finished.disconnect(self._onFinished) + self._manager.networkAccessibleChanged.disconnect(self._onNetworkAccesibleChanged) + self._manager.authenticationRequired.disconnect(self._onAuthenticationRequired) + + self._manager = QNetworkAccessManager() + self._manager.finished.connect(self._onFinished) + self._manager.authenticationRequired.connect(self._onAuthenticationRequired) + self._manager.networkAccessibleChanged.connect(self._onNetworkAccesibleChanged) # for debug purposes + + ## Convenience function that gets information from the received json data and converts it to the right internal + # values / variables + def _spliceJSONData(self): + # Check for hotend temperatures + for index in range(0, self._num_extruders): + temperature = self._json_printer_state["heads"][0]["extruders"][index]["hotend"]["temperature"]["current"] + self._setHotendTemperature(index, temperature) + try: + material_id = self._json_printer_state["heads"][0]["extruders"][index]["active_material"]["guid"] + except KeyError: + material_id = "" + self._setMaterialId(index, material_id) + try: + hotend_id = self._json_printer_state["heads"][0]["extruders"][index]["hotend"]["id"] + except KeyError: + hotend_id = "" + self._setHotendId(index, hotend_id) + + bed_temperature = self._json_printer_state["bed"]["temperature"]["current"] + self._setBedTemperature(bed_temperature) + + head_x = self._json_printer_state["heads"][0]["position"]["x"] + head_y = self._json_printer_state["heads"][0]["position"]["y"] + head_z = self._json_printer_state["heads"][0]["position"]["z"] + self._updateHeadPosition(head_x, head_y, head_z) + self._updatePrinterState(self._json_printer_state["status"]) + + + def close(self): + Logger.log("d", "Closing connection of printer %s with ip %s", self._key, self._address) + self._updateJobState("") + self.setConnectionState(ConnectionState.closed) + if self._progress_message: + self._progress_message.hide() + + # Reset authentication state + self._authentication_requested_message.hide() + self._authentication_state = AuthState.NotAuthenticated + self._authentication_counter = 0 + self._authentication_timer.stop() + + self._authentication_requested_message.hide() + self._authentication_failed_message.hide() + self._authentication_succeeded_message.hide() + + # Reset stored material & hotend data. + self._material_ids = [""] * self._num_extruders + self._hotend_ids = [""] * self._num_extruders + + if self._error_message: + self._error_message.hide() + + # Reset timeout state + self._connection_state_before_timeout = None + self._last_response_time = time() + self._last_request_time = None + + # Stop update timers + self._update_timer.stop() + + self.stopCamera() + + ## Request the current scene to be sent to a network-connected printer. + # + # \param nodes A collection of scene nodes to send. This is ignored. + # \param file_name \type{string} A suggestion for a file name to write. + # This is ignored. + # \param filter_by_machine Whether to filter MIME types by machine. This + # is ignored. + def requestWrite(self, nodes, file_name = None, filter_by_machine = False): + if self._progress != 0: + self._error_message = Message(i18n_catalog.i18nc("@info:status", "Unable to start a new print job because the printer is busy. Please check the printer.")) + self._error_message.show() + return + if self._printer_state != "idle": + self._error_message = Message( + i18n_catalog.i18nc("@info:status", "Unable to start a new print job, printer is busy. Current printer status is %s.") % self._printer_state) + self._error_message.show() + return + elif self._authentication_state != AuthState.Authenticated: + self._not_authenticated_message.show() + Logger.log("d", "Attempting to perform an action without authentication. Auth state is %s", self._authentication_state) + return + + Application.getInstance().showPrintMonitor.emit(True) + self._print_finished = True + self._gcode = getattr(Application.getInstance().getController().getScene(), "gcode_list") + + print_information = Application.getInstance().getPrintInformation() + + # Check if PrintCores / materials are loaded at all. Any failure in these results in an Error. + for index in range(0, self._num_extruders): + if print_information.materialLengths[index] != 0: + if self._json_printer_state["heads"][0]["extruders"][index]["hotend"]["id"] == "": + Logger.log("e", "No cartridge loaded in slot %s, unable to start print", index + 1) + self._error_message = Message( + i18n_catalog.i18nc("@info:status", "Unable to start a new print job. No PrinterCore loaded in slot {0}".format(index + 1))) + self._error_message.show() + return + if self._json_printer_state["heads"][0]["extruders"][index]["active_material"]["guid"] == "": + Logger.log("e", "No material loaded in slot %s, unable to start print", index + 1) + self._error_message = Message( + i18n_catalog.i18nc("@info:status", + "Unable to start a new print job. No material loaded in slot {0}".format(index + 1))) + self._error_message.show() + return + + warnings = [] # There might be multiple things wrong. Keep a list of all the stuff we need to warn about. + + for index in range(0, self._num_extruders): + # Check if there is enough material. Any failure in these results in a warning. + material_length = self._json_printer_state["heads"][0]["extruders"][index]["active_material"]["length_remaining"] + if material_length != -1 and print_information.materialLengths[index] > material_length: + Logger.log("w", "Printer reports that there is not enough material left for extruder %s. We need %s and the printer has %s", index + 1, print_information.materialLengths[index], material_length) + warnings.append(i18n_catalog.i18nc("@label", "Not enough material for spool {0}.").format(index+1)) + + # Check if the right cartridges are loaded. Any failure in these results in a warning. + extruder_manager = cura.Settings.ExtruderManager.getInstance() + if print_information.materialLengths[index] != 0: + variant = extruder_manager.getExtruderStack(index).findContainer({"type": "variant"}) + core_name = self._json_printer_state["heads"][0]["extruders"][index]["hotend"]["id"] + if variant: + if variant.getName() != core_name: + Logger.log("w", "Extruder %s has a different Cartridge (%s) as Cura (%s)", index + 1, core_name, variant.getName()) + warnings.append(i18n_catalog.i18nc("@label", "Different PrintCore (Cura: {0}, Printer: {1}) selected for extruder {2}".format(variant.getName(), core_name, index + 1))) + + material = extruder_manager.getExtruderStack(index).findContainer({"type": "material"}) + if material: + remote_material_guid = self._json_printer_state["heads"][0]["extruders"][index]["active_material"]["guid"] + if material.getMetaDataEntry("GUID") != remote_material_guid: + Logger.log("w", "Extruder %s has a different material (%s) as Cura (%s)", index + 1, + remote_material_guid, + material.getMetaDataEntry("GUID")) + + remote_materials = UM.Settings.ContainerRegistry.getInstance().findInstanceContainers(type = "material", GUID = remote_material_guid, read_only = True) + remote_material_name = "Unknown" + if remote_materials: + remote_material_name = remote_materials[0].getName() + warnings.append(i18n_catalog.i18nc("@label", "Different material (Cura: {0}, Printer: {1}) selected for extruder {2}").format(material.getName(), remote_material_name, index + 1)) + + try: + is_offset_calibrated = self._json_printer_state["heads"][0]["extruders"][index]["hotend"]["offset"]["state"] == "valid" + except KeyError: # Older versions of the API don't expose the offset property, so we must asume that all is well. + is_offset_calibrated = True + + if not is_offset_calibrated: + warnings.append(i18n_catalog.i18nc("@label", "PrintCore {0} is not properly calibrated. XY calibration needs to be performed on the printer.").format(index + 1)) + + if warnings: + text = i18n_catalog.i18nc("@label", "Are you sure you wish to print with the selected configuration?") + informative_text = i18n_catalog.i18nc("@label", "There is a mismatch between the configuration or calibration of the printer and Cura. " + "For the best result, always slice for the PrintCores and materials that are inserted in your printer.") + detailed_text = "" + for warning in warnings: + detailed_text += warning + "\n" + + Application.getInstance().messageBox(i18n_catalog.i18nc("@window:title", "Mismatched configuration"), + text, + informative_text, + detailed_text, + buttons=QMessageBox.Yes + QMessageBox.No, + icon=QMessageBox.Question, + callback=self._configurationMismatchMessageCallback + ) + return + + self.startPrint() + + def _configurationMismatchMessageCallback(self, button): + def delayedCallback(): + if button == QMessageBox.Yes: + self.startPrint() + else: + Application.getInstance().showPrintMonitor.emit(False) + # For some unknown reason Cura on OSX will hang if we do the call back code + # immediately without first returning and leaving QML's event system. + QTimer.singleShot(100, delayedCallback) + + def isConnected(self): + return self._connection_state != ConnectionState.closed and self._connection_state != ConnectionState.error + + ## Start requesting data from printer + def connect(self): + self.close() # Ensure that previous connection (if any) is killed. + + self._createNetworkManager() + + self.setConnectionState(ConnectionState.connecting) + self._update() # Manually trigger the first update, as we don't want to wait a few secs before it starts. + if not self._use_stream: + self._updateCamera() + Logger.log("d", "Connection with printer %s with ip %s started", self._key, self._address) + + ## Check if this machine was authenticated before. + self._authentication_id = Application.getInstance().getGlobalContainerStack().getMetaDataEntry("network_authentication_id", None) + self._authentication_key = Application.getInstance().getGlobalContainerStack().getMetaDataEntry("network_authentication_key", None) + + self._update_timer.start() + #self.startCamera() + + ## Stop requesting data from printer + def disconnect(self): + Logger.log("d", "Connection with printer %s with ip %s stopped", self._key, self._address) + self.close() + + newImage = pyqtSignal() + + @pyqtProperty(QUrl, notify = newImage) + def cameraImage(self): + self._camera_image_id += 1 + # There is an image provider that is called "camera". In order to ensure that the image qml object, that + # requires a QUrl to function, updates correctly we add an increasing number. This causes to see the QUrl + # as new (instead of relying on cached version and thus forces an update. + temp = "image://camera/" + str(self._camera_image_id) + return QUrl(temp, QUrl.TolerantMode) + + def getCameraImage(self): + return self._camera_image + + def _setJobState(self, job_state): + self._last_command = job_state + url = QUrl("http://" + self._address + self._api_prefix + "print_job/state") + put_request = QNetworkRequest(url) + put_request.setHeader(QNetworkRequest.ContentTypeHeader, "application/json") + data = "{\"target\": \"%s\"}" % job_state + self._manager.put(put_request, data.encode()) + + ## Convenience function to get the username from the OS. + # The code was copied from the getpass module, as we try to use as little dependencies as possible. + def _getUserName(self): + for name in ("LOGNAME", "USER", "LNAME", "USERNAME"): + user = os.environ.get(name) + if user: + return user + return "Unknown User" # Couldn't find out username. + + def _progressMessageActionTrigger(self, message_id = None, action_id = None): + if action_id == "Abort": + Logger.log("d", "User aborted sending print to remote.") + self._progress_message.hide() + self._compressing_print = False + if self._post_reply: + self._post_reply.abort() + self._post_reply = None + Application.getInstance().showPrintMonitor.emit(False) + + ## Attempt to start a new print. + # This function can fail to actually start a print due to not being authenticated or another print already + # being in progress. + def startPrint(self): + try: + self._send_gcode_start = time() + self._progress_message = Message(i18n_catalog.i18nc("@info:status", "Sending data to printer"), 0, False, -1) + self._progress_message.addAction("Abort", i18n_catalog.i18nc("@action:button", "Cancel"), None, "") + self._progress_message.actionTriggered.connect(self._progressMessageActionTrigger) + self._progress_message.show() + Logger.log("d", "Started sending g-code to remote printer.") + self._compressing_print = True + ## Mash the data into single string + byte_array_file_data = b"" + for line in self._gcode: + if not self._compressing_print: + self._progress_message.hide() + return # Stop trying to zip, abort was called. + if self._use_gzip: + byte_array_file_data += gzip.compress(line.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() + else: + byte_array_file_data += line.encode("utf-8") + + if self._use_gzip: + file_name = "%s.gcode.gz" % Application.getInstance().getPrintInformation().jobName + else: + file_name = "%s.gcode" % Application.getInstance().getPrintInformation().jobName + + self._compressing_print = False + ## Create multi_part request + self._post_multi_part = QHttpMultiPart(QHttpMultiPart.FormDataType) + + ## Create part (to be placed inside multipart) + self._post_part = QHttpPart() + self._post_part.setHeader(QNetworkRequest.ContentDispositionHeader, + "form-data; name=\"file\"; filename=\"%s\"" % file_name) + self._post_part.setBody(byte_array_file_data) + self._post_multi_part.append(self._post_part) + + url = QUrl("http://" + self._address + self._api_prefix + "print_job") + + ## Create the QT request + self._post_request = QNetworkRequest(url) + + ## Post request + data + self._post_reply = self._manager.post(self._post_request, self._post_multi_part) + self._post_reply.uploadProgress.connect(self._onUploadProgress) + + except IOError: + self._progress_message.hide() + self._error_message = Message(i18n_catalog.i18nc("@info:status", "Unable to send data to printer. Is another job still active?")) + self._error_message.show() + except Exception as e: + self._progress_message.hide() + Logger.log("e", "An exception occurred in network connection: %s" % str(e)) + + ## Verify if we are authenticated to make requests. + def _verifyAuthentication(self): + url = QUrl("http://" + self._address + self._api_prefix + "auth/verify") + request = QNetworkRequest(url) + self._manager.get(request) + + ## Check if the authentication request was allowed by the printer. + def _checkAuthentication(self): + Logger.log("d", "Checking if authentication is correct.") + self._manager.get(QNetworkRequest(QUrl("http://" + self._address + self._api_prefix + "auth/check/" + str(self._authentication_id)))) + + ## Request a authentication key from the printer so we can be authenticated + def _requestAuthentication(self): + url = QUrl("http://" + self._address + self._api_prefix + "auth/request") + request = QNetworkRequest(url) + request.setHeader(QNetworkRequest.ContentTypeHeader, "application/json") + self.setAuthenticationState(AuthState.AuthenticationRequested) + self._manager.post(request, json.dumps({"application": "Cura-" + Application.getInstance().getVersion(), "user": self._getUserName()}).encode()) + + ## Send all material profiles to the printer. + def sendMaterialProfiles(self): + for container in UM.Settings.ContainerRegistry.getInstance().findInstanceContainers(type = "material"): + try: + xml_data = container.serialize() + if xml_data == "" or xml_data is None: + continue + material_multi_part = QHttpMultiPart(QHttpMultiPart.FormDataType) + + material_part = QHttpPart() + file_name = "none.xml" + material_part.setHeader(QNetworkRequest.ContentDispositionHeader, "form-data; name=\"file\";filename=\"%s\"" % file_name) + material_part.setBody(xml_data.encode()) + material_multi_part.append(material_part) + url = QUrl("http://" + self._address + self._api_prefix + "materials") + material_post_request = QNetworkRequest(url) + reply = self._manager.post(material_post_request, material_multi_part) + + # Keep reference to material_part, material_multi_part and reply so the garbage collector won't touch them. + self._material_post_objects[id(reply)] = (material_part, material_multi_part, reply) + except NotImplementedError: + # If the material container is not the most "generic" one it can't be serialized an will raise a + # NotImplementedError. We can simply ignore these. + pass + + ## Handler for all requests that have finished. + def _onFinished(self, reply): + if reply.error() == QNetworkReply.TimeoutError: + Logger.log("w", "Received a timeout on a request to the printer") + self._connection_state_before_timeout = self._connection_state + # Check if we were uploading something. Abort if this is the case. + # Some operating systems handle this themselves, others give weird issues. + if self._post_reply: + self._post_reply.abort() + self._post_reply.uploadProgress.disconnect(self._onUploadProgress) + Logger.log("d", "Uploading of print failed after %s", time() - self._send_gcode_start) + self._post_reply = None + self._progress_message.hide() + + self.setConnectionState(ConnectionState.error) + return + + if self._connection_state_before_timeout and reply.error() == QNetworkReply.NoError: # There was a timeout, but we got a correct answer again. + Logger.log("d", "We got a response (%s) from the server after %0.1f of silence. Going back to previous state %s", reply.url().toString(), time() - self._last_response_time, self._connection_state_before_timeout) + self.setConnectionState(self._connection_state_before_timeout) + self._connection_state_before_timeout = None + + if reply.error() == QNetworkReply.NoError: + self._last_response_time = time() + + status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) + if not status_code: + if self._connection_state != ConnectionState.error: + Logger.log("d", "A reply from %s did not have status code.", reply.url().toString()) + # Received no or empty reply + return + reply_url = reply.url().toString() + + if reply.operation() == QNetworkAccessManager.GetOperation: + if "printer" in reply_url: # Status update from printer. + if status_code == 200: + if self._connection_state == ConnectionState.connecting: + self.setConnectionState(ConnectionState.connected) + self._json_printer_state = json.loads(bytes(reply.readAll()).decode("utf-8")) + self._spliceJSONData() + + # Hide connection error message if the connection was restored + if self._connection_message: + self._connection_message.hide() + self._connection_message = None + 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: + if status_code == 200: + json_data = json.loads(bytes(reply.readAll()).decode("utf-8")) + progress = json_data["progress"] + ## If progress is 0 add a bit so another print can't be sent. + if progress == 0: + progress += 0.001 + elif progress == 1: + self._print_finished = True + else: + self._print_finished = False + self.setProgress(progress * 100) + + state = json_data["state"] + + # There is a short period after aborting or finishing a print where the printer + # reports a "none" state (but the printer is not ready to receive a print) + # If this happens before the print has reached progress == 1, the print has + # been aborted. + if state == "none" or state == "": + if self._last_command == "abort": + self.setErrorText(i18n_catalog.i18nc("@label:MonitorStatus", "Aborting print...")) + state = "error" + else: + state = "printing" + if state == "wait_cleanup" and self._last_command == "abort": + # Keep showing the "aborted" error state until after the buildplate has been cleaned + self.setErrorText(i18n_catalog.i18nc("@label:MonitorStatus", "Print aborted. Please check the printer")) + state = "error" + + # NB/TODO: the following two states are intentionally added for future proofing the i18n strings + # but are currently non-functional + if state == "!pausing": + self.setErrorText(i18n_catalog.i18nc("@label:MonitorStatus", "Pausing print...")) + if state == "!resuming": + self.setErrorText(i18n_catalog.i18nc("@label:MonitorStatus", "Resuming print...")) + + self._updateJobState(state) + self.setTimeElapsed(json_data["time_elapsed"]) + self.setTimeTotal(json_data["time_total"]) + self.setJobName(json_data["name"]) + elif status_code == 404: + self.setProgress(0) # No print job found, so there can't be progress or other data. + self._updateJobState("") + self.setErrorText("") + self.setTimeElapsed(0) + self.setTimeTotal(0) + self.setJobName("") + else: + Logger.log("w", "We got an unexpected status (%s) while requesting print job state", status_code) + elif "snapshot" in reply_url: # Status update from image: + if status_code == 200: + self._camera_image.loadFromData(reply.readAll()) + self.newImage.emit() + elif "auth/verify" in reply_url: # Answer when requesting authentication + if status_code == 401: + if self._authentication_state != AuthState.AuthenticationRequested: + # Only request a new authentication when we have not already done so. + Logger.log("i", "Not authenticated. Attempting to request authentication") + self._requestAuthentication() + elif status_code == 403: + # If we already had an auth (eg; didn't request one), we only need a single 403 to see it as denied. + if self._authentication_state != AuthState.AuthenticationRequested: + Logger.log("d", "While trying to verify the authentication state, we got a forbidden response. Our own auth state was %s", self._authentication_state) + self.setAuthenticationState(AuthState.AuthenticationDenied) + elif status_code == 200: + self.setAuthenticationState(AuthState.Authenticated) + global_container_stack = Application.getInstance().getGlobalContainerStack() + ## Save authentication details. + if global_container_stack: + if "network_authentication_key" in global_container_stack.getMetaData(): + global_container_stack.setMetaDataEntry("network_authentication_key", self._authentication_key) + else: + global_container_stack.addMetaDataEntry("network_authentication_key", self._authentication_key) + if "network_authentication_id" in global_container_stack.getMetaData(): + global_container_stack.setMetaDataEntry("network_authentication_id", self._authentication_id) + else: + global_container_stack.addMetaDataEntry("network_authentication_id", self._authentication_id) + Application.getInstance().saveStack(global_container_stack) # Force save so we are sure the data is not lost. + Logger.log("i", "Authentication succeeded") + else: # Got a response that we didn't expect, so something went wrong. + Logger.log("w", "While trying to authenticate, we got an unexpected response: %s", reply.attribute(QNetworkRequest.HttpStatusCodeAttribute)) + self.setAuthenticationState(AuthState.NotAuthenticated) + + elif "auth/check" in reply_url: # Check if we are authenticated (user can refuse this!) + data = json.loads(bytes(reply.readAll()).decode("utf-8")) + if data.get("message", "") == "authorized": + Logger.log("i", "Authentication was approved") + self._verifyAuthentication() # Ensure that the verification is really used and correct. + elif data.get("message", "") == "unauthorized": + Logger.log("i", "Authentication was denied.") + self.setAuthenticationState(AuthState.AuthenticationDenied) + else: + pass + + elif reply.operation() == QNetworkAccessManager.PostOperation: + if "/auth/request" in reply_url: + # We got a response to requesting authentication. + data = json.loads(bytes(reply.readAll()).decode("utf-8")) + + global_container_stack = Application.getInstance().getGlobalContainerStack() + if global_container_stack: # Remove any old data. + global_container_stack.removeMetaDataEntry("network_authentication_key") + global_container_stack.removeMetaDataEntry("network_authentication_id") + Application.getInstance().saveStack(global_container_stack) # Force saving so we don't keep wrong auth data. + + self._authentication_key = data["key"] + self._authentication_id = data["id"] + Logger.log("i", "Got a new authentication ID. Waiting for authorization: %s", self._authentication_id ) + + # Check if the authentication is accepted. + self._checkAuthentication() + elif "materials" in reply_url: + # Remove cached post request items. + del self._material_post_objects[id(reply)] + elif "print_job" in reply_url: + reply.uploadProgress.disconnect(self._onUploadProgress) + Logger.log("d", "Uploading of print succeeded after %s", time() - self._send_gcode_start) + # Only reset the _post_reply if it was the same one. + if reply == self._post_reply: + self._post_reply = None + self._progress_message.hide() + + elif reply.operation() == QNetworkAccessManager.PutOperation: + if status_code == 204: + pass # Request was successful! + else: + Logger.log("d", "Something went wrong when trying to update data of API (%s). Message: %s Statuscode: %s", reply_url, reply.readAll(), status_code) + else: + Logger.log("d", "NetworkPrinterOutputDevice got an unhandled operation %s", reply.operation()) + + def _onStreamDownloadProgress(self, bytes_received, bytes_total): + # An MJPG stream is (for our purpose) a stream of concatenated JPG images. + # JPG images start with the marker 0xFFD8, and end with 0xFFD9 + if self._image_reply is None: + return + self._stream_buffer += self._image_reply.readAll() + + if self._stream_buffer_start_index == -1: + self._stream_buffer_start_index = self._stream_buffer.indexOf(b'\xff\xd8') + stream_buffer_end_index = self._stream_buffer.lastIndexOf(b'\xff\xd9') + # If this happens to be more than a single frame, then so be it; the JPG decoder will + # ignore the extra data. We do it like this in order not to get a buildup of frames + + if self._stream_buffer_start_index != -1 and stream_buffer_end_index != -1: + jpg_data = self._stream_buffer[self._stream_buffer_start_index:stream_buffer_end_index + 2] + self._stream_buffer = self._stream_buffer[stream_buffer_end_index + 2:] + self._stream_buffer_start_index = -1 + + self._camera_image.loadFromData(jpg_data) + self.newImage.emit() + + def _onUploadProgress(self, bytes_sent, bytes_total): + if bytes_total > 0: + new_progress = bytes_sent / bytes_total * 100 + # Treat upload progress as response. Uploading can take more than 10 seconds, so if we don't, we can get + # timeout responses if this happens. + self._last_response_time = time() + if new_progress > self._progress_message.getProgress(): + self._progress_message.show() # Ensure that the message is visible. + self._progress_message.setProgress(bytes_sent / bytes_total * 100) + else: + self._progress_message.setProgress(0) + self._progress_message.hide() + + ## Let the user decide if the hotends and/or material should be synced with the printer + def materialHotendChangedMessage(self, callback): + Application.getInstance().messageBox(i18n_catalog.i18nc("@window:title", "Changes on the Printer"), + i18n_catalog.i18nc("@label", + "Would you like to update your current printer configuration into Cura?"), + i18n_catalog.i18nc("@label", + "The PrintCores and/or materials on your printer were changed. For the best result, always slice for the PrintCores and materials that are inserted in your printer."), + buttons=QMessageBox.Yes + QMessageBox.No, + icon=QMessageBox.Question, + callback=callback + ) diff --git a/NetworkPrinterOutputDevicePlugin.py b/NetworkPrinterOutputDevicePlugin.py new file mode 100644 index 0000000000..bb1fade0bc --- /dev/null +++ b/NetworkPrinterOutputDevicePlugin.py @@ -0,0 +1,202 @@ +from UM.OutputDevice.OutputDevicePlugin import OutputDevicePlugin +from . import NetworkPrinterOutputDevice + +from zeroconf import Zeroconf, ServiceBrowser, ServiceStateChange, ServiceInfo +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 time +import json + +## 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): + def __init__(self): + super().__init__() + self._zero_conf = None + self._browser = None + self._printers = {} + + self._api_version = "1" + self._api_prefix = "/api/v" + self._api_version + "/" + + self._network_manager = QNetworkAccessManager() + self._network_manager.finished.connect(self._onNetworkRequestFinished) + + # List of old printer names. This is used to ensure that a refresh of zeroconf does not needlessly forces + # authentication requests. + self._old_printers = [] + + # Because the model needs to be created in the same thread as the QMLEngine, we use a signal. + self.addPrinterSignal.connect(self.addPrinter) + self.removePrinterSignal.connect(self.removePrinter) + Application.getInstance().globalContainerStackChanged.connect(self.reCheckConnections) + + # Get list of manual printers from preferences + self._preferences = Preferences.getInstance() + 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(",") + + addPrinterSignal = Signal() + removePrinterSignal = Signal() + printerListChanged = Signal() + + ## Start looking for devices on network. + def start(self): + self.startDiscovery() + + def startDiscovery(self): + self.stop() + if self._browser: + self._browser.cancel() + self._browser = None + self._old_printers = [printer_name for printer_name in self._printers] + self._printers = {} + self.printerListChanged.emit() + # After network switching, one must make a new instance of Zeroconf + # On windows, the instance creation is very fast (unnoticable). Other platforms? + self._zero_conf = Zeroconf() + self._browser = ServiceBrowser(self._zero_conf, u'_ultimaker._tcp.local.', [self._onServiceChanged]) + + # Look for manual instances from preference + for address in self._manual_instances: + if address: + self.addManualPrinter(address) + + def addManualPrinter(self, address): + if address not in self._manual_instances: + self._manual_instances.append(address) + self._preferences.setValue("um3networkprinting/manual_instances", ",".join(self._manual_instances)) + + name = address + instance_name = "manual:%s" % address + properties = { b"name": name.encode("utf-8"), b"manual": b"true", b"incomplete": b"true" } + + if instance_name not in self._printers: + # Add a preliminary printer instance + self.addPrinter(instance_name, address, properties) + + self.checkManualPrinter(address) + + def removeManualPrinter(self, key, address = None): + if key in self._printers: + if not address: + address = self._printers[key].ipAddress + self.removePrinter(key) + + if address in self._manual_instances: + self._manual_instances.remove(address) + self._preferences.setValue("um3networkprinting/manual_instances", ",".join(self._manual_instances)) + + 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") + name_request = QNetworkRequest(url) + self._network_manager.get(name_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. + if status_code == 200: + system_info = json.loads(bytes(reply.readAll()).decode("utf-8")) + address = reply.url().host() + name = ("%s (%s)" % (system_info["name"], address)) + + instance_name = "manual:%s" % address + properties = { b"name": name.encode("utf-8"), b"firmware_version": system_info["firmware"].encode("utf-8"), b"manual": b"true" } + 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) + + ## Stop looking for devices on network. + def stop(self): + if self._zero_conf is not None: + self._zero_conf.close() + + def getPrinters(self): + return self._printers + + def reCheckConnections(self): + active_machine = Application.getInstance().getGlobalContainerStack() + if not active_machine: + return + + for key in self._printers: + if key == active_machine.getMetaDataEntry("um_network_key"): + self._printers[key].connect() + self._printers[key].connectionStateChanged.connect(self._onPrinterConnectionStateChanged) + else: + if self._printers[key].isConnected(): + self._printers[key].close() + + ## 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) + 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"): + if printer.getKey() not in self._old_printers: # Was the printer already connected, but a re-scan forced? + self._printers[printer.getKey()].connect() + printer.connectionStateChanged.connect(self._onPrinterConnectionStateChanged) + self.printerListChanged.emit() + + def removePrinter(self, name): + printer = self._printers.pop(name, None) + if printer: + if printer.isConnected(): + printer.connectionStateChanged.disconnect(self._onPrinterConnectionStateChanged) + printer.disconnect() + self.printerListChanged.emit() + + ## Handler for when the connection state of one of the detected printers changes + def _onPrinterConnectionStateChanged(self, key): + if key not in self._printers: + return + if self._printers[key].isConnected(): + self.getOutputDeviceManager().addOutputDevice(self._printers[key]) + else: + self.getOutputDeviceManager().removeOutputDevice(key) + + ## Handler for zeroConf detection + def _onServiceChanged(self, zeroconf, service_type, name, state_change): + if state_change == ServiceStateChange.Added: + Logger.log("d", "Bonjour service added: %s" % name) + + # First try getting info from zeroconf cache + info = ServiceInfo(service_type, name, properties = {}) + for record in zeroconf.cache.entries_with_name(name.lower()): + info.update_record(zeroconf, time.time(), record) + + for record in zeroconf.cache.entries_with_name(info.server): + info.update_record(zeroconf, time.time(), record) + if info.address: + break + + # Request more data if info is not complete + if not info.address: + Logger.log("d", "Trying to get address of %s", name) + info = zeroconf.get_service_info(service_type, name) + + if info: + if info.properties.get(b"type", None) == b'printer': + address = '.'.join(map(lambda n: str(n), info.address)) + self.addPrinterSignal.emit(str(name), address, info.properties) + else: + Logger.log("w", "Could not get information about %s" % name) + + elif state_change == ServiceStateChange.Removed: + Logger.log("d", "Bonjour service removed: %s" % name) + self.removePrinterSignal.emit(str(name)) \ No newline at end of file diff --git a/README.md b/README.md index e1f23bbe04..376bdd8547 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ +<<<<<<< HEAD Cura ==== @@ -28,6 +29,8 @@ Dependencies This will be needed at runtime to perform the actual slicing. * [PySerial](https://github.com/pyserial/pyserial) Only required for USB printing support. +* [python-zeroconf](https://github.com/jstasiak/python-zeroconf) + Only required to detect mDNS-enabled printers Configuring Cura ---------------- diff --git a/UM3InfoComponents.qml b/UM3InfoComponents.qml new file mode 100644 index 0000000000..a5ed944773 --- /dev/null +++ b/UM3InfoComponents.qml @@ -0,0 +1,124 @@ +import UM 1.2 as UM +import Cura 1.0 as Cura + +import QtQuick 2.2 +import QtQuick.Controls 1.1 +import QtQuick.Layouts 1.1 +import QtQuick.Window 2.1 + +Item +{ + id: base + + property bool isUM3: Cura.MachineManager.activeQualityDefinitionId == "ultimaker3" + property bool printerConnected: Cura.MachineManager.printerOutputDevices.length != 0 + property bool printerAcceptsCommands: printerConnected && Cura.MachineManager.printerOutputDevices[0].acceptsCommands + property bool authenticationRequested: printerConnected && Cura.MachineManager.printerOutputDevices[0].authenticationState == 2 // AuthState.AuthenticationRequested + + Row + { + objectName: "networkPrinterConnectButton" + visible: isUM3 + spacing: UM.Theme.getSize("default_margin").width + + Button + { + height: UM.Theme.getSize("save_button_save_to_button").height + tooltip: catalog.i18nc("@info:tooltip", "Send access request to the printer") + text: catalog.i18nc("@action:button", "Request Access") + style: UM.Theme.styles.sidebar_action_button + onClicked: Cura.MachineManager.printerOutputDevices[0].requestAuthentication() + visible: printerConnected && !printerAcceptsCommands && !authenticationRequested + } + + Button + { + height: UM.Theme.getSize("save_button_save_to_button").height + tooltip: catalog.i18nc("@info:tooltip", "Connect to a printer") + text: catalog.i18nc("@action:button", "Connect") + style: UM.Theme.styles.sidebar_action_button + onClicked: connectActionDialog.show() + visible: !printerConnected + } + } + + UM.Dialog + { + id: connectActionDialog + Loader + { + anchors.fill: parent + source: "DiscoverUM3Action.qml" + } + rightButtons: Button + { + text: catalog.i18nc("@action:button", "Close") + iconName: "dialog-close" + onClicked: connectActionDialog.reject() + } + } + + + Column + { + objectName: "networkPrinterConnectionInfo" + visible: isUM3 + spacing: UM.Theme.getSize("default_margin").width + anchors.fill: parent + + Button + { + tooltip: catalog.i18nc("@info:tooltip", "Send access request to the printer") + text: catalog.i18nc("@action:button", "Request Access") + onClicked: Cura.MachineManager.printerOutputDevices[0].requestAuthentication() + visible: printerConnected && !printerAcceptsCommands && !authenticationRequested + } + + Row + { + visible: printerConnected + spacing: UM.Theme.getSize("default_margin").width + + anchors.left: parent.left + anchors.right: parent.right + height: childrenRect.height + + Column + { + Repeater + { + model: Cura.ExtrudersModel { simpleNames: true } + Label { text: model.name } + } + } + Column + { + Repeater + { + id: nozzleColumn + model: printerConnected ? Cura.MachineManager.printerOutputDevices[0].hotendIds : null + Label { text: nozzleColumn.model[index] } + } + } + Column + { + Repeater + { + id: materialColumn + model: printerConnected ? Cura.MachineManager.printerOutputDevices[0].materialNames : null + Label { text: materialColumn.model[index] } + } + } + } + + Button + { + tooltip: catalog.i18nc("@info:tooltip", "Load the configuration of the printer into Cura") + text: catalog.i18nc("@action:button", "Activate Configuration") + visible: printerConnected + onClicked: manager.loadConfigurationFromPrinter() + } + } + + UM.I18nCatalog{id: catalog; name:"cura"} +} \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000000..be9f1195ec --- /dev/null +++ b/__init__.py @@ -0,0 +1,20 @@ +# Copyright (c) 2015 Ultimaker B.V. +# Cura is released under the terms of the AGPLv3 or higher. +from . import NetworkPrinterOutputDevicePlugin +from . import DiscoverUM3Action +from UM.i18n import i18nCatalog +catalog = i18nCatalog("cura") + +def getMetaData(): + return { + "plugin": { + "name": "UM3 Network Connection", + "author": "Ultimaker", + "description": catalog.i18nc("@info:whatsthis", "Manages network connections to Ultimaker 3 printers"), + "version": "1.0", + "api": 3 + } + } + +def register(app): + return { "output_device": NetworkPrinterOutputDevicePlugin.NetworkPrinterOutputDevicePlugin(), "machine_action": DiscoverUM3Action.DiscoverUM3Action()} \ No newline at end of file