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
+56 -7
View File
@@ -45,7 +45,18 @@ Every test declares, in code (`forgetest/forgetest/suite/*.py`):
- **always** - membership in the **always-required core**, which is run
in every campaign and is never inherited: image health, the kernel
latch/safety readbacks, and one live emission witness with the
armed-window disarm.
armed-window disarm;
- **actions** - the machine actions the test asks for by name (`lid`,
`interlock`, `button`; see "The operator's part"). An `auto` test
declares none. The page lists them before a start; a bench actuator
that covers a channel can perform them;
- **precheck** - a condition the machine must meet for the test to start
at all (`kernel.fire-line` needs HV not reporting good, the kernel's
rule for a zero-duty latch unlock; `cloud.mode-switch` needs
`homing_mode = gfcloud`). A start the precheck refuses is not a result:
the page says why, a queue skips the test with the reason and carries
on, and nothing is recorded. Neither field is part of the gate-visible
definition.
`GET /catalog` on the tool lists the definitions; the page shows them under
each test's *details*.
@@ -127,11 +138,13 @@ bench, or one whose `/data` has been wiped, starts from a full campaign.
one switches from GRBL mode (once, its connect-time hunt waited out)
and the following ones reuse the live session; nothing switches back
after them. The tests that need GRBL mode (`motion.*`, `laser.*`,
`cooling.fans-quiet-after-motion`, `cloud.mode-switch`,
`cloud.gfhome-homing`) declare it, and the runner switches back the
moment one of them starts - so the mode changes only where the next
test asks for it, never between tests of the same mode.
`cloud.mode-switch` is the one round trip.
`cooling.fans-quiet-after-motion`, `cloud.mode-switch`) declare it,
and the runner switches back the moment one of them starts - so the
mode changes only where the next test asks for it, never between
tests of the same mode. `cloud.mode-switch` is the one round trip, and
it carries the two service-driven motions with it: the connect-time
hunt run with the lid open, and the web-service homing (`$H` with
`homing_mode = gfcloud`) after the switch back.
4. Or hand the whole list to a queue. **Run what is left** offers two:
**Unattended** takes every `auto` test the campaign does not already
count as satisfied, and needs nobody in the room; **Operator and live**
@@ -152,7 +165,43 @@ bench, or one whose `/data` has been wiped, starts from a full campaign.
`releases/v<version>/acceptance.json` and `.md`.
The raw log (`/data/forgetest/results.jsonl`, `Raw log` in the footer) is
the bench's own record; the artifact is the release's.
the bench's own record; the artifact is the release's. The runner's own
events - a queue opening, skipping or stopping, a takeover recovered at
start-up, the leftovers a baseline pass found - go to the **journal**
(`Runner journal` in the footer: the daemon's `daemon.log` under the
data directory, also syslog under the `forgetest` name, and the log of
the run in progress), never to the page's campaign card.
### The operator's part
The run card shows **what you will do** before anything is asked: the
running test's `steps`, the attended tests still waiting in a queue, or
the test whose title you clicked while the machine is idle. What follows
during the run is those steps, taken in turn, in one of four forms:
- a **Ready** prompt pre-announces a timed step: what happens on the
click and what you do during it ("On Ready the head starts an 8 s move;
press the button once while it moves"). Nothing moves until you click;
- a **notice** is a standing instruction with no button. The test shows
it and watches the machine for the result - the lid switch reading
open, the interlock loop reading open, the controller entering Hold
after the press, the client's log line - and takes it down when it sees
it. There is nothing to answer and nothing to race;
- a machine **action** (`ctx.act("lid", "open")`, `("interlock",
"close")`, `("button", "press", until=...)`) is a notice the runner
manages: the wording is the action's own, the test adds its context,
the machine's reading proves it done, and the result's
`evidence.actions` records each one with who performed it. This is the
seam a bench actuator plugs into: a runner `fixture` covering a channel
performs the action instead of the notice, and a test reads the same
either way;
- a **confirm** is a yes/no the evidence cannot answer. One is left in
the catalog: the mark `laser.emission-witness` leaves on the scrap, the
once-per-campaign calibration of the sensor witnesses (the head's beam
detector, the HV current, the kernel's LASER_ON count), plus the app's
own display in `cloud.pause-resume`. The head accelerometer stands in
for "did the gantry move", the button LEDs for "is the button dark",
the lid lamp toggled between two snapshots for "is the camera live".
### Every run starts from, and leaves, the fresh-boot idle state
+26 -7
View File
@@ -48,7 +48,7 @@ hardware-validated.**
modes (cancel-and-return on a lid or interlock open, button pause/resume),
bench-validated 2026-08-17.
- **Releases are gated by the acceptance tool** (`forgetest`, dev image only):
a 44-test catalog, domain-scoped inheritance, an always-required safety core,
a 43-test catalog, domain-scoped inheritance, an always-required safety core,
and a release gate that reads the exported artifact. The full campaign on
dev image `20260821181036` (the first built on the `<recipe>-pin.inc`
layout) satisfied 42 of 42 and its export authorizes a release;
@@ -579,17 +579,29 @@ under the domain model from the day's earlier dev images) and the export reads "
YES" for that image's manifest. That authorizes a release; it is not one
until `releases/v<version>/acceptance.json` is committed.
- **Catalog: 45 tests** in `forgetest/forgetest/suite/`, every one a port of a
proven bench drill or a bench-verified check — the always-required core
- **Catalog: 43 tests** in `forgetest/forgetest/suite/`, every one a port of a
proven bench drill or a bench-verified check: the always-required core
(`image.health`, `kernel.latch-locked-idle`, `kernel.k1-k2`,
`kernel.fire-line`), `forgectrl.*`, `logs.*`, `update.*`, `motion.*`
(pacing, jog round-trip, liveness probe, cancel/abort, dead-man, the lid,
interlock and button parity tests), `cooling.*` (flow verification, fans
quiet after motion, a gate setting tripping and off by value, a fan under
its floor), `camera.snapshot`,
its floor), `camera.*`,
`laser.*` (emission witness, arm-wait lid, disarm-in-hold, armed kill,
pause/resume/lid-cancel) and `cloud.*`. Tests that share a setup are merged;
the `auto` tests stay separate for failure isolation.
pause/resume/lid-cancel) and `cloud.*` (the mode round trip with the
lid-open hunt and the web-service homing on it, and the job-behavior
tests). Tests that share a setup are merged; the `auto` tests stay
separate for failure isolation. 27 are `auto`, 8 `operator`, 8 `live`.
- **The operator's part is asked for by name, not by popup**
(`docs/ACCEPTANCE.md` "The operator's part"): a Ready prompt before a
timed step, a standing notice the test takes down when the machine shows
the action done (`ctx.act("lid", "open")` and its kin, the seam a bench
actuator will plug into), and one confirm by eye left in the catalog (the
emission witness's mark). The head accelerometer, the beam detector, the
button LEDs, and a lid-lamp toggle between two snapshots replaced the
other eyeball confirmations; `kernel.fire-line` and `camera.snapshot` are
`auto`. Code-complete 2026-08-22 with host replays; **bench validation
pending** on the next dev image.
- **Machine identity is content-defined.** Every component recipe contributes
`forgefirm-manifest.bbclass` entries (the kernel and the module through
`do_deploy`), `forgefirm-image-manifest.bbclass` assembles them plus the layer
@@ -1134,7 +1146,14 @@ Open items only. Anything closed is in `CAMPAIGN-LOG.md`.
service. Still owed: exercising the ported bench tools from the page
(they are registered and unit-tested, not yet driven from the page), and
the first release, which commits `releases/v<version>/acceptance.json`.
Catalog gaps left from the tool's own plan: `cooling.confirm-escalate` and
Cutting the operator's part of a campaign: the forgetest-only step
(the operator channel, the merged mode-switch, the sensor witnesses,
the steps pane, the journal) is code-complete and host-replayed; it
needs a bench run of every re-ported attended test on the next dev
image. The steps after it, an offline cloud service for the
machine-behavior tests and a bench actuator for the lid, interlock and
button, are planned, not started. Catalog
gaps left from the tool's own plan: `cooling.confirm-escalate` and
`cooling.fire-gate-blocks-arm` are not ported (both need the pump switched
by hand mid-run, so they are bench-tab material first), and whether
`laser.armed-kill` belongs in the always-required core rather than its
+2 -3
View File
@@ -18,7 +18,7 @@ from . import catalog as _catalog
from . import manifest as _manifest
from . import server as _server
from .log import Log, data_dir
from .runner import Runner
from .runner import Runner, configure_journal
def main(argv=None):
@@ -36,6 +36,7 @@ def main(argv=None):
return 2
registry = _catalog.load_suite()
os.makedirs(data_dir(), exist_ok=True)
configure_journal()
log = Log()
bench = _bench.Bench()
runner = Runner(log, manifest, registry, bench)
@@ -52,8 +53,6 @@ def main(argv=None):
print("forgetest %s: %d tests, image %s (%s), listening on %s:%d"
% (VERSION, len(registry), manifest.version, (manifest.content_sha or "")[:12],
args.host, args.port), file=sys.stderr, flush=True)
for m in runner.messages:
print("forgetest: %s" % m, file=sys.stderr, flush=True)
th = threading.Thread(target=srv.serve_forever, name="forgetest-http", daemon=True)
th.start()
try:
+45 -6
View File
@@ -5,7 +5,12 @@ what the release gate needs to know without running anything: the id,
the subsystem, the kind (auto / operator / live), how it takes the
hardware (api / takeover), the controller mode it needs (if any), what
source it covers, what it requires, and whether it belongs to the
always-required core. The function body runs
always-required core. Two more fields describe the operator's part
without affecting the gate: `actions`, the machine actions the test asks
for by name (the page lists them before a start; a bench actuator can
perform them), and `precheck`, a condition the machine must meet for the
test to start at all (a reason string refuses the start, the way an
unmet prerequisite does, and records no result). The function body runs
under the runner with a Context (log, prompts, evidence, hardware
helpers) and reports by returning normally (PASS) or raising
runner.Failed (FAIL).
@@ -20,6 +25,11 @@ from . import manifest as _manifest
KINDS = ("auto", "operator", "live")
HARDWARE = ("api", "takeover")
MODES = ("grbl", "cloud")
# The machine actions a test may ask of the operator (Context.act):
# the lid, the remote-interlock loop, and the big button. Everything a
# test needs done to the machine is one of these, so a bench actuator
# that covers a channel can stand in for the hands on it.
ACTIONS = ("lid", "interlock", "button")
_ID_RX = re.compile(r"^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*$")
REGISTRY = {}
@@ -27,7 +37,8 @@ REGISTRY = {}
class Test:
def __init__(self, id, title, subsystem, kind, hardware, covers, requires,
always, est_min, steps, description, fn, mode=None):
always, est_min, steps, description, fn, mode=None, actions=(),
precheck=None):
self.id = id
self.title = title
self.subsystem = subsystem
@@ -39,6 +50,8 @@ class Test:
self.always = bool(always)
self.est_min = est_min
self.steps = tuple(steps)
self.actions = tuple(actions)
self.precheck = precheck
self.description = description or (fn.__doc__ or "").strip()
self.fn = fn
self._source_sha = None
@@ -82,9 +95,21 @@ class Test:
def describe(self):
d = self.definition()
d.update({"title": self.title, "est_min": self.est_min, "mode": self.mode,
"steps": list(self.steps), "description": self.description})
"steps": list(self.steps), "actions": list(self.actions),
"precheck": bool(self.precheck), "description": self.description})
return d
def cannot_start(self):
"""The reason the test cannot start on the machine as it is, or
None. Evaluated right before a start; never a result."""
if self.precheck is None:
return None
try:
reason = self.precheck()
except Exception as e: # noqa: BLE001 - a broken precheck refuses, it never crashes the runner
return "precheck errored: %s: %s" % (type(e).__name__, e)
return reason or None
def source_file_sha(path):
with open(path, "rb") as f:
@@ -93,13 +118,19 @@ def source_file_sha(path):
def test(id, *, title, subsystem, kind="auto", hardware="api", mode=None, covers=(),
requires=(), always=False, est_min=1, steps=(), description=""):
requires=(), always=False, est_min=1, steps=(), description="", actions=(),
precheck=None):
"""`mode` names the controller mode the test needs live when it starts
("grbl" or "cloud"); the runner switches the machine there before the
test and leaves it there, so a queue crosses modes only where a test
asks it to. None means the test runs in whatever mode it finds (or
manages the mode itself, as the cloud tests do through enter_cloud,
which also waits for the service session)."""
which also waits for the service session).
`actions` names the machine actions (ACTIONS) the test performs
through Context.act; an `auto` test declares none. `precheck` is a
callable returning a reason string when the machine cannot run the
test as it is (None when it can)."""
if not _ID_RX.match(id):
raise ValueError("test id %r must look like subsystem.name" % id)
if kind not in KINDS:
@@ -111,12 +142,20 @@ def test(id, *, title, subsystem, kind="auto", hardware="api", mode=None, covers
for c, g in covers:
if c in _manifest.DEV_ONLY_COMPONENTS:
raise ValueError("test %s: may not cover dev-only component %r" % (id, c))
for a in actions:
if a not in ACTIONS:
raise ValueError("test %s: action %r" % (id, a))
if actions and kind == "auto":
raise ValueError("test %s: an auto test asks for no machine actions" % id)
if precheck is not None and not callable(precheck):
raise ValueError("test %s: precheck must be callable" % id)
def deco(fn):
if id in REGISTRY:
raise ValueError("duplicate test id %r" % id)
REGISTRY[id] = Test(id, title, subsystem, kind, hardware, covers, requires,
always, est_min, steps, description, fn, mode=mode)
always, est_min, steps, description, fn, mode=mode,
actions=actions, precheck=precheck)
return fn
return deco
+132
View File
@@ -13,6 +13,7 @@ import json
import os
import socket
import subprocess
import threading
import time
import urllib.error
import urllib.parse
@@ -152,6 +153,137 @@ def sysfs_write(attr, value):
f.write(str(value))
# ------------------------------------------------------ head accelerometer
# The head accelerometer (LIS2HH12 on i2c-3 at 0x1e): the supervisor's
# motion-liveness witness, and the catalog's. Resolved by bus address,
# never by iio index. The raw sysfs read is slow (~150 ms), which still
# lands several samples in a one-second move; the verdict is peak-to-peak
# on X or Y, against the thresholds forgectrl's probe established (a live
# head reads 1800-2900 on its probe move, a wedged one <= 250).
HEAD_ACCEL_I2C = "3-001e"
ACCEL_P2P_MOVING = 800
def head_accel_dir():
"""The head accel's iio directory, or None. GF_IIO_ROOT overrides the
/sys/bus/iio/devices root (host tests)."""
root = os.environ.get("GF_IIO_ROOT") or "/sys/bus/iio/devices"
try:
names = sorted(os.listdir(root))
except OSError:
return None
for n in names:
d = os.path.join(root, n)
try:
target = os.readlink(d)
except OSError:
target = ""
if HEAD_ACCEL_I2C in target or HEAD_ACCEL_I2C in n:
return d
# a plain directory (host fixture): its name carries the address
try:
with open(os.path.join(d, "name")) as f:
if HEAD_ACCEL_I2C in f.read():
return d
except OSError:
pass
return None
def head_accel_read(d):
"""(x, y) raw counts, or None."""
out = []
for axis in ("x", "y"):
try:
with open(os.path.join(d, "in_accel_%s_raw" % axis)) as f:
out.append(int(f.read().strip()))
except (OSError, ValueError):
return None
return tuple(out)
class AccelSampler:
"""Samples the head accelerometer in a thread for as long as it is
running; `p2p(t0, t1)` is the peak-to-peak on X and Y over the samples
in that window and how many there were. Used as a context manager
around a motion the test wants the head to have made."""
def __init__(self, period=0.0):
self.dir = head_accel_dir()
self.period = period
self.samples = [] # (t, x, y)
self._stop = threading.Event()
self._th = None
self.errors = 0
@property
def available(self):
return self.dir is not None
def __enter__(self):
if self.dir is not None:
self._th = threading.Thread(target=self._loop, daemon=True, name="forgetest-accel")
self._th.start()
return self
def __exit__(self, *a):
self._stop.set()
if self._th is not None:
self._th.join(2.0)
return False
def _loop(self):
while not self._stop.is_set():
v = head_accel_read(self.dir)
if v is None:
self.errors += 1
else:
self.samples.append((time.time(), v[0], v[1]))
if self.period:
self._stop.wait(self.period)
def p2p(self, t0, t1=None):
"""(p2p_x, p2p_y, n) over [t0, t1]; (0, 0, 0) with no samples."""
t1 = time.time() if t1 is None else t1
xs = [x for t, x, _y in self.samples if t0 <= t <= t1]
ys = [y for t, _x, y in self.samples if t0 <= t <= t1]
if not xs:
return 0, 0, 0
return max(xs) - min(xs), max(ys) - min(ys), len(xs)
# ----------------------------------------------------------- button LEDs
BUTTON_LEDS = ("button_led_1", "button_led_2", "button_led_3")
def leds_root():
r = os.environ.get("GF_LEDS_ROOT") or "/sys/class/leds/"
return r if r.endswith("/") else r + "/"
def button_leds():
"""The three button LED brightnesses, or None where unreadable."""
out = []
for name in BUTTON_LEDS:
try:
with open(leds_root() + name + "/brightness") as f:
out.append(int(f.read().strip()))
except (OSError, ValueError):
out.append(None)
return out
def button_lit():
"""True when any button LED is on, False when all three read 0, None
when none is readable."""
vals = [v for v in button_leds() if v is not None]
if not vals:
return None
return any(v > 0 for v in vals)
# --------------------------------------------------------------- init.d
def initd(service, action, timeout=60):
+39 -5
View File
@@ -87,6 +87,13 @@ input[type=text],input[type=number],select{background:#fff;border:1px solid #c9c
.hint{color:var(--dim);font-size:12.5px;line-height:1.55;margin:8px 0 0}
pre#log{background:#1d1e26;color:#d7dae0;font-family:ui-monospace,Consolas,monospace;font-size:11.5px;padding:10px;border-radius:4px;height:380px;overflow:auto;margin:8px 0;white-space:pre-wrap;word-break:break-all}
#prompt{background:#fdf3e3;border:1px solid #eccb90;border-radius:6px;padding:10px 12px;margin:8px 0}
#notice{background:#e8f1fb;border:1px solid #9dbde6;border-radius:6px;padding:10px 12px;margin:8px 0;font-weight:600}
#steps{background:#f7f8fa;border-radius:6px;padding:8px 12px;margin:8px 0;font-size:12.5px;line-height:1.5}
#steps .sh{font-weight:600;margin-bottom:4px}
#steps .sub{color:var(--dim);margin:6px 0 2px}
#steps ol{margin:2px 0 4px 20px}
#steps .auto{color:var(--dim);font-style:italic}
.tsel{cursor:pointer}.tsel:hover{text-decoration:underline}
#prompt .q{font-weight:600;margin-bottom:8px}
.details{display:none;background:#f7f8fa;padding:8px 10px;border-radius:4px;font-size:12.5px;line-height:1.55;margin-top:6px}
.details.on{display:block}
@@ -120,7 +127,7 @@ pre#log{background:#1d1e26;color:#d7dae0;font-family:ui-monospace,Consolas,monos
<div class='card'><h2>Campaign</h2>
<div class='banner'><div class='auth' id='auth'>?</div>
<div class='kv' id='banner'></div></div>
<div id='invnote'></div><div id='msgs'></div>
<div id='invnote'></div>
<div class='card queue'><h2>Run what is left</h2>
<div class='actions'>
<button class='pri' id='q-unattended' onclick='startBatch("unattended")'>Unattended</button>
@@ -139,6 +146,7 @@ pre#log{background:#1d1e26;color:#d7dae0;font-family:ui-monospace,Consolas,monos
<a id='dljson' href='/export/acceptance.json' style='display:none'><button>acceptance.json</button></a>
<a id='dlmd' href='/export/acceptance.md' style='display:none'><button>acceptance.md</button></a>
<a href='/log'><button>Raw log</button></a>
<a href='/journal'><button>Runner journal</button></a>
<button onclick='toggleInv()'>Invalidate all&hellip;</button>
<button onclick='doReset()'>Reset campaign</button>
</div>
@@ -165,6 +173,8 @@ pre#log{background:#1d1e26;color:#d7dae0;font-family:ui-monospace,Consolas,monos
<div id='right'>
<div class='card'><h2 id='runtitle'>Run</h2>
<div id='runhead' class='kv'>idle</div>
<div id='steps' style='display:none'></div>
<div id='notice' style='display:none'></div>
<div id='prompt' style='display:none'><div class='q' id='promptq'></div><div class='actions' id='promptb'></div></div>
<pre id='log'></pre>
<div class='actions'><button class='danger' id='abortbtn' onclick='doAbort()' disabled>Abort</button><span class='hint' id='runfoot'></span></div>
@@ -244,8 +254,7 @@ function render(){if(!state||!catalog)return;
if(state.log_corrupt)b+='<br><span style="color:var(--red)">'+state.log_corrupt+' corrupt log line(s) skipped</span>';
setHtml($('banner'),b);
var inv=state.invalidate;setHtml($('invnote'),inv?"<div class='note'>Full campaign required since "+esc(fmtTs(inv.ts))+": "+esc(inv.reason)+"</div>":'');
var ms='';(state.messages||[]).forEach(function(x){ms+="<div class='note'>"+esc(x)+"</div>"});setHtml($('msgs'),ms);
renderQueue();renderGroups();renderRun();if(bench)renderBench()}
renderQueue();renderGroups();renderRun();renderSteps();if(bench)renderBench()}
/* The two queues: what each would run now, and how the running one is
getting on. Built from the state, so a reload picks the queue back up
exactly where it is - the queue lives in the runner, not in this tab. */
@@ -292,9 +301,10 @@ function buildGroups(){var groups={},order=[];
var det="<div class='details"+(openDetails[t.id]?' on':'')+"' id='det-"+d+"'>"+
(t.description?("<div class='dsc'>"+esc(t.description)+"</div>"):'')+
(t.steps&&t.steps.length?"<b>Operator steps:</b><ol>"+t.steps.map(function(x){return '<li>'+esc(x)+'</li>'}).join('')+"</ol>":'')+
(t.actions&&t.actions.length?"<b>Machine actions:</b> "+esc(t.actions.join(', '))+"<br>":'')+
"<b>Requires:</b> "+esc((t.requires||[]).join(', ')||'-')+"<br><b>Covers:</b> "+esc((t.covers||[]).map(function(c){return c[0]+':'+c[1]}).join(', ')||'-')+
"<br><span id='detdyn-"+d+"'></span></div>";
h+="<tr><td><div>"+esc(t.title)+"</div><div class='tid'>"+d+" <a href='#' onclick='toggleDet(\""+d+"\");return false'>details</a></div>"+
h+="<tr><td><div class='tsel' title='show what this test asks of you' onclick='selectTest(\""+d+"\")'>"+esc(t.title)+"</div><div class='tid'>"+d+" <a href='#' onclick='toggleDet(\""+d+"\");return false'>details</a></div>"+
"</td><td>"+badges+"</td>"+
"<td><div id='st-"+d+"'></div><div id='unmet-"+d+"'></div><div id='note-"+d+"'></div></td>"+
"<td id='last-"+d+"'></td>"+
@@ -327,7 +337,7 @@ function updateGroups(){if(!rowEls||!state)return;var busy=isBusy();
function toggleDet(id){openDetails[id]=!openDetails[id];var e=$('det-'+id);if(e)e.className='details'+(openDetails[id]?' on':'')}
function renderRun(){var r=state.running||state.last_run;var key=r?(r.kind+':'+r.id+':'+r.started):null;
if(key!==lastRunKey)abortSent=false;
if(!r){setText($('runhead'),'idle');setText($('log'),'');$('prompt').style.display='none';
if(!r){setText($('runhead'),'idle');setText($('log'),'');$('prompt').style.display='none';$('notice').style.display='none';
curPrompt=null;promptKey=null;setDis($('abortbtn'),true);setText($('runtitle'),'Run');lastRunKey=null;return}
setText($('runtitle'),(r.kind==='bench'?'Bench: ':'Test: ')+r.title);
var hd=esc(r.id)+' &middot; started '+esc(fmtTs(r.started))+' &middot; '+r.elapsed_s+' s';
@@ -338,6 +348,8 @@ function renderRun(){var r=state.running||state.last_run;var key=r?(r.kind+':'+r
if(lg.__t!==txt){var atBottom=lg.scrollTop+lg.clientHeight>=lg.scrollHeight-20;
lg.__t=txt;lg.textContent=txt;if(atBottom||key!==lastRunKey)lg.scrollTop=lg.scrollHeight}
lastRunKey=key;
var nt=(r.notice&&!r.finished)?r.notice.text:'';
setText($('notice'),nt);$('notice').style.display=nt?'':'none';
/* The prompt buttons are rebuilt only when the prompt itself changes -
they are the ones the operator stares at before pressing. */
if(r.prompt&&!r.finished){var pk=r.prompt.id+'|'+r.prompt.question+'|'+r.prompt.options.length+':'+r.prompt.options.join(',');
@@ -349,6 +361,28 @@ function renderRun(){var r=state.running||state.last_run;var key=r?(r.kind+':'+r
setDis($('abortbtn'),!!r.finished||abortSent||!!r.aborting);
if(r.finished&&tab==='bench'&&r.kind==='bench'&&benchNeedsRefresh){benchNeedsRefresh=false;loadBench()}}
function findTest(id){for(var i=0;i<catalog.length;i++)if(catalog[i].id===id)return catalog[i];return null}
/* What the operator will be asked to do, shown before it is asked: the
running test's steps for the whole run, the queue's attended tests
before the first one starts, or the test the operator clicked on. The
prompts and notices that follow are these steps, taken in turn. */
var selected=null;
function selectTest(id){selected=(selected===id)?null:id;renderSteps()}
function stepsOf(t,head){var h='';
var acts=(t.actions||[]);
if(head)h+="<div class='sub'>"+head+"</div>";
if(t.kind==='live')h+="<div>This test fires the laser: eye protection, fire watch, exhaust, scrap under the head.</div>";
if(acts.length)h+="<div>Machine actions: <b>"+esc(acts.join(', '))+"</b> (a standing instruction tells you when; the test watches the machine for it)</div>";
if(t.steps&&t.steps.length)h+="<ol>"+t.steps.map(function(x){return '<li>'+esc(x)+'</li>'}).join('')+"</ol>";
return h}
function renderSteps(){var e=$('steps');if(!e||!catalog)return;var h='',r=state.running;
if(r&&r.kind==='test'){var t=findTest(r.id);
if(t&&((t.steps&&t.steps.length)||(t.actions&&t.actions.length)||t.kind==='live'))h="<div class='sh'>What you will do</div>"+stepsOf(t,null)}
else if(batchActive()&&state.batch.pending&&state.batch.pending.length){var parts=[];
state.batch.pending.forEach(function(id){var t=findTest(id);if(t&&t.kind!=='auto')parts.push(stepsOf(t,esc(id)))});
if(parts.length)h="<div class='sh'>Coming up in this queue</div>"+parts.join('')}
else if(selected&&!r){var t=findTest(selected);
if(t)h="<div class='sh'>Before you start "+esc(t.id)+"</div>"+(t.kind==='auto'?"<div class='auto'>Nothing: this test needs nobody at the machine.</div>":stepsOf(t,null))}
setHtml(e,h);e.style.display=h?'':'none'}
function startTest(id){if(isBusy())return;var t=findTest(id);var body={test:id};
if(ignoreReq)body.ignore_requires=true;
if(t&&t.kind==='live'){if(!confirmLive())return;body.ack_live=true}
+161 -11
View File
@@ -17,11 +17,28 @@ prerequisite order and stops on the first result that is not a PASS,
because a FAIL closes the campaign and going on would quietly open a
second one.
The operator's part of a test is asked for in one of three ways. A
`ready` prompt pre-announces a timed step and waits for the click that
starts it. A `notice` is a standing instruction with no button: the test
shows it and watches the machine for the result. An `act` is a machine
action by name (lid, interlock, button) - a notice for the operator
today, and the seam a bench actuator plugs into - that returns when the
machine shows the action done. A `confirm` stays what it was: a yes/no
the evidence cannot answer.
Safety, in code rather than convention: a live test starts only with the
operator's acknowledgment in the request; the runner never touches the
laser latch; a takeover always ends with forgectrl started again, and a
marker file makes a crash mid-takeover recoverable at the next start.
Runner-level events (a queue opening or stopping, a takeover recovered,
the leftovers a baseline pass found) go to the journal: the daemon's
stderr (daemon.log under the data directory), syslog when there is one,
and the log of the run in progress. The page shows the run, never the
journal.
"""
import logging
import logging.handlers
import os
import random
import subprocess
@@ -57,6 +74,44 @@ class Failed(Exception):
pass
journal = logging.getLogger("forgetest")
journal.setLevel(logging.INFO)
def configure_journal(syslog_path="/dev/log"):
"""The daemon's journal: stderr (the init script keeps it in
daemon.log) and, where the socket exists, syslog under the
`forgetest` name (the unified log tree files it under system/)."""
if journal.handlers:
return journal
h = logging.StreamHandler()
h.setFormatter(logging.Formatter("%(asctime)s forgetest: %(message)s", "%Y-%m-%dT%H:%M:%S"))
journal.addHandler(h)
if os.path.exists(syslog_path):
try:
sh = logging.handlers.SysLogHandler(address=syslog_path)
sh.setFormatter(logging.Formatter("forgetest[%(process)d]: %(message)s"))
sh.ident = ""
journal.addHandler(sh)
except OSError:
pass
return journal
# The wording of each machine action, for the operator who performs it
# while the bench has no actuator. The text names the action and nothing
# else; a test adds its own context.
ACTION_TEXT = {
("lid", "open"): "Open the lid.",
("lid", "close"): "Close the lid.",
("interlock", "open"): "Open the remote-interlock loop: unplug the Pro's interlock plug, or "
"pull the jumper at J8 on a Basic/Plus.",
("interlock", "close"): "Restore the remote-interlock loop (plug or jumper back in).",
("button", "press"): "Press the button once.",
}
ACT_TIMEOUT_S = 180
class Run:
def __init__(self, kind, id, title):
self.kind = kind # 'test' | 'bench'
@@ -67,6 +122,7 @@ class Run:
self.lines = []
self.dropped = 0
self.prompt = None # {"id","question","options"}
self.notice = None # {"id","text"}: a standing instruction, no button
self.answers = []
self.evidence = {}
self.baseline_captured = None # preserved state the post pass hands back
@@ -90,10 +146,11 @@ class Run:
with self._lock:
lines = self.lines[-tail:]
prompt = dict(self.prompt) if self.prompt else None
notice = dict(self.notice) if self.notice else None
return {
"kind": self.kind, "id": self.id, "title": self.title,
"started": self.started_ts, "elapsed_s": int(time.time() - self.started),
"log": lines, "dropped": self.dropped, "prompt": prompt,
"log": lines, "dropped": self.dropped, "prompt": prompt, "notice": notice,
"finished": self.finished, "aborting": self.aborted.is_set(),
}
@@ -114,6 +171,14 @@ class Run:
self.answers.append({"ts": now_ts(), "question": question, "answer": ans})
return ans
def set_notice(self, text):
with self._lock:
if text is None:
self.notice = None
else:
self._prompt_seq += 1
self.notice = {"id": "n%d" % self._prompt_seq, "text": text}
def answer(self, prompt_id, value):
with self._cv:
if not self.prompt or self.prompt["id"] != prompt_id:
@@ -190,6 +255,88 @@ class Context:
if ans != "Done":
raise Failed("operator could not: %s" % text)
def ready(self, text):
"""Pre-announce a timed step: what happens when the operator
clicks Ready and what they do during it. Returns on the click;
the test then starts the thing and watches the machine."""
ans = self.prompt(text, ("Ready", "Cannot"))
if ans != "Ready":
raise Failed("operator could not: %s" % text)
def notice(self, text):
"""A standing instruction with no button: the page shows it
until clear_notice(), while the test watches the machine for the
result. Logged like a prompt."""
self.log("NOTICE: %s", text)
self.run.set_notice(text)
def clear_notice(self):
self.run.set_notice(None)
def wait_for(self, cond, timeout, poll=0.25):
"""Poll `cond()` until true; the seconds it took, or None on
timeout. Abort-aware. An error in cond counts as not yet."""
t0 = time.time()
while time.time() - t0 < timeout:
self.checkpoint()
try:
if cond():
return time.time() - t0
except Exception: # noqa: BLE001 - a transient read error is "not yet"
pass
time.sleep(poll)
return None
def switch(self, name):
"""One of forgectrl's switch readings (lid, interlock_ok, ...)."""
return (self.forgectrl.status().get("switches") or {}).get(name)
def act(self, channel, state, until=None, timeout=ACT_TIMEOUT_S, text="", fail=True):
"""A machine action by name: ("lid", "open"|"close"),
("interlock", "open"|"close"), ("button", "press"). The bench's
actuator performs it when one covers the channel (the runner's
`fixture`; none exists yet); otherwise the operator does, told by
a standing notice, and the test watches the machine for the
result - the switch reaching the state, or `until()` true
(required for the button, whose press is proven by what it does).
`text` adds the test's own context after the action's wording.
Returns the seconds the action took; with fail=False a timeout
returns None instead of failing the test."""
key = (channel, state)
if key not in ACTION_TEXT:
raise ValueError("unknown action %r" % (key,))
if until is None:
if channel == "button":
raise ValueError("a button press needs `until`: what the press is expected to do")
want_open = state == "open"
if channel == "lid":
until = lambda: self.switch("lid") is (not want_open) # noqa: E731
else:
until = lambda: self.switch("interlock_ok") is (not want_open) # noqa: E731
wording = ACTION_TEXT[key] + ((" " + text) if text else "")
fixture = getattr(self.runner, "fixture", None) if self.runner is not None else None
rec = {"channel": channel, "state": state, "by": "operator", "ts": now_ts()}
self.evidence.setdefault("actions", []).append(rec)
if fixture is not None and fixture.covers(channel):
self.log("ACT %s %s (fixture)", channel, state)
rec["by"] = "fixture"
fixture.act(channel, state)
else:
self.notice(wording)
try:
dt = self.wait_for(until, timeout)
finally:
if rec["by"] == "operator":
self.clear_notice()
rec["took_s"] = round(dt, 2) if dt is not None else None
if dt is None:
self.log("ACT %s %s: not seen within %d s", channel, state, timeout)
if fail:
raise Failed("%s %s was not seen on the machine within %d s" % (channel, state, timeout))
return None
self.log("ACT %s %s: done after %.1f s", channel, state, dt)
return dt
# -- hardware ------------------------------------------------------
@property
def forgectrl(self):
@@ -326,7 +473,7 @@ class Runner:
self.current = None
self.last = None
self.batch = None
self.messages = []
self.fixture = None # the bench actuator, when one is configured (none yet)
self.boot_ref = None
self.recover()
threading.Thread(target=self._take_boot_reference, daemon=True,
@@ -339,10 +486,11 @@ class Runner:
self._note("baseline: boot reference failed: %s: %s" % (type(e).__name__, e))
def _note(self, msg):
"""A runner-level line: kept in messages for the page (bounded)."""
with self._lock:
self.messages.append(msg)
del self.messages[:-50]
"""A runner-level line: to the journal, and to the run in progress."""
journal.info(msg)
r = self.current
if r is not None and not r.finished:
r.log(msg)
# -- startup recovery ------------------------------------------------
def recover(self):
@@ -353,7 +501,7 @@ class Runner:
who = f.read().strip()
except OSError:
who = "?"
self.messages.append("recovered a takeover left by '%s': starting forgectrl" % who)
self._note("recovered a takeover left by '%s': starting forgectrl" % who)
hw.initd("forgectrl", "start")
try:
os.remove(m)
@@ -376,7 +524,6 @@ class Runner:
st["manifest"] = {"sha": self.manifest.content_sha, "identity": self.manifest.identity_sha(),
"image": self.manifest.image_name, "version": self.manifest.version}
st["log_corrupt"] = self.log.corrupt
st["messages"] = list(self.messages)
r = self.current
st["running"] = r.snapshot() if r and not r.finished else None
last = r if (r and r.finished) else self.last
@@ -450,6 +597,9 @@ class Runner:
return False, "prerequisites not satisfied: %s" % ", ".join(missing), None
if t.kind == "live" and not ack_live:
return False, "live test: acknowledge eye protection, fire watch, and exhaust first", None
reason = t.cannot_start()
if reason:
return False, "cannot start: %s" % reason, None
campaign = self._open_campaign_if_needed(state)
run = Run("test", t.id, t.title)
self.last = self.current
@@ -585,8 +735,8 @@ class Runner:
left = bl.enforce("pre", captured=None)
if left:
who = self.last.id if self.last is not None else "an earlier run"
self.messages.append("leftovers before %s (left by %s): %s"
% (run.id, who, "; ".join(str(x) for x in left)))
journal.info("leftovers before %s (left by %s): %s"
% (run.id, who, "; ".join(str(x) for x in left)))
run.evidence["baseline"] = {"pre": [x.as_dict() for x in left]}
if mode:
found = bl.mode
@@ -605,7 +755,7 @@ class Runner:
left = bl.enforce("post", captured=run.baseline_captured or captured)
run.evidence.setdefault("baseline", {})["post"] = [x.as_dict() for x in left]
if left:
self.messages.append("leftovers after %s: %s" % (run.id, "; ".join(str(x) for x in left)))
journal.info("leftovers after %s: %s" % (run.id, "; ".join(str(x) for x in left)))
return left
def _exec_test(self, t, run, campaign):
+21
View File
@@ -17,6 +17,7 @@ Routes
GET /bench bench tool listing
GET /result?test&ts one full result record (log, evidence)
GET /log the raw JSONL
GET /journal the daemon's journal (daemon.log tail)
GET /export/acceptance.json | .md the last export
POST /start {test, ack_live, ignore_requires} start an acceptance test
POST /batch {group, ack_live, ignore_requires} run everything a queue
@@ -43,6 +44,24 @@ from . import artifact as _artifact
from . import page as _page
from .log import data_dir
JOURNAL_TAIL_BYTES = 256 << 10
def journal_tail(path=None, max_bytes=JOURNAL_TAIL_BYTES):
"""The last max_bytes of the daemon's journal (daemon.log, written by
the init script from the daemon's stderr), or a note that there is
none yet."""
path = path or os.path.join(data_dir(), "daemon.log")
try:
size = os.path.getsize(path)
with open(path, "rb") as f:
if size > max_bytes:
f.seek(size - max_bytes)
f.readline()
return f.read()
except OSError:
return b"(no journal yet: %s)\n" % path.encode()
TOKEN_HEX = 32
_LITERAL_RX = re.compile(r"^[0-9.]+$")
@@ -229,6 +248,8 @@ class Handler(BaseHTTPRequestHandler):
self._send(200, rec)
elif path == "/log":
self._send(200, r.log.raw(), "text/plain")
elif path == "/journal":
self._send(200, journal_tail(), "text/plain")
elif path in ("/export/acceptance.json", "/export/acceptance.md"):
fn = os.path.join(self.app.export_dir, os.path.basename(path))
if not os.path.exists(fn):
+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"])
+91 -2
View File
@@ -69,9 +69,11 @@ def _noop(ctx):
pass
def make_test(id, covers, always=False, requires=(), kind="auto", fn=None, subsystem=None, mode=None):
def make_test(id, covers, always=False, requires=(), kind="auto", fn=None, subsystem=None, mode=None,
actions=(), precheck=None, steps=()):
return catalog_mod.Test(id, "Title " + id, subsystem or id.split(".")[0], kind, "api",
covers, requires, always, 1, (), "desc", fn or _noop, mode=mode)
covers, requires, always, 1, steps, "desc", fn or _noop, mode=mode,
actions=actions, precheck=precheck)
def registry(*tests):
@@ -165,3 +167,90 @@ class FakeForgectrl:
self._srv.server_close()
os.environ.pop("FORGECTRL_URL", None)
os.environ.pop("FORGECTRL_TOKEN_FILE", None)
# ------------------------------------------------------------ fake grbl
class FakeGrbl:
"""A Grbl-over-TCP stand-in for the controller: answers '?' with a
status report built from its mutable `state`/`mpos`, 'ok' to every
command line, and records what it was sent. Point the suite at it
with GRBL_HOST/GRBL_PORT (see start/stop)."""
def __init__(self):
import socket as _socket
import threading as _threading
self.state = "Idle"
self.mpos = [0.0, 0.0, 0.0]
self.sent = []
self.extra = b"" # text pushed to the client on the next poll
self._sock = _socket.socket()
self._sock.setsockopt(_socket.SOL_SOCKET, _socket.SO_REUSEADDR, 1)
self._sock.bind(("127.0.0.1", 0))
self._sock.listen(5)
self.port = self._sock.getsockname()[1]
self._stop = False
self._th = _threading.Thread(target=self._serve, daemon=True)
def _serve(self):
import socket as _socket
import threading as _threading
self._sock.settimeout(0.2)
while not self._stop:
try:
c, _ = self._sock.accept()
except (_socket.timeout, OSError):
continue
_threading.Thread(target=self._client, args=(c,), daemon=True).start()
def _client(self, c):
import socket as _socket
c.settimeout(0.1)
buf = b""
try:
c.sendall(b"\r\nGrblHAL 1.1f ['$' for help]\r\n")
while not self._stop:
try:
d = c.recv(4096)
if not d:
break
buf += d
except _socket.timeout:
continue
except OSError:
break
out = b""
if self.extra:
out += self.extra
self.extra = b""
while buf:
if buf[0:1] == b"?":
buf = buf[1:]
out += ("<%s|MPos:%.3f,%.3f,%.3f|FS:0,0>\r\n" % (self.state, *self.mpos)).encode()
elif buf[0] in (0x18, 0x85, 0x7E, 0x21):
buf = buf[1:]
elif b"\n" in buf:
line, buf = buf.split(b"\n", 1)
self.sent.append(line.strip().decode("utf-8", "replace"))
out += b"ok\r\n"
else:
break
if out:
c.sendall(out)
finally:
c.close()
def start(self):
self._th.start()
os.environ["GRBL_HOST"] = "127.0.0.1"
os.environ["GRBL_PORT"] = str(self.port)
return self
def stop(self):
self._stop = True
try:
self._sock.close()
except OSError:
pass
for k in ("GRBL_HOST", "GRBL_PORT"):
os.environ.pop(k, None)
+144 -86
View File
@@ -1,8 +1,9 @@
"""The cloud.* job-behavior tests against a fake forgectrl and a replayed
gfcloud log: the real test functions run under the real runner Context,
the operator prompts answered by a script that also drives the replay
(open the lid -> the machine's lid reads open; close it -> the service's
re-hunt lines land). The log lines are the machine's own: bench excerpts
the operator prompts answered - and the standing notices of the machine
actions acted on - by a script that also drives the replay (open the
lid -> the machine's lid reads open; close it -> the service's re-hunt
lines land). The log lines are the machine's own: bench excerpts
(the lid-abort, button-wait, and lid-open-hunt runs) and, for the pause,
the run loop's lines as its host test emits them on the print skeleton
of the lid-abort excerpt.
@@ -62,13 +63,16 @@ def cut(lines, marker, count=1):
class Script:
"""Answers every prompt with its first option; a hook per prompt
substring drives the fake machine and the log replay."""
"""Answers every prompt with its first option; a hook per prompt or
notice substring drives the fake machine and the log replay (a
machine action is a notice the test watches the machine for, so the
hook is what makes the machine show it)."""
def __init__(self, run, hooks=None):
self.run = run
self.hooks = hooks or {}
self.asked = []
self.noticed = []
self.th = threading.Thread(target=self._loop, daemon=True)
self.stop = False
@@ -76,16 +80,25 @@ class Script:
self.th.start()
return self
def _fire(self, text):
for key, fn in self.hooks.items():
if key in text:
fn()
def _loop(self):
seen = None
seen_n = None
while not self.stop:
n = self.run.notice
if n and n["id"] != seen_n:
seen_n = n["id"]
self.noticed.append(n["text"])
self._fire(n["text"])
p = self.run.prompt
if p and p["id"] != seen:
seen = p["id"]
self.asked.append(p["question"])
for key, fn in self.hooks.items():
if key in p["question"]:
fn()
self._fire(p["question"])
self.run.answer(p["id"], p["options"][0])
time.sleep(0.02)
@@ -100,13 +113,23 @@ class CloudSuiteTests(unittest.TestCase):
self._attr("cnc/interlock_circuit", "45") # latch locked (bit 3)
self._pos(0, 0, 3)
os.environ["GF_SYSFS_ROOT"] = self.sysfs
# the button LEDs, dark (the tests check a cancel leaves them so)
for n in ("button_led_1", "button_led_2", "button_led_3"):
os.makedirs(os.path.join(self.tmp, "leds", n), exist_ok=True)
with open(os.path.join(self.tmp, "leds", n, "brightness"), "w") as f:
f.write("0")
os.environ["GF_LEDS_ROOT"] = os.path.join(self.tmp, "leds") + os.sep
self.fc = helpers.FakeForgectrl().start()
self.grbl = None
self.fclog = os.path.join(self.tmp, "forgectrl.log")
open(self.fclog, "wb").close()
self.homelog = os.path.join(self.tmp, "gfhome.log")
open(self.homelog, "wb").close()
self.saved = (cloud.GFCLOUD_LOG, cloud.FORGECTRL_LOG, cloud.QUIET_S, cloud.QUIET_TIMEOUT_S,
cloud.HUNT_TIMEOUT_S)
cloud.HUNT_TIMEOUT_S, cloud.GFHOME_LOG)
cloud.GFCLOUD_LOG = self.log
cloud.FORGECTRL_LOG = self.fclog
cloud.GFHOME_LOG = self.homelog
self.engine_line = EFFECTIVE_LINE # what the engine logs at the print; None = nothing
self.client_limits = True # the client names its header limits
cloud.QUIET_S = 0.4
@@ -118,9 +141,12 @@ class CloudSuiteTests(unittest.TestCase):
if self.script:
self.script.stop = True
self.fc.stop()
if self.grbl:
self.grbl.stop()
(cloud.GFCLOUD_LOG, cloud.FORGECTRL_LOG, cloud.QUIET_S, cloud.QUIET_TIMEOUT_S,
cloud.HUNT_TIMEOUT_S) = self.saved
cloud.HUNT_TIMEOUT_S, cloud.GFHOME_LOG) = self.saved
os.environ.pop("GF_SYSFS_ROOT", None)
os.environ.pop("GF_LEDS_ROOT", None)
shutil.rmtree(self.tmp, ignore_errors=True)
# -- fakes -----------------------------------------------------------
@@ -222,80 +248,107 @@ class CloudSuiteTests(unittest.TestCase):
self.assertFails(cloud.enter_cloud, "still running service moves")
# -- the hunt with the lid open ------------------------------------------
def test_hunt_lid_open_restarts_the_client_in_cloud_mode(self):
self.in_cloud(pid=1927)
# -- the mode switch: hunt with the lid open, then $H -----------------------
HUNT_RUN_SAMPLE = {"phase": "run", "verdict": "ok", "armed": False,
"fan_gates": {"exhaust": {"state": "unjudged", "reading": 0, "floor": 500}}}
def mode_switch_setup(self, hunt_lines=None, home_complete=True):
"""The fakes a mode-switch run needs: grbl to answer $H, the lid
lamp attr, homing_mode = gfcloud, the service lines landing on
the switch to cloud (the hunt reads as a run to the cooling
engine while it lasts), the re-hunt on the lid close, and gfhome
finishing the homing after $H."""
self.grbl = helpers.FakeGrbl().start()
os.makedirs(self.sysfs + "pic", exist_ok=True)
self._attr("pic/lid_led", "236")
self.fc.state["settings"]["homing_mode"] = "gfcloud"
lines = fixture("huntlid")
pre, post = cut(lines, "gfuiservice:__init__ INITIALIZED")
hunt_part, close_part = cut(post, "_switch_event lid closed")
self.append(pre)
if hunt_lines is not None:
hunt_part = hunt_lines(hunt_part)
fc = self.fc
def on_post(path, form):
if path == "/controller/stop":
self.fc.state["mode"] = dict(self.fc.state["mode"], controller="standby", pid=0)
elif path == "/controller/start":
self.fc.state["mode"] = dict(self.fc.state["mode"], controller="running", pid=2278)
self.append(hunt_part, delay=0.2)
if path == "/mode" and form.get("controller") == "cloud":
fc.state["cool"] = dict(self.HUNT_RUN_SAMPLE)
def land():
self.append(pre, delay=0.0)
time.sleep(0.3)
self.append(hunt_part, delay=0.0)
time.sleep(4.0) # the hunt outlasts the session wait on the bench
fc.state["cool"] = {"phase": "idle", "armed": False, "hold": False}
threading.Thread(target=land, daemon=True).start()
elif path == "/mode" and form.get("controller") == "grbl":
self.grbl.state = "Idle"
return None
self.fc.on_post = on_post
def lid_closed():
self.lid(True)
self.append(close_part, delay=0.2)
run = self.run_test(cloud.hunt_lid_open,
hooks={"Open the lid": lambda: self.lid(False), "Close the lid": lid_closed},
test_id="cloud.hunt-lid-open")
self.assertEqual([p for p, _ in self.fc.posts], ["/controller/stop", "/controller/start"])
def homing():
self.grbl.state = "Home"
time.sleep(0.4)
self.grbl.state = "Idle"
self.fc.state["status"]["homed"] = True
if home_complete:
with open(self.homelog, "ab") as f:
f.write(b"2026-08-17T09:46:00.000000+00:00 gfhome[2300] INFO homing complete "
b"(service quiet 8s, 3 motion windows)\n")
self.home_hook = homing
return {"Open the lid.": lambda: self.lid(False), "Close the lid.": lid_closed}
def wait_home_command(self):
while "$H" not in self.grbl.sent:
time.sleep(0.02)
self.home_hook()
def test_mode_switch_round_trip_with_the_hunt_lid_open_and_the_homing(self):
hooks = self.mode_switch_setup()
threading.Thread(target=self.wait_home_command, daemon=True).start()
run = self.run_test(cloud.mode_switch, hooks=hooks, test_id="cloud.mode-switch")
ev = run.evidence
posts = [(p, f.get("controller")) for p, f in self.fc.posts if p == "/mode"]
self.assertEqual(posts, [("/mode", "cloud"), ("/mode", "grbl")])
self.assertEqual(self.fc.state["mode"]["mode"], "grbl")
self.assertIn(":completed", ev["hunt_line"])
self.assertEqual(ev["refusals_before_hunt_end"], 0)
self.assertTrue(ev["lens_homed"])
self.assertEqual(ev["motions_after_lid_close"], 3)
self.assertEqual(self.fc.state["mode"]["mode"], "cloud") # still cloud
self.assertTrue(any("PASS:" in l for l in run.lines))
# the prompts, in order: open, confirm lens, close
self.assertEqual(len(self.script.asked), 3)
self.assertTrue(all(c.get("verdict") != "AIRFLOW" for c in ev["hunt_gates"]))
self.assertTrue(ev["homed"])
self.assertIn("3 motion windows", ev["gfhome_complete"])
self.assertIn("$H", self.grbl.sent)
# the lid was a machine action, not a prompt: two notices, no questions
self.assertEqual([r["state"] for r in ev["actions"]], ["open", "close"])
self.assertEqual(self.script.asked, [])
self.assertTrue(any("PASS:" in l for l in run.lines), run.lines[-5:])
def test_hunt_lid_open_from_grbl_switches_and_stays(self):
lines = fixture("huntlid")
pre, post = cut(lines, "gfuiservice:__init__ INITIALIZED")
hunt_part, close_part = cut(post, "_switch_event lid closed")
self.append(pre)
def test_mode_switch_fails_when_the_hunt_is_refused_for_the_lid(self):
def refused(hunt_part):
i = next(i for i, l in enumerate(hunt_part) if "z_axis:home starting z homing cycle" in l)
return hunt_part[:i] + ["2026-08-17T09:45:10.595000+00:00 gfcloud[2278] INFO machine:_safe_to_move "
"lid opened, unsafe to move"] + hunt_part[i:]
hooks = self.mode_switch_setup(hunt_lines=refused)
self.assertFails(cloud.mode_switch, "refused for the lid", hooks=hooks)
def on_post(path, form):
if path == "/mode":
self.append(hunt_part, delay=0.2)
return None
self.fc.on_post = on_post
run = self.run_test(cloud.hunt_lid_open,
hooks={"Open the lid": lambda: self.lid(False),
"Close the lid": lambda: (self.lid(True), self.append(close_part, delay=0.2))})
self.assertEqual([p for p, _ in self.fc.posts], ["/mode"])
self.assertEqual(self.fc.state["mode"]["mode"], "cloud")
self.assertEqual(run.baseline_captured["mode"], "cloud")
self.assertIn(":completed", run.evidence["hunt_line"])
def test_mode_switch_fails_when_the_hunt_skipped_the_lens(self):
hooks = self.mode_switch_setup(hunt_lines=lambda part: [l for l in part if "z homing cycle" not in l])
self.assertFails(cloud.mode_switch, "did not home the lens", hooks=hooks)
def test_hunt_lid_open_needs_the_lid_open(self):
self.in_cloud()
self.append(fixture("huntlid"))
self.assertFails(cloud.hunt_lid_open, "lid reads closed")
def test_mode_switch_fails_when_gfhome_never_saw_the_head_move(self):
hooks = self.mode_switch_setup(home_complete=False)
threading.Thread(target=self.wait_home_command, daemon=True).start()
self.assertFails(cloud.mode_switch, "no 'homing complete' line", hooks=hooks)
def test_hunt_refused_for_the_lid_fails(self):
self.in_cloud(pid=1927)
lines = fixture("huntlid")
pre, post = cut(lines, "gfuiservice:__init__ INITIALIZED")
# a refusal ahead of the hunt's end (the lid gating the hunt would look like this)
i = next(i for i, l in enumerate(post) if "z_axis:home starting z homing cycle" in l)
post = post[:i] + ["2026-08-17T09:45:10.595000+00:00 gfcloud[2278] INFO machine:_safe_to_move lid opened, unsafe to move"] + post[i:]
self.append(pre)
def on_post(path, form):
if path == "/controller/stop":
self.fc.state["mode"] = dict(self.fc.state["mode"], controller="standby", pid=0)
elif path == "/controller/start":
self.fc.state["mode"] = dict(self.fc.state["mode"], controller="running", pid=2278)
self.append(post, delay=0.1)
return None
self.fc.on_post = on_post
self.assertFails(cloud.hunt_lid_open, "refused for the lid", hooks={"Open the lid": lambda: self.lid(False)})
def test_mode_switch_precheck_needs_gfcloud_homing(self):
self.fc.state["settings"]["homing_mode"] = "switches"
self.assertIn("needs gfcloud", cloud.homing_mode_is_gfcloud())
self.fc.state["settings"]["homing_mode"] = "gfcloud"
self.assertIsNone(cloud.homing_mode_is_gfcloud())
# -- pause / resume ---------------------------------------------------------
def replay_print(self, name, at_run, at_end, tail_delay=0.3):
@@ -327,7 +380,7 @@ class CloudSuiteTests(unittest.TestCase):
self.append(["2026-08-17T09:41:00.000000+00:00 gfcloud[1522] INFO authentication:authenticate_machine SUCCESS",
"2026-08-17T09:41:00.500000+00:00 gfcloud[1522] INFO websocket:_on_open RX-EVENT: ready",
"2026-08-17T09:41:00.900000+00:00 gfcloud[1522] INFO websocket:ws_connect ESTABLISHED"])
hooks = self.replay_print("pause", "Press the button once NOW", "current state: MachineState.IDLE")
hooks = self.replay_print("pause", "the press pauses the print", "current state: MachineState.IDLE")
self.fc.state["cool"]["armed"] = True
run = self.run_test(cloud.pause_resume, hooks=hooks, test_id="cloud.pause-resume")
ev = run.evidence
@@ -351,7 +404,7 @@ class CloudSuiteTests(unittest.TestCase):
self.client_limits = False
self.in_cloud(pid=1522)
self.append(["2026-08-17T09:41:00.500000+00:00 gfcloud[1522] INFO websocket:_on_open RX-EVENT: ready"])
hooks = self.replay_print("pause", "Press the button once NOW", "current state: MachineState.IDLE")
hooks = self.replay_print("pause", "the press pauses the print", "current state: MachineState.IDLE")
self.fc.state["cool"]["armed"] = True
self.assertFails(cloud.pause_resume, "named no job limits", hooks=hooks)
@@ -359,7 +412,7 @@ class CloudSuiteTests(unittest.TestCase):
self.engine_line = EFFECTIVE_LINE.replace("header 33.0", "header none 0.0")
self.in_cloud(pid=1522)
self.append(["2026-08-17T09:41:00.500000+00:00 gfcloud[1522] INFO websocket:_on_open RX-EVENT: ready"])
hooks = self.replay_print("pause", "Press the button once NOW", "current state: MachineState.IDLE")
hooks = self.replay_print("pause", "the press pauses the print", "current state: MachineState.IDLE")
self.fc.state["cool"]["armed"] = True
self.assertFails(cloud.pause_resume, "never resolved an effective ceiling", hooks=hooks)
@@ -372,7 +425,7 @@ class CloudSuiteTests(unittest.TestCase):
pre, rest = cut(lines, "current state: MachineState.RUNNING")
pre, rest = pre + [rest[0]], rest[1:]
hooks = {"Click Done here": lambda: self.append(pre, delay=0.1),
"Press the button once NOW": lambda: self.append(rest, delay=0.05)}
"the press pauses the print": lambda: self.append(rest, delay=0.05)}
self.assertFails(cloud.pause_resume, "did not complete after the resume", hooks=hooks)
def test_pause_resume_fails_without_the_retraced_restart(self):
@@ -385,7 +438,7 @@ class CloudSuiteTests(unittest.TestCase):
pre, rest = pre + [rest[0]], rest[1:]
with self.fast_wait_log():
hooks = {"Click Done here": lambda: self.append(pre, delay=0.1),
"Press the button once NOW": lambda: self.append(rest, delay=0.05)}
"the press pauses the print": lambda: self.append(rest, delay=0.05)}
self.assertFails(cloud.pause_resume, "no retraced restart logged", hooks=hooks)
def test_pause_resume_fails_when_the_kernel_refuses_the_resume(self):
@@ -398,7 +451,7 @@ class CloudSuiteTests(unittest.TestCase):
pre, rest = pre + [rest[0]], rest[1:]
with self.fast_wait_log():
hooks = {"Click Done here": lambda: self.append(pre, delay=0.1),
"Press the button once NOW": lambda: self.append(rest, delay=0.05)}
"the press pauses the print": lambda: self.append(rest, delay=0.05)}
self.assertFails(cloud.pause_resume, "the resume was refused", hooks=hooks)
@contextlib.contextmanager
@@ -410,10 +463,13 @@ class CloudSuiteTests(unittest.TestCase):
def fast(ctx, offset, needles, timeout, poll=0.5):
return saved(ctx, offset, needles, min(timeout, 1.5), poll=0.1)
cloud.wait_log = fast
press = cloud.PRESS_TIMEOUT_S
cloud.PRESS_TIMEOUT_S = 1.5
try:
yield
finally:
cloud.wait_log = saved
cloud.PRESS_TIMEOUT_S = press
def test_pause_resume_fails_without_the_pause_line(self):
self.in_cloud(pid=1522)
@@ -423,7 +479,7 @@ class CloudSuiteTests(unittest.TestCase):
pre, rest = pre + [rest[0]], rest[1:]
with self.fast_wait_log():
hooks = {"Click Done here": lambda: self.append(pre, delay=0.1),
"Press the button once NOW": lambda: self.append(rest, delay=0.05)}
"the press pauses the print": lambda: self.append(rest, delay=0.05)}
self.assertFails(cloud.pause_resume, "did not pause the run", hooks=hooks)
# -- the lid/interlock abort and the button-wait tests, on their excerpts ------
@@ -455,12 +511,12 @@ class CloudSuiteTests(unittest.TestCase):
self.lid(True)
self.fc.state["status"]["switches"]["interlock_ok"] = True
return {"Click Done here": next_print,
"Open the lid NOW": lambda: (self.lid(False), self.append(lid_tail, delay=0.05)),
"Close the lid, then click Done": lambda: self.lid(True),
"Open the INTERLOCK loop now": pull_interlock,
"Open the LID now as well": lambda: (self.lid(False),
self.append(ilk_park, delay=0.05)),
"Close the lid and restore the interlock": restore}
"leave the lid open until the head has returned": lambda: (self.lid(False),
self.append(lid_tail, delay=0.05)),
"Close the lid.": lambda: self.lid(True),
"Open the remote-interlock loop": pull_interlock,
"on its way back": lambda: (self.lid(False), self.append(ilk_park, delay=0.05)),
"Restore the remote-interlock loop": restore}
def test_lid_interlock_abort_on_the_bench_excerpt(self):
self.in_cloud(pid=1522)
@@ -527,7 +583,8 @@ class CloudSuiteTests(unittest.TestCase):
pre, rest = cut(lines, "waiting for button")
pre, rest = pre + [rest[0]], rest[1:]
hooks = {"Click Done here": lambda: self.append(pre, delay=0.1),
"Open the lid now": lambda: self.append(rest, delay=0.05)}
"do NOT press it": lambda: (self.lid(False), self.append(rest, delay=0.05)),
"Close the lid.": lambda: self.lid(True)}
run = self.run_test(cloud.lid_during_button_wait, hooks=hooks, test_id="cloud.lid-during-button-wait")
ev = run.evidence
self.assertEqual(ev["runs_started_after_wait"], 0)
@@ -563,11 +620,11 @@ class CloudSuiteTests(unittest.TestCase):
self.append(pre, delay=0.1)
run = self.run_test(cloud.pause_cancel_paths,
hooks={"Click Done here": next_print,
"Press the button once NOW": lambda: self.append(paused, delay=0.05),
"Open the lid NOW": lambda: (self.lid(False),
self.append(lid_tail, delay=0.05)),
"Close the lid, then click Done": lambda: self.lid(True),
"Cancel the print from the app now": lambda: self.append(app_tail,
"the press pauses the print": lambda: self.append(paused, delay=0.05),
"The print is paused: leave the lid open": lambda: (
self.lid(False), self.append(lid_tail, delay=0.05)),
"Close the lid.": lambda: self.lid(True),
"cancel the print from the app now": lambda: self.append(app_tail,
delay=0.05)},
test_id="cloud.pause-cancel-paths")
ev = run.evidence
@@ -593,8 +650,9 @@ class CloudSuiteTests(unittest.TestCase):
self.assertFails(
cloud.pause_cancel_paths, "print 1 did not end ':cancelled'",
hooks={"Click Done here": lambda: self.append(pre, delay=0.1),
"Press the button once NOW": lambda: self.append(paused, delay=0.05),
"Open the lid NOW": lambda: (self.lid(False), self.append(lid_tail, delay=0.05))})
"the press pauses the print": lambda: self.append(paused, delay=0.05),
"The print is paused: leave the lid open": lambda: (
self.lid(False), self.append(lid_tail, delay=0.05))})
def test_print_finish_is_the_prints_not_another_actions(self):
# a motion that completes before the print must not satisfy the print's finish
+299
View File
@@ -0,0 +1,299 @@
"""The operator channel: notices, `ready`, `act`, and the precheck.
A test asks for the operator's part in a way the page can show before
it is needed and a bench actuator can later perform: `act` names a
machine action and watches the machine for it (a standing notice for
the human, no button to race), `ready` pre-announces a timed step, and
a `precheck` refuses a start the machine cannot honor instead of
recording a FAIL. Runner-level events go to the journal, never to the
page state.
"""
import logging
import os
import shutil
import tempfile
import threading
import time
import unittest
import helpers
from forgetest import catalog
from forgetest.log import Log
from forgetest.runner import Context, Failed, Run, Runner, journal
class Catch(logging.Handler):
def __init__(self):
logging.Handler.__init__(self)
self.lines = []
def emit(self, rec):
self.lines.append(rec.getMessage())
class Answerer:
"""Answers every prompt with its first option and records it, and
records every notice it sees."""
def __init__(self, run, on_prompt=None):
self.run = run
self.on_prompt = on_prompt
self.asked = []
self.notices = []
self.stop = False
threading.Thread(target=self._loop, daemon=True).start()
def _loop(self):
seen = None
seen_n = None
while not self.stop:
n = self.run.notice
if n and n["id"] != seen_n:
seen_n = n["id"]
self.notices.append(n["text"])
p = self.run.prompt
if p and p["id"] != seen:
seen = p["id"]
self.asked.append((p["question"], list(p["options"])))
if self.on_prompt:
self.on_prompt(p)
self.run.answer(p["id"], p["options"][0])
time.sleep(0.01)
class ActTests(unittest.TestCase):
def setUp(self):
self.fc = helpers.FakeForgectrl().start()
self.run = Run("test", "t.x", "t.x")
self.ctx = Context(self.run, None, helpers.make_test("t.x", [], kind="operator"))
self.ans = Answerer(self.run)
def tearDown(self):
self.ans.stop = True
self.fc.stop()
def lid(self, closed):
self.fc.state["status"]["switches"]["lid"] = bool(closed)
def interlock(self, ok):
self.fc.state["status"]["switches"]["interlock_ok"] = bool(ok)
def test_lid_open_is_a_notice_until_the_switch_shows_it(self):
threading.Timer(0.3, self.lid, args=(False,)).start()
dt = self.ctx.act("lid", "open", timeout=5)
self.assertGreater(dt, 0.2)
self.assertEqual(self.ans.asked, []) # no button to click
self.assertEqual(self.ans.notices, ["Open the lid."])
self.assertIsNone(self.run.notice) # cleared when seen
rec = self.run.evidence["actions"][0]
self.assertEqual((rec["channel"], rec["state"], rec["by"]), ("lid", "open", "operator"))
self.assertIsNotNone(rec["took_s"])
self.assertTrue(any("ACT lid open: done" in ln for ln in self.run.lines))
def test_the_tests_context_follows_the_wording(self):
self.lid(False)
self.ctx.act("lid", "close", text="Leave it closed.", until=lambda: True, timeout=1)
self.assertTrue(any("NOTICE: Close the lid. Leave it closed." in ln for ln in self.run.lines))
def test_already_done_returns_at_once(self):
self.lid(False)
dt = self.ctx.act("lid", "open", timeout=2)
self.assertLess(dt, 1.0)
def test_interlock_watches_interlock_ok(self):
threading.Timer(0.2, self.interlock, args=(False,)).start()
self.ctx.act("interlock", "open", timeout=5)
threading.Timer(0.2, self.interlock, args=(True,)).start()
self.ctx.act("interlock", "close", timeout=5)
self.assertEqual([r["state"] for r in self.run.evidence["actions"]], ["open", "close"])
def test_a_timeout_fails_the_test_and_clears_the_notice(self):
with self.assertRaises(Failed) as cm:
self.ctx.act("lid", "open", timeout=0.6)
self.assertIn("lid open was not seen", str(cm.exception))
self.assertIsNone(self.run.notice)
self.assertIsNone(self.run.evidence["actions"][0]["took_s"])
def test_a_timeout_can_be_handed_back_instead(self):
self.assertIsNone(self.ctx.act("lid", "open", timeout=0.4, fail=False))
def test_a_button_press_needs_what_it_is_expected_to_do(self):
with self.assertRaises(ValueError):
self.ctx.act("button", "press")
held = []
threading.Timer(0.2, held.append, args=(1,)).start()
self.ctx.act("button", "press", until=lambda: bool(held), timeout=5)
self.assertEqual(self.ans.notices, ["Press the button once."])
def test_unknown_actions_are_refused(self):
with self.assertRaises(ValueError):
self.ctx.act("lid", "press")
def test_a_fixture_covering_the_channel_performs_it(self):
class Fixture:
done = []
def covers(self, channel):
return channel == "lid"
def act(self, channel, state):
self.done.append((channel, state))
self_outer.lid(state != "open")
self_outer = self
class R:
fixture = Fixture()
ctx = Context(self.run, R(), helpers.make_test("t.f", [], kind="operator"))
ctx.act("lid", "open", timeout=3)
self.assertEqual(Fixture.done, [("lid", "open")])
self.assertEqual(self.ans.notices, [])
self.assertEqual(self.run.evidence["actions"][0]["by"], "fixture")
# a channel the fixture does not cover falls back to the operator
threading.Timer(0.2, self.interlock, args=(False,)).start()
ctx.act("interlock", "open", timeout=3)
self.assertEqual(self.ans.notices, [ctx.runner and "Open the remote-interlock loop: unplug the "
"Pro's interlock plug, or pull the jumper at J8 on a Basic/Plus."])
def test_ready_is_a_single_button_prompt(self):
self.ctx.ready("On Ready the head moves.")
self.assertEqual(self.ans.asked, [("On Ready the head moves.", ["Ready", "Cannot"])])
def test_cannot_on_ready_fails(self):
self.ans.stop = True
ans = Answerer(self.run, on_prompt=lambda p: None)
ans.stop = True
time.sleep(0.05)
def say_cannot():
while self.run.prompt is None:
time.sleep(0.01)
self.run.answer(self.run.prompt["id"], "Cannot")
threading.Thread(target=say_cannot, daemon=True).start()
with self.assertRaises(Failed):
self.ctx.ready("x")
def test_notice_is_logged_and_in_the_snapshot(self):
self.ctx.notice("Hold still.")
self.assertEqual(self.run.snapshot()["notice"]["text"], "Hold still.")
self.assertTrue(any("NOTICE: Hold still." in ln for ln in self.run.lines))
self.ctx.clear_notice()
self.assertIsNone(self.run.snapshot()["notice"])
class PrecheckTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp(prefix="forgetest-pre-")
os.environ["FORGETEST_DATA"] = self.tmp
os.environ["FORGETEST_MARKER"] = os.path.join(self.tmp, "marker")
self.man = helpers.make_manifest()
self.blocked = {"why": "HV reports good: open the lid"}
self.reg = helpers.registry(
helpers.make_test("p.gated", [("forgectrl", "src/main.c")],
precheck=lambda: self.blocked["why"]),
helpers.make_test("p.free", [("forgectrl", "src/ui.c")]),
)
self.log = Log(os.path.join(self.tmp, "r.jsonl"))
self.runner = Runner(self.log, self.man, self.reg)
def tearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
def test_a_precheck_reason_refuses_the_start_without_a_result(self):
ok, msg = self.runner.start_test("p.gated")
self.assertFalse(ok)
self.assertEqual(msg, "cannot start: HV reports good: open the lid")
self.assertEqual([r for r in self.log.read() if r["t"] == "result"], [])
# the campaign is not even opened by a refused start
self.assertEqual([r for r in self.log.read() if r["t"] == "campaign"], [])
def test_a_clear_precheck_starts(self):
self.blocked["why"] = None
ok, msg = self.runner.start_test("p.gated")
self.assertTrue(ok, msg)
while self.runner.busy():
time.sleep(0.02)
self.assertEqual(self.runner.state()[0]["tests"]["p.gated"]["status"], "pass")
def test_the_queue_skips_it_with_the_reason_and_carries_on(self):
ok, msg, order = self.runner.start_batch("unattended")
self.assertTrue(ok, msg)
deadline = time.time() + 20
while not self.runner.batch["finished"] and time.time() < deadline:
time.sleep(0.02)
b = self.runner.batch_snapshot()
self.assertEqual([x["test"] for x in b["skipped"]], ["p.gated"])
self.assertIn("cannot start: HV reports good", b["skipped"][0]["reason"])
self.assertEqual([x["test"] for x in b["done"]], ["p.free"])
def test_an_erroring_precheck_refuses_rather_than_crashes(self):
def boom():
raise RuntimeError("sysfs gone")
t = helpers.make_test("p.boom", [], precheck=boom)
self.assertIn("precheck errored: RuntimeError: sysfs gone", t.cannot_start())
def test_the_catalog_describes_actions_and_the_precheck(self):
t = helpers.make_test("p.x", [], kind="operator", actions=("lid", "button"),
precheck=lambda: None)
d = t.describe()
self.assertEqual(d["actions"], ["lid", "button"])
self.assertTrue(d["precheck"])
# neither is part of the gate-visible definition
self.assertNotIn("actions", t.definition())
def test_the_decorator_checks_actions(self):
saved = dict(catalog.REGISTRY)
try:
with self.assertRaises(ValueError):
catalog.test("x.a", title="t", subsystem="x", kind="operator", actions=("foot",))(lambda c: None)
with self.assertRaises(ValueError):
catalog.test("x.b", title="t", subsystem="x", kind="auto", actions=("lid",))(lambda c: None)
with self.assertRaises(ValueError):
catalog.test("x.c", title="t", subsystem="x", precheck="no")(lambda c: None)
catalog.test("x.d", title="t", subsystem="x", kind="operator", actions=("lid",),
precheck=lambda: None)(lambda c: None)
self.assertIn("x.d", catalog.REGISTRY)
finally:
catalog.REGISTRY.clear()
catalog.REGISTRY.update(saved)
class JournalTests(unittest.TestCase):
def test_runner_notes_go_to_the_journal_and_the_run_in_progress(self):
tmp = tempfile.mkdtemp(prefix="forgetest-j-")
os.environ["FORGETEST_DATA"] = tmp
os.environ["FORGETEST_MARKER"] = os.path.join(tmp, "marker")
h = Catch()
journal.addHandler(h)
try:
r = Runner(Log(os.path.join(tmp, "r.jsonl")), helpers.make_manifest(),
helpers.registry(helpers.make_test("j.a", [])))
r._note("queue unattended: 1 test(s) to run")
self.assertIn("queue unattended: 1 test(s) to run", h.lines)
self.assertNotIn("messages", r.state()[0])
run = Run("test", "j.a", "j.a")
r.current = run
r._note("queue unattended: stop requested")
self.assertTrue(any("stop requested" in ln for ln in run.lines))
finally:
journal.removeHandler(h)
shutil.rmtree(tmp, ignore_errors=True)
def test_journal_tail_serves_the_daemon_log(self):
from forgetest.server import journal_tail
tmp = tempfile.mkdtemp(prefix="forgetest-jt-")
try:
p = os.path.join(tmp, "daemon.log")
self.assertIn(b"no journal yet", journal_tail(p))
with open(p, "wb") as f:
for i in range(2000):
f.write(b"line %d\n" % i)
tail = journal_tail(p, max_bytes=200)
self.assertTrue(tail.endswith(b"line 1999\n"))
self.assertLess(len(tail), 200)
self.assertTrue(tail.startswith(b"line ")) # a whole line, not a fragment
finally:
shutil.rmtree(tmp, ignore_errors=True)
if __name__ == "__main__":
unittest.main()
+16 -2
View File
@@ -376,8 +376,22 @@ class ServerTests(unittest.TestCase):
marker = os.environ["FORGETEST_MARKER"]
with open(marker, "w") as f:
f.write("x fake.slow\n")
r = Runner(self.log, self.man, self.reg, self.bench)
self.assertTrue(any("recovered" in m for m in r.messages))
import logging
from forgetest.runner import journal
seen = []
class Catch(logging.Handler):
def emit(self, rec):
seen.append(rec.getMessage())
h = Catch()
journal.addHandler(h)
try:
r = Runner(self.log, self.man, self.reg, self.bench)
finally:
journal.removeHandler(h)
# the recovery is a journal line, not a page message
self.assertTrue(any("recovered" in m for m in seen), seen)
self.assertNotIn("messages", r.state()[0])
self.assertFalse(os.path.exists(marker))
def test_10_state_is_conditional(self):