diff --git a/forgetest/forgetest/manifest.py b/forgetest/forgetest/manifest.py index 3f66ca3..94ef947 100644 --- a/forgetest/forgetest/manifest.py +++ b/forgetest/forgetest/manifest.py @@ -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 diff --git a/forgetest/forgetest/suite/__init__.py b/forgetest/forgetest/suite/__init__.py index 970f77c..cf354b9 100644 --- a/forgetest/forgetest/suite/__init__.py +++ b/forgetest/forgetest/suite/__init__.py @@ -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 diff --git a/forgetest/forgetest/suite/motion.py b/forgetest/forgetest/suite/motion.py index 792efca..448f486 100644 --- a/forgetest/forgetest/suite/motion.py +++ b/forgetest/forgetest/suite/motion.py @@ -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): diff --git a/forgetest/forgetest/suite/tray.py b/forgetest/forgetest/suite/tray.py deleted file mode 100644 index 6827240..0000000 --- a/forgetest/forgetest/suite/tray.py +++ /dev/null @@ -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) diff --git a/forgetest/tests/test_manifest.py b/forgetest/tests/test_manifest.py index 85e6385..6e5bac7 100644 --- a/forgetest/tests/test_manifest.py +++ b/forgetest/tests/test_manifest.py @@ -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() diff --git a/scripts/bench/laser_lifecycle_test.py b/scripts/bench/laser_lifecycle_test.py index a91b995..a0b1192 100644 --- a/scripts/bench/laser_lifecycle_test.py +++ b/scripts/bench/laser_lifecycle_test.py @@ -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")