mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-27 16:51:12 -07:00
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.
175 lines
5.5 KiB
Python
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
|