mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-28 01:01:12 -07:00
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:
+56
-7
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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…</button>
|
<button onclick='toggleInv()'>Invalidate all…</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)+' · started '+esc(fmtTs(r.started))+' · '+r.elapsed_s+' s';
|
var hd=esc(r.id)+' · started '+esc(fmtTs(r.started))+' · '+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
@@ -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):
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
@@ -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'")
|
||||||
|
|||||||
@@ -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 "
|
||||||
|
|||||||
@@ -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 "
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user