Files
forgefirm/forgetest/forgetest/suite/laser.py
T
ScottW514 b3a40b4aea Catalog: twelve tests leave for their host proofs; image.health reads the board temperatures
Each test below leaves for the host cases that now hold it, every one
run and passing before this commit:

- cooling.fans-quiet-after-motion, cooling.gate-off (its engine half),
  cooling.floor-and-warm-up, cooling.tec-drive, cooling.fire-watch-tiers,
  cooling.crash-watch-plumbing, cooling.critical-tier and
  cooling.fan-gate-trips: forgectrl's cool_flow_test and cool_gate_test
  (forgectrl 8e9cc0a). The jog and M8/M9 report stays in grblHAL's
  cooling_test.c and cool_report_test.py; real heater climb, a lit tube,
  tach spin-down and fans meeting their floors stay with
  cooling.flow-verify, cooling.flow-under-load, cooling.fail-tier-stop,
  cooling.aa-offset-calibrate and setup.check-airflow.
- motion.gate-waits-for-lid: super_test D2 (forgectrl 8e9cc0a). The
  physical lid read stays with setup.check-switches.
- motion.lid-cancel-home: laser_lifecycle_test's hold-lid-cancel (forgefirm
  107676b). check_kernel_returned stays in laser.pause-resume-lid-cancel's
  live lid phase.
- homing.manual: manual_home_test.py cases 1 and 7, and status_test's
  anchor readback (forgectrl 8e9cc0a).
- laser.recorder-dark: curverec_test (forgectrl 8e9cc0a). The dark arm
  wait stays test_button_wait_arms.

cooling.gate-off's board half moves into image.health: /status
temps.chassis_c from 5 through 80, temps.soc_c from 10 through 100,
temps.supply_raw an integer from 0 through 1023, and temps.soc_throttle
0. image.health's covers gain src/status.*, which serves them. The next
campaign reruns image.health (it is always run).

Stays: motion.cancel-abort (its host case fails on the abort's cnc/stop:
the null-sink controller writes cnc/stop only with a device open),
exthost.service (its init-script case has not passed on unmodified code
here), exthost.armed-freeze (its armed-capture decision has no host
proof), exthost.motion-jog (the package's jog through the host credential
has no host proof), exthost.motion-job (its POST /job reader is step 4's
jobpost_test.c).

Requires re-pointed off the tests that left: setup.check-airflow and
setup.check-intakes no longer require cooling.fan-gate-trips,
laser.pause-resume-lid-cancel no longer requires motion.lid-cancel-home,
cooling.fail-tier-stop no longer requires cooling.crash-watch-plumbing,
and exthost.armed-freeze no longer requires cooling.floor-and-warm-up.
cooling.py's docstring now describes the four cooling tests that stay.
The helpers only the departed tests used go with them, and laser.py
imports only the motion helpers it still calls.

Proof: the coverage lint on the tree manifest reports 0 uncovered paths
and 0 host-tested row problems across 66 tests; its one failure is
setup.check-lens's src/lensfind.* entry, which clears with the forgectrl
pin. No HOST_TESTED row was needed. The catalog validates; pyflakes
reports only the two unused locals that were there before
(cooling.fail-tier-stop's t0, update.py's plain);
test_cooling_order, test_cooling_suite, test_image, test_laser_dwell,
test_camera_home_return, test_setup_dark, test_setup_burns,
test_kernel_suite and test_cloud_suite pass.

Acceptance cost: the catalog goes from 78 tests to 66. Implementation
hashes move for cooling.aa-offset-calibrate, cooling.flow-verify,
cooling.flow-under-load and cooling.fail-tier-stop (cooling.py's shared
text); motion.pacing, motion.jog-roundtrip, motion.microstep-modes,
motion.liveness-probe, motion.cancel-abort, motion.deadman,
motion.step-timing-under-load, motion.release, motion.job,
laser.armed-kill, laser.emission-witness, laser.pause-resume-lid-cancel
and setup.check-envelope (motion.py's and laser.py's shared text, and the
modules that import motion.py); image.health, setup.check-airflow,
setup.check-intakes and exthost.armed-freeze (their own text).
2026-10-14 19:33:43 -04:00

866 lines
45 KiB
Python

# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""laser.* - LIVE laser tests, ported from `scripts/bench/live_fire_drills.py`.
The live tests here emit. The page starts a firing one only with the
operator's eye-protection / fire-watch / exhaust acknowledgment; the
test then prompts for the scrap and the button, streams a small job
through the controller, and the machine fires only after the operator
presses the physical arm button - nothing here defeats that gate, and
forgetest never touches the laser latch. One emission per test; on abort
or error the job is soft-reset (`^X`: controlled stop, latch relocked).
The witnesses are forgectrl's `/status` (`laser.emission_samples` = the
kernel's LASER_ON sample count, `hv_current_raw`, `lid_ir`),
`/cool/status` (`armed`), the head's beam detector
(`head/beam_detect_analog`, the tube's own emission seen from the head:
a few hundred counts over its idle level while the beam is on, and
`beam_detect_digital` asserted), and the button LEDs (lit while the arm
waits or the window is open, dark after the disarm) - all sampled at
~8 Hz through the arm -> fire -> disarm lifecycle. The one thing the
operator still judges by eye is the mark the emission witness leaves,
once per campaign: the calibration of the sensor witnesses.
"""
import time
from ..catalog import test
from .. import hw
from ..runner import Failed
from .motion import kernel_start, check_kernel_returned, Watch
_LASER_COVERS = [("grblhal-glowforge", "src/**"), ("kernel-module-glowforge", "**"),
("forgectrl", "src/super.c"), ("forgectrl", "src/cool.c"),
("forgectrl", "src/status.c"), ("forgectrl", "src/main.c")]
ARM_CUE = ("LIVE FIRE. Eye protection on. Exhaust on. Extinguisher in reach. "
"Put scrap under the head with %s clear, and close the lid.")
# The beam detector's emission signature: this much over its pre-fire
# level while the beam is on (bench: ~1834 idle, 2600-2890 at S300/S400).
BEAM_DELTA_MIN = 300
def sample(ctx):
"""One combined /status + /cool/status sample, or None on error."""
fc = ctx.forgectrl
try:
st1, st = fc.get("/status")
st2, cs = fc.get("/cool/status")
except hw.HwError:
return None
if st1 != 200 or st2 != 200 or not isinstance(st, dict) or not isinstance(cs, dict):
return None
return {
"t": time.time(),
"kstate": st.get("state"),
"emission": (st.get("laser") or {}).get("emission_samples"),
"pgood": (st.get("laser") or {}).get("pgood_samples"),
"faults": st.get("faults"),
"hv": st.get("hv_current_raw"),
"ir": st.get("lid_ir"),
"homed": st.get("homed"),
"armed": cs.get("armed"),
"phase": cs.get("phase"),
"fire_watch": cs.get("fire_watch"),
"verdict": cs.get("verdict"),
"beam": hw.sysfs_int("head/beam_detect_analog"),
"beam_d": hw.sysfs_int("head/beam_detect_digital"),
"exhaust": (st.get("fans") or {}).get("exhaust"),
"button_lit": hw.button_lit(),
"hv_enable": (st.get("switches") or {}).get("hv_enable"),
"button_latch": hw.sysfs_int("cnc/button_latch"),
# the whole readback word (bit 0 LASER_ON, 1 LASER_ENABLE, 2 button
# latch, 3 laser latch, 4 interlock latch reset, 5 charge pump alive)
# and the debounced switches, so a latch that sets mid-job says
# which of its two inputs did it
"il": hw.sysfs_int("cnc/interlock_circuit"),
"lid": (st.get("switches") or {}).get("lid"),
"button": (st.get("switches") or {}).get("button"),
"locked": st.get("laser_locked"),
"gstate": None,
"msgs": "",
}
TRAIL_FIELDS = ("t", "gstate", "kstate", "emission", "armed", "phase", "il", "button_latch",
"locked", "lid", "button", "hv_enable", "beam", "hv", "msgs")
# The cooling phases that run the fans at their idle duty: the engine has
# no run session, so no cut airflow and no flow interrogation. Emission
# in one of these is the beam on the work with idle airflow.
IDLE_AIRFLOW_PHASES = ("idle", "warm-up")
def trail(samples):
"""The sample trail as rows for the evidence: relative seconds and the
fields TRAIL_FIELDS names, so a run's timeline can be read back
without a rerun."""
if not samples:
return []
t0 = samples[0]["t"]
rows = []
for s in samples:
row = [round(s["t"] - t0, 2)]
for f in TRAIL_FIELDS[1:]:
v = s.get(f)
row.append(v if v != "" else None)
rows.append(row)
return rows
def beam_witness(ctx, ev, samples, base, tag=""):
"""The head's beam detector over a sample trail: its peak against the
pre-fire level, and whether the digital flag asserted. Recorded, and
judged where the trail carried emission."""
beams = [s["beam"] for s in samples if s.get("beam") is not None]
base_beam = base.get("beam") if base else None
peak = max(beams) if beams else None
digital = any(s.get("beam_d") for s in samples)
key = ("beam" + ("_" + tag if tag else ""))
ev[key] = {"idle": base_beam, "peak": peak, "delta": (peak - base_beam) if (peak is not None and
base_beam is not None) else None,
"digital_seen": digital, "samples": len(beams)}
ctx.log("beam detector%s: idle %s, peak %s, digital asserted %s", (" [%s]" % tag) if tag else "",
base_beam, peak, digital)
return ev[key]
def judge_beam(ctx, b, what="the burn"):
ctx.check(b["samples"] > 0, "the beam detector was not readable during %s", what)
ctx.check(b["delta"] is not None and b["delta"] >= BEAM_DELTA_MIN,
"the head's beam detector did not see %s (idle %s, peak %s; the tube did not lase, "
"or the detector is not reading)", what, b["idle"], b["peak"])
def check_button_dark(ctx, ev, key="button_dark", settle_s=5.0):
"""The button commanded dark (the LEDs' target level) within a few
seconds: the controller writes it a moment after the cancel or the
disarm it reports."""
dt = ctx.wait_for(lambda: hw.button_lit() is False, settle_s)
lit = hw.button_lit()
ev[key] = lit
ctx.check(lit is not None, "the button LEDs are not readable")
ctx.check(lit is False, "the button is still lit %.0f s after it should have gone dark", settle_s)
if dt:
ctx.log("button dark after %.1f s", dt)
def prepare(ctx, g):
"""Guarantee a clean Idle start: clear a latched Door hold or an Alarm."""
st = g.status_report()
if "Door" in st["state"] or "Hold" in st["state"]:
g.realtime(0x18)
ctx.sleep(2)
g.drain()
st = g.status_report()
if "Alarm" in st["state"]:
ctx.log("unlock: %s", g.command("$X"))
st = g.status_report()
ctx.log("connect: %s", st["state"])
ctx.check(st["state"].startswith("Idle"), "controller is %s, expected Idle", st["state"])
return st
def stream(g, lines):
for ln in lines:
g.send_raw((ln + "\n").encode())
MARK_JOB = ["G91", "G21", "M4", "S400",
"G1 X40 F200", "G1 Y40 F200", "G1 X-40 F200", "G1 Y-40 F200",
"M5", "G90", "M2"]
# The same square at constant power. M4 scales power with speed, which hides
# what a restart does to the cut; M3 puts it on the scrap where the operator
# can see it - the deeper spot where a resumed cut accelerates from zero.
MARK_JOB_M3 = ["M3" if ln == "M4" else ln for ln in MARK_JOB]
def arm_and_fire(ctx, g, room="40 mm +X and +Y", job=None, timeout=240):
"""The arm cue, the job, and the wait for the emission witness - the
prologue every live test shares. Returns the first sample with emission,
or soft-resets and fails: no emission means the arm was refused or the
button was never pressed."""
ctx.ready(ARM_CUE % room)
stream(g, job or MARK_JOB)
ctx.arm_press()
t0 = time.time()
while time.time() - t0 < timeout:
ctx.checkpoint()
smp = sample(ctx)
if smp and smp["emission"] and smp["emission"] > 0:
ctx.clear_notice()
return smp
time.sleep(0.15)
ctx.clear_notice()
g.realtime(0x18)
raise Failed("no emission seen within %d s (arm refused, or no button press)" % timeout)
def wait_grbl_port(ctx, timeout=30):
"""The controller's Grbl listener is accepting again. /mode reporting the
process running is not the same thing: the supervisor has spawned it, but
the socket may not be bound yet, and a bare connect would fail the test on
a race rather than on the behavior it is about."""
end = time.time() + timeout
while time.time() < end:
ctx.checkpoint()
try:
with ctx.grbl():
return True
except OSError:
time.sleep(1)
return False
def kill_trail(ctx, t0, seconds=5.0):
"""Sample emission / kernel state / armed for `seconds` after a kill."""
trail = []
for _ in range(int(seconds / 0.12)):
s = sample(ctx)
if s:
trail.append((round(time.time() - t0, 2), s["emission"], s["kstate"], s["armed"]))
time.sleep(0.12)
return trail
def kill_fast_trail(t0, seconds=1.5):
"""The latch and the kernel state read from sysfs every few
milliseconds after a kill: the supervisor's own reaction, which the
HTTP samples of kill_trail cannot resolve. A controller death is a
signal to the supervisor, not a poll, so the relock lands within
milliseconds; the kernel leaves running once its stop ramp is done."""
locked_at = stopped_at = None
end = t0 + seconds
while time.time() < end and (locked_at is None or stopped_at is None):
now = time.time() - t0
ilk = hw.sysfs_int("cnc/interlock_circuit")
if locked_at is None and ilk is not None and ilk & IL_LASER_LATCH:
locked_at = round(now, 3)
st = hw.sysfs_read("cnc/state")
if stopped_at is None and st is not None and st != "running":
stopped_at = round(now, 3)
time.sleep(0.005)
return {"latch_locked_at_s": locked_at, "kernel_stopped_at_s": stopped_at}
def judge_kill(ctx, trail, what):
"""(first zero, tail stayed zero, kernel stopped running) from a trail."""
for t in trail:
ctx.log(" post-%s %s", what, t)
zero_at = next((t for t, e, _, _ in trail if e == 0), None)
tail_zero = all(e == 0 for _, e, _, _ in trail[-16:])
not_running = all(k != "running" for _, _, k, _ in trail[-16:])
ctx.log("emission first 0 at +%s s; last 2 s all zero: %s; kernel not running: %s",
zero_at, tail_zero, not_running)
return zero_at, tail_zero, not_running
IL_BUTTON_LATCH = 4 # cnc/interlock_circuit bit 2
IL_LASER_LATCH = 8 # bit 3: the SoC lock line, 1 = locked
def dwell_gap(samples):
"""The dwell-gap verdict over a sample trail. The button latch has
two set inputs, the lid and the SoC lock, so while the lock line
reads released the latch must read clear: from the first sample
with emission (the press has cleared it by then) through every
sample whose readback word shows the laser latch unlocked, which
spans the kernel-run gap of the dwell and ends at the relock. Both
bits come from the same word, so no lagging flag can misplace the
window: the engine's armed flag follows the controller's next report,
and the emission counter latches once per second and stays nonzero
about two seconds past the relock (three runs on the bench failed on
windows drawn from those). HV_ENABLE's dip in the gap and its return
with emission after it are judged from the first emission on."""
first = next((i for i, s in enumerate(samples) if s["emission"]), None)
span = ([s for s in samples[first:] if s.get("il") is not None and not (s["il"] & IL_LASER_LATCH)]
if first is not None else [])
latch = [1 if (s["il"] & IL_BUTTON_LATCH) else 0 for s in span]
set_at = [i for i, v in enumerate(latch) if v]
hv_en = [s.get("hv_enable") for s in samples[first:]] if first is not None else []
dip = next((i for i, v in enumerate(hv_en) if v is False), None)
back_lit = dip is not None and any(
s.get("hv_enable") and s["emission"] for s in samples[first + dip:])
return {"button_latch_unlocked_max": max(latch) if latch else None,
"button_latch_unlocked_samples": len(latch),
"button_latch_set_at": set_at[:8],
"hv_enable_dipped": dip is not None, "hv_enable_back_lit": back_lit}
def run_and_sample(ctx, g, job, sample_hz=8, overall_timeout=200):
"""Stream the job; sample forgectrl through arm -> fire -> disarm.
Completes on: emission seen then Idle > 3 s; or armed then disarmed
with no fire, Idle > 3 s, > 15 s in; or the overall timeout."""
samples = []
period = 1.0 / sample_hz
s0 = sample(ctx)
if s0:
samples.append(s0)
stream(g, job)
t_start = time.time()
next_t = t_start
seen_emission = seen_armed = disarmed_now = False
idle_since = None
while time.time() - t_start < overall_timeout:
ctx.checkpoint()
now = time.time()
if now >= next_t:
smp = sample(ctx)
if smp:
samples.append(smp)
if smp["emission"] and smp["emission"] > 0:
seen_emission = True
if smp["armed"]:
seen_armed = True
disarmed_now = seen_armed and not smp["armed"]
next_t = now + period
st = g.status_report()["state"]
if samples:
# the controller's state and whatever it said since the last
# sample, next to the sample they belong to
samples[-1]["gstate"] = st
said = " ".join(l.strip() for l in g.drain().splitlines()
if l.strip() and l.strip() != "ok")
if said:
samples[-1]["msgs"] = (samples[-1]["msgs"] + " " + said).strip()[:400]
if st.startswith("Idle"):
if idle_since is None:
idle_since = now
idle_for = now - idle_since
if seen_emission and idle_for > 3.0:
break
if disarmed_now and (now - t_start) > 15 and idle_for > 3.0:
break
else:
idle_since = None
time.sleep(0.05)
return samples
class LiveJob:
"""Leaves the laser commanded off on every exit; a soft reset on
abort/failure stops motion, relocks, and closes the armed window."""
def __init__(self, ctx, g):
self.ctx, self.g = ctx, g
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
try:
if exc_type is not None:
self.ctx.log("stopping the job: soft reset (%s)", exc_type.__name__)
self.g.realtime(0x18)
time.sleep(1)
self.g.command("M5", timeout=1)
except Exception: # noqa: BLE001 - best effort on the way out
pass
return False
def wait_disarm(ctx, timeout):
t0 = time.time()
while time.time() - t0 < timeout:
ctx.checkpoint()
s = sample(ctx)
if s and not s["armed"]:
return time.time() - t0
time.sleep(0.5)
return None
@test("laser.emission-witness", title="Live emission witness (S400 vector mark) and job-based disarm",
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"],
actions=["button"],
steps=["Scrap under the head with 20 mm of free +X and +Y travel; lid closed; exhaust on.",
"Press the physical button when it lights white (the arm).",
"At the end, confirm the square the laser marked, all four sides: the one judgment "
"by eye in the campaign, the calibration of the sensor witnesses."],
description="A 20 mm square outline at S400/F600 in dynamic laser mode with a 2 s dwell "
"(G4) between its second and third sides: emission_samples (the kernel's "
"LASER_ON sample count) goes nonzero during the fire window and returns to 0 "
"at Idle, HV current rises during the burn, the head's beam detector sees the "
"beam, and the armed window is observed. Across the dwell the kernel run ends "
"and restarts: HV_ENABLE drops with the charge-pump watchdog and is back for "
"the third side, while the hardware button latch stays clear (cnc/button_latch "
"0 in every armed sample), so the second half of the square marks without a "
"second press. The square ends at M5 with the window still open; once the "
"LASER_ON count reads 0 (the dark point), a rapid runs 20 mm out over the "
"first side and back, then the program ends. From the dark point until the "
"window closes, every sample reads no emission and the HV current at its idle "
"reading, and the head's reports place the rapid inside that stretch: LASER_ON "
"stays off with FIRE clear while the chain can fire. The M2 program end "
"disarms promptly (job-based, not the 60 s idle grace) and the button goes "
"dark. The operator confirms all four sides.")
def emission_witness(ctx):
ev = ctx.evidence
# The rapid after the M5 (G91 is modal from the square): out over the
# first side and back, then the program end.
rapid_tail = ["G0 X20", "G4 P2.5", "G0 X-20", "G4 P0.5", "G90", "M2"]
def sample_to_disarm(g, timeout=20.0, sample_hz=8):
"""Sample until the armed window closes, or the timeout, with the
controller's state and machine X beside each sample ("x"). A G4
dwell reports Idle, so Idle cannot end this; the window closing at
the program end does."""
samples = []
period = 1.0 / sample_hz
t0 = time.time()
while time.time() - t0 < timeout:
ctx.checkpoint()
smp = sample(ctx)
rep = g.status_report()
if smp:
smp["gstate"] = rep.get("state")
smp["x"] = (rep.get("MPos") or (None,))[0]
samples.append(smp)
if not smp["armed"]:
break
time.sleep(period)
return samples
with ctx.grbl() as g, LiveJob(ctx, g):
prepare(ctx, g)
base = sample(ctx)
ctx.check(base, "forgectrl /status or /cool/status unavailable")
ev["pre_fire"] = base
ctx.log("pre-fire: emission=%s hv=%s armed=%s verdict=%s", base["emission"], base["hv"],
base["armed"], base["verdict"])
ctx.check(not base["emission"], "emission_samples nonzero before the job (%s)", base["emission"])
ctx.ready(ARM_CUE % "20 mm +X and +Y")
# The square ends at M5 with no program end: the window stays open
# for the rapid that follows.
job = ["G91", "G21", "M4", "S400",
"G1 X20 F600", "G1 Y20 F600", "G4 P2", "G1 X-20 F600", "G1 Y-20 F600",
"M5"]
ctx.arm_press()
try:
cut = run_and_sample(ctx, g, job)
# The dark point: the M5 has run, the LASER_ON count reads 0, and
# the window is still open. The rapid is sent only after it, so
# the dark stretch covers the rapid by construction, and the
# mid-square dwell's own zero cannot be taken for it.
ctx.wait_for(lambda: (sample(ctx) or {}).get("emission") == 0, 10, poll=0.125)
dark = sample(ctx)
ctx.check(dark and dark["emission"] == 0,
"the LASER_ON count did not return to 0 after the M5 (%s)", dark and dark["emission"])
ctx.check(dark["armed"], "the armed window closed after the M5, before the rapid "
"(phase %s): the rapid would not run under it", dark["phase"])
# The engine's armed flag can still read true after the latch has
# locked, and a dark reading with the latch locked is the unarmed
# case this sample is here to exclude: the SoC lock line must read
# released.
ctx.check(dark["locked"] is False, "the laser latch is locked at the dark point "
"(laser_locked %s): the rapid would run unarmed", dark["locked"])
rep = g.status_report()
dark["gstate"] = rep.get("state")
dark["x"] = (rep.get("MPos") or (None,))[0]
ctx.check(dark["x"] is not None, "the controller reports no machine position: %s", rep)
stream(g, rapid_tail)
tail = sample_to_disarm(g)
finally:
ctx.clear_notice()
samples = cut + [dark] + tail
ev["trail_fields"] = list(TRAIL_FIELDS)
ev["trail"] = trail(samples)
beam = beam_witness(ctx, ev, cut, base)
emis = [s["emission"] for s in cut if s["emission"] is not None]
peak = max(emis) if emis else 0
end = next((s["emission"] for s in reversed(samples) if s["emission"] is not None), None)
hv = [s["hv"] for s in cut if s["hv"] is not None]
ir_peak = [0, 0, 0, 0]
for s in samples:
if s["ir"] and len(s["ir"]) == 4:
for i in range(4):
ir_peak[i] = max(ir_peak[i], s["ir"][i])
armed_seen = any(s["armed"] for s in samples)
# The beam may only come on inside a run session. Until the engine
# has taken the armed window it is still holding the fans at their
# idle duty, and the verdict standing on file is the one it
# computed for the idle session before the arm.
idle_fire = [s for s in samples
if s["emission"] and s["phase"] in IDLE_AIRFLOW_PHASES]
ev.update({"samples": len(samples), "emission_peak": peak, "emission_end": end,
"hv_min": min(hv) if hv else None, "hv_max": max(hv) if hv else None,
"lid_ir_peak": ir_peak, "armed_seen": armed_seen,
"idle_airflow_fire": [(round(s["t"] - samples[0]["t"], 2),
s["phase"], s["emission"]) for s in idle_fire],
"pgood_peak": max((s["pgood"] for s in samples if s["pgood"] is not None), default=None)})
ctx.log("emission_samples peak=%s end=%s; hv %s..%s; lid_ir peak %s; armed seen %s",
peak, end, ev["hv_min"], ev["hv_max"], ir_peak, armed_seen)
# The dwell: a kernel-run gap inside the unlocked window. The button
# latch has no input from the charge-pump watchdog (its SET inputs
# are the lid and the SoC lock), so it must read clear from the
# first emission until the relock, across the gap, while HV_ENABLE
# drops with the watchdog and returns for the third side.
gap = dwell_gap(samples)
ev.update(gap)
ctx.log("dwell gap: button latch through the unlocked window max=%s over %d samples (set at %s); "
"HV_ENABLE dipped=%s, back with emission after it=%s", gap["button_latch_unlocked_max"],
gap["button_latch_unlocked_samples"], gap["button_latch_set_at"], gap["hv_enable_dipped"],
gap["hv_enable_back_lit"])
# The first fire waits for the airflow: the exhaust reads at or
# above its floor on the first sample that shows emission.
floor = (ctx.forgectrl.settings() or {}).get("cool_tach_exhaust_min_rpm") or ""
try:
floor = float(floor) if floor else 6400.0
except ValueError:
floor = 6400.0
first_lit = next((s for s in samples if s["emission"]), None)
ev["exhaust_at_first_fire"] = first_lit["exhaust"] if first_lit else None
ev["exhaust_floor"] = floor
ctx.log("exhaust at the first emission sample: %s rpm (floor %.0f)",
ev["exhaust_at_first_fire"], floor)
# The rapid after the M5, with the window open and FIRE clear: every
# sample from the dark point until the window closes reads no emission
# and the HV current at its idle reading. The head's reports place the
# rapid inside that stretch: out to +20 mm, then back at the start.
stretch = [dark] + tail
x0 = dark["x"]
i_out = next((i for i, s in enumerate(stretch) if s.get("x") is not None and s["x"] - x0 >= 19.5), None)
i_back = (next((i for i in range(i_out + 1, len(stretch))
if stretch[i].get("x") is not None and abs(stretch[i]["x"] - x0) < 0.05), None)
if i_out is not None else None)
closed = stretch[-1] if not stretch[-1]["armed"] else None
relit = [s for s in stretch if s["emission"]]
hv_dark = max((s["hv"] for s in stretch if s["hv"] is not None), default=0)
dt = (closed["t"] - stretch[i_back]["t"]) if (closed and i_back is not None) else None
ev["rapid"] = {"samples": len(stretch), "span_s": round(stretch[-1]["t"] - dark["t"], 2),
"x_start": x0, "x_max": max((s["x"] for s in stretch if s.get("x") is not None), default=None),
"out_at_s": round(stretch[i_out]["t"] - dark["t"], 2) if i_out is not None else None,
"back_at_s": round(stretch[i_back]["t"] - dark["t"], 2) if i_back is not None else None,
"relit": [(round(s["t"] - dark["t"], 2), s["emission"]) for s in relit][:8],
"hv_max": hv_dark, "locked_at_dark": dark["locked"],
"locked_at_out": stretch[i_out]["locked"] if i_out is not None else None}
ev["disarm_after_idle_s"] = round(dt, 1) if dt is not None else None
ctx.log("the rapid after the M5: %d samples over %.1f s from the dark point, out at +%s s, back at +%s s, "
"%d with emission, HV max %s; the window closed %s s after the head was back", len(stretch),
ev["rapid"]["span_s"], ev["rapid"]["out_at_s"], ev["rapid"]["back_at_s"], len(relit), hv_dark,
ev["disarm_after_idle_s"])
ctx.check(armed_seen, "the armed window was never observed (arm refused, or no button press)")
ctx.check(peak > 0, "no emission witnessed on the square (emission_samples stayed 0)")
first_idle_fire = ev["idle_airflow_fire"][0] if idle_fire else (None, None, None)
ctx.check(not idle_fire,
"the laser fired while the cooling engine had no run session, so the fans were at "
"their idle duty (phase %s at t=%s s, emission %s)",
first_idle_fire[1], first_idle_fire[0], first_idle_fire[2])
ctx.check(end == 0, "emission_samples did not return to 0 at Idle (%s)", end)
ctx.check(ev["exhaust_at_first_fire"] is not None and ev["exhaust_at_first_fire"] >= floor,
"the beam started before the exhaust reached its floor (%s rpm at the first "
"emission sample, floor %.0f)", ev["exhaust_at_first_fire"], floor)
ctx.check(hv and max(hv) > min(hv), "HV current did not rise during the burn (%s..%s)",
ev["hv_min"], ev["hv_max"])
ctx.check(i_out is not None and i_back is not None,
"the rapid did not run inside the dark stretch: out at %s, back at %s (X from %s, max %s)",
ev["rapid"]["out_at_s"], ev["rapid"]["back_at_s"], x0, ev["rapid"]["x_max"])
ctx.check(stretch[i_out]["locked"] is False, "the laser latch was locked with the head at +20 mm "
"(laser_locked %s): the rapid ran unarmed", stretch[i_out]["locked"])
ctx.check(not relit, "the laser emitted after the M5: %d samples, first at +%s s (emission_samples %s) "
"- a rapid after M5 fired", len(relit), relit[0]["t"] - dark["t"] if relit else None,
relit[0]["emission"] if relit else None)
ctx.check(hv_dark <= HV_DARK_MAX, "HV current %s after the M5 (idle reads ~0): the discharge ran "
"through the rapid", hv_dark)
ctx.check(dt is not None and dt < 10.0,
"the M2 job did not disarm promptly (%s s after the head was back; the idle grace is ~60 s)",
ev["disarm_after_idle_s"])
ctx.check(gap["button_latch_unlocked_samples"] and gap["button_latch_unlocked_max"] == 0,
"the hardware button latch read SET while the laser latch was unlocked (max %s over %d "
"samples, at %s)", gap["button_latch_unlocked_max"], gap["button_latch_unlocked_samples"],
gap["button_latch_set_at"])
ctx.check(ev["hv_enable_dipped"], "HV_ENABLE never dropped across the 2 s dwell (the kernel run did not end)")
ctx.check(gap["hv_enable_back_lit"],
"HV_ENABLE did not return with emission after the dwell (the third side ran dark)")
judge_beam(ctx, beam)
check_button_dark(ctx, ev)
ctx.confirm("Did the laser mark a 20 mm square outline on the scrap, all four sides?")
ctx.log("PASS: emission peak %s -> 0, HV %s..%s, beam +%s; the rapid after the M5 dark (HV max %s); "
"disarmed %.1f s after the head was back, button dark, mark confirmed", peak, ev["hv_min"],
ev["hv_max"], beam["delta"], hv_dark, dt)
HV_DARK_MAX = 20 # hv_current_raw reads 0 with the tube off; hundreds under fire
@test("laser.armed-kill", title="Armed kill mid-fire: the expected stop, then a SIGKILL",
subsystem="laser", kind="live", mode="grbl", est_min=6,
covers=_LASER_COVERS + [("forgectrl", "src/main.c")],
requires=["laser.emission-witness", "motion.deadman"], actions=["button"],
steps=["Scrap under the head with 40 mm of free +X and +Y travel; lid closed; exhaust on.",
"Press the physical button when it lights white - twice over the test, once per burn. "
"Each burn is cut short by the test; the sample trails judge the stops."],
description="Both ways an armed job is killed, on one setup. Expected: mid-burn "
"POST /controller/stop - the supervisor writes cnc/stop and relocks before the "
"SIGTERM, so emission drops within 2.5 s and stays 0, the kernel is not running, "
"and the restart is a separate operator-judged step. Unexpected: mid-burn SIGKILL "
"of the controller - the supervisor's exit safing must end the fire tail inside "
"the ring's in-flight window, leave the latch locked, and respawn the controller. "
"The death is a signal to the supervisor, not a poll: the latch relocks within "
"300 ms of the kill and the kernel is out of running within a second.")
def armed_kill(ctx):
ev = ctx.evidence
fc = ctx.forgectrl
# -- 1. the expected stop -------------------------------------------------
with ctx.grbl() as g, LiveJob(ctx, g):
prepare(ctx, g)
smp = arm_and_fire(ctx, g)
ctx.log("emission live (%s) - stopping the controller NOW", smp["emission"])
t_stop = time.time()
code, body = fc.post("/controller/stop")
post_dt = time.time() - t_stop
ctx.log("POST /controller/stop -> %s %s (%.2f s)", code, body, post_dt)
trail = kill_trail(ctx, t_stop)
zero_at, tail_zero, not_running = judge_kill(ctx, trail, "stop")
st_mode, mode = fc.get("/mode")
ev["expected"] = {"post_status": code, "post_s": round(post_dt, 2), "zero_at_s": zero_at,
"tail_zero": tail_zero, "kernel_not_running": not_running,
"mode_after_stop": mode, "trail": trail}
ctx.check(code == 200, "POST /controller/stop -> %s", code)
ctx.check(zero_at is not None and zero_at < 2.5,
"emission did not drop within 2.5 s of the stop (first 0 at %s)", zero_at)
ctx.check(tail_zero, "emission returned after the stop")
ctx.check(not_running, "the kernel was still running after the stop")
ev["beam_at_fire_stop"] = smp.get("beam")
ctx.log("the controller is stopped (supervision held); restarting it (no motion, no laser)")
st, body = fc.post("/controller/start")
ctx.log("POST /controller/start -> %s %s", st, body)
ctx.check(st == 200, "POST /controller/start -> %s", st)
ctx.sleep(6)
st, mode = fc.get("/mode")
ev["mode_after_start"] = mode
ctx.log("/mode after start: %s", mode)
ctx.check(isinstance(mode, dict) and mode.get("controller") == "running",
"controller not running after the restart: %s", mode)
ctx.log("expected stop PASS: returned in %.2f s, emission 0 at +%s s, controller restarted",
post_dt, zero_at)
# -- 2. the SIGKILL -------------------------------------------------------
# The pid is the RESTARTED controller's, not the one phase 1 stopped.
import os as _os
import signal as _signal
st, m0 = fc.get("/mode")
ctx.check(st == 200 and isinstance(m0, dict) and m0.get("controller") == "running",
"controller not running before the kill: %s", m0)
pid = m0.get("pid")
ctx.check(wait_grbl_port(ctx), "the restarted controller never accepted a Grbl connection")
with ctx.grbl() as g, LiveJob(ctx, g):
prepare(ctx, g)
smp = arm_and_fire(ctx, g)
ctx.log("emission live (%s) - SIGKILL controller pid %s NOW", smp["emission"], pid)
t_kill = time.time()
_os.kill(pid, _signal.SIGKILL)
fast = kill_fast_trail(t_kill)
trail = kill_trail(ctx, t_kill)
ctx.log("after the kill (sysfs): %s", fast)
zero_at, tail_zero, not_running = judge_kill(ctx, trail, "kill")
ilk = hw.sysfs_int("cnc/interlock_circuit")
locked = ilk is not None and bool(ilk & (1 << 3))
ev["sigkill"] = {"pid": pid, "zero_at_s": zero_at, "tail_zero": tail_zero,
"kernel_not_running": not_running, "latch_locked": locked, "trail": trail,
"fast": fast}
ctx.log("latch locked after the kill: %s", locked)
ctx.check(fast["latch_locked_at_s"] is not None and fast["latch_locked_at_s"] < 0.3,
"the latch was not locked within 300 ms of the kill: %s", fast)
ctx.check(fast["kernel_stopped_at_s"] is not None and fast["kernel_stopped_at_s"] < 1.0,
"the kernel was still running 1 s after the kill: %s", fast)
ctx.check(zero_at is not None and zero_at < 2.5,
"emission did not drop within 2.5 s of the kill (first 0 at %s)", zero_at)
ctx.check(tail_zero, "emission returned after the kill")
ctx.check(not_running, "the kernel was still running after the kill")
ctx.check(locked, "latch not locked after the kill")
t0 = time.time()
m1 = None
while time.time() - t0 < 60:
st, m1 = fc.get("/mode")
if isinstance(m1, dict) and m1.get("controller") == "running" and m1.get("pid") != pid:
break
ctx.sleep(1)
ev["mode_after_kill"] = m1
ctx.log("/mode after the kill: %s", m1)
ctx.check(m1 and m1.get("controller") == "running" and m1.get("pid") != pid,
"supervisor did not respawn the controller: %s", m1)
# The respawned controller starts locked and stays locked: a latch it
# did not unlock is never relit by a run start, and no arm has asked.
ctx.check(wait_grbl_port(ctx), "the respawned controller never accepted a Grbl connection")
ctx.sleep(2)
ilk = hw.sysfs_int("cnc/interlock_circuit")
ev["latch_locked_after_respawn"] = ilk is not None and bool(ilk & IL_LASER_LATCH)
ctx.check(ev["latch_locked_after_respawn"],
"the respawned controller unlocked the latch with no arm (interlock_circuit=%s)", ilk)
ev["beam_at_fire_kill"] = smp.get("beam")
check_button_dark(ctx, ev)
ctx.log("PASS: expected stop 0 at +%s s and SIGKILL 0 at +%s s, latch locked, controller "
"respawned with the latch still locked, button dark", ev["expected"]["zero_at_s"], zero_at)
@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", 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"],
actions=["button", "lid"],
steps=["Scrap under the head with 40 mm of free +X and +Y travel; lid closed; exhaust on.",
"Press the physical button when it lights white (arm). Once the cut is under way the "
"test asks for a press (pause), then another (resume), then for the lid: open it and "
"leave it open until the head has come back, then close it.",
"A pause longer than the armed window's idle grace (about a minute) closes the window; "
"the resume then lights the button again and waits for a press."],
description="The machine's own controls during one armed burn, in the order the factory uses "
"them. Press: the job feed-holds, emission stops, and the latch stays UNLOCKED "
"with the armed window open - a pause is not a cancel. Press again: the cut "
"resumes from where it stopped, lit from the first step (a pause is a sharp corner "
"in time; the cut is M3 so the corner dose is there to look at). Lid: emission "
"stops in hardware, the job is canceled "
"with the reason reported, the controller resets with the position kept and no "
"alarm, the armed window closes and the kernel latch relocks, the hardware button "
"latch reads SET, and the head returns to the job start with the lid still open.")
def pause_resume_lid_cancel(ctx):
ev = ctx.evidence
with ctx.grbl() as g, LiveJob(ctx, g):
prepare(ctx, g)
k0 = kernel_start(ctx)
start = g.status_report()["MPos"]
ev["start"] = start
ev["kernel_start"] = k0
base_beam = sample(ctx)
beams = [] # (analog, digital) through the cut
smp = arm_and_fire(ctx, g, job=MARK_JOB_M3)
ev["emission_running"] = smp["emission"]
beams.append((smp.get("beam"), smp.get("beam_d")))
ctx.log("emission live (%s) - asking the operator to pause", smp["emission"])
# -- the button pauses ------------------------------------------------
g.drain() # the message window opens here
w = Watch(g)
ctx.act("button", "press", text="The laser is cutting: the press pauses it.",
until=w.in_state("Hold"), timeout=20, fail=False)
st, text = w.last, w.text
if st is not None and not st["state"].startswith("Hold"):
st = None
ctx.check(st is not None, "the press did not hold the job (state %s)", g.status_report()["state"])
ev["hold_state"] = st["state"]
ev["pause_message"] = "job paused" in text
paused = []
t1 = time.time()
while time.time() - t1 < 4:
s = sample(ctx)
if s:
paused.append((round(time.time() - t1, 2), s["emission"], s["armed"]))
time.sleep(0.2)
for p in paused:
ctx.log(" paused %s", p)
ev["paused_trail"] = paused
ev["emission_zero_when_paused"] = all(e == 0 for _t, e, _a in paused[-8:]) if paused else None
ev["armed_while_paused"] = paused[-1][2] if paused else None
ilk = hw.sysfs_int("cnc/interlock_circuit")
ev["latch_locked_while_paused"] = ilk is not None and bool(ilk & (1 << 3))
ctx.log("paused: %s, emission 0 = %s, armed = %s, latch locked = %s", ev["hold_state"],
ev["emission_zero_when_paused"], ev["armed_while_paused"],
ev["latch_locked_while_paused"])
ctx.check(ev["emission_zero_when_paused"], "emission did not stop while the job was paused: %s", paused)
ctx.check(ev["armed_while_paused"], "the armed window closed on the pause (a pause is not a cancel)")
ctx.check(not ev["latch_locked_while_paused"],
"the kernel latch relocked on the pause - the resume could not fire without a new arm press")
# -- the button resumes -----------------------------------------------
g.drain()
w = Watch(g)
ctx.act("button", "press", text="The cut is paused: the press resumes it.",
until=w.left_state("Hold"), timeout=40, fail=False)
st, text = w.last, w.text
if st is not None and st["state"].startswith("Hold"):
st = None
ev["resumed_state"] = st["state"] if st else g.status_report()["state"]
ev["resume_message"] = "job resumed" in text
ctx.check(st is not None, "the second press did not resume the job (still held: %s)",
ev["resumed_state"])
ctx.check(st["state"].startswith(("Run", "Idle")),
"the job left the hold into %s, not into motion", st["state"])
back = False
t2 = time.time()
trail = []
while time.time() - t2 < 20:
ctx.checkpoint()
s = sample(ctx)
if s:
trail.append((round(time.time() - t2, 2), s["emission"]))
beams.append((s.get("beam"), s.get("beam_d")))
if s["emission"] and s["emission"] > 0:
back = True
break
time.sleep(0.15)
ev["emission_back_after_s"] = trail[-1][0] if trail else None
ev["resume_trail"] = trail[-6:]
ctx.log("emission after the resume: %s", trail[-6:])
ctx.check(back, "emission did not return after the resume: %s", trail[-6:])
# -- the lid cancels --------------------------------------------------
g.drain()
ctx.act("lid", "open", text="The cut is running again: leave the lid open until the head "
"has come back.", timeout=30)
t_lid = time.time()
lid_trail = []
text = ""
while time.time() - t_lid < 8:
s = sample(ctx)
if s:
lid_trail.append((round(time.time() - t_lid, 2), s["emission"], s["kstate"], s["armed"]))
text += g.drain()
time.sleep(0.12)
for t in lid_trail:
ctx.log(" post-lid %s", t)
ev["messages"] = [ln for ln in text.splitlines() if ln.startswith("[MSG:") or "help]" in ln
or ln.startswith("ALARM")]
ctx.log("controller: %s", ev["messages"])
zero_at = next((t for t, e, _, _ in lid_trail if e == 0), None)
tail_zero = all(e == 0 for _, e, _, _ in lid_trail[-16:])
ev.update({"lid_zero_at_s": zero_at, "lid_tail_zero": tail_zero})
ctx.check(zero_at is not None and zero_at < 3.0,
"emission did not stop after the lid opened (first 0 at %s)", zero_at)
ctx.check(tail_zero, "emission returned after the lid opened")
ctx.check("lid opened - job canceled" in text, "the lid open was not reported as canceling the job")
ctx.check("help]" in text, "no reset banner after the cancel")
ctx.check("ALARM" not in text, "an alarm was raised on the cancel (position should be kept)")
t3 = time.time()
returned = "returned to the job start" in text
while not returned and time.time() - t3 < 30:
ctx.checkpoint()
text += g.drain()
returned = "returned to the job start" in text
time.sleep(0.2)
ev["returned_message"] = returned
ctx.check(returned, "the head did not report returning to the job start")
st = g.status_report()
drift = max(abs(st["MPos"][i] - start[i]) for i in range(2))
ev["drift_mm"] = round(drift, 3)
s = sample(ctx)
ev["armed_after"] = s["armed"] if s else None
ilk = hw.sysfs_int("cnc/interlock_circuit")
ev["latch_locked"] = ilk is not None and bool(ilk & (1 << 3))
ev["button_latch"] = hw.sysfs_int("cnc/button_latch")
ctx.log("returned: drift %.3f mm; armed=%s latch_locked=%s button_latch=%s", drift,
ev["armed_after"], ev["latch_locked"], ev["button_latch"])
ctx.check(drift <= 0.05, "head not back at the job start (drift %.3f mm)", drift)
ctx.check(ctx.forgectrl.wait_idle(10, abort=ctx.aborted), "machine not idle after the return")
check_kernel_returned(ctx, ev, k0)
ctx.check(not ev["armed_after"], "armed window still open after the cancel")
ctx.check(ev["latch_locked"], "kernel latch not locked after the cancel")
ctx.check(ev["button_latch"] == 1, "hardware button latch not SET after the lid open (%s)",
ev["button_latch"])
beam_witness(ctx, ev, [{"beam": b, "beam_d": d} for b, d in beams], base_beam)
judge_beam(ctx, ev["beam"], "the cut")
check_button_dark(ctx, ev)
ctx.act("lid", "close")
ctx.sleep(1)
ctx.log("PASS: button paused the burn (emission 0, armed kept, latch unlocked) and resumed it; "
"the lid then canceled it - emission 0 at +%s s, reset without alarm, returned (drift "
"%.3f mm), button latch SET", zero_at, drift)