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]>
120 lines
5.0 KiB
Python
Executable File
120 lines
5.0 KiB
Python
Executable File
#!/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()
|