Files
forgefirm/scripts/bench/gfbench.py
T
ScottW514 cd8a01a3d9 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 <data>/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 (<drill> [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.
2026-08-16 16:28:39 -04:00

175 lines
5.5 KiB
Python

#!/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 <distro> -- 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 <data>/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