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