Files
forgefirm/forgetest/forgetest/suite/commission_dark.py
T
ScottW514 f0c40e7d4f Name every machine after its own MAC address, and drop mDNS
One name for every machine was wrong: an operator with two of them on a
network had one forgefirm.local, and mDNS does not work on many networks
at all. The machine now calls itself forgefirm-<xxxx>, from the last four
hex digits of its WiFi MAC address, and sends that name with its DHCP
request, so a network with dynamic DNS publishes it and a router lists
the machine by name. The name is the same at every boot, two machines
take different names, and no serial number leaves the machine.

forgefirm-hostname (new): reads the wlan0 MAC address (eth0 on a machine
with no WiFi) at S38 in rcS, after udev has probed the network drivers
and before poky's hostname.sh reads the file and before the network
starts. The rootfs is read-only, so the name is written through a
bind-mounted copy under /run/forgefirm. A bounded wait covers a slow
probe. hostname:pn-base-files is "forgefirm": the name before S38, and
the fallback when no MAC address can be read.

avahi is deleted - the bbappend, the daemon configuration, the service
file, the image install and the distro block. The address is the way in
that works on every network, and the DHCP name covers the rest.

forgefirm-banner: the marker lines are gone. "# ForgeFIRM addresses" and
"# end" delimited the address block inside /etc/issue, and getty prints
every line of that file, so both markers were on the console. The script
now keeps the image's own text in a second copy under /run/forgefirm,
captured once per boot before the first write, and renders the whole
banner from it. The block is the addresses alone: no mDNS name.

forgefirm-image.bb: the ForgeFIRM mark, under the OpenGlow one the base
image carries, with the version on the mark's own last line,
right-justified to the mark's last column. The mark is written once and
rendered per reader, because /etc/issue is parsed by busybox getty (a
backslash or a percent sign starts an escape, so the art goes in with
every backslash doubled) while /etc/motd is written out as it is. Widths
are measured in columns, not bytes: the color sequences take no room on
the screen. /etc/issue.net stays unused - the machine tells a client that
has not logged in nothing.

Acceptance: commission.mdns-announce is replaced by
commission.machine-name, which checks the name against the MAC address,
the bind-mounted /etc/hostname, the DHCP client's hostname option, the
banner's addresses, and that no mDNS responder is on the image; it covers
nothing by design, like the test it replaces. forgectrl.auth gains the
own-name Host check and its refusal with a domain on it. image.health
checks the /etc/hostname mount and the version on the mark's last line in
both files. commission.ssh-until-reboot asserts there is no
pre-authentication banner. commission_dark's lens coverage widens to
src/lenshome.* so src/lenshome.h is covered; the lint is clean at 83
tests.

Pins: forgectrl 0.1.14 (9e5330f, the hostname certificate and the Host
rule), meta-openglow ced2af2 (the DHCP hostname option and the motd mark)
in the kas lock.

Proven on the bench reference, hot-deployed and rebooted (image
20260910000208 dev): hostname forgefirm-b00a from MAC 2c:6b:7d:0d:b0:0a,
live and in the bind-mounted file; the DHCP client running with
-x hostname:forgefirm-b00a; the console banner and the motd carrying both
marks with the version aligned to the mark's last column, no marker line
and no .local name; forgectrl regenerating its certificate for the new
name. Host tests: 357 forgetest unit tests, forgectrl clean under
-Werror, tls_test and sanitize_test.
2026-09-10 07:18:27 -04:00

346 lines
20 KiB
Python

"""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, the purge fan is on again and drawing over the floor the check just wrote, "
"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))
# The check switches purge air off to read its off current. Leaving
# it off costs the next job an airflow hold mid-cut, judged against
# the very floor written above, and nothing puts it back until the
# daemon restarts: the engine's idle phase never re-applies its own
# duties. Prove the machine is whole, not just measured.
purge_on = ctx.sysfs("head/purge_air")
ctx.check(purge_on == "1", "purge air reads %r after the check, expected 1 (on)", purge_on)
gate = ((ctx.forgectrl.get("/cool/status")[1] or {}).get("fan_gates") or {}).get("purge") or {}
ctx.evidence["purge_after"] = {"purge_air": purge_on, "gate": gate}
ctx.log("purge after the check: commanded %s, drawing %s against the %s floor",
purge_on, gate.get("reading"), gate.get("floor"))
ctx.check((gate.get("reading") or 0) >= (gate.get("floor") or 0),
"purge draws %s, under the %s floor the check just wrote: the next job would be held",
gate.get("reading"), gate.get("floor"))
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/lenshome.*"),
("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 and the "
"lens takes its hall-edge reference behind it (a lens that cannot reach its "
"edge is a motion fault, and no controller starts), 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"))