mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-28 17:11:15 -07:00
forgectrl's job runner (POST /job, and the dose-curve recorder and the sheet wizards moved onto it) needs a test of a posted program, and nothing in the catalog ran the recorder's start: only its refusal and its status. motion.job posts programs the way a client does, as a multipart form. A program with a $ line and a job with no name are refused with 400, and a job beside the suite's own Grbl client with 409, each with nothing moved. A dark program (out 20 mm, a dwell, back) then plays: /status names job:forgetest as the lease holder of kind sender, GET /job reports it running, and a second job, a port jog, and a settings write are each refused in the job's name. At its end the record says done with every line and the runner's own M2 acknowledged, no discharge and no LASER_ON sample, the kernel's counters saw the 20 mm and are back where they began, and the lease is free. POST /job/abort stops a 40 mm move short, into the controller's alarm state, with the lease free. A job sent into the alarm fails at its first line with nothing moved; the same job with unlock=1 clears the alarm and returns the head. laser.recorder-dark starts the recorder and never presses. While the controller waits at its arm: the recorder holds the machine as a sender, GET /job shows its run (not a posted program's), the floor and the curve read 0 and off, POST /job/abort refuses to stop a run it did not start, a posted job is refused in the recorder's name, and two seconds of witnesses stay dark. POST /curve/stop ends it: stopped before the ladder fired, the lease free, both laser keys as they were. The ladder begins at the controller's X0 Y0, so the head is returned by a posted job. covers: setup.sheet and forgectrl.lease name src/jobrun.*, since the sheet wizards stream through the runner and the recorder's refusal comes from it; setup.sheet names src/lease.* for the hold its jobs take inside the wizard's. Proven on the bench reference, the job runner's daemon hot-deployed over image 20260920130136. motion.job: PASS; the kernel saw 20.000 mm out and 0.000 back, 6 lines with 7 sent and acknowledged, every witness zero, the abort at 9.83 mm of 40 into Alarm, the unlocking job with its $X and its M2 the two lines more. Its first run failed, and found a defect in the daemon and not in the test: the record said done with the head 0.19 mm short of its end, because the controller acknowledges M2 before the pulse engine has played the last of its ring. The runner now waits for the kernel to be idle, and the test passes unchanged. laser.recorder-dark: PASS; the controller reached its arm wait, 16 samples dark, the stop left the controller Idle and the head where it was. setup.sheet: PASS with the operator at the machine. forgectrl.lease: PASS. The unit suite passes (420) with no undefined name. Acceptance. These two tests are the gate for forgectrl's job runner, with setup.sheet for the sheet wizards' side of it.
284 lines
16 KiB
Python
284 lines
16 KiB
Python
# Copyright 2026 514 LLC d/b/a OpenGlow
|
|
# Written by Scott Wiederhold
|
|
# https://community.openglow.org
|
|
# SPDX-License-Identifier: MIT
|
|
|
|
"""setup.sheet - the setup's sheet (the live wizards) in one run,
|
|
driven the way the page drives them: POST /wiz/<id>/start, GET /wiz/dark
|
|
polled, the prompts answered from here, each burn armed by a press, the
|
|
result judged, and every setting a wizard wrote put back as found. One
|
|
piece of wood at least 200 x 150 mm (8 x 6 in) carries the placement,
|
|
the frame, and the five cards. Each wizard re-runs on the real record,
|
|
so a completed one completes again at the same version.
|
|
|
|
The operator's part is one press on the machine at the start (the
|
|
presence check of the ready gate; with the bench actuator up it then
|
|
makes every arm press) and nothing else. The judgments a person makes
|
|
by eye on the sheet (the thinnest line, the faintest rung, the best
|
|
corner, the frame's fit) are answered here with a middle option or the
|
|
witnesses: the test proves each wizard's mechanics and the emission
|
|
witnesses, not the operator's eye, and the settings it writes are
|
|
restored. The page is where the real numbers go in.
|
|
"""
|
|
import re
|
|
|
|
from ..catalog import test
|
|
from .setup_dark import run_check, Restore
|
|
|
|
SHEET_COVERS = [("forgectrl", "src/wizlive.*"), ("forgectrl", "src/wizrun.h"),
|
|
("forgectrl", "src/lens.*"), ("forgectrl", "src/sheet.*"),
|
|
("forgectrl", "src/font_hershey.*"), ("forgectrl", "src/jobstream.*"),
|
|
("forgectrl", "src/jobrun.*"), ("forgectrl", "src/lease.*"),
|
|
("forgectrl", "src/curverec.*"), ("forgectrl", "src/wizdark.*"),
|
|
("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"),
|
|
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*"),
|
|
("forgectrl", "src/super.c"),
|
|
("forgectrl", "src/status.c"), ("forgectrl", "src/cool.c"),
|
|
("forgectrl", "src/accel.*"), ("forgectrl", "src/wizcalc.*"),
|
|
("grblhal-glowforge", "src/glowforge_laser.c"),
|
|
("grblhal-glowforge", "src/glowforge_status.c"),
|
|
("grblhal-glowforge", "src/glowforge_homing.*"),
|
|
("grblhal-glowforge", "src/glowforge_io.*")]
|
|
|
|
# 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
|
|
# sheet), and the keys its result must carry. The arm press is not timed
|
|
# from here: each card opens a press prompt of its own when the button is
|
|
# lit and the tube still dark, and run_check presses on that.
|
|
CARDS = [
|
|
("sheet.frame", {"frame-arm": "Continue", "frame-ok": "Yes"}, ["mark_s", "mark_feed"]),
|
|
("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",
|
|
"focus_range_mm", "stops"]),
|
|
("laser.floor", {"floor-arm": "Continue", "floor-pick": "8"}, ["faintest_density", "floor_density"]),
|
|
("laser.dose-curve", {"dose-arm": "Continue"}, ["points", "curve"]),
|
|
("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"]),
|
|
]
|
|
# 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",
|
|
"laser_floor_density", "laser_dose_curve", "laser_corner_gamma",
|
|
"cool_laser_heat_density", "cool_laser_heat_cw"]
|
|
# The evidence run_check and the witnesses write at the top level, moved
|
|
# under the card's own key once it is done.
|
|
CARD_EVIDENCE = ("started", "elapsed_s", "phases", "log", "error", "result",
|
|
"preview_bytes", "program_lines", "emission")
|
|
|
|
|
|
def preview_ok(ctx, wid):
|
|
"""The page's preview and the program for the wizard are served."""
|
|
fc = ctx.forgectrl
|
|
st, body = fc.get("/wiz/sheet.svg?card=%s" % wid, raw=True)
|
|
ctx.check(st == 200 and body.lstrip().startswith(b"<svg"), "no preview for %s (%s)", wid, st)
|
|
ctx.evidence["preview_bytes"] = len(body)
|
|
st, body = fc.get("/wiz/sheet.gcode?card=%s" % wid, raw=True)
|
|
# The text and the frame are M4 at the mark dose; the patterns bring
|
|
# their own M3 or M4 lines. A program is one with a laser-on command.
|
|
ctx.check(st == 200 and re.search(rb"^M[34] S\d", body or b"", re.M) is not None,
|
|
"no program for %s (%s)", wid, st)
|
|
ctx.evidence["program_lines"] = body.count(b"\n")
|
|
# Every Z the program commands is a height the head reaches in the
|
|
# window the machine holds now: the focus card's own numbers once it
|
|
# has them, and the conservative fallback on a head whose travel is
|
|
# not known yet. The controller refuses a Z outside it with ALARM:2,
|
|
# and it refuses it while the body is still burning, so the card dies
|
|
# where the parser is and not where the bad line is.
|
|
lens = (fc.status() or {}).get("lens") or {}
|
|
lo, hi = lens.get("reach_min"), lens.get("reach_max")
|
|
zs = [float(m) for m in re.findall(rb"^G0 Z(-?[0-9.]+)", body or b"", re.M)]
|
|
ctx.evidence.setdefault("program_z", {})[wid] = {
|
|
"z": zs, "reach": [lo, hi], "stops_found": lens.get("stops_found")}
|
|
ctx.check(lo is not None and hi is not None, "/status carries no lens reach: %s", lens)
|
|
if lo is not None and hi is not None:
|
|
out = [z for z in zs if z < lo - 0.01 or z > hi + 0.01]
|
|
ctx.check(not out, "%s commands Z outside the lens window %.2f..%.2f: %s",
|
|
wid, lo, hi, out)
|
|
|
|
|
|
def emission_ok(ctx, last):
|
|
"""The three witnesses of the burn, from the wizard's result."""
|
|
e = (last.get("result") or {}).get("emission") or {}
|
|
ctx.evidence["emission"] = e
|
|
ctx.check(e.get("hv_max", 0) > 30, "the tube current never rose (%s)", e.get("hv_max"))
|
|
ctx.check(e.get("laser_on_samples", 0) > 0, "the kernel never sampled LASER_ON")
|
|
ctx.check(e.get("thermopile_delta", 0) >= 100, "the thermopile did not see the beam (%s)",
|
|
e.get("thermopile_delta"))
|
|
|
|
|
|
def file_card(ctx, wid):
|
|
"""Move the card's evidence under its own key."""
|
|
ev = ctx.evidence
|
|
ev.setdefault("cards", {})[wid] = {k: ev.pop(k) for k in CARD_EVIDENCE if k in ev}
|
|
|
|
|
|
# The lens settings: the head's numbers, which a fresh machine does not
|
|
# have. Cleared before the cards so the run is a fresh machine's.
|
|
LENS_SETTINGS = CARD_SETTINGS[:3]
|
|
FALLBACK_WINDOW = (10, 12)
|
|
|
|
|
|
def fresh_lens(ctx):
|
|
"""The lens as a fresh machine has it: the head's numbers unknown, so
|
|
the frame runs in the fallback window and the focus card has to find
|
|
the stops and write them before its ladder burns. The machine's own
|
|
numbers come back with the restore at the end of the run."""
|
|
fc = ctx.forgectrl
|
|
for k in LENS_SETTINGS:
|
|
st, _ = fc.post("/settings", params={k: ""})
|
|
ctx.check(st == 200, "clearing %s -> %s", k, st)
|
|
lens = (fc.status() or {}).get("lens") or {}
|
|
ctx.evidence["lens_fresh"] = lens
|
|
ctx.check(lens.get("stops_found") is False and (lens.get("below"), lens.get("above")) == FALLBACK_WINDOW,
|
|
"the lens window is not the fallback after the clear: %s", lens)
|
|
|
|
|
|
def focus_window_ok(ctx):
|
|
"""The window the focus card burned its ladder in is the one it wrote
|
|
before its controller started, and the one the machine holds now: the
|
|
result's window is the stops it found (or the fallback), the settings
|
|
hold that window, every ladder height lies in its reach, and the
|
|
program served now agrees with /status."""
|
|
fc = ctx.forgectrl
|
|
r = ((ctx.evidence.get("cards") or {}).get("laser.focus") or {}).get("result") or {}
|
|
w, stops = r.get("window") or {}, r.get("stops") or {}
|
|
s = fc.settings() or {}
|
|
held = (str(s.get("lens_stop_below_steps")), str(s.get("lens_stop_above_steps")))
|
|
ctx.check(held == (str(w.get("below")), str(w.get("above"))),
|
|
"the settings hold the window %s, the ladder ran in %s/%s", held, w.get("below"), w.get("above"))
|
|
if stops.get("found"):
|
|
ctx.check((w.get("below"), w.get("above")) == (stops.get("below"), stops.get("above")),
|
|
"the ladder's window %s is not the stops found %s", w, stops)
|
|
else:
|
|
ctx.check((w.get("below"), w.get("above")) == FALLBACK_WINDOW,
|
|
"stops not found (%s), but the ladder's window is %s", stops.get("why"), w)
|
|
zs = r.get("ladder_z") or []
|
|
lo, hi = w.get("reach_min"), w.get("reach_max")
|
|
out = [z for z in zs if lo is None or hi is None or z < lo - 0.01 or z > hi + 0.01]
|
|
ctx.check(len(zs) == 12 and not out, "ladder heights outside the reach %s..%s: %s", lo, hi, out or zs)
|
|
ev = ctx.evidence
|
|
before = dict(ev.get("program_z") or {}).get("laser.focus")
|
|
preview_ok(ctx, "laser.focus")
|
|
ev["focus_window"] = {"window": w, "stops": stops, "ladder_z": zs,
|
|
"settings": {k: s.get(k) for k in LENS_SETTINGS},
|
|
"program_z_before": before,
|
|
"program_z_after": (ev.get("program_z") or {}).get("laser.focus"),
|
|
"preview_bytes": ev.pop("preview_bytes", None),
|
|
"program_lines": ev.pop("program_lines", None)}
|
|
|
|
|
|
def thickness_answer(ctx):
|
|
"""The thickness the placement is answered with, in the machine's
|
|
units (the number prompts read ui_units), and the millimeters the
|
|
record must carry for it."""
|
|
imperial = (ctx.forgectrl.settings() or {}).get("ui_units") == "imperial"
|
|
return ("0.125", 3.175) if imperial else ("3.2", 3.2)
|
|
|
|
|
|
def place(ctx):
|
|
"""The placement: the lens reference, two jogs, the origin at the
|
|
head's position, the full sheet, a thickness. Nothing fires."""
|
|
jogs = ["X+10", "X-10"]
|
|
answer, want_mm = thickness_answer(ctx)
|
|
ctx.evidence["thickness_answer"] = answer
|
|
|
|
def on_prompt(p):
|
|
# Each jog answer closes the prompt and the wizard asks again with
|
|
# the next sequence number, so the pad is answered one move at a
|
|
# time until the origin is set.
|
|
if p.get("id") == "place":
|
|
return jogs.pop(0) if jogs else "Set origin"
|
|
if p.get("id") == "sheet-kind":
|
|
return "Full sheet"
|
|
if p.get("id") == "thickness":
|
|
return answer
|
|
return None
|
|
|
|
last = run_check(ctx, "sheet.place", on_prompt, 300)
|
|
r = last.get("result") or {}
|
|
ctx.check("origin_x" in r and "origin_y" in r, "no origin in the result: %s", r)
|
|
ctx.check((r.get("steps_per_mm") or {}).get("x", 0) > 0, "no steps per mm: %s", r)
|
|
ctx.check(r.get("z_referenced") is True, "the lens was not referenced")
|
|
ctx.check(abs((r.get("thickness_mm") or 0) - want_mm) < 0.01,
|
|
"thickness %s mm, expected %s (answered %s)", r.get("thickness_mm"), want_mm, answer)
|
|
ctx.check(r.get("alone") is False, "alone %s", r.get("alone"))
|
|
ctx.log("placed: datum at %s, %s; steps per mm %s", r.get("origin_x"), r.get("origin_y"),
|
|
r.get("steps_per_mm"))
|
|
file_card(ctx, "sheet.place")
|
|
|
|
|
|
def burn(ctx, wid, answers, want):
|
|
"""One live card: the preview, the burn after the press with the
|
|
prompts answered from `answers`, the witnesses, the keys of the
|
|
result in `want`. run_check makes the arm press when the card asks
|
|
for it."""
|
|
preview_ok(ctx, wid)
|
|
|
|
def on_prompt(p):
|
|
return answers.get(p.get("id"))
|
|
|
|
try:
|
|
last = run_check(ctx, wid, on_prompt, 900)
|
|
finally:
|
|
ctx.clear_notice()
|
|
emission_ok(ctx, last)
|
|
r = last.get("result") or {}
|
|
missing = [k for k in want if k not in r]
|
|
ctx.check(not missing, "the result lacks %s: %s", missing, sorted(r))
|
|
ctx.log("%s: %s, emission %s", wid, {k: r.get(k) for k in want}, ctx.evidence.get("emission"))
|
|
file_card(ctx, wid)
|
|
|
|
|
|
@test("setup.sheet", title="The setup sheet: the placement, the frame, and the five cards",
|
|
subsystem="setup", kind="live", hardware="takeover", mode="grbl", est_min=35,
|
|
covers=SHEET_COVERS,
|
|
requires=["forgectrl.auth", "setup.check-motion", "laser.emission-witness", "cooling.flow-verify"],
|
|
actions=["button"], hands=["scrap"],
|
|
steps=["A piece of wood at least 200 x 150 mm (8 x 6 in) on the bed, pushed as far left as "
|
|
"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.",
|
|
"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, each one when the "
|
|
"card itself asks for it. With no actuator the start is the Ready answer instead, "
|
|
"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 "
|
|
"cards write. It runs about half an hour, mostly the coolant settle and the dark "
|
|
"tails."],
|
|
description="One run over the sheet's seven wizards on one piece of wood, as a fresh "
|
|
"machine runs them: the three lens settings are cleared first, so the frame "
|
|
"runs in the fallback window and the focus card has to find the stops and write "
|
|
"them before its ladder burns. "
|
|
"POST /wiz/sheet.place/start references the lens, takes the controller in "
|
|
"loopback posture, jogs +X 10 and -X 10, and sets the origin at the head "
|
|
"(Full sheet; the thickness answered in the machine's units, 3.2 mm or "
|
|
"0.125 in). Then, each after the press and each with its preview "
|
|
"and program served by GET /wiz/sheet.svg and /wiz/sheet.gcode, every Z the "
|
|
"program commands inside the reach /status reports: the frame "
|
|
"(180 x 130 mm) and the header band at the mark dose; the focus card (the "
|
|
"lens on its hall edge, the stops found by the head accelerometer and written "
|
|
"as the window before the controller starts, twelve lines from the top of the "
|
|
"window to the bottom; the pick is line 11, the thickness kept; after it the "
|
|
"settings, the result's window, and the served program must hold the window "
|
|
"the ladder ran in); the floor card (twelve "
|
|
"rungs from 2 to 24 percent with the floor and the curve off for the job; the "
|
|
"pick is 8, the floor written is 10); the dose card (seven rungs sampled at "
|
|
"25 Hz, the curve fit on 20 s of dark); the corner card (five gammas reloaded "
|
|
"through M102 inside one armed job; the pick is 1.50); and the flow-load card "
|
|
"(the coolant loop settled, the box and the patch at 60 percent, the "
|
|
"downstream peak over 100 s of dark as the coefficient). Every burn must be "
|
|
"seen by the three witnesses (the tube current, the kernel's LASER_ON samples, "
|
|
"the thermopile rise); every result must carry its keys; the record must "
|
|
"carry each wizard at version 1; the seven settings the cards write are "
|
|
"restored at the end.")
|
|
def sheet(ctx):
|
|
ctx.ready("The sheet: the placement, then the frame and the five cards burn after a press "
|
|
"each. The bench presses when the button lights.")
|
|
place(ctx)
|
|
with Restore(ctx, CARD_SETTINGS):
|
|
fresh_lens(ctx)
|
|
for wid, answers, want in CARDS:
|
|
burn(ctx, wid, answers, want)
|
|
if wid == "laser.focus":
|
|
focus_window_ok(ctx)
|
|
ctx.log("PASS: the sheet's seven wizards ran on one piece as a fresh machine's; settings restored")
|