mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-10-17 18:11:42 -07:00
385 lines
22 KiB
Python
385 lines
22 KiB
Python
# Copyright 2026 514 LLC d/b/a OpenGlow
|
|
# Written by Scott Wiederhold
|
|
# https://community.openglow.org
|
|
# SPDX-License-Identifier: MIT
|
|
|
|
"""setup.check-* - the setup's checks (the dark wizards), driven the
|
|
way the page drives them: POST /wiz/<id>/start, GET /wiz/dark polled,
|
|
the prompts answered from here (the bench fixture works the lid and the
|
|
button; a confirmation is answered yes once the snapshot exists), the
|
|
result judged, and every setting a check wrote put back as found. Each
|
|
check re-runs on the real record: a completed check completes again at
|
|
the same version, so the record reads as before.
|
|
"""
|
|
import json
|
|
import os
|
|
import time
|
|
from ..catalog import test
|
|
from .. import hw
|
|
from ..baseline import read_position
|
|
from .setup import wiz, Restore # noqa: F401 - Restore is re-exported for the sheet
|
|
|
|
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."
|
|
# How long the purge fan's current is given to come up after the airflow
|
|
# check switches it back on. The bench reference draws its off current
|
|
# (74) for the first seconds and settles near 630.
|
|
PURGE_SPINUP_S = 30
|
|
DARK_COVERS = [("forgectrl", "src/wizdark.*"), ("forgectrl", "src/wizcalc.*"),
|
|
("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"),
|
|
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*")]
|
|
|
|
|
|
def dark(fc):
|
|
st, body = fc.get("/wiz/dark")
|
|
if st != 200 or not isinstance(body, dict):
|
|
raise hw.HwError("GET /wiz/dark -> %s" % st)
|
|
return body
|
|
|
|
|
|
def start(ctx, fc, wid):
|
|
ev = ctx.evidence
|
|
st, body = fc.post("/wiz/%s/start" % wid)
|
|
if st == 409 and dark(fc).get("id") == wid and dark(fc).get("running"):
|
|
# The same check left running by an earlier run of this test (an
|
|
# abort mid-wait): this run owns the machine, so it ends that one
|
|
# and starts its own.
|
|
ctx.log("the %s check is still running from an earlier run: aborting it", wid)
|
|
fc.post("/wiz/%s/abort" % wid)
|
|
ctx.wait_for(lambda: not dark(fc).get("running"), 30)
|
|
st, body = fc.post("/wiz/%s/start" % wid)
|
|
ctx.log("POST /wiz/%s/start -> %s %s", wid, st, body if isinstance(body, dict) else "")
|
|
ctx.check(st == 200, "the %s check did not start (%s %s)", wid, st, body)
|
|
ev["started"] = st == 200
|
|
|
|
|
|
def answer(fc, wid, prompt, value):
|
|
return fc.post("/wiz/%s/answer" % wid, data={"seq": str(prompt["seq"]), "value": value})
|
|
|
|
|
|
def run_check(ctx, wid, on_prompt, timeout_s):
|
|
"""Start `wid` and follow it to its end. on_prompt(prompt) is called
|
|
once per prompt (by sequence number) and returns the value to answer
|
|
with, or None when it handled the prompt another way (a fixture
|
|
action). Returns the final status document."""
|
|
fc = ctx.forgectrl
|
|
ev = ctx.evidence
|
|
start(ctx, fc, wid)
|
|
t0 = time.time()
|
|
seen = set()
|
|
phases = []
|
|
last = None
|
|
try:
|
|
while time.time() - t0 < timeout_s:
|
|
ctx.checkpoint()
|
|
d = dark(fc)
|
|
if d.get("id") != wid:
|
|
raise hw.HwError("another check took the slot: %s" % d.get("id"))
|
|
if d.get("phase") and (not phases or phases[-1] != d["phase"]):
|
|
phases.append(d["phase"])
|
|
ctx.log("%s: %s", wid, d["phase"])
|
|
p = d.get("prompt")
|
|
if p and p.get("seq") not in seen:
|
|
seen.add(p["seq"])
|
|
ctx.log("prompt %s (%s): %s", p.get("id"), p.get("kind"), p.get("text"))
|
|
value = on_prompt(p)
|
|
if value is not None:
|
|
st, body = answer(fc, wid, p, value)
|
|
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"):
|
|
last = d
|
|
break
|
|
time.sleep(POLL_S)
|
|
except BaseException:
|
|
# An abort or a failure on the way out leaves no check running
|
|
# behind this test: the next run must be able to start its own.
|
|
fc.post("/wiz/%s/abort" % wid)
|
|
raise
|
|
ev["elapsed_s"] = round(time.time() - t0, 1)
|
|
ev["phases"] = phases[-12:]
|
|
ctx.check(last is not None, "the %s check did not end within %d s", wid, timeout_s)
|
|
if last is None:
|
|
fc.post("/wiz/%s/abort" % wid)
|
|
return {}
|
|
ev["log"] = last.get("log", [])[-12:]
|
|
ev["error"] = last.get("error")
|
|
ev["result"] = last.get("result")
|
|
ctx.check(not last.get("error"), "the %s check failed: %s", wid, last.get("error"))
|
|
ctx.check(isinstance(last.get("result"), dict), "the %s check ended without a result", wid)
|
|
w = wiz(fc)
|
|
ctx.check((w.get("versions") or {}).get(wid) == 1, "the record does not carry %s at version 1", wid)
|
|
return last
|
|
|
|
|
|
@test("setup.check-switches", title="The switches check follows the lid and the button",
|
|
subsystem="setup", kind="operator", est_min=3,
|
|
covers=DARK_COVERS + [("forgectrl", "src/status.c")],
|
|
requires=["forgectrl.auth"], actions=["lid", "button"],
|
|
description="POST /wiz/switches/start; the check asks for the lid to open and close and for "
|
|
"a press, each as a 'wait' prompt that the switch edge itself answers; the bench "
|
|
"fixture works the lid and the button. On a Basic or Plus the interlock loop reads "
|
|
"satisfied without a prompt. The result says lid, button, head present, and "
|
|
"whether the HV enable readback followed the lid; the record carries switches at "
|
|
"version 1.")
|
|
def check_switches(ctx):
|
|
def on_prompt(p):
|
|
pid = p.get("id", "")
|
|
if pid in ("lid-open", "lid-close-first", "lid-close"):
|
|
ctx.act("lid", "open" if pid == "lid-open" else "close",
|
|
until=lambda: (dark(ctx.forgectrl).get("prompt") or {}).get("seq") != p["seq"],
|
|
text=p.get("text", ""))
|
|
elif pid == "button-press":
|
|
ctx.act("button", "press",
|
|
until=lambda: (dark(ctx.forgectrl).get("prompt") or {}).get("id") != "button-press",
|
|
text=p.get("text", ""))
|
|
elif pid == "button-release":
|
|
pass # the fixture's press releases on its own
|
|
elif pid.startswith("interlock-"):
|
|
ctx.act("interlock", "open" if pid == "interlock-open" else "close",
|
|
until=lambda: (dark(ctx.forgectrl).get("prompt") or {}).get("seq") != p["seq"],
|
|
text=p.get("text", ""))
|
|
return None
|
|
last = run_check(ctx, "switches", on_prompt, 600)
|
|
r = last.get("result") or {}
|
|
ctx.check(r.get("lid") is True and r.get("button") is True, "lid or button not proven: %s", r)
|
|
ctx.check(r.get("head_present") is True, "the head did not read present: %s", r)
|
|
ctx.log("switches: %s", json.dumps(r))
|
|
|
|
|
|
@test("setup.check-sensors", title="The sensors check reads a plausible machine at rest",
|
|
subsystem="setup", kind="auto", est_min=2,
|
|
covers=DARK_COVERS + [("forgectrl", "src/status.c"), ("forgectrl", "src/accel.c"),
|
|
("forgectrl", "src/cool.c")],
|
|
requires=["forgectrl.auth"],
|
|
description="POST /wiz/sensors/start; ten seconds of readings and no question asked. The "
|
|
"result carries both coolant temperatures, the chassis and SoC, the lid IR "
|
|
"maxima, the accelerometer event count, the supply power-good, the HV current, "
|
|
"and the idle fan speeds; the record carries sensors at version 1 and the "
|
|
"settings read as before (the check writes none).")
|
|
def check_sensors(ctx):
|
|
fc = ctx.forgectrl
|
|
ev = ctx.evidence
|
|
before = fc.settings() or {}
|
|
ev["settings_before"] = dict(before)
|
|
|
|
def on_prompt(p):
|
|
ctx.fail("the sensors check asked a question: %s", p)
|
|
last = run_check(ctx, "sensors", on_prompt, 120)
|
|
r = last.get("result") or {}
|
|
for k in ("coolant_down_c", "coolant_up_c", "lid_ir_max", "laser_pgood", "hv_current_max",
|
|
"exhaust_rpm_idle", "intake_rpm_idle"):
|
|
ctx.check(k in r, "the result lacks %s: %s", k, r)
|
|
ctx.check(r.get("laser_pgood") == 1, "power-good read %s", r.get("laser_pgood"))
|
|
after = fc.settings() or {}
|
|
changed = {k: (before.get(k), after.get(k)) for k in set(before) | set(after)
|
|
if before.get(k) != after.get(k)}
|
|
ctx.check(not changed, "the sensors check wrote a setting: %s", changed)
|
|
ctx.log("sensors: coolant %s/%s C, IR %s, HV %s", r.get("coolant_down_c"), r.get("coolant_up_c"),
|
|
r.get("lid_ir_max"), r.get("hv_current_max"))
|
|
|
|
|
|
@test("setup.check-airflow", title="The airflow check measures the fans and sets the floors",
|
|
subsystem="setup", kind="auto", hardware="takeover", est_min=3,
|
|
covers=DARK_COVERS + [("forgectrl", "src/cool.c"), ("forgectrl", "src/airflow.*"),
|
|
("forgectrl", "src/gates.c"), ("forgectrl", "src/super.c")],
|
|
requires=["forgectrl.auth", "cooling.fan-gate-trips"],
|
|
description="POST /wiz/airflow/start: the controller stops, the fans run at the cut profile "
|
|
"for 35 s, the purge is measured on and off, and five settings are written: the "
|
|
"four floors at 55 percent of steady and the grace from the slowest spin-up. Each "
|
|
"fan must read at least 1000 rpm. The five settings are put back as found at the "
|
|
"end, the purge fan is on again and drawing over the floor the check just wrote, "
|
|
"and the controller must be running again.")
|
|
def check_airflow(ctx):
|
|
keys = ["cool_tach_exhaust_min_rpm", "cool_tach_intake_min_rpm", "cool_tach_air_assist_min_rpm",
|
|
"cool_purge_min_current", "cool_fan_grace_s"]
|
|
with Restore(ctx, keys):
|
|
last = run_check(ctx, "airflow", lambda p: None, 240)
|
|
r = last.get("result") or {}
|
|
fans = r.get("fans") or {}
|
|
for name in ("exhaust", "intake 1", "intake 2", "air assist"):
|
|
f = fans.get(name) or {}
|
|
ctx.check(f.get("ok") is True, "%s: %s", name, f)
|
|
ctx.log("%s: %s rpm steady, 90 percent at %s s", name, f.get("steady_rpm"), f.get("spinup_s"))
|
|
floors = r.get("floors") or {}
|
|
for k in keys:
|
|
ctx.check(k in floors, "no floor for %s: %s", k, floors)
|
|
s = ctx.forgectrl.settings() or {}
|
|
for k in keys:
|
|
ctx.check(s.get(k) == floors.get(k), "%s reads %r, the check wrote %r", k, s.get(k), floors.get(k))
|
|
ctx.log("floors: %s", json.dumps(floors))
|
|
# The check switches purge air off to read its off current. Leaving
|
|
# it off costs the next job an airflow hold mid-cut, judged against
|
|
# the very floor written above, and nothing puts it back until the
|
|
# daemon restarts: the engine's idle phase never re-applies its own
|
|
# duties. Prove the machine is whole, not just measured.
|
|
purge_on = ctx.sysfs("head/purge_air")
|
|
ctx.check(purge_on == "1", "purge air reads %r after the check, expected 1 (on)", purge_on)
|
|
|
|
def purge_gate():
|
|
return ((ctx.forgectrl.get("/cool/status")[1] or {}).get("fan_gates") or {}).get("purge") or {}
|
|
|
|
# The check read its off current with the fan off, and the stand-down
|
|
# commands it back on. The current follows the command, it does not
|
|
# arrive with it: read in the same second and what comes back is the
|
|
# off current the check just measured. Wait for the draw the way the
|
|
# controller below is waited for.
|
|
took = ctx.wait_for(lambda: (purge_gate().get("reading") or 0) >= (purge_gate().get("floor") or 0),
|
|
PURGE_SPINUP_S)
|
|
gate = purge_gate()
|
|
ctx.evidence["purge_after"] = {"purge_air": purge_on, "gate": gate, "took_s": took}
|
|
ctx.log("purge after the check: commanded %s, drawing %s against the %s floor (%s)",
|
|
purge_on, gate.get("reading"), gate.get("floor"),
|
|
"after %.1f s" % took if took is not None else "never reached the floor")
|
|
ctx.check(took is not None,
|
|
"purge draws %s after %d s, under the %s floor the check just wrote: the next job "
|
|
"would be held", gate.get("reading"), PURGE_SPINUP_S, gate.get("floor"))
|
|
ok = ctx.wait_for(lambda: (ctx.forgectrl.get("/mode")[1] or {}).get("controller") == "running", 60)
|
|
ctx.check(ok is not None, "the controller did not come back after the check")
|
|
|
|
|
|
@test("setup.check-cameras", title="The cameras check captures both cameras",
|
|
subsystem="setup", kind="operator", est_min=2,
|
|
covers=DARK_COVERS + [("forgectrl", "src/cam.c")],
|
|
requires=["forgectrl.auth", "camera.snapshot"], actions=["lid"],
|
|
description="POST /wiz/cameras/start with the lid closed: a lid snapshot, the question, a "
|
|
"head snapshot, the question. Each question is answered yes once GET /wiz/shot "
|
|
"serves the snapshot as image/jpeg. The result names the sensor and says both "
|
|
"views were accepted.")
|
|
def check_cameras(ctx):
|
|
fc = ctx.forgectrl
|
|
ev = ctx.evidence
|
|
ctx.act("lid", "close")
|
|
shots = {}
|
|
|
|
def on_prompt(p):
|
|
pid = p.get("id", "")
|
|
if pid in ("lid-view", "head-view"):
|
|
cam = "lid" if pid == "lid-view" else "head"
|
|
st, body = fc.get("/wiz/shot", params={"cam": cam}, raw=True)
|
|
shots[cam] = {"status": st, "bytes": len(body) if body else 0,
|
|
"jpeg": bool(body) and body[:2] == b"\xff\xd8"}
|
|
ctx.check(st == 200 and shots[cam]["jpeg"], "no %s snapshot to judge: %s", cam, shots[cam])
|
|
return "Yes"
|
|
if pid == "lid-close":
|
|
ctx.act("lid", "close", until=lambda: (dark(fc).get("prompt") or {}).get("seq") != p["seq"])
|
|
return None
|
|
last = run_check(ctx, "cameras", on_prompt, 180)
|
|
ev["shots"] = shots
|
|
r = last.get("result") or {}
|
|
ctx.check(r.get("lid_ok") is True and r.get("head_ok") is True, "a view was not accepted: %s", r)
|
|
ctx.check(bool(r.get("sensor")) and r.get("sensor") != "unknown", "no sensor named: %s", r)
|
|
ctx.log("cameras: %s, lid %d bytes, head %d bytes", r.get("sensor"),
|
|
shots.get("lid", {}).get("bytes", 0), shots.get("head", {}).get("bytes", 0))
|
|
|
|
|
|
@test("setup.check-motion", title="The motion check proves the rail, the lens reference, and the jogs",
|
|
subsystem="setup", kind="auto", hardware="takeover", est_min=5,
|
|
covers=DARK_COVERS + [("forgectrl", "src/super.c"), ("forgectrl", "src/liveness.c"),
|
|
("forgectrl", "src/lenshome.*"),
|
|
("forgectrl", "src/accel.c"), ("forgectrl", "src/cool.c")],
|
|
requires=["forgectrl.auth", "motion.pacing"],
|
|
description="POST /wiz/motion/start: the controller stops, the liveness probe runs and the "
|
|
"lens takes its hall-edge reference behind it (a lens that cannot reach its "
|
|
"edge is a motion fault, and no controller starts), the lens "
|
|
"finds the hall reference in five agreeing passes, the controller comes back in "
|
|
"loopback posture, and the head jogs 50 mm each way on X and Y with the "
|
|
"accelerometer as the witness. The one prompt (the jogs are about to move) is "
|
|
"answered Continue. The result carries the probe line, the passes, and a "
|
|
"witnessed reading for each jog; the controller must be back in its normal "
|
|
"posture, and the kernel position counters must read what they read before: "
|
|
"every move played to its end (the probe's return leg under the cooling "
|
|
"engine's dead-man, the last jog under the controller stop).")
|
|
def check_motion(ctx):
|
|
fc = ctx.forgectrl
|
|
before = read_position()
|
|
ctx.notice("The head moves 50 mm each way on X and on Y. Keep the bed clear.")
|
|
last = run_check(ctx, "motion", lambda p: "Continue" if p.get("id") == "jogs" else None, 420)
|
|
ctx.clear_notice()
|
|
r = last.get("result") or {}
|
|
ctx.check(r.get("z_referenced") is True, "the lens was not referenced: %s", r)
|
|
ctx.check(len(r.get("z_passes") or []) == 5, "not five reference passes: %s", r.get("z_passes"))
|
|
moves = r.get("moves") or {}
|
|
for name in ("+X", "-X", "+Y", "-Y"):
|
|
m = moves.get(name) or {}
|
|
ctx.check(m.get("witnessed") is True, "the %s jog was not witnessed: %s", name, m)
|
|
ok = ctx.wait_for(lambda: (fc.get("/mode")[1] or {}).get("local") is False
|
|
and (fc.get("/mode")[1] or {}).get("controller") == "running", 90)
|
|
ctx.check(ok is not None, "the controller is not back in its normal posture")
|
|
after = read_position()
|
|
if before is not None and after is not None:
|
|
ctx.check(all(abs(a - b) <= 2 for a, b in zip(after[:2], before[:2])),
|
|
"the check lost motion: position %s before, %s after (steps)", before, after)
|
|
ctx.log("motion: probe '%s', passes %s, rest %s, moves %s", r.get("probe"), r.get("z_passes"),
|
|
r.get("rest"), {k: (v.get("p2p_lp_x"), v.get("p2p_lp_y")) for k, v in moves.items()})
|
|
|
|
|
|
@test("setup.check-flow-verify", title="The flow check runs as a setup check",
|
|
subsystem="setup", kind="auto", hardware="takeover", est_min=5,
|
|
covers=DARK_COVERS + [("forgectrl", "src/diag.c")],
|
|
requires=["forgectrl.auth", "cooling.aa-offset-calibrate", "cooling.flow-verify"],
|
|
description="POST /wiz/cooling.flow-verify/start drives the flow-verify diagnostic through the "
|
|
"check runner: the diagnostic's phases show as the check's, its result becomes the "
|
|
"check's result with pass true, and the record carries cooling.flow-verify at "
|
|
"version 1. A thin margin recommends the calibration in the record's flags. The "
|
|
"air-assist offset calibration is named first among the prerequisites so a queue "
|
|
"keeps it ahead of every heater tool this check pulls forward.")
|
|
def check_flow_verify(ctx):
|
|
last = run_check(ctx, "cooling.flow-verify", lambda p: None, 600)
|
|
r = last.get("result") or {}
|
|
ctx.check(r.get("pass") is True, "the flow check did not pass: %s", r)
|
|
ctx.log("flow check: threshold %s, flow %s, no-flow %s, thin %s", r.get("threshold"),
|
|
r.get("flow_rise"), r.get("noflow_rise"), r.get("thin_margin"))
|
|
|
|
|
|
@test("setup.cloud-header-capture", title="The cloud header check takes one print's envelope",
|
|
subsystem="setup", kind="operator", hardware="takeover", mode="grbl", est_min=8,
|
|
covers=DARK_COVERS + [("forgectrl", "src/super.c"),
|
|
("python3-gfhardware", "gfhardware/machine.py"),
|
|
("python3-gfhardware", "forgefirm-app/gfcloud.py"),
|
|
("python3-gfhardware", "forgefirm-app/ffmachine.py")],
|
|
requires=["forgectrl.auth", "cloud.mode-switch"], hands=["workstation"],
|
|
steps=["The Glowforge app open on the workstation, signed in to this machine. The test "
|
|
"turns cloud mode on itself and puts it back.",
|
|
"Once the app shows the machine online, place any small design on the bed image "
|
|
"and press Print. That is the only action: the check takes the print's header and "
|
|
"cancels the print before it arms. Do not press the machine's button.",
|
|
"The machine returns to GRBL mode on its own."],
|
|
description="POST /wiz/cloud.header/start with cloud mode on: the wizard writes the "
|
|
"client's one-start capture marker, starts cloud mode in the wizard's posture, "
|
|
"asks for a print, and takes the header the client writes to the run directory "
|
|
"before it cancels the print. The result carries the tag count, the "
|
|
"calibration-bearing tags, the limits, and the machine's own numbers; the log "
|
|
"has the tagged block; the machine is back in GRBL mode with the marker gone.")
|
|
def cloud_header_capture(ctx):
|
|
fc = ctx.forgectrl
|
|
ev = ctx.evidence
|
|
with Restore(ctx, ["cloud_enabled", "homing_mode", "controller_mode"]):
|
|
s = fc.settings() or {}
|
|
if s.get("cloud_enabled") != "1":
|
|
st, body = fc.post("/settings", data={"cloud_enabled": "1"})
|
|
ctx.check(st == 200, "cloud_enabled=1 -> %s %s", st, body)
|
|
ctx.log("cloud mode turned on for the test")
|
|
ctx.notice("Print any small design from the Glowforge app once it shows the machine "
|
|
"online. Do not press the machine's button.")
|
|
last = run_check(ctx, "cloud.header",
|
|
lambda p: "Continue" if p.get("id") == "print" else None, 900)
|
|
ctx.clear_notice()
|
|
r = last.get("result") or {}
|
|
ctx.check(isinstance(r.get("tag_count"), int) and r["tag_count"] > 100,
|
|
"the header carried %s tags", r.get("tag_count"))
|
|
kept = r.get("calibration_tags") or {}
|
|
ctx.check(len(kept) > 10, "only %d calibration-bearing tags kept", len(kept))
|
|
ev["kept"] = sorted(kept)[:40]
|
|
ctx.check(not os.path.exists("/run/gfcloud-capture"), "the capture marker is still down")
|
|
ok = ctx.wait_for(lambda: (fc.get("/mode")[1] or {}).get("mode") == "grbl"
|
|
and (fc.get("/mode")[1] or {}).get("controller") == "running", 120)
|
|
ctx.check(ok is not None, "the machine did not return to GRBL mode")
|
|
ctx.log("header: %d tags, %d kept; job %s", r.get("tag_count", 0), len(kept), r.get("job_id"))
|