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
+87
@@ -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())
|
||||
Executable
+36
@@ -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()
|
||||
Executable
+49
@@ -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)
|
||||
Executable
+122
@@ -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())
|
||||
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