mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-28 09:11:11 -07:00
commissioning: the layer, the acceptance tests, the harness rule, the docs, and the bench drills
meta-forgefirm: the forgefirm-users init replays the account at boot; sshd refuses root and empty passwords and runs only while the panel turns it on; the release image keeps an empty root password for the console; the console banner; avahi announces forgefirm.local; https in libmicrohttpd and ulfius; the panel on 80 and 443; the license bundle on the rootfs; release.sh checks the root policy on the built rootfs. forgetest: the commission suites (commission, commission_dark, commission_sheet: 23 cases); the runner turns cloud mode on with the typed phrase for a test that declares it; the baseline's motor_lock is 0; the log-export test checks the bundle for the camera key; the record helpers write bytes as given and join the daemon's paths as POSIX. The stream harness gains rule 24: a hold verdict is held again after a resume. Bench drills: lens_travel.py and lens_stop_accel.py. Docs: BRINGUP carries the present state; CAMPAIGN-LOG carries the dated record.
This commit is contained in:
@@ -44,12 +44,14 @@ from .log import now_ts
|
||||
# GRBL-mode resting values (kernel attribute -> value as read back), as a
|
||||
# fresh boot of the dev image leaves them (2026-08-16 bench dump).
|
||||
# motor_lock/x_mode/y_mode/x_decay/y_decay and the hold currents are the
|
||||
# GRBL controller's init writes (glowforge_io.c), step_freq its default
|
||||
# machine tick, ramp_rate the module default; streaming is only ever 1
|
||||
# inside a live job; the head white LED is a camera lamp, off at idle; the
|
||||
# loop heater and TEC are the diagnostics' tools, off at idle.
|
||||
# GRBL controller's init writes (glowforge_io.c): motor_lock 0, every axis
|
||||
# in the pulse path (a job's Z moves the lens; the driver's Z soft limit
|
||||
# guards it), step_freq its default machine tick, ramp_rate the module
|
||||
# default; streaming is only ever 1 inside a live job; the head white LED
|
||||
# is a camera lamp, off at idle; the loop heater and TEC are the
|
||||
# diagnostics' tools, off at idle.
|
||||
FIXED_SYSFS = [
|
||||
("cnc/motor_lock", "8"),
|
||||
("cnc/motor_lock", "0"),
|
||||
("cnc/x_mode", "8"),
|
||||
("cnc/y_mode", "8"),
|
||||
("cnc/x_decay", "1"),
|
||||
@@ -229,8 +231,10 @@ class Baseline:
|
||||
def wait_settled(self, timeout=SETTLE_S, unreachable_s=10):
|
||||
"""Block until forgectrl reports a settled supervisor: motion
|
||||
verified (the probe passed), motion-fault (the ladder exhausted),
|
||||
or standby (the manual stop lever). Gives up after unreachable_s
|
||||
without an answer. Returns the last /mode body (None if unreachable)."""
|
||||
standby (the manual stop lever), or gated (the commissioning gate
|
||||
is closed: no controller spawns until it opens). Gives up after
|
||||
unreachable_s without an answer. Returns the last /mode body (None
|
||||
if unreachable)."""
|
||||
t0 = time.time()
|
||||
deadline = t0 + timeout
|
||||
last = seen = heard = None
|
||||
@@ -252,7 +256,7 @@ class Baseline:
|
||||
seen = key
|
||||
self.log("/mode controller=%s motion=%s" % key)
|
||||
ctl = body.get("controller")
|
||||
if ctl in ("motion-fault", "standby") or (ctl == "running" and body.get("motion") == "verified"):
|
||||
if ctl in ("motion-fault", "standby", "gated") or (ctl == "running" and body.get("motion") == "verified"):
|
||||
if ctl == "motion-fault":
|
||||
self.log("WARNING - motion liveness ladder failed, controllers are "
|
||||
"down (motion-fault); retry via POST /mode")
|
||||
@@ -304,6 +308,20 @@ class Baseline:
|
||||
return True, "already in %s mode" % want
|
||||
self.log("switching to %s mode (found %s, controller %s)"
|
||||
% (want, mode.get("mode"), mode.get("controller")))
|
||||
if want == "cloud":
|
||||
# Cloud mode exists only while cloud_enabled is 1. A test that
|
||||
# needs it gets it turned on here, with a line in the log; it
|
||||
# stays on afterward, as a cloud job the owner ran would leave it.
|
||||
st, settings = self.fc_get("/settings")
|
||||
if st == 200 and isinstance(settings, dict) and settings.get("cloud_enabled") != "1":
|
||||
# the typed phrase the cloud step asks for: the runner
|
||||
# gives it under the operator's rule for cloud tests
|
||||
st, body = self.fc_post("/settings", data={"cloud_enabled": "1",
|
||||
"phrase": "I UNDERSTAND"})
|
||||
if st != 200:
|
||||
return False, "cloud_enabled=1 for the test -> %s %s" % (st, body)
|
||||
self.log("cloud mode turned on for the test (cloud_enabled was %r)"
|
||||
% (settings.get("cloud_enabled") or ""))
|
||||
self.nohunt_on(want)
|
||||
st, body = self.fc_post("/mode", data={"controller": want})
|
||||
if st != 200:
|
||||
@@ -697,19 +715,20 @@ def check_fixed_against(ref, log):
|
||||
# The attributes the GRBL controller writes at its own start (its analog
|
||||
# config + machine tick): once they read the fixed values the controller
|
||||
# has configured the machine. Before that the kernel shows the supervisor's
|
||||
# motion-probe leftovers (motor_lock 0, step_freq 10000, y_mode at the
|
||||
# module default) - the state /mode already calls "running", because
|
||||
# "running" is the spawn, not the config.
|
||||
CONFIGURED_MARKERS = [(a, dict(FIXED_SYSFS)[a]) for a in ("cnc/step_freq", "cnc/motor_lock", "cnc/y_mode")]
|
||||
# motion-probe leftovers (step_freq 10000, y_mode at the module default) -
|
||||
# the state /mode already calls "running", because "running" is the spawn,
|
||||
# not the config. motor_lock is no marker: the probe and the controller
|
||||
# both leave it 0.
|
||||
CONFIGURED_MARKERS = [(a, dict(FIXED_SYSFS)[a]) for a in ("cnc/step_freq", "cnc/y_mode")]
|
||||
CONFIGURED_TIMEOUT_S = 20
|
||||
CONFIGURED_SETTLE_S = 1.0
|
||||
|
||||
|
||||
def reference_preconfig(ref):
|
||||
"""True when a saved reference shows the pre-controller state: every
|
||||
marker present differs from its fixed value (the probe's step_freq /
|
||||
motor_lock and the module's y_mode together), i.e. it was dumped
|
||||
before the controller's init writes landed."""
|
||||
marker present differs from its fixed value (the probe's step_freq and
|
||||
the module's y_mode together), i.e. it was dumped before the
|
||||
controller's init writes landed."""
|
||||
sysfs = (ref or {}).get("sysfs") or {}
|
||||
seen = [(sysfs.get(a), want) for a, want in CONFIGURED_MARKERS if sysfs.get(a) is not None]
|
||||
return bool(seen) and all(got != want for got, want in seen)
|
||||
|
||||
@@ -3,7 +3,7 @@ kernel module's sysfs, the init scripts, and the Grbl TCP port.
|
||||
|
||||
Everything is reachable through environment overrides so the suite can be
|
||||
exercised against a mock on a host:
|
||||
FORGECTRL_URL default http://127.0.0.1:8080
|
||||
FORGECTRL_URL default http://127.0.0.1
|
||||
FORGECTRL_TOKEN_FILE default /data/forgefirm/panel.token
|
||||
GF_SYSFS_ROOT default /sys/glowforge/ (must end with '/')
|
||||
GRBL_HOST / GRBL_PORT default 127.0.0.1 / 23
|
||||
@@ -41,7 +41,7 @@ class Forgectrl:
|
||||
"""Thin client for the machine-services daemon."""
|
||||
|
||||
def __init__(self, base=None, token=None, timeout=10.0, slow_timeout=SLOW_TIMEOUT_S):
|
||||
self.base = (base or os.environ.get("FORGECTRL_URL") or "http://127.0.0.1:8080").rstrip("/")
|
||||
self.base = (base or os.environ.get("FORGECTRL_URL") or "http://127.0.0.1").rstrip("/")
|
||||
self.timeout = timeout
|
||||
self.slow_timeout = slow_timeout
|
||||
self._token = token
|
||||
|
||||
@@ -424,15 +424,17 @@ 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."):
|
||||
def arm_press(self, text="The button lights white: press it to arm. The machine fires after your press.",
|
||||
lit_timeout=60):
|
||||
"""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."""
|
||||
button channel is enabled: a thread waits up to `lit_timeout`
|
||||
seconds for the button to light (the job may still be on its way
|
||||
to the arm wait; a card that settles the coolant first takes
|
||||
minutes) 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)
|
||||
@@ -452,9 +454,9 @@ class Context:
|
||||
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)
|
||||
lit = self.wait_for(hw.button_lit, lit_timeout)
|
||||
if lit is None:
|
||||
self.log("ARM: the button never lit within 60 s - asking the operator")
|
||||
self.log("ARM: the button never lit within %d s - asking the operator", lit_timeout)
|
||||
self.notice(text)
|
||||
return
|
||||
try:
|
||||
|
||||
@@ -4,6 +4,9 @@ order. Each module registers its tests with @catalog.test."""
|
||||
from . import image # noqa: F401,E402
|
||||
from . import kernel # noqa: F401,E402
|
||||
from . import forgectrl # noqa: F401,E402
|
||||
from . import commission # noqa: F401,E402
|
||||
from . import commission_dark # noqa: F401,E402
|
||||
from . import commission_sheet # noqa: F401,E402
|
||||
from . import logs # noqa: F401,E402
|
||||
from . import motion # noqa: F401,E402
|
||||
from . import cooling # noqa: F401,E402
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"""camera.* - the lid camera pipeline through forgectrl."""
|
||||
import json
|
||||
|
||||
from ..catalog import test
|
||||
from .. import hw
|
||||
from ..baseline import LID_LAMP_ATTR
|
||||
@@ -380,3 +382,66 @@ def lid_privacy(ctx):
|
||||
ctx.check(st == 200 and data[:2] == b"\xff\xd8",
|
||||
"snapshot after closing the lid -> %s", st)
|
||||
ctx.log("lid closed again: snapshot %d bytes", len(data))
|
||||
|
||||
|
||||
@test("camera.key-read", title="The camera key reads without a login", subsystem="camera",
|
||||
kind="auto", est_min=1,
|
||||
covers=[("forgectrl", "src/camkey.*"), ("forgectrl", "src/auth.c"),
|
||||
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/panel.js"),
|
||||
("forgectrl", "src/ui/index.html"), ("forgectrl", "src/ui/help.js")],
|
||||
requires=["forgectrl.auth"],
|
||||
description="With the read routes closed to the network (panel_open_reads=0), a camera "
|
||||
"status read from the board's LAN address is refused without the key and "
|
||||
"served with it, on HTTP and on HTTPS; a wrong key is refused; the key never "
|
||||
"authorizes a write; a rotation from the panel route stops the old key and "
|
||||
"the new one reads. The setting is put back after.")
|
||||
def key_read(ctx):
|
||||
from .commission import request
|
||||
from .forgectrl import lan_ip
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
ip = lan_ip()
|
||||
ctx.check(ip, "cannot determine the board's LAN address")
|
||||
http_base = "http://%s" % ip
|
||||
tls_base = "https://%s" % ip
|
||||
token = {"X-ForgeFIRM-Token": fc.token}
|
||||
|
||||
st, body, _ = request(fc.base, "GET", "/system/camera-key", headers=token)
|
||||
ctx.check(st == 200, "GET /system/camera-key from loopback -> %s", st)
|
||||
key = json.loads(body)["key"]
|
||||
ctx.check(len(key) == 32, "the key is not 32 characters: %r", key)
|
||||
ev["key_len"] = len(key)
|
||||
|
||||
before = fc.settings().get("panel_open_reads", "")
|
||||
st, _ = fc.post("/settings", params={"panel_open_reads": "0"})
|
||||
ctx.check(st == 200, "closing the reads -> %s", st)
|
||||
try:
|
||||
for base in (http_base, tls_base):
|
||||
st, _, _ = request(base, "GET", "/cam/status")
|
||||
ctx.log("GET %s/cam/status (no key) -> %s", base, st)
|
||||
ctx.check(st == 403, "a closed read without the key -> %s, expected 403", st)
|
||||
st, _, _ = request(base, "GET", "/cam/status?key=" + key)
|
||||
ctx.log("GET %s/cam/status?key=... -> %s", base, st)
|
||||
ctx.check(st == 200, "a read with the key -> %s, expected 200", st)
|
||||
st, _, _ = request(base, "GET", "/cam/status", headers={"X-ForgeFIRM-Camera-Key": key})
|
||||
ctx.check(st == 200, "a read with the key header -> %s, expected 200", st)
|
||||
wrong = ("0" if key[0] != "0" else "1") + key[1:]
|
||||
st, _, _ = request(base, "GET", "/cam/status?key=" + wrong)
|
||||
ctx.check(st == 403, "a wrong key -> %s, expected 403", st)
|
||||
# the key is a read credential only
|
||||
st, _, _ = request(tls_base, "POST", "/settings", data={"panel_open_reads": "0"},
|
||||
headers={"X-ForgeFIRM-Camera-Key": key})
|
||||
ctx.log("POST /settings with the camera key -> %s", st)
|
||||
ctx.check(st == 403, "the camera key authorized a write (%s)", st)
|
||||
# rotation
|
||||
st, body, _ = request(fc.base, "POST", "/system/camera-key?rotate=1", headers=token)
|
||||
ctx.check(st == 200, "rotate -> %s", st)
|
||||
new = json.loads(body)["key"]
|
||||
ctx.check(new != key, "rotation kept the same key")
|
||||
st, _, _ = request(http_base, "GET", "/cam/status?key=" + key)
|
||||
ctx.check(st == 403, "the old key still reads after rotation (%s)", st)
|
||||
st, _, _ = request(http_base, "GET", "/cam/status?key=" + new)
|
||||
ctx.check(st == 200, "the new key does not read (%s)", st)
|
||||
ev["rotated"] = True
|
||||
finally:
|
||||
fc.post("/settings", params={"panel_open_reads": before})
|
||||
|
||||
@@ -165,17 +165,6 @@ GFHOME_LOG = "/data/log/forgefirm/gfhome/gfhome.log"
|
||||
HOMING_TIMEOUT_S = 600
|
||||
|
||||
|
||||
def homing_mode_is_gfcloud():
|
||||
"""Precheck: the web-service homing needs homing_mode = gfcloud."""
|
||||
try:
|
||||
hm = (hw.Forgectrl().settings() or {}).get("homing_mode")
|
||||
except hw.HwError as e:
|
||||
return "forgectrl unreachable: %s" % e
|
||||
if hm != "gfcloud":
|
||||
return "homing_mode is %r; the web-service homing needs gfcloud" % (hm,)
|
||||
return None
|
||||
|
||||
|
||||
def judge_hunt_with_lid_open(ctx, ev, offset):
|
||||
"""The connect-time hunt from `offset` on: its terminal line is
|
||||
:completed, nothing before it was refused for the lid, and the lens
|
||||
@@ -246,9 +235,9 @@ def gfhome_homing(ctx, ev, g):
|
||||
covers=_HOMING_PATH + [("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.",
|
||||
steps=["Bed clear; cloud credentials configured; the machine on the network. The test "
|
||||
"turns cloud mode and the gfcloud homing on itself when they are off, and puts "
|
||||
"the settings back at the end.",
|
||||
"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."],
|
||||
@@ -374,9 +363,17 @@ def mode_switch(ctx):
|
||||
|
||||
# -- the web-service homing from grbl mode --------------------------------
|
||||
ev["homing_mode"] = (fc.settings() or {}).get("homing_mode")
|
||||
ctx.check(ev["homing_mode"] == "gfcloud", "homing_mode is %r; $H needs gfcloud", ev["homing_mode"])
|
||||
with ctx.grbl() as g:
|
||||
gfhome_homing(ctx, ev, g)
|
||||
if ev["homing_mode"] != "gfcloud":
|
||||
st, body = fc.post("/settings", data={"homing_mode": "gfcloud"})
|
||||
ctx.log("homing_mode=gfcloud for the homing -> %s %s", st, body if isinstance(body, str) else "")
|
||||
ctx.check(st == 200, "homing_mode=gfcloud -> %s %s", st, body)
|
||||
try:
|
||||
with ctx.grbl() as g:
|
||||
gfhome_homing(ctx, ev, g)
|
||||
finally:
|
||||
if ev["homing_mode"] != "gfcloud":
|
||||
st, body = fc.post("/settings", data={"homing_mode": ev["homing_mode"] or "none"})
|
||||
ctx.log("restore homing_mode=%r -> %s", ev["homing_mode"], st)
|
||||
ctx.log("PASS: grbl -> cloud (session, hunt with the lid open, lens homed, airflow unjudged) -> "
|
||||
"grbl (port open, %s), then $H homed in %.1f s", ev["grbl_state"], ev["homing_s"])
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,327 @@
|
||||
"""commission.check-* - the setup's checks (the dark wizards), driven the
|
||||
way the page drives them: POST /wiz/<id>/start, GET /wiz/dark polled,
|
||||
the prompts answered from here (the bench fixture works the lid and the
|
||||
button; a confirmation is answered yes once the snapshot exists), the
|
||||
result judged, and every setting a check wrote put back as found. Each
|
||||
check re-runs on the real record: a completed check completes again at
|
||||
the same version, so the record reads as before.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from ..catalog import test
|
||||
from .. import hw
|
||||
from ..baseline import read_position
|
||||
from .commission import wiz, Restore # noqa: F401 - Restore is re-exported for the sheet
|
||||
|
||||
POLL_S = 1.0
|
||||
DARK_COVERS = [("forgectrl", "src/wizdark.*"), ("forgectrl", "src/wizcalc.*"),
|
||||
("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"),
|
||||
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*")]
|
||||
|
||||
|
||||
def dark(fc):
|
||||
st, body = fc.get("/wiz/dark")
|
||||
if st != 200 or not isinstance(body, dict):
|
||||
raise hw.HwError("GET /wiz/dark -> %s" % st)
|
||||
return body
|
||||
|
||||
|
||||
def start(ctx, fc, wid):
|
||||
ev = ctx.evidence
|
||||
st, body = fc.post("/wiz/%s/start" % wid)
|
||||
if st == 409 and dark(fc).get("id") == wid and dark(fc).get("running"):
|
||||
# The same check left running by an earlier run of this test (an
|
||||
# abort mid-wait): this run owns the machine, so it ends that one
|
||||
# and starts its own.
|
||||
ctx.log("the %s check is still running from an earlier run: aborting it", wid)
|
||||
fc.post("/wiz/%s/abort" % wid)
|
||||
ctx.wait_for(lambda: not dark(fc).get("running"), 30)
|
||||
st, body = fc.post("/wiz/%s/start" % wid)
|
||||
ctx.log("POST /wiz/%s/start -> %s %s", wid, st, body if isinstance(body, dict) else "")
|
||||
ctx.check(st == 200, "the %s check did not start (%s %s)", wid, st, body)
|
||||
ev["started"] = st == 200
|
||||
|
||||
|
||||
def answer(fc, wid, prompt, value):
|
||||
return fc.post("/wiz/%s/answer" % wid, data={"seq": str(prompt["seq"]), "value": value})
|
||||
|
||||
|
||||
def run_check(ctx, wid, on_prompt, timeout_s):
|
||||
"""Start `wid` and follow it to its end. on_prompt(prompt) is called
|
||||
once per prompt (by sequence number) and returns the value to answer
|
||||
with, or None when it handled the prompt another way (a fixture
|
||||
action). Returns the final status document."""
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
start(ctx, fc, wid)
|
||||
t0 = time.time()
|
||||
seen = set()
|
||||
phases = []
|
||||
last = None
|
||||
try:
|
||||
while time.time() - t0 < timeout_s:
|
||||
ctx.checkpoint()
|
||||
d = dark(fc)
|
||||
if d.get("id") != wid:
|
||||
raise hw.HwError("another check took the slot: %s" % d.get("id"))
|
||||
if d.get("phase") and (not phases or phases[-1] != d["phase"]):
|
||||
phases.append(d["phase"])
|
||||
ctx.log("%s: %s", wid, d["phase"])
|
||||
p = d.get("prompt")
|
||||
if p and p.get("seq") not in seen:
|
||||
seen.add(p["seq"])
|
||||
ctx.log("prompt %s (%s): %s", p.get("id"), p.get("kind"), p.get("text"))
|
||||
value = on_prompt(p)
|
||||
if value is not None:
|
||||
st, body = answer(fc, wid, p, value)
|
||||
ctx.check(st == 200, "the answer to %s -> %s %s", p.get("id"), st, body)
|
||||
if not d.get("running"):
|
||||
last = d
|
||||
break
|
||||
time.sleep(POLL_S)
|
||||
except BaseException:
|
||||
# An abort or a failure on the way out leaves no check running
|
||||
# behind this test: the next run must be able to start its own.
|
||||
fc.post("/wiz/%s/abort" % wid)
|
||||
raise
|
||||
ev["elapsed_s"] = round(time.time() - t0, 1)
|
||||
ev["phases"] = phases[-12:]
|
||||
ctx.check(last is not None, "the %s check did not end within %d s", wid, timeout_s)
|
||||
if last is None:
|
||||
fc.post("/wiz/%s/abort" % wid)
|
||||
return {}
|
||||
ev["log"] = last.get("log", [])[-12:]
|
||||
ev["error"] = last.get("error")
|
||||
ev["result"] = last.get("result")
|
||||
ctx.check(not last.get("error"), "the %s check failed: %s", wid, last.get("error"))
|
||||
ctx.check(isinstance(last.get("result"), dict), "the %s check ended without a result", wid)
|
||||
w = wiz(fc)
|
||||
ctx.check((w.get("versions") or {}).get(wid) == 1, "the record does not carry %s at version 1", wid)
|
||||
return last
|
||||
|
||||
|
||||
@test("commission.check-switches", title="The switches check follows the lid and the button",
|
||||
subsystem="commission", kind="operator", est_min=3,
|
||||
covers=DARK_COVERS + [("forgectrl", "src/status.c")],
|
||||
requires=["forgectrl.auth"], actions=["lid", "button"],
|
||||
description="POST /wiz/switches/start; the check asks for the lid to open and close and for "
|
||||
"a press, each as a 'wait' prompt that the switch edge itself answers; the bench "
|
||||
"fixture works the lid and the button. On a Basic or Plus the interlock loop reads "
|
||||
"satisfied without a prompt. The result says lid, button, head present, and "
|
||||
"whether the HV enable readback followed the lid; the record carries switches at "
|
||||
"version 1.")
|
||||
def check_switches(ctx):
|
||||
def on_prompt(p):
|
||||
pid = p.get("id", "")
|
||||
if pid in ("lid-open", "lid-close-first", "lid-close"):
|
||||
ctx.act("lid", "open" if pid == "lid-open" else "close",
|
||||
until=lambda: (dark(ctx.forgectrl).get("prompt") or {}).get("seq") != p["seq"],
|
||||
text=p.get("text", ""))
|
||||
elif pid == "button-press":
|
||||
ctx.act("button", "press",
|
||||
until=lambda: (dark(ctx.forgectrl).get("prompt") or {}).get("id") != "button-press",
|
||||
text=p.get("text", ""))
|
||||
elif pid == "button-release":
|
||||
pass # the fixture's press releases on its own
|
||||
elif pid.startswith("interlock-"):
|
||||
ctx.act("interlock", "open" if pid == "interlock-open" else "close",
|
||||
until=lambda: (dark(ctx.forgectrl).get("prompt") or {}).get("seq") != p["seq"],
|
||||
text=p.get("text", ""))
|
||||
return None
|
||||
last = run_check(ctx, "switches", on_prompt, 600)
|
||||
r = last.get("result") or {}
|
||||
ctx.check(r.get("lid") is True and r.get("button") is True, "lid or button not proven: %s", r)
|
||||
ctx.check(r.get("head_present") is True, "the head did not read present: %s", r)
|
||||
ctx.log("switches: %s", json.dumps(r))
|
||||
|
||||
|
||||
@test("commission.check-sensors", title="The sensors check reads a plausible machine at rest",
|
||||
subsystem="commission", kind="auto", est_min=2,
|
||||
covers=DARK_COVERS + [("forgectrl", "src/status.c"), ("forgectrl", "src/accel.c"),
|
||||
("forgectrl", "src/cool.c")],
|
||||
requires=["forgectrl.auth"],
|
||||
description="POST /wiz/sensors/start; ten seconds of readings, then the room-temperature "
|
||||
"prompt, answered Skip so no offset is written. The result carries both coolant "
|
||||
"temperatures, the chassis and SoC, the lid IR maxima, the accelerometer event "
|
||||
"count, the supply power-good, the HV current, and the idle fan speeds; the "
|
||||
"record carries sensors at version 1 and cool_temp_offset_c reads as before.")
|
||||
def check_sensors(ctx):
|
||||
with Restore(ctx, ["cool_temp_offset_c"]):
|
||||
last = run_check(ctx, "sensors", lambda p: "Skip" if p.get("id") == "room-temp" else None, 120)
|
||||
r = last.get("result") or {}
|
||||
for k in ("coolant_down_c", "coolant_up_c", "lid_ir_max", "laser_pgood", "hv_current_max",
|
||||
"exhaust_rpm_idle", "intake_rpm_idle"):
|
||||
ctx.check(k in r, "the result lacks %s: %s", k, r)
|
||||
ctx.check(r.get("laser_pgood") == 1, "power-good read %s", r.get("laser_pgood"))
|
||||
ctx.log("sensors: coolant %s/%s C, IR %s, HV %s", r.get("coolant_down_c"), r.get("coolant_up_c"),
|
||||
r.get("lid_ir_max"), r.get("hv_current_max"))
|
||||
|
||||
|
||||
@test("commission.check-airflow", title="The airflow check measures the fans and sets the floors",
|
||||
subsystem="commission", kind="auto", hardware="takeover", est_min=3,
|
||||
covers=DARK_COVERS + [("forgectrl", "src/cool.c"), ("forgectrl", "src/airflow.*"),
|
||||
("forgectrl", "src/gates.c"), ("forgectrl", "src/super.c")],
|
||||
requires=["forgectrl.auth", "cooling.fan-gate-trips"],
|
||||
description="POST /wiz/airflow/start: the controller stops, the fans run at the cut profile "
|
||||
"for 35 s, the purge is measured on and off, and five settings are written: the "
|
||||
"four floors at 55 percent of steady and the grace from the slowest spin-up. Each "
|
||||
"fan must read at least 1000 rpm. The five settings are put back as found at the "
|
||||
"end and the controller must be running again.")
|
||||
def check_airflow(ctx):
|
||||
keys = ["cool_tach_exhaust_min_rpm", "cool_tach_intake_min_rpm", "cool_tach_air_assist_min_rpm",
|
||||
"cool_purge_min_current", "cool_fan_grace_s"]
|
||||
with Restore(ctx, keys):
|
||||
last = run_check(ctx, "airflow", lambda p: None, 240)
|
||||
r = last.get("result") or {}
|
||||
fans = r.get("fans") or {}
|
||||
for name in ("exhaust", "intake 1", "intake 2", "air assist"):
|
||||
f = fans.get(name) or {}
|
||||
ctx.check(f.get("ok") is True, "%s: %s", name, f)
|
||||
ctx.log("%s: %s rpm steady, 90 percent at %s s", name, f.get("steady_rpm"), f.get("spinup_s"))
|
||||
floors = r.get("floors") or {}
|
||||
for k in keys:
|
||||
ctx.check(k in floors, "no floor for %s: %s", k, floors)
|
||||
s = ctx.forgectrl.settings() or {}
|
||||
for k in keys:
|
||||
ctx.check(s.get(k) == floors.get(k), "%s reads %r, the check wrote %r", k, s.get(k), floors.get(k))
|
||||
ctx.log("floors: %s", json.dumps(floors))
|
||||
ok = ctx.wait_for(lambda: (ctx.forgectrl.get("/mode")[1] or {}).get("controller") == "running", 60)
|
||||
ctx.check(ok is not None, "the controller did not come back after the check")
|
||||
|
||||
|
||||
@test("commission.check-cameras", title="The cameras check captures both cameras",
|
||||
subsystem="commission", kind="operator", est_min=2,
|
||||
covers=DARK_COVERS + [("forgectrl", "src/cam.c")],
|
||||
requires=["forgectrl.auth", "camera.snapshot"], actions=["lid"],
|
||||
description="POST /wiz/cameras/start with the lid closed: a lid snapshot, the question, a "
|
||||
"head snapshot, the question. Each question is answered yes once GET /wiz/shot "
|
||||
"serves the snapshot as image/jpeg. The result names the sensor and says both "
|
||||
"views were accepted.")
|
||||
def check_cameras(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
ctx.act("lid", "close")
|
||||
shots = {}
|
||||
|
||||
def on_prompt(p):
|
||||
pid = p.get("id", "")
|
||||
if pid in ("lid-view", "head-view"):
|
||||
cam = "lid" if pid == "lid-view" else "head"
|
||||
st, body = fc.get("/wiz/shot", params={"cam": cam}, raw=True)
|
||||
shots[cam] = {"status": st, "bytes": len(body) if body else 0,
|
||||
"jpeg": bool(body) and body[:2] == b"\xff\xd8"}
|
||||
ctx.check(st == 200 and shots[cam]["jpeg"], "no %s snapshot to judge: %s", cam, shots[cam])
|
||||
return "Yes"
|
||||
if pid == "lid-close":
|
||||
ctx.act("lid", "close", until=lambda: (dark(fc).get("prompt") or {}).get("seq") != p["seq"])
|
||||
return None
|
||||
last = run_check(ctx, "cameras", on_prompt, 180)
|
||||
ev["shots"] = shots
|
||||
r = last.get("result") or {}
|
||||
ctx.check(r.get("lid_ok") is True and r.get("head_ok") is True, "a view was not accepted: %s", r)
|
||||
ctx.check(bool(r.get("sensor")) and r.get("sensor") != "unknown", "no sensor named: %s", r)
|
||||
ctx.log("cameras: %s, lid %d bytes, head %d bytes", r.get("sensor"),
|
||||
shots.get("lid", {}).get("bytes", 0), shots.get("head", {}).get("bytes", 0))
|
||||
|
||||
|
||||
@test("commission.check-motion", title="The motion check proves the rail, the lens reference, and the jogs",
|
||||
subsystem="commission", kind="auto", hardware="takeover", est_min=5,
|
||||
covers=DARK_COVERS + [("forgectrl", "src/super.c"), ("forgectrl", "src/liveness.c"),
|
||||
("forgectrl", "src/accel.c"), ("forgectrl", "src/cool.c")],
|
||||
requires=["forgectrl.auth", "motion.pacing"],
|
||||
description="POST /wiz/motion/start: the controller stops, the liveness probe runs, the lens "
|
||||
"finds the hall reference in five agreeing passes, the controller comes back in "
|
||||
"loopback posture, and the head jogs 50 mm each way on X and Y with the "
|
||||
"accelerometer as the witness. The one prompt (the jogs are about to move) is "
|
||||
"answered Continue. The result carries the probe line, the passes, and a "
|
||||
"witnessed reading for each jog; the controller must be back in its normal "
|
||||
"posture, and the kernel position counters must read what they read before: "
|
||||
"every move played to its end (the probe's return leg under the cooling "
|
||||
"engine's dead-man, the last jog under the controller stop).")
|
||||
def check_motion(ctx):
|
||||
fc = ctx.forgectrl
|
||||
before = read_position()
|
||||
ctx.notice("The head moves 50 mm each way on X and on Y. Keep the bed clear.")
|
||||
last = run_check(ctx, "motion", lambda p: "Continue" if p.get("id") == "jogs" else None, 420)
|
||||
ctx.clear_notice()
|
||||
r = last.get("result") or {}
|
||||
ctx.check(r.get("z_referenced") is True, "the lens was not referenced: %s", r)
|
||||
ctx.check(len(r.get("z_passes") or []) == 5, "not five reference passes: %s", r.get("z_passes"))
|
||||
moves = r.get("moves") or {}
|
||||
for name in ("+X", "-X", "+Y", "-Y"):
|
||||
m = moves.get(name) or {}
|
||||
ctx.check(m.get("witnessed") is True, "the %s jog was not witnessed: %s", name, m)
|
||||
ok = ctx.wait_for(lambda: (fc.get("/mode")[1] or {}).get("local") is False
|
||||
and (fc.get("/mode")[1] or {}).get("controller") == "running", 90)
|
||||
ctx.check(ok is not None, "the controller is not back in its normal posture")
|
||||
after = read_position()
|
||||
if before is not None and after is not None:
|
||||
ctx.check(all(abs(a - b) <= 2 for a, b in zip(after[:2], before[:2])),
|
||||
"the check lost motion: position %s before, %s after (steps)", before, after)
|
||||
ctx.log("motion: probe '%s', passes %s, rest %s, moves %s", r.get("probe"), r.get("z_passes"),
|
||||
r.get("rest"), {k: (v.get("p2p_lp_x"), v.get("p2p_lp_y")) for k, v in moves.items()})
|
||||
|
||||
|
||||
@test("commission.check-flow-verify", title="The flow check runs as a setup check",
|
||||
subsystem="commission", kind="auto", hardware="takeover", est_min=5,
|
||||
covers=DARK_COVERS + [("forgectrl", "src/diag.c")],
|
||||
requires=["forgectrl.auth", "cooling.aa-offset-calibrate", "cooling.flow-verify"],
|
||||
description="POST /wiz/cooling.flow-verify/start drives the flow-verify diagnostic through the "
|
||||
"check runner: the diagnostic's phases show as the check's, its result becomes the "
|
||||
"check's result with pass true, and the record carries cooling.flow-verify at "
|
||||
"version 1. A thin margin recommends the calibration in the record's flags. The "
|
||||
"air-assist offset calibration is named first among the prerequisites so a queue "
|
||||
"keeps it ahead of every heater tool this check pulls forward.")
|
||||
def check_flow_verify(ctx):
|
||||
last = run_check(ctx, "cooling.flow-verify", lambda p: None, 600)
|
||||
r = last.get("result") or {}
|
||||
ctx.check(r.get("pass") is True, "the flow check did not pass: %s", r)
|
||||
ctx.log("flow check: threshold %s, flow %s, no-flow %s, thin %s", r.get("threshold"),
|
||||
r.get("flow_rise"), r.get("noflow_rise"), r.get("thin_margin"))
|
||||
|
||||
|
||||
@test("commission.cloud-header-capture", title="The cloud header check takes one print's envelope",
|
||||
subsystem="commission", kind="operator", hardware="takeover", mode="grbl", est_min=8,
|
||||
covers=DARK_COVERS + [("forgectrl", "src/super.c"),
|
||||
("python3-gfhardware", "gfhardware/machine.py"),
|
||||
("python3-gfhardware", "forgefirm-app/gfcloud.py"),
|
||||
("python3-gfhardware", "forgefirm-app/ffmachine.py")],
|
||||
requires=["forgectrl.auth", "cloud.mode-switch"], hands=["workstation"],
|
||||
steps=["The Glowforge app open on the workstation, signed in to this machine. The test "
|
||||
"turns cloud mode on itself and puts it back.",
|
||||
"Once the app shows the machine online, place any small design on the bed image "
|
||||
"and press Print. That is the only action: the check takes the print's header and "
|
||||
"cancels the print before it arms. Do not press the machine's button.",
|
||||
"The machine returns to GRBL mode on its own."],
|
||||
description="POST /wiz/cloud.header/start with cloud mode on: the wizard writes the "
|
||||
"client's one-start capture marker, starts cloud mode in the wizard's posture, "
|
||||
"asks for a print, and takes the header the client writes to the run directory "
|
||||
"before it cancels the print. The result carries the tag count, the "
|
||||
"calibration-bearing tags, the limits, and the machine's own numbers; the log "
|
||||
"has the tagged block; the machine is back in GRBL mode with the marker gone.")
|
||||
def cloud_header_capture(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
with Restore(ctx, ["cloud_enabled", "homing_mode", "controller_mode"]):
|
||||
s = fc.settings() or {}
|
||||
if s.get("cloud_enabled") != "1":
|
||||
st, body = fc.post("/settings", data={"cloud_enabled": "1"})
|
||||
ctx.check(st == 200, "cloud_enabled=1 -> %s %s", st, body)
|
||||
ctx.log("cloud mode turned on for the test")
|
||||
ctx.notice("Print any small design from the Glowforge app once it shows the machine "
|
||||
"online. Do not press the machine's button.")
|
||||
last = run_check(ctx, "cloud.header",
|
||||
lambda p: "Continue" if p.get("id") == "print" else None, 900)
|
||||
ctx.clear_notice()
|
||||
r = last.get("result") or {}
|
||||
ctx.check(isinstance(r.get("tag_count"), int) and r["tag_count"] > 100,
|
||||
"the header carried %s tags", r.get("tag_count"))
|
||||
kept = r.get("calibration_tags") or {}
|
||||
ctx.check(len(kept) > 10, "only %d calibration-bearing tags kept", len(kept))
|
||||
ev["kept"] = sorted(kept)[:40]
|
||||
ctx.check(not os.path.exists("/run/gfcloud-capture"), "the capture marker is still down")
|
||||
ok = ctx.wait_for(lambda: (fc.get("/mode")[1] or {}).get("mode") == "grbl"
|
||||
and (fc.get("/mode")[1] or {}).get("controller") == "running", 120)
|
||||
ctx.check(ok is not None, "the machine did not return to GRBL mode")
|
||||
ctx.log("header: %d tags, %d kept; job %s", r.get("tag_count", 0), len(kept), r.get("job_id"))
|
||||
@@ -0,0 +1,195 @@
|
||||
"""commission.sheet - the setup's sheet (the live wizards) in one run,
|
||||
driven the way the page drives them: POST /wiz/<id>/start, GET /wiz/dark
|
||||
polled, the prompts answered from here, each burn armed by a press, the
|
||||
result judged, and every setting a wizard wrote put back as found. One
|
||||
piece of wood at least 200 x 150 mm (8 x 6 in) carries the placement,
|
||||
the frame, and the five cards. Each wizard re-runs on the real record,
|
||||
so a completed one completes again at the same version.
|
||||
|
||||
The operator's part is one press on the machine at the start (the
|
||||
presence check of the ready gate; with the bench actuator up it then
|
||||
makes every arm press) and nothing else. The judgments a person makes
|
||||
by eye on the sheet (the thinnest line, the faintest rung, the best
|
||||
corner, the frame's fit) are answered here with a middle option or the
|
||||
witnesses: the test proves each wizard's mechanics and the emission
|
||||
witnesses, not the operator's eye, and the settings it writes are
|
||||
restored. The page is where the real numbers go in.
|
||||
"""
|
||||
import re
|
||||
|
||||
from ..catalog import test
|
||||
from .commission_dark import run_check, Restore
|
||||
|
||||
SHEET_COVERS = [("forgectrl", "src/wizlive.*"), ("forgectrl", "src/sheet.*"),
|
||||
("forgectrl", "src/font_hershey.*"), ("forgectrl", "src/jobstream.*"),
|
||||
("forgectrl", "src/curverec.*"), ("forgectrl", "src/wizdark.*"),
|
||||
("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"),
|
||||
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*"),
|
||||
("forgectrl", "tools/hershey_gen.py"), ("forgectrl", "src/super.c"),
|
||||
("forgectrl", "src/status.c"), ("forgectrl", "src/cool.c"),
|
||||
("forgectrl", "src/accel.*"), ("forgectrl", "src/wizcalc.*"),
|
||||
("grblhal-glowforge", "src/glowforge_laser.c"),
|
||||
("grblhal-glowforge", "src/glowforge_status.c"),
|
||||
("grblhal-glowforge", "src/glowforge_homing.*"),
|
||||
("grblhal-glowforge", "src/glowforge_io.*")]
|
||||
|
||||
# The live wizards in the order the sheet burns them: the wizard id, the
|
||||
# answers to its prompts (a middle pick where a person would look at the
|
||||
# sheet), the keys its result must carry, and how long the bench actuator
|
||||
# waits for the button to light before the arm press goes to a person.
|
||||
# The flow-load card settles the coolant loop before it lights the button
|
||||
# (60 to 240 s); the others light within seconds of the lens reference.
|
||||
CARDS = [
|
||||
("sheet.frame", {"frame-arm": "Continue", "frame-ok": "Yes"}, ["mark_s", "mark_feed"], 120),
|
||||
("laser.focus", {"focus-arm": "Continue", "focus-pick": "11", "thickness": "Keep"},
|
||||
["pick", "thickness_mm", "pick_half_steps", "edge_z_mm", "steps_per_mm", "max_height_mm",
|
||||
"focus_range_mm", "stops"], 120),
|
||||
("laser.floor", {"floor-arm": "Continue", "floor-pick": "8"}, ["faintest_density", "floor_density"], 120),
|
||||
("laser.dose-curve", {"dose-arm": "Continue"}, ["points", "curve"], 120),
|
||||
("laser.corner", {"corner-arm": "Continue", "corner-pick": "1.50"}, ["gamma"], 120),
|
||||
("cooling.flow-load", {"load-arm": "Continue"}, ["lit_s", "dose_raw_s", "peak_c", "k_density", "k_cw"], 420),
|
||||
]
|
||||
# Every setting a card writes, restored as found when the run ends.
|
||||
CARD_SETTINGS = ["lens_hall_edge_z_mm", "lens_stop_below_steps", "lens_stop_above_steps",
|
||||
"laser_floor_density", "laser_dose_curve", "laser_corner_gamma",
|
||||
"cool_laser_heat_density", "cool_laser_heat_cw"]
|
||||
# The evidence run_check and the witnesses write at the top level, moved
|
||||
# under the card's own key once it is done.
|
||||
CARD_EVIDENCE = ("started", "elapsed_s", "phases", "log", "error", "result",
|
||||
"preview_bytes", "program_lines", "emission")
|
||||
|
||||
|
||||
def preview_ok(ctx, wid):
|
||||
"""The page's preview and the program for the wizard are served."""
|
||||
fc = ctx.forgectrl
|
||||
st, body = fc.get("/wiz/sheet.svg?card=%s" % wid, raw=True)
|
||||
ctx.check(st == 200 and body.lstrip().startswith(b"<svg"), "no preview for %s (%s)", wid, st)
|
||||
ctx.evidence["preview_bytes"] = len(body)
|
||||
st, body = fc.get("/wiz/sheet.gcode?card=%s" % wid, raw=True)
|
||||
# The text and the frame are M4 at the mark dose; the patterns bring
|
||||
# their own M3 or M4 lines. A program is one with a laser-on command.
|
||||
ctx.check(st == 200 and re.search(rb"^M[34] S\d", body or b"", re.M) is not None,
|
||||
"no program for %s (%s)", wid, st)
|
||||
ctx.evidence["program_lines"] = body.count(b"\n")
|
||||
|
||||
|
||||
def emission_ok(ctx, last):
|
||||
"""The three witnesses of the burn, from the wizard's result."""
|
||||
e = (last.get("result") or {}).get("emission") or {}
|
||||
ctx.evidence["emission"] = e
|
||||
ctx.check(e.get("hv_max", 0) > 30, "the tube current never rose (%s)", e.get("hv_max"))
|
||||
ctx.check(e.get("laser_on_samples", 0) > 0, "the kernel never sampled LASER_ON")
|
||||
ctx.check(e.get("thermopile_delta", 0) >= 100, "the thermopile did not see the beam (%s)",
|
||||
e.get("thermopile_delta"))
|
||||
|
||||
|
||||
def file_card(ctx, wid):
|
||||
"""Move the card's evidence under its own key."""
|
||||
ev = ctx.evidence
|
||||
ev.setdefault("cards", {})[wid] = {k: ev.pop(k) for k in CARD_EVIDENCE if k in ev}
|
||||
|
||||
|
||||
def thickness_answer(ctx):
|
||||
"""The thickness the placement is answered with, in the machine's
|
||||
units (the number prompts read ui_units), and the millimeters the
|
||||
record must carry for it."""
|
||||
imperial = (ctx.forgectrl.settings() or {}).get("ui_units") == "imperial"
|
||||
return ("0.125", 3.175) if imperial else ("3.2", 3.2)
|
||||
|
||||
|
||||
def place(ctx):
|
||||
"""The placement: the lens reference, two jogs, the origin at the
|
||||
head's position, the full sheet, a thickness. Nothing fires."""
|
||||
jogs = ["X+10", "X-10"]
|
||||
answer, want_mm = thickness_answer(ctx)
|
||||
ctx.evidence["thickness_answer"] = answer
|
||||
|
||||
def on_prompt(p):
|
||||
# Each jog answer closes the prompt and the wizard asks again with
|
||||
# the next sequence number, so the pad is answered one move at a
|
||||
# time until the origin is set.
|
||||
if p.get("id") == "place":
|
||||
return jogs.pop(0) if jogs else "Set origin"
|
||||
if p.get("id") == "sheet-kind":
|
||||
return "Full sheet"
|
||||
if p.get("id") == "thickness":
|
||||
return answer
|
||||
return None
|
||||
|
||||
last = run_check(ctx, "sheet.place", on_prompt, 300)
|
||||
r = last.get("result") or {}
|
||||
ctx.check("origin_x" in r and "origin_y" in r, "no origin in the result: %s", r)
|
||||
ctx.check((r.get("steps_per_mm") or {}).get("x", 0) > 0, "no steps per mm: %s", r)
|
||||
ctx.check(r.get("z_referenced") is True, "the lens was not referenced")
|
||||
ctx.check(abs((r.get("thickness_mm") or 0) - want_mm) < 0.01,
|
||||
"thickness %s mm, expected %s (answered %s)", r.get("thickness_mm"), want_mm, answer)
|
||||
ctx.check(r.get("alone") is False, "alone %s", r.get("alone"))
|
||||
ctx.log("placed: datum at %s, %s; steps per mm %s", r.get("origin_x"), r.get("origin_y"),
|
||||
r.get("steps_per_mm"))
|
||||
file_card(ctx, "sheet.place")
|
||||
|
||||
|
||||
def burn(ctx, wid, answers, want, lit_s):
|
||||
"""One live card: the preview, the burn after the press with the
|
||||
prompts answered from `answers`, the witnesses, the keys of the
|
||||
result in `want`. The actuator waits `lit_s` for the button to light."""
|
||||
preview_ok(ctx, wid)
|
||||
ctx.arm_press(lit_timeout=lit_s)
|
||||
|
||||
def on_prompt(p):
|
||||
return answers.get(p.get("id"))
|
||||
|
||||
try:
|
||||
last = run_check(ctx, wid, on_prompt, 900)
|
||||
finally:
|
||||
ctx.clear_notice()
|
||||
emission_ok(ctx, last)
|
||||
r = last.get("result") or {}
|
||||
missing = [k for k in want if k not in r]
|
||||
ctx.check(not missing, "the result lacks %s: %s", missing, sorted(r))
|
||||
ctx.log("%s: %s, emission %s", wid, {k: r.get(k) for k in want}, ctx.evidence.get("emission"))
|
||||
file_card(ctx, wid)
|
||||
|
||||
|
||||
@test("commission.sheet", title="The commissioning sheet: the placement, the frame, and the five cards",
|
||||
subsystem="commission", kind="live", hardware="takeover", mode="grbl", est_min=35,
|
||||
covers=SHEET_COVERS,
|
||||
requires=["forgectrl.auth", "commission.check-motion", "laser.emission-witness", "cooling.flow-verify"],
|
||||
actions=["button"], hands=["scrap"],
|
||||
steps=["A piece of wood at least 200 x 150 mm (8 x 6 in) on the bed, pushed as far left as "
|
||||
"it goes with its top edge at the top of the cut area (the head's home corner); "
|
||||
"lid closed. Eye protection on, exhaust on, extinguisher in reach.",
|
||||
"Start the test and press the machine's button once. That press says you are at the "
|
||||
"machine, and the bench actuator makes every arm press after it. With no actuator "
|
||||
"the start is the Ready answer instead, and you press the button each time it lights "
|
||||
"white: six times, the frame and the five cards.",
|
||||
"Nothing else: the test answers every prompt itself and puts back every setting the "
|
||||
"cards write. It runs about half an hour, mostly the coolant settle and the dark "
|
||||
"tails."],
|
||||
description="One run over the sheet's seven wizards on one piece of wood. "
|
||||
"POST /wiz/sheet.place/start references the lens, takes the controller in "
|
||||
"loopback posture, jogs +X 10 and -X 10, and sets the origin at the head "
|
||||
"(Full sheet; the thickness answered in the machine's units, 3.2 mm or "
|
||||
"0.125 in). Then, each after the press and each with its preview "
|
||||
"and program served by GET /wiz/sheet.svg and /wiz/sheet.gcode: the frame "
|
||||
"(180 x 130 mm) and the header band at the mark dose; the focus card (the "
|
||||
"lens homed on its bottom stop, the hall edge counted, twelve lines down the "
|
||||
"travel; the pick is line 11, the thickness kept); the floor card (twelve "
|
||||
"rungs from 2 to 24 percent with the floor and the curve off for the job; the "
|
||||
"pick is 8, the floor written is 10); the dose card (seven rungs sampled at "
|
||||
"25 Hz, the curve fit on 20 s of dark); the corner card (five gammas reloaded "
|
||||
"through M102 inside one armed job; the pick is 1.50); and the flow-load card "
|
||||
"(the coolant loop settled, the box and the patch at 60 percent, the "
|
||||
"downstream peak over 100 s of dark as the coefficient). Every burn must be "
|
||||
"seen by the three witnesses (the tube current, the kernel's LASER_ON samples, "
|
||||
"the thermopile rise); every result must carry its keys; the record must "
|
||||
"carry each wizard at version 1; the seven settings the cards write are "
|
||||
"restored at the end.")
|
||||
def sheet(ctx):
|
||||
ctx.ready("The sheet: the placement, then the frame and the five cards burn after a press "
|
||||
"each. The bench presses when the button lights.")
|
||||
place(ctx)
|
||||
with Restore(ctx, CARD_SETTINGS):
|
||||
for wid, answers, want, lit_s in CARDS:
|
||||
burn(ctx, wid, answers, want, lit_s)
|
||||
ctx.log("PASS: the sheet's seven wizards ran on one piece; settings restored")
|
||||
@@ -6,7 +6,12 @@ import time
|
||||
from ..catalog import test
|
||||
from .. import hw
|
||||
|
||||
_COVERS_AUTH = [("forgectrl", "src/auth.*"), ("forgectrl", "src/peer.*"), ("forgectrl", "src/main.c")]
|
||||
# The write guard reads the account and the session store (a machine with
|
||||
# an account needs a session from the LAN; this host and the dev image
|
||||
# write with the token), so both are the guard's domain.
|
||||
_COVERS_AUTH = [("forgectrl", "src/auth.*"), ("forgectrl", "src/peer.*"), ("forgectrl", "src/main.c"),
|
||||
("forgectrl", "src/session.*"), ("forgectrl", "src/users.*"),
|
||||
("forgectrl", "src/ui/login.js"), ("forgectrl", "src/ui/wizard.js")]
|
||||
|
||||
|
||||
def lan_ip():
|
||||
@@ -27,12 +32,15 @@ def lan_ip():
|
||||
|
||||
@test("forgectrl.auth", title="API access control", subsystem="forgectrl", kind="auto", est_min=1,
|
||||
covers=_COVERS_AUTH,
|
||||
description="Every state-changing endpoint refuses an unauthenticated write; a non-literal "
|
||||
description="Every state-changing endpoint refuses an unauthenticated write (the factory "
|
||||
"return, the SSH switch and the wizard's own routes included); a non-literal "
|
||||
"Host, a non-literal Origin and a cross-site Sec-Fetch-Site are refused; the "
|
||||
"cooling report channel accepts the loopback peer and refuses a non-loopback "
|
||||
"one; the fuse view is two-factor "
|
||||
"one (over HTTP the write is sent to HTTPS first, 302; over HTTPS the route "
|
||||
"answers 403 loopback only); the fuse view is two-factor "
|
||||
"(token and the physical button) and refused without either; "
|
||||
"the flash and factory-restore chain is refused unauthenticated.")
|
||||
"the flash and factory-restore chain is refused unauthenticated; a page "
|
||||
"asked for without a session is sent to the login carrying its path.")
|
||||
def auth(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
@@ -46,7 +54,10 @@ def auth(ctx):
|
||||
("/mode", {"controller": "grbl"}), ("/settings", {"ui_units": "mm"}),
|
||||
("/diag/flow-verify", None), ("/diag/abort", None),
|
||||
("/update/apply", None), ("/boot", {"target": "a"}),
|
||||
("/system/reboot", None), ("/restore/factory", None)):
|
||||
("/system/reboot", None), ("/restore/factory", None),
|
||||
("/restore/factory-return", {"confirm": "1"}), ("/system/ssh", {"enable": "1"}),
|
||||
("/wiz/advisories/accept", None), ("/wiz/account", None),
|
||||
("/wiz/complete", None)):
|
||||
st, body = fc.post(path, params=params, auth=False)
|
||||
ctx.log("POST %s (no token) -> %s %s", path, st, body if isinstance(body, dict) else "")
|
||||
ev["noauth " + path] = st
|
||||
@@ -92,7 +103,7 @@ def auth(ctx):
|
||||
ev["sfs_cross"] = st
|
||||
ctx.log("GET /status Sec-Fetch-Site=cross-site -> %s", st)
|
||||
ctx.check(st == 403, "a cross-site fetch was accepted (%s)", st)
|
||||
st, body = fc.get("/status", headers={"Sec-Fetch-Site": "same-origin", "Origin": "http://127.0.0.1:8080"})
|
||||
st, body = fc.get("/status", headers={"Sec-Fetch-Site": "same-origin", "Origin": "http://127.0.0.1"})
|
||||
ctx.check(st == 200, "same-origin literal Origin refused (%s)", st)
|
||||
|
||||
# the fuse view is two-factor: the token AND the physical button held.
|
||||
@@ -122,17 +133,42 @@ def auth(ctx):
|
||||
ctx.check(st == 200, "/cool/state refused the loopback peer (%s %r): the controller's "
|
||||
"reports never reach the engine", st, body)
|
||||
|
||||
# ...and a non-loopback peer is refused, even with a token
|
||||
# ...and a non-loopback peer is refused, even with a token. Over HTTP
|
||||
# the write is sent to HTTPS first (302, the listener's rule); over
|
||||
# HTTPS the route itself refuses the peer (403 loopback only). Neither
|
||||
# request follows the redirect, and the self-signed certificate is
|
||||
# not verified.
|
||||
from .commission import request, decode
|
||||
ip = lan_ip()
|
||||
ev["lan_ip"] = ip
|
||||
ctx.check(ip, "cannot determine the board's LAN address")
|
||||
port = fc.base.rsplit(":", 1)[-1]
|
||||
lan = hw.Forgectrl("http://%s:%s" % (ip, port), token=fc.token)
|
||||
st, body = lan.post("/cool/state", params={"mode": "idle", "armed": "0"})
|
||||
token = {"X-ForgeFIRM-Token": fc.token}
|
||||
report = {"mode": "idle", "armed": "0"}
|
||||
st, body, hdrs = request("http://%s" % ip, "POST", "/cool/state", data=report, headers=token)
|
||||
loc = hdrs.get("location", "")
|
||||
ev["cool_state_from_lan_http"] = {"status": st, "location": loc}
|
||||
ctx.log("POST http://%s/cool/state -> %s %s", ip, st, loc)
|
||||
ctx.check(st == 302 and loc.startswith("https://"),
|
||||
"a LAN write over HTTP -> %s %r, expected a 302 to HTTPS", st, loc)
|
||||
st, body, hdrs = request("https://%s" % ip, "POST", "/cool/state", data=report, headers=token)
|
||||
b = decode(body)
|
||||
ev["cool_state_from_lan"] = st
|
||||
ctx.log("POST /cool/state from %s -> %s %s", ip, st, body if isinstance(body, dict) else "")
|
||||
ctx.check(st == 403 and isinstance(body, dict) and body.get("error") == "loopback only",
|
||||
"/cool/state accepted a non-loopback peer (%s %r)", st, body)
|
||||
ctx.log("POST https://%s/cool/state -> %s %s", ip, st, b if isinstance(b, dict) else "")
|
||||
ctx.check(st == 403 and isinstance(b, dict) and b.get("error") == "loopback only",
|
||||
"/cool/state accepted a non-loopback peer (%s %r)", st, b)
|
||||
|
||||
# the login return path: a page asked for from the LAN without a
|
||||
# session is sent to the login carrying the path it asked for (with
|
||||
# its query), so the login can come back to it. Before the setup has
|
||||
# made the account the page is served instead (200).
|
||||
st, body, hdrs = request("https://%s" % ip, "GET", "/setup?step=laser.focus")
|
||||
loc = hdrs.get("location", "")
|
||||
ev["setup_from_lan"] = {"status": st, "location": loc}
|
||||
ctx.log("GET https://%s/setup?step=laser.focus (no session) -> %s %s", ip, st, loc)
|
||||
ctx.check(st in (200, 302), "the setup page from the LAN -> %s", st)
|
||||
if st == 302:
|
||||
ctx.check(loc == "/login?next=/setup%3Fstep%3Dlaser.focus",
|
||||
"the login redirect does not carry the path: %r", loc)
|
||||
|
||||
|
||||
@test("forgectrl.settings-bounds", title="Settings validation and restore", subsystem="forgectrl",
|
||||
|
||||
@@ -252,3 +252,57 @@ def image_health(ctx):
|
||||
ctx.check("kernel-module-glowforge" in manifest.components, "manifest lacks kernel-module-glowforge")
|
||||
ctx.check("linux-fslc" in manifest.components, "manifest lacks the kernel entry")
|
||||
ctx.log("manifest %s: %d components", manifest.content_sha[:12], len(manifest.components))
|
||||
|
||||
|
||||
@test("image.license-bundle", title="The license texts travel with the image",
|
||||
subsystem="image", kind="auto", est_min=1,
|
||||
covers=[("forgectrl", "src/main.c"), ("forgectrl", "src/ui/index.html"),
|
||||
("forgectrl", "src/ui/wizard.html"), ("forgectrl", "src/ui/login.html")],
|
||||
requires=["forgectrl.panel-serves"],
|
||||
description="The image carries /usr/share/forgefirm/licenses.tar.gz, a gzip tar with "
|
||||
"the license manifest (every installed package with its license) and the "
|
||||
"license texts, and no loose common-licenses tree; the panel serves the "
|
||||
"bundle, the manifest as text, and the Licenses page every footer links, "
|
||||
"each without a login.")
|
||||
def license_bundle(ctx):
|
||||
import io
|
||||
import tarfile
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
path = "/usr/share/forgefirm/licenses.tar.gz"
|
||||
ctx.check(os.path.isfile(path), "%s is missing", path)
|
||||
ctx.check(not os.path.isdir("/usr/share/common-licenses"),
|
||||
"the loose common-licenses tree is still on the rootfs")
|
||||
size = os.path.getsize(path)
|
||||
ev["bundle_bytes"] = size
|
||||
ctx.log("%s: %d bytes", path, size)
|
||||
with tarfile.open(path, "r:gz") as t:
|
||||
names = t.getnames()
|
||||
ctx.check("common-licenses/license.manifest" in names, "no license.manifest in the bundle")
|
||||
manifest = t.extractfile("common-licenses/license.manifest").read().decode("utf-8", "replace")
|
||||
packages = manifest.count("PACKAGE NAME: ")
|
||||
texts = sum(1 for n in names if "/generic_" in n and not n.endswith("/"))
|
||||
ev["packages"] = packages
|
||||
ev["generic_texts"] = texts
|
||||
ctx.log("manifest lists %d packages; %d generic license entries", packages, texts)
|
||||
ctx.check(packages >= 40, "only %d packages in the manifest", packages)
|
||||
ctx.check(texts >= 5, "only %d generic license entries", texts)
|
||||
for name in ("forgectrl", "grblhal-glowforge", "kernel-module-glowforge"):
|
||||
ctx.check("RECIPE NAME: %s\n" % name in manifest, "%s is not in the manifest", name)
|
||||
|
||||
st, body = fc.get("/system/licenses/manifest", raw=True)
|
||||
ctx.check(st == 200, "GET /system/licenses/manifest -> %s", st)
|
||||
ctx.check(isinstance(body, (bytes, bytearray)) and b"PACKAGE NAME: " in body,
|
||||
"the served manifest is not the manifest")
|
||||
st, body = fc.get("/system/licenses", raw=True)
|
||||
ctx.check(st == 200, "GET /system/licenses -> %s", st)
|
||||
ctx.check(isinstance(body, (bytes, bytearray)) and len(body) == size,
|
||||
"the served bundle is %s bytes, the file %d", len(body) if body else None, size)
|
||||
with tarfile.open(fileobj=io.BytesIO(bytes(body)), mode="r:gz") as t:
|
||||
ctx.check("common-licenses/license.manifest" in t.getnames(),
|
||||
"the served bundle holds no manifest")
|
||||
st, body = fc.get("/licenses", raw=True)
|
||||
ctx.check(st == 200, "GET /licenses -> %s", st)
|
||||
text = bytes(body).decode("utf-8", "replace") if body else ""
|
||||
ctx.check("/system/licenses" in text and "PACKAGE NAME: " in text,
|
||||
"the Licenses page lacks the download link or the manifest")
|
||||
|
||||
@@ -93,6 +93,7 @@ def sample(ctx):
|
||||
"verdict": cs.get("verdict"),
|
||||
"beam": hw.sysfs_int("head/beam_detect_analog"),
|
||||
"beam_d": hw.sysfs_int("head/beam_detect_digital"),
|
||||
"exhaust": (st.get("fans") or {}).get("exhaust"),
|
||||
"button_lit": hw.button_lit(),
|
||||
"hv_enable": (st.get("switches") or {}).get("hv_enable"),
|
||||
"button_latch": hw.sysfs_int("cnc/button_latch"),
|
||||
@@ -494,6 +495,18 @@ def emission_witness(ctx):
|
||||
"HV_ENABLE dipped=%s, back with emission after it=%s", gap["button_latch_unlocked_max"],
|
||||
gap["button_latch_unlocked_samples"], gap["button_latch_set_at"], gap["hv_enable_dipped"],
|
||||
gap["hv_enable_back_lit"])
|
||||
# The first fire waits for the airflow: the exhaust reads at or
|
||||
# above its floor on the first sample that shows emission.
|
||||
floor = (ctx.forgectrl.settings() or {}).get("cool_tach_exhaust_min_rpm") or ""
|
||||
try:
|
||||
floor = float(floor) if floor else 6400.0
|
||||
except ValueError:
|
||||
floor = 6400.0
|
||||
first_lit = next((s for s in samples if s["emission"]), None)
|
||||
ev["exhaust_at_first_fire"] = first_lit["exhaust"] if first_lit else None
|
||||
ev["exhaust_floor"] = floor
|
||||
ctx.log("exhaust at the first emission sample: %s rpm (floor %.0f)",
|
||||
ev["exhaust_at_first_fire"], floor)
|
||||
# X-3: job-based disarm at Idle after M2
|
||||
dt = wait_disarm(ctx, 75)
|
||||
ev["disarm_after_idle_s"] = round(dt, 1) if dt is not None else None
|
||||
@@ -506,6 +519,9 @@ def emission_witness(ctx):
|
||||
"their idle duty (phase %s at t=%s s, emission %s)",
|
||||
first_idle_fire[1], first_idle_fire[0], first_idle_fire[2])
|
||||
ctx.check(end == 0, "emission_samples did not return to 0 at Idle (%s)", end)
|
||||
ctx.check(ev["exhaust_at_first_fire"] is not None and ev["exhaust_at_first_fire"] >= floor,
|
||||
"the beam started before the exhaust reached its floor (%s rpm at the first "
|
||||
"emission sample, floor %.0f)", ev["exhaust_at_first_fire"], floor)
|
||||
ctx.check(hv and max(hv) > min(hv), "HV current did not rise during the burn (%s..%s)",
|
||||
ev["hv_min"], ev["hv_max"])
|
||||
ctx.check(dt is not None and dt < 10.0,
|
||||
|
||||
@@ -28,7 +28,7 @@ _LOG_COVERS = [("forgectrl", "src/logs.*"), ("forgectrl", "src/fflog.*"), ("forg
|
||||
covers=_LOG_COVERS, requires=["forgectrl.auth"],
|
||||
description="/logs lists the loggers with their levels and files, /logs/tail returns the "
|
||||
"forgectrl logger's tail, and POST /logs/export streams a sanitized tar.gz "
|
||||
"bundle that contains no panel token.")
|
||||
"bundle that contains neither the panel token nor the camera key.")
|
||||
def tree_tail_export(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
@@ -66,16 +66,21 @@ def tree_tail_export(ctx):
|
||||
ctx.log("bundle: %d members, e.g. %s", len(members), members[:5])
|
||||
ctx.check(members, "empty bundle")
|
||||
ctx.check(any(m.endswith("README.txt") for m in members), "sanitized bundle lacks README.txt")
|
||||
token = fc.token
|
||||
if token:
|
||||
leaked = []
|
||||
for m in tf.getmembers():
|
||||
if m.isfile():
|
||||
content = tf.extractfile(m).read()
|
||||
if token.encode() in content:
|
||||
leaked.append(m.name)
|
||||
ev["token_leaks"] = leaked
|
||||
ctx.check(not leaked, "the sanitized bundle contains the panel token: %s", leaked)
|
||||
# the two secrets the sanitizer knows by value and a bundle could
|
||||
# carry: the panel token and the camera key (a URL parameter that
|
||||
# lands in sender logs)
|
||||
secrets = {}
|
||||
if fc.token:
|
||||
secrets["panel token"] = fc.token
|
||||
st, body = fc.get("/system/camera-key")
|
||||
if st == 200 and isinstance(body, dict) and body.get("key"):
|
||||
secrets["camera key"] = body["key"]
|
||||
ctx.check("camera key" in secrets, "GET /system/camera-key -> %s: no key to check for", st)
|
||||
contents = [(m.name, tf.extractfile(m).read()) for m in tf.getmembers() if m.isfile()]
|
||||
for what, value in secrets.items():
|
||||
leaked = [name for name, content in contents if value.encode() in content]
|
||||
ev[what.replace(" ", "_") + "_leaks"] = leaked
|
||||
ctx.check(not leaked, "the sanitized bundle contains the %s: %s", what, leaked)
|
||||
|
||||
|
||||
# The routing test proves the whole path every logger takes: emitter (or
|
||||
|
||||
@@ -392,7 +392,7 @@ def _liveness_masked_restart(ctx, fc, ev):
|
||||
ctx.check(lines and "MOTION OK" in lines[0],
|
||||
"the first probe after the restart was not MOTION OK: %s", lines[:1])
|
||||
ctx.check(len(lines) == 1, "the probe needed the recovery ladder (%d probes) - a false dead verdict", len(lines))
|
||||
ctx.check(ctx.sysfs("cnc/motor_lock") == "8", "motor_lock reads %s after the controller start (expected 8)",
|
||||
ctx.check(ctx.sysfs("cnc/motor_lock") == "0", "motor_lock reads %s after the controller start (expected 0)",
|
||||
ctx.sysfs("cnc/motor_lock"))
|
||||
ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle after the probe")
|
||||
x1 = _kernel_x_mm(ctx)
|
||||
|
||||
Reference in New Issue
Block a user