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 - **always** - membership in the **always-required core**, which is run
in every campaign and is never inherited: image health, the kernel in every campaign and is never inherited: image health, the kernel
latch/safety readbacks, and one live emission witness with the 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 `GET /catalog` on the tool lists the definitions; the page shows them under
each test's *details*. 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) one switches from GRBL mode (once, its connect-time hunt waited out)
and the following ones reuse the live session; nothing switches back and the following ones reuse the live session; nothing switches back
after them. The tests that need GRBL mode (`motion.*`, `laser.*`, after them. The tests that need GRBL mode (`motion.*`, `laser.*`,
`cooling.fans-quiet-after-motion`, `cloud.mode-switch`, `cooling.fans-quiet-after-motion`, `cloud.mode-switch`) declare it,
`cloud.gfhome-homing`) declare it, and the runner switches back the and the runner switches back the moment one of them starts - so the
moment one of them starts - so the mode changes only where the next mode changes only where the next test asks for it, never between
test asks for it, never between tests of the same mode. tests of the same mode. `cloud.mode-switch` is the one round trip, and
`cloud.mode-switch` is the one round trip. 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: 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 **Unattended** takes every `auto` test the campaign does not already
count as satisfied, and needs nobody in the room; **Operator and live** 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`. `releases/v<version>/acceptance.json` and `.md`.
The raw log (`/data/forgetest/results.jsonl`, `Raw log` in the footer) is 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 ### 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), modes (cancel-and-return on a lid or interlock open, button pause/resume),
bench-validated 2026-08-17. bench-validated 2026-08-17.
- **Releases are gated by the acceptance tool** (`forgetest`, dev image only): - **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 and a release gate that reads the exported artifact. The full campaign on
dev image `20260821181036` (the first built on the `<recipe>-pin.inc` dev image `20260821181036` (the first built on the `<recipe>-pin.inc`
layout) satisfied 42 of 42 and its export authorizes a release; 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 YES" for that image's manifest. That authorizes a release; it is not one
until `releases/v<version>/acceptance.json` is committed. until `releases/v<version>/acceptance.json` is committed.
- **Catalog: 45 tests** in `forgetest/forgetest/suite/`, every one a port of a - **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 proven bench drill or a bench-verified check: the always-required core
(`image.health`, `kernel.latch-locked-idle`, `kernel.k1-k2`, (`image.health`, `kernel.latch-locked-idle`, `kernel.k1-k2`,
`kernel.fire-line`), `forgectrl.*`, `logs.*`, `update.*`, `motion.*` `kernel.fire-line`), `forgectrl.*`, `logs.*`, `update.*`, `motion.*`
(pacing, jog round-trip, liveness probe, cancel/abort, dead-man, the lid, (pacing, jog round-trip, liveness probe, cancel/abort, dead-man, the lid,
interlock and button parity tests), `cooling.*` (flow verification, fans interlock and button parity tests), `cooling.*` (flow verification, fans
quiet after motion, a gate setting tripping and off by value, a fan under 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, `laser.*` (emission witness, arm-wait lid, disarm-in-hold, armed kill,
pause/resume/lid-cancel) and `cloud.*`. Tests that share a setup are merged; pause/resume/lid-cancel) and `cloud.*` (the mode round trip with the
the `auto` tests stay separate for failure isolation. 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 - **Machine identity is content-defined.** Every component recipe contributes
`forgefirm-manifest.bbclass` entries (the kernel and the module through `forgefirm-manifest.bbclass` entries (the kernel and the module through
`do_deploy`), `forgefirm-image-manifest.bbclass` assembles them plus the layer `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 service. Still owed: exercising the ported bench tools from the page
(they are registered and unit-tested, not yet driven from the page), and (they are registered and unit-tested, not yet driven from the page), and
the first release, which commits `releases/v<version>/acceptance.json`. 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 `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 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 `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 manifest as _manifest
from . import server as _server from . import server as _server
from .log import Log, data_dir from .log import Log, data_dir
from .runner import Runner from .runner import Runner, configure_journal
def main(argv=None): def main(argv=None):
@@ -36,6 +36,7 @@ def main(argv=None):
return 2 return 2
registry = _catalog.load_suite() registry = _catalog.load_suite()
os.makedirs(data_dir(), exist_ok=True) os.makedirs(data_dir(), exist_ok=True)
configure_journal()
log = Log() log = Log()
bench = _bench.Bench() bench = _bench.Bench()
runner = Runner(log, manifest, registry, 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" print("forgetest %s: %d tests, image %s (%s), listening on %s:%d"
% (VERSION, len(registry), manifest.version, (manifest.content_sha or "")[:12], % (VERSION, len(registry), manifest.version, (manifest.content_sha or "")[:12],
args.host, args.port), file=sys.stderr, flush=True) 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 = threading.Thread(target=srv.serve_forever, name="forgetest-http", daemon=True)
th.start() th.start()
try: 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 the subsystem, the kind (auto / operator / live), how it takes the
hardware (api / takeover), the controller mode it needs (if any), what hardware (api / takeover), the controller mode it needs (if any), what
source it covers, what it requires, and whether it belongs to the 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 under the runner with a Context (log, prompts, evidence, hardware
helpers) and reports by returning normally (PASS) or raising helpers) and reports by returning normally (PASS) or raising
runner.Failed (FAIL). runner.Failed (FAIL).
@@ -20,6 +25,11 @@ from . import manifest as _manifest
KINDS = ("auto", "operator", "live") KINDS = ("auto", "operator", "live")
HARDWARE = ("api", "takeover") HARDWARE = ("api", "takeover")
MODES = ("grbl", "cloud") 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-]*$") _ID_RX = re.compile(r"^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*$")
REGISTRY = {} REGISTRY = {}
@@ -27,7 +37,8 @@ REGISTRY = {}
class Test: class Test:
def __init__(self, id, title, subsystem, kind, hardware, covers, requires, 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.id = id
self.title = title self.title = title
self.subsystem = subsystem self.subsystem = subsystem
@@ -39,6 +50,8 @@ class Test:
self.always = bool(always) self.always = bool(always)
self.est_min = est_min self.est_min = est_min
self.steps = tuple(steps) self.steps = tuple(steps)
self.actions = tuple(actions)
self.precheck = precheck
self.description = description or (fn.__doc__ or "").strip() self.description = description or (fn.__doc__ or "").strip()
self.fn = fn self.fn = fn
self._source_sha = None self._source_sha = None
@@ -82,9 +95,21 @@ class Test:
def describe(self): def describe(self):
d = self.definition() d = self.definition()
d.update({"title": self.title, "est_min": self.est_min, "mode": self.mode, 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 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): def source_file_sha(path):
with open(path, "rb") as f: 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=(), 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 """`mode` names the controller mode the test needs live when it starts
("grbl" or "cloud"); the runner switches the machine there before the ("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 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 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, 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): if not _ID_RX.match(id):
raise ValueError("test id %r must look like subsystem.name" % id) raise ValueError("test id %r must look like subsystem.name" % id)
if kind not in KINDS: 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: for c, g in covers:
if c in _manifest.DEV_ONLY_COMPONENTS: if c in _manifest.DEV_ONLY_COMPONENTS:
raise ValueError("test %s: may not cover dev-only component %r" % (id, c)) 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): def deco(fn):
if id in REGISTRY: if id in REGISTRY:
raise ValueError("duplicate test id %r" % id) raise ValueError("duplicate test id %r" % id)
REGISTRY[id] = Test(id, title, subsystem, kind, hardware, covers, requires, 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 fn
return deco return deco
+132
View File
@@ -13,6 +13,7 @@ import json
import os import os
import socket import socket
import subprocess import subprocess
import threading
import time import time
import urllib.error import urllib.error
import urllib.parse import urllib.parse
@@ -152,6 +153,137 @@ def sysfs_write(attr, value):
f.write(str(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 # --------------------------------------------------------------- init.d
def initd(service, action, timeout=60): 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} .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} 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} #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} #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{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} .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='card'><h2>Campaign</h2>
<div class='banner'><div class='auth' id='auth'>?</div> <div class='banner'><div class='auth' id='auth'>?</div>
<div class='kv' id='banner'></div></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='card queue'><h2>Run what is left</h2>
<div class='actions'> <div class='actions'>
<button class='pri' id='q-unattended' onclick='startBatch("unattended")'>Unattended</button> <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='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 id='dlmd' href='/export/acceptance.md' style='display:none'><button>acceptance.md</button></a>
<a href='/log'><button>Raw log</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='toggleInv()'>Invalidate all&hellip;</button>
<button onclick='doReset()'>Reset campaign</button> <button onclick='doReset()'>Reset campaign</button>
</div> </div>
@@ -165,6 +173,8 @@ pre#log{background:#1d1e26;color:#d7dae0;font-family:ui-monospace,Consolas,monos
<div id='right'> <div id='right'>
<div class='card'><h2 id='runtitle'>Run</h2> <div class='card'><h2 id='runtitle'>Run</h2>
<div id='runhead' class='kv'>idle</div> <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> <div id='prompt' style='display:none'><div class='q' id='promptq'></div><div class='actions' id='promptb'></div></div>
<pre id='log'></pre> <pre id='log'></pre>
<div class='actions'><button class='danger' id='abortbtn' onclick='doAbort()' disabled>Abort</button><span class='hint' id='runfoot'></span></div> <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>'; if(state.log_corrupt)b+='<br><span style="color:var(--red)">'+state.log_corrupt+' corrupt log line(s) skipped</span>';
setHtml($('banner'),b); 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 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();renderSteps();if(bench)renderBench()}
renderQueue();renderGroups();renderRun();if(bench)renderBench()}
/* The two queues: what each would run now, and how the running one is /* 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 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. */ 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+"'>"+ var det="<div class='details"+(openDetails[t.id]?' on':'')+"' id='det-"+d+"'>"+
(t.description?("<div class='dsc'>"+esc(t.description)+"</div>"):'')+ (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.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(', ')||'-')+ "<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>"; "<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><td>"+badges+"</td>"+
"<td><div id='st-"+d+"'></div><div id='unmet-"+d+"'></div><div id='note-"+d+"'></div></td>"+ "<td><div id='st-"+d+"'></div><div id='unmet-"+d+"'></div><div id='note-"+d+"'></div></td>"+
"<td id='last-"+d+"'></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 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; 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(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} curPrompt=null;promptKey=null;setDis($('abortbtn'),true);setText($('runtitle'),'Run');lastRunKey=null;return}
setText($('runtitle'),(r.kind==='bench'?'Bench: ':'Test: ')+r.title); 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'; 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; 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} lg.__t=txt;lg.textContent=txt;if(atBottom||key!==lastRunKey)lg.scrollTop=lg.scrollHeight}
lastRunKey=key; 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 - /* The prompt buttons are rebuilt only when the prompt itself changes -
they are the ones the operator stares at before pressing. */ 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(','); 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); setDis($('abortbtn'),!!r.finished||abortSent||!!r.aborting);
if(r.finished&&tab==='bench'&&r.kind==='bench'&&benchNeedsRefresh){benchNeedsRefresh=false;loadBench()}} 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} 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}; function startTest(id){if(isBusy())return;var t=findTest(id);var body={test:id};
if(ignoreReq)body.ignore_requires=true; if(ignoreReq)body.ignore_requires=true;
if(t&&t.kind==='live'){if(!confirmLive())return;body.ack_live=true} if(t&&t.kind==='live'){if(!confirmLive())return;body.ack_live=true}
+160 -10
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 because a FAIL closes the campaign and going on would quietly open a
second one. 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 Safety, in code rather than convention: a live test starts only with the
operator's acknowledgment in the request; the runner never touches the operator's acknowledgment in the request; the runner never touches the
laser latch; a takeover always ends with forgectrl started again, and a laser latch; a takeover always ends with forgectrl started again, and a
marker file makes a crash mid-takeover recoverable at the next start. 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 os
import random import random
import subprocess import subprocess
@@ -57,6 +74,44 @@ class Failed(Exception):
pass 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: class Run:
def __init__(self, kind, id, title): def __init__(self, kind, id, title):
self.kind = kind # 'test' | 'bench' self.kind = kind # 'test' | 'bench'
@@ -67,6 +122,7 @@ class Run:
self.lines = [] self.lines = []
self.dropped = 0 self.dropped = 0
self.prompt = None # {"id","question","options"} self.prompt = None # {"id","question","options"}
self.notice = None # {"id","text"}: a standing instruction, no button
self.answers = [] self.answers = []
self.evidence = {} self.evidence = {}
self.baseline_captured = None # preserved state the post pass hands back self.baseline_captured = None # preserved state the post pass hands back
@@ -90,10 +146,11 @@ class Run:
with self._lock: with self._lock:
lines = self.lines[-tail:] lines = self.lines[-tail:]
prompt = dict(self.prompt) if self.prompt else None prompt = dict(self.prompt) if self.prompt else None
notice = dict(self.notice) if self.notice else None
return { return {
"kind": self.kind, "id": self.id, "title": self.title, "kind": self.kind, "id": self.id, "title": self.title,
"started": self.started_ts, "elapsed_s": int(time.time() - self.started), "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(), "finished": self.finished, "aborting": self.aborted.is_set(),
} }
@@ -114,6 +171,14 @@ class Run:
self.answers.append({"ts": now_ts(), "question": question, "answer": ans}) self.answers.append({"ts": now_ts(), "question": question, "answer": ans})
return 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): def answer(self, prompt_id, value):
with self._cv: with self._cv:
if not self.prompt or self.prompt["id"] != prompt_id: if not self.prompt or self.prompt["id"] != prompt_id:
@@ -190,6 +255,88 @@ class Context:
if ans != "Done": if ans != "Done":
raise Failed("operator could not: %s" % text) 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 ------------------------------------------------------ # -- hardware ------------------------------------------------------
@property @property
def forgectrl(self): def forgectrl(self):
@@ -326,7 +473,7 @@ class Runner:
self.current = None self.current = None
self.last = None self.last = None
self.batch = None self.batch = None
self.messages = [] self.fixture = None # the bench actuator, when one is configured (none yet)
self.boot_ref = None self.boot_ref = None
self.recover() self.recover()
threading.Thread(target=self._take_boot_reference, daemon=True, 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)) self._note("baseline: boot reference failed: %s: %s" % (type(e).__name__, e))
def _note(self, msg): def _note(self, msg):
"""A runner-level line: kept in messages for the page (bounded).""" """A runner-level line: to the journal, and to the run in progress."""
with self._lock: journal.info(msg)
self.messages.append(msg) r = self.current
del self.messages[:-50] if r is not None and not r.finished:
r.log(msg)
# -- startup recovery ------------------------------------------------ # -- startup recovery ------------------------------------------------
def recover(self): def recover(self):
@@ -353,7 +501,7 @@ class Runner:
who = f.read().strip() who = f.read().strip()
except OSError: except OSError:
who = "?" 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") hw.initd("forgectrl", "start")
try: try:
os.remove(m) os.remove(m)
@@ -376,7 +524,6 @@ class Runner:
st["manifest"] = {"sha": self.manifest.content_sha, "identity": self.manifest.identity_sha(), st["manifest"] = {"sha": self.manifest.content_sha, "identity": self.manifest.identity_sha(),
"image": self.manifest.image_name, "version": self.manifest.version} "image": self.manifest.image_name, "version": self.manifest.version}
st["log_corrupt"] = self.log.corrupt st["log_corrupt"] = self.log.corrupt
st["messages"] = list(self.messages)
r = self.current r = self.current
st["running"] = r.snapshot() if r and not r.finished else None st["running"] = r.snapshot() if r and not r.finished else None
last = r if (r and r.finished) else self.last 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 return False, "prerequisites not satisfied: %s" % ", ".join(missing), None
if t.kind == "live" and not ack_live: if t.kind == "live" and not ack_live:
return False, "live test: acknowledge eye protection, fire watch, and exhaust first", None 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) campaign = self._open_campaign_if_needed(state)
run = Run("test", t.id, t.title) run = Run("test", t.id, t.title)
self.last = self.current self.last = self.current
@@ -585,7 +735,7 @@ class Runner:
left = bl.enforce("pre", captured=None) left = bl.enforce("pre", captured=None)
if left: if left:
who = self.last.id if self.last is not None else "an earlier run" who = self.last.id if self.last is not None else "an earlier run"
self.messages.append("leftovers before %s (left by %s): %s" journal.info("leftovers before %s (left by %s): %s"
% (run.id, who, "; ".join(str(x) for x in left))) % (run.id, who, "; ".join(str(x) for x in left)))
run.evidence["baseline"] = {"pre": [x.as_dict() for x in left]} run.evidence["baseline"] = {"pre": [x.as_dict() for x in left]}
if mode: if mode:
@@ -605,7 +755,7 @@ class Runner:
left = bl.enforce("post", captured=run.baseline_captured or captured) left = bl.enforce("post", captured=run.baseline_captured or captured)
run.evidence.setdefault("baseline", {})["post"] = [x.as_dict() for x in left] run.evidence.setdefault("baseline", {})["post"] = [x.as_dict() for x in left]
if 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 return left
def _exec_test(self, t, run, campaign): def _exec_test(self, t, run, campaign):
+21
View File
@@ -17,6 +17,7 @@ Routes
GET /bench bench tool listing GET /bench bench tool listing
GET /result?test&ts one full result record (log, evidence) GET /result?test&ts one full result record (log, evidence)
GET /log the raw JSONL GET /log the raw JSONL
GET /journal the daemon's journal (daemon.log tail)
GET /export/acceptance.json | .md the last export GET /export/acceptance.json | .md the last export
POST /start {test, ack_live, ignore_requires} start an acceptance test POST /start {test, ack_live, ignore_requires} start an acceptance test
POST /batch {group, ack_live, ignore_requires} run everything a queue 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 . import page as _page
from .log import data_dir 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 TOKEN_HEX = 32
_LITERAL_RX = re.compile(r"^[0-9.]+$") _LITERAL_RX = re.compile(r"^[0-9.]+$")
@@ -229,6 +248,8 @@ class Handler(BaseHTTPRequestHandler):
self._send(200, rec) self._send(200, rec)
elif path == "/log": elif path == "/log":
self._send(200, r.log.raw(), "text/plain") 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"): elif path in ("/export/acceptance.json", "/export/acceptance.md"):
fn = os.path.join(self.app.export_dir, os.path.basename(path)) fn = os.path.join(self.app.export_dir, os.path.basename(path))
if not os.path.exists(fn): if not os.path.exists(fn):
+34 -11
View File
@@ -1,5 +1,7 @@
"""camera.* - the lid camera pipeline through forgectrl.""" """camera.* - the lid camera pipeline through forgectrl."""
from ..catalog import test from ..catalog import test
from .. import hw
from ..baseline import LID_LAMP_ATTR
_CAM_COVERS = [("forgectrl", "src/cam.*"), ("forgectrl", "src/camhealth.*"), _CAM_COVERS = [("forgectrl", "src/cam.*"), ("forgectrl", "src/camhealth.*"),
("forgectrl", "src/debayer.*"), ("forgectrl", "src/vpu_jpeg.*"), ("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", @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"], 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 " 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 " "far smaller), a second snapshot with the lid lamp switched off differs from it "
"the panel shows the bed.") "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): def snapshot(ctx):
fc = ctx.forgectrl fc = ctx.forgectrl
ev = ctx.evidence ev = ctx.evidence
@@ -66,6 +70,26 @@ def snapshot(ctx):
ev["snapshot_bytes"] = len(data) ev["snapshot_bytes"] = len(data)
ctx.check(len(data) > 20000, "snapshot is only %d bytes - a dark or empty frame?", 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) 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.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) 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") st, body = fc.get("/cam/status")
ev["cam_status_after"] = body ev["cam_status_after"] = body
ctx.log("cam status after: %s", 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 " ctx.log("PASS: snapshot %d bytes, %d with the lamp off, stream %s", len(data), len(dark), ctype)
"(not black, not frozen, roughly the right orientation)?")
@test("camera.sensor-profile", title="Camera geometry follows the fitted sensor", subsystem="camera", @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", @test("camera.lid-privacy", title="Cameras capture only with the lid closed", subsystem="camera",
kind="operator", est_min=3, kind="operator", est_min=3,
covers=_PRIVACY_COVERS, requires=["forgectrl.panel-serves"], covers=_PRIVACY_COVERS, requires=["forgectrl.panel-serves"], actions=["lid"],
steps=["You will be asked to open the lid, then close it again.", 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."], "Nothing moves and the laser is not involved."],
description="The privacy gate: with the lid open neither camera captures. A running stream " 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 " "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"" return st, data or b""
# --- lid closed: the baseline the rest is measured against ---------- # --- lid closed: the baseline the rest is measured against ----------
ctx.instruct("Close the lid, then click Done.") ctx.act("lid", "close")
body = status() body = status()
ctx.check(body.get("capture_allowed") is True, ctx.check(body.get("capture_allowed") is True,
"with the lid closed /cam/status should allow capture, got %r", "with the lid closed /cam/status should allow capture, got %r",
@@ -227,7 +250,7 @@ def lid_privacy(ctx):
try: try:
first = stream.read(4096) first = stream.read(4096)
ctx.check(b"\xff\xd8" in first, "the stream did not start before the lid test") 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 # The engine tears the pipeline down on the next frame and the
# stream ends. Draining now returns whatever was buffered before # stream ends. Draining now returns whatever was buffered before
# the lid opened and then EOF; what must not happen is frames # 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) ctx.check(e.code == 409, "the stream should be refused with 409, got %s", e.code)
# --- closing the lid restores it ----------------------------------- # --- closing the lid restores it -----------------------------------
ctx.instruct("Close the lid again, then click Done.") ctx.act("lid", "close")
body = status() body = status()
ctx.check(body.get("capture_allowed") is True, ctx.check(body.get("capture_allowed") is True,
"closing the lid should allow capture again, got %r", "closing the lid should allow capture again, got %r",
+207 -172
View File
@@ -1,7 +1,8 @@
"""cloud.* - the controller mode switch and the optional Glowforge web-service """cloud.* - the controller mode switch and the optional Glowforge web-service
mode (gfcloud daemon, gfhome homing runner). The mode-switch test makes the 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 grbl -> cloud -> grbl round trip with the connect-time hunt run lid-open and
and leave the machine there (see enter_cloud).""" 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 json
import os import os
import time import time
@@ -95,20 +96,110 @@ def wait_mode(ctx, fc, want_mode, want_controller="running", timeout=90):
return last return last
@test("cloud.mode-switch", title="Controller mode switch grbl -> cloud -> grbl", subsystem="cloud", GFHOME_LOG = "/data/log/forgefirm/gfhome/gfhome.log"
kind="auto", mode="grbl", est_min=5, HOMING_TIMEOUT_S = 600
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 " def homing_mode_is_gfcloud():
"hunt) and the test jogs it back to where it started afterward. Cloud credentials " """Precheck: the web-service homing needs homing_mode = gfcloud."""
"configured; the machine on the network."], try:
description="POST /mode switches to the cloud controller: gfcloud comes up under " hm = (hw.Forgectrl().settings() or {}).get("homing_mode")
"supervision, authenticates and establishes its service session (its own log " except hw.HwError as e:
"lines are the evidence; the connect-time firmware probe is recorded when " return "forgectrl unreachable: %s" % e
"configured); its connect-time hunt, which runs with the extraction fans off, " 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 " "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 " "reads unjudged); the camera service survives the switch. The lid closed, the "
"grblHAL up with the Grbl port open and Idle, and the head returns to its start.") "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): def mode_switch(ctx):
fc = ctx.forgectrl fc = ctx.forgectrl
ev = ctx.evidence ev = ctx.evidence
@@ -124,9 +215,10 @@ def mode_switch(ctx):
probe_before = os.stat(GF_LATEST).st_mtime probe_before = os.stat(GF_LATEST).st_mtime
except OSError: except OSError:
pass pass
log_offset = log_size(GFCLOUD_LOG)
lamp0 = hw.sysfs_read("pic/lid_led") 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"}) st, body = fc.post("/mode", data={"controller": "cloud"})
ctx.log("POST /mode controller=cloud -> %s %s", st, body) ctx.log("POST /mode controller=cloud -> %s %s", st, body)
ctx.check(st == 200, "mode switch to cloud refused: %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", ctx.check(m and m.get("mode") == "cloud" and m.get("controller") == "running",
"cloud controller did not come up: %s", m) "cloud controller did not come up: %s", m)
# the client's own session lines are the evidence of a live cloud session # the client's own session lines are the evidence of a live cloud session
t0 = time.time() session = wait_session(ctx, log_offset)
session = []
probe = None 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 ev["session"] = session
for ln in session: for ln in session:
ctx.log(" gfcloud: %s", ln.split(" ", 1)[-1] if " " in ln else ln) ctx.log(" gfcloud: %s", ln.split(" ", 1)[-1] if " " in ln else ln)
@@ -157,6 +242,9 @@ def mode_switch(ctx):
pass pass
ev["gf_probe"] = probe ev["gf_probe"] = probe
ctx.log("cloud session established: %s; firmware probe: %s", session_established(session), 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") st, cam1 = fc.get("/cam/status")
ev["cam_during_cloud"] = cam1 ev["cam_during_cloud"] = cam1
ctx.check(st == 200 and isinstance(cam1, dict), "camera status lost during cloud mode (%s)", st) 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), 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 " "the exhaust was not off during the hunt (readings %s), so the unjudged state "
"proves nothing", sorted({g.get("reading") for g in exh})) "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"}) st, body = fc.post("/mode", data={"controller": "grbl"})
ctx.log("POST /mode controller=grbl -> %s %s", st, body) ctx.log("POST /mode controller=grbl -> %s %s", st, body)
@@ -197,9 +293,6 @@ def mode_switch(ctx):
ev["grbl_state"] = st ev["grbl_state"] = st
ctx.log("grbl state after: %s", st) ctx.log("grbl state after: %s", st)
ctx.check(st.startswith("Idle") or st.startswith("Alarm"), "grbl reports %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") st, cam2 = fc.get("/cam/status")
ev["cam_after"] = cam2 ev["cam_after"] = cam2
ctx.check(st == 200, "camera status lost after the switch back") 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) hw.sysfs_write("pic/lid_led", lamp0)
ctx.log("lid lamp: cloud mode left %s, restored %s", lamp1, lamp0) ctx.log("lid lamp: cloud mode left %s, restored %s", lamp1, lamp0)
# -- the web-service homing from grbl mode --------------------------------
@test("cloud.gfhome-homing", title="Glowforge web-service homing ($H with homing_mode=gfcloud)", ev["homing_mode"] = (fc.settings() or {}).get("homing_mode")
subsystem="cloud", kind="operator", mode="grbl", est_min=5, ctx.check(ev["homing_mode"] == "gfcloud", "homing_mode is %r; $H needs gfcloud", ev["homing_mode"])
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.")
with ctx.grbl() as g: with ctx.grbl() as g:
st = g.status_report()["state"] gfhome_homing(ctx, ev, g)
ctx.check(st.startswith("Idle") or st.startswith("Alarm"), "controller is %s", st) ctx.log("PASS: grbl -> cloud (session, hunt with the lid open, lens homed, airflow unjudged) -> "
if st.startswith("Alarm"): "grbl (port open, %s), then $H homed in %.1f s", ev["grbl_state"], ev["homing_s"])
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")
# ---- lid / button behavior of a cloud job (the factory'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 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): def action_finish_index(lines, action):
"""Index of the first '<action> [id]: finished with event ...' line, or None.""" """Index of the first '<action> [id]: finished with event ...' line, or None."""
return next((i for i, ln in enumerate(lines) 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") PAUSE_LINES = ("button pressed mid-run; pausing", "paused at")
RESUME_LINES = ("button pressed while paused", "resuming (laser lead") RESUME_LINES = ("button pressed while paused", "resuming (laser lead")
RESUME_REFUSED = "resume refused" 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 " 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).") "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): def judge_abort_tail(ctx, ev, offset, tag, fin):
@@ -598,8 +657,9 @@ def judge_abort_tail(ctx, ev, offset, tag, fin):
"an open lid", "an open lid",
subsystem="cloud", kind="live", est_min=11, subsystem="cloud", kind="live", est_min=11,
covers=_CLOUD_COVERS + [("forgectrl", "src/super.c")], covers=_CLOUD_COVERS + [("forgectrl", "src/super.c")],
requires=["laser.emission-witness"], requires=["laser.emission-witness"], actions=["button", "lid", "interlock"],
steps=[CLOUD_STEP, "The app open in a browser; scrap on the bed and a small engrave/score job " 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.", "ready - the test runs TWO prints.",
"Be able to open the remote-interlock loop for the second print: unplug the Pro's " "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.", "interlock plug, or pull the jumper at J8 on a Basic/Plus. Restore it at the end.",
@@ -622,8 +682,8 @@ def lid_interlock_abort(ctx):
ctx.instruct(APP_PRINT_CUE) ctx.instruct(APP_PRINT_CUE)
got = wait_print_running(ctx, offset, 300) 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.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 " ctx.act("lid", "open", text="The head is moving: leave the lid open until the head has returned "
"has returned to the corner.") "to the corner.", timeout=60)
needles = ["lid opened", "lid opened mid-run; stopping motion", "start return home", needles = ["lid opened", "lid opened mid-run; stopping motion", "start return home",
"return home complete"] "return home complete"]
got = wait_log(ctx, offset, needles, 90) 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"], ctx.check(got["start return home"] and got["return home complete"],
"the park did not run to completion with the lid open") "the park did not run to completion with the lid open")
judge_abort_tail(ctx, ev, offset, "lid", fin) 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 " ctx.act("lid", "close")
"still open, and does the app show the print as cancelled?")
ctx.instruct("Close the lid, then click Done.")
settle_cloud(ctx, offset) settle_cloud(ctx, offset)
# -- print 2: the interlock, with the lid opened during the park --------- # -- 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) ctx.instruct(APP_PRINT_CUE)
got = wait_print_running(ctx, offset, 300) 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.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 " ctx.act("interlock", "open", text="The head is moving.", timeout=60)
"click Done.")
sw = (ctx.forgectrl.status().get("switches") or {}) sw = (ctx.forgectrl.status().get("switches") or {})
ev["interlock_ok_after_pull"] = sw.get("interlock_ok") ev["interlock_ok_after_pull"] = sw.get("interlock_ok")
ctx.check(sw.get("interlock_ok") is False, 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) 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[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.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 " ctx.act("lid", "open", text="The head is on its way back: leave both open until it has stopped.",
"open until the head has stopped.") timeout=60)
done = wait_log(ctx, offset, ["return home complete"], 90) done = wait_log(ctx, offset, ["return home complete"], 90)
fin = wait_action_finished(ctx, offset, "print", 60) fin = wait_action_finished(ctx, offset, "print", 60)
ev["interlock_log"] = {stop_line: message(got[stop_line]), 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, 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") "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) 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 " ctx.act("lid", "close")
"open lid interrupting it, and does the app show the print as cancelled?") ctx.act("interlock", "close")
ctx.instruct("Close the lid and restore the interlock loop (plug/jumper back in), then click Done.")
sw = (ctx.forgectrl.status().get("switches") or {}) sw = (ctx.forgectrl.status().get("switches") or {})
ev["restored"] = {"lid": sw.get("lid"), "interlock_ok": sw.get("interlock_ok")} 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") 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", @test("cloud.lid-during-button-wait", title="Lid open at the cloud button prompt cancels the print",
subsystem="cloud", kind="operator", est_min=6, subsystem="cloud", kind="operator", est_min=6,
covers=_CLOUD_COVERS, requires=[], covers=_CLOUD_COVERS, requires=[], actions=["lid"],
steps=[CLOUD_STEP, "The app open; any small job ready (nothing will fire).", 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."], "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 " 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 " "with the lid named as the reason, the laser latch relocks, the armed window "
"closes, no run starts, and the job ends ':cancelled'.") "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.") "Print in the app. When the button lights white, do NOT press it.")
got = wait_log(ctx, offset, ["waiting for button"], 300) got = wait_log(ctx, offset, ["waiting for button"], 300)
ctx.check(got["waiting for button"], "the print never reached the button wait") 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" relock = "button wait lid opened - relocking the laser"
got = wait_log(ctx, offset, [relock], 60) got = wait_log(ctx, offset, [relock], 60)
fin = wait_action_finished(ctx, offset, "print", 60) fin = wait_action_finished(ctx, offset, "print", 60)
@@ -742,62 +799,22 @@ def lid_during_button_wait(ctx):
ev["latch_locked"] = latch_locked() ev["latch_locked"] = latch_locked()
ctx.check(not ev["armed_after"], "armed window still open after the cancel") 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.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 " ev["button_dark"] = hw.button_lit()
"the print as cancelled?") ctx.check(ev["button_dark"] is False, "the button is still lit after the cancel (%s)", ev["button_dark"])
ctx.instruct("Close the lid, then click Done.") ctx.act("lid", "close")
settle_cloud(ctx, offset) settle_cloud(ctx, offset)
ctx.log("PASS: lid open at the button prompt cancelled the print; latch locked, armed=false") ctx.log("PASS: lid open at the button prompt cancelled the print; latch locked, armed=false, "
"button dark")
@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"])
@test("cloud.pause-resume", title="Button pauses and resumes a cloud print (factory backtrack + lead)", @test("cloud.pause-resume", title="Button pauses and resumes a cloud print (factory backtrack + lead)",
subsystem="cloud", kind="live", est_min=8, subsystem="cloud", kind="live", est_min=8,
covers=_CLOUD_COVERS + [("forgectrl", "src/main.c")], 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.", 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 " "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 " 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 " "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 " "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) ctx.instruct(APP_PRINT_CUE)
got = wait_print_running(ctx, offset, 300) 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.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 " ctx.act("button", "press", text="The head is moving: the press pauses the print.",
"a few millimeters, wait about 3 seconds, press it again (resume), then click Done.") until=lambda: log_has(offset, PAUSE_LINES[0]), timeout=PRESS_TIMEOUT_S, fail=False)
got = wait_log(ctx, offset, list(PAUSE_LINES + RESUME_LINES), 90) 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()} ev["log"] = {k: bool(v) for k, v in got.items()}
check_pause_resume(ctx, got, offset) check_pause_resume(ctx, got, offset)
st, cs = ctx.forgectrl.get("/cool/status") 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)] if "relocking the laser" in ln or ("print [" in ln and CANCELLED in ln)]
ev["relock_or_cancel_lines"] = len(relocked) ev["relock_or_cancel_lines"] = len(relocked)
ctx.check(not relocked, "the pause relocked or cancelled the job (%s)", relocked[:2]) 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 # 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 # 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.log("progress reported against %s bytes", declared)
ctx.check(declared is not None, "the print reported no progress at all") 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.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 " ctx.confirm("In the app: did the print's progress advance while it cut (not standing still, "
"standing still or jumping straight to nearly finished?") "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 # The job's envelope, from the same print: the client derives the
# limits the header carries (a cut job carries the coolant window and # 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", @test("cloud.oversize-stream", title="A print longer than the ring is fed while it plays",
subsystem="cloud", kind="live", est_min=12, subsystem="cloud", kind="live", est_min=12,
covers=_CLOUD_COVERS, covers=_CLOUD_COVERS,
requires=["cloud.lid-interlock-abort", "cloud.pause-resume"], requires=["cloud.lid-interlock-abort", "cloud.pause-resume"], actions=["button"],
steps=[CLOUD_STEP, steps=[CLOUD_STEP,
"Scrap on the bed and a LONG job ready in the app - one whose run time is longer " "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 " "than the ring holds (over an hour at the usual print tick). A full-bed raster "
"engrave is the easy way to get one.", "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 " "Print from the app and press the button when it lights. The test lets it cut for "
"minutes, pause and resume it with the button, then cancel the print from the app."], "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 " 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, " "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 - " "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. # keeps growing while it plays.
first = read_program_total() first = read_program_total()
ctx.log("program total at the start of the run: %s bytes", first) 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() grown = read_program_total()
ev["total_first"], ev["total_later"] = first, grown 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)) 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. # streamed print retraces and leads back on exactly like a preloaded one.
ev["max_backtrack"] = hw.sysfs_int("cnc/max_backtrack", 0) ev["max_backtrack"] = hw.sysfs_int("cnc/max_backtrack", 0)
ctx.log("max_backtrack while the feed runs: %s steps", ev["max_backtrack"]) 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 " ctx.act("button", "press", text="The press pauses the live-fed print.",
"millimeters, wait about 3 seconds, press it again (resume), then click Done.") until=lambda: log_has(offset, PAUSE_LINES[0]), timeout=PRESS_TIMEOUT_S, fail=False)
got = wait_log(ctx, offset, list(PAUSE_LINES + RESUME_LINES), 90) 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()} ev["pause_log"] = {k: bool(v) for k, v in got.items()}
check_pause_resume(ctx, got, offset, what="the live-fed run") check_pause_resume(ctx, got, offset, what="the live-fed run")
backtracked = [ln for ln in log_lines_since(GFCLOUD_LOG, offset) 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(not backtracked, "the live-fed pause could not back up (%s)", backtracked[:1])
ctx.check(hw.sysfs_int("cnc/underruns", 0) == before, ctx.check(hw.sysfs_int("cnc/underruns", 0) == before,
"the pause or the resume starved the ring") "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.") ctx.notice("Now cancel the print from the app. The test watches for the cancel.")
try:
fin = wait_action_finished(ctx, offset, "print", 300) fin = wait_action_finished(ctx, offset, "print", 300)
finally:
ctx.clear_notice()
ev["print_finished"] = message(fin) ev["print_finished"] = message(fin)
ctx.check(fin, "the cancelled print did not finish within 300 s") 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)) 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", "by the app",
subsystem="cloud", kind="live", est_min=12, subsystem="cloud", kind="live", est_min=12,
covers=_CLOUD_COVERS, requires=["cloud.pause-resume", "cloud.lid-interlock-abort"], 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 " 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.", "ready - the test runs TWO prints.",
"Print 1: press the button to start, press it again a few seconds in (pause), then open " "Print 1: press the button to start; when asked, press it again (pause), then open "
"the lid.", "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 while it runs."], "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 " 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 " "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 " "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) ctx.instruct(APP_PRINT_CUE)
got = wait_print_running(ctx, offset, 300) 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.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 " ctx.act("button", "press", text="The head is moving: the press pauses the print.",
"few millimeters, then click Done.") until=lambda: log_has(offset, PAUSE_LINES[0]), timeout=PRESS_TIMEOUT_S, fail=False)
got = wait_log(ctx, offset, list(PAUSE_LINES), 90) got = wait_log(ctx, offset, list(PAUSE_LINES), 10)
ev["paused"] = {k: bool(v) for k, v in got.items()} 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["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.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 " ctx.act("lid", "open", text="The print is paused: leave the lid open until the head has returned "
"has returned to the corner.") "to the corner.", timeout=60)
lid_stop = "lid opened mid-run; stopping motion" lid_stop = "lid opened mid-run; stopping motion"
got = wait_log(ctx, offset, [lid_stop, "start return home", "return home complete"], 120) got = wait_log(ctx, offset, [lid_stop, "start return home", "return home complete"], 120)
fin1 = wait_action_finished(ctx, offset, "print", 60) 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(not ev["armed_after_print1"], "armed window still open after the paused print was cancelled")
ctx.check(ev["latch_locked_after_print1"], ctx.check(ev["latch_locked_after_print1"],
"kernel latch not locked after the paused print was cancelled") "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) settle_cloud(ctx, offset)
# -- print 2: cancelled from the app ------------------------------------ # -- print 2: cancelled from the app ------------------------------------
@@ -1054,9 +1085,12 @@ def pause_cancel_paths(ctx):
ctx.instruct(APP_PRINT_CUE) ctx.instruct(APP_PRINT_CUE)
got = wait_print_running(ctx, offset, 300) 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.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" svc_stop = "action cancelled mid-run; stopping motion"
try:
got = wait_log(ctx, offset, [svc_stop, "start return home", "return home complete"], 120) 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) fin2 = wait_action_finished(ctx, offset, "print", 60)
ev["service_cancel"] = {k: message(v) for k, v in got.items()} ev["service_cancel"] = {k: message(v) for k, v in got.items()}
ev["service_cancel"]["print finished"] = message(fin2) ev["service_cancel"]["print finished"] = message(fin2)
@@ -1076,7 +1110,8 @@ def pause_cancel_paths(ctx):
ev["latch_locked_after_print2"] = latch_locked() ev["latch_locked_after_print2"] = latch_locked()
ctx.check(not ev["armed_after_print2"], "armed window still open after the app cancel") 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.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) settle_cloud(ctx, offset)
ctx.log("PASS: a paused print cancelled by the lid and a running print cancelled from the app both " 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'") "stopped, parked, relocked and reported ':cancelled'")
+21 -13
View File
@@ -130,16 +130,24 @@ class PulseDevice:
return False return False
def require_hv_not_good(ctx): def hv_not_good():
"""K3 and fire B/U unlock the latch: refuse while HV reports good. """Precheck for the drills that unlock the latch with a zero-duty
Called before the takeover; gives the operator one chance to drop it stream: they run only while the HV supply does NOT report good. At
(open the lid).""" 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") pgood = rd("cnc/laser_pgood")
if pgood is None:
return "cnc/laser_pgood unreadable"
if pgood != "0": if pgood != "0":
ctx.log("laser_pgood=%s: the HV supply reports good", pgood) return ("laser_pgood=%s: the HV supply reports good; this drill unlocks the latch with a "
ctx.instruct("This drill unlocks the laser latch with a zero-duty stream and must run " "zero-duty stream and needs HV not good (open the lid, then start again)" % pgood)
"with the HV supply NOT good. Open the lid (the safety chain holds HV off), " return None
"then Done.")
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") pgood = rd("cnc/laser_pgood")
ctx.evidence["laser_pgood"] = pgood ctx.evidence["laser_pgood"] = pgood
ctx.check(pgood == "0", "laser_pgood=%s (HV supply reports good) - refusing the latch unlock", 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 " @test("kernel.fire-line", title="FIRE line: latch locked, unlocked-unarmed, true underrun, and a "
"mid-run unlock", "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, covers=_KERNEL_COVERS,
requires=["kernel.k1-k2"], requires=["kernel.k1-k2"], precheck=hv_not_good,
steps=["Phases B, U and K3 unlock the latch with a zero-duty stream: if the HV supply " steps=["Phases B, U and K3 unlock the latch with a zero-duty stream, so the drill starts only "
"reports good the drill asks you to open the lid first (the safety chain holds HV " "while the HV supply does not report good (true at idle; if it is refused, open the "
"off)."], "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 " 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 " "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 " "unlocked with the chain unarmed, the FIRE line is driven mid-window and "
+126 -61
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 never touches the laser latch. One emission per test; on abort or error
the job is soft-reset (`^X`: controlled stop, latch relocked). The the job is soft-reset (`^X`: controlled stop, latch relocked). The
witnesses are forgectrl's `/status` (`laser.emission_samples` = the witnesses are forgectrl's `/status` (`laser.emission_samples` = the
kernel's LASER_ON sample count, `hv_current_raw`, `lid_ir`) and kernel's LASER_ON sample count, `hv_current_raw`, `lid_ir`),
`/cool/status` (`armed`), sampled at ~8 Hz through the arm -> fire -> `/cool/status` (`armed`), the head's beam detector
disarm lifecycle. (`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 import time
@@ -19,7 +25,7 @@ from ..catalog import test
from .. import hw from .. import hw
from ..runner import Failed from ..runner import Failed
from .motion import (kernel_xy_mm, kernel_start, check_kernel_returned, wait_state, 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", "**"), _LASER_COVERS = [("grblhal-glowforge", "src/**"), ("kernel-module-glowforge", "**"),
("forgectrl", "src/super.c"), ("forgectrl", "src/cool.c"), ("forgectrl", "src/super.c"), ("forgectrl", "src/cool.c"),
@@ -35,9 +41,13 @@ PWM_PERIOD = 127
PWMSAR_FLOOR_MIN = 12 PWMSAR_FLOOR_MIN = 12
ARM_CUE = ("LIVE FIRE. Eye protection on, exhaust running, fire watch and extinguisher in reach, " 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 " "scrap under the head with room to move (%s), lid closed. On Ready the job starts: "
"white button lights and the stream blocks until you press the physical arm button; " "the white button lights and the stream waits for your press of the physical arm "
"the machine fires only after your press. Ready?") "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): def sample(ctx):
@@ -62,9 +72,43 @@ def sample(ctx):
"armed": cs.get("armed"), "armed": cs.get("armed"),
"fire_watch": cs.get("fire_watch"), "fire_watch": cs.get("fire_watch"),
"verdict": cs.get("verdict"), "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): def prepare(ctx, g):
"""Guarantee a clean Idle start: clear a latched Door hold or an Alarm.""" """Guarantee a clean Idle start: clear a latched Door hold or an Alarm."""
st = g.status_report() 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, 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 or soft-resets and fails: no emission means the arm was refused or the
button was never pressed.""" button was never pressed."""
ctx.instruct(ARM_CUE % room) ctx.ready(ARM_CUE % room)
stream(g, job or MARK_JOB) 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() t0 = time.time()
while time.time() - t0 < timeout: while time.time() - t0 < timeout:
ctx.checkpoint() ctx.checkpoint()
smp = sample(ctx) smp = sample(ctx)
if smp and smp["emission"] and smp["emission"] > 0: if smp and smp["emission"] and smp["emission"] > 0:
ctx.clear_notice()
return smp return smp
time.sleep(0.15) time.sleep(0.15)
ctx.clear_notice()
g.realtime(0x18) g.realtime(0x18)
raise Failed("no emission seen within %d s (arm refused, or no button press)" % timeout) 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, subsystem="laser", kind="live", mode="grbl", always=True, est_min=5,
covers=_LASER_COVERS, covers=_LASER_COVERS,
requires=["kernel.latch-locked-idle", "kernel.k1-k2", "motion.jog-roundtrip"], 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.", 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 " 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 " "(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 " "returns to 0 at Idle, HV current rises during the burn, the head's beam "
"observed, and the M2 program end disarms promptly at Idle (job-based, not " "detector sees the beam, the armed window is observed, the M2 program end "
"the 60 s idle grace). The operator confirms the mark.") "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): def emission_witness(ctx):
ev = ctx.evidence ev = ctx.evidence
with ctx.grbl() as g, LiveJob(ctx, g): 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"], ctx.log("pre-fire: emission=%s hv=%s armed=%s verdict=%s", base["emission"], base["hv"],
base["armed"], base["verdict"]) base["armed"], base["verdict"])
ctx.check(not base["emission"], "emission_samples nonzero before the job (%s)", base["emission"]) 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", job = ["G91", "G21", "M4", "S400",
"G1 X20 F600", "G1 Y20 F600", "G1 X-20 F600", "G1 Y-20 F600", "G1 X20 F600", "G1 Y20 F600", "G1 X-20 F600", "G1 Y-20 F600",
"M5", "G90", "M2"] "M5", "G90", "M2"]
ctx.notice("The button lights white: press it to arm. The machine fires after your press.")
try:
samples = run_and_sample(ctx, g, job) 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] emis = [s["emission"] for s in samples if s["emission"] is not None]
peak = max(emis) if emis else 0 peak = max(emis) if emis else 0
end = emis[-1] if emis else None 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, 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)", "the M2 job did not disarm promptly at Idle (%s s; the idle grace is ~60 s)",
ev["disarm_after_idle_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 " judge_beam(ctx, beam)
"idle with the button dark?") check_button_dark(ctx, ev)
ctx.log("PASS: emission peak %s -> 0, HV %s..%s, disarmed %.1f s after Idle, mark confirmed", ctx.confirm("Did the laser mark a 20 mm square outline on the scrap?")
peak, ev["hv_min"], ev["hv_max"], dt) 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", @test("laser.disarm-in-hold", title="Disarm grace counts down in Hold", subsystem="laser",
kind="live", mode="grbl", est_min=4, kind="live", mode="grbl", est_min=4,
covers=_LASER_COVERS, 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.", 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 " 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 " "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): def disarm_in_hold(ctx):
ev = ctx.evidence ev = ctx.evidence
with ctx.grbl() as g, LiveJob(ctx, g): with ctx.grbl() as g, LiveJob(ctx, g):
prepare(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"]) stream(g, ["G91", "G21", "M4", "S400", "G1 X40 F300"])
ctx.log("armed; waiting for motion to start (arm + your button press)...") ctx.log("armed; waiting for motion to start (arm + your button press)...")
t0 = time.time() w = Watch(g)
st = None ctx.act("button", "press", text="The button is lit white: the press arms the job and the "
while time.time() - t0 < 180: "move starts.", until=w.in_state("Run"), timeout=180, fail=False)
ctx.checkpoint() st = w.last["state"] if w.last else None
st = g.status_report()["state"]
if st.startswith("Run"):
break
time.sleep(0.1)
ctx.check(st and st.startswith("Run"), "motion never started (state=%s) - arm refused or no press", st) 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.log("moving under laser: %s; feed-hold in 2 s", st)
ctx.sleep(2) ctx.sleep(2)
@@ -394,19 +448,17 @@ def disarm_in_hold(ctx):
g.command("$X") g.command("$X")
ctx.check(disarmed_at is not None, "still armed after 120 s in Hold") 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.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) check_button_dark(ctx, ev)
ctx.confirm("Did the head stop after ~2 s of the +X move and stay stopped, with the button " ctx.log("PASS: disarmed in Hold after %.1f s, button dark", disarmed_at)
"going dark on its own about a minute later?")
@test("laser.armed-kill", title="Armed kill mid-fire: the expected stop, then a SIGKILL", @test("laser.armed-kill", title="Armed kill mid-fire: the expected stop, then a SIGKILL",
subsystem="laser", kind="live", mode="grbl", est_min=6, subsystem="laser", kind="live", mode="grbl", est_min=6,
covers=_LASER_COVERS + [("forgectrl", "src/main.c")], 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.", 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.", "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 " "Each burn is cut short by the test; the sample trails judge the stops."],
"test then restarts it and runs the second burn."],
description="Both ways an armed job is killed, on one setup. Expected: mid-burn " 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 " "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, " "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) "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(tail_zero, "emission returned after the stop")
ctx.check(not_running, "the kernel was still running 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 " ev["beam_at_fire_stop"] = smp.get("beam")
"short cut, then an abrupt end - and confirm the machine is quiet; then Done to " ctx.log("the controller is stopped (supervision held); restarting it (no motion, no laser)")
"restart the controller (no motion, no laser).")
st, body = fc.post("/controller/start") st, body = fc.post("/controller/start")
ctx.log("POST /controller/start -> %s %s", st, body) ctx.log("POST /controller/start -> %s %s", st, body)
ctx.check(st == 200, "POST /controller/start -> %s", st) 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.log("/mode after the kill: %s", m1)
ctx.check(m1 and m1.get("controller") == "running" and m1.get("pid") != pid, ctx.check(m1 and m1.get("controller") == "running" and m1.get("pid") != pid,
"supervisor did not respawn the controller: %s", m1) "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), " ev["beam_at_fire_kill"] = smp.get("beam")
"with the machine quiet and the button dark now?") check_button_dark(ctx, ev)
ctx.log("PASS: expected stop 0 at +%s s and SIGKILL 0 at +%s s, latch locked, controller " 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", @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"), covers=_LASER_COVERS + [("grblhal-glowforge", "src/glowforge_laser.c"),
("grblhal-glowforge", "src/glowforge_switches.c"), ("grblhal-glowforge", "src/glowforge_switches.c"),
("grblhal-glowforge", "src/glowforge_switch_map.h")], ("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.", 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, " 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 " "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 " "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) s = sample(ctx)
ev["armed_during_wait"] = s["armed"] if s else None 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.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() t1 = time.time()
while time.time() - t1 < 15: while time.time() - t1 < 15:
ctx.checkpoint() 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(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(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.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) ctx.sleep(1)
st = g.status_report()["state"] st = g.status_report()["state"]
ev["state_after"] = st ev["state_after"] = st
@@ -572,10 +625,11 @@ def arm_wait_lid(ctx):
covers=_LASER_COVERS + [("grblhal-glowforge", "src/glowforge_switches.c"), covers=_LASER_COVERS + [("grblhal-glowforge", "src/glowforge_switches.c"),
("grblhal-glowforge", "src/glowforge_switch_map.h")], ("grblhal-glowforge", "src/glowforge_switch_map.h")],
requires=["laser.emission-witness", "motion.lid-cancel-home", "motion.button-hold-resume"], 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.", 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 " "Press the physical button when it lights white (arm). Once the cut is under way the "
"it again (pause), wait about 3 seconds, press it once more (resume), and then open the " "test asks for a press (pause), then another (resume), then for the lid: open it and "
"lid and leave it open until the head has come back.", "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 " "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."], "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 " 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"] start = g.status_report()["MPos"]
ev["start"] = start ev["start"] = start
ev["kernel_start"] = k0 ev["kernel_start"] = k0
base_beam = sample(ctx)
beams = [] # (analog, digital) through the cut
smp = arm_and_fire(ctx, g, job=MARK_JOB_M3) smp = arm_and_fire(ctx, g, job=MARK_JOB_M3)
ev["emission_running"] = smp["emission"] 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"]) ctx.log("emission live (%s) - asking the operator to pause", smp["emission"])
# -- the button pauses ------------------------------------------------ # -- the button pauses ------------------------------------------------
g.drain() # the message window opens at the prompt g.drain() # the message window opens here
ctx.instruct("The laser is cutting. Press the button ONCE now (pause), then click Done - " w = Watch(g)
"do not wait long before the next step.") ctx.act("button", "press", text="The laser is cutting: the press pauses it.",
st, text = wait_state_text(ctx, g, "Hold", 8) 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"]) 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["hold_state"] = st["state"]
ev["pause_message"] = "job paused" in text ev["pause_message"] = "job paused" in text
@@ -631,8 +691,12 @@ def pause_resume_lid_cancel(ctx):
# -- the button resumes ----------------------------------------------- # -- the button resumes -----------------------------------------------
g.drain() g.drain()
ctx.instruct("Press the button once more now (resume), then click Done.") w = Watch(g)
st, text = wait_left_state(ctx, g, "Hold", 10) 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["resumed_state"] = st["state"] if st else g.status_report()["state"]
ev["resume_message"] = "job resumed" in text ev["resume_message"] = "job resumed" in text
ctx.check(st is not None, "the second press did not resume the job (still held: %s)", 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) s = sample(ctx)
if s: if s:
trail.append((round(time.time() - t2, 2), s["emission"])) 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: if s["emission"] and s["emission"] > 0:
back = True back = True
break break
@@ -658,7 +723,8 @@ def pause_resume_lid_cancel(ctx):
# -- the lid cancels -------------------------------------------------- # -- the lid cancels --------------------------------------------------
g.drain() 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() t_lid = time.time()
lid_trail = [] lid_trail = []
text = "" 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["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)", ctx.check(ev["button_latch"] == 1, "hardware button latch not SET after the lid open (%s)",
ev["button_latch"]) ev["button_latch"])
ctx.confirm("Did the burn stop on the first press, start again on the second, then stop the " beam_witness(ctx, ev, [{"beam": b, "beam_d": d} for b, d in beams], base_beam)
"instant the lid opened - and did the head go straight back to where the job " judge_beam(ctx, ev["beam"], "the cut")
"started, with the lid still open and dark? (A small mark where the cut " check_button_dark(ctx, ev)
"restarted is expected - GRBL mode does not backtrack.)") ctx.act("lid", "close")
ctx.instruct("Close the lid, then click Done.")
ctx.sleep(1) ctx.sleep(1)
ctx.log("PASS: button paused the burn (emission 0, armed kept, latch unlocked) and resumed it; " 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 " "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", @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"], 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.", steps=["Setup: the head parked with at least 60 mm of free +X and 40 mm of free +Y travel; "
"Watch the gantry: it must move on every jog and end where it started."], "bed clear, lid closed."],
description="Sanity jogs (X, Y 40 mm out/back at F2400), max-rate X out/back (60 mm at " 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. " "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 " "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): def jog_roundtrip(ctx):
ev = ctx.evidence ev = ctx.evidence
ctx.instruct("Head parked with >= 60 mm free +X and >= 40 mm free +Y, bed clear, lid closed. " accel = hw.AccelSampler()
"Watch the gantry during this test.") ctx.check(accel.available, "no head accelerometer found (i2c %s): the motion witness is missing",
with ctx.grbl() as g: hw.HEAD_ACCEL_I2C)
with ctx.grbl() as g, accel:
st0 = clean_slate(ctx, g) st0 = clean_slate(ctx, g)
start = st0.get("MPos") start = st0.get("MPos")
ctx.check(start, "no MPos in the status report") 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"), ("X max-rate 60mm", "$J=G91X60F12000", "$J=G91X-60F12000"),
("diag 40mm", "$J=G91X40Y40F8000", "$J=G91X-40Y-40F8000")): ("diag 40mm", "$J=G91X40Y40F8000", "$J=G91X-40Y-40F8000")):
for jog in (out, back): for jog in (out, back):
t0 = time.time()
r = g.command(jog) r = g.command(jog)
ctx.check(not any(x.startswith("error") for x in r), "%s: jog refused: %s", name, r) ctx.check(not any(x.startswith("error") for x in r), "%s: jog refused: %s", name, r)
peak, states, _ = wait_idle(ctx, g) peak, states, _ = wait_idle(ctx, g)
p2px, p2py, n = accel.p2p(t0)
leg = "out" if jog == out else "back" leg = "out" if jog == out else "back"
ctx.log("%s %s: peak %.0f mm/min, states %s", name, leg, peak, states) ctx.log("%s %s: peak %.0f mm/min, states %s; accel p2p x=%d y=%d over %d samples",
moves.append({"name": name, "leg": leg, "peak": peak, "states": states}) 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) ctx.check("TIMEOUT" not in states, "%s %s did not return to Idle", name, leg)
ev["moves"] = moves 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")) maxrate = max(m["peak"] for m in moves if m["name"].startswith("X max-rate"))
ev["max_rate_peak"] = maxrate ev["max_rate_peak"] = maxrate
ctx.check(maxrate >= 6000, "max-rate jog peaked at only %.0f mm/min", 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) machine_idle(ctx)
ctx.check("Hold" in held["state"], "feed hold did not park (state %s)", held["state"]) 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.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) " ctx.log("PASS: %d jogs, peak %.0f mm/min, hold parked, drift %.3f mm, the head seen moving on "
"and end where it started?") "%d of %d legs", len(moves), maxrate, drift, len(judged), len(moves))
ctx.log("PASS: %d jogs, peak %.0f mm/min, hold parked, drift %.3f mm, operator confirmed",
len(moves), maxrate, drift)
# ---------------------------------------------------------------- liveness # ---------------------------------------------------------------- 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) "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): def drain_text(g, seconds):
"""Everything the controller said in the next `seconds`.""" """Everything the controller said in the next `seconds`."""
end = time.time() + 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", @test("motion.button-hold-resume", title="The button pauses and resumes a job",
subsystem="motion", kind="operator", mode="grbl", est_min=2, 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.", 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 " 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 " "sees Hold), the next press resumes it (Run) and the move completes with its "
"position intact - the factory's pause/resume on the machine.") "position intact - the factory's pause/resume on the machine.")
@@ -711,28 +748,35 @@ def button_hold_resume(ctx):
with ctx.grbl() as g: with ctx.grbl() as g:
clean_slate(ctx, g) clean_slate(ctx, g)
start = g.status_report()["MPos"] 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("M5")
g.command("G91") g.command("G91")
g.command("G1X40F300", timeout=0.5) # an 8 s move g.command("G1X40F300", timeout=0.5) # an 8 s move
ctx.sleep(0.5) ctx.sleep(0.5)
ctx.check(g.status_report()["state"].startswith("Run"), "the move did not start") ctx.check(g.status_report()["state"].startswith("Run"), "the move did not start")
g.drain() # the message window opens at the prompt g.drain() # the message window opens here
ctx.instruct("The head is moving. Press the button once now, then click Done.") w = Watch(g)
st, text = wait_state_text(ctx, g, "Hold", 8) ctx.act("button", "press", until=w.in_state("Hold"), timeout=12, fail=False)
ctx.check(st is not None, "the press did not hold the job (state %s)", g.status_report()["state"]) 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_state"] = st["state"]
ev["held_at_mm"] = round(st["MPos"][0] - start[0], 3) ev["held_at_mm"] = round(st["MPos"][0] - start[0], 3)
ev["pause_message"] = "job paused" in text 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"], ctx.log("held: %s at %.3f mm of 40; message seen: %s", st["state"], ev["held_at_mm"],
ev["pause_message"]) 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 # 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 # 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 # 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 # exactly the right thing and the test would still have called it a
# failure. # 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["state_after_resume"] = st["state"] if st else None
ev["resume_message"] = "job resumed" in text ev["resume_message"] = "job resumed" in text
ctx.check(st is not None, "the second press did not resume the job (still held: %s)", 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", "to the job start",
subsystem="motion", kind="operator", mode="grbl", est_min=5, subsystem="motion", kind="operator", mode="grbl", est_min=5,
covers=_LID_COVERS, requires=["motion.pacing", "motion.cancel-abort"], 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.", 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 " "Twice, on Ready the head starts an 8 s move. First: open the lid while it moves and "
"with the job running, once with it paused on the button)."], "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), " 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 " "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 " "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"] start = g.status_report()["MPos"]
ev["kernel_start"] = k0 ev["kernel_start"] = k0
ev["start"] = start 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("M5")
g.command("G91") g.command("G91")
g.command("G1X40F300", timeout=0.5) # an 8 s move g.command("G1X40F300", timeout=0.5) # an 8 s move
ctx.sleep(0.5) ctx.sleep(0.5)
ctx.check(g.status_report()["state"].startswith("Run"), "the move did not start") ctx.check(g.status_report()["state"].startswith("Run"), "the move did not start")
g.drain() # the message window opens at the prompt g.drain() # the message window opens here
ctx.instruct("The head is moving. Open the lid NOW and leave it open, then click Done.") 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") drift = expect_cancel_and_return(ctx, g, ev, start, k0, "lid opened", "running")
sw = (ctx.forgectrl.status().get("switches") or {}) sw = (ctx.forgectrl.status().get("switches") or {})
ev["lid_at_return"] = sw.get("lid") ev["lid_at_return"] = sw.get("lid")
ctx.instruct("Close the lid, then click Done.") ctx.act("lid", "close")
ctx.sleep(1) ctx.sleep(1)
# a jog afterward proves the controller is usable without $X # a jog afterward proves the controller is usable without $X
r = g.command("$J=G91X5F1200") r = g.command("$J=G91X5F1200")
@@ -808,23 +857,30 @@ def lid_cancel_home(ctx):
k1 = kernel_start(ctx) k1 = kernel_start(ctx)
start2 = g.status_report()["MPos"] start2 = g.status_report()["MPos"]
ev["hold_start"] = start2 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("G91") # the reset restored G90
g.command("G1X40F300", timeout=0.5) g.command("G1X40F300", timeout=0.5)
ctx.sleep(0.5) ctx.sleep(0.5)
ctx.check(g.status_report()["state"].startswith("Run"), "the second move did not start") ctx.check(g.status_report()["state"].startswith("Run"), "the second move did not start")
g.drain() g.drain()
ctx.instruct("The head is moving again. Press the button once now (pause), then click Done.") w = Watch(g)
st, held = wait_state_text(ctx, g, "Hold", 8) ctx.act("button", "press", text="The job pauses.", until=w.in_state("Hold"), timeout=12,
ctx.check(st is not None, "the press did not hold the job (state %s)", g.status_report()["state"]) 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_state"] = st["state"]
ev["hold_pause_message"] = "job paused" in held ev["hold_pause_message"] = "job paused" in held
ctx.log("paused: %s; message seen: %s", st["state"], ev["hold_pause_message"]) ctx.log("paused: %s; message seen: %s", st["state"], ev["hold_pause_message"])
g.drain() 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") hold_drift = expect_cancel_and_return(ctx, g, ev, start2, k1, "lid opened", "hold")
ctx.check(not g.status_report()["state"].startswith("Hold"), ctx.check(not g.status_report()["state"].startswith("Hold"),
"the controller is still holding after the lid cancelled the paused job") "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) ctx.sleep(1)
r = g.command("$J=G91X5F1200") r = g.command("$J=G91X5F1200")
ctx.check(not any(x.startswith("error") for x in r), "jog refused after the paused cancel: %s", r) 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 " @test("motion.interlock-cancel-home", title="The interlock loop cancels a job like the lid and returns to "
"the job start", "the job start",
subsystem="motion", kind="operator", mode="grbl", est_min=4, 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.", 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 " "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.", "jumper at J8 on a Basic/Plus.",
"Open the interlock when told and leave it open until the head has come back."], "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 " 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 " "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. " "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"] start = g.status_report()["MPos"]
ev["start"] = start ev["start"] = start
ev["kernel_start"] = k0 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("M5")
g.command("G91") g.command("G91")
g.command("G1X60F300", timeout=0.5) # a 12 s move g.command("G1X60F300", timeout=0.5) # a 12 s move
ctx.sleep(0.5) ctx.sleep(0.5)
ctx.check(g.status_report()["state"].startswith("Run"), "the move did not start") ctx.check(g.status_report()["state"].startswith("Run"), "the move did not start")
g.drain() g.drain()
ctx.instruct("The head is moving. Open the INTERLOCK loop now (unplug it / pull the jumper) and " ctx.act("interlock", "open", text="Leave it open until the head has come back.", timeout=16)
"leave it open, then click Done.")
sw = (ctx.forgectrl.status().get("switches") or {}) sw = (ctx.forgectrl.status().get("switches") or {})
ev["interlock_ok_after_pull"] = sw.get("interlock_ok") ev["interlock_ok_after_pull"] = sw.get("interlock_ok")
ctx.check(sw.get("interlock_ok") is False, 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, ctx.check(sw.get("interlock_ok") is False,
"the interlock was closed again before the park finished - the park ran with the loop " "the interlock was closed again before the park finished - the park ran with the loop "
"restored, not open") "restored, not open")
ctx.instruct("Restore the interlock loop (plug/jumper back in), then click Done.") ctx.act("interlock", "close")
ctx.sleep(1) ctx.sleep(1)
sw = (ctx.forgectrl.status().get("switches") or {}) sw = (ctx.forgectrl.status().get("switches") or {})
ev["restored"] = {"lid": sw.get("lid"), "interlock_ok": sw.get("interlock_ok")} 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", @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, subsystem="motion", kind="operator", mode="grbl", est_min=4,
covers=_LID_COVERS + [("forgectrl", "src/settings.*")], requires=["motion.lid-cancel-home"], 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.", 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 " 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 - " "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 " "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: with ctx.grbl() as g:
clean_slate(ctx, g) clean_slate(ctx, g)
start = g.status_report()["MPos"] 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("M5")
g.command("G91") g.command("G91")
g.command("G1X40F300", timeout=0.5) # an 8 s move g.command("G1X40F300", timeout=0.5) # an 8 s move
ctx.sleep(0.5) ctx.sleep(0.5)
ctx.check(g.status_report()["state"].startswith("Run"), "the move did not start") ctx.check(g.status_report()["state"].startswith("Run"), "the move did not start")
g.drain() 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) st, text = wait_state_text(ctx, g, "Door", 8)
ev["door_state"] = st["state"] if st else g.status_report()["state"] 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"]) 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"] held = g.status_report()["MPos"]
ev["parked_at"] = held ev["parked_at"] = held
ctx.log("parked in %s at %s", ev["door_state"], 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) ctx.sleep(1)
ev["state_after_close"] = g.status_report()["state"] ev["state_after_close"] = g.status_report()["state"]
ctx.log("after the lid closed: %s (a cycle start is needed)", ev["state_after_close"]) 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 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", 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): def registry(*tests):
@@ -165,3 +167,90 @@ class FakeForgectrl:
self._srv.server_close() self._srv.server_close()
os.environ.pop("FORGECTRL_URL", None) os.environ.pop("FORGECTRL_URL", None)
os.environ.pop("FORGECTRL_TOKEN_FILE", 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 """The cloud.* job-behavior tests against a fake forgectrl and a replayed
gfcloud log: the real test functions run under the real runner Context, gfcloud log: the real test functions run under the real runner Context,
the operator prompts answered by a script that also drives the replay the operator prompts answered - and the standing notices of the machine
(open the lid -> the machine's lid reads open; close it -> the service's actions acted on - by a script that also drives the replay (open the
re-hunt lines land). The log lines are the machine's own: bench excerpts 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 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 the run loop's lines as its host test emits them on the print skeleton
of the lid-abort excerpt. of the lid-abort excerpt.
@@ -62,13 +63,16 @@ def cut(lines, marker, count=1):
class Script: class Script:
"""Answers every prompt with its first option; a hook per prompt """Answers every prompt with its first option; a hook per prompt or
substring drives the fake machine and the log replay.""" 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): def __init__(self, run, hooks=None):
self.run = run self.run = run
self.hooks = hooks or {} self.hooks = hooks or {}
self.asked = [] self.asked = []
self.noticed = []
self.th = threading.Thread(target=self._loop, daemon=True) self.th = threading.Thread(target=self._loop, daemon=True)
self.stop = False self.stop = False
@@ -76,16 +80,25 @@ class Script:
self.th.start() self.th.start()
return self return self
def _fire(self, text):
for key, fn in self.hooks.items():
if key in text:
fn()
def _loop(self): def _loop(self):
seen = None seen = None
seen_n = None
while not self.stop: 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 p = self.run.prompt
if p and p["id"] != seen: if p and p["id"] != seen:
seen = p["id"] seen = p["id"]
self.asked.append(p["question"]) self.asked.append(p["question"])
for key, fn in self.hooks.items(): self._fire(p["question"])
if key in p["question"]:
fn()
self.run.answer(p["id"], p["options"][0]) self.run.answer(p["id"], p["options"][0])
time.sleep(0.02) time.sleep(0.02)
@@ -100,13 +113,23 @@ class CloudSuiteTests(unittest.TestCase):
self._attr("cnc/interlock_circuit", "45") # latch locked (bit 3) self._attr("cnc/interlock_circuit", "45") # latch locked (bit 3)
self._pos(0, 0, 3) self._pos(0, 0, 3)
os.environ["GF_SYSFS_ROOT"] = self.sysfs 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.fc = helpers.FakeForgectrl().start()
self.grbl = None
self.fclog = os.path.join(self.tmp, "forgectrl.log") self.fclog = os.path.join(self.tmp, "forgectrl.log")
open(self.fclog, "wb").close() 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, 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.GFCLOUD_LOG = self.log
cloud.FORGECTRL_LOG = self.fclog 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.engine_line = EFFECTIVE_LINE # what the engine logs at the print; None = nothing
self.client_limits = True # the client names its header limits self.client_limits = True # the client names its header limits
cloud.QUIET_S = 0.4 cloud.QUIET_S = 0.4
@@ -118,9 +141,12 @@ class CloudSuiteTests(unittest.TestCase):
if self.script: if self.script:
self.script.stop = True self.script.stop = True
self.fc.stop() self.fc.stop()
if self.grbl:
self.grbl.stop()
(cloud.GFCLOUD_LOG, cloud.FORGECTRL_LOG, cloud.QUIET_S, cloud.QUIET_TIMEOUT_S, (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_SYSFS_ROOT", None)
os.environ.pop("GF_LEDS_ROOT", None)
shutil.rmtree(self.tmp, ignore_errors=True) shutil.rmtree(self.tmp, ignore_errors=True)
# -- fakes ----------------------------------------------------------- # -- fakes -----------------------------------------------------------
@@ -222,80 +248,107 @@ class CloudSuiteTests(unittest.TestCase):
self.assertFails(cloud.enter_cloud, "still running service moves") self.assertFails(cloud.enter_cloud, "still running service moves")
# -- the hunt with the lid open ------------------------------------------ # -- the hunt with the lid open ------------------------------------------
def test_hunt_lid_open_restarts_the_client_in_cloud_mode(self): # -- the mode switch: hunt with the lid open, then $H -----------------------
self.in_cloud(pid=1927) 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") lines = fixture("huntlid")
pre, post = cut(lines, "gfuiservice:__init__ INITIALIZED") pre, post = cut(lines, "gfuiservice:__init__ INITIALIZED")
hunt_part, close_part = cut(post, "_switch_event lid closed") 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): def on_post(path, form):
if path == "/controller/stop": if path == "/mode" and form.get("controller") == "cloud":
self.fc.state["mode"] = dict(self.fc.state["mode"], controller="standby", pid=0) fc.state["cool"] = dict(self.HUNT_RUN_SAMPLE)
elif path == "/controller/start":
self.fc.state["mode"] = dict(self.fc.state["mode"], controller="running", pid=2278) def land():
self.append(hunt_part, delay=0.2) 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 return None
self.fc.on_post = on_post self.fc.on_post = on_post
def lid_closed(): def lid_closed():
self.lid(True) self.lid(True)
self.append(close_part, delay=0.2) 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}, def homing():
test_id="cloud.hunt-lid-open") self.grbl.state = "Home"
self.assertEqual([p for p, _ in self.fc.posts], ["/controller/stop", "/controller/start"]) 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 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.assertIn(":completed", ev["hunt_line"])
self.assertEqual(ev["refusals_before_hunt_end"], 0) self.assertEqual(ev["refusals_before_hunt_end"], 0)
self.assertTrue(ev["lens_homed"])
self.assertEqual(ev["motions_after_lid_close"], 3) self.assertEqual(ev["motions_after_lid_close"], 3)
self.assertEqual(self.fc.state["mode"]["mode"], "cloud") # still cloud self.assertTrue(all(c.get("verdict") != "AIRFLOW" for c in ev["hunt_gates"]))
self.assertTrue(any("PASS:" in l for l in run.lines)) self.assertTrue(ev["homed"])
# the prompts, in order: open, confirm lens, close self.assertIn("3 motion windows", ev["gfhome_complete"])
self.assertEqual(len(self.script.asked), 3) 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): def test_mode_switch_fails_when_the_hunt_is_refused_for_the_lid(self):
lines = fixture("huntlid") def refused(hunt_part):
pre, post = cut(lines, "gfuiservice:__init__ INITIALIZED") i = next(i for i, l in enumerate(hunt_part) if "z_axis:home starting z homing cycle" in l)
hunt_part, close_part = cut(post, "_switch_event lid closed") return hunt_part[:i] + ["2026-08-17T09:45:10.595000+00:00 gfcloud[2278] INFO machine:_safe_to_move "
self.append(pre) "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): def test_mode_switch_fails_when_the_hunt_skipped_the_lens(self):
if path == "/mode": hooks = self.mode_switch_setup(hunt_lines=lambda part: [l for l in part if "z homing cycle" not in l])
self.append(hunt_part, delay=0.2) self.assertFails(cloud.mode_switch, "did not home the lens", hooks=hooks)
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_hunt_lid_open_needs_the_lid_open(self): def test_mode_switch_fails_when_gfhome_never_saw_the_head_move(self):
self.in_cloud() hooks = self.mode_switch_setup(home_complete=False)
self.append(fixture("huntlid")) threading.Thread(target=self.wait_home_command, daemon=True).start()
self.assertFails(cloud.hunt_lid_open, "lid reads closed") self.assertFails(cloud.mode_switch, "no 'homing complete' line", hooks=hooks)
def test_hunt_refused_for_the_lid_fails(self): def test_mode_switch_precheck_needs_gfcloud_homing(self):
self.in_cloud(pid=1927) self.fc.state["settings"]["homing_mode"] = "switches"
lines = fixture("huntlid") self.assertIn("needs gfcloud", cloud.homing_mode_is_gfcloud())
pre, post = cut(lines, "gfuiservice:__init__ INITIALIZED") self.fc.state["settings"]["homing_mode"] = "gfcloud"
# a refusal ahead of the hunt's end (the lid gating the hunt would look like this) self.assertIsNone(cloud.homing_mode_is_gfcloud())
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)})
# -- pause / resume --------------------------------------------------------- # -- pause / resume ---------------------------------------------------------
def replay_print(self, name, at_run, at_end, tail_delay=0.3): 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", 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.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"]) "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 self.fc.state["cool"]["armed"] = True
run = self.run_test(cloud.pause_resume, hooks=hooks, test_id="cloud.pause-resume") run = self.run_test(cloud.pause_resume, hooks=hooks, test_id="cloud.pause-resume")
ev = run.evidence ev = run.evidence
@@ -351,7 +404,7 @@ class CloudSuiteTests(unittest.TestCase):
self.client_limits = False self.client_limits = False
self.in_cloud(pid=1522) self.in_cloud(pid=1522)
self.append(["2026-08-17T09:41:00.500000+00:00 gfcloud[1522] INFO websocket:_on_open RX-EVENT: ready"]) 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.fc.state["cool"]["armed"] = True
self.assertFails(cloud.pause_resume, "named no job limits", hooks=hooks) 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.engine_line = EFFECTIVE_LINE.replace("header 33.0", "header none 0.0")
self.in_cloud(pid=1522) self.in_cloud(pid=1522)
self.append(["2026-08-17T09:41:00.500000+00:00 gfcloud[1522] INFO websocket:_on_open RX-EVENT: ready"]) 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.fc.state["cool"]["armed"] = True
self.assertFails(cloud.pause_resume, "never resolved an effective ceiling", hooks=hooks) 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 = cut(lines, "current state: MachineState.RUNNING")
pre, rest = pre + [rest[0]], rest[1:] pre, rest = pre + [rest[0]], rest[1:]
hooks = {"Click Done here": lambda: self.append(pre, delay=0.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) self.assertFails(cloud.pause_resume, "did not complete after the resume", hooks=hooks)
def test_pause_resume_fails_without_the_retraced_restart(self): 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:] pre, rest = pre + [rest[0]], rest[1:]
with self.fast_wait_log(): with self.fast_wait_log():
hooks = {"Click Done here": lambda: self.append(pre, delay=0.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, "no retraced restart logged", hooks=hooks) self.assertFails(cloud.pause_resume, "no retraced restart logged", hooks=hooks)
def test_pause_resume_fails_when_the_kernel_refuses_the_resume(self): 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:] pre, rest = pre + [rest[0]], rest[1:]
with self.fast_wait_log(): with self.fast_wait_log():
hooks = {"Click Done here": lambda: self.append(pre, delay=0.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, "the resume was refused", hooks=hooks) self.assertFails(cloud.pause_resume, "the resume was refused", hooks=hooks)
@contextlib.contextmanager @contextlib.contextmanager
@@ -410,10 +463,13 @@ class CloudSuiteTests(unittest.TestCase):
def fast(ctx, offset, needles, timeout, poll=0.5): def fast(ctx, offset, needles, timeout, poll=0.5):
return saved(ctx, offset, needles, min(timeout, 1.5), poll=0.1) return saved(ctx, offset, needles, min(timeout, 1.5), poll=0.1)
cloud.wait_log = fast cloud.wait_log = fast
press = cloud.PRESS_TIMEOUT_S
cloud.PRESS_TIMEOUT_S = 1.5
try: try:
yield yield
finally: finally:
cloud.wait_log = saved cloud.wait_log = saved
cloud.PRESS_TIMEOUT_S = press
def test_pause_resume_fails_without_the_pause_line(self): def test_pause_resume_fails_without_the_pause_line(self):
self.in_cloud(pid=1522) self.in_cloud(pid=1522)
@@ -423,7 +479,7 @@ class CloudSuiteTests(unittest.TestCase):
pre, rest = pre + [rest[0]], rest[1:] pre, rest = pre + [rest[0]], rest[1:]
with self.fast_wait_log(): with self.fast_wait_log():
hooks = {"Click Done here": lambda: self.append(pre, delay=0.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 pause the run", hooks=hooks) self.assertFails(cloud.pause_resume, "did not pause the run", hooks=hooks)
# -- the lid/interlock abort and the button-wait tests, on their excerpts ------ # -- the lid/interlock abort and the button-wait tests, on their excerpts ------
@@ -455,12 +511,12 @@ class CloudSuiteTests(unittest.TestCase):
self.lid(True) self.lid(True)
self.fc.state["status"]["switches"]["interlock_ok"] = True self.fc.state["status"]["switches"]["interlock_ok"] = True
return {"Click Done here": next_print, return {"Click Done here": next_print,
"Open the lid NOW": lambda: (self.lid(False), self.append(lid_tail, delay=0.05)), "leave the lid open until the head has returned": lambda: (self.lid(False),
"Close the lid, then click Done": lambda: self.lid(True), self.append(lid_tail, delay=0.05)),
"Open the INTERLOCK loop now": pull_interlock, "Close the lid.": lambda: self.lid(True),
"Open the LID now as well": lambda: (self.lid(False), "Open the remote-interlock loop": pull_interlock,
self.append(ilk_park, delay=0.05)), "on its way back": lambda: (self.lid(False), self.append(ilk_park, delay=0.05)),
"Close the lid and restore the interlock": restore} "Restore the remote-interlock loop": restore}
def test_lid_interlock_abort_on_the_bench_excerpt(self): def test_lid_interlock_abort_on_the_bench_excerpt(self):
self.in_cloud(pid=1522) self.in_cloud(pid=1522)
@@ -527,7 +583,8 @@ class CloudSuiteTests(unittest.TestCase):
pre, rest = cut(lines, "waiting for button") pre, rest = cut(lines, "waiting for button")
pre, rest = pre + [rest[0]], rest[1:] pre, rest = pre + [rest[0]], rest[1:]
hooks = {"Click Done here": lambda: self.append(pre, delay=0.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") run = self.run_test(cloud.lid_during_button_wait, hooks=hooks, test_id="cloud.lid-during-button-wait")
ev = run.evidence ev = run.evidence
self.assertEqual(ev["runs_started_after_wait"], 0) self.assertEqual(ev["runs_started_after_wait"], 0)
@@ -563,11 +620,11 @@ class CloudSuiteTests(unittest.TestCase):
self.append(pre, delay=0.1) self.append(pre, delay=0.1)
run = self.run_test(cloud.pause_cancel_paths, run = self.run_test(cloud.pause_cancel_paths,
hooks={"Click Done here": next_print, hooks={"Click Done here": next_print,
"Press the button once NOW": lambda: self.append(paused, delay=0.05), "the press pauses the print": lambda: self.append(paused, delay=0.05),
"Open the lid NOW": lambda: (self.lid(False), "The print is paused: leave the lid open": lambda: (
self.append(lid_tail, delay=0.05)), self.lid(False), self.append(lid_tail, delay=0.05)),
"Close the lid, then click Done": lambda: self.lid(True), "Close the lid.": lambda: self.lid(True),
"Cancel the print from the app now": lambda: self.append(app_tail, "cancel the print from the app now": lambda: self.append(app_tail,
delay=0.05)}, delay=0.05)},
test_id="cloud.pause-cancel-paths") test_id="cloud.pause-cancel-paths")
ev = run.evidence ev = run.evidence
@@ -593,8 +650,9 @@ class CloudSuiteTests(unittest.TestCase):
self.assertFails( self.assertFails(
cloud.pause_cancel_paths, "print 1 did not end ':cancelled'", cloud.pause_cancel_paths, "print 1 did not end ':cancelled'",
hooks={"Click Done here": lambda: self.append(pre, delay=0.1), hooks={"Click Done here": lambda: self.append(pre, delay=0.1),
"Press the button once NOW": lambda: self.append(paused, delay=0.05), "the press pauses the print": lambda: self.append(paused, delay=0.05),
"Open the lid NOW": lambda: (self.lid(False), self.append(lid_tail, 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): 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 # 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()
+15 -1
View File
@@ -376,8 +376,22 @@ class ServerTests(unittest.TestCase):
marker = os.environ["FORGETEST_MARKER"] marker = os.environ["FORGETEST_MARKER"]
with open(marker, "w") as f: with open(marker, "w") as f:
f.write("x fake.slow\n") f.write("x fake.slow\n")
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) r = Runner(self.log, self.man, self.reg, self.bench)
self.assertTrue(any("recovered" in m for m in r.messages)) 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)) self.assertFalse(os.path.exists(marker))
def test_10_state_is_conditional(self): def test_10_state_is_conditional(self):