diff --git a/.gitignore b/.gitignore index 27839a4..3eec870 100644 --- a/.gitignore +++ b/.gitignore @@ -129,3 +129,5 @@ dmypy.json .pyre/ .vscode release.sh + +*.zip diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..99501ee --- /dev/null +++ b/Makefile @@ -0,0 +1,22 @@ +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/README.en-us.md b/README.en-us.md index 388e827..f2f299c 100644 --- a/README.en-us.md +++ b/README.en-us.md @@ -34,10 +34,9 @@ Install profile from [snapmaker official cura profiles page](https://support.sna - Tap Yes in Snapmaker 2 touchscreen WiFi Connection Request (only required the first time after boot) - ![](_snapshots/touchscreen_auth.png) - + ![](_snapshots/touchscreen_auth.jpg) + - Wait for file upload process to complete -- Tap disconnect on the touchscreen or wait for touchscreen to return to home - Tap start buton on touchscreen - Navigate to files and choose the file that was just uploaded to your snapmaker @@ -53,7 +52,7 @@ You can also use Save to file in Snapmaker G-code file format to disk if require # Troubleshooting Snapmaker Discovery ⚠️ If your snapmaker name or IP address does not appear try the following steps: - 1. Ensure you snapmaker is connected to wifi by checking your wireless router or checking on the Snapmakers touchscreen + 1. Ensure you snapmaker is connected to wifi by checking your wireless router or checking on the Snapmakers touchscreen 2. Wait 5-10 seconds, Cura continuously looks for all compatible devices in the LAN and displays them automatically 3. Restart Snapmaker 2 (poweroff for **at least 15 seconds**) and wait for touchscreen to fully start and ensure it is connected to the right wireless lan 4. Check your computer's firewall to see if Cura access to the local area network is blocked (win10 is blocked by default) @@ -64,5 +63,4 @@ Please note that the instructions above are valid on firmware 1.12.1 and 1.12.3; --- -**__Make Something Wonderful_** - +**__Make Something Wonderful__** diff --git a/README.md b/README.md index 0f920b9..98b25f9 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,3 @@ - [Please click here for English README.md](README.en-us.md) # Snapmaker 2 Plugin for Cura - 无需配置,通过 UDP 广播自动查找局域网内所有 Snapmaker 2 设备 @@ -24,33 +23,36 @@ Cura 4.1 以后已经内置了 Snapmaker 2 的配置文件,参考 https://supp # Usage - 对模型切片后,将在 Save to File 按钮的位置出现设备选择菜单 - ![](_snapshots/sendto.png) - - ⚠️ 如果不出现这个菜单,可按照如下方法排查: - 1. 检查 Snapmaker 2 是否已经联网 - 2. 检查电脑的防火墙,是否阻止了 Cura 访问局域网(win10 默认会阻止) - 3. 等待 5-10 秒,Cura 会持续查找局域网内所有兼容设备并自动显示 - 4. 重启 Snapmaker 2 并等待联网,因为它的应答服务可能挂了 - 5. 检查路由器设置,是否阻止了 UDP 广播 - 6. 如果可能,确保电脑、Snapmaker 2、路由器尽可能靠近,避免丢包率过高 + ![](_snapshots/sendto.png) - 选择需要发送的设备,点击 Send to -- 每次 Snapmaker 2 启动后,需要在触摸屏进行一次连接授权,在 WiFi Connection Request 中点击 Yes +- 从触摸屏进行连接授权 - 等待文件发送完成 - ![](_snapshots/screen_auth.png) + ![](_snapshots/screen_auth.png) - 在 Snapmaker 2 触摸屏确认进行打印 - ![](_snapshots/preview.jpg) + ![](_snapshots/preview.jpg) -- 你也可以使用 Save to File,将文件保存为 Snapmaker G-code file(*.gcode) 格式 +你也可以使用 Save to File,将文件保存为 Snapmaker G-code file(*.gcode) 格式 - ![](_snapshots/savetofile.png) + ![](_snapshots/savetofile.png) -以上说明适用于固件 1.12 后续版本,不同版本的界面可能有所调整。Snapmaker 2 的无线连接有时候不稳定,如果按照以上排查方法仍无法解决,请提供 cura.log 文件到 issues 中以便分析,感谢你的帮助。 +以上说明适用于固件 1.12 后续版本,不同版本的界面可能有所调整。 +# 问题排查 +⚠️ Snapmaker 2 的无线连接有时候不稳定,如果无法出现设备选择菜单,可按如下方法排查: ---- -**_Make Something Wonderful_** + 1. 检查 Snapmaker 2 是否已经联网 + 2. 检查电脑的防火墙,是否阻止了 Cura 访问局域网(win10 默认会阻止) + 3. 等待 5-10 秒,Cura 会持续查找局域网内所有兼容设备并自动显示 + 4. 重启 Snapmaker 2 并等待联网,因为它的应答服务可能挂了 + 5. 检查路由器设置,是否阻止了 UDP 广播 + 6. 如果可能,确保电脑、Snapmaker 2、路由器尽可能靠近,避免丢包率过高 + +如仍无法解决,请提供 cura.log 文件到 issues 中以便分析,感谢你的帮助。 + +--- +**__Make Something Wonderful__** diff --git a/SM2GCodeWriter.py b/SM2GCodeWriter.py index 10e40e2..a138a20 100644 --- a/SM2GCodeWriter.py +++ b/SM2GCodeWriter.py @@ -1,30 +1,24 @@ -#Based of https://github.com/Razor10021990/SnapmakerGcodeWriter - -import re # For escaping characters in the settings. -import json -import copy import base64 +from io import StringIO +from typing import cast from UM.Mesh.MeshWriter import MeshWriter from UM.Logger import Logger -from UM.Application import Application -from UM.Settings.InstanceContainer import InstanceContainer -from cura.Machines.ContainerTree import ContainerTree +from UM.PluginRegistry import PluginRegistry -# from cura.CuraApplication import CuraApplication from cura.Snapshot import Snapshot from cura.CuraApplication import CuraApplication +from cura.Settings.ExtruderManager import ExtruderManager from cura.Utils.Threading import call_on_qt_thread -# from cura.UI import PrintInformation try: from PyQt6.QtCore import QBuffer from PyQt6.QtGui import QImage - QImageFormat = QImage.Format.Format_Indexed8 + # 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 + # QImageFormat = QImage.Format_Indexed8 QBufferOpenMode = QBuffer.ReadWrite from UM.i18n import i18nCatalog @@ -32,290 +26,131 @@ class SM2GCodeWriter(MeshWriter): - """Writes g-code to a file. - - While this poses as a mesh writer, what this really does is take the g-code - in the entire scene and write it to an output device. Since the g-code of a - single mesh isn't separable from the rest what with rafts and travel moves - and all, it doesn't make sense to write just a single mesh. - - So this plug-in takes the g-code that is stored in the root of the scene - node tree, adds a bit of extra information about the profiles and writes - that to the output device. - """ - - version = 1 - """The file format version of the serialised g-code. - - It can only read settings with the same version as the version it was - written with. If the file format is changed in a way that breaks reverse - compatibility, increment this version number! - """ - - escape_characters = { - re.escape("\\"): "\\\\", # The escape character. - re.escape("\n"): "\\n", # Newlines. They break off the comment. - re.escape("\r"): "\\r" # Carriage return. Windows users may need this for visualisation in their editors. - } - """Dictionary that defines how characters are escaped when embedded in - - g-code. - - Note that the keys of this dictionary are regex strings. The values are - not. - """ - - _setting_keyword = ";SETTING_" - - def __init__(self): - super().__init__(add_to_recent_files = False) - self._application = Application.getInstance() + PROCESSED_IDENTITY = ";Processed by Snapmaker2Plugin (https://github.com/macdylan/Snapmaker2Plugin)" @call_on_qt_thread - def _createSnapshot(self, *args): - Logger.log("d", "Creating thumbnail image...") - if not CuraApplication.getInstance().isVisible: - Logger.log("w", "Can't create snapshot when renderer not initialized.") - return None - try: - ss = Snapshot.snapshot(width=300, height=300).convertToFormat(QImageFormat) - except Exception: - Logger.logException("w", "Failed to create snapshot image") - return None - return ss - - def write(self, stream, nodes, mode = MeshWriter.OutputMode.TextMode): - """Writes the g-code for the entire scene to a stream. - - Note that even though the function accepts a collection of nodes, the - entire scene is always written to the file since it is not possible to - separate the g-code for just specific nodes. - - :param stream: The stream to write the g-code to. - :param nodes: This is ignored. - :param mode: Additional information on how to format the g-code in the - file. This must always be text mode. - """ - + 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.")) return False - active_build_plate = Application.getInstance().getMultiBuildPlateModel().activeBuildPlate - scene = Application.getInstance().getController().getScene() - if not hasattr(scene, "gcode_dict"): - self.setInformation(catalog.i18nc("@warning:status", "Please prepare G-code before exporting.")) - return False - gcode_dict = getattr(scene, "gcode_dict") - gcode_list = gcode_dict.get(active_build_plate, None) + gcode = StringIO() + writer = cast(MeshWriter, PluginRegistry.getInstance().getPluginObject("GCodeWriter")) + success = writer.write(gcode, None) - printTemp = None - bedTemp = None - for x in gcode_list: - if ("M190" in x or "M140" in x ) and bedTemp is None: - for y in re.findall(r"(M190|M140) S(\d+)", x): - bedTemp = y[1] - break; - if ("M109" in x or "M104" in x ) and printTemp is None: - for y in re.findall(r"(M109|M104) S(\d+)", x): - printTemp = y[1] - break; - if not (printTemp is None or bedTemp is None): - break; + if not success: + self.setInformation(writer.getInformation()) + return False - print_info = Application.getInstance().getPrintInformation() - feature_times = print_info.getFeaturePrintTimes() - estiTime = 0 #in Seconds - for x in feature_times: - estiTime += int(feature_times[x]) + gcode.seek(0) + result = self.mod(gcode) + stream.write(result.getvalue()) + Logger.log("i", "SM2GCodeWriter done") + return True + + def mod(self, data: StringIO) -> StringIO: + i = 0 + for x in data: + if i > 100: + break + if x.find(self.PROCESSED_IDENTITY) != -1: + return data + i += 1 + + data.seek(0) + gcodes = data.readlines() + + p = StringIO() + p.write(self.PROCESSED_IDENTITY + "\n") + p.write(";Header Start\n") + p.write(gcodes[0]) # FLAVOR + p.write(gcodes[1]) # TIME + p.write(gcodes[2]) # Filament used + p.write(gcodes[3]) # Layer height + p.write(";header_type: 3dp\n") - # Generate snapshot - base64_bytes = b"" ss = self._createSnapshot() if ss: + p.write(";thumbnail: data:image/png;base64,") + p.write(self._encodeSnapshot(ss)) + p.write("\n") + + app = CuraApplication.getInstance() + print_time = int(app.getPrintInformation().currentPrintTime) + print_temp = self._getValue("material_print_temperature") + bed_temp = self._getValue("material_bed_temperature") + + p.write(";file_total_lines: %d\n" % len(gcodes)) + p.write(";estimated_time(s): %d\n" % print_time) + p.write(";nozzle_temperature(°C): %s\n" % print_temp) + p.write(";build_plate_temperature(°C): %s\n" % bed_temp) + p.write(gcodes[7].replace("MAXX:", "max_x(mm): ")) # max_x + p.write(gcodes[8].replace("MAXY:", "max_y(mm): ")) # max_y + p.write(gcodes[9].replace("MAXZ:", "max_z(mm): ")) # max_z + p.write(gcodes[4].replace("MINX:", "min_x(mm): ")) # min_x + p.write(gcodes[5].replace("MINY:", "min_y(mm): ")) # min_y + p.write(gcodes[6].replace("MINZ:", "min_z(mm): ")) # min_z + p.write(";Header End\n") + + p.write("".join(gcodes[10:])) + return p + + def _createSnapshot(self): + Logger.log("d", "Creating thumbnail image...") + try: + return Snapshot.snapshot(width=150, height=150) # .convertToFormat(QImageFormat) + except Exception: + Logger.logException("w", "Failed to create snapshot image") + return None + + def _encodeSnapshot(self, snapshot) -> str: + Logger.log("d", "Encoding thumbnail image...") + try: thumbnail_buffer = QBuffer() thumbnail_buffer.open(QBufferOpenMode) - ss.save(thumbnail_buffer, "PNG") + thumbnail_image = snapshot + thumbnail_image.save(thumbnail_buffer, "PNG") base64_bytes = base64.b64encode(thumbnail_buffer.data()) + base64_message = base64_bytes.decode('ascii') thumbnail_buffer.close() - - # Start header - gcode_buffer = "" - header_buffer = False - model_line_count = 22 - if gcode_list is not None: - has_settings = False - is_gcode_file = False - for line, gcode in enumerate(gcode_list): - if line is 0 and len(re.findall(r"\n", gcode)) is 1: - is_gcode_file = True - - if is_gcode_file: - gcode_buffer += gcode - - else: - if gcode[:len(self._setting_keyword)] == self._setting_keyword: - has_settings = True - if ";FLAVOR:" not in gcode: - model_line_count += len(gcode.splitlines()) + 1 - gcode_buffer += gcode + "\n" - else: - # Split header lines and write to buffer - header_buffer = gcode.splitlines(keepends=True) - - if not is_gcode_file: - if not has_settings: - settings = self._serialiseSettings(Application.getInstance().getGlobalContainerStack()) - model_line_count += len(settings.splitlines()) - # Combine everything - stream.write(";Header Start\n\n") - stream.write(header_buffer[0]) # FLAVOR - stream.write(header_buffer[1]) # TIME - stream.write(header_buffer[2]) # Filament used - stream.write(header_buffer[3]) # Layer height - stream.write("\n;header_type: 3dp\n") - if base64_bytes: - stream.write(";thumbnail: data:image/png;base64,") - stream.write(base64_bytes.decode("ascii")) - stream.write("\n") - stream.write(";file_total_lines: %s\n" % model_line_count) - stream.write(";estimated_time(s): %s\n" % estiTime) - stream.write(";nozzle_temperature(°C): %s\n" % printTemp) - stream.write(";build_plate_temperature(°C): %s\n" % bedTemp) - stream.write(header_buffer[7].replace("MAXX:","max_x(mm): ")) # max_x - stream.write(header_buffer[8].replace("MAXY:","max_y(mm): ")) # max_y - stream.write(header_buffer[9].replace("MAXZ:","max_z(mm): ")) # max_z - stream.write(header_buffer[4].replace("MINX:","min_x(mm): ")) # min_x - stream.write(header_buffer[5].replace("MINY:","min_y(mm): ")) # min_y - stream.write(header_buffer[6].replace("MINZ:","min_z(mm): ")) # min_z - stream.write("\n;Header End\n\n") - - stream.write(gcode_buffer) - - # Serialise the current container stack and put it at the end of the file. - if not has_settings and not is_gcode_file: - stream.write(settings) - - return True - - self.setInformation(catalog.i18nc("@warning:status", "Please prepare G-code before exporting.")) - return False - - def _createFlattenedContainerInstance(self, instance_container1, instance_container2): - """Create a new container with container 2 as base and container 1 written over it.""" - - flat_container = InstanceContainer(instance_container2.getName()) - - # The metadata includes id, name and definition - flat_container.setMetaData(copy.deepcopy(instance_container2.getMetaData())) - - if instance_container1.getDefinition(): - flat_container.setDefinition(instance_container1.getDefinition().getId()) - - for key in instance_container2.getAllKeys(): - flat_container.setProperty(key, "value", instance_container2.getProperty(key, "value")) - - for key in instance_container1.getAllKeys(): - flat_container.setProperty(key, "value", instance_container1.getProperty(key, "value")) - - return flat_container - - def _serialiseSettings(self, stack): - """Serialises a container stack to prepare it for writing at the end of the g-code. - - The settings are serialised, and special characters (including newline) - are escaped. - - :param stack: A container stack to serialise. - :return: A serialised string of the settings. - """ - container_registry = self._application.getContainerRegistry() - - prefix = self._setting_keyword + str(SM2GCodeWriter.version) + " " # The prefix to put before each line. - prefix_length = len(prefix) - - quality_type = stack.quality.getMetaDataEntry("quality_type") - container_with_profile = stack.qualityChanges - machine_definition_id_for_quality = ContainerTree.getInstance().machines[stack.definition.getId()].quality_definition - if container_with_profile.getId() == "empty_quality_changes": - # If the global quality changes is empty, create a new one - quality_name = container_registry.uniqueName(stack.quality.getName()) - quality_id = container_registry.uniqueName((stack.definition.getId() + "_" + quality_name).lower().replace(" ", "_")) - container_with_profile = InstanceContainer(quality_id) - container_with_profile.setName(quality_name) - container_with_profile.setMetaDataEntry("type", "quality_changes") - container_with_profile.setMetaDataEntry("quality_type", quality_type) - if stack.getMetaDataEntry("position") is not None: # For extruder stacks, the quality changes should include an intent category. - container_with_profile.setMetaDataEntry("intent_category", stack.intent.getMetaDataEntry("intent_category", "default")) - container_with_profile.setDefinition(machine_definition_id_for_quality) - - flat_global_container = self._createFlattenedContainerInstance(stack.userChanges, container_with_profile) - # If the quality changes is not set, we need to set type manually - if flat_global_container.getMetaDataEntry("type", None) is None: - flat_global_container.setMetaDataEntry("type", "quality_changes") - - # Ensure that quality_type is set. (Can happen if we have empty quality changes). - if flat_global_container.getMetaDataEntry("quality_type", None) is None: - flat_global_container.setMetaDataEntry("quality_type", stack.quality.getMetaDataEntry("quality_type", "normal")) - - # Get the machine definition ID for quality profiles - flat_global_container.setMetaDataEntry("definition", machine_definition_id_for_quality) - - serialized = flat_global_container.serialize() - data = {"global_quality": serialized} - - all_setting_keys = flat_global_container.getAllKeys() - for extruder in stack.extruderList: - extruder_quality = extruder.qualityChanges - if extruder_quality.getId() == "empty_quality_changes": - # Same story, if quality changes is empty, create a new one - quality_name = container_registry.uniqueName(stack.quality.getName()) - quality_id = container_registry.uniqueName((stack.definition.getId() + "_" + quality_name).lower().replace(" ", "_")) - extruder_quality = InstanceContainer(quality_id) - extruder_quality.setName(quality_name) - extruder_quality.setMetaDataEntry("type", "quality_changes") - extruder_quality.setMetaDataEntry("quality_type", quality_type) - extruder_quality.setDefinition(machine_definition_id_for_quality) - - flat_extruder_quality = self._createFlattenedContainerInstance(extruder.userChanges, extruder_quality) - # If the quality changes is not set, we need to set type manually - if flat_extruder_quality.getMetaDataEntry("type", None) is None: - flat_extruder_quality.setMetaDataEntry("type", "quality_changes") - - # Ensure that extruder is set. (Can happen if we have empty quality changes). - if flat_extruder_quality.getMetaDataEntry("position", None) is None: - flat_extruder_quality.setMetaDataEntry("position", extruder.getMetaDataEntry("position")) - - # Ensure that quality_type is set. (Can happen if we have empty quality changes). - if flat_extruder_quality.getMetaDataEntry("quality_type", None) is None: - flat_extruder_quality.setMetaDataEntry("quality_type", extruder.quality.getMetaDataEntry("quality_type", "normal")) - - # Change the default definition - flat_extruder_quality.setMetaDataEntry("definition", machine_definition_id_for_quality) - - extruder_serialized = flat_extruder_quality.serialize() - data.setdefault("extruder_quality", []).append(extruder_serialized) - - all_setting_keys.update(flat_extruder_quality.getAllKeys()) - - # Check if there is any profiles - if not all_setting_keys: - Logger.log("i", "No custom settings found, not writing settings to g-code.") + return base64_message + except Exception: + Logger.logException("w", "Failed to encode snapshot image") + + def _getValue(self, key) -> str: + app = CuraApplication.getInstance() + stack1 = 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 "" - json_string = json.dumps(data) - - # Escape characters that have a special meaning in g-code comments. - pattern = re.compile("|".join(SM2GCodeWriter.escape_characters.keys())) - - # Perform the replacement with a regular expression. - escaped_string = pattern.sub(lambda m: SM2GCodeWriter.escape_characters[re.escape(m.group(0))], json_string) - - # Introduce line breaks so that each comment is no longer than 80 characters. Prepend each line with the prefix. - result = "" - - # Lines have 80 characters, so the payload of each line is 80 - prefix. - for pos in range(0, len(escaped_string), 80 - prefix_length): - result += prefix + escaped_string[pos: pos + 80 - prefix_length] + "\n" - return result + GetType = stack.getProperty(key, "type") + GetVal = stack.getProperty(key, "value") + + 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) + + return GelValStr diff --git a/SM2OutputDeviceManager.py b/SM2OutputDeviceManager.py index df8b73f..ff8fe8a 100644 --- a/SM2OutputDeviceManager.py +++ b/SM2OutputDeviceManager.py @@ -1,6 +1,8 @@ import threading import socket import requests +import time +import json from io import StringIO try: from PyQt6.QtCore import QTimer @@ -11,7 +13,6 @@ from PyQt5.QtNetwork import QNetworkReply QNetworkReplyNetworkErrors = QNetworkReply.NetworkError - from cura.CuraApplication import CuraApplication from cura.PrinterOutput.NetworkedPrinterOutputDevice import NetworkedPrinterOutputDevice, AuthState from cura.PrinterOutput.PrinterOutputDevice import ConnectionState, ConnectionType @@ -21,43 +22,81 @@ 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._discovered_devices = [] # List[ip:bytes, id:bytes] + self._discovering = threading.Event() + self._discover_thread = None + self._discovered_devices = [] # List[ip:bytes, id:bytes] self.discoveredDevicesChanged.connect(self.addOutputDevice) - self._update_thread = None - self._check_update = False - - self._app = CuraApplication.getInstance() - self._preferences = self._app.getPreferences() - - self._app.globalContainerStackChanged.connect(self.start) - self._app.applicationShuttingDown.connect(self.stop) + 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", "Load tokens: {}".format(self._tokens)) + + app.initializationFinished.connect(self.start) + app.applicationShuttingDown.connect(self.stop) def start(self): - self._check_update = True - if not self._update_thread or not self._update_thread.is_alive(): - self._update_thread = threading.Thread(target=self._updateThread, daemon=True) - self._update_thread.start() + if self._discover_thread is None or not self._discover_thread.is_alive(): + self._discover_thread = threading.Thread(target=self._discoverThread, daemon=True) + self._discover_thread.start() def stop(self): - self._check_update = False - self._update_thread.join() - - def _updateThread(self): - while self._check_update: + self._discovering.set() + if self._discover_thread and self._discover_thread.is_alive(): + self._discover_thread.join(timeout=1) + self._saveTokens() + + def _saveTokens(self): + 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()) + self._tokens[name] = d.getToken() + if self._preferences and len(self._tokens.keys()): + self._preferences.setValue(self.PREFERENCE_KEY_TOKEN, json.dumps(self._tokens)) + Logger.log("d", "%d tokens saved." % len(self._tokens.keys())) + + def startDiscovery(self): + Logger.log("i", "Discover start") + if self._preferences: + self._preferences.resetPreference(self.PREFERENCE_KEY_TOKEN) + self._addRemoveDevice(self._discover(timeout=3)) + Logger.log("i", "Discover finished, found %d devices.", len(self._discovered_devices)) + + def _discoverThread(self): + while not self._discovering.is_set(): self._addRemoveDevice(self._discover()) + self._saveTokens() # TODO + self._discovering.wait(4.0) - def _discover(self, msg=b"discover", port=20054, timeout=5): + def _discover(self, msg=b"discover", port=20054, timeout=2): devices = [] cs = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) cs.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) @@ -69,7 +108,7 @@ def _discover(self, msg=b"discover", port=20054, timeout=5): while True: resp, (ip, _) = cs.recvfrom(512) - if b"model:" in resp and b"status:" in resp: + if b"|model:" in resp and b"|status:" in resp: Logger.log("d", "Found device [%s] %s", ip, resp) devices.append((ip, resp)) else: @@ -83,37 +122,46 @@ def _addRemoveDevice(self, devices: list): def addOutputDevice(self): for ip, resp in self._discovered_devices: - id, model, status = self._parse(resp) - if id: - device = self.getOutputDeviceManager().getOutputDevice(id.decode()) - if not device: - properties = {b"model": model, b"status": status} - device = SM2OutputDevice(id.decode(), ip, properties) - self.getOutputDeviceManager().addOutputDevice(device) - - def _parse(self, resp:bytes): + id, name, model = self._parse(resp.decode()) + if not id: + continue + device = self.getOutputDeviceManager().getOutputDevice(id) + if not device: + device = SM2OutputDevice(ip, id, name, model) + key = self._tokensKeyName(name, model) + if key in self._tokens: + device.setToken(self._tokens[key]) + self.getOutputDeviceManager().addOutputDevice(device) + + 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(b"|model:") - p_status = resp.find(b"|status:") + p_model = resp.find("|model:") + p_status = resp.find("|status:") if p_model and p_status: id = resp[:p_model] - model = resp[p_model+7:p_status] - status = resp[p_status+8:] - return id, model, status + name = id[:id.rfind("@")] + model = resp[p_model + 7:p_status] + # status = resp[p_status + 8:] + return id, name, model return None, None, None class SM2OutputDevice(NetworkedPrinterOutputDevice): - def __init__(self, device_id, address, properties={}): + def __init__(self, address, device_id, name, model): super().__init__( device_id=device_id, address=address, - properties=properties, + properties={}, connection_type=ConnectionType.NetworkConnection) + self._model = model + self._name = name self._api_prefix = ":8080/api/v1" self._auth_token = "" self._gcode_stream = StringIO() @@ -129,18 +177,27 @@ def __init__(self, device_id, address, properties={}): 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", "setToken: %s", token) + self._auth_token = token + + def getModel(self) -> str: + return self._model + def _setInterface(self): self.setPriority(2) - self.setName("Snapmaker 2.0 Printing") - self.setShortDescription("Send to {}".format(self._address)) - self.setDescription("Send to {}".format(self._id)) + 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 _onConnectionStateChanged(self): if self.connectionState == ConnectionState.Busy: Message(title="Unable to upload", text="{} is busy".format(self._id)).show() - def _setAuthState(self, state): + def _setAuthState(self, state: "AuthState"): self._authentication_state = state self.authenticationStateChanged.emit() @@ -175,11 +232,16 @@ def _onWriteJobFinished(self, job): self._auth_token = self.connect() self._startUpload() + def _queryParams(self): + return { + "token": self._auth_token, + "_": time.time(), + } + def connect(self) -> str: super().connect() try: - conn = requests.post("http://" + self._address + self._api_prefix + "/connect", - data={"token": self._auth_token}) + conn = requests.post("http://" + self._address + self._api_prefix + "/connect", data=self._queryParams()) Logger.log("d", "/connect: %d from %s", conn.status_code, self._address) if conn.status_code == 200: return conn.json().get("token") @@ -188,7 +250,7 @@ def connect(self) -> str: self._auth_token = "" return self.connect() else: - Message(text="Please check the touchscreen and try again.", lifetime=10, dismissable=True).show() + Message(text="Please check the touchscreen and try again (Err: %d)." % conn.status_code, lifetime=10, dismissable=True).show() return self._auth_token except requests.exceptions.ConnectionError as e: @@ -196,14 +258,13 @@ def connect(self) -> str: return self._auth_token def disconnect(self): - requests.post("http://" + self._address + self._api_prefix + "/disconnect", - data={"token": self._auth_token}) + requests.post("http://" + self._address + self._api_prefix + "/disconnect", data=self._queryParams()) self.setConnectionState(ConnectionState.Closed) Logger.log("d", "/disconnect") def check_status(self): try: - conn = requests.get("http://" + self._address + self._api_prefix + "/status", params={"token": self._auth_token}) + conn = requests.get("http://" + self._address + self._api_prefix + "/status", params=self._queryParams()) Logger.log("d", "/status: %d from %s", conn.status_code, self._address) if conn.status_code == 200: @@ -228,7 +289,7 @@ def check_status(self): self._setAuthState(AuthState.NotAuthenticated) def _startUpload(self): - Logger.log("d", "Token: %s", self._auth_token) + Logger.log("d", "{} token is {}".format(self._name, self._auth_token)) if not self._auth_token: return @@ -242,21 +303,30 @@ def _startUpload(self): self._progress.show() - name = CuraApplication.getInstance().getPrintInformation().jobName.strip() - if name is "": - name = "untitled_print" - file_name = "%s.gcode" % name + print_info = CuraApplication.getInstance().getPrintInformation() + job_name = print_info.jobName.strip() + print_time = print_info.currentPrintTime + material_name = "-".join(print_info.materialNames) + + file_name = "{}_{}_{}.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._createFormPart("name=token", self._auth_token.encode()), self._createFormPart("name=file; filename=\"{}\"".format(file_name), self._gcode_stream.getvalue().encode()) ] - self._gcode_stream = StringIO() + self._gcode_stream.close() self.postFormWithParts("/upload", parts, - on_finished=self._onUploadCompleted, - on_progress=self._onUploadProgress) + on_finished=lambda reply: self._onUploadCompleted(file_name, reply), + on_progress=self._onUploadProgress) - def _onUploadCompleted(self, reply): + def _onUploadCompleted(self, filename, reply): self._progress.hide() if self.connectionState == ConnectionState.Connected: @@ -265,7 +335,7 @@ def _onUploadCompleted(self, reply): if reply.error() == QNetworkReplyNetworkErrors.NoError: Message( title="Sent to {}".format(self._id), - text="Start print on the touchscreen.", + text="Start print on the touchscreen: {}".format(filename), lifetime=0).show() self.writeFinished.emit() else: @@ -285,12 +355,12 @@ def checkAndStartUpload(self): class PrintJobUploadProgressMessage(Message): def __init__(self, device): super().__init__( - title = "Sending Print Job", - text = "Uploading print job to printer:", - progress = -1, - lifetime = 0, - dismissable = False, - use_inactivity_timer = False + title="Sending to {}".format(device.getId()), + text="Uploading print job to printer:", + progress=-1, + lifetime=0, + dismissable=False, + use_inactivity_timer=False ) self._device = device self._gTimer = QTimer() @@ -323,11 +393,11 @@ def _stopTimer(self): 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 + 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) diff --git a/_snapshots/touchscreen_auth.jpg b/_snapshots/touchscreen_auth.jpg new file mode 100644 index 0000000..b80b30b Binary files /dev/null and b/_snapshots/touchscreen_auth.jpg differ diff --git a/_snapshots/touchscreen_auth.png b/_snapshots/touchscreen_auth.png deleted file mode 100644 index 4f637ad..0000000 Binary files a/_snapshots/touchscreen_auth.png and /dev/null differ diff --git a/plugin.json b/plugin.json index d06c106..e6c98f4 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "name": "Snapmaker 2.0 Connection", "author": "https://github.com/macdylan", - "version": "6.0.0", + "version": "7.0.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"