Files
forgefirm/scripts/bench/bench_m2.py
T
ScottW514 cc927aca5f 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.
2026-08-14 15:38:24 -04:00

140 lines
4.5 KiB
Python

#!/usr/bin/env python3
"""Milestone-2 motion-quality bench: factory-true rates/accels over TCP.
Runs a bounded, return-to-start jog sequence against grblHAL on the board
(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).
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 os.environ.get('GF_HOST')
if not HOST:
raise SystemExit('pass the machine IP as argv[1] or set GF_HOST')
PORT = 23
class Grbl:
def __init__(self, host, port):
self.s = socket.create_connection((host, port), timeout=5)
self.s.settimeout(0.2)
self.buf = b''
time.sleep(0.5)
self.drain()
def drain(self):
try:
while True:
d = self.s.recv(4096)
if not d:
break
self.buf += d
except socket.timeout:
pass
out, self.buf = self.buf, b''
return out.decode('ascii', 'replace')
def cmd(self, line, timeout=5.0):
"""Send a line, wait for ok/error."""
self.s.sendall(line.encode() + b'\n')
deadline = time.time() + timeout
text = ''
while time.time() < deadline:
text += self.drain()
if 'ok' in text or 'error' in text:
return text.strip()
time.sleep(0.02)
return '(timeout) ' + text.strip()
def status(self):
"""Realtime ? query -> raw <...> report or ''."""
self.s.sendall(b'?')
t = time.time() + 1.0
text = ''
while time.time() < t:
text += self.drain()
if '>' in text:
break
time.sleep(0.02)
if '<' in text and '>' in text:
return text[text.rfind('<'):text.rfind('>') + 1]
return ''
def wait_idle(self, timeout=30.0, poll=0.05):
"""Poll until Idle; return (peak_feed_mm_min, states_seen)."""
peak = 0.0
states = []
deadline = time.time() + timeout
while time.time() < deadline:
st = self.status()
if st:
state = st[1:].split('|')[0]
if not states or states[-1] != state:
states.append(state)
for f in st[1:-1].split('|'):
# FS:feed,speed with a spindle registered; F:feed without
if f.startswith('FS:'):
peak = max(peak, float(f[3:].split(',')[0]))
elif f.startswith('F:'):
peak = max(peak, float(f[2:].split(',')[0]))
if state.startswith('Idle'):
return peak, states
time.sleep(poll)
return peak, states + ['TIMEOUT']
def rt(self, ch):
self.s.sendall(ch)
def main():
g = Grbl(HOST, PORT)
st = g.status()
print('connect: %s' % st)
if 'Alarm' in st:
print('unlock: %s' % g.cmd('$X'))
for name, out, back, feed in (
('X sanity 40mm', '$J=G91X40F2400', '$J=G91X-40F2400', 2400),
('Y sanity 40mm', '$J=G91Y40F2400', '$J=G91Y-40F2400', 2400),
('X max-rate 60mm', '$J=G91X60F12000', '$J=G91X-60F12000', 12000),
('diag 40mm', '$J=G91X40Y40F8000', '$J=G91X-40Y-40F8000', 8000),
):
for jog in (out, back):
r = g.cmd(jog)
if 'error' in r:
print('%s: JOG REFUSED: %s' % (name, r))
return 1
peak, states = g.wait_idle()
print('%s %s: peak %.0f mm/min, states %s'
% (name, 'out' if jog == out else 'back', peak, states))
# Feed-hold mid-move: G1 at 600 mm/min takes 3 s for 30 mm.
print('hold test: %s' % g.cmd('G91'))
g.cmd('G1X30F600', timeout=0.5) # ok arrives while moving
time.sleep(1.0)
g.rt(b'!')
time.sleep(0.8)
st = g.status()
print('after !: %s' % st)
held = 'Hold' in st
g.rt(b'~')
peak, states = g.wait_idle()
print('after ~: states %s' % states)
g.cmd('G1X-30F2400', timeout=0.5) # return to start
g.wait_idle()
print('hold worked: %s' % held)
print('final: %s' % g.status())
return 0
if __name__ == '__main__':
sys.exit(main())