#!/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 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 --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 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()