tray_test.py and motion.tray: the crumb tray's mode on the null-sink controller and on the machine

- scripts/bench/tray_test.py, run by the driver's CI: M103 P1 and P0
  move Z and its envelope by tray_offset_mm on the lens step grid, tell
  the sender, write and remove the marker beside the shared config, and
  ship no step; a missing P, P2, P0.5, P-1, and an axis word are errors
  that change nothing; the switch behind a move waits for the move; the
  port's tray op with a sender connected, its count exact, refused out of
  form, during a program, and at a package's M-code; the mode across a
  soft reset and a killed controller, a start over the marker taking the
  lens reference in the tray-out frame; the offset's range; an
  unreferenced lens; a camera home in both modes handing the runner the
  same park. The kernel-idle half of the barrier needs the pulse device:
  a null sink is always idle.
- scripts/bench/ctlport_test.py: the dark case carries tray out and in
  under an open armed window.
- forgetest: motion.tray in its own module, suite/tray.py, so no other
  test's fingerprint moves. It switches the mode both ways through the
  panel's route and through M103 from a Grbl client and judges each
  switch: /status's mode, Z, and reach moved by the offset's grid, the
  port's state, the marker, the reference and its source unchanged, the
  client told, the kernel's counters unmoved. A jog to the Z the lens
  read before a switch is refused in the other mode and taken back in
  the mode found; a setup card started with the tray out is refused in
  the operator's words; the panel page carries the switch. The mode goes
  back as found in a finally. It does not restart the controller, which
  would drop the X and Y reference: persistence is tray_test.py's.
- The bench registry and README list tray_test.py.

Proof: tray_test.py ALL PASS against the driver with the mode, and six
driver copies each missing one part of it fail. The driver's whole CI
harness list passes. The forgetest unit suite: 500 tests OK. The
coverage lint over the working trees: nothing uncovered, 126 tests. On
the bench reference, with the binaries hot-deployed on the fx18 image:
motion.tray PASS, and FAIL against the driver whose anchor lost its
source word on the second switch.

