Files
forgefirm/scripts/bench/envelope_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

248 lines
8.8 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 measured work envelope and the bed check's port op.
Runs the native grblHAL_glowforge binary in null-sink mode, with the
scripted sender and the port client of ctlport_test.py, homing manually
(nothing moves; X and Y become homed and the envelope is set):
1. envelope_x_mm and _y set the far edges at a home, and a jog past one
is the core's error:15 (an unset key being the travel, and a key held
to GFHOME_ENVELOPE_MIN_MM and to the travel plus
GFHOME_ENVELOPE_EXTRA_MM, are lens_home_test.c's in grblHAL-glowforge)
2. the port's envelope open: refused before a home (error:homed); after
one, the port's jogs reach the travel plus 30 mm for X and Y, and the
port's state says the envelope is open; envelope apply puts the keys'
edges back without a home
3. an open envelope is the port's jogs' alone: a status poll and an
empty line leave it open, and the sender's first line closes it before
the core reads the line (the line then answered ok, and the keys'
edges in force), a port jog it meets canceled
4. a soft reset and the port client going away each close it
5. envelope open is refused under an open armed window (busy:state)
Usage: envelope_test.py [path/to/grblHAL_glowforge]
"""
import os
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import ctlport_test as ct # noqa: E402
fail = ct.fail
TRAVEL_X, TRAVEL_Y, EXTRA = 495.0, 279.0, 30.0
def conf(s, **keys):
with open(os.path.join(s.workdir, "forgefirm.conf"), "w") as f:
f.write("cool_fan_grace_s = 0\nlaser_disarm_s = 30\nhoming_mode = manual\n")
for k, v in keys.items():
f.write("%s = %s\n" % (k, v))
def home(s):
if s.sender.send("$H", timeout=10) != "ok":
fail("manual $H refused")
s.sender.wait_state("Idle")
def jog_to(s, axis, mm):
"""The sender's absolute jog; its response (ok or error:N), and back to 10 mm when it ran."""
r = s.sender.send("$J=G90 G21 %s%.3f F6000" % (axis, mm))
s.sender.wait_state("Idle")
if r == "ok":
s.sender.send("$J=G90 G21 %s10 F6000" % axis)
s.sender.wait_state("Idle")
return r
def edge(s, axis, inside, outside, what):
a, b = jog_to(s, axis, inside), jog_to(s, axis, outside)
if a != "ok" or b != "error:15":
fail("[%s] %s: a jog to %.2f -> %s, to %.2f -> %s" % (what, axis, inside, a, outside, b))
def port_idle(s, timeout=20.0):
end = time.time() + timeout
while time.time() < end:
st = s.port.state()
if st["state"] == "Idle" and not st["port_jog"]:
return st
time.sleep(0.05)
fail("the port never saw Idle: %r" % s.port.state())
def port_jog_to(s, axis, mm):
"""The port's absolute jog, which leaves an open envelope open."""
r = s.port.request("jog G90 G21 %s%.3f F6000" % (axis, mm))
port_idle(s)
if r == "ok":
s.port.request("jog G90 G21 %s10 F6000" % axis)
port_idle(s)
return r
def port_edge(s, axis, inside, outside, what):
a, b = port_jog_to(s, axis, inside), port_jog_to(s, axis, outside)
if a != "ok" or b != "error:15":
fail("[%s] %s: a port jog to %.2f -> %s, to %.2f -> %s" % (what, axis, inside, a, outside, b))
def envelope_open(s):
return s.port.state()["envelope_open"]
def open_envelope(s, what):
s.quiet()
r = s.port.request("envelope open")
if r != "ok" or not envelope_open(s):
fail("[%s] envelope open after a home -> %r, state %r" % (what, r, s.port.state()))
def saw_within(s, needle, seconds=2.0):
end = time.time() + seconds
while not s.sender.saw(needle) and time.time() < end:
time.sleep(0.05)
return s.sender.saw(needle)
def test_measured(s):
conf(s, envelope_x_mm=480, envelope_y_mm=284.5)
home(s)
edge(s, "X", 479, 481, "measured")
edge(s, "Y", 284, 285, "measured")
print("PASS [measured]: envelope_x_mm 480 and envelope_y_mm 284.5 (past the travel) are the far edges at a home")
def test_open_apply(s):
conf(s, envelope_x_mm=480, envelope_y_mm=250)
home(s)
before = s.sender.saw("Envelope open for the bed check")
open_envelope(s, "open")
if saw_within(s, "Envelope open for the bed check") <= before:
fail("[open] the sender got no [MSG:] for the open envelope: %r" % s.sender.other[-4:])
port_edge(s, "X", TRAVEL_X + EXTRA - 1, TRAVEL_X + EXTRA + 1, "open")
port_edge(s, "Y", TRAVEL_Y + EXTRA - 1, TRAVEL_Y + EXTRA + 1, "open")
if not envelope_open(s):
fail("[open] the port's own jogs closed the envelope")
r = s.port.request("envelope apply")
if r != "ok" or envelope_open(s):
fail("[apply] envelope apply -> %r, state %r" % (r, s.port.state()))
edge(s, "X", 479, 481, "apply")
edge(s, "Y", 249, 251, "apply")
print("PASS [open-apply]: open, the port's jogs reach the travel plus 30 mm; apply, the keys' edges again, "
"no home")
def test_sender_closes(s):
conf(s, envelope_x_mm=480, envelope_y_mm=250)
home(s)
open_envelope(s, "sender")
s.sender.state() # '?' is realtime
s.sender.realtime(b"\n") # and an empty line no claim on the machine
time.sleep(0.5)
if not envelope_open(s):
fail("[sender] a status poll or an empty line closed the envelope")
before = s.sender.saw("Bed check envelope closed")
r = s.sender.send("$J=G90 G21 X%.3f F6000" % (TRAVEL_X + EXTRA - 1))
s.sender.wait_state("Idle")
if r != "error:15":
fail("[sender] the sender's jog into the open envelope's margin -> %r: the core read it before the "
"envelope closed" % r)
if envelope_open(s) or saw_within(s, "Bed check envelope closed") <= before:
fail("[sender] the sender's line did not close the envelope: %r" % s.port.state())
edge(s, "X", 479, 481, "sender")
# A port jog running when the sender's line arrives is canceled first.
open_envelope(s, "sender-jog")
x0 = s.port.state()["mpos"][0]
if s.port.request("jog G91 G21 X300 F6000") != "ok":
fail("[sender-jog] the long port jog was refused")
time.sleep(0.7)
# A jog line: the core holds a G-code line's parser error for the next
# one, and the edge above ended in error:15.
r = s.sender.send("$J=G90 G21 X20 F6000", timeout=10.0)
s.sender.wait_state("Idle")
st = s.port.state()
if r != "ok" or st["envelope_open"] or st["port_jog"] or abs(st["mpos"][0] - 20) > 0.01 or x0 > 20:
fail("[sender-jog] the sender's line during a port jog -> %r, state %r" % (r, st))
print("PASS [sender]: a status poll and an empty line leave the envelope open; the sender's first line "
"closes it before the core reads it, a running port jog canceled first")
def test_reset_closes(s):
conf(s, envelope_x_mm=480, envelope_y_mm=250)
home(s)
open_envelope(s, "reset")
s.sender.realtime(b"\x18")
time.sleep(1.0)
if envelope_open(s):
fail("[reset] a soft reset left the envelope open")
if s.sender.state() == "Alarm":
s.sender.send("$X")
s.sender.wait_state("Idle")
edge(s, "X", 479, 481, "reset")
open_envelope(s, "client")
s.port.close()
s.port = ct.PortClient(s.path)
if envelope_open(s):
fail("[client] the port client went away and the envelope stayed open")
print("PASS [reset]: a soft reset and the port client going away each closed the open envelope")
def test_not_homed():
s = ct.Session()
try:
conf(s)
s.quiet()
r = s.port.request("envelope open")
if r != "error:homed":
fail("[not-homed] envelope open before a home -> %r" % r)
print("PASS [not-homed]: envelope open before a home -> error:homed")
finally:
s.close()
def test_armed():
s = ct.Session(laser=True)
try:
conf(s)
home(s)
s.sender.send("G91")
s.sender.send("M3 S500")
s.sender.send("G1 X5 F1500")
s.sender.wait_state("Idle")
time.sleep(0.5)
if not s.sender.saw("laser armed"):
fail("[armed] the laser line did not arm: the case would prove nothing")
s.quiet()
r = s.port.request("envelope open")
if r != "busy:state":
fail("[armed] envelope open under an open armed window -> %r" % r)
print("PASS [armed]: envelope open under an open armed window -> busy:state")
finally:
s.close()
def main():
if not os.path.exists(ct.BIN):
fail("no controller binary at %s" % ct.BIN)
s = ct.Session()
try:
test_measured(s)
test_open_apply(s)
test_sender_closes(s)
test_reset_closes(s)
finally:
s.close()
test_not_homed()
test_armed()
print("ALL PASS")
if __name__ == "__main__":
main()