mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-27 08:41:13 -07:00
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:
+4
-1
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user