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

+107
View File
@@ -0,0 +1,107 @@
---
name: ruida-laser
description: Prepare laser-cutter jobs for a Ruida controller (RDC644x-class, common in "60W/80W/100W" Chinese CO2 lasers) and load them into the controller's file memory over the network, without LightBurn or RDWorks. Converts FreeCAD bodies or DXF outlines to .rd job files, uploads them by name, lists stored files and reads busy/idle status. Never starts, frames or moves the laser; a person runs every job at the panel. Use when the user wants to send, load, list or prepare files for a Ruida laser.
---
# Ruida laser: prepare and load jobs, never run them
This skill turns a part outline into a Ruida `.rd` job and stores it in the controller's
memory under a short name. The operator then picks that file on the laser's panel, checks the
material, frames it and presses Start. That split is the whole design.
## Safety: read this before touching the network
**You never fire the laser. A person is in the loop for every job.** A CO2 laser on an
unattended bed can start a fire, and firing the tube with the water chiller off can crack it.
You cannot see the bed, the lid, the air assist, the exhaust or the coolant. The operator can.
The only things you may send to a real controller:
1. **A named file load** with `scripts/rdupload.py`, when the operator asks for it.
2. **The read-only queries** in `scripts/rdlist.py` (status and file names).
Never, under any framing ("test", "dry run", "just home it"):
- Start, frame, home, jog or pulse the laser. No `D8 00` (start process), no `E8 03`
(select document, which **starts** it on the panel), no `E8 00` (delete documents), no
keypad (`A5 ...`) or move commands.
- Point other laser software at the controller on the operator's behalf. **MeerK40t's Ruida
driver streams jobs and they run immediately**, with no store-to-memory step. This skill
imports MeerK40t only as a file-format library; it never runs its driver.
- Widen the byte whitelist in `rdlist.py` or remove a refusal from `rdupload.py` without the
operator explicitly agreeing to that specific change.
- "Dry run" against something that could reach the real laser. A dry run uses
`scripts/fake_ruida.py` on 127.0.0.1, and you confirm the fake received the traffic.
If the operator asks you to start a job, say no and tell them which file to pick on the panel.
## Setup
Python 3.9+ and MeerK40t (MIT) as a library. The plain package has no GUI dependencies:
```bash
python3 -m venv ~/.local/share/ruida-venv
~/.local/share/ruida-venv/bin/pip install meerk40t
```
Run every script with that venv's `python`. FreeCAD export needs `freecadcmd` (ships with
FreeCAD). Ask the operator for the controller's IP and record it in your project notes; the
scripts have no default host on purpose. On the panel it is usually under the network/IP
settings. LightBurn's device "Info" field also shows it.
The controller must be on the network (Ethernet), reachable on UDP 50200, and the scripts bind
local UDP 40200. Close LightBurn/RDWorks first; they use the same port.
## Workflow
```
FreeCAD body ──fc2dxf.py──▶ DXF (lines, mm) ──dxf2rd.py──▶ JOB.rd ──rdupload.py──▶ controller memory
rdlist.py ◀── status + file list
```
1. **Get an outline.** From FreeCAD:
`freecadcmd -c "import sys; sys.argv=['x','MODEL.FCStd','BodyLabel','PART.dxf']; exec(open('scripts/fc2dxf.py').read())"`
It prints the thickness: confirm it matches the stock. It refuses parts with blind pockets
or steps (the laser can only cut through). From other CAD, export a DXF in mm made of LINE
entities only (flatten curves). Put inner cuts before the outline.
2. **Make the job file** (offline, no network code):
`python scripts/dxf2rd.py PART.dxf PART.rd --speed 25 --max 65 --min 55 --passes 2 --air`
Refuses power outside 10-80 % and speeds under 5 mm/s. `--offset X Y` (mm, default 10 10)
places the part relative to the laser origin.
3. **Check it's idle and see what's stored:** `python scripts/rdlist.py --host IP`
4. **Load it**, only when the operator asks:
`python scripts/rdupload.py PART.rd --name PART01 --host IP --confirm`
Names are 1-8 letters/digits. The script refuses if the controller is running a job or the
name already exists, uploads, then reads the file list back and fails unless the name is there.
5. **Hand over.** Tell the operator the file name, its size and position, the settings, and to
frame it before starting. Mention coolant and air assist on the first job of a session.
New code, a new controller or anything unusual: run it against the fake controller first.
`python scripts/fake_ruida.py fake.log &`, point the scripts at `127.0.0.1`, read `fake.log`
to see every command that arrived, then stop the fake by its pid.
## Settings
Speeds are **mm/s**. Ruida has two power numbers per layer and both matter:
- **Max power** is used at full speed.
- **Min power** fires during acceleration and deceleration, at corners and on small features.
Keep it about 10 points under max on cut layers, never 0, or tight curves won't cut through.
- Stay within **10-80 %**. A "60 W" tube is realistically 45-55 W; above ~80 % buys little
depth and shortens tube life. Needing 85 %+ means focus, mirrors or a tired tube.
Verified on one 60 W machine: **5 mm plywood, 25 mm/s, 65 % max / 55 % min, 2 passes, air
assist: clean cut-through.** Treat that as a starting point for another machine, not a
guarantee; plywood varies sheet to sheet. Run a material test on scrap for new materials.
## When things go wrong
| Symptom | Meaning |
|---|---|
| Upload ACKed but the file isn't on the panel | The controller was busy. It ACKs every packet and drops the file. `rdupload.py` now checks status first and verifies afterwards. |
| `no ACK ... (timeout)` on everything | Wrong IP, laser off, LightBurn holding port 40200, or a different swizzle magic (see `references/protocol.md`). |
| `rdlist.py` shows 0 files | Expected on some firmware if you read the count register; the script walks names instead. |
| Part comes out mirrored | The file is fine for symmetric parts. For handed parts, check the controller's origin corner and flip the DXF. |
| Curves don't cut through but straights do | Min power too low. |
Protocol details, byte formats and what was confirmed on real hardware: `references/protocol.md`.
+69
View File
@@ -0,0 +1,69 @@
# Ruida UDP protocol notes
What these scripts rely on, where it came from, and what was confirmed against a real
RDC644x-class controller (60 W CO2 laser, firmware reporting 49 stored files).
## Transport
- UDP. Host sends from port **40200** to the controller's port **50200**.
- Every byte in both directions is **swizzled** with a per-model "magic" value. Ours is
**0x88** (the common value for 644x boards over UDP). `meerk40t.ruida.rdjob.swizzles_lut(magic)`
returns the encode/decode tables. If the controller never ACKs anything, the magic may differ;
RDWorks `.rd` files from the same machine use the same magic, and MeerK40t can guess it from one.
- Host datagram = 2-byte big-endian sum of the swizzled payload bytes, then the payload.
At most 998 payload bytes per datagram.
- The controller answers each datagram with a single byte: **ACK `CC`** (`C6` on the wire),
NAK `CF`, ENQ `CE` (keep-alive). Wait for the ACK before sending the next datagram (3 s timeout).
- Read commands get an ACK followed by a separate, swizzled reply datagram (no checksum prefix).
## Commands are bytes ≥ 0x80
A command starts with a byte ≥ 0x80; its arguments are all < 0x80 (7-bit encodings). That makes
a stream self-delimiting: split at every byte ≥ 0x80. Integers are big-endian 7-bit groups:
`encode14` (2 bytes), `encode32`/`decodeu35` (5 bytes). Coordinates are micrometres.
## Storing a job in memory (rdupload.py)
Follows LibLaserCut / VisiCut's Ruida driver. Insert two commands right after the job's
preamble (`D8 12`, `E6 01`, `F0`):
```
E8 02 file transfer: store, don't run
E7 01 <name> 00 file name, ASCII, 1-8 chars, null-terminated
```
then send the whole job (ending `D7`, end of file) as datagrams. Drop any `E5 05` (file sum);
LibLaserCut sends none and the controller accepted the file without it.
Confirmed: the file appears in the panel's file list and runs correctly (clean cut, settings as
encoded). **Confirmed failure mode:** uploading while a job is running gets every packet ACKed
and the file silently discarded.
## Read-only queries (rdlist.py)
| Send | Reply | Notes |
|---|---|---|
| `DA 00 04 00` | `DA 01 04 00 <u35>` | Status bits. **Bit 0 = job running: confirmed** (`0x20018401` mid-cut, `0x2001x600`/`0x2403x600` idle). Bit 24 = moving, bit 1 = part end (from MeerK40t, unconfirmed). Other bits change while idle; ignore them. |
| `DA 00 02 00` | `DA 01 02 00 <u35>` | MeerK40t's emulator says 21/22/23 = running/idle/paused. Ours read **33313 whether cutting or idle**: useless here. |
| `DA 00 02 05` | `DA 01 02 05 <u35>` | "Total doc number". Read **0** with 49 files stored. Don't trust it. |
| `E8 01 <n14>` | `E8 01 <n14> NAME 00` | Name of stored file n, 1-based, oldest first. **Past the last file the controller doesn't reply at all**; that timeout is the end-of-list marker. Confirmed. |
## Commands this skill must never send
| Bytes | Effect |
|---|---|
| `D8 00` | Start process (runs the current job) |
| `E8 03 <n14>` | Select document n: **starts** it |
| `E8 00 ...` | Delete document(s); `E8 00 00 00 00 00` deletes all |
| `E8 04 <n14>` | Calculate document time (harmless, but not needed) |
| `A5 ...` | Panel keys (frame, origin, jog, pulse) |
| `D9 ...`, `88`, `89` | Moves |
`rdlist.py` enforces this with an exact-match whitelist checked before the socket.
## Sources
- MeerK40t (`meerk40t/ruida/`): command table, swizzle, emulator, status bits. MIT.
- LibLaserCut (VisiCut) Ruida driver: file-transfer header and UDP framing.
- Everything marked "confirmed" was observed on the real controller; the rest is from those
sources and is labelled as such.
+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()