Lid open during the arm wait: harness cases, laser.arm-wait-lid, operator doc

laser_lifecycle_test drives the controller's button wait through the
file-backed switch source (GF_SWITCH_FILE): a press with the lid closed
arms and nothing arms before it; the lid or the interlock loop opening
during the wait cancels the job (reason reported, alarm 3, never armed).

Acceptance catalog: laser.arm-wait-lid (operator kind - no press is
given, so nothing can fire) starts a laser job, has the operator open
the lid at the white-button prompt, and checks the cancel message,
alarm 3, armed=false, the kernel latch locked, no emission, and Idle
after $X. covers names glowforge_laser.c, glowforge_switches.c and
glowforge_switch_map.h explicitly.

LIGHTBURN.md: opening the lid or the interlock loop while the button is
lit cancels the job; a press with the lid open never arms.
This commit is contained in:
ScottW514
2026-08-16 18:36:24 -04:00
parent b90c53c739
commit 36b4a3a5f4
3 changed files with 172 additions and 4 deletions
+4 -1
View File
@@ -32,7 +32,10 @@ The laser fires only inside an operator-armed window:
requires). LightBurn simply waits; press the button and the job requires). LightBurn simply waits; press the button and the job
runs. If nobody presses within `laser_button_timeout_s` (default runs. If nobody presses within `laser_button_timeout_s` (default
300 s) the job aborts with alarm 3. Stop in LightBurn (soft reset) 300 s) the job aborts with alarm 3. Stop in LightBurn (soft reset)
cancels the wait at any time. cancels the wait at any time. Opening the lid (or a Pro's interlock
loop) while the button is lit cancels the job the same way — the
message names the reason, the latch relocks, and a press with the lid
open never arms; close the lid, clear the alarm, and start again.
- One press covers one job — power changes and M5/M3 toggles do not - One press covers one job — power changes and M5/M3 toggles do not
re-prompt. The window relocks when the job ends (program end re-prompt. The window relocks when the job ends (program end
`M2`/`M30`), when the sender connection changes, or after `M2`/`M30`), when the sender connection changes, or after
+70
View File
@@ -424,3 +424,73 @@ def kill_mid_fire(ctx):
ctx.confirm("Did the cut end abruptly at the kill (a short line, no run-on), with the machine " ctx.confirm("Did the cut end abruptly at the kill (a short line, no run-on), with the machine "
"quiet and the button dark now?") "quiet and the button dark now?")
ctx.log("PASS: emission 0 at +%s s after SIGKILL, latch locked, controller respawned", zero_at) ctx.log("PASS: emission 0 at +%s s after SIGKILL, latch locked, controller respawned", zero_at)
@test("laser.arm-wait-lid", title="Lid open during the arm wait cancels the job",
subsystem="laser", kind="operator", est_min=3,
covers=_LASER_COVERS + [("grblhal-glowforge", "src/glowforge_laser.c"),
("grblhal-glowforge", "src/glowforge_switches.c"),
("grblhal-glowforge", "src/glowforge_switch_map.h")],
requires=["kernel.latch-locked-idle", "motion.jog-roundtrip"],
steps=["Lid closed; nothing under the head needs to be in place - the machine will not fire.",
"When the white button lights, do NOT press it: open the lid instead."],
description="Start a laser job so the controller unlocks the latch and lights the button, "
"then open the lid while it waits. The wait must abort with the lid named as the "
"reason and alarm 3, the armed window closed (armed -> false), the kernel latch "
"back to locked, and no emission; closing the lid and clearing the alarm returns "
"the controller to Idle. No press is given, so nothing can fire.")
def arm_wait_lid(ctx):
ev = ctx.evidence
with ctx.grbl() as g, LiveJob(ctx, g):
prepare(ctx, g)
base = sample(ctx)
ctx.check(base, "forgectrl /status or /cool/status unavailable")
ctx.check(not base["armed"], "armed window already open before the job")
stream(g, ["G91", "G21", "M4", "S400", "G1 X5 F600"])
# The prompt: the latch is unlocked and the button lit from here on.
t0 = time.time()
text = ""
while time.time() - t0 < 30 and "press the button" not in text:
ctx.checkpoint()
text += g.drain()
time.sleep(0.1)
ctx.check("press the button" in text, "no arm prompt within 30 s (job did not reach the arm)")
s = sample(ctx)
ev["armed_during_wait"] = s["armed"] if s else None
ctx.log("arm prompt seen; armed=%s; asking the operator to open the lid", ev["armed_during_wait"])
ctx.instruct("The button is lit white. Do NOT press it. Open the lid now, then click Done.")
t1 = time.time()
while time.time() - t1 < 15:
ctx.checkpoint()
text += g.drain()
if "job cancelled" in text and "ALARM:3" in text:
break
time.sleep(0.1)
ev["messages"] = [ln for ln in text.splitlines() if ln.startswith("[MSG:") or ln.startswith("ALARM")]
ctx.log("controller: %s", ev["messages"])
ctx.check("lid opened during arm - job cancelled" in text,
"the lid open was not reported as cancelling the arm")
ctx.check("ALARM:3" in text, "no alarm 3 after the lid-open cancel")
# Armed window closed and the kernel latch locked.
t2 = time.time()
s = None
while time.time() - t2 < 10:
s = sample(ctx)
if s and not s["armed"]:
break
time.sleep(0.25)
ev["armed_after"] = s["armed"] if s else None
ilk = hw.sysfs_int("cnc/interlock_circuit")
locked = ilk is not None and bool(ilk & (1 << 3))
ev["latch_locked"] = locked
ev["emission"] = s["emission"] if s else None
ctx.check(s and not s["armed"], "the armed window stayed open after the lid-open cancel")
ctx.check(locked, "kernel latch not locked after the lid-open cancel (interlock_circuit=%s)", ilk)
ctx.check(not ev["emission"], "emission_samples nonzero (%s) - nothing may have fired", ev["emission"])
ctx.instruct("Close the lid, then click Done.")
ctx.sleep(1)
ctx.log("unlock: %s", g.command("$X"))
st = g.status_report()["state"]
ev["state_after"] = st
ctx.check(st.startswith("Idle"), "controller is %s after $X, expected Idle", st)
ctx.log("PASS: lid open during the arm wait cancelled the job (alarm 3), armed=false, latch locked")
+98 -3
View File
@@ -16,10 +16,15 @@ reported messages:
job abandoned in feed hold disarms after laser_disarm_s job abandoned in feed hold disarms after laser_disarm_s
5. arming is refused while the cooling verdict blocks fire, and no 5. arming is refused while the cooling verdict blocks fire, and no
armed message appears armed message appears
6. with a switch source present (GF_SWITCH_FILE), the arm blocks on the
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)
The disarm grace is shortened via a temp config (GFHOME_CONF) and the The disarm grace is shortened via a temp config (GFHOME_CONF), the
cooling verdict is published hermetically (GF_VERDICT_FILE), the same cooling verdict is published hermetically (GF_VERDICT_FILE), the same
overrides the emission harness uses. overrides the emission harness uses, and the switches are driven through
the file-backed test source (GF_SWITCH_FILE: the EV_SW word as an integer).
Usage: laser_lifecycle_test.py [path-to-binary] Usage: laser_lifecycle_test.py [path-to-binary]
(default ./build-native/grblHAL_glowforge) (default ./build-native/grblHAL_glowforge)
@@ -41,6 +46,16 @@ PORT = 2398
ARMED = "laser armed" ARMED = "laser armed"
DISARMED = "laser disarmed - latch locked" DISARMED = "laser disarmed - latch locked"
BLOCKED = "laser fire blocked" BLOCKED = "laser fire blocked"
PROMPT = "press the button to start the laser job"
LID_CANCEL = "lid opened during arm - job cancelled"
LOOP_CANCEL = "interlock open during arm - job cancelled"
# EV_SW words for the file-backed switch source: bit 2 button, bit 3 doors
# (set = closed), bit 5 interlock loop (set = OPEN).
SW_CLOSED = 1 << 3
SW_PRESSED = SW_CLOSED | (1 << 2)
SW_LID_OPEN = 0
SW_LOOP_OPEN = SW_CLOSED | (1 << 5)
def fail(msg): def fail(msg):
@@ -122,7 +137,7 @@ def publish_verdicts(path, stop, fire_ok):
class Session: class Session:
"""One controller process with the lifecycle overrides applied.""" """One controller process with the lifecycle overrides applied."""
def __init__(self, name, fire_ok=True, disarm_s=2): def __init__(self, name, fire_ok=True, disarm_s=2, switches=None):
self.name = name self.name = name
self.workdir = tempfile.mkdtemp(prefix="laser-lifecycle-") self.workdir = tempfile.mkdtemp(prefix="laser-lifecycle-")
conf = os.path.join(self.workdir, "forgefirm.conf") conf = os.path.join(self.workdir, "forgefirm.conf")
@@ -132,6 +147,12 @@ class Session:
env = dict(os.environ, GF_VERDICT_FILE=verdict, GFHOME_CONF=conf, env = dict(os.environ, GF_VERDICT_FILE=verdict, GFHOME_CONF=conf,
FFLOG_STDERR="1") FFLOG_STDERR="1")
env.pop("GFSINK", None) env.pop("GFSINK", None)
env.pop("GF_SWITCH_FILE", None)
self.switch_file = None
if switches is not None:
self.switch_file = os.path.join(self.workdir, "switches")
self.set_switches(switches)
env["GF_SWITCH_FILE"] = self.switch_file
self.stop = threading.Event() self.stop = threading.Event()
self.pub = threading.Thread(target=publish_verdicts, self.pub = threading.Thread(target=publish_verdicts,
args=(verdict, self.stop, fire_ok), args=(verdict, self.stop, fire_ok),
@@ -158,6 +179,17 @@ class Session:
fail("[%s] cannot connect to the controller (exit=%s)\n%s" fail("[%s] cannot connect to the controller (exit=%s)\n%s"
% (self.name, self.proc.poll(), err.decode(errors="replace"))) % (self.name, self.proc.poll(), err.decode(errors="replace")))
def set_switches(self, word):
tmp = self.switch_file + ".tmp"
with open(tmp, "w") as f:
f.write("%d\n" % word)
os.replace(tmp, self.switch_file)
def send_raw(self, line):
"""Send a line without waiting for ok/error (the arm wait blocks
the gcode stream, so the ok only comes once the button is pressed)."""
self.sock.sendall((line + "\n").encode())
def armed_count(self): def armed_count(self):
return "".join(self.log).count(ARMED) return "".join(self.log).count(ARMED)
@@ -322,12 +354,75 @@ def test_sigterm_mid_job():
s.close() s.close()
def test_button_wait_arms():
"""Rule 6a: with a switch source the arm blocks on the button; a press
with the lid closed arms; the window is not opened before it."""
s = Session("button-wait", disarm_s=60, switches=SW_CLOSED)
try:
s.send_raw("M4 S100")
s.send_raw("G1 X1 F600")
if not wait_for(s.log, PROMPT, 5, s.sock):
fail("[button-wait] no button prompt with a switch source present")
read_avail(s.sock, s.log, 1.0)
if ARMED in "".join(s.log):
fail("[button-wait] armed before the button was pressed")
s.set_switches(SW_PRESSED)
if not wait_for(s.log, ARMED, 5, s.sock):
fail("[button-wait] the button press did not arm")
s.set_switches(SW_CLOSED)
wait_idle(s.sock, s.log)
print("PASS [button-wait]: the arm blocked on the button and the press armed")
finally:
s.close()
def test_lid_open_in_wait():
"""Rule 6b: the lid opening during the button wait cancels the job:
the reason is reported, an alarm is raised, the window never opens.
A press with the lid still open must not arm either."""
s = Session("lid-in-wait", disarm_s=60, switches=SW_CLOSED)
try:
s.send_raw("M4 S100")
s.send_raw("G1 X1 F600")
if not wait_for(s.log, PROMPT, 5, s.sock):
fail("[lid-in-wait] no button prompt")
s.set_switches(SW_LID_OPEN)
if not wait_for(s.log, LID_CANCEL, 5, s.sock):
fail("[lid-in-wait] lid open during the wait was not reported as a cancel")
if not wait_for(s.log, "ALARM:3", 5, s.sock):
fail("[lid-in-wait] no alarm after the lid-open cancel")
# Press with the lid still open: nothing may arm.
s.set_switches(1 << 2)
read_avail(s.sock, s.log, 1.0)
if ARMED in "".join(s.log):
fail("[lid-in-wait] armed after a lid-open cancel")
# Interlock variant on a fresh session, so the alarm state does not
# mask the check.
s.close()
s = Session("loop-in-wait", disarm_s=60, switches=SW_CLOSED)
s.send_raw("M4 S100")
s.send_raw("G1 X1 F600")
if not wait_for(s.log, PROMPT, 5, s.sock):
fail("[loop-in-wait] no button prompt")
s.set_switches(SW_LOOP_OPEN)
if not wait_for(s.log, LOOP_CANCEL, 5, s.sock):
fail("[loop-in-wait] interlock open during the wait was not reported as a cancel")
if ARMED in "".join(s.log):
fail("[loop-in-wait] armed despite the open interlock loop")
print("PASS [lid-in-wait]: lid or interlock open during the arm wait "
"cancelled the job and never armed")
finally:
s.close()
def main(): def main():
if not os.path.isfile(BIN): if not os.path.isfile(BIN):
fail("controller binary not found at %s" % BIN) fail("controller binary not found at %s" % BIN)
test_job_window() test_job_window()
test_sender_change() test_sender_change()
test_hold_grace() test_hold_grace()
test_button_wait_arms()
test_lid_open_in_wait()
test_verdict_blocks_arm() test_verdict_blocks_arm()
test_sigterm_mid_job() test_sigterm_mid_job()
print("PASS: the armed-window lifecycle holds") print("PASS: the armed-window lifecycle holds")