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.
This commit is contained in:
ScottW514
2026-08-02 16:49:02 -04:00
parent e8b52b4469
commit 2907072f0b
2 changed files with 128 additions and 7 deletions
+26 -7
View File
@@ -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
+102
View File
@@ -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'))