Add laser-safety and regulatory documentation; scrub bench identity

- LIGHTBURN.md: mandatory "Before you cut" safety section; the
  walkthrough now reflects the firing machine (dry runs need the
  layer output off or M5; live first-cut instructions); the homing
  entry documents homing_mode and the gfcloud method; the machine
  address is a placeholder.
- README.md: condensed safety section linking the full text and the
  regulatory notes.
- INSTALL.md: "Regulatory and legal" section ahead of the install
  steps; routine updates route through the panel updater rather than
  the installer.
- BRINGUP.md: the release signing key is described as held offline
  (no on-disk path); bench address and credential notes removed;
  Next-work item 7 corrected (the installer embeds the production
  release key); status entry for audit remediation Phases 0-1; the
  GATE A kernel drills join the pending image-flash checklist.
- bench scripts: the target host comes from GF_HOST (or argv) instead
  of a hardcoded address.
- laser_stream_test.py: per-session controller runs with a hermetic
  cooling-verdict publisher; new assertions that every stream
  terminates with FIRE clear (including M3 held to stream end) and
  that no FIRE bit rides a zero-step gap; a cycle-churn session
  exercises the stop/start seams.

Audit findings D-1, D-2, D-3, D-5, D-10, D-12, B-10, and the harness
half of D-4/G-1.
This commit is contained in:
ScottW514
2026-08-14 15:38:24 -04:00
parent 5dddea12ee
commit cc927aca5f
13 changed files with 361 additions and 63 deletions
+5 -2
View File
@@ -2,7 +2,7 @@
"""Milestone-2 motion-quality bench: factory-true rates/accels over TCP.
Runs a bounded, return-to-start jog sequence against grblHAL on the board
(default 172.16.1.97:23) and reports peak feed reached, state transitions,
(argv[1] or GF_HOST, port 23) and reports peak feed reached, state transitions,
and final position drift. Every move is relative and round-trip, so the
head ends where it started; the laser stays latched (motion-only backend).
@@ -10,11 +10,14 @@ Sequence: sanity jogs (X, Y, 40 mm out/back at 2400 mm/min), max-rate X
out/back (60 mm at F12000 - peaks ~200 mm/s mid-move), diagonal out/back,
then a G1 move with a feed-hold/resume in the middle.
"""
import os
import socket
import sys
import time
HOST = sys.argv[1] if len(sys.argv) > 1 else '172.16.1.97'
HOST = sys.argv[1] if len(sys.argv) > 1 else os.environ.get('GF_HOST')
if not HOST:
raise SystemExit('pass the machine IP as argv[1] or set GF_HOST')
PORT = 23
+4 -1
View File
@@ -1,6 +1,9 @@
import socket, subprocess, time
import os
HOST = '172.16.1.97'
HOST = os.environ.get('GF_HOST')
if not HOST:
raise SystemExit('set GF_HOST to the machine IP address')
def board(cmd):
r = subprocess.run(['wsl', '-d', 'forge-yocto', '--', 'ssh',
+4 -1
View File
@@ -15,11 +15,14 @@ driver only writes the heater on M8/M9 transitions, so an idle driver
leaves this alone.
"""
import math
import os
import subprocess
import sys
import time
HOST = '172.16.1.97'
HOST = os.environ.get('GF_HOST')
if not HOST:
raise SystemExit('set GF_HOST to the machine IP address')
# Factory B-equation conversion (see kernel-module-glowforge/UAPI.md).
F = 1024.0 * 1.3
+3 -1
View File
@@ -35,7 +35,9 @@ import subprocess
import sys
import time
HOST = '172.16.1.97'
HOST = os.environ.get('GF_HOST')
if not HOST:
raise SystemExit('set GF_HOST to the machine IP address')
HERE = os.path.dirname(os.path.abspath(__file__))
RESULTS = os.path.join(HERE, os.environ.get('FM_RESULTS', 'flow_matrix_results.json'))
+4 -1
View File
@@ -37,11 +37,14 @@ Runs both flow and no-flow cases from a comparable loop state and
prints the differential separation. Aborts if downstream passes 45 C.
"""
import math
import os
import subprocess
import sys
import time
HOST = '172.16.1.97'
HOST = os.environ.get('GF_HOST')
if not HOST:
raise SystemExit('set GF_HOST to the machine IP address')
F = 1024.0 * 1.3
RD, BETA = 10000.0, 3380.0
RINF = 10000.0 * math.exp(-3380.0 / 298.15)
+4 -1
View File
@@ -9,13 +9,16 @@ cut-profile fans, logging bulk coolant temperature and every verdict.
Usage: flow_sustained.py [minutes] (default 30)
"""
import math
import os
import re
import socket
import subprocess
import sys
import time
HOST = '172.16.1.97'
HOST = os.environ.get('GF_HOST')
if not HOST:
raise SystemExit('set GF_HOST to the machine IP address')
F = 1024.0 * 1.3
RD, BETA = 10000.0, 3380.0
RINF = 10000.0 * math.exp(-3380.0 / 298.15)
+3 -1
View File
@@ -20,7 +20,9 @@ import subprocess
import sys
import time
HOST = '172.16.1.97'
HOST = os.environ.get('GF_HOST')
if not HOST:
raise SystemExit('set GF_HOST to the machine IP address')
HERE = os.path.dirname(os.path.abspath(__file__))
RESULTS = os.path.join(HERE, 'flow_warm_results.json')
+166 -27
View File
@@ -2,8 +2,8 @@
"""Host-side verification of the laser pulse-stream emission.
Runs the native grblHAL_glowforge binary in null-sink mode with
GFSINK_DUMP capturing the shipped byte stream, drives a small M4 laser
job over TCP, then checks the dump against the kernel feeder contract:
GFSINK_DUMP capturing the shipped byte stream, drives small laser jobs
over TCP, then checks the dumps against the kernel feeder contract:
1. a power byte (bit 7) leads the stream, before any tick byte
2. no two consecutive power bytes (the SDMA script drops the second)
@@ -13,6 +13,15 @@ job over TCP, then checks the dump against the kernel feeder contract:
the G0 return, none at the tail
6. step accounting survives the insertions: X returns to net zero and
peaks at the programmed 10 mm
7. termination: every stream ends with FIRE clear, including an M3
(constant-power) job whose core never issues a laser-off update -
the stream must never lean on the kernel's end-of-data backstop
8. no FIRE bit ever rides a zero-step gap: a stepless run of stream
bytes carrying FIRE longer than any legitimate between-step
interval is a stationary dwell burn
9. rules 7-8 hold across rapid cycle stop/start churn (planner-starve
shaped jobs), where the FIRE state of the previous cycle must not
leak into the idle-gap pad bytes
Usage: laser_stream_test.py [path-to-binary] (default ./build-native/grblHAL_glowforge)
"""
@@ -24,13 +33,24 @@ import socket
import subprocess
import sys
import tempfile
import threading
import time
BIN = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else "build-native/grblHAL_glowforge")
PORT = 2399
STEPS_PER_MM = 53.333
JOB = [
# Longest stepless run allowed to carry FIRE, in machine ticks. The
# slowest legitimate between-step interval in these jobs is the first
# step of an accel-from-rest: sqrt(2 * (1/53.333 mm) / 700 mm/s^2)
# = 7.3 ms = ~206 ticks at 28160 Hz. 500 gives >2x margin while staying
# far below any idle-gap pad run.
FIRE_GAP_LIMIT_TICKS = 500
WAIT_IDLE = ("wait_idle",)
# Session A: the original M4 dynamic-power job (rules 1-6).
JOB_M4 = [
"M4 S0",
"G1 X5 F600 S500",
"G1 X10 S1000",
@@ -38,6 +58,29 @@ JOB = [
"M5",
]
# 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).
JOB_M3_TERM = [
"M3 S1000",
"G1 X5 F600",
WAIT_IDLE,
("sleep", 1.0),
"M5",
]
# Session C: rapid cycle churn - many tiny laser moves sent one at a
# time with small gaps, so cycles stop and restart the way a planner
# starve produces them (rules 8-9).
JOB_CHURN = []
for _ in range(30):
JOB_CHURN.append("G1 X0.2 F600 S800")
JOB_CHURN.append(("sleep", 0.02))
JOB_CHURN.append("G1 X0 S800")
JOB_CHURN.append(("sleep", 0.02))
JOB_CHURN.insert(0, "M4 S0")
JOB_CHURN.append("M5")
def fail(msg):
print("FAIL: %s" % msg)
@@ -76,12 +119,43 @@ def read_avail(sock, log, timeout, until=None):
return None
def main():
def wait_idle(sock, log):
for _ in range(100):
sock.sendall(b"?")
read_avail(sock, log, 0.3)
if re.search(r"<Idle", "".join(log[-3:])):
return
time.sleep(0.2)
fail("controller never returned to Idle")
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."""
while not stop.is_set():
body = ('{"ts_mono":%.3f,"fire_ok":true,"hold":false,'
'"resume_ok":true,"reason":""}'
% time.clock_gettime(time.CLOCK_MONOTONIC))
tmp = path + ".tmp"
with open(tmp, "w") as f:
f.write(body)
os.replace(tmp, path)
stop.wait(0.5)
def run_session(name, steps):
"""Launch the controller, run the job steps, return the dump bytes."""
workdir = tempfile.mkdtemp(prefix="laser-test-")
dump = os.path.join(workdir, "stream.bin")
env = dict(os.environ, GFSINK_DUMP=dump)
verdict = os.path.join(workdir, "cooling.state")
env = dict(os.environ, GFSINK_DUMP=dump, GF_VERDICT_FILE=verdict)
env.pop("GFSINK", None)
stop = threading.Event()
pub = threading.Thread(target=publish_verdicts, args=(verdict, stop), daemon=True)
pub.start()
proc = subprocess.Popen([BIN, "-p", str(PORT)], cwd=workdir, env=env,
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
try:
@@ -93,31 +167,27 @@ def main():
except OSError:
time.sleep(0.1)
if sock is None:
fail("cannot connect to the controller")
fail("[%s] cannot connect to the controller" % name)
log = []
read_avail(sock, log, 0.5) # banner / hello
for line in JOB:
send_line(sock, line, log)
for step in steps:
if step == WAIT_IDLE:
wait_idle(sock, log)
elif isinstance(step, tuple) and step[0] == "sleep":
time.sleep(step[1])
else:
send_line(sock, step, log)
# Wait for the motion to play out on the wall clock (the shipper
# is wall-paced), then for the Idle report.
idle = False
for _ in range(100):
sock.sendall(b"?")
read_avail(sock, log, 0.3)
if re.search(r"<Idle", "".join(log[-3:])):
idle = True
break
time.sleep(0.2)
if not idle:
fail("controller never returned to Idle")
wait_idle(sock, log)
time.sleep(1.0) # let the shipper drain the tail
text = "".join(log)
if "laser armed" not in text:
fail("no 'laser armed' message (arming flow did not run)")
fail("[%s] no 'laser armed' message (arming flow did not run)" % name)
sock.close()
finally:
@@ -126,12 +196,51 @@ def main():
proc.wait(5)
except subprocess.TimeoutExpired:
proc.kill()
stop.set()
pub.join(2)
data = open(dump, "rb").read()
if not data:
fail("empty stream dump")
fail("[%s] empty stream dump" % name)
shutil.rmtree(workdir, ignore_errors=True)
return data
# --- contract checks -------------------------------------------------
def tick_bytes(data):
"""The stream with power bytes stripped (tick bytes only)."""
return bytes(b for b in data if not b & 0x80)
def check_fire_gaps(name, data):
"""Rule 8: no stepless run carrying FIRE longer than the limit."""
run = 0
worst = 0
for tick, b in enumerate(tick_bytes(data)):
if b & 0x10 and not b & 0x25: # FIRE, no X/Y/Z step
run += 1
worst = max(worst, run)
if run >= FIRE_GAP_LIMIT_TICKS:
fail("[%s] FIRE carried across a %d-tick zero-step gap "
"ending at tick %d (stationary dwell burn)"
% (name, run, tick))
else:
run = 0
return worst
def check_termination(name, data):
"""Rule 7: the stream's final tick byte must carry FIRE clear."""
ticks = tick_bytes(data)
if not ticks:
fail("[%s] no tick bytes in the stream" % name)
if ticks[-1] & 0x10:
fail("[%s] stream ends with FIRE set (0x%02x) - termination "
"rule violated, relies on the end-of-data backstop"
% (name, ticks[-1]))
def check_m4_job(data):
"""Rules 1-6 on the original M4 job."""
if not data[0] & 0x80:
fail("stream does not lead with a power byte (first byte 0x%02x)" % data[0])
@@ -184,10 +293,8 @@ def main():
last_fire = fire_ticks[-1][0]
tail_steps = 0
tick = 0
prev_power = False
for b in data:
if b & 0x80:
prev_power = True
continue
if tick > last_fire and b & 0x01:
tail_steps += 1
@@ -195,11 +302,43 @@ def main():
if tail_steps < 400:
fail("only %d fire-free steps after the last FIRE bit - G0 return not dark" % tail_steps)
print("PASS: %d bytes, %d power bytes, %d fire ticks, powers %s, "
"X peak %d steps net 0, %d dark return steps"
return fire_ticks, powers, x_max, tail_steps
def count_fire(data):
return sum(1 for b in tick_bytes(data) if b & 0x10)
def main():
# --- session A: M4 dynamic power, rules 1-6 + 7-8 -------------------
data = run_session("m4", JOB_M4)
fire_ticks, powers, x_max, tail_steps = check_m4_job(data)
check_termination("m4", data)
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"
% (len(data), sum(1 for b in data if b & 0x80), len(fire_ticks),
powers, x_max, tail_steps))
shutil.rmtree(workdir, ignore_errors=True)
powers, x_max, tail_steps, gap_a))
# --- session B: M3 constant power to stream end, rule 7 -------------
data = run_session("m3-term", JOB_M3_TERM)
if not count_fire(data):
fail("[m3-term] no FIRE bits in the stream")
check_termination("m3-term", data)
gap_b = check_fire_gaps("m3-term", data)
print("PASS [m3-term]: %d bytes, %d fire ticks end dark, max fire gap %d"
% (len(data), count_fire(data), gap_b))
# --- session C: cycle churn, rules 8-9 ------------------------------
data = run_session("churn", JOB_CHURN)
if not count_fire(data):
fail("[churn] no FIRE bits in the stream")
check_termination("churn", data)
gap_c = check_fire_gaps("churn", data)
print("PASS [churn]: %d bytes, %d fire ticks, max fire gap %d"
% (len(data), count_fire(data), gap_c))
print("PASS: all stream emission rules hold")
if __name__ == "__main__":
+3 -1
View File
@@ -25,7 +25,9 @@ import subprocess
import sys
import time
HOST = '172.16.1.97'
HOST = os.environ.get('GF_HOST')
if not HOST:
raise SystemExit('set GF_HOST to the machine IP address')
STORE = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'temp_calibration.json')