diff --git a/.gitignore b/.gitignore index 3eec870..f30924a 100644 --- a/.gitignore +++ b/.gitignore @@ -131,3 +131,4 @@ dmypy.json release.sh *.zip +Makefile diff --git a/SM2GCodeWriter.py b/GCodeWriter.py similarity index 74% rename from SM2GCodeWriter.py rename to GCodeWriter.py index 18f477a..c99e4cc 100644 --- a/SM2GCodeWriter.py +++ b/GCodeWriter.py @@ -13,15 +13,14 @@ try: from PyQt6.QtCore import QBuffer from PyQt6.QtGui import QImage - # QImageFormat = QImage.Format.Format_Indexed8 QBufferOpenMode = QBuffer.OpenModeFlag.ReadWrite except ImportError: from PyQt5.QtCore import QBuffer from PyQt5.QtGui import QImage - # QImageFormat = QImage.Format_Indexed8 QBufferOpenMode = QBuffer.ReadWrite from UM.i18n import i18nCatalog + catalog = i18nCatalog("cura") @@ -33,14 +32,22 @@ class SM2GCodeWriter(MeshWriter): PROCESSED_IDENTITY = ";Processed by Snapmaker2Plugin (https://github.com/macdylan/Snapmaker2Plugin)" @call_on_qt_thread - def write(self, stream, nodes, mode=MeshWriter.OutputMode.TextMode) -> bool: + def write(self, + stream, + nodes, + mode=MeshWriter.OutputMode.TextMode) -> bool: if mode != MeshWriter.OutputMode.TextMode: - Logger.log("e", "SM2GCodeWriter does not support non-text mode.") - self.setInformation(catalog.i18nc("@error:not supported", "SM2GCodeWriter does not support non-text mode.")) + Logger.error("SM2GCodeWriter does not support non-text mode.") + self.setInformation( + catalog.i18nc( + "@error:not supported", + "SM2GCodeWriter does not support non-text mode.")) return False gcode = StringIO() - writer = cast(MeshWriter, PluginRegistry.getInstance().getPluginObject("GCodeWriter")) + writer = cast( + MeshWriter, + PluginRegistry.getInstance().getPluginObject("GCodeWriter")) success = writer.write(gcode, None) if not success: @@ -51,11 +58,11 @@ def write(self, stream, nodes, mode=MeshWriter.OutputMode.TextMode) -> bool: try: result = self.mod(gcode) stream.write(result.getvalue()) - Logger.log("i", "SM2GCodeWriter done") + Logger.info("SM2GCodeWriter done") return True except ModError as e: self.setInformation(str(e)) - Logger.log("e", e) + Logger.error(e) return False def mod(self, data: StringIO) -> StringIO: @@ -86,13 +93,16 @@ def mod(self, data: StringIO) -> StringIO: p.write("\n") app = CuraApplication.getInstance() - print_time = int(app.getPrintInformation().currentPrintTime) * 1.07 # Times empirical parameter: 1.07 + print_time = int(app.getPrintInformation().currentPrintTime + ) * 1.07 # Times empirical parameter: 1.07 print_speed = float(self._getValue("speed_infill")) print_temp = float(self._getValue("material_print_temperature")) bed_temp = float(self._getValue("material_bed_temperature")) or 0.0 if not print_speed or not print_temp: - raise ModError("Unable to slice with the current settings: speed_infill or material_print_temperature") + raise ModError( + "Unable to slice with the current settings: speed_infill or material_print_temperature" + ) p.write(";file_total_lines: %d\n" % len(gcodes)) p.write(";estimated_time(s): %.0f\n" % print_time) @@ -111,15 +121,15 @@ def mod(self, data: StringIO) -> StringIO: return p def _createSnapshot(self) -> QImage: - Logger.log("d", "Creating thumbnail image...") + Logger.debug("Creating thumbnail image...") try: - return Snapshot.snapshot(width=150, height=150) # .convertToFormat(QImageFormat) + return Snapshot.snapshot(width=240, height=160) except Exception: Logger.logException("w", "Failed to create snapshot image") return None def _encodeSnapshot(self, snapshot: QImage) -> str: - Logger.log("d", "Encoding thumbnail image...") + Logger.debug("Encoding thumbnail image...") try: thumbnail_buffer = QBuffer() thumbnail_buffer.open(QBufferOpenMode) @@ -133,19 +143,7 @@ def _encodeSnapshot(self, snapshot: QImage) -> str: Logger.logException("w", "Failed to encode snapshot image") def _getValue(self, key) -> str: - # app = CuraApplication.getInstance() stack = ExtruderManager.getInstance().getActiveExtruderStack() - # stack2 = app.getGlobalContainerStack() - # stack3 = app.getMachineManager() - - # stack = None - # if stack1.hasProperty(key, "value"): - # stack = stack1 - # elif stack2.hasProperty(key, "value"): - # stack = stack2 - # elif stack3.hasProperty(key, "value"): - # stack = stack3 - if not stack: return "" @@ -155,15 +153,11 @@ def _getValue(self, key) -> str: if str(GetType) == "float": GelValStr = "{:.4f}".format(GetVal).rstrip("0").rstrip(".") else: - # enum = Option list if str(GetType) == "enum": - # definition_option = key + " option " + str(GetVal) get_option = str(GetVal) GetOption = stack.getProperty(key, "options") GetOptionDetail = GetOption[get_option] - # GelValStr=i18n_catalog.i18nc(definition_option, GetOptionDetail) GelValStr = GetOptionDetail - # Logger.log("d", "GetType_doTree = %s ; %s ; %s ; %s",definition_option, GelValStr, GetOption, GetOptionDetail) else: GelValStr = str(GetVal) diff --git a/Makefile b/Makefile deleted file mode 100644 index 99501ee..0000000 --- a/Makefile +++ /dev/null @@ -1,22 +0,0 @@ -TARGET=~/Library/Application\ Support/cura/5.0/plugins/Snapmaker2Plugin/Snapmaker2Plugin/ - -PROJ = Snapmaker2Plugin - -SRC = plugin.json \ - __init__.py \ - SM2GCodeWriter.py \ - SM2OutputDeviceManager.py - -REL = $(SRC) \ - LICENSE \ - README.md \ - README.en-us.md - -install: - cp -f $(SRC) $(TARGET) - -release: - mkdir $(PROJ) - cp $(REL) $(PROJ) - zip -r $(PROJ).zip $(PROJ) - rm -fr $(PROJ) diff --git a/OutputDevice.py b/OutputDevice.py new file mode 100644 index 0000000..8c7c334 --- /dev/null +++ b/OutputDevice.py @@ -0,0 +1,321 @@ +import time +import json +from typing import List +from io import StringIO + +from cura.CuraApplication import CuraApplication +from cura.PrinterOutput.NetworkedPrinterOutputDevice import NetworkedPrinterOutputDevice, AuthState +from cura.PrinterOutput.PrinterOutputDevice import ConnectionState + +from UM.Logger import Logger +from UM.Message import Message +from UM.FileHandler.WriteFileJob import WriteFileJob + +from .qt_comp import * +from .GCodeWriter import SM2GCodeWriter + + +class SM2OutputDevice(NetworkedPrinterOutputDevice): + + def __init__(self, device_id, address, token, properties={}, **kwargs): + assert "@" in device_id + super().__init__(device_id, address, properties, **kwargs) + + self._name, self._model = device_id.rsplit("@", 1) + self._token = token + + self._filename = "" + self._api_prefix = ":8080/api/v1" + self._gcode_stream = StringIO() + + self.setPriority(2) + self.setShortDescription("Send to {}".format(self._address)) # button + self.setDescription("Send to {}".format(self._id)) # pop menu + self.setConnectionText("Connected to {}".format(self._id)) + + self.authenticationStateChanged.connect( + self._onAuthenticationStateChanged) + self.connectionStateChanged.connect(self._onConnectionStateChanged) + self.writeFinished.connect(self._byebye) + + self._progress = PrintJobUploadProgressMessage(self) + self._need_auth = PrintJobNeedAuthMessage(self) + + def getToken(self) -> str: + return self._token + + def setToken(self, token: str): + self._token = token + + def getModel(self) -> str: + return self._model + + def setDeviceStatus(self, status: str): + Logger.debug("%s setDeviceStatus: %s, last state: %s", self.getId(), + status, self.connectionState) + if status == "IDLE": + if self.connectionState != ConnectionState.Connected: + self.setConnectionState(ConnectionState.Connected) + elif status in ("RUNNING", "PAUSED", "STOPPED"): + if self.connectionState != ConnectionState.Busy: + self.setConnectionState(ConnectionState.Busy) + + def _onConnectionStateChanged(self, id): + Logger.debug("onConnectionStateChanged: id: %s, state: %s", id, + self.connectionState) + if id != self.getId(): + return + + if (self.connectionState == ConnectionState.Connected + and self.authenticationState == AuthState.Authenticated): + if self._sending_gcode and not self._progress.visible: + self._progress.show() + self._upload() + + def _onAuthenticationStateChanged(self): + if self.authenticationState == AuthState.Authenticated: + self._need_auth.hide() + elif self.authenticationState == AuthState.AuthenticationRequested: + self._need_auth.show() + elif self.authenticationState == AuthState.AuthenticationDenied: + self._token = "" + self._sending_gcode = False + self._need_auth.hide() + + def requestWrite(self, + nodes, + file_name=None, + limit_mimetypes=False, + file_handler=None, + filter_by_machine=False, + **kwargs) -> None: + if self.connectionState == ConnectionState.Busy: + Message(title="Unable to upload", + text="{} is busy.".format(self.getId())).show() + return + + if self._progress.visible or self._need_auth.visible: + Logger.info("Still working in progress.") + return + + # reset + self._sending_gcode = True + self.setConnectionState(ConnectionState.Closed) + self.setAuthenticationState(AuthState.NotAuthenticated) + + self.writeStarted.emit(self) + self._gcode_stream = StringIO() + job = WriteFileJob(SM2GCodeWriter(), self._gcode_stream, nodes, + SM2GCodeWriter.OutputMode.TextMode) + job.finished.connect(self._onWriteJobFinished) + + message = Message(title="Preparing for upload", + progress=-1, + lifetime=0, + dismissable=False, + use_inactivity_timer=False) + message.show() + + job.setMessage(message) + job.start() + + def _onWriteJobFinished(self, job): + self._hello() + + def _queryParams(self) -> List[QHttpPart]: + return [ + self._createFormPart('name=token', self._token.encode()), + self._createFormPart('name=_', "{}".format(time.time()).encode()) + ] + + def _hello(self) -> None: + self.postFormWithParts("/connect", self._queryParams(), + self._onRequestFinished) + + def _byebye(self): + if self._token: + self.postFormWithParts( + "/disconnect", self._queryParams(), + lambda r: self.setConnectionState(ConnectionState.Closed)) + + def checkStatus(self): + url = "/status?token={}&_={}".format(self._token, time.time()) + self.get(url, self._onRequestFinished) + + def _upload(self): + Logger.debug("Start upload to {}".format(self._name)) + if not self._token: + return + + print_info = CuraApplication.getInstance().getPrintInformation() + job_name = print_info.jobName.strip() + print_time = print_info.currentPrintTime + material_name = "-".join(print_info.materialNames) + + self._filename = "{}_{}_{}.gcode".format( + job_name, material_name, + "{}h{}m{}s".format(print_time.days * 24 + print_time.hours, + print_time.minutes, print_time.seconds)) + + parts = self._queryParams() + parts.append( + self._createFormPart( + 'name=file; filename="{}"'.format(self._filename), + self._gcode_stream.getvalue().encode())) + self._gcode_stream.close() + self.postFormWithParts("/upload", + parts, + on_finished=self._onRequestFinished, + on_progress=self._onUploadProgress) + + def _onUploadProgress(self, bytes_sent: int, bytes_total: int): + if bytes_total > 0: + perc = (bytes_sent / bytes_total) if bytes_total else 0 + self._progress.setProgress(perc * 100) + self.writeProgress.emit() + + def _onRequestFinished(self, reply: QNetworkReply) -> None: + http_url = reply.url().toString() + + if reply.error() not in ( + QNetworkReplyNetworkErrors.NoError, + QNetworkReplyNetworkErrors. + AuthenticationRequiredError # 204 is No Content, not an error + ): + Logger.warning("Error %d from %s", reply.error(), http_url) + self.setConnectionState(ConnectionState.Closed) + Message(title="Error", + text=reply.errorString(), + lifetime=0, + dismissable=True).show() + return + + http_code = reply.attribute( + QNetworkRequestAttributes.HttpStatusCodeAttribute) + Logger.info("Request: %s - %d", http_url, http_code) + if not http_code: + return + + http_method = reply.operation() + if http_method == QNetworkAccessManagerOperations.GetOperation: + if self._api_prefix + "/status" in http_url: + if http_code == 200: + self.setAuthenticationState(AuthState.Authenticated) + resp = self._jsonReply(reply) + device_status = resp.get("status", "UNKNOWN") + self.setDeviceStatus(device_status) + elif http_code == 401: + self.setAuthenticationState(AuthState.AuthenticationDenied) + elif http_code == 204: + self.setAuthenticationState( + AuthState.AuthenticationRequested) + else: + self.setAuthenticationState(AuthState.NotAuthenticated) + + elif http_method == QNetworkAccessManagerOperations.PostOperation: + if self._api_prefix + "/connect" in http_url: + if http_code == 200: + resp = self._jsonReply(reply) + token = resp.get("token") + if self._token != token: + self._token = token + self.checkStatus() # check status and upload + + elif http_code == 403 and self._token: + # expired + self._token = "" + self.connect() + else: + self.setConnectionState(ConnectionState.Closed) + Message( + title="Error", + text= + "Please check the touchscreen and try again (Err: {})." + .format(http_code), + lifetime=10, + dismissable=True).show() + + # elif self._api_prefix + "/disconnect" in http_url: + # self.setConnectionState(ConnectionState.Closed) + + elif self._api_prefix + "/upload" in http_url: + self._progress.hide() + self.writeFinished.emit() + self._sending_gcode = False + + Message(title="Sent to {}".format(self.getId()), + text="Start print on the touchscreen: {}".format( + self._filename), + lifetime=60).show() + + def _jsonReply(self, reply: QNetworkReply): + try: + return json.loads(bytes(reply.readAll()).decode("utf-8")) + except json.decoder.JSONDecodeError: + Logger.warning("Received invalid JSON from snapmaker.") + return {} + + +class PrintJobUploadProgressMessage(Message): + + def __init__(self, device: SM2OutputDevice): + super().__init__(title="Sending to {}".format(device.getId()), + progress=-1, + lifetime=0, + dismissable=False, + use_inactivity_timer=False) + self._device = device + self._gTimer = QTimer() + self._gTimer.setInterval(3 * 1000) + self._gTimer.timeout.connect(lambda: self._heartbeat()) + self.inactivityTimerStart.connect(self._startTimer) + self.inactivityTimerStop.connect(self._stopTimer) + + def show(self): + self.setProgress(0) + super().show() + + def update(self, percentage: int): + if not self._visible: + super().show() + self.setProgress(percentage) + + def _heartbeat(self): + self._device.checkStatus() + + def _startTimer(self): + if self._gTimer and not self._gTimer.isActive(): + self._gTimer.start() + + def _stopTimer(self): + if self._gTimer and self._gTimer.isActive(): + self._gTimer.stop() + + +class PrintJobNeedAuthMessage(Message): + + def __init__(self, device: SM2OutputDevice): + super().__init__( + title="Screen authorization needed", + text="Please tap Yes on Snapmaker touchscreen to continue.", + lifetime=0, + dismissable=True, + use_inactivity_timer=False) + self._device = device + self.setProgress(-1) + self._gTimer = QTimer() + self._gTimer.setInterval(1500) + self._gTimer.timeout.connect(lambda: self._onCheck(None, None)) + self.inactivityTimerStart.connect(self._startTimer) + self.inactivityTimerStop.connect(self._stopTimer) + + def _startTimer(self): + if self._gTimer and not self._gTimer.isActive(): + self._gTimer.start() + + def _stopTimer(self): + if self._gTimer and self._gTimer.isActive(): + self._gTimer.stop() + + def _onCheck(self, *args, **kwargs): + self._device.checkStatus() diff --git a/OutputDevicePlugin.py b/OutputDevicePlugin.py new file mode 100644 index 0000000..7c74809 --- /dev/null +++ b/OutputDevicePlugin.py @@ -0,0 +1,262 @@ +import json +import socket +from typing import List, Dict + +from UM.Logger import Logger +from UM.Platform import Platform +from UM.Signal import signalemitter, Signal +from UM.OutputDevice.OutputDevicePlugin import OutputDevicePlugin +from UM.Application import Application + +from .qt_comp import * +from .OutputDevice import SM2OutputDevice + +MACHINE_SERIES = "Snapmaker 2" +DISCOVER_PORT = 20054 +DISCOVER_INTERVAL = 6000 # 6 seconds + + +@signalemitter +class DiscoverSocket: + dataReady = Signal() + + def __init__(self, address_entry: QNetworkAddressEntry) -> None: + self._address_entry = address_entry + self._broadcast_address = address_entry.broadcast() + + self._socket = None # internal socket + + self._collect_timer = QTimer() + self._collect_timer.setInterval(200) + self._collect_timer.setSingleShot(True) + self._collect_timer.timeout.connect(self.__collect) + + @property + def address(self) -> QHostAddress: + return self._address_entry.ip() + + def bind(self) -> bool: + sock = QUdpSocket() + + bind_result = sock.bind(self._address_entry.ip(), + mode=QAbstractSocket.BindFlag.DontShareAddress + | QAbstractSocket.BindFlag.ReuseAddressHint) + if not bind_result: + return False + + if Platform.isWindows(): + # On Windows, QUdpSocket is unable to receive broadcast data, we use original socket instead + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, + socket.IPPROTO_UDP) + sock.settimeout(0.2) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 255) + self._socket = sock + else: + # On Unix, we use socket interface provided by Qt 6 + self._socket = sock + sock.readyRead.connect(self.__read) + + return True + + def discover(self, message: bytes) -> None: + if isinstance(self._socket, QUdpSocket): + self._socket.writeDatagram(message, self._broadcast_address, + DISCOVER_PORT) + else: + self._socket.sendto( + message, (self._broadcast_address.toString(), DISCOVER_PORT)) + self._collect_timer.start() + + def abort(self) -> None: + if not self._socket: + return + + if isinstance(self._socket, QUdpSocket): + self._socket.abort() + else: + self._socket.close() + + self._socket = None + + def __read(self) -> None: + while self._socket.hasPendingDatagrams(): + data = self._socket.receiveDatagram() + if data.isValid() and not data.senderAddress().isNull(): + try: + message = bytes(data.data()).decode("utf-8") + self.dataReady.emit(message) + except UnicodeDecodeError: + pass + + def __collect(self) -> None: + if isinstance(self._socket, QUdpSocket): + return + + while True: + try: + msg, _ = self._socket.recvfrom(128) + except TimeoutError: + break + + try: + message = msg.decode("utf-8") + self.dataReady.emit(message) + except UnicodeDecodeError: + pass + + +class SM2OutputDevicePlugin(OutputDevicePlugin): + PREFERENCE_KEY_TOKEN = "Snapmaker2PluginSettings/tokens" + + def __init__(self) -> None: + super().__init__() + + self._discover_timer = QTimer() + self._discover_timer.setInterval(DISCOVER_INTERVAL) + self._discover_timer.setSingleShot(False) + self._discover_timer.timeout.connect(self.__discover) + + self._discover_sockets = [] # type: List[QUdpSocket] + self._tokens = {} # type: Dict[str, str] + + Application.getInstance().globalContainerStackChanged.connect( + self._onGlobalContainerStackChanged) + Application.getInstance().applicationShuttingDown.connect(self.stop) + + def _loadTokens(self) -> None: + preferences = Application.getInstance().getPreferences() + preferences.addPreference(self.PREFERENCE_KEY_TOKEN, "{}") + + try: + self._tokens = json.loads( + preferences.getValue(self.PREFERENCE_KEY_TOKEN)) + except ValueError: + pass + + if not isinstance(self._tokens, dict): + self._tokens = {} + + Logger.debug("(%d) tokens loaded.", len(self._tokens.keys())) + + def _saveTokens(self) -> None: + updated = False + devices = self.getOutputDeviceManager().getOutputDevices() + + for d in devices: + if isinstance(d, SM2OutputDevice) and d.getToken(): + ex_token = self._tokens.get(d.getId(), "") + if not ex_token or ex_token != d.getToken(): + self._tokens[d.getId()] = d.getToken() + updated = True + + if updated: + try: + Application.getInstance().getPreferences().setValue( + self.PREFERENCE_KEY_TOKEN, json.dumps(self._tokens)) + Logger.debug("(%d) tokens saved.", len(self._tokens.keys())) + except ValueError: + self._tokens = {} + + def _deviceId(self, name, model) -> str: + return "{}@{}".format(name, model) + + def __prepare(self) -> None: + self._discover_sockets = [] + for interface in QNetworkInterface.allInterfaces(): + for address_entry in interface.addressEntries(): + address = address_entry.ip() + if address.isLoopback(): + continue + if address.protocol() != QIPv4Protocol: + continue + + sock = DiscoverSocket(address_entry) + if sock.bind(): + Logger.info( + "Discovering printers on network interface: %s", + address.toString()) + sock.dataReady.connect(self.__onData) + self._discover_sockets.append(sock) + + def __discover(self) -> None: + if not self._discover_sockets: + self.__prepare() + + for sock in self._discover_sockets: + Logger.debug("Discovering networked printer... (interface: %s)", + sock.address.toString()) + sock.discover(b"discover") + + self._saveTokens() # TODO + + # TODO: remove output devices that not reply message for a period of time + + def __onData(self, msg: str) -> None: + """Parse message. + + msg: Snapmaker-DUMMY@127.0.0.1|model:Snapmaker 2 Model A350|status:IDLE + """ + parts = msg.split("|") + if len(parts) < 1 or "@" not in parts[0]: + # invalid message + return + + name, address = parts[0].rsplit("@", 1) + + properties = {} + for part in parts[1:]: + if ":" not in part: + continue + + key, value = part.split(":") + properties[key] = value + + model = properties.get("model", "") + if not model.startswith(MACHINE_SERIES): + return + + device_id = self._deviceId(name, model) + + device = self.getOutputDeviceManager().getOutputDevice(device_id) + if not device: + token = self._tokens.get(device_id, "") + Logger.info("Discovered Snapmaker printer: %s@%s (token: '%s')", + name, address, token) + device = SM2OutputDevice(device_id, address, token, properties) + self.getOutputDeviceManager().addOutputDevice(device) + + def start(self) -> None: + if self._isSM2Container() and not self._discover_timer.isActive(): + self._loadTokens() + self._discover_timer.start() + Logger.info("Snapmaker discovering started.") + + def stop(self) -> None: + if self._discover_timer.isActive(): + self._discover_timer.stop() + + for sock in self._discover_sockets: + sock.abort() + + # clear all discover sockets + self._discover_sockets.clear() + + self._saveTokens() + + Logger.info("Snapmaker discovering stopped.") + + def startDiscovery(self) -> None: + self.__discover() + + def _onGlobalContainerStackChanged(self) -> None: + if self._isSM2Container(): + self.start() + else: + self.stop() + + def _isSM2Container(self) -> bool: + machine_name = Application.getInstance().getGlobalContainerStack().name + Logger.debug('machine name: %s', machine_name) + return machine_name.startswith(MACHINE_SERIES) diff --git a/SM2OutputDeviceManager.py b/SM2OutputDeviceManager.py deleted file mode 100644 index 88abf0c..0000000 --- a/SM2OutputDeviceManager.py +++ /dev/null @@ -1,516 +0,0 @@ -import time -import json -from io import StringIO -from typing import List -try: - from PyQt6.QtCore import QTimer - from PyQt6.QtNetwork import ( - QUdpSocket, - QHostAddress, - QHttpPart, - QNetworkRequest, - QNetworkAccessManager, - QNetworkReply, - QNetworkInterface, - QAbstractSocket - ) - QNetworkAccessManagerOperations = QNetworkAccessManager.Operation - QNetworkRequestAttributes = QNetworkRequest.Attribute - QNetworkReplyNetworkErrors = QNetworkReply.NetworkError - QHostAddressBroadcast = QHostAddress.SpecialAddress.Broadcast - QIPv4Protocol = QAbstractSocket.NetworkLayerProtocol.IPv4Protocol -except ImportError: - from PyQt5.QtCore import QTimer - from PyQt5.QtNetwork import ( - QUdpSocket, - QHostAddress, - QHttpPart, - QNetworkRequest, - QNetworkAccessManager, - QNetworkReply, - QNetworkInterface, - QAbstractSocket - ) - QNetworkAccessManagerOperations = QNetworkAccessManager - QNetworkRequestAttributes = QNetworkRequest - QNetworkReplyNetworkErrors = QNetworkReply - - if hasattr(QHostAddress, 'Broadcast'): - QHostAddressBroadcast = QHostAddress.Broadcast - else: - QHostAddressBroadcast = QHostAddress.SpecialAddress.Broadcast - - if hasattr(QAbstractSocket, 'IPv4Protocol'): - QIPv4Protocol = QAbstractSocket.IPv4Protocol - else: - QIPv4Protocol = QAbstractSocket.NetworkLayerProtocol.IPv4Protocol - - -from cura.CuraApplication import CuraApplication -from cura.PrinterOutput.NetworkedPrinterOutputDevice import NetworkedPrinterOutputDevice, AuthState -from cura.PrinterOutput.PrinterOutputDevice import ConnectionState - -from UM.Signal import Signal -from UM.Logger import Logger -from UM.Message import Message -from UM.FileHandler.WriteFileJob import WriteFileJob -from UM.OutputDevice.OutputDevicePlugin import OutputDevicePlugin -from UM.Application import Application - -from .SM2GCodeWriter import SM2GCodeWriter - - -class SM2OutputDeviceManager(OutputDevicePlugin): - """ - tokens: - { - "My3DP@Snapmaker 2 Model A350": "token1", - "MyCNC@Snapmaker 2 Model A250": "token2", - } - """ - PREFERENCE_KEY_TOKEN = "Snapmaker2PluginSettings/tokens" - - discoveredDevicesChanged = Signal() - - def __init__(self): - super().__init__() - - self._discover_sockets = [] - for interface in QNetworkInterface.allInterfaces(): - for addr in interface.addressEntries(): - bcastAddress = addr.broadcast() - ipAddress = addr.ip() - if not ipAddress.isLoopback() and ipAddress.protocol() == QIPv4Protocol and bcastAddress != ipAddress: - Logger.log("i", "Discovering printers on network interface: {}".format(ipAddress.toString())) - socket = QUdpSocket() - socket.bind(ipAddress) - socket.readyRead.connect(lambda: self._udpProcessor(socket)) - self._discover_sockets.append(socket) - - self._discover_timer = QTimer() - self._discover_timer.setInterval(6000) - self._discover_timer.timeout.connect(self._onDiscovering) - - self._discovered_devices = set() # set{ip:bytes, id:bytes} - self.discoveredDevicesChanged.connect(self.addOutputDevice) - - app = Application.getInstance() - self._preferences = app.getPreferences() - self._preferences.addPreference(self.PREFERENCE_KEY_TOKEN, "{}") - try: - self._tokens = json.loads( - self._preferences.getValue(self.PREFERENCE_KEY_TOKEN) - ) - except ValueError: - self._tokens = {} - if not isinstance(self._tokens, dict): - self._tokens = {} - Logger.log("d", "'{}' tokens loaded.".format(len(self._tokens.keys()))) - - app.globalContainerStackChanged.connect(self.start) - app.applicationShuttingDown.connect(self.stop) - - def start(self): - if self._discover_timer and not self._discover_timer.isActive(): - self._onDiscovering() - self._discover_timer.start() - Logger.log("i", "Snapmaker2Plugin started.") - - def stop(self): - for socket in self._discover_sockets: - socket.abort() - if self._discover_timer and self._discover_timer.isActive(): - self._discover_timer.stop() - self._saveTokens() - Logger.log("i", "Snapmaker2Plugin stopped.") - - def _saveTokens(self): - updated = False - devices = self.getOutputDeviceManager().getOutputDevices() - for d in devices: - if hasattr(d, "getToken") and hasattr(d, "getModel") and d.getToken(): - name = self._tokensKeyName(d.getName(), d.getModel()) - if name not in self._tokens or self._tokens[name] != d.getToken(): - self._tokens[name] = d.getToken() - updated = True - if updated and self._preferences: - try: - self._preferences.setValue(self.PREFERENCE_KEY_TOKEN, json.dumps(self._tokens)) - Logger.log("d", "'%d' tokens saved." % len(self._tokens.keys())) - except ValueError: - self._tokens = {} - - def startDiscovery(self): - Logger.log("i", "Discovering ...") - self._onDiscovering() - - def _udpProcessor(self, socket): - devices = set() - while socket.hasPendingDatagrams(): - data = socket.receiveDatagram() - if data.isValid() and not data.senderAddress().isNull(): - ip = data.senderAddress().toString() - try: - msg = bytes(data.data()).decode("utf-8") - if "|model:" in msg and "|status:" in msg: - devices.add((ip, msg)) - else: - Logger.log("w", "Unknown device %s from %s", msg, ip) - except UnicodeDecodeError: - pass - if len(devices) and self._discovered_devices != devices: - Logger.log("i", "Discover finished, found %d devices.", len(devices)) - self._discovered_devices = devices - self.discoveredDevicesChanged.emit() - - def _onDiscovering(self, *args, **kwargs): - for socket in self._discover_sockets: - socket.writeDatagram(b"discover", QHostAddressBroadcast, 20054) - self._saveTokens() # TODO - - def addOutputDevice(self): - for ip, resp in self._discovered_devices: - Logger.log("d", "Found device [%s] %s", ip, resp) - id, name, model, status = self._parse(resp) - if not id: - continue - device = self.getOutputDeviceManager().getOutputDevice(id) - if not device: - device = SM2OutputDevice(ip, id, name, model) - self.getOutputDeviceManager().addOutputDevice(device) - key = self._tokensKeyName(name, model) - if key in self._tokens: - device.setToken(self._tokens[key]) - device.setDeviceStatus(status) - - def _tokensKeyName(self, name, model) -> str: - return "{}@{}".format(name, model) - - def _parse(self, resp): - """ - Snapmaker-DUMMY@127.0.0.1|model:Snapmaker 2 Model A350|status:IDLE - """ - p_model = resp.find("|model:") - p_status = resp.find("|status:") - if p_model and p_status: - id = resp[:p_model] - name = id[:id.rfind("@")] - model = resp[p_model + 7:p_status] - status = resp[p_status + 8:] - return id, name, model, status - return None, None, None, None - - -class SM2OutputDevice(NetworkedPrinterOutputDevice): - - def __init__(self, address, device_id, name, model, **kwargs): - super().__init__( - device_id=device_id, - address=address, - properties={}, - **kwargs) - - self._model = model - self._name = name - self._api_prefix = ":8080/api/v1" - self._auth_token = "" - self._filename = "" - self._gcode_stream = StringIO() - - self._setInterface() - - self.authenticationStateChanged.connect(self._onAuthenticationStateChanged) - self.connectionStateChanged.connect(self._onConnectionStateChanged) - self.writeFinished.connect(self._byebye) - - self._progress = PrintJobUploadProgressMessage(self) - self._need_auth = PrintJobNeedAuthMessage(self) - - def getToken(self) -> str: - return self._auth_token - - def setToken(self, token: str): - # Logger.log("d", "%s setToken: %s", self.getId(), token) - self._auth_token = token - - def getModel(self) -> str: - return self._model - - def setDeviceStatus(self, status: str): - Logger.log("d", "%s setDeviceStatus: %s, last state: %s", self.getId(), status, self.connectionState) - if status == "IDLE": - if self.connectionState != ConnectionState.Connected: - self.setConnectionState(ConnectionState.Connected) - elif status in ("RUNNING", "PAUSED", "STOPPED"): - if self.connectionState != ConnectionState.Busy: - self.setConnectionState(ConnectionState.Busy) - - def _onConnectionStateChanged(self, id): - Logger.log("d", "onConnectionStateChanged: id: %s, state: %s", id, self.connectionState) - if id != self.getId(): - return - - if ( - self.connectionState == ConnectionState.Connected and - self.authenticationState == AuthState.Authenticated - ): - if self._sending_gcode and not self._progress.visible: - self._progress.show() - self._upload() - - def _onAuthenticationStateChanged(self): - if self.authenticationState == AuthState.Authenticated: - self._need_auth.hide() - elif self.authenticationState == AuthState.AuthenticationRequested: - self._need_auth.show() - elif self.authenticationState == AuthState.AuthenticationDenied: - self._auth_token = "" - self._sending_gcode = False - self._need_auth.hide() - - def _setInterface(self): - self.setPriority(2) - self.setShortDescription("Send to {}".format(self._address)) # button - self.setDescription("Send to {}".format(self._id)) # pop menu - self.setConnectionText("Connected to {}".format(self._id)) - - def requestWrite(self, nodes, - file_name=None, limit_mimetypes=False, file_handler=None, filter_by_machine=False, **kwargs) -> None: - if self.connectionState == ConnectionState.Busy: - Message(title="Unable to upload", text="{} is busy.".format(self.getId())).show() - return - - if self._progress.visible or self._need_auth.visible: - Logger.log("i", "Still working in progress.") - return - - # reset - self._sending_gcode = True - self.setConnectionState(ConnectionState.Closed) - self.setAuthenticationState(AuthState.NotAuthenticated) - - self.writeStarted.emit(self) - self._gcode_stream = StringIO() - job = WriteFileJob(SM2GCodeWriter(), self._gcode_stream, nodes, SM2GCodeWriter.OutputMode.TextMode) - job.finished.connect(self._onWriteJobFinished) - - message = Message( - title="Preparing for upload", - progress=-1, - lifetime=0, - dismissable=False, - use_inactivity_timer=False) - message.show() - - job.setMessage(message) - job.start() - - def _onWriteJobFinished(self, job): - self._hello() - - def _queryParams(self) -> List[QHttpPart]: - return [ - self._createFormPart('name=token', self._auth_token.encode()), - self._createFormPart('name=_', "{}".format(time.time()).encode()) - ] - - def _hello(self) -> None: - # if self._auth_token: - # # Closed to prevent set Connected in NetworkedPrinterOutputDevice._handleOnFinished - # self.setConnectionState(ConnectionState.Closed) - self.postFormWithParts("/connect", self._queryParams(), self._onRequestFinished) - - def _byebye(self): - if self._auth_token: - self.postFormWithParts("/disconnect", self._queryParams(), - lambda r: self.setConnectionState(ConnectionState.Closed)) - - def checkStatus(self): - url = "/status?token={}&_={}".format(self._auth_token, time.time()) - self.get(url, self._onRequestFinished) - - def _upload(self): - Logger.log("d", "Start upload to {}".format(self._name)) - if not self._auth_token: - return - - print_info = CuraApplication.getInstance().getPrintInformation() - job_name = print_info.jobName.strip() - print_time = print_info.currentPrintTime - material_name = "-".join(print_info.materialNames) - - self._filename = "{}_{}_{}.gcode".format( - job_name, - material_name, - "{}h{}m{}s".format( - print_time.days * 24 + print_time.hours, - print_time.minutes, - print_time.seconds) - ) - - parts = self._queryParams() - parts.append( - self._createFormPart( - 'name=file; filename="{}"'.format(self._filename), - self._gcode_stream.getvalue().encode() - ) - ) - self._gcode_stream.close() - self.postFormWithParts("/upload", parts, - on_finished=self._onRequestFinished, - on_progress=self._onUploadProgress) - - def _onUploadProgress(self, bytes_sent: int, bytes_total: int): - if bytes_total > 0: - perc = (bytes_sent / bytes_total) if bytes_total else 0 - self._progress.setProgress(perc * 100) - self.writeProgress.emit() - - def _onRequestFinished(self, reply: QNetworkReply) -> None: - http_url = reply.url().toString() - - if reply.error() not in ( - QNetworkReplyNetworkErrors.NoError, - QNetworkReplyNetworkErrors.AuthenticationRequiredError # 204 is No Content, not an error - ): - Logger.log("w", "Error %d from %s", reply.error(), http_url) - self.setConnectionState(ConnectionState.Closed) - Message( - title="Error", - text=reply.errorString(), - lifetime=0, - dismissable=True - ).show() - return - - http_code = reply.attribute(QNetworkRequestAttributes.HttpStatusCodeAttribute) - Logger.log("i", "Request: %s - %d", http_url, http_code) - if not http_code: - return - - http_method = reply.operation() - if http_method == QNetworkAccessManagerOperations.GetOperation: - if self._api_prefix + "/status" in http_url: - if http_code == 200: - self.setAuthenticationState(AuthState.Authenticated) - resp = self._jsonReply(reply) - device_status = resp.get("status", "UNKNOWN") - self.setDeviceStatus(device_status) - elif http_code == 401: - self.setAuthenticationState(AuthState.AuthenticationDenied) - elif http_code == 204: - self.setAuthenticationState(AuthState.AuthenticationRequested) - else: - self.setAuthenticationState(AuthState.NotAuthenticated) - - elif http_method == QNetworkAccessManagerOperations.PostOperation: - if self._api_prefix + "/connect" in http_url: - if http_code == 200: - resp = self._jsonReply(reply) - token = resp.get("token") - if self._auth_token != token: - self._auth_token = token - self.checkStatus() # check status and upload - - elif http_code == 403 and self._auth_token: - # expired - self._auth_token = "" - self.connect() - else: - self.setConnectionState(ConnectionState.Closed) - Message( - title="Error", - text="Please check the touchscreen and try again (Err: {}).".format(http_code), - lifetime=10, - dismissable=True - ).show() - - # elif self._api_prefix + "/disconnect" in http_url: - # self.setConnectionState(ConnectionState.Closed) - - elif self._api_prefix + "/upload" in http_url: - self._progress.hide() - self.writeFinished.emit() - self._sending_gcode = False - - Message( - title="Sent to {}".format(self.getId()), - text="Start print on the touchscreen: {}".format(self._filename), - lifetime=60 - ).show() - - def _jsonReply(self, reply: QNetworkReply): - try: - return json.loads(bytes(reply.readAll()).decode("utf-8")) - except json.decoder.JSONDecodeError: - Logger.log("w", "Received invalid JSON from snapmaker.") - return {} - - -class PrintJobUploadProgressMessage(Message): - def __init__(self, device): - super().__init__( - title="Sending to {}".format(device.getId()), - progress=-1, - lifetime=0, - dismissable=False, - use_inactivity_timer=False - ) - self._device = device - self._gTimer = QTimer() - self._gTimer.setInterval(3 * 1000) - self._gTimer.timeout.connect(lambda: self._heartbeat()) - self.inactivityTimerStart.connect(self._startTimer) - self.inactivityTimerStop.connect(self._stopTimer) - - def show(self): - self.setProgress(0) - super().show() - - def update(self, percentage: int) -> None: - if not self._visible: - super().show() - self.setProgress(percentage) - - def _heartbeat(self): - self._device.checkStatus() - - def _startTimer(self): - if self._gTimer and not self._gTimer.isActive(): - self._gTimer.start() - - def _stopTimer(self): - if self._gTimer and self._gTimer.isActive(): - self._gTimer.stop() - - -class PrintJobNeedAuthMessage(Message): - def __init__(self, device) -> None: - super().__init__( - title="Screen authorization needed", - text="Please tap Yes on Snapmaker touchscreen to continue.", - lifetime=0, - dismissable=True, - use_inactivity_timer=False - ) - self._device = device - self.setProgress(-1) - # self.addAction("", "Continue", "", "") - # self.actionTriggered.connect(self._onCheck) - self._gTimer = QTimer() - self._gTimer.setInterval(1500) - self._gTimer.timeout.connect(lambda: self._onCheck(None, None)) - self.inactivityTimerStart.connect(self._startTimer) - self.inactivityTimerStop.connect(self._stopTimer) - - def _startTimer(self): - if self._gTimer and not self._gTimer.isActive(): - self._gTimer.start() - - def _stopTimer(self): - if self._gTimer and self._gTimer.isActive(): - self._gTimer.stop() - - def _onCheck(self, messageId, actionId): - self._device.checkStatus() - # self.hide() diff --git a/__init__.py b/__init__.py index 73d8bce..777b5a8 100644 --- a/__init__.py +++ b/__init__.py @@ -1,22 +1,22 @@ -from . import SM2OutputDeviceManager -from . import SM2GCodeWriter +from .OutputDevicePlugin import SM2OutputDevicePlugin +from .GCodeWriter import SM2GCodeWriter + def getMetaData(): return { "mesh_writer": { - "output": [ - { - "extension": "gcode", - "description": "Snapmaker G-code file", - "mime_type": "text/x-gcode", - "mode": SM2GCodeWriter.SM2GCodeWriter.OutputMode.TextMode - } - ] + "output": [{ + "extension": "gcode", + "description": "Snapmaker 2 G-code file", + "mime_type": "text/x-gcode", + "mode": SM2GCodeWriter.OutputMode.TextMode + }] } } + def register(app): return { - "output_device": SM2OutputDeviceManager.SM2OutputDeviceManager(), - "mesh_writer": SM2GCodeWriter.SM2GCodeWriter() + "output_device": SM2OutputDevicePlugin(), + "mesh_writer": SM2GCodeWriter() } diff --git a/plugin.json b/plugin.json index 51ea5bc..f0ac78e 100644 --- a/plugin.json +++ b/plugin.json @@ -1,8 +1,13 @@ { + "version": "8.0.0", "name": "Snapmaker 2.0 Connection", + "description": "Provides support for Snapmaker 2.0", "author": "https://github.com/macdylan", - "version": "7.2.0", - "api": 5, - "supported_sdk_versions": ["5.0.0", "6.0.0", "7.0.0", "8.0.0"], - "description": "Output Device plugin for Snapmaker 2.0" + "supported_sdk_versions": [ + "5.0.0", + "6.0.0", + "7.0.0", + "8.0.0" + ], + "api": 5 } diff --git a/qt_comp.py b/qt_comp.py new file mode 100644 index 0000000..854357b --- /dev/null +++ b/qt_comp.py @@ -0,0 +1,38 @@ + +try: + from PyQt6.QtCore import QTimer, pyqtProperty, pyqtSignal + from PyQt6.QtNetwork import ( + QHttpPart, + QUdpSocket, + QNetworkInterface, + QAbstractSocket, + QNetworkAddressEntry, + QHostAddress, + QNetworkRequest, + QNetworkAccessManager, + QNetworkReply + ) + QIPv4Protocol = QAbstractSocket.NetworkLayerProtocol.IPv4Protocol + QNetworkAccessManagerOperations = QNetworkAccessManager.Operation + QNetworkRequestAttributes = QNetworkRequest.Attribute + QNetworkReplyNetworkErrors = QNetworkReply.NetworkError +except ImportError: + from PyQt5.QtCore import QTimer, pyqtProperty, pyqtSignal + from PyQt5.QtNetwork import ( + QHttpPart, + QNetworkRequest, + QNetworkAccessManager, + QNetworkReply, + QUdpSocket, + QNetworkInterface, + QAbstractSocket, + QNetworkAddressEntry, + QHostAddress + ) + QNetworkAccessManagerOperations = QNetworkAccessManager + QNetworkRequestAttributes = QNetworkRequest + QNetworkReplyNetworkErrors = QNetworkReply + if hasattr(QAbstractSocket, 'IPv4Protocol'): + QIPv4Protocol = QAbstractSocket.IPv4Protocol + else: + QIPv4Protocol = QAbstractSocket.NetworkLayerProtocol.IPv4Protocol