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
+2
View File
@@ -35,6 +35,8 @@ page's takeover does that; from a host, stop them first.
| `live_fire_drills.py` | **LIVE LASER** drills, on the board (the bench page) or from a LAN host (`GF_HOST`): `live_fire_drills.py <drill> [S] [F]` - `witness` (emission witness, lid-IR peaks vs the ambient baseline, HV current, job-based disarm on M2), `hold` (disarm grace in Hold), `faultpos` (armed job refuses a stale origin after an underrun), `ircut` (lid-IR characterization cut at S/F), `pthresh` (laser power-threshold ladder: 13 constant-power rungs from 2 % to 30 % of full on scrap; the lowest rung that marks is the tube's striking threshold and reads directly as the `$35` value - requires `$35` = 0 for the run), `dladder` (density ladder at a chosen base period), `pcurve` (laser performance-curve ladder: one 100 mm line per level at 10 mm/s under M3, the laser off between rungs and a mid-ladder rung repeated at the end; reads `pic/hv_current` and the head thermopile `head/beam_detect_analog` (a scatter detector in the beam path upstream of the final mirror, so it sees the beam, not the material) from sysfs at ~25 Hz on the board, brackets each rung on the controller's Run/Idle states, and reports per rung the current with a clipped-at-1023 flag, the thermopile delta over its laser-off baseline and in-line drift, then the normalized curve, monotonicity, a line fit with its threshold intercept and the repeat-rung drift; JSON record with the raw trace in the bench data directory; rungs follow `laser_power_model`, a comma list overrides; a curve measurement wants `$35` = 0), `dpatch [F] [pitch] [length]` (depth witness for the density dose curve: two rows of small serpentine-filled patches, row A CW at feeds giving relative doses 1.0 to 0.25 of the reference feed, row B at the reference feed at 100/80/60/45/30 % density; the operator matches each row-B patch to the row-A patch of equal depth, which reads the density's light fraction off the material beside the thermopile's prediction; JSON record), `m4feeds [S] [F1] [F2]` (the density time base across feeds: one out-and-back line pair per feed at the same S under M4 density, one armed run; the operator reads within-line evenness and reversal darkness at both feeds - M4's velocity scaling is what holds dose per mm through the accel), `m4corner [S] [F]` (M4 velocity-scaled power into corners: a corner-heavy vector pattern at 30 % under M4 density, one armed run; the operator confirms every commanded segment marks - the floor makes a dropout unreachable - and the drill asserts the arm report, one discharge window and dark after), `m5dark` (the rapids after an M5 ship dark: one 20 mm line at M3 S400, M5, dwell, rapid back, dwell, rapid forward; PASS when the 25 Hz current trace shows one discharge segment and reads dark after the M5 and `laser_on_sampled` never re-lights; the catalog's `laser.m5-rapid-dark` is its port), `flowload` (cooling under laser load, one armed run per invocation, the conf keys it writes put back at the end, the pump never commanded off: `t1` reproduces the flow-check trip with the check on at its defaults and two 30 x 4 mm CW fills at F1500 starting on the press with no dark dwell, and reports the engine's rise/dT verdict beside the 25 Hz trace of both coolant sensors, the current, the digital witness and the heater output in 5 s bins across the window, with the shape at fire start; `t2 <secs> [pct]` runs with the check off and one fill of about `secs` lit seconds at CW or at `pct` density, and reports the lag to each sensor, the rise per raw-second of `hv_current` and what a full 50 s window would add against the 1.6 C margin; `fit` fits rise against dose over every t2 record; JSON records), `expstop` (armed kill on the expected-stop path; needs the panel token - `GF_TOKEN`, or the board's token file) and `ctrlstart` (the separate controller restart after it). Every drill waits for the operator's physical arm press; eye protection, fire watch, extinguisher, and exhaust are mandatory. |
| `pacing_test.py` | Protocol-loop pacing check (runs on the board, dry motion): idle and parked-in-Hold states are coarse-paced, active motion is tight-paced, and a feed-hold/resume mid-move preserves position with no feeder starve. |
| `gfbench.py` | Not a tool: the helper the board/host tools share - `HOST`/`LOCAL` from `GF_HOST`, `board(cmd)` (local `sh -c` or ssh), the factory coolant conversion `degc()`, `data_path()` (`FORGETEST_BENCH_DATA` or next to the tool), forgectrl's HTTP API with the panel token, `setting(key)` (from forgectrl, or from `/data/forgefirm.conf` on the board while forgectrl is stopped). |
| `lens_travel.py` | Lens travel drill (runs on the board; stops the controller through forgectrl and restarts it; lens motion only): steps the lens over sysfs the way the focus card's lens home does and counts, per condition and round, the hall's hysteresis band (down off the rising edge until the hall leaves home, back up until it reads home), the rising edge's height above the bottom stop after a drive onto it, and the top stop's height above the edge (a drive up, down until the hall leaves home, back up; the difference). Conditions: the card's half-step drive onto the stop, a short drive, a long one, the factory's full-step home mode; `--ladder` drives a list of half-step descents below the leave-home point and counts each back (exact until the stop, short by an even number after a stall), `--current` picks the run current, the hold current, or hold down / run up, `--settle` and `--cadence` vary the timing; `--park bottom|top|edge-N|edge+N` holds the lens there for a depth-gauge reading with the controller in standby, `--park edge` returns it to the rising edge and restarts the controller. Stall drills on the bench reference machine only. The posture found is put back. |
| `lens_stop_accel.py` | Lens stop detection by the head accelerometer (runs on the board; stops the controller through forgectrl and restarts it; lens motion only; stall drills on the bench reference machine only): reads the LIS2HH12 straight over `/dev/i2c-3` in six-byte bursts at 800 Hz (the iio path waits a sample period per read), steps the lens one half-step at a time from the hall's rising edge toward each stop and past it, and prints per step the peak-to-peak on each axis. A free step rings strongly on every second half-step; at a stop the ring dies, and a rotor slip a few steps later is a burst three times any free ring. `--find N` runs the contact rule N times per stop (a strong-parity step ringing under `--thresh`, or a burst over four times it, calls contact; the lens backs off `--back` and the count home proves no slip). `--save` keeps the per-step sample traces as JSON. |
| `fan_test.py` | Fan/coolant bench (board or host; controller running): snapshots fan PWMs/tachs/temps, drives M8 → cut fans, M9 → cooldown → idle, verifying via tach readbacks. |
| `fan_floor_measure.py` | The numbers the airflow gates ship with (board or host): `spinup` opens a run session with M8 from idle and samples the four tachs and the purge current at 1 Hz, reporting per fan the steady speed, the time to 90 percent and the spread over the steady window, plus the purge current at idle and at run duty (the pump is always on; a dead one reads about 1), and candidate floors at 55 percent; `cut` samples only, during a real cut, for the spread under load. Results as JSON in the bench data directory. |
| `flow_characterize.py` | Coolant flow characterization using the factory temperature curve (board or host; forgectrl and controller stopped): baseline → flow → no-flow → recovery, printing the ΔT bands and their separation. Takes the heater duty as an argument (`flow_characterize.py 30`); aborts if downstream passes 45 °C. |
+7 -7
View File
@@ -16,7 +16,7 @@ import sys
import time
import urllib.request
from live_fire_drills import Grbl, HOST, PORT
from live_fire_drills import Grbl, HOST, PORT, BASE
def raw():
@@ -28,11 +28,11 @@ def raw():
def status():
with urllib.request.urlopen('http://127.0.0.1:8080/status', timeout=2) as r:
with urllib.request.urlopen(BASE + '/status', timeout=2) as r:
s = json.load(r)
with urllib.request.urlopen('http://127.0.0.1:8080/cool/status', timeout=2) as r:
with urllib.request.urlopen(BASE + '/cool/status', timeout=2) as r:
c = json.load(r)
with urllib.request.urlopen('http://127.0.0.1:8080/settings', timeout=2) as r:
with urllib.request.urlopen(BASE + '/settings', timeout=2) as r:
st = json.load(r)
return (s['coolant']['down_c'], s['coolant']['up_c'], c['down_c'], c['up_c'], c['phase'],
st.get('cool_aa_offset_counts'))
@@ -58,7 +58,7 @@ def post_settings(**kv):
tok = f.read().strip()
except OSError:
tok = ''
req = urllib.request.Request('http://127.0.0.1:8080/settings',
req = urllib.request.Request(BASE + '/settings',
data=urllib.parse.urlencode(kv).encode(),
headers={'X-ForgeFIRM-Token': tok})
with urllib.request.urlopen(req, timeout=4) as r:
@@ -76,7 +76,7 @@ def main():
# A run session starts the flow check, whose heater warms the
# downstream sensor inside this check's dwell: off for this session,
# back afterward.
with urllib.request.urlopen('http://127.0.0.1:8080/settings', timeout=2) as r:
with urllib.request.urlopen(BASE + '/settings', timeout=2) as r:
was = json.load(r).get('cool_flow_check_s')
if was in (None, ''):
was = '50' # unset reads as empty: the shipped default
@@ -95,7 +95,7 @@ def main():
post_settings(cool_flow_check_s=str(was))
else:
post_settings(cool_flow_check_s='50')
with urllib.request.urlopen('http://127.0.0.1:8080/settings', timeout=2) as r:
with urllib.request.urlopen(BASE + '/settings', timeout=2) as r:
print('flow check restored: cool_flow_check_s = %s' % json.load(r).get('cool_flow_check_s'))
time.sleep(25.0) # the session closes, the fans idle
r2, s2 = mean_over(8.0)
+1
View File
@@ -15,5 +15,6 @@ TC="$FF_BUILD_TOP/tmp/work/cortexa9t2hf-neon-fslc-linux-gnueabi/ulfius/2.7.15"
"$TC/recipe-sysroot-native/usr/bin/arm-fslc-linux-gnueabi/arm-fslc-linux-gnueabi-gcc" \
--sysroot="$TC/recipe-sysroot" \
-mthumb -mfpu=neon -mfloat-abi=hard -mcpu=cortex-a9 \
-D_TIME_BITS=64 -D_FILE_OFFSET_BITS=64 \
-O2 -Wall -Wextra -o "$SP/feeder" "$SP/feeder.c"
echo FEEDER-OK
+10 -5
View File
@@ -1,8 +1,10 @@
#!/bin/bash
# Cross-compiles forgectrl (the sibling repo) for the factory board,
# borrowing the Yocto cross toolchain + sysroot from the forgectrl
# recipe work directory (which carries ulfius and libjpeg). If that
# path ages out after a clean, regenerate it with: bitbake forgectrl.
# recipe work directory (which carries ulfius, jansson, gnutls,
# libxcrypt and libjpeg). The newest version directory under the
# recipe's work tree is taken. If that path ages out after a clean,
# regenerate it with: bitbake forgectrl.
#
# Environment (defaults derive from this script's location, assuming
# the standard multi-repo checkout layout):
@@ -12,8 +14,11 @@ set -e
SP="$(cd "$(dirname "$0")" && pwd)"
FF_SRC_TOP="${FF_SRC_TOP:-$(cd "$SP/../../.." && pwd)}"
FF_BUILD_TOP="${FF_BUILD_TOP:-$FF_SRC_TOP/forgefirm/build}"
TC="$FF_BUILD_TOP/tmp/work/cortexa9t2hf-neon-fslc-linux-gnueabi/forgectrl/0.1.0"
[ -d "$TC/recipe-sysroot" ] || { echo "toolchain not staged at $TC (run: bitbake forgectrl)"; exit 1; }
TCROOT="$FF_BUILD_TOP/tmp/work/cortexa9t2hf-neon-fslc-linux-gnueabi/forgectrl"
TC=$(ls -d "$TCROOT"/*/ 2>/dev/null | sort -V | tail -n 1)
TC="${TC%/}"
[ -n "$TC" ] && [ -d "$TC/recipe-sysroot" ] || { echo "toolchain not staged under $TCROOT (run: bitbake forgectrl)"; exit 1; }
echo "toolchain: $TC"
export PATH="$TC/recipe-sysroot-native/usr/bin:$TC/recipe-sysroot-native/usr/bin/arm-fslc-linux-gnueabi:$PATH"
LOG=$(mktemp -t fcbuild.XXXXXX)
cd "$FF_SRC_TOP/forgectrl"
@@ -22,7 +27,7 @@ cmake -B build-arm \
-DCMAKE_SYSTEM_NAME=Linux -DCMAKE_SYSTEM_PROCESSOR=arm \
-DCMAKE_C_COMPILER=arm-fslc-linux-gnueabi-gcc \
-DCMAKE_BUILD_TYPE=None \
"-DCMAKE_C_FLAGS=--sysroot=$TC/recipe-sysroot -mthumb -mfpu=neon -mfloat-abi=hard -mcpu=cortex-a9 -O2 -g" \
"-DCMAKE_C_FLAGS=--sysroot=$TC/recipe-sysroot -mthumb -mfpu=neon -mfloat-abi=hard -mcpu=cortex-a9 -D_TIME_BITS=64 -D_FILE_OFFSET_BITS=64 -O2 -g" \
"-DCMAKE_EXE_LINKER_FLAGS=--sysroot=$TC/recipe-sysroot" \
> "$LOG" 2>&1
cmake --build build-arm -j8 >> "$LOG" 2>&1 || { tail -30 "$LOG"; exit 1; }
+1 -1
View File
@@ -22,7 +22,7 @@ cmake -B build-arm \
-DCMAKE_SYSTEM_NAME=Linux -DCMAKE_SYSTEM_PROCESSOR=arm \
-DCMAKE_C_COMPILER=arm-fslc-linux-gnueabi-gcc \
-DCMAKE_BUILD_TYPE=None \
"-DCMAKE_C_FLAGS=--sysroot=$TC/recipe-sysroot -mthumb -mfpu=neon -mfloat-abi=hard -mcpu=cortex-a9 -O1 -g" \
"-DCMAKE_C_FLAGS=--sysroot=$TC/recipe-sysroot -mthumb -mfpu=neon -mfloat-abi=hard -mcpu=cortex-a9 -D_TIME_BITS=64 -D_FILE_OFFSET_BITS=64 -O1 -g" \
"-DCMAKE_EXE_LINKER_FLAGS=--sysroot=$TC/recipe-sysroot" \
> "$LOG" 2>&1
cmake --build build-arm -j8 >> "$LOG" 2>&1 || { tail -30 "$LOG"; exit 1; }
+2 -2
View File
@@ -10,8 +10,8 @@ P=$(pidof forgectrl)
CTRL=$(pidof grblHAL_glowforge)
echo "forgectrl pid $P, controller pid ${CTRL:-none}"
# Kick the helpers off in the background.
( curl -s -X POST -H "X-ForgeFIRM-Token: $TOK" http://127.0.0.1:8080/update/check >/tmp/upd.out 2>&1 ) &
( curl -s -o /tmp/snap.jpg http://127.0.0.1:8080/cam/snapshot ) &
( curl -s -X POST -H "X-ForgeFIRM-Token: $TOK" http://127.0.0.1:${FORGECTRL_PORT:-80}/update/check >/tmp/upd.out 2>&1 ) &
( curl -s -o /tmp/snap.jpg http://127.0.0.1:${FORGECTRL_PORT:-80}/cam/snapshot ) &
hits=0; seen=0; names=""
i=0
while [ $i -lt 60 ]; do # ~6 s of scanning at 10 Hz
+2 -2
View File
@@ -18,7 +18,7 @@ degc(raw) the factory B-equation coolant conversion
data_path(f) where a tool keeps its data files: FORGETEST_BENCH_DATA
when set (the bench page passes <data>/bench/), else next
to the tool.
forgectrl_* the machine-services HTTP API (:8080) with the panel token
forgectrl_* the machine-services HTTP API (:80) with the panel token
from GF_TOKEN or, on the board, /data/forgefirm/panel.token.
"""
import json
@@ -90,7 +90,7 @@ def data_path(name):
# ------------------------------------------------------------- forgectrl
def forgectrl_base():
return os.environ.get("FORGECTRL_URL") or "http://%s:8080" % HOST
return os.environ.get("FORGECTRL_URL") or "http://%s:%s" % (HOST, os.environ.get("FORGECTRL_PORT") or "80")
def token():
File diff suppressed because it is too large Load Diff
+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
+313
View File
@@ -0,0 +1,313 @@
#!/usr/bin/env python3
"""Lens stop detection by the head accelerometer: a bench drill.
Steps the lens one half-step at a time from the hall's rising edge toward
each stop and a little past it, sampling the head accelerometer through
the step's whole cadence window, and prints per step the peak-to-peak on
each axis. A normal step rings a little; a stall against a stop (the
rotor slipping, the carriage still) should ring differently. If it does,
a shipped lens move could find a stop with a single slipped step instead
of driving onto it.
The accelerometer (an ST LIS2HH12 on i2c-3 at 0x1e, the crash watch's
chip) is read the way the crash watch reads it: straight over the bus in
six-byte bursts, its rate register set to 800 Hz for the run and put
back after. The iio path waits a sample period per read and is far too
slow for this.
Runs on the board with the controller stopped through forgectrl and
restarted after (the same handling as lens_travel.py). Lens motion only;
nothing fires. Stall drills run on the bench reference machine only.
lens_stop_accel.py [--past N] [--window MS] [--save FILE]
"""
import argparse
import fcntl
import json
import os
import struct
import sys
import time
import urllib.request
SYS = "/sys/glowforge/"
TOKEN_FILE = "/data/forgefirm/panel.token"
BASE = "http://127.0.0.1"
STEP_S = 0.180
I2C_DEV = "/dev/i2c-3"
I2C_SLAVE_FORCE = 0x0706
ADDR = 0x1E
CTRL1 = 0x20 # ODR [6:4], BDU, XYZ enables
CTRL1_RUN = 0x6F # 800 Hz, BDU, XYZ on (the crash watch's run value)
OUT_X_L = 0x28
AUTO_INC = 0x80
def rd(attr):
with open(SYS + attr) as f:
return f.read().strip()
def wr(attr, value):
with open(SYS + attr, "w") as f:
f.write(str(value))
def api(method, path):
with open(TOKEN_FILE) as f:
token = f.read().strip()
req = urllib.request.Request(BASE + path, method=method, headers={"X-ForgeFIRM-Token": token})
with urllib.request.urlopen(req, timeout=15) as r:
raw = r.read().decode()
return json.loads(raw) if raw.strip().startswith("{") else raw
def at_home():
return rd("head/hall_sensor") == "0"
class Accel:
"""The chip over the bus: bursts of x, y, z at up to the bus rate."""
def __init__(self):
self.fd = os.open(I2C_DEV, os.O_RDWR)
fcntl.ioctl(self.fd, I2C_SLAVE_FORCE, ADDR)
self.ctrl1_found = self.reg(CTRL1)
def reg(self, r):
os.write(self.fd, bytes([r]))
return os.read(self.fd, 1)[0]
def set_reg(self, r, v):
os.write(self.fd, bytes([r, v]))
def start(self):
self.set_reg(CTRL1, CTRL1_RUN)
time.sleep(0.02)
def restore(self):
self.set_reg(CTRL1, self.ctrl1_found)
def read(self):
os.write(self.fd, bytes([OUT_X_L | AUTO_INC]))
return struct.unpack("<hhh", os.read(self.fd, 6))
def sample(self, seconds, keep=None):
"""Peak-to-peak per axis and the sample count over `seconds`;
the samples appended to `keep` when given."""
lo = [None] * 3
hi = [None] * 3
n = 0
end = time.monotonic() + seconds
while time.monotonic() < end:
v = self.read()
if keep is not None:
keep.append(v)
for i in range(3):
if lo[i] is None or v[i] < lo[i]:
lo[i] = v[i]
if hi[i] is None or v[i] > hi[i]:
hi[i] = v[i]
n += 1
return tuple(hi[i] - lo[i] for i in range(3)), n
def step_sampled(accel, up, window_s, keep=None):
"""One half-step, the accelerometer sampled through the window."""
wr("cnc/z_step", "1" if up else "0")
p2p, n = accel.sample(window_s, keep)
rest = STEP_S - window_s
if rest > 0:
time.sleep(rest)
return p2p, n
def until(home, up, limit):
for n in range(limit):
if at_home() == home:
return n
wr("cnc/z_step", "1" if up else "0")
time.sleep(STEP_S)
return -1
def to_edge():
if at_home():
if until(False, False, 60) < 0:
return False
return until(True, True, 80) >= 0
def fmt(p2p):
return "x %5d y %5d z %5d" % p2p
def run_leg(accel, up, count, window_s, expect, trace):
"""From the edge, `count` single steps `up` or down, each sampled."""
rows = []
label = "above" if up else "below"
print("\n== %s the edge, one half-step at a time (the bench stop is about %d %s)" % (label, expect, label))
for i in range(1, count + 1):
keep = []
p2p, n = step_sampled(accel, up, window_s, keep)
k = i if up else -i
rows.append((i, p2p))
trace.append({"k": k, "p2p": p2p, "samples": keep})
mark = " <- past the expected stop" if i > expect else ""
print(" %+3d: %s (%d samples)%s" % (k, fmt(p2p), n, mark))
return rows
def summarize(rows, expect):
free = [p for i, p in rows if i <= expect - 2]
stall = [p for i, p in rows if i > expect]
if not free or not stall:
return
for i, a in enumerate("xyz"):
fv = sorted(p[i] for p in free)
sv = sorted(p[i] for p in stall)
print(" %s: free steps p2p min %d median %d max %d; past the stop min %d median %d max %d"
% (a, fv[0], fv[len(fv) // 2], fv[-1], sv[0], sv[len(sv) // 2], sv[-1]))
def find_stop(accel, up, window_s, limit, thresh):
"""Step `up` or down from the edge one half-step at a time until the
carriage stops moving. A free step rings strongly on every second
half-step (the parity is learned from the first two), so contact is
called the moment a strong-parity step rings under `thresh`; a burst
over four times `thresh` is a rotor slip, which means the stop was
reached a step or two earlier. Returns (count, slipped), or (-1, 0)."""
sums = []
strong_parity = None
for i in range(1, limit + 1):
p2p, n = step_sampled(accel, up, window_s)
total = sum(p2p)
sums.append(total)
k = i if up else -i
note = ""
if total > 4 * thresh:
note = " <- slip burst"
elif strong_parity is None and i == 2:
strong_parity = 2 if sums[1] > sums[0] else 1
note = " (strong steps are the %s ones)" % ("even" if strong_parity == 2 else "odd")
print(" %+3d: %s sum %6d%s" % (k, fmt(p2p), total, note))
if total > 4 * thresh:
return i, 1
if strong_parity is not None and i % 2 == strong_parity % 2 and total < thresh:
return i, 0
return -1, 0
def find_leg(accel, up, window_s, limit, thresh, back):
label = "top" if up else "bottom"
print("\n== finding the %s stop by the ring (contact = two quiet steps in a row, sum under %d)"
% (label, thresh))
if not to_edge():
print(" could not find the edge first")
return None
i, slipped = find_stop(accel, up, window_s, limit, thresh)
if i < 0:
print(" no contact within %d half-steps" % limit)
return None
for _ in range(back):
wr("cnc/z_step", "0" if up else "1")
time.sleep(STEP_S)
print(" contact called at %+d%s; backed off %d" % (i if up else -i, " by a slip" if slipped else "", back))
# The proof of gentleness: the count back to the edge equals the
# steps taken only if nothing slipped.
if up:
c = until(False, False, 80)
c2 = until(True, True, 60)
print(" back down to leave home: %d, up to the edge: %d (expected %d and the band)"
% (c, c2, i - back))
else:
c = until(True, True, 80)
print(" back up to the edge: %d (expected %d: no slip)" % (c, i - back))
return i
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--past", type=int, default=6, help="half-steps past each expected stop")
ap.add_argument("--window", type=int, default=170, help="ms sampled after each step (of the 180 cadence)")
ap.add_argument("--below", type=int, default=18, help="the bench bottom stop, half-steps below the edge")
ap.add_argument("--above", type=int, default=20, help="the bench top stop, half-steps above the edge")
ap.add_argument("--save", default=None, help="write the per-step sample traces to this JSON file")
ap.add_argument("--find", type=int, default=0,
help="instead of the legs: find each stop by the ring this many times")
ap.add_argument("--thresh", type=int, default=8000, help="the quiet-step threshold on the summed p2p")
ap.add_argument("--back", type=int, default=2, help="half-steps to back off after contact")
args = ap.parse_args()
window_s = min(args.window, 180) / 1000.0
dark = api("GET", "/wiz/dark")
if dark.get("running"):
sys.exit("a wizard is running: %s" % dark.get("id"))
mode = api("GET", "/mode")
print("mode before:", mode)
accel = Accel()
print("accelerometer: %s at 0x%02x, CTRL1 found 0x%02x" % (I2C_DEV, ADDR, accel.ctrl1_found))
found = {a: rd(a) for a in ("cnc/motor_lock", "head/z_current", "head/z_mode", "head/z_enable")}
stopped = False
trace = []
try:
if mode.get("controller") == "running":
print("stopping the controller:", api("POST", "/controller/stop"))
stopped = True
time.sleep(2)
accel.start()
p2p, n = accel.sample(2.0)
print("at rest, 2 s: %s (%d samples, %.0f Hz)" % (fmt(p2p), n, n / 2.0))
wr("cnc/motor_lock", "0")
wr("head/z_current", "0")
wr("head/z_mode", "1")
wr("head/z_enable", "0")
time.sleep(0.3)
p2p, n = accel.sample(1.0)
print("energized, at rest, 1 s: %s" % fmt(p2p))
if not to_edge():
sys.exit("could not find the hall edge")
if args.find:
for r in range(args.find):
print("### find round %d" % (r + 1))
find_leg(accel, False, window_s, 30, args.thresh, args.back)
find_leg(accel, True, window_s, 32, args.thresh, args.back)
to_edge()
print(" back on the edge")
return
below = run_leg(accel, False, args.below + args.past, window_s, args.below, trace)
summarize(below, args.below)
back = until(True, True, 80)
print(" back up to the edge: %d half-steps" % back)
above = run_leg(accel, True, args.above + args.past, window_s, args.above, trace)
summarize(above, args.above)
to_edge()
print(" back on the edge")
finally:
try:
accel.restore()
except OSError as e:
print("restore of the accelerometer rate failed: %s" % e)
for a, v in found.items():
try:
wr(a, v)
except OSError as e:
print("restore %s=%s failed: %s" % (a, v, e))
print("posture restored:", {a: rd(a) for a in found}, "hall:", rd("head/hall_sensor"),
"CTRL1 0x%02x" % accel.reg(CTRL1))
if stopped:
print("starting the controller:", api("POST", "/controller/start"))
time.sleep(4)
print("mode after:", api("GET", "/mode"))
if args.save:
with open(args.save, "w") as f:
json.dump(trace, f)
print("traces saved to", args.save)
if __name__ == "__main__":
main()
+306
View File
@@ -0,0 +1,306 @@
#!/usr/bin/env python3
"""Lens travel drill: where the carriage's stops sit against the hall edge.
Runs on the board with the controller stopped (through forgectrl, so the
supervisor holds it), steps the lens over sysfs the way the focus card's
lens home does, and counts, under each condition and for several rounds:
band the hall's hysteresis: from the rising edge, the half-steps
down until the hall leaves home, and back up until it reads
home again
below the half-steps from the bottom stop up to the hall edge
(down off the edge, a drive of N steps onto the stop, the stall
takes the rest, then up until the hall reads home)
above the half-steps from the hall edge up to the top stop (a drive
of 36 half-steps up from the edge, down until the hall leaves
home, then up until it reads home again; the top sits the
difference above the rising edge)
Conditions: the focus card's (half-step, run current, the whole travel
driven onto the stop), a short drive that may not reach the stop, a long
drive well past it, and the factory's lens home mode (full steps at the
run current; the counts are doubled to half-steps). The current is the
run current throughout: the lens does not rise at the hold current.
Lens motion only; nothing fires. The posture found is put back (motor
lock, current, mode, enable) and the controller restarted.
lens_travel.py [--rounds N] [--only wizard|short|long|full] [--no-above]
"""
import argparse
import json
import sys
import time
import urllib.request
SYS = "/sys/glowforge/"
TOKEN_FILE = "/data/forgefirm/panel.token"
BASE = "http://127.0.0.1"
STEP_S = 0.180 # the cadence of every lens reference (the service's and the card's)
TRAVEL_HALF = 36 # the carriage's travel in half-steps of its screw
CONDITIONS = [
# key, label, z_mode (1 half, 0 full), the drive onto the stop in that mode's steps
("wizard", "half-step, run current, drive 36 half-steps onto the stop (the focus card)", 1, 36),
("short", "half-step, run current, drive 16 half-steps (may not reach the stop)", 1, 16),
("long", "half-step, run current, drive 60 half-steps (well past the stop)", 1, 60),
("full", "full-step, run current, drive 18 full steps (the factory's lens home mode)", 0, 18),
]
def rd(attr):
with open(SYS + attr) as f:
return f.read().strip()
def wr(attr, value):
with open(SYS + attr, "w") as f:
f.write(str(value))
def api(method, path):
with open(TOKEN_FILE) as f:
token = f.read().strip()
req = urllib.request.Request(BASE + path, method=method, headers={"X-ForgeFIRM-Token": token})
with urllib.request.urlopen(req, timeout=15) as r:
raw = r.read().decode()
return json.loads(raw) if raw.strip().startswith("{") else raw
def at_home():
return rd("head/hall_sensor") == "0"
def step(up):
wr("cnc/z_step", "1" if up else "0")
time.sleep(STEP_S)
SETTLE_S = 0.0 # extra rest before every hall read (--settle)
def until(home, up, limit):
"""Step `up` until the hall reads `home`; the steps taken, or -1."""
for n in range(limit):
if SETTLE_S:
time.sleep(SETTLE_S)
if at_home() == home:
return n
step(up)
return -1
CURRENT = "high" # high | low | low-down (--current)
def count_below(drive):
"""Down off the edge, `drive` steps onto the stop, up to the edge.
Returns (count, the steps it took to leave home, error)."""
if CURRENT == "low-down":
wr("head/z_current", "1")
left = until(False, False, 60)
if left < 0:
return None, left, "the hall never left home going down"
for _ in range(drive):
step(False)
if CURRENT == "low-down":
wr("head/z_current", "0")
time.sleep(0.1)
c = until(True, True, 80)
return (c if c >= 0 else None), left, ("" if c >= 0 else "the hall never read home going up")
def to_edge():
"""Put the lens on the rising edge: below it if needed, then up."""
if at_home():
if until(False, False, 60) < 0:
return False
return until(True, True, 80) >= 0
def count_band():
"""From the rising edge: down until the hall leaves home, then up
until it reads home again."""
if not to_edge():
return None, None, "could not find the edge"
down = until(False, False, 60)
if down < 0:
return None, None, "the hall never left home going down"
up = until(True, True, 60)
return down, (up if up >= 0 else None), ("" if up >= 0 else "the hall never read home going up")
def count_above(drive):
"""From the edge, `drive` steps up onto the top stop, down until the
hall leaves home (a), up until it reads home again (b): the top is
a - b above the rising edge. The lens ends on the edge."""
if not to_edge():
return None, None, "could not find the edge first"
for _ in range(drive):
step(True)
a = until(False, False, 80)
if a < 0:
return None, None, "the hall never left home coming down"
b = until(True, True, 60)
if b < 0:
return a, None, "the hall never read home going back up"
return a, b, ""
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--rounds", type=int, default=3)
ap.add_argument("--only", choices=[c[0] for c in CONDITIONS])
ap.add_argument("--no-above", action="store_true", help="skip the top-stop count")
ap.add_argument("--settle", type=int, default=0, help="extra ms of rest before every hall read")
ap.add_argument("--cadence", type=int, default=180, help="ms per step (180 is every reference's)")
ap.add_argument("--ladder", default=None,
help="instead of the conditions: comma-separated half-step drives below the "
"leave-home point, each counted back up; half-step mode, run current")
ap.add_argument("--full", action="store_true", help="the ladder in full-step mode")
ap.add_argument("--current", choices=["high", "low", "low-down"], default="high",
help="the drive current: the run current, the hold current, or the hold "
"current for the drive onto the stop and the run current for the count")
ap.add_argument("--park",
help="park the lens for a depth-gauge measurement: bottom, top, edge-N or "
"edge+N (N half-steps from the rising edge, stall-free; the controller "
"stays stopped), or edge (back on the hall edge; the controller restarts)")
args = ap.parse_args()
global SETTLE_S, STEP_S, CURRENT
SETTLE_S = args.settle / 1000.0
STEP_S = args.cadence / 1000.0
CURRENT = args.current
print("step cadence %d ms, extra settle before each hall read %d ms, current %s"
% (STEP_S * 1000, args.settle, CURRENT))
dark = api("GET", "/wiz/dark")
if dark.get("running"):
sys.exit("a wizard is running: %s" % dark.get("id"))
mode = api("GET", "/mode")
print("mode before:", mode)
found = {a: rd(a) for a in ("cnc/motor_lock", "head/z_current", "head/z_mode", "head/z_enable")}
print("posture found:", found, "hall:", rd("head/hall_sensor"))
stopped = False
results = []
try:
if mode.get("controller") == "running":
print("stopping the controller:", api("POST", "/controller/stop"))
stopped = True
time.sleep(2)
wr("cnc/motor_lock", "0")
wr("head/z_current", "1" if CURRENT == "low" else "0")
wr("head/z_enable", "0")
if args.park:
wr("head/z_mode", "1")
time.sleep(0.2)
if args.park == "bottom":
left = until(False, False, 60)
for _ in range(24):
step(False)
print("parked on the bottom stop (left home after %d, then 24 half-steps down)" % left)
elif args.park == "top":
to_edge()
for _ in range(26):
step(True)
print("parked on the top stop (26 half-steps up from the rising edge)")
elif args.park.startswith("edge-") or args.park.startswith("edge+"):
n = int(args.park[5:])
up = args.park[4] == "+"
to_edge()
for _ in range(n):
step(up)
print("parked %d half-steps %s the rising edge, stall-free" % (n, "above" if up else "below"))
else:
wr("head/z_current", "0")
to_edge()
print("parked on the hall's rising edge")
stopped = True # the controller restarts whatever state it was found in
if args.park != "edge":
# the hold posture keeps the lens where it is; the controller
# stays stopped so nothing moves the head during the measurement
for a, v in found.items():
wr(a, v)
print("posture:", {a: rd(a) for a in found}, "hall:", rd("head/hall_sensor"))
print("the controller stays stopped: run --park edge to return and restart it")
stopped = False
found = {}
return
mult = 2 if args.full else 1
wr("head/z_mode", 0 if args.full else 1)
time.sleep(0.2)
print("\n== ladder of drives below the leave-home point (%s-step mode)"
% ("full" if args.full else "half"))
for r in range(args.rounds):
row = []
for n in [int(x) for x in args.ladder.split(",")]:
c, left, err = count_below(n // mult)
row.append((n, None if c is None else c * mult, left * mult))
print(" round %d: " % (r + 1) + " ".join("drive %d -> up %s (left %d)" % t for t in row))
args.only = "none"
for key, label, zmode, drive in CONDITIONS:
if args.only and key != args.only:
continue
wr("head/z_mode", zmode)
time.sleep(0.2)
mult = 1 if zmode == 1 else 2
unit = "half-steps" if mult == 1 else "full steps"
print("\n== %s" % label)
bands = []
for r in range(args.rounds):
d, u, err = count_band()
if d is None or u is None:
print(" band %d: FAILED: %s (down %s)" % (r + 1, err, d))
continue
bands.append((d * mult, u * mult))
print(" band %d: home for %d half-steps below the edge going down, home again %d "
"half-steps up" % (r + 1, d * mult, u * mult))
below = []
for r in range(args.rounds):
t0 = time.time()
c, left, err = count_below(drive)
if c is None:
print(" round %d: FAILED: %s" % (r + 1, err))
below.append(None)
continue
below.append(c * mult)
print(" round %d: the edge %d half-steps above the bottom stop (%d %s; left home "
"after %d; %.1f s)" % (r + 1, c * mult, c, unit, left * mult, time.time() - t0))
above = None
if not args.no_above:
for r in range(2):
t0 = time.time()
a, b, err = count_above(TRAVEL_HALF // mult)
if a is None or b is None:
print(" top %d: FAILED: %s (a=%s b=%s)" % (r + 1, err, a, b))
continue
above = (a - b) * mult
print(" top %d: down %d half-steps to leave home, %d back up to the edge: the top "
"stop %d half-steps above the rising edge (%.1f s)"
% (r + 1, a * mult, b * mult, above, time.time() - t0))
results.append((key, bands, below, above))
wr("head/z_mode", "1")
wr("head/z_current", "0")
# leave the lens on the edge, as every reference does
to_edge()
finally:
for a, v in found.items():
try:
wr(a, v)
except OSError as e:
print("restore %s=%s failed: %s" % (a, v, e))
print("\nposture restored:", {a: rd(a) for a in found}, "hall:", rd("head/hall_sensor"))
if stopped:
print("starting the controller:", api("POST", "/controller/start"))
time.sleep(4)
print("mode after:", api("GET", "/mode"))
print("\n%-8s %-22s %-22s %-6s %s" % ("cond", "band down/up", "below (half-steps)", "above", "total"))
for key, bands, below, above in results:
ok = [b for b in below if b is not None]
tot = (ok[-1] + above) if ok and above is not None else None
print("%-8s %-22s %-22s %-6s %s" % (key, bands, below, above, tot))
if __name__ == "__main__":
main()
+4 -3
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""Live-fire bench drills - Phases 4, 5, 6. Runs on the board (the bench
page) or from a LAN host, against grblHAL over TCP (port 23) and
forgectrl over HTTP (:8080); the machine is GF_HOST, default 127.0.0.1.
forgectrl over HTTP (:80, FORGECTRL_PORT overrides); the machine is GF_HOST, default 127.0.0.1.
LIVE LASER: the operator must be armed with eye protection, a fire
watch, an extinguisher, and the exhaust running. Every drill waits for
the operator to press the physical arm button before the machine fires;
@@ -184,7 +184,8 @@ import urllib.request
HOST = os.environ.get('GF_HOST') or '127.0.0.1'
PORT = 23
BASE = 'http://%s:8080' % HOST
CTRL_PORT = int(os.environ.get('FORGECTRL_PORT') or 80)
BASE = 'http://%s:%d' % (HOST, CTRL_PORT)
def panel_token():
@@ -3021,7 +3022,7 @@ def post_ctrl(action):
# http.client preserves the header-name case exactly as given.
import http.client
tok = panel_token()
c = http.client.HTTPConnection(HOST, 8080, timeout=8)
c = http.client.HTTPConnection(HOST, CTRL_PORT, timeout=8)
c.putrequest('POST', '/controller/' + action)
c.putheader('X-ForgeFIRM-Token', tok)
c.putheader('Content-Length', '0')
+5 -2
View File
@@ -32,6 +32,9 @@ import sys
import threading
import time
# forgectrl on the board (FORGECTRL_PORT overrides the port)
BASE = 'http://127.0.0.1:%s' % (os.environ.get('FORGECTRL_PORT') or '80')
SYSFS = '/sys/glowforge/'
LEDS = '/sys/class/leds/'
HZ = 25
@@ -225,9 +228,9 @@ def run_armed():
def status():
try:
with urllib.request.urlopen('http://127.0.0.1:8080/status', timeout=2) as r:
with urllib.request.urlopen(BASE + '/status', timeout=2) as r:
s = _json.load(r)
with urllib.request.urlopen('http://127.0.0.1:8080/cool/status', timeout=2) as r:
with urllib.request.urlopen(BASE + '/cool/status', timeout=2) as r:
c = _json.load(r)
return {'t': time.time(), 'armed': c.get('armed'), 'phase': c.get('phase'),
'hv_enable': (s.get('switches') or {}).get('hv_enable'),
+21 -10
View File
@@ -180,16 +180,27 @@ else
echo "acceptance gate OK ($ART)"
fi
# Back-door gate: the release image must not ship a passwordless root. A
# debug-tweaks image sets root's password field empty (root::...); a
# hardened image leaves it locked (root:*: / root:!:) or hashed. Read the
# actual built shadow file - this catches the flag however it slipped in
# (recipe, local.conf, an inherited class).
ROOT_PW=$(debugfs -R "cat /etc/shadow" "$EXT4" 2>/dev/null \
| awk -F: '$1=="root"{print $2; exit}')
[ -n "$ROOT_PW" ] \
|| die "release rootfs has a passwordless root (debug-tweaks leaked into forgefirm-image?)"
echo "root login gate OK (root password field is not empty)"
# Root policy gate. The release image ships root WITHOUT a password (the
# serial console is the recovery path) and sshd refuses root and empty
# passwords: PermitRootLogin no and PermitEmptyPasswords no must be
# active (uncommented) in the built sshd_config, and root's shadow field
# must be empty. Read the built files, not the recipes: this catches a
# drift however it got in (recipe, local.conf, an inherited class).
SSHD_CONFIG=$(debugfs -R "cat /etc/ssh/sshd_config" "$EXT4" 2>/dev/null)
[ -n "$SSHD_CONFIG" ] \
|| die "release rootfs carries no /etc/ssh/sshd_config"
printf '%s\n' "$SSHD_CONFIG" | grep -Eq '^PermitRootLogin[[:space:]]+no[[:space:]]*$' \
|| die "release sshd_config has no active 'PermitRootLogin no' (recipes-connectivity/openssh drift?)"
printf '%s\n' "$SSHD_CONFIG" | grep -Eq '^PermitEmptyPasswords[[:space:]]+no[[:space:]]*$' \
|| die "release sshd_config has no active 'PermitEmptyPasswords no' (recipes-connectivity/openssh drift?)"
ROOT_SHADOW=$(debugfs -R "cat /etc/shadow" "$EXT4" 2>/dev/null \
| awk -F: '$1=="root"{print; exit}')
[ -n "$ROOT_SHADOW" ] \
|| die "release rootfs has no root entry in /etc/shadow"
ROOT_PW=$(printf '%s\n' "$ROOT_SHADOW" | awk -F: '{print $2}')
[ -z "$ROOT_PW" ] \
|| die "release rootfs has a non-empty root password field: the policy is an empty field (empty-root-password in forgefirm-image.bb); a build drift"
echo "root policy gate OK (root field empty; sshd refuses root and empty passwords)"
# Config-level guard: debug-tweaks must not sit in the shared kas config,
# where it would apply to every target including the release image.