Lid/button parity: harness cases, acceptance tests, operator and safety docs, BRINGUP item 16

laser_lifecycle_test.py: the button toggle (press = Hold, press = Run;
the arming press is not a pause), lid and interlock cancel mid-job with
the return to the job start and no alarm, and lid_policy=hold.

Acceptance catalog: motion.button-hold-resume, motion.lid-cancel-home
(operator: travel job, lid open -> cancel message, banner, no alarm,
autonomous return, Idle at the start), laser.lid-cancel-mid-fire (live:
emission stops in hardware, cancelled, returned, armed=false, latch
locked, hardware button latch SET). covers name the switch and laser
sources explicitly.

LIGHTBURN.md: what the lid, Stop and the button now do; SAFETY.md: the
door policy and the button as a software layer; BRINGUP.md: item 16
records the whole parity change (host-proven, bench validation pending)
and items 4/12 point at it.
This commit is contained in:
ScottW514
2026-08-16 19:26:11 -04:00
parent c255b265c5
commit c4ea96127c
6 changed files with 474 additions and 22 deletions
+158 -2
View File
@@ -20,6 +20,14 @@ reported messages:
button: a press with the lid closed arms, and the lid or the
interlock loop opening during the wait cancels the job (alarm,
latch relocked, never armed)
7. outside the arm wait the button is the pause/resume toggle: a press
while running feed-holds, a press while held resumes; the arming
press itself is not a pause press
8. the lid or the interlock loop opening mid-job cancels the job (the
default lid_policy): reason reported, armed window closed, a soft
reset with the position kept (no alarm), the head returns to the
job start on its own; with lid_policy = hold the stock door hold and
cycle-start resume apply
The disarm grace is shortened via a temp config (GFHOME_CONF), the
cooling verdict is published hermetically (GF_VERDICT_FILE), the same
@@ -137,12 +145,12 @@ def publish_verdicts(path, stop, fire_ok):
class Session:
"""One controller process with the lifecycle overrides applied."""
def __init__(self, name, fire_ok=True, disarm_s=2, switches=None):
def __init__(self, name, fire_ok=True, disarm_s=2, switches=None, conf_extra=""):
self.name = name
self.workdir = tempfile.mkdtemp(prefix="laser-lifecycle-")
conf = os.path.join(self.workdir, "forgefirm.conf")
with open(conf, "w") as f:
f.write("laser_disarm_s = %d\n" % disarm_s)
f.write("laser_disarm_s = %d\n%s" % (disarm_s, conf_extra))
verdict = os.path.join(self.workdir, "cooling.state")
env = dict(os.environ, GF_VERDICT_FILE=verdict, GFHOME_CONF=conf,
FFLOG_STDERR="1")
@@ -190,6 +198,42 @@ class Session:
the gcode stream, so the ok only comes once the button is pressed)."""
self.sock.sendall((line + "\n").encode())
def press_button(self, hold_s=0.15):
"""A momentary press: word with the button bit, then without."""
base = SW_CLOSED if self.switch_word_closed() else SW_LID_OPEN
self.set_switches(base | (1 << 2))
time.sleep(hold_s)
self.set_switches(base)
def switch_word_closed(self):
try:
with open(self.switch_file) as f:
return int(f.read().strip() or "0", 0) & SW_CLOSED
except (OSError, ValueError):
return True
def state(self):
"""One '?' report's state word (e.g. 'Run', 'Hold:0', 'Door:1', 'Idle')."""
self.sock.sendall(b"?")
read_avail(self.sock, self.log, 0.3)
m = re.findall(r"<([A-Za-z]+(?::\d)?)", "".join(self.log[-4:]))
return m[-1] if m else ""
def wait_state(self, prefix, timeout):
end = time.time() + timeout
while time.time() < end:
st = self.state()
if st.startswith(prefix):
return st
time.sleep(0.1)
return None
def mpos_x(self):
self.sock.sendall(b"?")
read_avail(self.sock, self.log, 0.3)
m = re.findall(r"MPos:(-?[\d.]+)", "".join(self.log[-4:]))
return float(m[-1]) if m else None
def armed_count(self):
return "".join(self.log).count(ARMED)
@@ -415,6 +459,115 @@ def test_lid_open_in_wait():
s.close()
def start_armed_move(s, tag, gcode="G1 X30 F60"):
"""Arm through the button and get a long move under way; returns once
the controller reports Run."""
s.send_raw("M4 S100")
s.send_raw(gcode)
if not wait_for(s.log, PROMPT, 5, s.sock):
fail("[%s] no button prompt" % tag)
s.press_button()
if not wait_for(s.log, ARMED, 5, s.sock):
fail("[%s] the button press did not arm" % tag)
if not s.wait_state("Run", 5):
fail("[%s] the job never reported Run" % tag)
def test_button_pause_resume():
"""Rule 7: outside the arm wait the button is the pause/resume toggle -
a press while running is a feed hold, a press while held is a cycle
start; the arming press itself is not a pause press."""
s = Session("button-toggle", disarm_s=60, switches=SW_CLOSED)
try:
start_armed_move(s, "button-toggle")
time.sleep(0.5)
if s.state().startswith("Hold"):
fail("[button-toggle] the arming press was taken as a pause press")
s.press_button()
if not s.wait_state("Hold", 3):
fail("[button-toggle] a press while running did not feed-hold (state %s)" % s.state())
if not wait_for(s.log, "button pressed - job paused", 2, s.sock):
fail("[button-toggle] no pause message")
s.press_button()
if not s.wait_state("Run", 3):
fail("[button-toggle] a press while held did not resume (state %s)" % s.state())
if not wait_for(s.log, "button pressed - job resumed", 2, s.sock):
fail("[button-toggle] no resume message")
s.sock.sendall(b"\x18")
print("PASS [button-toggle]: press paused (Hold), press resumed (Run); the arming press did not")
finally:
s.close()
def test_lid_cancels_and_returns():
"""Rule 8 (lid_policy = cancel, the default): the lid opening mid-job
parks the job and cancels it - reason reported, armed window closed,
a soft reset (no alarm: position kept), then the head returns on its
own to the job start; the sender sees the reset banner. The interlock
loop is the same event with its own reason."""
s = Session("lid-cancel", disarm_s=60, switches=SW_CLOSED)
try:
start_armed_move(s, "lid-cancel", gcode="G1 X20 F600")
time.sleep(0.5)
x_mid = s.mpos_x()
s.set_switches(SW_LID_OPEN)
if not wait_for(s.log, "lid opened - job cancelled", 5, s.sock):
fail("[lid-cancel] the lid open did not cancel the job")
if not wait_for(s.log, DISARMED, 5, s.sock):
fail("[lid-cancel] the cancel did not close the armed window")
if not wait_for(s.log, "for help]", 5, s.sock):
fail("[lid-cancel] no reset banner after the cancel")
if not wait_for(s.log, "returned to the job start", 15, s.sock):
fail("[lid-cancel] the head did not report returning to the job start")
st = s.wait_state("Idle", 5)
if not st:
fail("[lid-cancel] not Idle after the return (state %s)" % s.state())
if "ALARM" in "".join(s.log):
fail("[lid-cancel] an alarm was raised on the cancel (position should be kept)")
x_end = s.mpos_x()
if x_end is None or abs(x_end) > 0.05:
fail("[lid-cancel] head not back at the job start: MPos X=%s (was %s mid-job)" % (x_end, x_mid))
print("PASS [lid-cancel]: lid open mid-job -> cancelled, disarmed, reset banner, "
"returned to X=%.3f (mid-job X=%.3f), Idle, no alarm" % (x_end, x_mid))
s.close()
# Interlock variant.
s = Session("loop-cancel", disarm_s=60, switches=SW_CLOSED)
start_armed_move(s, "loop-cancel", gcode="G1 X20 F600")
time.sleep(0.5)
s.set_switches(SW_LOOP_OPEN)
if not wait_for(s.log, "interlock open - job cancelled", 5, s.sock):
fail("[loop-cancel] the interlock open did not cancel the job")
if not wait_for(s.log, "returned to the job start", 15, s.sock):
fail("[loop-cancel] no return to the job start after the interlock cancel")
print("PASS [loop-cancel]: interlock open mid-job -> cancelled and returned")
finally:
s.close()
def test_lid_policy_hold():
"""lid_policy = hold keeps stock grblHAL behavior: the lid parks the job
in Door and a cycle start resumes it once closed."""
s = Session("lid-hold", disarm_s=60, switches=SW_CLOSED, conf_extra="lid_policy = hold\n")
try:
start_armed_move(s, "lid-hold", gcode="G1 X20 F60")
time.sleep(0.5)
s.set_switches(SW_LID_OPEN)
if not s.wait_state("Door", 3):
fail("[lid-hold] the lid open did not park the job in Door (state %s)" % s.state())
read_avail(s.sock, s.log, 1.0)
if "job cancelled" in "".join(s.log):
fail("[lid-hold] the hold policy cancelled the job")
s.set_switches(SW_CLOSED)
time.sleep(0.5)
s.sock.sendall(b"~")
if not s.wait_state("Run", 3):
fail("[lid-hold] cycle start did not resume the held job (state %s)" % s.state())
s.sock.sendall(b"\x18")
print("PASS [lid-hold]: with lid_policy=hold the lid parked the job in Door and ~ resumed it")
finally:
s.close()
def main():
if not os.path.isfile(BIN):
fail("controller binary not found at %s" % BIN)
@@ -423,6 +576,9 @@ def main():
test_hold_grace()
test_button_wait_arms()
test_lid_open_in_wait()
test_button_pause_resume()
test_lid_cancels_and_returns()
test_lid_policy_hold()
test_verdict_blocks_arm()
test_sigterm_mid_job()
print("PASS: the armed-window lifecycle holds")