forgetest: cloud tests stay in cloud mode; the page can ignore prerequisites

The cloud job tests (lid-abort, lid-during-button-wait, hunt-lid-open,
pause-resume) run in cloud mode and leave the machine there: enter_cloud
reuses a live session (pid-scoped from the client's own websocket state
lines) and switches once from GRBL mode, declaring the change to the
baseline; nothing switches back. Each test judges the log from its own
window, the print by its own "print [id]: finished" line, and waits the
service's follow-up moves out (wait_quiet) before it ends. hunt-lid-open
restarts the cloud client through the supervisor's stop/start lever for
a fresh connect. The former switch-back is what failed the last bench run
of hunt-lid-open (409 machine is not idle: the service was still
re-finding the head after the lid closed).

The baseline is mode-aware: in cloud mode the client owns the GRBL
controller's init values, the lid lamp, and the position counters; the
mode itself is preserved unless the run declared the change
(Context.mode_changed); controller_mode is never handed back as a bare
setting (a bare write left the persisted mode out of step with the live
one). The undeclared-change restore now uses the captured state, which
the post pass never saw before.

The acceptance page gets an "Ignore prerequisites" switch (remembered by
the browser): POST /start {ignore_requires} starts a test whose requires
are unmet, and the run records the unmet prerequisites in its evidence
and log; they stay required for the release. The cloud tests' requires
no longer chain through cloud.mode-switch.

