forgetest: the operator's part asked for by name, and fewer hands in a campaign

A campaign asked a person for about eighty things: lid, button and
interlock actions, app jobs, and sixteen confirmations by eye, most of
them as popups to read and answer while the head was already moving.
This is the forgetest-only step of cutting that down.

The operator channel. A test asks for its operator's part in four ways:
ctx.ready() pre-announces a timed step and waits for the click that
starts it; ctx.notice() is a standing instruction with no button, the
test watching the machine for the result; ctx.act(channel, state) is a
machine action by name (lid, interlock, button) - a notice for the
operator today, proven done by the switch reading or an `until`
condition, recorded in evidence.actions with who performed it, and the
seam a bench actuator plugs into through runner.fixture; ctx.confirm()
stays for the yes/no the evidence cannot answer. Tests declare
`actions`; a `precheck` refuses a start the machine cannot honor (a
reason, no result, a queue skips it and carries on).

The page shows what you will do before it is asked: the running test's
steps, a queue's attended tests still waiting, or the test whose title
you clicked while idle; notices and prompts sit under it. The campaign
card no longer carries baseline, queue and leftover notes: those go to
the runner journal (daemon.log, syslog as `forgetest`, and the run in
progress), with a Runner journal button in the footer.

The catalog, 43 tests (27 auto, 8 operator, 8 live; was 45: 25/12/8):
cloud.mode-switch absorbs cloud.hunt-lid-open and cloud.gfhome-homing
(the connect made with the lid open, the hunt judged lid-open with its
Z cycle, the re-hunt waited out after the close, the switch back, then
$H judged by gfhome's own "homing complete" line with its motion
windows; precheck homing_mode = gfcloud). kernel.fire-line is auto with
the HV-not-good precheck, camera.snapshot is auto (a second frame with
the lid lamp off differs and is smaller), motion.jog-roundtrip is auto
(the head accelerometer per leg, the supervisor's own witness). The
remaining attended tests use Ready gates and act(); the head's beam
detector and the button LEDs replace the eye, leaving two confirms: the
emission witness's mark and the app's display in cloud.pause-resume.

Proof: tests/test_operator.py (the channel, the precheck, the journal),
the cloud replays re-targeted to the merged round trip over a fake grbl
and the bench excerpts, 196 host tests green, coverage lint 0 uncovered.
Every re-ported attended test is owed one bench run on the next dev
image (BRINGUP). Catalog consequence: the merged and reclassified
tests' implementation hashes move; nothing else is invalidated.
This commit is contained in:
ScottW514
2026-08-22 16:36:03 -04:00
parent 58d1ac4746
commit de324cc32f
17 changed files with 1529 additions and 433 deletions
+34 -11
View File
@@ -1,5 +1,7 @@
"""camera.* - the lid camera pipeline through forgectrl."""
from ..catalog import test
from .. import hw
from ..baseline import LID_LAMP_ATTR
_CAM_COVERS = [("forgectrl", "src/cam.*"), ("forgectrl", "src/camhealth.*"),
("forgectrl", "src/debayer.*"), ("forgectrl", "src/vpu_jpeg.*"),
@@ -45,12 +47,14 @@ def _jpeg_size(data):
@test("camera.snapshot", title="Lid camera snapshot and stream", subsystem="camera",
kind="operator", est_min=2,
kind="auto", est_min=2,
covers=_CAM_COVERS, requires=["forgectrl.panel-serves"],
steps=["Lid closed. You will be asked to look at the control panel's Status tab."],
steps=["Setup: lid closed."],
description="/cam/snapshot returns a JPEG of a plausible size (a black frame compresses "
"far smaller), the MJPEG stream starts and stops, and the operator confirms "
"the panel shows the bed.")
"far smaller), a second snapshot with the lid lamp switched off differs from it "
"and is smaller (the capture is live, not a frame the pipeline is re-serving, "
"and the lamp lights what the camera sees), and the MJPEG stream starts and "
"stops.")
def snapshot(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
@@ -66,6 +70,26 @@ def snapshot(ctx):
ev["snapshot_bytes"] = len(data)
ctx.check(len(data) > 20000, "snapshot is only %d bytes - a dark or empty frame?", len(data))
# Live, and lit: the same frame with the lid lamp off must be a
# different and smaller picture. The lamp is put back where it was.
lamp0 = hw.sysfs_read(LID_LAMP_ATTR)
ev["lid_lamp"] = lamp0
ctx.check(lamp0 is not None, "the lid lamp (%s) is not readable", LID_LAMP_ATTR)
try:
hw.sysfs_write(LID_LAMP_ATTR, "0")
ctx.sleep(1.5)
st, dark = fc.get("/cam/snapshot", params={"cam": "lid", "res": "half"}, raw=True)
finally:
hw.sysfs_write(LID_LAMP_ATTR, lamp0)
ctx.check(st == 200 and dark and dark[:2] == b"\xff\xd8", "snapshot with the lamp off -> %s", st)
ev["snapshot_dark_bytes"] = len(dark)
ctx.log("lamp %s -> %d bytes; lamp off -> %d bytes", lamp0, len(data), len(dark))
ctx.check(dark != data, "the snapshot with the lamp off is byte-identical to the lit one: the "
"capture is not live")
ctx.check(len(dark) < len(data), "the frame with the lamp off is not smaller than the lit one "
"(%d vs %d bytes): the lamp is not lighting what the camera "
"sees", len(dark), len(data))
st, data = fc.get("/cam/snapshot", params={"cam": "lid", "res": "full"}, raw=True)
ctx.log("GET /cam/snapshot?cam=lid&res=full -> %s (%d bytes)", st, len(data) if data else 0)
ctx.check(st == 200 and data[:2] == b"\xff\xd8", "full-resolution snapshot -> %s", st)
@@ -87,8 +111,7 @@ def snapshot(ctx):
st, body = fc.get("/cam/status")
ev["cam_status_after"] = body
ctx.log("cam status after: %s", body)
ctx.confirm("Open the control panel (port 8080), Status tab: does the lid snapshot show the bed "
"(not black, not frozen, roughly the right orientation)?")
ctx.log("PASS: snapshot %d bytes, %d with the lamp off, stream %s", len(data), len(dark), ctype)
@test("camera.sensor-profile", title="Camera geometry follows the fitted sensor", subsystem="camera",
@@ -182,8 +205,8 @@ def frame_health(ctx):
@test("camera.lid-privacy", title="Cameras capture only with the lid closed", subsystem="camera",
kind="operator", est_min=3,
covers=_PRIVACY_COVERS, requires=["forgectrl.panel-serves"],
steps=["You will be asked to open the lid, then close it again.",
covers=_PRIVACY_COVERS, requires=["forgectrl.panel-serves"], actions=["lid"],
steps=["Start with the lid closed; you will be told to open it, then close it again.",
"Nothing moves and the laser is not involved."],
description="The privacy gate: with the lid open neither camera captures. A running stream "
"stops within a frame or so of the lid opening, /cam/status reports capture as "
@@ -209,7 +232,7 @@ def lid_privacy(ctx):
return st, data or b""
# --- lid closed: the baseline the rest is measured against ----------
ctx.instruct("Close the lid, then click Done.")
ctx.act("lid", "close")
body = status()
ctx.check(body.get("capture_allowed") is True,
"with the lid closed /cam/status should allow capture, got %r",
@@ -227,7 +250,7 @@ def lid_privacy(ctx):
try:
first = stream.read(4096)
ctx.check(b"\xff\xd8" in first, "the stream did not start before the lid test")
ctx.instruct("The stream is running. Open the lid now, then click Done.")
ctx.act("lid", "open", text="The stream is running.")
# The engine tears the pipeline down on the next frame and the
# stream ends. Draining now returns whatever was buffered before
# the lid opened and then EOF; what must not happen is frames
@@ -283,7 +306,7 @@ def lid_privacy(ctx):
ctx.check(e.code == 409, "the stream should be refused with 409, got %s", e.code)
# --- closing the lid restores it -----------------------------------
ctx.instruct("Close the lid again, then click Done.")
ctx.act("lid", "close")
body = status()
ctx.check(body.get("capture_allowed") is True,
"closing the lid should allow capture again, got %r",
+211 -176
View File
@@ -1,7 +1,8 @@
"""cloud.* - the controller mode switch and the optional Glowforge web-service
mode (gfcloud daemon, gfhome homing runner). The mode-switch test makes the
grbl -> cloud -> grbl round trip; the job-behavior tests run in cloud mode
and leave the machine there (see enter_cloud)."""
grbl -> cloud -> grbl round trip with the connect-time hunt run lid-open and
the web-service homing ($H) on the way back; the job-behavior tests run in
cloud mode and leave the machine there (see enter_cloud)."""
import json
import os
import time
@@ -95,20 +96,110 @@ def wait_mode(ctx, fc, want_mode, want_controller="running", timeout=90):
return last
@test("cloud.mode-switch", title="Controller mode switch grbl -> cloud -> grbl", subsystem="cloud",
kind="auto", mode="grbl", est_min=5,
covers=_CLOUD_COVERS + [("forgectrl", "src/cool.*"), ("forgectrl", "src/airflow.*")],
requires=["forgectrl.auth", "motion.pacing"],
steps=["Bed clear: the cloud client homes the head to the corner on connect (the factory "
"hunt) and the test jogs it back to where it started afterward. Cloud credentials "
"configured; the machine on the network."],
description="POST /mode switches to the cloud controller: gfcloud comes up under "
"supervision, authenticates and establishes its service session (its own log "
"lines are the evidence; the connect-time firmware probe is recorded when "
"configured); its connect-time hunt, which runs with the extraction fans off, "
GFHOME_LOG = "/data/log/forgefirm/gfhome/gfhome.log"
HOMING_TIMEOUT_S = 600
def homing_mode_is_gfcloud():
"""Precheck: the web-service homing needs homing_mode = gfcloud."""
try:
hm = (hw.Forgectrl().settings() or {}).get("homing_mode")
except hw.HwError as e:
return "forgectrl unreachable: %s" % e
if hm != "gfcloud":
return "homing_mode is %r; the web-service homing needs gfcloud" % (hm,)
return None
def judge_hunt_with_lid_open(ctx, ev, offset):
"""The connect-time hunt from `offset` on: its terminal line is
:completed, nothing before it was refused for the lid, and the lens
homed (the Z cycle is the part of a hunt the lid would have gated).
Returns the hunt's terminal line."""
hunt_line = wait_action_finished(ctx, offset, "hunt", HUNT_TIMEOUT_S)
ev["hunt_line"] = message(hunt_line)
ctx.check(hunt_line, "the service sent no hunt (or it never finished) within %d s of the session",
HUNT_TIMEOUT_S)
lines = log_lines_since(GFCLOUD_LOG, offset)
hunt_i = action_finish_index(lines, "hunt")
refused = [ln for ln in lines[:hunt_i] if "unsafe to move" in ln]
ev["refusals_before_hunt_end"] = len(refused)
ctx.check(not refused, "the hunt was refused for the lid (%d 'unsafe to move')", len(refused))
ctx.check(COMPLETED in hunt_line, "the hunt did not complete: %s", ev["hunt_line"])
ev["lens_homed"] = any("starting z homing cycle" in ln for ln in lines[:hunt_i])
ctx.check(ev["lens_homed"], "the hunt did not home the lens (no Z homing cycle before its end)")
ctx.log("hunt with the lid open: %s (lens homed)", ev["hunt_line"])
return hunt_line
def gfhome_homing(ctx, ev, g):
"""$H in grbl mode with homing_mode = gfcloud: gfhome runs the
web-service homing session with its own head-accelerometer motion
witness. Homed within the session timeout, and gfhome's own
completion line (with its count of motion windows) is the proof the
head traveled under the service's corrections."""
st = g.status_report()["state"]
ctx.check(st.startswith("Idle") or st.startswith("Alarm"), "controller is %s", st)
if st.startswith("Alarm"):
g.command("$X")
home_offset = log_size(GFHOME_LOG)
t0 = time.time()
g.send_raw(b"$H\n")
homed = False
state = gs = None
while time.time() - t0 < HOMING_TIMEOUT_S:
ctx.checkpoint()
s = ctx.forgectrl.status()
state = s.get("state")
try:
gs = g.status_report()["state"]
except hw.HwError:
gs = "?"
if s.get("homed") and gs.startswith("Idle"):
homed = True
break
if gs.startswith("Alarm"):
break
time.sleep(2)
ev["homing_s"] = round(time.time() - t0, 1)
ev["homed"] = homed
ctx.log("homing: homed=%s after %.1f s (kernel %s, grbl %s)", homed, ev["homing_s"], state, gs)
ctx.check(homed, "homing did not complete (grbl %s)", gs)
done = [ln.strip()[:160] for ln in log_lines_since(GFHOME_LOG, home_offset) if "homing complete" in ln]
ev["gfhome_complete"] = done[-1] if done else None
ctx.log("gfhome: %s", ev["gfhome_complete"])
ctx.check(done, "gfhome logged no 'homing complete' line (its accelerometer witness never saw the "
"head move, or the session ended another way)")
# homed: the counters are re-anchored at the corner, where the head stays
ctx.counters_rezeroed()
ctx.check(ctx.forgectrl.wait_idle(15, abort=ctx.aborted), "machine not idle after homing")
@test("cloud.mode-switch", title="Controller mode switch grbl -> cloud -> grbl, the connect-time hunt "
"with the lid open, and the web-service homing",
subsystem="cloud", kind="operator", mode="grbl", est_min=10,
covers=_CLOUD_COVERS + [("forgectrl", "src/cool.*"), ("forgectrl", "src/airflow.*"),
("grblhal-glowforge", "src/**")],
requires=["forgectrl.auth", "motion.pacing"], actions=["lid"],
precheck=homing_mode_is_gfcloud,
steps=["Bed clear; cloud credentials configured and homing_mode = gfcloud; the machine on "
"the network.",
"Open the lid when told and leave it open through the cloud client's connect and its "
"hunt; close it when told. Nothing else: the switch back and the $H homing run on "
"their own, and the head ends parked at the home corner."],
description="One round trip with the two service-driven motions on it. POST /mode switches "
"to the cloud controller: gfcloud comes up under supervision, authenticates and "
"establishes its service session (its own log lines are the evidence; the "
"connect-time firmware probe is recorded when configured). Its connect-time hunt "
"runs with the lid OPEN, as the factory's does - it completes, nothing before its "
"end is refused for the lid, the lens homes - and with the extraction fans off it "
"is measured by the airflow gates but not judged (no AIRFLOW, the exhaust row "
"reads unjudged); the camera service survives the switch; switching back brings "
"grblHAL up with the Grbl port open and Idle, and the head returns to its start.")
"reads unjudged); the camera service survives the switch. The lid closed, the "
"service's re-hunt is waited out; switching back brings grblHAL up with the Grbl "
"port open and Idle, and the head returns to its start. Then $H with "
"homing_mode = gfcloud runs gfhome, the web-service homing session with the "
"head-accelerometer motion witness: the controller returns to Idle with "
"homed:true and gfhome reports the homing complete with the motion it saw.")
def mode_switch(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
@@ -124,9 +215,10 @@ def mode_switch(ctx):
probe_before = os.stat(GF_LATEST).st_mtime
except OSError:
pass
log_offset = log_size(GFCLOUD_LOG)
lamp0 = hw.sysfs_read("pic/lid_led")
ctx.act("lid", "open", text="Leave it open: the cloud client connects and hunts with the lid open.")
log_offset = log_size(GFCLOUD_LOG)
st, body = fc.post("/mode", data={"controller": "cloud"})
ctx.log("POST /mode controller=cloud -> %s %s", st, body)
ctx.check(st == 200, "mode switch to cloud refused: %s %s", st, body)
@@ -136,15 +228,8 @@ def mode_switch(ctx):
ctx.check(m and m.get("mode") == "cloud" and m.get("controller") == "running",
"cloud controller did not come up: %s", m)
# the client's own session lines are the evidence of a live cloud session
t0 = time.time()
session = []
session = wait_session(ctx, log_offset)
probe = None
while time.time() - t0 < 120:
ctx.checkpoint()
session = session_lines(GFCLOUD_LOG, log_offset)
if session_established(session):
break
time.sleep(2)
ev["session"] = session
for ln in session:
ctx.log(" gfcloud: %s", ln.split(" ", 1)[-1] if " " in ln else ln)
@@ -157,6 +242,9 @@ def mode_switch(ctx):
pass
ev["gf_probe"] = probe
ctx.log("cloud session established: %s; firmware probe: %s", session_established(session), probe)
ctx.check(session_established(session),
"the cloud client never established its service session (no credentials, no "
"network, or the service refused) - cloud mode not proven")
st, cam1 = fc.get("/cam/status")
ev["cam_during_cloud"] = cam1
ctx.check(st == 200 and isinstance(cam1, dict), "camera status lost during cloud mode (%s)", st)
@@ -180,6 +268,14 @@ def mode_switch(ctx):
ctx.check(exh and all(g.get("reading", 0) < g.get("floor", 0) for g in exh),
"the exhaust was not off during the hunt (readings %s), so the unjudged state "
"proves nothing", sorted({g.get("reading") for g in exh}))
# ...and it ran with the lid open, as the factory's does
judge_hunt_with_lid_open(ctx, ev, log_offset)
ctx.act("lid", "close", text="The service now re-finds the head: several moves with lid images "
"between them, which the test waits out.")
settle_cloud(ctx, log_offset)
lines = log_lines_since(GFCLOUD_LOG, log_offset)
ev["motions_after_lid_close"] = sum(1 for ln in lines if "motion [" in ln and COMPLETED in ln)
ctx.log("%d service motion(s) completed after the lid closed", ev["motions_after_lid_close"])
st, body = fc.post("/mode", data={"controller": "grbl"})
ctx.log("POST /mode controller=grbl -> %s %s", st, body)
@@ -197,9 +293,6 @@ def mode_switch(ctx):
ev["grbl_state"] = st
ctx.log("grbl state after: %s", st)
ctx.check(st.startswith("Idle") or st.startswith("Alarm"), "grbl reports %s", st)
ctx.check(session_established(session),
"the cloud client never established its service session (no credentials, no "
"network, or the service refused) - cloud mode not proven")
st, cam2 = fc.get("/cam/status")
ev["cam_after"] = cam2
ctx.check(st == 200, "camera status lost after the switch back")
@@ -214,57 +307,13 @@ def mode_switch(ctx):
hw.sysfs_write("pic/lid_led", lamp0)
ctx.log("lid lamp: cloud mode left %s, restored %s", lamp1, lamp0)
@test("cloud.gfhome-homing", title="Glowforge web-service homing ($H with homing_mode=gfcloud)",
subsystem="cloud", kind="operator", mode="grbl", est_min=5,
covers=_CLOUD_COVERS + [("grblhal-glowforge", "src/**")], requires=[],
steps=["homing_mode = gfcloud and cloud credentials configured; bed clear, lid closed.",
"Watch the gantry: the service drives it to the corner with camera corrections.",
"The machine ends homed, the head parked at the home corner (the position "
"counters are re-anchored there)."],
description="In grbl mode, $H runs gfhome: the web-service homing session with the "
"head-accelerometer motion witness. The controller returns to Idle with "
"homed:true within the session timeout, and the operator confirms the head "
"reached the home corner.")
def gfhome_homing(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
settings = fc.settings()
hm = settings.get("homing_mode")
ev["homing_mode"] = hm
ctx.check(hm == "gfcloud", "homing_mode is %r, this test needs gfcloud", hm)
ctx.instruct("Bed clear, lid closed, head anywhere. Watch the gantry during homing.")
# -- the web-service homing from grbl mode --------------------------------
ev["homing_mode"] = (fc.settings() or {}).get("homing_mode")
ctx.check(ev["homing_mode"] == "gfcloud", "homing_mode is %r; $H needs gfcloud", ev["homing_mode"])
with ctx.grbl() as g:
st = g.status_report()["state"]
ctx.check(st.startswith("Idle") or st.startswith("Alarm"), "controller is %s", st)
if st.startswith("Alarm"):
g.command("$X")
t0 = time.time()
g.send_raw(b"$H\n")
homed = False
state = None
while time.time() - t0 < 600:
ctx.checkpoint()
s = fc.status()
state = s.get("state")
try:
gs = g.status_report()["state"]
except hw.HwError:
gs = "?"
if s.get("homed") and gs.startswith("Idle"):
homed = True
break
if gs.startswith("Alarm"):
break
time.sleep(2)
ev["homing_s"] = round(time.time() - t0, 1)
ev["homed"] = homed
ctx.log("homing: homed=%s after %.1f s (kernel %s, grbl %s)", homed, ev["homing_s"], state, gs)
ctx.check(homed, "homing did not complete (grbl %s)", gs)
ctx.confirm("Did the head travel to the home corner under camera corrections and stop there?")
# homed: the counters are re-anchored at the corner, where the head stays
ctx.counters_rezeroed()
ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle after homing")
gfhome_homing(ctx, ev, g)
ctx.log("PASS: grbl -> cloud (session, hunt with the lid open, lens homed, airflow unjudged) -> "
"grbl (port open, %s), then $H homed in %.1f s", ev["grbl_state"], ev["homing_s"])
# ---- lid / button behavior of a cloud job (the factory's) ------------------
@@ -339,6 +388,12 @@ def wait_log(ctx, offset, needles, timeout, poll=0.5):
return found
def log_has(offset, needle):
"""True when the gfcloud log carries needle since offset (one read;
the condition an `act` waits on)."""
return any(needle in ln for ln in log_lines_since(GFCLOUD_LOG, offset))
def action_finish_index(lines, action):
"""Index of the first '<action> [id]: finished with event ...' line, or None."""
return next((i for i, ln in enumerate(lines)
@@ -569,8 +624,12 @@ COMPLETED = 'finished with event ":completed"'
PAUSE_LINES = ("button pressed mid-run; pausing", "paused at")
RESUME_LINES = ("button pressed while paused", "resuming (laser lead")
RESUME_REFUSED = "resume refused"
# A button press during a print is seen through the run loop's own line;
# the judgment of what it did (check_pause_resume) follows the wait.
PRESS_TIMEOUT_S = 90
CLOUD_STEP = ("Cloud credentials configured; the machine in cloud mode (the test switches once from "
"GRBL mode and stays in cloud mode; switch back on the panel when done).")
LID_STEP = "Lid and interlock steps are standing instructions: the test watches the switches."
def judge_abort_tail(ctx, ev, offset, tag, fin):
@@ -598,9 +657,10 @@ def judge_abort_tail(ctx, ev, offset, tag, fin):
"an open lid",
subsystem="cloud", kind="live", est_min=11,
covers=_CLOUD_COVERS + [("forgectrl", "src/super.c")],
requires=["laser.emission-witness"],
steps=[CLOUD_STEP, "The app open in a browser; scrap on the bed and a small engrave/score job "
"ready - the test runs TWO prints.",
requires=["laser.emission-witness"], actions=["button", "lid", "interlock"],
steps=[CLOUD_STEP, LID_STEP,
"The app open in a browser; scrap on the bed and a small engrave/score job "
"ready - the test runs TWO prints.",
"Be able to open the remote-interlock loop for the second print: unplug the Pro's "
"interlock plug, or pull the jumper at J8 on a Basic/Plus. Restore it at the end.",
"Print 1: press the button to start, open the lid a few seconds in. Print 2: press the "
@@ -622,8 +682,8 @@ def lid_interlock_abort(ctx):
ctx.instruct(APP_PRINT_CUE)
got = wait_print_running(ctx, offset, 300)
ctx.check(got, "print 1 never reached its run within 300 s (not started, or the button not pressed)")
ctx.instruct("The head is moving. Open the lid NOW, then click Done. Leave it open until the head "
"has returned to the corner.")
ctx.act("lid", "open", text="The head is moving: leave the lid open until the head has returned "
"to the corner.", timeout=60)
needles = ["lid opened", "lid opened mid-run; stopping motion", "start return home",
"return home complete"]
got = wait_log(ctx, offset, needles, 90)
@@ -652,9 +712,7 @@ def lid_interlock_abort(ctx):
ctx.check(got["start return home"] and got["return home complete"],
"the park did not run to completion with the lid open")
judge_abort_tail(ctx, ev, offset, "lid", fin)
ctx.confirm("Did the head stop as soon as the lid opened and go straight home with the lid "
"still open, and does the app show the print as cancelled?")
ctx.instruct("Close the lid, then click Done.")
ctx.act("lid", "close")
settle_cloud(ctx, offset)
# -- print 2: the interlock, with the lid opened during the park ---------
@@ -662,8 +720,7 @@ def lid_interlock_abort(ctx):
ctx.instruct(APP_PRINT_CUE)
got = wait_print_running(ctx, offset, 300)
ctx.check(got, "print 2 never reached its run within 300 s (not started, or the button not pressed)")
ctx.instruct("The head is moving. Open the INTERLOCK loop now (unplug it / pull the jumper), then "
"click Done.")
ctx.act("interlock", "open", text="The head is moving.", timeout=60)
sw = (ctx.forgectrl.status().get("switches") or {})
ev["interlock_ok_after_pull"] = sw.get("interlock_ok")
ctx.check(sw.get("interlock_ok") is False,
@@ -672,8 +729,8 @@ def lid_interlock_abort(ctx):
got = wait_log(ctx, offset, [stop_line, "start return home"], 90)
ctx.check(got[stop_line], "the interlock open did not stop the run")
ctx.check(got["start return home"], "the abort did not start the return home")
ctx.instruct("The head is on its way back. Open the LID now as well, then click Done - leave both "
"open until the head has stopped.")
ctx.act("lid", "open", text="The head is on its way back: leave both open until it has stopped.",
timeout=60)
done = wait_log(ctx, offset, ["return home complete"], 90)
fin = wait_action_finished(ctx, offset, "print", 60)
ev["interlock_log"] = {stop_line: message(got[stop_line]),
@@ -689,9 +746,8 @@ def lid_interlock_abort(ctx):
ctx.check(sw.get("lid") is False,
"the lid was not open at the end of the park - the park's immunity was not exercised")
judge_abort_tail(ctx, ev, offset, "interlock", fin)
ctx.confirm("Did the head stop when the interlock opened and go back to the corner without the "
"open lid interrupting it, and does the app show the print as cancelled?")
ctx.instruct("Close the lid and restore the interlock loop (plug/jumper back in), then click Done.")
ctx.act("lid", "close")
ctx.act("interlock", "close")
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")
@@ -703,9 +759,10 @@ def lid_interlock_abort(ctx):
@test("cloud.lid-during-button-wait", title="Lid open at the cloud button prompt cancels the print",
subsystem="cloud", kind="operator", est_min=6,
covers=_CLOUD_COVERS, requires=[],
steps=[CLOUD_STEP, "The app open; any small job ready (nothing will fire).",
"Print from the app; when the button lights white, do NOT press it - open the lid."],
covers=_CLOUD_COVERS, requires=[], actions=["lid"],
steps=[CLOUD_STEP, LID_STEP, "The app open; any small job ready (nothing will fire).",
"Print from the app; when the button lights white, do NOT press it - open the lid, and "
"close it when told."],
description="A cloud print waiting for the button is cancelled by the lid: the wait ends "
"with the lid named as the reason, the laser latch relocks, the armed window "
"closes, no run starts, and the job ends ':cancelled'.")
@@ -716,7 +773,7 @@ def lid_during_button_wait(ctx):
"Print in the app. When the button lights white, do NOT press it.")
got = wait_log(ctx, offset, ["waiting for button"], 300)
ctx.check(got["waiting for button"], "the print never reached the button wait")
ctx.instruct("The button is lit. Open the lid now (do not press the button), then click Done.")
ctx.act("lid", "open", text="The button is lit: do NOT press it.", timeout=120)
relock = "button wait lid opened - relocking the laser"
got = wait_log(ctx, offset, [relock], 60)
fin = wait_action_finished(ctx, offset, "print", 60)
@@ -742,62 +799,22 @@ def lid_during_button_wait(ctx):
ev["latch_locked"] = latch_locked()
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.confirm("Did the button go dark when the lid opened, with no motion, and does the app show "
"the print as cancelled?")
ctx.instruct("Close the lid, then click Done.")
ev["button_dark"] = hw.button_lit()
ctx.check(ev["button_dark"] is False, "the button is still lit after the cancel (%s)", ev["button_dark"])
ctx.act("lid", "close")
settle_cloud(ctx, offset)
ctx.log("PASS: lid open at the button prompt cancelled the print; latch locked, armed=false")
@test("cloud.hunt-lid-open", title="A cloud hunt runs with the lid open",
subsystem="cloud", kind="operator", est_min=5,
covers=_CLOUD_COVERS + [("forgectrl", "src/super.c")], requires=[],
steps=[CLOUD_STEP, "Bed clear.",
"Open the lid when asked and leave it open through the connect-time hunt of the fresh "
"cloud client the test starts (in cloud mode the client is restarted; from GRBL mode "
"the switch is made)."],
description="The service's connect-time hunt (lens homing plus its XY hunt) is not gated by "
"the lid: it runs and reports ':completed' with the lid open, as the factory's does. "
"The service's moves after the lid closes again are waited out.")
def hunt_lid_open(ctx):
ev = ctx.evidence
ctx.instruct("Open the lid and leave it open, then click Done.")
sw = (ctx.forgectrl.status().get("switches") or {})
ev["lid_before"] = sw.get("lid")
ctx.check(not sw.get("lid"), "the lid reads closed (%s)", sw)
offset = fresh_cloud_connect(ctx)
# The hunt's own terminal line ("hunt [id]: finished with event ..."):
# it must be :completed, and no lid refusal may precede it. Service
# motions AFTER the hunt are rightly refused with the lid open and
# are outside this window.
hunt_line = wait_action_finished(ctx, offset, "hunt", HUNT_TIMEOUT_S)
ev["hunt_line"] = message(hunt_line)
ctx.check(hunt_line, "the service sent no hunt (or it never finished) within %d s of the session",
HUNT_TIMEOUT_S)
lines = log_lines_since(GFCLOUD_LOG, offset)
hunt_i = action_finish_index(lines, "hunt")
refused = [ln for ln in lines[:hunt_i] if "unsafe to move" in ln]
ev["refusals_before_hunt_end"] = len(refused)
ctx.check(not refused, "the hunt was refused for the lid (%d 'unsafe to move')", len(refused))
ctx.check(COMPLETED in hunt_line, "the hunt did not complete: %s", ev["hunt_line"])
ctx.log("hunt with the lid open: %s", ev["hunt_line"])
ctx.confirm("Did the lens home (Z motion) with the lid open, with no error in the app?")
ctx.instruct("Close the lid, then click Done. (The service now re-finds the head: several "
"moves with lid images between them - the test waits them out.)")
settle_cloud(ctx, offset)
lines = log_lines_since(GFCLOUD_LOG, offset)
ev["motions_after_lid_close"] = sum(1 for ln in lines if "motion [" in ln and COMPLETED in ln)
ctx.log("PASS: the connect-time hunt ran and completed with the lid open; %d service motion(s) "
"completed after the lid closed", ev["motions_after_lid_close"])
ctx.log("PASS: lid open at the button prompt cancelled the print; latch locked, armed=false, "
"button dark")
@test("cloud.pause-resume", title="Button pauses and resumes a cloud print (factory backtrack + lead)",
subsystem="cloud", kind="live", est_min=8,
covers=_CLOUD_COVERS + [("forgectrl", "src/main.c")],
requires=["laser.emission-witness"],
requires=["laser.emission-witness"], actions=["button"],
steps=[CLOUD_STEP, "The app open; scrap on the bed and a small engrave/score job (about 60 s) ready.",
"Print from the app and press the button when it lights; a few seconds into the run "
"press it again (pause), wait ~3 s, press again (resume); let the job finish."],
"the test asks for a press (pause) and, once the head has stopped, another (resume); "
"let the job finish and confirm it in the app."],
description="Pressing the button during a cloud print pauses it the factory way - controlled "
"stop, backtrack with the laser off, print:paused - and the next press resumes "
"with the laser-off lead, print:resumed; the job then completes and parks. The "
@@ -813,9 +830,13 @@ def pause_resume(ctx):
ctx.instruct(APP_PRINT_CUE)
got = wait_print_running(ctx, offset, 300)
ctx.check(got, "the print never reached its run within 300 s (not started, or the button not pressed)")
ctx.instruct("The head is moving. Press the button once NOW (pause), watch the head stop and back up "
"a few millimeters, wait about 3 seconds, press it again (resume), then click Done.")
got = wait_log(ctx, offset, list(PAUSE_LINES + RESUME_LINES), 90)
ctx.act("button", "press", text="The head is moving: the press pauses the print.",
until=lambda: log_has(offset, PAUSE_LINES[0]), timeout=PRESS_TIMEOUT_S, fail=False)
ctx.act("button", "press", text="The print is paused (the head backed up a few millimeters): "
"the press resumes it.",
until=lambda: log_has(offset, RESUME_LINES[0]) or log_has(offset, RESUME_REFUSED),
timeout=PRESS_TIMEOUT_S, fail=False)
got = wait_log(ctx, offset, list(PAUSE_LINES + RESUME_LINES), 10)
ev["log"] = {k: bool(v) for k, v in got.items()}
check_pause_resume(ctx, got, offset)
st, cs = ctx.forgectrl.get("/cool/status")
@@ -832,8 +853,6 @@ def pause_resume(ctx):
if "relocking the laser" in ln or ("print [" in ln and CANCELLED in ln)]
ev["relock_or_cancel_lines"] = len(relocked)
ctx.check(not relocked, "the pause relocked or cancelled the job (%s)", relocked[:2])
ctx.confirm("Did the head stop and back up a few millimeters (laser off) on the first press, "
"resume on the second, and did the job finish and the app show it complete?")
# The job's lifecycle, from the same print: a warm-up before the first
# fire and a rest after the park are equipment protection the service
@@ -871,8 +890,8 @@ def pause_resume(ctx):
ctx.log("progress reported against %s bytes", declared)
ctx.check(declared is not None, "the print reported no progress at all")
ctx.check(declared and declared > 0, "the print reported progress against %s bytes", declared)
ctx.confirm("Did the app show the print's progress advancing while it cut, rather than "
"standing still or jumping straight to nearly finished?")
ctx.confirm("In the app: did the print's progress advance while it cut (not standing still, "
"not jumping straight to nearly finished), and does the app show it complete?")
# The job's envelope, from the same print: the client derives the
# limits the header carries (a cut job carries the coolant window and
@@ -902,13 +921,14 @@ def pause_resume(ctx):
@test("cloud.oversize-stream", title="A print longer than the ring is fed while it plays",
subsystem="cloud", kind="live", est_min=12,
covers=_CLOUD_COVERS,
requires=["cloud.lid-interlock-abort", "cloud.pause-resume"],
requires=["cloud.lid-interlock-abort", "cloud.pause-resume"], actions=["button"],
steps=[CLOUD_STEP,
"Scrap on the bed and a LONG job ready in the app - one whose run time is longer "
"than the ring holds (over an hour at the usual print tick). A full-bed raster "
"engrave is the easy way to get one.",
"Print from the app and press the button when it lights. Let it cut for about two "
"minutes, pause and resume it with the button, then cancel the print from the app."],
"Print from the app and press the button when it lights. The test lets it cut for "
"about two minutes, then asks for a press (pause) and another (resume), then for the "
"cancel from the app."],
description="The service sends one pulse file for a print however long it is, and a long one "
"is several times the ring: the machine holds the job in memory, fills the ring, "
"starts, and tops the ring up as it drains. This checks the signature of that - "
@@ -939,7 +959,11 @@ def oversize_stream(ctx):
# keeps growing while it plays.
first = read_program_total()
ctx.log("program total at the start of the run: %s bytes", first)
ctx.instruct("Let it cut for about two minutes, then click Done (do not cancel yet).")
ctx.notice("Cutting: the test watches the ring being topped up for two minutes. Do nothing yet.")
try:
ctx.sleep(120)
finally:
ctx.clear_notice()
grown = read_program_total()
ev["total_first"], ev["total_later"] = first, grown
ctx.log("program total two minutes in: %s bytes (+%s)", grown, (grown or 0) - (first or 0))
@@ -967,9 +991,13 @@ def oversize_stream(ctx):
# streamed print retraces and leads back on exactly like a preloaded one.
ev["max_backtrack"] = hw.sysfs_int("cnc/max_backtrack", 0)
ctx.log("max_backtrack while the feed runs: %s steps", ev["max_backtrack"])
ctx.instruct("Press the button once (pause), watch the head stop and back up a few "
"millimeters, wait about 3 seconds, press it again (resume), then click Done.")
got = wait_log(ctx, offset, list(PAUSE_LINES + RESUME_LINES), 90)
ctx.act("button", "press", text="The press pauses the live-fed print.",
until=lambda: log_has(offset, PAUSE_LINES[0]), timeout=PRESS_TIMEOUT_S, fail=False)
ctx.act("button", "press", text="The print is paused (the head backed up a few millimeters): "
"the press resumes it.",
until=lambda: log_has(offset, RESUME_LINES[0]) or log_has(offset, RESUME_REFUSED),
timeout=PRESS_TIMEOUT_S, fail=False)
got = wait_log(ctx, offset, list(PAUSE_LINES + RESUME_LINES), 10)
ev["pause_log"] = {k: bool(v) for k, v in got.items()}
check_pause_resume(ctx, got, offset, what="the live-fed run")
backtracked = [ln for ln in log_lines_since(GFCLOUD_LOG, offset)
@@ -978,11 +1006,12 @@ def oversize_stream(ctx):
ctx.check(not backtracked, "the live-fed pause could not back up (%s)", backtracked[:1])
ctx.check(hw.sysfs_int("cnc/underruns", 0) == before,
"the pause or the resume starved the ring")
ctx.confirm("Did the head back up a few millimeters with the laser off on the first press, "
"and pick the cut back up on the second?")
ctx.instruct("Now cancel the print from the app.")
fin = wait_action_finished(ctx, offset, "print", 300)
ctx.notice("Now cancel the print from the app. The test watches for the cancel.")
try:
fin = wait_action_finished(ctx, offset, "print", 300)
finally:
ctx.clear_notice()
ev["print_finished"] = message(fin)
ctx.check(fin, "the cancelled print did not finish within 300 s")
ctx.check(CANCELLED in fin, "the cancelled print did not report cancelled: %s", message(fin))
@@ -998,11 +1027,13 @@ def oversize_stream(ctx):
"by the app",
subsystem="cloud", kind="live", est_min=12,
covers=_CLOUD_COVERS, requires=["cloud.pause-resume", "cloud.lid-interlock-abort"],
steps=[CLOUD_STEP, "The app open in a browser; scrap on the bed and a small engrave/score job "
"ready - the test runs TWO prints.",
"Print 1: press the button to start, press it again a few seconds in (pause), then open "
"the lid.",
"Print 2: press the button to start, then cancel the print from the app while it runs."],
actions=["button", "lid"],
steps=[CLOUD_STEP, LID_STEP,
"The app open in a browser; scrap on the bed and a small engrave/score job "
"ready - the test runs TWO prints.",
"Print 1: press the button to start; when asked, press it again (pause), then open "
"the lid and leave it open until the head is back; close it when told.",
"Print 2: press the button to start, then cancel the print from the app when asked."],
description="The two ways a print ends other than finishing, each from the state the factory ends "
"it in: a job paused on the button is cancelled by the lid - there is no resume past a "
"lid open - and a running job is cancelled from the app. Both take the same tail: the "
@@ -1016,14 +1047,14 @@ def pause_cancel_paths(ctx):
ctx.instruct(APP_PRINT_CUE)
got = wait_print_running(ctx, offset, 300)
ctx.check(got, "print 1 never reached its run within 300 s (not started, or the button not pressed)")
ctx.instruct("The head is moving. Press the button once NOW (pause), watch it stop and back up a "
"few millimeters, then click Done.")
got = wait_log(ctx, offset, list(PAUSE_LINES), 90)
ctx.act("button", "press", text="The head is moving: the press pauses the print.",
until=lambda: log_has(offset, PAUSE_LINES[0]), timeout=PRESS_TIMEOUT_S, fail=False)
got = wait_log(ctx, offset, list(PAUSE_LINES), 10)
ev["paused"] = {k: bool(v) for k, v in got.items()}
ctx.check(got["button pressed mid-run; pausing"], "the press did not pause print 1")
ctx.check(got["paused at"], "the pause did not settle (no 'paused at')")
ctx.instruct("The print is paused. Open the lid NOW, then click Done - leave it open until the head "
"has returned to the corner.")
ctx.act("lid", "open", text="The print is paused: leave the lid open until the head has returned "
"to the corner.", timeout=60)
lid_stop = "lid opened mid-run; stopping motion"
got = wait_log(ctx, offset, [lid_stop, "start return home", "return home complete"], 120)
fin1 = wait_action_finished(ctx, offset, "print", 60)
@@ -1046,7 +1077,7 @@ def pause_cancel_paths(ctx):
ctx.check(not ev["armed_after_print1"], "armed window still open after the paused print was cancelled")
ctx.check(ev["latch_locked_after_print1"],
"kernel latch not locked after the paused print was cancelled")
ctx.instruct("Close the lid, then click Done.")
ctx.act("lid", "close")
settle_cloud(ctx, offset)
# -- print 2: cancelled from the app ------------------------------------
@@ -1054,9 +1085,12 @@ def pause_cancel_paths(ctx):
ctx.instruct(APP_PRINT_CUE)
got = wait_print_running(ctx, offset, 300)
ctx.check(got, "print 2 never reached its run within 300 s (not started, or the button not pressed)")
ctx.instruct("The head is moving. Cancel the print from the app now, then click Done.")
ctx.notice("The head is moving: cancel the print from the app now. The test watches for it.")
svc_stop = "action cancelled mid-run; stopping motion"
got = wait_log(ctx, offset, [svc_stop, "start return home", "return home complete"], 120)
try:
got = wait_log(ctx, offset, [svc_stop, "start return home", "return home complete"], 120)
finally:
ctx.clear_notice()
fin2 = wait_action_finished(ctx, offset, "print", 60)
ev["service_cancel"] = {k: message(v) for k, v in got.items()}
ev["service_cancel"]["print finished"] = message(fin2)
@@ -1076,7 +1110,8 @@ def pause_cancel_paths(ctx):
ev["latch_locked_after_print2"] = latch_locked()
ctx.check(not ev["armed_after_print2"], "armed window still open after the app cancel")
ctx.check(ev["latch_locked_after_print2"], "kernel latch not locked after the app cancel")
ctx.confirm("Did both prints end back at the corner, dark, and does the app show both as cancelled?")
ev["button_dark"] = hw.button_lit()
ctx.check(ev["button_dark"] is False, "the button is still lit after the cancel (%s)", ev["button_dark"])
settle_cloud(ctx, offset)
ctx.log("PASS: a paused print cancelled by the lid and a running print cancelled from the app both "
"stopped, parked, relocked and reported ':cancelled'")
+22 -14
View File
@@ -130,17 +130,25 @@ class PulseDevice:
return False
def require_hv_not_good(ctx):
"""K3 and fire B/U unlock the latch: refuse while HV reports good.
Called before the takeover; gives the operator one chance to drop it
(open the lid)."""
def hv_not_good():
"""Precheck for the drills that unlock the latch with a zero-duty
stream: they run only while the HV supply does NOT report good. At
idle the chain holds HV_ENABLE low, so this refuses a start only on a
machine that is not idle the way it should be; the cure is the lid
(the safety chain holds HV off with it open)."""
pgood = rd("cnc/laser_pgood")
if pgood is None:
return "cnc/laser_pgood unreadable"
if pgood != "0":
ctx.log("laser_pgood=%s: the HV supply reports good", pgood)
ctx.instruct("This drill unlocks the laser latch with a zero-duty stream and must run "
"with the HV supply NOT good. Open the lid (the safety chain holds HV off), "
"then Done.")
pgood = rd("cnc/laser_pgood")
return ("laser_pgood=%s: the HV supply reports good; this drill unlocks the latch with a "
"zero-duty stream and needs HV not good (open the lid, then start again)" % pgood)
return None
def require_hv_not_good(ctx):
"""The same rule at the start of the run (the precheck ran a moment
earlier; the machine must still agree)."""
pgood = rd("cnc/laser_pgood")
ctx.evidence["laser_pgood"] = pgood
ctx.check(pgood == "0", "laser_pgood=%s (HV supply reports good) - refusing the latch unlock", pgood)
@@ -508,12 +516,12 @@ def _fire_phase(ctx, mode):
@test("kernel.fire-line", title="FIRE line: latch locked, unlocked-unarmed, true underrun, and a "
"mid-run unlock",
subsystem="kernel", kind="operator", hardware="takeover", always=True, est_min=4,
subsystem="kernel", kind="auto", hardware="takeover", always=True, est_min=4,
covers=_KERNEL_COVERS,
requires=["kernel.k1-k2"],
steps=["Phases B, U and K3 unlock the latch with a zero-duty stream: if the HV supply "
"reports good the drill asks you to open the lid first (the safety chain holds HV "
"off)."],
requires=["kernel.k1-k2"], precheck=hv_not_good,
steps=["Phases B, U and K3 unlock the latch with a zero-duty stream, so the drill starts only "
"while the HV supply does not report good (true at idle; if it is refused, open the "
"lid - the safety chain holds HV off - and start it again)."],
description="Four phases behind one takeover of the pulse device, all zero duty. A: latch "
"locked, 40 000 streamed FIRE bits, nothing on the FIRE/LASER_ON nets. B: latch "
"unlocked with the chain unarmed, the FIRE line is driven mid-window and "
+127 -62
View File
@@ -9,9 +9,15 @@ physical arm button - nothing here defeats that gate, and forgetest
never touches the laser latch. One emission per test; on abort or error
the job is soft-reset (`^X`: controlled stop, latch relocked). The
witnesses are forgectrl's `/status` (`laser.emission_samples` = the
kernel's LASER_ON sample count, `hv_current_raw`, `lid_ir`) and
`/cool/status` (`armed`), sampled at ~8 Hz through the arm -> fire ->
disarm lifecycle.
kernel's LASER_ON sample count, `hv_current_raw`, `lid_ir`),
`/cool/status` (`armed`), the head's beam detector
(`head/beam_detect_analog`, the tube's own emission seen from the head:
a few hundred counts over its idle level while the beam is on, and
`beam_detect_digital` asserted), and the button LEDs (lit while the arm
waits or the window is open, dark after the disarm) - all sampled at
~8 Hz through the arm -> fire -> disarm lifecycle. The one thing the
operator still judges by eye is the mark the emission witness leaves,
once per campaign: the calibration of the sensor witnesses.
"""
import time
@@ -19,7 +25,7 @@ from ..catalog import test
from .. import hw
from ..runner import Failed
from .motion import (kernel_xy_mm, kernel_start, check_kernel_returned, wait_state,
wait_state_text, wait_left_state, wait_idle, drain_text)
wait_state_text, wait_left_state, wait_idle, drain_text, Watch)
_LASER_COVERS = [("grblhal-glowforge", "src/**"), ("kernel-module-glowforge", "**"),
("forgectrl", "src/super.c"), ("forgectrl", "src/cool.c"),
@@ -35,9 +41,13 @@ PWM_PERIOD = 127
PWMSAR_FLOOR_MIN = 12
ARM_CUE = ("LIVE FIRE. Eye protection on, exhaust running, fire watch and extinguisher in reach, "
"scrap under the head with room to move (%s), lid closed. When the job starts the "
"white button lights and the stream blocks until you press the physical arm button; "
"the machine fires only after your press. Ready?")
"scrap under the head with room to move (%s), lid closed. On Ready the job starts: "
"the white button lights and the stream waits for your press of the physical arm "
"button; the machine fires only after that press.")
# The beam detector's emission signature: this much over its pre-fire
# level while the beam is on (bench: ~1834 idle, 2600-2890 at S300/S400).
BEAM_DELTA_MIN = 300
def sample(ctx):
@@ -62,9 +72,43 @@ def sample(ctx):
"armed": cs.get("armed"),
"fire_watch": cs.get("fire_watch"),
"verdict": cs.get("verdict"),
"beam": hw.sysfs_int("head/beam_detect_analog"),
"beam_d": hw.sysfs_int("head/beam_detect_digital"),
"button_lit": hw.button_lit(),
}
def beam_witness(ctx, ev, samples, base, tag=""):
"""The head's beam detector over a sample trail: its peak against the
pre-fire level, and whether the digital flag asserted. Recorded, and
judged where the trail carried emission."""
beams = [s["beam"] for s in samples if s.get("beam") is not None]
base_beam = base.get("beam") if base else None
peak = max(beams) if beams else None
digital = any(s.get("beam_d") for s in samples)
key = ("beam" + ("_" + tag if tag else ""))
ev[key] = {"idle": base_beam, "peak": peak, "delta": (peak - base_beam) if (peak is not None and
base_beam is not None) else None,
"digital_seen": digital, "samples": len(beams)}
ctx.log("beam detector%s: idle %s, peak %s, digital asserted %s", (" [%s]" % tag) if tag else "",
base_beam, peak, digital)
return ev[key]
def judge_beam(ctx, b, what="the burn"):
ctx.check(b["samples"] > 0, "the beam detector was not readable during %s", what)
ctx.check(b["delta"] is not None and b["delta"] >= BEAM_DELTA_MIN,
"the head's beam detector did not see %s (idle %s, peak %s; the tube did not lase, "
"or the detector is not reading)", what, b["idle"], b["peak"])
def check_button_dark(ctx, ev, key="button_dark"):
lit = hw.button_lit()
ev[key] = lit
ctx.check(lit is not None, "the button LEDs are not readable")
ctx.check(lit is False, "the button is still lit")
def prepare(ctx, g):
"""Guarantee a clean Idle start: clear a latched Door hold or an Alarm."""
st = g.status_report()
@@ -101,15 +145,18 @@ def arm_and_fire(ctx, g, room="40 mm +X and +Y", job=None, timeout=240):
prologue every live test shares. Returns the first sample with emission,
or soft-resets and fails: no emission means the arm was refused or the
button was never pressed."""
ctx.instruct(ARM_CUE % room)
ctx.ready(ARM_CUE % room)
stream(g, job or MARK_JOB)
ctx.notice("The button lights white: press it to arm. The machine fires after your press.")
t0 = time.time()
while time.time() - t0 < timeout:
ctx.checkpoint()
smp = sample(ctx)
if smp and smp["emission"] and smp["emission"] > 0:
ctx.clear_notice()
return smp
time.sleep(0.15)
ctx.clear_notice()
g.realtime(0x18)
raise Failed("no emission seen within %d s (arm refused, or no button press)" % timeout)
@@ -277,13 +324,17 @@ def power_floor(ctx):
subsystem="laser", kind="live", mode="grbl", always=True, est_min=5,
covers=_LASER_COVERS,
requires=["kernel.latch-locked-idle", "kernel.k1-k2", "motion.jog-roundtrip"],
actions=["button"],
steps=["Scrap under the head with 20 mm of free +X and +Y travel; lid closed; exhaust on.",
"Press the physical button when it lights white (the arm)."],
"Press the physical button when it lights white (the arm).",
"At the end, confirm the square the laser marked: the one judgment by eye in the "
"campaign, the calibration of the sensor witnesses."],
description="A 20 mm square outline at S400/F600 in dynamic laser mode: emission_samples "
"(the kernel's LASER_ON sample count) goes nonzero during the fire window and "
"returns to 0 at Idle, HV current rises during the burn, the armed window is "
"observed, and the M2 program end disarms promptly at Idle (job-based, not "
"the 60 s idle grace). The operator confirms the mark.")
"returns to 0 at Idle, HV current rises during the burn, the head's beam "
"detector sees the beam, the armed window is observed, the M2 program end "
"disarms promptly at Idle (job-based, not the 60 s idle grace) and the button "
"goes dark. The operator confirms the mark.")
def emission_witness(ctx):
ev = ctx.evidence
with ctx.grbl() as g, LiveJob(ctx, g):
@@ -294,11 +345,16 @@ def emission_witness(ctx):
ctx.log("pre-fire: emission=%s hv=%s armed=%s verdict=%s", base["emission"], base["hv"],
base["armed"], base["verdict"])
ctx.check(not base["emission"], "emission_samples nonzero before the job (%s)", base["emission"])
ctx.instruct(ARM_CUE % "20 mm +X and +Y")
ctx.ready(ARM_CUE % "20 mm +X and +Y")
job = ["G91", "G21", "M4", "S400",
"G1 X20 F600", "G1 Y20 F600", "G1 X-20 F600", "G1 Y-20 F600",
"M5", "G90", "M2"]
samples = run_and_sample(ctx, g, job)
ctx.notice("The button lights white: press it to arm. The machine fires after your press.")
try:
samples = run_and_sample(ctx, g, job)
finally:
ctx.clear_notice()
beam = beam_witness(ctx, ev, samples, base)
emis = [s["emission"] for s in samples if s["emission"] is not None]
peak = max(emis) if emis else 0
end = emis[-1] if emis else None
@@ -327,36 +383,34 @@ def emission_witness(ctx):
ctx.check(dt is not None and dt < 10.0,
"the M2 job did not disarm promptly at Idle (%s s; the idle grace is ~60 s)",
ev["disarm_after_idle_s"])
ctx.confirm("Did the laser mark a 20 mm square outline on the scrap, and is the machine now "
"idle with the button dark?")
ctx.log("PASS: emission peak %s -> 0, HV %s..%s, disarmed %.1f s after Idle, mark confirmed",
peak, ev["hv_min"], ev["hv_max"], dt)
judge_beam(ctx, beam)
check_button_dark(ctx, ev)
ctx.confirm("Did the laser mark a 20 mm square outline on the scrap?")
ctx.log("PASS: emission peak %s -> 0, HV %s..%s, beam +%s, disarmed %.1f s after Idle, button "
"dark, mark confirmed", peak, ev["hv_min"], ev["hv_max"], beam["delta"], dt)
@test("laser.disarm-in-hold", title="Disarm grace counts down in Hold", subsystem="laser",
kind="live", mode="grbl", est_min=4,
covers=_LASER_COVERS,
requires=["laser.emission-witness"],
requires=["laser.emission-witness"], actions=["button"],
steps=["Scrap under the head with 40 mm of free +X travel; lid closed; exhaust on.",
"Press the physical button when it lights white."],
"Press the physical button when it lights white. Nothing else: the test holds the "
"job itself and waits about a minute for the disarm."],
description="Arm and start a +X move at S400/F300, feed-hold it after ~2 s of motion, and "
"hold: the disarm grace must count down while held and close the armed window "
"(armed -> false) without the job resuming.")
"(armed -> false) without the job resuming, and the button goes dark with it.")
def disarm_in_hold(ctx):
ev = ctx.evidence
with ctx.grbl() as g, LiveJob(ctx, g):
prepare(ctx, g)
ctx.instruct(ARM_CUE % "40 mm +X")
ctx.ready(ARM_CUE % "40 mm +X")
stream(g, ["G91", "G21", "M4", "S400", "G1 X40 F300"])
ctx.log("armed; waiting for motion to start (arm + your button press)...")
t0 = time.time()
st = None
while time.time() - t0 < 180:
ctx.checkpoint()
st = g.status_report()["state"]
if st.startswith("Run"):
break
time.sleep(0.1)
w = Watch(g)
ctx.act("button", "press", text="The button is lit white: the press arms the job and the "
"move starts.", until=w.in_state("Run"), timeout=180, fail=False)
st = w.last["state"] if w.last else None
ctx.check(st and st.startswith("Run"), "motion never started (state=%s) - arm refused or no press", st)
ctx.log("moving under laser: %s; feed-hold in 2 s", st)
ctx.sleep(2)
@@ -394,19 +448,17 @@ def disarm_in_hold(ctx):
g.command("$X")
ctx.check(disarmed_at is not None, "still armed after 120 s in Hold")
ctx.check(left_hold is None, "the job left Hold (%s) before the disarm", left_hold)
ctx.log("PASS: disarmed in Hold after %.1f s", disarmed_at)
ctx.confirm("Did the head stop after ~2 s of the +X move and stay stopped, with the button "
"going dark on its own about a minute later?")
check_button_dark(ctx, ev)
ctx.log("PASS: disarmed in Hold after %.1f s, button dark", disarmed_at)
@test("laser.armed-kill", title="Armed kill mid-fire: the expected stop, then a SIGKILL",
subsystem="laser", kind="live", mode="grbl", est_min=6,
covers=_LASER_COVERS + [("forgectrl", "src/main.c")],
requires=["laser.emission-witness", "motion.deadman"],
requires=["laser.emission-witness", "motion.deadman"], actions=["button"],
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 - twice over the test, once per burn.",
"After the first burn the controller is left stopped until you judge the stop; the "
"test then restarts it and runs the second burn."],
"Press the physical button when it lights white - twice over the test, once per burn. "
"Each burn is cut short by the test; the sample trails judge the stops."],
description="Both ways an armed job is killed, on one setup. Expected: mid-burn "
"POST /controller/stop - the supervisor writes cnc/stop and relocks before the "
"SIGTERM, so emission drops within 2.5 s and stays 0, the kernel is not running, "
@@ -437,9 +489,8 @@ def armed_kill(ctx):
"emission did not drop within 2.5 s of the stop (first 0 at %s)", zero_at)
ctx.check(tail_zero, "emission returned after the stop")
ctx.check(not_running, "the kernel was still running after the stop")
ctx.instruct("The controller is STOPPED (supervision held). Judge the stop on the scrap - a "
"short cut, then an abrupt end - and confirm the machine is quiet; then Done to "
"restart the controller (no motion, no laser).")
ev["beam_at_fire_stop"] = smp.get("beam")
ctx.log("the controller is stopped (supervision held); restarting it (no motion, no laser)")
st, body = fc.post("/controller/start")
ctx.log("POST /controller/start -> %s %s", st, body)
ctx.check(st == 200, "POST /controller/start -> %s", st)
@@ -490,10 +541,10 @@ def armed_kill(ctx):
ctx.log("/mode after the kill: %s", m1)
ctx.check(m1 and m1.get("controller") == "running" and m1.get("pid") != pid,
"supervisor did not respawn the controller: %s", m1)
ctx.confirm("Did both burns end abruptly where they were killed (a short line, no run-on), "
"with the machine quiet and the button dark now?")
ev["beam_at_fire_kill"] = smp.get("beam")
check_button_dark(ctx, ev)
ctx.log("PASS: expected stop 0 at +%s s and SIGKILL 0 at +%s s, latch locked, controller "
"respawned", ev["expected"]["zero_at_s"], zero_at)
"respawned, button dark", ev["expected"]["zero_at_s"], zero_at)
@test("laser.arm-wait-lid", title="Lid open during the arm wait cancels the job",
@@ -501,9 +552,10 @@ def armed_kill(ctx):
covers=_LASER_COVERS + [("grblhal-glowforge", "src/glowforge_laser.c"),
("grblhal-glowforge", "src/glowforge_switches.c"),
("grblhal-glowforge", "src/glowforge_switch_map.h")],
requires=["kernel.latch-locked-idle", "motion.jog-roundtrip"],
requires=["kernel.latch-locked-idle", "motion.jog-roundtrip"], actions=["lid"],
steps=["Lid closed; nothing under the head needs to be in place - the machine will not fire.",
"When the white button lights, do NOT press it: open the lid instead."],
"When the white button lights, do NOT press it: open the lid instead, then close it "
"when told."],
description="Start a laser job so the controller unlocks the latch and lights the button, "
"then open the lid while it waits. The wait must abort with the lid named as the "
"reason, cancelled with a soft reset (no alarm - nothing to unlock), the armed "
@@ -528,7 +580,7 @@ def arm_wait_lid(ctx):
s = sample(ctx)
ev["armed_during_wait"] = s["armed"] if s else None
ctx.log("arm prompt seen; armed=%s; asking the operator to open the lid", ev["armed_during_wait"])
ctx.instruct("The button is lit white. Do NOT press it. Open the lid now, then click Done.")
ctx.act("lid", "open", text="The button is lit white: do NOT press it.", timeout=60)
t1 = time.time()
while time.time() - t1 < 15:
ctx.checkpoint()
@@ -558,7 +610,8 @@ def arm_wait_lid(ctx):
ctx.check(s and not s["armed"], "the armed window stayed open after the lid-open cancel")
ctx.check(locked, "kernel latch not locked after the lid-open cancel (interlock_circuit=%s)", ilk)
ctx.check(not ev["emission"], "emission_samples nonzero (%s) - nothing may have fired", ev["emission"])
ctx.instruct("Close the lid, then click Done.")
check_button_dark(ctx, ev)
ctx.act("lid", "close")
ctx.sleep(1)
st = g.status_report()["state"]
ev["state_after"] = st
@@ -572,10 +625,11 @@ def arm_wait_lid(ctx):
covers=_LASER_COVERS + [("grblhal-glowforge", "src/glowforge_switches.c"),
("grblhal-glowforge", "src/glowforge_switch_map.h")],
requires=["laser.emission-witness", "motion.lid-cancel-home", "motion.button-hold-resume"],
actions=["button", "lid"],
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 (arm). Once the cut is under way press "
"it again (pause), wait about 3 seconds, press it once more (resume), and then open the "
"lid and leave it open until the head has come back.",
"Press the physical button when it lights white (arm). Once the cut is under way the "
"test asks for a press (pause), then another (resume), then for the lid: open it and "
"leave it open until the head has come back, then close it.",
"Keep the pause short: the armed window's idle grace closes it after about a minute in "
"a hold, and a job that disarms cannot resume its emission."],
description="The machine's own controls during one armed burn, in the order the factory uses "
@@ -595,15 +649,21 @@ def pause_resume_lid_cancel(ctx):
start = g.status_report()["MPos"]
ev["start"] = start
ev["kernel_start"] = k0
base_beam = sample(ctx)
beams = [] # (analog, digital) through the cut
smp = arm_and_fire(ctx, g, job=MARK_JOB_M3)
ev["emission_running"] = smp["emission"]
beams.append((smp.get("beam"), smp.get("beam_d")))
ctx.log("emission live (%s) - asking the operator to pause", smp["emission"])
# -- the button pauses ------------------------------------------------
g.drain() # the message window opens at the prompt
ctx.instruct("The laser is cutting. Press the button ONCE now (pause), then click Done - "
"do not wait long before the next step.")
st, text = wait_state_text(ctx, g, "Hold", 8)
g.drain() # the message window opens here
w = Watch(g)
ctx.act("button", "press", text="The laser is cutting: the press pauses it.",
until=w.in_state("Hold"), timeout=20, fail=False)
st, text = w.last, w.text
if st is not None and not st["state"].startswith("Hold"):
st = None
ctx.check(st is not None, "the press did not hold the job (state %s)", g.status_report()["state"])
ev["hold_state"] = st["state"]
ev["pause_message"] = "job paused" in text
@@ -631,8 +691,12 @@ def pause_resume_lid_cancel(ctx):
# -- the button resumes -----------------------------------------------
g.drain()
ctx.instruct("Press the button once more now (resume), then click Done.")
st, text = wait_left_state(ctx, g, "Hold", 10)
w = Watch(g)
ctx.act("button", "press", text="The cut is paused: the press resumes it.",
until=w.left_state("Hold"), timeout=40, fail=False)
st, text = w.last, w.text
if st is not None and st["state"].startswith("Hold"):
st = None
ev["resumed_state"] = st["state"] if st else g.status_report()["state"]
ev["resume_message"] = "job resumed" in text
ctx.check(st is not None, "the second press did not resume the job (still held: %s)",
@@ -647,6 +711,7 @@ def pause_resume_lid_cancel(ctx):
s = sample(ctx)
if s:
trail.append((round(time.time() - t2, 2), s["emission"]))
beams.append((s.get("beam"), s.get("beam_d")))
if s["emission"] and s["emission"] > 0:
back = True
break
@@ -658,7 +723,8 @@ def pause_resume_lid_cancel(ctx):
# -- the lid cancels --------------------------------------------------
g.drain()
ctx.instruct("The cut is running again. Open the lid NOW and leave it open, then click Done.")
ctx.act("lid", "open", text="The cut is running again: leave the lid open until the head "
"has come back.", timeout=30)
t_lid = time.time()
lid_trail = []
text = ""
@@ -708,11 +774,10 @@ def pause_resume_lid_cancel(ctx):
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 on the first press, start again on the second, then stop the "
"instant the lid opened - and did the head go straight back to where the job "
"started, with the lid still open and dark? (A small mark where the cut "
"restarted is expected - GRBL mode does not backtrack.)")
ctx.instruct("Close the lid, then click Done.")
beam_witness(ctx, ev, [{"beam": b, "beam_d": d} for b, d in beams], base_beam)
judge_beam(ctx, ev["beam"], "the cut")
check_button_dark(ctx, ev)
ctx.act("lid", "close")
ctx.sleep(1)
ctx.log("PASS: button paused the burn (emission 0, armed kept, latch unlocked) and resumed it; "
"the lid then cancelled it - emission 0 at +%s s, reset without alarm, returned (drift "
+103 -41
View File
@@ -199,19 +199,21 @@ def pacing(ctx):
@test("motion.jog-roundtrip", title="Motion quality: bounded jogs, max rate, diagonal, hold/resume",
subsystem="motion", kind="operator", mode="grbl", est_min=3,
subsystem="motion", kind="auto", mode="grbl", 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."],
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 operator saw the gantry move.")
"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
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:
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")
@@ -223,14 +225,28 @@ def jog_roundtrip(ctx):
("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)
p2px, p2py, n = accel.p2p(t0)
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.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})
ctx.check("TIMEOUT" not in states, "%s %s did not return to Idle", name, leg)
ev["moves"] = moves
# The witness: every leg the sampler caught with enough samples
# must have moved the head; the short max-rate legs may land too
# few samples to judge on their own and are judged with the rest.
judged = [m for m in moves if m["accel_samples"] >= 3]
ctx.check(len(judged) >= 4, "the accelerometer sampled too few legs to judge motion (%d of %d; "
"%d read errors)", len(judged), len(moves), accel.errors)
still = [m for m in judged if max(m["accel_p2p"]) < hw.ACCEL_P2P_MOVING]
ev["accel_still_legs"] = [m["name"] + " " + m["leg"] for m in still]
ctx.check(not still, "the head did not move on %s (accel p2p below %d): the counters ran "
"without the gantry", ", ".join(ev["accel_still_legs"]), hw.ACCEL_P2P_MOVING)
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)
@@ -258,10 +274,8 @@ def jog_roundtrip(ctx):
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)
ctx.log("PASS: %d jogs, peak %.0f mm/min, hold parked, drift %.3f mm, the head seen moving on "
"%d of %d legs", len(moves), maxrate, drift, len(judged), len(moves))
# ---------------------------------------------------------------- liveness
@@ -652,6 +666,28 @@ def check_kernel_returned(ctx, ev, k0, tol_mm=0.1, tag=""):
"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
@@ -700,9 +736,10 @@ def expect_cancel_and_return(ctx, g, ev, start, k0, why, tag):
@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"],
covers=_LID_COVERS, requires=["motion.pacing"], actions=["button"],
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)."],
"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.")
@@ -711,28 +748,35 @@ def button_hold_resume(ctx):
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 at the prompt
ctx.instruct("The head is moving. Press the button once now, then click Done.")
st, text = wait_state_text(ctx, g, "Hold", 8)
ctx.check(st is not None, "the press did not hold the job (state %s)", g.status_report()["state"])
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"])
g.drain()
ctx.instruct("The head is stopped. Press the button once more now, then click Done.")
# 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.
st, text = wait_left_state(ctx, g, "Hold", 10)
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)",
@@ -759,9 +803,12 @@ def button_hold_resume(ctx):
"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.",
"Open the lid when told, and leave it open until the head has come back (twice: once "
"with the job running, once with it paused on the button)."],
"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 "
@@ -779,17 +826,19 @@ def lid_cancel_home(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 at the prompt
ctx.instruct("The head is moving. Open the lid NOW and leave it open, then click Done.")
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.instruct("Close the lid, then click Done.")
ctx.act("lid", "close")
ctx.sleep(1)
# a jog afterward proves the controller is usable without $X
r = g.command("$J=G91X5F1200")
@@ -808,23 +857,30 @@ def lid_cancel_home(ctx):
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()
ctx.instruct("The head is moving again. Press the button once now (pause), then click Done.")
st, held = wait_state_text(ctx, g, "Hold", 8)
ctx.check(st is not None, "the press did not hold the job (state %s)", g.status_report()["state"])
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.instruct("The job is paused. Open the lid NOW and leave it open, then click Done.")
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 cancelled the paused job")
ctx.instruct("Close the lid, then click Done.")
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)
@@ -840,11 +896,12 @@ def lid_cancel_home(ctx):
@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"],
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. Restore it at the end.",
"Open the interlock when told and leave it open until the head has come back."],
"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. "
@@ -864,14 +921,15 @@ def interlock_cancel_home(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.instruct("The head is moving. Open the INTERLOCK loop now (unplug it / pull the jumper) and "
"leave it open, then click Done.")
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,
@@ -883,7 +941,7 @@ def interlock_cancel_home(ctx):
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.instruct("Restore the interlock loop (plug/jumper back in), then click Done.")
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")}
@@ -902,8 +960,10 @@ def interlock_cancel_home(ctx):
@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.",
"Open the lid when told, then close it when told; the job finishes after that."],
"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 "
@@ -920,13 +980,15 @@ def lid_policy_hold(ctx):
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.instruct("The head is moving. Open the lid NOW and leave it open, then click Done.")
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"])
@@ -937,7 +999,7 @@ def lid_policy_hold(ctx):
held = g.status_report()["MPos"]
ev["parked_at"] = held
ctx.log("parked in %s at %s", ev["door_state"], held)
ctx.instruct("Close the lid, then click Done.")
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"])