math
This commit is contained in:
parent
b17ff98e74
commit
fbdc1a9eb8
5 changed files with 371 additions and 34 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
G0 Z0.1 F720 ; Move to the desired physical Z clearance height
|
||||
G92 Z0 ; Redefine this position as Z=0
|
||||
|
|
@ -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
327
infra/printer_backends.py
Normal 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()
|
||||
Loading…
Add table
Reference in a new issue