Press the button when the machine asks, not when its LED is on

The sheet handed the bench actuator every press after the operator's
presence press, and the actuator pressed every time - into nothing. On
the bench reference all six cards were pressed before the card asked:
the frame by 8 s, the focus by 33, the floor by 10, the dose curve by 8,
the corner by 9, and the flow-load card by 69. The operator then pressed
all six himself, which is the opposite of what the ready gate promises.

arm_press() waits for hw.button_lit(), which is true when any button LED
is on, and burn() started that wait before run_check had even started the
wizard. The button is lit through parts of a card that are not the arm -
the lens reference, the program on its way to the controller - so the
wait ended at once, the press landed before the job waited for it, and
the thread was gone by the time the real cue came. forgectrl uses the
same predicate but only inside the job's own sample callback, with the
tube still dark, where a lit button does mean the arm.

The machine already says when it wants the press: a live check opens a
`press` wait prompt at that moment, and run_check sees every prompt. It
now presses there, through a new Ctx.press_now() - no LED read, no
waiting thread, no timing guess. That retires the per-card lit-timeout
column of CARDS, which existed only to give the flow-load card's coolant
settle enough room for a wait that was reading the wrong thing.

The four grbl-driven arm_press() callers in laser.py and cooling.py are
left as they are: they call it with the machine idle and its LEDs dark,
so the level read is the edge they mean. The same shape would bite them
if that ever stopped being true.

Pin: forgectrl 0.1.15 (6e69479, the flow-load tail ending at the coolant
peak and the way out of a finished setup page).

