mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-27 16:51:12 -07:00
1194 lines
58 KiB
Python
1194 lines
58 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:
|
|
try:
|
|
st, m = hw.Forgectrl().get("/mode")
|
|
where = ("forgectrl reports mode=%s controller=%s" % (m.get("mode"), m.get("controller"))
|
|
if st == 200 and isinstance(m, dict) else "forgectrl /mode -> %s" % st)
|
|
except hw.HwError as e:
|
|
where = "forgectrl unreachable (%s)" % e
|
|
raise Failed("controller process not found (grblHAL_glowforge); %s" % where)
|
|
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_state_text(ctx, g, prefix, timeout):
|
|
"""wait_state, keeping everything the controller said while polling.
|
|
The driver reports a press with a [MSG:] line the moment it acts on it -
|
|
inside the poll window - so a test that wants both the state and the
|
|
message has to collect them together."""
|
|
end = time.time() + timeout
|
|
text = ""
|
|
while time.time() < end:
|
|
ctx.checkpoint()
|
|
st = g.status_report()
|
|
text += g.drain()
|
|
if st["state"].startswith(prefix):
|
|
return st, text
|
|
time.sleep(0.1)
|
|
return None, text + g.drain()
|
|
|
|
|
|
def wait_left_state(ctx, g, prefix, timeout):
|
|
"""The first report whose state is no longer `prefix`, with the text.
|
|
Leaving a state is what proves a command was acted on; catching the
|
|
state it moves INTO is a race whenever the remaining work is short."""
|
|
end = time.time() + timeout
|
|
text = ""
|
|
while time.time() < end:
|
|
ctx.checkpoint()
|
|
st = g.status_report()
|
|
text += g.drain()
|
|
if not st["state"].startswith(prefix):
|
|
return st, text
|
|
time.sleep(0.1)
|
|
return None, text + g.drain()
|
|
|
|
|
|
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", mode="grbl", 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="auto", mode="grbl", est_min=3,
|
|
covers=_MOTION_COVERS, requires=["kernel.latch-locked-idle"],
|
|
steps=["Setup: the head parked with at least 60 mm of free +X and 40 mm of free +Y travel; "
|
|
"bed clear, lid closed."],
|
|
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 head accelerometer - the supervisor's own motion witness - saw the head move "
|
|
"on every jog it could sample; the counters alone are not proof of motion.")
|
|
def jog_roundtrip(ctx):
|
|
ev = ctx.evidence
|
|
accel = hw.AccelSampler()
|
|
ctx.check(accel.available, "no head accelerometer found (i2c %s): the motion witness is missing",
|
|
hw.HEAD_ACCEL_I2C)
|
|
with ctx.grbl() as g, accel:
|
|
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):
|
|
t0 = time.time()
|
|
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)
|
|
t1 = time.time()
|
|
p2px, p2py, n = accel.p2p(t0, t1)
|
|
leg = "out" if jog == out else "back"
|
|
ctx.log("%s %s: peak %.0f mm/min, states %s; accel p2p x=%d y=%d over %d samples",
|
|
name, leg, peak, states, p2px, p2py, n)
|
|
moves.append({"name": name, "leg": leg, "peak": peak, "states": states,
|
|
"accel_p2p": [p2px, p2py], "accel_samples": n, "t0": t0, "t1": t1})
|
|
ctx.check("TIMEOUT" not in states, "%s %s did not return to Idle", name, leg)
|
|
ev["moves"] = moves
|
|
# The witness sees the ramps, not the travel: a sysfs read lands
|
|
# two or three samples in a one-second leg, and two samples on
|
|
# the constant-velocity stretch read near the idle level with the
|
|
# head in full flight (bench: 17 samples over the eight legs, a
|
|
# ramp caught on three of them). So the verdict is over the
|
|
# sequence: the head moved during the jogs (p2p over all the
|
|
# legs), and on more than one of them, never one leg alone.
|
|
p2px, p2py, n = accel.p2p(moves[0]["t0"], moves[-1]["t1"])
|
|
moving = [m for m in moves if max(m["accel_p2p"]) >= hw.ACCEL_P2P_MOVING]
|
|
ev["accel_overall"] = {"p2p": [p2px, p2py], "samples": n, "errors": accel.errors}
|
|
ev["accel_moving_legs"] = [m["name"] + " " + m["leg"] for m in moving]
|
|
ctx.log("accel over all %d legs: p2p x=%d y=%d (%d samples, %d errors); motion seen on %d legs",
|
|
len(moves), p2px, p2py, n, accel.errors, len(moving))
|
|
ctx.check(n >= 8, "the accelerometer landed only %d samples over the jogs (%d read errors): "
|
|
"the motion witness is not reading", n, accel.errors)
|
|
ctx.check(max(p2px, p2py) >= hw.ACCEL_P2P_MOVING,
|
|
"the head did not move during the jogs (accel p2p x=%d y=%d, below %d): the counters "
|
|
"ran without the gantry", p2px, p2py, hw.ACCEL_P2P_MOVING)
|
|
ctx.check(len(moving) >= 2, "the accelerometer saw motion on only %d leg(s) of %d (one jolt is "
|
|
"not a gantry moving on every jog)", len(moving), len(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.log("PASS: %d jogs, peak %.0f mm/min, hold parked, drift %.3f mm, accel p2p %d over the jogs, "
|
|
"motion on %d of %d legs", len(moves), maxrate, drift, max(p2px, p2py), len(moving), len(moves))
|
|
|
|
|
|
# ---------------------------------------------------------------- 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 _log_lines(path, offset, needle):
|
|
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 needle in ln]
|
|
|
|
|
|
def _probe_lines(path, offset):
|
|
return _log_lines(path, offset, "liveness probe:")
|
|
|
|
|
|
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)
|
|
# The probe's own line reaches the file through rsyslog a moment after
|
|
# the supervisor reports its verdict: wait for it, briefly.
|
|
t1 = time.time()
|
|
lines = _probe_lines(FORGECTRL_LOG, off)
|
|
while not lines and time.time() - t1 < 10:
|
|
ctx.sleep(0.5)
|
|
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") == "0", "motor_lock reads %s after the controller start (expected 0)",
|
|
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", mode="grbl", 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 (what the sender's Stop sends) "
|
|
"decelerates under control into Alarm with machine position retained, leaves the "
|
|
"head where it stopped - no return-to-start, that is the lid policy's move alone - "
|
|
"and $X recovers to Idle with a subsequent jog running (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 sender abort is not a lid cancel: it stops where it stopped and
|
|
# the head stays there. Only the lid/interlock policy returns to the
|
|
# job start, and an unasked-for return move would be a surprise to
|
|
# whoever pressed Stop.
|
|
text = drain_text(g, 2.0)
|
|
ev["abort_returned_home"] = "returned to the job start" in text
|
|
p3 = g.status_report()["MPos"]
|
|
ev["abort_drift_after_recovery_mm"] = round(abs(p3[0] - p2[0]), 3)
|
|
ctx.log("after $X: %s (moved %.3f mm since the abort)", p3, ev["abort_drift_after_recovery_mm"])
|
|
ctx.check(not ev["abort_returned_home"], "a sender abort triggered the return-to-start motion")
|
|
ctx.check(ev["abort_drift_after_recovery_mm"] <= 0.05,
|
|
"the head moved %.3f mm on its own after a sender abort",
|
|
ev["abort_drift_after_recovery_mm"])
|
|
# 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", mode="grbl", 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 process resumed from the hang recovers on a soft reset and an unlock "
|
|
"(the alarm is critical, so $X alone is refused; the reset acknowledges the "
|
|
"fault to the stream and clears the stale ring) and moves again without a "
|
|
"restart. 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)
|
|
ring_free_idle = hw.sysfs_int("cnc/free")
|
|
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"])
|
|
# The controller comes back from the hang to find its stream
|
|
# faulted and the core in a critical alarm (17, motor fault). The
|
|
# core refuses an unlock until a soft reset (error 79), and the
|
|
# reset is what the stream takes as the operator's acknowledgment:
|
|
# the kernel is stopped and re-armed, the stale ring cleared, the
|
|
# producer armed again. Reset, unlock, and the controller moves
|
|
# again without a restart (the position is not trusted until a
|
|
# re-home, so the move is a jog).
|
|
_os.kill(pid1, _signal.SIGCONT)
|
|
ctx.sleep(1.0)
|
|
st = g.status_report()["state"]
|
|
ev["sigstop"]["state_after_cont"] = st
|
|
ctx.log("controller resumed: state %s", st)
|
|
ctx.check(st.startswith("Alarm"), "the resumed controller is %s, expected Alarm", st)
|
|
early = g.command("$X")
|
|
ev["sigstop"]["unlock_before_reset"] = early
|
|
ctx.log("$X before the reset: %s", early)
|
|
ctx.check(any(l.startswith("error:79") for l in early),
|
|
"$X before the reset should be refused as a critical event, got %s", early)
|
|
g.realtime(0x18)
|
|
ctx.sleep(2)
|
|
g.drain()
|
|
st = g.status_report()["state"]
|
|
ctx.log("after the soft reset: %s", st)
|
|
unlock = g.command("$X")
|
|
ev["sigstop"]["unlock_after_reset"] = unlock
|
|
ctx.log("$X after the reset: %s", unlock)
|
|
ctx.check(unlock and unlock[-1] == "ok", "$X after the reset was refused (%s)", unlock)
|
|
st = g.status_report()["state"]
|
|
ctx.check(st.startswith("Idle"), "controller is %s after the reset and unlock, expected Idle", st)
|
|
free = hw.sysfs_int("cnc/free")
|
|
ev["sigstop"]["ring_free_after_reset"] = free
|
|
ctx.log("ring after the reset: free %s (idle %s)", free, ring_free_idle)
|
|
ctx.check(free == ring_free_idle, "the reset did not clear the stale ring (free %s, idle %s)",
|
|
free, ring_free_idle)
|
|
g.command("$J=G91X-5F1200")
|
|
peak, states, st = wait_idle(ctx, g, 15)
|
|
ev["sigstop"]["recovery_states"] = states
|
|
ctx.check("TIMEOUT" not in states and not str((st or {}).get("state", "")).startswith("Alarm"),
|
|
"the controller did not move again after the reset and unlock (states %s)", states)
|
|
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(10)
|
|
ev["sigstop"]["after"] = m2
|
|
ctx.check(m2 and m2.get("pid") == pid1, "the recovered controller was replaced (%s)", m2)
|
|
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 kernel_xy_mm(ctx):
|
|
"""The kernel's own position counters, in mm (forgectrl /status pos):
|
|
what the machine physically did, independent of what grbl believes."""
|
|
pos = (ctx.forgectrl.status().get("pos") or {})
|
|
return float(pos.get("x", 0.0)), float(pos.get("y", 0.0))
|
|
|
|
|
|
def kernel_start(ctx, timeout=15.0):
|
|
"""The kernel counters AT REST, for use as a reference point.
|
|
|
|
grblHAL reports Idle when its planner is empty; the kernel is still
|
|
playing the stream depth and the decel tail behind that. Sampling the
|
|
counters in that window records a position the head is only passing
|
|
through, and every later comparison is then measured against a
|
|
transient - which reads exactly like the failure this reference exists
|
|
to catch (a move counted but never played)."""
|
|
machine_idle(ctx, timeout)
|
|
return kernel_xy_mm(ctx)
|
|
|
|
|
|
def check_kernel_returned(ctx, ev, k0, tol_mm=0.1, tag=""):
|
|
"""After a return-to-start: the kernel counters must be back where the
|
|
job started too. grbl's own drift can read 0.000 while the head never
|
|
moved (a run the kernel did not take), which is exactly the failure
|
|
that must not pass."""
|
|
k1 = kernel_xy_mm(ctx)
|
|
kdrift = max(abs(k1[0] - k0[0]), abs(k1[1] - k0[1]))
|
|
ev[(tag + "_" if tag else "") + "kernel_drift_mm"] = round(kdrift, 3)
|
|
ctx.log("kernel counters: start (%.2f, %.2f) -> now (%.2f, %.2f), drift %.3f mm",
|
|
k0[0], k0[1], k1[0], k1[1], kdrift)
|
|
ctx.check(kdrift <= tol_mm,
|
|
"the kernel counters did not return to the job start (drift %.3f mm) - "
|
|
"the return move was counted by grbl but not played by the machine", kdrift)
|
|
|
|
|
|
class Watch:
|
|
"""Conditions over the controller's state for Context.act / wait_for
|
|
that also keep everything the controller said while they were polled
|
|
(the `[MSG:]` lines a pause or a cancel reports)."""
|
|
|
|
def __init__(self, g):
|
|
self.g = g
|
|
self.text = ""
|
|
self.last = None
|
|
|
|
def poll(self):
|
|
self.text += self.g.drain()
|
|
self.last = self.g.status_report()
|
|
return self.last
|
|
|
|
def in_state(self, prefix):
|
|
return lambda: self.poll()["state"].startswith(prefix)
|
|
|
|
def left_state(self, prefix):
|
|
return lambda: not self.poll()["state"].startswith(prefix)
|
|
|
|
|
|
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
|
|
|
|
|
|
def expect_cancel_and_return(ctx, g, ev, start, k0, why, tag):
|
|
"""The cancel policy's whole tail, shared by every trigger that ends a
|
|
job this way (lid or interlock, from Run or from a hold): the reason is
|
|
reported, the controller resets without an alarm and with the position
|
|
kept, and the head goes back to where the job started - which the KERNEL
|
|
counters have to confirm, not grbl's belief about them."""
|
|
text = drain_text(g, 3.0)
|
|
msgs = [ln for ln in text.splitlines()
|
|
if ln.startswith("[MSG:") or "help]" in ln or ln.startswith("ALARM")]
|
|
ev[tag + "_messages"] = msgs
|
|
ctx.log("[%s] controller: %s", tag, msgs)
|
|
cancel_msg = "%s - job canceled" % why
|
|
ctx.check(cancel_msg in text, "[%s] the job was not canceled with %r as the reason", tag, why)
|
|
ctx.check("help]" in text, "[%s] no reset banner after the cancel (the sender must see the job end)", tag)
|
|
ctx.check("ALARM" not in text, "[%s] an alarm was raised on the cancel (position should be kept)", tag)
|
|
t0 = time.time()
|
|
returned = "returned to the job start" in text
|
|
while not returned and time.time() - t0 < 30:
|
|
ctx.checkpoint()
|
|
text += g.drain()
|
|
returned = "returned to the job start" in text
|
|
time.sleep(0.2)
|
|
ev[tag + "_returned_message"] = returned
|
|
ctx.check(returned, "[%s] the head did not report returning to the job start within 30 s", tag)
|
|
st = wait_state(ctx, g, "Idle", 5)
|
|
ctx.check(st is not None, "[%s] not Idle after the return (state %s)", tag,
|
|
g.status_report()["state"])
|
|
drift = max(abs(st["MPos"][i] - start[i]) for i in range(2))
|
|
ev[tag + "_drift_mm"] = round(drift, 3)
|
|
ctx.log("[%s] back at the job start: drift %.3f mm", tag, drift)
|
|
ctx.check(drift <= 0.05, "[%s] head not back at the job start (drift %.3f mm)", tag, drift)
|
|
machine_idle(ctx, 10)
|
|
check_kernel_returned(ctx, ev, k0, tag=tag)
|
|
return drift
|
|
|
|
|
|
@test("motion.button-hold-resume", title="The button pauses and resumes a job",
|
|
subsystem="motion", kind="operator", mode="grbl", est_min=2,
|
|
covers=_LID_COVERS, requires=["motion.pacing"], actions=["button"],
|
|
steps=["Bed clear; the head needs 40 mm of free +X travel. No laser is involved.",
|
|
"On Ready the head starts an 8 s move: press the button once while it moves (the "
|
|
"job holds), then once more when told (it resumes and finishes)."],
|
|
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"]
|
|
ctx.ready("On Ready the head starts an 8 s move along +X. Press the button ONCE while it "
|
|
"moves; the job holds and the test sees it.")
|
|
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")
|
|
g.drain() # the message window opens here
|
|
w = Watch(g)
|
|
ctx.act("button", "press", until=w.in_state("Hold"), timeout=12, fail=False)
|
|
st, text = w.last, w.text
|
|
ctx.check(st is not None and st["state"].startswith("Hold"),
|
|
"the press did not hold the job (state %s)", st["state"] if st else "?")
|
|
ev["held_state"] = st["state"]
|
|
ev["held_at_mm"] = round(st["MPos"][0] - start[0], 3)
|
|
ev["pause_message"] = "job paused" in text
|
|
ctx.log("held: %s at %.3f mm of 40; message seen: %s", st["state"], ev["held_at_mm"],
|
|
ev["pause_message"])
|
|
# The press is proven by the job LEAVING the hold. Catching it in Run
|
|
# is a race: a pause late in the move leaves a fraction of a second of
|
|
# travel, which can be over before the next poll - the machine did
|
|
# exactly the right thing and the test would still have called it a
|
|
# failure.
|
|
w = Watch(g)
|
|
ctx.act("button", "press", text="The head is stopped: the press resumes the move.",
|
|
until=w.left_state("Hold"), timeout=60, fail=False)
|
|
st, text = w.last, w.text
|
|
if st is not None and st["state"].startswith("Hold"):
|
|
st = None
|
|
ev["state_after_resume"] = st["state"] if st else None
|
|
ev["resume_message"] = "job resumed" in text
|
|
ctx.check(st is not None, "the second press did not resume the job (still held: %s)",
|
|
g.status_report()["state"])
|
|
# Leaving the hold for Run or Idle is the resume; leaving it for Alarm
|
|
# or Door is something else entirely, and must not read as a pass.
|
|
ctx.check(st["state"].startswith(("Run", "Idle")),
|
|
"the job left the hold into %s, not into motion", st["state"])
|
|
ctx.log("resumed: %s; message seen: %s", ev["state_after_resume"], ev["resume_message"])
|
|
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 - running or paused - cancels it and returns "
|
|
"to the job start",
|
|
subsystem="motion", kind="operator", mode="grbl", est_min=5,
|
|
covers=_LID_COVERS, requires=["motion.pacing", "motion.cancel-abort"],
|
|
actions=["lid", "button"],
|
|
steps=["Bed clear; the head needs 40 mm of free +X travel. No laser is involved.",
|
|
"Twice, on Ready the head starts an 8 s move. First: open the lid while it moves and "
|
|
"leave it open until the head has come back, then close it. Second: press the button "
|
|
"once while it moves (pause), then open the lid, leave it open until the head is back, "
|
|
"and close it."],
|
|
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. The same "
|
|
"job paused on the button takes the same path - a lid open from the hold cancels "
|
|
"and returns, it never resumes. 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)
|
|
k0 = kernel_start(ctx)
|
|
start = g.status_report()["MPos"]
|
|
ev["kernel_start"] = k0
|
|
ev["start"] = start
|
|
ctx.ready("On Ready the head starts an 8 s move along +X. Open the lid while it moves and "
|
|
"leave it open until the head has come back on its own.")
|
|
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")
|
|
g.drain() # the message window opens here
|
|
ctx.act("lid", "open", text="Leave it open until the head has come back.", timeout=12)
|
|
drift = expect_cancel_and_return(ctx, g, ev, start, k0, "lid opened", "running")
|
|
sw = (ctx.forgectrl.status().get("switches") or {})
|
|
ev["lid_at_return"] = sw.get("lid")
|
|
ctx.act("lid", "close")
|
|
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)
|
|
|
|
# -- the same cancel, entered from a hold ---------------------------
|
|
# A job paused on the button must not be resumable past a lid open:
|
|
# the armed window and the hardware button latch have to agree, so
|
|
# the lid ends the job here exactly as it does from Run.
|
|
# The reference for this phase is taken only once the MACHINE is at
|
|
# rest: the jogs above waited for grblHAL's Idle, which arrives while
|
|
# the kernel is still playing their tail.
|
|
k1 = kernel_start(ctx)
|
|
start2 = g.status_report()["MPos"]
|
|
ev["hold_start"] = start2
|
|
ctx.ready("On Ready the head starts the 8 s move again. Press the button ONCE while it "
|
|
"moves (the job pauses); then open the lid and leave it open until the head has "
|
|
"come back.")
|
|
g.command("G91") # the reset restored G90
|
|
g.command("G1X40F300", timeout=0.5)
|
|
ctx.sleep(0.5)
|
|
ctx.check(g.status_report()["state"].startswith("Run"), "the second move did not start")
|
|
g.drain()
|
|
w = Watch(g)
|
|
ctx.act("button", "press", text="The job pauses.", until=w.in_state("Hold"), timeout=12,
|
|
fail=False)
|
|
st, held = w.last, w.text
|
|
ctx.check(st is not None and st["state"].startswith("Hold"),
|
|
"the press did not hold the job (state %s)", st["state"] if st else "?")
|
|
ev["hold_state"] = st["state"]
|
|
ev["hold_pause_message"] = "job paused" in held
|
|
ctx.log("paused: %s; message seen: %s", st["state"], ev["hold_pause_message"])
|
|
g.drain()
|
|
ctx.act("lid", "open", text="The job is paused: leave the lid open until the head has come "
|
|
"back.", timeout=60)
|
|
hold_drift = expect_cancel_and_return(ctx, g, ev, start2, k1, "lid opened", "hold")
|
|
ctx.check(not g.status_report()["state"].startswith("Hold"),
|
|
"the controller is still holding after the lid canceled the paused job")
|
|
ctx.act("lid", "close")
|
|
ctx.sleep(1)
|
|
r = g.command("$J=G91X5F1200")
|
|
ctx.check(not any(x.startswith("error") for x in r), "jog refused after the paused 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 canceled the job from Run (drift %.3f mm) and from the hold (drift %.3f mm), "
|
|
"reset without alarm, head returned to the start both times", drift, hold_drift)
|
|
|
|
|
|
@test("motion.interlock-cancel-home", title="The interlock loop cancels a job like the lid and returns to "
|
|
"the job start",
|
|
subsystem="motion", kind="operator", mode="grbl", est_min=4,
|
|
covers=_LID_COVERS, requires=["motion.lid-cancel-home"], actions=["interlock"],
|
|
steps=["Bed clear; the head needs 60 mm of free +X travel. No laser is involved.",
|
|
"Be able to open the remote-interlock loop: unplug the Pro's interlock plug, or pull the "
|
|
"jumper at J8 on a Basic/Plus.",
|
|
"On Ready the head starts a 12 s move: open the interlock while it moves and leave it "
|
|
"open until the head has come back, then restore it."],
|
|
description="The remote-interlock loop is the lid's equal in the cancel policy: opening it mid-job "
|
|
"cancels the job with 'interlock open' named as the reason - the lid's own message would "
|
|
"be wrong here - and sends the head back to the job start with the loop still open. "
|
|
"(An open lid not stopping the park is the lid test's park, which runs with the lid open "
|
|
"throughout; in GRBL mode the park is a rapid too short to open a lid during, so the "
|
|
"mid-park lid edge is the cloud test's.)")
|
|
def interlock_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)
|
|
sw = (ctx.forgectrl.status().get("switches") or {})
|
|
ctx.check(sw.get("interlock_ok"), "the interlock loop already reads open - close it before this test")
|
|
with ctx.grbl() as g:
|
|
clean_slate(ctx, g)
|
|
k0 = kernel_start(ctx)
|
|
start = g.status_report()["MPos"]
|
|
ev["start"] = start
|
|
ev["kernel_start"] = k0
|
|
ctx.ready("On Ready the head starts a 12 s move along +X. Open the interlock loop while it "
|
|
"moves and leave it open until the head has come back on its own.")
|
|
g.command("M5")
|
|
g.command("G91")
|
|
g.command("G1X60F300", timeout=0.5) # a 12 s move
|
|
ctx.sleep(0.5)
|
|
ctx.check(g.status_report()["state"].startswith("Run"), "the move did not start")
|
|
g.drain()
|
|
ctx.act("interlock", "open", text="Leave it open until the head has come back.", timeout=16)
|
|
sw = (ctx.forgectrl.status().get("switches") or {})
|
|
ev["interlock_ok_after_pull"] = sw.get("interlock_ok")
|
|
ctx.check(sw.get("interlock_ok") is False,
|
|
"the interlock still reads closed - the loop was not opened (switches: %s)", sw)
|
|
drift = expect_cancel_and_return(ctx, g, ev, start, k0, "interlock open", "interlock")
|
|
sw = (ctx.forgectrl.status().get("switches") or {})
|
|
ev["switches_at_return"] = {"lid": sw.get("lid"), "interlock_ok": sw.get("interlock_ok")}
|
|
ctx.log("at the end of the park: %s", ev["switches_at_return"])
|
|
ctx.check(sw.get("interlock_ok") is False,
|
|
"the interlock was closed again before the park finished - the park ran with the loop "
|
|
"restored, not open")
|
|
ctx.act("interlock", "close")
|
|
ctx.sleep(1)
|
|
sw = (ctx.forgectrl.status().get("switches") or {})
|
|
ev["restored"] = {"lid": sw.get("lid"), "interlock_ok": sw.get("interlock_ok")}
|
|
ctx.check(sw.get("interlock_ok"), "the interlock loop is still open - restore it before continuing")
|
|
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: interlock open canceled the job with its own reason and the head returned to the "
|
|
"start (drift %.3f mm) with the loop still open", drift)
|
|
|
|
|
|
@test("motion.lid-policy-hold", title="lid_policy=hold parks the job in Door and a cycle start resumes it",
|
|
subsystem="motion", kind="operator", mode="grbl", est_min=4,
|
|
covers=_LID_COVERS + [("forgectrl", "src/settings.*")], requires=["motion.lid-cancel-home"],
|
|
actions=["lid"],
|
|
steps=["Bed clear; the head needs 40 mm of free +X travel. No laser is involved.",
|
|
"On Ready the head starts an 8 s move: open the lid while it moves (the job parks in "
|
|
"Door), then close it when told; the job finishes after that."],
|
|
description="The other lid policy, kept for senders that expect stock grblHAL: with "
|
|
"lid_policy=hold a lid open parks the job in the door state and holds it there - "
|
|
"no cancel, no return home - and once the lid is closed a cycle start finishes the "
|
|
"move with its position intact. The setting is restored to cancel at the end.")
|
|
def lid_policy_hold(ctx):
|
|
ev = ctx.evidence
|
|
fc = ctx.forgectrl
|
|
was = (fc.settings() or {}).get("lid_policy") or "cancel"
|
|
ev["lid_policy_before"] = was
|
|
st, _b = fc.post("/settings", data={"lid_policy": "hold"})
|
|
ctx.check(st == 200, "could not set lid_policy=hold (%s)", st)
|
|
ctx.check(((fc.settings() or {}).get("lid_policy")) == "hold", "lid_policy did not take")
|
|
try:
|
|
with ctx.grbl() as g:
|
|
clean_slate(ctx, g)
|
|
start = g.status_report()["MPos"]
|
|
ctx.ready("On Ready the head starts an 8 s move along +X. Open the lid while it moves; "
|
|
"the job parks in the door state and waits.")
|
|
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")
|
|
g.drain()
|
|
ctx.act("lid", "open", timeout=12)
|
|
st, text = wait_state_text(ctx, g, "Door", 8)
|
|
ev["door_state"] = st["state"] if st else g.status_report()["state"]
|
|
ctx.check(st is not None, "the lid did not park the job in Door (state %s)", ev["door_state"])
|
|
ev["messages"] = [ln for ln in text.splitlines() if ln.startswith("[MSG:")]
|
|
ctx.check("job canceled" not in text, "the job was canceled under lid_policy=hold: %s", ev["messages"])
|
|
ctx.check("returned to the job start" not in text,
|
|
"the head returned to the job start under lid_policy=hold")
|
|
held = g.status_report()["MPos"]
|
|
ev["parked_at"] = held
|
|
ctx.log("parked in %s at %s", ev["door_state"], held)
|
|
ctx.act("lid", "close")
|
|
ctx.sleep(1)
|
|
ev["state_after_close"] = g.status_report()["state"]
|
|
ctx.log("after the lid closed: %s (a cycle start is needed)", ev["state_after_close"])
|
|
g.realtime(0x7E) # ~ cycle start
|
|
peak, states, st = wait_idle(ctx, g, 30)
|
|
ev["states_after_resume"] = states
|
|
ctx.check("TIMEOUT" not in states, "the job did not finish after the resume: %s", states)
|
|
final = st["MPos"]
|
|
moved = final[0] - start[0]
|
|
ev["moved_mm"] = round(moved, 3)
|
|
ctx.log("finished: moved %.3f mm of 40 (states %s)", moved, states)
|
|
ctx.check(abs(moved - 40.0) <= 0.05,
|
|
"the resumed job did not finish its move (%.3f mm of 40)", moved)
|
|
g.command("$J=G91X-40F1200")
|
|
wait_idle(ctx, g, 30)
|
|
g.command("G90")
|
|
machine_idle(ctx)
|
|
finally:
|
|
st, _b = fc.post("/settings", data={"lid_policy": was})
|
|
ev["lid_policy_restored"] = (fc.settings() or {}).get("lid_policy")
|
|
ctx.log("lid_policy restored to %s", ev["lid_policy_restored"])
|
|
ctx.check(ev["lid_policy_restored"] == was, "lid_policy was not restored to %r", was)
|
|
ctx.log("PASS: lid_policy=hold parked the job in Door and the cycle start finished it (%.3f mm)",
|
|
ev["moved_mm"])
|
|
|
|
|
|
GRBLHAL_LOG = "/data/log/forgefirm/grblhal/grblhal.log"
|
|
SCHED_FIFO = 1
|
|
|
|
|
|
def _thread_sched(pid):
|
|
"""(tid, policy, rt_priority) for every thread of pid. Those are fields
|
|
41 and 40 of /proc/<tid>/stat; comm can hold spaces and parentheses, so
|
|
the fields are indexed from the last ')' - rest[0] is field 3."""
|
|
out = []
|
|
for tid in sorted(os.listdir("/proc/%d/task" % pid)):
|
|
try:
|
|
with open("/proc/%d/task/%s/stat" % (pid, tid)) as f:
|
|
s = f.read()
|
|
except OSError:
|
|
continue
|
|
rest = s[s.rindex(")") + 1:].split()
|
|
if len(rest) >= 39:
|
|
out.append((tid, int(rest[38]), int(rest[37])))
|
|
return out
|
|
|
|
|
|
@test("motion.step-timing-under-load",
|
|
title="Step timing holds while userspace competes for the core",
|
|
subsystem="motion", kind="auto", mode="grbl", est_min=2,
|
|
covers=_MOTION_COVERS, requires=["kernel.latch-locked-idle", "motion.jog-roundtrip"],
|
|
steps=["Bed clear, lid closed; the head needs >= 40 mm of free +X travel."],
|
|
description="The board has one core, so the thread that stamps steps onto the pulse grid "
|
|
"has to outrank ordinary userspace: when its virtual clock slips behind wall "
|
|
"clock past the queue depth, late events clamp forward and the backlog ships "
|
|
"one step per machine tick - a burst no motor follows, while cnc/underruns "
|
|
"stays 0 because the ring never runs dry. Asserts the producer and the shipper "
|
|
"both hold SCHED_FIFO, then drives 2000 mm/min round trips against a deliberate "
|
|
"nice-5 CPU hog and requires the controller to report no clamped events.")
|
|
def step_timing_under_load(ctx):
|
|
import subprocess
|
|
|
|
ev = ctx.evidence
|
|
pid = controller_pid()
|
|
|
|
threads = _thread_sched(pid)
|
|
rt = sorted(prio for _tid, pol, prio in threads if pol == SCHED_FIFO)
|
|
ev["threads"] = len(threads)
|
|
ev["rt_priorities"] = rt
|
|
ctx.log("controller threads: %d, SCHED_FIFO priorities: %s", len(threads), rt)
|
|
ctx.check(len(rt) >= 2,
|
|
"expected the stream producer and the shipper on SCHED_FIFO, found %d of %d "
|
|
"threads at real time (%s): step timing is exposed to ordinary userspace",
|
|
len(rt), len(threads), rt)
|
|
|
|
off = _log_offset(GRBLHAL_LOG)
|
|
load = None
|
|
try:
|
|
# One SCHED_OTHER hog at the same nice as forgectrl's HTTP threads:
|
|
# the realistic competitor, and the one the fix must outrank. The
|
|
# image has no `nice` binary (BusyBox ships renice only), so the
|
|
# niceness is applied from here once the child exists, and the value
|
|
# that actually took is recorded - a hog left at nice 0 would be a
|
|
# harsher test than intended, and one left unset must not pass
|
|
# silently.
|
|
load = subprocess.Popen(["sh", "-c", "while :; do :; done"],
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
try:
|
|
os.setpriority(os.PRIO_PROCESS, load.pid, 5)
|
|
hog_nice = os.getpriority(os.PRIO_PROCESS, load.pid)
|
|
except OSError as exc:
|
|
hog_nice = None
|
|
ctx.log("could not set the hog niceness: %s", exc)
|
|
ev["hog_nice"] = hog_nice
|
|
ctx.check(hog_nice == 5, "CPU hog is at nice %s, expected 5", hog_nice)
|
|
ctx.log("CPU hog started (pid %d, nice %s)", load.pid, hog_nice)
|
|
with ctx.grbl() as g:
|
|
clean_slate(ctx, g)
|
|
ctrl0 = cpu_ticks(pid)
|
|
t0 = time.time()
|
|
legs = 0
|
|
for _i in range(10):
|
|
ctx.checkpoint()
|
|
for jog in ("$J=G91X40F2000", "$J=G91X-40F2000"):
|
|
r = g.command(jog)
|
|
ctx.check(not any(x.startswith("error") for x in r),
|
|
"jog refused under load: %s", r)
|
|
_peak, states, _ = wait_idle(ctx, g, 30)
|
|
ctx.check("TIMEOUT" not in states, "a leg did not return to Idle under load")
|
|
legs += 1
|
|
elapsed = time.time() - t0
|
|
hz = os.sysconf("SC_CLK_TCK")
|
|
ev["legs"] = legs
|
|
ev["motion_s"] = round(elapsed, 1)
|
|
ev["controller_cpu_pct"] = round(100.0 * (cpu_ticks(pid) - ctrl0) / (hz * elapsed), 1)
|
|
ctx.log("%d legs in %.1f s, controller CPU %.1f %%",
|
|
legs, elapsed, ev["controller_cpu_pct"])
|
|
machine_idle(ctx)
|
|
finally:
|
|
if load is not None:
|
|
load.kill()
|
|
load.wait()
|
|
ctx.log("CPU hog stopped")
|
|
|
|
clamped = _log_lines(GRBLHAL_LOG, off, "late events clamped")
|
|
ev["clamp_lines"] = clamped
|
|
ctx.check(not clamped,
|
|
"step generation was starved while userspace competed for the core: %s",
|
|
"; ".join(clamped))
|
|
ctx.log("PASS: %d legs at 2000 mm/min against a nice-5 CPU hog, no clamped events",
|
|
ev["legs"])
|