From 765521455d86b0ff3fcca20d1d852642f160e86b Mon Sep 17 00:00:00 2001 From: ScottW514 Date: Mon, 31 Aug 2026 17:56:53 -0400 Subject: [PATCH] bench: head-accel crash-detector de-risk drill (item 6, first step) accel_crash_probe.py arms the head LIS2HH12's on-chip interrupt generator (IG_CFG1/IG_THS/IG_DUR1) and polls the latched IG_SRC1 for a strike, reporting the axes and raw magnitude. It settles the bench fact the crash detector rides on: coexist mode reaches the IG registers over i2c-dev with I2C_SLAVE_FORCE while st_accel stays bound, so it proves whether the detector can be forgectrl-only with the liveness path untouched, or whether the accel must move under glowforge.ko. It touches only the IG registers (0x30-0x35) plus the CTRL7 latch bit, never the full scale, so st_accel's raw scaling is undisturbed; no emission, no commanded motion by default. Registered on the bench page (dry, board) and in the bench README. BRINGUP item 6 now stages the drill as the committed first step, with the readout path, per-state thresholds and the two-tier wiring owed after it. Tooling only: no shipped component source changed, so no acceptance-catalog consequence (the detector feature gets its case when it is built); the bench registry test and coverage lint pass. --- docs/BRINGUP.md | 30 ++-- forgetest/forgetest/bench.py | 13 ++ scripts/bench/README.md | 1 + scripts/bench/accel_crash_probe.py | 271 +++++++++++++++++++++++++++++ 4 files changed, 305 insertions(+), 10 deletions(-) create mode 100644 scripts/bench/accel_crash_probe.py diff --git a/docs/BRINGUP.md b/docs/BRINGUP.md index 893b304..385eecd 100644 --- a/docs/BRINGUP.md +++ b/docs/BRINGUP.md @@ -1219,16 +1219,26 @@ Open items only. Anything closed is in `CAMPAIGN-LOG.md`. bench-measure a filter is moot now that the HA* thresholds are known to be LIS2HH12 register values at a known full scale. - Owed for the detector: program the LIS2HH12 interrupt generator over - i2c-3 (per-axis threshold, duration, full scale) and poll IG_SRC1 for a - latched per-axis trip, on the factory's two-tier shape (an alert that - pauses, an abort that fails). The rail-contact signature in the facts - bank sets the first threshold in register units; a pause on contact is - the first use. ForgeFIRM already reads the head accel raw for liveness, - so this shares the bus, not new hardware; the one wrinkle is reaching the - interrupt-generator registers past the bound `st_accel` driver (IIO - events if the driver exposes them, else a direct-register path as the - retired homing spike used). + The detector arms the LIS2HH12 interrupt generator over i2c-3 (per-axis + threshold, duration) and polls IG_SRC1 for a latched per-axis trip, on + the factory's two-tier shape (an alert that pauses, an abort that fails). + The rail-contact signature in the facts bank sets the first threshold in + register units; a pause on contact is the first use. ForgeFIRM already + reads the head accel raw for liveness, so this shares the bus, not new + hardware. The open decision is where the IG programming lives, and it + turns on a bench fact: the IG registers (0x30 to 0x35) are ones + `st_accel` never touches, so if they can be reached over i2c-dev + (I2C_SLAVE_FORCE) while the driver stays bound, the detector is + forgectrl-only with the liveness path untouched; if the two collide, the + accel moves under `glowforge.ko`. The de-risk drill + (`scripts/bench/accel_crash_probe.py`, on the bench page) settles that + first: it arms IG1 in coexist mode, provokes a strike, confirms IG_SRC1 + latches the expected axis at a factory-derived threshold, and checks that + `st_accel`'s raw reads keep working through the run. Keeping the factory + full scale (no CTRL4 write) is what keeps the coexistence clean. Owed + after the drill: the chosen readout path, per-state (idle/run) thresholds + seeded from a captured cut header's HA* values, and the two tiers wired + into the existing feed-hold and stop-plus-latch paths. Owed for the head IRQ, only if a coarse hardware interrupt is wanted instead of the poll: arm the accel bit in the head MCU (reg 0x03/0x04), diff --git a/forgetest/forgetest/bench.py b/forgetest/forgetest/bench.py index f490117..05b4bf6 100644 --- a/forgetest/forgetest/bench.py +++ b/forgetest/forgetest/bench.py @@ -75,6 +75,19 @@ TOOLS = [ _arg("feed", "int", 120, "creep feed"), _arg("segment_mm", "float", 15.0, "jog segment"), _arg("max_mm", "float", 200.0, "travel bound")], "desc": "Creeps toward a rail in bounded jog segments, detects the contact jolt, jog-cancels and backs off."}, + {"id": "accel-crash-probe", "title": "Head accel crash-detector probe", "script": "accel_crash_probe.py", + "safety": "dry", "where": "board", "ported": True, + "args": [_arg("seconds", "float", 20.0, "arm-and-watch window"), + _arg("mode", "choice", "coexist", "coexist keeps st_accel bound (forgectrl up); unbind frees it", + ["coexist", "unbind"], flag="--mode"), + _arg("ths", "int", 40, "per-axis threshold register value 0..255", flag="--ths"), + _arg("dur", "int", 0, "IG_DUR1 duration counter (ODR samples)", flag="--dur"), + _arg("axes", "str", "xyz", "axes to arm for high events", flag="--axes"), + _arg("jog", "str", None, "optional one jog at t=2 s, e.g. $J=G91X5F1000 (+X only)", flag="--jog")], + "desc": "Arms the head LIS2HH12's on-chip interrupt generator (IG1) and polls IG_SRC1 for a latched strike. " + "coexist mode reaches the IG registers over i2c-dev with st_accel still bound (the forgectrl-only " + "path proof); provoke a trip by hand or with --jog. Touches only the IG registers, no emission, " + "no full-scale change. CSV to /tmp/accel_crash.csv."}, # -- board-side, takeover / scope ------------------------------------------ {"id": "pwm-sweep", "title": "LASER_PWM scope sweep", "script": "pwm_sweep.py", "safety": "scope", "where": "board", "ported": True, diff --git a/scripts/bench/README.md b/scripts/bench/README.md index 925a7d8..c9f3cfa 100644 --- a/scripts/bench/README.md +++ b/scripts/bench/README.md @@ -54,6 +54,7 @@ page's takeover does that; from a host, stop them first. | `build-forgectrl.sh` | Cross-compiles **forgectrl** (the canonical control-daemon repo) the same way, borrowing the toolchain from the forgectrl recipe workdir (regenerate with `bitbake forgectrl` after a clean). | | `accel_fast.py` | Direct-I2C sampler for the two head-bus LIS2HH12s (runs on the board; unbinds/rebinds st-accel around the capture, 800 Hz ODR, ~270 Hz per device polled): optional mid-capture jogs via local grblHAL TCP. CSV to /tmp/accel.csv. The head accel is i2c-3 0x1e. | | `bump_seek.py` | Accelerometer bump-seek homing prototype (runs on the board): creeps toward a rail in bounded jog segments via grblHAL TCP, learns the moving-noise baseline per segment, detects the contact jolt (~530 Hz sampling, 2-sample confirm), jog-cancels (0x85) and backs off. CSV to /tmp/bump.csv. | +| `accel_crash_probe.py` | Head-accel crash-detector de-risk drill (runs on the board; BRINGUP item 6): arms the head LIS2HH12's on-chip interrupt generator (IG_CFG1/IG_THS/IG_DUR1) and polls the latched IG_SRC1 for a strike, reporting the axes and raw magnitude. `coexist` (default) reaches the IG registers with I2C_SLAVE_FORCE while st_accel stays bound, so it proves whether the detector can be forgectrl-only with the liveness path untouched; `unbind` frees the device (stop the controller and forgectrl first). Touches only the IG registers, no full-scale change, no emission, no commanded motion by default (provoke a trip by hand or with one `--jog`). CSV to /tmp/accel_crash.csv. | | `build-feeder.sh` | Cross-compiles `feeder.c` the same way. | | `puls_profile.py` | Decodes factory `.puls` streams (raw or GF1-headered) into velocity/accel profiles: peak speeds, ramp-slope fits, per-move segments, Z cadence. Runs anywhere (stdlib only). Source of the factory-true grblHAL defaults: 700/590 mm/s² accel, 200 mm/s max rate, 28160 Hz travel tick. | | `cp_watchdog_timing.py` | HV charge-pump watchdog one-shot timing (runs on the board): latches every CHG_PUMP feed pulse in GPIO3's edge detector (pin 24 only, IMR untouched, ICR2 restored on exit) and polls the `!Q` (`charge_pump_alive`) and `!HV_ENABLE` (`hv_enable`) pads through /dev/mem in a tight loop while it commands short local jogs; prints per-run t_w (last pulse → Q fall), Q → HV_ENABLE delay, priming latency and the feed period, with the loop's worst gap as the resolution. Motion only, laser locked, no other Grbl client attached. | diff --git a/scripts/bench/accel_crash_probe.py b/scripts/bench/accel_crash_probe.py new file mode 100644 index 0000000..c9a7683 --- /dev/null +++ b/scripts/bench/accel_crash_probe.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +"""De-risk drill for the head-accelerometer crash detector (BRINGUP item 6). + +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 ONLY the IG registers plus the IG-latch bit of CTRL7, +which st_accel never writes; it does not change the full scale (CTRL4) +or the ODR (CTRL1), 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 dependent, so the printed CTRL4 FS fixes + the g conversion (+/-2 g default: ~15.6 mg/LSB, 1 g ~= 64) + --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 0 = LIR1 (latch IG_SRC1, read-to-clear) +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 + + ctrl1 = rd(fd, CTRL1) + ctrl4 = rd(fd, CTRL4) + fs = FS_G.get((ctrl4 >> 4) & 0b11, '?') + if unbind and (ctrl1 & 0x07) == 0: + wr(fd, CTRL1, 0x6F) # 800 Hz, BDU, XYZ on; st_accel does this itself + ctrl1 = rd(fd, CTRL1) + lsb_mg = (fs * 1000.0 / 128.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 | 0x01) + 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('