Files
forgefirm/scripts/bench/accel_crash_probe.py
T
ScottW514 e36a322ec5 Retire BRINGUP.md and CAMPAIGN-LOG.md
Every fact in the two documents is now on the documentation site, which
is the single source of truth. This repository carries no project
documentation any more: it is the build and release base plus the
acceptance tool, the bench tools and the fixture firmware.

BRINGUP.md was the runbook, the hardware facts bank and the open-work
list. CAMPAIGN-LOG.md was the dated record of how each result was
obtained. What replaces them: the site for present state, and the
commit message for the record of what a change did and how it was
proven, so the change and its record stay together. Local open work is
the developer's own file at the tree root and is not tracked here.

README.md becomes an index card: what this is, build, test, and where
the documentation is.

The release pipeline tags the documentation. Firmware on a machine
needs the documentation that agrees with it, so release.sh now tags the
forgefirm-docs checkout with the same v<version> as the release, and
prints the command that pushes the tag with the release. The checkout
must exist and be clean, which is a new gate before the signature.
FORGEFIRM_DOCS_DIR names the checkout (default: the sibling one) and
FORGEFIRM_DOCS_SKIP releases without a tag, loudly, and is never the
default. The tag is made at staging and pushed with the release, never
before: a documentation tag for a release that never shipped is worse
than no tag.

No catalog consequence. release.sh is host-side and is in no image.
The commission.py change is one sentence of a test description, not
behavior. accel_crash_probe.py and the kas header lose pointers to the
retired files.

Checks: bash -n and sh -n on release.sh, and the tracked trees carry no
reference to either retired file.
2026-09-08 13:29:43 -04:00

279 lines
9.6 KiB
Python

