docs: bring-up runbook + bench tools from the audit corrective work

BRINGUP.md is the cold-start reference: project status (audit phases
0-5 complete, Phase 6 spike achieved - first grblHAL-commanded motion
2026-07-26), bench/board access, build+deploy procedures, the step
backend runbook incl. the required analog machine config, the measured
hardware facts bank, and the ordered next-work list. scripts/bench/
preserves the hardware-verification tools (underrun feeder, end-of-
data protocol bench, PWM register check, cross-build scripts).
This commit is contained in:
ScottW514
2026-07-26 18:55:53 -04:00
parent 14470e9122
commit 1a5fc27530
6 changed files with 510 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
# ForgeFIRM bench tools
Hardware-verification tools for the ForgeFIRM bench. All run ON the
target board (dev image, python3 present) unless noted.
| Tool | Purpose |
|---|---|
| `feeder.c` | Spike-step-3 underrun proof: streams NOP pulse bytes to `/dev/glowforge` with wall-clock pacing, bounded queue depth, deadman flock, SCHED_FIFO. Usage: `feeder <hz> <seconds> <depth_ms>`. Passed 100 kHz × 120 s under full load with 0.2 ms worst write latency. Cross-compile with `build-feeder.sh` (WSL). |
| `bench_phase2.py` | End-of-data protocol bench (audit M2–M5): underrun detection/ack, parked no-replay guard, resume(0), continuous-feed stability, 20× run/underrun cycles. Motion-safe (motors locked, laser latched). 16/16 PASS on 2026-07-26. |
| `check_pwm.py` | Laser PWM register check (audit M8): reads PWM2 PWMCR/PWMPR via /dev/mem, expects divider 13 × ~127 counts ≈ 40 kHz. The scope on LASER_PWM remains the final pre-live-fire gate. |
| `build-feeder.sh` | Cross-compiles `feeder.c` the same way. |
The build scripts borrow the Yocto cross toolchain + sysroot from the ulfius
2.7.15 work directory in the WSL build tree; if that path ages out after a
`bitbake -c clean`, point `TC` at any current target recipe workdir (or build
a proper SDK with `bitbake meta-toolchain`).
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""Phase 2.8 bench test: the reworked end-of-data protocol (audit M2-M5).
Motion-safe by construction: all four motors are locked via motor_lock (the
SDMA script masks the STEP bits), the laser latch is locked (LASER_ON pin is
Hi-Z and the hardware safety chain is open anyway), and only NOP (0x00) and
laser-bit (0x10) bytes are streamed.
Exercises: normal completion, underrun detection/ack (M5), parked no-replay
guard (M4), alldone GPIO clear (M2, via GPIO2_DR readback - the data register
retains the last value the script wrote), resume(0) (M3), continuous-feed
stability, and 20x run/underrun cycling (M3 wedge check).
"""
import os, re, sys, time, mmap, struct
C = "/sys/glowforge/cnc"
DEV = "/dev/glowforge"
GPIO2_BASE = 0x020A0000 # GPIO2 DR at offset 0
passed = failed = 0
def chk(name, cond, detail=""):
global passed, failed
tag = "PASS" if cond else "FAIL"
if cond: passed += 1
else: failed += 1
print(f"{tag}: {name} [{detail}]", flush=True)
def rd(attr):
with open(f"{C}/{attr}") as f: return f.read().strip()
def wr(attr, val):
with open(f"{C}/{attr}", "w") as f: f.write(str(val))
def state(): return rd("state")
def gpio2_dr():
with open("/dev/mem", "rb") as f:
m = mmap.mmap(f.fileno(), 4096, mmap.MAP_SHARED, mmap.PROT_READ,
offset=GPIO2_BASE)
v = struct.unpack("<I", m[:4])[0]
m.close()
return v
def sc(ctx, n):
return int(re.search(rf"sc{n}=([0-9a-f]{{8}})", ctx).group(1), 16)
def wait_state(target, timeout=15):
t0 = time.time()
while time.time() - t0 < timeout:
s = state()
if s == target: return s
time.sleep(0.05)
return state()
# ---- setup: everything locked ----
wr("laser_latch", 1) # LASER_ON pin Hi-Z
wr("motor_lock", 15) # X|Y1|Y2|Z locked: no motion regardless of data
if state() == "disabled":
wr("enable", 1)
time.sleep(0.5)
SEC = int(rd("step_freq")) # bytes per second of playback
print(f"setup: state={state()} step_freq={SEC} underruns={rd('underruns')}", flush=True)
fd = os.open(DEV, os.O_WRONLY) # exclusive open, held for the whole bench
def clear_data():
os.lseek(fd, 1, os.SEEK_SET) # clear pulse data + byte counters
def feed(b, n):
return os.write(fd, bytes([b]) * n)
# ---- T1: normal completion (streaming=0) -> idle ----
wr("streaming", 0)
clear_data()
feed(0x00, 2 * SEC)
wr("run", 1)
chk("T1 run starts", state() == "running", state())
s = wait_state("idle", 10)
chk("T1 normal completion -> idle", s == "idle", s)
# ---- T2: underrun (streaming=1): state, counter, run refused ----
u0 = int(rd("underruns"))
wr("streaming", 1)
clear_data()
feed(0x00, 1 * SEC)
wr("run", 1)
s = wait_state("underrun", 10)
chk("T2 underrun state entered", s == "underrun", s)
chk("T2 underrun counter incremented", int(rd("underruns")) == u0 + 1, rd("underruns"))
try:
wr("run", 1)
chk("T2 run refused while unacked", False, "run was accepted!")
except OSError as e:
chk("T2 run refused while unacked", True, str(e))
# ---- T3: parked no-replay guard (M4): append while parked, counters frozen ----
ctx_a = rd("sdma_context")
feed(0x01, 1000) # X-step bytes appended while parked (motors locked anyway)
time.sleep(1.0)
ctx_b = rd("sdma_context")
chk("T3 byte counter frozen while parked", sc(ctx_a, 3) == sc(ctx_b, 3),
f"sc3 {sc(ctx_a,3)} -> {sc(ctx_b,3)}")
chk("T3 head frozen while parked", sc(ctx_a, 4) == sc(ctx_b, 4),
f"sc4 {sc(ctx_a,4)} -> {sc(ctx_b,4)}")
wr("stop", 1)
chk("T3 stop acks underrun -> idle", state() == "idle", state())
clear_data()
# ---- T4: alldone clears laser+step GPIO bits (M2) ----
wr("streaming", 1)
feed(0x10, 1 * SEC) # laser bit set, no steps
wr("run", 1)
s = wait_state("underrun", 10)
chk("T4 underrun after laser-bit stream", s == "underrun", s)
time.sleep(0.2)
dr = gpio2_dr()
chk("T4 LASER_ENABLE/LASER_ON_HEAD bits low in GPIO2_DR", (dr >> 30) == 0,
f"DR=0x{dr:08x}")
chk("T4 STEP bits low in GPIO2_DR",
(dr & ((1 << 20) | (1 << 21) | (1 << 22) | (1 << 29))) == 0, f"DR=0x{dr:08x}")
wr("stop", 1)
clear_data()
# ---- T5: resume(0) completes instead of wedging (M3) ----
wr("streaming", 0)
feed(0x00, SEC // 2)
wr("resume", 0)
chk("T5 resume(0) starts", state() == "running", state())
s = wait_state("idle", 10)
chk("T5 resume(0) completes -> idle (no wedge)", s == "idle", s)
clear_data()
# ---- T6: continuous feed never underruns; stopping the feed does ----
wr("streaming", 1)
feed(0x00, SEC)
wr("run", 1)
t0 = time.time(); ok = True
while time.time() - t0 < 5:
feed(0x00, SEC // 4)
if state() != "running":
ok = False
break
time.sleep(0.2)
chk("T6 no underrun while feeding (5 s)", ok, state())
s = wait_state("underrun", 10)
chk("T6 underrun after feed stops", s == "underrun", s)
wr("stop", 1)
clear_data()
# ---- T7: 20x run/underrun cycles, no wedge (M3) ----
anomalies = 0
for i in range(20):
feed(0x00, SEC // 10)
wr("run", 1)
s = wait_state("underrun", 5)
if s != "underrun":
anomalies += 1
print(f" cycle {i}: state={s}", flush=True)
wr("stop", 1)
chk("T7 20 run/underrun cycles clean", anomalies == 0, f"{anomalies} anomalies")
# ---- wrap up ----
wr("streaming", 0)
clear_data()
os.close(fd)
wr("motor_lock", 0)
print(f"\nRESULT: {passed} passed, {failed} failed; underruns total={rd('underruns')}",
flush=True)
sys.exit(1 if failed else 0)
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
set -e
TC=/home/builder/dev/openglow-forgefirm/forgefirm/build/tmp/work/cortexa9t2hf-neon-fslc-linux-gnueabi/ulfius/2.7.15
SP="$(cd "$(dirname "$0")" && pwd)"
"$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 \
-O2 -Wall -Wextra -o "$SP/feeder" "$SP/feeder.c"
echo FEEDER-OK
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env python3
"""Phase 3.2 software check: laser PWM carrier frequency from PWM2 registers.
Expected with the fsl,extra-prescale=<13> fix: PWMCR prescaler divider = 13,
PWMPR ~125 (127 counts - 2), effective carrier = perclk / (13 * 127) ~= 40 kHz.
Without the fix: divider 1 -> ~520 kHz (audit M8). Safe: read-only register
inspection; the laser PWM output feeds the PSU power input, firing stays gated
by the hardware chain. The definitive gate remains the scope on LASER_PWM.
"""
import mmap, struct
PWM2_BASE = 0x02084000
PERCLK_HZ = 66_000_000 # ipg_high; cross-check against the EPIT rate in dmesg
with open("/dev/mem", "rb") as f:
m = mmap.mmap(f.fileno(), 4096, mmap.MAP_SHARED, mmap.PROT_READ,
offset=PWM2_BASE)
cr, sr, ir, sar, pr = struct.unpack("<5I", m[:20])
m.close()
prescaler_field = (cr >> 4) & 0xFFF
divider = prescaler_field + 1
period_counts = pr + 2
freq = PERCLK_HZ / (divider * period_counts) if period_counts else 0
enabled = cr & 1
print(f"PWMCR=0x{cr:08x} PWMSAR={sar} PWMPR={pr} enabled={bool(enabled)}")
print(f"prescaler divider = {divider} (field {prescaler_field})")
print(f"period counts = {period_counts}")
print(f"carrier frequency = {freq/1000:.2f} kHz")
ok = divider == 13 and 120 <= period_counts <= 135 and 38_000 <= freq <= 42_000
print(f"\n{'PASS' if ok else 'FAIL'}: expected divider 13, ~127 counts, ~40 kHz")
+164
View File
@@ -0,0 +1,164 @@
/*
* feeder.c - ForgeFIRM spike step 3: prove no-underrun continuous live
* feeding of the glowforge.ko SDMA pulse ring under load (audit 6.3).
*
* Streams NOP (0x00) pulse bytes to /dev/glowforge, pacing by wall clock to
* hold a bounded queue depth (like a real grblHAL backend would - audit M6),
* with the deadman flock held. Motors must be locked and the laser latch
* locked by the caller (bench_phase6.sh does this).
*
* Reports: feed statistics, worst scheduling stall, ENOMEM count, and the
* final driver state (expect "running" throughout, "underrun" only after
* the deliberate starve at the end).
*
* Usage: feeder <step_freq_hz> <duration_s> <depth_ms>
*/
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/file.h>
#include <sys/stat.h>
#include <time.h>
#include <unistd.h>
#include <sched.h>
#define DEV "/dev/glowforge"
#define CNC "/sys/glowforge/cnc/"
static double now_s(void)
{
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return ts.tv_sec + ts.tv_nsec / 1e9;
}
static int wr_attr(const char *attr, const char *val)
{
char path[128];
int fd, ret;
snprintf(path, sizeof path, CNC "%s", attr);
fd = open(path, O_WRONLY);
if (fd < 0) return -1;
ret = (int)write(fd, val, strlen(val));
close(fd);
return ret < 0 ? -1 : 0;
}
static int rd_attr(const char *attr, char *buf, size_t len)
{
char path[128];
int fd;
ssize_t n;
snprintf(path, sizeof path, CNC "%s", attr);
fd = open(path, O_RDONLY);
if (fd < 0) return -1;
n = read(fd, buf, len - 1);
close(fd);
if (n < 0) return -1;
while (n > 0 && (buf[n-1] == '\n')) n--;
buf[n] = 0;
return 0;
}
int main(int argc, char **argv)
{
long freq = argc > 1 ? atol(argv[1]) : 10000;
long duration = argc > 2 ? atol(argv[2]) : 60;
long depth_ms = argc > 3 ? atol(argv[3]) : 150;
static unsigned char chunk[8192]; /* NOP bytes: no step, no laser */
char state[32], u0[16], u1[16];
long long target, enqueued = 0, enomem = 0, writes = 0;
double t0, t_end, last = 0, max_stall = 0, max_wr = 0;
long depth_bytes = (long)((double)freq * depth_ms / 1000.0);
int fd;
memset(chunk, 0, sizeof chunk);
/* SCHED_FIFO like a real feeder; fall back silently if not permitted */
struct sched_param sp = { .sched_priority = 10 };
sched_setscheduler(0, SCHED_FIFO, &sp);
fd = open(DEV, O_WRONLY);
if (fd < 0) { perror("open " DEV); return 1; }
if (flock(fd, LOCK_EX) != 0) { perror("flock"); return 1; }
char fbuf[16];
snprintf(fbuf, sizeof fbuf, "%ld", freq);
if (wr_attr("step_freq", fbuf)) { perror("step_freq"); return 1; }
wr_attr("streaming", "1");
rd_attr("underruns", u0, sizeof u0);
lseek(fd, 0, SEEK_SET); /* clear data + position */
/* Prefill one queue depth, then start the run */
while (enqueued < depth_bytes) {
long n = depth_bytes - enqueued;
if (n > (long)sizeof chunk) n = sizeof chunk;
if (write(fd, chunk, n) < 0) { perror("prefill"); return 1; }
enqueued += n;
}
if (wr_attr("run", "1")) { perror("run"); return 1; }
t0 = now_s();
t_end = t0 + duration;
last = t0;
printf("feeding: %ld Hz for %ld s, queue depth %ld ms (%ld bytes)\n",
freq, duration, depth_ms, depth_bytes);
while (1) {
double t = now_s();
if (t >= t_end) break;
if (t - last > max_stall) max_stall = t - last;
last = t;
/* wall-clock pacing: keep enqueued = consumed-so-far + depth */
target = (long long)((t - t0) * freq) + depth_bytes;
while (enqueued < target) {
long n = (long)(target - enqueued);
if (n > (long)sizeof chunk) n = sizeof chunk;
double w0 = now_s(), w1;
if (write(fd, chunk, n) < 0) {
if (errno == ENOMEM) { enomem++; break; }
perror("write"); return 1;
}
w1 = now_s();
if (w1 - w0 > max_wr) max_wr = w1 - w0;
enqueued += n;
writes++;
}
struct timespec ts = { 0, 20 * 1000 * 1000 }; /* 20 ms */
nanosleep(&ts, NULL);
}
rd_attr("state", state, sizeof state);
printf("after %ld s: state=%s enqueued=%lld writes=%lld enomem=%lld\n",
duration, state, enqueued, writes, enomem);
printf("max loop stall: %.1f ms, max write latency: %.1f ms\n",
max_stall * 1e3, max_wr * 1e3);
int fed_ok = (strcmp(state, "running") == 0) && enomem == 0;
/* Deliberate starve: stop feeding, expect a clean underrun */
double ts0 = now_s();
do {
struct timespec ts = { 0, 50 * 1000 * 1000 };
nanosleep(&ts, NULL);
rd_attr("state", state, sizeof state);
} while (strcmp(state, "running") == 0 && now_s() - ts0 < 10 + depth_ms / 1000.0);
rd_attr("underruns", u1, sizeof u1);
printf("after starve: state=%s underruns %s -> %s\n", state, u0, u1);
int starve_ok = strcmp(state, "underrun") == 0;
wr_attr("stop", "1"); /* acknowledge */
lseek(fd, 0, SEEK_SET);
wr_attr("streaming", "0");
flock(fd, LOCK_UN);
close(fd);
printf("%s: feed %s, starve->underrun %s\n",
(fed_ok && starve_ok) ? "PASS" : "FAIL",
fed_ok ? "clean" : "FAILED", starve_ok ? "detected" : "MISSED");
return (fed_ok && starve_ok) ? 0 : 1;
}