Files
forgefirm/forgetest/forgetest/suite/motion.py
T
ScottW514 c4ea96127c 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.
2026-08-16 19:26:11 -04:00

680 lines
31 KiB
Python

"""motion.* - the motion controller under grblHAL: dry motion, no emission.
Ported from `scripts/bench/pacing_test.py` (protocol-loop pacing) and
`scripts/bench/bench_m2.py` (motion-quality bench). Every move is relative
and round-trip; the laser stays latched (the tests never touch it); the
suite is the only Grbl client while a test runs.
"""
import os
import time
from ..catalog import test
from .. import hw
from ..runner import Failed
_MOTION_COVERS = [("grblhal-glowforge", "src/**"), ("kernel-module-glowforge", "**"),
("forgectrl", "src/super.*"), ("forgectrl", "src/liveness.*")]
def controller_pid():
pids = hw.pidof("grblHAL_glowfor")
if not pids:
raise Failed("controller process not found (grblHAL_glowforge)")
return pids[0]
def cpu_ticks(pid):
with open("/proc/%d/stat" % pid) as f:
s = f.read().split()
return int(s[13]) + int(s[14]) # utime + stime (all threads)
def cpu_percent(ctx, pid, window):
import os
hz = os.sysconf("SC_CLK_TCK")
a = cpu_ticks(pid)
ctx.sleep(window)
b = cpu_ticks(pid)
return 100.0 * (b - a) / (hz * window)
def wait_state(ctx, g, prefix, timeout):
end = time.time() + timeout
while time.time() < end:
ctx.checkpoint()
st = g.status_report()
if st["state"].startswith(prefix):
return st
time.sleep(0.1)
return None
def wait_idle(ctx, g, timeout=30.0, poll=0.05, grace=0.3):
"""Poll until Idle; returns (peak_feed_mm_min, states_seen, final_report).
An Idle report inside the first `grace` seconds counts only once a
non-Idle state was seen: a move just commanded may not have started."""
peak = 0.0
states = []
t0 = time.time()
deadline = t0 + timeout
st = None
while time.time() < deadline:
ctx.checkpoint()
st = g.status_report()
state = st["state"]
if not states or states[-1] != state:
states.append(state)
f = st.get("FS") or st.get("F")
if f:
try:
peak = max(peak, float(str(f).split(",")[0]))
except ValueError:
pass
if state.startswith("Idle") and (time.time() - t0 >= grace or len(states) > 1):
return peak, states, st
time.sleep(poll)
return peak, states + ["TIMEOUT"], st
def machine_idle(ctx, timeout=15.0):
"""The machine itself idle - the kernel has played out the stream
depth and the decel tail behind grblHAL's Idle. Every motion test ends
on this, so it hands the machine back at rest."""
ok = ctx.forgectrl.wait_idle(timeout, abort=ctx.aborted)
ctx.check(ok, "the machine did not return to idle within %.0f s of the last move", timeout)
def clean_slate(ctx, g):
st = g.status_report()
ctx.log("connect: %s", st["state"])
if any(k in st["state"] for k in ("Alarm", "Door", "Hold")):
g.realtime(0x18) # soft reset
ctx.sleep(2)
g.drain()
st = g.status_report()
if "Alarm" in st["state"]:
ctx.log("unlock: %s", g.command("$X"))
st = g.status_report()
ctx.check(st["state"].startswith("Idle"), "controller is %s, expected Idle", st["state"])
return st
@test("motion.pacing", title="Protocol-loop pacing (idle, parked, moving) and hold/resume position",
subsystem="motion", kind="auto", est_min=1,
covers=_MOTION_COVERS, requires=["kernel.latch-locked-idle"],
steps=["Bed clear; the head needs 30 mm of free +X travel."],
description="Idle CPU is low; a job parked in a completed feed hold is coarse-paced (not "
"busy-spinning at the motion rate); active motion is tight-paced; a feed-hold "
"mid-move then resume preserves position (the feeder never starves).")
def pacing(ctx):
dist, feed = 30.0, 600.0
pid = controller_pid()
ev = ctx.evidence
ev["controller_pid"] = pid
with ctx.grbl() as g:
clean_slate(ctx, g)
g.command("M5")
g.command("G91")
idle = cpu_percent(ctx, pid, 3)
ev["idle_cpu_pct"] = round(idle, 1)
ctx.log("[1] idle CPU = %.1f%%", idle)
start = g.status_report().get("MPos")
ctx.check(start, "no MPos in the status report")
g.command("G1 X%.3f F%.0f" % (dist, feed), timeout=0.5)
ctx.sleep(0.6)
moving = cpu_percent(ctx, pid, 1.0)
st_mv = g.status_report()["state"]
ev["moving_cpu_pct"] = round(moving, 1)
ctx.log("[3] state=%s CPU during move = %.1f%%", st_mv, moving)
g.realtime(ord("!")) # feed hold
wait_state(ctx, g, "Hold", 5)
ctx.sleep(1.5) # decel completes (Hold:1 -> Hold:0)
held = g.status_report()
parked = cpu_percent(ctx, pid, 3)
ev["held_state"] = held["state"]
ev["parked_cpu_pct"] = round(parked, 1)
ctx.log("[2] %s: CPU parked in Hold = %.1f%%", held["state"], parked)
g.realtime(ord("~")) # resume
peak, states, st = wait_idle(ctx, g, 30)
ctx.check("TIMEOUT" not in states, "did not return to Idle after the resume: %s", states)
end = st.get("MPos")
moved = end[0] - start[0]
ev["moved_mm"] = round(moved, 3)
ctx.log("[4] start X=%.3f end X=%.3f moved=%.3f (expect %.1f)", start[0], end[0], moved, dist)
# return to the starting position
g.command("G1 X%.3f F%.0f" % (-dist, feed), timeout=0.5)
wait_idle(ctx, g, 30)
g.command("G90")
final = g.status_report().get("MPos")
ev["final_drift_mm"] = round(final[0] - start[0], 3) if final else None
machine_idle(ctx)
ctx.check(abs(moved - dist) < 0.05, "hold+resume lost steps: moved %.3f of %.1f mm", moved, dist)
ctx.check(parked < moving * 0.5 and parked < 8.0,
"parked Hold is not coarse-paced: %.1f%% (moving %.1f%%)", parked, moving)
ctx.check(idle < 8.0, "idle CPU %.1f%%", idle)
ctx.log("PASS: idle %.1f%%, moving %.1f%%, parked %.1f%%, hold/resume exact", idle, moving, parked)
@test("motion.jog-roundtrip", title="Motion quality: bounded jogs, max rate, diagonal, hold/resume",
subsystem="motion", kind="operator", est_min=3,
covers=_MOTION_COVERS, requires=["kernel.latch-locked-idle"],
steps=["Park the head with at least 60 mm of free +X and 40 mm of free +Y travel; bed clear.",
"Watch the gantry: it must move on every jog and end where it started."],
description="Sanity jogs (X, Y 40 mm out/back at F2400), max-rate X out/back (60 mm at "
"F12000), a diagonal out/back, then a G1 with a feed-hold/resume in the middle. "
"No jog refused, every move returns to Idle, position drift within 0.05 mm, and "
"the operator saw the gantry move.")
def jog_roundtrip(ctx):
ev = ctx.evidence
ctx.instruct("Head parked with >= 60 mm free +X and >= 40 mm free +Y, bed clear, lid closed. "
"Watch the gantry during this test.")
with ctx.grbl() as g:
st0 = clean_slate(ctx, g)
start = st0.get("MPos")
ctx.check(start, "no MPos in the status report")
ev["start"] = start
moves = []
for name, out, back in (
("X sanity 40mm", "$J=G91X40F2400", "$J=G91X-40F2400"),
("Y sanity 40mm", "$J=G91Y40F2400", "$J=G91Y-40F2400"),
("X max-rate 60mm", "$J=G91X60F12000", "$J=G91X-60F12000"),
("diag 40mm", "$J=G91X40Y40F8000", "$J=G91X-40Y-40F8000")):
for jog in (out, back):
r = g.command(jog)
ctx.check(not any(x.startswith("error") for x in r), "%s: jog refused: %s", name, r)
peak, states, _ = wait_idle(ctx, g)
leg = "out" if jog == out else "back"
ctx.log("%s %s: peak %.0f mm/min, states %s", name, leg, peak, states)
moves.append({"name": name, "leg": leg, "peak": peak, "states": states})
ctx.check("TIMEOUT" not in states, "%s %s did not return to Idle", name, leg)
ev["moves"] = moves
maxrate = max(m["peak"] for m in moves if m["name"].startswith("X max-rate"))
ev["max_rate_peak"] = maxrate
ctx.check(maxrate >= 6000, "max-rate jog peaked at only %.0f mm/min", maxrate)
# feed-hold mid-move: G1 at 600 mm/min takes 3 s for 30 mm
g.command("G91")
g.command("G1X30F600", timeout=0.5)
ctx.sleep(1.0)
g.realtime(ord("!"))
ctx.sleep(0.8)
held = g.status_report()
ev["held_state"] = held["state"]
ctx.log("after !: %s", held["state"])
g.realtime(ord("~"))
peak, states, _ = wait_idle(ctx, g)
ctx.log("after ~: states %s", states)
g.command("G1X-30F2400", timeout=0.5)
wait_idle(ctx, g)
g.command("G90")
final = g.status_report().get("MPos")
ev["final"] = final
drift = max(abs(a - b) for a, b in zip(final[:2], start[:2]))
ev["drift_mm"] = round(drift, 3)
ctx.log("final drift %.3f mm (start %s, final %s)", drift, start, final)
machine_idle(ctx)
ctx.check("Hold" in held["state"], "feed hold did not park (state %s)", held["state"])
ctx.check(drift <= 0.05, "position drift %.3f mm", drift)
ctx.confirm("Did the gantry move on every jog (X, Y, the fast X, the diagonal, the held move) "
"and end where it started?")
ctx.log("PASS: %d jogs, peak %.0f mm/min, hold parked, drift %.3f mm, operator confirmed",
len(moves), maxrate, drift)
# ---------------------------------------------------------------- liveness
@test("motion.liveness-probe", title="Supervisor motion-liveness verdict", subsystem="motion",
kind="auto", est_min=1,
covers=[("forgectrl", "src/super.c"), ("forgectrl", "src/liveness.c"), ("kernel-module-glowforge", "**")],
requires=["kernel.latch-locked-idle"],
steps=["Bed clear, lid closed: the probe jogs the head 15 mm out and back (+X first); "
"forgectrl is restarted once for a fresh probe."],
description="forgectrl's supervisor reports the head-accelerometer liveness probe as "
"verified for the running controller (the DRV8825s are not wedged); when the "
"probe was skipped at spawn, the controller is respawned once so it runs. Then "
"the regression: with every axis masked (cnc/motor_lock=15, as a bench tool "
"may leave it) forgectrl is restarted and its fresh probe must still read "
"MOTION OK - the probe unmasks the axes itself - with the head-accel p2p at "
"or above the moving threshold.")
def liveness_probe(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
_liveness_verdict(ctx, fc, ev)
_liveness_masked_restart(ctx, fc, ev)
def _liveness_verdict(ctx, fc, ev):
st, m = fc.get("/mode")
ctx.check(st == 200 and isinstance(m, dict), "GET /mode -> %s", st)
ev["mode_before"] = m
ctx.log("mode: %s", m)
ctx.check(m.get("motion") != "fault", "supervisor reports motion-fault: %s", m)
if m.get("motion") != "verified":
ctx.log("probe %s at the last spawn - respawning the controller so it runs", m.get("motion"))
st, body = fc.post("/controller/stop")
ctx.check(st == 200, "POST /controller/stop -> %s", st)
ctx.sleep(2)
st, body = fc.post("/controller/start")
ctx.check(st == 200, "POST /controller/start -> %s", st)
t0 = time.time()
while time.time() - t0 < 60:
ctx.sleep(1)
st, m = fc.get("/mode")
if isinstance(m, dict) and m.get("controller") == "running":
break
ctx.sleep(3)
st, m = fc.get("/mode")
ev["mode_after"] = m
ctx.log("mode after: %s", m)
ctx.check(m.get("controller") == "running", "controller is %s", m.get("controller"))
ctx.check(m.get("motion") == "verified", "liveness is %r, expected verified", m.get("motion"))
FORGECTRL_LOG = "/data/log/forgefirm/forgectrl/forgectrl.log"
def _log_offset(path):
try:
return os.path.getsize(path)
except OSError:
return 0
def _probe_lines(path, offset):
try:
with open(path, "rb") as f:
f.seek(offset)
data = f.read().decode("utf-8", "replace")
except OSError:
return []
return [ln.strip() for ln in data.splitlines() if "liveness probe:" in ln]
def _liveness_masked_restart(ctx, fc, ev):
"""The regression: a leftover motor_lock must not read as a wedge."""
ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle before the masked restart")
x0 = _kernel_x_mm(ctx)
hw.sysfs_write("cnc/motor_lock", "15")
ctx.log("masked every axis (cnc/motor_lock=15); restarting forgectrl for a fresh probe")
off = _log_offset(FORGECTRL_LOG)
rc, out = hw.initd("forgectrl", "restart")
ctx.check(rc == 0, "forgectrl restart -> rc %s", rc)
m = None
t0 = time.time()
while time.time() - t0 < 150:
ctx.checkpoint()
try:
st, m = fc.get("/mode")
except hw.HwError:
m = None # the daemon is still coming up
if isinstance(m, dict) and ((m.get("controller") == "running" and m.get("motion") == "verified")
or m.get("controller") == "motion-fault"):
break
ctx.sleep(1)
lines = _probe_lines(FORGECTRL_LOG, off)
for ln in lines:
ctx.log(" %s", ln.split(" INFO ", 1)[-1] if " INFO " in ln else ln[-160:])
ev["masked_restart"] = {"mode": m, "probe_lines": lines[-4:], "motor_lock_after": ctx.sysfs("cnc/motor_lock")}
ctx.check(m and m.get("controller") == "running" and m.get("motion") == "verified",
"fresh probe under a leftover mask did not verify motion: %s", m)
ctx.check(lines and "MOTION OK" in lines[0],
"the first probe after the restart was not MOTION OK: %s", lines[:1])
ctx.check(len(lines) == 1, "the probe needed the recovery ladder (%d probes) - a false dead verdict", len(lines))
ctx.check(ctx.sysfs("cnc/motor_lock") == "8", "motor_lock reads %s after the controller start (expected 8)",
ctx.sysfs("cnc/motor_lock"))
ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle after the probe")
x1 = _kernel_x_mm(ctx)
ctx.log("kernel X %s -> %s mm across the probe (out and back)", x0, x1)
ctx.log("PASS: masked restart probed MOTION OK on the first try, mask cleared, controller up")
# ---------------------------------------------------------------- cancel / abort
@test("motion.cancel-abort", title="Jog cancel and controlled abort recover cleanly", subsystem="motion",
kind="auto", est_min=2,
covers=_MOTION_COVERS, requires=["motion.pacing"],
steps=["Bed clear; the head needs 40 mm of free +X travel."],
description="A jog-cancel (0x85) stops a jog short of its target and returns to Idle with "
"position preserved; a ^X abort mid-move decelerates under control into Alarm "
"with machine position retained, $X recovers to Idle, and a subsequent jog runs "
"(no driver wedge: the rail never cycled).")
def cancel_abort(ctx):
ev = ctx.evidence
with ctx.grbl() as g:
clean_slate(ctx, g)
start = g.status_report()["MPos"]
# jog cancel
g.command("$J=G91X40F2400")
ctx.sleep(0.4)
g.realtime(0x85)
st = wait_state(ctx, g, "Idle", 5)
ctx.check(st is not None, "not Idle within 5 s of the jog cancel")
p1 = st["MPos"]
moved1 = p1[0] - start[0]
ev["cancel_moved_mm"] = round(moved1, 3)
ctx.log("jog cancel: moved %.3f mm of 40 (state %s)", moved1, st["state"])
ctx.check(0.5 < moved1 < 39.0, "jog cancel did not stop short of the target (%.3f mm)", moved1)
# ^X abort mid-move
g.command("G91")
g.command("G1X30F600", timeout=0.5)
ctx.sleep(1.0)
g.realtime(0x18)
st = wait_state(ctx, g, "Alarm", 5)
ctx.check(st is not None, "^X did not land in Alarm within 5 s")
p2 = st["MPos"]
moved2 = p2[0] - p1[0]
ev["abort_moved_mm"] = round(moved2, 3)
ctx.log("^X abort: state %s, moved %.3f mm of 30, position retained %s", st["state"], moved2, p2)
ctx.check(0.5 < moved2 < 29.5, "abort position not retained/plausible (%.3f mm)", moved2)
r = g.command("$X")
ctx.log("$X -> %s", r)
st = wait_state(ctx, g, "Idle", 5)
ctx.check(st is not None, "$X did not recover to Idle")
# a jog after the abort proves the drivers are alive; return to start
back = -(p2[0] - start[0])
r = g.command("$J=G91X%.3fF2400" % back)
ctx.check(not any(x.startswith("error") for x in r), "return jog refused: %s", r)
peak, states, st = wait_idle(ctx, g, 30)
ctx.check("TIMEOUT" not in states, "return jog did not complete: %s", states)
final = st["MPos"]
drift = abs(final[0] - start[0])
ev["final_drift_mm"] = round(drift, 3)
ctx.log("returned: drift %.3f mm", drift)
ctx.check(drift <= 0.05, "position drift %.3f mm after cancel/abort/return", drift)
g.command("G90")
machine_idle(ctx)
# ---------------------------------------------------------------- dead-man
def _kernel_x_mm(ctx):
pos = (ctx.forgectrl.status().get("pos") or {})
return pos.get("x")
def _return_x(ctx, delta_mm):
"""Jog back by the kernel-measured X delta (grbl's own position may be
untrusted after a kill; the kernel counters kept counting)."""
if delta_mm is None or abs(delta_mm) < 0.05:
return
with ctx.grbl() as g:
st = g.status_report()["state"]
if st.startswith("Alarm"):
g.command("$X")
g.command("$J=G91X%.3fF1200" % (-delta_mm))
wait_idle(ctx, g, 30)
machine_idle(ctx)
@test("motion.deadman", title="Dead-man: controller kill, controller hang, forgectrl restart mid-move",
subsystem="motion", kind="auto", est_min=4,
covers=_MOTION_COVERS + [("forgectrl", "src/main.c"), ("forgectrl", "init/**")],
requires=["motion.cancel-abort", "kernel.k1-k2"],
steps=["Bed clear; the head needs 40 mm of free +X travel and must not be at the left rail."],
description="SIGKILL of the controller mid-move: the supervisor reaps it, safes (cnc/stop, "
"latch relocked - it never unlocked), and respawns within seconds. SIGSTOP (a "
"hang) mid-move: the ring drains into a kernel underrun (fast halt, latch "
"locked); the hung process is killed and the supervisor respawns. forgectrl "
"restart mid-move: the busy controller finishes the move unmanaged and the new "
"daemon retakes supervision at idle. After each drill the head is jogged back "
"by the kernel-measured distance.")
def deadman(ctx):
import os as _os
import signal as _signal
fc = ctx.forgectrl
ev = ctx.evidence
def latch_locked():
v = hw.sysfs_int("cnc/interlock_circuit")
return v is not None and bool(v & (1 << 3))
def wait_running(timeout=30, not_pid=None):
"""A running controller; with not_pid, one other than that pid (a
killed controller can still read as running until it is reaped)."""
t0 = time.time()
while time.time() - t0 < timeout:
st, m = fc.get("/mode")
if (isinstance(m, dict) and m.get("controller") == "running" and m.get("pid")
and m.get("pid") != not_pid):
return m
ctx.sleep(0.5)
return None
# ---- 1. SIGKILL mid-move
m0 = wait_running(10)
ctx.check(m0, "controller not running")
pid0 = m0["pid"]
x0 = _kernel_x_mm(ctx)
unlocked_seen = False
with ctx.grbl() as g:
clean_slate(ctx, g)
g.command("G91")
g.command("G1X30F300", timeout=0.5)
ctx.sleep(1.0)
_os.kill(pid0, _signal.SIGKILL)
ctx.log("SIGKILL sent to controller pid %d mid-move", pid0)
t0 = time.time()
while time.time() - t0 < 15:
if not latch_locked():
unlocked_seen = True
st, m = fc.get("/mode")
if isinstance(m, dict) and m.get("controller") == "running" and m.get("pid") != pid0:
break
ctx.sleep(0.2)
m1 = wait_running(30)
respawn_s = round(time.time() - t0, 1)
ev["sigkill"] = {"old_pid": pid0, "new": m1, "respawn_s": respawn_s, "unlocked_seen": unlocked_seen}
ctx.log("after SIGKILL: respawned as %s in %s s; latch unlocked seen: %s", m1, respawn_s, unlocked_seen)
ctx.check(m1 and m1.get("pid") != pid0, "supervisor did not respawn the controller")
ctx.check(not unlocked_seen, "the latch unlocked during the kill/respawn")
ctx.check(m1.get("motion") != "fault", "motion fault after the respawn")
ctx.sleep(3)
x1 = _kernel_x_mm(ctx)
ctx.log("kernel X: %s -> %s mm", x0, x1)
_return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None)
# ---- 2. SIGSTOP (hang) mid-move -> kernel underrun
m1 = wait_running(10)
pid1 = m1["pid"]
x0 = _kernel_x_mm(ctx)
underruns0 = hw.sysfs_int("cnc/underruns", 0)
with ctx.grbl() as g:
clean_slate(ctx, g)
g.command("G91")
g.command("G1X30F300", timeout=0.5)
ctx.sleep(1.0)
_os.kill(pid1, _signal.SIGSTOP)
ctx.log("SIGSTOP sent to controller pid %d mid-move", pid1)
t0 = time.time()
kstate = None
while time.time() - t0 < 10:
kstate = hw.sysfs_read("cnc/state")
if kstate == "underrun":
break
ctx.sleep(0.05)
halt_s = round(time.time() - t0, 2)
ev["sigstop"] = {"kernel_state": kstate, "halt_s": halt_s, "latch_locked": latch_locked(),
"underruns": hw.sysfs_int("cnc/underruns", 0)}
ctx.log("after SIGSTOP: kernel %s in %s s, latch locked %s, underruns %s -> %s",
kstate, halt_s, latch_locked(), underruns0, ev["sigstop"]["underruns"])
_os.kill(pid1, _signal.SIGKILL) # the hung controller cannot recover itself
ctx.check(kstate == "underrun", "the ring did not drain into a kernel underrun (state %s)", kstate)
ctx.check(latch_locked(), "latch unlocked after the underrun")
m2 = wait_running(30, not_pid=pid1)
ev["sigstop"]["respawn"] = m2
ctx.check(m2 and m2.get("pid") != pid1, "supervisor did not respawn after the hang")
ctx.sleep(3)
x1 = _kernel_x_mm(ctx)
_return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None)
# ---- 3. forgectrl restart mid-move: the move finishes, supervision retaken at idle
m2 = wait_running(10)
pid2 = m2["pid"]
x0 = _kernel_x_mm(ctx)
with ctx.grbl() as g:
clean_slate(ctx, g)
g.command("G91")
g.command("G1X30F300", timeout=0.5) # ~6 s of motion
ctx.sleep(1.0)
rc, out = hw.initd("forgectrl", "restart")
ctx.log("forgectrl restart mid-move -> rc %s", rc)
peak, states, st = wait_idle(ctx, g, 30)
ev["restart"] = {"rc": rc, "states": states}
ctx.log("move after the restart: states %s", states)
ctx.check("TIMEOUT" not in states, "the move did not finish after the forgectrl restart")
g.command("G90")
t0 = time.time()
m3 = None
while time.time() - t0 < 60:
st, m3 = fc.get("/mode")
if isinstance(m3, dict) and m3.get("controller") == "running":
break
ctx.sleep(1)
ev["restart"]["mode_after"] = m3
ctx.log("mode after restart: %s", m3)
ctx.check(m3 and m3.get("controller") == "running", "supervision not retaken after the restart: %s", m3)
ctx.check(m3.get("motion") == "verified", "motion not verified after the retake: %s", m3)
# the retake, by design: the busy controller (unmanaged, its own fd carrying
# the dead-man) finished its move; at idle the new supervisor stopped it,
# re-probed motion, and started a supervised one under the broker
ev["restart"]["replaced_at_idle"] = m3.get("pid") != pid2
ctx.log("retake: unmanaged pid %s finished the move; supervised pid %s started at idle",
pid2, m3.get("pid"))
ctx.check(latch_locked(), "latch unlocked after the restart drill")
x1 = _kernel_x_mm(ctx)
_return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None)
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"])