Files
forgefirm/scripts/bench/laser_lifecycle_test.py
T
ScottW514 97287aa6a9 commissioning: the layer, the acceptance tests, the harness rule, the docs, and the bench drills
meta-forgefirm: the forgefirm-users init replays the account at boot;
sshd refuses root and empty passwords and runs only while the panel
turns it on; the release image keeps an empty root password for the
console; the console banner; avahi announces forgefirm.local; https in
libmicrohttpd and ulfius; the panel on 80 and 443; the license bundle on
the rootfs; release.sh checks the root policy on the built rootfs.

forgetest: the commission suites (commission, commission_dark,
commission_sheet: 23 cases); the runner turns cloud mode on with the
typed phrase for a test that declares it; the baseline's motor_lock is
0; the log-export test checks the bundle for the camera key; the record
helpers write bytes as given and join the daemon's paths as POSIX. The
stream harness gains rule 24: a hold verdict is held again after a
resume. Bench drills: lens_travel.py and lens_stop_accel.py.

Docs: BRINGUP carries the present state; CAMPAIGN-LOG carries the dated
record.
2026-09-06 19:56:05 -04:00

1077 lines
47 KiB
Python

#!/usr/bin/env python3
"""Host-side verification of the operator-armed window lifecycle.
Runs the native grblHAL_glowforge binary in null-sink mode (no
hardware, no root; arming is automatic without the physical button)
and drives jobs over TCP, asserting the window's state machine on the
reported messages:
1. the first laser-on of a job arms exactly once, and the window
persists across M5/M3 toggles within the job (no re-prompt)
2. program end (M2) closes the window: the disarm lands promptly,
and the next job arms afresh
3. a sender change closes the window: a reconnected client must
re-arm - the displaced session's consent does not carry over
4. the spindle-off grace counts down in HOLD, not only in Idle - a
job abandoned in feed hold disarms after laser_disarm_s
5. arming is refused while the cooling verdict blocks fire, and no
armed message appears
6. with a switch source present (GF_SWITCH_FILE), the arm blocks on the
button: a press with the lid closed arms, and the lid or the
interlock loop opening during the wait cancels the job (a clean
soft reset, no alarm; latch relocked, never armed)
7. outside the arm wait the button is the pause/resume toggle: a press
while running feed-holds, a press while held resumes; the arming
press itself is not a pause press
8. the lid or the interlock loop opening mid-job cancels the job (the
default lid_policy): reason reported, armed window closed, a soft
reset with the position kept (no alarm), the head returns to the
job start on its own; with lid_policy = hold the stock door hold and
cycle-start resume apply
9. the controller publishes its state for the daemon: grbl.settings
(the $$ view, the derived floor in force from boot) and grbl.state
(JSON with ts_mono, machine state, sender session, laser window
and dose model, modals) appear under GF_STATE_DIR, follow the
sender connection and the armed window, and carry a fresh
ts_mono
10. the job start survives a pause: a job paused and resumed by the
button, then canceled by the lid, returns to where the job began,
not to where it was paused (the core restarts a held cycle through
Idle); a job abandoned in a hold and reset ends there, so the next
job's start is captured afresh where it begins
The disarm grace is shortened via a temp config (GFHOME_CONF), the
cooling verdict is published hermetically (GF_VERDICT_FILE), the same
overrides the emission harness uses, and the switches are driven through
the file-backed test source (GF_SWITCH_FILE: the EV_SW word as an integer).
Usage: laser_lifecycle_test.py [path-to-binary]
(default ./build-native/grblHAL_glowforge)
"""
import os
import re
import shutil
import signal
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-native/grblHAL_glowforge")
PORT = 2398
ARMED = "laser armed"
DISARMED = "laser disarmed - latch locked"
BLOCKED = "laser fire blocked"
PROMPT = "press the button to start the laser job"
RESUME_PROMPT = "press the button to resume the laser job"
LID_CANCEL = "lid opened during arm - job canceled"
LOOP_CANCEL = "interlock open during arm - job canceled"
# EV_SW words for the file-backed switch source: bit 2 button, bit 3 doors
# (set = closed), bit 5 interlock loop (set = OPEN).
SW_CLOSED = 1 << 3
SW_PRESSED = SW_CLOSED | (1 << 2)
SW_LID_OPEN = 0
SW_LOOP_OPEN = SW_CLOSED | (1 << 5)
def fail(msg):
print("FAIL: %s" % msg)
sys.exit(1)
def read_avail(sock, log, timeout, until=None):
end = time.time() + timeout
buf = b""
while time.time() < end:
sock.settimeout(max(0.05, end - time.time()))
try:
data = sock.recv(4096)
except socket.timeout:
data = b""
except OSError:
return None
if data:
buf += data
log.append(data.decode(errors="replace"))
if until:
text = buf.decode(errors="replace")
for token in until:
if token in text or re.search(r"^%s\b" % re.escape(token), text, re.M):
return token
elif until is None:
return None
return None
def send_line(sock, line, log, tolerate_error=False):
sock.sendall((line + "\n").encode())
r = read_avail(sock, log, 5.0, until=("ok", "error"))
if r is None:
fail("no ok/error for %r" % line)
if r == "error" and not tolerate_error:
fail("error response to %r" % line)
return r
def wait_for(log, needle, timeout, sock=None):
"""Wait until needle appears in the accumulated log (draining the
socket while waiting when one is given)."""
end = time.time() + timeout
while time.time() < end:
if needle in "".join(log):
return True
if sock is not None:
read_avail(sock, log, 0.2)
else:
time.sleep(0.1)
return False
def wait_idle(sock, log):
for _ in range(100):
sock.sendall(b"?")
read_avail(sock, log, 0.3)
if re.search(r"<Idle", "".join(log[-3:])):
return
time.sleep(0.2)
fail("controller never returned to Idle")
def publish_verdicts(path, stop, fire_ok, armed_ack=True, ack_after_s=0.0):
"""Stand in for the cooling engine. "armed" is the engine's
acknowledgment that it has taken the controller's armed window and
applied the run airflow; the controller refuses to fire on a verdict
that lacks it, so an engine that never acknowledges (armed_ack
False) must produce a refused arm and no emission. ack_after_s
withholds the acknowledgment for that long first, which is the real
engine's case: it answers on its next tick, and the controller has
to see the refreshed verdict to get past the arm."""
t0 = time.monotonic()
while not stop.is_set():
acked = armed_ack and time.monotonic() - t0 >= ack_after_s
body = ('{"ts_mono":%.3f,"fire_ok":%s,"hold":false,'
'"resume_ok":true,"armed":%s,"reason":""}'
% (time.clock_gettime(time.CLOCK_MONOTONIC),
"true" if fire_ok else "false",
"true" if acked else "false"))
tmp = path + ".tmp"
with open(tmp, "w") as f:
f.write(body)
os.replace(tmp, path)
stop.wait(0.5)
class Session:
"""One controller process with the lifecycle overrides applied."""
def __init__(self, name, fire_ok=True, disarm_s=2, switches=None, conf_extra="",
armed_ack=True, ack_after_s=0.0):
self.name = name
self.workdir = tempfile.mkdtemp(prefix="laser-lifecycle-")
conf = os.path.join(self.workdir, "forgefirm.conf")
with open(conf, "w") as f:
# The fan grace at 0 keeps the arm's acknowledgment budget at its
# 5 s floor, so the refusal cases stay short; a case that wants
# the real budget sets cool_fan_grace_s in conf_extra.
f.write("laser_disarm_s = %d\ncool_fan_grace_s = 0\n%s" % (disarm_s, conf_extra))
verdict = os.path.join(self.workdir, "cooling.state")
env = dict(os.environ, GF_VERDICT_FILE=verdict, GFHOME_CONF=conf,
GF_STATE_DIR=self.workdir, FFLOG_STDERR="1")
env.pop("GFSINK", None)
env.pop("GF_SWITCH_FILE", None)
self.switch_file = None
if switches is not None:
self.switch_file = os.path.join(self.workdir, "switches")
self.set_switches(switches)
env["GF_SWITCH_FILE"] = self.switch_file
self.stop = threading.Event()
self.pub = threading.Thread(target=publish_verdicts,
args=(verdict, self.stop, fire_ok, armed_ack, ack_after_s),
daemon=True)
self.pub.start()
self.proc = subprocess.Popen([BIN, "-p", str(PORT)],
cwd=self.workdir, env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE)
self.log = []
self.sock = self.connect()
def connect(self):
for _ in range(50):
try:
s = socket.create_connection(("127.0.0.1", PORT), timeout=1)
read_avail(s, self.log, 0.5) # banner
return s
except OSError:
time.sleep(0.1)
err = b""
if self.proc.poll() is not None:
err = self.proc.stderr.read() or b""
fail("[%s] cannot connect to the controller (exit=%s)\n%s"
% (self.name, self.proc.poll(), err.decode(errors="replace")))
def set_switches(self, word):
tmp = self.switch_file + ".tmp"
with open(tmp, "w") as f:
f.write("%d\n" % word)
os.replace(tmp, self.switch_file)
def send_raw(self, line):
"""Send a line without waiting for ok/error (the arm wait blocks
the gcode stream, so the ok only comes once the button is pressed)."""
self.sock.sendall((line + "\n").encode())
def press_button(self, hold_s=0.15):
"""A momentary press: word with the button bit, then without."""
base = SW_CLOSED if self.switch_word_closed() else SW_LID_OPEN
self.set_switches(base | (1 << 2))
time.sleep(hold_s)
self.set_switches(base)
def switch_word_closed(self):
try:
with open(self.switch_file) as f:
return int(f.read().strip() or "0", 0) & SW_CLOSED
except (OSError, ValueError):
return True
def state(self):
"""One '?' report's state word (e.g. 'Run', 'Hold:0', 'Door:1', 'Idle')."""
self.sock.sendall(b"?")
read_avail(self.sock, self.log, 0.3)
m = re.findall(r"<([A-Za-z]+(?::\d)?)", "".join(self.log[-4:]))
return m[-1] if m else ""
def wait_state(self, prefix, timeout):
end = time.time() + timeout
while time.time() < end:
st = self.state()
if st.startswith(prefix):
return st
time.sleep(0.1)
return None
def mpos_x(self):
self.sock.sendall(b"?")
read_avail(self.sock, self.log, 0.3)
m = re.findall(r"MPos:(-?[\d.]+)", "".join(self.log[-4:]))
return float(m[-1]) if m else None
def armed_count(self):
return "".join(self.log).count(ARMED)
def disarmed_count(self):
return "".join(self.log).count(DISARMED)
def close(self):
try:
self.sock.close()
except OSError:
pass
self.proc.send_signal(signal.SIGINT)
try:
self.proc.wait(5)
except subprocess.TimeoutExpired:
self.proc.kill()
self.stop.set()
self.pub.join(2)
shutil.rmtree(self.workdir, ignore_errors=True)
def read_state(sess, key=None, timeout=8.0):
"""The published grbl.state as text, polled until `key` appears (the
publisher runs on the protocol thread's pace; a '?' keeps it ticking)."""
path = os.path.join(sess.workdir, "grbl.state")
deadline = time.time() + timeout
text = ""
while time.time() < deadline:
sess.sock.sendall(b"?")
read_avail(sess.sock, sess.log, 0.2)
try:
with open(path) as f:
text = f.read()
except OSError:
text = ""
if text.endswith("\n") and (key is None or key in text):
return text
time.sleep(0.2)
return text
def test_status_files():
print("state files: settings and state published, following the edges")
s = Session("state-files", disarm_s=30)
try:
st = read_state(s, '"connected":true')
assert '"state":"Idle"' in st, "state file lacks Idle: %r" % st
assert '"connected":true' in st, "sender not reported connected: %r" % st
assert '"armed":false' in st, "armed before any job: %r" % st
assert '"model":"density"' in st and '"floor_pct":10' in st, \
"model/floor missing (the floor must be derived from boot): %r" % st
assert '"curve":"bench-default"' in st, \
"the curve in force is not published: %r" % st
assert '"modals":"[GC:' in st, "modal report missing: %r" % st
ts0 = float(st.split('"ts_mono":')[1].split(',')[0])
cfg = open(os.path.join(s.workdir, "grbl.settings")).read()
assert "$35=" in cfg and "$30=" in cfg, "settings file lacks $ lines"
# The derived floor is in force from boot, before any arm.
assert "$35=10" in cfg, "the derived floor is not in $$ from boot: %r" % [
l for l in cfg.splitlines() if l.startswith("$35")]
# The armed window reaches the file.
send_line(s.sock, "G91", s.log)
send_line(s.sock, "M3 S100", s.log)
st = read_state(s, '"armed":true')
assert '"armed":true' in st, "armed window not published: %r" % st
send_line(s.sock, "M5", s.log)
send_line(s.sock, "M2", s.log)
# A reconnect bumps the generation and stays connected.
gen0 = int(st.split('"generation":')[1].split(',')[0])
s.sock.close()
s.sock = s.connect()
st = read_state(s, '"connected":true')
gen1 = int(st.split('"generation":')[1].split(',')[0])
assert gen1 > gen0, "generation did not advance on reconnect (%d -> %d)" % (gen0, gen1)
ts1 = float(st.split('"ts_mono":')[1].split(',')[0])
assert ts1 > ts0, "ts_mono did not advance"
print("PASS [state-files]: published, armed/switch/revert followed, "
"generation %d -> %d" % (gen0, gen1))
finally:
s.close()
def test_laser_keys_reload():
"""M102 reloads the corner gamma, the curve, and the floor inside an
armed job, synchronized, and the state file follows them."""
print("laser keys reload: M102 takes rewritten laser keys mid-job")
s = Session("keys-reload", disarm_s=30, conf_extra="laser_corner_gamma = 1.5\n")
try:
st = read_state(s, '"gamma":1.50')
assert '"gamma":1.50' in st, "the gamma from the conf is not in force at boot: %r" % st
send_line(s.sock, "G91", s.log)
send_line(s.sock, "M4 S100", s.log)
st = read_state(s, '"armed":true')
assert '"armed":true' in st, "armed window not published: %r" % st
send_line(s.sock, "G1 X2 F600", s.log)
conf = os.path.join(s.workdir, "forgefirm.conf")
with open(conf, "w") as f:
f.write("laser_disarm_s = 30\nlaser_corner_gamma = 1.25\nlaser_dose_curve = off\n"
"laser_floor_density = 0\n")
send_line(s.sock, "M102", s.log)
assert wait_for(s.log, "laser keys reloaded: corner gamma 1.25, curve off, floor 0 %", 5, s.sock), \
"M102 did not report the reload: %r" % s.log[-6:]
st = read_state(s, '"gamma":1.25')
assert '"gamma":1.25' in st and '"curve":"off"' in st and '"floor_pct":0' in st, \
"the reloaded keys did not reach the state file: %r" % st
assert '"armed":true' in st, "the reload closed the armed window: %r" % st
send_line(s.sock, "G1 X2 F600", s.log)
send_line(s.sock, "M5", s.log)
send_line(s.sock, "M2", s.log)
print("PASS [keys-reload]: gamma 1.50 -> 1.25, the curve off, the floor 0, inside the armed job")
finally:
s.close()
def test_job_window():
"""Rules 1 + 2: arm once per job, persist across M5/M3, close at M2,
fresh consent for the next job. The grace is set far beyond the test
horizon so only the program-end close can produce the disarm."""
s = Session("job-window", disarm_s=60)
try:
send_line(s.sock, "M4 S100", s.log)
send_line(s.sock, "G1 X1 F600", s.log)
if not wait_for(s.log, ARMED, 5, s.sock):
fail("[job-window] first laser-on did not arm")
send_line(s.sock, "M5", s.log)
send_line(s.sock, "M3 S200", s.log)
send_line(s.sock, "G1 X0 F600", s.log)
wait_idle(s.sock, s.log)
if s.armed_count() != 1:
fail("[job-window] M5/M3 inside one job re-prompted the arm "
"(armed messages: %d)" % s.armed_count())
send_line(s.sock, "M2", s.log)
if not wait_for(s.log, DISARMED, 5, s.sock):
fail("[job-window] program end (M2) did not close the armed window")
send_line(s.sock, "M4 S100", s.log)
send_line(s.sock, "G1 X1 F600", s.log)
if not wait_for(s.log, ARMED, 5, s.sock) or s.armed_count() != 2:
fail("[job-window] next job after M2 did not arm afresh")
wait_idle(s.sock, s.log)
print("PASS [job-window]: armed once per job, M2 closed the window, "
"next job re-armed")
finally:
s.close()
def test_sender_change_mid_job():
"""Rule 3, the hard case: the sender changes while the job is still
running with the spindle on. The window closes and the job is HELD
where the cut stopped, so the next sender finds it in Hold rather than
at the end of a dark pass. Its cycle start goes through the resume
gate, which re-arms first: with no button on a host build that is
immediate, and the job then runs to its end lit."""
s = Session("sender-change-mid-job", disarm_s=60)
try:
send_line(s.sock, "M4 S100", s.log)
send_line(s.sock, "G1 X5 F60", s.log) # 5 s of motion
if not wait_for(s.log, ARMED, 5, s.sock):
fail("[sender-change-mid-job] first laser-on did not arm")
time.sleep(1.0)
s.sock.close() # mid-move, spindle on
time.sleep(0.5)
# The disarm and hold messages are written while no client is
# connected, and output with no client is discarded, so the new
# session cannot see them; the Hold state and the re-arm below are
# the evidence.
s.sock = s.connect()
st = s.wait_state("Hold", 5)
if st is None:
fail("[sender-change-mid-job] the job was not held on the sender change "
"(state %r)" % s.state())
x_held = s.mpos_x()
if x_held is None or not 0.5 < x_held < 4.5:
fail("[sender-change-mid-job] held outside the move (MPos X %s)" % x_held)
before = s.armed_count()
s.sock.sendall(b"~") # the new sender resumes
end = time.time() + 15
while time.time() < end and s.armed_count() != before + 1:
read_avail(s.sock, s.log, 0.2)
if s.armed_count() != before + 1:
fail("[sender-change-mid-job] the resume after a mid-job sender change did "
"not re-arm")
wait_idle(s.sock, s.log)
x_end = s.mpos_x()
if x_end is None or abs(x_end - 5.0) > 0.01:
fail("[sender-change-mid-job] the resumed job did not finish the move "
"(MPos X %s)" % x_end)
send_line(s.sock, "M5", s.log)
print("PASS [sender-change-mid-job]: the job held at X=%.3f on the sender change, "
"re-armed on the resume and finished at X=%.3f" % (x_held, x_end))
finally:
s.close()
def test_sender_change_holds_and_rearms():
"""Rule 3 on a machine with a button: the held job's resume from the
new sender prompts for the press (collected from the poll, never
inside the core's held state), and the press re-arms and resumes."""
s = Session("sender-change-rearm", disarm_s=60, switches=SW_CLOSED)
try:
s.send_raw("M4 S100")
if not wait_for(s.log, PROMPT, 5, s.sock):
fail("[sender-change-rearm] no arm prompt")
s.press_button()
if not wait_for(s.log, ARMED, 5, s.sock):
fail("[sender-change-rearm] the press did not arm")
send_line(s.sock, "G1 X5 F60", s.log) # 5 s of motion
time.sleep(1.0)
s.sock.close() # mid-move, spindle on
time.sleep(0.5)
s.sock = s.connect()
if s.wait_state("Hold", 5) is None:
fail("[sender-change-rearm] the job was not held on the sender change")
before = s.armed_count()
s.sock.sendall(b"~")
if not wait_for(s.log, RESUME_PROMPT, 5, s.sock):
fail("[sender-change-rearm] the resume did not prompt for the button")
if s.wait_state("Hold", 2) is None:
fail("[sender-change-rearm] the job left Hold before the press")
s.press_button()
end = time.time() + 10
while time.time() < end and s.armed_count() != before + 1:
read_avail(s.sock, s.log, 0.2)
if s.armed_count() != before + 1:
fail("[sender-change-rearm] the press did not re-arm the held job")
wait_idle(s.sock, s.log)
x_end = s.mpos_x()
if x_end is None or abs(x_end - 5.0) > 0.01:
fail("[sender-change-rearm] the resumed job did not finish the move "
"(MPos X %s)" % x_end)
send_line(s.sock, "M5", s.log)
print("PASS [sender-change-rearm]: the held job prompted on ~, the press re-armed "
"it, and it finished at X=%.3f" % x_end)
finally:
s.close()
def test_sender_change_then_reset():
"""A new sender that does not want the held job resets it: a soft
reset from the hold ends the job with the position kept and no alarm."""
s = Session("sender-change-reset", disarm_s=60)
try:
send_line(s.sock, "M4 S100", s.log)
send_line(s.sock, "G1 X5 F60", s.log)
if not wait_for(s.log, ARMED, 5, s.sock):
fail("[sender-change-reset] first laser-on did not arm")
time.sleep(1.0)
s.sock.close()
time.sleep(0.5)
s.sock = s.connect()
if s.wait_state("Hold", 5) is None:
fail("[sender-change-reset] the job was not held on the sender change")
s.sock.sendall(b"\x18")
st = s.wait_state("Idle", 5)
if st is None:
fail("[sender-change-reset] a reset from the held job did not end in Idle "
"(state %r)" % s.state())
print("PASS [sender-change-reset]: a reset from the held job ends in Idle, no alarm")
finally:
s.close()
def test_resume_after_grace():
"""Rule 4, the other half: a job whose window closed while it sat in
Hold can still be resumed. The sender's ~ prompts for the button, the
press re-arms, and the cut finishes lit (the controller must not sit
in the arm wait inside the held state, where a press is never read)."""
s = Session("resume-after-grace", disarm_s=2, switches=SW_CLOSED)
try:
s.send_raw("M4 S100")
if not wait_for(s.log, PROMPT, 5, s.sock):
fail("[resume-after-grace] no arm prompt")
s.press_button()
if not wait_for(s.log, ARMED, 5, s.sock):
fail("[resume-after-grace] the press did not arm")
send_line(s.sock, "G1 X5 F60", s.log) # 5 s of motion
time.sleep(1.0)
s.sock.sendall(b"!") # feed hold
if not wait_for(s.log, DISARMED, 10, s.sock):
fail("[resume-after-grace] the window did not close in the hold")
before = s.armed_count()
s.sock.sendall(b"~")
if not wait_for(s.log, RESUME_PROMPT, 5, s.sock):
fail("[resume-after-grace] the resume did not prompt for the button")
if s.wait_state("Hold", 2) is None:
fail("[resume-after-grace] the job left Hold before the press")
s.press_button()
end = time.time() + 10
while time.time() < end and s.armed_count() != before + 1:
read_avail(s.sock, s.log, 0.2)
if s.armed_count() != before + 1:
fail("[resume-after-grace] the press did not re-arm the held job")
wait_idle(s.sock, s.log)
x_end = s.mpos_x()
if x_end is None or abs(x_end - 5.0) > 0.01:
fail("[resume-after-grace] the resumed job did not finish the move "
"(MPos X %s)" % x_end)
send_line(s.sock, "M5", s.log)
print("PASS [resume-after-grace]: ~ prompted, the press re-armed the held job, "
"and it finished at X=%.3f" % x_end)
finally:
s.close()
def test_rx_overrun_aborts():
"""A sender that writes past the RX ring (Bf: is the contract) loses
lines, and a job with lines missing is not the job the sender wrote:
the controller reports the overrun, stops the way a ^X stops it
(alarm, window closed) and never runs on with a hole in it. A clean
job after the reset arms again."""
s = Session("rx-overrun", disarm_s=60)
try:
send_line(s.sock, "M4 S100", s.log)
send_line(s.sock, "G1 X1 F600", s.log)
if not wait_for(s.log, ARMED, 5, s.sock):
fail("[rx-overrun] first laser-on did not arm")
job = "".join("G1 X%d F60\n" % (2 + (i % 2)) for i in range(120)) # ~1.3 KB at once
s.sock.sendall(job.encode())
if not wait_for(s.log, "RX overrun", 10, s.sock):
fail("[rx-overrun] the overrun was not reported to the sender")
if not s.wait_state("Alarm", 5):
fail("[rx-overrun] the overrun did not stop the job (state %s)" % s.state())
if s.disarmed_count() < 1:
read_avail(s.sock, s.log, 1.0)
if s.disarmed_count() < 1:
fail("[rx-overrun] the overrun did not close the armed window")
send_line(s.sock, "$X", s.log)
before = s.armed_count()
send_line(s.sock, "M4 S100", s.log)
send_line(s.sock, "G1 X0 F600", s.log)
end = time.time() + 5
while time.time() < end and s.armed_count() != before + 1:
read_avail(s.sock, s.log, 0.2)
if s.armed_count() != before + 1:
fail("[rx-overrun] a clean job after the overrun did not arm")
wait_idle(s.sock, s.log)
print("PASS [rx-overrun]: the overrun was reported, the job stopped in alarm with "
"the window closed, and the next clean job armed")
finally:
s.close()
def test_sender_change():
"""Rule 3: a reconnected sender must re-arm. The grace is set far
beyond the test horizon so only the sender change can close the
window; the first job ends in M5 and the next M4 is a fresh laser-on."""
s = Session("sender-change", disarm_s=60)
try:
send_line(s.sock, "M4 S100", s.log)
send_line(s.sock, "G1 X1 F600", s.log)
if not wait_for(s.log, ARMED, 5, s.sock):
fail("[sender-change] first laser-on did not arm")
send_line(s.sock, "M5", s.log)
wait_idle(s.sock, s.log)
s.sock.close()
time.sleep(0.5)
s.sock = s.connect()
before = s.armed_count()
send_line(s.sock, "M4 S100", s.log)
send_line(s.sock, "G1 X0 F600", s.log)
end = time.time() + 5
while time.time() < end and s.armed_count() != before + 1:
read_avail(s.sock, s.log, 0.2)
if s.armed_count() != before + 1:
fail("[sender-change] the reconnected sender inherited the "
"displaced session's armed window")
wait_idle(s.sock, s.log)
print("PASS [sender-change]: displaced consent did not survive; "
"the new sender re-armed")
finally:
s.close()
def test_hold_grace():
"""Rule 4: the disarm grace counts down in HOLD."""
s = Session("hold-grace", disarm_s=2)
try:
send_line(s.sock, "M4 S100", s.log)
send_line(s.sock, "G1 X30 F60", s.log) # ~30 s move
if not wait_for(s.log, ARMED, 5, s.sock):
fail("[hold-grace] job did not arm")
time.sleep(1.0)
s.sock.sendall(b"!") # feed hold
if not wait_for(s.log, DISARMED, 10, s.sock):
fail("[hold-grace] a job abandoned in Hold stayed armed past "
"the disarm grace")
print("PASS [hold-grace]: the armed window closed while parked in Hold")
finally:
s.close()
def test_verdict_blocks_arm():
"""Rule 5: a blocking cooling verdict refuses the arm."""
s = Session("verdict-blocked", fire_ok=False)
try:
send_line(s.sock, "M4 S100", s.log, tolerate_error=True)
send_line(s.sock, "G1 X1 F600", s.log, tolerate_error=True)
if not wait_for(s.log, BLOCKED, 5, s.sock):
fail("[verdict-blocked] no refusal message for a blocking verdict")
if ARMED in "".join(s.log):
fail("[verdict-blocked] armed despite a blocking cooling verdict")
print("PASS [verdict-blocked]: arming refused while fire_ok=false")
finally:
s.close()
def test_arm_waits_for_engine_ack():
"""Rule 5: the arm does not fire on a verdict computed before it.
The armed window is reported to the cooling engine when it opens,
and the engine answers by applying the cut airflow and the flow
interrogation for the job. The verdict standing on file until that
answer arrives is the one computed for the idle session that
preceded the arm, and at idle nothing is wrong, so it says fire is
fine. Firing on it puts the beam on the work with the fans still at
their idle duty - observed on the bench as a burn of a few
millimeters before the engine's first tick took the job into a hold.
Here the engine never acknowledges: fire_ok stays true throughout,
so the only thing that can refuse the job is the missing
acknowledgment. The arm must be refused and no window may open."""
s = Session("arm-ack", fire_ok=True, armed_ack=False)
try:
# The arm blocks in the acknowledgment wait, so the ok for these
# lines only arrives once it gives up: send without waiting.
s.send_raw("M4 S100")
s.send_raw("G1 X1 F600")
if not wait_for(s.log, BLOCKED, 15, s.sock):
fail("[arm-ack] a job whose cooling engine never took it was not "
"refused (the arm fired on the pre-arm verdict)")
if ARMED in "".join(s.log):
fail("[arm-ack] the armed window opened without the engine's "
"acknowledgment")
print("PASS [arm-ack]: the arm refused a verdict that predates it")
finally:
s.close()
def test_arm_proceeds_on_a_late_ack():
"""Rule 5: the acknowledgment arriving late is the ordinary case.
The real engine answers on its next tick, so the arm has to observe
a verdict refreshed under it and then go on to open the window. This
is the case that proves the wait is a wait and not a refusal: the
controller must keep polling the verdict file while it is blocked in
the arm, or every job on the machine would fail here."""
ack_s = 2.0
s = Session("arm-ack-late", fire_ok=True, ack_after_s=ack_s)
try:
t0 = time.time()
s.send_raw("M4 S100")
s.send_raw("G1 X1 F600")
if not wait_for(s.log, ARMED, 15, s.sock):
fail("[arm-ack-late] the arm never completed once the engine took "
"the job (the verdict is not refreshed inside the wait)")
dt = time.time() - t0
if dt < ack_s - 0.5:
fail("[arm-ack-late] armed after %.1f s, before the engine took the "
"job at %.1f s" % (dt, ack_s))
if BLOCKED in "".join(s.log):
fail("[arm-ack-late] a late acknowledgment was reported as a refusal")
print("PASS [arm-ack-late]: the arm waited %.1f s for the engine, then armed" % dt)
finally:
s.close()
def test_arm_waits_for_the_fans():
"""Rule 5: the acknowledgment budget follows the fan grace. The
engine acknowledges once the fans are at their floors, seconds after
the arm; with cool_fan_grace_s at 8 the controller waits up to 13 s
for it, and an acknowledgment at 9 s arms the job rather than
refusing it at the old 5 s budget."""
ack_s = 9.0
s = Session("arm-fans", fire_ok=True, ack_after_s=ack_s,
conf_extra="cool_fan_grace_s = 8\n")
try:
t0 = time.time()
s.send_raw("M4 S100")
s.send_raw("G1 X1 F600")
if not wait_for(s.log, ARMED, 20, s.sock):
fail("[arm-fans] the arm never completed with the acknowledgment at %.0f s "
"(the budget did not follow cool_fan_grace_s)" % ack_s)
dt = time.time() - t0
if dt < ack_s - 0.5:
fail("[arm-fans] armed after %.1f s, before the fans were up at %.1f s" % (dt, ack_s))
if BLOCKED in "".join(s.log):
fail("[arm-fans] the wait for the fans was reported as a refusal")
print("PASS [arm-fans]: the arm waited %.1f s for the fans, then armed" % dt)
finally:
s.close()
def test_sigterm_mid_job():
"""Rule 5: a termination signal during an armed job is a STOP, not a
"finish the job first". The supervisor's expected-stop path (mode
switch, diagnostics, the emergency lever) delivers SIGTERM; the
controller must bring motion to a controlled stop, close the armed
window (latch relocked) and exit promptly - long before the job
would have ended and inside the supervisor's SIGKILL grace."""
s = Session("sigterm-mid-job", disarm_s=60)
try:
send_line(s.sock, "M4 S100", s.log)
send_line(s.sock, "G1 X300 F60", s.log) # a 5-minute move
if not wait_for(s.log, ARMED, 5, s.sock):
fail("[sigterm-mid-job] the job did not arm")
# Let the run get under way.
deadline = time.time() + 5
running = False
while time.time() < deadline and not running:
s.sock.sendall(b"?")
read_avail(s.sock, s.log, 0.3)
running = "<Run" in "".join(s.log[-5:])
if not running:
fail("[sigterm-mid-job] the job never reported Run")
t0 = time.time()
s.proc.send_signal(signal.SIGTERM)
# Keep draining the sender socket so the disarm report is captured.
exited = False
while time.time() - t0 < 10:
read_avail(s.sock, s.log, 0.2)
if s.proc.poll() is not None:
exited = True
break
dt = time.time() - t0
if not exited:
fail("[sigterm-mid-job] the controller did not exit within 10 s of "
"SIGTERM during a job (it must stop, not finish the job)")
if dt > 3.0:
fail("[sigterm-mid-job] exit took %.1f s after SIGTERM (want < 3 s)" % dt)
if s.disarmed_count() < 1:
fail("[sigterm-mid-job] no disarm (latch relock) reported before exit")
if s.proc.returncode != 0:
fail("[sigterm-mid-job] exit status %s (want a clean 0)" % s.proc.returncode)
print("PASS [sigterm-mid-job]: SIGTERM during a job stopped it, relocked "
"the latch and exited in %.2f s" % dt)
finally:
s.close()
def test_button_wait_arms():
"""Rule 6a: with a switch source the arm blocks on the button; a press
with the lid closed arms; the window is not opened before it."""
s = Session("button-wait", disarm_s=60, switches=SW_CLOSED)
try:
s.send_raw("M4 S100")
s.send_raw("G1 X1 F600")
if not wait_for(s.log, PROMPT, 5, s.sock):
fail("[button-wait] no button prompt with a switch source present")
read_avail(s.sock, s.log, 1.0)
if ARMED in "".join(s.log):
fail("[button-wait] armed before the button was pressed")
s.set_switches(SW_PRESSED)
if not wait_for(s.log, ARMED, 5, s.sock):
fail("[button-wait] the button press did not arm")
s.set_switches(SW_CLOSED)
wait_idle(s.sock, s.log)
print("PASS [button-wait]: the arm blocked on the button and the press armed")
finally:
s.close()
def test_lid_open_in_wait():
"""Rule 6b: the lid opening during the button wait cancels the job:
the reason is reported, an alarm is raised, the window never opens.
A press with the lid still open must not arm either."""
s = Session("lid-in-wait", disarm_s=60, switches=SW_CLOSED)
try:
s.send_raw("M4 S100")
s.send_raw("G1 X1 F600")
if not wait_for(s.log, PROMPT, 5, s.sock):
fail("[lid-in-wait] no button prompt")
s.set_switches(SW_LID_OPEN)
if not wait_for(s.log, LID_CANCEL, 5, s.sock):
fail("[lid-in-wait] lid open during the wait was not reported as a cancel")
if not wait_for(s.log, "for help]", 5, s.sock):
fail("[lid-in-wait] no reset banner after the lid-open cancel (a clean cancel, not an alarm)")
if "ALARM" in "".join(s.log):
fail("[lid-in-wait] an alarm was raised on the lid-open cancel")
# Press with the lid still open: nothing may arm.
s.set_switches(1 << 2)
read_avail(s.sock, s.log, 1.0)
if ARMED in "".join(s.log):
fail("[lid-in-wait] armed after a lid-open cancel")
# Interlock variant on a fresh session, so the alarm state does not
# mask the check.
s.close()
s = Session("loop-in-wait", disarm_s=60, switches=SW_CLOSED)
s.send_raw("M4 S100")
s.send_raw("G1 X1 F600")
if not wait_for(s.log, PROMPT, 5, s.sock):
fail("[loop-in-wait] no button prompt")
s.set_switches(SW_LOOP_OPEN)
if not wait_for(s.log, LOOP_CANCEL, 5, s.sock):
fail("[loop-in-wait] interlock open during the wait was not reported as a cancel")
if ARMED in "".join(s.log):
fail("[loop-in-wait] armed despite the open interlock loop")
print("PASS [lid-in-wait]: lid or interlock open during the arm wait "
"canceled the job and never armed")
finally:
s.close()
def start_armed_move(s, tag, gcode="G1 X30 F60"):
"""Arm through the button and get a long move under way; returns once
the controller reports Run."""
s.send_raw("M4 S100")
s.send_raw(gcode)
if not wait_for(s.log, PROMPT, 5, s.sock):
fail("[%s] no button prompt" % tag)
s.press_button()
if not wait_for(s.log, ARMED, 5, s.sock):
fail("[%s] the button press did not arm" % tag)
if not s.wait_state("Run", 5):
fail("[%s] the job never reported Run" % tag)
def test_button_pause_resume():
"""Rule 7: outside the arm wait the button is the pause/resume toggle -
a press while running is a feed hold, a press while held is a cycle
start; the arming press itself is not a pause press."""
s = Session("button-toggle", disarm_s=60, switches=SW_CLOSED)
try:
start_armed_move(s, "button-toggle")
time.sleep(0.5)
if s.state().startswith("Hold"):
fail("[button-toggle] the arming press was taken as a pause press")
s.press_button()
if not s.wait_state("Hold", 3):
fail("[button-toggle] a press while running did not feed-hold (state %s)" % s.state())
if not wait_for(s.log, "button pressed - job paused", 2, s.sock):
fail("[button-toggle] no pause message")
s.press_button()
if not s.wait_state("Run", 3):
fail("[button-toggle] a press while held did not resume (state %s)" % s.state())
if not wait_for(s.log, "button pressed - job resumed", 2, s.sock):
fail("[button-toggle] no resume message")
s.sock.sendall(b"\x18")
print("PASS [button-toggle]: press paused (Hold), press resumed (Run); the arming press did not")
finally:
s.close()
def test_lid_cancels_and_returns():
"""Rule 8 (lid_policy = cancel, the default): the lid opening mid-job
parks the job and cancels it - reason reported, armed window closed,
a soft reset (no alarm: position kept), then the head returns on its
own to the job start; the sender sees the reset banner. The interlock
loop is the same event with its own reason."""
s = Session("lid-cancel", disarm_s=60, switches=SW_CLOSED)
try:
start_armed_move(s, "lid-cancel", gcode="G1 X20 F600")
time.sleep(0.5)
x_mid = s.mpos_x()
s.set_switches(SW_LID_OPEN)
if not wait_for(s.log, "lid opened - job canceled", 5, s.sock):
fail("[lid-cancel] the lid open did not cancel the job")
if not wait_for(s.log, DISARMED, 5, s.sock):
fail("[lid-cancel] the cancel did not close the armed window")
if not wait_for(s.log, "for help]", 5, s.sock):
fail("[lid-cancel] no reset banner after the cancel")
if not wait_for(s.log, "returned to the job start", 15, s.sock):
fail("[lid-cancel] the head did not report returning to the job start")
st = s.wait_state("Idle", 5)
if not st:
fail("[lid-cancel] not Idle after the return (state %s)" % s.state())
if "ALARM" in "".join(s.log):
fail("[lid-cancel] an alarm was raised on the cancel (position should be kept)")
x_end = s.mpos_x()
if x_end is None or abs(x_end) > 0.05:
fail("[lid-cancel] head not back at the job start: MPos X=%s (was %s mid-job)" % (x_end, x_mid))
print("PASS [lid-cancel]: lid open mid-job -> canceled, disarmed, reset banner, "
"returned to X=%.3f (mid-job X=%.3f), Idle, no alarm" % (x_end, x_mid))
s.close()
# Interlock variant.
s = Session("loop-cancel", disarm_s=60, switches=SW_CLOSED)
start_armed_move(s, "loop-cancel", gcode="G1 X20 F600")
time.sleep(0.5)
s.set_switches(SW_LOOP_OPEN)
if not wait_for(s.log, "interlock open - job canceled", 5, s.sock):
fail("[loop-cancel] the interlock open did not cancel the job")
if not wait_for(s.log, "returned to the job start", 15, s.sock):
fail("[loop-cancel] no return to the job start after the interlock cancel")
print("PASS [loop-cancel]: interlock open mid-job -> canceled and returned")
finally:
s.close()
def test_pause_then_lid_cancel_returns_to_the_job_start():
"""Rule 9: the button pause + resume does not move the job start. The
lid cancel after a resume returns the head to where the job began;
the pause point is not a job start. Then the other side: a job left
in a hold and soft-reset is over, and the next job from that spot
returns to that spot, not to the earlier job's start."""
s = Session("pause-lid-cancel", disarm_s=60, switches=SW_CLOSED)
try:
start_armed_move(s, "pause-lid-cancel", gcode="G1 X30 F600")
time.sleep(0.6)
s.press_button()
if not s.wait_state("Hold", 3):
fail("[pause-lid-cancel] the press did not feed-hold (state %s)" % s.state())
time.sleep(0.5)
x_hold = s.mpos_x()
if x_hold is None or x_hold < 1.0:
fail("[pause-lid-cancel] the hold landed too close to the start to tell (X=%s)" % x_hold)
s.press_button()
if not s.wait_state("Run", 3):
fail("[pause-lid-cancel] the second press did not resume (state %s)" % s.state())
time.sleep(0.4)
s.set_switches(SW_LID_OPEN)
if not wait_for(s.log, "lid opened - job canceled", 5, s.sock):
fail("[pause-lid-cancel] the lid open did not cancel the job")
if not wait_for(s.log, "returned to the job start", 15, s.sock):
fail("[pause-lid-cancel] the head did not report returning to the job start")
if not s.wait_state("Idle", 5):
fail("[pause-lid-cancel] not Idle after the return (state %s)" % s.state())
x_end = s.mpos_x()
if x_end is None or abs(x_end) > 0.05:
fail("[pause-lid-cancel] the head returned to X=%s, not to the job start X=0 "
"(paused at X=%.3f: the resume was taken as a new job start)" % (x_end, x_hold))
print("PASS [pause-lid-cancel]: paused at X=%.3f, resumed, lid cancel returned to X=%.3f"
% (x_hold, x_end))
s.close()
# A job abandoned in a hold and reset is over: the next job starts
# where it starts.
s = Session("hold-reset-restart", disarm_s=60, switches=SW_CLOSED)
start_armed_move(s, "hold-reset-restart", gcode="G1 X30 F600")
time.sleep(0.6)
s.press_button()
if not s.wait_state("Hold", 3):
fail("[hold-reset-restart] the press did not feed-hold (state %s)" % s.state())
time.sleep(0.5)
s.sock.sendall(b"\x18")
if not wait_for(s.log, "for help]", 5, s.sock):
fail("[hold-reset-restart] no reset banner")
if not s.wait_state("Idle", 5):
fail("[hold-reset-restart] not Idle after the reset (state %s)" % s.state())
x_new_start = s.mpos_x()
if x_new_start is None or x_new_start < 1.0:
fail("[hold-reset-restart] the reset did not keep the position (X=%s)" % x_new_start)
read_avail(s.sock, s.log, 0.3)
start_armed_move(s, "hold-reset-restart", gcode="G1 X60 F600") # absolute: past the old start
time.sleep(0.5)
s.set_switches(SW_LID_OPEN)
if not wait_for(s.log, "returned to the job start", 15, s.sock):
fail("[hold-reset-restart] no return to the job start after the lid cancel")
if not s.wait_state("Idle", 5):
fail("[hold-reset-restart] not Idle after the return (state %s)" % s.state())
x_end = s.mpos_x()
if x_end is None or abs(x_end - x_new_start) > 0.05:
fail("[hold-reset-restart] the head returned to X=%s, not to this job's start X=%.3f "
"(the earlier job's start survived the reset)" % (x_end, x_new_start))
print("PASS [hold-reset-restart]: a job reset from a hold ended there; the next job "
"returned to its own start X=%.3f" % x_end)
finally:
s.close()
def test_lid_policy_hold():
"""lid_policy = hold keeps stock grblHAL behavior: the lid parks the job
in Door and a cycle start resumes it once closed."""
s = Session("lid-hold", disarm_s=60, switches=SW_CLOSED, conf_extra="lid_policy = hold\n")
try:
start_armed_move(s, "lid-hold", gcode="G1 X20 F60")
time.sleep(0.5)
s.set_switches(SW_LID_OPEN)
if not s.wait_state("Door", 3):
fail("[lid-hold] the lid open did not park the job in Door (state %s)" % s.state())
read_avail(s.sock, s.log, 1.0)
if "job canceled" in "".join(s.log):
fail("[lid-hold] the hold policy canceled the job")
s.set_switches(SW_CLOSED)
time.sleep(0.5)
s.sock.sendall(b"~")
if not s.wait_state("Run", 3):
fail("[lid-hold] cycle start did not resume the held job (state %s)" % s.state())
s.sock.sendall(b"\x18")
print("PASS [lid-hold]: with lid_policy=hold the lid parked the job in Door and ~ resumed it")
finally:
s.close()
def main():
if not os.path.isfile(BIN):
fail("controller binary not found at %s" % BIN)
test_job_window()
test_status_files()
test_laser_keys_reload()
test_sender_change()
test_sender_change_mid_job()
test_sender_change_holds_and_rearms()
test_sender_change_then_reset()
test_rx_overrun_aborts()
test_hold_grace()
test_resume_after_grace()
test_button_wait_arms()
test_lid_open_in_wait()
test_button_pause_resume()
test_lid_cancels_and_returns()
test_pause_then_lid_cancel_returns_to_the_job_start()
test_lid_policy_hold()
test_verdict_blocks_arm()
test_arm_waits_for_engine_ack()
test_arm_proceeds_on_a_late_ack()
test_arm_waits_for_the_fans()
test_sigterm_mid_job()
print("PASS: the armed-window lifecycle holds")
if __name__ == "__main__":
main()