From 2907072f0bf1ba70be010dbd8475288db04c5f79 Mon Sep 17 00:00:00 2001 From: ScottW514 Date: Sun, 2 Aug 2026 16:49:02 -0400 Subject: [PATCH] Laser latch + safety-chain gating scope-verified; interlock semantics pinned fire_test.py phases A/B with the scope on the PSU LASER_ON pin, power byte 0 throughout, HV unpowered, operator-executed unlock: latch LOCKED severs FIRE entirely (pin flat + kernel laser_enable 0 under 40k streamed fire bits); latch unlocked with the chain unarmed shows FIRE live kernel-side while the PSU pin stays flat - the factory board gates LASER_ON behind OK_2_FIRE exactly like the OpenGlow AND design. interlock_circuit semantics pinned by the 13-to-7 transition: b0 LASER_ON monitor active low, b1 FIRE active high, b3 latch 1=locked. The 1-tick FIRE drop measurement is deferred (SoC net inaccessible; PSU pin needs an armed chain) - documented with both closure paths. --- docs/BRINGUP.md | 33 +++++++++--- scripts/bench/fire_test.py | 102 +++++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 7 deletions(-) create mode 100644 scripts/bench/fire_test.py diff --git a/docs/BRINGUP.md b/docs/BRINGUP.md index c9c37ed..8131ef4 100644 --- a/docs/BRINGUP.md +++ b/docs/BRINGUP.md @@ -183,13 +183,32 @@ hardware I/O — host testing). after end-of-data** (PWMSAR retains the last value; the end-of-data backstop forces FIRE/step lines low, not the power setpoint) — the laser-off guarantee rests entirely on FIRE. - - **≤1-tick FIRE drop at underrun/end-of-data: OPEN** — needs a - scope on the FIRE net (bit-4 toggling with the latch locked: FIRE - is observable while LASER_ON stays gated off by the hardware AND). - Signal naming (per the OpenGlow LASER SAFING sheet): FIRE = the - CPU's per-tick request (kernel attr `laser_enable`); OK_2_FIRE = - chain verdict; LASER_ON = FIRE∧OK_2_FIRE to the tube (active low); - HV_EN = HV supply enable, driven by the safing hardware only. + - **Laser latch + safety-chain gating: scope-verified 2026-08-02** + (`scripts/bench/fire_test.py`, probe on the PSU-connector LASER_ON + pin; power byte 0 throughout, zero step bytes, HV unpowered, + operator at the power switch; phase B latch-unlock executed by the + operator). Phase A (latch LOCKED): 40,000 streamed FIRE bits → + pin dead flat AND kernel `laser_enable` stayed 0 — the latch + severs the FIRE drive entirely. Phase B (latch unlocked, chain + unarmed): kernel `laser_enable=1` mid-window, but the PSU pin + stayed flat and `laser_on`/`laser_on_sampled` stayed 0 — the + factory board gates LASER_ON behind OK_2_FIRE exactly like the + OpenGlow AND design (FIRE ∧ OK_2_FIRE, active high at the PSU + pin). **Interlock snapshot semantics pinned by experiment** + (13→7 during the unlocked FIRE window): b0 = SoC-side LASER_ON + monitor, active LOW (1 = not lasing); b1 = FIRE, active high; + b3 = latch, 1 = locked/0 = unlocked. + - **≤1-tick FIRE drop at underrun/end-of-data: OPEN (deferred).** + The SoC FIRE net is not probe-accessible on the bench, and the + chain-gated PSU pin cannot show FIRE without arming the full + chain (HV powered + pgood + HV_WDOG retriggered). Close it later + either by finding a probe point on the FIRE net (safing-logic + input) or as the first measurement of the eventual chain-armed + laser-milestone session, with the duty-0 discipline from this one. + Signal naming (per the OpenGlow LASER SAFING sheet, now confirmed + to match the factory board): FIRE = per-tick request (kernel + `laser_enable`); OK_2_FIRE = chain verdict; LASER_ON = + FIRE∧OK_2_FIRE to the PSU; HV_EN = HV enable, safing-driven only. - **Interlock readback semantics cross-check: OPEN** (see factory-laser-safety-readbacks notes). 3. **Homing**: X/Y home switch GPIOs exist in the cnc pin map (unused so diff --git a/scripts/bench/fire_test.py b/scripts/bench/fire_test.py new file mode 100644 index 0000000..15d940f --- /dev/null +++ b/scripts/bench/fire_test.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""FIRE-line drop-timing test - runs ON the board. Usage: fire_test.py A|B + +Stream: power(0) first byte (duty forced to ZERO before any FIRE bit - +the run-start reset would otherwise leave it at 100%), then +1 s pads / 2.000 s FIRE bits / 1 s pads / 2.000 s FIRE bits ending +exactly at end-of-data (the backstop edge the gate wants timed). +No step bytes; motor_lock=15; streaming=0 (normal completion). + +Phase A: laser latch LOCKED - expects nothing on the LASER_ON pin. +Phase B: latch UNLOCKED for the run (re-locked in finally). Refuses to +run if laser_pgood reports the HV supply good. Duty is zero throughout. +""" +import fcntl, os, struct, sys, time + +TICK_HZ = 10000 +FIRE = b'\x10' +PAD = b'\x00' + + +def wr(attr, val): + with open('/sys/glowforge/' + attr, 'w') as f: + f.write(str(val)) + + +def rd(attr): + with open('/sys/glowforge/' + attr) as f: + return f.read().strip() + + +def rd_pos(): + with open('/sys/glowforge/cnc/position', 'rb') as f: + raw = f.read(32) + return struct.unpack('<5i', raw[:20]) + + +def snap(tag): + print('%s: state=%s laser_enable=%s laser_on=%s laser_on_sampled=%s interlock=%s' + % (tag, rd('cnc/state'), rd('cnc/laser_enable'), rd('cnc/laser_on'), + rd('cnc/laser_on_sampled'), rd('cnc/interlock_circuit'))) + + +mode = sys.argv[1].upper() if len(sys.argv) > 1 else 'A' + +stream = ( + bytes([0x80]) + # power = 0: duty zero before any FIRE bit + PAD * TICK_HZ + # 1 s baseline + FIRE * (2 * TICK_HZ) + # 2.000 s FIRE window (bounded by pads) + PAD * TICK_HZ + # 1 s gap + FIRE * (2 * TICK_HZ) # 2.000 s FIRE window ending AT end-of-data +) + +print('phase %s: stream %d bytes = %.3f s' % (mode, len(stream), len(stream) / TICK_HZ)) + +if mode == 'B': + pgood = rd('cnc/laser_pgood') + if pgood != '0': + print('ABORT: laser_pgood=%s (HV supply reports good) - refusing phase B' % pgood) + sys.exit(1) + +snap('pre ') +wr('cnc/motor_lock', 15) +wr('cnc/step_freq', TICK_HZ) +wr('cnc/laser_latch', 1) + +fd = os.open('/dev/glowforge', os.O_WRONLY) +try: + fcntl.flock(fd, fcntl.LOCK_EX) + os.lseek(fd, 1, os.SEEK_SET) + wr('cnc/enable', 1) + time.sleep(0.5) + os.write(fd, stream) + pos_before = rd_pos() + + if mode == 'B': + wr('cnc/laser_latch', 0) # UNLOCK for this run only + print('latch UNLOCKED for phase B run') + + wr('cnc/run', 1) + t0 = time.time() + sampled_mid = False + state = '' + while time.time() - t0 < 20: + state = rd('cnc/state') + if not sampled_mid and 1.5 < time.time() - t0 < 3.0: + snap('mid (inside FIRE window)') + sampled_mid = True + if state != 'running': + break + time.sleep(0.1) + print('done: state=%s after %.1f s' % (state, time.time() - t0)) +finally: + wr('cnc/laser_latch', 1) # re-lock unconditionally + fcntl.flock(fd, fcntl.LOCK_UN) + os.close(fd) + +pos_after = rd_pos() +print('pos before=%s after=%s MOVED=%s' % (pos_before, pos_after, pos_before[:3] != pos_after[:3])) +snap('post') +print('underruns=%s faults=%s' % (rd('cnc/underruns'), rd('cnc/faults'))) +wr('cnc/disable', 1) +print('safe state restored: state=%s latch=LOCKED' % rd('cnc/state'))