#!/usr/bin/env python3
"""De-risk drill for the head-accelerometer crash detector.
The LIS2HH12 on the head bus (i2c-3 @0x1e) carries an on-chip interrupt
generator: a per-axis high-event threshold (IG_THS_X1/Y1/Z1), a duration
counter (IG_DUR1), an axis enable/AND-OR word (IG_CFG1) and a latched
per-axis source register (IG_SRC1). The factory arms this generator per
job and reads trips from IG_SRC1; ForgeFIRM does not. This drill arms it
on the bench and answers the three questions the crash-detector design
rides on, before any kernel or forgectrl work:
1. Does IG_SRC1 latch a real head strike at a plausible threshold, and
which axes does it report?
2. Can the IG registers be programmed and polled over i2c-dev while
st_accel stays bound for the motion-liveness reads (coexist mode,
I2C_SLAVE_FORCE)? If so the detector is forgectrl-only, no kernel
change and the liveness path untouched. If the two collide, the
accel must move under glowforge.ko instead.
3. What raw magnitude does a strike produce, so the shipped threshold
can be set in register units against the rail-contact signature
(facts bank: 20-40x over creep within ~4 ms on a fast strike).
The drill touches the IG registers plus the IG-latch bit of CTRL7,
which st_accel never writes, and CTRL1: st_accel powers the part down
between one-shot reads (ODR=0), and the IG generator only samples at a
running ODR, so the drill saves CTRL1, sets 800 Hz for the window and
restores the saved value on exit. The full scale (CTRL4) is never
changed, so st_accel's raw scaling is undisturbed. Default is coexist
mode with forgectrl left running: no emission, no commanded motion. Provoke a trip by hand (a firm tap or nudge on the head) or pass
--jog to send one gentle grblHAL jog; a jog needs the controller free,
so run it from the bench page's takeover or stop the controller first.
Usage:
accel_crash_probe.py [seconds] [--mode coexist|unbind] [--ths N]
[--dur N] [--axes xyz] [--jog GCODE] [--rate HZ]
seconds arm-and-watch window (default 20)
--mode coexist (default): leave st_accel bound, reach the IG
registers with I2C_SLAVE_FORCE, forgectrl stays up; unbind:
unbind st_accel for the run and rebind after (clean access,
but liveness is down meanwhile, so stop the controller and
forgectrl first)
--ths N per-axis threshold register value 0..255 (default 40); the
LSB is full scale / 256 (bench-confirmed against gravity:
+/-2 g gives ~7.8 mg/LSB, 1 g ~= 128), so the printed CTRL4
FS fixes the g conversion
--dur N IG_DUR1 duration counter, samples at the running ODR
(default 0 = fire on the first over-threshold sample)
--axes which axes arm high events (default xyz)
--jog one grblHAL jog at t=2 s, e.g. "$J=G91 X5 F1000" (+X first,
never -X: a cable lives at the end of LEFT travel)
--rate IG_SRC1 poll rate in Hz (default 200)
CSV of the poll trace to /tmp/accel_crash.csv: t,ig_src,xh,yh,zh,x,y,z
Exit 0 on a clean run whether or not a trip was seen; the summary states
what happened. Reads/writes only; relocks nothing (no laser path).
"""
import fcntl
import os
import struct
import sys
import time
I2C_SLAVE = 0x0703
I2C_SLAVE_FORCE = 0x0706
BUS = '/dev/i2c-3'
ADDR = 0x1e # head accel; 0x1d is the board accel
WHO_AM_I = 0x0F
WHO_AM_I_LIS2HH12 = 0x41
CTRL1 = 0x20
CTRL4 = 0x23 # bits 5:4 = FS (00=+/-2g, 10=+/-4g, 11=+/-8g)
CTRL7 = 0x26 # bit 2 = LIR1 (latch IG_SRC1, read-to-clear;
# bit 3 = LIR2, bits 1:0 are the 4D enables)
OUT_X_L = 0x28
IG_CFG1 = 0x30 # AOI,6D,ZHIE,ZLIE,YHIE,YLIE,XHIE,XLIE
IG_SRC1 = 0x31 # IA(6),ZH,ZL,YH,YL,XH,XL
IG_THS_X1 = 0x32
IG_THS_Y1 = 0x33
IG_THS_Z1 = 0x34
IG_DUR1 = 0x35
# IG_CFG1 high-event enables and the matching IG_SRC1 source bits share
# the same odd bit positions: X high = 1, Y high = 3, Z high = 5 (the even
# positions 0/2/4 are the low-event bits, which this drill does not arm).
XHIE = 1 << 1
YHIE = 1 << 3
ZHIE = 1 << 5
IG_IA = 1 << 6
IG_XH = 1 << 1
IG_YH = 1 << 3
IG_ZH = 1 << 5
DRIVER = '/sys/bus/i2c/drivers/st-accel-i2c'
DEV = '3-%04x' % ADDR
FS_G = {0b00: 2, 0b10: 4, 0b11: 8}
def bind_ctl(op):
try:
with open(DRIVER + '/' + op, 'w') as f:
f.write(DEV)
except OSError as e:
print('%s %s: %s' % (op, DEV, e))
def rd(fd, reg, n=1):
os.write(fd, bytes([reg]))
d = os.read(fd, n)
return d[0] if n == 1 else d
def wr(fd, reg, val):
os.write(fd, bytes([reg, val & 0xff]))
VALUE_OPTS = ('--mode', '--ths', '--dur', '--axes', '--jog', '--rate')
def parse(argv):
"""Return (positionals, {opt: value}). Value options consume the next
token."""
pos, opts = [], {}
i = 0
while i < len(argv):
a = argv[i]
if a in VALUE_OPTS:
opts[a] = argv[i + 1] if i + 1 < len(argv) else None
i += 2
else:
pos.append(a)
i += 1
return pos, opts
def main():
pos, opts = parse(sys.argv[1:])
dur_s = float(pos[0]) if pos else 20.0
ths = int(opts.get('--ths', 40))
ig_dur = int(opts.get('--dur', 0))
axes = opts.get('--axes', 'xyz')
rate = float(opts.get('--rate', 200))
jog = opts.get('--jog')
unbind = opts.get('--mode', 'coexist') == 'unbind'
cfg = 0
if 'x' in axes:
cfg |= XHIE
if 'y' in axes:
cfg |= YHIE
if 'z' in axes:
cfg |= ZHIE
if unbind:
bind_ctl('unbind')
fd = os.open(BUS, os.O_RDWR)
# Coexist mode leaves st_accel bound, so FORCE the address; unbind
# mode owns it outright.
fcntl.ioctl(fd, I2C_SLAVE if unbind else I2C_SLAVE_FORCE, ADDR)
who = rd(fd, WHO_AM_I)
if who != WHO_AM_I_LIS2HH12:
print('WHO_AM_I=0x%02x, expected 0x41 (LIS2HH12) at %s' % (who, DEV))
os.close(fd)
if unbind:
bind_ctl('bind')
return 1
orig_ctrl1 = rd(fd, CTRL1)
ctrl1 = orig_ctrl1
ctrl4 = rd(fd, CTRL4)
fs = FS_G.get((ctrl4 >> 4) & 0b11, '?')
if (ctrl1 & 0x70) == 0: # ODR bits 6:4 zero = power-down; IG needs
wr(fd, CTRL1, 0x6F) # a running ODR: 800 Hz, BDU, XYZ on
ctrl1 = rd(fd, CTRL1)
lsb_mg = (fs * 1000.0 / 256.0) if isinstance(fs, int) else 0
print('WHO_AM_I=0x%02x CTRL1=0x%02x CTRL4=0x%02x FS=+/-%sg '
'ths=%d (~%.0f mg, ~%.2f g) dur=%d axes=%s mode=%s'
% (who, ctrl1, ctrl4, fs, ths, ths * lsb_mg, ths * lsb_mg / 1000.0,
ig_dur, axes, 'unbind' if unbind else 'coexist(FORCE)'))
# Arm IG1: latch the source (CTRL7 LIR1), set the thresholds and
# duration, then enable the axes last. Preserve CTRL7's other bits.
ctrl7 = rd(fd, CTRL7)
wr(fd, CTRL7, ctrl7 | 0x04)
wr(fd, IG_THS_X1, ths)
wr(fd, IG_THS_Y1, ths)
wr(fd, IG_THS_Z1, ths)
wr(fd, IG_DUR1, ig_dur & 0x7f)
wr(fd, IG_CFG1, cfg)
rd(fd, IG_SRC1) # clear any stale latch
sock = None
if jog:
import socket
sock = socket.create_connection(('127.0.0.1', 23), timeout=3)
sock.settimeout(0.1)
out = open('/tmp/accel_crash.csv', 'w')
out.write('t,ig_src,xh,yh,zh,x,y,z\n')
period = 1.0 / rate
t0 = time.monotonic()
jogged = False
trips = []
live_ok = True
n = 0
while True:
t = time.monotonic() - t0
if t > dur_s:
break
if sock and not jogged and t > 2.0:
sock.sendall((jog + '\n').encode())
jogged = True
src = rd(fd, IG_SRC1)
n += 1
if src & IG_IA:
raw = rd(fd, OUT_X_L, 6)
x, y, z = struct.unpack('<hhh', raw)
xh = 1 if src & IG_XH else 0
yh = 1 if src & IG_YH else 0
zh = 1 if src & IG_ZH else 0
out.write('%.5f,0x%02x,%d,%d,%d,%d,%d,%d\n'
% (t, src, xh, yh, zh, x, y, z))
trips.append((t, xh, yh, zh, x, y, z))
time.sleep(period)
out.close()
# Coexist proof: st_accel's raw reads still work while we polled.
if not unbind:
try:
base = ('/sys/bus/i2c/devices/%s/iio:device' % DEV)
import glob
hits = glob.glob(base + '*/in_accel_x_raw')
if hits:
with open(hits[0]) as f:
_ = int(f.read())
else:
live_ok = False
except (OSError, ValueError):
live_ok = False
# Disarm and restore.
wr(fd, IG_CFG1, 0x00)
wr(fd, CTRL7, ctrl7)
if ctrl1 != orig_ctrl1:
wr(fd, CTRL1, orig_ctrl1)
if sock:
try:
sock.recv(4096)
except OSError:
pass
sock.close()
os.close(fd)
if unbind:
bind_ctl('bind')
print('polled %d samples at ~%.0f Hz over %.0f s' % (n, n / dur_s, dur_s))
if trips:
ax = ''.join(a for a, f in zip('xyz', (
any(t[1] for t in trips),
any(t[2] for t in trips),
any(t[3] for t in trips))) if f)
first = trips[0]
print('TRIPPED %d times, axes seen: %s' % (len(trips), ax or '-'))
print(' first at t=%.3f s raw x=%d y=%d z=%d (FS +/-%sg, 1 g ~= %d)'
% (first[0], first[4], first[5], first[6], fs,
int(32768 / fs) if isinstance(fs, int) else 0))
else:
print('no trip in the window (raise the strike or lower --ths)')
if not unbind:
print('coexist liveness read after the run: %s'
% ('OK (st_accel still serving raw)' if live_ok else 'FAILED'))
return 0
if __name__ == '__main__':
sys.exit(main())