10 Commits
Author SHA1 Message Date
ScottW514 c0d4302d73 ForgeFIRM v0.0.4
FORGEFIRM_RELEASE 0.0.4. The acceptance record that authorizes this
build: campaign c-20260912182427-e1ee on dev image 20260912180956
(manifest 8bb5b6f3), 85 tests, 85 satisfied, 0 required, exported
2026-09-12T19:21:35Z. The release notes in releases/v0.0.4/notes.md
name this the first public beta; the pipeline takes a release's
notes.md, when it carries one, in place of GitHub's generated notes.
2026-09-12 15:22:33 -04:00
ScottW514 884d014d2c Pin forgectrl 0.1.24 (verified fan writes) and cover the lost-duty put-back
forgectrl 0.1.24 = 2380e07: every fan duty write is read back and
retried, a duty a device lost is put back by the tick, and the airflow
fault names the duty commanded and the duty in force. The corner card
of the sheet on the bench reference was held as a slow air-assist fan
after a run-duty write the head never took; nothing checked.

Acceptance: cooling.fan-duty-readback (auto, grbl) opens an M8 session,
reads every fan's run duty back, writes the head's air-assist register
back to the idle duty and the exhaust PWM to zero behind the engine's
back, and holds both to their run duties again within a few ticks, each
loss named in the log, the session OK to its end, the idle duties after
M9. Covers the head driver too. On the bench reference with forgectrl
0.1.24 hot-deployed it PASSED: both duties back after 0.5 s.
2026-09-12 14:08:34 -04:00
ScottW514 a9f0ad0d51 Take forgectrl 0.1.23's theme.css into the forgetest page
The shared theme gained the release dialog's styles in forgectrl; the
copy the acceptance page uses follows it, as check-ui-vendor requires.
2026-09-12 12:58:27 -04:00
ScottW514 2dde10cf43 Pin forgectrl 0.1.23 (the mirror test reads the release reply's packed keys)
forgectrl 0.1.23 = cb8c140: the dev-server mirror test holds the mock's
release replies to the keys reply_release packs; the daemon is the one
0.1.22 carried. CI is green on this revision.
2026-09-12 12:48:40 -04:00
ScottW514 f41464cc58 Pin forgectrl 0.1.22 (the releases-API check and the release dialog) and cover its routes
forgectrl 0.1.22 = 0235a88: the release check reads the GitHub releases
API and never requests the firmware file's URL, the daemon checks daily,
and the panel raises a per-release dismissable alert and runs the install
from one dialog.

Acceptance: update.release-check (auto) exercises GET /update/release,
POST /update/check (a machine with no route to the API answers 502, which
the drill records and steps over), the v<semver> shape and `new` of a
published release, and the dismissal round trip, and puts the dismissal
back. forgectrl.auth's unauthenticated-write list gains /update/check and
/update/dismiss. Coverage lint: 84 tests, 0 uncovered paths; the
forgetest unit tests pass (373). On the bench reference (dev image
20260911203113 with forgectrl 0.1.22 hot-deployed) every check of both
tests passed; the runs were marked FAIL only by the hand-back baseline,
because the controller is gated until the changed privacy advisory is
accepted again.
2026-09-12 12:41:56 -04:00
ScottW514 20c6256527 forgectrl 0.1.21: the release check reads the tag from the first hop
Pin forgectrl at 92cead6 (0.1.21). The published-release check reads
the release tag from the first redirect hop instead of the end of the
chain, where the asset store's URL carries none; every image through
v0.0.3 reported "release server error (HTTP 200)" against a published
release. The forgectrl commit carries the proof (relcheck_test in its
CI). The update.slots-and-signature covers map names the new
src/relcheck.c and src/relcheck.h so the coverage lint stays whole. A
catalog test of the check against a published release is held for a
later change.
2026-09-11 16:29:34 -04:00
ScottW514 b7a595ced7 Release v0.0.3: the version and the acceptance artifact for this tree
FORGEFIRM_RELEASE takes 0.0.3. The file sits outside the layer content
hash, so the bump changes the version and invalidates no acceptance
result.

releases/v0.0.3 carries the artifact the bench exported for this image:
campaign c-20260911182256-078f on 20260911172215 (dev), manifest identity
fb24c3f4508ae54592f8d0b98ed0b2a07a933274980d8c1e4e5a967618ef9bd8, 83
tests, 83 satisfied (57 inherited), 0 required, release authorized. The
release gate recomputes every catalog test's domain fingerprint from the
manifest inside the release rootfs and signs only when the recorded
results agree.

The release carries, since v0.0.2: the lens frame in one place, so every
Z a commissioning card sends comes from the settings the controller
opens its Z limit from (the tail at the head's reference, the focus
window written before the card's controller starts, every program judged
against the reach before its first line goes out); the commissioning
sheet acceptance test run as a fresh machine's; and the CPU percent on
/status that holds across a status read inside the same scheduler tick
as another reader's. Components: forgectrl 0.1.20, grblhal-glowforge
0.1.11, forgefirm-app 0.1.28+git, kernel-module 0.0.5, meta-openglow
ced2af2.
2026-09-11 14:55:25 -04:00
ScottW514 5c93e49c8c Pin forgectrl 0.1.20: the CPU percent holds across a same-tick status read
forgectrl 864b8da repeats the last CPU percent when a /status read finds
the /proc/stat counters unmoved, the race that failed
forgectrl.panel-serves on the release candidate when the test's second
read shared a scheduler tick with a panel poll. Every test covering
forgectrl re-runs.
2026-09-11 13:19:45 -04:00
ScottW514 650483c085 Pin forgectrl 0.1.19: the lens frame with a workflow file that parses
forgectrl 3e54612 renames the lens_test step in build.yml, whose name
held a colon that YAML read as a mapping, so CI on f8ddb17 ran nothing.
The source is f8ddb17's; the pin moves so the release names a commit
with a green build.
2026-09-11 12:55:02 -04:00
ScottW514 7b8f72b632 Run the commissioning sheet as a fresh machine, and pin forgectrl 0.1.18 (the lens frame)
forgectrl f8ddb17 puts the lens frame in one place and writes the focus
window before the card's controller starts, after two commissioning
cards ended in ALARM:2 on a Z the wizard sent from one source while the
controller's Z limit stood on another. The acceptance run had passed
only because the bench's settings already held the stops from an
earlier focus run, so the run is now a fresh machine's.

commission.sheet clears the three lens settings inside its Restore
before the cards, checks that the frame runs in the fallback window,
and, after the focus card, that the settings hold the window the ladder
ran in (the stops found or the fallback), that every ladder height lies
in that window's reach, and that the program served now agrees with
/status. Every served program's Z is checked against the reach /status
reports before the card starts, so a stray Z fails the test with nothing
burned. The test covers src/lens.*. The host mock in
tests/test_commission_sheet.py mirrors the daemon (the /status lens block
from the settings, the ladder served from the settings, the window
written at the focus start); two regression tests reproduce the defects:
a focus result naming a window the settings do not hold, and a served
program with a Z beyond the reach. 10/10 green.

scripts/bench/z_envelope_test.py, the grblHAL CI harness, adds the
referenced-lens cases: with forgectrl's marker and the shared settings,
the fallback window and a 14/20 window run to the ends of their reach
and two half-steps past either end alarms, and a count of 41 falls back
on its side alone. Passed on the null-sink build. The bench page's
description of the harness follows.

The forgectrl pin moves to f8ddb17 (0.1.18); every test covering
forgectrl re-runs.
2026-09-11 12:44:43 -04:00
16 changed files with 43513 additions and 22 deletions
+5 -2
View File
@@ -247,8 +247,11 @@ TOOLS = [
{"id": "z-envelope-test", "title": "Z envelope harness", "script": "z_envelope_test.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "The Z soft limit belongs to the driver, not to $20: an unreferenced Z is collapsed to where the "
"lens stands, and neither a $20 nor a $132 write frees it. A CI harness (the grblHAL repo): needs "
"the host-built null-sink controller, not the machine, so it is not a bench-page tool."},
"lens stands, and neither a $20 nor a $132 write frees it. A referenced lens (forgectrl's marker "
"and the lens settings) opens it to the window the settings hold, the fallback or the focus "
"card's stops: the ends of the reach run, two half-steps past either end alarms. A CI harness "
"(the grblHAL repo): needs the host-built null-sink controller, not the machine, so it is not a "
"bench-page tool."},
{"id": "raster-dry", "title": "Dry top-speed raster per XY microstep mode", "script": "raster_dry.py",
"safety": "dry", "where": "board", "ported": True,
"args": [_arg("modes", "choice", "8 16 32", "the modes to run, in order", ["8", "16", "32", "8 16 32"])],
+87 -6
View File
@@ -21,7 +21,7 @@ from ..catalog import test
from .commission_dark import run_check, Restore
SHEET_COVERS = [("forgectrl", "src/wizlive.*"), ("forgectrl", "src/wizrun.h"),
("forgectrl", "src/sheet.*"),
("forgectrl", "src/lens.*"), ("forgectrl", "src/sheet.*"),
("forgectrl", "src/font_hershey.*"), ("forgectrl", "src/jobstream.*"),
("forgectrl", "src/curverec.*"), ("forgectrl", "src/wizdark.*"),
("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"),
@@ -71,6 +71,22 @@ def preview_ok(ctx, wid):
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):
@@ -89,6 +105,61 @@ def file_card(ctx, wid):
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
@@ -167,15 +238,22 @@ def burn(ctx, wid, answers, want):
"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. "
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: the frame "
"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 homed on its bottom stop, the hall edge counted, twelve lines down the "
"travel; the pick is line 11, the thickness kept); the floor card (twelve "
"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 "
@@ -191,6 +269,9 @@ def sheet(ctx):
"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)
ctx.log("PASS: the sheet's seven wizards ran on one piece; settings restored")
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")
+61
View File
@@ -1329,3 +1329,64 @@ def flow_under_load(ctx):
ctx.check(dt is not None, "the window did not close after M2")
ctx.log("PASS: lit through the window, raw rise %.1f C, laser %.1f C off, judged %.1f C against %.1f "
"(dT %.1f), window closed %.1f s after M2", raw, share, rise, float(limit), float(dtc), dt)
@test("cooling.fan-duty-readback", title="A fan duty a device lost is read back and put back inside a tick",
subsystem="cooling", kind="auto", mode="grbl", est_min=2,
covers=_COOL_COVERS + [("kernel-module-glowforge", "src/head*")],
requires=["kernel.latch-locked-idle"],
description="M8 opens a run session and every fan reads its run duty back from its device. "
"The head's air-assist register is then written back to the idle duty behind the "
"engine's back (what a head reset or a dropped transaction leaves) and the exhaust "
"PWM to zero: within a few ticks each reads its run duty again, the log names each "
"loss and its put-back, and the session stays OK with no airflow fault (a fan put "
"back gets its spin-up grace). Then M9 and the idle duties.")
def fan_duty_readback(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
run = {"head/air_assist_pwm": 1023, "thermal/exhaust_pwm": 65535, "thermal/intake_pwm": 43278}
with ctx.grbl() as g:
c = _hold_session(ctx, g, fc, lambda c: c.get("phase") == "run", "readback", 20)
try:
ctx.check(c.get("phase") == "run", "no run session opened: %s", c)
ctx.sleep(2)
before = {k: hw.sysfs_int(k) for k in run}
ctx.log("duties at run: %s", before)
ev["run_duties"] = before
ctx.check(all(before.get(k) == v for k, v in run.items()),
"not every fan reads its run duty: %s", before)
hw.sysfs_write("head/air_assist_pwm", 204)
hw.sysfs_write("thermal/exhaust_pwm", 0)
lost = {k: hw.sysfs_int(k) for k in ("head/air_assist_pwm", "thermal/exhaust_pwm")}
ctx.log("written behind the engine's back: %s", lost)
back = {}
t0 = time.time()
while time.time() - t0 < 6:
ctx.sleep(0.5)
back = {k: hw.sysfs_int(k) for k in ("head/air_assist_pwm", "thermal/exhaust_pwm")}
if back.get("head/air_assist_pwm") == 1023 and back.get("thermal/exhaust_pwm") == 65535:
break
took = round(time.time() - t0, 1)
ev["put_back"] = {"duties": back, "after_s": took}
ctx.log("put back after %s s: %s", took, back)
ctx.check(back.get("head/air_assist_pwm") == 1023, "the air-assist duty was not put back: %s", back)
ctx.check(back.get("thermal/exhaust_pwm") == 65535, "the exhaust duty was not put back: %s", back)
ctx.sleep(2)
ctx.check(_tail_has(fc, "head/air_assist_pwm read 204 with 1023 commanded: put back"),
"the air-assist loss is not named in the log")
ctx.check(_tail_has(fc, "thermal/exhaust_pwm read 0 with 65535 commanded: put back"),
"the exhaust loss is not named in the log")
for _ in range(8):
ctx.sleep(1)
c = _cool(fc)
if c.get("verdict") != "OK":
break
ev["verdict_after"] = {"verdict": c.get("verdict"), "hold": c.get("hold"), "reason": c.get("reason")}
ctx.check(c.get("verdict") == "OK" and not c.get("hold"),
"the session did not stay OK after the put-back: %s %s", c.get("verdict"), c.get("reason"))
finally:
g.command("M9")
_session_ended(ctx, fc, "readback")
idle = {k: hw.sysfs_int(k) for k in run}
ev["idle_duties"] = idle
ctx.log("duties at idle: %s", idle)
+1
View File
@@ -56,6 +56,7 @@ def auth(ctx):
("/mode", {"controller": "grbl"}), ("/settings", {"ui_units": "mm"}),
("/diag/flow-verify", None), ("/diag/abort", None),
("/update/apply", None), ("/boot", {"target": "a"}),
("/update/check", None), ("/update/dismiss", {"version": "v0.0.0"}),
("/system/reboot", None), ("/restore/factory", None),
("/restore/factory-return", {"confirm": "1"}), ("/system/ssh", {"enable": "1"}),
("/wiz/advisories/accept", None), ("/wiz/account", None),
+75
View File
@@ -1,5 +1,6 @@
"""update.* - the A/B slot inventory and the firmware verification path."""
import os
import re
import tempfile
import time
import shutil
@@ -8,6 +9,7 @@ from ..catalog import test
from .. import hw
_UPDATE_COVERS = [("forgectrl", "src/update.c"), ("forgectrl", "src/update.h"),
("forgectrl", "src/relcheck.c"), ("forgectrl", "src/relcheck.h"),
("ffboot", "**")]
@@ -117,3 +119,76 @@ def slots_and_signature(ctx):
except OSError:
pass
shutil.rmtree(work, ignore_errors=True)
_VERSION_RX = re.compile(r"^v?\d+\.\d+\.\d+")
@test("update.release-check", title="Release check answers from the releases API; the alert dismissal is per release",
subsystem="update", kind="auto", est_min=1,
covers=_UPDATE_COVERS + [("forgectrl", "src/ui/panel.js"), ("forgectrl", "src/ui/index.html")],
requires=["forgectrl.auth"],
description="GET /update/release answers the kept answer of the daily check (available, version, "
"current, new, checked, dismissed). POST /update/check asks the releases API now and "
"answers the same shape; a machine with no route to the API answers 502, which the drill "
"records and steps over. A published release is a v<semver> tag with the firmware "
"file's size and its notes, and `new` follows the version order: a development build "
"sees every release as newer. POST /update/dismiss marks that version dismissed, an "
"empty version undoes it, and a version that is not a tag is refused. The dismissal "
"the machine had before the drill is put back.")
def release_check(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
keys = ("available", "version", "current", "new", "checked", "dismissed", "detail", "bytes")
st, before = fc.get("/update/release")
ctx.log("GET /update/release -> %s %s", st, {k: before.get(k) for k in keys} if isinstance(before, dict) else before)
ctx.check(st == 200 and isinstance(before, dict) and "available" in before and "checked" in before,
"GET /update/release -> %s", st)
ev["before"] = {k: before.get(k) for k in keys}
prior = before.get("version", "") if before.get("dismissed") else ""
st, now = fc.post("/update/check")
ctx.log("POST /update/check -> %s %s", st, {k: now.get(k) for k in keys} if isinstance(now, dict) else now)
if st == 502:
ctx.log("the machine has no route to the releases API; the kept answer stands")
now = before
else:
ctx.check(st == 200 and isinstance(now, dict) and "available" in now, "POST /update/check -> %s", st)
ctx.check(isinstance(now.get("checked"), int) and now["checked"] >= before.get("checked", 0),
"the check did not move `checked`: %s", now.get("checked"))
ev["after_check"] = {k: now.get(k) for k in keys}
if now.get("available"):
ctx.check(_VERSION_RX.match(str(now.get("version", ""))) is not None,
"the release version is not a v<semver> tag: %r", now.get("version"))
ctx.check(isinstance(now.get("bytes"), int) and now["bytes"] > 0,
"the firmware file's size is missing: %r", now.get("bytes"))
ctx.check(isinstance(now.get("notes"), str), "the notes are not a string")
ctx.check(isinstance(now.get("new"), bool), "`new` is not a bool")
if not _VERSION_RX.match(str(now.get("current", ""))):
ctx.check(now.get("new") is True,
"a development build (%r) must see release %s as newer", now.get("current"), now["version"])
else:
ctx.log("no release available: %s", now.get("detail"))
ctx.check(isinstance(now.get("detail"), str) and now["detail"], "an unavailable release names no reason")
try:
st, body = fc.post("/update/dismiss", params={"version": "v0.0.1; rm -rf /"})
ctx.log("POST /update/dismiss version=<not a tag> -> %s %s", st, body)
ctx.check(st == 400, "a version that is not a tag was not refused: %s", st)
tag = now["version"] if now.get("available") else "v0.0.0"
st, body = fc.post("/update/dismiss", params={"version": tag})
ctx.log("POST /update/dismiss version=%s -> %s %s", tag, st,
{k: body.get(k) for k in keys} if isinstance(body, dict) else body)
ctx.check(st == 200 and isinstance(body, dict), "POST /update/dismiss -> %s", st)
if now.get("available"):
ctx.check(body.get("dismissed") is True, "the dismissal of %s was not recorded: %r", tag, body.get("dismissed"))
st, body = fc.post("/update/dismiss", params={"version": ""})
ctx.log("POST /update/dismiss version= -> %s", st)
ctx.check(st == 200 and isinstance(body, dict) and body.get("dismissed") is False,
"the empty version did not undo the dismissal: %s %r", st, body.get("dismissed") if isinstance(body, dict) else body)
finally:
st, body = fc.post("/update/dismiss", params={"version": prior})
ctx.log("dismissal put back to %r -> %s", prior, st)
+94
View File
@@ -206,6 +206,100 @@
border-left: 4px solid var(--og-red);
}
/* ---- The release dialog -------------------------------------------- */
#rel-view h3 {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--og-dim);
margin: 12px 0 4px;
}
.relnotes {
max-height: 40vh;
overflow: auto;
border: 1px solid var(--og-line);
border-radius: 6px;
padding: 8px 12px;
margin: 4px 0 8px;
font-size: 13px;
line-height: 1.5;
}
.relnotes h1,
.relnotes h2,
.relnotes h3 {
font-size: 14px;
font-weight: 600;
margin: 10px 0 4px;
}
.relnotes h1:first-child,
.relnotes h2:first-child,
.relnotes h3:first-child {
margin-top: 0;
}
.relnotes p,
.relnotes ul {
margin: 0 0 6px;
}
.relnotes ul {
padding-left: 20px;
}
.relnotes code {
font-size: 12px;
}
.relnotes table {
border-collapse: collapse;
margin: 4px 0 8px;
}
.relnotes th,
.relnotes td {
border: 1px solid var(--og-line);
padding: 2px 8px;
text-align: left;
}
.relwarn {
background: var(--og-warn-bg);
border: 1px solid var(--og-warn);
color: var(--og-warn-txt);
border-radius: 8px;
padding: 10px 12px;
margin: 0 0 12px;
font-size: 14px;
}
.relsteps {
margin: 0 0 12px;
padding-left: 22px;
font-size: 13px;
line-height: 1.7;
}
.relsteps li {
color: var(--og-dim);
}
.relsteps li.cur {
color: var(--og-txt);
font-weight: 600;
}
.relsteps li.done {
color: var(--og-ok);
}
.relsteps li.done::after {
content: ' \2713';
}
.progress {
--bs-progress-bg: var(--og-line-soft);
--bs-progress-bar-bg: var(--og-action);
--bs-progress-height: 10px;
margin: 0 0 8px;
}
#rel-phase {
min-height: 1.4em;
font-size: 12.5px;
margin: 0;
}
#rel-err {
margin: 10px 0 0;
}
/* ---- Chrome: header, tabs, footer ---------------------------------- */
body {
margin: 0;
+121 -7
View File
@@ -17,7 +17,44 @@ from forgetest.runner import Context, Run # noqa: E402
from forgetest.suite import commission_sheet # noqa: E402
LIVE = ["sheet.frame", "laser.focus", "laser.floor", "laser.dose-curve", "laser.corner", "cooling.flow-load"]
# The focus card's program is read again after it ran: the served ladder
# must then lie in the window the card wrote.
PREVIEWS = ["sheet.frame", "laser.focus", "laser.focus", "laser.floor", "laser.dose-curve", "laser.corner",
"cooling.flow-load"]
EMISSION = {"hv_max": 610, "laser_on_samples": 900, "thermopile_delta": 600, "lit_s": 30.0}
SPM = 2.922 # the lens screw, half-steps per mm ($102)
EDGE_DEFAULT = 3.35
def lens_block(s):
"""The /status lens block as the daemon builds it from the settings: a
stop count from 1 to 40 stands, anything else is the fallback."""
def count(key, fallback):
try:
n = float(s.get(key, ""))
except ValueError:
return fallback, False
return (int(n), True) if 1 <= n <= 40 else (fallback, False)
try:
edge = float(s.get("lens_hall_edge_z_mm", ""))
except ValueError:
edge = EDGE_DEFAULT
below, a = count("lens_stop_below_steps", 10)
above, b = count("lens_stop_above_steps", 12)
return {"edge_z": round(edge, 2), "below": below, "above": above, "stops_found": a and b,
"reach_min": round(edge - below / SPM, 2), "reach_max": round(edge + above / SPM, 2)}
def ladder_z(edge, below, above):
"""The focus ladder the daemon builds: twelve whole half-steps from
the top of the window to the bottom."""
return [round(edge + round(above - (above + below) * i / 11) / SPM, 2) for i in range(12)]
# The window the focus card finds and writes before its controller starts.
STOPS = {"lens_stop_below_steps": "14", "lens_stop_above_steps": "20"}
FOCUS_WINDOW = {"below": 14, "above": 20, "reach_min": round(EDGE_DEFAULT - 14 / SPM, 2),
"reach_max": round(EDGE_DEFAULT + 20 / SPM, 2)}
# Each wizard as the daemon runs it: its prompts in order (id, kind,
# options) and the result it ends with. A "jog" prompt repeats until the
@@ -37,7 +74,9 @@ SCRIPT = {
"steps_per_mm": 2.922, "max_height_mm": 13.81,
"focus_range_mm": {"min": 2.17, "max": 13.81},
"stops": {"found": True, "below": 14, "above": 20, "contact_below": 16,
"contact_above": 22, "why": ""}, "emission": EMISSION}),
"contact_above": 22, "why": ""},
"window": dict(FOCUS_WINDOW), "ladder_z": ladder_z(EDGE_DEFAULT, 14, 20),
"emission": EMISSION}),
"laser.floor": ([("floor-arm", "continue", ["Continue"]),
("floor-pick", "choice", ["2", "4", "6", "8", "None"])],
{"faintest_density": 8.0, "floor_density": 10.0, "emission": EMISSION}),
@@ -50,10 +89,11 @@ SCRIPT = {
{"lit_s": 30.0, "dose_raw_s": 12000, "peak_c": 0.72, "k_density": 2.14e-5,
"k_cw": 2.78e-5, "emission": EMISSION}),
}
# What each card writes when it ends.
# What a card writes when it starts (the focus card's window, before its
# controller starts) and when it ends.
START_WRITES = {"laser.focus": dict(STOPS)}
WRITES = {
"laser.focus": {"lens_hall_edge_z_mm": "6.96", "lens_stop_below_steps": "14",
"lens_stop_above_steps": "20"},
"laser.focus": {"lens_hall_edge_z_mm": "6.96"},
"laser.floor": {"laser_floor_density": "10"},
"laser.dose-curve": {"laser_dose_curve": "10:0.44,100:100"},
"laser.corner": {"laser_corner_gamma": "1.5"},
@@ -115,9 +155,11 @@ class ScriptedDaemon:
self.versions = {}
self.answers = {} # wid -> [values]
self.order = [] # the wizards as started
self.settings_at_start = {} # wid -> the settings as the wizard found them
self.previews = []
self.queue = []
self.seq = 0
self.stray_z = None # a Z the served program commands beyond the reach, when set
fake.on_get = self.on_get
fake.on_post = self.on_post
@@ -130,6 +172,10 @@ class ScriptedDaemon:
return True
def on_get(self, path, q):
s = self.fake.state["settings"]
if path == "/status":
# the lens block follows the settings, as the daemon's does
return 200, dict(self.fake.state["status"], lens=lens_block(s))
if path == "/wiz/dark":
return 200, dict(self.dark)
if path == "/wiz":
@@ -140,8 +186,18 @@ class ScriptedDaemon:
return 404, {"error": "no such card"}
return 200, b"<svg xmlns='http://www.w3.org/2000/svg'></svg>", "image/svg+xml"
if path == "/wiz/sheet.gcode":
# the frame and the text are M4 at the mark dose, as the daemon serves them
return 200, b"; mock\nM4 S400\nG0 X60 Y42\nG1 X120 Y42 F3000\nM5\n", "text/plain"
# the frame and the text are M4 at the mark dose, as the daemon
# serves them; the head's Z is the edge, the tail goes back to
# it, and the focus card's ladder spans the window the settings
# hold now
lens = lens_block(s)
lines = ["; mock", "G0 Z%.2f" % lens["edge_z"], "M4 S400", "G0 X60 Y42", "G1 X120 Y42 F3000"]
if q.get("card") == "laser.focus":
lines += ["G0 Z%.2f" % z for z in ladder_z(lens["edge_z"], lens["below"], lens["above"])]
if self.stray_z is not None:
lines.append("G0 Z%.2f" % self.stray_z)
lines += ["M5", "G0 Z%.2f" % lens["edge_z"], "G0 X0 Y0", "M2"]
return 200, ("\n".join(lines) + "\n").encode(), "text/plain"
return None
def on_post(self, path, form):
@@ -151,6 +207,8 @@ class ScriptedDaemon:
if wid not in SCRIPT:
return 404, {"error": "no such wizard"}
self.order.append(wid)
self.settings_at_start[wid] = dict(s)
s.update(START_WRITES.get(wid, {}))
self.queue = list(SCRIPT[wid][0])
self.dark.update({"id": wid, "running": True, "phase": "starting", "result": None, "error": ""})
self.open_next()
@@ -199,7 +257,7 @@ class SheetRunTests(unittest.TestCase):
self.assertEqual(d.answers["laser.focus"], ["Continue", "11", "Keep"])
self.assertEqual(d.answers["laser.floor"], ["Continue", "8"])
self.assertEqual(d.answers["laser.corner"], ["Continue", "1.50"])
self.assertEqual(d.previews, LIVE)
self.assertEqual(d.previews, PREVIEWS)
cards = run.evidence["cards"]
self.assertEqual(sorted(cards), sorted(["sheet.place"] + LIVE))
for wid in LIVE:
@@ -208,6 +266,24 @@ class SheetRunTests(unittest.TestCase):
self.assertNotIn("emission", run.evidence)
self.assertEqual(cards["sheet.place"]["result"]["origin_x"], 100)
self.assertEqual(run.evidence["settings_before"], FOUND)
# A fresh machine's run: the lens settings were gone before the
# frame, the frame's program ran in the fallback window, the focus
# card's window was in the settings when the floor card started,
# and the window check filed what it compared.
for k in commission_sheet.LENS_SETTINGS:
self.assertNotIn(k, d.settings_at_start["sheet.frame"], k)
self.assertEqual(run.evidence["lens_fresh"]["stops_found"], False)
self.assertEqual(run.evidence["program_z"]["sheet.frame"]["reach"], [-0.07, 7.46])
floor_start = d.settings_at_start["laser.floor"]
self.assertEqual({k: floor_start[k] for k in commission_sheet.LENS_SETTINGS},
{"lens_hall_edge_z_mm": "6.96", "lens_stop_below_steps": "14", "lens_stop_above_steps": "20"})
fw = run.evidence["focus_window"]
self.assertEqual(fw["window"], FOCUS_WINDOW)
self.assertEqual(fw["settings"]["lens_stop_below_steps"], "14")
self.assertEqual(len(fw["ladder_z"]), 12)
self.assertEqual(fw["program_z_before"]["reach"], [-0.07, 7.46]) # the fallback ladder
self.assertEqual(fw["program_z_after"]["reach"], [2.17, 13.8]) # the written window at the new edge
self.assertEqual(len(fw["program_z_after"]["z"]), 14) # head, twelve lines, tail
for k, v in FOUND.items():
self.assertEqual(self.fake.state["settings"][k], v, k) # restored
@@ -261,6 +337,44 @@ class SheetRunTests(unittest.TestCase):
finally:
SCRIPT["laser.floor"] = (SCRIPT["laser.floor"][0], dict(SCRIPT_DARK, emission=EMISSION))
def test_a_focus_window_the_settings_do_not_hold_fails_the_run(self):
# The defect the bench found: the focus card burned its ladder over
# the stops it found while the controller's Z limit stood on the
# settings, which did not hold them. A daemon whose focus result
# names a window the settings do not hold fails the run before the
# floor card, and the settings still go back.
from forgetest.runner import Failed
saved = dict(START_WRITES["laser.focus"])
START_WRITES["laser.focus"] = {}
try:
t = catalog.load_suite()["commission.sheet"]
run = Run("test", t.id, t.title)
run.unattended = True
with self.assertRaises(Failed) as cm:
t.fn(Context(run, None, t))
self.assertIn("the settings hold", str(cm.exception))
self.assertEqual(self.daemon.order, ["sheet.place", "sheet.frame", "laser.focus"])
for k, v in FOUND.items():
self.assertEqual(self.fake.state["settings"][k], v, k)
finally:
START_WRITES["laser.focus"] = saved
def test_a_program_z_beyond_the_reach_fails_before_the_burn(self):
# A served program that commands a Z the lens does not reach is
# refused before the card starts: nothing burns.
from forgetest.runner import Failed
self.daemon.stray_z = 10.6
t = catalog.load_suite()["commission.sheet"]
run = Run("test", t.id, t.title)
run.unattended = True
with self.assertRaises(Failed) as cm:
t.fn(Context(run, None, t))
self.assertIn("outside the lens window", str(cm.exception))
self.assertIn("10.6", str(cm.exception))
self.assertEqual(self.daemon.order, ["sheet.place"])
for k, v in FOUND.items():
self.assertEqual(self.fake.state["settings"][k], v, k)
if __name__ == "__main__":
unittest.main()
@@ -2,5 +2,5 @@
# only SRCREV and PV here - the image manifest leaves *-pin.inc out of the
# layer content hash because the component entry already identifies the
# pinned source (forgefirm-image-manifest.bbclass).
SRCREV = "a9e7073b2325c8c9d89d490fe6d067abfac22da5"
PV = "0.1.17"
SRCREV = "2380e07cce024e40f6097bdbe8fdb2034b5bd624"
PV = "0.1.24"
@@ -15,4 +15,4 @@
# it to equal the rootfs stamp, the .fw meta-version and the release tag,
# and the acceptance test image.health compares the stamp on the running
# machine with the manifest's.
FORGEFIRM_RELEASE ?= "0.0.2"
FORGEFIRM_RELEASE ?= "0.0.4"
File diff suppressed because it is too large Load Diff
+97
View File
@@ -0,0 +1,97 @@
# ForgeFIRM acceptance - 20260911172215 (dev)
- Image: `20260911172215 (dev)` (forgefirm-image-dev)
- Manifest identity: `fb24c3f4508ae54592f8d0b98ed0b2a07a933274980d8c1e4e5a967618ef9bd8`
- Catalog: `ceabc830345fc4cac4832de691c0571e93a5c4e155666ce91601c844c40942fc`
- Campaign: `c-20260911182256-078f` opened 2026-09-11T18:22:56Z
- Exported: 2026-09-11T18:54:20Z
- **Release authorized: YES**
- Tests: 83 total, 83 satisfied (57 inherited), 0 required
| Test | Kind | Result | Run at | Campaign | Inherited |
|---|---|---|---|---|---|
| `image.health` | auto, core | PASS | 2026-09-11T18:22:57Z | `c-20260911182256-078f` | no |
| `image.license-bundle` | auto | PASS | 2026-09-11T17:29:26Z | `c-20260911172921-9426` | yes |
| `kernel.latch-locked-idle` | auto, core | PASS | 2026-09-11T18:22:58Z | `c-20260911182256-078f` | no |
| `kernel.k1-k2` | auto, core, takeover | PASS | 2026-09-11T18:23:19Z | `c-20260911182256-078f` | no |
| `kernel.deadman-close` | auto, core, takeover | PASS | 2026-09-11T18:23:29Z | `c-20260911182256-078f` | no |
| `kernel.backtrack-bounds` | auto, core, takeover | PASS | 2026-09-11T18:23:48Z | `c-20260911182256-078f` | no |
| `kernel.fire-line` | auto, core, takeover | PASS | 2026-09-11T18:24:32Z | `c-20260911182256-078f` | no |
| `kernel.resume-lead` | auto, takeover | PASS | 2026-09-11T17:31:23Z | `c-20260911172921-9426` | yes |
| `kernel.pic-soc-load` | auto | PASS | 2026-09-11T17:31:26Z | `c-20260911172921-9426` | yes |
| `forgectrl.auth` | auto | PASS | 2026-09-11T17:31:27Z | `c-20260911172921-9426` | yes |
| `forgectrl.settings-bounds` | auto | PASS | 2026-09-11T17:31:27Z | `c-20260911172921-9426` | yes |
| `forgectrl.panel-serves` | auto | PASS | 2026-09-11T17:29:23Z | `c-20260911172921-9426` | yes |
| `commission.gate-blocks-controllers` | auto, takeover | PASS | 2026-09-11T17:31:46Z | `c-20260911172921-9426` | yes |
| `commission.override-until-reboot` | auto | PASS | 2026-09-11T17:31:46Z | `c-20260911172921-9426` | yes |
| `commission.advisories-rehash` | auto, takeover | PASS | 2026-09-11T17:31:55Z | `c-20260911172921-9426` | yes |
| `commission.account-login` | auto, takeover | PASS | 2026-09-11T17:32:44Z | `c-20260911172921-9426` | yes |
| `commission.https-only-writes` | auto | PASS | 2026-09-11T17:32:45Z | `c-20260911172921-9426` | yes |
| `commission.ssh-until-reboot` | auto | PASS | 2026-09-11T17:32:46Z | `c-20260911172921-9426` | yes |
| `commission.cloud-disabled-surface` | auto | PASS | 2026-09-11T17:32:47Z | `c-20260911172921-9426` | yes |
| `commission.factory-return` | auto | PASS | 2026-09-11T17:32:49Z | `c-20260911172921-9426` | yes |
| `commission.machine-name` | auto | PASS | 2026-09-11T17:32:50Z | `c-20260911172921-9426` | yes |
| `commission.first-run-flow` | operator, takeover | PASS | 2026-09-11T17:54:32Z | `c-20260911172921-9426` | yes |
| `commission.first-run-page` | operator, takeover | PASS | 2026-09-11T17:55:49Z | `c-20260911172921-9426` | yes |
| `commission.cert-page` | auto | PASS | 2026-09-11T17:32:50Z | `c-20260911172921-9426` | yes |
| `commission.what-changed` | auto, takeover | PASS | 2026-09-11T17:32:59Z | `c-20260911172921-9426` | yes |
| `commission.record-export` | auto | PASS | 2026-09-11T17:34:22Z | `c-20260911172921-9426` | yes |
| `commission.mirror` | auto, takeover | PASS | 2026-09-11T17:34:54Z | `c-20260911172921-9426` | yes |
| `commission.check-switches` | operator | PASS | 2026-09-11T17:56:07Z | `c-20260911172921-9426` | yes |
| `commission.check-sensors` | auto | PASS | 2026-09-11T17:34:34Z | `c-20260911172921-9426` | yes |
| `commission.check-airflow` | auto, takeover | PASS | 2026-09-11T17:36:26Z | `c-20260911172921-9426` | yes |
| `commission.check-cameras` | operator | PASS | 2026-09-11T17:56:22Z | `c-20260911172921-9426` | yes |
| `commission.check-motion` | auto, takeover | PASS | 2026-09-11T17:37:06Z | `c-20260911172921-9426` | yes |
| `commission.check-flow-verify` | auto, takeover | PASS | 2026-09-11T17:44:42Z | `c-20260911172921-9426` | yes |
| `commission.cloud-header-capture` | operator, takeover | PASS | 2026-09-11T17:59:41Z | `c-20260911172921-9426` | yes |
| `commission.sheet` | live, takeover | PASS | 2026-09-11T18:17:29Z | `c-20260911172921-9426` | yes |
| `logs.tree-tail-export` | auto | PASS | 2026-09-11T17:33:40Z | `c-20260911172921-9426` | yes |
| `logs.routing` | auto | PASS | 2026-09-11T17:44:46Z | `c-20260911172921-9426` | yes |
| `logs.level-settings` | auto | PASS | 2026-09-11T17:44:46Z | `c-20260911172921-9426` | yes |
| `motion.pacing` | auto | PASS | 2026-09-11T17:36:41Z | `c-20260911172921-9426` | yes |
| `motion.jog-roundtrip` | auto | PASS | 2026-09-11T17:45:00Z | `c-20260911172921-9426` | yes |
| `motion.microstep-modes` | auto | PASS | 2026-09-11T17:45:25Z | `c-20260911172921-9426` | yes |
| `motion.liveness-probe` | auto | PASS | 2026-09-11T17:45:33Z | `c-20260911172921-9426` | yes |
| `motion.gate-waits-for-lid` | operator | PASS | 2026-09-11T18:24:48Z | `c-20260911182256-078f` | no |
| `motion.cancel-abort` | auto | PASS | 2026-09-11T17:45:40Z | `c-20260911172921-9426` | yes |
| `motion.deadman` | auto | PASS | 2026-09-11T17:46:09Z | `c-20260911172921-9426` | yes |
| `motion.button-hold-resume` | operator | PASS | 2026-09-11T18:24:59Z | `c-20260911182256-078f` | no |
| `motion.lid-cancel-home` | operator | PASS | 2026-09-11T18:25:15Z | `c-20260911182256-078f` | no |
| `motion.interlock-cancel-home` | operator | PASS | 2026-09-11T18:25:24Z | `c-20260911182256-078f` | no |
| `motion.lid-policy-hold` | operator | PASS | 2026-09-11T18:25:37Z | `c-20260911182256-078f` | no |
| `motion.step-timing-under-load` | auto | PASS | 2026-09-11T17:46:40Z | `c-20260911172921-9426` | yes |
| `cooling.aa-offset-calibrate` | auto | PASS | 2026-09-11T17:38:38Z | `c-20260911172921-9426` | yes |
| `cooling.flow-verify` | auto | PASS | 2026-09-11T17:41:29Z | `c-20260911172921-9426` | yes |
| `cooling.fans-quiet-after-motion` | auto | PASS | 2026-09-11T17:47:35Z | `c-20260911172921-9426` | yes |
| `cooling.gate-off` | auto | PASS | 2026-09-11T17:35:02Z | `c-20260911172921-9426` | yes |
| `cooling.floor-and-warm-up` | auto | PASS | 2026-09-11T17:49:02Z | `c-20260911172921-9426` | yes |
| `cooling.tec-drive` | auto | PASS | 2026-09-11T17:49:10Z | `c-20260911172921-9426` | yes |
| `cooling.fire-watch-tiers` | auto | PASS | 2026-09-11T17:49:25Z | `c-20260911172921-9426` | yes |
| `cooling.crash-watch-plumbing` | auto | PASS | 2026-09-11T17:49:32Z | `c-20260911172921-9426` | yes |
| `cooling.critical-tier` | auto | PASS | 2026-09-11T17:49:41Z | `c-20260911172921-9426` | yes |
| `cooling.fan-gate-trips` | auto | PASS | 2026-09-11T17:35:45Z | `c-20260911172921-9426` | yes |
| `cooling.flow-under-load` | live | PASS | 2026-09-11T18:41:47Z | `c-20260911182256-078f` | no |
| `laser.power-floor` | auto | PASS | 2026-09-11T17:49:42Z | `c-20260911172921-9426` | yes |
| `laser.emission-witness` | live, core | PASS | 2026-09-11T18:40:15Z | `c-20260911182256-078f` | no |
| `laser.m5-rapid-dark` | live | PASS | 2026-09-11T18:42:25Z | `c-20260911182256-078f` | no |
| `laser.disarm-in-hold` | live | PASS | 2026-09-11T18:43:51Z | `c-20260911182256-078f` | no |
| `laser.armed-kill` | live | PASS | 2026-09-11T18:44:43Z | `c-20260911182256-078f` | no |
| `laser.arm-wait-lid` | operator | PASS | 2026-09-11T18:25:57Z | `c-20260911182256-078f` | no |
| `laser.pause-resume-lid-cancel` | live | PASS | 2026-09-11T18:45:12Z | `c-20260911182256-078f` | no |
| `camera.snapshot` | auto | PASS | 2026-09-11T17:49:57Z | `c-20260911172921-9426` | yes |
| `camera.sensor-profile` | auto | PASS | 2026-09-11T17:50:10Z | `c-20260911172921-9426` | yes |
| `camera.h264-stream` | auto | PASS | 2026-09-11T17:50:23Z | `c-20260911172921-9426` | yes |
| `camera.frame-health` | auto | PASS | 2026-09-11T17:50:36Z | `c-20260911172921-9426` | yes |
| `camera.lid-privacy` | operator | PASS | 2026-09-11T18:26:12Z | `c-20260911182256-078f` | no |
| `camera.key-read` | auto | PASS | 2026-09-11T17:50:37Z | `c-20260911172921-9426` | yes |
| `update.slots-and-signature` | auto | PASS | 2026-09-11T17:32:49Z | `c-20260911172921-9426` | yes |
| `cloud.mode-switch` | operator | PASS | 2026-09-11T17:58:28Z | `c-20260911172921-9426` | yes |
| `cloud.service-protocol` | operator | PASS | 2026-09-11T18:46:13Z | `c-20260911182256-078f` | no |
| `cloud.lid-interlock-abort` | live | PASS | 2026-09-11T18:47:21Z | `c-20260911182256-078f` | no |
| `cloud.lid-during-button-wait` | operator | PASS | 2026-09-11T18:26:51Z | `c-20260911182256-078f` | no |
| `cloud.verdict-hold` | operator | PASS | 2026-09-11T18:38:58Z | `c-20260911182256-078f` | no |
| `cloud.pause-resume` | live | PASS | 2026-09-11T18:49:33Z | `c-20260911182256-078f` | no |
| `cloud.oversize-stream` | live | PASS | 2026-09-11T18:52:19Z | `c-20260911182256-078f` | no |
| `cloud.paused-lid-cancel` | live | PASS | 2026-09-11T18:52:48Z | `c-20260911182256-078f` | no |
Artifact sha256: `d7bb5d900f9237711a17ae207df90025605340c2eb2eaf34f9ed508cc3a899a6`
File diff suppressed because it is too large Load Diff
+99
View File
@@ -0,0 +1,99 @@
# ForgeFIRM acceptance - 20260912180956 (dev)
- Image: `20260912180956 (dev)` (forgefirm-image-dev)
- Manifest identity: `8bb5b6f39ca4e0a5afca9b620aa38980c4ede8cdfda240b24bbc01fd6c28fa05`
- Catalog: `f906de649b7533e3713309f141afacbc06420f96824936a60b70febd71bb2df7`
- Campaign: `c-20260912182427-e1ee` opened 2026-09-12T18:24:27Z
- Exported: 2026-09-12T19:21:35Z
- **Release authorized: YES**
- Tests: 85 total, 85 satisfied (50 inherited), 0 required
| Test | Kind | Result | Run at | Campaign | Inherited |
|---|---|---|---|---|---|
| `image.health` | auto, core | PASS | 2026-09-12T18:24:28Z | `c-20260912182427-e1ee` | no |
| `image.license-bundle` | auto | PASS | 2026-09-12T16:56:02Z | `c-20260912165539-ac51` | yes |
| `kernel.latch-locked-idle` | auto, core | PASS | 2026-09-12T18:24:29Z | `c-20260912182427-e1ee` | no |
| `kernel.k1-k2` | auto, core, takeover | PASS | 2026-09-12T18:24:50Z | `c-20260912182427-e1ee` | no |
| `kernel.deadman-close` | auto, core, takeover | PASS | 2026-09-12T18:25:02Z | `c-20260912182427-e1ee` | no |
| `kernel.backtrack-bounds` | auto, core, takeover | PASS | 2026-09-12T18:25:21Z | `c-20260912182427-e1ee` | no |
| `kernel.fire-line` | auto, core, takeover | PASS | 2026-09-12T18:26:04Z | `c-20260912182427-e1ee` | no |
| `kernel.resume-lead` | auto, takeover | PASS | 2026-09-11T17:31:23Z | `c-20260911172921-9426` | yes |
| `kernel.pic-soc-load` | auto | PASS | 2026-09-11T17:31:26Z | `c-20260911172921-9426` | yes |
| `forgectrl.auth` | auto | PASS | 2026-09-12T16:58:12Z | `c-20260912165539-ac51` | yes |
| `forgectrl.settings-bounds` | auto | PASS | 2026-09-12T16:58:19Z | `c-20260912165539-ac51` | yes |
| `forgectrl.panel-serves` | auto | PASS | 2026-09-12T16:55:54Z | `c-20260912165539-ac51` | yes |
| `commission.gate-blocks-controllers` | auto, takeover | PASS | 2026-09-12T16:58:44Z | `c-20260912165539-ac51` | yes |
| `commission.override-until-reboot` | auto | PASS | 2026-09-12T16:58:44Z | `c-20260912165539-ac51` | yes |
| `commission.advisories-rehash` | auto, takeover | PASS | 2026-09-12T16:58:53Z | `c-20260912165539-ac51` | yes |
| `commission.account-login` | auto, takeover | PASS | 2026-09-12T16:59:43Z | `c-20260912165539-ac51` | yes |
| `commission.https-only-writes` | auto | PASS | 2026-09-12T16:59:44Z | `c-20260912165539-ac51` | yes |
| `commission.ssh-until-reboot` | auto | PASS | 2026-09-12T16:59:46Z | `c-20260912165539-ac51` | yes |
| `commission.cloud-disabled-surface` | auto | PASS | 2026-09-12T16:59:46Z | `c-20260912165539-ac51` | yes |
| `commission.factory-return` | auto | PASS | 2026-09-12T16:59:49Z | `c-20260912165539-ac51` | yes |
| `commission.machine-name` | auto | PASS | 2026-09-11T17:32:50Z | `c-20260911172921-9426` | yes |
| `commission.first-run-flow` | operator, takeover | PASS | 2026-09-12T17:10:18Z | `c-20260912170819-8dec` | yes |
| `commission.first-run-page` | operator, takeover | PASS | 2026-09-11T17:55:49Z | `c-20260911172921-9426` | yes |
| `commission.cert-page` | auto | PASS | 2026-09-12T16:59:50Z | `c-20260912165539-ac51` | yes |
| `commission.what-changed` | auto, takeover | PASS | 2026-09-12T16:59:59Z | `c-20260912165539-ac51` | yes |
| `commission.record-export` | auto | PASS | 2026-09-12T17:01:26Z | `c-20260912165539-ac51` | yes |
| `commission.mirror` | auto, takeover | PASS | 2026-09-11T17:34:54Z | `c-20260911172921-9426` | yes |
| `commission.check-switches` | operator | PASS | 2026-09-12T17:10:23Z | `c-20260912170819-8dec` | yes |
| `commission.check-sensors` | auto | PASS | 2026-09-12T18:26:17Z | `c-20260912182427-e1ee` | no |
| `commission.check-airflow` | auto, takeover | PASS | 2026-09-12T18:27:51Z | `c-20260912182427-e1ee` | no |
| `commission.check-cameras` | operator | PASS | 2026-09-12T17:10:54Z | `c-20260912170819-8dec` | yes |
| `commission.check-motion` | auto, takeover | PASS | 2026-09-12T18:28:17Z | `c-20260912182427-e1ee` | no |
| `commission.check-flow-verify` | auto, takeover | PASS | 2026-09-12T17:16:41Z | `c-20260912170819-8dec` | yes |
| `commission.cloud-header-capture` | operator, takeover | PASS | 2026-09-12T17:29:14Z | `c-20260912170819-8dec` | yes |
| `commission.sheet` | live, takeover | PASS | 2026-09-12T19:02:59Z | `c-20260912182427-e1ee` | no |
| `logs.tree-tail-export` | auto | PASS | 2026-09-12T17:00:43Z | `c-20260912165539-ac51` | yes |
| `logs.routing` | auto | PASS | 2026-09-11T17:44:46Z | `c-20260911172921-9426` | yes |
| `logs.level-settings` | auto | PASS | 2026-09-12T17:16:42Z | `c-20260912170819-8dec` | yes |
| `motion.pacing` | auto | PASS | 2026-09-11T17:36:41Z | `c-20260911172921-9426` | yes |
| `motion.jog-roundtrip` | auto | PASS | 2026-09-11T17:45:00Z | `c-20260911172921-9426` | yes |
| `motion.microstep-modes` | auto | PASS | 2026-09-12T17:17:07Z | `c-20260912170819-8dec` | yes |
| `motion.liveness-probe` | auto | PASS | 2026-09-11T17:45:33Z | `c-20260911172921-9426` | yes |
| `motion.gate-waits-for-lid` | operator | PASS | 2026-09-12T17:17:23Z | `c-20260912170819-8dec` | yes |
| `motion.cancel-abort` | auto | PASS | 2026-09-11T17:45:40Z | `c-20260911172921-9426` | yes |
| `motion.deadman` | auto | PASS | 2026-09-12T17:17:53Z | `c-20260912170819-8dec` | yes |
| `motion.button-hold-resume` | operator | PASS | 2026-09-11T18:24:59Z | `c-20260911182256-078f` | yes |
| `motion.lid-cancel-home` | operator | PASS | 2026-09-11T18:25:15Z | `c-20260911182256-078f` | yes |
| `motion.interlock-cancel-home` | operator | PASS | 2026-09-11T18:25:24Z | `c-20260911182256-078f` | yes |
| `motion.lid-policy-hold` | operator | PASS | 2026-09-11T18:25:37Z | `c-20260911182256-078f` | yes |
| `motion.step-timing-under-load` | auto | PASS | 2026-09-11T17:46:40Z | `c-20260911172921-9426` | yes |
| `cooling.aa-offset-calibrate` | auto | PASS | 2026-09-12T18:29:50Z | `c-20260912182427-e1ee` | no |
| `cooling.flow-verify` | auto | PASS | 2026-09-12T18:32:33Z | `c-20260912182427-e1ee` | no |
| `cooling.fans-quiet-after-motion` | auto | PASS | 2026-09-12T18:33:39Z | `c-20260912182427-e1ee` | no |
| `cooling.gate-off` | auto | PASS | 2026-09-12T18:26:26Z | `c-20260912182427-e1ee` | no |
| `cooling.floor-and-warm-up` | auto | PASS | 2026-09-12T18:35:06Z | `c-20260912182427-e1ee` | no |
| `cooling.tec-drive` | auto | PASS | 2026-09-12T18:35:15Z | `c-20260912182427-e1ee` | no |
| `cooling.fire-watch-tiers` | auto | PASS | 2026-09-12T18:35:31Z | `c-20260912182427-e1ee` | no |
| `cooling.crash-watch-plumbing` | auto | PASS | 2026-09-12T18:35:38Z | `c-20260912182427-e1ee` | no |
| `cooling.critical-tier` | auto | PASS | 2026-09-12T18:35:47Z | `c-20260912182427-e1ee` | no |
| `cooling.fan-gate-trips` | auto | PASS | 2026-09-12T18:27:10Z | `c-20260912182427-e1ee` | no |
| `cooling.flow-under-load` | live | PASS | 2026-09-12T19:05:38Z | `c-20260912182427-e1ee` | no |
| `cooling.fan-duty-readback` | auto | PASS | 2026-09-12T18:36:02Z | `c-20260912182427-e1ee` | no |
| `laser.power-floor` | auto | PASS | 2026-09-12T17:20:57Z | `c-20260912170819-8dec` | yes |
| `laser.emission-witness` | live, core | PASS | 2026-09-12T18:50:46Z | `c-20260912182427-e1ee` | no |
| `laser.m5-rapid-dark` | live | PASS | 2026-09-12T19:06:30Z | `c-20260912182427-e1ee` | no |
| `laser.disarm-in-hold` | live | PASS | 2026-09-12T19:08:04Z | `c-20260912182427-e1ee` | no |
| `laser.armed-kill` | live | PASS | 2026-09-12T19:08:58Z | `c-20260912182427-e1ee` | no |
| `laser.arm-wait-lid` | operator | PASS | 2026-09-12T18:36:21Z | `c-20260912182427-e1ee` | no |
| `laser.pause-resume-lid-cancel` | live | PASS | 2026-09-12T19:09:43Z | `c-20260912182427-e1ee` | no |
| `camera.snapshot` | auto | PASS | 2026-09-12T17:10:39Z | `c-20260912170819-8dec` | yes |
| `camera.sensor-profile` | auto | PASS | 2026-09-12T17:21:13Z | `c-20260912170819-8dec` | yes |
| `camera.h264-stream` | auto | PASS | 2026-09-12T17:21:27Z | `c-20260912170819-8dec` | yes |
| `camera.frame-health` | auto | PASS | 2026-09-12T17:21:40Z | `c-20260912170819-8dec` | yes |
| `camera.lid-privacy` | operator | PASS | 2026-09-12T17:21:56Z | `c-20260912170819-8dec` | yes |
| `camera.key-read` | auto | PASS | 2026-09-12T17:21:57Z | `c-20260912170819-8dec` | yes |
| `update.slots-and-signature` | auto | PASS | 2026-09-12T16:59:48Z | `c-20260912165539-ac51` | yes |
| `update.release-check` | auto | PASS | 2026-09-12T17:21:58Z | `c-20260912170819-8dec` | yes |
| `cloud.mode-switch` | operator | PASS | 2026-09-12T18:38:00Z | `c-20260912182427-e1ee` | no |
| `cloud.service-protocol` | operator | PASS | 2026-09-12T19:10:50Z | `c-20260912182427-e1ee` | no |
| `cloud.lid-interlock-abort` | live | PASS | 2026-09-12T19:11:59Z | `c-20260912182427-e1ee` | no |
| `cloud.lid-during-button-wait` | operator | PASS | 2026-09-12T17:24:21Z | `c-20260912170819-8dec` | yes |
| `cloud.verdict-hold` | operator | PASS | 2026-09-12T18:40:33Z | `c-20260912182427-e1ee` | no |
| `cloud.pause-resume` | live | PASS | 2026-09-12T19:14:09Z | `c-20260912182427-e1ee` | no |
| `cloud.oversize-stream` | live | PASS | 2026-09-12T19:16:55Z | `c-20260912182427-e1ee` | no |
| `cloud.paused-lid-cancel` | live | PASS | 2026-09-12T19:17:30Z | `c-20260912182427-e1ee` | no |
Artifact sha256: `9650b1ed9862eefab808ca32cdaa8f6b9b6debd8ee640adde9d18dcdfc03ba25`
+5
View File
@@ -0,0 +1,5 @@
ForgeFIRM v0.0.4 is the first public beta release.
Expect bugs. When you find one, open an issue in this repository or report it on the community forum at https://community.openglow.org so it can be fixed.
Every release below 0.1.0 is a beta release. Installation, usage, and safety are documented at https://docs.forgefirm.org/.
+98 -2
View File
@@ -25,6 +25,19 @@ no root) and drives it over TCP:
does not free Z either
6. X and Y are still free after both writes
A referenced lens opens the envelope to the window the shared settings
hold, the same keys the daemon's wizards build their Z from. forgectrl
leaves the lens on its hall edge and a marker in the state directory
before the controller starts; the driver takes it as it loads its
settings and places the edge on its step grid:
7. the marker with the edge alone: Z sits at the edge's grid step, the
fallback window (10 below, 12 above) runs to its ends, and two
half-steps past either end alarms
8. the marker with the focus card's stops (14 below, 20 above): the
wider window runs to its ends, two half-steps past either alarms
9. a stop count out of range (41) falls back on its side alone
The binary keeps its settings in EEPROM.DAT in the working directory, so
each run starts from defaults in a temporary directory and leaves
nothing behind.
@@ -53,13 +66,24 @@ def fail(msg):
class Session:
"""One null-sink controller process with its own settings store."""
"""One null-sink controller process with its own settings store.
`conf` (key: value) is the shared config the driver reads; with
`referenced` the lens marker forgectrl leaves is in the state
directory, so the driver takes the lens reference at its start."""
def __init__(self):
def __init__(self, conf=None, referenced=False):
self.workdir = tempfile.mkdtemp(prefix="z-envelope-")
env = dict(os.environ, GF_STATE_DIR=self.workdir, FFLOG_STDERR="1")
for key in ("GFSINK", "GF_SWITCH_FILE", "GF_VERDICT_FILE"):
env.pop(key, None)
conf_path = os.path.join(self.workdir, "forgefirm.conf")
with open(conf_path, "w") as f:
for k, v in (conf or {}).items():
f.write("%s = %s\n" % (k, v))
env["GFHOME_CONF"] = conf_path
if referenced:
with open(os.path.join(self.workdir, "lens.home"), "w") as f:
f.write("edge 0 3 3\n")
self.proc = subprocess.Popen([BIN, "-p", str(PORT)],
cwd=self.workdir, env=env,
stdout=subprocess.DEVNULL,
@@ -113,6 +137,23 @@ class Session:
m = re.search(r"<(\w+)", self.send("?"))
return m.group(1) if m else "?"
def mpos_z(self):
"""The reported machine Z (no work offset is set in a session)."""
m = re.search(r"[MW]Pos:(-?[\d.]+),(-?[\d.]+),(-?[\d.]+)", self.send("?"))
if not m:
fail("no position in the status report")
return float(m.group(3))
def wait_idle(self, timeout=15.0):
"""A move that ran plays out before the next line is judged: the
soft limit on a moving machine holds first and alarms after."""
end = time.time() + timeout
while time.time() < end:
if self.state() == "Idle":
return
time.sleep(0.1)
fail("the controller did not return to Idle")
def move(self, gcode):
"""Run one move. Returns True when the soft limit blocked it.
An alarm needs a reset and an unlock before the next move."""
@@ -160,6 +201,60 @@ def free_in_xy(s, when):
check(ran, "X and Y free %s" % when, "an X or Y move was blocked %s" % when)
def window_holds(s, steps, below, above, what):
"""The envelope of a referenced lens whose edge sits on grid step
`steps`: the ends of the reach (`below` and `above` half-steps from
the edge) run, two half-steps past either end alarms. The controller
keeps a half-step of slack beyond the reach, so one past is not
judged here; two is outside on every head."""
spm = float(s.setting("$102"))
top, bottom = (steps + above) / spm, (steps - below) / spm
s.wait_idle()
check(not s.move("G0 Z%.3f" % top), "%s: the top of the reach, Z %.3f, runs" % (what, top),
"%s: Z %.3f, the top of the reach, was blocked" % (what, top))
s.wait_idle()
check(not s.move("G0 Z%.3f" % bottom), "%s: the bottom of the reach, Z %.3f, runs" % (what, bottom),
"%s: Z %.3f, the bottom of the reach, was blocked" % (what, bottom))
over, under = (steps + above + 2) / spm, (steps - below - 2) / spm
s.wait_idle()
check(s.move("G0 Z%.3f" % over), "%s: two half-steps over the top, Z %.3f, alarms" % (what, over),
"%s: Z %.3f, two half-steps over the top, ran" % (what, over))
s.wait_idle()
check(s.move("G0 Z%.3f" % under), "%s: two half-steps under the bottom, Z %.3f, alarms" % (what, under),
"%s: Z %.3f, two half-steps under the bottom, ran" % (what, under))
def referenced_cases():
"""The lens referenced by forgectrl's marker: the envelope is the
window the shared settings hold, as the daemon's wizards read it."""
edge = 3.35
conf = {"lens_hall_edge_z_mm": "%.2f" % edge}
s = Session(conf=conf, referenced=True)
try:
spm = float(s.setting("$102"))
steps = int(round(edge * spm))
z = s.mpos_z()
check(abs(z - steps / spm) < 0.002,
"referenced: Z sits at the edge's grid step, %.3f" % z,
"referenced: Z reads %.3f, not the edge's grid step %.3f" % (z, steps / spm))
window_holds(s, steps, 10, 12, "the fallback window")
free_in_xy(s, "with the lens referenced")
finally:
s.close()
s = Session(conf=dict(conf, lens_stop_below_steps="14", lens_stop_above_steps="20"), referenced=True)
try:
window_holds(s, steps, 14, 20, "the focus card's window")
finally:
s.close()
s = Session(conf=dict(conf, lens_stop_below_steps="41", lens_stop_above_steps="20"), referenced=True)
try:
window_holds(s, steps, 10, 20, "a below count out of range")
finally:
s.close()
def main():
if not os.path.isfile(BIN):
fail("no controller binary at %s" % BIN)
@@ -189,6 +284,7 @@ def main():
free_in_xy(s, "after the settings writes")
finally:
s.close()
referenced_cases()
print("PASS z_envelope_test")
+15 -2
View File
@@ -365,6 +365,19 @@ else
DOCS_TAG_CMD="git -C $DOCS_DIR push origin v$VERSION"
fi
# The release notes: releases/v<version>/notes.md when the release carries
# one (staged beside the assets, not uploaded as one), GitHub's generated
# notes otherwise.
NOTES_OPT="--generate-notes"
if [ -f "$REPO/releases/v$VERSION/notes.md" ]; then
cp "$REPO/releases/v$VERSION/notes.md" "$STAGE/notes.md"
NOTES_OPT="--notes-file notes.md"
echo "notes: releases/v$VERSION/notes.md"
else
rm -f "$STAGE/notes.md"
echo "notes: generated by GitHub"
fi
cat <<EOF
== release v$VERSION staged ==
@@ -377,7 +390,7 @@ Pre-publish checklist (docs.forgefirm.org, Developers, "Release flow"):
Publish (from a directory with an authenticated gh):
cd "$STAGE"
gh release create "v$VERSION" --repo openglow-org/forgefirm \\
--title "ForgeFIRM v$VERSION" --generate-notes $PRERELEASE \\
--title "ForgeFIRM v$VERSION" $NOTES_OPT $PRERELEASE \\
$ASSETS
Push the documentation tag with it:
@@ -387,7 +400,7 @@ EOF
if [ "$PUBLISH" = "1" ]; then
command -v gh >/dev/null || die "--publish requested but gh is not on PATH"
( cd "$STAGE" && gh release create "v$VERSION" --repo openglow-org/forgefirm \
--title "ForgeFIRM v$VERSION" --generate-notes $PRERELEASE \
--title "ForgeFIRM v$VERSION" $NOTES_OPT $PRERELEASE \
$ASSETS ) \
|| die "gh release create failed"
echo "== published v$VERSION =="