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

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