diff --git a/docs/LIGHTBURN.md b/docs/LIGHTBURN.md index 172df47..1b5f4ae 100644 --- a/docs/LIGHTBURN.md +++ b/docs/LIGHTBURN.md @@ -32,7 +32,10 @@ The laser fires only inside an operator-armed window: requires). LightBurn simply waits; press the button and the job runs. If nobody presses within `laser_button_timeout_s` (default 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 re-prompt. The window relocks when the job ends (program end `M2`/`M30`), when the sender connection changes, or after diff --git a/forgetest/forgetest/suite/laser.py b/forgetest/forgetest/suite/laser.py index 4299e48..b76dc95 100644 --- a/forgetest/forgetest/suite/laser.py +++ b/forgetest/forgetest/suite/laser.py @@ -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 " "quiet and the button dark now?") 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") diff --git a/scripts/bench/laser_lifecycle_test.py b/scripts/bench/laser_lifecycle_test.py index 5c02a03..feee98a 100644 --- a/scripts/bench/laser_lifecycle_test.py +++ b/scripts/bench/laser_lifecycle_test.py @@ -16,10 +16,15 @@ reported messages: job abandoned in feed hold disarms after laser_disarm_s 5. arming is refused while the cooling verdict blocks fire, and no 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 -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] (default ./build-native/grblHAL_glowforge) @@ -41,6 +46,16 @@ PORT = 2398 ARMED = "laser armed" DISARMED = "laser disarmed - latch locked" 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): @@ -122,7 +137,7 @@ 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): + def __init__(self, name, fire_ok=True, disarm_s=2, switches=None): self.name = name self.workdir = tempfile.mkdtemp(prefix="laser-lifecycle-") 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, FFLOG_STDERR="1") 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.pub = threading.Thread(target=publish_verdicts, args=(verdict, self.stop, fire_ok), @@ -158,6 +179,17 @@ class Session: fail("[%s] cannot connect to the controller (exit=%s)\n%s" % (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): return "".join(self.log).count(ARMED) @@ -322,12 +354,75 @@ def test_sigterm_mid_job(): 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(): if not os.path.isfile(BIN): fail("controller binary not found at %s" % BIN) test_job_window() test_sender_change() test_hold_grace() + test_button_wait_arms() + test_lid_open_in_wait() test_verdict_blocks_arm() test_sigterm_mid_job() print("PASS: the armed-window lifecycle holds")