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
+91
View File
@@ -494,3 +494,94 @@ def arm_wait_lid(ctx):
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")
@test("laser.lid-cancel-mid-fire", title="Lid open mid-burn: beam off in hardware, job cancelled, head home",
subsystem="laser", kind="live", est_min=5,
covers=_LASER_COVERS + [("grblhal-glowforge", "src/glowforge_switches.c"),
("grblhal-glowforge", "src/glowforge_switch_map.h")],
requires=["laser.emission-witness", "motion.lid-cancel-home"],
steps=["Scrap under the head with 40 mm of free +X and +Y travel; lid closed; exhaust on.",
"Press the physical button when it lights white; open the lid once the burn is under way "
"and leave it open until the head has come back."],
description="Start the S400 square; once emission is live, open the lid. Emission stops in "
"hardware, the job parks and is cancelled (reason reported, controller reset "
"with the position kept), the armed window closes and the kernel latch relocks, "
"the hardware button latch reads SET, and the head returns to the job start "
"with the lid still open.")
def lid_cancel_mid_fire(ctx):
ev = ctx.evidence
with ctx.grbl() as g, LiveJob(ctx, g):
prepare(ctx, g)
start = g.status_report()["MPos"]
ctx.instruct(ARM_CUE % "40 mm +X and +Y")
stream(g, ["G91", "G21", "M4", "S400",
"G1 X40 F200", "G1 Y40 F200", "G1 X-40 F200", "G1 Y-40 F200",
"M5", "G90", "M2"])
t0 = time.time()
smp = None
seen = False
while time.time() - t0 < 240:
ctx.checkpoint()
smp = sample(ctx)
if smp and smp["emission"] and smp["emission"] > 0:
seen = True
break
time.sleep(0.15)
if not seen:
g.realtime(0x18)
raise Failed("no emission seen within 240 s (arm refused, or no button press)")
ctx.log("emission live (%s) - asking the operator to open the lid", smp["emission"])
ctx.instruct("The laser is cutting. Open the lid NOW and leave it open, then click Done.")
t_lid = time.time()
trail = []
text = ""
while time.time() - t_lid < 8:
s = sample(ctx)
if s:
trail.append((round(time.time() - t_lid, 2), s["emission"], s["kstate"], s["armed"]))
text += g.drain()
time.sleep(0.12)
for t in trail:
ctx.log(" post-lid %s", t)
ev["messages"] = [ln for ln in text.splitlines() if ln.startswith("[MSG:") or "help]" in ln
or ln.startswith("ALARM")]
ctx.log("controller: %s", ev["messages"])
zero_at = next((t for t, e, _, _ in trail if e == 0), None)
tail_zero = all(e == 0 for _, e, _, _ in trail[-16:])
ev.update({"zero_at_s": zero_at, "tail_zero": tail_zero})
ctx.check(zero_at is not None and zero_at < 3.0, "emission did not stop after the lid opened (first 0 at %s)", zero_at)
ctx.check(tail_zero, "emission returned after the lid opened")
ctx.check("lid opened - job cancelled" in text, "the lid open was not reported as cancelling the job")
ctx.check("help]" in text, "no reset banner after the cancel")
ctx.check("ALARM" not in text, "an alarm was raised on the cancel (position should be kept)")
# the head returns to the job start on its own
t1 = time.time()
returned = "returned to the job start" in text
while not returned and time.time() - t1 < 30:
ctx.checkpoint()
text += g.drain()
returned = "returned to the job start" in text
time.sleep(0.2)
ev["returned_message"] = returned
ctx.check(returned, "the head did not report returning to the job start")
st = g.status_report()
drift = max(abs(st["MPos"][i] - start[i]) for i in range(2))
ev["drift_mm"] = round(drift, 3)
s = sample(ctx)
ev["armed_after"] = s["armed"] if s else None
ilk = hw.sysfs_int("cnc/interlock_circuit")
ev["latch_locked"] = ilk is not None and bool(ilk & (1 << 3))
ev["button_latch"] = hw.sysfs_int("cnc/button_latch")
ctx.log("returned: drift %.3f mm; armed=%s latch_locked=%s button_latch=%s", drift,
ev["armed_after"], ev["latch_locked"], ev["button_latch"])
ctx.check(drift <= 0.05, "head not back at the job start (drift %.3f mm)", drift)
ctx.check(not ev["armed_after"], "armed window still open after the cancel")
ctx.check(ev["latch_locked"], "kernel latch not locked after the cancel")
ctx.check(ev["button_latch"] == 1, "hardware button latch not SET after the lid open (%s)", ev["button_latch"])
ctx.confirm("Did the burn stop the instant the lid opened, and did the head then go straight back "
"to where the job started with the lid still open, dark?")
ctx.instruct("Close the lid, then click Done.")
ctx.sleep(1)
ctx.log("PASS: lid open mid-burn -> emission 0 at +%s s, cancelled, reset without alarm, returned (drift %.3f mm), "
"button latch SET", zero_at, drift)
+127
View File
@@ -550,3 +550,130 @@ def deadman(ctx):
machine_idle(ctx)
ctx.log("PASS: kill respawned in %s s, hang -> underrun in %s s, restart retook supervision (pid %s)",
respawn_s, halt_s, m3.get("pid"))
# ------------------------------------------------- lid / button (the factory's)
_LID_COVERS = _MOTION_COVERS + [("grblhal-glowforge", "src/glowforge_switches.c"),
("grblhal-glowforge", "src/glowforge_switch_map.h"),
("grblhal-glowforge", "src/glowforge_laser.c")]
def drain_text(g, seconds):
"""Everything the controller said in the next `seconds`."""
end = time.time() + seconds
text = ""
while time.time() < end:
text += g.drain()
time.sleep(0.1)
return text
@test("motion.button-hold-resume", title="The button pauses and resumes a job",
subsystem="motion", kind="operator", est_min=2,
covers=_LID_COVERS, requires=["motion.pacing"],
steps=["Bed clear; the head needs 40 mm of free +X travel. No laser is involved.",
"Press the button once when told (pause), and once more when told (resume)."],
description="A travel job is running; one press of the big button feed-holds it (the sender "
"sees Hold), the next press resumes it (Run) and the move completes with its "
"position intact - the factory's pause/resume on the machine.")
def button_hold_resume(ctx):
ev = ctx.evidence
with ctx.grbl() as g:
clean_slate(ctx, g)
start = g.status_report()["MPos"]
g.command("M5")
g.command("G91")
g.command("G1X40F300", timeout=0.5) # an 8 s move
ctx.sleep(0.5)
ctx.check(g.status_report()["state"].startswith("Run"), "the move did not start")
ctx.instruct("The head is moving. Press the button once now, then click Done.")
st = wait_state(ctx, g, "Hold", 8)
ctx.check(st is not None, "the press did not hold the job (state %s)", g.status_report()["state"])
text = drain_text(g, 0.5)
ev["held_state"] = st["state"]
ev["pause_message"] = "job paused" in text
ctx.log("held: %s; message seen: %s", st["state"], ev["pause_message"])
ctx.instruct("The head is stopped. Press the button once more now, then click Done.")
st = wait_state(ctx, g, "Run", 8)
ctx.check(st is not None, "the second press did not resume the job (state %s)",
g.status_report()["state"])
text = drain_text(g, 0.5)
ev["resume_message"] = "job resumed" in text
peak, states, st = wait_idle(ctx, g, 40)
ctx.check("TIMEOUT" not in states, "the resumed move did not complete: %s", states)
moved = st["MPos"][0] - start[0]
ev["moved_mm"] = round(moved, 3)
ctx.log("move completed after pause/resume: %.3f mm of 40", moved)
ctx.check(abs(moved - 40.0) <= 0.05, "the resumed move did not land on its target (%.3f mm)", moved)
g.command("$J=G91X-40F2400")
wait_idle(ctx, g, 30)
g.command("G90")
machine_idle(ctx)
ctx.log("PASS: button press held the job (%s), the next press resumed it, target reached", ev["held_state"])
@test("motion.lid-cancel-home", title="Lid open during a job cancels it and returns to the job start",
subsystem="motion", kind="operator", est_min=3,
covers=_LID_COVERS, requires=["motion.pacing", "motion.cancel-abort"],
steps=["Bed clear; the head needs 40 mm of free +X travel. No laser is involved.",
"Open the lid when told, and leave it open until the head has come back."],
description="A travel job is running when the lid opens: the job parks (planned deceleration), "
"the reason is reported, the controller resets (position kept, no alarm - the "
"sender's job is over), and the head returns on its own to where the job started "
"with the lid still open; the controller ends Idle at the start position. With "
"lid_policy=cancel (the default).")
def lid_cancel_home(ctx):
ev = ctx.evidence
policy = (ctx.forgectrl.settings() or {}).get("lid_policy") or "cancel"
ev["lid_policy"] = policy
ctx.check(policy == "cancel", "lid_policy is %r; this test needs cancel", policy)
with ctx.grbl() as g:
clean_slate(ctx, g)
start = g.status_report()["MPos"]
ev["start"] = start
g.command("M5")
g.command("G91")
g.command("G1X40F300", timeout=0.5) # an 8 s move
ctx.sleep(0.5)
ctx.check(g.status_report()["state"].startswith("Run"), "the move did not start")
ctx.instruct("The head is moving. Open the lid NOW and leave it open, then click Done.")
text = drain_text(g, 3.0)
ev["messages"] = [ln for ln in text.splitlines() if ln.startswith("[MSG:") or "help]" in ln
or ln.startswith("ALARM")]
ctx.log("controller: %s", ev["messages"])
ctx.check("lid opened - job cancelled" in text, "the lid open was not reported as cancelling the job")
ctx.check("help]" in text, "no reset banner after the cancel (the sender must see the job end)")
ctx.check("ALARM" not in text, "an alarm was raised on the cancel (position should be kept)")
# the head returns on its own; wait for it to report back
t0 = time.time()
returned = False
while time.time() - t0 < 30:
ctx.checkpoint()
text += g.drain()
if "returned to the job start" in text:
returned = True
break
time.sleep(0.2)
ev["returned_message"] = returned
ctx.check(returned, "the head did not report returning to the job start within 30 s")
st = wait_state(ctx, g, "Idle", 5)
ctx.check(st is not None, "not Idle after the return (state %s)", g.status_report()["state"])
drift = abs(st["MPos"][0] - start[0])
ev["drift_mm"] = round(drift, 3)
ctx.log("back at the job start: drift %.3f mm (lid still open)", drift)
ctx.check(drift <= 0.05, "head not back at the job start (drift %.3f mm)", drift)
sw = (ctx.forgectrl.status().get("switches") or {})
ev["lid_at_return"] = sw.get("lid")
ctx.instruct("Close the lid, then click Done.")
ctx.sleep(1)
# a jog afterward proves the controller is usable without $X
r = g.command("$J=G91X5F1200")
ctx.check(not any(x.startswith("error") for x in r), "jog refused after the cancel: %s", r)
wait_idle(ctx, g, 15)
g.command("$J=G91X-5F1200")
wait_idle(ctx, g, 15)
g.command("G90")
machine_idle(ctx)
ctx.log("PASS: lid open cancelled the job, reset without alarm, head returned to the start (drift %.3f mm)",
ev["drift_mm"])