Files
forgefirm/scripts/bench/mcode_test.py
T
ScottW514 8e1fe5090d Harnesses: the null-sink cases other tests prove leave
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.
2026-10-13 21:20:31 -04:00

272 lines
11 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-side verification of the M-codes an extension package answers.
Runs the native grblHAL_glowforge binary in null-sink mode, with the
scripted sender and the port client of ctlport_test.py standing in for a
sender and for the machine daemon:
1. the table: the port's mcodes op takes "-" or numbers from M160 to
M179, each once, the whole range included, and refuses every other
form, leaving the table as it was
2. a number nothing answers is an unsupported command where the line is
parsed (error:20), and so is every number outside the table
3. an answered M-code is a barrier: the sender's line waits for its ok,
the port's state names the M-code with its P, Q and R words and its
seq, the head does not move, a port jog is refused (busy:mcode), and
the answer lets the line finish with a [MSG:] of its words; a second
answer, or one under another seq, is stale, and one out of form is
refused
4. an answer that says the work was not done holds the job with a
[MSG:] of its words, and a cycle start resumes it
5. a soft reset ends the wait at once, and the controller answers the
next line
6. no answer in GFMCODE_WAIT_S holds the job: 5 s in the host build
(configured with -DGFMCODE_WAIT_S=5.0, as grblHAL-glowforge's CI
does; the image keeps 30 s), longer than any case here waits at a
barrier on purpose
7. the barrier is dark: under an open armed window with M3 S500, a job
waiting at an M-code ships no FIRE tick, the window stays open across
the wait, and the next laser move fires again
Usage: mcode_test.py [path/to/grblHAL_glowforge]
(a build configured with -DGFMCODE_WAIT_S=5.0)
"""
import os
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import ctlport_test as ct # noqa: E402
fail = ct.fail
WAIT_S = 5.0 # the host build's GFMCODE_WAIT_S
def send_nowait(s, line):
"""A sender line whose response is not waited for; the count before it."""
n = s.sender.count()
s.sender.sock.sendall((line + "\n").encode())
return n
def wait_pending(s, timeout=5.0):
end = time.time() + timeout
while time.time() < end:
st = s.port.state()
if st.get("mcode"):
return st
time.sleep(0.05)
fail("no M-code waits in the port's state: %r" % s.port.state())
def test_table(s):
if s.port.state().get("mcode", "missing") is not None:
fail("[table] the state carries no mcode null when nothing waits: %r" % s.port.state())
for good in ("-", ",".join(str(n) for n in range(160, 180)), "160", "160,179,165"):
r = s.port.request("mcodes " + good)
if r != "ok":
fail("[table] mcodes %s -> %r" % (good, r))
# one form per refusal: out of range, twice, a separator with nothing
# after it, not a number, and nothing at all
for bad in ("159", "160,160", "160,", ",160", ""):
r = s.port.request("mcodes " + bad)
if r != "error:invalid":
fail("[table] mcodes %r -> %r, not error:invalid" % (bad, r))
# the refusals left the last good table: 160, 179 and 165
n = send_nowait(s, "M165")
m = wait_pending(s)["mcode"]
if m.get("code") != 165:
fail("[table] a refused list changed the table: %r" % m)
s.port.request("mcode_result %d ok" % m["seq"])
end = time.time() + 3
while s.sender.count() == n and time.time() < end:
time.sleep(0.02)
if s.sender.responses[n:n + 1] != ["ok"]:
fail("[table] M165 got %r" % s.sender.responses[n:])
print("PASS [table]: the port takes '-' and numbers from M160 to M179, the whole range; a form "
"of each refusal is refused, the table left as it was")
def test_unanswered(s):
s.port.request("mcodes 160")
# The core holds a parser error against every later block until an empty
# line (the sync) or a reset: after an unsupported command a job halts.
for line in ("M161", "M179", "M159", "M180 P1"):
r = s.sender.send(line)
if r != "error:20":
fail("[unanswered] %s with only M160 answered got %r, not error:20" % (line, r))
s.sender.send("")
s.port.request("mcodes -")
r = s.sender.send("M160")
if r != "error:20":
fail("[unanswered] M160 with nothing answered got %r" % r)
s.sender.send("")
print("PASS [unanswered]: a number nothing answers is error:20 where it is parsed")
def test_answered(s):
s.port.request("mcodes 160,161")
s.sender.send("G91")
s.sender.send("G0 X3")
s.sender.wait_state("Idle")
n = send_nowait(s, "M160 P2 Q3.5 R-1")
st = wait_pending(s)
m = st["mcode"]
if m.get("code") != 160 or m.get("words") != {"P": 2, "Q": 3.5, "R": -1} or not m.get("seq"):
fail("[answered] the state names %r" % m)
x0 = st["mpos"]
time.sleep(1.0)
st2 = s.port.state()
if st2["mpos"] != x0 or st2["state"] != "Idle":
fail("[answered] the head moved or the state is %s while the job waits: %r -> %r"
% (st2["state"], x0, st2["mpos"]))
if s.sender.count() != n:
fail("[answered] the M-code line got its response before the answer: %r" % s.sender.responses[n:])
r = s.port.request("jog G91 X1 F600")
if r != "busy:mcode":
fail("[answered] a port jog while the job waits -> %r, not busy:mcode" % r)
for bad in ("mcode_result %d maybe" % m["seq"], "mcode_result x ok", "mcode_result 0 ok",
"mcode_result %d ok [bracket]" % m["seq"], "mcode_result %d ok %s" % (m["seq"], "w" * 97)):
r = s.port.request(bad)
if r not in ("error:invalid", "error:stale"):
fail("[answered] %r -> %r" % (bad, r))
if s.port.request("mcode_result %d ok" % (m["seq"] + 7)) != "error:stale":
fail("[answered] an answer under another seq was taken")
if s.port.state().get("mcode") is None:
fail("[answered] a refused answer ended the wait")
r = s.port.request("mcode_result %d ok all set" % m["seq"])
if r != "ok":
fail("[answered] the answer -> %r" % r)
end = time.time() + 3
while s.sender.count() == n and time.time() < end:
time.sleep(0.02)
if s.sender.responses[n:n + 1] != ["ok"]:
fail("[answered] the M-code line got %r after the answer" % s.sender.responses[n:])
if not s.sender.saw("M160: done: all set"):
fail("[answered] the sender got no [MSG:] of the answer's words")
if s.port.request("mcode_result %d ok" % m["seq"]) != "error:stale":
fail("[answered] a second answer was taken")
if s.port.state().get("mcode") is not None:
fail("[answered] the state still names an M-code")
if s.sender.send("G0 X-3") != "ok":
fail("[answered] the job did not go on")
print("PASS [answered]: the line waited for the answer with the head still and a port jog "
"refused; the state named M160 with P, Q and R; the answer let it go on with its words")
def test_fail_holds(s):
s.sender.wait_state("Idle")
n = send_nowait(s, "M161")
m = wait_pending(s)["mcode"]
s.port.request("mcode_result %d fail the exhaust did not start" % m["seq"])
end = time.time() + 3
while s.sender.count() == n and time.time() < end:
time.sleep(0.02)
s.sender.wait_state("Hold", timeout=5)
if not s.sender.saw("M161: the exhaust did not start: the job is held"):
fail("[fail] the sender got no [MSG:] of why the job is held")
s.sender.realtime(b"~")
s.sender.wait_state("Idle", timeout=5)
if s.sender.send("G0 X1") != "ok":
fail("[fail] the job did not go on after the resume")
print("PASS [fail]: an answer that the work was not done held the job with its words; a cycle "
"start resumed it")
def test_reset_ends_wait(s):
s.sender.wait_state("Idle")
send_nowait(s, "M160")
wait_pending(s)
t0 = time.time()
s.sender.realtime(b"\x18")
end = time.time() + 3
while time.time() < end and s.port.state().get("mcode") is not None:
time.sleep(0.02)
dt = time.time() - t0
if s.port.state().get("mcode") is not None:
fail("[reset] the wait outlived a soft reset")
time.sleep(0.5)
s.sender.send("$X")
if s.sender.send("G0 X1") != "ok":
fail("[reset] the controller did not answer after the reset")
print("PASS [reset]: a soft reset ended the wait in %.2f s" % dt)
def test_timeout_holds(s):
s.port.request("mcodes 160")
s.sender.wait_state("Idle")
t0 = time.time()
send_nowait(s, "M160")
wait_pending(s)
s.sender.wait_state("Hold", timeout=WAIT_S + 5)
dt = time.time() - t0
if dt < WAIT_S - 0.1:
fail("[timeout] the job was held after %.1f s, not after the wait" % dt)
if not s.sender.saw("M160 had no answer from its extension"):
fail("[timeout] the sender got no [MSG:] of the timeout")
s.sender.realtime(b"~")
s.sender.wait_state("Idle", timeout=5)
print("PASS [timeout]: with no answer the job was held after %.1f s, and resumed" % dt)
def test_dark():
s = ct.Session(laser=True)
try:
s.port.request("mcodes 160")
s.sender.send("G91")
s.sender.send("M3 S500")
s.sender.send("G1 X10 F1500")
s.sender.wait_state("Idle")
time.sleep(0.5)
if not s.sender.saw("laser armed"):
fail("[dark] the laser line did not arm: the case would prove nothing")
steps0, fire0 = s.ticks()
if fire0 == 0:
fail("[dark] the armed G1 shipped no FIRE tick: the dump cannot see emission")
send_nowait(s, "M160")
m = wait_pending(s)["mcode"]
time.sleep(3.0)
steps1, fire1 = s.ticks()
s.port.request("mcode_result %d ok" % m["seq"])
time.sleep(0.3)
s.sender.send("G1 X5 F1500")
s.sender.wait_state("Idle")
time.sleep(0.5)
steps2, fire2 = s.ticks()
if fire1 != fire0 or steps1 != steps0:
fail("[dark] the job waiting at M160 under M3 S500 shipped %d FIRE ticks and %d steps"
% (fire1 - fire0, steps1 - steps0))
if s.sender.saw("laser disarmed"):
fail("[dark] the armed window closed across the wait")
if fire2 <= fire1:
fail("[dark] the laser move after the answer shipped no FIRE: the case would prove nothing")
print("PASS [dark]: 3 s waiting at M160 under an open window with M3 S500 shipped no FIRE tick "
"and no step; the window stayed open and the next move fired (%d ticks)" % (fire2 - fire1))
finally:
s.close()
def main():
if not os.path.exists(ct.BIN):
fail("no controller binary at %s" % ct.BIN)
s = ct.Session()
try:
test_table(s)
test_unanswered(s)
test_answered(s)
test_fail_holds(s)
test_reset_ends_wait(s)
test_timeout_holds(s)
finally:
s.close()
test_dark()
print("ALL PASS")
if __name__ == "__main__":
main()