diff --git a/core/nodeslicer/nodes.py b/core/nodeslicer/nodes.py index db1f779..ddfb925 100644 --- a/core/nodeslicer/nodes.py +++ b/core/nodeslicer/nodes.py @@ -5,7 +5,7 @@ from typing import Any, TypeAlias import bpy import numpy as np from mathutils import Euler -from bpy.props import StringProperty, FloatProperty, IntProperty +from bpy.props import StringProperty, FloatProperty, IntProperty, EnumProperty from ...registry import register_class from .. import slice @@ -61,8 +61,54 @@ class SLICENODE_InputObject(InputNode): input_type = "Object" bl_icon = "OBJECT_DATA" +@register_class +class SLICENODE_InputPrinter(SliceNode): + bl_label = "Printer" + bl_icon = "OUTPUT" + + def update_label(self, context): + self.label = self.printer_label + + printer_label: StringProperty(name="Label", default="Printer", update=update_label) # pyright: ignore[reportInvalidTypeForm] + + host_type: EnumProperty( + name="Type", + items=[ + ('PRUSALINK', "PrusaLink", "PrusaLink API"), + ('CREALITY', "Creality", "Creality Cloud API"), + ('MOONRAKER', "Moonraker", "Moonraker (Klipper) API"), + ], + default='PRUSALINK' + ) # pyright: ignore[reportInvalidTypeForm] + + def init(self, context): + self.add_output("SLICESOCK_Printer") + self.add_input("SLICESOCK_String", "Host").value = "localhost" + self.add_input("SLICESOCK_Int", "Port").value = 80 + self.add_input("SLICESOCK_String", "Prefix") + self.add_input("SLICESOCK_String", "Username") + self.add_input("SLICESOCK_String", "Password") + + def draw_buttons(self, context, layout): + layout.prop(self, "printer_label") # pyright: ignore[reportOptionalMemberAccess] + layout.prop(self, "host_type") # pyright: ignore[reportOptionalMemberAccess] + + def evaluate_output(self, socket_name: str, **_kwargs) -> Any: + from ...infra.printer_backends import PrinterConfig + return PrinterConfig( + host=self.resolve("Host"), + port=self.resolve("Port"), + prefix=self.resolve("Prefix"), + label=self.printer_label, + username=self.resolve("Username"), + password=self.resolve("Password"), + host_type=self.host_type + ) + + @register_class class SLICENODE_InputIntrinsics(SliceNode): + bl_label = "Printer Intrinsics" def init(self, context): @@ -657,6 +703,66 @@ class ExecSliceNode(SliceNode): layout.operator("slicenode.execute", text=self.button_label).node_name = self.name @register_class +class SLICENODE_PrinterList(SliceNode): + bl_label = "Printer List" + + def _refresh(self): + import json + if not self.config_path: + return + + abs_path = bpy.path.abspath(self.config_path) + if not os.path.exists(abs_path): + return + + try: + with open(abs_path, 'r', encoding='utf-8') as f: + data = json.load(f) + except Exception: + return + + printers = data.get("printers", []) + printer_names = set() + + for p_data in printers: + name = p_data.get("name", "Unknown") + printer_names.add(name) + sock = self.outputs.get(name) + if not sock: + sock = self.add_output("SLICESOCK_Printer", name) + + # Update properties + sock.label = name + sock.host = str(p_data.get("ip", "localhost")) + sock.port = int(p_data.get("port", 80)) + sock.prefix = str(p_data.get("prefix", "")) + sock.username = str(p_data.get("username", "")) + sock.password = str(p_data.get("password", "")) + sock.host_type = str(p_data.get("host_type", "PRUSALINK")).upper() + + # Remove stale sockets + for sock in list(self.outputs): + if sock.name not in printer_names: + self.outputs.remove(sock) + + def update_config(self, context): + self._refresh() + + config_path: StringProperty(name="Config File", default="", subtype='FILE_PATH', update=update_config) # pyright: ignore[reportInvalidTypeForm] + + def init(self, context): + pass + + def draw_buttons(self, context, layout): + layout.prop(self, "config_path") # pyright: ignore[reportOptionalMemberAccess] + + def evaluate_output(self, socket_name: str, **_kwargs) -> Any: + self._refresh() + sock = self.outputs.get(socket_name) + if sock: + return sock.value + return None +@register_class class SLICENODE_GcodeToFile(ExecSliceNode): bl_label = "Gcode To File" @@ -666,7 +772,7 @@ class SLICENODE_GcodeToFile(ExecSliceNode): self.add_input("SLICESOCK_Gcode") self.add_input("SLICESOCK_FilePath", "Filename") - def evaluate(self): + def evaluate(self, operator=None): gcode: str = resolve_input(self.inputs.get("Gcode")) filename: str = resolve_input(self.inputs.get("Filename")) @@ -678,6 +784,66 @@ class SLICENODE_GcodeToFile(ExecSliceNode): return 'ok' +@register_class +class SLICENODE_StartPrint(ExecSliceNode): + bl_label = "Start Print" + button_label = "Send to Printer" + + def init(self, context): + self.add_input("SLICESOCK_Gcode") + self.add_input("SLICESOCK_Printer") + + def evaluate(self, operator=None): + from ...infra.printer_backends import ( + PrinterConfig, + PrusaLinkBackend, + CrealityBackend, + MoonrakerBackend + ) + import tempfile + from pathlib import Path + + gcode: str = resolve_input(self.inputs.get("Gcode")) + config: PrinterConfig | None = resolve_input(self.inputs.get("Printer")) + + if not gcode: + if operator: operator.report({'ERROR'}, "No Gcode to send") + return 'no gcode' + if not config: + if operator: operator.report({'ERROR'}, "No Printer configuration") + return 'no printer' + + # Instantiate correct backend + backend = None + if config.host_type == 'PRUSALINK': + backend = PrusaLinkBackend(config.host, config.port, config.prefix, api_key=config.password) + elif config.host_type == 'CREALITY': + backend = CrealityBackend(config.host, config.port, config.prefix) + elif config.host_type == 'MOONRAKER': + backend = MoonrakerBackend(config.host, config.port, config.prefix) + + if not backend: + if operator: operator.report({'ERROR'}, f"Unsupported host type: {config.host_type}") + return 'unsupported' + + # Write to temp file to send + with tempfile.NamedTemporaryFile(mode='w', suffix='.gcode', delete=False) as tf: + tf.write(gcode) + temp_path = Path(tf.name) + + try: + print_name = f"NodeSlicer_{config.label}.gcode".replace(" ", "_") + backend.start_print(temp_path, print_name) + if operator: operator.report({'INFO'}, f"Print started on {config.label}") + except Exception as e: + if operator: operator.report({'ERROR'}, f"Failed to start print: {str(e)}") + return 'error' + finally: + if temp_path.exists(): + temp_path.unlink() + + return 'ok' + @register_class class SLICENODE_GcodeToMesh(ExecSliceNode): bl_label = "Gcode to Mesh" @@ -687,7 +853,7 @@ class SLICENODE_GcodeToMesh(ExecSliceNode): def init(self, context): self.add_input("SLICESOCK_Gcode") - def evaluate(self): + def evaluate(self, operator=None): gcode: str = resolve_input(self.inputs.get("Gcode")) if not gcode: return 'empty' @@ -795,6 +961,6 @@ class SLICENODE_Test(ExecSliceNode): def init(self, context): self.add_input("SLICESOCK_Gcode") - def evaluate(self): + def evaluate(self, operator=None): _ = resolve_input(self.inputs.get("Gcode")) return 'ok' diff --git a/core/nodeslicer/sockets.py b/core/nodeslicer/sockets.py index 408d8f5..db2f895 100644 --- a/core/nodeslicer/sockets.py +++ b/core/nodeslicer/sockets.py @@ -1,7 +1,7 @@ from __future__ import annotations from typing import Any import bpy -from bpy.props import PointerProperty, StringProperty, FloatProperty, IntProperty +from bpy.props import PointerProperty, StringProperty, FloatProperty, IntProperty, EnumProperty from ...registry import register_class from ...infra.blender_slice_nodes_generic import SliceNodeSocket @@ -114,3 +114,52 @@ class SLICESOCK_Intrinsics(LabelNodeSocket): bl_label = "Intrinsics" default = None color = (0.2, 0.2, 0.2, 1.0) + +@register_class +class SLICESOCK_Printer(InputNodeSocket): + bl_label = "Printer" + + host: StringProperty(name="Host", default="localhost") # pyright: ignore[reportInvalidTypeForm] + port: IntProperty(name="Port", default=80, min=1, max=65535) # pyright: ignore[reportInvalidTypeForm] + prefix: StringProperty(name="Prefix", default="") # pyright: ignore[reportInvalidTypeForm] + label: StringProperty(name="Label", default="Printer") # pyright: ignore[reportInvalidTypeForm] + username: StringProperty(name="Username", default="") # pyright: ignore[reportInvalidTypeForm] + password: StringProperty(name="Password", default="", subtype='PASSWORD') # pyright: ignore[reportInvalidTypeForm] + + host_type: EnumProperty( + name="Type", + items=[ + ('PRUSALINK', "PrusaLink", "PrusaLink API"), + ('CREALITY', "Creality", "Creality Cloud API"), + ('MOONRAKER', "Moonraker", "Moonraker (Klipper) API"), + ], + default='PRUSALINK' + ) # pyright: ignore[reportInvalidTypeForm] + + color = (1.0, 0.45, 0.1, 1.0) # Orange + + def draw(self, context, layout, node, text): + if self.is_output or self.is_linked: + layout.label(text=text or self.name) + else: + col = layout.column(align=True) + col.prop(self, "label", text="") + col.prop(self, "host_type", text="") + col.prop(self, "host") + col.prop(self, "port") + col.prop(self, "prefix") + col.prop(self, "username") + col.prop(self, "password") + + @property + def value(self) -> Any: + from ...infra.printer_backends import PrinterConfig + return PrinterConfig( + host=self.host, + port=self.port, + prefix=self.prefix, + label=self.label, + username=self.username, + password=self.password, + host_type=self.host_type + ) diff --git a/core/nodeslicer/tree.py b/core/nodeslicer/tree.py index 32dd879..f9cd581 100644 --- a/core/nodeslicer/tree.py +++ b/core/nodeslicer/tree.py @@ -25,9 +25,16 @@ class SLICENODE_OT_Execute(Operator): def execute(self, context) -> set['OperatorReturnItems']: node_tree = context.space_data.edit_tree # pyright: ignore[reportAttributeAccessIssue, reportOptionalMemberAccess] node = node_tree.nodes[self.node_name] - _ = node.evaluate() + + try: + res = node.evaluate(operator=self) + if res == 'ok': + self.report({"INFO"}, f"Execution Successful") + except Exception as e: + self.report({"ERROR"}, f"Execution failed: {str(e)}") + import traceback + traceback.print_exc() - self.report({"INFO"}, f"Execution Successful") return {"FINISHED"} @register_class @@ -45,6 +52,8 @@ class SLICENODE_MT_Menu(Menu): layout.operator("node.add_node", text="Math").type = "SLICENODE_FloatMath" layout.operator("node.add_node", text="Integer Math").type = "SLICENODE_IntMath" layout.operator("node.add_node", text="Object").type = "SLICENODE_InputObject" + layout.operator("node.add_node", text="Printer").type = "SLICENODE_InputPrinter" + layout.operator("node.add_node", text="Printer List").type = "SLICENODE_PrinterList" layout.operator("node.add_node", text="Printer Intrinsics").type = "SLICENODE_InputIntrinsics" layout.operator("node.add_node", text="Test").type = "SLICENODE_Test" layout.operator("node.add_node", text="Get Mesh").type = "SLICENODE_GetMesh" @@ -66,6 +75,7 @@ class SLICENODE_MT_Menu(Menu): layout.operator("node.add_node", text="Boolean Paths").type = "SLICENODE_JoinSlices" layout.separator() layout.operator("node.add_node", text="Gcode to File").type = "SLICENODE_GcodeToFile" + layout.operator("node.add_node", text="Start Print").type = "SLICENODE_StartPrint" layout.operator("node.add_node", text="Gcode to Mesh").type = "SLICENODE_GcodeToMesh" def draw_add_menu(self, context): diff --git a/infra/ftp.py b/infra/ftp.py new file mode 100644 index 0000000..57d0484 --- /dev/null +++ b/infra/ftp.py @@ -0,0 +1,97 @@ +import os +from pathlib import Path + +def ftp_upload( + host: str, + filepath: Path, + storage_path: str, + filename: str, + overwrite: bool = False, + timeout: float = 30, + user: str = "", + passwd: str = "", +): + from ftplib import FTP, error_perm + + if not os.path.isfile(filepath): + raise FileNotFoundError(f"Local file not found: {filepath}") + + remote_name = filename or os.path.basename(filepath) + + ftp = FTP(host, timeout=timeout) + ftp.login(user=user, passwd=passwd) + + try: + # Change to target directory + ftp.cwd(storage_path) + + # Check if file exists remotely + existing = ftp.nlst() + if remote_name in existing: + if not overwrite: + raise FileExistsError(f"Remote file already exists: {remote_name}") + # delete existing file + try: + ftp.delete(str(remote_name)) + except error_perm as e: + raise RuntimeError(f"Failed to delete remote file: {e}") + + # Upload new file + with open(filepath, 'rb') as f: + ftp.storbinary(f'STOR {remote_name}', f) + + finally: + ftp.quit() + +def ftp_wipe( + host: str, + storage_path: str, + timeout: int = 30, + user: str = "", + passwd: str = "", +): + from ftplib import FTP, error_perm + + ftp = FTP(host, timeout=timeout) + ftp.login(user=user, passwd=passwd) + + try: + ftp.cwd(storage_path) + existing = ftp.nlst() + for f in existing: + try: + ftp.delete(filename=f) + except error_perm as e: + raise RuntimeError(f"Failed to delete remote file: {f}") + except error_perm as e: + raise RuntimeError("e") + finally: + ftp.quit() + +def ftp_get_filesize( + host: str, + storage_path: str, + filename: str, + timeout: float = 30, + user: str = "", + passwd: str = "", +) -> int: + from ftplib import FTP, error_perm + + ftp = FTP(host, timeout=timeout) + ftp.login(user=user, passwd=passwd) + + try: + ftp.cwd(storage_path) + try: + size = ftp.size(filename) + except error_perm as e: + raise FileNotFoundError(f"Remote file not found or SIZE not supported: {filename}") from e + + if size is None: + raise RuntimeError("FTP server returned no size information") + + return size + + finally: + ftp.quit() \ No newline at end of file diff --git a/infra/printer_backends.py b/infra/printer_backends.py index 6c2e1e9..5427a2f 100644 --- a/infra/printer_backends.py +++ b/infra/printer_backends.py @@ -29,6 +29,16 @@ class PrinterStatus: bed_temperature: float = 0.0 nozzle_diameter: float = 0.0 +@dataclass +class PrinterConfig: + host: str = "localhost" + port: int = 80 + prefix: str = "" + label: str = "Printer" + username: str = "" + password: str = "" + host_type: str = "PRUSALINK" + class PrinterBackend(Protocol): def query_status(self) -> PrinterStatus: ... def pause(self) -> None: ...