mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-28 09:11:11 -07:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -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)'));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user