Files
forgefirm/scripts/bench/tray_test.py
T
ScottW514 8e1fe5090d Harnesses: the null-sink cases other tests prove leave
The grblHAL harnesses keep what only a running controller shows; the
cases a host unit test in grblHAL-glowforge proves, and the log and
reply wording, leave.

- laser_lifecycle_test: test_arm_waits_for_the_fans goes. laser_arm_test
  case A4 (grblHAL-glowforge) proves the fan grace stretching the arm's
  acknowledgment budget, on the same arm path. The docstring names it.
- xy_mode_test: case_x32, case_invalid and the log checks go; the
  default, x16 and the low tick stay. xy_scale_test holds each mode's
  tick and the not-a-mode parse.
- planner_blocks_test: one restart, at $398 1000; the 400 pass and the
  fresh-store probe go.
- ctlport_test: test_poll_lf and test_crlf go (the CR LF poll stays); in
  test_sender_out the network turn-away goes (serial_test holds it), and
  the busy gating, the grant and the outlives-client part stay.
  test_port_dark is whole.
- mcode_test: the timeout case runs on the host build's 5 s
  GFMCODE_WAIT_S (grblHAL-glowforge's CI configures it; the image keeps
  30 s) and still shows the hold, at 5.1 s; 5 s outlasts the 3 s
  test_dark watches the wait; test_table refuses one form per
  parser branch. test_timeout_holds and test_dark stay.
- envelope_test: test_unset and test_bounds go (lens_home_test holds the
  clamp); test_armed and the open, apply and close cases stay.
- manual_home_test: the out-of-range offset block goes, a repeat of
  gfhome_clamp_home_mm (lens_home_test); the modal-M3 FIRE check and
  every release and energize case stay.
- cool_report_test: one non-hex secret and one CR LF injection stay;
  runner-malformed and the exact header-set checks go. A bad secret is
  never sent and a good one is.
- tray_test: test_offset keeps 20 mm; test_errors keeps a bad P and a
  bad axis; the frame shift, envelope, barrier, persistence, port op,
  unreferenced lens and camera home stay.

Proof: with grblHAL-glowforge's matching commit, the eleven harnesses
its CI runs pass under Linux against the null-sink build: nine on a
build with a 1 s M-code wait, and mcode_test and tray_test, the two
that wait at an M-code, on the 5 s build CI configures
(-DGFMCODE_WAIT_S=5.0). pyflakes reports nothing in the nine changed
files.

Push this before grblHAL-glowforge's commit.

Acceptance: none; host harnesses only.
2026-10-13 21:20:31 -04:00

475 lines
18 KiB
Python

#!/usr/bin/env python3
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""Host-side verification of the crumb tray's mode (glowforge_tray.c).
With the tray out the work sits on the floor of the cut area, and Z is the
focal height above that floor: the Z position and the Z envelope move up by
tray_offset_mm on the lens step grid (1.35" is 100 half-steps). M103 P1 sets
the tray out, M103 P0 sets it in, and the controller port's "tray out|in"
runs the same line for the panel. Runs the native grblHAL_glowforge binary
in null-sink mode, with GFSINK_DUMP capturing the shipped stream, a scripted
sender on the Grbl socket, and the port client of ctlport_test.py:
1. M103 P1 and P0 shift the reported Z by the offset's whole half-steps,
tell the sender [MSG:Tray out] and [MSG:Tray in], keep Z referenced,
write and remove the marker beside the shared config, and ship no
step tick; asking for the mode already set changes nothing and says so
2. the envelope moves with the position: with the tray out the ends of
the lens's reach, shifted, run and two half-steps past them are
refused, and a jog to a tray-in Z is refused; with the tray in again
the same jog runs
3. a P other than 0 or 1, and an axis word on the line, are errors (39,
31) that change nothing
4. the M-code is a barrier: behind a move it waits for the move to end,
and the frame changes only once the machine is idle
5. the port's tray op switches the mode with a sender connected, the
sender's response count stays exact and it is told the mode; the op
is refused out of form (error:invalid), during a program
(busy:state), and while a job waits at a package's M-code
(busy:mcode)
6. the mode survives a soft reset, and a controller restart: a start with
the marker present takes the lens reference in the tray-out frame,
and one without it in the tray-in frame
7. tray_offset_mm sets the shift
8. an unreferenced lens shifts the same way, and stays pinned where it
reads
9. a camera home with the tray out declares Z the offset higher, and
hands the runner the same park: the lens parks in the same place
The kernel-idle half of the barrier (the wait a package's M-code makes)
needs the pulse device: a null sink is always idle.
Usage: tray_test.py [path/to/grblHAL_glowforge]
"""
import os
import shutil
import signal
import subprocess
import sys
import tempfile
import threading
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import ctlport_test as ct # noqa: E402
BIN = ct.BIN
fail = ct.fail
PORT = 2395
SPM = 2.922
EDGE = 3.35 # the edge's grid step is 10
EDGE_K = 10
SHIFT_K = 100 # the default offset, 34.29 mm
DOWN, UP = 10, 12 # the fallback window
def zk(k):
return k / SPM
class Session:
"""One null-sink controller over its own state directory. `conf` is the
shared config; with `referenced` the lens marker forgectrl leaves is
there, so the controller takes the lens reference at its start."""
def __init__(self, conf=None, referenced=True):
self.workdir = tempfile.mkdtemp(prefix="tray-")
self.conf = os.path.join(self.workdir, "forgefirm.conf")
self.dump = os.path.join(self.workdir, "stream.bin")
self.marker = os.path.join(self.workdir, "tray.out")
self.write_conf(conf or {})
if referenced:
with open(os.path.join(self.workdir, "lens.home"), "w") as f:
f.write("edge 0 3 3\n")
verdict = os.path.join(self.workdir, "cooling.state")
self.stop = threading.Event()
self.pub = threading.Thread(target=ct.publish_verdicts, args=(verdict, self.stop), daemon=True)
self.pub.start()
env = dict(os.environ, GFHOME_CONF=self.conf, GF_STATE_DIR=self.workdir,
GF_VERDICT_FILE=verdict, GFSINK_DUMP=self.dump, FFLOG_STDERR="1")
env.pop("GFSINK", None)
env.pop("GF_SWITCH_FILE", None)
self.env = env
self.start()
def write_conf(self, conf):
with open(self.conf, "w") as f:
f.write("cool_fan_grace_s = 0\nlaser_disarm_s = 1\nlens_hall_edge_z_mm = %.2f\n" % EDGE)
for k, v in conf.items():
f.write("%s = %s\n" % (k, v))
def start(self):
self.proc = subprocess.Popen([BIN, "-p", str(PORT)], cwd=self.workdir, env=self.env,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
self.sender = ct.Sender(PORT)
path = os.path.join(self.workdir, "grbl.ctl")
for _ in range(50):
if os.path.exists(path):
break
time.sleep(0.1)
else:
fail("the controller did not create %s" % path)
self.port = ct.PortClient(path)
# The lens reference is taken on the first pass of the realtime hook.
time.sleep(0.3)
def restart(self, hard):
"""End the controller (SIGKILL when hard) and start another over the
same state directory and shared config."""
self.port.close()
self.sender.close()
if hard:
self.proc.kill()
else:
self.proc.send_signal(signal.SIGINT)
self.proc.wait(10)
self.start()
def state(self):
return self.port.state()
def z(self):
return self.state()["mpos"][2]
def tray(self):
return self.state()["tray"]
def ticks(self):
"""Step ticks shipped so far (a byte with bit 7 set is a power byte)."""
try:
with open(self.dump, "rb") as f:
data = f.read()
except OSError:
return 0
return sum(1 for b in data if not b & 0x80 and b & 0x25)
def messages(self, text, want=None):
"""How many [MSG:<text>] lines the sender has had. With `want`, wait
up to a second for that many: the port answers at once, and the
sender's lines go out on the controller's next pass."""
end = time.time() + (1.0 if want is not None else 0)
while True:
n = self.sender.saw("[MSG:%s]" % text)
if want is None or n >= want or time.time() > end:
return n
time.sleep(0.02)
def quiet(self):
time.sleep(0.45)
def close(self):
self.port.close()
self.sender.close()
self.proc.send_signal(signal.SIGINT)
try:
self.proc.wait(5)
except subprocess.TimeoutExpired:
self.proc.kill()
self.stop.set()
self.pub.join(2)
shutil.rmtree(self.workdir, ignore_errors=True)
def expect_z(s, k, what):
z = s.z()
if abs(z - zk(k)) > 0.002:
fail("[%s] Z reads %.3f, not %.3f (%d half-steps)" % (what, z, zk(k), k))
def refused(s, line):
"""Send a line that must be refused, and return its error, acknowledged
with an empty line (the core holds a parser error against the next
g-code line until then)."""
r = s.sender.send(line)
if r.startswith("error"):
if s.sender.send("") != "ok":
fail("the empty line that acknowledges %s did not get ok" % r)
return r
return None
def jog_to(s, k):
"""An absolute jog to half-step k: its response."""
r = s.sender.send("$J=G90 G21 Z%.4f F200" % zk(k))
if r == "ok":
s.sender.wait_state("Idle")
elif s.sender.send("") != "ok":
fail("the empty line after a refused jog did not get ok")
return r
def test_switch(s):
expect_z(s, EDGE_K, "switch")
st = s.state()
if st["tray"] != "in" or not st["homed"] & 4:
fail("[switch] not tray in with Z referenced at the start: %r" % st)
ticks = s.ticks()
if s.sender.send("M103 P1") != "ok":
fail("[switch] M103 P1 was refused")
st = s.state()
if st["tray"] != "out" or not st["homed"] & 4:
fail("[switch] not tray out with Z referenced after M103 P1: %r" % st)
expect_z(s, EDGE_K + SHIFT_K, "switch")
if not s.messages("Tray out", 1):
fail("[switch] the sender was not told [MSG:Tray out]")
if not os.path.exists(s.marker):
fail("[switch] M103 P1 left no marker beside the shared config")
if s.sender.send("M103 P1") != "ok" or s.tray() != "out":
fail("[switch] a second M103 P1 was refused or changed the mode")
expect_z(s, EDGE_K + SHIFT_K, "switch: the mode already set")
if s.messages("Tray out", 2) != 2:
fail("[switch] the mode already set was not told again")
if s.sender.send("M103 P0") != "ok" or s.tray() != "in":
fail("[switch] M103 P0 did not set the tray in")
expect_z(s, EDGE_K, "switch: back in")
if os.path.exists(s.marker) or not s.messages("Tray in", 1):
fail("[switch] M103 P0 left the marker or did not tell the sender")
time.sleep(0.3)
if s.ticks() != ticks:
fail("[switch] the switches shipped %d step ticks" % (s.ticks() - ticks))
print("PASS [switch]: M103 P1 moved Z up %d half-steps and P0 back, the sender was told, the "
"marker followed, and no step shipped" % SHIFT_K)
def test_envelope(s):
s.sender.send("M103 P1")
base = EDGE_K + SHIFT_K
for k, want, what in ((base + UP, "ok", "the top of the reach"),
(base + UP + 2, "error:15", "two half-steps over the top"),
(base - DOWN, "ok", "the bottom of the reach"),
(base - DOWN - 2, "error:15", "two half-steps under the bottom"),
(EDGE_K + UP, "error:15", "a tray-in Z")):
r = jog_to(s, k)
if r != want:
fail("[envelope] tray out: a jog to %s (Z %.3f) got %r, not %s" % (what, zk(k), r, want))
if jog_to(s, base) != "ok":
fail("[envelope] the jog back to the edge was refused")
s.sender.send("M103 P0")
expect_z(s, EDGE_K, "envelope")
if jog_to(s, EDGE_K + UP) != "ok":
fail("[envelope] tray in: the jog to the same tray-in Z was refused")
if jog_to(s, EDGE_K) != "ok":
fail("[envelope] the jog back to the edge was refused")
print("PASS [envelope]: with the tray out the shifted reach ran to its ends, past them and a "
"tray-in Z were refused; with the tray in the tray-in Z ran")
def test_errors(s):
ticks = s.ticks()
for line, want in (("M103 P2", "error:39"), ("M103 P1 X5", "error:31")):
r = refused(s, line)
if r != want:
fail("[errors] %r got %r, not %s" % (line, r, want))
if s.tray() != "in" or os.path.exists(s.marker):
fail("[errors] %r changed the mode" % line)
expect_z(s, EDGE_K, "errors")
time.sleep(0.3)
if s.ticks() != ticks:
fail("[errors] the refused lines shipped %d step ticks" % (s.ticks() - ticks))
print("PASS [errors]: P2 and an axis word were refused and changed nothing")
def test_barrier(s):
"""Behind a move the switch waits for it to end."""
x0 = s.state()["mpos"][0]
s.sender.send("G91 G1 X10 F300") # about two seconds
n = s.sender.count()
s.sender.sock.sendall(b"M103 P1\n")
seen_run = False
end = time.time() + 10
while time.time() < end:
st = s.port.state()
if st["state"] == "Run":
seen_run = True
if st["tray"] != "in" or abs(st["mpos"][2] - zk(EDGE_K)) > 0.002:
fail("[barrier] the frame changed while the move before M103 ran: %r" % st)
if st["tray"] == "out":
break
time.sleep(0.05)
if not seen_run:
fail("[barrier] the move was never seen running, so the case proves nothing")
st = s.port.state()
if st["tray"] != "out" or st["state"] != "Idle" or abs(st["mpos"][0] - x0 - 10) > 0.01:
fail("[barrier] the switch did not land after the move ended: %r" % st)
for _ in range(100):
if s.sender.count() > n:
break
time.sleep(0.05)
s.sender.send("G90")
s.sender.send("M103 P0")
print("PASS [barrier]: M103 behind a move changed the frame only once the move had ended")
def test_port(s):
s.sender.wait_state("Idle")
s.quiet()
before = s.sender.count()
told = s.messages("Tray out")
r = s.port.request("tray out")
if r != "ok" or s.tray() != "out":
fail("[port] tray out got %r" % r)
expect_z(s, EDGE_K + SHIFT_K, "port")
if s.port.request("tray out") != "ok":
fail("[port] a second tray out was refused")
if s.messages("Tray out", told + 2) != told + 2:
fail("[port] the sender was not told the mode each time")
if s.port.request("tray sideways") != "error:invalid":
fail("[port] a tray op out of form was not refused")
if s.sender.count() != before:
fail("[port] the sender got %d responses for the port's tray ops" % (s.sender.count() - before))
s.sender.send("G91 G1 X-10 F300")
time.sleep(0.4)
r = s.port.request("tray in")
if r != "busy:state" or s.tray() != "out":
fail("[port] tray in during a program got %r" % r)
s.sender.wait_state("Idle")
s.sender.send("G90")
if s.port.request("mcodes 160") != "ok":
fail("[port] the M-code table was refused")
n = s.sender.count()
s.sender.sock.sendall(b"M160 P1\n")
for _ in range(100):
st = s.state()
if st.get("mcode"):
break
time.sleep(0.05)
else:
fail("[port] no M-code waits: %r" % s.state())
r = s.port.request("tray in")
if r != "busy:mcode" or s.tray() != "out":
fail("[port] tray in while a job waits at an M-code got %r" % r)
if s.port.request("mcode_result %d ok" % st["mcode"]["seq"]) != "ok":
fail("[port] the M-code's answer was refused")
for _ in range(100):
if s.sender.count() > n:
break
time.sleep(0.05)
s.port.request("mcodes -")
s.quiet()
before = s.sender.count()
if s.port.request("tray in") != "ok" or s.tray() != "in":
fail("[port] tray in was refused")
expect_z(s, EDGE_K, "port")
if s.sender.count() != before or s.sender.send("G0 X0") != "ok":
fail("[port] the sender's count was disturbed by the tray op")
s.sender.wait_state("Idle")
print("PASS [port]: tray out and in through the port with a sender connected, its count exact "
"and the mode told; refused out of form, during a program, and at a package's M-code")
def test_persistence(s):
s.sender.send("M103 P1")
s.sender.realtime(b"\x18")
time.sleep(1.0)
s.sender.send("$X")
if s.tray() != "out":
fail("[persistence] a soft reset set the tray in")
expect_z(s, EDGE_K + SHIFT_K, "persistence: soft reset")
s.restart(hard=True)
st = s.state()
if st["tray"] != "out" or not st["homed"] & 4:
fail("[persistence] a restart over the marker came up %r" % st)
expect_z(s, EDGE_K + SHIFT_K, "persistence: restart with the tray out")
if jog_to(s, EDGE_K + SHIFT_K + UP) != "ok" or jog_to(s, EDGE_K + UP) != "error:15":
fail("[persistence] the restarted controller's envelope is not the tray-out one")
jog_to(s, EDGE_K + SHIFT_K)
if s.port.request("tray in") != "ok":
fail("[persistence] tray in was refused after the restart")
expect_z(s, EDGE_K, "persistence: in after the restart")
s.restart(hard=False)
if s.tray() != "in":
fail("[persistence] a restart with no marker came up tray out")
expect_z(s, EDGE_K, "persistence: restart with the tray in")
print("PASS [persistence]: the tray out survived a soft reset and a controller kill, the new "
"controller referenced Z in the tray-out frame, and a start with no marker in the tray-in frame")
def test_offset():
for key, k in (("20", 58),):
s = Session(conf={"tray_offset_mm": key})
try:
s.sender.send("M103 P1")
expect_z(s, EDGE_K + k, "offset %s" % key)
s.sender.send("M103 P0")
expect_z(s, EDGE_K, "offset %s: back in" % key)
finally:
s.close()
print("PASS [offset]: tray_offset_mm 20 moved Z 58 half-steps")
def test_unreferenced():
s = Session(referenced=False)
try:
z0 = s.z()
k0 = round(z0 * SPM)
s.sender.send("M103 P1")
expect_z(s, k0 + SHIFT_K, "unreferenced")
for step in ("1", "-1"):
r = s.sender.send("$J=G91 G21 Z%s F200" % step)
if r != "error:15":
fail("[unreferenced] a Z jog of %s got %r: the pinned envelope did not follow" % (step, r))
s.sender.send("")
s.sender.send("M103 P0")
expect_z(s, k0, "unreferenced: back in")
finally:
s.close()
print("PASS [unreferenced]: an unreferenced lens shifted the same way and stayed pinned where it read")
def test_camera_home():
s = Session(conf={"homing_mode": "gfcloud"})
park = os.path.join(s.workdir, "park.log")
s.write_conf({"homing_mode": "gfcloud", "gfcloud_home_cmd": "echo $GFHOME_PARK_HALF_STEPS >> %s" % park})
try:
z = {}
for mode in ("P0", "P1"):
s.sender.send("M103 " + mode)
if s.sender.send("$H") != "ok":
fail("[camera-home] $H with M103 %s was refused" % mode)
s.sender.wait_state("Idle")
z[mode] = s.z()
with open(park) as f:
parks = f.read().split()
if len(parks) != 2 or parks[0] != parks[1]:
fail("[camera-home] the runner was handed parks %r, not the same one twice" % parks)
k = EDGE_K + int(parks[0])
if abs(z["P0"] - zk(k)) > 0.002 or abs(z["P1"] - zk(k + SHIFT_K)) > 0.002:
fail("[camera-home] Z after the homes read %r, not %.3f and %.3f" % (z, zk(k), zk(k + SHIFT_K)))
if jog_to(s, EDGE_K + SHIFT_K + UP) != "ok" or jog_to(s, EDGE_K + UP) != "error:15":
fail("[camera-home] the envelope after a tray-out home is not the tray-out one")
s.sender.send("M103 P0")
finally:
s.close()
print("PASS [camera-home]: the park handed to the runner was %s half-steps in both modes, and Z "
"after the home read %.3f tray in, %.3f tray out" % (parks[0], z["P0"], z["P1"]))
def main():
if not os.path.exists(BIN):
fail("no controller binary at %s" % BIN)
s = Session()
try:
if s.sender.send("$102") != "ok" or not s.sender.saw("$102=2.922"):
fail("$102 is not 2.922: this harness needs the default")
test_switch(s)
test_envelope(s)
test_errors(s)
test_barrier(s)
test_port(s)
test_persistence(s)
finally:
s.close()
test_offset()
test_unreferenced()
test_camera_home()
print("ALL PASS")
if __name__ == "__main__":
main()