Browse Source

Merge remote-tracking branch 'UM3NPP/master' into UM3NPP_merge

Contributes to CURA-2862
Thomas Karl Pietrowski 8 years ago
parent
commit
c48d064eca
10 changed files with 1889 additions and 4 deletions
  1. 3 2
      .gitignore
  2. 0 1
      CMakeLists.txt
  3. 148 0
      DiscoverUM3Action.py
  4. 369 0
      DiscoverUM3Action.qml
  5. 1 1
      LICENSE
  6. 1019 0
      NetworkPrinterOutputDevice.py
  7. 202 0
      NetworkPrinterOutputDevicePlugin.py
  8. 3 0
      README.md
  9. 124 0
      UM3InfoComponents.qml
  10. 20 0
      __init__.py

+ 3 - 2
.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
+plugins/X3GWriter
+

+ 0 - 1
CMakeLists.txt

@@ -1,4 +1,3 @@
-
 project(cura NONE)
 cmake_minimum_required(VERSION 2.8.12)
 

+ 148 - 0
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"))

+ 369 - 0
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 <a href='%1'>network-printing troubleshooting guide</a>").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
+            }
+        ]
+    }
+}

+ 1 - 1
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
-<http://www.gnu.org/licenses/>.
+<http://www.gnu.org/licenses/>.

+ 1019 - 0
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
+        )

+ 202 - 0
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))

+ 3 - 0
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
 ----------------

+ 124 - 0
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"}
+}

+ 20 - 0
__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()}