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