From a2bc4233d58d4684d42570783b7c020d1a470d53 Mon Sep 17 00:00:00 2001 From: ScottW514 Date: Wed, 2 Sep 2026 08:08:19 -0400 Subject: [PATCH] bench: the stale-origin drill goes, drills report their verdict, the pgood gate follows the pin The faultpos live-fire drill armed and commanded a cut at an origin it called stale to test a refusal the design decided not to gate: its only outcome was an emission at an unknown position. Removed from the script and the bench page. live_fire_drills.py discarded every drill's return value, so the bench page recorded a failed live-fire drill as OK. The exit status is the drill's. laser_pgood is the supply's power-good, high on every healthy machine; fire_test.py and the K3 drill aborted on it and pgood_probe.py inverted it. The latch-unlock drills now gate on the safety chain holding HV off (charge-pump watchdog dead, pulse engine idle), as the kernel suite does, and the probe reports the pin as the kernel publishes it. motion.deadman: the controller resumed from its hang recovers on $X and moves again without a restart (the stream's fault acknowledgment). --- forgetest/forgetest/bench.py | 6 +++--- forgetest/forgetest/suite/motion.py | 26 +++++++++++++++++----- scripts/bench/fire_test.py | 17 ++++++++++++--- scripts/bench/gate_a_kernel_drills.py | 17 ++++++++++++--- scripts/bench/live_fire_drills.py | 31 +++------------------------ scripts/bench/pgood_probe.py | 8 +++---- 6 files changed, 59 insertions(+), 46 deletions(-) diff --git a/forgetest/forgetest/bench.py b/forgetest/forgetest/bench.py index c173d02..1ed2430 100644 --- a/forgetest/forgetest/bench.py +++ b/forgetest/forgetest/bench.py @@ -207,10 +207,10 @@ TOOLS = [ # -- laser (live) -------------------------------------------------------------- {"id": "live-fire", "title": "LIVE laser drills", "script": "live_fire_drills.py", "safety": "live", "where": "board", "ported": True, - "args": [_arg("drill", "choice", "witness", "witness / hold / faultpos / ircut / expstop / ctrlstart", - ["witness", "hold", "faultpos", "ircut", "expstop", "ctrlstart"]), + "args": [_arg("drill", "choice", "witness", "witness / hold / ircut / expstop / ctrlstart", + ["witness", "hold", "ircut", "expstop", "ctrlstart"]), _arg("power", "int", 1000, "ircut: S value"), _arg("feed", "int", 300, "ircut: F value")], - "desc": "Emission witness, disarm grace in Hold, stale-origin refusal, lid-IR characterization cut, armed " + "desc": "Emission witness, disarm grace in Hold, lid-IR characterization cut, armed " "kill on the expected-stop path (+ the separate controller restart). The operator's arm press is " "required for every drill; eye protection, fire watch, extinguisher, exhaust."}, {"id": "resume-dark-lead", "title": "Pause / resume chain timing (dark lead)", "script": "resume_dark_lead.py", diff --git a/forgetest/forgetest/suite/motion.py b/forgetest/forgetest/suite/motion.py index 03f6049..3a659b8 100644 --- a/forgetest/forgetest/suite/motion.py +++ b/forgetest/forgetest/suite/motion.py @@ -501,7 +501,8 @@ def _return_x(ctx, delta_mm): description="SIGKILL of the controller mid-move: the supervisor reaps it, safes (cnc/stop, " "latch relocked - it never unlocked), and respawns within seconds. SIGSTOP (a " "hang) mid-move: the ring drains into a kernel underrun (fast halt, latch " - "locked); the hung process is killed and the supervisor respawns. forgectrl " + "locked); the process resumed from the hang recovers on $X and moves again " + "without a restart. forgectrl " "restart mid-move: the busy controller finishes the move unmanaged and the new " "daemon retakes supervision at idle. After each drill the head is jogged back " "by the kernel-measured distance.") @@ -584,12 +585,27 @@ def deadman(ctx): "underruns": hw.sysfs_int("cnc/underruns", 0)} ctx.log("after SIGSTOP: kernel %s in %s s, latch locked %s, underruns %s -> %s", kstate, halt_s, latch_locked(), underruns0, ev["sigstop"]["underruns"]) - _os.kill(pid1, _signal.SIGKILL) # the hung controller cannot recover itself + # The controller comes back from the hang to find its stream + # faulted and the core alarmed. An unlock ($X) is the operator's + # acknowledgment: it must restore a controller that moves again, + # without a restart (the position is not trusted until a re-home, + # so the move is a jog). + _os.kill(pid1, _signal.SIGCONT) + ctx.sleep(1.0) + st = g.status_report()["state"] + ev["sigstop"]["state_after_cont"] = st + ctx.log("controller resumed: state %s", st) + g.command("$X") + g.command("$J=G91X-5F1200") + peak, states, st = wait_idle(ctx, g, 15) + ev["sigstop"]["recovery_states"] = states + ctx.check("TIMEOUT" not in states and not st.startswith("Alarm"), + "the controller did not move again after $X (states %s)", states) ctx.check(kstate == "underrun", "the ring did not drain into a kernel underrun (state %s)", kstate) ctx.check(latch_locked(), "latch unlocked after the underrun") - m2 = wait_running(30, not_pid=pid1) - ev["sigstop"]["respawn"] = m2 - ctx.check(m2 and m2.get("pid") != pid1, "supervisor did not respawn after the hang") + m2 = wait_running(10) + ev["sigstop"]["after"] = m2 + ctx.check(m2 and m2.get("pid") == pid1, "the recovered controller was replaced (%s)", m2) ctx.sleep(3) x1 = _kernel_x_mm(ctx) _return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None) diff --git a/scripts/bench/fire_test.py b/scripts/bench/fire_test.py index 4b3503d..7fa9e64 100644 --- a/scripts/bench/fire_test.py +++ b/scripts/bench/fire_test.py @@ -34,6 +34,17 @@ def rd(attr): return f.read().strip() +# The safety chain asserts HV_ENABLE only while a run feeds the charge-pump +# watchdog: a dead watchdog and an idle pulse engine mean HV off. That is +# the gate for a latch unlock; laser_pgood is the supply's power-good, +# high on every healthy machine, and says nothing about HV. +def hv_off_reason(): + alive = rd('cnc/charge_pump_alive') + state = rd('cnc/state') + if alive != '0' or state != 'idle': + return 'charge_pump_alive=%s state=%s' % (alive, state) + return None + def rd_pos(): with open('/sys/glowforge/cnc/position', 'rb') as f: raw = f.read(32) @@ -61,9 +72,9 @@ stream = ( print('phase %s: stream %d bytes = %.3f s' % (mode, len(stream), len(stream) / TICK_HZ)) if unlock: - pgood = rd('cnc/laser_pgood') - if pgood != '0': - print('ABORT: laser_pgood=%s (HV supply reports good) - refusing latch unlock' % pgood) + why = hv_off_reason() + if why is not None: + print('ABORT: the safety chain is not holding HV off (%s) - refusing latch unlock' % why) sys.exit(1) snap('pre ') diff --git a/scripts/bench/gate_a_kernel_drills.py b/scripts/bench/gate_a_kernel_drills.py index 700c561..0a80e86 100644 --- a/scripts/bench/gate_a_kernel_drills.py +++ b/scripts/bench/gate_a_kernel_drills.py @@ -54,6 +54,17 @@ def rd(attr): return f.read().strip() +# The safety chain asserts HV_ENABLE only while a run feeds the charge-pump +# watchdog: a dead watchdog and an idle pulse engine mean HV off. That is +# the gate for a latch unlock; laser_pgood is the supply's power-good, +# high on every healthy machine, and says nothing about HV. +def hv_off_reason(): + alive = rd('cnc/charge_pump_alive') + state = rd('cnc/state') + if alive != '0' or state != 'idle': + return 'charge_pump_alive=%s state=%s' % (alive, state) + return None + def rd_pos(): with open('/sys/glowforge/cnc/position', 'rb') as f: raw = f.read(32) @@ -209,9 +220,9 @@ def drill_k2(): def drill_k3(): - pgood = rd('cnc/laser_pgood') - if pgood != '0': - print('ABORT: laser_pgood=%s (HV supply reports good) - refusing latch unlock' % pgood) + why = hv_off_reason() + if why is not None: + print('ABORT: the safety chain is not holding HV off (%s) - refusing latch unlock' % why) return 1 stream = POWER0 + FIRE * (3 * TICK_HZ) + PAD * (TICK_HZ // 2) print('K3: %d bytes = %.1f s of FIRE bits; ramp_rate 10000 Hz/s ' diff --git a/scripts/bench/live_fire_drills.py b/scripts/bench/live_fire_drills.py index 36a0599..1f840f1 100644 --- a/scripts/bench/live_fire_drills.py +++ b/scripts/bench/live_fire_drills.py @@ -23,10 +23,6 @@ Drills (pass a name): hold Phase 4 G-10: arm + start a longer job, feed-hold mid-run, then hold. PASS: the disarm grace counts down in Hold and the window closes (armed -> false) without the job resuming. - faultpos Phase 6 G-2/G-3: after a run that was stopped by an - underrun (position no longer trusted), a subsequent armed - job must refuse to cut at the stale origin - the sender - alarms and re-home is required. Reads homed via /status. ircut Lid-IR fire characterization at cutting power: a 30 mm square at S (default 1000 = full) and F (default 300) on scrap, sampled like `witness`. Prints the @@ -492,27 +488,6 @@ def drill_hold(g): g.cmd('$X') -def drill_faultpos(g): - print('=== Phase 6 G-2/G-3 drill: stale origin refused after underrun ===') - s = get_json('/status') - print('homed=%s (an underrun should have cleared this)' % s.get('homed')) - if s.get('homed'): - print('NOTE: homed is still true - run the SIGSTOP/underrun drill ' - 'first, then re-run this to confirm the refusal.') - return - print('attempting an armed cut at the stale origin - it must refuse/alarm') - prepare(g) - arm_cue() - r = g.cmd('M4 S400', timeout=2) - r2 = g.cmd('G1 X10 F300', timeout=3) - st = g.state() - print('controller response: %s / %s state=%s' % (r, r2, st)) - print('FAULTPOS %s' % ('PASS (refused/alarm at stale origin)' - if ('error' in (r + r2).lower() or 'Alarm' in st) else - 'REVIEW - cut was accepted; check G-3 anchor invalidation')) - g.cmd('M5', timeout=1) - - def drill_ircut(g): power = int(sys.argv[2]) if len(sys.argv) > 2 else 1000 feed = int(sys.argv[3]) if len(sys.argv) > 3 else 300 @@ -3239,7 +3214,7 @@ def drill_ctrlstart(g): def main(): drill = sys.argv[1] if len(sys.argv) > 1 else '' drills = {'witness': drill_witness, 'hold': drill_hold, - 'faultpos': drill_faultpos, 'ircut': drill_ircut, + 'ircut': drill_ircut, 'pthresh': drill_pthresh, 'dladder': drill_dladder, 'pcurve': drill_pcurve, 'm5dark': drill_m5dark, 'dpatch': drill_dpatch, 'flowload': drill_flowload, @@ -3254,14 +3229,14 @@ def main(): return drills[drill](None) or 0 # no controller connection needed g = Grbl(HOST, PORT) try: - drills[drill](g) + rc = drills[drill](g) finally: # Always leave the laser commanded off. try: g.cmd('M5', timeout=1) except Exception: pass - return 0 + return rc or 0 # the bench page's verdict is this exit status if __name__ == '__main__': diff --git a/scripts/bench/pgood_probe.py b/scripts/bench/pgood_probe.py index d3d9276..df37875 100644 --- a/scripts/bench/pgood_probe.py +++ b/scripts/bench/pgood_probe.py @@ -12,9 +12,9 @@ against what the chain and the supply were doing: idle, a dry run hv_current), a pause and a resume, a lid open. hv_current comes from the PIC at a lower rate and rides along as a range. -PGOOD is reported as the RAW PIN LEVEL (the kernel's laser_pgood attribute -is the logical, inverted value: 1 = pin low). Everything else is the -kernel's logical value. +PGOOD is the raw pin level as the kernel publishes it (laser_pgood is the +supply's power-good: 1 = the supply reports its outputs within spec). +Everything else is the kernel's logical value. The loop must not hog the CPU: single core, the protocol thread is SCHED_OTHER, so the sampler sleeps between passes and reports its worst gap. @@ -33,7 +33,7 @@ import time CNC = '/sys/glowforge/cnc/' ATTRS = [ # name, attribute, invert-to-raw - ('PGOOD', CNC + 'laser_pgood', True), + ('PGOOD', CNC + 'laser_pgood', False), ('LASER_ON', CNC + 'laser_on', False), ('FIRE', CNC + 'laser_enable', False), ('CP_ALIVE', CNC + 'charge_pump_alive', False),