Proof: tests/test_cloud_suite.py replays the four tests on the bench's
own gfcloud excerpts (fixtures/) and the run loop's emitted pause/resume
lines under the real runner Context against a fake forgectrl; baseline
mode tests and the server override test; the whole suite (98) and the
coverage lint pass. Catalog consequence: cloud.* fingerprints move with
the module; the catalog hash moves with the requires.
This commit is contained in:
ScottW514
2026-08-17 06:42:48 -04:00
parent e908db7f3a
commit 86ce0419e5
15 changed files with 1408 additions and 209 deletions
+319 -191
View File
@@ -1,5 +1,7 @@
"""cloud.* - the controller mode switch and the optional Glowforge web-service
mode (gfcloud daemon, gfhome homing runner)."""
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)."""
import json
import os
import socket
@@ -198,7 +200,7 @@ def mode_switch(ctx):
@test("cloud.gfhome-homing", title="Glowforge web-service homing ($H with homing_mode=gfcloud)",
subsystem="cloud", kind="operator", est_min=5,
covers=_CLOUD_COVERS + [("grblhal-glowforge", "src/**")], requires=["cloud.mode-switch"],
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 "
@@ -249,6 +251,24 @@ def gfhome_homing(ctx):
# ---- lid / button behavior of a cloud job (the factory's) ------------------
#
# These tests run IN cloud mode and stay there: enter_cloud() reuses a live
# cloud session when the machine is already in cloud mode (the operator
# switched once, on the panel or through an earlier test) and switches -
# once, declaring the change to the baseline - only when it finds GRBL
# mode. Nothing switches back; the operator does, when done. Each test
# judges the gfcloud log from its own window (the offset enter_cloud
# returns) and waits for the service's deferred moves (the re-hunt after a
# lid close, the hunt after a print) to finish before it ends, so the next
# run - or the operator - gets a quiet machine.
WS_MARKS = ("RX-EVENT: ready", "RX-EVENT: closed", "RECONNECTING", "CLOSING")
ACTIVITY_MARKS = ("start motion", "start return home", "starting run", "starting z homing cycle")
LOG_TAIL_BYTES = 4 << 20
QUIET_S = 8 # the re-hunt's motions are ~4 s apart (a lid image between them)
QUIET_TIMEOUT_S = 180
HUNT_TIMEOUT_S = 180
def log_lines_since(path, offset):
"""New gfcloud log lines since offset (each 'ISO-time gfcloud[pid] LEVEL where message')."""
@@ -260,6 +280,12 @@ def log_lines_since(path, offset):
return []
def log_tail(path, max_bytes=LOG_TAIL_BYTES):
"""The last max_bytes of the log, as lines (the first may be partial)."""
size = log_size(path)
return log_lines_since(path, max(0, size - max_bytes))
def line_time(line):
"""Seconds (float) from the log line's ISO timestamp, or None."""
try:
@@ -296,47 +322,150 @@ def wait_log(ctx, offset, needles, timeout, poll=0.5):
return found
def enter_cloud(ctx):
"""Switch to the cloud controller and wait for its service session.
Returns (log offset at the switch, lid lamp level before)."""
fc = ctx.forgectrl
st, m0 = fc.get("/mode")
ctx.check(st == 200 and isinstance(m0, dict) and m0.get("mode") == "grbl",
"start this test in grbl mode (now %s)", m0)
lamp0 = hw.sysfs_read("pic/lid_led")
offset = log_size(GFCLOUD_LOG)
st, body = fc.post("/mode", data={"controller": "cloud"})
ctx.check(st == 200, "mode switch to cloud refused: %s %s", st, body)
m = wait_mode(ctx, fc, "cloud", timeout=90)
ctx.check(m and m.get("mode") == "cloud" and m.get("controller") == "running",
"cloud controller did not come up: %s", m)
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)
if (action + " [") in ln and "finished with event" in ln), None)
def wait_action_finished(ctx, offset, action, timeout, poll=0.5):
"""The action's own terminal line ('<action> [id]: finished with event
":completed"' / '":cancelled"'), or None within timeout."""
t0 = time.time()
while time.time() - t0 < timeout:
ctx.checkpoint()
lines = log_lines_since(GFCLOUD_LOG, offset)
i = action_finish_index(lines, action)
if i is not None:
return lines[i]
time.sleep(poll)
return None
def message(line):
"""The message part of a log line (after 'ISO-time gfcloud[pid]')."""
return line.split(" ", 2)[-1] if line else None
def session_live(pid):
"""(live, detail): the running cloud client (gfcloud[pid]) has a live
service session when its last websocket state line is 'ready' - a
later 'closed'/'RECONNECTING'/'CLOSING' means it is not connected
now. Without any line for that pid the newest lines of the log stand
in (the client may log under a wrapper's pid)."""
tag = "gfcloud[%s]" % pid if pid else None
last_pid = last_any = None
for ln in log_tail(GFCLOUD_LOG):
for m in WS_MARKS:
if m in ln:
last_any = m
if tag and tag in ln:
last_pid = m
last = last_pid if last_pid is not None else last_any
where = "pid %s" % pid if last_pid is not None else "newest lines"
return last == "RX-EVENT: ready", "%s: last websocket state %s" % (where, last)
def wait_quiet(ctx, offset, quiet_s=None, timeout=None):
"""The service's moves are over: the machine idle and no new service
activity in the log (a motion, a park, a run, a lens homing) for
quiet_s (default QUIET_S; timeout QUIET_TIMEOUT_S). False on timeout."""
quiet_s = QUIET_S if quiet_s is None else quiet_s
timeout = QUIET_TIMEOUT_S if timeout is None else timeout
fc = ctx.forgectrl
t0 = time.time()
n_seen = -1
last_change = t0
while time.time() - t0 < timeout:
ctx.checkpoint()
lines = log_lines_since(GFCLOUD_LOG, offset)
n = sum(1 for ln in lines if any(m in ln for m in ACTIVITY_MARKS))
if n != n_seen:
n_seen = n
last_change = time.time()
if fc.status().get("state") == "idle" and time.time() - last_change >= quiet_s:
return True
time.sleep(0.5)
return False
def wait_session(ctx, offset, timeout=120):
session = []
while time.time() - t0 < 120:
t0 = time.time()
while time.time() - t0 < timeout:
ctx.checkpoint()
session = session_lines(GFCLOUD_LOG, offset)
if session_established(session):
break
return session
time.sleep(2)
ctx.check(session_established(session), "the cloud client never established its service session")
ctx.log("cloud session established")
return offset, lamp0
return session
def leave_cloud(ctx, lamp0):
"""Back to grbl; the head returns to where the run found it."""
def fresh_cloud_connect(ctx):
"""A NEW cloud client with a fresh service session (its connect-time
hunt follows): in cloud mode the controller is restarted through the
supervisor's stop/start lever; in GRBL mode the mode is switched (and
the change declared - the machine stays in cloud mode). Returns the log
offset from before the connect, so the caller sees the whole session."""
fc = ctx.forgectrl
st, body = fc.post("/mode", data={"controller": "grbl"})
ctx.check(st == 200, "mode switch back to grbl refused: %s %s", st, body)
m = wait_mode(ctx, fc, "grbl", timeout=120)
ctx.check(m and m.get("mode") == "grbl" and m.get("controller") == "running",
"grbl controller did not come back: %s", m)
ctx.sleep(3)
ctx.counters_rezeroed()
return_head(ctx)
lamp1 = hw.sysfs_read("pic/lid_led")
if lamp0 is not None and lamp1 != lamp0:
hw.sysfs_write("pic/lid_led", lamp0)
st, m = fc.get("/mode")
ctx.check(st == 200 and isinstance(m, dict), "GET /mode -> %s", st)
offset = log_size(GFCLOUD_LOG)
if m.get("mode") == "cloud":
ctx.log("cloud mode: restarting the cloud client for a fresh connect (was pid %s)", m.get("pid"))
st, body = fc.post("/controller/stop")
ctx.check(st == 200, "controller stop refused: %s %s", st, body)
m = wait_mode(ctx, fc, "cloud", want_controller="standby", timeout=30)
ctx.check(m and m.get("controller") == "standby", "the cloud client did not stop: %s", m)
st, body = fc.post("/controller/start")
ctx.check(st == 200, "controller start refused: %s %s", st, body)
else:
ctx.log("%s mode: switching to cloud", m.get("mode"))
st, body = fc.post("/mode", data={"controller": "cloud"})
ctx.check(st == 200, "mode switch to cloud refused: %s %s", st, body)
m = wait_mode(ctx, fc, "cloud", timeout=90)
ctx.check(m and m.get("mode") == "cloud" and m.get("controller") == "running",
"cloud controller did not come up: %s", m)
ctx.mode_changed("cloud")
session = wait_session(ctx, offset)
ctx.check(session_established(session),
"the cloud client never established its service session (no credentials, no "
"network, or the service refused)")
ctx.log("cloud session established (pid %s)", m.get("pid"))
return offset
def enter_cloud(ctx):
"""Cloud mode with a live service session and a quiet machine. An
existing cloud session is reused; from GRBL mode the switch is made
once (its connect-time hunt is waited out) and the machine stays in
cloud mode. Returns the log offset where the test's own window begins."""
fc = ctx.forgectrl
st, m = fc.get("/mode")
ctx.check(st == 200 and isinstance(m, dict), "GET /mode -> %s", st)
if m.get("mode") == "cloud" and m.get("controller") == "running":
live, detail = session_live(m.get("pid"))
ctx.check(live, "cloud mode is up (pid %s) but the client has no live service session (%s) - "
"check credentials and network, or restart the controller", m.get("pid"), detail)
ctx.log("cloud mode already up (pid %s), service session live - reusing it", m.get("pid"))
offset = log_size(GFCLOUD_LOG)
else:
offset = fresh_cloud_connect(ctx)
hunt = wait_action_finished(ctx, offset, "hunt", HUNT_TIMEOUT_S)
ctx.check(hunt, "the service sent no connect-time hunt (or it never finished) within %d s",
HUNT_TIMEOUT_S)
ctx.log("connect-time hunt: %s", message(hunt))
ctx.check(wait_quiet(ctx, offset), "the cloud client was still running service moves after %d s",
QUIET_TIMEOUT_S)
return log_size(GFCLOUD_LOG)
def settle_cloud(ctx, offset):
"""End of a cloud test: the service's follow-up moves (a hunt after a
print, the re-hunt after a lid close) done and the machine idle."""
ctx.check(wait_quiet(ctx, offset), "the service was still moving the head %d s after the test",
QUIET_TIMEOUT_S)
ctx.log("cloud mode stays up; the machine is quiet")
def wait_print_running(ctx, offset, timeout):
@@ -367,13 +496,14 @@ APP_PRINT_CUE = ("In the Glowforge app: scrap on the bed, lid closed, a SMALL en
CANCELLED = 'finished with event ":cancelled"'
COMPLETED = 'finished with event ":completed"'
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).")
@test("cloud.lid-abort", title="Lid open during a cloud print: stop, park with the lid open, cancelled",
subsystem="cloud", kind="live", est_min=8,
covers=_CLOUD_COVERS, requires=["cloud.mode-switch", "laser.emission-witness"],
steps=["Cloud credentials configured; the app open in a browser; scrap on the bed and a small "
"engrave/score job ready.",
covers=_CLOUD_COVERS, requires=["laser.emission-witness"],
steps=[CLOUD_STEP, "The app open in a browser; scrap on the bed and a small engrave/score job ready.",
"Print from the app and press the button when it lights; open the lid a few seconds "
"into the run."],
description="A cloud print aborted by the lid behaves as the factory's does: the edge "
@@ -382,161 +512,158 @@ COMPLETED = 'finished with event ":completed"'
"closes, and the job ends ':cancelled'.")
def lid_abort(ctx):
ev = ctx.evidence
offset, lamp0 = enter_cloud(ctx)
try:
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. Open the lid NOW, then click Done. Leave it open until the head "
"has returned to the corner.")
needles = ["lid opened", "lid opened mid-run; stopping motion", "start return home",
"return home complete", CANCELLED]
got = wait_log(ctx, offset, needles, 90)
ev["log"] = {k: (v.split(" ", 2)[-1] if v else None) for k, v in got.items()}
for k, v in got.items():
ctx.log(" %s: %s", k, "seen" if v else "MISSING")
ctx.check(got["lid opened mid-run; stopping motion"], "the lid open did not stop the run")
# The lid edge that stopped the run is the LAST "lid opened" edge line
# before the stop line (an earlier open, e.g. to place the scrap, is
# not the one).
lines = log_lines_since(GFCLOUD_LOG, offset)
stop_i = next((i for i, ln in enumerate(lines) if "lid opened mid-run; stopping motion" in ln), None)
edge_line = None
if stop_i is not None:
edge_line = next((ln for ln in reversed(lines[:stop_i])
if "_switch_event lid opened" in ln or ln.rstrip().endswith(" lid opened")), None)
t_edge = line_time(edge_line) if edge_line else None
t_stop = line_time(lines[stop_i]) if stop_i is not None else None
if t_edge is not None and t_stop is not None:
ev["edge_to_stop_ms"] = round((t_stop - t_edge) * 1000, 1)
ctx.log("lid edge -> stop: %s ms", ev["edge_to_stop_ms"])
ctx.check(ev["edge_to_stop_ms"] < 60, "stop was not edge-driven (%s ms after the lid edge)",
ev["edge_to_stop_ms"])
ctx.check(got["start return home"] and got["return home complete"],
"the park did not run to completion with the lid open")
# the machine, not the client: the job started at counters (0,0,0)
# (cloud clears them at every job start), so a completed park reads
# back there - stale ring bytes replayed ahead of the park would not
ctx.check(ctx.forgectrl.wait_idle(15, abort=ctx.aborted), "machine not idle after the park")
kpos = read_position()
ev["kernel_counters_after_park"] = kpos
ctx.log("kernel counters after the park: %s", kpos)
ctx.check(kpos is not None and abs(kpos[0]) <= 3 and abs(kpos[1]) <= 3,
"the head did not come back to the job start (kernel counters %s)", kpos)
ctx.check(got[CANCELLED], "the print did not end ':cancelled'")
st, cs = ctx.forgectrl.get("/cool/status")
ev["armed_after"] = cs.get("armed") if isinstance(cs, dict) else None
ev["latch_locked"] = latch_locked()
ctx.check(not ev["armed_after"], "armed window still open after the abort")
ctx.check(ev["latch_locked"], "kernel latch not locked after the abort")
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.sleep(3)
finally:
leave_cloud(ctx, lamp0)
offset = enter_cloud(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. Open the lid NOW, then click Done. Leave it open until the head "
"has returned to the corner.")
needles = ["lid opened", "lid opened mid-run; stopping motion", "start return home",
"return home complete"]
got = wait_log(ctx, offset, needles, 90)
fin = wait_action_finished(ctx, offset, "print", 60)
got["print finished"] = fin
ev["log"] = {k: message(v) for k, v in got.items()}
for k, v in got.items():
ctx.log(" %s: %s", k, "seen" if v else "MISSING")
ctx.check(got["lid opened mid-run; stopping motion"], "the lid open did not stop the run")
# The lid edge that stopped the run is the LAST "lid opened" edge line
# before the stop line (an earlier open, e.g. to place the scrap, is
# not the one).
lines = log_lines_since(GFCLOUD_LOG, offset)
stop_i = next((i for i, ln in enumerate(lines) if "lid opened mid-run; stopping motion" in ln), None)
edge_line = None
if stop_i is not None:
edge_line = next((ln for ln in reversed(lines[:stop_i])
if "_switch_event lid opened" in ln or ln.rstrip().endswith(" lid opened")), None)
t_edge = line_time(edge_line) if edge_line else None
t_stop = line_time(lines[stop_i]) if stop_i is not None else None
if t_edge is not None and t_stop is not None:
ev["edge_to_stop_ms"] = round((t_stop - t_edge) * 1000, 1)
ctx.log("lid edge -> stop: %s ms", ev["edge_to_stop_ms"])
ctx.check(ev["edge_to_stop_ms"] < 60, "stop was not edge-driven (%s ms after the lid edge)",
ev["edge_to_stop_ms"])
ctx.check(got["start return home"] and got["return home complete"],
"the park did not run to completion with the lid open")
# the machine, not the client: the job started at counters (0,0,0)
# (cloud clears them at every job start), so a completed park reads
# back there - stale ring bytes replayed ahead of the park would not
ctx.check(ctx.forgectrl.wait_idle(15, abort=ctx.aborted), "machine not idle after the park")
kpos = read_position()
ev["kernel_counters_after_park"] = kpos
ctx.log("kernel counters after the park: %s", kpos)
ctx.check(kpos is not None and abs(kpos[0]) <= 3 and abs(kpos[1]) <= 3,
"the head did not come back to the job start (kernel counters %s)", kpos)
ctx.check(fin and CANCELLED in fin, "the print did not end ':cancelled': %s",
message(fin) or "no finish line")
st, cs = ctx.forgectrl.get("/cool/status")
ev["armed_after"] = cs.get("armed") if isinstance(cs, dict) else None
ev["latch_locked"] = latch_locked()
ctx.check(not ev["armed_after"], "armed window still open after the abort")
ctx.check(ev["latch_locked"], "kernel latch not locked after the abort")
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.")
settle_cloud(ctx, offset)
ctx.log("PASS: lid open -> stop in %s ms, park completed with the lid open, ':cancelled'",
ev.get("edge_to_stop_ms"))
@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=["cloud.mode-switch"],
steps=["Cloud credentials configured; the app open; any small job ready (nothing will fire).",
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."],
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'.")
def lid_during_button_wait(ctx):
ev = ctx.evidence
offset, lamp0 = enter_cloud(ctx)
try:
ctx.instruct("In the Glowforge app: lid closed, a small job set up. Click Done here, then press "
"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.")
needles = ["button wait lid opened - relocking the laser", CANCELLED]
got = wait_log(ctx, offset, needles, 60)
ev["log"] = {k: bool(v) for k, v in got.items()}
ctx.check(got["button wait lid opened - relocking the laser"], "the lid did not end the button wait")
ctx.check(got[CANCELLED], "the print did not end ':cancelled'")
# No run may start between the button wait and the cancel: the print
# itself, or a park (the head never moved, there is nothing to park).
# The connect-time hunt and the service's moves BEFORE the print are
# legitimate runs and are outside this window.
lines = log_lines_since(GFCLOUD_LOG, offset)
wait_i = next((i for i, ln in enumerate(lines) if "waiting for button" in ln), None)
end_i = next((i for i, ln in enumerate(lines) if wait_i is not None and i > wait_i and CANCELLED in ln),
len(lines))
ran = ([ln for ln in lines[wait_i:end_i] if "starting run" in ln]
if wait_i is not None else [])
ev["runs_started_after_wait"] = len(ran)
ctx.check(not ran, "a run started after the lid-open cancel (%d)", len(ran))
st, cs = ctx.forgectrl.get("/cool/status")
ev["armed_after"] = cs.get("armed") if isinstance(cs, dict) else None
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.")
ctx.sleep(3)
finally:
leave_cloud(ctx, lamp0)
offset = enter_cloud(ctx)
ctx.instruct("In the Glowforge app: lid closed, a small job set up. Click Done here, then press "
"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.")
relock = "button wait lid opened - relocking the laser"
got = wait_log(ctx, offset, [relock], 60)
fin = wait_action_finished(ctx, offset, "print", 60)
ev["log"] = {relock: bool(got[relock]), "print finished": message(fin)}
ctx.check(got[relock], "the lid did not end the button wait")
ctx.check(fin and CANCELLED in fin, "the print did not end ':cancelled': %s",
message(fin) or "no finish line")
# No run may start between the button wait and the cancel: the print
# itself, or a park (the head never moved, there is nothing to park).
# The service's moves BEFORE the print are legitimate runs and are
# outside this window.
lines = log_lines_since(GFCLOUD_LOG, offset)
wait_i = next((i for i, ln in enumerate(lines) if "waiting for button" in ln), None)
end_i = action_finish_index(lines, "print")
if end_i is None or wait_i is None or end_i < wait_i:
end_i = len(lines)
ran = ([ln for ln in lines[wait_i:end_i] if "starting run" in ln]
if wait_i is not None else [])
ev["runs_started_after_wait"] = len(ran)
ctx.check(not ran, "a run started after the lid-open cancel (%d)", len(ran))
st, cs = ctx.forgectrl.get("/cool/status")
ev["armed_after"] = cs.get("armed") if isinstance(cs, dict) else None
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.")
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, requires=["cloud.mode-switch"],
steps=["Cloud credentials configured; bed clear.",
"Open the lid BEFORE the test switches to cloud mode and leave it open through the "
"connect-time hunt."],
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 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, lamp0 = enter_cloud(ctx)
try:
# 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 = None
t0 = time.time()
while time.time() - t0 < 180 and hunt_line is None:
ctx.checkpoint()
lines = log_lines_since(GFCLOUD_LOG, offset)
hunt_i = next((i for i, ln in enumerate(lines) if "hunt [" in ln and "finished with event" in ln), None)
if hunt_i is not None:
hunt_line = lines[hunt_i]
break
time.sleep(0.5)
ev["hunt_line"] = hunt_line.split(" ", 2)[-1] if hunt_line else None
ctx.check(hunt_line, "the service sent no hunt (or it never finished) within 180 s of the session")
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.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.")
ctx.sleep(3)
finally:
leave_cloud(ctx, lamp0)
ctx.log("PASS: the connect-time hunt ran and completed with the lid open")
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"])
@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=["cloud.mode-switch", "laser.emission-witness"],
steps=["Cloud credentials configured; the app open; scrap on the bed and a small engrave/score "
"job (about 60 s) ready.",
requires=["laser.emission-witness"],
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."],
description="Pressing the button during a cloud print pauses it the factory way - controlled "
@@ -545,32 +672,33 @@ def hunt_lid_open(ctx):
"latch stays unlocked and the armed window open through the pause.")
def pause_resume(ctx):
ev = ctx.evidence
offset, lamp0 = enter_cloud(ctx)
try:
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, ["button pressed mid-run; pausing", "paused at",
"button pressed while paused; resuming"], 90)
ev["log"] = {k: bool(v) for k, v in got.items()}
ctx.check(got["button pressed mid-run; pausing"], "the press did not pause the run")
ctx.check(got["paused at"], "the pause did not settle (no 'paused at')")
ctx.check(got["button pressed while paused; resuming"], "the second press did not resume")
st, cs = ctx.forgectrl.get("/cool/status")
ev["armed_after_resume"] = cs.get("armed") if isinstance(cs, dict) else None
ctx.log("armed after the resume: %s", ev["armed_after_resume"])
got = wait_log(ctx, offset, ["return home complete", COMPLETED], 300)
ev["log_end"] = {k: bool(v) for k, v in got.items()}
ctx.check(got[COMPLETED], "the print did not complete after the resume")
ctx.check(got["return home complete"], "the post-print park did not complete")
relocked = [ln for ln in log_lines_since(GFCLOUD_LOG, offset)
if "relocking the laser" in ln or 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?")
finally:
leave_cloud(ctx, lamp0)
offset = enter_cloud(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, ["button pressed mid-run; pausing", "paused at",
"button pressed while paused; resuming"], 90)
ev["log"] = {k: bool(v) for k, v in got.items()}
ctx.check(got["button pressed mid-run; pausing"], "the press did not pause the run")
ctx.check(got["paused at"], "the pause did not settle (no 'paused at')")
ctx.check(got["button pressed while paused; resuming"], "the second press did not resume")
st, cs = ctx.forgectrl.get("/cool/status")
ev["armed_after_resume"] = cs.get("armed") if isinstance(cs, dict) else None
ctx.log("armed after the resume: %s", ev["armed_after_resume"])
fin = wait_action_finished(ctx, offset, "print", 300)
got = wait_log(ctx, offset, ["return home complete"], 5)
ev["log_end"] = {"return home complete": bool(got["return home complete"]),
"print finished": message(fin)}
ctx.check(fin, "the print did not finish within 300 s of the resume")
ctx.check(COMPLETED in fin, "the print did not complete after the resume: %s", message(fin))
ctx.check(got["return home complete"], "the post-print park did not complete")
relocked = [ln for ln in log_lines_since(GFCLOUD_LOG, offset)
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?")
settle_cloud(ctx, offset)
ctx.log("PASS: button pause/resume mid-print, job completed and parked")