This commit is contained in:
Nicolas Predella 2026-05-14 14:09:09 +03:00
parent b17ff98e74
commit fbdc1a9eb8
5 changed files with 371 additions and 34 deletions

View file

@ -1,7 +1,7 @@
from __future__ import annotations
import os
from math import floor
from typing import Any
from typing import Any, TypeAlias
import bpy
import numpy as np
from mathutils import Euler
@ -65,34 +65,25 @@ class SLICENODE_InputObject(InputNode):
class SLICENODE_InputIntrinsics(SliceNode):
bl_label = "Printer Intrinsics"
bed_x: FloatProperty(name="Bed X", default=250.0) # pyright: ignore[reportInvalidTypeForm]
bed_y: FloatProperty(name="Bed Y", default=250.0) # pyright: ignore[reportInvalidTypeForm]
feedrate: FloatProperty(name="Feedrate", default=1200.0) # pyright: ignore[reportInvalidTypeForm]
extrusion_rate: FloatProperty(name="Extrusion Rate", default=0.017, precision=4) # pyright: ignore[reportInvalidTypeForm]
def init(self, context):
self.add_output("SLICESOCK_Intrinsics")
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "bed_x")
layout.prop(self, "bed_y")
layout.prop(self, "feedrate")
layout.prop(self, "extrusion_rate")
self.add_input("SLICESOCK_Float", "Bed X").value = 250.0
self.add_input("SLICESOCK_Float", "Bed Y").value = 210.0
self.add_input("SLICESOCK_Float", "Feedrate").value = 1200.0
self.add_input("SLICESOCK_Float", "Extrusion Rate").value = 0.035
def evaluate_output(self, socket_name: str, **_kwargs) -> SliceIntrinsics:
return SliceIntrinsics(
bed_x=self.bed_x,
bed_y=self.bed_y,
feedrate=self.feedrate,
extrusion_rate=self.extrusion_rate
bed_x=self.resolve("Bed X"),
bed_y=self.resolve("Bed Y"),
feedrate=self.resolve("Feedrate"),
extrusion_rate=self.resolve("Extrusion Rate")
)
# Math
@register_class
class SLICENODE_IntMath(SliceNode):
bl_label = "Integer Math"
class MathNode(SliceNode):
bl_label = "Float Math"
operation: bpy.props.EnumProperty(
name="Operation",
@ -100,7 +91,7 @@ class SLICENODE_IntMath(SliceNode):
('ADD', "Add", "Add the two values"),
('SUBTRACT', "Subtract", "Subtract the second value from the first"),
('MULTIPLY', "Multiply", "Multiply the two values"),
('DIVIDE', "Divide", "Integer division"),
('DIVIDE', "Divide", "Division"),
('ABS', "Absolute", "Absolute value of the first value"),
('NEGATE', "Negate", "Negate the first value"),
('POWER', "Power", "First value raised to the power of the second"),
@ -116,19 +107,24 @@ class SLICENODE_IntMath(SliceNode):
default='ADD'
) # pyright: ignore[reportInvalidTypeForm]
def init(self, context):
self.add_output("SLICESOCK_IntRo", "Value")
math_type: TypeAlias = float
socket_label: str = "Float"
self.add_input("SLICESOCK_Int", "Value")
self.add_input("SLICESOCK_Int", "Value")
def init(self, context):
self.add_output(f"SLICESOCK_{self.socket_label}Ro", "Value")
self.add_input(f"SLICESOCK_{self.socket_label}", "Value")
self.add_input(f"SLICESOCK_{self.socket_label}", "Value")
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "operation", text="")
def evaluate_output(self, socket_name: str, **_kwargs) -> int:
a: int = resolve_input(self.inputs[0])
b: int = resolve_input(self.inputs[1])
def evaluate_output(self, socket_name: str, **_kwargs) -> math_type:
t = self.math_type
a: t = resolve_input(self.inputs[0])
b: t = resolve_input(self.inputs[1])
op = self.operation
try:
@ -138,19 +134,31 @@ class SLICENODE_IntMath(SliceNode):
if op == 'DIVIDE': return a // b if b != 0 else 0
if op == 'ABS': return abs(a)
if op == 'NEGATE': return -a
if op == 'POWER': return int(a ** b) if not (a == 0 and b < 0) else 0
if op == 'POWER': return t(a ** b) if not (a == 0 and b < 0) else 0
if op == 'MIN': return min(a, b)
if op == 'MAX': return max(a, b)
if op == 'SIGN': return (a > 0) - (a < 0)
if op == 'DIVIDE_ROUND': return int(round(a / b)) if b != 0 else 0
if op == 'DIVIDE_ROUND': return t(round(a / b)) if b != 0 else 0
if op == 'DIVIDE_FLOOR': return a // b if b != 0 else 0
if op == 'DIVIDE_TRUNCATE': return int(a / b) if b != 0 else 0
if op == 'DIVIDE_TRUNCATE': return t(a / b) if b != 0 else 0
if op == 'MODULO_FLOORED': return a % b if b != 0 else 0
if op == 'MODULO_TRUNCATED': return a - (b * int(a / b)) if b != 0 else 0
if op == 'MODULO_TRUNCATED': return a - (b * t(a / b)) if b != 0 else 0
except Exception:
return 0
return 0
@register_class
class SLICENODE_FloatMath(MathNode):
bl_label = "Math"
math_type = float
socket_label: str = "Float"
@register_class
class SLICENODE_IntMath(MathNode):
bl_label = "Integer Math"
math_type = int
socket_label: str = "Int"
@register_class
class SLICENODE_GetMesh(SliceNode):
bl_label = "Get Mesh"

