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:
ScottW514
2026-09-06 19:56:05 -04:00
parent ff9796cde6
commit 97287aa6a9
59 changed files with 15113 additions and 9139 deletions
+34 -15
View File
@@ -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)
+2 -2
View File
@@ -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
+10 -8
View File
@@ -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:
+3
View File
@@ -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
+65
View File
@@ -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})
+14 -17
View File
@@ -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")
+49 -13
View File
@@ -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",
+54
View File
@@ -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")
+16
View File
@@ -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,
+16 -11
View File
@@ -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
+1 -1
View File
@@ -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)