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 from PyQt5.QtGui import QImage from PyQt5.QtWidgets import QMessageBox import json import os from time import time 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): super().__init__(key) self._address = address self._key = key self._properties = properties # Properties dict as provided by zero conf self._gcode = None self._print_finished = True # _print_finsihed == False means we're halfway in a print # 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._api_version = "1" self._api_prefix = "/api/v" + self._api_version + "/" self.setPriority(2) # Make sure the output device gets selected above local file output self.setName(key) self.setShortDescription(i18n_catalog.i18nc("@action:button", "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(2000) # Todo: Add preference for camera update interval self._camera_timer.setSingleShot(False) self._camera_timer.timeout.connect(self._update_camera) 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_state = AuthState.NotAuthenticated self._authentication_id = None self._authentication_key = None self._authentication_requested_message = Message(i18n_catalog.i18nc("@info:status", "Requested access. Please approve the request on the printer"), lifetime = 0, dismissable = False, progress = 0) self._authentication_failed_message = Message(i18n_catalog.i18nc("@info:status", "Pairing request failed due to a timeout or the printer refused the request.")) self._authentication_failed_message.addAction("Retry", i18n_catalog.i18nc("@action:button", "Retry "), None, i18n_catalog.i18nc("@info:tooltip", "Re-send the authentication request")) self._authentication_failed_message.actionTriggered.connect(self.messageActionTriggered) self._authentication_succeeded_message = Message(i18n_catalog.i18nc("@info:status", "Printer was successfully paired with Cura")) self._camera_image = QImage() self._material_post_objects = {} self._connection_state_before_timeout = None self._last_response_time = time() 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._not_authenticated_message = None self._send_gcode_start = time() # Time when the sending of the g-code started. self._last_command = "" 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: authenticator.setUser(self._authentication_id) authenticator.setPassword(self._authentication_key) def getProperties(self): return self._properties ## 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 _update_camera(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) ## 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: self.setAcceptsCommands(False) self.setConnectionText(i18n_catalog.i18nc("@info:status", "Connected over the network to {0} without access to control the printer.").format(self.name)) self._authentication_requested_message.show() self._authentication_timer.start() # Start timer so auth will fail after a while. elif auth_state == AuthState.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() 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._authentication_requested_message.hide() self._authentication_failed_message.show() # Stop waiting for a response self._authentication_timer.stop() self._authentication_counter = 0 self._authentication_state = auth_state def messageActionTriggered(self, message_id, action_id): self._authentication_failed_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 ## Request data from the connected device. def _update(self): # 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() - self._last_response_time > self._recreate_network_manager_time * self._recreate_network_manager_count: self._recreate_network_manager_count += 1 Logger.log("d", "Timeout lasted over 30 seconds, re-checking connection.") 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() # 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() try: self._post_reply.uploadProgress.disconnect(self._onUploadProgress) except TypeError: pass # The disconnection can fail on mac in some cases. Ignore that. self._progress_message.hide() return else: self._recreate_network_manager_count = 1 # Check that we aren't in a timeout state if self._last_response_time and not self._connection_state_before_timeout: if time() - self._last_response_time > 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() - self._last_response_time) 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() # 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() try: self._post_reply.uploadProgress.disconnect(self._onUploadProgress) except TypeError: pass # The disconnection can fail on mac in some cases. Ignore that. self._progress_message.hide() 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) 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) def close(self): 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() if self._error_message: self._error_message.hide() # Reset timeout state self._connection_state_before_timeout = None self._last_response_time = time() # Stop update timers self._update_timer.stop() self._camera_timer.stop() def requestWrite(self, node, 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._json_printer_state["status"] != "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._json_printer_state["status"]) self._error_message.show() return elif self._authentication_state != AuthState.Authenticated: self._not_authenticated_message = Message(i18n_catalog.i18nc("@info:status", "Not authenticated to print with this machine. Unable to start a new job.")) self._not_authenticated_message.show() 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. if print_information.materialLengths[0] != 0: # We need to print with extruder slot 1 if self._json_printer_state["heads"][0]["extruders"][0]["hotend"]["id"] == "": Logger.log("e", "No cartridge loaded in slot 1, unable to start print") self._error_message = Message( i18n_catalog.i18nc("@info:status", "Unable to start a new print job, no PowerCore loaded in slot 1")) self._error_message.show() return if self._json_printer_state["heads"][0]["extruders"][0]["active_material"]["GUID"] == "": Logger.log("e", "No material loaded in slot 1, unable to start print") self._error_message = Message( i18n_catalog.i18nc("@info:status", "Unable to start a new print job, no material loaded in slot 1")) self._error_message.show() return if print_information.materialLengths[1] != 0: # We need to print with extruder slot 2 if self._json_printer_state["heads"][0]["extruders"][1]["hotend"]["id"] == "": Logger.log("e", "No cartridge loaded in slot 2, unable to start print") self._error_message = Message( i18n_catalog.i18nc("@info:status", "Unable to start a new print job, no PowerCore loaded in slot 2")) self._error_message.show() return if self._json_printer_state["heads"][0]["extruders"][1]["active_material"]["GUID"] == "": Logger.log("e", "No material loaded in slot 2, unable to start print") self._error_message = Message( i18n_catalog.i18nc("@info:status", "Unable to start a new print job, no material loaded in slot 2")) self._error_message.show() return warnings = [] # There might be multiple things wrong. Keep a list of all the stuff we need to warn about. # Check if there is enough material. Any failure in these results in a warning. material_length_1 = self._json_printer_state["heads"][0]["extruders"][0]["active_material"]["length_remaining"] if material_length_1 != -1 and print_information.materialLengths[0] > material_length_1: warnings.append("not_enough_material_1") material_length_2 = self._json_printer_state["heads"][0]["extruders"][1]["active_material"]["length_remaining"] if material_length_2 != -1 and print_information.materialLengths[1] > material_length_2: warnings.append("not_enough_material_2") # 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[0] != 0: variant = extruder_manager.getExtruderStack(0).findContainer({"type": "variant"}) if variant: if variant.getId() != self._json_printer_state["heads"][0]["extruders"][0]["hotend"]["id"]: warnings.append("hotend_1") material = extruder_manager.getExtruderStack(0).findContainer({"type": "material"}) if material: if material.getMetaDataEntry("GUID") != self._json_printer_state["heads"][0]["extruders"][0]["active_material"]["GUID"]: warnings.append("wrong_material_1") if print_information.materialLengths[1] != 0: variant = extruder_manager.getExtruderStack(1).findContainer({"type": "variant"}) if variant: if variant.getId() != self._json_printer_state["heads"][0]["extruders"][1]["hotend"]["id"]: warnings.append("hotend_2") material = extruder_manager.getExtruderStack(1).findContainer({"type": "material"}) if material: if material.getMetaDataEntry("GUID") != self._json_printer_state["heads"][0]["extruders"][1]["active_material"]["GUID"]: warnings.append("wrong_material_2") if warnings: text = i18n_catalog.i18nc("@label", "A number of configurations are mismatched. Are you sure you wish to print with the selected configuration?") detailed_text = "