network print

This commit is contained in:
Nicolas Predella 2026-05-14 14:47:10 +03:00
parent fbdc1a9eb8
commit c37f93d91f
5 changed files with 339 additions and 7 deletions

View file

@ -5,7 +5,7 @@ from typing import Any, TypeAlias
import bpy import bpy
import numpy as np import numpy as np
from mathutils import Euler 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 ...registry import register_class
from .. import slice from .. import slice
@ -61,8 +61,54 @@ class SLICENODE_InputObject(InputNode):
input_type = "Object" input_type = "Object"
bl_icon = "OBJECT_DATA" 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 @register_class
class SLICENODE_InputIntrinsics(SliceNode): class SLICENODE_InputIntrinsics(SliceNode):
bl_label = "Printer Intrinsics" bl_label = "Printer Intrinsics"
def init(self, context): def init(self, context):
@ -657,6 +703,66 @@ class ExecSliceNode(SliceNode):
layout.operator("slicenode.execute", text=self.button_label).node_name = self.name layout.operator("slicenode.execute", text=self.button_label).node_name = self.name
@register_class @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): class SLICENODE_GcodeToFile(ExecSliceNode):
bl_label = "Gcode To File" bl_label = "Gcode To File"
@ -666,7 +772,7 @@ class SLICENODE_GcodeToFile(ExecSliceNode):
self.add_input("SLICESOCK_Gcode") self.add_input("SLICESOCK_Gcode")
self.add_input("SLICESOCK_FilePath", "Filename") self.add_input("SLICESOCK_FilePath", "Filename")
def evaluate(self): def evaluate(self, operator=None):
gcode: str = resolve_input(self.inputs.get("Gcode")) gcode: str = resolve_input(self.inputs.get("Gcode"))
filename: str = resolve_input(self.inputs.get("Filename")) filename: str = resolve_input(self.inputs.get("Filename"))
@ -678,6 +784,66 @@ class SLICENODE_GcodeToFile(ExecSliceNode):
return 'ok' 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 @register_class
class SLICENODE_GcodeToMesh(ExecSliceNode): class SLICENODE_GcodeToMesh(ExecSliceNode):
bl_label = "Gcode to Mesh" bl_label = "Gcode to Mesh"
@ -687,7 +853,7 @@ class SLICENODE_GcodeToMesh(ExecSliceNode):
def init(self, context): def init(self, context):
self.add_input("SLICESOCK_Gcode") self.add_input("SLICESOCK_Gcode")
def evaluate(self): def evaluate(self, operator=None):
gcode: str = resolve_input(self.inputs.get("Gcode")) gcode: str = resolve_input(self.inputs.get("Gcode"))
if not gcode: if not gcode:
return 'empty' return 'empty'
@ -795,6 +961,6 @@ class SLICENODE_Test(ExecSliceNode):
def init(self, context): def init(self, context):
self.add_input("SLICESOCK_Gcode") self.add_input("SLICESOCK_Gcode")
def evaluate(self): def evaluate(self, operator=None):
_ = resolve_input(self.inputs.get("Gcode")) _ = resolve_input(self.inputs.get("Gcode"))
return 'ok' return 'ok'

View file

@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import Any
import bpy 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 ...registry import register_class
from ...infra.blender_slice_nodes_generic import SliceNodeSocket from ...infra.blender_slice_nodes_generic import SliceNodeSocket
@ -114,3 +114,52 @@ class SLICESOCK_Intrinsics(LabelNodeSocket):
bl_label = "Intrinsics" bl_label = "Intrinsics"
default = None default = None
color = (0.2, 0.2, 0.2, 1.0) 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
)

View file

@ -25,9 +25,16 @@ class SLICENODE_OT_Execute(Operator):
def execute(self, context) -> set['OperatorReturnItems']: def execute(self, context) -> set['OperatorReturnItems']:
node_tree = context.space_data.edit_tree # pyright: ignore[reportAttributeAccessIssue, reportOptionalMemberAccess] node_tree = context.space_data.edit_tree # pyright: ignore[reportAttributeAccessIssue, reportOptionalMemberAccess]
node = node_tree.nodes[self.node_name] node = node_tree.nodes[self.node_name]
_ = node.evaluate()
self.report({"INFO"}, f"Execution Successful") 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()
return {"FINISHED"} return {"FINISHED"}
@register_class @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="Math").type = "SLICENODE_FloatMath"
layout.operator("node.add_node", text="Integer Math").type = "SLICENODE_IntMath" 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="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="Printer Intrinsics").type = "SLICENODE_InputIntrinsics"
layout.operator("node.add_node", text="Test").type = "SLICENODE_Test" layout.operator("node.add_node", text="Test").type = "SLICENODE_Test"
layout.operator("node.add_node", text="Get Mesh").type = "SLICENODE_GetMesh" 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.operator("node.add_node", text="Boolean Paths").type = "SLICENODE_JoinSlices"
layout.separator() layout.separator()
layout.operator("node.add_node", text="Gcode to File").type = "SLICENODE_GcodeToFile" 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" layout.operator("node.add_node", text="Gcode to Mesh").type = "SLICENODE_GcodeToMesh"
def draw_add_menu(self, context): def draw_add_menu(self, context):

97
infra/ftp.py Normal file
View file

@ -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()

View file

@ -29,6 +29,16 @@ class PrinterStatus:
bed_temperature: float = 0.0 bed_temperature: float = 0.0
nozzle_diameter: 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): class PrinterBackend(Protocol):
def query_status(self) -> PrinterStatus: ... def query_status(self) -> PrinterStatus: ...
def pause(self) -> None: ... def pause(self) -> None: ...