Let a test declare the controller mode it needs; the runner switches to it

The cloud job tests enter cloud mode and stay there, by design, so a
queue (or an operator) that goes on to a motion test reaches it with
gfcloud as the controller and no grblHAL process to find:
motion.step-timing-under-load failed on exactly that, before it touched
the machine. Nothing in the runner put the machine into the mode a test
needed; the baseline only preserved the mode it found.

A test now declares `mode="grbl"` (or "cloud") in @test. The runner's pre
pass, after the leftovers are handled and before the preserved state is
captured, switches through POST /mode, waits for the supervisor to settle
(controller running, motion verified) and for the Grbl port to answer,
and fails the test with the reason when the mode cannot be established.
Capturing after the switch means the post pass keeps the mode the test
asked for, so the machine changes mode only where the next test asks for
it and never between tests of the same mode. The cloud job tests keep
managing their own entry (enter_cloud also waits for the service
session) and declare nothing.

Tagged: every motion.* test but the mode-agnostic liveness probe, the six
laser.* tests, cooling.fans-quiet-after-motion, cloud.mode-switch and
cloud.gfhome-homing (both start in GRBL mode). controller_pid() now says
what mode forgectrl reports when the process is missing. The page shows
the declared mode as a badge; the Grbl port probe moved to hw.

