diff --git a/docs/BRINGUP.md b/docs/BRINGUP.md index cbfcf04..48ae688 100644 --- a/docs/BRINGUP.md +++ b/docs/BRINGUP.md @@ -2386,7 +2386,10 @@ dev image (the confirmation campaign's image).** approaches are near-silent** — belt compliance turns slow-speed skipping into sub-threshold grinding — so any contact-sensing 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 controller reads EV_SW with `EVIOCGSW` from the protocol thread's 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 `Door` state, and what the controller reports at each step. Until 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 2026-08-15, bench validation pending — ships with the next full image 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 `releases/v/acceptance.json` - **not yet: no release is 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). diff --git a/docs/LIGHTBURN.md b/docs/LIGHTBURN.md index 1b5f4ae..3e6ec93 100644 --- a/docs/LIGHTBURN.md +++ b/docs/LIGHTBURN.md @@ -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 (CO₂ preferred) within reach and know how you will open the lid and smother a fire before you start. -- **Stop means stop.** The big button, LightBurn's Stop, and opening the - lid each halt the job. If anything looks wrong, stop first and diagnose - second. +- **Stop means stop.** Opening the lid cancels the job (the beam is cut by + the hardware the same instant); LightBurn's Stop aborts it; the big button + pauses it. If anything looks wrong, stop first and diagnose second. 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 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 - queue + deceleration); Resume continues exactly. -- **Opening the lid (or a Pro's interlock loop) during a job** parks it - the same way and LightBurn shows **Door**; close the lid and press - **Resume** (a cycle start) to continue. At idle, while jogging, or during - homing the lid is yours to open and close freely — the controller does - not enter Door there (the hardware blocks the beam anyway), so a lid - cycle while loading material never leaves LightBurn waiting. + queue + deceleration); Resume continues exactly. **The big button does + the same**: one press while a job runs pauses it (LightBurn shows Hold), + the next press resumes it — the factory's pause/resume, on the machine. +- **Opening the lid (or a Pro's interlock loop) during a job cancels it**, + as the factory firmware does: the head parks with a controlled + deceleration (the hardware cut the beam the instant the lid moved), the + 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 and grblHAL raises an alarm with **position declared lost** (the stream queue means up to ~40 mm of in-flight difference). Recovery: diff --git a/docs/SAFETY.md b/docs/SAFETY.md index 513afb7..199fbe9 100644 --- a/docs/SAFETY.md +++ b/docs/SAFETY.md @@ -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 | | Button pressed with lid closed and lock released | — | armed (Q1 cleared) | — | | 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 | `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. - **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 - (cycle, hold, tool change, door): a running job parks; once the door/loop - closes the controller reports `Door:0` and a cycle start resumes it. While - idle, jogging or homing 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 — and it is delivered the moment the core leaves those - states, so a job started with the lid open parks 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). + (cycle, hold, tool change, door): a running job parks with a planned + deceleration and — with `lid_policy = cancel`, the default and the factory + firmware's behavior — is then cancelled: the armed window closes, a soft + reset ends the sender's stream (from a fully parked state, so the position + is kept and no alarm is raised), and the head returns to where the job + started with the latch locked, lid open or not. The next job re-arms with a + fresh button press, which is also what clears the hardware button latch + 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 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. diff --git a/forgetest/forgetest/suite/laser.py b/forgetest/forgetest/suite/laser.py index b76dc95..8d976af 100644 --- a/forgetest/forgetest/suite/laser.py +++ b/forgetest/forgetest/suite/laser.py @@ -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) diff --git a/forgetest/forgetest/suite/motion.py b/forgetest/forgetest/suite/motion.py index 9ee492f..6c5a7ed 100644 --- a/forgetest/forgetest/suite/motion.py +++ b/forgetest/forgetest/suite/motion.py @@ -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"]) diff --git a/scripts/bench/laser_lifecycle_test.py b/scripts/bench/laser_lifecycle_test.py index feee98a..6fe9e42 100644 --- a/scripts/bench/laser_lifecycle_test.py +++ b/scripts/bench/laser_lifecycle_test.py @@ -20,6 +20,14 @@ reported messages: 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) + 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 cooling verdict is published hermetically (GF_VERDICT_FILE), the same @@ -137,12 +145,12 @@ 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, switches=None): + def __init__(self, name, fire_ok=True, disarm_s=2, switches=None, conf_extra=""): self.name = name self.workdir = tempfile.mkdtemp(prefix="laser-lifecycle-") conf = os.path.join(self.workdir, "forgefirm.conf") 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") env = dict(os.environ, GF_VERDICT_FILE=verdict, GFHOME_CONF=conf, FFLOG_STDERR="1") @@ -190,6 +198,42 @@ class Session: the gcode stream, so the ok only comes once the button is pressed).""" 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): return "".join(self.log).count(ARMED) @@ -415,6 +459,115 @@ def test_lid_open_in_wait(): 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(): if not os.path.isfile(BIN): fail("controller binary not found at %s" % BIN) @@ -423,6 +576,9 @@ def main(): test_hold_grace() test_button_wait_arms() test_lid_open_in_wait() + test_button_pause_resume() + test_lid_cancels_and_returns() + test_lid_policy_hold() test_verdict_blocks_arm() test_sigterm_mid_job() print("PASS: the armed-window lifecycle holds")