Catalog: motion.port-jog and motion.tray leave; two leftovers of the audit

Two catalog tests leave for the host proofs that carry them; the catalog
goes from 80 to 78 (78 @test in forgetest/forgetest/suite/).

- motion.port-jog. forgectrl's tests/host_routes.sh (7b2f629 on e69d967)
  runs the controller port's routes against the port's stand-in: a jog
  refused without the token, a jog that is no number 400 with nothing on
  the port, a numeric jog on the port as G91 G21, cancel, release and
  energize each one line, GET /motion/state passing the port's state on
  with state and sender, GET /motion/sender 200. The port beside a
  sender, its polls, the cancel, the client first and the bound are
  grblHAL's ctlport_test.py and forgectrl's grblport_test; motion.release
  keeps the physical currents. The helpers before it (_port_state,
  _port_idle, _words) stay: bedsize, laser and motion.job read them.
- motion.tray. host_routes.sh holds POST /motion/tray (tray=in on the
  port, tray=up 400) and the panel page's switch (id="traybtn",
  trayToggle, /motion/tray); grblHAL's tray_test.py the mode, its offset,
  its persistence and its frame; forgectrl's tray_test the daemon's side.
  suite/tray.py held only it: the module and its import go.

The coverage lint then named forgectrl src/tray.c and src/tray.h, and
nothing else; grblhal-glowforge's src/glowforge_tray.* stays covered by
another catalog test, so it gets no row. One HOST_TESTED row takes
forgectrl's, to forgectrl:tests/tray_test.c on
forgectrl:.github/workflows/build.yml, which the pin c274f19 runs
(./build/tray_test).

Two of the audit's leftovers, in the same change:
- test_manifest.py's ImplementationHashTests sat after the __main__
  guard, so running the file directly skipped it. It is above the guard
  now, byte for byte: python3 -m unittest test_manifest.py and python3
  test_manifest.py (from tests/, where its helpers import resolves) each
  run its 7 cases, and 34 in all; before, the direct run ran none of
  them.
- laser_lifecycle_test.py's test_lid_open_in_wait said an alarm is
  raised; the docstring now says what the test asserts: the lid-open
  cancel is reported and no alarm is raised. The assertions are
  unchanged and the harness was not run.

The coverage lint on the tree manifest finds no uncovered path and no
host-tested row problem across the 78 tests; its one finding is
setup.check-lens's empty src/lensfind.* entry. pyflakes reports nothing
on motion.py, manifest.py, test_manifest.py or laser_lifecycle_test.py.
The full forgetest suite was not run.

