Files
forgefirm/scripts/bench/xy_mode_test.py
T
ScottW514 9a288b175b Track the x32 xy_microsteps default in forgetest and the bench tools
Acceptance baseline: XY_MODE_DEFAULT 8 -> 32, split out XY_MODE_BASE (8) for
the tick/ramp scaling, matching the driver. The mode-aware comparisons
(fixed_sysfs of the resolved mode) judge an unset machine at x32.

Host CI: xy_mode_test.py default and invalid cases expect x32/213.333/112640.

Bench tools: raster_dry.py and xy_pattern_accel.py set_mode() cleared the key
for mode 8, which relied on clear == the x8 default; clearing now yields x32,
so they set the mode explicitly and restore to x32. live_fire_drills
STREAM_RATE_HZ is the 28160 Hz laser reference tick, the same at every mode.
2026-09-16 15:39:20 -04:00

228 lines
7.6 KiB
Python

#!/usr/bin/env python3
"""Host-side verification of the XY microstep mode wiring.
The XY microstep mode is one number in the shared config, xy_microsteps
(8, 16 or 32), and the driver derives three things from it at start and
never takes them typed: $100/$101, the machine tick, and the kernel stop
ramp. This harness drives the native grblHAL_glowforge binary in
null-sink mode (no hardware, no root) over TCP, one process per config:
1. no key: x8, $100/$101 = 53.333, the 28160 Hz tick
2. xy_microsteps = 16: 106.667, the 56320 Hz tick; a typed $100 is
overwritten on the spot; $110 is left alone under a tick that
carries it
3. xy_microsteps = 32: 213.333, the 112640 Hz tick
4. a value that is not a mode: x8 with a warning in the log
5. GFSINK_RATE lowered under the mode's tick: $110/$111 are held at
the feed the tick carries, one step per tick per axis
The binary keeps its settings in EEPROM.DAT in the working directory, so
each run starts from defaults in a temporary directory and leaves
nothing behind.
Usage: xy_mode_test.py [path-to-binary]
(default ./build-native/grblHAL_glowforge)
"""
import os
import re
import shutil
import socket
import subprocess
import sys
import tempfile
import time
BIN = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else "build-native/grblHAL_glowforge")
PORT = 2401
def fail(msg):
print("FAIL: %s" % msg)
sys.exit(1)
class Session:
"""One null-sink controller process with its own settings store and
its own shared config file."""
def __init__(self, conf=None, env_extra=None):
self.workdir = tempfile.mkdtemp(prefix="xy-mode-")
env = dict(os.environ, GF_STATE_DIR=self.workdir, FFLOG_STDERR="1",
FFLOG_LEVEL="info")
for key in ("GFSINK", "GFSINK_RATE", "GF_SWITCH_FILE", "GF_VERDICT_FILE"):
env.pop(key, None)
conf_path = os.path.join(self.workdir, "forgefirm.conf")
with open(conf_path, "w") as f:
f.write(conf or "")
env["GFHOME_CONF"] = conf_path
env.update(env_extra or {})
self.proc = subprocess.Popen([BIN, "-p", str(PORT)],
cwd=self.workdir, env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE)
self.sock = self.connect()
self.read(1.0) # banner and any boot messages
def connect(self):
for _ in range(50):
try:
return socket.create_connection(("127.0.0.1", PORT), timeout=1)
except OSError:
time.sleep(0.1)
err = b""
if self.proc.poll() is not None:
err = self.proc.stderr.read() or b""
fail("cannot connect to the controller (exit=%s)\n%s"
% (self.proc.poll(), err.decode(errors="replace")))
def read(self, timeout=0.8):
out = b""
end = time.time() + timeout
while time.time() < end:
self.sock.settimeout(max(0.05, end - time.time()))
try:
data = self.sock.recv(4096)
except (socket.timeout, OSError):
break
if not data:
break
out += data
return out.decode(errors="replace")
def send(self, line, timeout=1.0):
self.sock.sendall((line + "\n").encode())
return self.read(timeout)
def setting(self, key):
reply = self.send(key)
m = re.search(r"^%s=(\S+)" % re.escape(key), reply, re.M)
if not m:
fail("no value reported for %s: %r" % (key, reply))
return float(m.group(1))
def write_setting(self, assignment):
reply = self.send(assignment)
if "ok" not in reply:
fail("settings write %r refused: %r" % (assignment, reply))
def close(self):
"""Stop the process and return everything it logged."""
try:
self.sock.close()
except OSError:
pass
self.proc.terminate()
try:
_out, err = self.proc.communicate(timeout=5)
except subprocess.TimeoutExpired:
self.proc.kill()
_out, err = self.proc.communicate()
shutil.rmtree(self.workdir, ignore_errors=True)
return (err or b"").decode(errors="replace")
def check(ok, ok_msg, fail_msg):
if not ok:
fail(fail_msg)
print("ok %s" % ok_msg)
def near(got, want, tol=0.0005):
return abs(got - want) <= tol
def expect_scale(s, mode, spm):
for key in ("$100", "$101"):
got = s.setting(key)
check(near(got, spm), "%s=%.3f at x%d" % (key, got, mode),
"%s is %.3f at x%d, want %.3f" % (key, got, mode, spm))
def expect_log(log, needle, what):
check(needle in log, what, "the log does not say %r:\n%s" % (needle, log))
def case_default():
s = Session()
try:
expect_scale(s, 32, 213.333)
rate = s.setting("$110")
check(near(rate, 12000.0), "$110=%.0f stands at x32" % rate,
"$110 is %.3f at x32, want 12000" % rate)
finally:
log = s.close()
expect_log(log, "x32 microsteps, 112640 Hz machine tick", "no key: x32, the 112640 Hz tick")
def case_x16():
s = Session("xy_microsteps = 16\n")
try:
expect_scale(s, 16, 106.667)
# A typed $100 is accepted and overwritten on the spot: the scale
# is the mode's, never the sender's.
s.write_setting("$100=53.333")
got = s.setting("$100")
check(near(got, 106.667), "a typed $100 is back at %.3f" % got,
"a typed $100 stuck at %.3f under x16" % got)
rate = s.setting("$110")
check(near(rate, 12000.0), "$110=%.0f stands under a tick that carries it" % rate,
"$110 is %.3f under x16, want 12000" % rate)
finally:
log = s.close()
expect_log(log, "x16 microsteps, 56320 Hz machine tick", "x16: the 56320 Hz tick")
def case_x32():
s = Session("xy_microsteps = 32\n")
try:
expect_scale(s, 32, 213.333)
finally:
log = s.close()
expect_log(log, "x32 microsteps, 112640 Hz machine tick", "x32: the 112640 Hz tick")
def case_invalid():
s = Session("xy_microsteps = 24\n")
try:
expect_scale(s, 32, 213.333)
finally:
log = s.close()
expect_log(log, "xy_microsteps '24' is not 8, 16 or 32", "a value that is not a mode is refused with a warning")
expect_log(log, "x32 microsteps, 112640 Hz machine tick", "and the machine runs at x32")
def case_low_tick():
# 1000 Hz carries 1000 steps/s per axis: 562.5 mm/min at 106.667
# steps/mm. $110/$111 are held there, and a typed value above it
# comes back held.
s = Session("xy_microsteps = 16\n", {"GFSINK_RATE": "1000"})
try:
expect_scale(s, 16, 106.667)
for key in ("$110", "$111"):
got = s.setting(key)
check(near(got, 562.5, 0.05), "%s held at %.3f under a 1000 Hz tick" % (key, got),
"%s is %.3f under a 1000 Hz tick, want 562.5" % (key, got))
s.write_setting("$110=6000")
got = s.setting("$110")
check(near(got, 562.5, 0.05), "a typed $110=6000 comes back held at %.3f" % got,
"a typed $110=6000 stuck at %.3f under a 1000 Hz tick" % got)
finally:
log = s.close()
expect_log(log, "1000 Hz machine tick", "GFSINK_RATE took the tick to 1000 Hz")
expect_log(log, "held at the ceiling", "the hold is logged")
def main():
if not os.path.isfile(BIN):
fail("no controller binary at %s" % BIN)
case_default()
case_x16()
case_x32()
case_invalid()
case_low_tick()
print("PASS xy_mode_test")
if __name__ == "__main__":
main()