Files
forgefirm/scripts/bench/z_envelope_test.py
T
ScottW514 79d2c07734 The Z envelope survives a settings write
The Z soft limit belongs to the driver, not to $20. glowforge_homing.c
owns sys.work_envelope, sys.homed and sys.soft_limits for Z, because Z is
always referenced, to the lens hall edge or to where the lens stands, and
the core knows neither. The core recomputes both masks from the settings
and drops Z when it does: $20 clears the soft-limit mask inside its
setter, and a $13x write clears the homed bit for the axis as well.

z_envelope_test.py drives the null-sink controller over TCP and holds the
rule. It checks that an unreferenced Z is collapsed to where the lens
stands and refuses a move each way, that X and Y stay free so the
reassert is Z's alone, and that neither write frees Z. It restores $132.

The harness runs in the grblHAL repo's CI, next to the laser stream and
lifecycle harnesses.
2026-09-07 11:02:54 -04:00

197 lines
6.6 KiB
Python

#!/usr/bin/env python3
"""Host-side verification that the Z envelope survives a settings write.
The Z soft limit belongs to the driver, not to $20. glowforge_homing.c
owns sys.work_envelope, sys.homed and sys.soft_limits for Z: Z is always
referenced, to the lens hall edge or to where the lens stands, and the
core knows neither. The core recomputes both masks from the settings and
drops Z when it does, so driver.c re-applies the envelope from the
settings-changed chain, after the rest of the chain has run.
The core drops Z in two places this harness drives:
$20 clears sys.soft_limits for every axis in the setter
$13x clears sys.homed for the axis as well, when the value changes
Runs the native grblHAL_glowforge binary in null-sink mode (no hardware,
no root) and drives it over TCP:
1. the envelope is in force from boot, with $20 off: an unreferenced Z
is collapsed to where the lens stands, so a Z move alarms
2. it is collapsed, not merely present: a move each way alarms
3. it is Z alone: X and Y follow $20 as the core intends, and stay free
4. $20=0, soft limits off for every axis, does not free Z
5. a $132 write, which un-homes Z and clears the mask in the setter,
does not free Z either
6. X and Y are still free after both writes
The binary keeps its settings in EEPROM.DAT in the working directory, so
each run starts from defaults in a temporary directory and leaves
nothing behind.
Usage: z_envelope_test.py [path-to-binary]
(default ./build-native/grblHAL_glowforge)
"""
import os
import re
import shutil
import socket
import subprocess
import sys
import tempfile
import time
BIN = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else "build-native/grblHAL_glowforge")
PORT = 2400
ALARM_SOFT_LIMIT = "ALARM:2"
def fail(msg):
print("FAIL: %s" % msg)
sys.exit(1)
class Session:
"""One null-sink controller process with its own settings store."""
def __init__(self):
self.workdir = tempfile.mkdtemp(prefix="z-envelope-")
env = dict(os.environ, GF_STATE_DIR=self.workdir, FFLOG_STDERR="1")
for key in ("GFSINK", "GF_SWITCH_FILE", "GF_VERDICT_FILE"):
env.pop(key, None)
self.proc = subprocess.Popen([BIN, "-p", str(PORT)],
cwd=self.workdir, env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE)
self.sock = self.connect()
self.read(1.0) # banner and any boot messages
def connect(self):
for _ in range(50):
try:
return socket.create_connection(("127.0.0.1", PORT), timeout=1)
except OSError:
time.sleep(0.1)
err = b""
if self.proc.poll() is not None:
err = self.proc.stderr.read() or b""
fail("cannot connect to the controller (exit=%s)\n%s"
% (self.proc.poll(), err.decode(errors="replace")))
def read(self, timeout=0.8):
out = b""
end = time.time() + timeout
while time.time() < end:
self.sock.settimeout(max(0.05, end - time.time()))
try:
data = self.sock.recv(4096)
except (socket.timeout, OSError):
break
if not data:
break
out += data
return out.decode(errors="replace")
def send(self, line, timeout=1.0):
self.sock.sendall(b"?" if line == "?" else (line + "\n").encode())
return self.read(timeout)
def setting(self, key):
reply = self.send(key)
m = re.search(r"^%s=(\S+)" % re.escape(key), reply, re.M)
if not m:
fail("no value reported for %s: %r" % (key, reply))
return m.group(1)
def write_setting(self, assignment):
reply = self.send(assignment)
if "ok" not in reply:
fail("settings write %r refused: %r" % (assignment, reply))
def state(self):
m = re.search(r"<(\w+)", self.send("?"))
return m.group(1) if m else "?"
def move(self, gcode):
"""Run one move. Returns True when the soft limit blocked it.
An alarm needs a reset and an unlock before the next move."""
reply = self.send(gcode, 1.5)
if ALARM_SOFT_LIMIT in reply:
self.recover()
return True
if "ok" not in reply:
fail("unexpected reply to %r: %r" % (gcode, reply))
return False
def recover(self):
self.sock.sendall(b"\x18")
self.read(1.0)
self.send("$X")
if self.state() != "Idle":
fail("controller did not return to Idle after an alarm")
def close(self):
try:
self.sock.close()
except OSError:
pass
self.proc.terminate()
try:
self.proc.wait(timeout=5)
except subprocess.TimeoutExpired:
self.proc.kill()
shutil.rmtree(self.workdir, ignore_errors=True)
def check(ok, ok_msg, fail_msg):
if not ok:
fail(fail_msg)
print("ok %s" % ok_msg)
def blocked_both_ways(s, when):
check(s.move("G0 Z1"), "Z+ blocked %s" % when, "a Z+ move ran %s" % when)
check(s.move("G0 Z-1"), "Z- blocked %s" % when, "a Z- move ran %s" % when)
def free_in_xy(s, when):
ran = not s.move("G0 X1") and not s.move("G0 Y1")
check(ran, "X and Y free %s" % when, "an X or Y move was blocked %s" % when)
def main():
if not os.path.isfile(BIN):
fail("no controller binary at %s" % BIN)
s = Session()
try:
soft = s.setting("$20")
check(soft == "0", "$20=0 at boot, so the Z envelope is the driver's alone",
"$20 is %s, not 0: this harness needs the default" % soft)
blocked_both_ways(s, "at boot")
free_in_xy(s, "at boot")
# $20 clears sys.soft_limits for every axis inside the setter.
s.write_setting("$20=0")
blocked_both_ways(s, "after a $20 write")
# $13x clears sys.homed for the axis too, but only when the value
# changes, so move it and put it back. Both writes drop Z.
z_travel = s.setting("$132")
s.write_setting("$132=%.3f" % (float(z_travel) + 1.0))
blocked_both_ways(s, "after a $132 write")
s.write_setting("$132=%s" % z_travel)
blocked_both_ways(s, "after $132 is restored")
check(s.setting("$132") == z_travel, "$132 restored to %s" % z_travel,
"$132 did not go back to %s" % z_travel)
free_in_xy(s, "after the settings writes")
finally:
s.close()
print("PASS z_envelope_test")
if __name__ == "__main__":
main()