From cd8a01a3d973a23612de9d1b090fe1fb3f8167e5 Mon Sep 17 00:00:00 2001 From: ScottW514 Date: Sun, 16 Aug 2026 16:28:39 -0400 Subject: [PATCH] bench: every board-runnable tool ported to the bench page The remaining bench diagnostics run from forgetest's #bench tab. The tools that also run from a LAN host share scripts/bench/gfbench.py: GF_HOST names a remote machine (host mode, sysfs through ssh, Grbl and forgectrl over the LAN); unset, the tool runs on the board itself (local mode, sysfs directly, everything on 127.0.0.1), which is how the page runs them - with GF_HOST=127.0.0.1, the panel token in GF_TOKEN and their data files under /bench/ (FORGETEST_BENCH_DATA). The helper also reads a machine setting from forgectrl, or from the settings file on the board while forgectrl is stopped. Ported: pwm_sweep / pwm_hold (scope = a takeover; the latch relocked, the write refused if FIRE or LASER_ON reads active), pwm_stream_test (PASS/FAIL exit), flow_characterize, flow_recheck_char, flow_warm_validate and flow_matrix (takeovers: forgectrl owns the thermal hardware, so the page's takeover replaces the tools' own controller stop/restart, whose command line predated the supervisor; results and logs in the bench data directory), flow_sustained, fan_test, temp_calibrate (dry; watch bounded in seconds; the threshold and the coolant conversion from the shared code), flow_escalate_drill (cool_confirm_max_s shortened through forgectrl's settings for the drill and restored; the setting's minimum is the default budget), and live_fire_drills ( [S] [F], all six drills, host from GF_HOST, token from the board). flow_matrix joins the registry. What stays unported cannot run against the machine at all: the two null-sink CI harnesses and the .puls decoder. Runner: a scope tool runs inside the takeover wrapper; the bench environment above is passed to every tool. Tests: test_bench_registry (registry <-> scripts/bench consistency, every ported tool builds its command line, every script compiles, gfbench host/local modes) and the server test (scope tool takeover, the environment reaching the tool). Local mode smoke-run on the bench (temp_calibrate watch, setting, token) from /tmp, removed after. No catalog consequence: bench tools are not image components (dev-only forgetest); the acceptance catalog is unchanged. --- docs/ACCEPTANCE.md | 20 ++- docs/BRINGUP.md | 37 +++++- forgetest/README.md | 1 + forgetest/forgetest/bench.py | 92 ++++++++----- forgetest/forgetest/runner.py | 12 +- forgetest/tests/test_bench_registry.py | 114 ++++++++++++++++ forgetest/tests/test_server.py | 18 ++- scripts/bench/README.md | 58 +++++---- scripts/bench/fan_test.py | 26 ++-- scripts/bench/flow_characterize.py | 40 +----- scripts/bench/flow_escalate_drill.py | 40 ++++-- scripts/bench/flow_matrix.py | 59 +++------ scripts/bench/flow_recheck_char.py | 30 +---- scripts/bench/flow_sustained.py | 34 ++--- scripts/bench/flow_warm_validate.py | 48 ++----- scripts/bench/gfbench.py | 174 +++++++++++++++++++++++++ scripts/bench/live_fire_drills.py | 46 ++++--- scripts/bench/pwm_hold.py | 42 +++++- scripts/bench/pwm_stream_test.py | 17 ++- scripts/bench/pwm_sweep.py | 28 ++++ scripts/bench/temp_calibrate.py | 71 +++++----- 21 files changed, 691 insertions(+), 316 deletions(-) create mode 100644 forgetest/tests/test_bench_registry.py create mode 100644 scripts/bench/gfbench.py diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 7456b9e..93d05db 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -203,10 +203,21 @@ change author's judgment (rule 1). The same daemon serves `#bench`: the registry of the bench tools (`scripts/bench`, installed under `/usr/share/forgetest/bench/`), each with -its safety class (`dry`, `takeover`, `live`, `scope`), argument form, and -last run. A ported tool runs as a subprocess with the output on the page; -unported tools are listed with Start disabled. Bench runs are recorded in -`/data/forgetest/bench.jsonl` and never enter a campaign. +its safety class, argument form, and last run. The classes: `dry` (reads +or dry motion, forgectrl stays up), `takeover` (forgectrl and the +controller stopped for the run, the pulse device free, the same wrapper +the takeover tests use), `scope` (a takeover whose result only means +something with the named instrument on the bench), `live` (emission +possible; the operator acknowledgment and the physical arm press). A tool +runs as a subprocess with the output on the page and, on the board, the +machine as `GF_HOST=127.0.0.1`, the panel token in `GF_TOKEN`, and its data +files under `/data/forgetest/bench/` (`FORGETEST_BENCH_DATA`); the same +scripts run from a LAN host with `GF_HOST` set (`scripts/bench/gfbench.py`, +`scripts/bench/README.md`). Every board-runnable tool is ported; the entries +that stay unported are the CI harnesses of the null-sink controller and the +factory `.puls` decoder, which do not run against the machine at all - they +are listed so the catalog of what exists is complete. Bench runs are +recorded in `/data/forgetest/bench.jsonl` and never enter a campaign. ## Layout @@ -221,6 +232,7 @@ unported tools are listed with Start disabled. Bench runs are recorded in bench.py / coverage.py bench registry + subprocess runner; the lint suite/ the catalog, one module per subsystem forgetest/tests/ host unit tests (python3 -m unittest discover -s tests) + scripts/bench/ the bench tools (+ gfbench.py, the board/host helper) scripts/acceptance-gate.py the gate scripts/manifest-from-tree.py manifest from the recipe pins (CI, workstation) releases/v/ the committed artifacts diff --git a/docs/BRINGUP.md b/docs/BRINGUP.md index a1f5d34..cbfcf04 100644 --- a/docs/BRINGUP.md +++ b/docs/BRINGUP.md @@ -1630,6 +1630,33 @@ that image's campaign is a full one, unavoidably; from then on a component pin bump re-requires only the tests covering that component. Run the full campaign on the first pin-file image, not on `191951`.** +**2026-08-16, bench-tab ports complete (item 15b).** Every tool that can +run against the machine is now runnable from the bench page: the scope +tools (`pwm_sweep`, `pwm_hold` - now a takeover with a locked-state guard: +the latch relocked, refused if FIRE or LASER_ON reads active; +`pwm_stream_test` with a PASS/FAIL exit), the flow characterization +family (`flow_characterize`, `flow_recheck_char`, `flow_warm_validate`, +`flow_matrix` as takeovers - forgectrl owns the thermal hardware, so the +page's takeover replaces the tools' own controller stop/restart, whose +command line predated the supervisor; `flow_sustained`, `fan_test` and +`temp_calibrate` stay dry), the escalation drill (`cool_confirm_max_s` +shortened through forgectrl's settings and restored; the setting's +minimum, 60 s, is the default budget) and the live drills (` [S] +[F]`, all six, the token from the board). The host tools keep working +from a workstation: `scripts/bench/gfbench.py` resolves `GF_HOST` (host +mode, ssh) or the board itself (local mode; the page runs them that way +with `GF_HOST=127.0.0.1`, `GF_TOKEN`, and their data files under +`/data/forgetest/bench/`). Not ported, by nature: the two null-sink CI +harnesses and the `.puls` decoder. Proof: `forgetest/tests/ +test_bench_registry.py` (registry <-> `scripts/bench` consistency, every +ported tool builds its command line, every script compiles, gfbench host +and local modes), the server test (a scope tool runs inside the takeover +wrapper; the bench environment reaches the tool), and a local-mode smoke +run on the bench (`temp_calibrate.py watch`, `gfbench.setting`, the +token) staged in `/tmp` and removed. **The ported tools themselves have +not been exercised from the page on the bench yet - that rides the next +dev image (the confirmation campaign's image).** + ## Hardware facts bank (measured) - **DRV8825 stepper drivers wedge on 40 V rail glitches** (factory board; @@ -2966,9 +2993,11 @@ Run the full campaign on the first pin-file image, not on `191951`.** built with the pin files** (that build is a platform change against every result so far; after it, a component pin bump re-requires only the tests covering that component) - the tool on the bench is the - tree, but a hot-patched image is not the image that ships; (b) the remaining - bench-tab ports (scope tools, host-side flow characterization, the - live drills - the catalog carries their acceptance forms); (c) the - first release runs the full campaign and commits + tree, but a hot-patched image is not the image that ships; (b) the + bench-tab ports are **code-complete 2026-08-16** (every board-runnable + tool is ported: the scope tools, the flow characterization family, + the escalation drill, the live drills; record in "Release acceptance" + above) - their bench validation rides the same next dev image; (c) + the first release runs the full campaign and commits `releases/v/acceptance.json` - **not yet: no release is cut.** diff --git a/forgetest/README.md b/forgetest/README.md index 3c5ed8f..fbaac1d 100644 --- a/forgetest/README.md +++ b/forgetest/README.md @@ -29,6 +29,7 @@ pins; the coverage lint is `python3 -m forgetest.coverage --manifest ...`. | `FORGETEST_MANIFEST` | `/etc/forgefirm-manifest.json` | the image manifest | | `FORGETEST_PORT`, `FORGETEST_HOST` | 8090, 0.0.0.0 | listener | | `FORGETEST_BENCH_DIR` | `/usr/share/forgetest/bench` | the installed bench scripts | +| `FORGETEST_BENCH_DATA` | `/bench` | passed to bench tools: where they keep their data files (with `GF_HOST=127.0.0.1` and the panel token in `GF_TOKEN`) | | `FORGETEST_MARKER` | `/run/forgetest.active` | takeover marker | | `FORGECTRL_URL`, `FORGECTRL_TOKEN_FILE` | `http://127.0.0.1:8080`, `/data/forgefirm/panel.token` | forgectrl client | | `GF_SYSFS_ROOT` | `/sys/glowforge/` | kernel module sysfs | diff --git a/forgetest/forgetest/bench.py b/forgetest/forgetest/bench.py index 5ed3c06..aca1cd8 100644 --- a/forgetest/forgetest/bench.py +++ b/forgetest/forgetest/bench.py @@ -12,10 +12,14 @@ so the catalog of what exists is complete, with Start disabled. Safety classes: dry reads or dry motion, no emission, forgectrl stays up takeover needs forgectrl stopped and the pulse device free + scope a takeover whose result only means something with the named + instrument on the bench (a scope on LASER_PWM / LASER_ON) live laser emission possible (operator acknowledgment required) - scope needs bench instrumentation on top of the class before it -Bench runs are recorded in /bench.jsonl and never enter a campaign. +The tools that also run from a LAN host (gfbench.py: GF_HOST) run on the +board here with GF_HOST=127.0.0.1, the panel token in GF_TOKEN, and +their data files under FORGETEST_BENCH_DATA (/bench/). Bench runs +are recorded in /bench.jsonl and never enter a campaign. """ import json import os @@ -69,20 +73,23 @@ TOOLS = [ "desc": "Creeps toward a rail in bounded jog segments, detects the contact jolt, jog-cancels and backs off."}, # -- board-side, takeover / scope ------------------------------------------ {"id": "pwm-sweep", "title": "LASER_PWM scope sweep", "script": "pwm_sweep.py", - "safety": "scope", "where": "board", "ported": False, + "safety": "scope", "where": "board", "ported": True, "args": [_arg("mode", "choice", "check", "check = read-only, sweep = duty staircase", ["check", "sweep"])], - "desc": "check: readbacks + PWM2 dump; sweep: PWMSAR through 50/25/75/6/100 percent with 4 s holds. Locked state only."}, + "desc": "check: readbacks + PWM2 dump; sweep: PWMSAR through 50/25/75/6/100 percent with 4 s holds. " + "Locked state (the takeover): latch relocked, refuses if FIRE or LASER_ON reads active."}, {"id": "pwm-hold", "title": "LASER_PWM scope hold", "script": "pwm_hold.py", - "safety": "scope", "where": "board", "ported": False, + "safety": "scope", "where": "board", "ported": True, "args": [_arg("sar", "int", 64, "PWMSAR value"), _arg("seconds", "int", 10, "hold time")], - "desc": "Holds one PWMSAR value for a scope window, then restores. Locked state only."}, + "desc": "Holds one PWMSAR value for a scope window, then restores. Locked state (the takeover): " + "latch relocked, refuses if FIRE or LASER_ON reads active."}, {"id": "fire-test", "title": "FIRE drop-timing test (A/B/U)", "script": "fire_test.py", "safety": "takeover", "where": "board", "ported": True, "args": [_arg("mode", "choice", "A", "A latch locked, B unlocked/unarmed, U true underrun", ["A", "B", "U"])], "desc": "Duty 0 throughout; refuses to unlock if HV reports good. Software witnesses + the PSU-connector LASER_ON scope point."}, {"id": "pwm-stream", "title": "LASER_PWM stream-path test", "script": "pwm_stream_test.py", - "safety": "takeover", "where": "board", "ported": False, "args": [], - "desc": "Streams power bytes only (no steps, no FIRE, motor_lock=15, latch locked) through /dev/glowforge."}, + "safety": "takeover", "where": "board", "ported": True, "args": [], + "desc": "Streams power bytes only (no steps, no FIRE, motor_lock=15, latch locked) through /dev/glowforge; " + "PASS = counters unmoved, idle at the end, no FIRE/emission read back. The scope on LASER_PWM sees the duty steps."}, {"id": "gate-a-kernel", "title": "Kernel laser-safety drills K1/K2/K3", "script": "gate_a_kernel_drills.py", "safety": "takeover", "where": "board", "ported": True, "args": [_arg("drill", "choice", "K1", "K1 stop floor, K2 resume honors latch, K3 mid-ramp unlock", ["K1", "K2", "K3"])], @@ -97,51 +104,78 @@ TOOLS = [ "safety": "dry", "where": "board", "ported": True, "args": [], "desc": "One M8 session walks the verdict state machine through real pump-off transients; PASS/FAIL per transition."}, {"id": "flow-escalate", "title": "Coolant starved re-check escalation drill", "script": "flow_escalate_drill.py", - "safety": "dry", "where": "board", "ported": False, "args": [], - "desc": "With the pump off the job-start check reads SUSPECT and the driver must escalate to FAULT."}, + "safety": "dry", "where": "board", "ported": True, + "args": [_arg("budget_s", "int", 60, "cool_confirm_max_s for the drill (60-3600), restored after")], + "desc": "With the pump off the job-start check reads SUSPECT and the engine must escalate to FAULT when " + "the confirmation budget expires; the budget setting is shortened for the drill and restored."}, {"id": "flow-characterize", "title": "Coolant flow characterization", "script": "flow_characterize.py", - "safety": "dry", "where": "host", "ported": False, + "safety": "takeover", "where": "board", "ported": True, "args": [_arg("duty", "int", 30, "heater duty percent")], - "desc": "Baseline -> flow -> no-flow -> recovery with the factory temperature curve; aborts past 45 C downstream."}, + "desc": "Baseline -> flow -> no-flow -> recovery with the factory temperature curve; aborts past 45 C " + "downstream. Drives the heater and pump directly (about 9 minutes)."}, {"id": "flow-sustained", "title": "Coolant sustained re-check run", "script": "flow_sustained.py", - "safety": "dry", "where": "host", "ported": False, "args": [], + "safety": "dry", "where": "board", "ported": True, + "args": [_arg("minutes", "float", 30.0, "how long to hold M8")], "desc": "Long run of the real re-check cadence via M8: verdicts, false faults, loop heat accumulation."}, {"id": "flow-warm", "title": "Coolant warm-baseline validation", "script": "flow_warm_validate.py", - "safety": "dry", "where": "host", "ported": False, "args": [], - "desc": "Runs the real check from a heater-warmed baseline."}, + "safety": "takeover", "where": "board", "ported": True, + "args": [_arg("cycles", "int", 3, "cycles per case (flow / no-flow)")], + "desc": "Runs the real check (40 percent / 50 s, cut-profile fans) from a heater-warmed baseline, alternating " + "flow and no-flow; results to the bench data directory. Slow: about 15 minutes per cycle."}, {"id": "flow-recheck", "title": "Coolant re-check characterization", "script": "flow_recheck_char.py", - "safety": "dry", "where": "host", "ported": False, "args": [], - "desc": "Short in-run re-checks and the differential metric."}, + "safety": "takeover", "where": "board", "ported": True, + "args": [_arg("heater_pct", "int", 50, "heater duty percent"), _arg("window_s", "int", 30, "re-check window")], + "desc": "Short in-run re-checks and the differential metric, flow vs no-flow from a settled loop; " + "aborts past 45 C downstream (about 5 minutes)."}, + {"id": "flow-matrix", "title": "Coolant flow-detection design matrix", "script": "flow_matrix.py", + "safety": "takeover", "where": "board", "ported": True, + "args": [_arg("duties", "str", "10,15,20,30,40,50", "heater duties, percent, comma-separated"), + _arg("repeats", "int", 5, "interleaved repeats per case")], + "desc": "duty x flow/no-flow x repeats from a common cooled baseline; cost and precision tables and a " + "ranked shortlist (the derivation of cool_flow_rise). Very slow: about 1.6 h for the full matrix; " + "resumable from the results file in the bench data directory."}, {"id": "flow-sampler", "title": "Coolant sampler", "script": "flow_sampler.py", "safety": "dry", "where": "board", "ported": True, "args": [_arg("duration_s", "int", 30, "capture length"), _arg("interval_s", "float", 1.0, "sample interval")], "desc": "Prints elapsed,raw_down,raw_up at the interval; the sampler behind the flow tools."}, {"id": "temp-calibrate", "title": "Coolant temperature spot-check", "script": "temp_calibrate.py", - "safety": "dry", "where": "host", "ported": False, + "safety": "dry", "where": "board", "ported": True, "args": [_arg("mode", "choice", "watch", "watch / point / fit", ["watch", "point", "fit"]), - _arg("measured_c", "float", None, "thermometer reading for point")], - "desc": "Pairs a measured temperature with averaged raw readings; fits a per-machine line."}, + _arg("value", "str", None, "point: the thermometer reading in C; watch: seconds (default 60)")], + "desc": "Pairs a measured temperature with averaged raw readings; fits a per-machine line. Points " + "accumulate in the bench data directory."}, {"id": "fan-test", "title": "Fan/coolant bench", "script": "fan_test.py", - "safety": "dry", "where": "host", "ported": False, "args": [], - "desc": "Snapshots fan PWMs/tachs/temps, drives M8 -> cut fans, M9 -> cooldown -> idle. Host-side; port pending."}, + "safety": "dry", "where": "board", "ported": True, "args": [], + "desc": "Snapshots fan PWMs/tachs/temps, drives M8 -> cut fans, M9 -> cooldown -> idle; the tach " + "readbacks in each snapshot are the evidence."}, # -- laser (live) -------------------------------------------------------------- {"id": "live-fire", "title": "LIVE laser drills", "script": "live_fire_drills.py", - "safety": "live", "where": "board", "ported": False, - "args": [_arg("drill", "choice", "witness", "witness / hold / faultpos", ["witness", "hold", "faultpos"])], - "argv_fixed_after": ["127.0.0.1"], - "desc": "Emission witness, disarm grace in Hold, stale-origin refusal. The operator's arm press is required for every drill."}, + "safety": "live", "where": "board", "ported": True, + "args": [_arg("drill", "choice", "witness", "witness / hold / faultpos / ircut / expstop / ctrlstart", + ["witness", "hold", "faultpos", "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 " + "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."}, # -- host-side harnesses (CI) ------------------------------------------------------ {"id": "laser-stream-test", "title": "Laser pulse-stream emission harness", "script": "laser_stream_test.py", "safety": "dry", "where": "host", "ported": False, "args": [], - "desc": "Null-sink controller stream capture against the feeder contract. Runs in the grblHAL repo's CI."}, + "desc": "Null-sink controller stream capture against the feeder contract. A CI harness (the grblHAL repo): " + "it needs the host-built null-sink controller, not the machine, so it is not a bench-page tool."}, {"id": "laser-lifecycle-test", "title": "Armed-window lifecycle harness", "script": "laser_lifecycle_test.py", "safety": "dry", "where": "host", "ported": False, "args": [], - "desc": "Arm/disarm lifecycle on the null-sink controller. Runs in the grblHAL repo's CI."}, + "desc": "Arm/disarm lifecycle on the null-sink controller. A CI harness (the grblHAL repo): needs the " + "host-built null-sink controller, not the machine, so it is not a bench-page tool."}, {"id": "puls-profile", "title": "Factory .puls profile decoder", "script": "puls_profile.py", "safety": "dry", "where": "host", "ported": False, "args": [], - "desc": "Decodes factory pulse streams into velocity/accel profiles. Runs anywhere; needs a .puls file."}, + "desc": "Decodes factory pulse streams into velocity/accel profiles. Runs anywhere; needs a .puls file " + "(the reference captures live off the machine), so it is not a bench-page tool."}, ] +# Files in scripts/bench that are not tools of their own: the helper module +# the host/board tools share, and the C feeder + build scripts (not python). +NOT_TOOLS = ("gfbench.py",) + class Bench: def __init__(self, tools=None, tool_dir=None, index_path=None): diff --git a/forgetest/forgetest/runner.py b/forgetest/forgetest/runner.py index ca95d40..7f03d0f 100644 --- a/forgetest/forgetest/runner.py +++ b/forgetest/forgetest/runner.py @@ -503,7 +503,17 @@ class Runner: captured = self._baseline_pre(run) env = dict(os.environ) env.setdefault("PYTHONUNBUFFERED", "1") - takeover = Takeover(run.log, "bench:" + tool["id"]) if tool.get("safety") == "takeover" else None + # The tools that also run from a LAN host (gfbench.py) run on + # the board here: the machine is local, the panel token is at + # hand, and their data files go under /bench/. + env.setdefault("GF_HOST", "127.0.0.1") + env.setdefault("FORGETEST_BENCH_DATA", os.path.join(data_dir(), "bench")) + if not env.get("GF_TOKEN"): + tok = hw.Forgectrl().token + if tok: + env["GF_TOKEN"] = tok + takeover = (Takeover(run.log, "bench:" + tool["id"]) + if tool.get("safety") in ("takeover", "scope") else None) if takeover is not None: takeover.__enter__() try: diff --git a/forgetest/tests/test_bench_registry.py b/forgetest/tests/test_bench_registry.py new file mode 100644 index 0000000..f32ed29 --- /dev/null +++ b/forgetest/tests/test_bench_registry.py @@ -0,0 +1,114 @@ +"""The bench registry against scripts/bench: every python tool is +registered (or named as not-a-tool), every registered script exists, +every argument spec builds a command line, every script compiles, and +the shared helper resolves host and local mode as documented.""" +import glob +import os +import py_compile +import subprocess +import sys +import unittest + +import helpers # noqa: F401 (sys.path) +from forgetest import bench as bench_mod + +REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +BENCH = os.path.join(REPO, "scripts", "bench") + + +class RegistryTests(unittest.TestCase): + def setUp(self): + self.bench = bench_mod.Bench(tool_dir=BENCH, index_path=os.devnull) + self.scripts = sorted(os.path.basename(p) for p in glob.glob(os.path.join(BENCH, "*.py"))) + + def test_every_python_tool_is_registered(self): + registered = {t["script"] for t in bench_mod.TOOLS} + missing = [s for s in self.scripts if s not in registered and s not in bench_mod.NOT_TOOLS] + self.assertEqual(missing, [], "scripts/bench python files missing from the registry") + + def test_every_registered_script_exists(self): + for t in bench_mod.TOOLS: + self.assertTrue(os.path.exists(os.path.join(BENCH, t["script"])), t["script"]) + + def test_ids_unique_and_fields_valid(self): + ids = [t["id"] for t in bench_mod.TOOLS] + self.assertEqual(len(ids), len(set(ids))) + for t in bench_mod.TOOLS: + self.assertIn(t["safety"], ("dry", "takeover", "scope", "live"), t["id"]) + self.assertIn(t["where"], ("board", "host"), t["id"]) + self.assertIsInstance(t["ported"], bool, t["id"]) + self.assertTrue(t["desc"], t["id"]) + for a in t.get("args", []): + self.assertIn(a["type"], ("str", "int", "float", "choice"), (t["id"], a["name"])) + if a["type"] == "choice": + self.assertIn(a["default"], a["choices"], (t["id"], a["name"])) + + def test_ported_tools_build_a_command_with_defaults(self): + for t in bench_mod.TOOLS: + if not t["ported"]: + continue + ok, argv, err = self.bench.command(t, {}) + self.assertTrue(ok, "%s: %s" % (t["id"], err)) + self.assertEqual(argv[1], os.path.join(BENCH, t["script"])) + + def test_unported_tools_are_host_only(self): + # what stays unported is what cannot run on the machine at all + for t in bench_mod.TOOLS: + if not t["ported"]: + self.assertEqual(t["where"], "host", t["id"]) + self.assertIn("not a bench-page tool", t["desc"], t["id"]) + + def test_scripts_compile(self): + for s in self.scripts: + py_compile.compile(os.path.join(BENCH, s), doraise=True) + + +class GfbenchTests(unittest.TestCase): + """gfbench.py in a subprocess (module-level host resolution).""" + + def run_snippet(self, code, env_extra): + env = dict(os.environ) + env.pop("GF_HOST", None) + env.pop("FORGETEST_BENCH_DATA", None) + env.update(env_extra) + r = subprocess.run([sys.executable, "-c", code], cwd=BENCH, env=env, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=30) + return r.returncode, r.stdout.strip() + + def test_host_mode(self): + rc, out = self.run_snippet("import gfbench; print(gfbench.HOST, gfbench.LOCAL, gfbench.SSH[0])", + {"GF_HOST": "192.0.2.7", "GF_SSH": "fakessh -x"}) + self.assertEqual(rc, 0, out) + self.assertEqual(out, "192.0.2.7 False fakessh") + + def test_local_when_named_local(self): + rc, out = self.run_snippet("import gfbench; print(gfbench.HOST, gfbench.LOCAL); " + "print(gfbench.board('echo hi').strip())", + {"GF_HOST": "127.0.0.1"}) + self.assertEqual(rc, 0, out) + self.assertEqual(out.splitlines(), ["127.0.0.1 True", "hi"]) + + def test_refuses_without_a_machine(self): + if os.path.isdir("/sys/glowforge"): + self.skipTest("running on the machine") + rc, out = self.run_snippet("import gfbench", {}) + self.assertNotEqual(rc, 0) + self.assertIn("GF_HOST", out) + + def test_degc_and_data_dir(self): + import tempfile + d = tempfile.mkdtemp(prefix="forgetest-bench-") + rc, out = self.run_snippet( + "import gfbench, os; print(round(gfbench.degc(700), 2), gfbench.degc('x') != gfbench.degc('x'), " + "os.path.dirname(gfbench.data_path('f.json')) == os.environ['FORGETEST_BENCH_DATA'])", + {"GF_HOST": "127.0.0.1", "FORGETEST_BENCH_DATA": d}) + self.assertEqual(rc, 0, out) + val, nan, indir = out.split() + # a plausible coolant reading (700 counts is room temperature territory) + self.assertTrue(15.0 < float(val) < 35.0, val) + self.assertEqual(nan, "True") + self.assertEqual(indir, "True") + + +if __name__ == "__main__": + unittest.main() diff --git a/forgetest/tests/test_server.py b/forgetest/tests/test_server.py index 38b1501..35fa12e 100644 --- a/forgetest/tests/test_server.py +++ b/forgetest/tests/test_server.py @@ -62,7 +62,9 @@ class ServerTests(unittest.TestCase): cls.tooldir = os.path.join(cls.tmp, "bench") os.makedirs(cls.tooldir) with open(os.path.join(cls.tooldir, "echo_tool.py"), "w") as f: - f.write("import sys, time\nprint('args', sys.argv[1:])\nsys.stdout.flush()\n" + f.write("import os, sys, time\nprint('args', sys.argv[1:])\n" + "print('env', os.environ.get('GF_HOST'), os.environ.get('FORGETEST_BENCH_DATA'))\n" + "sys.stdout.flush()\n" "if 'slow' in sys.argv: time.sleep(30)\nsys.exit(0 if 'fail' not in sys.argv else 3)\n") tools = [{"id": "echo", "title": "Echo", "script": "echo_tool.py", "safety": "dry", "where": "board", "ported": True, "desc": "echo", @@ -73,6 +75,8 @@ class ServerTests(unittest.TestCase): {"id": "hot", "title": "H", "script": "echo_tool.py", "safety": "live", "where": "board", "ported": True, "desc": "", "args": []}, {"id": "tk", "title": "T", "script": "echo_tool.py", "safety": "takeover", "where": "board", + "ported": True, "desc": "", "args": []}, + {"id": "sc", "title": "S", "script": "echo_tool.py", "safety": "scope", "where": "board", "ported": True, "desc": "", "args": []}] cls.bench = bench_mod.Bench(tools, tool_dir=cls.tooldir, index_path=os.path.join(cls.tmp, "bench.jsonl")) @@ -261,7 +265,7 @@ class ServerTests(unittest.TestCase): st, d = self.call("GET", "/bench") self.assertEqual(st, 200) ids = [t["id"] for t in d["tools"]] - self.assertEqual(ids, ["echo", "unported", "hot", "tk"]) + self.assertEqual(ids, ["echo", "unported", "hot", "tk", "sc"]) st, d = self.call("POST", "/bench/start", {"tool": "unported"}) self.assertEqual(st, 409) st, d = self.call("POST", "/bench/start", {"tool": "hot"}) @@ -274,6 +278,9 @@ class ServerTests(unittest.TestCase): self.assertEqual(state["last_run"]["kind"], "bench") self.assertEqual(state["last_run"]["finished"]["result"], "OK") self.assertTrue(any("['yo', '5']" in l for l in state["last_run"]["log"])) + # the bench environment: the machine is local, data under /bench + self.assertTrue(any(" env 127.0.0.1 " in l and l.endswith("bench") + for l in state["last_run"]["log"]), state["last_run"]["log"]) st, d = self.call("GET", "/bench") self.assertEqual(d["tools"][0]["last"]["result"]["result"], "OK") # a failing tool and an aborted one @@ -295,6 +302,13 @@ class ServerTests(unittest.TestCase): self.assertIn("takeover: forgectrl start", log) self.assertIn("baseline: forgectrl unreachable for 10 s", log) self.assertFalse(os.path.exists(os.environ["FORGETEST_MARKER"])) + # a scope tool is a takeover too + st, d = self.call("POST", "/bench/start", {"tool": "sc"}) + self.assertEqual(st, 200, d) + state = self.wait_idle(timeout=40) + log = "\n".join(state["last_run"]["log"]) + self.assertIn("takeover: pulse device free", log) + self.assertIn("takeover: forgectrl start", log) # bench runs never touched the acceptance log recs = self.log.read() self.assertFalse(any(r.get("t") == "result" and r.get("test") == "echo" for r in recs)) diff --git a/scripts/bench/README.md b/scripts/bench/README.md index fa368f3..40c7187 100644 --- a/scripts/bench/README.md +++ b/scripts/bench/README.md @@ -4,39 +4,47 @@ Hardware-verification tools for the ForgeFIRM bench. All run ON the target board (dev image, python3 present) unless noted. The dev image installs them under `/usr/share/forgetest/bench/`, and the acceptance tool's **Bench diagnostics** tab (`http://:8090/#bench`, -`docs/ACCEPTANCE.md`) runs the board-side ones with their arguments and -the output on the page (takeover tools get forgectrl stopped and started -around the run); the acceptance catalog itself is built from ports of -these drills. Host-side tools -take the machine address from `GF_HOST` (or `argv`, where stated); the -ones that shell into the board over ssh use the `ssh` on `PATH`, or the -client named by `GF_SSH` (for example `GF_SSH='wsl -d -- ssh'` -to go through a WSL distro from Windows). +`docs/ACCEPTANCE.md`) runs them with their arguments and the output on +the page - takeover and scope tools get forgectrl and the controller +stopped and started around the run, live tools need the operator +acknowledgment; the acceptance catalog itself is built from ports of +these drills. The tools that also run from a LAN host use `gfbench.py`: +`GF_HOST` names the machine (sysfs through ssh - the `ssh` on `PATH`, or +the client named by `GF_SSH`, for example `GF_SSH='wsl -d -- +ssh'` from Windows - Grbl and forgectrl over the LAN); with `GF_HOST` +unset they run on the board itself (sysfs directly, everything on +127.0.0.1), which is how the bench page runs them, with their data files +under `/data/forgetest/bench/` (`FORGETEST_BENCH_DATA`; next to the tool +otherwise) and the panel token in `GF_TOKEN`. Tools that drive the +thermal hardware directly (the flow characterization family) run with +forgectrl - the thermal-hardware owner - and the controller stopped: the +page's takeover does that; from a host, stop them first. | Tool | Purpose | |---|---| | `feeder.c` | Underrun proof: streams NOP pulse bytes to `/dev/glowforge` with wall-clock pacing, bounded queue depth, deadman flock, SCHED_FIFO. Usage: `feeder `. Proven envelope: 100 kHz × 120 s under full load, 0.2 ms worst write latency. Cross-compile with `build-feeder.sh` (WSL). | | `bench_phase2.py` | End-of-data protocol bench: underrun detection/ack, parked no-replay guard, resume(0), continuous-feed stability, 20× run/underrun cycles. Motion-safe (motors locked, laser latched). | | `check_pwm.py` | Laser PWM register check: reads PWM2 PWMCR/PWMPR via /dev/mem, expects divider 13 × ~127 counts ≈ 40 kHz. | -| `pwm_sweep.py` | LASER_PWM scope test (runs on the board): `check` = read-only safety readbacks + PWM2 dump; `sweep` = steps PWMSAR through 50/25/75/6/100 % duty with 4 s holds, then restores. Run only in the locked state (controller stopped, cnc disabled, latch locked). | -| `pwm_hold.py` | Holds one PWMSAR value for a scope-measurement window (`pwm_hold.py `), then restores. Same locked-state rule. | +| `pwm_sweep.py` | LASER_PWM scope test (runs on the board): `check` = read-only safety readbacks + PWM2 dump; `sweep` = steps PWMSAR through 50/25/75/6/100 % duty with 4 s holds, then restores. Locked state only (controller and forgectrl stopped, the pulse device closed - the page's takeover): the sweep relocks the latch itself and refuses to write if FIRE reads driven or LASER_ON reads active. | +| `pwm_hold.py` | Holds one PWMSAR value for a scope-measurement window (`pwm_hold.py `), then restores. Same locked-state rule and guard. | | `fire_test.py` | FIRE drop-timing scope test (runs on the board): A = latch locked (expects nothing on FIRE/LASER_ON), B = latch unlocked / normal end-of-data, U = true underrun. Duty 0 throughout; refuses to unlock if HV reports good. | -| `pwm_stream_test.py` | LASER_PWM stream-path scope test (runs on the board): streams power bytes only (no step bytes, no FIRE bits, `motor_lock=15`, latch locked) through `/dev/glowforge` so the scope verifies the real power path, including the run-start duty reset and the consecutive-power-byte drop; position counters compared before/after. | +| `pwm_stream_test.py` | LASER_PWM stream-path scope test (runs on the board, controller and forgectrl stopped): streams power bytes only (no step bytes, no FIRE bits, `motor_lock=15`, latch locked) through `/dev/glowforge` so the scope verifies the real power path, including the run-start duty reset and the consecutive-power-byte drop; position counters compared before/after. Exit 0 = counters unmoved, idle at the end, no FIRE/emission read back. | | `gate_a_kernel_drills.py` | Kernel laser-safety drills (run on the board with forgectrl stopped so the pulse device is free): `K1` controlled-stop deceleration floor, `K2` resume waypoint honors the locked latch, `K3` a mid-ramp latch unlock never re-arms the FIRE drive. Software witnesses (`cnc/state`, `laser_enable`, `laser_on`, `laser_on_sampled`, interlock bit 3) plus the PSU-connector LASER_ON scope point; K3 refuses to run if HV reports good. | | `laser_stream_test.py` | Host-side laser pulse-stream emission harness: runs the native null-sink controller with `GFSINK_DUMP`, drives small laser jobs over TCP, and checks the dumped bytes against the kernel feeder contract (leading power byte, no back-to-back power bytes, FIRE only inside cutting moves, every stream ends FIRE-clear, no FIRE on a stepless gap, no FIRE leak across cycle churn). Runs in the grblHAL repo's CI. | | `laser_lifecycle_test.py` | Host-side operator-armed-window lifecycle harness (null-sink controller): arm once per job with M5/M3 persistence, the M2 close, sender-change re-consent, the disarm grace counting down in Hold, and arm refusal under a blocking cooling verdict. Runs in the grblHAL repo's CI. | -| `live_fire_drills.py` | **LIVE LASER** drills from a LAN host (`live_fire_drills.py [host]`, or `GF_HOST`): `witness` (emission witness, lid-IR peaks vs the ambient baseline, HV current, job-based disarm on M2), `hold` (disarm grace in Hold), `faultpos` (armed job refuses a stale origin after an underrun). Every drill waits for the operator's physical arm press; eye protection, fire watch, extinguisher, and exhaust are mandatory. | +| `live_fire_drills.py` | **LIVE LASER** drills, on the board (the bench page) or from a LAN host (`GF_HOST`): `live_fire_drills.py [S] [F]` - `witness` (emission witness, lid-IR peaks vs the ambient baseline, HV current, job-based disarm on M2), `hold` (disarm grace in Hold), `faultpos` (armed job refuses a stale origin after an underrun), `ircut` (lid-IR characterization cut at S/F), `expstop` (armed kill on the expected-stop path; needs the panel token - `GF_TOKEN`, or the board's token file) and `ctrlstart` (the separate controller restart after it). Every drill waits for the operator's physical arm press; eye protection, fire watch, extinguisher, and exhaust are mandatory. | | `pacing_test.py` | Protocol-loop pacing check (runs on the board, dry motion): idle and parked-in-Hold states are coarse-paced, active motion is tight-paced, and a feed-hold/resume mid-move preserves position with no feeder starve. | -| `fan_test.py` | Fan/coolant bench (Windows-side): snapshots fan PWMs/tachs/temps, drives M8 → cut fans, M9 → cooldown → idle, verifying via tach readbacks. | -| `flow_characterize.py` | Coolant flow characterization using the factory temperature curve: baseline → flow → no-flow → recovery, printing the ΔT bands and their separation. Takes the heater duty as an argument (`flow_characterize.py 30`); aborts if downstream passes 45 °C. | -| `flow_matrix.py` | **The flow-detection design matrix** (with `flow_sampler.py`, run on the board from `/usr/share/forgetest/bench/`): duty × duration × flow/no-flow, every run from a common cooled baseline, interleaved repeats. One heating trace yields the metric at every candidate duration, so cost and precision come from the same 60 runs. Prints a cost table, a precision table (mean±sd, worst-case margin, d′) and a ranked shortlist. Env: `FM_DUTIES`, `FM_REPEATS`, `FM_RESULTS`. | -| `flow_sustained.py` | Long-run test of the real re-check cadence via M8: counts verdicts/false faults and tracks whether the loop accumulates heat. | -| `flow_warm_validate.py` | Runs the real check from a heater-warmed baseline. Note the ceiling: 100 % duty pushes the downstream sensor past 50 °C in 30 s while the bulk barely moves, so warm-loop validation above ~23 °C needs the laser, not the heater. | -| `flow_recheck_char.py` | Characterizes short in-run re-checks and the differential metric; shows why over-temp cannot see a stopped pump and why passive warming trends are ambiguous. | +| `gfbench.py` | Not a tool: the helper the board/host tools share - `HOST`/`LOCAL` from `GF_HOST`, `board(cmd)` (local `sh -c` or ssh), the factory coolant conversion `degc()`, `data_path()` (`FORGETEST_BENCH_DATA` or next to the tool), forgectrl's HTTP API with the panel token, `setting(key)` (from forgectrl, or from `/data/forgefirm.conf` on the board while forgectrl is stopped). | +| `fan_test.py` | Fan/coolant bench (board or host; controller running): snapshots fan PWMs/tachs/temps, drives M8 → cut fans, M9 → cooldown → idle, verifying via tach readbacks. | +| `flow_characterize.py` | Coolant flow characterization using the factory temperature curve (board or host; forgectrl and controller stopped): baseline → flow → no-flow → recovery, printing the ΔT bands and their separation. Takes the heater duty as an argument (`flow_characterize.py 30`); aborts if downstream passes 45 °C. | +| `flow_matrix.py` | **The flow-detection design matrix** (board or host; forgectrl and controller stopped; with `flow_sampler.py` from `/usr/share/forgetest/bench/`): duty × duration × flow/no-flow, every run from a common cooled baseline, interleaved repeats. One heating trace yields the metric at every candidate duration, so cost and precision come from the same 60 runs. Prints a cost table, a precision table (mean±sd, worst-case margin, d′) and a ranked shortlist. `flow_matrix.py [duties] [repeats]` (or env `FM_DUTIES`, `FM_REPEATS`, `FM_RESULTS`); results/log in the bench data directory, resumable. | +| `flow_sustained.py` | Long-run test of the real re-check cadence via M8 (board or host; controller running): counts verdicts/false faults against the configured `cool_flow_rise` and tracks whether the loop accumulates heat. `flow_sustained.py [minutes]`. | +| `flow_warm_validate.py` | Runs the real check from a heater-warmed baseline (board or host; forgectrl and controller stopped; `flow_warm_validate.py [cycles_per_case]`; results/log in the bench data directory; exit 1 if any run is misclassified). Note the ceiling: 100 % duty pushes the downstream sensor past 50 °C in 30 s while the bulk barely moves, so warm-loop validation above ~23 °C needs the laser, not the heater. | +| `flow_recheck_char.py` | Characterizes short in-run re-checks and the differential metric (board or host; forgectrl and controller stopped; `flow_recheck_char.py [heater_pct] [window_s]`); shows why over-temp cannot see a stopped pump and why passive warming trends are ambiguous. | | `flow_confirm_drill.py` | Coolant flow suspicion/confirmation drill (runs on the board): one continuous M8 session walks the verdict state machine through real pump-off transients — verified → SUSPECT (+ immediate re-check) → cleared → SUSPECT → FAULT (consecutive) → recovered — printing PASS/FAIL per transition. Leaves the machine idle (M9, pump on, heater off). | -| `flow_escalate_drill.py` | Coolant starved-re-check escalation drill (runs on the board against a controller started with a short confirmation budget): with the pump off the job-start check reads SUSPECT, the stagnant loop cannot pass the settle gate inside the budget, and the driver must escalate to FAULT. PASS/FAIL, leaves the machine idle. | +| `flow_escalate_drill.py` | Coolant starved-re-check escalation drill (runs on the board, controller running): sets the engine's confirmation budget `cool_confirm_max_s` to a short value through forgectrl's settings (`flow_escalate_drill.py [budget_s]`, default 60, the setting's minimum) and restores it after; with the pump off the job-start check reads SUSPECT, the stagnant loop cannot pass the settle gate inside the budget, and the engine must escalate to FAULT. PASS/FAIL (exit status), leaves the machine idle. | | `flow_sampler.py` | Board-side coolant sampler used by the flow tools (`flow_sampler.py `, prints `elapsed,raw_down,raw_up`); run on the board (dev image: `/usr/share/forgetest/bench/`) so cadence does not depend on ssh latency. | -| `temp_calibrate.py` | Coolant temperature spot-check helper (`watch` / `point ` / `fit`) — pairs a measured temperature with averaged raw ADC readings and fits a per-machine line to sanity-check the factory curve against a thermometer. | +| `temp_calibrate.py` | Coolant temperature spot-check helper (board or host): `watch [seconds]` / `point [note]` / `fit` — pairs a measured temperature with averaged raw ADC readings and fits a per-machine line to sanity-check the factory curve against a thermometer. Points accumulate in `temp_calibration.json` in the bench data directory. | | `build-glowforge.sh` | Cross-compiles **grblHAL-glowforge** (the canonical driver repo, `../../../grblHAL-glowforge`) in the Yocto build environment, borrowing the recipe toolchain. Run: `bash /build-glowforge.sh` (from Windows, launch it through the WSL distro from PowerShell; Git Bash mangles /mnt/c paths). Env: `FF_SRC_TOP`, `FF_BUILD_TOP`. This is the production controller build. | | `build-forgectrl.sh` | Cross-compiles **forgectrl** (the canonical control-daemon repo, `../../../forgectrl`) the same way, borrowing the toolchain from the forgectrl recipe workdir (regenerate with `bitbake forgectrl` after a clean). | | `accel_fast.py` | Direct-I2C sampler for the two head-bus LIS2HH12s (runs on the board; unbinds/rebinds st-accel around the capture, 800 Hz ODR, ~270 Hz per device polled): optional mid-capture jogs via local grblHAL TCP. CSV to /tmp/accel.csv. The head accel is i2c-3 0x1e. | @@ -82,11 +90,11 @@ the threshold set at the balanced midpoint of the two bands (14.4 °C). `flow_warm_validate.py` then re-ran the real check from heater-warmed baselines (`flow_warm_results.json`). -To reproduce on another machine: set `GF_HOST` (and `GF_SSH` if ssh needs a -wrapper), boot the dev image (it installs `flow_sampler.py` under -`/usr/share/forgetest/bench/`), and run `flow_matrix.py` (env `FM_DUTIES`, -`FM_REPEATS`, `FM_RESULTS`; ~1.6 h for the full matrix, the controller is -stopped for the duration). The same +To reproduce on another machine: boot the dev image and run the +**flow-matrix** tool from the bench page (`#bench`; a takeover, ~1.6 h for +the full matrix; the results land under `/data/forgetest/bench/`), or from a +host with `GF_HOST` (and `GF_SSH` if ssh needs a wrapper), forgectrl and the +controller stopped, `flow_matrix.py [duties] [repeats]`. The same derivation is also built into forgectrl as the panel's Diagnostics → **flow-calibrate** tool (3 trials per case at the operating point, reports both bands and a recommended threshold; the bench value it recommends diff --git a/scripts/bench/fan_test.py b/scripts/bench/fan_test.py index 1213698..ed9f4f7 100644 --- a/scripts/bench/fan_test.py +++ b/scripts/bench/fan_test.py @@ -1,18 +1,18 @@ -import shlex, socket, subprocess, time -import os +#!/usr/bin/env python3 +"""Fan/coolant bench: snapshots the fan PWMs, tachs and coolant readings, +drives M8 (cut-profile fans), then M9 (cooldown -> idle), and prints +each snapshot for the tach readbacks to be judged. Needs the controller +running with forgectrl's cooling engine. Runs on the board or from a +host (gfbench: GF_HOST).""" +import socket +import time + +from gfbench import HOST, board as _board, degc -HOST = os.environ.get('GF_HOST') -if not HOST: - raise SystemExit('set GF_HOST to the machine IP address') -# ssh client used to reach the board; override for a wrapper, e.g. -# GF_SSH='wsl -d -- ssh'. -SSH = shlex.split(os.environ.get('GF_SSH', 'ssh')) def board(cmd): - r = subprocess.run(SSH + ['-o', 'PreferredAuthentications=none', - 'root@' + HOST, cmd], - capture_output=True, text=True, timeout=30) - return r.stdout.strip().replace('\n', ' ') + return _board(cmd).strip().replace('\n', ' ') + ATTRS = ('head/air_assist_pwm head/air_assist_tach head/purge_air ' 'thermal/exhaust_pwm thermal/tach_exhaust ' @@ -44,7 +44,7 @@ time.sleep(0.5); drain() snap('--- baseline (driver idle profile)') w2 = board('cat /sys/glowforge/pic/water_temp_2') -print('water_temp_2 = %s -> %.1f C' % (w2, int(w2) * -0.09653 + 94)) +print('water_temp_2 = %s -> %.1f C' % (w2, degc(w2))) print() print('=== M8: cut-profile fans ON (loud) ===') diff --git a/scripts/bench/flow_characterize.py b/scripts/bench/flow_characterize.py index 5cd8455..500a12e 100644 --- a/scripts/bench/flow_characterize.py +++ b/scripts/bench/flow_characterize.py @@ -10,43 +10,17 @@ climbs. This measures both signatures and prints the separation. Phases: baseline (heater off) -> flow (heater on, pump on) -> no-flow (pump off) -> recovery (pump on). Restores heater off / pump on. -Run with the controller stopped, or accept that it will fight you: the -driver only writes the heater on M8/M9 transitions, so an idle driver -leaves this alone. +Drives the heater and pump directly, so it runs with forgectrl (the +thermal-hardware owner) and the controller stopped: the bench page's +takeover does that; from a host, stop them first. Runs on the board or +from a host (gfbench: GF_HOST). + +Usage: flow_characterize.py [heater_pct] (default 10) """ -import math -import os -import shlex -import subprocess import sys import time -HOST = os.environ.get('GF_HOST') -if not HOST: - raise SystemExit('set GF_HOST to the machine IP address') -# ssh client used to reach the board; override for a wrapper, e.g. -# GF_SSH='wsl -d -- ssh'. -SSH = shlex.split(os.environ.get('GF_SSH', 'ssh')) - -# Factory B-equation conversion (see kernel-module-glowforge/UAPI.md). -F = 1024.0 * 1.3 -RD = 10000.0 -BETA = 3380.0 -RINF = 10000.0 * math.exp(-3380.0 / 298.15) - - -def degc(raw): - if raw <= 0 or raw >= F: - return float('nan') - r = RD / (F / raw - 1.0) - return BETA / math.log(r / RINF) - 273.15 - - -def board(cmd): - r = subprocess.run(SSH + ['-o', 'PreferredAuthentications=none', - 'root@' + HOST, cmd], - capture_output=True, text=True, timeout=30) - return r.stdout.strip() +from gfbench import board, degc def sample(): diff --git a/scripts/bench/flow_escalate_drill.py b/scripts/bench/flow_escalate_drill.py index 9e0caf5..20b32e3 100644 --- a/scripts/bench/flow_escalate_drill.py +++ b/scripts/bench/flow_escalate_drill.py @@ -1,20 +1,26 @@ #!/usr/bin/env python3 """Bench drill for the starved-re-check escalation path. -Runs ON THE BOARD against a controller started with a short -confirmation budget (GFCOOL_CONFIRM_MAX_S=45; see the runbook for the -manual start line). With the pump off, the job-start check reads +Runs ON THE BOARD with the controller running. The cooling engine's +confirmation budget (`cool_confirm_max_s`, re-read at every run start) +is set to a short value for the drill through forgectrl's settings and +restored afterward. With the pump off, the job-start check reads over-limit -> SUSPECT; the cooked stagnant loop then cannot pass the -settle gate within 45 s, so the budget expires and the driver must -escalate: "COOLANT FLOW FAULT: no clean re-check within 45 s". +settle gate within the budget, so it expires and the engine must +escalate: "COOLANT FLOW FAULT: no clean re-check within N s". +Usage: flow_escalate_drill.py [budget_s] (default 60, the setting's minimum) Prints PASS/FAIL and leaves the machine idle: M9 sent, pump on, -heater off. +heater off, the budget setting as it was. """ import select import socket +import sys import time +from gfbench import forgectrl_post, setting + +BUDGET_S = int(sys.argv[1]) if len(sys.argv) > 1 else 60 T0 = time.monotonic() @@ -57,6 +63,14 @@ def wait_msg(substrs, deadline): return None +budget_was = setting('cool_confirm_max_s') # None = not set (compiled default) +st, body = forgectrl_post('/settings', data={'cool_confirm_max_s': str(BUDGET_S)}) +if st != 200: + log('!! could not set cool_confirm_max_s=%d (POST /settings -> %s %s)' % (BUDGET_S, st, body)) + s.close() + sys.exit(2) +log('cool_confirm_max_s=%d for the drill (was %s)' % (BUDGET_S, budget_was or 'unset')) + ok = True try: time.sleep(0.5) @@ -67,10 +81,19 @@ try: if wait_msg(['FLOW SUSPECT'], el() + 180) is None: log('!! no SUSPECT within 180 s') ok = False - elif wait_msg(['no clean re-check within'], el() + 120) is None: - log('!! no escalation FAULT within 120 s of the suspect') + elif wait_msg(['no clean re-check within'], el() + BUDGET_S + 75) is None: + log('!! no escalation FAULT within %d s of the suspect' % (BUDGET_S + 75)) ok = False finally: + try: + if budget_was is None: + st, _ = forgectrl_post('/settings', params={'cool_confirm_max_s': ''}) + else: + st, _ = forgectrl_post('/settings', data={'cool_confirm_max_s': str(budget_was)}) + log('cool_confirm_max_s restored to %s (-> %s)' % (budget_was or 'unset', st)) + except OSError as e: + log('!! could not restore cool_confirm_max_s: %s' % e) + ok = False try: s.sendall(b'M9\n') time.sleep(1.0) @@ -83,3 +106,4 @@ finally: log('--- M9 sent, pump on, heater off') print('ESCALATION DRILL %s' % ('COMPLETE' if ok else 'FAILED'), flush=True) +sys.exit(0 if ok else 1) diff --git a/scripts/bench/flow_matrix.py b/scripts/bench/flow_matrix.py index d7850d4..cebeb05 100644 --- a/scripts/bench/flow_matrix.py +++ b/scripts/bench/flow_matrix.py @@ -21,37 +21,32 @@ so slow ambient drift spreads across conditions instead of confounding any single one. Safety: aborts a run if downstream passes ABORT_C; heater off and pump -on at every exit path. The controller is stopped for the duration so it -cannot touch the fans or heater, and restarted at the end. +on at every exit path. Drives the heater, pump and fans directly: run +with forgectrl and the controller stopped for the duration (the bench +page's takeover does that; from a host, stop them first). Runs on the +board or from a host (gfbench: GF_HOST). -Output: incremental JSON to flow_matrix_results.json (so partial runs -are still usable) and a summary table at the end. +Output: incremental JSON to flow_matrix_results.json in the bench data +directory (gfbench.data_path; so partial runs are still usable and +resumable) and a summary table at the end. + +Usage: flow_matrix.py [duties] [repeats] e.g. flow_matrix.py 10,20,40 5 + (or env FM_DUTIES, FM_REPEATS, FM_RESULTS) """ import json import math import os -import shlex import statistics -import subprocess import sys import time -HOST = os.environ.get('GF_HOST') -if not HOST: - raise SystemExit('set GF_HOST to the machine IP address') -# ssh client used to reach the board; override for a wrapper, e.g. -# GF_SSH='wsl -d -- ssh'. -SSH = shlex.split(os.environ.get('GF_SSH', 'ssh')) -HERE = os.path.dirname(os.path.abspath(__file__)) -RESULTS = os.path.join(HERE, os.environ.get('FM_RESULTS', 'flow_matrix_results.json')) +from gfbench import board, degc, data_path -# Factory B-equation conversion (kernel-module-glowforge/UAPI.md). -F = 1024.0 * 1.3 -RD, BETA = 10000.0, 3380.0 -RINF = 10000.0 * math.exp(-3380.0 / 298.15) +RESULTS = data_path(os.environ.get('FM_RESULTS', 'flow_matrix_results.json')) -DUTIES = [int(x) for x in os.environ.get('FM_DUTIES', '10,15,20,30,40,50').split(',')] -REPEATS = int(os.environ.get('FM_REPEATS', '5')) +DUTIES = [int(x) for x in (sys.argv[1] if len(sys.argv) > 1 else + os.environ.get('FM_DUTIES', '10,15,20,30,40,50')).split(',')] +REPEATS = int(sys.argv[2]) if len(sys.argv) > 2 else int(os.environ.get('FM_REPEATS', '5')) RUN_S = 75 SAMPLE_IV = 1.0 DURATIONS = [15, 20, 25, 30, 40, 50, 60, 75] @@ -64,21 +59,6 @@ FANS_RUN = ('echo 65535 > /sys/glowforge/thermal/exhaust_pwm; ' 'echo 204 > /sys/glowforge/head/air_assist_pwm') -def degc(raw): - raw = float(raw) - if raw <= 0 or raw >= F: - return float('nan') - r = RD / (F / raw - 1.0) - return BETA / math.log(r / RINF) - 273.15 - - -def board(cmd, timeout=120): - r = subprocess.run(SSH + ['-o', 'PreferredAuthentications=none', - 'root@' + HOST, cmd], - capture_output=True, text=True, timeout=timeout) - return r.stdout - - def temps(): o = board('cat /sys/glowforge/pic/water_temp_1 /sys/glowforge/pic/water_temp_2').split() return degc(o[0]), degc(o[1]) @@ -204,7 +184,7 @@ def summarize(runs, log): def main(): t_start = time.time() - log_path = os.path.join(HERE, 'flow_matrix_log.txt') + log_path = data_path('flow_matrix_log.txt') logf = open(log_path, 'a') def log(msg): @@ -215,8 +195,6 @@ def main(): log('=== flow matrix started %s' % time.strftime('%Y-%m-%d %H:%M:%S')) log('duties=%s repeats=%d run=%ds durations=%s' % (DUTIES, REPEATS, RUN_S, DURATIONS)) - board('pkill -x grblHAL_glowfor; sleep 1; true') - log('controller stopped for the experiment') board(FANS_RUN) safe_state() @@ -264,10 +242,7 @@ def main(): 'echo 0 > /sys/glowforge/thermal/intake_pwm') summarize(runs, log) - - board('cd /data && GFSINK=/dev/glowforge nohup grblHAL_glowforge -p 23 ' - '-e /data/EEPROM-glowforge.DAT > /data/glowforge.log 2>&1 & sleep 2; true') - log('controller restarted; total elapsed %.2f h' % ((time.time() - t_start) / 3600)) + log('results: %s; total elapsed %.2f h' % (RESULTS, (time.time() - t_start) / 3600)) logf.close() diff --git a/scripts/bench/flow_recheck_char.py b/scripts/bench/flow_recheck_char.py index 07a110b..edefd5b 100644 --- a/scripts/bench/flow_recheck_char.py +++ b/scripts/bench/flow_recheck_char.py @@ -35,38 +35,18 @@ the machine). Usage: flow_recheck_char.py [heater_pct] [window_s] (default 50 30) Runs both flow and no-flow cases from a comparable loop state and prints the differential separation. Aborts if downstream passes 45 C. +Drives the heater and pump directly: run with forgectrl and the +controller stopped (the bench page's takeover does that). Runs on the +board or from a host (gfbench: GF_HOST). """ -import math -import os -import shlex -import subprocess import sys import time -HOST = os.environ.get('GF_HOST') -if not HOST: - raise SystemExit('set GF_HOST to the machine IP address') -# ssh client used to reach the board; override for a wrapper, e.g. -# GF_SSH='wsl -d -- ssh'. -SSH = shlex.split(os.environ.get('GF_SSH', 'ssh')) -F = 1024.0 * 1.3 -RD, BETA = 10000.0, 3380.0 -RINF = 10000.0 * math.exp(-3380.0 / 298.15) +from gfbench import board, degc + DOWN_ABORT_C = 45.0 -def degc(raw): - r = RD / (F / raw - 1.0) - return BETA / math.log(r / RINF) - 273.15 - - -def board(cmd): - r = subprocess.run(SSH + ['-o', 'PreferredAuthentications=none', - 'root@' + HOST, cmd], - capture_output=True, text=True, timeout=30) - return r.stdout.strip() - - def sample(): o = board('cat /sys/glowforge/pic/water_temp_1 /sys/glowforge/pic/water_temp_2').split() return degc(int(o[0])), degc(int(o[1])) diff --git a/scripts/bench/flow_sustained.py b/scripts/bench/flow_sustained.py index 1936038..e901522 100644 --- a/scripts/bench/flow_sustained.py +++ b/scripts/bench/flow_sustained.py @@ -6,38 +6,20 @@ job, and does the loop reach equilibrium or climb without bound? Runs the driver's real check cadence (M8 held for the duration) with the cut-profile fans, logging bulk coolant temperature and every verdict. +Needs the controller running with forgectrl's cooling engine (the +verdict lines arrive on the Grbl socket). Runs on the board or from a +host (gfbench: GF_HOST). + Usage: flow_sustained.py [minutes] (default 30) """ -import math -import os -import shlex import re import socket -import subprocess import sys import time -HOST = os.environ.get('GF_HOST') -if not HOST: - raise SystemExit('set GF_HOST to the machine IP address') -# ssh client used to reach the board; override for a wrapper, e.g. -# GF_SSH='wsl -d -- ssh'. -SSH = shlex.split(os.environ.get('GF_SSH', 'ssh')) -F = 1024.0 * 1.3 -RD, BETA = 10000.0, 3380.0 -RINF = 10000.0 * math.exp(-3380.0 / 298.15) +from gfbench import HOST, board, degc, setting - -def degc(raw): - r = RD / (F / float(raw) - 1.0) - return BETA / math.log(r / RINF) - 273.15 - - -def board(cmd): - r = subprocess.run(SSH + ['-o', 'PreferredAuthentications=none', - 'root@' + HOST, cmd], - capture_output=True, text=True, timeout=30) - return r.stdout.strip() +THRESHOLD = float(setting('cool_flow_rise', 14.4)) # forgectrl's configured threshold def temps(): @@ -92,8 +74,8 @@ print() print('checks run: %d over %.0f min' % (len(verdicts), minutes)) if verdicts: rises = [v[1] for v in verdicts] - print('rise values: min=%.2f max=%.2f mean=%.2f (threshold 13.7)' - % (min(rises), max(rises), sum(rises) / len(rises))) + print('rise values: min=%.2f max=%.2f mean=%.2f (threshold %.1f)' + % (min(rises), max(rises), sum(rises) / len(rises), THRESHOLD)) print('false faults: %d' % sum(1 for v in verdicts if v[2])) print('loop temperature: start %.2f -> end %.2f (%+.2f C), peak %.2f' % (u0, u, u - u0, peak_up)) diff --git a/scripts/bench/flow_warm_validate.py b/scripts/bench/flow_warm_validate.py index 3832d48..2f890e4 100644 --- a/scripts/bench/flow_warm_validate.py +++ b/scripts/bench/flow_warm_validate.py @@ -10,33 +10,25 @@ heater (which plateaus near 28-29 C - the most it can reach unaided), then runs the real check at 40% / 50 s with the cut-profile fans, and alternates flow / no-flow so both cases see the same conditions. +Drives the heater, pump and fans directly: run with forgectrl and the +controller stopped (the bench page's takeover does that; from a host, +stop them first). Runs on the board or from a host (gfbench: GF_HOST). +Results and the log go to the bench data directory (gfbench.data_path). + Usage: flow_warm_validate.py [cycles_per_case] (default 3) """ import json -import math -import os -import shlex import statistics -import subprocess import sys import time -HOST = os.environ.get('GF_HOST') -if not HOST: - raise SystemExit('set GF_HOST to the machine IP address') -# ssh client used to reach the board; override for a wrapper, e.g. -# GF_SSH='wsl -d -- ssh'. -SSH = shlex.split(os.environ.get('GF_SSH', 'ssh')) -HERE = os.path.dirname(os.path.abspath(__file__)) -RESULTS = os.path.join(HERE, 'flow_warm_results.json') +from gfbench import board, degc, data_path, setting -F = 1024.0 * 1.3 -RD, BETA = 10000.0, 3380.0 -RINF = 10000.0 * math.exp(-3380.0 / 298.15) +RESULTS = data_path('flow_warm_results.json') DUTY = 40 CHECK_S = 50 -THRESHOLD = 13.7 +THRESHOLD = float(setting('cool_flow_rise', 14.4)) # forgectrl's configured threshold WARM_TARGET_C = 25.5 # what a ~19-20 C room permits at 50% duty WARM_MAX_S = 780 ABORT_C = 48.0 @@ -47,21 +39,6 @@ FANS_OFF = ('echo 0 > /sys/glowforge/thermal/exhaust_pwm; ' 'echo 0 > /sys/glowforge/thermal/intake_pwm') -def degc(raw): - raw = float(raw) - if raw <= 1.0 or raw >= F: - return float('nan') - r = RD / (F / raw - 1.0) - return BETA / math.log(r / RINF) - 273.15 - - -def board(cmd, timeout=120): - r = subprocess.run(SSH + ['-o', 'PreferredAuthentications=none', - 'root@' + HOST, cmd], - capture_output=True, text=True, timeout=timeout) - return r.stdout - - _last_good = (22.0, 22.0) @@ -155,7 +132,7 @@ def check(flow, log): def main(): cycles = int(sys.argv[1]) if len(sys.argv) > 1 else 3 - logf = open(os.path.join(HERE, 'flow_warm_log.txt'), 'a') + logf = open(data_path('flow_warm_log.txt'), 'a') def log(msg): print(msg, flush=True) @@ -164,7 +141,6 @@ def main(): log('=== warm-baseline validation %s duty=%d%% window=%ds threshold=%.1f' % (time.strftime('%Y-%m-%d %H:%M:%S'), DUTY, CHECK_S, THRESHOLD)) - board('pkill -x grblHAL_glowfor; sleep 1; true') runs = [] try: @@ -199,11 +175,9 @@ def main(): % (min(nv) - max(fv), THRESHOLD, THRESHOLD - max(fv), min(nv) - THRESHOLD)) wrong = [r for r in runs if ((r['down_rise'] > THRESHOLD) != (not r['flow']))] log(' misclassified: %d of %d' % (len(wrong), len(runs))) - - board('cd /data && GFSINK=/dev/glowforge nohup grblHAL_glowforge -p 23 ' - '-e /data/EEPROM-glowforge.DAT > /data/glowforge.log 2>&1 & sleep 2; true') - log('controller restarted') + log('results: %s' % RESULTS) logf.close() + return 1 if wrong else 0 if __name__ == '__main__': diff --git a/scripts/bench/gfbench.py b/scripts/bench/gfbench.py new file mode 100644 index 0000000..196833e --- /dev/null +++ b/scripts/bench/gfbench.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""Shared helpers for the bench tools that can run either ON the board or +from a LAN host. + + from gfbench import HOST, LOCAL, board, degc, data_path + +HOST / LOCAL the machine address and whether this process runs on it. + GF_HOST names a remote machine (host mode: sysfs through + ssh, Grbl and forgectrl over the LAN). Unset, the tool + runs on the board itself when /sys/glowforge exists (local + mode: sysfs directly, Grbl and forgectrl on 127.0.0.1) - + the acceptance tool's bench page runs them that way. +board(cmd) run a shell command on the machine and return its stdout + (local: sh -c; host: ssh, or the client named by GF_SSH, + e.g. GF_SSH='wsl -d -- ssh'). +degc(raw) the factory B-equation coolant conversion + (kernel-module-glowforge/UAPI.md). +data_path(f) where a tool keeps its data files: FORGETEST_BENCH_DATA + when set (the bench page passes /bench/), else next + to the tool. +forgectrl_* the machine-services HTTP API (:8080) with the panel token + from GF_TOKEN or, on the board, /data/forgefirm/panel.token. +""" +import json +import math +import os +import shlex +import subprocess +import urllib.error +import urllib.parse +import urllib.request + +HERE = os.path.dirname(os.path.abspath(__file__)) +_LOCAL_HOSTS = ("127.0.0.1", "localhost", "::1") + + +def _resolve(): + host = os.environ.get("GF_HOST") + if host: + return host, host in _LOCAL_HOSTS + if os.path.isdir("/sys/glowforge"): + return "127.0.0.1", True + raise SystemExit("set GF_HOST to the machine IP address (or run this on the board)") + + +HOST, LOCAL = _resolve() +SSH = shlex.split(os.environ.get("GF_SSH", "ssh")) + + +def board(cmd, timeout=30): + """stdout of a shell command run on the machine (not stripped).""" + if LOCAL: + argv = ["sh", "-c", cmd] + else: + argv = SSH + ["-o", "PreferredAuthentications=none", "root@" + HOST, cmd] + r = subprocess.run(argv, capture_output=True, text=True, timeout=timeout) + return r.stdout + + +# Factory B-equation conversion: 10k B3380 NTC in a 10k divider behind a +# 1.3x gain stage, 10-bit ADC. +F = 1024.0 * 1.3 +RD, BETA = 10000.0, 3380.0 +RINF = 10000.0 * math.exp(-3380.0 / 298.15) + + +def degc(raw): + try: + raw = float(raw) + except (TypeError, ValueError): + return float("nan") + if raw <= 1.0 or raw >= F: + return float("nan") + r = RD / (F / raw - 1.0) + return BETA / math.log(r / RINF) - 273.15 + + +def data_dir(): + d = os.environ.get("FORGETEST_BENCH_DATA") + if d: + os.makedirs(d, exist_ok=True) + return d + return HERE + + +def data_path(name): + return os.path.join(data_dir(), name) + + +# ------------------------------------------------------------- forgectrl + +def forgectrl_base(): + return os.environ.get("FORGECTRL_URL") or "http://%s:8080" % HOST + + +def token(): + tok = os.environ.get("GF_TOKEN") + if tok: + return tok + if LOCAL: + try: + with open("/data/forgefirm/panel.token", "r", encoding="utf-8") as f: + return f.read().strip() + except OSError: + pass + return "" + + +def forgectrl_request(method, path, params=None, data=None, timeout=8.0): + """(status, body): body is parsed JSON when the response is JSON, else + text. Raises OSError-derived errors when the daemon is unreachable.""" + url = forgectrl_base() + path + if params: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(params) + body = None + hdrs = {"Host": urllib.parse.urlsplit(forgectrl_base()).netloc} + if data is not None: + body = urllib.parse.urlencode(data).encode() + hdrs["Content-Type"] = "application/x-www-form-urlencoded" + tok = token() + if tok: + hdrs["X-ForgeFIRM-Token"] = tok + req = urllib.request.Request(url, data=body, method=method, headers=hdrs) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + status, content, ctype = resp.status, resp.read(), resp.headers.get("Content-Type", "") + except urllib.error.HTTPError as e: + status, content = e.code, e.read() + ctype = e.headers.get("Content-Type", "") if e.headers else "" + text = content.decode("utf-8", "replace") + if "json" in ctype: + try: + return status, json.loads(text) + except ValueError: + pass + return status, text + + +def forgectrl_get(path, **kw): + return forgectrl_request("GET", path, **kw) + + +def forgectrl_post(path, **kw): + return forgectrl_request("POST", path, **kw) + + +SETTINGS_FILE = "/data/forgefirm.conf" + + +def setting(key, default=None): + """One shared machine setting (forgectrl's /settings; on the board with + forgectrl stopped - a takeover - the settings file itself), or default + when unset or unreadable.""" + try: + st, body = forgectrl_get("/settings") + if st == 200 and isinstance(body, dict): + val = body.get(key) + return default if val in (None, "") else val + except OSError: + pass + if LOCAL: + try: + with open(SETTINGS_FILE, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + if k.strip() == key: + v = v.strip() + return default if v == "" else v + except OSError: + pass + return default diff --git a/scripts/bench/live_fire_drills.py b/scripts/bench/live_fire_drills.py index cff69ec..11dc1d4 100644 --- a/scripts/bench/live_fire_drills.py +++ b/scripts/bench/live_fire_drills.py @@ -1,10 +1,13 @@ #!/usr/bin/env python3 -"""Live-fire bench drills - Phases 4, 5, 6. Runs from a LAN host against -grblHAL over TCP (argv[1] or GF_HOST, port 23) and forgectrl over HTTP -(:8080). LIVE LASER: the operator must be armed with eye protection, a -fire watch, an extinguisher, and the exhaust running. Every drill waits -for the operator to press the physical arm button before the machine -fires; nothing here defeats that gate. +"""Live-fire bench drills - Phases 4, 5, 6. Runs on the board (the bench +page) or from a LAN host, against grblHAL over TCP (port 23) and +forgectrl over HTTP (:8080); the machine is GF_HOST, default 127.0.0.1. +LIVE LASER: the operator must be armed with eye protection, a fire +watch, an extinguisher, and the exhaust running. Every drill waits for +the operator to press the physical arm button before the machine fires; +nothing here defeats that gate. + +Usage: live_fire_drills.py [S] [F] (S, F used by ircut) Drills (pass a name): witness Phase 5 A-1/A-2/A-5: a short vector mark at S400. Samples @@ -29,15 +32,15 @@ Drills (pass a name): engine's own "run telemetry" line is the record. Run it >= 3 times on representative material; the highest peak delta sizes cool_fire_ir_delta. - ircut [host] [S] [F] e.g. ircut 192.0.2.1 1000 300 + ircut [S] [F] e.g. ircut 1000 300 expstop Armed kill on the EXPECTED-stop path: start a mark job, then mid-burn POST /controller/stop (the supervisor stops the controller: SIGTERM, reap, exit safing). PASS: emission drops to 0 within a few samples of the stop and stays 0, the kernel is not running, and POST /controller/start is a SEPARATE step (`ctrlstart`, run after the operator has - judged the stop). Needs the panel token in GF_TOKEN - (cat /data/forgefirm/panel.token on the board). + judged the stop). Needs the panel token: GF_TOKEN, or + /data/forgefirm/panel.token when running on the board. ctrlstart POST /controller/start after an expstop; no motion, no laser. The G-4 arm-refuses-when-a-fire-gate-is-active drill is operator-manual @@ -50,12 +53,21 @@ import sys import time import urllib.request -HOST = sys.argv[2] if len(sys.argv) > 2 else os.environ.get('GF_HOST') -if not HOST: - raise SystemExit('usage: live_fire_drills.py [host] (or set GF_HOST)') +HOST = os.environ.get('GF_HOST') or '127.0.0.1' PORT = 23 BASE = 'http://%s:8080' % HOST + +def panel_token(): + tok = os.environ.get('GF_TOKEN', '') + if tok: + return tok + try: + with open('/data/forgefirm/panel.token') as f: + return f.read().strip() + except OSError: + return '' + # Ambient lid-IR baseline (2026-08-14, lid closed, idle): per-channel means. IR_BASELINE = [37.3, 36.3, 39.5, 40.0] @@ -357,8 +369,8 @@ def drill_faultpos(g): def drill_ircut(g): - power = int(sys.argv[3]) if len(sys.argv) > 3 else 1000 - feed = int(sys.argv[4]) if len(sys.argv) > 4 else 300 + power = int(sys.argv[2]) if len(sys.argv) > 2 else 1000 + feed = int(sys.argv[3]) if len(sys.argv) > 3 else 300 print('=== lid-IR characterization: S%d F%d 30 mm square ===' % (power, feed)) print('connect: %s' % prepare(g)) base = sample_forgectrl() @@ -405,7 +417,7 @@ def drill_ircut(g): def post_ctrl(action): # http.client preserves the header-name case exactly as given. import http.client - tok = os.environ.get('GF_TOKEN', '') + tok = panel_token() c = http.client.HTTPConnection(HOST, 8080, timeout=8) c.putrequest('POST', '/controller/' + action) c.putheader('X-ForgeFIRM-Token', tok) @@ -419,8 +431,8 @@ def post_ctrl(action): def drill_expstop(g): print('=== armed kill on the expected-stop path (POST /controller/stop) ===') - if not os.environ.get('GF_TOKEN'): - raise SystemExit('set GF_TOKEN to the panel token first') + if not panel_token(): + raise SystemExit('set GF_TOKEN to the panel token first (or run on the board)') print('connect: %s' % prepare(g)) arm_cue() job = ['G91', 'G21', 'M4', 'S400', diff --git a/scripts/bench/pwm_hold.py b/scripts/bench/pwm_hold.py index 3d5adf5..c8d8741 100644 --- a/scripts/bench/pwm_hold.py +++ b/scripts/bench/pwm_hold.py @@ -1,6 +1,12 @@ #!/usr/bin/env python3 """Hold LASER_PWM at one duty value for a measurement window, then restore. -Usage: pwm_hold.py [sar] [seconds] (defaults: 8, 30)""" +Usage: pwm_hold.py [sar] [seconds] (defaults: 8, 30) + +The duty register (PWMSAR) is the laser power SETPOINT only. Locked +state only: run with the controller and forgectrl stopped (the bench +page's takeover), the pulse device closed. The latch is relocked here +as well, and the hold refuses to write if the FIRE line reads driven or +LASER_ON reads active.""" import mmap, struct, sys, time PWM2_BASE = 0x02084000 @@ -9,6 +15,26 @@ SAR_OFF = 0x0C sar = int(sys.argv[1]) if len(sys.argv) > 1 else 8 secs = int(sys.argv[2]) if len(sys.argv) > 2 else 30 + +def rd(name): + with open('/sys/glowforge/' + name) as f: + return f.read().strip() + + +def wr(name, val): + with open('/sys/glowforge/' + name, 'w') as f: + f.write(str(val)) + + +try: + wr('cnc/laser_latch', 1) +except OSError as e: + raise SystemExit('could not lock the laser latch: %s' % e) +time.sleep(0.2) +en, on = rd('cnc/laser_enable'), rd('cnc/laser_on') +if en != '0' or on != '0': + raise SystemExit('not the locked state: laser_enable=%s laser_on=%s' % (en, on)) + with open('/dev/mem', 'r+b') as f: m = mmap.mmap(f.fileno(), 4096, mmap.MAP_SHARED, mmap.PROT_READ | mmap.PROT_WRITE, offset=PWM2_BASE) @@ -16,9 +42,11 @@ with open('/dev/mem', 'r+b') as f: m[SAR_OFF:SAR_OFF + 4] = struct.pack('Celsius conversion in UAPI.md is the factory B-equation (10k B3380 NTC in a 10k divider behind a 1.3x gain stage, 10-bit ADC). This @@ -9,47 +9,30 @@ the machine's raw ADC readings - and fits a per-machine line to cross-check that curve against a thermometer. Usage: - temp_calibrate.py watch live raw + current-formula C - temp_calibrate.py point record a calibration point - temp_calibrate.py fit fit and print the calibration + temp_calibrate.py watch [seconds] live raw + current-formula C + (default 60 s) + temp_calibrate.py point [note] record a calibration point + temp_calibrate.py fit fit and print the calibration -Points accumulate in temp_calibration.json next to this script. Take at -least two points as far apart in temperature as practical (e.g. cold +Points accumulate in temp_calibration.json in the bench data directory +(gfbench.data_path: next to this script, or FORGETEST_BENCH_DATA). Take +at least two points as far apart in temperature as practical (e.g. cold machine in the morning, and warm after a fan-off soak with the flow heater on). """ import json -import math import os -import shlex -import subprocess import sys import time -HOST = os.environ.get('GF_HOST') -if not HOST: - raise SystemExit('set GF_HOST to the machine IP address') -# ssh client used to reach the board; override for a wrapper, e.g. -# GF_SSH='wsl -d -- ssh'. -SSH = shlex.split(os.environ.get('GF_SSH', 'ssh')) -STORE = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'temp_calibration.json') +from gfbench import board, degc, data_path + +STORE = data_path('temp_calibration.json') def uapi_c(raw): """The UAPI.md factory conversion (B-equation NTC behind divider + gain).""" - adc_f = 1024.0 * 1.3 - if raw <= 0 or raw >= adc_f: - return float('nan') - rinf = 10000.0 * math.exp(-3380.0 / 298.15) - r = 10000.0 / (adc_f / raw - 1.0) - return 3380.0 / math.log(r / rinf) - 273.15 - - -def board(cmd): - r = subprocess.run(SSH + ['-o', 'PreferredAuthentications=none', - 'root@' + HOST, cmd], - capture_output=True, text=True, timeout=30) - return r.stdout.strip() + return degc(raw) def raws(samples=5, delay=1.0): @@ -93,24 +76,36 @@ def main(): mode = sys.argv[1] if len(sys.argv) > 1 else 'watch' if mode == 'watch': - print('raw1(down) raw2(up) uapi-C down/up (ctrl-C to stop)') - while True: + seconds = float(sys.argv[2]) if len(sys.argv) > 2 else 60.0 + print('raw1(down) raw2(up) uapi-C down/up (%.0f s)' % seconds) + t0 = time.time() + while time.time() - t0 < seconds: r1, r2 = raws(1, 0) - print(' %6.1f %6.1f %.2f / %.2f' - % (r1, r2, uapi_c(r1), uapi_c(r2))) + if r1 is None: + print(' (no reading)') + else: + print(' %6.1f %6.1f %.2f / %.2f' + % (r1, r2, uapi_c(r1), uapi_c(r2)), flush=True) time.sleep(2) elif mode == 'point': - measured = float(sys.argv[2]) + try: + measured = float(sys.argv[2]) + except (IndexError, ValueError): + print('point needs the thermometer reading in C (value)') + return 2 note = sys.argv[3] if len(sys.argv) > 3 else '' - print('sampling raws (10 s)...') + print('sampling raws (10 s)...', flush=True) r1, r2 = raws() + if r1 is None: + print('no readings from the machine') + return 1 data = load() data['points'].append({'measured_c': measured, 'raw1': r1, 'raw2': r2, 'note': note, 'when': time.strftime('%Y-%m-%d %H:%M:%S')}) save(data) - print('recorded: measured %.2f C raw1=%.1f raw2=%.1f (%d points total)' - % (measured, r1, r2, len(data['points']))) + print('recorded: measured %.2f C raw1=%.1f raw2=%.1f (%d points total in %s)' + % (measured, r1, r2, len(data['points']), STORE)) elif mode == 'fit': data = load()