Fingerprints, measured against the parent: 17 tests move with
motion.py's shared text: the 11 left in motion.py, and homing.manual,
laser's four and setup.check-envelope, whose modules read it. The
pending pin bumps from forgectrl c274f19 and forgeext 2d0391e already
owe all 17; this deletion adds none to the owed list. The two leftovers
move no fingerprint. releases/ is unchanged.
This commit is contained in:
ScottW514
2026-10-14 18:21:59 -04:00
parent 40c478d56c
commit 6594f57362
6 changed files with 9 additions and 375 deletions
+3
View File
@@ -162,6 +162,9 @@ HOST_TESTED = [
# The event stream: a button edge, a lease changing hands, telemetry
# (the live order, the take before the free, is tests/host_lease.sh).
("forgectrl", "src/events.*", "forgectrl:tests/events_test.c", "forgectrl:.github/workflows/build.yml"),
# The crumb tray's mode as the daemon keeps it: its offset and what it
# refuses (the route and the panel's switch are tests/host_routes.sh).
("forgectrl", "src/tray.*", "forgectrl:tests/tray_test.c", "forgectrl:.github/workflows/build.yml"),
]
# This repository's root, when the package runs from a checkout of it; a
-1
View File
@@ -23,5 +23,4 @@ from . import cloud # noqa: F401,E402
from . import exthost # noqa: F401,E402
from . import homeoff # noqa: F401,E402
from . import bedsize # noqa: F401,E402
from . import tray # noqa: F401,E402
from . import tlsrec # noqa: F401,E402
-165
View File
@@ -1753,171 +1753,6 @@ def _words(body):
return body if isinstance(body, str) else json.dumps(body)
@test("motion.port-jog", title="The controller port: the panel jogs beside a connected Grbl client",
subsystem="motion", kind="auto", mode="grbl", est_min=4,
covers=_MOTION_COVERS + [("forgectrl", "src/grblport.*"), ("forgectrl", "src/main.c"),
("forgectrl", "src/status.*")],
requires=["motion.jog-roundtrip"],
steps=["Bed clear; the head needs 180 mm of free travel toward +X. Nobody touches the gantry."],
description="With the suite connected as the Grbl client, POST /motion/jog moves the head by "
"what was asked (the kernel's counters are the witness), the client is told "
"([MSG:Panel jog]) and is not displaced: its next line still draws ok. "
"GET /motion/state agrees with the kernel. With the client polling the way "
"LightBurn does ('?' and an end of line), no port jog is refused for it, a 30 mm "
"jog runs whole, and every poll draws its ok. POST /motion/cancel stops a long jog "
"short. The client goes first: its line during a port jog cancels the jog and "
"draws ok, never an error. A jog past the per-request bound is refused with "
"nothing moved. POST /motion/release takes the step currents to 0 and /status "
"says so, a jog is then refused in words, and POST /motion/energize restores "
"the hold currents. The head is returned to where it was found.")
def port_jog(ctx):
ev = ctx.evidence
fc = hw.Forgectrl()
with ctx.grbl() as g:
clean_slate(ctx, g)
x0, y0 = kernel_start(ctx)
ctx.sleep(0.6) # past the port's sender-quiet interval
# A bounded jog, beside the client.
st, body = fc.post("/motion/jog", params={"x": "10", "feed": "2400"})
ctx.check(st == 200, "POST /motion/jog x=10 -> %s %s", st, _words(body))
_port_idle(ctx, fc)
x1, y1 = kernel_xy_mm(ctx)
ps = _port_state(fc)
ev["jog"] = {"kernel": [x0, x1], "port_mpos": ps.get("mpos"), "sender": ps.get("sender")}
ctx.log("port jog +10: kernel X %.3f -> %.3f, port state %s", x0, x1, ps)
ctx.check(abs((x1 - x0) - 10.0) < 0.1, "the kernel moved %.3f mm, not 10", x1 - x0)
ctx.check(abs(y1 - y0) < 0.05, "Y moved on an X jog: %.3f -> %.3f", y0, y1)
ctx.check(ps.get("sender") is True, "the port does not see the connected client: %s", ps)
text = drain_text(g, 0.5)
ctx.check("Panel jog" in text, "the client was not told of the panel's jog: %r", text)
r = g.command("G4 P0")
ctx.check("ok" in r and not any(x.startswith("error") for x in r),
"the client was displaced or refused after a port jog: %s", r)
# The client polls the way LightBurn does: '?' with an end of line
# behind it. The '?' is a realtime character; the end of line is an
# empty line, and an empty line is not the client speaking. No jog
# is refused for it, a jog runs whole under it, each poll draws its ok.
ctx.sleep(0.6)
g.drain()
polls, refused = 0, []
for _ in range(8):
g.send_raw(b"?\n")
polls += 1
st, body = fc.post("/motion/jog", params={"x": "0.5", "feed": "3000"})
if st != 200:
refused.append(_words(body))
ctx.sleep(0.12)
ctx.check(not refused, "%d of 8 port jogs were refused under a '?'+EOL poll: %s", len(refused), refused[:2])
_port_idle(ctx, fc)
xa = kernel_xy_mm(ctx)[0]
st, body = fc.post("/motion/jog", params={"x": "30", "feed": "1200"}) # 1.5 s: several polls long
ctx.check(st == 200, "the jog under the poll -> %s %s", st, _words(body))
in_jog = 0
deadline = time.time() + 6
while time.time() < deadline:
g.send_raw(b"?\n")
polls += 1
ctx.sleep(0.25)
ps = _port_state(fc)
if ps["state"] == "Idle" and not ps["port_jog"]:
break
in_jog += 1
_port_idle(ctx, fc)
xb = kernel_xy_mm(ctx)[0]
oks = sum(1 for line in drain_text(g, 1.0).splitlines() if line.strip() == "ok")
ev["poll"] = {"polls": polls, "oks": oks, "polls_in_jog": in_jog, "kernel": [xa, xb]}
ctx.log("'?'+EOL poll: 8 of 8 jogs accepted; a 30 mm jog moved %.3f with %d polls into it; "
"%d polls drew %d oks", xb - xa, in_jog, polls, oks)
ctx.check(in_jog >= 3, "only %d polls landed inside the jog: the case did not test it", in_jog)
ctx.check(abs((xb - xa) - 30.0) < 0.1, "a status poll cut the port jog short: moved %.3f of 30", xb - xa)
ctx.check(oks == polls, "%d polls drew %d oks", polls, oks)
x1 = xb
# Past the bound: refused at the door, nothing moved.
st, body = fc.post("/motion/jog", params={"x": "101"})
ev["past_bound"] = [st, _words(body)]
ctx.check(st == 400, "a 101 mm jog -> %s %s, expected 400", st, _words(body))
ctx.check(abs(kernel_xy_mm(ctx)[0] - x1) < 0.01, "a refused jog moved the head")
# The cancel stops a long jog short.
ctx.sleep(0.6)
st, body = fc.post("/motion/jog", params={"x": "40", "feed": "600"})
ctx.check(st == 200, "the long jog -> %s %s", st, _words(body))
ctx.sleep(1.0)
st, body = fc.post("/motion/cancel")
ctx.check(st == 200, "POST /motion/cancel -> %s %s", st, _words(body))
_port_idle(ctx, fc)
x2 = kernel_xy_mm(ctx)[0]
ev["cancel"] = [x1, x2]
ctx.log("canceled a 40 mm jog at %.3f mm", x2 - x1)
ctx.check(1.0 < x2 - x1 < 35.0, "the cancel did not stop the jog short: moved %.3f of 40", x2 - x1)
# The client goes first: its line cancels the port's jog and draws ok.
# A fast jog, so the cancel is a real deceleration: the core stays in
# the jog state for the length of it, and a client line read in that
# window is the error:9 the hold exists to prevent. A slow jog stops
# at once and would pass with no hold at all.
g.drain()
ctx.sleep(0.6)
st, body = fc.post("/motion/jog", params={"x": "100", "feed": "6000"})
ctx.check(st == 200, "the fast jog -> %s %s", st, _words(body))
ctx.sleep(0.3)
r = g.command("G4 P0", timeout=15)
_port_idle(ctx, fc)
x3 = kernel_xy_mm(ctx)[0]
ev["sender_first"] = {"reply": r, "kernel": [x2, x3]}
ctx.log("the client's line during a port jog drew %s; the jog stopped at %.3f of 100", r, x3 - x2)
ctx.check("ok" in r and not any(x.startswith("error") for x in r),
"the client's line during a port jog drew %s, not ok", r)
ctx.check(2.0 < x3 - x2 < 95.0, "the client's line did not cancel the port jog: moved %.3f", x3 - x2)
# The panel's own: the release and its end, by their routes.
ctx.sleep(0.6)
st, body = fc.post("/motion/release")
ctx.check(st == 200, "POST /motion/release -> %s %s", st, _words(body))
ctx.sleep(0.5)
ev["released"] = {"currents": list(_step_currents()),
"status": fc.status().get("motors_released"),
"port": _port_state(fc).get("released")}
ctx.check(ev["released"]["currents"] == ["0", "0"],
"the step currents read %s after the release", ev["released"]["currents"])
ctx.check(ev["released"]["status"] is True and ev["released"]["port"] is True,
"the release is not reported: %s", ev["released"])
k0 = _kernel_position()
st, body = fc.post("/motion/jog", params={"x": "1"})
ev["jog_released"] = [st, _words(body)]
ctx.check(st == 409 and "released" in _words(body),
"a jog while released -> %s %s", st, _words(body))
ctx.check(_kernel_position() == k0, "the kernel played something while released")
st, body = fc.post("/motion/energize")
ctx.check(st == 200, "POST /motion/energize -> %s %s", st, _words(body))
ctx.sleep(0.5)
ev["energized"] = {"currents": list(_step_currents()),
"status": fc.status().get("motors_released"),
"faults": hw.sysfs_int("cnc/faults")}
ctx.check(tuple(ev["energized"]["currents"]) == HOLD_CURRENTS and ev["energized"]["status"] is False,
"not energized: %s", ev["energized"])
ctx.check(ev["energized"]["faults"] == 0, "a driver fault on energize: %s", ev["energized"]["faults"])
# Back to where the head was found, by the kernel's own measure.
ctx.sleep(0.6)
for _ in range(3): # one request moves 100 mm at most
back = x0 - kernel_xy_mm(ctx)[0]
if abs(back) < 0.05:
break
st, body = fc.post("/motion/jog", params={"x": "%.3f" % max(-100.0, min(100.0, back)),
"feed": "2400"})
ctx.check(st == 200, "the return jog -> %s %s", st, _words(body))
_port_idle(ctx, fc)
ctx.sleep(0.6)
check_kernel_returned(ctx, ev, (x0, y0), tag="port")
machine_idle(ctx)
ctx.log("PASS: the port jogged beside the client, the cancel and the client's own line each stopped "
"a jog short, the bound held, and the release and the energize went through their routes")
# ---------------------------------------------------------------- the job runner
def _job(fc):
-203
View File
@@ -1,203 +0,0 @@
# 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 = "laser.focus" # a setup burn: 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: it 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)
+4 -4
View File
@@ -425,10 +425,6 @@ class CatalogTests(unittest.TestCase):
self.assertEqual(len(t.source_sha), 64)
if __name__ == "__main__":
unittest.main()
class ImplementationHashTests(unittest.TestCase):
"""A test's implementation hash is its own function plus the code its
module shares: a body edit moves one test, a helper edit moves them
@@ -530,3 +526,7 @@ def two(ctx):
for t in catalog.all_tests(reg):
path = inspect.getsourcefile(t.fn)
self.assertIn(t.id, catalog.module_parts(path)[1], t.id)
if __name__ == "__main__":
unittest.main()
+2 -2
View File
@@ -629,8 +629,8 @@ def test_button_wait_arms():
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."""
the lid-open cancel is reported, no 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")