The planner buffer depth harness; the XY microstep set is in

scripts/bench/planner_blocks_test.py restarts the null-sink controller
at $398=400 and at 1000 on one settings store and requires an answer on
the port, the depth in the status report and a move to Idle. It runs in
the grblHAL repo's CI; registered on the bench page and in the README.
BRINGUP: $398 runs over its whole range, the spin item is closed, and
the XY microstep item is committed, pushed and pinned. CAMPAIGN-LOG: the
landing and the index fix, with the host and bench proof.

No catalog consequence beyond the core submodule the motion tests
already cover; the coverage lint is clean.
This commit is contained in:
ScottW514
2026-09-07 18:38:39 -04:00
parent 0dc8c746e5
commit 59c4516c00
5 changed files with 283 additions and 13 deletions
+6 -13
View File
@@ -1026,8 +1026,10 @@ is committed.
finer on that radius) the feed sags mid-arc and the circle takes longer,
with the CPU flat at 33 percent and no underrun, and a deeper planner
buffer (`$398` 250) changes nothing. Finer chords move the tone up and
do not lower the vibration. `$398` at 255 or more spins the controller
at start (a core bug, "Next work"); the usable maximum is 254.
do not lower the vibration. `$398` runs over its whole range (30 to
1000): the core's block-ring index is wide enough for it, and
`scripts/bench/planner_blocks_test.py` starts the null-sink build at
400 and at 1000 in the grblHAL repo's CI.
- **Factory analog config** (constant across all captured jobs, 2018→2026):
PIC currents X 135 run / 33 hold, Y 22 run / 5 hold (axis DAC scales differ by
design); x/y_decay=1; ×8 microstepping; run currents applied only while
@@ -1483,7 +1485,8 @@ feature requests, enhancements) will eventually be tracked as GitHub issues.
accelerometer with every fan off (`scripts/bench/xy_pattern_accel.py`
through the new `POST /cool/quiet`): the vibration at 200 mm/s falls
with the finer mode, most on a circle (x RMS down 29 percent at 32).
Uncommitted. Owed: the commits, pins, image and campaign. 32 is admitted; the
Committed, pushed and pinned. Owed: the acceptance campaign on the
image that ships it. 32 is admitted; the
fallbacks, should another machine not hold it, are the 84480 Hz tick
or the `$110` ceiling the driver holds under a lower tick. Cloud mode
runs at the service's own 8: the service plans every stream at 8
@@ -1494,16 +1497,6 @@ feature requests, enhancements) will eventually be tracked as GitHub issues.
cloud mode (a local stream expander in the feeder, or the service
planning finer) wait for a user's report of such a refusal, by
decision.
9. **Planner buffer of 255 blocks or more spins the controller.** The
core's `plan_reset_buffer()` links the block ring with a `uint_fast8_t`
index, so `$398` at 255 or more never finishes at start: the main
thread sits at 100 percent in `plan_reset()`, the Grbl port stops
answering, and forgectrl still reports the controller running (the
supervisor sees deaths and silent armed reporters, not an idle spin).
Reproduced on the host build. Owed in the fork (openglow-org
grblHAL-core, branch forgefirm): the index widened to `uint_fast16_t`,
or the sanity check capped at 254, and a host test that starts the
null-sink build with `$398=400`. Until then the usable maximum is 254.
**Deliberately not gated:** an armed GRBL job after an underrun cuts at the
stale origin unless homing is required (GRBL mode permits unhomed cutting; the
+48
View File
@@ -9541,6 +9541,54 @@ decision: the service plans every stream at 8 whatever the machine
reports. Nothing committed at the time of writing: the operator's order is
no commit until the code is proven, and the commit is the operator's call.
## 2026-09-07: the XY microstep set lands; the planner buffer index
**Commits, pushes, pins and the image, 22:05 to 22:18Z.** On the
operator's order the set went out in CI order: forgectrl b1eee4d,
Glowforge-Utilities 428c725, python3-gfhardware d1c47b8, forgefirm-docs
d91bcf1, forgefirm 8fc5250 (before grblHAL, whose CI fetches the
harnesses unpinned), grblHAL-glowforge 48d5f1d. The branch pushes carried
the producer-lead-ceiling commits of the same day with them (grblHAL
249bcb8, forgefirm bfb5cb2, docs 0b63d50). Pins fetch-verified with
`bitbake -c fetch` and pushed: forgefirm 0dc8c74 (forgectrl 0.1.6,
grblhal-glowforge 0.1.5, forgefirm-app 0.1.25+git) and meta-openglow
c63777e (python3-gfhardware d1c47b8, python3-gfutilities 0.9.16+git).
Images 20260907221537, release and dev, one kernel
(6.12.20-fslc-g707c33df2d36), no QA warning, every built-image check
green, the new ones included: forgectrl carries `xy_microsteps` and
`/cool/quiet`, the driver carries the tick line and the ceiling warning,
gfutilities carries the mode refusal, the dev image carries
`motion.microstep-modes`, the mode-derived baseline and the four bench
tools on the bench page. Archived under `images/20260907221537/`.
**The planner buffer index, 22:25 to 22:40Z.** The core's
`plan_reset_buffer()` linked the block ring with a byte-wide index, so
`$398` at 255 or more never finished at start. The fork (openglow-org
grblHAL-core, branch forgefirm, 362577d) widens the index to
`uint_fast16_t`; the driver's submodule follows. The proof is
`scripts/bench/planner_blocks_test.py`: one settings store across
restarts, `$398` written to 400 and then to 1000, the controller
restarted on the store each time and required to answer on the port, to
report the depth in its status report and to run a move. Against the
build before the fix it reports "accepts the connection and never answers
within 5 s: the start spins" (the listener is its own thread, so a
connect alone proves nothing, and the harness kills the controller it
started on every exit); against the fixed build it passes at 400 and at
1000 (`Bf:400` and `Bf:1000`, a G1 move to Idle). The other six host
tests and the four harnesses pass on the fixed build; the coverage lint
is clean (82 tests). On the bench the stripped cross-build (md5
5a0f5a9f) went in through the controller stop and start route, so
forgectrl kept the pulse device: `$398` 100 as found, then 400 and a
restart: the controller answers, `Bf:400`, the process at 0.0 percent of
the core, a jog of +5 mm to MPos 5.006 and back to 0.000; then 1000 and a
restart: answers, `Bf:1000`, 0.0 percent; then 100 restored and a
restart: answers, forgectrl idle at (0, 0, 0). The usable range of `$398`
is the core's own, 30 to 1000. Left: the board at `$398` 100, `/tmp`
clean, the hot-deployed driver in place until the next flash; the fix
committed and pushed in the fork and the driver, the harness registered
in the bench page, the READMEs and the grblHAL CI, and the pin bump and
the image build to follow.
## Reference notes
### Head-IRQ source validation — the beam-emission hypothesis
+5
View File
@@ -275,6 +275,11 @@ TOOLS = [
"feed mid-circle, the fewest free planner blocks, the controller CPU, clamped events, underruns, the "
"accelerometer's cruise RMS. $12 goes back to what it was; the head returns home. Needs the machine "
"homed and at home, the lid closed, no other Grbl client."},
{"id": "planner-blocks-test", "title": "Planner buffer depth harness", "script": "planner_blocks_test.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "A deep planner buffer starts: the controller restarted at $398=400 and at $398=1000 answers "
"on the port, reports the depth in its status report and runs a move. A CI harness (the grblHAL "
"repo): needs the host-built null-sink controller, not the machine, so it is not a bench-page tool."},
{"id": "xy-mode-test", "title": "XY microstep mode harness", "script": "xy_mode_test.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "The XY scale is the microstep mode's, never typed: xy_microsteps sets $100/$101 and the "
+1
View File
@@ -35,6 +35,7 @@ page's takeover does that; from a host, stop them first.
| `z_envelope_test.py` | Host-side Z envelope harness (null-sink controller): the Z soft limit belongs to the driver, not to `$20`, so the driver re-applies `sys.work_envelope`, `sys.homed` and `sys.soft_limits` for Z from the settings-changed chain. Checks that an unreferenced Z is collapsed to where the lens stands and blocks a move each way, that X and Y stay free, and that neither a `$20` write (the core clears the soft-limit mask in the setter) nor a `$132` write (which un-homes the axis as well) frees Z. Runs in the grblHAL repo's CI. |
| `live_fire_drills.py` | **LIVE LASER** drills, on the board (the bench page) or from a LAN host (`GF_HOST`): `live_fire_drills.py <drill> [S] [F]` - `witness` (emission witness, lid-IR peaks vs the ambient baseline, HV current, job-based disarm on M2), `hold` (disarm grace in Hold), `faultpos` (armed job refuses a stale origin after an underrun), `ircut` (lid-IR characterization cut at S/F), `pthresh` (laser power-threshold ladder: 13 constant-power rungs from 2 % to 30 % of full on scrap; the lowest rung that marks is the tube's striking threshold and reads directly as the `$35` value - requires `$35` = 0 for the run), `dladder` (density ladder at a chosen base period), `pcurve` (laser performance-curve ladder: one 100 mm line per level at 10 mm/s under M3, the laser off between rungs and a mid-ladder rung repeated at the end; reads `pic/hv_current` and the head thermopile `head/beam_detect_analog` (a scatter detector in the beam path upstream of the final mirror, so it sees the beam, not the material) from sysfs at ~25 Hz on the board, brackets each rung on the controller's Run/Idle states, and reports per rung the current with a clipped-at-1023 flag, the thermopile delta over its laser-off baseline and in-line drift, then the normalized curve, monotonicity, a line fit with its threshold intercept and the repeat-rung drift; JSON record with the raw trace in the bench data directory; rungs follow `laser_power_model`, a comma list overrides; a curve measurement wants `$35` = 0), `dpatch [F] [pitch] [length]` (depth witness for the density dose curve: two rows of small serpentine-filled patches, row A CW at feeds giving relative doses 1.0 to 0.25 of the reference feed, row B at the reference feed at 100/80/60/45/30 % density; the operator matches each row-B patch to the row-A patch of equal depth, which reads the density's light fraction off the material beside the thermopile's prediction; JSON record), `m4feeds [S] [F1] [F2]` (the density time base across feeds: one out-and-back line pair per feed at the same S under M4 density, one armed run; the operator reads within-line evenness and reversal darkness at both feeds - M4's velocity scaling is what holds dose per mm through the accel), `m4corner [S] [F]` (M4 velocity-scaled power into corners: a corner-heavy vector pattern at 30 % under M4 density, one armed run; the operator confirms every commanded segment marks - the floor makes a dropout unreachable - and the drill asserts the arm report, one discharge window and dark after), `m5dark` (the rapids after an M5 ship dark: one 20 mm line at M3 S400, M5, dwell, rapid back, dwell, rapid forward; PASS when the 25 Hz current trace shows one discharge segment and reads dark after the M5 and `laser_on_sampled` never re-lights; the catalog's `laser.m5-rapid-dark` is its port), `flowload` (cooling under laser load, one armed run per invocation, the conf keys it writes put back at the end, the pump never commanded off: `t1` reproduces the flow-check trip with the check on at its defaults and two 30 x 4 mm CW fills at F1500 starting on the press with no dark dwell, and reports the engine's rise/dT verdict beside the 25 Hz trace of both coolant sensors, the current, the digital witness and the heater output in 5 s bins across the window, with the shape at fire start; `t2 <secs> [pct]` runs with the check off and one fill of about `secs` lit seconds at CW or at `pct` density, and reports the lag to each sensor, the rise per raw-second of `hv_current` and what a full 50 s window would add against the 1.6 C margin; `fit` fits rise against dose over every t2 record; JSON records), `expstop` (armed kill on the expected-stop path; needs the panel token - `GF_TOKEN`, or the board's token file) and `ctrlstart` (the separate controller restart after it). Every drill waits for the operator's physical arm press; eye protection, fire watch, extinguisher, and exhaust are mandatory. |
| `pacing_test.py` | Protocol-loop pacing check (runs on the board, dry motion): idle and parked-in-Hold states are coarse-paced, active motion is tight-paced, and a feed-hold/resume mid-move preserves position with no feeder starve. |
| `planner_blocks_test.py` | Host-side planner buffer depth harness (null-sink controller): `$398` written to 400 and to 1000, the controller restarted on the same settings store each time, and it must answer on the port, report the depth in its status report and run a move. A byte-wide ring index once made 255 blocks or more spin the start. Runs in the grblHAL repo's CI. |
| `xy_mode_test.py` | Host-side XY microstep mode harness (null-sink controller): `xy_microsteps` in the shared config sets `$100`/`$101` and the machine tick, a typed `$100` is overwritten on the spot, a value that is not a mode falls back to x8 with a warning, and `$110`/`$111` are held under a tick lowered with `GFSINK_RATE`. Runs in the grblHAL repo's CI. |
| `raster_dry.py` | A dry raster at top speed at each XY microstep mode (runs on the board; the controller in GRBL mode, idle, no other Grbl client): per mode given (default 8, 16, 32) it stores `xy_microsteps` through forgectrl, waits for the restarted controller, streams 60 passes of 150 mm at F12000 with the laser off, and reports the peak feed, the controller CPU, the kernel counters against the start, `cnc/underruns` and any clamped-event line. Needs 150 mm of free +X and 12 mm of free +Y travel. Ends at x8. |
| `xy_pattern_accel.py` | The XY microstep modes by the head accelerometer with the machine silent (runs on the board; the machine homed and at home, the lid closed, no other Grbl client): per mode it takes the cooling engine's quiet hold with the pump (`POST /cool/quiet?on=1&pump=1`: every fan, the pump and the TEC off), waits the fixed 10 s, samples the LIS2HH12 over `/dev/i2c-3` in one SMBus block transaction per reading at about 600 Hz, and runs the pattern from home at F12000: to (18, 9) in, to (9, 9) in, a 9 in circle from its mid-bottom back to (9, 9), to (9, 0), home. Reports the cruise-window RMS and peak-to-peak per leg and overall, the leg times, the kernel counters against home and `cnc/underruns`; JSON with the trace in the bench data directory. Ends at x8. |
+223
View File
@@ -0,0 +1,223 @@
#!/usr/bin/env python3
"""Host-side verification that a deep planner buffer starts.
$398 (planner buffer blocks) is a reboot-required setting with a range of
30 to 1000. The core links the block ring at start, one block after
another, and the index that walks the ring has to hold every block
number up to the buffer size: a byte-wide index never reaches 255, and a
buffer of 255 blocks or more then spins the controller at start. The
main thread sits at 100 percent in the planner reset, the Grbl port
accepts a connection (the listener is its own thread) and never answers,
and the supervisor still reports the controller running.
Runs the native grblHAL_glowforge binary in null-sink mode (no hardware,
no root) and drives it over TCP, keeping one settings store across
restarts:
1. a fresh store starts and reports its default $398
2. $398=400 is accepted and read back
3. the controller restarted on that store answers on the port, reports
400 blocks, and the status report shows a planner that deep
4. the same at $398=1000, the top of the range
5. a motion line runs to completion on the deep buffer
A controller that connects and never answers after the restart is the
failure this harness exists for. Every exit kills the controller it
started, so a spinning one never outlives the run or holds the port.
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: planner_blocks_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 = 2401
CONNECT_S = 8.0
ANSWER_S = 5.0
DEEP = 400
TOP = 1000
_live = None # the controller process a failure has to take down
def fail(msg):
print("FAIL: %s" % msg)
if _live is not None:
_live.stop()
sys.exit(1)
class Session:
"""One null-sink controller process on a settings store that outlives it."""
def __init__(self, workdir):
global _live
self.workdir = workdir
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)
_live = self
self.sock = self.connect()
self.read(1.0) # banner and any boot messages
def connect(self):
end = time.time() + CONNECT_S
while time.time() < end:
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("the controller never opened the port within %.0f s (exit=%s)\n%s"
% (CONNECT_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 answers(self):
"""True when the controller answers a settings query at all."""
return "$398=" in self.send("$398", ANSWER_S)
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 report(self):
return self.send("?")
def state(self):
m = re.search(r"<(\w+)", self.report())
return m.group(1) if m else "?"
def planner_free(self):
"""The planner blocks the status report shows free (Bf:<blocks>,<rx>)."""
m = re.search(r"\|Bf:(\d+),", self.report())
if not m:
fail("no buffer field in the status report")
return int(m.group(1))
def wait_idle(self, timeout=5.0):
end = time.time() + timeout
while time.time() < end:
if self.state() == "Idle":
return True
time.sleep(0.1)
return False
def stop(self):
global _live
try:
self.sock.close()
except (OSError, AttributeError):
pass
self.proc.terminate()
try:
self.proc.wait(timeout=5)
except subprocess.TimeoutExpired:
self.proc.kill()
self.proc.wait()
_live = None
def check(ok, ok_msg, fail_msg):
if not ok:
fail(fail_msg)
print("ok %s" % ok_msg)
def restart_on(workdir, blocks):
"""Write $398, restart on the same store, and prove the controller answers."""
s = Session(workdir)
s.write_setting("$398=%d" % blocks)
check(s.setting("$398") == str(blocks),
"$398=%d accepted" % blocks, "$398=%d not read back" % blocks)
s.stop()
s = Session(workdir)
check(s.answers(),
"the restarted controller answers at $398=%d" % blocks,
"the controller restarted at $398=%d accepts the connection and never "
"answers within %.0f s: the start spins" % (blocks, ANSWER_S))
check(s.setting("$398") == str(blocks),
"it reports $398=%d" % blocks, "it reports another $398")
s.write_setting("$10=%d" % (int(s.setting("$10")) | 2)) # buffer state in the report
free = s.planner_free()
check(free >= blocks - 1,
"the status report shows %d planner blocks free" % free,
"the status report shows %d planner blocks free, expected at least %d"
% (free, blocks - 1))
return s
def main():
if not os.path.isfile(BIN):
fail("no controller binary at %s" % BIN)
workdir = tempfile.mkdtemp(prefix="planner-blocks-")
try:
s = Session(workdir)
check(s.answers(), "a fresh store starts and answers",
"a fresh store does not answer")
print(" default $398=%s" % s.setting("$398"))
s.stop()
s = restart_on(workdir, DEEP)
s.stop()
s = restart_on(workdir, TOP)
reply = s.send("G91 G1 X1 F600", 1.5)
check("ok" in reply and "error" not in reply and "ALARM" not in reply,
"a motion line is accepted on the deep buffer",
"the motion line was refused: %r" % reply)
check(s.wait_idle(), "the move completes and the controller is Idle",
"the controller did not return to Idle after the move")
s.stop()
print("PASS: the planner buffer starts at %d and %d blocks" % (DEEP, TOP))
finally:
if _live is not None:
_live.stop()
shutil.rmtree(workdir, ignore_errors=True)
if __name__ == "__main__":
main()