From 4e957509cc84499c6cc71300f1a8dfb05f9ee08a Mon Sep 17 00:00:00 2001 From: William Rickert Date: Sun, 4 Oct 2026 10:24:05 -0500 Subject: [PATCH] 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 --- .gitignore | 5 ++ AGENTS.md | 6 ++ LICENSE | 21 +++++ README.md | 74 +++++++++++++++++ ruida-laser/SKILL.md | 107 +++++++++++++++++++++++++ ruida-laser/references/protocol.md | 69 ++++++++++++++++ ruida-laser/scripts/dxf2rd.py | 87 ++++++++++++++++++++ ruida-laser/scripts/fake_ruida.py | 36 +++++++++ ruida-laser/scripts/fc2dxf.py | 49 ++++++++++++ ruida-laser/scripts/rdlist.py | 122 +++++++++++++++++++++++++++++ ruida-laser/scripts/rdupload.py | 119 ++++++++++++++++++++++++++++ 11 files changed, 695 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 ruida-laser/SKILL.md create mode 100644 ruida-laser/references/protocol.md create mode 100755 ruida-laser/scripts/dxf2rd.py create mode 100755 ruida-laser/scripts/fake_ruida.py create mode 100755 ruida-laser/scripts/fc2dxf.py create mode 100755 ruida-laser/scripts/rdlist.py create mode 100755 ruida-laser/scripts/rdupload.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..45c6fef --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +*.log +.claude/ +.codex/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..757df84 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,6 @@ +# Agent instructions + +This repo is a skill for preparing and loading jobs on a Ruida laser controller. +Before doing anything with a laser, read `ruida-laser/SKILL.md` in full and follow its +Safety section: never start, frame, move or delete anything on the controller. Only load +named files when the operator asks, and only send the read-only queries in `rdlist.py`. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4deab91 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Tangent Heavy Industries + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6e8f830 --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# RuidaSkill + +Let Claude (or another LLM agent) prepare laser jobs for a **Ruida-controlled CO2 laser** and +load them into the controller's memory over the network, with no LightBurn or RDWorks. You still +start every job yourself at the laser's panel. + +Built for the [Tangent Heavy Industries](https://tangentheavy.com) shop's 60 W "black and red" +laser (RDC644x-class controller), and used to cut real parts. + +Short link: **https://git.tangentheavy.com/RuidaSkill** + +## What it does + +``` +FreeCAD body ──fc2dxf.py──▶ DXF ──dxf2rd.py──▶ JOB.rd ──rdupload.py──▶ controller memory + rdlist.py ◀── busy/idle + stored files +``` + +- **`fc2dxf.py`**: takes a sheet part from a FreeCAD model and writes its outline as a DXF. It cuts holes before the outline and refuses parts the laser can't cut through. +- **`dxf2rd.py`**: turns the DXF into a Ruida job file with speed, min/max power, passes and air assist. It works offline and has no network code. +- **`rdupload.py`**: stores the job on the controller under a name. It refuses while a job is running or if the name is already taken, then reads the file list back to prove the file landed. +- **`rdlist.py`**: read-only status and file list. It checks every outgoing byte against a whitelist, so it can't send delete or start. +- **`fake_ruida.py`**: a fake controller on localhost for dry runs. It logs every command it receives. + +## The rule + +**The agent never fires the laser.** It prepares files and loads them; a person checks the +material, coolant, air and exhaust, frames the job and presses Start. A CO2 laser can start a +fire, and firing without coolant can crack the tube. The scripts enforce this: there is no code +path that sends start, frame, move or delete commands. See the Safety section of +[`ruida-laser/SKILL.md`](ruida-laser/SKILL.md). + +## Install + +```bash +git clone https://git.tangentheavy.com/RuidaSkill.git +python3 -m venv ~/.local/share/ruida-venv +~/.local/share/ruida-venv/bin/pip install meerk40t +``` + +[MeerK40t](https://github.com/meerk40t/meerk40t) (MIT) is used only as a library for the `.rd` +file format. The plain package pulls in numpy, pyusb and pyserial, no GUI. + +**Claude Code:** copy or symlink the skill folder into your skills directory: + +```bash +ln -s "$PWD/RuidaSkill/ruida-laser" ~/.claude/skills/ruida-laser +``` + +Then ask, for example: *"Export the Side body from Box.FCStd and load it on the laser as SIDE01."* +Tell Claude your controller's IP once (it's on the panel's network settings screen, or LightBurn's +device Info), and keep it in your project's `CLAUDE.md`. + +**Other agents (Codex, Cursor, Aider, local models):** `ruida-laser/SKILL.md` is plain Markdown. +Point the agent at it (this repo's `AGENTS.md` does that), or paste it into the system prompt. +The scripts are ordinary command-line tools and work without any agent: + +```bash +PY=~/.local/share/ruida-venv/bin/python +$PY ruida-laser/scripts/dxf2rd.py part.dxf part.rd --speed 25 --max 65 --min 55 --passes 2 --air +$PY ruida-laser/scripts/rdlist.py --host 192.0.2.10 +$PY ruida-laser/scripts/rdupload.py part.rd --name PART01 --host 192.0.2.10 --confirm +``` + +## Compatibility + +Tested on one RDC644x-class controller over Ethernet (UDP), swizzle magic 0x88. Other Ruida +models probably work. If nothing ACKs, the magic is the first suspect. USB is not supported. +[`references/protocol.md`](ruida-laser/references/protocol.md) lists exactly what was confirmed +on hardware and what is taken from MeerK40t and LibLaserCut. + +## License + +MIT. See [LICENSE](LICENSE). Use at your own risk: you are responsible for what your laser does. diff --git a/ruida-laser/SKILL.md b/ruida-laser/SKILL.md new file mode 100644 index 0000000..23e2ea3 --- /dev/null +++ b/ruida-laser/SKILL.md @@ -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`. diff --git a/ruida-laser/references/protocol.md b/ruida-laser/references/protocol.md new file mode 100644 index 0000000..3333561 --- /dev/null +++ b/ruida-laser/references/protocol.md @@ -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 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 ` | 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 ` | 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 ` | "Total doc number". Read **0** with 49 files stored. Don't trust it. | +| `E8 01 ` | `E8 01 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 ` | Select document n: **starts** it | +| `E8 00 ...` | Delete document(s); `E8 00 00 00 00 00` deletes all | +| `E8 04 ` | 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. diff --git a/ruida-laser/scripts/dxf2rd.py b/ruida-laser/scripts/dxf2rd.py new file mode 100755 index 0000000..f071c51 --- /dev/null +++ b/ruida-laser/scripts/dxf2rd.py @@ -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()) diff --git a/ruida-laser/scripts/fake_ruida.py b/ruida-laser/scripts/fake_ruida.py new file mode 100755 index 0000000..da10cec --- /dev/null +++ b/ruida-laser/scripts/fake_ruida.py @@ -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() diff --git a/ruida-laser/scripts/fc2dxf.py b/ruida-laser/scripts/fc2dxf.py new file mode 100755 index 0000000..fa18fe3 --- /dev/null +++ b/ruida-laser/scripts/fc2dxf.py @@ -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) diff --git a/ruida-laser/scripts/rdlist.py b/ruida-laser/scripts/rdlist.py new file mode 100755 index 0000000..26bbb16 --- /dev/null +++ b/ruida-laser/scripts/rdlist.py @@ -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 name of document n (1-based) -> reply E8 01 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 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()) diff --git a/ruida-laser/scripts/rdupload.py b/ruida-laser/scripts/rdupload.py new file mode 100755 index 0000000..912109b --- /dev/null +++ b/ruida-laser/scripts/rdupload.py @@ -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 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()