Pin forgectrl: the crash watch; its catalog case and the drill's latch bit

forgectrl 161686f adds the head-accelerometer crash watch (two tiers
on the LIS2HH12's interrupt generators, armed inside the laser's
armed window). The catalog grows cooling.crash-watch-plumbing - the
unarmed half of the watch provable without a button press: accel_watch
stays at watch through an unarmed session, three zeros read as the
three crash gates off, an out-of-range threshold is refused - and
_COOL_COVERS widens to src/accel.*. The armed-tier bench drill is a
BRINGUP next-work item.

The de-risk drill script's CTRL7 write moves from bit 0 (4D_IG1) to
bit 2 (LIR1), the latch the datasheet actually puts there; the drill's
findings did not depend on latching (a continuous gravity signal).
This commit is contained in:
ScottW514
2026-08-31 18:47:49 -04:00
parent 4a0338a03f
commit c7c96f9cbe
4 changed files with 89 additions and 33 deletions
+6 -30
View File
@@ -1225,36 +1225,12 @@ Open items only. Anything closed is in `CAMPAIGN-LOG.md`.
5. **Update system Phase 5 — recovery refresh.** The remaining phase of
`docs/UPDATE-SYSTEM.md` (a refreshed recovery image in boot0); Phases 0–4
are done.
6. **Head accelerometer crash detector, and the head IRQ (planned;
exploratory).** The factory's head crash detector is the head
accelerometer's own on-chip interrupt generator, armed per job from the
HA* header thresholds and read by polling the sensor; the head IRQ is the
coarse aggregate path to that event and the others (hall, beam). Both are
one mechanism (the facts bank, "The head MCU flag register and HEAD_IRQ"
and "The head accelerometer"), which is why these two items are one. The
detector is adopted, not measured from scratch: the earlier plan to
bench-measure a filter is moot now that the HA* thresholds are known to be
LIS2HH12 register values at a known full scale.
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 readout path is settled by the de-risk drill
(`scripts/bench/accel_crash_probe.py`, on the bench page; the record is
in CAMPAIGN-LOG): the detector is **forgectrl-only**. The IG registers
(0x30 to 0x35) program and poll over i2c-dev (I2C_SLAVE_FORCE) while
`st_accel` stays bound, and the liveness raw reads keep working through
an armed window. Keeping the factory full scale (no CTRL4 write) keeps
the coexistence clean, and the armed detector must own the ODR: the
facts bank ("The head accelerometer") has the power-down behavior. The
gravity axis rules the thresholds: X and Y arm below 1 g, a Z threshold
must sit above 1 g plus margin. Owed: the forgectrl detector itself,
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.
6. **Crash-watch bench commissioning, and the head IRQ (exploratory).**
Commission the crash watch on the bench, on the next image: a knock
inside an armed window must walk both tiers (`BUMP` holds and
releases after the quiet polls; `CRASH` stops motion and locks the
latch for the session), and judge the shipped thresholds against
that knock.
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),
+79
View File
@@ -12,6 +12,7 @@ from .. import hw
_COOL_COVERS = [("forgectrl", "src/cool.*"), ("forgectrl", "src/coolfmt.*"), ("forgectrl", "src/diag.*"),
("forgectrl", "src/gates.*"), ("forgectrl", "src/airflow.*"),
("forgectrl", "src/accel.*"),
("forgectrl", "src/settings.*"),
("forgectrl", "src/status.*"), ("forgectrl", "src/ui/**"),
("grblhal-glowforge", "src/glowforge_cooling.*"),
@@ -836,6 +837,84 @@ def fire_watch_tiers(ctx):
ctx.check(fc.wait_idle(60, abort=ctx.aborted), "machine did not return to idle")
CRASH_KEYS = ("cool_accel_x_alert", "cool_accel_y_alert", "cool_accel_abort")
CRASH_GATES = ["crash_abort", "crash_x_alert", "crash_y_alert"]
@test("cooling.crash-watch-plumbing", title="The crash watch stays off the accelerometer unarmed and its gates say off",
subsystem="cooling", kind="auto", mode="grbl", est_min=3,
covers=_COOL_COVERS, requires=["kernel.latch-locked-idle"],
steps=["Machine idle, lid closed. The test writes the three crash thresholds and restores "
"them; two M8/M9 sessions, no fire, no motion."],
description="The head-accelerometer crash watch's plumbing, provable without an armed "
"window: /cool/status carries accel_watch at watch with the factory-seeded "
"thresholds standing; an unarmed run session never arms the watch (the "
"coexistence contract: liveness and homing own the accel outside the armed "
"window), so accel_watch reads watch through it; the three thresholds at 0 "
"read as the three crash gates off by value (gates_off names them) with the "
"session OK; a threshold past the register range is refused; restored, the "
"gates read ok. The tiers themselves (BUMP, CRASH) trip only on a physical "
"knock inside an armed window and are commissioned at the bench, not here.")
def crash_watch_plumbing(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
before = fc.settings()
orig = {k: before.get(k, "") for k in CRASH_KEYS}
ev["orig"] = orig
ctx.log("original: %s", orig)
gates = (before.get("gates") or {})
for k in CRASH_KEYS:
g = gates.get(k) or {}
ctx.check(g.get("gate", "").startswith("crash_"), "settings gates has no %s row: %s", k, g)
c0 = _cool(fc)
ctx.check(c0.get("accel_watch") == "watch",
"accel_watch %r at idle with thresholds set, expected watch", c0.get("accel_watch"))
st, body = fc.post("/settings", params={"cool_accel_x_alert": "300"})
ctx.check(st != 200 or body.get("cool_accel_x_alert") != "300",
"a threshold past the register range (300) was accepted")
restored = False
with ctx.grbl() as grbl:
ctx.check(grbl.status_report()["state"].startswith("Idle"), "controller is not idle")
try:
# Leg 1: an unarmed session never arms the watch.
c = _run_session(ctx, grbl, fc, lambda c: c.get("phase") == "run", "unarmed session")
ev["unarmed"] = c
ctx.check(c.get("accel_watch") == "watch",
"accel_watch %r in an unarmed run session, expected watch", c.get("accel_watch"))
ctx.check(c.get("verdict") == "OK", "unarmed session not OK: %s", c)
# Leg 2: the three thresholds at zero are the gates off.
_set_gates(ctx, fc, {k: "0" for k in CRASH_KEYS})
c = _run_session(ctx, grbl, fc,
lambda c: c.get("verdict") == "OK" and
set(CRASH_GATES) <= set(c.get("gates_off") or []),
"watch off")
ev["off"] = c
ctx.check(set(CRASH_GATES) <= set(c.get("gates_off") or []),
"gates_off %s does not name the three crash gates", c.get("gates_off"))
ctx.check(c.get("verdict") == "OK", "the watch at zero did not read OK: %s", c)
# Restore, and prove the gates read ok again.
_set_gates(ctx, fc, orig)
restored = True
c = _run_session(ctx, grbl, fc,
lambda c: not (set(CRASH_GATES) & set(c.get("gates_off") or [])),
"restored")
ev["restored"] = c
ctx.check(not (set(CRASH_GATES) & set(c.get("gates_off") or [])),
"the crash gates stayed off after the restore: %s", c.get("gates_off"))
finally:
if not restored:
st, body = fc.post("/settings", params=orig)
ctx.log("restore on failure: POST /settings %s -> %s", orig, st)
after = fc.settings()
ctx.check(all(after.get(k, "") == orig[k] for k in CRASH_KEYS),
"settings not restored: %s", {k: after.get(k) for k in CRASH_KEYS})
ctx.check(fc.wait_idle(60, abort=ctx.aborted), "machine did not return to idle")
@test("cooling.critical-tier", title="The coolant critical line is a fault above the ceiling's pause",
subsystem="cooling", kind="auto", mode="grbl", est_min=3,
covers=_COOL_COVERS + [("forgectrl", "src/main.c")], requires=["cooling.gate-off"],
@@ -2,5 +2,5 @@
# only SRCREV and PV here - the image manifest leaves *-pin.inc out of the
# layer content hash because the component entry already identifies the
# pinned source (forgefirm-image-manifest.bbclass).
SRCREV = "c77f040b954e531ca01b460d5567e23d811737a8"
SRCREV = "161686fddab446270c63bd3c4cfec169f1b14277"
PV = "0.1.0"
+3 -2
View File
@@ -70,7 +70,8 @@ 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)
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
@@ -182,7 +183,7 @@ def main():
# 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, CTRL7, ctrl7 | 0x04)
wr(fd, IG_THS_X1, ths)
wr(fd, IG_THS_Y1, ths)
wr(fd, IG_THS_Z1, ths)