diff --git a/forgetest/forgetest/suite/kernel.py b/forgetest/forgetest/suite/kernel.py index 1913426..4437b06 100644 --- a/forgetest/forgetest/suite/kernel.py +++ b/forgetest/forgetest/suite/kernel.py @@ -164,10 +164,34 @@ def hv_off(): return None +HV_RELEASE_S = 3.0 + + +def wait_hv_off(ctx, timeout_s=HV_RELEASE_S): + """hv_off_reason() once the chain has released, or the reason that still + stands after timeout_s. A run feeds the charge-pump watchdog every + 200 ms and the one-shot holds ALIVE for 0.45 s after the last feed (the + feed's soft timer can add one more feed after the state leaves + running), so a phase that follows a run finds the chain still up for + under a second. The wait covers that release and nothing more; the + time it took is logged when it was not immediate and kept in the + evidence.""" + t0 = time.time() + why = hv_off_reason() + while why is not None and time.time() - t0 < timeout_s: + ctx.sleep(0.05) + why = hv_off_reason() + dt = round(time.time() - t0, 2) + ctx.evidence.setdefault("hv_release_s", []).append(dt) + if why is not None or dt >= 0.1: + ctx.log("hv off: %s after %.2f s", "released" if why is None else "still held", dt) + return why + + def require_hv_off(ctx): """The same rule at the start of the run (the precheck ran a moment earlier; the machine must still agree).""" - why = hv_off_reason() + why = wait_hv_off(ctx) ctx.evidence["charge_pump_alive"] = rd("cnc/charge_pump_alive") ctx.evidence["kernel_state"] = rd("cnc/state") ctx.check(why is None, "%s - refusing the latch unlock", why) @@ -175,7 +199,7 @@ def require_hv_off(ctx): def check_hv_off(ctx): """The hard check right before an unlock (no prompt: forgectrl is down).""" - why = hv_off_reason() + why = wait_hv_off(ctx) ctx.check(why is None, "%s - refusing the latch unlock", why) diff --git a/forgetest/tests/test_kernel_suite.py b/forgetest/tests/test_kernel_suite.py new file mode 100644 index 0000000..48805d6 --- /dev/null +++ b/forgetest/tests/test_kernel_suite.py @@ -0,0 +1,67 @@ +"""wait_hv_off against a scripted charge-pump readback: a chain that +releases inside the window passes, a chain held past it is the refusal, +and a chain already off costs no wait.""" +import time +import unittest + +from forgetest.suite import kernel + + +class Ctx: + def __init__(self): + self.evidence = {} + self.lines = [] + + def sleep(self, s): + time.sleep(s) + + def log(self, fmt, *a): + self.lines.append(fmt % a) + + +def scripted(alive_for_s): + t0 = time.time() + + def rd(path): + if path == "cnc/charge_pump_alive": + return "1" if time.time() - t0 < alive_for_s else "0" + if path == "cnc/state": + return "idle" + return None + return rd + + +class WaitHvOffTests(unittest.TestCase): + def setUp(self): + self._rd = kernel.rd + + def tearDown(self): + kernel.rd = self._rd + + def test_release_inside_the_window_passes(self): + kernel.rd = scripted(0.3) + ctx = Ctx() + t0 = time.time() + self.assertIsNone(kernel.wait_hv_off(ctx, timeout_s=2.0)) + self.assertGreaterEqual(time.time() - t0, 0.25) + self.assertTrue(any("released" in l for l in ctx.lines)) + self.assertGreaterEqual(ctx.evidence["hv_release_s"][0], 0.25) + + def test_chain_held_past_the_window_is_the_refusal(self): + kernel.rd = scripted(99) + ctx = Ctx() + why = kernel.wait_hv_off(ctx, timeout_s=0.3) + self.assertIn("charge_pump_alive=1", why) + self.assertTrue(any("still held" in l for l in ctx.lines)) + + def test_chain_already_off_costs_no_wait(self): + kernel.rd = scripted(0) + ctx = Ctx() + t0 = time.time() + self.assertIsNone(kernel.wait_hv_off(ctx, timeout_s=2.0)) + self.assertLess(time.time() - t0, 0.1) + self.assertEqual(ctx.lines, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/bench/fire_test.py b/scripts/bench/fire_test.py index 7fa9e64..2dfcd62 100644 --- a/scripts/bench/fire_test.py +++ b/scripts/bench/fire_test.py @@ -45,6 +45,18 @@ def hv_off_reason(): return 'charge_pump_alive=%s state=%s' % (alive, state) return None + +def wait_hv_off(timeout_s=3.0): + # A run feeds the charge-pump watchdog every 200 ms and the one-shot + # holds ALIVE for 0.45 s after the last feed, so a phase that follows a + # run finds the chain still up for under a second: wait for the release. + t0 = time.time() + why = hv_off_reason() + while why is not None and time.time() - t0 < timeout_s: + time.sleep(0.05) + why = hv_off_reason() + return why + def rd_pos(): with open('/sys/glowforge/cnc/position', 'rb') as f: raw = f.read(32) @@ -72,7 +84,7 @@ stream = ( print('phase %s: stream %d bytes = %.3f s' % (mode, len(stream), len(stream) / TICK_HZ)) if unlock: - why = hv_off_reason() + why = wait_hv_off() if why is not None: print('ABORT: the safety chain is not holding HV off (%s) - refusing latch unlock' % why) sys.exit(1) diff --git a/scripts/bench/gate_a_kernel_drills.py b/scripts/bench/gate_a_kernel_drills.py index 0a80e86..15df71c 100644 --- a/scripts/bench/gate_a_kernel_drills.py +++ b/scripts/bench/gate_a_kernel_drills.py @@ -65,6 +65,18 @@ def hv_off_reason(): return 'charge_pump_alive=%s state=%s' % (alive, state) return None + +def wait_hv_off(timeout_s=3.0): + # A run feeds the charge-pump watchdog every 200 ms and the one-shot + # holds ALIVE for 0.45 s after the last feed, so a phase that follows a + # run finds the chain still up for under a second: wait for the release. + t0 = time.time() + why = hv_off_reason() + while why is not None and time.time() - t0 < timeout_s: + time.sleep(0.05) + why = hv_off_reason() + return why + def rd_pos(): with open('/sys/glowforge/cnc/position', 'rb') as f: raw = f.read(32) @@ -220,7 +232,7 @@ def drill_k2(): def drill_k3(): - why = hv_off_reason() + why = wait_hv_off() if why is not None: print('ABORT: the safety chain is not holding HV off (%s) - refusing latch unlock' % why) return 1