View file

@ -42,6 +42,7 @@ class SLICENODE_MT_Menu(Menu):
layout.operator("node.add_node", text="Float").type = "SLICENODE_InputFloat"
layout.operator("node.add_node", text="Integer").type = "SLICENODE_InputInt"
layout.operator("node.add_node", text="Rotation").type = "SLICENODE_InputVectorRotation"
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 Intrinsics").type = "SLICENODE_InputIntrinsics"

View file

@ -1,2 +0,0 @@
G0 Z0.1 F720 ; Move to the desired physical Z clearance height
G92 Z0 ; Redefine this position as Z=0

View file

@ -23,6 +23,9 @@ class SliceNodeSocket(NodeSocket, AutoBlIdName):
class SliceNode(Node, AutoBlIdName):
def evaluate_output(self, socket_name: str, **_kwargs) -> Any: ...
def resolve(self, fieldname) -> Any:
return resolve_input(self.inputs.get(fieldname))
def add_output(self, bl_idname: str, name: str | None = None):
from ..registry import get_label_by_name
return self.outputs.new(bl_idname, name or get_label_by_name(bl_idname))

327
infra/printer_backends.py Normal file
View file

@ -0,0 +1,327 @@
from __future__ import annotations
from functools import wraps
from typing import Any, Callable
def with_api_state(api_state: str) -> Callable[..., Any]:
def decorator(func: Callable[..., Any]) -> Any:
@wraps(func)
def wrapped(self: Any, *args: Any, **kwargs: Any) -> Any:
self.api_state = api_state
try: return func(self, *args, **kwargs)
finally: self.api_state = None
return wrapped
return decorator
from dataclasses import dataclass, field
from pathlib import Path
from typing import Protocol, Any, Optional
import requests
import time
import json
@dataclass(frozen=True)
class PrinterStatus:
progress: float = 0.0
state: str = "OFFLINE"
job_name: str = ""
job_id: str = ""
nozzle_temperature: float = 0.0
bed_temperature: float = 0.0
nozzle_diameter: float = 0.0
class PrinterBackend(Protocol):
def query_status(self) -> PrinterStatus: ...
def pause(self) -> None: ...
def resume(self) -> None: ...
def stop(self) -> None: ...
def start_print(self, gcode: Path, name: str) -> None: ...
class HttpBackend:
def __init__(self, host: str, port: int, prefix: str, api_key: Optional[str] = None, timeout: float = 30.0):
self.host = host
self.port = port
self.base = f"http://{host}:{port}{prefix}"
self.timeout = timeout
self.session = requests.Session()
self.headers = {}
self.api_state: str | None = None
if api_key:
self.headers["X-Api-Key"] = api_key
def _get(self, path: str, **kw) -> requests.Response:
headers = {**self.headers, **kw.pop('headers', {})}
return self.session.get(self.base + path, headers=headers, timeout=self.timeout, **kw)
def _post(self, path: str, **kw) -> requests.Response:
headers = {**self.headers, **kw.pop('headers', {})}
return self.session.post(self.base + path, headers=headers, timeout=self.timeout, **kw)
def _put(self, path: str, **kw) -> requests.Response:
headers = {**self.headers, **kw.pop('headers', {})}
return self.session.put(self.base + path, headers=headers, timeout=self.timeout, **kw)
def _delete(self, path: str, **kw) -> requests.Response:
headers = {**self.headers, **kw.pop('headers', {})}
return self.session.delete(self.base + path, headers=headers, timeout=self.timeout, **kw)
def _json(self, resp, default: dict = {}, ok=(200, 201, 202, 204)) -> dict:
if resp.status_code not in ok:
resp.raise_for_status()
if resp.status_code == 204 or not resp.content:
return default
return resp.json()
class PrinterHttpBackend(HttpBackend, PrinterBackend):
pass
class PrusaLinkBackend(PrinterHttpBackend):
def query_status(self) -> PrinterStatus:
s = self._get("/api/v1/status").json()
info = self._get("/api/v1/info").json()
job = self._json(self._get("/api/v1/job"))
return PrinterStatus(
progress=float(job.get("progress", 0.0)),
state=str(s.get("printer", {}).get("state", "OFFLINE")),
job_name=str(job.get("file", {}).get("display_name", "")),
job_id=str(s.get("job", {}).get("id", "")),
nozzle_temperature=float(s.get("printer", {}).get("temp_nozzle", 0) or 0),
bed_temperature=float(s.get("printer", {}).get("temp_bed", 0) or 0),
nozzle_diameter=float(info.get('nozzle_diameter', 0.0)),
)
@with_api_state('PAUSING')
def pause(self) -> None:
jid = self.query_status().job_id
if not jid: raise RuntimeError("No job to pause")
self._put(f"/api/v1/job/{jid}/pause").raise_for_status()
@with_api_state('RESUMING')
def resume(self) -> None:
jid = self.query_status().job_id
if not jid: raise RuntimeError("No job to resume")
self._put(f"/api/v1/job/{jid}/resume").raise_for_status()
@with_api_state('STOPPING')
def stop(self) -> None:
jid = self.query_status().job_id
if not jid: raise RuntimeError("No job to stop")
self._delete(f"/api/v1/job/{jid}").raise_for_status()
@with_api_state("STARTING")
def start_print(self, gcode: Path, name: str) -> None:
storage = self._get("/api/v1/storage").json()
writables = [s for s in storage.get("storage_list", []) if s.get("available") and not s.get("read_only")]
if not writables:
raise RuntimeError("No writable storage")
path = writables[0]["path"].lstrip("/")
file_size = gcode.stat().st_size
with open(gcode, "rb") as f:
self._put(
f"/api/v1/files/{path}/{name}",
headers={
"Overwrite": "?1",
"Print-After-Upload": "?1",
"Content-Type": "text/x.gcode" if Path(name).suffix == ".gcode" else "application/octet-stream",
"Content-Length": str(file_size),
},
data=f,
).raise_for_status()
try: # This block will ensure a start on mk3s printers, which don't seem to respond to Print-After-Upload
# wait until /transfer is gone (204)
for _ in range(int(self.timeout)):
r = self._get("/api/v1/transfer")
if r.status_code == 204: break
time.sleep(1)
else: raise TimeoutError("Transfer timed out")
# verify remote metadata size matches
for _ in range(int(self.timeout)):
r = self._get(f"/api/v1/files/{path}/{name}", headers={"Accept": "application/json"})
if r.status_code == 200 and (r.json().get("size") == file_size): break
time.sleep(1)
else: raise TimeoutError("Remote file size did not match expected size")
self._post(f"/api/v1/files/{path}/{name}").raise_for_status()
finally: return
class CrealityBackend(PrinterHttpBackend):
STATUS_EP = "/protocal.csp?fname=Info&opt=main&function=get"
STORAGE_PATH = "/mmcblk0p1/creality/gztemp/"
STORAGE_PATH_PRINT = "/media/mmcblk0p1/creality/gztemp/"
def query_status(self) -> PrinterStatus:
r = self._get(self.STATUS_EP)
r.raise_for_status()
d = r.json()
# Map numeric state to string
state_id = str(d.get("state", "-1"))
state: str = (
"OFFLINE" if state_id == "-1" else
"PAUSED" if state_id == "5" else
"IDLE" if state_id in {"0", "3", "4"} else
"PRINTING" if state_id == "1" else
"FINISHED" if state_id == "2" else
"UNKNOWN"
)
# Defensive casting
def f(x, default=0.0):
try:
return float(x)
except Exception:
return default
def s(x, default=""):
return str(x) if x is not None else default
return PrinterStatus(
progress=round(f(d.get("printProgress"), 0.0), 1),
state=state,
job_name=s(d.get("print")),
job_id="", # Not exposed by this API
nozzle_temperature=f(d.get("nozzleTemp"), 0.0),
bed_temperature=f(d.get("bedTemp"), 0.0),
)
@with_api_state('PAUSING')
def pause(self) -> None:
r = self._get("/protocal.csp?fname=net&opt=iot_conf&function=set&pause=1")
r.raise_for_status()
@with_api_state('RESUMING')
def resume(self) -> None:
r = self._get("/protocal.csp?fname=net&opt=iot_conf&function=set&pause=0")
r.raise_for_status()
@with_api_state('STOPPING')
def stop(self) -> None:
r = self._get("/protocal.csp?fname=net&opt=iot_conf&function=set&stop=1")
r.raise_for_status()
@with_api_state('STARTING')
def start_print(self, gcode: Path, name: str) -> None:
from ..infra.ftp import ftp_upload, ftp_wipe, ftp_get_filesize
file_size = gcode.stat().st_size
# Clean target dir and upload
ftp_wipe(self.host, self.STORAGE_PATH)
ftp_upload(self.host, gcode, self.STORAGE_PATH, filename=name, overwrite=True, timeout=self.timeout)
for _ in range(10):
print(_)
uploaded_size = ftp_get_filesize(self.host, storage_path=self.STORAGE_PATH, filename=name, timeout=self.timeout)
if uploaded_size == file_size:
break
time.sleep(2)
else:
raise TimeoutError("Uploaded file did not reach expected size on printer")
# Trigger print
ep = (
"/protocal.csp?fname=net&opt=iot_conf&function=set"
f"&print={self.STORAGE_PATH_PRINT}{name}"
)
r = self._get(ep)
r.raise_for_status()
class MoonrakerBackend(PrinterHttpBackend):
def query_status(self) -> PrinterStatus:
url = f"{self.base}/printer/objects/query?webhooks&virtual_sdcard&print_stats"
r = self.session.get(url, headers=self.headers, timeout=self.timeout)
r.raise_for_status()
data = r.json().get("result", {}).get("status", {})
sd = data.get("virtual_sdcard", {}) or {}
ps = data.get("print_stats", {}) or {}
state = str(ps.get("state", "OFFLINE")).upper()
if state == "STANDBY":
state = "IDLE"
progress = 0.0
try:
progress = float(sd.get("progress", 0.0)) * 100.0
except Exception:
pass
file_path = sd.get("file_path", "") or ""
return PrinterStatus(
progress=round(progress, 1),
state=state,
job_name=str(file_path),
job_id="",
nozzle_temperature=0.0,
bed_temperature=0.0,
)
@with_api_state('PAUSING')
def pause(self) -> None:
url = f"{self.base}/printer/print/pause"
r = self.session.post(url, headers=self.headers, timeout=self.timeout)
r.raise_for_status()
@with_api_state('RESUMING')
def resume(self) -> None:
url = f"{self.base}/printer/print/resume"
r = self.session.post(url, headers=self.headers, timeout=self.timeout)
r.raise_for_status()
@with_api_state('STOPPING')
def stop(self) -> None:
url = f"{self.base}/printer/print/cancel"
r = self.session.post(url, headers=self.headers, timeout=self.timeout)
r.raise_for_status()
@with_api_state('STARTING')
def start_print(self, gcode: Path, name: str) -> None:
import hashlib
expected_size = gcode.stat().st_size
h = hashlib.sha256()
with open(gcode, "rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
checksum = h.hexdigest()
upload_url = f"{self.base}/server/files/upload"
with open(gcode, "rb") as f:
files = {"file": (name, f, "application/octet-stream")}
form = {
"root": "gcodes",
"print": "true",
"checksum": checksum,
}
r = self.session.post(
upload_url,
headers=self.headers,
files=files,
data=form,
timeout=self.timeout,
)
r.raise_for_status()
result = r.json()
if not (result.get("print_started") or result.get("print_queued")):
raise RuntimeError(f"Upload succeeded but print did not start: {result}")
@dataclass
class ManagedPrinter:
name: str
backend: PrinterHttpBackend
status: PrinterStatus = field(default_factory=PrinterStatus)
last_error: str | None = None
last_command_time: str | None = None
last_command_response: str = ""
def query_status(self):
self.status = self.backend.query_status()