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
+56 -1
View File
@@ -2386,7 +2386,10 @@ dev image (the confirmation campaign's image).**
approaches are near-silent** — belt compliance turns slow-speed approaches are near-silent** — belt compliance turns slow-speed
skipping into sub-threshold grinding — so any contact-sensing skipping into sub-threshold grinding — so any contact-sensing
scheme must strike fast. scheme must strike fast.
4. **Controller safety mapping — IMPLEMENTED 2026-08-13, bench validation 4. **Controller safety mapping — IMPLEMENTED 2026-08-13; the mid-job
Door hold described here is superseded by the factory-parity policy of
item 16 (lid = cancel + return to the job start; Door hold only with
`lid_policy = hold`) — bench validation
pending** (`grblHAL-glowforge/src/glowforge_switches.c`). The pending** (`grblHAL-glowforge/src/glowforge_switches.c`). The
controller reads EV_SW with `EVIOCGSW` from the protocol thread's controller reads EV_SW with `EVIOCGSW` from the protocol thread's
realtime hook (no grab — forgectrl polls the same device) and maps: realtime hook (no grab — forgectrl polls the same device) and maps:
@@ -2802,6 +2805,10 @@ dev image (the confirmation campaign's image).**
Resume path, Start-with-lid-open, or the sender's own handling of the Resume path, Start-with-lid-open, or the sender's own handling of the
`Door` state, and what the controller reports at each step. Until `Door` state, and what the controller reports at each step. Until
then the door change stands as partially validated (item 4). then the door change stands as partially validated (item 4).
**2026-08-16:** the default mid-job lid path is now the factory cancel
(item 16), so LightBurn no longer lives in `Door` at all; retest the
symptoms with the item-16 tests, and only chase what remains under
`lid_policy = hold`.
13. **uSDHC pad strength brought to the factory values (DTS change 13. **uSDHC pad strength brought to the factory values (DTS change
2026-08-15, bench validation pending — ships with the next full image 2026-08-15, bench validation pending — ships with the next full image
flash, per the batched kernel/BSP rule).** Trigger: one flash, per the batched kernel/BSP rule).** Trigger: one
@@ -3001,3 +3008,51 @@ dev image (the confirmation campaign's image).**
the first release runs the full campaign and commits the first release runs the full campaign and commits
`releases/v<version>/acceptance.json` - **not yet: no release is `releases/v<version>/acceptance.json` - **not yet: no release is
cut.** cut.**
16. **Lid / button / interlock parity with the factory firmware — CODE-COMPLETE
and host-verified 2026-08-16, bench validation pending.** Both controller
modes now react to the lid, the interlock loop and the button the way the
factory daemon does (its behavior was decoded and then observed on the
bench machine booted into factory 2.6.0-2228 the same day: lid open
mid-print → `cnc/stop` 5 ms after the edge, immediate return to the job
start with the lid still open, `:cancelled`; app cancel the same path;
button → pause with a 2000-tick laser-off backtrack, resume with a
1950-tick laser-off lead; lid while paused → cancel + park).
- **GRBL mode** (`grblHAL-glowforge/src/glowforge_switches.c`,
`glowforge_laser.c`): the arm wait aborts on lid or interlock (relock,
alarm 3, reason reported; a press with the lid open never arms); the
button is the pause/resume toggle outside the arm wait (feed hold /
cycle start; the arming press is consumed and never a pause press);
lid or interlock mid-job → the core parks the job (planned decel) and
the driver cancels it — armed window closed, reason reported, soft
reset from the parked state (position kept, no alarm; the sender sees
the banner), then a driver-enqueued `G53 G0` back to the position the
job started from with the door hidden and the latch locked; the
`lid_policy` setting (`cancel` default / `hold` = stock door hold)
selects it. Job start = machine position at the Idle → Cycle
transition. Test hook: `GF_SWITCH_FILE` (file-backed EV_SW word for
null-sink builds).
- **Cloud mode** (`python3-gfhardware/gfhardware/machine.py`,
`Glowforge-Utilities` basemachine): interlock joins the lid in every
gate; the switch thread wakes the run loop on the edge (stop within
milliseconds, level read as backstop); the park ignores the lid and
the cancel flag; a hunt ignores the lid; a job refused at start ends
`:cancelled`; the button pauses/resumes a print exactly as the factory
(kernel `resume -2000` / `resume 1950`, `print:paused` / `print:resumed`;
`cloud_pause_backtrack_ticks` / `cloud_resume_lead_ticks` settings);
hunt honors the cancel flag; every job's terminal event is logged.
- **Proof so far (host):** `laser_arm_test` (17 new checks),
`laser_lifecycle_test.py` (button-wait, lid/interlock in the wait,
button toggle, lid/interlock cancel + return to X=0 without alarm,
`lid_policy=hold`), `python3-gfhardware/tests/test_machine_lid_button.py`
(22 cases), gfutilities tests (58), forgetest unit + coverage lint;
forgectrl builds clean with the three new settings and panel cards.
- **Bench validation pending (acceptance catalog):** `laser.arm-wait-lid`,
`motion.button-hold-resume`, `motion.lid-cancel-home`,
`laser.lid-cancel-mid-fire` (live), `cloud.lid-abort` (live),
`cloud.lid-during-button-wait`, `cloud.hunt-lid-open`,
`cloud.pause-resume` (live). Items 4 and 12 above are superseded by
this policy (the mid-job Door hold is no longer the default path);
close them with these tests. Still to observe once on the bench: the
~90 ms HV_ENABLE re-arm gap on a GRBL resume (whether a dark dwell
lead is wanted), the app's rendering of `print:paused`, and a lid open
during the return-to-start motion (should be ignored).
+19 -10
View File
@@ -19,9 +19,9 @@ roughly 45 W. **Jobs sent from LightBurn fire the laser.**
with some materials; sustained flame is not. Keep a fire extinguisher with some materials; sustained flame is not. Keep a fire extinguisher
(CO₂ preferred) within reach and know how you will open the lid and (CO₂ preferred) within reach and know how you will open the lid and
smother a fire before you start. smother a fire before you start.
- **Stop means stop.** The big button, LightBurn's Stop, and opening the - **Stop means stop.** Opening the lid cancels the job (the beam is cut by
lid each halt the job. If anything looks wrong, stop first and diagnose the hardware the same instant); LightBurn's Stop aborts it; the big button
second. pauses it. If anything looks wrong, stop first and diagnose second.
The laser fires only inside an operator-armed window: The laser fires only inside an operator-armed window:
@@ -109,13 +109,22 @@ restart the controller with the head re-parked.)
- **Start** runs the job. Travels run up to 200 mm/s; anything faster - **Start** runs the job. Travels run up to 200 mm/s; anything faster
in a layer is clamped by the controller ($110/$111 = 12000 mm/min). in a layer is clamped by the controller ($110/$111 = 12000 mm/min).
- **Pause** = grbl feed hold: motion parks within ~0.4 s (0.2 s stream - **Pause** = grbl feed hold: motion parks within ~0.4 s (0.2 s stream
queue + deceleration); Resume continues exactly. queue + deceleration); Resume continues exactly. **The big button does
- **Opening the lid (or a Pro's interlock loop) during a job** parks it the same**: one press while a job runs pauses it (LightBurn shows Hold),
the same way and LightBurn shows **Door**; close the lid and press the next press resumes it — the factory's pause/resume, on the machine.
**Resume** (a cycle start) to continue. At idle, while jogging, or during - **Opening the lid (or a Pro's interlock loop) during a job cancels it**,
homing the lid is yours to open and close freely — the controller does as the factory firmware does: the head parks with a controlled
not enter Door there (the hardware blocks the beam anyway), so a lid deceleration (the hardware cut the beam the instant the lid moved), the
cycle while loading material never leaves LightBurn waiting. console reports the reason, the job ends for LightBurn (the controller
resets — position is kept, no alarm), and the head returns on its own to
where the job started, lid open or not. Close the lid and start again
from LightBurn; the next job asks for the button, which is also what
re-arms the machine's hardware button latch. The `lid_policy` setting on
the control panel's GRBL tab can select the stock Grbl behavior instead
(Door hold, Resume once closed). At idle, while jogging, or during homing
the lid is yours to open and close freely — the controller does nothing
there (the hardware blocks the beam anyway), so a lid cycle while loading
material never leaves LightBurn waiting.
- **Stop** = soft reset: motion aborts with a controlled deceleration - **Stop** = soft reset: motion aborts with a controlled deceleration
and grblHAL raises an alarm with **position declared lost** (the and grblHAL raises an alarm with **position declared lost** (the
stream queue means up to ~40 mm of in-flight difference). Recovery: stream queue means up to ~40 mm of in-flight difference). Recovery:
+23 -9
View File
@@ -121,7 +121,7 @@ machine when the lid is closed *and* the SoC has already released its lock.
| SoC stops toggling CHG_PUMP (hang, panic, stop, fault, underrun) | drops within one one-shot period | FIRE is parked by the same paths | next run restarts the feed | | SoC stops toggling CHG_PUMP (hang, panic, stop, fault, underrun) | drops within one one-shot period | FIRE is parked by the same paths | next run restarts the feed |
| Button pressed with lid closed and lock released | — | armed (Q1 cleared) | — | | Button pressed with lid closed and lock released | — | armed (Q1 cleared) | — |
| Button pressed while lid open or lock held | — | stays blocked (SET is dominant) | — | | Button pressed while lid open or lock held | — | stays blocked (SET is dominant) | — |
| Remote-interlock loop opens (Pro) | unchanged | blocked: the kernel drives INTERLOCK_RESET high on the switch edge, setting the interlock latch. Opening the loop by itself only releases the latch's RESET — the board has no direct trip path — so this SoC drive is what makes the interlock a hardware cut (see §3.1); software additionally parks the job on `interlock` | close the loop: the kernel releases INTERLOCK_RESET and the closed loop resets the latch | | Remote-interlock loop opens (Pro) | unchanged | blocked: the kernel drives INTERLOCK_RESET high on the switch edge, setting the interlock latch. Opening the loop by itself only releases the latch's RESET — the board has no direct trip path — so this SoC drive is what makes the interlock a hardware cut (see §3.1); software additionally cancels (or, with `lid_policy = hold`, parks) the job on `interlock` | close the loop: the kernel releases INTERLOCK_RESET and the closed loop resets the latch |
| Interlock latch already SET | unchanged | blocked | closing the loop clears it | | Interlock latch already SET | unchanged | blocked | closing the loop clears it |
`hv_enable` (GPIO4_06) is a readback of this chain's own output, not an `hv_enable` (GPIO4_06) is a readback of this chain's own output, not an
@@ -198,14 +198,28 @@ would not allow.
emission witness); a stale or failed verdict relocks in-process. emission witness); a stale or failed verdict relocks in-process.
- **Safety door.** `doors` (lid) and `interlock` (loop open) are the core's - **Safety door.** `doors` (lid) and `interlock` (loop open) are the core's
safety-door signal, shown to the core only while it is in a job-time state safety-door signal, shown to the core only while it is in a job-time state
(cycle, hold, tool change, door): a running job parks; once the door/loop (cycle, hold, tool change, door): a running job parks with a planned
closes the controller reports `Door:0` and a cycle start resumes it. While deceleration and — with `lid_policy = cancel`, the default and the factory
idle, jogging or homing the signal is hidden — the lid is opened at idle firmware's behavior — is then cancelled: the armed window closes, a soft
every time material is loaded and a door seen there would strand the reset ends the sender's stream (from a fully parked state, so the position
controller in Door — and it is delivered the moment the core leaves those is kept and no alarm is raised), and the head returns to where the job
states, so a job started with the lid open parks on its first poll. This started with the latch locked, lid open or not. The next job re-arms with a
is a motion/UX gate; the lid is *also* cut in hardware by the button latch, fresh button press, which is also what clears the hardware button latch
and the interlock by the interlock latch (§3.1). the lid set — the software armed window and the hardware latch cannot
disagree. `lid_policy = hold` keeps the stock door hold (once the door/loop
closes the controller reports `Door:0` and a cycle start resumes it). During
the arm wait either opening cancels the job outright under both policies.
While idle, jogging or homing — and during the return-to-start motion after
a cancel — the signal is hidden: the lid is opened at idle every time
material is loaded and a door seen there would strand the controller in
Door; it is delivered the moment the core leaves those states, so a job
started with the lid open parks (and cancels) on its first poll. This is a
motion/UX gate; the lid is *also* cut in hardware by the button latch, and
the interlock by the interlock latch (§3.1).
- **Button.** Outside the arm wait the button is the job pause/resume toggle
in both controller modes (feed hold / cycle start in GRBL mode; the
factory's stop-backtrack-hold and lead-in resume in cloud mode); a held
button has no further meaning during a job.
- **Head/motion witnesses.** Position counters are not proof of motion (the - **Head/motion witnesses.** Position counters are not proof of motion (the
step-stream drives are open loop); the head accelerometer is the motion step-stream drives are open loop); the head accelerometer is the motion
witness, and `beam_detect_analog` on the head is the live emission witness. witness, and `beam_detect_analog` on the head is the live emission witness.
+91
View File
@@ -494,3 +494,94 @@ def arm_wait_lid(ctx):
ev["state_after"] = st ev["state_after"] = st
ctx.check(st.startswith("Idle"), "controller is %s after $X, expected Idle", 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") 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) machine_idle(ctx)
ctx.log("PASS: kill respawned in %s s, hang -> underrun in %s s, restart retook supervision (pid %s)", 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")) 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"])
+158 -2
View File
@@ -20,6 +20,14 @@ reported messages:
button: a press with the lid closed arms, and the lid or the button: a press with the lid closed arms, and the lid or the
interlock loop opening during the wait cancels the job (alarm, interlock loop opening during the wait cancels the job (alarm,
latch relocked, never armed) 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 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
@@ -137,12 +145,12 @@ 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, switches=None): def __init__(self, name, fire_ok=True, disarm_s=2, switches=None, conf_extra=""):
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")
with open(conf, "w") as f: 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") verdict = os.path.join(self.workdir, "cooling.state")
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")
@@ -190,6 +198,42 @@ class Session:
the gcode stream, so the ok only comes once the button is pressed).""" the gcode stream, so the ok only comes once the button is pressed)."""
self.sock.sendall((line + "\n").encode()) 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): def armed_count(self):
return "".join(self.log).count(ARMED) return "".join(self.log).count(ARMED)
@@ -415,6 +459,115 @@ def test_lid_open_in_wait():
s.close() 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(): 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)
@@ -423,6 +576,9 @@ def main():
test_hold_grace() test_hold_grace()
test_button_wait_arms() test_button_wait_arms()
test_lid_open_in_wait() test_lid_open_in_wait()
test_button_pause_resume()
test_lid_cancels_and_returns()
test_lid_policy_hold()
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")