forgetest: the bench actuator plugs into the action seam

fixture.py: the bench's /data/forgetest/fixture.json (hostname, key,
optional ip, the channels wired, arm_press), a resolver for
<hostname>.local asked of the network directly (the image has no mDNS
resolver), and the client. The runner probes it before every run and
at most every 30 s otherwise; ctx.act asks it for a channel it covers
and still waits for the machine's own reading, falling back to the
operator's notice when the box fails. A test declares with hands=(...)
what it asks of a person beyond its typed actions; an operator test
with none, whose actions the fixture covers, is routed into the
unattended queue, its Ready gates pass, and a prompt it raises anyway
is a FAIL naming the undeclared step. Live tests never move; their arm
press stays a person's unless the bench opted in, in which case the
fixture presses when the button lights. Whatever the box still holds
after a run is released before the baseline's post pass and recorded.
The page shows what the fixture covers. Contract in ACCEPTANCE.md; the
wiring facts, with the interlock connector left to the bench to settle
(SAFETY.md and the sister map differ), in BRINGUP.

Catalog unchanged in its definitions; the cloud and laser suites'
shared code moved, so their implementation hashes move with it.
This commit is contained in:
ScottW514
2026-08-23 14:15:53 -04:00
parent 8ee4ee3946
commit b54b94e8ae
10 changed files with 1026 additions and 23 deletions
+25 -7
View File
@@ -40,7 +40,7 @@ REGISTRY = {}
class Test:
def __init__(self, id, title, subsystem, kind, hardware, covers, requires,
always, est_min, steps, description, fn, mode=None, actions=(),
precheck=None):
precheck=None, hands=()):
self.id = id
self.title = title
self.subsystem = subsystem
@@ -53,6 +53,7 @@ class Test:
self.est_min = est_min
self.steps = tuple(steps)
self.actions = tuple(actions)
self.hands = tuple(hands)
self.precheck = precheck
self.description = description or (fn.__doc__ or "").strip()
self.fn = fn
@@ -102,9 +103,20 @@ class Test:
d = self.definition()
d.update({"title": self.title, "est_min": self.est_min, "mode": self.mode,
"steps": list(self.steps), "actions": list(self.actions),
"precheck": bool(self.precheck), "description": self.description})
"hands": list(self.hands), "precheck": bool(self.precheck),
"description": self.description})
return d
def fixture_runnable(self, channels):
"""Whether a bench actuator covering `channels` lets this test
run with nobody in the room: an operator test whose every action
is a covered channel and which asks a person for nothing else. A
live test never qualifies (the arm press and the fire watch are
a person's); an auto test needs no fixture."""
if self.kind != "operator" or self.hands or not self.actions:
return False
return all(a in channels for a in self.actions)
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."""
@@ -174,7 +186,7 @@ def implementation_sha(path, test_id):
def test(id, *, title, subsystem, kind="auto", hardware="api", mode=None, covers=(),
requires=(), always=False, est_min=1, steps=(), description="", actions=(),
precheck=None):
precheck=None, hands=()):
"""`mode` names the controller mode the test needs live when it starts
("grbl" or "cloud"); the runner switches the machine there before the
test and leaves it there, so a queue crosses modes only where a test
@@ -183,9 +195,13 @@ def test(id, *, title, subsystem, kind="auto", hardware="api", mode=None, covers
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)."""
through Context.act; an `auto` test declares none. `hands` names what
the test asks of a person beyond those actions ("app" for a job in
the Glowforge app, "scrap", "mark" for an eye on the result...): an
operator test with none, whose actions a bench actuator covers, runs
unattended when one is up. `precheck` is a callable returning a
reason string when the machine cannot run the test as it is (None
when it can)."""
if not _ID_RX.match(id):
raise ValueError("test id %r must look like subsystem.name" % id)
if kind not in KINDS:
@@ -202,6 +218,8 @@ def test(id, *, title, subsystem, kind="auto", hardware="api", mode=None, covers
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 hands and kind == "auto":
raise ValueError("test %s: an auto test asks nothing of a person" % id)
if precheck is not None and not callable(precheck):
raise ValueError("test %s: precheck must be callable" % id)
@@ -210,7 +228,7 @@ def test(id, *, title, subsystem, kind="auto", hardware="api", mode=None, covers
raise ValueError("duplicate test id %r" % id)
REGISTRY[id] = Test(id, title, subsystem, kind, hardware, covers, requires,
always, est_min, steps, description, fn, mode=mode,
actions=actions, precheck=precheck)
actions=actions, precheck=precheck, hands=hands)
return fn
return deco
+324
View File
@@ -0,0 +1,324 @@
"""The bench actuator (forgefixture): the box on the bench network that
opens the lid loop, pulls the interlock loop and presses the button on
request, so an operator test can run with nobody in the room.
Configured by a bench-local file, FIXTURE_CONFIG (default
/data/forgetest/fixture.json, mode 0600, never in the repo):
{"hostname": "forgefixture", "key": "<the fixture's API key>",
"ip": null, "channels": ["lid", "interlock", "button"],
"arm_press": false}
`hostname` is resolved as <hostname>.local by the resolver here (the
image carries no mDNS resolver; the fixture answers the query itself);
`ip` overrides that. `channels` names what is wired. `arm_press` is the
one policy switch: whether the fixture may press the button to arm the
laser for a live test (a person's press by default).
The fixture does not read the machine: every action the runner asks of
it is verified through the machine's own readings afterward, exactly as
an operator's hand is (Context.act). Contract: fixture/README.md.
"""
import json
import os
import socket
import struct
import threading
import time
import urllib.error
import urllib.request
FIXTURE_CONFIG = os.environ.get("FORGETEST_FIXTURE_CONFIG", "/data/forgetest/fixture.json")
CHANNELS = ("lid", "interlock", "button")
MDNS_GROUP = "224.0.0.251"
MDNS_PORT = 5353
RESOLVE_TTL_S = 300.0 # a resolved address is trusted this long
class FixtureError(Exception):
pass
# ---------------------------------------------------------------- mDNS
def mdns_query(name):
"""One mDNS A query for `name` (e.g. "forgefixture.local"), as the
bytes on the wire: a standard DNS header with id 0, one question,
class IN with the unicast-response bit set so the answer comes back
to this socket rather than the group."""
labels = b"".join(struct.pack("B", len(p)) + p.encode("ascii") for p in name.rstrip(".").split("."))
header = struct.pack("!HHHHHH", 0, 0, 1, 0, 0, 0)
return header + labels + b"\x00" + struct.pack("!HH", 1, 0x8001)
def _read_name(data, off):
"""A DNS name at `off`, compression pointers followed; (name, next
offset)."""
parts = []
jumped = False
end = off
guard = 0
while True:
if off >= len(data):
raise ValueError("truncated name")
n = data[off]
if n == 0:
off += 1
break
if n & 0xC0 == 0xC0:
ptr = struct.unpack("!H", data[off:off + 2])[0] & 0x3FFF
if not jumped:
end = off + 2
jumped = True
off = ptr
guard += 1
if guard > 32:
raise ValueError("pointer loop")
continue
parts.append(data[off + 1:off + 1 + n].decode("ascii", "replace"))
off += 1 + n
if not jumped:
end = off
return ".".join(parts).lower(), end
def mdns_answers(data, name):
"""The IPv4 addresses in an mDNS response's A records for `name`
(answers and additionals; a response is anything with QR set)."""
name = name.rstrip(".").lower()
out = []
try:
_id, flags, qd, an, ns, ar = struct.unpack("!HHHHHH", data[:12])
if not flags & 0x8000:
return out
off = 12
for _ in range(qd):
_n, off = _read_name(data, off)
off += 4
for _ in range(an + ns + ar):
rname, off = _read_name(data, off)
rtype, rclass, _ttl, rdlen = struct.unpack("!HHIH", data[off:off + 10])
off += 10
rdata = data[off:off + rdlen]
off += rdlen
if rtype == 1 and rdlen == 4 and rname == name:
out.append(socket.inet_ntoa(rdata))
except (struct.error, ValueError):
pass
return out
def resolve_mdns(hostname, timeout=2.0, tries=3):
"""The IPv4 address of <hostname>.local, asked of the network
directly; None when nothing answers."""
name = hostname if hostname.endswith(".local") else hostname + ".local"
query = mdns_query(name)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 1)
# On the mDNS port and in the group, so a responder that answers
# the group rather than the asker is heard too; a port already
# taken (a resolver daemon) means that daemon would have served
# the name, so the ephemeral port and unicast replies will do.
try:
sock.bind(("", MDNS_PORT))
sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP,
socket.inet_aton(MDNS_GROUP) + socket.inet_aton("0.0.0.0"))
except OSError:
sock.bind(("", 0))
for _ in range(tries):
sock.sendto(query, (MDNS_GROUP, MDNS_PORT))
deadline = time.time() + timeout
while True:
left = deadline - time.time()
if left <= 0:
break
sock.settimeout(left)
try:
data, _peer = sock.recvfrom(2048)
except socket.timeout:
break
except OSError:
break
for ip in mdns_answers(data, name):
return ip
return None
finally:
sock.close()
# -------------------------------------------------------------- client
def load_config(path=None):
"""The bench's fixture config, or None when there is none (a bench
without a fixture: the runner behaves exactly as before)."""
path = path or FIXTURE_CONFIG
try:
with open(path, "r", encoding="utf-8") as f:
cfg = json.load(f)
except OSError:
return None
except ValueError as e:
raise FixtureError("%s: not JSON: %s" % (path, e))
if not isinstance(cfg, dict) or not cfg.get("key"):
raise FixtureError("%s: needs at least a key" % path)
cfg.setdefault("hostname", "forgefixture")
cfg.setdefault("ip", None)
cfg.setdefault("port", 80)
chans = cfg.get("channels")
if chans is None:
chans = list(CHANNELS)
bad = [c for c in chans if c not in CHANNELS]
if bad:
raise FixtureError("%s: unknown channel(s) %s" % (path, ", ".join(bad)))
cfg["channels"] = list(chans)
cfg["arm_press"] = bool(cfg.get("arm_press", False))
return cfg
class Fixture:
"""The actuator as the runner sees it: which channels it covers, an
action per channel, a release, its state. Every request carries the
key; the address comes from the config's ip or the mDNS lookup,
re-resolved when a request fails to connect."""
def __init__(self, cfg, timeout=3.0, resolver=resolve_mdns):
self.hostname = cfg["hostname"]
self.ip_override = cfg.get("ip") or None
self.port = int(cfg.get("port") or 80)
self.key = cfg["key"]
self.channels = tuple(cfg["channels"])
self.arm_press = bool(cfg.get("arm_press", False))
self.timeout = timeout
self._resolver = resolver
self._ip = self.ip_override
self._resolved_at = time.time() if self.ip_override else 0.0
self._lock = threading.Lock()
self.last_state = None
# -- address -------------------------------------------------------
def address(self, refresh=False):
with self._lock:
if self.ip_override:
return self.ip_override
stale = time.time() - self._resolved_at > RESOLVE_TTL_S
if self._ip is None or refresh or stale:
ip = self._resolver(self.hostname)
if ip is None:
raise FixtureError("%s.local did not answer the mDNS lookup (set ip in the config "
"to skip it)" % self.hostname)
self._ip = ip
self._resolved_at = time.time()
return self._ip
def where(self):
return "%s (%s)" % (self.hostname, self._ip or "unresolved")
# -- requests ------------------------------------------------------
def _request(self, method, path, body=None, retry=True):
ip = self.address()
data = json.dumps(body).encode() if body is not None else None
host = ip if self.port == 80 else "%s:%d" % (ip, self.port)
req = urllib.request.Request("http://%s%s" % (host, path), data=data, method=method,
headers={"X-Fixture-Key": self.key, "Host": host,
"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
return resp.status, json.loads(resp.read().decode("utf-8", "replace") or "{}")
except urllib.error.HTTPError as e:
try:
body = json.loads(e.read().decode("utf-8", "replace") or "{}")
except ValueError:
body = {}
return e.code, body
except (urllib.error.URLError, socket.timeout, OSError) as e:
if retry and not self.ip_override:
# the address may have moved: one fresh lookup, one retry
self.address(refresh=True)
return self._request(method, path, body, retry=False)
raise FixtureError("fixture %s: %s %s: %s" % (self.where(), method, path, e))
def status(self):
st, body = self._request("GET", "/")
if st == 401:
raise FixtureError("fixture %s refused the key" % self.where())
if st != 200 or body.get("device") != "forgefixture":
raise FixtureError("fixture %s: unexpected answer %s %s" % (self.where(), st, body))
self.last_state = body
return body
def covers(self, channel):
"""The runner asks this per action. The button needs the jumper
in, which the fixture reports; a channel it does not cover falls
back to the operator."""
if channel not in self.channels:
return False
if channel == "button":
st = self.last_state
return bool(st and st.get("button_enabled"))
return True
def act(self, channel, state):
if channel == "button":
if state != "press":
raise FixtureError("the button is only ever pressed")
st, body = self._request("POST", "/button", {})
else:
if state not in ("open", "close"):
raise FixtureError("%s: unknown state %r" % (channel, state))
st, body = self._request("POST", "/" + channel, {"state": state})
if st != 200:
raise FixtureError("fixture %s: %s %s -> %s %s" % (self.where(), channel, state, st,
body.get("error") or body))
self.last_state = body
return body
def release(self):
st, body = self._request("POST", "/release", {})
if st != 200:
raise FixtureError("fixture %s: release -> %s %s" % (self.where(), st, body.get("error") or body))
self.last_state = body
return body
@staticmethod
def energized(state):
"""The channels a state report shows energized (a loop open, the
button pressed)."""
ch = (state or {}).get("channels") or {}
return [c for c, s in ch.items() if s in ("open", "pressed")]
def summary(self):
st = self.last_state or {}
return {"hostname": self.hostname, "ip": self._ip, "channels": list(self.channels),
"button_enabled": bool(st.get("button_enabled")), "arm_press": self.arm_press,
"version": st.get("version"), "uptime_s": st.get("uptime_s")}
def probe(log, path=None, resolver=resolve_mdns):
"""The bench's fixture, up and answering, or None: no config means
no fixture; a config whose fixture does not answer is logged and
treated the same, so the run goes to the operator."""
try:
cfg = load_config(path)
except FixtureError as e:
log("fixture: %s - running without it" % e)
return None
if cfg is None:
return None
fx = Fixture(cfg, resolver=resolver)
try:
st = fx.status()
except FixtureError as e:
log("fixture: %s - running without it" % e)
return None
chans = ", ".join(fx.channels)
log("fixture up: %s at %s, v%s, covers %s%s" % (
fx.hostname, fx._ip, st.get("version"), chans,
"" if st.get("button_enabled") or "button" not in fx.channels
else " (button disabled: the enable jumper is out)"))
return fx
__all__ = ["Fixture", "FixtureError", "CHANNELS", "FIXTURE_CONFIG", "load_config", "probe",
"resolve_mdns", "mdns_query", "mdns_answers"]
+11 -2
View File
@@ -134,10 +134,11 @@ pre#log{background:#1d1e26;color:#d7dae0;font-family:ui-monospace,Consolas,monos
<button class='pri' id='q-attended' onclick='startBatch("attended")'>Operator and live</button>
<button class='danger' id='q-stop' onclick='stopBatch()' disabled>Stop the queue</button>
</div>
<div id='qstate'></div><div id='qmsg'></div>
<div id='qstate'></div><div id='qmsg'></div><div id='fixture' class='hint'></div>
<p class='hint'>Each queue takes every test of its kind that the campaign does not
already count as satisfied, runs them one at a time in prerequisite order, and stops
on the first result that is not a PASS. The unattended queue needs nobody in the room.
on the first result that is not a PASS. The unattended queue needs nobody in the room;
with the bench fixture up, the operator tests it can perform by itself run there too.
The other one does: it prompts, and it fires the laser. Stop-the-queue cancels what is
still waiting and lets the run in progress finish; Abort ends that one too.</p>
</div>
@@ -259,7 +260,15 @@ function render(){if(!state||!catalog)return;
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. */
var QUEUES=[['unattended','Unattended'],['attended','Operator and live']];
function renderFixture(){var f=state.fixture,e=$('fixture');if(!e)return;
if(!f){setHtml(e,'');return}
var ch=(f.channels||[]).filter(function(c){return c!=='button'||f.button_enabled});
setHtml(e,'Bench fixture <b>'+esc(f.hostname)+'</b> at '+esc(f.ip||'?')+' (v'+esc(f.version||'?')+') covers <b>'+
(ch.length?esc(ch.join(', ')):'nothing')+'</b>'+
((f.channels||[]).indexOf('button')>=0&&!f.button_enabled?' (button disabled: enable jumper out)':'')+
'; arm press: '+(f.arm_press?'<b>the fixture</b>':'the operator'))}
function renderQueue(){var av=state.batch_available||{},b=state.batch,busy=isBusy();
renderFixture();
QUEUES.forEach(function(p){var e=$('q-'+p[0]);if(!e)return;
var ids=av[p[0]]||[];
setText(e,ids.length?(p[1]+' ('+ids.length+')'):(p[1]+' (none left)'));
+124 -4
View File
@@ -49,6 +49,7 @@ import traceback
from . import artifact as _artifact
from . import baseline as _baseline
from . import campaign as _campaign
from . import fixture as _fixture
from . import catalog as _catalog
from . import hw
from .log import now_ts, data_dir
@@ -65,6 +66,11 @@ BATCH_GROUPS = {
"attended": ("operator", "live"),
}
# A bench actuator (fixture.py) moves an operator test whose actions it
# covers into the unattended queue. The probe that decides is one GET;
# it is repeated at most this often, and before every run.
FIXTURE_PROBE_S = 30.0
class Aborted(Exception):
pass
@@ -124,6 +130,7 @@ class Run:
self.prompt = None # {"id","question","options"}
self.notice = None # {"id","text"}: a standing instruction, no button
self.answers = []
self.unattended = False # a fixture-run test: no prompt can be answered
self.evidence = {}
self.baseline_captured = None # preserved state the post pass hands back
self.aborted = threading.Event()
@@ -156,6 +163,9 @@ class Run:
# -- prompt channel -----------------------------------------------
def ask(self, question, options):
if self.unattended:
raise Failed("the test asked a person (%r) while running unattended with the fixture: "
"declare the step in hands=... so the test stays in the attended queue" % question)
with self._cv:
self._prompt_seq += 1
pid = "p%d" % self._prompt_seq
@@ -258,7 +268,12 @@ class Context:
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."""
the test then starts the thing and watches the machine. With the
fixture performing the step (the run is unattended) there is
nobody to announce it to: the gate passes at once, logged."""
if self.run.unattended:
self.log("READY (fixture performs the step): %s", text)
return
ans = self.prompt(text, ("Ready", "Cannot"))
if ans != "Ready":
raise Failed("operator could not: %s" % text)
@@ -320,7 +335,15 @@ class Context:
if fixture is not None and fixture.covers(channel):
self.log("ACT %s %s (fixture)", channel, state)
rec["by"] = "fixture"
fixture.act(channel, state)
try:
fixture.act(channel, state)
except _fixture.FixtureError as e:
# the box did not do it: the operator is asked instead,
# and the record says so
self.log("ACT %s %s: fixture failed (%s) - asking the operator", channel, state, e)
rec["by"] = "operator"
rec["fixture_error"] = str(e)
self.notice(wording)
else:
self.notice(wording)
try:
@@ -337,6 +360,43 @@ class Context:
self.log("ACT %s %s: done after %.1f s", channel, state, dt)
return dt
def arm_press(self, text="The button lights white: press it to arm. The machine fires after your press."):
"""The arm cue of a live test. A person's press by default: a
standing notice until the caller clears it. The fixture presses
only where the bench opted in (arm_press in its config) and its
button channel is enabled: a thread waits for the button to light
(the job may still be on its way to the arm wait) and presses
once, recorded as the fixture's; if the button never lights or
the press fails, the notice goes up for a person. Returns True
when the fixture has been asked."""
fixture = getattr(self.runner, "fixture", None) if self.runner is not None else None
rec = {"channel": "button", "state": "arm", "by": "operator", "ts": now_ts()}
self.evidence.setdefault("actions", []).append(rec)
if not (fixture is not None and fixture.arm_press and fixture.covers("button")):
self.notice(text)
return False
self.log("ARM: the fixture presses when the button lights (the bench's arm_press opt-in)")
def press():
lit = self.wait_for(hw.button_lit, 60)
if lit is None:
self.log("ARM: the button never lit within 60 s - asking the operator")
self.notice(text)
return
try:
fixture.act("button", "press")
except _fixture.FixtureError as e:
self.log("ARM press: fixture failed (%s) - asking the operator", e)
rec["fixture_error"] = str(e)
self.notice(text)
return
rec["by"] = "fixture"
rec["took_s"] = round(lit, 2)
self.log("ARM press by the fixture, button lit after %.1f s", lit)
threading.Thread(target=press, daemon=True, name="forgetest-arm-press").start()
return True
# -- hardware ------------------------------------------------------
@property
def forgectrl(self):
@@ -473,7 +533,8 @@ class Runner:
self.current = None
self.last = None
self.batch = None
self.fixture = None # the bench actuator, when one is configured (none yet)
self.fixture = None # the bench actuator, when one is up (fixture.py)
self._fixture_probed = 0.0
self.boot_ref = None
self.recover()
threading.Thread(target=self._take_boot_reference, daemon=True,
@@ -492,6 +553,47 @@ class Runner:
if r is not None and not r.finished:
r.log(msg)
# -- the bench actuator ------------------------------------------------
def probe_fixture(self, force=False):
"""The fixture, up and answering, or None; re-probed at most every
FIXTURE_PROBE_S unless forced (before a run). A probe that finds
it gone, or a config that appeared, changes the queues' routing
from then on."""
now = time.time()
if not force and now - self._fixture_probed < FIXTURE_PROBE_S:
return self.fixture
self._fixture_probed = now
had = self.fixture
fx = _fixture.probe(self._note if had is None else (lambda m: None))
if fx is None and had is not None:
self._note("fixture: %s no longer answers - running without it" % had.hostname)
self.fixture = fx
return fx
def fixture_channels(self):
"""The channels the fixture covers right now (the button only
with its enable jumper in)."""
fx = self.probe_fixture()
if fx is None:
return ()
return tuple(c for c in fx.channels if fx.covers(c))
def fixture_release(self, run):
"""After a run: any channel the fixture still holds energized is
released and recorded, like any other leftover."""
fx = self.fixture
if fx is None:
return
try:
held = fx.energized(fx.status())
if held:
fx.release()
run.log("fixture: released %s left energized" % ", ".join(held))
run.evidence.setdefault("fixture", {})["released"] = held
except _fixture.FixtureError as e:
run.log("fixture: release check failed: %s" % e)
run.evidence.setdefault("fixture", {})["release_error"] = str(e)
# -- startup recovery ------------------------------------------------
def recover(self):
m = marker_path()
@@ -532,6 +634,8 @@ class Runner:
# what each queue would run if started now, so the page can label
# its buttons with the work rather than a bare verb
st["batch_available"] = {g: self.batch_selection(g, st) for g in BATCH_GROUPS}
fx = self.fixture
st["fixture"] = fx.summary() if fx is not None else None
return st, records
def busy(self):
@@ -605,6 +709,16 @@ class Runner:
self.last = self.current
self.current = run
run.log("start %s (%s, %s) in campaign %s" % (t.id, t.kind, t.hardware, campaign["id"]))
fx = self.probe_fixture(force=True)
if fx is not None:
run.log("fixture: %s at %s covers %s" % (fx.hostname, fx._ip, ", ".join(self.fixture_channels())))
run.evidence["fixture"] = {"hostname": fx.hostname, "ip": fx._ip,
"channels": list(self.fixture_channels())}
# nobody is expected in the room for a test the fixture runs
# in the unattended queue: a prompt there is a defect, not a wait
if batch is not None and batch.get("group") == "unattended" and t.kind != "auto":
run.unattended = True
run.log("fixture: running unattended (no prompt can be answered)")
if missing:
run.log("prerequisites overridden by the operator - not satisfied: %s" % ", ".join(missing))
run.evidence["prerequisites"] = {"overridden": True, "missing": missing, "ts": now_ts()}
@@ -633,8 +747,13 @@ class Runner:
if state is None:
state, _ = self.state()
tests = self.tests()
# an operator test the fixture can run alone goes to the
# unattended queue and leaves the attended one
channels = self.fixture_channels()
routed = set(t.id for t in tests if channels and t.fixture_runnable(channels))
want = [t.id for t in tests
if t.kind in kinds and not state["tests"][t.id]["satisfied"]]
if ((t.kind in kinds and t.id not in routed) or (group == "unattended" and t.id in routed))
and not state["tests"][t.id]["satisfied"]]
return _catalog.order_by_requires(tests, want)
def start_batch(self, group, ack_live=False, ignore_requires=False):
@@ -775,6 +894,7 @@ class Runner:
except Exception as e: # noqa: BLE001 - an erroring test is a failed test
result, message = _campaign.ERROR, "%s: %s" % (type(e).__name__, e)
run.log(traceback.format_exc().rstrip())
self.fixture_release(run)
try:
self._baseline_post(run, captured)
except Exception as e: # noqa: BLE001 - never lose the result over the cleanup
+1 -1
View File
@@ -385,7 +385,7 @@ def mode_switch(ctx):
"machine's identity",
subsystem="cloud", kind="operator", est_min=5,
covers=_SERVICE_LAYER,
requires=["forgectrl.auth"],
requires=["forgectrl.auth"], hands=["app"],
steps=["Cloud credentials configured; the machine on the network; the app open in a browser "
"(anyone can drive it, at the machine or not: nothing here moves, arms, or fires).",
"When told, set up any small job in the app and press Print; the emulator runs it at "
+2 -2
View File
@@ -153,7 +153,7 @@ def arm_and_fire(ctx, g, room="40 mm +X and +Y", job=None, timeout=240):
button was never pressed."""
ctx.ready(ARM_CUE % room)
stream(g, job or MARK_JOB)
ctx.notice("The button lights white: press it to arm. The machine fires after your press.")
ctx.arm_press()
t0 = time.time()
while time.time() - t0 < timeout:
ctx.checkpoint()
@@ -355,7 +355,7 @@ def emission_witness(ctx):
job = ["G91", "G21", "M4", "S400",
"G1 X20 F600", "G1 Y20 F600", "G1 X-20 F600", "G1 Y-20 F600",
"M5", "G90", "M2"]
ctx.notice("The button lights white: press it to arm. The machine fires after your press.")
ctx.arm_press()
try:
samples = run_and_sample(ctx, g, job)
finally: