XY microstep modes: the baseline, the catalog test and the bench tools

The baseline derives x/y_mode, step_freq, ramp_rate and the configured
markers from the xy_microsteps setting; ramp_rate joins the GRBL
controller's set (the driver writes it; the cloud client runs at the
module's). motion.microstep-modes cycles 8, 16 and 32: the save restarts
the idle controller, the kernel reads the mode with its tick and ramp,
$100/$101 are the mode's and a typed $100 is overwritten, a 40 mm jog at
top speed returns to Idle with the kernel counters over the mode agreeing
with the commanded travel and the accelerometer seeing the head move; the
setting is put back as found.

Bench tools: xy_mode_test.py (the null-sink harness the grblHAL CI runs),
raster_dry.py (a top-speed raster per mode), xy_pattern_accel.py (the
operator's pattern from home with the machine silent and the head
accelerometer listening), arc_tolerance_sweep.py (a $12 ladder on the 9
in circle: the chord rate the protocol loop feeds, about 300 a second, is
the ceiling, not the core), and the xymode and xycircle live drills.
BRINGUP carries the present state and the facts; CAMPAIGN-LOG the dated
record, including the planner-blocks spin (a $398 of 255 or more loops
forever at start, a core bug) and its recovery.
This commit is contained in:
ScottW514
2026-09-07 18:05:28 -04:00
parent bfb5cb27d8
commit 8fc5250d6d
13 changed files with 1974 additions and 26 deletions
+226
View File
@@ -0,0 +1,226 @@
#!/usr/bin/env python3
"""A dry raster at top speed at each XY microstep mode (on the board).
Usage: raster_dry.py [modes...] (default: 8 16 32)
Per mode: stores xy_microsteps through forgectrl (which restarts the
idle GRBL controller), waits for the controller to come back at that
mode, then streams a raster with the laser off (M5): LINES passes of
LEN mm along X at F12000, Y stepped by PITCH between passes, and back to
the start. Reports the peak feed, the controller CPU over the job, the
kernel counters against the start (the head must come back within TOL mm
on both axes), Grbl's own drift, cnc/underruns, and any "late events
clamped" line the driver logged. Ends with the key cleared (x8).
Needs the controller in GRBL mode, idle, no other Grbl client, the bed
clear, and LEN mm of free +X travel plus LINES x PITCH mm of free +Y
travel from where the head stands.
"""
import json
import os
import re
import socket
import subprocess
import sys
import time
import urllib.request
FC = "http://127.0.0.1"
FC_TOKEN = "/data/forgefirm/panel.token"
MODES = [int(a) for arg in sys.argv[1:] for a in arg.split()] or [8, 16, 32]
LINES, LEN, PITCH, FEED, TOL = 60, 150.0, 0.2, 12000, 0.05
LOG = "/data/log/forgefirm/grblhal/grblhal.log"
def fc(path, data=None):
token = open(FC_TOKEN).read().strip()
body = None
if data is not None:
body = "&".join("%s=%s" % (k, v) for k, v in data.items()).encode()
req = urllib.request.Request(FC + path, data=body,
headers={"Authorization": "Bearer " + token, "X-ForgeFIRM-Token": token,
"Content-Type": "application/x-www-form-urlencoded"})
try:
return json.load(urllib.request.urlopen(req, timeout=30))
except urllib.error.HTTPError as e:
return {"http": e.code, "body": e.read().decode()}
def sysfs(attr):
with open("/sys/glowforge/" + attr) as f:
return f.read().strip()
def kernel_mm():
p = fc("/status").get("pos") or {}
return float(p.get("x", 0)), float(p.get("y", 0))
def controller_pid():
out = subprocess.run(["pidof", "grblHAL_glowforge"], capture_output=True, text=True).stdout.split()
return int(out[0]) if out else None
def cpu_ticks(pid):
with open("/proc/%d/stat" % pid) as f:
s = f.read().split()
return int(s[13]) + int(s[14])
def set_mode(mode):
r = fc("/settings?xy_microsteps=", {}) if mode == 8 else fc("/settings", {"xy_microsteps": str(mode)})
if "http" in r:
raise SystemExit("settings write refused: %s" % r)
time.sleep(3)
t0 = time.time()
while time.time() - t0 < 180:
m = fc("/mode")
if m.get("controller") == "running" and m.get("motion") == "verified" and sysfs("cnc/x_mode") == str(mode):
time.sleep(2)
print("x%d: controller pid %s, step_freq %s, ramp_rate %s"
% (mode, m.get("pid"), sysfs("cnc/step_freq"), sysfs("cnc/ramp_rate")))
return
time.sleep(2)
raise SystemExit("x%d: the controller did not come back" % mode)
class Grbl:
def __init__(self):
self.s = socket.create_connection(("127.0.0.1", 23), timeout=5)
time.sleep(0.3)
self.drain()
def drain(self):
self.s.settimeout(0.2)
out = b""
try:
while True:
d = self.s.recv(4096)
if not d:
break
out += d
except (socket.timeout, OSError):
pass
return out.decode(errors="replace")
def cmd(self, line, timeout=30):
self.s.sendall((line + "\n").encode())
self.s.settimeout(timeout)
buf = ""
end = time.time() + timeout
while time.time() < end:
try:
buf += self.s.recv(4096).decode(errors="replace")
except socket.timeout:
break
if "ok\r\n" in buf or "error" in buf or "ALARM" in buf:
break
return buf
def status(self):
self.s.sendall(b"?")
self.s.settimeout(1.0)
buf = ""
end = time.time() + 1.0
while time.time() < end and ">" not in buf:
try:
buf += self.s.recv(4096).decode(errors="replace")
except socket.timeout:
break
m = re.search(r"<(\w+)[^>]*MPos:([-\d.]+),([-\d.]+),([-\d.]+)", buf)
f = re.search(r"FS:([\d.]+),", buf)
if not m:
return None, None, 0.0
return m.group(1), (float(m.group(2)), float(m.group(3))), float(f.group(1)) if f else 0.0
def wait_idle(self, timeout):
peak = 0.0
end = time.time() + timeout
seen_run = False
while time.time() < end:
st, pos, f = self.status()
peak = max(peak, f)
if st and st.startswith(("Run", "Jog")):
seen_run = True
if st and st.startswith("Idle") and seen_run:
return True, peak, pos
if st and st.startswith("Alarm"):
return False, peak, pos
time.sleep(0.1)
return False, peak, None
def close(self):
self.s.close()
def raster(mode):
pid = controller_pid()
k0 = kernel_mm()
und0 = int(sysfs("cnc/underruns"))
log_off = os.path.getsize(LOG) if os.path.exists(LOG) else 0
g = Grbl()
peak = 0.0
try:
st, pos0, _ = g.status()
if not st or not st.startswith("Idle"):
print("x%d: controller is %s, not Idle" % (mode, st))
return False
for line in ("M5", "G21", "G90", "G91"):
g.cmd(line)
c0 = cpu_ticks(pid)
t0 = time.time()
for i in range(LINES):
dx = LEN if i % 2 == 0 else -LEN
r = g.cmd("G1 X%.3f F%d" % (dx, FEED), timeout=60)
if "error" in r or "ALARM" in r:
print("x%d: line %d refused: %r" % (mode, i, r.strip()))
break
g.cmd("G1 Y%.3f F%d" % (PITCH, FEED), timeout=60)
peak = max(peak, g.status()[2])
g.cmd("G1 Y%.3f F%d" % (-LINES * PITCH, FEED), timeout=60)
ok, peak_end, pos1 = g.wait_idle(120)
peak = max(peak, peak_end)
g.cmd("G90")
elapsed = time.time() - t0
cpu = 100.0 * (cpu_ticks(pid) - c0) / (os.sysconf("SC_CLK_TCK") * elapsed)
finally:
g.close()
# the machine itself idle: the kernel plays out the depth behind Idle
end = time.time() + 20
while time.time() < end and fc("/status").get("state") != "idle":
time.sleep(0.5)
k1 = kernel_mm()
und1 = int(sysfs("cnc/underruns"))
clamped = []
if os.path.exists(LOG):
with open(LOG, errors="replace") as f:
f.seek(log_off)
clamped = [l.strip() for l in f if "late events clamped" in l or "underrun" in l]
drift = (pos1[0] - pos0[0], pos1[1] - pos0[1]) if (pos1 and pos0) else None
kd = (k1[0] - k0[0], k1[1] - k0[1])
verdict = ok and abs(kd[0]) <= TOL and abs(kd[1]) <= TOL and und1 == und0 and not clamped
print("x%d: %s | %d passes of %.0f mm at F%d in %.1f s, peak %.0f mm/min, controller CPU %.1f %% | "
"kernel return dx=%.3f dy=%.3f mm | grbl drift %s | underruns %d->%d | clamped lines %d"
% (mode, "PASS" if verdict else "FAIL", LINES, LEN, FEED, elapsed, peak, cpu, kd[0], kd[1],
drift, und0, und1, len(clamped)))
for l in clamped[:5]:
print(" ", l)
return verdict
def main():
verdicts = {}
for mode in MODES:
print("=== x%d ===" % mode)
set_mode(mode)
verdicts[mode] = raster(mode)
if MODES[-1] != 8:
print("=== restore x8 ===")
set_mode(8)
print("settings xy_microsteps:", repr(fc("/settings").get("xy_microsteps")))
print("RESULT:", " ".join("x%d=%s" % (m, "PASS" if v else "FAIL") for m, v in verdicts.items()))
return 0 if all(verdicts.values()) else 1
if __name__ == "__main__":
sys.exit(main())