Ruida laser skill: prepare and load jobs, never run them

FreeCAD/DXF to .rd conversion, named upload to controller memory with
idle check and read-back verification, read-only status/file listing,
and a fake controller for dry runs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
wrickertandClaude Opus 5.5 committed 2026-10-04 10:24:05 -05:00
commit 4e957509cc
11 files changed
+695

No files matched your search

+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""dxf2rd.py — DXF (mm, LINE entities) -> Ruida .rd job FILE. Offline only.
Writes bytes to disk. Contains NO networking: it never talks to the laser.
A person loads/runs jobs at the laser (see SKILL.md, "Safety").
usage: dxf2rd.py in.dxf out.rd --speed 25 --max 65 --min 55 --passes 2 [--air]
"""
import argparse, sys
from meerk40t.ruida.rdjob import RDJob, swizzles_lut
UM = 1000 # Ruida native units are micrometres
def read_lines(path):
t = open(path).read().split("\n")
out = []
for i, v in enumerate(t):
if v.strip() == "LINE":
g = dict(zip([s.strip() for s in t[i + 1:i + 17:2]], [s.strip() for s in t[i + 2:i + 18:2]]))
out.append(((float(g["10"]), float(g["20"])), (float(g["11"]), float(g["21"]))))
return out
def chain(segs, tol=1e-3):
key = lambda p: (round(p[0] / tol), round(p[1] / tol))
segs, loops = segs[:], []
while segs:
a, b = segs.pop(0)
path = [a, b]
while key(path[-1]) != key(path[0]):
for k, (p, q) in enumerate(segs):
if key(p) == key(path[-1]): path.append(q); segs.pop(k); break
if key(q) == key(path[-1]): path.append(p); segs.pop(k); break
else:
break # open path: cut as-is
loops.append(path)
return loops
class Box:
def __init__(self, pts):
xs, ys = [p[0] for p in pts], [p[1] for p in pts]
self._b = (min(xs), min(ys), max(xs), max(ys))
def left(self): return self._b[0]
def upper(self): return self._b[1]
def right(self): return self._b[2]
def lower(self): return self._b[3]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("dxf"); ap.add_argument("rd")
ap.add_argument("--speed", type=float, required=True, help="mm/s")
ap.add_argument("--max", type=float, required=True, help="max power %%")
ap.add_argument("--min", type=float, required=True, help="min power %%")
ap.add_argument("--passes", type=int, default=1)
ap.add_argument("--air", action="store_true")
ap.add_argument("--offset", type=float, nargs=2, default=(10, 10), help="mm from origin")
a = ap.parse_args()
assert 10 <= a.min <= a.max <= 80, "power outside 10-80% (shop rule)"
assert a.speed >= 5, "speed below 5 mm/s is a fire risk on wood"
loops = chain(read_lines(a.dxf))
ox, oy = a.offset
loops = [[(int(round((x + ox) * UM)), int(round((y + oy) * UM))) for x, y in lp] for lp in loops]
job = RDJob(magic=0x88)
allpts = [p for lp in loops for p in lp]
job.write_header([Box(allpts)])
job.write_settings({"speed": a.speed, "power": a.max * 10, "coolant": 1 if a.air else 2})
job.min_power_1(a.min); job.min_power_2(a.min) # Ruida min power: fires during accel/decel
cx = cy = None
for _ in range(a.passes):
for lp in loops:
x0, y0 = lp[0]
job.move_abs_xy(x0, y0); cx, cy = x0, y0
for x, y in lp[1:]:
job.mark(x, y, x - cx, y - cy); cx, cy = x, y
job.write_tail()
data = job.get_contents()
sw, _ = swizzles_lut(0x88)
open(a.rd, "wb").write(bytes(sw[b] for b in data))
print(f"{a.rd}: {len(loops)} paths x {a.passes} passes, {len(data)} bytes")
if __name__ == "__main__":
sys.exit(main())
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env python3
"""fake_ruida.py: a stand-in Ruida controller on 127.0.0.1:50200 for dry runs.
Logs every command it receives (unswizzled hex) to the .log file, ACKs, answers the status and
document-name reads the way the real controller does (no reply past the last file), and adds
uploaded file names to its list. Pass "busy" to report a running job so you can watch
rdupload.py refuse. Test here first; never point untested code at a real laser.
usage: fake_ruida.py [busy] fake.log
"""
import socket, sys
from meerk40t.ruida.rdjob import swizzles_lut, encode32, decodeu14
SW, UN = swizzles_lut(0x88)
BUSY = len(sys.argv) > 1 and sys.argv[1] == "busy"
files = ["PART01", "PART02"]
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.bind(("127.0.0.1", 50200))
log = open(sys.argv[-1] if sys.argv[-1].endswith(".log") else "fake.log", "a")
upload = bytearray()
def send(addr, raw): s.sendto(bytes(SW[b] for b in raw), addr)
while True:
pkt, addr = s.recvfrom(2048)
data = bytes(UN[b] for b in pkt[2:])
log.write("RX " + data.hex() + "\n"); log.flush()
if data[:2] != b"\xe8\x01" or decodeu14(data[2:4]) <= len(files): send(addr, b"\xcc")
if data[:2] == b"\xda\x00":
mem = data[2:4]
v = {b"\x04\x00": 1 if BUSY else 0, b"\x02\x00": 21 if BUSY else 22, b"\x02\x05": len(files)}.get(mem, 0)
send(addr, b"\xda\x01" + mem + encode32(v))
elif data[:2] == b"\xe8\x01":
n = decodeu14(data[2:4])
if n <= len(files): send(addr, data[:4] + files[n - 1].encode() + b"\x00")
else:
upload += data
if b"\xe7\x01" in upload and upload.endswith(b"\xd7"):
i = upload.index(b"\xe7\x01") + 2
files.append(upload[i:upload.index(b"\x00", i)].decode()); upload = bytearray()
+49
View File
@@ -0,0 +1,49 @@
# fc2dxf.py: export a flat FreeCAD body (sheet part) to a LINE-only DXF for dxf2rd.py.
# Takes the largest planar face normal to the body's thinnest axis, writes its outline and holes
# with inner loops first (so the part doesn't drop before its holes are cut), origin at the
# bottom-left corner, mm. Curves are flattened to lines within 0.02 mm. Opens the .FCStd
# read-only and never saves it.
#
# usage (FreeCAD's headless interpreter):
# freecadcmd -c "import sys; sys.argv=['x','MODEL.FCStd','BodyLabel','OUT.dxf']; exec(open('fc2dxf.py').read())"
# Prints the part thickness: check it matches your stock. A blind pocket (not through) can't be
# cut, only engraved, so the script refuses bodies with planar faces between the two sides.
import FreeCAD, sys
MODEL, NAME, OUT = sys.argv[-3], sys.argv[-2], sys.argv[-1]
d = FreeCAD.openDocument(MODEL)
objs = d.getObjectsByLabel(NAME)
assert objs, f"no object labelled {NAME!r}"
s = objs[0].Shape; b = s.BoundBox
dims = [b.XLength, b.YLength, b.ZLength]; t = dims.index(min(dims))
ax = [i for i in range(3) if i != t]
mn = [b.XMin, b.YMin, b.ZMin]
flat = [f for f in s.Faces if f.Surface.__class__.__name__ == 'Plane'
and abs(abs(f.normalAt(0, 0)[t]) - 1) < 1e-6]
levels = sorted({round(f.CenterOfMass[t], 3) for f in flat})
assert len(levels) == 2, f"faces at {levels}: blind pocket or step, not a through-cut sheet part"
f = max(flat, key=lambda f: f.Area)
loops = []
for w in f.Wires:
segs = []
for e in w.OrderedEdges:
pts = ([e.Vertexes[0].Point, e.Vertexes[-1].Point] if e.Curve.__class__.__name__ == 'Line'
else e.discretize(Deflection=0.02))
pts = [(p[ax[0]] - mn[ax[0]], p[ax[1]] - mn[ax[1]]) for p in pts]
segs += list(zip(pts, pts[1:]))
xs = [p[0] for sg in segs for p in sg]; ys = [p[1] for sg in segs for p in sg]
loops.append(((max(xs) - min(xs)) * (max(ys) - min(ys)), segs))
loops.sort(key=lambda l: l[0]) # inner cuts first, outline last
out = ["0", "SECTION", "2", "HEADER", "9", "$INSUNITS", "70", "4", "0", "ENDSEC",
"0", "SECTION", "2", "ENTITIES"]
n = 0
for _, segs in loops:
for (x1, y1), (x2, y2) in segs:
if abs(x1 - x2) + abs(y1 - y2) < 1e-6:
continue
out += ["0", "LINE", "8", "CUT", "10", "%.4f" % x1, "20", "%.4f" % y1, "30", "0",
"11", "%.4f" % x2, "21", "%.4f" % y2, "31", "0"]; n += 1
out += ["0", "ENDSEC", "0", "EOF"]
open(OUT, 'w').write("\n".join(out) + "\n")
print(f"{NAME} -> {OUT}: {n} lines, {len(loops)} loops, {dims[ax[0]]:.2f} x {dims[ax[1]]:.2f} mm, "
f"thickness {dims[t]:.2f} mm")
FreeCAD.closeDocument(d.Name)
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""rdlist.py: READ-ONLY queries to a Ruida controller: machine status and stored file names.
It can only send the byte strings in ALLOWED below; anything else raises before it reaches the
socket. Never send E8 00 (delete documents) or E8 03 (select document = START it): they sit right
next to E8 01 (document name) in the protocol. See SKILL.md, "Safety".
Protocol as MeerK40t's Ruida driver: swizzle magic 0x88, each datagram prefixed with a 16-bit sum,
40200 -> 50200. The controller ACKs (CC, C6 on the wire) and then sends a swizzled reply.
DA 00 04 00 status bits (0x01 job running, 0x02 part end, 0x01000000 moving)
DA 00 02 00 machine status (MeerK40t emulator: 22 idle, 23 paused, 21 running)
DA 00 02 05 total document count (read 0 with 49 files stored on our controller: unused)
E8 01 <n14> name of document n (1-based) -> reply E8 01 <n14> NAME 00
Confirmed on a real controller: name replies, end of list = no reply.
Status bit 0 (job running) confirmed on a real controller: set mid-cut (0x20018401), clear when idle (0x2001x600).
usage: rdlist.py --host <controller-ip> status + file list
rdlist.py --host 127.0.0.1 (fake controller)
"""
import argparse, socket, sys
from meerk40t.ruida.rdjob import swizzles_lut, encode14, decodeu35
MAGIC = 0x88
SRC_PORT, DST_PORT = 40200, 50200
TIMEOUT = 3.0
ACK, ENQ = 0xCC, 0xCE
SW, UN = swizzles_lut(MAGIC)
MEM_STATUS_BITS = b"\xda\x00\x04\x00"
MEM_MACHINE_STATUS = b"\xda\x00\x02\x00"
MEM_DOC_COUNT = b"\xda\x00\x02\x05"
MAX_DOCS = 200
ALLOWED = {MEM_STATUS_BITS, MEM_MACHINE_STATUS, MEM_DOC_COUNT} | {
b"\xe8\x01" + encode14(n) for n in range(1, MAX_DOCS + 1)}
RUNNING, MOVING = 0x00000001, 0x01000000
class NoReply(Exception):
pass
class Ruida:
def __init__(self, host):
self.host = host
self.s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self.s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.s.bind(("", SRC_PORT))
self.s.settimeout(TIMEOUT)
def close(self):
self.s.close()
def query(self, cmd): # raises NoReply on timeout
"""Send one whitelisted read command; return the unswizzled reply payload."""
if cmd not in ALLOWED:
raise SystemExit(f"refusing to send non-whitelisted bytes {cmd.hex()}")
data = bytes(SW[b] for b in cmd)
self.s.sendto((sum(data) & 0xFFFF).to_bytes(2, "big") + data, (self.host, DST_PORT))
acked = False
while True:
try:
pkt, _ = self.s.recvfrom(1024)
except socket.timeout:
raise NoReply(f"no {'reply' if acked else 'ACK'} to {cmd.hex()} (timeout)")
r = bytes(UN[b] for b in pkt)
if len(r) == 1:
if r[0] == ACK:
acked = True
elif r[0] != ENQ:
raise SystemExit(f"{cmd.hex()}: unexpected reply {r.hex()}")
continue
if r[:len(cmd)] != cmd[:len(cmd)] and not (cmd[0] == 0xDA and r[:2] == b"\xda\x01"
and r[2:4] == cmd[2:4]):
raise SystemExit(f"{cmd.hex()}: reply {r.hex()} does not match the query")
return r
def mem(self, cmd):
r = self.query(cmd)
if len(r) < 9:
raise SystemExit(f"{cmd.hex()}: short reply {r.hex()}")
return decodeu35(r[4:9])
def status(self):
return self.mem(MEM_STATUS_BITS), self.mem(MEM_MACHINE_STATUS)
def files(self):
# This controller's doc-count register (DA 00 02 05) reads 0 with 49 files stored, so walk
# E8 01 n upward; the list ends at the first number that gets no reply (seen on a real controller).
names = []
for i in range(1, MAX_DOCS + 1):
try:
r = self.query(b"\xe8\x01" + encode14(i))
except NoReply:
if i == 1:
raise SystemExit("no reply to the first document-name query")
return names
names.append(r[4:].split(b"\x00")[0].decode("ascii", "replace"))
raise SystemExit(f"more than MAX_DOCS={MAX_DOCS} documents")
def idle(bits):
return not (bits & (RUNNING | MOVING))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--host", required=True, help="controller IP (no default, on purpose)")
a = ap.parse_args()
r = Ruida(a.host)
try:
bits, ms = r.status()
print(f"status bits 0x{bits:08x} ({'IDLE' if idle(bits) else 'BUSY'}), machine status {ms}")
names = r.files()
print(f"{len(names)} file(s):")
for i, nm in enumerate(names, 1):
print(f" {i:3d} {nm}")
finally:
r.close()
if __name__ == "__main__":
sys.exit(main())
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""rdupload.py: LOAD a .rd job (refuses unless the controller reports idle; verifies the name is listed after) into a Ruida controller's file memory, under a name.
It never runs a job. The transfer is prefixed with "E8 02 E7 01 <name> 00" (file transfer +
set filename), the same header LibLaserCut/VisiCut use, so the controller stores the job and
a person selects and starts it at the panel. See SKILL.md, "Safety".
Protocol follows LibLaserCut's Ruida driver (UdpStream): swizzle magic 0x88, payload split into
<=998-byte datagrams, each prefixed with a 16-bit big-endian sum of its bytes, sent from UDP
40200 to 50200; every datagram must be ACKed (0xC6) within 3 s. NAK (0x46), a timeout, or any
other reply aborts the upload. No file-sum command (LibLaserCut sends none).
usage: rdupload.py JOB.rd --name PART01 --host <controller-ip> --confirm
rdupload.py JOB.rd --name PART01 --host 127.0.0.1 (fake controller)
"""
import argparse, re, socket, sys
from meerk40t.ruida.rdjob import swizzles_lut
from rdlist import Ruida, idle
MAGIC = 0x88
MTU = 998
SRC_PORT, DST_PORT = 40200, 50200
TIMEOUT = 3.0
ACK, NAK = 0xC6, 0x46
SW, UN = swizzles_lut(MAGIC)
def commands(raw):
out, cur = [], bytearray()
for b in raw:
if b >= 0x80 and cur:
out.append(bytes(cur)); cur = bytearray()
cur.append(b)
if cur:
out.append(bytes(cur))
return out
def build(rd_bytes, name):
cmds = commands(bytes(UN[b] for b in rd_bytes))
if not cmds or cmds[-1] != b"\xd7":
raise SystemExit("refusing: job does not end with D7 (End Of File)")
if any(c[:2] == b"\xe8\x02" for c in cmds):
raise SystemExit("refusing: job already contains a file-transfer header")
cmds = [c for c in cmds if c[:2] != b"\xe5\x05"] # drop file sum, as LibLaserCut does
# Header goes right after the leading D8 1x / E6 01 / F0 preamble (LibLaserCut: D812 F0 E802 ...).
i = 0
while i < len(cmds) and (cmds[i][:1] == b"\xd8" and cmds[i][1:2] in (b"\x10", b"\x12")
or cmds[i] in (b"\xe6\x01", b"\xf0")):
i += 1
if i == 0 or any(c[0] in (0x88, 0x89, 0x8A, 0x8B, 0xA8, 0xA9, 0xAA, 0xAB) for c in cmds[:i]):
raise SystemExit("refusing: unexpected job preamble")
header = [b"\xe8\x02", b"\xe7\x01" + name.encode("ascii") + b"\x00"]
# E7 01 <name> 00: the name bytes are < 0x80, so the command parser keeps them together.
return b"".join(cmds[:i] + header + cmds[i:])
def upload(payload, host):
data = bytes(SW[b] for b in payload)
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("", SRC_PORT))
s.settimeout(TIMEOUT)
sent = 0
try:
for start in range(0, len(data), MTU):
chunk = data[start:start + MTU]
pkt = (sum(chunk) & 0xFFFF).to_bytes(2, "big") + chunk
s.sendto(pkt, (host, DST_PORT))
try:
reply, _ = s.recvfrom(64)
except socket.timeout:
raise SystemExit(f"ABORT: no ACK after packet {sent + 1} (timeout)")
if len(reply) != 1 or reply[0] != ACK:
raise SystemExit(f"ABORT: packet {sent + 1} reply {reply.hex()} (expected c6 ACK)")
sent += 1
finally:
s.close()
return sent
def main():
ap = argparse.ArgumentParser()
ap.add_argument("rd")
ap.add_argument("--name", required=True, help="1-8 letters/digits, shown on the panel")
ap.add_argument("--host", required=True, help="controller IP (no default, on purpose)")
ap.add_argument("--confirm", action="store_true",
help="required for any host other than 127.0.0.1")
a = ap.parse_args()
if not re.fullmatch(r"[A-Za-z0-9]{1,8}", a.name):
raise SystemExit("name must be 1-8 letters/digits")
if a.host != "127.0.0.1" and not a.confirm:
raise SystemExit("refusing: real controller needs --confirm (the operator's go-ahead)")
payload = build(open(a.rd, "rb").read(), a.name.upper())
# A busy controller ACKs every packet and then drops the file (seen on a real 644x-class controller), so check first.
r = Ruida(a.host)
try:
bits, ms = r.status()
before = r.files()
finally:
r.close()
if a.name.upper() in before:
raise SystemExit(f"refusing: {a.name.upper()} is already stored; delete it at the panel or pick another name")
if not idle(bits):
raise SystemExit(f"refusing: controller not idle (status bits 0x{bits:08x}, machine status {ms})")
n = upload(payload, a.host)
print(f"sent {a.name.upper()} to {a.host}: {len(payload)} bytes, {n} packets, all ACKed")
r = Ruida(a.host)
try:
after = r.files()
finally:
r.close()
if a.name.upper() not in after:
raise SystemExit(f"NOT STORED: {a.name.upper()} missing from file list {after}")
print(f"verified: {a.name.upper()} is in the file list ({len(before)} -> {len(after)} files)")
if __name__ == "__main__":
main()