Proof: tests/test_mode.py (switch_mode against the fake forgectrl,
including a refused switch, a controller that never comes up and a port
that never opens; the runner end to end from cloud mode, from grbl mode,
an undeclared test, and a failed switch). 151 unit tests pass; the
coverage lint is clean. No catalog consequence beyond the suite modules'
own source hashes: the change is to how a test is started, not to what
it proves.
This commit is contained in:
ScottW514
2026-08-21 12:34:03 -04:00
parent e810d52c9d
commit 54e1689889
13 changed files with 321 additions and 51 deletions
+3 -14
View File
@@ -4,7 +4,6 @@ grbl -> cloud -> grbl round trip; the job-behavior tests run in cloud mode
and leave the machine there (see enter_cloud)."""
import json
import os
import socket
import time
from ..catalog import test
@@ -91,18 +90,8 @@ def wait_mode(ctx, fc, want_mode, want_controller="running", timeout=90):
return last
def grbl_port_open(timeout=5):
try:
s = socket.create_connection((os.environ.get("GRBL_HOST") or "127.0.0.1",
int(os.environ.get("GRBL_PORT") or 23)), timeout=timeout)
s.close()
return True
except OSError:
return False
@test("cloud.mode-switch", title="Controller mode switch grbl -> cloud -> grbl", subsystem="cloud",
kind="auto", est_min=4,
kind="auto", mode="grbl", est_min=4,
covers=_CLOUD_COVERS, requires=["forgectrl.auth", "motion.pacing"],
steps=["Bed clear: the cloud client homes the head to the corner on connect (the factory "
"hunt) and the test jogs it back to where it started afterward. Cloud credentials "
@@ -174,7 +163,7 @@ def mode_switch(ctx):
"grbl controller did not come back: %s", m)
ctx.check(m.get("motion") != "fault", "motion fault after the switch")
ctx.sleep(3)
ctx.check(grbl_port_open(), "Grbl port not open after the switch back")
ctx.check(hw.grbl_port_open(), "Grbl port not open after the switch back")
with ctx.grbl() as g:
st = g.status_report()["state"]
ev["grbl_state"] = st
@@ -199,7 +188,7 @@ def mode_switch(ctx):
@test("cloud.gfhome-homing", title="Glowforge web-service homing ($H with homing_mode=gfcloud)",
subsystem="cloud", kind="operator", est_min=5,
subsystem="cloud", kind="operator", mode="grbl", est_min=5,
covers=_CLOUD_COVERS + [("grblhal-glowforge", "src/**")], requires=[],
steps=["homing_mode = gfcloud and cloud credentials configured; bed clear, lid closed.",
"Watch the gantry: the service drives it to the corner with camera corrections.",
+1 -1
View File
@@ -64,7 +64,7 @@ def flow_verify(ctx):
@test("cooling.fans-quiet-after-motion", title="Fan profile returns to idle after motion and after M8/M9",
subsystem="cooling", kind="auto", est_min=2,
subsystem="cooling", kind="auto", mode="grbl", est_min=2,
covers=_COOL_COVERS + [("forgectrl", "src/super.c")], requires=["motion.pacing"],
steps=["Bed clear; the head needs 20 mm of free +X travel."],
description="A dry jog and an M8/M9 cycle must not leave the run fan profile on: within "
+6 -6
View File
@@ -229,7 +229,7 @@ def wait_disarm(ctx, timeout):
@test("laser.power-floor", title="The shipped duty floor holds commanded power above the lasing threshold",
subsystem="laser", kind="auto", est_min=1,
subsystem="laser", kind="auto", mode="grbl", est_min=1,
covers=[("grblhal-glowforge", "src/**")],
description="$35 floors the bottom of the laser's output range, and unfloored the low end "
"of S asks for pulses too far apart for the discharge to re-strike - a "
@@ -274,7 +274,7 @@ def power_floor(ctx):
@test("laser.emission-witness", title="Live emission witness (S400 vector mark) and job-based disarm",
subsystem="laser", kind="live", always=True, est_min=5,
subsystem="laser", kind="live", mode="grbl", always=True, est_min=5,
covers=_LASER_COVERS,
requires=["kernel.latch-locked-idle", "kernel.k1-k2", "motion.jog-roundtrip"],
steps=["Scrap under the head with 20 mm of free +X and +Y travel; lid closed; exhaust on.",
@@ -334,7 +334,7 @@ def emission_witness(ctx):
@test("laser.disarm-in-hold", title="Disarm grace counts down in Hold", subsystem="laser",
kind="live", est_min=4,
kind="live", mode="grbl", est_min=4,
covers=_LASER_COVERS,
requires=["laser.emission-witness"],
steps=["Scrap under the head with 40 mm of free +X travel; lid closed; exhaust on.",
@@ -400,7 +400,7 @@ def disarm_in_hold(ctx):
@test("laser.armed-kill", title="Armed kill mid-fire: the expected stop, then a SIGKILL",
subsystem="laser", kind="live", est_min=6,
subsystem="laser", kind="live", mode="grbl", est_min=6,
covers=_LASER_COVERS + [("forgectrl", "src/main.c")],
requires=["laser.emission-witness", "motion.deadman"],
steps=["Scrap under the head with 40 mm of free +X and +Y travel; lid closed; exhaust on.",
@@ -497,7 +497,7 @@ def armed_kill(ctx):
@test("laser.arm-wait-lid", title="Lid open during the arm wait cancels the job",
subsystem="laser", kind="operator", est_min=3,
subsystem="laser", kind="operator", mode="grbl", est_min=3,
covers=_LASER_COVERS + [("grblhal-glowforge", "src/glowforge_laser.c"),
("grblhal-glowforge", "src/glowforge_switches.c"),
("grblhal-glowforge", "src/glowforge_switch_map.h")],
@@ -568,7 +568,7 @@ def arm_wait_lid(ctx):
@test("laser.pause-resume-lid-cancel", title="One live cut: the button pauses and resumes it, the lid "
"cancels it and sends the head home",
subsystem="laser", kind="live", est_min=7,
subsystem="laser", kind="live", mode="grbl", est_min=7,
covers=_LASER_COVERS + [("grblhal-glowforge", "src/glowforge_switches.c"),
("grblhal-glowforge", "src/glowforge_switch_map.h")],
requires=["laser.emission-witness", "motion.lid-cancel-home", "motion.button-hold-resume"],
+16 -10
View File
@@ -19,7 +19,13 @@ _MOTION_COVERS = [("grblhal-glowforge", "src/**"), ("kernel-module-glowforge", "
def controller_pid():
pids = hw.pidof("grblHAL_glowfor")
if not pids:
raise Failed("controller process not found (grblHAL_glowforge)")
try:
st, m = hw.Forgectrl().get("/mode")
where = ("forgectrl reports mode=%s controller=%s" % (m.get("mode"), m.get("controller"))
if st == 200 and isinstance(m, dict) else "forgectrl /mode -> %s" % st)
except hw.HwError as e:
where = "forgectrl unreachable (%s)" % e
raise Failed("controller process not found (grblHAL_glowforge); %s" % where)
return pids[0]
@@ -133,7 +139,7 @@ def clean_slate(ctx, g):
@test("motion.pacing", title="Protocol-loop pacing (idle, parked, moving) and hold/resume position",
subsystem="motion", kind="auto", est_min=1,
subsystem="motion", kind="auto", mode="grbl", est_min=1,
covers=_MOTION_COVERS, requires=["kernel.latch-locked-idle"],
steps=["Bed clear; the head needs 30 mm of free +X travel."],
description="Idle CPU is low; a job parked in a completed feed hold is coarse-paced (not "
@@ -193,7 +199,7 @@ def pacing(ctx):
@test("motion.jog-roundtrip", title="Motion quality: bounded jogs, max rate, diagonal, hold/resume",
subsystem="motion", kind="operator", est_min=3,
subsystem="motion", kind="operator", mode="grbl", est_min=3,
covers=_MOTION_COVERS, requires=["kernel.latch-locked-idle"],
steps=["Park the head with at least 60 mm of free +X and 40 mm of free +Y travel; bed clear.",
"Watch the gantry: it must move on every jog and end where it started."],
@@ -372,7 +378,7 @@ def _liveness_masked_restart(ctx, fc, ev):
# ---------------------------------------------------------------- cancel / abort
@test("motion.cancel-abort", title="Jog cancel and controlled abort recover cleanly", subsystem="motion",
kind="auto", est_min=2,
kind="auto", mode="grbl", est_min=2,
covers=_MOTION_COVERS, requires=["motion.pacing"],
steps=["Bed clear; the head needs 40 mm of free +X travel."],
description="A jog-cancel (0x85) stops a jog short of its target and returns to Idle with "
@@ -463,7 +469,7 @@ def _return_x(ctx, delta_mm):
@test("motion.deadman", title="Dead-man: controller kill, controller hang, forgectrl restart mid-move",
subsystem="motion", kind="auto", est_min=4,
subsystem="motion", kind="auto", mode="grbl", est_min=4,
covers=_MOTION_COVERS + [("forgectrl", "src/main.c"), ("forgectrl", "init/**")],
requires=["motion.cancel-abort", "kernel.k1-k2"],
steps=["Bed clear; the head needs 40 mm of free +X travel and must not be at the left rail."],
@@ -693,7 +699,7 @@ def expect_cancel_and_return(ctx, g, ev, start, k0, why, tag):
@test("motion.button-hold-resume", title="The button pauses and resumes a job",
subsystem="motion", kind="operator", est_min=2,
subsystem="motion", kind="operator", mode="grbl", est_min=2,
covers=_LID_COVERS, requires=["motion.pacing"],
steps=["Bed clear; the head needs 40 mm of free +X travel. No laser is involved.",
"Press the button once when told (pause), and once more when told (resume)."],
@@ -751,7 +757,7 @@ def button_hold_resume(ctx):
@test("motion.lid-cancel-home", title="Lid open during a job - running or paused - cancels it and returns "
"to the job start",
subsystem="motion", kind="operator", est_min=5,
subsystem="motion", kind="operator", mode="grbl", est_min=5,
covers=_LID_COVERS, requires=["motion.pacing", "motion.cancel-abort"],
steps=["Bed clear; the head needs 40 mm of free +X travel. No laser is involved.",
"Open the lid when told, and leave it open until the head has come back (twice: once "
@@ -833,7 +839,7 @@ def lid_cancel_home(ctx):
@test("motion.interlock-cancel-home", title="The interlock loop cancels a job like the lid and returns to "
"the job start",
subsystem="motion", kind="operator", est_min=4,
subsystem="motion", kind="operator", mode="grbl", est_min=4,
covers=_LID_COVERS, requires=["motion.lid-cancel-home"],
steps=["Bed clear; the head needs 60 mm of free +X travel. No laser is involved.",
"Be able to open the remote-interlock loop: unplug the Pro's interlock plug, or pull the "
@@ -894,7 +900,7 @@ def interlock_cancel_home(ctx):
@test("motion.lid-policy-hold", title="lid_policy=hold parks the job in Door and a cycle start resumes it",
subsystem="motion", kind="operator", est_min=4,
subsystem="motion", kind="operator", mode="grbl", est_min=4,
covers=_LID_COVERS + [("forgectrl", "src/settings.*")], requires=["motion.lid-cancel-home"],
steps=["Bed clear; the head needs 40 mm of free +X travel. No laser is involved.",
"Open the lid when told, then close it when told; the job finishes after that."],
@@ -981,7 +987,7 @@ def _thread_sched(pid):
@test("motion.step-timing-under-load",
title="Step timing holds while userspace competes for the core",
subsystem="motion", kind="auto", est_min=2,
subsystem="motion", kind="auto", mode="grbl", est_min=2,
covers=_MOTION_COVERS, requires=["kernel.latch-locked-idle", "motion.jog-roundtrip"],
steps=["Bed clear, lid closed; the head needs >= 40 mm of free +X travel."],
description="The board has one core, so the thread that stamps steps onto the pulse grid "