mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-10-15 17:19:55 -07:00
The grblHAL harnesses keep what only a running controller shows; the cases a host unit test in grblHAL-glowforge proves, and the log and reply wording, leave. - laser_lifecycle_test: test_arm_waits_for_the_fans goes. laser_arm_test case A4 (grblHAL-glowforge) proves the fan grace stretching the arm's acknowledgment budget, on the same arm path. The docstring names it. - xy_mode_test: case_x32, case_invalid and the log checks go; the default, x16 and the low tick stay. xy_scale_test holds each mode's tick and the not-a-mode parse. - planner_blocks_test: one restart, at $398 1000; the 400 pass and the fresh-store probe go. - ctlport_test: test_poll_lf and test_crlf go (the CR LF poll stays); in test_sender_out the network turn-away goes (serial_test holds it), and the busy gating, the grant and the outlives-client part stay. test_port_dark is whole. - mcode_test: the timeout case runs on the host build's 5 s GFMCODE_WAIT_S (grblHAL-glowforge's CI configures it; the image keeps 30 s) and still shows the hold, at 5.1 s; 5 s outlasts the 3 s test_dark watches the wait; test_table refuses one form per parser branch. test_timeout_holds and test_dark stay. - envelope_test: test_unset and test_bounds go (lens_home_test holds the clamp); test_armed and the open, apply and close cases stay. - manual_home_test: the out-of-range offset block goes, a repeat of gfhome_clamp_home_mm (lens_home_test); the modal-M3 FIRE check and every release and energize case stay. - cool_report_test: one non-hex secret and one CR LF injection stay; runner-malformed and the exact header-set checks go. A bad secret is never sent and a good one is. - tray_test: test_offset keeps 20 mm; test_errors keeps a bad P and a bad axis; the frame shift, envelope, barrier, persistence, port op, unreferenced lens and camera home stay. Proof: with grblHAL-glowforge's matching commit, the eleven harnesses its CI runs pass under Linux against the null-sink build: nine on a build with a 1 s M-code wait, and mcode_test and tray_test, the two that wait at an M-code, on the 5 s build CI configures (-DGFMCODE_WAIT_S=5.0). pyflakes reports nothing in the nine changed files. Push this before grblHAL-glowforge's commit. Acceptance: none; host harnesses only.
195 lines
7.6 KiB
Python
195 lines
7.6 KiB
Python
#!/usr/bin/env python3
|
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
|
# Written by Scott Wiederhold
|
|
# https://community.openglow.org
|
|
# SPDX-License-Identifier: MIT
|
|
"""Host harness: the controller's cooling reports carry the supervisor's secret.
|
|
|
|
POST /cool/state is the running controller's channel alone: forgectrl hands
|
|
each controller it spawns a secret in its environment (GF_REPORT_SECRET), and
|
|
the route asks for it. This harness stands in for forgectrl's listener, runs
|
|
the null-sink controller against it, and reads every report as it arrives.
|
|
|
|
secret every report carries X-ForgeFIRM-Report with the secret it was
|
|
started with
|
|
none started with no secret, the reports carry no such header
|
|
malformed a value that is not 32 hex digits (a letter out of range, or a
|
|
CR LF and a header of its own behind it, 32 characters long) is
|
|
never sent, and nothing of it reaches the wire
|
|
runner the homing runner the controller starts is handed the secret (it
|
|
reports in the controller's place while a gfcloud $H holds the
|
|
machine, and the controller is quiet then), and the reports
|
|
still carry it afterwards; a controller with no secret hands the
|
|
runner none
|
|
|
|
Usage: cool_report_test.py <path-to-grblHAL_glowforge>
|
|
"""
|
|
import http.server
|
|
import os
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
|
|
BIN = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 else "build/grblHAL_glowforge"
|
|
SECRET = "0123456789abcdef0123456789abcdef"
|
|
GRBL_PORT = 23960
|
|
|
|
|
|
def fail(msg):
|
|
print("FAIL:", msg)
|
|
sys.exit(1)
|
|
|
|
|
|
class Listener:
|
|
"""forgectrl's HTTP listener, as far as the reports need it: every
|
|
request is kept whole (the raw header block included) and answered 200."""
|
|
|
|
def __init__(self):
|
|
self.seen = []
|
|
outer = self
|
|
|
|
class H(http.server.BaseHTTPRequestHandler):
|
|
def do_POST(self):
|
|
outer.seen.append({"path": self.path, "headers": {k.lower(): v for k, v in self.headers.items()},
|
|
"raw": bytes(self.headers)})
|
|
self.send_response(200)
|
|
self.send_header("Content-Length", "2")
|
|
self.end_headers()
|
|
self.wfile.write(b"{}")
|
|
|
|
def log_message(self, *a):
|
|
pass
|
|
self.srv = http.server.ThreadingHTTPServer(("127.0.0.1", 0), H)
|
|
self.port = self.srv.server_address[1]
|
|
threading.Thread(target=self.srv.serve_forever, daemon=True).start()
|
|
|
|
def wait(self, n, timeout=8.0):
|
|
end = time.time() + timeout
|
|
while time.time() < end and len(self.seen) < n:
|
|
time.sleep(0.05)
|
|
return list(self.seen)
|
|
|
|
def close(self):
|
|
self.srv.shutdown()
|
|
|
|
|
|
def publish_verdicts(path, stop):
|
|
while not stop.is_set():
|
|
tmp = path + ".tmp"
|
|
with open(tmp, "w") as f:
|
|
f.write('{"ts_mono":%.3f,"fire_ok":true,"verdict":"OK","hold":false,'
|
|
'"resume_ok":true,"armed":false,"reason":""}' % time.clock_gettime(time.CLOCK_MONOTONIC))
|
|
os.replace(tmp, path)
|
|
stop.wait(0.5)
|
|
|
|
|
|
class Controller:
|
|
def __init__(self, listener, secret, runner_cmd=None):
|
|
self.workdir = tempfile.mkdtemp(prefix="cool-report-")
|
|
conf = os.path.join(self.workdir, "forgefirm.conf")
|
|
with open(conf, "w") as f:
|
|
f.write("cool_fan_grace_s = 0\nhoming_mode = %s\n" % ("gfcloud" if runner_cmd else "none"))
|
|
if runner_cmd:
|
|
f.write("gfcloud_home_cmd = %s\n" % runner_cmd)
|
|
verdict = os.path.join(self.workdir, "cooling.state")
|
|
self.stop = threading.Event()
|
|
threading.Thread(target=publish_verdicts, args=(verdict, self.stop), daemon=True).start()
|
|
env = dict(os.environ, GFHOME_CONF=conf, GF_STATE_DIR=self.workdir, GF_VERDICT_FILE=verdict,
|
|
GFSINK_DUMP=os.path.join(self.workdir, "stream.bin"), FFLOG_STDERR="1",
|
|
FORGECTRL_PORT=str(listener.port))
|
|
for k in ("GFSINK", "GF_SWITCH_FILE", "GF_REPORT_SECRET"):
|
|
env.pop(k, None)
|
|
if secret is not None:
|
|
env["GF_REPORT_SECRET"] = secret
|
|
self.proc = subprocess.Popen([BIN, "-p", str(GRBL_PORT)], cwd=self.workdir, env=env,
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
|
|
def sender(self):
|
|
for _ in range(50):
|
|
try:
|
|
s = socket.create_connection(("127.0.0.1", GRBL_PORT), timeout=2)
|
|
s.settimeout(6)
|
|
return s
|
|
except OSError:
|
|
time.sleep(0.1)
|
|
fail("the controller's Grbl socket never opened")
|
|
|
|
def close(self):
|
|
self.stop.set()
|
|
self.proc.terminate()
|
|
try:
|
|
self.proc.wait(5)
|
|
except subprocess.TimeoutExpired:
|
|
self.proc.kill()
|
|
shutil.rmtree(self.workdir, ignore_errors=True)
|
|
|
|
|
|
def run(name, secret, check, runner=False, handed=None):
|
|
lis = Listener()
|
|
ctl = Controller(lis, secret, runner_cmd=("env > %s" % "RUNNER_ENV") if runner else None)
|
|
try:
|
|
if runner:
|
|
env_file = os.path.join(ctl.workdir, "RUNNER_ENV")
|
|
s = ctl.sender()
|
|
time.sleep(0.5)
|
|
s.sendall(b"$H\n")
|
|
end = time.time() + 10
|
|
while time.time() < end and not os.path.exists(env_file):
|
|
time.sleep(0.1)
|
|
if not os.path.exists(env_file):
|
|
fail("[%s] the stand-in homing runner never ran" % name)
|
|
time.sleep(0.3)
|
|
with open(env_file) as f:
|
|
runner_env = f.read()
|
|
got = [ln.split("=", 1)[1] for ln in runner_env.splitlines() if ln.startswith("GF_REPORT_SECRET=")]
|
|
if got != ([handed] if handed else []):
|
|
fail("[%s] the homing runner was handed %r, not %r" % (name, got, handed))
|
|
if not handed and SECRET in runner_env:
|
|
fail("[%s] the secret reached the homing runner another way" % name)
|
|
if "GF_STATE_DIR" not in runner_env:
|
|
fail("[%s] the runner's environment was not read: %r" % (name, runner_env[:80]))
|
|
s.close()
|
|
lis.seen.clear() # what matters is what is reported after it
|
|
reports = lis.wait(3)
|
|
if len(reports) < 3:
|
|
fail("[%s] %d reports in 8 s: the level-triggered report did not arrive" % (name, len(reports)))
|
|
for r in reports:
|
|
if not r["path"].startswith("/cool/state?mode="):
|
|
fail("[%s] a request that is no report: %s" % (name, r["path"]))
|
|
check(name, r)
|
|
print("ok: %s (%d reports)" % (name, len(reports)))
|
|
finally:
|
|
ctl.close()
|
|
lis.close()
|
|
|
|
|
|
def with_secret(name, r):
|
|
if r["headers"].get("x-forgefirm-report") != SECRET:
|
|
fail("[%s] a report without the secret: %s" % (name, r["headers"]))
|
|
|
|
|
|
def without(name, r):
|
|
if "x-forgefirm-report" in r["headers"] or "x-evil" in r["headers"] or b"Evil" in r["raw"]:
|
|
fail("[%s] a header that must not be sent: %s" % (name, r["headers"]))
|
|
|
|
|
|
def main():
|
|
if not os.path.exists(BIN):
|
|
fail("no controller binary at %s" % BIN)
|
|
run("secret", SECRET, with_secret)
|
|
run("none", None, without)
|
|
for i, bad in enumerate((SECRET[:-1] + "g",
|
|
SECRET[:16] + "\r\nX-Evil: 1\r\nX:1"), 1): # the second is 32 long
|
|
run("malformed-%d" % i, bad, without)
|
|
run("runner", SECRET, with_secret, runner=True, handed=SECRET)
|
|
run("runner-none", None, without, runner=True)
|
|
print("cool_report_test: all passed")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|