Acceptance. motion.tray is new; the suite and bench files are in no
other test's fingerprint.
This commit is contained in:
ScottW514
2026-09-27 15:23:02 -04:00
parent 6e71ac86f0
commit cd84786fc5
6 changed files with 698 additions and 2 deletions
+11
View File
@@ -304,6 +304,17 @@ TOOLS = [
"do the same with the sender's count exact. Imports its sender and port client from "
"ctlport_test.py. 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": "tray-test", "title": "Crumb tray mode harness", "script": "tray_test.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "The crumb tray's mode on the null-sink controller: M103 P1 and P0 move Z and its envelope by "
"tray_offset_mm on the lens step grid, tell the sender, write and remove the marker beside the "
"shared config, and ship no step; a missing P, a P other than 0 or 1 and an axis word are "
"errors; the switch waits for the move before it; the port's tray op, and its refusals during "
"a program and at a package's M-code; the mode survives a soft reset and a restart, and a start "
"over the marker references Z in the tray-out frame; the offset's range; an unreferenced lens; "
"and a camera home in both modes hands the runner the same park. Imports its sender and port "
"client from ctlport_test.py. 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": "raster-dry", "title": "Dry top-speed raster per XY microstep mode", "script": "raster_dry.py",
"safety": "dry", "where": "board", "ported": True,
"args": [_arg("modes", "choice", "8 16 32", "the modes to run, in order", ["8", "16", "32", "8 16 32"])],
+1
View File
@@ -33,3 +33,4 @@ from . import updlock # noqa: F401,E402
from . import homeoff # noqa: F401,E402
from . import bedsize # noqa: F401,E402
from . import extsender # noqa: F401,E402
from . import tray # noqa: F401,E402
+203
View File
@@ -0,0 +1,203 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The crumb tray's mode, on the machine.
Its own module, so that no other test's fingerprint moves. With the tray
out, the GRBL controller's Z is the focal height above the floor of the cut
area: its Z position and its Z range move up by tray_offset_mm on the lens
step grid, and nothing moves. The mode persists as the controller's marker
in the data directory. That it survives a controller restart is the
driver's harness case (tray_test.py): a restart here would drop the X and
Y reference, and the machine would not be handed back as it was found.
"""
import math
import os
import sys
import time
from ..catalog import test
from .. import hw
from .motion import (_kernel_position, _port_state, _words, clean_slate, drain_text, machine_idle)
TRAY_MARKER = "/data/forgefirm/tray.out"
LENS_SPM = 2.922 # $102: the lens screw's half-steps per mm
SETUP_CARD = "sheet.place" # the first setup card: it asks before it moves anything
def _grid_mm(offset_mm):
"""The offset on the lens step grid, the controller's way (lroundf)."""
return math.floor(offset_mm * LENS_SPM + 0.5) / LENS_SPM
def _lens(fc):
return fc.status().get("lens") or {}
def _judge(ctx, fc, ev, key, mode, found, shift):
"""The machine reads the tray `mode`, with Z and the reach moved by
`shift` mm from what the test found."""
s = fc.status()
lens, pos = s.get("lens") or {}, s.get("pos") or {}
port = _port_state(fc)
rec = ev[key] = {"lens": lens, "pos_z": pos.get("z"), "port_tray": port.get("tray"),
"port_z": port.get("mpos", [None] * 3)[2], "marker": os.path.exists(TRAY_MARKER),
"home": [s.get("homed_axes"), s.get("home_source")]}
ctx.log("%s: %s", key, rec)
ctx.check(lens.get("tray") == mode and port.get("tray") == mode,
"%s: the status says tray %s and the port %s, not %s", key, lens.get("tray"), port.get("tray"), mode)
ctx.check(rec["marker"] == (mode == "out"), "%s: the marker %s is %s with the tray %s", key, TRAY_MARKER,
"present" if rec["marker"] else "absent", mode)
for k in ("reach_min", "reach_max"):
ctx.check(lens.get(k) is not None and abs(lens[k] - (found["lens"][k] + shift)) < 0.011,
"%s: %s reads %s, not %.2f", key, k, lens.get(k), found["lens"][k] + shift)
ctx.check(pos.get("z") is not None and abs(pos["z"] - (found["pos_z"] + shift)) < 0.006,
"%s: the status Z reads %s, not %.2f", key, pos.get("z"), found["pos_z"] + shift)
ctx.check(rec["port_z"] is not None and abs(rec["port_z"] - (found["port_z"] + shift)) < 0.002,
"%s: the controller's Z reads %s, not %.3f", key, rec["port_z"], found["port_z"] + shift)
ctx.check(rec["home"] == found["home"], "%s: the reference changed across the switch: %s, not %s",
key, rec["home"], found["home"])
def _tray_route(fc, mode):
st, body = fc.post("/motion/tray", params={"tray": mode})
return st, _words(body)
def _card_refused(ctx, fc, ev):
"""A setup card is refused while the tray is out. The machine is idle a
moment after the suite's Grbl client closes; until then the refusal is
the idle rule's. A card that started anyway is ended at once: the first
one asks before it moves anything."""
path = "/wiz/%s/start" % SETUP_CARD
deadline = time.time() + 20
while True:
st, body = fc.post(path)
words = _words(body)
if st == 200:
fc.post("/wiz/%s/abort" % SETUP_CARD)
ctx.wait_for(lambda: not (fc.get("/wiz/dark")[1] or {}).get("running"), 20, poll=0.5)
if st != 409 or "not idle" not in words or time.time() > deadline:
break
ctx.sleep(0.5)
ev["setup_card"] = [st, words]
ctx.log("a setup card started with the tray out -> %s %s", st, words)
ctx.check(st == 409 and "crumb tray" in words, "a setup card started with the tray out -> %s %s", st, words)
@test("motion.tray", title="The crumb tray: tray out moves Z and its range up by the offset, and nothing moves",
subsystem="motion", kind="auto", mode="grbl", est_min=2,
covers=[("grblhal-glowforge", "src/glowforge_tray.*"), ("grblhal-glowforge", "src/glowforge_homing.*"),
("grblhal-glowforge", "src/ctlport.*"), ("grblhal-glowforge", "src/glowforge_io.*"),
("forgectrl", "src/tray.*"), ("forgectrl", "src/grblport.*"), ("forgectrl", "src/status.*"),
("forgectrl", "src/main.c"), ("forgectrl", "src/wizdark.*"), ("forgectrl", "src/ui/panel.js"),
("forgectrl", "src/ui/index.html")],
requires=["motion.port-jog"],
steps=["Nothing moves; the crumb tray can stay where it is."],
description="The tray mode is switched to the other one and back, through the panel's route and "
"through M103 from a Grbl client, both ways. Each switch changes the numbers and "
"moves nothing: /status says the tray, its Z and the lens reach move by the offset on "
"the lens step grid (1.35 in is 100 half-steps, 34.22 mm), the reference and what set it "
"stand, the controller port's state "
"agrees, the marker in the data directory follows, the client is told [MSG:Tray in] or "
"[MSG:Tray out] and its next line draws ok, and the kernel's position and byte counters "
"do not move. In the other mode, a jog to the Z the lens read before the switch is "
"refused (error:15): the two Z ranges never overlap; back in the mode found, the same "
"jog is taken. A setup card started with the tray out is refused in the operator's "
"words, with nothing sent. The page "
"the panel serves carries the switch. The tray mode is put back as found in a finally. "
"The controller is not restarted: that would drop the X and Y reference. That the mode "
"survives a restart, the errors of M103, and its wait for the move before it are the "
"driver's harness cases.")
def tray(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
s0 = fc.status()
found_mode = (s0.get("lens") or {}).get("tray")
ctx.check(found_mode in ("in", "out"), "the status carries no tray mode: %s", s0.get("lens"))
other = "out" if found_mode == "in" else "in"
p_found = "P1" if found_mode == "out" else "P0"
p_other = "P1" if other == "out" else "P0"
shift = _grid_mm((s0.get("lens") or {}).get("tray_offset_mm") or 34.29) * (1 if other == "out" else -1)
machine_idle(ctx)
found = {"lens": s0.get("lens"), "pos_z": (s0.get("pos") or {}).get("z"),
"port_z": _port_state(fc)["mpos"][2], "kernel": _kernel_position(),
"home": [s0.get("homed_axes"), s0.get("home_source")]}
ev["found"] = {"tray": found_mode, "shift_mm": round(shift, 4), **found}
ctx.log("found the tray %s; switching to %s moves Z %+.3f mm", found_mode, other, shift)
ctx.check(found["pos_z"] is not None, "the status carries no Z")
st, page = fc.get("/", raw=True)
text = page.decode("utf-8", "replace") if isinstance(page, bytes) else str(page)
for want in ('id="traybtn"', "function trayToggle", "/motion/tray?tray="):
ctx.check(st == 200 and want in text, "the panel page carries no %s", want)
switched = False
try:
if found_mode == "out":
_card_refused(ctx, fc, ev)
with ctx.grbl() as g:
clean_slate(ctx, g)
ctx.sleep(0.6) # past the port's sender-quiet interval
# Through the panel's route.
st, words = _tray_route(fc, other)
ctx.check(st == 200, "POST /motion/tray tray=%s -> %s %s", other, st, words)
switched = True
said = drain_text(g, 0.5)
ctx.check("[MSG:Tray %s]" % other in said, "the client was not told the tray is %s: %r", other, said)
_judge(ctx, fc, ev, "route_" + other, other, found, shift)
r = g.command("G4 P0")
ctx.check(r[-1:] == ["ok"], "the client's line after the switch drew %s", r)
# The Z the lens read before the switch is outside this mode's range.
jog = "$J=G90 G21 Z%.3f F200" % found["port_z"]
r = g.command(jog)
ev["jog_" + other] = [jog, r]
ctx.check(r[-1:] == ["error:15"], "with the tray %s, %s drew %s, not error:15", other, jog, r)
g.command("") # the empty line that acknowledges the error
# Back through M103, from the client.
r = g.command("M103 " + p_found)
ev["m103_" + found_mode] = r
ctx.check(r[-1:] == ["ok"] and "[MSG:Tray %s]" % found_mode in r,
"M103 %s drew %s", p_found, r)
switched = False
_judge(ctx, fc, ev, "m103_" + found_mode, found_mode, found, 0.0)
r = g.command(jog)
ev["jog_" + found_mode] = [jog, r]
ctx.check(r[-1:] == ["ok"], "with the tray %s again, %s drew %s", found_mode, jog, r)
machine_idle(ctx)
# And the other way: M103 out, the route back.
r = g.command("M103 " + p_other)
ev["m103_" + other] = r
ctx.check(r[-1:] == ["ok"] and "[MSG:Tray %s]" % other in r, "M103 %s drew %s", p_other, r)
switched = True
_judge(ctx, fc, ev, "m103_" + other, other, found, shift)
if other == "out":
_card_refused(ctx, fc, ev)
st, words = _tray_route(fc, found_mode)
ctx.check(st == 200, "POST /motion/tray tray=%s -> %s %s", found_mode, st, words)
switched = False
_judge(ctx, fc, ev, "route_" + found_mode, found_mode, found, 0.0)
finally:
if switched:
# The route is refused for a moment after the suite's Grbl client closes.
first = sys.exc_info()[1]
took = ctx.wait_for(lambda: _tray_route(fc, found_mode)[0] == 200 or None, 20, poll=0.5)
ctx.log("put the tray back %s -> %s", found_mode, "taken" if took is not None else "not taken")
ev["put_back"] = took is not None
if first is None:
ctx.check(took is not None, "the tray mode is left %s, not put back %s", other, found_mode)
machine_idle(ctx)
k1 = _kernel_position()
ev["kernel"] = [found["kernel"], k1]
ctx.check(k1 == found["kernel"], "the kernel moved across the switches: %s -> %s", found["kernel"], k1)
ctx.log("PASS: the tray mode switched both ways through the route and through M103, Z and the reach "
"moved by %.3f mm and back, nothing moved, and it is %s as found", abs(shift), found_mode)