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
+110 -9
View File
@@ -79,6 +79,12 @@ over TCP, then checks the dumps against the kernel feeder contract:
the default gamma of 2 than at gamma 1, and the cruise middle
renders the same - the exponent shapes only the velocity-scaled
rolloff, never the programmed level
24. a hold verdict is held again after a resume: with the engine's
verdict at its fail tier (hold, fire blocked, no resume) the client
holds the job; a ~ under that verdict, which is what a button press
or a sender does, moves the head for at most one client poll, dark,
before the client holds it again and says so; the clean verdict then
resumes the hold the client took, and the rest of the line cuts lit
The analog sessions select the reference mode through the config; on
hardware the controller ignores it (density is the only product model -
@@ -138,6 +144,16 @@ JOB_M4 = [
"M5",
]
# Session Z: the lens is in the stream. The sender references the lens
# (M103, as a commissioning card does), then a 1 mm move up and back at
# the screw's 2.922 half-steps per millimeter: three Z steps with the
# direction bit set, three with it clear.
JOB_Z = [
"M103 Z3 P14 Q20",
"G0 Z4",
"G0 Z3",
]
# Session B: M3 constant power to the end of the stream. The core never
# issues a laser-off update for M3, so the stream engine itself must
# terminate the cycle dark (rule 7).
@@ -372,17 +388,28 @@ def wait_state(sock, log, prefix, timeout=5.0):
fail("controller never reached %s" % prefix)
# The published verdict is clean unless a session sets this: then it is
# the engine's fail tier (hold, fire blocked, no resume), what an airflow
# fault publishes. A ("verdict", "hold") step sets it, ("verdict",
# "clean") clears it.
VERDICT_HOLD = threading.Event()
def publish_verdicts(path, stop):
"""Publish a fresh, clean cooling verdict every 0.5 s (the arm flow
refuses without one; freshness window is 2 s). Same-host monotonic
clock, atomic rename so the reader never sees a torn file. "armed"
is the engine's acknowledgment that it has taken the controller's
armed window; the arm waits for it, so a stand-in engine that means
to let jobs run must assert it."""
"""Publish a fresh cooling verdict every 0.5 s (the arm flow refuses
without one; freshness window is 2 s), clean unless VERDICT_HOLD is
set. Same-host monotonic clock, atomic rename so the reader never
sees a torn file. "armed" is the engine's acknowledgment that it has
taken the controller's armed window; the arm waits for it, so a
stand-in engine that means to let jobs run must assert it."""
while not stop.is_set():
body = ('{"ts_mono":%.3f,"fire_ok":true,"hold":false,'
'"resume_ok":true,"armed":true,"reason":""}'
% time.clock_gettime(time.CLOCK_MONOTONIC))
hold = VERDICT_HOLD.is_set()
body = ('{"ts_mono":%.3f,"fire_ok":%s,"hold":%s,'
'"resume_ok":%s,"armed":true,"reason":"%s"}'
% (time.clock_gettime(time.CLOCK_MONOTONIC),
"false" if hold else "true", "true" if hold else "false",
"false" if hold else "true",
"harness: airflow fault" if hold else ""))
tmp = path + ".tmp"
with open(tmp, "w") as f:
f.write(body)
@@ -412,6 +439,7 @@ def run_session(name, steps, conf=None, workdir=None, keep=False,
env["GFHOME_CONF"] = conf_path
stop = threading.Event()
VERDICT_HOLD.clear()
pub = threading.Thread(target=publish_verdicts, args=(verdict, stop), daemon=True)
pub.start()
@@ -444,6 +472,11 @@ def run_session(name, steps, conf=None, workdir=None, keep=False,
sock.sendall(step[1]) # a realtime character: no ok follows
elif isinstance(step, tuple) and step[0] == "wait_state":
wait_state(sock, log, step[1])
elif isinstance(step, tuple) and step[0] == "verdict":
if step[1] == "hold":
VERDICT_HOLD.set()
else:
VERDICT_HOLD.clear()
else:
send_line(sock, step, log)
@@ -466,6 +499,7 @@ def run_session(name, steps, conf=None, workdir=None, keep=False,
proc.kill()
stop.set()
pub.join(2)
VERDICT_HOLD.clear()
data = open(dump, "rb").read()
if not data and arm_required:
@@ -497,6 +531,18 @@ def check_fire_gaps(name, data):
return worst
def check_z_move(name, data):
"""The lens in the stream: a job's Z move steps it, up with the
direction bit set, down with it clear, the count the scale gives."""
ticks = tick_bytes(data)
up = sum(1 for b in ticks if b & 0x20 and b & 0x40)
down = sum(1 for b in ticks if b & 0x20 and not b & 0x40)
if up != 3 or down != 3:
fail("[%s] Z steps up %d, down %d (expected 3 and 3 for 1 mm at "
"2.922 half-steps per mm)" % (name, up, down))
print("PASS [%s]: a 1 mm Z move steps the lens %d up and %d back" % (name, up, down))
def check_termination(name, data):
"""Rule 7: the stream's final tick byte must carry FIRE clear."""
ticks = tick_bytes(data)
@@ -735,6 +781,10 @@ def main():
data = run_session("m4", JOB_M4, conf=ANALOG_CONF)
fire_ticks, powers, x_max, tail_steps = check_m4_job(data)
check_termination("m4", data)
# --- session Z: the lens in the stream --------------------------------
zdata = run_session("z", JOB_Z, arm_required=False)
check_z_move("z", zdata)
gap_a = check_fire_gaps("m4", data)
print("PASS [m4]: %d bytes, %d power bytes, %d fire ticks, powers %s, "
"X peak %d steps net 0, %d dark return steps, max fire gap %d"
@@ -1005,6 +1055,57 @@ def main():
"(%d ticks), lit from the first step out (%d fire ticks)"
% (mode, decel, dlen, accel))
# --- rule 24: a hold verdict is held again after a resume -----------
# One long line at 50 mm/s. Mid-move the engine's verdict goes to its
# fail tier (hold, fire blocked, no resume: an airflow fault) and the
# client takes the feed hold. A ~ then resumes the job under the
# standing verdict, which is what a button press or a sender does;
# the client must hold it again within its poll, saying so, and the
# stretch it moved in between ships dark. The clean verdict then
# resumes the hold the client took, and the rest of the line cuts lit.
TICK_HZ = 28160
steps = ["G90", "G21", "M3 S500", "G1 X150 F3000", ("sleep", 0.7),
("verdict", "hold"), ("wait_state", "Hold:0"), ("sleep", 0.5),
("rt", b"~"), ("sleep", 1.2), ("wait_state", "Hold:0"),
("sleep", 0.5), ("verdict", "clean"), WAIT_IDLE, "M5"]
data = run_session("verdict-rehold", steps, conf=DENSITY_CONF_FLOORED)
text = run_session.text
if "held again" not in text:
fail("[verdict-rehold] the client did not say it held the job again")
if "resuming" not in text:
fail("[verdict-rehold] the client did not resume its own hold once the verdict cleared")
ticks = tick_bytes(data)
step = [1 if t & 0x05 else 0 for t in ticks]
fire = [1 if t & 0x10 else 0 for t in ticks]
first = step.index(1)
last = len(step) - 1 - step[::-1].index(1)
holds, run = [], 0 # the stationary stretches inside the motion
for i in range(first, last + 1):
if step[i]:
if run >= 2000:
holds.append((i - run, i))
run = 0
else:
run += 1
if len(holds) != 2:
fail("[verdict-rehold] expected two holds in the stream, found %d: %s"
% (len(holds), holds))
(_h1s, h1e), (h2s, h2e) = holds
between = sum(fire[h1e:h2s])
if between:
fail("[verdict-rehold] FIRE while resumed under the hold verdict: %d fire ticks "
"between the holds" % between)
if h2s - h1e > TICK_HZ:
fail("[verdict-rehold] the second hold came late: %d ticks (%.2f s) of motion under "
"the verdict" % (h2s - h1e, (h2s - h1e) / float(TICK_HZ)))
lit_after = sum(fire[h2e:last + 1])
if lit_after < 50:
fail("[verdict-rehold] the resume after the clean verdict ran dark (%d fire ticks)"
% lit_after)
print("PASS [verdict-rehold]: held, resumed dark for %d ticks (%.2f s), held again, "
"lit after the clear (%d fire ticks)"
% (h2s - h1e, (h2s - h1e) / float(TICK_HZ), lit_after))
# --- rule 22: a jog never fires, whatever the modal spindle says ----
# The arm flow runs on the M3 (window open), the modal spindle is on
# at S1000, and the jogs come from Idle: exactly what a sender's Fire