Host-proven: 357 forgetest unit tests, including two new ones - the
actuator presses on the prompt with button_lit stubbed false throughout,
so the LED is provably not consulted, and the press falls to the operator
without a takeover.
This commit is contained in:
ScottW514
2026-09-10 08:41:53 -04:00
parent f0c40e7d4f
commit 1dd6ad608b
5 changed files with 102 additions and 20 deletions
+39
View File
@@ -424,6 +424,45 @@ class Context:
self.log("ACT %s %s: done after %.1f s", channel, state, dt) self.log("ACT %s %s: done after %.1f s", channel, state, dt)
return dt return dt
def press_now(self, text="The button is lit white. Press it now: the laser fires after your press."):
"""The arm press, at the moment the machine says it is waiting.
A check that runs a job opens a `press` prompt of its own when
it sees the button lit and the tube still dark, which is the
machine's own word that the press it wants is this one. Pressing
on that is exact; pressing on a button LED read from here is
not, because the button is lit through parts of a check that are
not the arm (the lens reference, the program on its way to the
controller), and a press then lands before the job waits for it
and is lost.
The fixture presses where the bench took this test's presses (or
opted in standing); otherwise the notice goes up for a person,
who is looking at the same prompt. Returns True when the fixture
pressed."""
fixture = getattr(self.runner, "fixture", None) if self.runner is not None else None
rec = {"channel": "button", "state": "arm", "by": "operator", "ts": now_ts()}
self.evidence.setdefault("actions", []).append(rec)
opted_in = fixture is not None and fixture.covers("button") and \
(fixture.arm_press or self.run.fixture_takeover)
if not opted_in:
if self.run.fixture_takeover:
rec["fixture_lost"] = True
self.log("ARM: WARNING the bench actuator took this test's presses and is now "
"gone - asking the operator for the arm press")
self.notice(text)
return False
try:
fixture.act("button", "press")
except _fixture.FixtureError as e:
rec["fixture_error"] = str(e)
self.log("ARM press: fixture failed (%s) - asking the operator", e)
self.notice(text)
return False
rec["by"] = "fixture"
self.log("ARM press by the fixture, at the machine's own press prompt")
return True
def arm_press(self, text="The button lights white: press it to arm. The machine fires after your press.", def arm_press(self, text="The button lights white: press it to arm. The machine fires after your press.",
lit_timeout=60): lit_timeout=60):
"""The arm cue of a live test. A person's press by default: a """The arm cue of a live test. A person's press by default: a
@@ -15,6 +15,8 @@ from ..baseline import read_position
from .commission import wiz, Restore # noqa: F401 - Restore is re-exported for the sheet from .commission import wiz, Restore # noqa: F401 - Restore is re-exported for the sheet
POLL_S = 1.0 POLL_S = 1.0
# What the machine's own press prompt says, used when it sends no text.
PRESS_TEXT = "The button is lit white. Press it now: the laser fires after your press."
DARK_COVERS = [("forgectrl", "src/wizdark.*"), ("forgectrl", "src/wizcalc.*"), DARK_COVERS = [("forgectrl", "src/wizdark.*"), ("forgectrl", "src/wizcalc.*"),
("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"), ("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"),
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*")] ("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*")]
@@ -76,6 +78,12 @@ def run_check(ctx, wid, on_prompt, timeout_s):
if value is not None: if value is not None:
st, body = answer(fc, wid, p, value) st, body = answer(fc, wid, p, value)
ctx.check(st == 200, "the answer to %s -> %s %s", p.get("id"), st, body) ctx.check(st == 200, "the answer to %s -> %s %s", p.get("id"), st, body)
elif p.get("id") == "press":
# The arm press, asked for by the machine itself: the
# check opens this prompt when the button is lit and
# the tube is still dark. Nothing answers it - the
# press does, and the discharge closes it.
ctx.press_now(p.get("text") or PRESS_TEXT)
if not d.get("running"): if not d.get("running"):
last = d last = d
break break
+18 -18
View File
@@ -36,19 +36,18 @@ SHEET_COVERS = [("forgectrl", "src/wizlive.*"), ("forgectrl", "src/wizrun.h"),
# The live wizards in the order the sheet burns them: the wizard id, the # The live wizards in the order the sheet burns them: the wizard id, the
# answers to its prompts (a middle pick where a person would look at the # answers to its prompts (a middle pick where a person would look at the
# sheet), the keys its result must carry, and how long the bench actuator # sheet), and the keys its result must carry. The arm press is not timed
# waits for the button to light before the arm press goes to a person. # from here: each card opens a press prompt of its own when the button is
# The flow-load card settles the coolant loop before it lights the button # lit and the tube still dark, and run_check presses on that.
# (60 to 240 s); the others light within seconds of the lens reference.
CARDS = [ CARDS = [
("sheet.frame", {"frame-arm": "Continue", "frame-ok": "Yes"}, ["mark_s", "mark_feed"], 120), ("sheet.frame", {"frame-arm": "Continue", "frame-ok": "Yes"}, ["mark_s", "mark_feed"]),
("laser.focus", {"focus-arm": "Continue", "focus-pick": "11", "thickness": "Keep"}, ("laser.focus", {"focus-arm": "Continue", "focus-pick": "11", "thickness": "Keep"},
["pick", "thickness_mm", "pick_half_steps", "edge_z_mm", "steps_per_mm", "max_height_mm", ["pick", "thickness_mm", "pick_half_steps", "edge_z_mm", "steps_per_mm", "max_height_mm",
"focus_range_mm", "stops"], 120), "focus_range_mm", "stops"]),
("laser.floor", {"floor-arm": "Continue", "floor-pick": "8"}, ["faintest_density", "floor_density"], 120), ("laser.floor", {"floor-arm": "Continue", "floor-pick": "8"}, ["faintest_density", "floor_density"]),
("laser.dose-curve", {"dose-arm": "Continue"}, ["points", "curve"], 120), ("laser.dose-curve", {"dose-arm": "Continue"}, ["points", "curve"]),
("laser.corner", {"corner-arm": "Continue", "corner-pick": "1.50"}, ["gamma"], 120), ("laser.corner", {"corner-arm": "Continue", "corner-pick": "1.50"}, ["gamma"]),
("cooling.flow-load", {"load-arm": "Continue"}, ["lit_s", "dose_raw_s", "peak_c", "k_density", "k_cw"], 420), ("cooling.flow-load", {"load-arm": "Continue"}, ["lit_s", "dose_raw_s", "peak_c", "k_density", "k_cw"]),
] ]
# Every setting a card writes, restored as found when the run ends. # Every setting a card writes, restored as found when the run ends.
CARD_SETTINGS = ["lens_hall_edge_z_mm", "lens_stop_below_steps", "lens_stop_above_steps", CARD_SETTINGS = ["lens_hall_edge_z_mm", "lens_stop_below_steps", "lens_stop_above_steps",
@@ -130,12 +129,12 @@ def place(ctx):
file_card(ctx, "sheet.place") file_card(ctx, "sheet.place")
def burn(ctx, wid, answers, want, lit_s): def burn(ctx, wid, answers, want):
"""One live card: the preview, the burn after the press with the """One live card: the preview, the burn after the press with the
prompts answered from `answers`, the witnesses, the keys of the prompts answered from `answers`, the witnesses, the keys of the
result in `want`. The actuator waits `lit_s` for the button to light.""" result in `want`. run_check makes the arm press when the card asks
for it."""
preview_ok(ctx, wid) preview_ok(ctx, wid)
ctx.arm_press(lit_timeout=lit_s)
def on_prompt(p): def on_prompt(p):
return answers.get(p.get("id")) return answers.get(p.get("id"))
@@ -161,9 +160,10 @@ def burn(ctx, wid, answers, want, lit_s):
"it goes with its top edge at the top of the cut area (the head's home corner); " "it goes with its top edge at the top of the cut area (the head's home corner); "
"lid closed. Eye protection on, exhaust on, extinguisher in reach.", "lid closed. Eye protection on, exhaust on, extinguisher in reach.",
"Start the test and press the machine's button once. That press says you are at the " "Start the test and press the machine's button once. That press says you are at the "
"machine, and the bench actuator makes every arm press after it. With no actuator " "machine, and the bench actuator makes every arm press after it, each one when the "
"the start is the Ready answer instead, and you press the button each time it lights " "card itself asks for it. With no actuator the start is the Ready answer instead, "
"white: six times, the frame and the five cards.", "and you press the button each time the page asks: six times, the frame and the "
"five cards.",
"Nothing else: the test answers every prompt itself and puts back every setting the " "Nothing else: the test answers every prompt itself and puts back every setting the "
"cards write. It runs about half an hour, mostly the coolant settle and the dark " "cards write. It runs about half an hour, mostly the coolant settle and the dark "
"tails."], "tails."],
@@ -191,6 +191,6 @@ def sheet(ctx):
"each. The bench presses when the button lights.") "each. The bench presses when the button lights.")
place(ctx) place(ctx)
with Restore(ctx, CARD_SETTINGS): with Restore(ctx, CARD_SETTINGS):
for wid, answers, want, lit_s in CARDS: for wid, answers, want in CARDS:
burn(ctx, wid, answers, want, lit_s) burn(ctx, wid, answers, want)
ctx.log("PASS: the sheet's seven wizards ran on one piece; settings restored") ctx.log("PASS: the sheet's seven wizards ran on one piece; settings restored")
+35
View File
@@ -586,6 +586,41 @@ class RoutingTests(unittest.TestCase):
finally: finally:
runner_mod.hw.button_lit = saved runner_mod.hw.button_lit = saved
def test_the_press_prompt_is_what_the_actuator_presses_on(self):
"""The machine's own press prompt, not a button LED read from
here: the button is lit through parts of a live check that are
not the arm, and a press then lands before the job waits for it
and is lost. press_now presses at the moment the check asks, with
no LED reading and no waiting thread."""
run = Run("test", "r.live", "r.live")
ctx = Context(run, self.runner, self.reg["r.live"])
self.runner.probe_fixture(force=True)
self.stub.arm_press = False
run.fixture_takeover = True
saved = runner_mod.hw.button_lit
runner_mod.hw.button_lit = lambda: False # never consulted
try:
self.assertTrue(ctx.press_now())
self.assertIn(("button", "press"), self.stub.acts)
finally:
runner_mod.hw.button_lit = saved
rec = [r for r in run.evidence["actions"] if r["state"] == "arm"][-1]
self.assertEqual(rec["by"], "fixture")
def test_the_press_prompt_goes_to_the_operator_without_an_actuator(self):
"""No actuator, or one that never took this test's presses: the
notice goes up for a person, who is looking at the same prompt."""
run = Run("test", "r.live", "r.live")
ctx = Context(run, self.runner, self.reg["r.live"])
self.runner.probe_fixture(force=True)
self.stub.arm_press = False
run.fixture_takeover = False
seen = []
run.set_notice = lambda text: seen.append(text)
self.assertFalse(ctx.press_now("Press it now."))
self.assertNotIn(("button", "press"), self.stub.acts)
self.assertIn("Press it now.", seen)
def test_the_arm_press_waits_as_long_as_the_card_says_for_the_light(self): def test_the_arm_press_waits_as_long_as_the_card_says_for_the_light(self):
"""A card that settles the coolant before it lights the button """A card that settles the coolant before it lights the button
takes minutes; the caller names the wait, and the actuator presses takes minutes; the caller names the wait, and the actuator presses
@@ -2,5 +2,5 @@
# only SRCREV and PV here - the image manifest leaves *-pin.inc out of the # only SRCREV and PV here - the image manifest leaves *-pin.inc out of the
# layer content hash because the component entry already identifies the # layer content hash because the component entry already identifies the
# pinned source (forgefirm-image-manifest.bbclass). # pinned source (forgefirm-image-manifest.bbclass).
SRCREV = "9e5330ffbb27dcf2fb89f414f1e8fc38bfdfb6f7" SRCREV = "6e694798903df6add92ec97876cda4851b739aa0"
PV = "0.1.14" PV = "0.1.15"