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:
commit
4e957509cc
11 files changed
+695
No files matched your search
Executable
+119
@@ -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()
|
||||
Reference in new issue
Block a user