FORGEFIRM_RELEASE is 0.0.7, and releases/v0.0.7 carries the release notes
and the acceptance artifact that forgetest exported on the bench reference.
The artifact is from campaign c-20260929000044-e1ea on the pins-only dev
image 20260928194539: authorized, 127 of 127 tests satisfied, 7 run on that
image and 120 inherited. scripts/acceptance-gate.py against that build's
release manifest gives AUTHORIZED, with inputs identical to the bench image.
The pin to forgectrl 4b39663 brings theme.css's rules for the Extensions
tab and a package's page in the panel's theme (94d8af5), which
forgetest's shared copy did not have, so check-ui-vendor would fail
forgetest-ci. The copy is forgectrl's file at 4b39663, byte for byte; the
rules only add classes forgetest's page does not use.
Proof: scripts/check-ui-vendor.py, fetching forgectrl's files from GitHub
at the pinned revision, reports the shared UI files identical.
The work since the last pins, pushed to each component's default branch,
is what the recipes now fetch:
- forgectrl 4b39663 (0.1.28 -> 0.1.29): the manual home offset and the
gantry stops by name in the panel, the Extensions tab and ui.background,
the Grbl sender keep-out, the head camera for local viewers and live
video on a package's page, the display units in the bridge's self
answer, the crumb tray's switch and offset, the Lens card's fields
saved, the listener's cipher order (ChaCha20-Poly1305 first), and the
camera pipeline (coherent capture buffers on the GPU path, the two-pass
NV12 render, the frame gate, the /cam/status timing, one switch fd).
- forgeext 2d0391e (0.1.0 -> 0.1.1): ui.background, sender.keep_out and
live video in the SDK, a package removed and installed again started
from the new install, and the same ffx archive for the same input.
- grblHAL-glowforge b34636f (0.1.19 -> 0.1.20): the manual home offset,
the gantry stops by name, the controller port's sender out and in with
the keep-out, and the crumb tray's M103 P0 and P1.
- kas lock: meta-openglow f1f7f58 -> f012d8e, kernel TLS with the NEON
crypto and the TX records in cache lines of their own (patch 0016).
Proof: bitbake -c fetch of forgectrl, forgeext, grblhal-glowforge,
linux-fslc and kernel-module-glowforge from these pins succeeds (10
tasks), with kas moving layers/meta-openglow to f012d8e. The tree
manifest from these pins holds forgectrl at 4b39663 (217 files), forgeext
at 2d0391e (90) and the driver at b34636f (177), and the coverage lint
over it finds no uncovered path and no empty entry (127 tests).
Acceptance: the same trees, through the externalsrc overlay and the
mirrored meta-openglow at f012d8e, are image 20260928182434, on which all
127 tests are satisfied (the unattended campaign and the attended tests
the operator ran). The meta-openglow move is a platform change; the
image built from these pins alone is next.
The test judges each connection by the kernel's TLS counters and the
crypto engine's interrupt, and both are the machine's, not the
connection's. On image 20260928175056's campaign the test before it
restarted forgectrl, the operator's panel (six HTTPS connections from
the bench PC) reconnected in the same second as aes-1.3#1, and the
kernel counted 7 sessions each way where the test wanted exactly 1; the
connection itself chose AES-128-GCM, carried the page byte for byte, and
counted no decrypt error. _fetch now also reads the machine's accepted
TCP connections (PassiveOpens, shared by both families); a case whose
window saw any connection but its own is measured again, up to 30 times
after a pause drawn from 0.1 to 0.9 s (so no steady poller, such as a
page polling this suite once a second, stays in step with it), and
fails if every window was contested. The judgments are unchanged: exactly one session each way for a cipher the kernel seals,
none for CBC, the engine's interrupt counting the AES records and not
the ChaCha20 ones.
Proof: pyflakes clean. On the bench reference, image 20260928175056,
with an openssl client connecting to :443 every 0.25 s for the first
6 s of the run and the drill polling this suite once a second: the
image's test fails (desktop-1.2 counted 2 sessions each way), the fixed
test passes with contested windows measured again (at most 4 attempts a
case), and passes again without the extra clients (at most 4).
Acceptance: the change is forgectrl.tls-records itself; tlsrec.py holds
no other test, so no other fingerprint moves.
The test took its first reading of the frozen probe's cpu.stat the moment
cgroup.events said frozen 1, and wanted 0 us over the next 1.5 s. The
kernel reports a task frozen before its last way into schedule() is
charged to the group, so a reading at the event can come before that
charge lands. On image 20260928170058's campaign the frozen probe was
charged 16 us and the test failed. The first reading now comes 0.2 s
after the event; the window and its judgment (0 us) are unchanged.
Proof: pyflakes clean. On the bench reference, a spinner under cpu.max
5% in a cgroup of its own, frozen 520 times with the first reading taken
both ways: read at the event (polled every 50 ms, as the test does), the
next 0.5 s was charged 4 times (13 to 20 us); read after the 0.2 s
settle, never.
Acceptance: the change is exthost.platform itself, inside its own
function, so no other test's fingerprint moves.
After a home the driver's X/Y envelope reaches to the far edges the bed
check measured (envelope_x_mm, envelope_y_mm, held by
gfhome_clamp_envelope_mm to 50 mm up to the travel plus 30 mm), and to
$130 by $131 only on a machine that was never measured. The test aimed
its refusals at $130 + 5 and $131 + 5, which holds only on an unmeasured
machine. On the bench reference, measured at X 508 and Y 288.925, image
20260928153805's campaign sent G90 G1 X500 F600, the controller rightly
ran it, and the failed check ended the test before its soft reset, so
the head drove the whole 476.6 mm while the restores were refused with
409 for 40 s; the jog check after it would have run a 500 mm jog the
same way. The test now takes the far edges as the driver does and sends
each move and the jog 5 mm past them, and it stops a move that was not
refused (the soft reset, now ahead of the checks) and a jog that was not
refused (the jog cancel) before it fails. The judgment is unchanged:
ALARM:2 with no kernel motion, error 15 for the jog, a move inside the
bed runs.
Proof: pyflakes clean. On the bench reference, image 20260928153805 with
this suite bind-mounted, the test passes: the far edges 508 and 288.925,
G90 G1 X513, Y293.9 and X-1 each refused with ALARM:2 and 0.0 mm of
kernel motion, the jog refused with error 15, the move inside the bed
run, the head handed back where it was found, the baseline clean.
Acceptance: the change is motion.soft-limits itself; its own function and
description carry it, so no other test's fingerprint moves.
forgectrl now publishes a stream frame only once the frame after it has
come back clean, withholds both neighbors of a frame the receiver
flagged, and reports health.withheld and a per-stage timing block in
/cam/status. camera.frame-health keeps its snapshot burst and adds five
seconds of MJPEG: at least 30 frames dequeued, none flagged, none
withheld, a withheld count and a timing block present while the viewer
watches, and capture end to publication above 0 and under 250 ms.
Proof: the test fails on image 20260928000227's forgectrl, which reports
neither the withheld count nor the timing block, and passes on forgectrl
with the frame gate (75 frames, none withheld, 67.3 ms capture to
publication, on the bench reference). The unit suite (500 tests),
pyflakes, and the coverage lint (0 uncovered paths) pass on the host.
The gnutls bbappend builds GnuTLS with --enable-ktls and installs
/etc/gnutls/config with ktls = true, so after the handshake the kernel
seals and opens forgectrl's HTTPS records (the kernel side is
meta-openglow's CONFIG_TLS and patch 0016). It also backports GnuTLS
dc016daf: 3.8.4 hands the kernel the record sequence number where a TLS
1.2 ChaCha20-Poly1305 connection's IV belongs, so every such connection
failed the kernel's first decryption. forgectrl is the only program on
the image that links GnuTLS.
forgectrl.tls-records, in its own module (suite/tlsrec.py), reads the
result from the outside with openssl s_client on loopback: a desktop
offer (AES-GCM first) gets ChaCha20-Poly1305 over TLS 1.3 and 1.2 and the
kernel takes both directions' keys; an AES-128-GCM-only offer, three
times over each protocol, gets it, the kernel takes its keys, and the
CAAM's job-ring interrupt counts the records; a TLS 1.2 CBC-only offer
connects and stays in GnuTLS (the control for the kernel's counters);
every copy of the panel page equals the plain-HTTP copy byte for byte,
with no decrypt error; the kernel's drivers are
rfc7539(chacha20-neon,poly1305-neon) and ctr-aes-caam with ghash-ce.
Proof: on the image before these fixes the test failed on both bugs it
names (the TLS 1.2 ChaCha20 page arrived empty with a decrypt error; the
AES-GCM pages arrived damaged); on image 20260927224418 it passes. The
unit suite (500 tests) and the coverage lint (0 uncovered paths) pass on
the host.
- scripts/bench/tray_test.py, run by the driver's CI: M103 P1 and P0
move Z and its envelope by tray_offset_mm on the lens step grid, tell
the sender, write and remove the marker beside the shared config, and
ship no step; a missing P, P2, P0.5, P-1, and an axis word are errors
that change nothing; the switch behind a move waits for the move; the
port's tray op with a sender connected, its count exact, refused out of
form, during a program, and at a package's M-code; the mode across a
soft reset and a killed controller, a start over the marker taking the
lens reference in the tray-out frame; the offset's range; an
unreferenced lens; a camera home in both modes handing the runner the
same park. The kernel-idle half of the barrier needs the pulse device:
a null sink is always idle.
- scripts/bench/ctlport_test.py: the dark case carries tray out and in
under an open armed window.
- forgetest: motion.tray in its own module, suite/tray.py, so no other
test's fingerprint moves. It switches the mode both ways through the
panel's route and through M103 from a Grbl client and judges each
switch: /status's mode, Z, and reach moved by the offset's grid, the
port's state, the marker, the reference and its source unchanged, the
client told, the kernel's counters unmoved. A jog to the Z the lens
read before a switch is refused in the other mode and taken back in
the mode found; a setup card started with the tray out is refused in
the operator's words; the panel page carries the switch. The mode goes
back as found in a finally. It does not restart the controller, which
would drop the X and Y reference: persistence is tray_test.py's.
- The bench registry and README list tray_test.py.
Proof: tray_test.py ALL PASS against the driver with the mode, and six
driver copies each missing one part of it fail. The driver's whole CI
harness list passes. The forgetest unit suite: 500 tests OK. The
coverage lint over the working trees: nothing uncovered, 126 tests. On
the bench reference, with the binaries hot-deployed on the fx18 image:
motion.tray PASS, and FAIL against the driver whose anchor lost its
source word on the second switch.
Acceptance. motion.tray is new; the suite and bench files are in no
other test's fingerprint.
- suite/extsender.py (new module, so no other test's fingerprint moves):
exthost.sender-keep-out. A reference package whose service claims the
Grbl sender out as it starts, installed with the sender.keep_out
grant (and refused without it), on an idle GRBL-mode machine with no
sender connected. It checks the holder and the ext: lease of the
extension kind, the host's claim file fresh past forgectrl's grace, a
sender from the machine's LAN address turned away with the message
and one from the loopback admitted, the operator's release (lease
gone, network welcomed, the package reading released), a new claim
after a restart, and the stale end with the notice after a disable;
everything is put back as found.
- camera.lid-privacy: the head camera answers the panel's token and the
extension host's header with the lid open, and refuses the cloud
client's form (no token, no header) and a stream for no local viewer;
the lid camera stays refused to everyone. covers gains src/auth.*.
- scripts/bench/ctlport_test.py: test_sender_out on the null-sink
controller build (Sender(host=) and closed, own_address(),
generation()).
Proof: forgetest's unit tests pass; the coverage lint passes against the
working trees' file lists. Against the current pins it reports
src/senderout.* as selecting nothing until forgectrl's pin moves to the
revision that adds it, which lands with this. On the bench reference
with the suite mounted: exthost.sender-keep-out PASS (first run found
the check racing the host's first write; the test now waits for the
file and holds past the grace), and camera.lid-privacy passed every
check of its body (its hand-back failed only on the controller gated by
the privacy advisory's Revision 5 before it was accepted).
ctlport_test.py passes in the driver's CI.
The parts the manual home rests against are the gantry stops in
homing.manual's title and comments, in manual_home_test.py's
docstrings, and in a comment of the baseline's hand-back. No check
changes.
Acceptance: homing.manual's own text changed, inside its function, so
no other test's fingerprint moves. manual_home_test.py passes against
the driver.
homing.manual runs two homes. With manual_home_x and _y unset, $H
plays no byte and declares X0 Y0 (the checks it had). With 5 and 3, $H
declares the head at minus them and jogs it to X0 Y0 before it answers:
the sender is told the declared position, the kernel plays the move,
Grbl and forgectrl read X0 Y0, and a jog behind the origin draws
error:15. The test restores homing_mode and both offsets as found, and
returns the head by what the machine read, so a move cut short still
comes home. The change stays inside the test's own function.
manual_home_test.py: case 7 is the offset's new meaning (declared at
minus the offset, the move dark under a modal M3, the port's home, the
clamps); a new case cancels the move with a jog cancel and expects it
to stop short with $H saying where; case 8 reads the declared position
from the sender's message. The bench README says so.
Proof: manual_home_test.py ALL PASS against the driver change (ten
cases), and its cancel case fails against a build with the driver's
cancel hook left out. On the bench reference, fx14 with the driver
change and this suite file in place of the image's, homing.manual PASS
in campaign c-20260926000059-e731: with no offset no byte and X0 Y0;
with 5 and 3 the sender was told X-5.002 Y-3.000, the kernel played
1067 and 640 steps, Grbl and forgectrl read X0 Y0, error:15 behind the
origin; settings restored and the baseline clean. The forgetest unit
tests pass.
Acceptance: homing.manual is the test; its covers are unchanged (the
driver's src/**, forgectrl's status and main.c).
The pin to forgectrl 55bfae9 (9f7744b) brought theme.css's extension
rules - the advisory's document box and the capability lists (cf140a9),
and a package's frame (7543eea) - that forgetest's shared copy did not
have, so check-ui-vendor failed forgetest-ci. The copy is forgectrl's file
at 55bfae9, byte for byte; the rules only add classes forgetest's page
does not use.
Proof: scripts/check-ui-vendor.py reports the shared UI files identical
at the pinned revision.
meta-openglow 2978522 -> f1f7f58. The library's pin lives in the BSP
layer and still named 6cc4f45, so the image built from 9f7744b carried
forgefirm-app 021323d beside python3-gfhardware 6cc4f45: without the
report secret in coolsvc, which forgectrl 0.1.28 requires of every
cooling report. f1f7f58 moves the library to 021323d with the app.
The extensions work, merged into each component's default branch and
pushed, is what the recipes now fetch:
- forgectrl 55bfae9 (0.1.27 -> 0.1.28): the machine lease, the job runner,
the controller port client and motion routes, scoped tokens, the event
stream, the built-in registry, the operator's door to extension packages
and the Extension packages card, the catalog from its own repository,
and privacy advisory revision 4.
- forgeext 711f099 (first pin, 0.1.0): the extension host, and ffx new
making a package's repository.
- grblHAL-glowforge 0da0fe6 (0.1.18 -> 0.1.19): manual homing, the X and Y
motor release, the controller port, custom M-codes, the envelope rule,
and the homing runner's report secret.
- forgefirm-app 021323d (0.1.30 -> 0.1.31): the report secret, and a
homing motion cut short failing the home.
- kas lock: meta-openglow 71fad52 -> 2978522, the kernel's sandbox options
(NF_TABLES, LANDLOCK, MEMCG, the cgroup scheduler) and their notes.
Proof: bitbake -c fetch of forgectrl, forgeext, grblhal-glowforge,
python3-gfhardware, gfhome, gfcloud and python3-ffmachine from these pins
succeeds (14 tasks), with kas moving layers/meta-openglow to 2978522. The
tree manifest from these pins holds forgectrl at 55bfae9 (211 files),
forgeext at 711f099 (90) and the driver at 0da0fe6 (175), and the coverage
lint over it finds no uncovered path (124 tests).
Acceptance: the meta-openglow move is a platform change, so the whole
catalog runs again on the image built from these pins.
test_stale_preconfig_reference_is_retaken_on_a_fresh_boot read the host's
uptime after boot_reference() had read it to decide, and expected the
decision its own reading implied. On a CI runner the uptime crossed
BOOT_MAX_AGE_S (600 s) between the two readings: boot_reference retook
the reference and the test expected the stale one (forgetest-ci on
0eb0a16). The test now holds uptime_s() at 60 s under and over the limit
and judges both answers: retaken with the fresh sysfs under it, the stale
reference kept and marked over it.
Proof: test_baseline passes on Windows and on Linux; with
boot_reference() ignoring the uptime (a planted fault) the test fails in
all three classes that run it.
OpenGlow's packages left forgeext for repositories of their own, so the
non-behavioral entry ("forgeext", "packages/**") names nothing. The
manifest test checks the shared package workflow's kit action as
non-behavioral in its place (".github/**" covers it). The manifest tests
pass (26); manifest.py is in no fingerprint.
The address is the catalog repository's index-1.ffi. On the scratch root
the index lists the probe in two versions: 1.1.0 asks for a capability no
firmware has, and read back it is kept and not offered, the offer being
1.0.0. An older index is refused however well signed, and the one kept
stays. An index withdrawing the probe's 1.0.0 makes inspect refuse it in
OpenGlow's words. GET /ext/catalog carries core_checked. The covers widen
from forgeext src/install.c to src/install.*. The test still never
requests the index's address.
Its own module (suite/extcat.py), so only exthost.catalog's fingerprint
moves. forgetest's 500 unit tests pass; the function was read against
_forgeext, fc.get and fc.post, and ctx.check, since no host test runs a
suite function.
Six checks in five cooling tests read forgectrl's log tail once for the
line the engine writes when it acts: the TEC-on line (cooling.tec-drive),
the off gate's line and the run-end temperature line (cooling.gate-off),
the warm-up release and the floor's off line (cooling.floor-and-warm-up),
the critical line's off line (cooling.critical-tier), and the two
put-back lines (cooling.fan-duty-readback). The engine writes to the
device first and logs after, and the line reaches the file through
rsyslog a moment later still: on the bench reference cooling.tec-drive
read thermal/tec_on = 1 and then the tail a few milliseconds before
"TEC on: coolant 26.3 C over 24.5 C, airflow up" landed, and failed. A
tail read also takes a line an earlier run left behind for this run's.
Each check now marks forgectrl's log before the action that makes the
line (the settings write, the M8, the duty written behind the engine's
back) and waits up to 5 s for the line among those written after the
mark. Every line was checked against where cool.c writes it: gates_apply
for an off gate, the run's end for the temperatures, the warm-up release,
fans_verify for a put-back, the TEC policy for the TEC. The helpers
are motion's log offset pair, imported inside each test's function as the
fire-watch tests already do; the tests that poll the tail already
(_tail_wait) are unchanged.
Proof: tests/test_cooling_suite.py's fake engine writes its lines to a
scratch forgectrl log as well as to the fake tail, and a new case puts
an earlier run's off-gate line in the log with the engine writing none
this run: it fails as it must, and against the checks before this it
passes. 25 cooling cases pass; forgetest 500 OK. On the bench reference,
image 20260925183749 with this file mounted: cooling.tec-drive,
cooling.gate-off, cooling.floor-and-warm-up, cooling.critical-tier and
cooling.fan-duty-readback PASS, each baseline clean.
Acceptance: the five tests are the change; their fingerprints move and
no other test's does.
motion.soft-limits homed only when the machine was not homed already,
and then with whatever homing_mode the machine had: it counted on the
home cloud.mode-switch used to leave behind. cloud.mode-switch now drops
its camera home when it hands the head back, so the test started
unhomed, and under the operator's homing_mode = manual its $H was a
manual home, declared at once where the head stood (2.2 s, no web-service
session), and the test failed on gfhome's missing completion line. When
it did home, it left the head at the corner and the machine homed.
The test now sets homing_mode = gfcloud and unsets the camera-home
offsets for its run, so the envelope is the bed from the home corner,
and always runs its own camera home, every motion of it judged whole
(homeoff.judge_whole_motions). A setting is written when the controller
takes it (it refuses one for a moment after a Grbl client closes). At
the end the settings are put back as found and homeoff.camera_home_return
drops the home and jogs the head back to where the test found it. The
helpers are imported inside the function: homeoff imports this module.
Proof: on the bench reference, image 20260925183749 with this file
mounted, motion.soft-limits PASS: the camera home in 52.6 s, every
motion completed; X max, Y max and X min refused with ALARM:2 and no
motion, the jog past the bed refused with error 15, the move inside the
bed ran and came back; the settings put back; the head jogged back
28.95/13.03 mm to where the test found it; the baseline clean. forgetest
499 OK.
Acceptance: the test is the change; its fingerprint moves and no other
test's does.
wait_quiet took the machine as quiet after 8 s without the start of a
motion, a park, a run or a lens homing. Between a lid image and its next
move the service is working on the image and the log is silent, and on
the bench reference the re-hunt's moves came 9.6 s apart: cloud.mode-switch
switched back to GRBL mode in the middle of the re-hunt, twice, the first
time canceling a motion at 988 of its 1002 steps.
Every line of a service action now counts as activity: the requests, the
image uploads, the action ends, the runs, the parks, the motions and the
lens homing. The quiet is 30 s. Over 1901 motion, lid-image and hunt
requests in the bench reference's logs, half came within 1.6 s of the
line before, 99 percent within 12.8 s, and two after more than 30 s
(32.8 and 62.2 s).
return_head goes: cloud.mode-switch hands its cloud stretch back through
the cloud client's log (homeoff.cloud_mode_return), and nothing else
called it. RETURN_MAX_MM moves to homeoff, its one user.
Proof: tests/test_cloud_suite.py's new case lands a motion, a lid image
0.8 s later and the next move 1.6 s after the motion, with the quiet at
1 s: the quiet comes after the move. With the old activity marks it is
declared after the image, before the move. forgetest 499 OK.
Acceptance: cloud.mode-switch gates the change on a machine, its re-hunt
waited out before the switch back. The fingerprints of every test in
suite/cloud.py and of every module that imports from it move: 29 tests,
the cloud tests, events.button-telemetry, the exthost tests,
homing.cloud-offsets and setup.check-envelope.
homing.cloud-offsets, setup.check-envelope and cloud.mode-switch let the
service move the head, and ended with it where the service left it: at
the camera home, or, in cloud.mode-switch, under the camera, where the
service's re-hunt had taken it. Each told the baseline the counters had
been re-zeroed at the starting position, so the hand-back saw nothing to
do. No counter reading can say where the head was found: every service
motion zeroes the counters at its start, and the home and every
controller start zero them again.
suite/homeoff.py now holds the helpers for a test that lets the service
move the head:
- session_travel sums a client's own record of each motion's end ("end
positions (x, y, z)", the counters the motion zeroed at its start, in
x8 steps, the one mode a service motion runs at). A motion with no end
on record, or a log rotated under the run, leaves the travel unknown.
- camera_home_return drops the camera home, jogs the head back by the
session's travel plus what the counters read since the home, starts
the controller once more so the counters read zero where the head
began, and tells the baseline. cloud_mode_return does the same for a
stay in cloud mode, from the cloud client's log.
- A travel that cannot be known fails the run and moves nothing. A
hand-back that fails while the test is already failing is logged, and
the test's own failure is the one reported.
- judge_whole_motions fails a homing motion that stopped short: the
position declared after it is false.
cloud.mode-switch imports the helpers inside its function, so no other
cloud test's fingerprint moves. Its cloud stretch no longer goes through
return_head, which read 0/0 after the controller's start and left the
head under the camera.
Proof: tests/test_camera_home_return.py, 15 cases on the machine's own
log lines (a session stopped short, a whole three-motion one, a cut log,
a refused motion, homed and unhomed counters, a restart since the home,
the first failure winning). tests/test_cloud_suite.py's mode-switch fakes
now zero the counters and remove the anchor at a controller start, write
the anchor at the home, and move the counters on a jog: 10 cases, a
failed hand-back that must not hide the test's failure among them.
forgetest 498 OK. On the bench reference, with the driver and the runner
fixes: homing.cloud-offsets, setup.check-envelope and cloud.mode-switch
PASS, each ending with the head where it was found; cloud.mode-switch
jogged its cloud stretch 246.06/139.01 mm back with the counters across
the jog agreeing, and every baseline was clean.
Acceptance: the three tests are the change; their fingerprints move and
no other test's does.
The runner case asserted that the homing runner the controller starts
does not inherit GF_REPORT_SECRET. The runner is the reporter while a
gfcloud $H holds the machine (the controller sends no report then), so
the assertion described the defect: every camera home ran with nobody
reporting, and the cooling engine's hung-controller dead-man stopped the
session's first motion.
The case now requires the runner to be handed exactly the controller's
secret, and two new cases require that it be handed none when the
controller was started with no secret (runner-none) or with one that is
not 32 hex digits (runner-malformed). The harness's descriptions in
scripts/bench/README.md and in the bench page's catalog say so.
Proof: all 11 cases pass on the driver that hands the secret over
(grblHAL-glowforge, "gfhome: the homing runner is handed the report
secret"); the driver before it fails the runner case: "the homing
runner was handed [], not '0123456789abcdef0123456789abcdef'".
Acceptance: a host harness in the driver's CI; no catalog test runs it.
forgetest/forgetest/bench.py changes one description string, which no
test judges.
In cloud mode forgectrl's start at the end of a takeover starts a cloud
client, and what that client is comes only from gfcloud's one-start
markers, which the client that starts reads and takes down. The takeover
started it bare: the online client, with the service's connect-time
hunt. Seen on the bench with exthost.armed-freeze, which runs its print
under the offline service and puts the setup record back under a
takeover: the machine came back online in cloud mode, the hunt homed the
head at 14:21:01, the run's result was written at 14:21:02, and the
service sent a head move at 14:21:08, after the run had ended, where the
next test's switch to GRBL mode would have cut it off. It also broke the
harness's own rule that every cloud client start it makes comes up
without the hunt.
The takeover now reads, on entry, whether the running cloud client is
the offline service: the client itself holds the listening socket at
/run/gfcloud-offline.sock (hw.listens_on reads /proc/net/unix and the
process's own descriptors, so a socket file another process left behind
does not count). forgectrl's start is made under the no-hunt marker in
cloud mode, and under the offline marker as well when the offline
service was found. After the start the client that came up must listen
on the offline socket within 60 s; a miss is recorded on the run's
baseline capture and the post pass turns it into a leftover ("cloud
client at the takeover end", not restored: a cloud test starts the
client it needs), which fails the run. A marker no client read (the
gate, standby, a fault) is taken down, so a later start never comes up
under it. The judge never breaks the exit path.
Proof: forgetest's unit tests pass on the host (481 OK). The new
tests/test_takeover_client.py drives a real enter and exit against the
fake forgectrl, a fake /proc, and an init script that plays gfcloud's
start: the offline service comes back offline, an online client after
it fails the run, a start that never happened takes the markers down,
an online client comes back without the hunt, a stale socket file is not
the offline service, GRBL mode starts under no marker. Negative
controls: without the markers four of them fail, without the judge
three. Bench drill on the bench reference with this package from /tmp:
GRBL mode, the offline service started as enter_offline starts it (pid
4834), a takeover; the client forgectrl brought back (pid 4898) read
both markers, logged "OFFLINE service", listened on the socket, and in
the 10 s after had no web session, no hunt, and no motion. The bench was
then handed back to GRBL mode as found.
Acceptance: runner.py, baseline.py and hw.py are in no test's
fingerprint, so no result moves; the catalog test that exercises the
change is exthost.armed-freeze, whose takeover now fails the run unless
the offline service comes back.
update.job-locks started a real download in every campaign: POST
/update/download fetched the published release's forgefirm.fw, whose
GitHub download count is how installs are tracked. ext.catalog asked
POST /ext/catalog/refresh, which fetches the extension index's fixed
address, index.ffi, counted the same way.
update.job-locks now starts the download job with probe=1, forgectrl's
probe download: the release's acceptance.json through the download's
own path, refused by the signature check and discarded. The record is
small and the job can end within a second, so the test writes the
settings at once after the 202 (the lease is taken before it) and runs
the probe again, up to three times, when the job ended first. A posted
job is refused in the job's name beside it, /status names the holder
when it is read while the job runs, the job must end refused by the
signature check, and the staged download must be as found (size and
mtime) with no probe file left. Its covers gain forgectrl's
src/relcheck.*.
ext.catalog no longer asks the refresh. The refusals before a fetch
stay, and the index kept must be unchanged after them. The refresh
(curl, https alone and bounded, 502 in curl's words, 409 in the host's,
the file removed) is proven on the host by forgectrl's extpkg_test with
a stand-in for curl, and the fetch of a listed package and the tiers an
install takes from it by forgeext's install_test, as the description
now says.
Nothing else in the tree requests a counted release file: the
installer's own request of forgefirm.fw is an install, and its unit
test puts a stand-in curl first in PATH.
Proof: forgetest's unit tests pass on the host (474 OK, 4 skipped) and
pyflakes finds no undefined name.
Acceptance: the change is the two catalog tests themselves; their
fingerprints move, and update.job-locks now covers src/relcheck.* too.
forgectrl's start at the end of a takeover is a controller start, which
re-zeroes the step counters wherever the head then stands. After it a
head left out reads as home, so the baseline's position check after the
run could not see a head a test left out before its takeover.
setup.check-envelope did exactly that on 2026-09-23 and passed (fixed by
56af6ea).
The takeover now judges the head at the two moments it can, and moves
nothing: as the controller goes away, against the position the run's
baseline expects (when the counters are still in the frame the run began
in), and as the takeover ends, before forgectrl comes back, against where
it found the head (when the microstep mode has not changed under it). A
miss past the dead band is recorded on the run's baseline capture, and
the post pass turns it into an unrestorable leftover ("a controller
restart re-zeroed the counters there, so the head is not moved"), which
fails the run. A frame the test did not declare, cloud mode (its counters
are the cloud client's), a bench tool without a run, and a reading that
fails are not judged and never keep forgectrl down. ctx.counters_rezeroed()
now records the frame in force, so a takeover after a declared re-zero
can still judge.
The frame is read before the controller is stopped: forgectrl unlinks
/run/grblhal.homed when the controller exits (super.c), which the
bench's negative control found.
Proof: tests/test_takeover_position.py, 12 cases against a fake sysfs
tree and a fake anchor file (a head in place, a head left out at the
start, the frame read before the stop, the dead band, an undeclared
frame, cloud mode, a bench tool, a head left out by a drill at the end,
a changed microstep mode, the post pass failing the run and moving
nothing, a failed reading, a declared re-zero). forgetest's unit tests
pass on the host (474 OK, 4 skipped). On the bench reference, image
20260923232513 with the files bind-mounted: the old bedsize.py (56af6ea^)
fails as it must, "position at the takeover start=[2134, 2133, 0]
(expected [0, 0, 0])"; then 18 unattended tests that take over the
controller PASS in sequence (setup.check-envelope, kernel.k1-k2,
kernel.deadman-close, kernel.backtrack-bounds, kernel.fire-line,
kernel.resume-lead, setup.gate-blocks-controllers,
setup.advisories-rehash, setup.extensions-consent, setup.what-changed,
setup.mirror, setup.account-login, setup.first-run-flow,
exthost.service, exthost.events, exthost.hold-pause-tier,
exthost.motion-job, exthost.motion-jog). A second takeover in one run meets the frame the
first one's restart made, which no test declared, so its start is logged
and not judged.
Acceptance: forgetest is the dev-only harness, outside the catalog's
coverage; no test's fingerprint moves (runner.py and baseline.py are not
suite modules).
Both tests failed for the operator on the bench reference on
2026-09-24, image 20260923232513, from the harness and not the machine.
laser.verdict-cut (00:10:52) needed the kernel's sampled LASER_ON count
to read 0 within 2.5 s of the hold. That count latches once a second
over the second before, so it reads 0 only once a whole window has
closed inside the hold, up to 2 s in, and this hold lasted 1.37 s. Every
sample of the hold read bit 0 of interlock_circuit as 1: the gated
LASER_ON, active low, dark.
The dark judge now reads the gated output itself, cnc/laser_on (1 = on),
about 3300 times a second on the bench reference, yielding the CPU
between reads (the stream threads run SCHED_FIFO). Its witness is the
same reads over the second before the pause, which must see the cut lit
(10 reads or more, none failed). The hold is dark when every read that
falls wholly inside it, from 0.3 s after the first Hold:0 to the last
Hold row, reads off, none failed, and there are at least 500. The 0.3 s
is the pause tier's first deceleration, lit on purpose, still playing
out of the driver's 200 ms queue and its 10 ms lead when the controller
reports Hold:0. The daemon's pause is 3.0 s, as the code already had
and the description now says. dark_span() is pure, kept inside the
test's function, and tests/test_laser_verdict.py runs it through the
function's code object over synthetic trails (8 cases: a dark hold, the
lit deceleration inside the drain, emission after it caught, a burst the
resume ends, failed reads counted, the drain counted from the first
Hold:0, no Hold:0, and a hold too short for the drain).
laser.disarm-in-hold (00:06:23, 180 s) pressed the button through
ctx.act at once, before the job reached its arm wait, so the press was
lost and the move never started. It now uses ctx.arm_press(), which
presses when the button lights, and waits for Run.
Proof: forgetest's unit tests pass on the host (474 OK, 4 skipped). Only the
fingerprints of laser.verdict-cut and laser.disarm-in-hold move.
Acceptance: the change is the two catalog tests themselves; both are
attended (laser emission, the operator present) and a campaign runs
both again.
The first GET /state after forgetest starts hashes every test's
implementation, and it took from 81 s to more than 12 minutes on the
bench reference. Two causes:
- sibling_imports() read and parsed a test's module and every sibling it
imports, transitively, once per test: 280 ast.parse calls for 26 suite
modules. module_parts() kept its own parse, sibling_imports() kept
none.
- The page gives up on a poll after 20 s and polls again; the server
thread goes on computing. Each new poll started the same cold work
beside the first, all of it on the one CPU, so the longer the first
took the more copies ran. That is the spread between 81 s and 12
minutes.
Each module's text and tree are now read and parsed once and kept, as
are its direct sibling imports, and the implementation hash is filled
by one thread at a time: a poll that arrives while it is computed waits
for it instead of repeating it. catalog.forget(path) drops what is kept
about a file, for the unit tests that edit their modules.
The hashes do not move: every test's implementation hash and domain
fingerprint on the image manifest of 20260923220034 is byte-identical
before and after, so no result is invalidated. On the host the cold
computation went from 7.00 s to 0.29 s (280 parses to 25). On the bench
reference (the file bind-mounted on image 20260923220034, the page
open) the first /state answered in 8 to 12 s after a restart, where the
same restart earlier in the evening had not answered after 7 minutes.
tests/test_responsiveness.py pins both: each suite module parsed at most
once while every test's implementation hash is computed, and three
threads reading one test's hash compute it once. With the old behavior
put back by a patch both fail (280 parses for 26 modules; the hash
computed 3 times). forgetest's unit tests 454 OK. forgetest is the
dev-only harness, outside the catalog's coverage; its catalog
consequence is none, since no fingerprint moves.
The test's cleanup jogged the head back to the camera home through the
panel's relative jog, the increment taken from the port's position. The
core refused it every time (error:15, "the move would leave the work
envelope"), eight tries each run, and the test logged "the head is not
back at the home" and passed. The takeover that puts the setup record
back restarts the controller, which zeroes the kernel counters where the
head stands, so the baseline read (0, 0, 0) and called the machine
clean. On the bench reference the head was left 10 mm from the home in X
and in Y by every run of 2026-09-23 that got that far (three PASSes and
one FAIL, images 20260922225653, 20260923084705, 20260923220034).
Why the jog was refused: the port reports the step counters, while the
core adds a relative jog to its own parser position. The bed check's
last jogs, sized from the counters, leave the two apart by up to half a
step. The arithmetic reproduces every logged value: the parser held X
60.1 after the answers, the port read 60.099, the check jogged -50.099
toward X 10, so the parser took 10.001, which the step grid (213.33
steps/mm) turned into 2134 steps, read back as 10.003. The test's
return of -10.003 then targeted X -0.002, and a camera home's envelope
begins at exactly 0, so the core refused the whole jog. No product path returns the head by a relative
jog sized from the counters; the panel's Jog card sends fixed steps.
Now the head goes back in one jog from a Grbl client to the home in
machine coordinates ($J=G90 G53, 0.5 um inside the envelope's start,
far under half a step), and the run fails unless the port reads the head
on the home's step. That reading is taken before the restart, because
nothing after it can see a head left out.
Bench reference, image 20260923220034 with the suite file bind-mounted:
a negative control that aims the return 1 mm off the home FAILs with
"the head is not back at the home (0.0, 0.0, 3.08): [0.998, 0.998,
3.08]" while the baseline still says clean; the fix PASSes with
"$J=G90 G53 X0.0005 Y0.0005 F1200 -> ok" and the head at [0.0, 0.0,
3.08]. forgetest's unit tests 454 OK; the coverage lint passes with
--enforce on the image's manifest.
The test took the release to download from GET /update/release, the
kept answer of the daily check. That answer does not outlive a restart
of forgectrl, and a campaign restarts it many times after
update.release-check has run, so on the bench reference (image
20260923084705) the test found "not checked yet" and failed with "no
release is published to download". When the kept answer was never
checked, the test now asks POST /update/check itself, the way
update.release-check does.
PASS on the bench in the unattended queue (the check asked, 200; the
download of v0.0.6 held the lease and ended; no archive left behind);
forgetest's unit tests 452 OK.
The package's jog goes through the controller port, which only the GRBL
controller serves. The test declared no mode, so it ran in whatever mode
it found. Alone it always found GRBL mode; in the unattended queue it
runs after the cloud tests, which leave the machine in cloud mode, and on
the bench reference (image 20260923084705) the jog came back 409 "the
GRBL controller is not running". It now declares mode="grbl", so the
runner switches before it starts.
The other tests without a mode were checked for the same need: none of
them moves the machine or reaches the port (update.job-locks posts a
job, and the job route refuses on the lease before anything else, in
either mode).
exthost.motion-jog PASS on the bench in the queue after the cloud tests;
forgetest's unit tests 452 OK.
The extension host has had its own logger (log_forgeext_disk and
log_forgeext_remote, a block in the rendered rules, and
/data/log/forgefirm/forgeext/) since it joined the image, and GET /logs
reports it. The test's list of loggers never gained it, so the first run
of logs.routing on an image that carries forgeext failed on the bench
reference (image 20260923084705): "loggers reported: [... 'forgeext'
...]". The list now names forgeext, so the test also holds its rules
block, its effective levels, and its directory, and a stray program's
line must not land in forgeext.log either.
logs.routing PASS on the bench with this file mounted; forgetest's unit
tests 452 OK.
Two harness faults found on the bench reference in the campaign on image
20260923084705.
setup.gate-blocks-controllers probed the Grbl port once, at the moment
GET /mode first read the controller running. The supervisor reports
running from the fork and the controller binds TCP 23 a few tens of
milliseconds later, so the probe failed whenever the 0.25 s poll landed
inside that window ("the Grbl port is closed with the controller
running"). Timed on the board with the controller restarted through
forgectrl: /mode read running at 0.066 s, the port accepted at 0.087 s.
The test now waits up to 10 s for the port (50 ms polls) and records
how long it took (port_open_after_s). PASS on the bench (0.001 s).
first_run() replayed the system accounts (forgefirm-users reload) and
removed the temporary home only after its block succeeded. When the
block failed, the records came back but /etc/passwd kept the temporary
account in place of the operator's, and image.health failed after it
(the record's account missing from /etc/passwd). The replay and
the home removal now run in a finally. Proof on the bench: the failing
case run on purpose (setup.first-run-flow against the image's own
forgectrl, which refuses a home by hand at the cloud step) leaves the
operator's account the one account from uid 1000 up and no temporary
home; the same failure before the fix left the temporary account in
/etc/passwd and its home in /data/forgefirm/home.
forgetest's unit tests: 452 OK. setup.first-run-flow and
setup.gate-blocks-controllers PASS on the bench with this file mounted.
scripts/bench/envelope_test.py drives the null-sink controller with the
scripted sender and port client of ctlport_test.py, homing manually:
unset keys are the axis travel; envelope_x_mm and envelope_y_mm are the
far edges at a home and are held to 50 mm up to the travel plus 30 mm;
the port's envelope open is refused before a home and under an open
armed window, and lets the port's jogs reach the travel plus 30 mm;
envelope apply puts the keys' edges back without a home; a status poll
and an empty line leave an open envelope open, while the sender's first
line closes it before the core reads it (a jog into the margin draws
error:15), a running port jog canceled first; a soft reset and the port
client going away each close it. It is registered in the bench
registry as a CI harness, not a bench-page tool.
setup.check-envelope (suite/bedsize.py, its own module): with cloud mode
on, the machine is homed with the camera, and the Bed size check runs
with its jog prompts answered as the page would, out 60.1 mm in X and
61.1 mm in Y from the home. The ends it reports are the home plus
those, the far edges 1 mm short of them are written as envelope_x_mm
and envelope_y_mm, and the controller holds them at once: a panel jog
0.5 mm short of each edge runs, and one 0.5 mm past it is refused with
nothing moved. A second run is opened, and while its first prompt waits
a Grbl client sends one line, which draws the controller's word that
the envelope closed and then ok; the port then says the envelope is
closed, the next answer ends the check in words, and the keys are as
the first run left them. The head is jogged back to the home, and the
keys, the homing mode, and the setup record are put back as found, the
record under a restart.
Proof: envelope_test.py ALL PASS on the host build of the controller of
this change. On the bench reference, with that controller and forgectrl
of the same change bind-mounted: the first two campaigns found flaws in
the test, not the check (jogs of 20 mm made an edge below the 50 mm
forgectrl refuses; the second run started while the machine was still
busy; the head was not returned to the home), then setup.check-envelope
PASS, with forgectrl.auth, kernel.latch-locked-idle, motion.pacing,
motion.jog-roundtrip, and motion.port-jog PASS in the same campaign, and
cloud.mode-switch, motion.soft-limits, and homing.cloud-offsets PASS
after it. forgetest's unit tests pass (452), and the coverage lint
passes with --enforce.
update.job-locks (suite/updlock.py, its own module): with a release
published on the releases API, POST /update/download starts the
download job (202), which fetches and checks the release's archive and
applies nothing. While it runs, /status names update:download, of kind
system, as the lease's holder, and a settings write and a posted job are
each refused in its name (409). When it ends, the lease is free and the
same settings write is taken. The downloaded archive is removed when it
was not there before.
homing.cloud-offsets (suite/homeoff.py, its own module): with cloud mode
on, homing_mode = gfcloud, and the camera-home offsets at (4.5, -3.25),
$H runs the web-service homing session, and when it ends the controller
declares (4.5, -3.25) on the step grid. The work envelope reaches back
to the home on the negative axis: a jog 2 mm out and 2 mm back in Y and
then in X is accepted and ends at the home. The homing mode and the
offsets are put back as found, each retried until the daemon takes it,
since a settings write is refused for a moment after the suite's Grbl
client closes.
Proof: on the bench reference, update.job-locks PASS after
update.release-check, and homing.cloud-offsets PASS in a campaign with
forgectrl.auth, kernel.latch-locked-idle, and motion.pacing, which also
PASS. forgetest's unit tests pass (452), and the coverage lint passes
with --enforce.
exthost.wizard (suite/extwizard.py, its own module): a package with the
reference id that asks for wizard, whose service runs a check on its
call socket and keeps whether it passed. GET /wiz lists it among the
extensions as pkg:<id> with the package's title, not done. POST
/wiz/pkg:<id>/start runs it on the wizard runner: GET /wiz/dark shows
the package's log line, phase, progress, and confirm prompt, and the
machine lease stays free while it waits. The operator's Yes reaches the
service as yes, the run ends complete with the package's summary, and
GET /wiz then lists the check as done from the package's own answer. The
machine's setup record is the same before and after, with no pkg: wizard
in it and the gate as it was. A second run is aborted from its prompt,
and the service is told. A check of a package that is not installed is
404.
Proof: on the bench reference, with forgectrl and forgeext of the wizard
change bind-mounted, exthost.wizard PASS, beside setup.check-sensors,
exthost.service, and exthost.package-routes PASS on the same daemons.
forgetest's unit tests pass (452), and the coverage lint passes with
--enforce.
scripts/bench/mcode_test.py drives the null-sink controller with the
scripted sender and port client of ctlport_test.py: the port's mcodes
table and its refusals, error:20 for a number nothing answers, an
answered M-code's wait (the head still, the port's state naming it with
its words, a port jog busy:mcode, stale and out-of-form answers refused,
the answer's words on the console), a refusal and a timeout each holding
the job, a soft reset ending the wait, and a wait under an open armed
window with M3 S500 shipping no FIRE tick. It is registered in the bench
registry as a CI harness, not a bench-page tool.
exthost.mcode (suite/extmcode.py, its own module): a package with the
reference id that asks for mcode:160 and job_time.run, the one granted,
and whose service answers POST /mcode on its call socket. The host's
status names it as the one that answers M160. A dark job (out 5 mm, M160
P1, back) through POST /job waits at the M-code with the port's state
naming it and the head still over 1 s (the kernel's counters and the
port's position), the service answering after 2 s; the job then ends
done, every line acknowledged, the head back, the service asked once with
the code and its words, no discharge. A job naming M161 fails at that
line with error:20 and nothing moved. A job at M160 P2, which the service
refuses, is held, and is aborted from there. A job left running by a
failed check is aborted before the put-back.
Proof: on the bench reference, with the controller, forgectrl, and
forgeext of this change bind-mounted, exthost.mcode first FAILED and
found the controller announcing the M-code before the kernel had played
the last move (the driver now waits for the kernel to go idle), then
PASS; exthost.service, motion.job, exthost.page-call, exthost.motion-jog,
motion.port-jog, and exthost.package-routes PASS on the same daemons.
mcode_test.py ALL PASS on the host build. forgetest's unit tests pass,
and the coverage lint passes with --enforce.
exthost.catalog (suite/extcat.py, its own module): GET /ext/catalog
answers the index the host keeps and the one address it is fetched from.
On a scratch root under /tmp, with a throwaway key standing in for the
OpenGlow extension key, the machine's own forgeext keeps an index signed
with it, and the author key it names for one id makes a package of that
id read as community and endorsed, where before it was unverified; the
same key on another id counts for nothing. On the machine's own root that
index is refused in words, a package handed over as an index is refused
by the product gate, and the index kept is left as it was. The relay
refuses an id with no such form (400) and one the kept index does not
list (404, or 409 with none kept) before anything is fetched, and a
refresh from the fixed address keeps OpenGlow's index when one is
published there and is 502 in curl's words when none is, the kept index
left as it was. Nothing is left in the staging directory.
The coverage lint had a gap: coverage_report() let the allowlist's
docs/** and **/*.md take out a path the BEHAVIORAL list keeps in every
fingerprint, so the four first-run advisory documents were covered by no
test and the lint passed. A change to the privacy advisory would have
invalidated nothing. A behavioral path is now never allowed away, and
setup.advisories-rehash, which accepts every first-run document at its
current hash, covers the four.
Proof: on the bench reference, with forgectrl 848ccc1 and forgeext
a64b933 bind-mounted and the privacy document accepted again at its new
hash with the fixture's press, exthost.catalog PASS (the refresh was 502:
GitHub answered 404, nothing is published at the address yet), and
setup.advisories-rehash PASS with the rest of the campaign. The lint's
new unit test reports the uncovered advisory, and the old reading (the
override ignored) reports nothing for it, which is the gap. forgetest's
unit tests pass (452), and the coverage lint passes with --enforce.
Two new tests, each in its own module so that no other test's
fingerprint moves.
events.button-telemetry (suite/evmore.py): a stream opened at
forgectrl's read-only listener, from a loopback source of its own, gets
a telemetry.tick within 14 s with exactly its fields (phase, verdict,
fire_ok, down_c, up_c, state, lid). With the machine idle and nothing
waiting for the button, one press (the fixture's, or the operator's)
arrives as pressed true and then false, in that order. That a press the
machine is waiting for is no event, and the update and setup edges, are
events_test's.
exthost.lifecycle (suite/extlife.py): a package that follows its feed is
running when extensions are turned off. It reads ext.shutdown with the
host's reason and writes it to its data directory before its process
ends, the host stops it a second later, and nothing is frozen in that
second. Its covers name forgeext's run, machine (the armed question
asked during the grace), evfeed, and api.
Proof: on the bench reference, with forgectrl a9c45fc and forgeext
dbb00e5 bind-mounted, both PASS. exthost.lifecycle FAILed on the host
before its grace fix (the service frozen for want of an armed reading)
and PASSed after it. forgetest's unit tests pass (451), and the coverage
lint passes with --enforce.
The cloud suite's host tests replay a print's log from threads, and the
suite's quiet wait gave up after 3 s (QUIET_TIMEOUT_S in setUp). On a
loaded host the last replayed lines landed after that, and a test failed
with "still running service moves after 3 s" instead of its own finding:
about one run of the module in five, a different test each time, alone
as well as under the full suite. The deadline is now 15 s. It is a
deadline and not a wait: a quiet machine is seen at once, so a passing
run is no slower. The one test that waits the deadline out on purpose (a
motion that never goes idle) sets 3 s for itself; tearDown puts the
module's value back.
Proof: before, test_cloud_suite failed 2 of 10 runs by itself, every
failure the quiet deadline (test_lid_during_button_wait_on_the_bench_excerpt,
test_pause_resume_fails_without_the_retraced_restart,
test_mode_switch_fails_when_gfhome_never_saw_the_head_move,
test_pause_resume_fails_when_the_kernel_refuses_the_resume). After, 10 of
10. forgetest's whole unit suite: 451 tests OK, 4 skipped, in 492 s
against 491 s before. A host-test change: no catalog consequence.
A new catalog test in a module of its own (suite/extdest.py). It installs
the reference package's id and key asking for net.outbound.operator, with
a service that reports at every start what GET /v0/self says it may
reach and what one dial to the machine's DNS resolver on TCP port 53 does,
and one to the port above it. The resolver is an address every network
gives the machine, that is not the machine, and that answers; named by
address, it gives the service no resolver of its own. It holds: before
anything is named, no destination and the dial refused inside the
sandbox (EACCES), and no rule for it in the package's chain; the machine's
LAN address and loopback refused in the host's words, a destination out
of form 400, an unnamed removal refused; named through POST /ext/dest,
the service started again with the destination, connected, still refused
one port up, and the chain carrying the rule, with no crash; taken away,
started again and refused again, the rule gone. A resolver that takes no
TCP connection even from root fails the test in words rather than passing
it on nothing. covers: forgeext src/install.*, state.*, run.*, super.*,
api.*, caps.*, manifest.*, netrules.*, main.c; forgectrl src/extpkg.*,
main.c.
Proof: on the bench reference (image 20260922225653, with forgeext
28f829e and forgectrl e2d0d05 cross-built and bind-mounted, and this
suite bind-mounted over the installed one), exthost.operator-destinations
PASS in 22 s, with exthost.service, exthost.page-call,
exthost.ui-delivery, exthost.package-routes, exthost.events, and
exthost.panel-install PASS in the same campaign. The first run of it
failed in its own reading of /etc/resolv.conf (bytes against text); the
fix is in the test's module alone, and the PASS is on the fixed module.
A new catalog test, in a module of its own (suite/extcall.py) so that no
other test's fingerprint moves. It installs the reference package's id
and key with a page and a service of its own, whose service reports what
it was handed and answers its page's calls on the listening end the
host gave it. It holds: FFX_CALL_FD=4 with descriptor 4 a listening
socket and 3 and 5 closed; the socket root's 0600 in root's 0700
directory; POST /ext/call relaying a POST and a GET, the service's own
refusal passed through as its status and words, and a service asking the
API while it answers; five calls out of form refused, 400 by the relay
or 409 in the host's words; forgeext call answering alike; and a disabled
package refused in words, its service stopped and its socket's name
gone, then answering again once enabled. The package, the key, the
setting, and the setup record are put back as found, and no socket name
is left. covers: forgeext src/call.*, src/run.*, src/sandbox.*, src/main.c;
forgectrl src/extpkg.*, src/main.c.
Proof: on the bench reference (image 20260922225653, with forgeext
5c13f1c and forgectrl 2cf6b12 cross-built and bind-mounted, and this
suite bind-mounted over the installed one), exthost.page-call PASS in
16 s, and exthost.service, exthost.ui-delivery, and
exthost.package-routes PASS in the same campaign. forgetest's unit tests:
451, one failure in test_cloud_suite, whose replayed prints flake on
their timing alone as well (1 of 3 runs of that module by itself, in a
different test each time) and touch nothing of this change.
forgectrl embeds its advisory documents in the binary, serves them, and
records the operator's consent to one by its hash, so an edited advisory
is a changed consent. They are Markdown under docs/, which the
non-behavioral list takes out of every fingerprint, and that left
setup.extensions-consent's covers entry for docs/advisories/extensions.md
selecting nothing: the enforced coverage lint fails on it (exit 1 on the
dev image's manifest of 20260922225653), and an edited advisory moved no
fingerprint at all. A BEHAVIORAL list now keeps forgectrl's
docs/advisories/** in, ahead of the non-behavioral one.
forgeext's recipe installs the binary and its init script and nothing
else, so packages/ (the official packages, which carry their own
acceptance artifact), sdk/ (the author's kit), template/ and tools/ are
non-behavioral for the image: without that, an edit to the alignment
page or the kit would make every exthost test stale on the next image.
Proof: test_manifest passes its 25 cases, the new ones among them; the
enforced coverage lint on the dev image's manifest of 20260922225653
exits 0 with no empty entry and nothing uncovered, where it exited 1
before. The whole forgetest suite ran its 451 tests; one,
test_cloud_suite's test_pause_resume_passes_on_the_machines_lines,
errored under the suite's load and passes 16 runs of 16 alone, at HEAD
and on this tree alike: it replays a print against timed hooks.
The reference service takes two more lid-camera frames through its API
socket, with the camera's lamp at 0 and at 1023, and one with a lamp
past its range. The test holds both named frames to being JPEGs, the lit
one the larger (the lamp reached the camera and changed the picture),
and the out-of-range one to a 400 that names the lamp. The service's
text is shared by every exthost test, and every one of them already
covers forgeext's api.c and run.c, which this change moves, so no test
is made stale that was not stale already.
Proof: on the bench reference, image 20260922225653 with forgeext
cd8b471b and forgectrl 9c9ee557 bind-mounted, exthost.platform and
exthost.service PASS: 51,561 bytes at lamp 0, 109,850 at lamp 1023,
and lamp 5000 refused.
The panel page now carries Content-Security-Policy: frame-src 'none'
(forgectrl 95970cf), the one thing that keeps a package's page from
navigating its own frame to another address with what it was shown in
the URL. What the header does in each browser is the frame-isolation
harness's to prove (forgectrl tools/frame_isolation.py); this test holds
the machine's own served page to carrying it, read with the headers from
the loopback listener. The edit is inside the test's own function, so no
other test's fingerprint moves.
Proof: on the bench reference, image 20260922225653, PASS with forgectrl
95970cf bind-mounted ("GET / Content-Security-Policy: frame-src 'none'"),
and FAIL against the image's own daemon: "the panel page lets a frame
navigate: 200 None".
The test held a missing package's page to any status of 400 or more, and
the relay's 502 passed it: the host's refusal lost on the way to the
panel looked the same as a refusal. It is 404 in the host's words now.
The same relay carries a package's settings, which no test asked for
through forgectrl: the test reads them, sends a value over its bound,
and holds the answer to 400 in the host's words with the stored value
unmoved.
The edits are inside the test's own function, so no other test's
fingerprint moves.
Proof: on the bench reference, image 20260922152358, the image's own
forgectrl fails the test (a disabled package's page 502, "the extension
host did not answer"); with forgectrl 8055000 and this file bind-mounted
it passes: 404 "that package is not installed", 404 "this package is
disabled: its interface is not served", and 400 '"threshold" is at most
100' with the threshold still 40.
The test wrote its evidence through ev.note(), which the run's evidence
does not have: it is a plain dict, as every other test uses it. On the
bench the test stopped at its first record with an AttributeError, an
ERROR before anything was asked of the host. The records are keys now,
and the three ranges are one list. The description said a package asking
for a range was installed; the test only inspects, and it says accepted.
No host test runs a suite function, which is how this reached the image.
Proof: on the bench reference, image 20260922152358 with this file
bind-mounted over the installed one, exthost.core-range PASS: the image
stamps "20260922152358", which is no version; inspect answers
core_checked false with no range, with a minimum of 99.0.0, a maximum of
0.0.1, and both ends, and installs nothing.
forgeext ships from its own repository with its own recipe and pin, and
62 covers entries across the catalog name it - but the tree manifest the
coverage lint runs on did not know the component existed, so every one of
those entries selected nothing. The lint exits 1 on an empty entry, so
the merge would have failed on it; worse, until then forgeext's sources
were not coverage-checked at all.
A pin nothing has been pushed to yet (SRCREV all zeros, which is how a
work branch carries this component until the merge) names the component
with no files instead of failing the whole manifest. The lint then
reports its entries as empty and exits 1, which is what an unbumped pin
should do, and the script stays usable in the meantime.
Proof: the script names forgeext and says its pin is not set; with the
component's file list stood in, the 31 empty (forgeext, ...) entries
become zero, and the 32 that remain are forgectrl and grblHAL paths that
do not exist at their pinned commits yet - the pre-push state the work
branches are in.
The bundle carries system/extensions.json now. The case reads it out of
the tar, holds it to being the status document, and holds every listed
package to naming its id, version, tier, whether it is enabled, and what
the operator granted it - which is what a report from a machine running
extensions has to be read against. covers gains forgectrl's src/extpkg.*,
where the document is built.
exthost.package-routes reads the new `effective` field beside the
manifest's own list: what the package may use is never wider than what it
asked for, holds everything the operator granted, and the panel's list
and the host's own command line say the same thing. That is the list the
panel's bridge decides on, so it is the one the case has to hold.
exthost.ui-delivery gains the disabled package: its page is refused in
words, and served again once the operator enables it. Disabling a package
is the way out of everything it does, and its interface was the one door
that stayed open.
Both are changes inside the tests' own functions, so no other case's
fingerprint moves.
The case proved the freeze and stopped there. A package with the
operator's job_time.run grant is not frozen, and a capture costs
kernel-side work beside the step stream, so the window has a second half:
no program takes a picture during a cut.
From inside the same real armed window the case already opens, the
sampler now takes one background capture and keeps what the machine said.
It must be refused (409, in words that name the window), and a second one
after the window closes must come back a JPEG. The refusal is decided
before any frame is taken, so the probe costs the print nothing - and if
it were served instead, the capture that happened is the one the rule
forbids, which is what the case is here to catch.
covers gains forgectrl's src/cam.* and src/main.c, where the refusal is.
A package's core range is judged against the version the host reads
from /etc/forgefirm-version, and forgeext read no version at all out of
the form a release image writes it in, so a release image judged no
range. That fault is fixed and proven in forgeext 4fefdb6.
What a campaign can add to that is narrower than it first looks. The
release image does not ship this suite, so a campaign only ever runs on
a dev image, and a dev image stamps ${DATETIME} (dev), which is no
version. The branch where a range is actually judged is therefore
unreachable from here on every machine this suite can run on, and it is
not this test's to claim: version_test in forgeext holds that branch,
over the file as each image build writes it.
So this test proves the half a machine can, which is the half that ties
the daemon to the image it is on: the host reads the image's own file,
finds a build stamp, and judges nothing rather than guessing a version
out of it. A package asking for a minimum far above or a maximum far
below installs all the same, and inspect answers core_checked false. A
dev image that began stamping something version-shaped would fail here,
and that is the regression this test is for; it says so in its own
words rather than leaving a reader to wonder why it only ever takes one
branch.
It is a module of its own, and that is deliberate. A test's fingerprint
holds the shared text of the module it lives in, and the shared text is
every line outside a @test function, the blank lines between them
included - so a test cannot be added to exthost.py without moving the
fingerprint of every test there. Adding it there moved all ten,
exthost.platform (which covers linux-fslc alone) and the operator drill
exthost.panel-install among them. In suite/extcore.py it moves none:
measured across the change, 113 tests before and 114 after, no
fingerprint moved. The helpers come from exthost by import, which puts
that module's shared text in this test's fingerprint and not the other
way round.
Proven on the host: the catalog loads and registers 114 tests, the
coverage lint passes with 0 uncovered paths, pyflakes reports nothing,
and the unit tests run 451 and pass, as they do on the revision before
this one.
The reference package is installed with motion.job granted, writes a
program into its own data directory, and asks the host to run it.
What it proves on the machine: a program named with a path and one that
is not there are refused by the host, with the machine never asked; the
real one is taken by the machine as the package, every line sent and
acknowledged; the head moves and the head accelerometer sees it; and the
laser latch stays locked with every emission witness at zero.
The program commands no laser, so the runner ends the job as one that
ended without a discharge. That is what a dark program is, and the test
asserts it rather than glossing it: the whole program ran and nothing
fired. A job that does fire is cloud.dark-print's and
laser.emission-witness's.
Three things the first runs taught. A button press needs an "until" - what
the press is expected to do - and a dark program never opens an armed
window, so there is nothing for a press to arm and the test does not
press. The job's lease owner is prefixed "job:". And the job record's
emission.samples counts samples taken, not samples that saw light; the
witnesses are laser_on_samples, lit_s and thermopile_delta.
Proven. The unit suite: 451 tests; install_test: 225 checks. On the
bench reference, image 20260922014545 with the cross-built daemons
mounted over the image's: exthost.motion-job PASS, with exthost.service,
exthost.motion-jog and motion.job PASS beside it. Against the image's
own daemons the route is 404.
A package that asks for ui must ship ui/index.html and one that ships it
must ask: the test packs an archive each way and checks the install
refuses both in words. With a proper one it checks GET /ext/ui hands the
page back byte for byte, and that a package which is not installed is
refused.
Each control archive is packed in a directory of its own, because the
packer makes a signing key there and fwup will not remake one over an
existing file - the first version shared a directory and errored.
Proven. The unit suite: 451 tests. On the bench reference, image
20260921225820 with the cross-built daemons mounted over the image's:
exthost.ui-delivery PASS, with exthost.service and exthost.package-routes
PASS beside it. Against the image's own daemons it FAILS: the older host
installs a package that asks for ui and ships no file.
A new catalog test for the one capability that moves the head. The
reference package is installed with motion.jog and jogs on the test's
word, through a file the service watches, so the motion happens at a
moment the test chose and can be witnessed while it does.
What it proves on the machine: the host's credential is 0600 and root's
and holds motion.jog and not a camera; a jog past the bounds is refused
by the host with the machine never asked; the package's jog moves the
head and the head accelerometer sees it; and across the whole of it the
laser latch stays locked and the emission witness stays at zero. The
head is jogged back to where it started.
The laser check reads the field names the machine actually publishes
(laser_locked, laser.emission_samples). The first version of it asked
for names that are not in /status, so it compared None to None and could
not have failed.
Proven. The unit suite: 451 tests. On the bench reference, image
20260921220023 with the cross-built daemons mounted over the image's:
exthost.motion-jog PASS, and exthost.service and motion.port-jog PASS
beside it. Against the image's own daemons it FAILS: the route is 404.
The reference package asks for the lid camera and not the head's. From
inside its sandbox the test checks that the granted camera comes back as
a JPEG with the image type to match, that the camera it does not hold is
403, and that a camera there is none of is 400.
It then holds a stream open and asks the service for a capture through a
file the service watches, so the capture happens at a moment of the
test's choosing: it is refused in the machine's own words while somebody
is watching, and served once the viewer stops.
Proven. The unit suite: 451 tests. On the bench reference, image
20260921204711 with the cross-built forgectrl and extension host mounted
over the image's: exthost.service PASS, and exthost.events,
exthost.hold-pause-tier and camera.snapshot PASS beside it.
Acceptance. exthost.service already covers forgeext's **; a package's
camera is part of what a confined service can reach.
The test opens a stream, holds it, and checks that a capture marked
background=1 is refused in words while somebody is watching, that the
operator's own unmarked capture is still served, and that the background
capture is served again once the viewer stops. A background value that
is neither 0 nor 1 is refused.
Proven. On the bench reference, image 20260921204711 with the
cross-built forgectrl mounted over the image's: camera.snapshot PASS.
Against the image's own daemon it FAILS at the first of the new checks,
answering a parameter it does not know with a JPEG.
Acceptance. camera.snapshot already covers the camera sources and
src/main.c.
The reference package declares storage:1. The test writes 2 MiB into its
data directory and watches the host measure it, stop it, and show the
quota's reason, then clears the data, enables it again, and sees the
service come back. That is the operator's way out, so the test walks it.
Proven. The unit suite: 451 tests. On the bench reference, image
20260921190848 with the cross-built extension host mounted over the
image's: exthost.service PASS, and exthost.events and
exthost.hold-pause-tier PASS beside it. The first run of this check
caught a real bug: the reason arrived empty, because the host built it
in the buffer the supervisor overwrites when it stops a service.
Acceptance. exthost.service already covers forgeext's **; the quota is
part of what the host does to a confined service, so it is exercised
there.
The package now declares three settings and, from inside its sandbox,
reads them, sets two, and is refused an undeclared key and a value out of
bounds. The store on disk is checked for its mode and its contents, so
the test proves the host keeps them and not only that it answered.
Proven. The unit suite: 451 tests. On the bench reference, image
20260921190848 with the cross-built forgectrl and extension host mounted
over the image's: exthost.service PASS. Against the image's own daemons
it FAILS where it must, the older parser refusing the manifest with
unknown key "settings".
Acceptance. exthost.service already covers forgeext's ** and forgectrl's
src/main.c; the settings are part of what a confined service can reach,
so they are exercised there rather than in a test of their own.
A new catalog test for the one subscription the extension host holds and
the poll a package reads it with. The reference service learns from
GET /v0/self whether it was granted events and polls POST /v0/events only
then, so every other test keeps the half-second heartbeat those tests
watch; what it polled and what it saw go in its data directory.
What it proves on the machine: the host holds the stream while one package
wants it and lets it go when none does; a poll that names its place and
has nothing to be told comes back at its own deadline and not before,
while the first poll, which only asks where the present is, comes back at
once; the lid opened and closed on the fixture reaches the package as lid
events, in order and numbered, which also proves a waiting poll is woken
rather than left to time out; and with the three ordinary streams taken
the host's subscription is still there while a LAN client that asks for
the host's slot by name is refused, because only a loopback peer may claim
it.
The three ordinary streams each bind a source address of their own. The
cap counts peer addresses, and the kernel sends every loopback connection
from 127.0.0.1 whatever address it was aimed at, so three streams without
that would replace each other and leave two slots free. The first run of
this test said a LAN client had been admitted; it had been, correctly, to
a slot the test had failed to fill.
Proven. The unit suite: 451 tests, and the coverage lint passes. On the
bench reference, image 20260921171224 with the cross-built forgectrl and
extension host mounted over the image's: exthost.events PASS, and beside
it exthost.service, exthost.armed-freeze, exthost.hold-pause-tier and
events.stream PASS. Against the image's own daemons exthost.events FAILS
at its first new check, as it should. The bench was left as found, GRBL
mode, no mounts.
Acceptance. exthost.events covers forgeext's src/evfeed.*, src/api.* and
src/run.*, and forgectrl's src/events.* and src/main.c.
The test added the owner's key by copying the file. It now adds it the way
an operator does: POST /ext/key with the machine's button held. Without the
button it is 409 and no file lands; a name with a space, a name that is a
path, and no key at all are 400; a key that is no key is 409 from the host;
with the button held it is added and listed with its id. Removed again, the
same archive reads unverified, and removing a key that is not there is 409.
The bench actuator's press was 200 ms, the firmware's default, and a
request that must reach the machine while the button is down often missed
it: the unverified install took three presses on one run and all ten on
another. ctx.act() now takes `ms`, the fixture client passes it to the box
(the firmware clamps it into 20 to 500), and the two button-held steps ask
for 500. Both landed on the first or second press afterward.
Proven. The unit suite: 451 tests, 0 undefined names (both stub fixtures
take the new argument, and the fixture test pins that the box is asked for
the longest press). On the bench reference, image 20260921161446 with the
cross-built forgectrl and extension host mounted over the image's:
exthost.panel-install PASS, exthost.package-routes PASS,
exthost.hold-pause-tier PASS. Against the image's own daemons the install
test FAILS, as it should.
Acceptance. exthost.panel-install covers forgectrl's src/extpkg.*,
src/main.c, src/auth.* and forgeext's src/main.c, src/install.*,
src/pkg.*; the runner and the fixture client are harness, outside the
suite and outside every fingerprint.
The page the image serves must hold the card (extpkgs, extswitch, extfile,
extstaged, extinstallphrase, extAct, loadExt) and must define loadExt
before the call that runs while panel.js parses. That second check is the
bug the browser found: ext.js loaded after panel.js is an undefined
loadExt, and it takes the rest of the System tab's loads down with it.
Proven. The unit suite: 451 tests, 0 undefined names. On the bench
reference, image 20260921154037 with the cross-built forgectrl mounted over
the image's: PASS. Against a forgectrl built with the scripts in the old
order the order check FAILS, as it should.
Acceptance. exthost.package-routes gains forgectrl's src/ui/ext.js,
src/ui/index.html, and src/ui/embed.cmake in its covers map.
Kind operator with the button as its action, GRBL mode, nothing moves and
nothing fires; extensions stay as found and the packages never run. The
reference package signed with a key nobody trusts uploads as unverified
with consent button: refused without the button held (the phrase is no
substitute, the staged file stays), installed while it is held, listed
unverified with the hold the request granted. A person holds the button; the
bench actuator's press is a half-second pulse, so the test presses again
until a request has landed inside one. With the same key added as the
owner's the upload reads community with consent typed, and goes through
no phrase, the phrase in another case, no grant (the host's words), a grant
with a shell's words, a grant that is an option of the host's, and then
installs. An upload without the login, bytes that are no archive, an install
with nothing staged, and a discarded upload are refused as they should be.
Both packages are removed through the route and the extension root is as
found, the staged file included.
Proven. The unit suite: 451 tests, 0 undefined names. On the bench
reference, image 20260921150233 with the cross-built forgectrl mounted
over the image's: PASS (the third press landed); against the image's own
forgectrl FAIL at its first request.
Acceptance. The test is new. It covers forgectrl's src/extpkg.*,
src/main.c, src/auth.* and forgeext's src/main.c, src/install.*, src/pkg.*,
and requires exthost.package-routes.
The reference package is installed with the hold grant through the host's
command line and never runs (extensions stay as found, so the test needs
no takeover). GET /ext/status is refused without the login and, with it,
lists the package as the host does beside enabled, safe_mode, and the
host's own status with running true and the living host's pid. POST
/ext/package: hold-required and hold-advisory name and un-name the package
under required-holds; disable and enable change the host's state file; an
action outside the closed list, an action with a shell's words in it, an id
that has not the form of one, and an id with a path in it are 400 and leave
the state file byte for byte; a package that is not installed is 409 in the
host's words; without the login 403 with the package still there; remove
takes the package, its data, and its name away. The key and the work
directory are removed and the extension root is as found.
Proven. The unit suite: 451 tests, 0 undefined names. On the bench
reference, image 20260921140920 with the cross-built forgectrl and
extension host mounted over the image's: PASS; against the image's own
forgectrl, FAIL at its first request.
Acceptance. The test is new: auto. It covers forgectrl's src/extpkg.* and
src/main.c and forgeext's src/main.c, src/install.*, and src/state.*, and
requires exthost.service and forgectrl.auth.
The reference service talks to the host over the socket named in FFX_API
and takes machine.read. exthost.service adds, from the inside: GET /v0/self
names the package and what it may use; GET /v0/machine/mode is forgectrl's
answer; a hold it was not granted is 403, a path the API does not have
404, a path with .. 400; FFX_API is in the fixed environment. From the
outside: the socket is root's and the account's at 0660, and another pool
account that connects to it gets EACCES (a new probe mode).
exthost.hold-pause-tier adds the package's own word. The test leaves what
to say in the service's data directory and the service passes it on as
POST /v0/hold: raised, the verdict is EXT with the package's words and fire
withheld; words the form does not take are refused and the hold reads as
before; cleared, the verdict is OK. Then the rest as before: the host's own
raise for a package that ends at every start, the operator's exits, the
stale file under a suspended host.
Proven. The unit suite: 451 tests, 0 undefined names. On the bench
reference, image 20260921130558 with the cross-built host mounted over
the image's: exthost.service PASS, exthost.hold-pause-tier PASS (the
package's hold stood 1.5 s after it raised it; held 2.0 s after the
service was killed; safe mode released it in 0.8 s, extensions off in
1.1 s), exthost.armed-freeze PASS with the changed reference service.
Against the image's own host, which gives a service no socket,
exthost.service FAILS, as it should.
Acceptance. Both tests already cover forgeext whole (exthost.service) and
the hold's files by name (exthost.hold-pause-tier); neither covers map
changes.
The reference package takes the hold capability (its service ends at once
when the test leaves a file in its data directory: a package that cannot
speak for itself). The install is refused without the operator's grant; a
granted hold is advisory until `forgeext hold <id> required` names the
package under required-holds. With extensions on, the required hold stands
in the host's words until the service has run healthy, then the host keeps
its file fresh and clear and GET /cool/status reads OK. The test then makes
the service end at every start: verdict EXT, fire_ok false, hold true, the
reason naming the package, and never clear in 24 looks over the crash loop.
Safe mode ends it within four seconds and leaving safe mode brings it
back; marked advisory it is dropped; marked required again and the host
suspended, the reason becomes that the host is not answering, and resumed
it is the package's again; ext_enabled=0 ends it. The verdict is read at
idle: nothing moves and nothing fires. Everything is put back as
exthost.service puts it back, the required holds are empty, and the
verdict is OK at the end.
Proven. The unit suite: 451 tests, 0 undefined names. On the bench
reference, image 20260921121235 with the cross-built forgectrl and
extension host mounted over the image's: exthost.hold-pause-tier PASS
(held 1.0 s after the service was killed; safe mode released it in 0.8 s,
extensions off in 0.8 s); against the image's own forgectrl, whose engine
knows no holds, it FAILS with the verdict OK, as it should.
Acceptance. The test is new: auto, takeover. It covers forgeext's
src/holdkeep.*, src/run.*, src/install.*, src/state.*, src/main.c, and
forgectrl's src/holds.* and src/cool.*, and requires exthost.service.
On the bench reference /data/forgefirm is 0700, and the operator account
could not enter its own home under it (su scott -c "cd ~": permission
denied). forgefirm-users.init makes the directory of homes 0755 on purpose
("a login traverses it to reach its home") and said nothing about the data
directory above it. No script in the tree makes that directory 0700:
settings.c makes it 0755, forgefirm-logging makes it under rcS's umask, and
this script's own mkdir -p under umask 077 never makes it, because the
record it waits for lives inside it (forgectrl 35684ca's message names
that mkdir as a possible author, and it cannot be). The bench reference's
directory dates from 2026-09-11. The render does not depend on who made it.
replay() now puts the search bit for group and others on the data directory
at every render (boot, and every reload forgectrl asks for), whoever made
it and under whatever umask. Nothing is taken away and nothing in it
becomes listable; what is private there is closed file by file. The same
bit is what lets an extension package's account walk to its files.
setup.account-login sets the data directory to 0700 before it makes its
temporary account, as a strict umask leaves it, and then tries the account
from the inside (a child that becomes it): the render that made the account
left the directory at 0711, the home can be entered and written, the data
directory cannot be listed, and the account record cannot be read. Its
put-back (the found mode, the replay, the temporary home) now runs on every
exit path; before, a failure inside the account's lifetime left the
temporary home and its passwd line behind. image.health asks for the search
bit on a machine with an account record.
Proven. On the bench reference, image 20260921022220, the suite files
mounted over the image's: with the image's own init script
setup.account-login FAILS (cd EACCES, write EACCES, the directory still
0700), as it should; with this script mounted over /etc/init.d it PASSES
(0700 to 0711 at the account's render, cd ok, write ok, the listing and the
record EACCES), image.health PASSES reading mode 711, and the operator
account enters /data/forgefirm/home/scott and is refused the listing of
/data/forgefirm. The mounts were taken away and /tmp cleared; the
directory stays at 0711, which is the repair. The unit suite passes (451
tests, 0 undefined names).
Also on image 20260921022220 as flashed, before any of this was mounted:
image.health (its extension host section on a machine for the first time),
exthost.platform, setup.extensions-consent, exthost.service (a killed
host's service gone in 0.05 s, the host back after 5.7 s),
update.product-gate, and exthost.armed-freeze (the window open 38.6 s,
frozen 0.21 s after it opened and 6.02 s before the latch unlocked for the
run, in all 177 samples; thawed 0.41 s after the close) all PASS, and the
host logs under its own logger after a plain boot.
Acceptance. setup.account-login is the regression test, and image.health
proves the boot-time render on every campaign's first test. The init
script is layer content, in the platform identity of every fingerprint.
The freeze is the rule that keeps a package off the core while a job can
fire, and it had a host test against a stand-in forgectrl and one drill by
hand. This is its test on the machine, against the real engine.
The reference package runs, its heartbeat advancing twice a second.
cloud.dark-print's own job and body (a 30 s square at S0, the fixture's
press, the latch locked at the button, unlocked for the run, locked after)
open a real armed window over it. A sampler reads five times a second: the
engine's armed flag (GET /cool/status), the group's frozen state as the
kernel reports it (cgroup.events), the heartbeat, the service's pid, and
the latch. The test asks for a window of 10 s or more; frozen in every
sample from 2 s in to the close, with the heartbeat still; the first frozen
sample no later than the first unlocked-latch sample, so the freeze is in
place before the run; thawed, with the heartbeat moving again, 3 s after
the close; and one process throughout.
exthost.service's put-back and its as-found checks became _put_back() and
_as_found(), shared by both tests. exthost's top-level imports pulled the
setup suite (and now the cloud suite) in ahead of it and reordered the
catalog, so exthost is imported last: image, kernel, forgectrl, setup,
logs, motion, cooling, laser, camera, update, cloud, exthost; 106 tests.
Proven. The unit suite: 451 tests, 0 undefined names. On the bench
reference, image 20260920211625 with the cross-built forgectrl and host
from /tmp, the coolant taken from 27.2 to 25.4 C with an M8 session first
(the cloud client starts no print above 27): exthost.armed-freeze PASS, the
window open 37.5 s, the group frozen 0.42 s after it opened and 4.97 s
before the latch unlocked for the run, frozen in all 172 samples from 2 s
in to the close with the heartbeat still, thawed 0.62 s after the close.
With the host suspended (SIGSTOP) across the window, so that nobody
freezes anything, it FAILS: not frozen in 177 of 177 samples. exthost.service
PASS again after the refactor (a killed host's service gone in 0.05 s, the
host back and the service running after 5.6 s). The bench was left as
found.
Acceptance. exthost.armed-freeze is new: kind operator (the fixture
presses), takeover; it covers forgeext's src/super.*, src/run.*,
src/machine.*, src/cgroup.*, and forgectrl's src/cool.*, and requires
exthost.service and cloud.dark-print.
An extension package is software the image does not carry, and a result
taken beside one is not a result about the image. Two places hold the line.
The baseline (_ext_side, in every pre and post pass): a package under the
tests' own prefix (org.forgetest.) and an owner key named forgetest-*.pub
are what a test made and left behind; they are removed (forgeext remove,
the key's file), recorded as restored, and the host stops the service on
its next turn. A process that still runs under a pool account after that
belongs to the operator's own packages: it is recorded as unrestorable and
never touched. An installed package that does not run is nobody's
leftover. hw.pool_pids() reads each process's Uid line; hw.ext_packages()
lists the package directory.
image.health (5b): the extension host is one process (/usr/bin/forgeext
run), its start link sorts after forgectrl's and its kill link before it,
no package is installed, and nothing runs under a pool account.
Proven. test_baseline.py, ExtensionFreeTests, over a stand-in forgeext and
a fake package tree: an installed package that does not run leaves
nothing; a test's package and key are removed and the operator's package
and key stay; a removal that fails says so in the host's own words;
running extensions are reported and left alone. The unit suite passes (451
tests, 0 undefined names). The link order image.health asks for is the one
the built root filesystems of image 20260921014201 have: S90forgectrl
before S91forgeext, K09forgeext before K90forgectrl.
Acceptance. image.health gains forgeext's init/** in its covers map; it
runs first in every campaign and is the on-image proof of 5b. The
baseline is harness, outside the suite and outside every fingerprint.
The image installs forgeext beside forgefirm-sandbox. The recipe installs
the init script from forgeext's own tree (start 91, after forgectrl at 90,
whose read-only routes the host takes the machine's state from; stop 9, down
before it). The host runs nothing while ext_enabled is 0, the default.
setup.extensions-consent (takeover): the Extensions advisory is served and
is no first-run document; ext_enabled=1 is refused without the advisory's
hash, with a stale one, without the phrase, with the phrase in another
case, and whole beside a write the daemon refuses, each leaving the setting
and the record on disk untouched; with the hash and the phrase it is
accepted, recorded under on_demand.extensions, and the data directory gains
the search bit and nothing else. The setting, the directory's mode, and the
record are put back, the record under a forgectrl restart.
exthost.service (takeover): a reference package built on the board, signed
with a key made there and added as an owner key. It is unverified before
the key is the owner's and community after; the install is refused without
the consent. Turned on over the advisory, the service is looked at from
outside (account, no_new_privs, seccomp, group, limits, chain) and from
inside (what it can read, write, dial, and open), its first line is looked
for in the forgeext logger's file, safe mode stops it, a host killed with
the service in its quiet loop takes it along within 2 s and comes back, and
ext_enabled=0 leaves no group and no chain. The package, the key, the
setting, the mode, and the record are put back, and the extension root is
compared with how it was found. The reference package exists only while the
test runs: no image carries it, and no image trusts its key.
Proven. forgetest's unit suite: 422 tests pass, 0 undefined names. On the
bench reference, image 20260920211625 with the cross-built forgectrl and
host from /tmp: setup.extensions-consent PASS and, against the image's own
daemon, FAIL at its first request; exthost.service PASS and, with the init
wrapper's kill taken out, FAIL at the killed-host check. The first runs of
exthost.service found what the host tests could not: /data/forgefirm is
0700 on the bench reference, and the service ended with EACCES on its own
entry point until forgectrl opened the directory for search with the
consent.
Acceptance. Both tests are new. exthost.service covers forgeext whole and
forgectrl's src/main.c and src/logs.*; setup.extensions-consent covers
forgectrl's consent path. The recipe, the image line, and the init
script's install are layer content, in the platform identity of every
fingerprint. forgeext joins scripts/manifest-from-tree.py with its first
pin.
The way through the extension sandbox's deny rules is an allowlist, and an
allowlist names addresses. The machine's own LAN address is not a fact
anybody can pin: a new DHCP lease can turn a peer's address on some
package's list into the machine's, and with it open the Grbl port or
forgectrl's listeners to that package.
ffx.nft now refuses it structurally. Everything a host sends to one of its
own addresses, the LAN one included, leaves through lo, so chain pool
refuses `oifname "lo"` before it looks at the allow map; the two refusals
(a reset for TCP, a drop for the rest, both counted) move into chain
refuse, which pool jumps to from both places. No kernel option is new:
oifname is in the nf_tables core.
scripts/sandbox-rules-test.py gets a destination that is not the machine:
a second network namespace joined by a veth pair, with listeners of its
own. A pool uid is refused on loopback, IPv6 loopback, its own LAN address,
and the peer; an allow chain opens one port of the peer to one uid and
nothing else; with loopback, IPv6 loopback, and the machine's own address
added to that list the uid is still refused at all three while the peer
still answers; a reload closes it. It needs ip and nsenter now.
exthost.platform reads its counters from chain refuse, holds the rule's
place ahead of the map, and adds the case on the machine: an allow chain
for the last pool uid that names forgectrl on loopback and on the LAN
address opens neither, and the chain is removed.
Proven. The rules test passes with nft 1.0.9, and four controls each fail
it: the range one uid short, the TCP reject turned to accept, the
delete-table line removed, and the lo rule removed (the uid then reaches
all three of the machine's addresses). On the bench reference, image
20260920211625, this rule file loaded from /tmp with nft -f and this suite
file mounted: exthost.platform PASS, uid 831 refused at 127.0.0.1:443 and
172.16.1.97:443 with both on its allowlist, the counters [0, 0] to
[12, 4]. Against the image's own rules the same test fails on the rule's
absence, which is the control. The image's rules were reloaded after. The
unit suite passes (422).
Acceptance. exthost.platform gates the rule on the machine; sandbox-ci
gates the file. The rule file is layer content, in the platform identity
of every fingerprint.
forgeext is the extension host, a component of its own: recipe
recipes-forgefirm/forgeext (cmake, pkgconfig, forgefirm-manifest, so it is
a manifest component with its own pin file). It links jansson, libarchive,
and libsodium, and runs with fwup, the keyring, and forgefirm-sandbox.
libarchive and jansson are on the image already; libsodium comes with it.
The pin is all zeros because the repository has no pushed commit to name:
the recipe builds from a working tree through externalsrc, and a build
from pins cannot fetch it until the first push sets the pin. It is not in
the image's install list.
forgefirm-keys installs a third trust anchor,
/etc/forgefirm/keys/ext/forgefirm-ext.pub: the OpenGlow extension-signing
public key, the official tier of extension packages. It is a different key
from the release key on purpose: it signs more often, and its loss must
not sign firmware. forgeext refuses an extension archive whose only valid
signature is the release key's or a factory key's.
Proven. The recipe cross-builds forgeext from the working tree, and that
binary ran the verify-and-install cases on the bench reference (image
20260920211625, from /tmp, with the board's own fwup 1.16.0 and the
image's keyring) with the results of the host test. forgefirm-keys builds
and packages the key 0644 under ext/ (0755), byte-identical to the file
here; it is 32 key bytes and is not the release key.
Acceptance. No catalog test reads either yet: forgeext's tests are the
exthost suite that comes with its daemon, and the key is layer content, in
the platform identity of every fingerprint.
forgectrl's firmware paths now read an archive's product and take firmware
only (fwproduct.c). The upload route was not exercised by any catalog test;
this one is its door.
Archives made on the spot with the machine's own fwup go to
POST /update/upload as a multipart form: an extension package unsigned,
one signed with a throwaway key, and one that carries a task are each
refused with 400 "this archive is an extension package, not firmware" and
nothing is staged; an archive of another product is refused as not
firmware; one whose product is ForgeFIRM firmware is taken as the unsigned
upload it is, and the staged file is removed. Nothing is applied: the
apply calls the same gate before fwup -a, and its proof is the daemon's
host test, because a catalog test that could fail there would write a slot.
Proven. On the bench reference, image 20260920211625 with the gated daemon
and this file mounted over the image's: update.product-gate PASS, and
update.slots-and-signature PASS beside it (the signature refusal still
comes first). Against the image's own daemon, which has no gate, the test
fails on its first upload (200), and its cleanup leaves nothing staged.
The unit suite passes (422) with no undefined name.
Acceptance. update.product-gate covers forgectrl's src/fwproduct.* with
the update sources.
The cloud client turns a print away before the button wait for four
reasons of its own (machine._safe_to_move): the lid or the interlock, a
machine that is not idle, the coolant above its start ceiling
(THERMAL.max_start_temp), a coolant sensor that reads nothing. It logs the
reason and finishes the print ':cancelled' within a millisecond. The four
tests that wait for the button looked only for the wait, so on the bench
reference, with the loop at 27.4 C against the ceiling of 27,
cloud.dark-print sat out its 120 s and said "the print never reached the
button wait".
wait_button_wait replaces the four waits: it ends on the print's finish
line as well, and fails with the client's own lines, for example "the
client turned the print away before the button wait: INFO
machine:_safe_to_move machine temp is too high, temp: 27.4 (... finished
with event ":cancelled")". The ceiling is the client's and stays where it
is: the remedy for a warm loop is airflow, a run session for a minute or
two.
Proven. test_a_print_turned_away_before_the_button_fails_at_once_with_the_reason
replays the bench reference's lines and fails in under 30 s with that
reason; with the early exit disabled it fails after the full 120 s with the
old words. Each reason is a line the pinned cloud library can log, so the
phrase check passes. The unit suite passes (422) with no undefined name. On
the bench reference, image 20260920211625 with this cloud.py over the
image's, cloud.dark-print passes through the new wait with the loop at
25.8 C.
Acceptance. cloud.dark-print, cloud.verdict-refuse,
cloud.lid-during-button-wait, and every test that starts an offline print
go through the new wait. The helper is module text, so every cloud.py
test's fingerprint moves; no product behavior changes.
_job_wait read its own starting X when it was called, which is after the
lease wait and three refused requests beside the running job. On image
20260920204744 those took long enough that the head was already 2.55 mm
out, and the test failed with "the kernel saw 17.450 mm of the 20" on a
job whose record said done, 7 of 7 acknowledged, no emission sample, and
the counters back at 0.0. It now takes the caller's X, read before the job
was posted; laser.recorder-dark, the helper's other caller, reads no
distance and passes none.
Proven. On the bench reference with the fixed file over image
20260920204744's: motion.job PASS three times running, laser.recorder-dark
PASS. The file is in image 20260920211625. The unit suite passes (422).
Acceptance. motion.job is the test. The helper is module text, so every
motion.py test's fingerprint moves; no product behavior changes.
What the image holds ready before any extension package exists, so that
the first one starts inside it.
forgefirm-sandbox (new recipe, on both images):
- the account pool: ffx0 to ffx31, uid and gid 800 to 831, one group
each, /nonexistent, /bin/false, locked. Below 1000 on purpose: the
forgefirm-users render replaces only the accounts from 1000 up, so an
account reset leaves the pool alone and the read-only rootfs never
needs an account made at run time. The image's dynamic system ids
count down from 999 and stop at 997.
- an rcS script at S30: cgroup v2 mounted at /sys/fs/cgroup, the cpu,
memory, and pids controllers handed down to /sys/fs/cgroup/ffx, and
ffx marked idle-class (cpu.idle; the kernel refuses a cpu.weight on
top of it, so none is written). The firmware's processes stay in the
root group. `status` reports both halves and exits nonzero when
either is missing.
- /etc/forgefirm/ffx.nft, loaded by the same script, before the network
starts in rc5: table inet ffx, an output-hook filter with policy
accept that sends uid 800-831 to chain pool; pool looks the uid up in
the verdict map `allow`, then answers TCP with a reset (a drop would
leave a connect to time out) and drops the rest (the sender sees
EPERM), both counted. The map is the one way through: a uid mapped to
a chain of that package's destinations. Loading the file again
replaces the table, allowlists included: it fails closed.
nftables comes in as its runtime dependency, trimmed in the distro config
to the binary and its library with JSON output: no interactive shell, no
Python binding. gmp and jansson were on the image; libmnl and libnftnl are
new. The release rootfs goes from 34.5 to 34.1 MiB free.
scripts/sandbox-rules-test.py, and the workflow sandbox-ci that runs it:
the rule file loaded into a network namespace of its own and sent at from
real uids. Root, 799, and 832 are not touched; 800, 815, and 831 are
refused on 127.0.0.1 and ::1 at once, UDP with EPERM, and a receiver hears
nobody from the pool; an allow chain opens one port on one address to one
uid and nothing else; a reload closes it.
exthost.platform (new suite module exthost.py): the platform proven on a
probe process, not read off a config. In a probe group under ffx, as the
last pool uid: held to cpu.max, stopped by cgroup.freeze and running again
after, stopped at pids.max, killed by the group's own OOM at memory.max
while forgectrl keeps its pid. The 32 accounts as the boot's render left
them. Pool uids 800 and 831 refused TCP to forgectrl on loopback (both
ports, IPv4 and IPv6), to the LAN address, and to the Grbl port, at once,
UDP EPERM, with the rules' counters moving by at least the attempts, while
root reaches the same listeners. A root probe under landlock loses /etc
and TCP connects and keeps /usr; a seccomp filter returns EPERM for the
filtered call. The probe group is removed whatever happens.
Proven. The rules test passes with nft 1.0.9, the image's version, and
three controls each fail it: the range one uid short, the TCP reject
turned to accept, the delete-table line removed. The unit suite passes
(422) with no undefined name. Image 20260920211625 carries all of it (read
back from both rootfs images: 32 accounts in passwd, group, and shadow,
S30forgefirm-sandbox, the rule file and the script byte-identical, nft
with its libraries and no Python binding). On the bench reference, that
image: exthost.platform PASS (5.0 percent of the core under a 5 percent
cpu.max, 0 us frozen and 87358 us thawed over 1.5 s each, 5 of 12 forks
then EAGAIN, rc -9 with oom_kill 1 at a 24 MiB memory.max, the counters
[0, 0] to [12, 4], landlock ABI 6), and forgefirm-sandbox status reports
both halves in place.
Acceptance. exthost.platform gates the platform; sandbox-ci gates the rule
file. The recipe, the rules, and the distro option are layer content, in
the platform identity of every fingerprint.
forgectrl's POST /cool/state now asks for the secret the supervisor hands
the running controller at its spawn; both controllers' cooling clients
send it.
scripts/bench/cool_report_test.py is the driver's host harness for it
(null-sink controller, a stand-in for forgectrl's listener on
FORGECTRL_PORT): every report carries the secret and no other new header;
none does when there is none; a value that is not 32 hex digits, a CR LF
with a header behind it included, never reaches the wire; the homing
runner the controller starts does not inherit the secret. It is in the
bench registry and the bench README, and the driver's CI runs it.
forgectrl.auth: the loopback case used to assert that any local peer is
accepted. It now asserts the three answers: no secret 403, a made-up
secret 403, and the running controller's own secret 200, read as only root
on the machine can read it, out of the controller's environment, and never
logged. The LAN cases carry the secret too and are still refused.
cooling.report-channel is new, the drill the change exists for: M8 opens a
run session, three forged idle reports from this host (no secret, a
made-up one, a made-up one with a forged Host) are each refused, and over
the next five seconds the engine stays in phase run and no commanded fan
duty drops; M9 ends the session on the controller's own report.
Proven. The harness passes on the host-built controller, with three
negative controls that each fail as they should. The unit suite passes
(421) with no undefined name. On the bench reference, forgectrl and both
clients hot-deployed over image 20260920152153: forgectrl.auth PASS,
cooling.report-channel PASS (phase run throughout, the exhaust at 65535 and
the intake at 43278), and cooling.fans-quiet-after-motion and motion.job
PASS on the same binaries.
Acceptance. forgectrl.auth and cooling.report-channel are the gate for the
report channel's secret; cloud.dark-print gates the cloud client's side.
forgectrl gains a table of built-in extensions, with cloud mode as entry
one, GET /extensions to serve it, and POST /settings asking it which
selections point at the cloud, how to refuse them, and what they fall back
to. It also gains an example client under examples/, and its test tokens
lose the names of clients nobody is building.
setup.cloud-disabled-surface is the gate for "nothing points at the cloud
while it is off", so it now holds the list to the settings three times: as
found (enabled as cloud_enabled says, the two roles with their providers
and fallbacks, each active exactly when its setting selects it), with the
cloud off (not enabled, no role active), and as restored. The two refusals
are held to the table's words. Its covers name src/builtin.*. The helper
lives inside the test's own function, so no other test of the module
changes its fingerprint.
The unit test's fake daemon serves the route the way builtin.c does, and
gains a case with two lists that lie (enabled after the cloud went off; a
role that stays active), each of which must fail the test.
forgectrl.tokens: the jog token is "forgetest jogger".
manifest: forgectrl's examples/** joins the non-behavioral paths. They are
clients that run on another computer: outside the image, outside every
fingerprint, and outside the coverage lint.
Proven. The unit suite passes (421) with no undefined name; the two lying
lists fail the test as they should. On the bench reference, forgectrl's
registry daemon hot-deployed over image 20260920152153, on a machine with
cloud mode on: setup.cloud-disabled-surface PASS through the whole path
(off, the sweep, the refusals, the restore), and forgectrl.tokens PASS with
the renamed token.
Acceptance. setup.cloud-disabled-surface is the gate for forgectrl's
built-in table through the settings route.
forgectrl gains scoped API tokens: a credential made in the panel that
reaches the routes it was granted and nothing else, with no login session.
forgectrl.tokens creates three through the panel's route (machine.read with
camera.lid; motion.jog; camera.lid alone) and uses each the way a client on
the network does: over HTTPS to the machine's own LAN address, so the
daemon sees a peer that is not this host, and the dev image's and the
loopback's allowances do not apply. The first reads /status as a bearer
token, /mode in the panel token's header, and /cam/status. The camera-only
token reads /cam/status as ?key=. Refused with 403 and the reason in
words: the head camera, the first token in a URL (it holds more than a
camera), a jog, a settings write, a mode switch, the token list, a token
of its own, the panel's ?token= form, a stranger's token, and /status for
the jog token. The jog token then moves the head 1 mm with no session and
no panel token, the kernel's counters as the witness. A token over plain
HTTP from the LAN is refused and the daemon's log names it. With
panel_open_reads=0 the LAN reads /status with machine.read and with
nothing else. The list names the three with a last-used time and carries
neither a token nor a hash; the store is mode 0600 and holds no token. A
revoked token is refused at once, with the reads open again, and the other
tokens live on. Every token is revoked, the setting restored, and the head
returned on the way out, and tokens a broken run left behind are revoked at
the start. No token is written to the log or the evidence. The refused
requests are ones that would do no harm if the guard failed: a settings
write of the value in force, a mode switch to the mode in force.
Proven on the bench reference, forgectrl's scoped-token daemon hot-deployed
over image 20260920152153: PASS with a clean hand-back, the jog at 1.000
mm. The unit suite passes (420) with no undefined name.
Acceptance. This test is the gate for forgectrl's scoped tokens.
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.
Seen on the bench reference, twice in one session: at the end of a passing
run the hand-back jogged the head 30 mm into the back-left stop blocks,
from a head that had not moved.
The baseline compares the kernel's step counters at the end of a run with
the start and jogs the head back by the difference. The GRBL controller
zeroes those counters at every start (the lens's startup reference) and at
every home (home_completed()), and rewrites its anchor, /run/grblhal.homed,
each time. Across either event the difference between two counter readings
is not a distance the head traveled. It stayed hidden because the counters
normally read zero between tests. homing.manual broke that: a manual home
zeroes the counters 30 mm out from where the test began, so after the
test's own correct return they read -6400, and the next test that restarts
the controller (setup.check-flow-verify, then motion.release) ended at 0,
"expected -6400", and was "returned" by 30 mm. An operator who jogs the
head from a Grbl client and then starts any takeover test from the page
would have met the same thing, by whatever distance they had jogged.
The baseline's capture() now records the counters' frame, the anchor's
inode and mtime. If the frame changed during the run and the test did not
vouch for the new one, the hand-back logs that the two readings share no
frame, and moves nothing. ctx.counters_rezeroed() is how a test vouches: it
now sets rezero_declared beside the position it expects. The start_reads
argument it briefly took is gone, since it made the baseline accept
counters that nothing after it could live with.
homing.manual restarts the controller once more after returning the head,
so it ends with the counters at zero where it began, and declares that.
events.stream waits for its three places. A stream an earlier test closed
keeps its place until the daemon's next write to it (its keep-alive), as
documented, so run straight after forgectrl.lease the third stream drew
503. The test now opens the three once they can be opened, and says so in
its log.
Proven. test_baseline gains test_a_lost_counter_frame_never_moves_the_head:
counters at -6400, a new anchor, counters at 0: no jog, no leftover, and the
log says why; the same counters with the frame intact are still a displaced
head; a declared re-zero is held to the position it declared. With the
frame check unable to see the change (the first cut of the test reused an
inode inside one clock tick) the case fails with ['position'], which is the
old behavior. The unit suite passes. On the bench reference, arranged so a
failure would move the head away from the stop: the head jogged to +60 mm
(counters 12800), motion.release run, PASS, "the controller re-zeroed its
counters during the run ... the head is not moved", and nothing moved.
forgectrl.lease then events.stream: two logged waits, PASS. homing.manual
then setup.check-flow-verify, the sequence that drove the head into the
stop: both PASS with a clean hand-back.
The acceptance test for forgectrl's machine lease. The switches check is
started and left waiting at its first prompt: it moves nothing, and it
holds the lease for as long as it waits. /status must name it as the holder
(wizard:switches, kind hardware), and everything that asks the lease must be
refused with 409 and the holder's name: a diagnostic, the dose-curve
recorder, a log export, a mode switch to the mode already in force, POST
/controller/start, and POST /settings, the last with "settings are locked".
Only requests that would do no harm if the lease failed are made: no
reboot, no boot-slot change, no update job; and whatever a failed refusal
may have started is stopped on the way out. The check is then aborted: the
lease must read free, POST /settings must be accepted again, and the event
stream, open through all of it, must have reported lease.changed with the
owner and then with null.
Passes on the bench reference. The nested hold (a diagnostic under its
cooling wizard) is setup.check-flow-verify's to exercise, and it passes
there with /status reading diag:flow-verify under
wizard:cooling.flow-verify through the run. The unit suite passes (418).
It covers forgectrl's src/lease.*, src/main.c, src/status.*, src/wizdark.*,
src/diag.*, src/curverec.*, src/update.*, src/logs.*, src/super.*, and
src/events.*.
The acceptance test for forgectrl's GET /events. The daemon counts event
streams per peer address, so the test is several peers at once: it binds its
client sockets to 127.0.0.2 through 127.0.0.5, which are all this host. Three
streams must each get 200, text/event-stream, and the hello event; a fourth
address must get 503 with the reason while GET /settings still answers; a
second stream from the first address must be served, and the older one must
get bye and the end of its response; POST /motion/release and
/motion/energize must arrive on a stream that was open all along as
motors.released and motors.energized, in order, with ids that count up; and
with every stream closed a place must come back within 25 s, since a closed
client is only noticed at the daemon's next write to it (its keep-alive).
The SSE client was run against a host-built daemon before any bench time was
spent, which found two defects in it: its reads blocked for the socket
timeout, not the window the caller asked for, and a replaced stream ends
with the last chunk of the response, not with the socket closing.
Passes on the bench reference: the fourth stream refused in words, the
replacement told and ended, alarm, motors.released, and motors.energized as
ids 1 to 3, and a place back 3 s after every stream closed. It covers
forgectrl's src/events.*, src/main.c, src/status.*, and src/grblport.*.
Four catalog tests for the controller and daemon change of the same area,
and one hand-back rule they needed.
motion.release (takeover, 8 min): $MD takes the X and Y step currents to 0
with the 40 V rail untouched (no supply line in the kernel log, cnc/state
idle, no fault), drops the X and Y reference, and locks the machine in
alarm; a jog, a G0, $X, and $X after a soft reset are refused; forgectrl
refuses a switch to cloud mode with 409; the kernel's position record is the
witness that nothing was shipped, since the accelerometer cannot say "still"
across a release (the rotors relax and the head feels it); $ME restores 33
and 5 with no fault; and the drivers are proven alive the way the machine
proves it to itself, by the Setup motion check's liveness probe and
witnessed jogs.
homing.manual: a manual $H after an outbound jog plays no pulse byte, clears
the counters, declares manual_home_x and _y, keeps Z, turns the soft limits
on (error 15 behind the home), tells the client the home was set by hand,
and reads back through forgectrl as source manual.
motion.port-jog: with the suite connected as the Grbl client, POST
/motion/jog moves the head by what was asked on the kernel's counters, the
client is told and not displaced, and /motion/state agrees. The client then
polls the way LightBurn does, '?' with an end of line behind it: no port jog
may be refused for it, a 30 mm jog must run whole with at least three polls
landing inside it, and every poll must draw its ok. The cancel stops a long
jog short; the client's own line stops a fast one (100 mm at F6000, fast on
purpose: a slow jog stops at once and would pass with no hold) and must draw
ok, never an error; the 100 mm bound holds with nothing moved; the release
and the energize go through their routes with the currents read; and the
head is returned, in requests of 100 mm at most.
laser.port-dark (live, one button press): a 20 mm line at M3 S400 with no
M5 and no program end after it leaves the armed window open with M3 modal
and S above zero, the state in which an injected G1 would fire. The line
must be witnessed lit or the case proves nothing, the window must read armed
before and after, and through three port jogs back over the line the
LASER_ON sample count stays 0, the HV current stays idle, and the head's
beam detector does not rise over its level before the jogs (its own
pre-jog level, because it may still be settling after the cut).
The hand-back. The baseline compares the kernel's step counters at the end
of a run with the start. A manual home clears them by design, and
homing.manual takes its home 30 mm out from where it began, so after the
test's own correct return the counters read -6400: on the bench reference
the baseline "returned" the head 30 mm the wrong way and failed a run whose
body had passed. ctx.counters_rezeroed() now takes start_reads, what the
counters read in the new frame with the head at its starting position;
homing.manual passes the start minus the counters at the home. Without the
argument it means what it meant: re-zeroed at the start.
All four pass on the bench reference: motion.release (the rail untouched,
every refusal, the 409, the probe and four witnessed jogs), homing.manual
with offsets 12.5 and 8 (declared 12.502, 8.002, a clean hand-back
expecting -6400), motion.port-jog (10.000 mm for 10; 8 of 8 accepted under
the poll, 30.000 mm with 5 polls inside the jog, 14 polls and 14 oks; the
cancel at 10.3 of 40; the client's line at 33 of 100 with ok; drift 0.000),
and laser.port-dark (emission peak 148 on the line; 20 samples across the
jogs with 0 emission, HV 0, beam rise 5). The unit suite passes (418).
Coverage: motion.port-jog names forgectrl's src/grblport.*, src/main.c, and
src/status.*; motion.release adds src/status.* and src/wizdark.*;
laser.port-dark adds src/grblport.*; the driver's new sources fall under
src/**, which every motion and laser test already names.
Two host harnesses for the grblHAL driver's null-sink build, in the bench
registry and the README with the others. The driver's CI runs them.
ctlport_test.py drives the controller port beside a scripted Grbl sender
that counts every ok and error it is sent, which is the only way to see a
status routed to the wrong source. 13 cases: the socket's mode; a port
jog's status going to the port with the sender's count exact and the jog
run whole under the sender's '?' polls; a port error the sender's next line
does not inherit; the sender's line canceling a fast port jog and drawing
its own ok (fast on purpose: a slow jog stops at once and would pass with no
hold at all); a sender line queued right behind the port's; the refusals;
one client, and five reconnects right after a close; the status hook across
a soft reset; the dead-man; a CR LF sender; a sender that polls the way
LightBurn does, '?' with an end of line behind it, LF and CR LF (every port
jog accepted, a 60 mm port jog run whole with the polls landing inside it,
one ok per poll, a real line still canceling a port jog); and every
operation of both sets under an open armed window with M3 modal and S500,
where the dump must hold no FIRE tick.
manual_home_test.py reads the stream dump (GFSINK_DUMP) and the attribute
log (GFSINK_ATTR_LOG), so it can say that nothing was shipped and which
current was written, and how many times. 9 cases: a manual $H ships no step
and no FIRE tick and declares the offsets with the soft limits on and Z
kept; $H refused in a cycle; $MD refused under an open armed window with
nothing written; every motion source and $X refused while released; each
energize written exactly once; the port's panel operations; both pairs of
home offsets, alone and at once; and a controller killed under a release,
whose replacement writes only 0 and 0.
Both pass against the driver's extensions tree. The poll cases fail against
the driver without its empty-line rule ("8 of 8 port jogs were refused under
a status poll"), which is the defect they were written from: it was found
on the bench reference with LightBurn connected, and the sender these
harnesses had until then polled a bare '?'.
The release image carries only the standard-library module packages its own
Python recipes declare, and none of them imports asyncio, so it was left
out. An extension runs on the image's interpreter and brings no
standard-library module of its own: the set it may import is the set
installed. Its event-stream and socket clients are written on asyncio, so
the image installs python3-asyncio on purpose, with the reason in the
recipe. The other modules the trim leaves out (multiprocessing, xmlrpc, the
debugger, venv, ensurepip) stay out until something asks for them.
Proven: an image pair built from this recipe has
/usr/lib/python3.12/asyncio/__init__.py in the release rootfs, read out of
the ext4 image; on the bench reference, running the dev image of that pair,
python3 -c "import asyncio" succeeds.
Acceptance: the change is recipe body, so it is platform content and takes
the full campaign. No catalog test can hold the module itself: forgetest
runs on the dev image, which installs python3-asyncio with the rest of the
standard library whatever this recipe says, so the release rootfs is the
thing to read, as it was here.
The cooling engine publishes its state once a tick (1 Hz). A /cool/status
read inside the tick after a run ended still shows the run: while a
diagnostic owns the hardware every tick publishes phase "diag" with the
hold set, and a fail tier's hold stands until the tick that ends its
session. The baseline took one read, and by its rule an arm or a hold is
the run's doing, so a test that finished inside that second failed its
hand-back on a hold the engine's next tick cleared.
Seen on the bench reference twice. cooling.aa-offset-calibrate in campaign
c-20260919215024-c402: the diagnostic reported done at 22:07:14 with its
offset measured (15.7 counts, spread 0.7), and the hand-back at 22:07:15
read "cool=diag/armed=False/hold=True ... -> waited" and failed the run;
the test had passed on five images before, the last one earlier the same
day. cooling.fail-tier-stop in c-20260919202934-3d3a, the same way on the
crash fault's hold (0ceb4ab made that test wait for its own fault; this is
the general case).
The cooling check moves into Baseline._cool_side. An arm or a hold is read
again for up to COOL_PUBLISH_S (2.5 s: two ticks and a margin) before it is
called the run's doing. One the next tick cleared is logged as the engine's
last word on the run and judged on what the engine reads then: idle is
clean, a cooldown phase is waited out as the engine's own post-job work. A
hold a run did leave stands for a job that is never coming back, so it is
still there after the tick and is recorded, stood down and failed exactly
as before; a hold that clears only later is still a leftover ("waited").
Proven: tests/test_baseline.py CoolPublishTests - a diagnostic's hold the
next tick clears, a fail tier's hold clearing into the smoke phase, a hold
that outlives the tick (recorded, machine stood down), a hold that clears
only later (recorded as waited), an idle engine and a silent daemon; the
baseline, queue, operator, mode, responsiveness and server host tests
pass under Linux. baseline.py is not a suite module, so no test's
fingerprint moves with it.
The test counted "heater rise" in the last 300 lines of the forgectrl log
before the job, then waited for the count to grow. A tail of fixed length
cannot show that: the new verdict line comes in at the bottom as an old one
leaves at the top, the count does not move, and a check that verified reads
as one that never judged.
Seen on the bench reference in campaign c-20260919210959-8780: the engine
logged "coolant flow verified (heater rise 11.5 C, dT 9.5 C; laser 1.6 off
13.1)" at 21:37:46, 70 s into the job and inside the wait, and the test
failed at 21:39:21 with "the engine published no flow verdict within 120 s".
The 21:17:27 verdict of an earlier test sat near the top of the 300-line
tail when the test began. Replayed against that log, the old method reads 4
before the job and 4 with the new verdict in the tail; the search from the
byte offset finds the 21:37:46 line.
The test now takes the log's byte offset before the job (_log_offset, as
the fail-tier and liveness tests do) and searches what the file gained
since with the verdict expression, stopping only on a line that matches.
_log_since reads a file that is now shorter than the offset from its start:
a rotation under the test leaves only newer lines. The /logs/tail helper
and its line count are gone with their one user.
Proven: tests/test_cooling_suite.py FlowVerdictLogTests - the text after the
offset alone, the real verdict line through the expression, an old verdict
before the offset not taken for the new one, a rotated log read whole, a
missing log read as nothing; the cooling host tests pass under Linux, 24
tests. No component source changed, so no pin moves.
The test raised a crash fault and handed the machine back with it still
standing. The engine trips the crash tier on one 1 Hz tick and ends the run
session on its next tick, which is when the fault and its hold clear ("head
crash fault cleared with the run session"). The supervisor has the new
controller up inside that second, and the test returned as soon as it saw
the new pid and the two log lines, so the runner's hand-back check could
read /cool/status before the clearing tick: phase run, hold true. A hold is
the run's doing by the baseline's rule, so the check recorded a leftover
and failed the test, though the wait that followed ended on its own.
Seen on the bench reference in campaign c-20260919202934-3d3a, the only
failure among 65 results: the trip at 20:54:44.871, the new controller at
20:54:45.094, the test's PASS line at 20:54:45, the session end at
20:54:45.863, and "cool=run/armed=False/hold=True ... -> waited" from the
hand-back check. The engine opened no session after that one; the 16 s the
check waited were the smoke phase that follows an armed session.
The test now asserts what the engine documents: after the restart checks it
waits up to 10 s for the verdict to leave CRASH, records how long that took
(fault_cleared_s) with the phase, verdict, hold and armed flags it found,
and fails when the fault does not end with the session. The machine is then
handed back in the engine's own post-job phase, which the baseline already
treats as the engine's work and not a leftover.
Proven: the cooling, artifact, campaign and queue host tests pass and the
catalog loads with the new source. No component source changed, so no pin
moves; the test's own source hash does, so its result comes from this
revision.
meta-openglow 31c52050bc70 -> 71fad52e8ff5
The layer head removes the wlan0 inet6 stanza and the busybox bbappend that
built udhcpc6, the client that held a field machine's boot in ifup. The
acceptance test image.network-boot fails on any image that still carries
that client, so the lock moves with it. poky, meta-openembedded,
meta-freescale and meta-freescale-distro keep their commits.
Proven: kas checks layers/meta-openglow out at 71fad52, the layer holds no
busybox recipe directory and its interfaces file names no udhcpc6, and
bitbake -c fetch busybox init-ifupdown succeeds against it.
forgectrl 7a9de005ede5 -> 0dae758cb93e (PV 0.1.26 -> 0.1.27)
forgectrl 0dae758 stages logs/install/ into the log export, which is what
logs.tree-tail-export now requires of the bundle. The PV moves with the
SRCREV so the hash-derived package version stays monotonic.
Proven: bitbake -c fetch forgectrl resolves the pin (forgectrl-1_0.1.27-r0
do_fetch succeeded).
A field install failed on "firmware download failed", and worked after a
reboot. The download was one bare curl -fL: no retry, no resume, no bound
on a stalled transfer, and nothing on the machine recorded what had gone
wrong.
The download. download_fw makes up to five tries, 5, 15, 30 and 60 seconds
apart. Each try resumes the partial file (curl -C -) and is bounded: 20 s
to connect, and a transfer below 1 KB/s for 30 s ends the try. The file is
written as forgefirm.fw.part and takes its name only when curl finished;
the signature check that follows is what vouches for its content. A full
disk (curl 23) and a release that is not there (HTTP 404) end the tries at
once, because waiting cannot fix them. A partial file the server will not
resume (curl 33 or 36, HTTP 416) starts over. The loop is the installer's
own rather than curl --retry: the factory curl on the bench reference is
7.69.1, whose --retry does not count a resolver failure or a dropped
transfer as retryable, and older factory builds carry older curls. The
owner sees the reason in words with each retry, and the final failure says
that a re-run goes straight to the download, because the archives are kept.
The log. Every run appends to /data/log/forgefirm/install/install.log, in
the log tree's own line format (UTC, program "install"): the installer's
md5 (which revision ran), the factory version and the slots, the owner's
answers, each archive, each download try with curl's exit code, the HTTP
code and the reason, the machine's clock at each try (a wrong clock breaks
TLS), and after a failed try the address, the default route, the resolver
and whether github.com resolves; then the signature and identity checks,
the write, the boot selection, and the reason for any failure through
die(). The log is appended across runs, so the run that failed is still
there after the run that worked. Logging never fails the install.
forgectrl's log export carries the directory (forgectrl 0dae758).
Proven: tests/test_installer.py runs the installer's own functions under
sh against a scripted curl - a clean download, a resolver failure and a
dropped transfer that resume to the full file, the tries running out, 404
and a full disk ending them at once, a stale partial file starting over,
the TLS reason naming the clock, every log line in the tree format, die()
leaving its reason, and an unwritable log not failing the run. The whole
host suite, 409 tests, passes under Linux and the coverage lint is clean.
Bench: the same functions under the factory firmware's own shell (busybox
1.31.1 ash, the factory slot of the bench reference in a chroot) resumed,
retried, ran out of tries and logged exactly as under sh.
Acceptance: logs.tree-tail-export now plants a probe file in the install
directory and requires it back in the export bundle, its line intact and
its MAC and IPv4 address redacted, and requires an install log in the
bundle when the machine has one. The installer itself is not on the image:
the install page fetches it from master, so it is live with this push.
A client that waits in ifup's foreground on a server's answer holds the
whole machine, because init starts the rest of the boot - sshd, forgectrl,
the console login - only after S01networking returns. A field machine sat
there forever on a router that refused DHCPv6 (the record is in the
meta-openglow commit that drops the DHCPv6 client). Nothing in the catalog
looked at the network boot path; this test does.
It asserts: wlan0 is in ifupdown's state file and no ifup is running; the
console getty is up; udhcpc runs with -b (it leaves ifup after three
unanswered discovers) and has been reparented to init; no DHCPv6 client is
named in /etc/network/interfaces, running, or on the image, and its hook
script is gone; IPv6 on wlan0 is the kernel's own - enabled, router
advertisements accepted, a link-local address up. A global address is
evidence only: a network whose router advertisement offers no SLAAC prefix
gives none. What a hostile server does to a client is a bench drill, not a
test.
covers is empty by design, as with setup.machine-name: the interfaces file
is layer content, in the platform identity of every fingerprint, so a
change there already makes every test necessary again.
Proven: the host tests for the parsers and the registration
(tests/test_image.py), and the whole host suite, 398 tests, under Linux.
The test's logic, run read-only on the bench reference against an image
that carries the client, fails exactly the four DHCPv6 checks and passes
every other one. The test fails on any image built from a meta-openglow
that still carries the client, so the kas lock moves to the layer head
that drops it.
check_size guards the 200 MiB eMMC slot. The dev path called it against the
release rootfs until 88ec984 switched that path to the dev image, and the
call was left in place: the dev rootfs is about 368 MiB - forgetest and the
bench tools - so release.sh --dev has died on "too close to the 200 MiB
slot" ever since, which is why no forgefirm-dev.fw exists anywhere in the
tree.
The gate is not the real limit. A 368 MiB rootfs cannot be raw_written into
a 200 MiB slot at all, so the dev image is never installed into one: it runs
from an SD card, which the boot selector already carries as a first-class
location and which the bench has always used. The comment says so, and the
gate now applies to the release rootfs alone, which is what it was written
for.
The dev rootfs has been this size in every archived build back to
20260911172215 (386,465,792 bytes then, 386,727,936 today), so nothing here
is a regression - the gate simply outlived the image it was guarding.
The lock held meta-openglow at dd8f74c, three commits behind the pushed
scarthgap head, so a build from this repository resolved the old BSP and
none of the component pins just bumped there. Moves that one entry to
31c5205.
poky, meta-openembedded, meta-freescale and meta-freescale-distro keep their
commits: the platform is deliberately unchanged, so the build exercises the
pins and nothing else.
Proven: bitbake -c fetch and -c populate_lic over forgectrl,
grblhal-glowforge, gfcloud, gfhome, python3-ffmachine, python3-gfhardware,
python3-gfutilities and kernel-module-glowforge all succeed - the four
LIC_FILES_CHKSUM values written ahead of their pins now match the fetched
license text. A full bitbake forgefirm-image forgefirm-image-dev run
completed, 5841 tasks, no errors, and the release rootfs manifest carries
forgectrl 0.1.26, grblhal-glowforge 0.1.18, kernel-module-glowforge 0.0.6,
python3-gfutilities 0.9.19+git0+0a93ebf30b and
gfcloud/gfhome/python3-ffmachine 0.1.30+git0+6cc4f45d31. Release rootfs
125 MiB, inside the 195 MiB gate.
forgectrl da3eddcc0f43 -> 7a9de005ede5 (PV 0.1.25 -> 0.1.26)
grblhal-glowforge ecebe9c8eb8d -> f93aca89821a (PV 0.1.17 -> 0.1.18)
forgefirm-app 5ca279a1f600 -> 6cc4f45d311a (PV 0.1.29 -> 0.1.30)
forgectrl brings the jobstream_test SIGPIPE fix and the x32 setting comment;
grblHAL-glowforge brings the x32 xy_microsteps default and the rewritten
COPYING this layer's LIC_FILES_CHKSUM already expects, which no longer fails
the fetch now that the pin resolves to it. forgefirm-app tracks the same
python3-gfhardware revision meta-openglow just pinned.
Each PV moves with its SRCREV so the hash-derived package version stays
monotonic.
The x32 default landed in forgetest/baseline.py and the driver, but two
callers still judged the machine at x8 and both failed on the host.
forgetest/tests/test_baseline.py: setUp seeded the fake machine from the x8
FIXED_SYSFS literals while enforce() compares against fixed_sysfs() of the
resolved mode, so x_mode, y_mode, step_freq and ramp_rate read as deviations
on a clean machine - 23 failures across BaselineTests and
TransientNotLeftoverTests. It seeds from fixed_sysfs() now, and the tick
expectations come from it (DEFAULT_TICK) rather than a typed 28160. The
xy_mode_of and ref_xy_mode unset/invalid cases expect 32, with an explicit
"8" case added that had no coverage. Two reference_preconfig dumps taken on
an x8 machine carry xy_microsteps = 8, because the markers are read at the
reference's own mode. wait_configured wrote the static CONFIGURED_MARKERS
where the function watches configured_markers() of the mode in force, and
the held-controller jog typed 221 steps for "4.144 mm", which is 1.036 mm at
x32; both derive from the mode now. 69 tests, all pass.
scripts/bench/laser_stream_test.py: STEPS_PER_MM was the x8 53.333, so the
X-peak check failed at 2133 steps against an expected 533. The whole harness
now derives from XY_MICROSTEPS_BASE/DEFAULT the way glowforge.h and
baseline.py do, which uncovered five more x8-only expectations behind the
first: the machine tick, the fire-gap limit (it grows as sqrt(k), not k - a
finer mode shortens the accel interval by sqrt(k) while speeding the tick by
k), the rung split in fire_spans, the density period and minimum burst
(laser_pulse_ticks is in x8 ticks and the stream scales it, so the config
keeps the x8 numbers and the measured lengths scale), and the decel/hold
budgets. Run against the null-sink build: all stream emission rules hold.
xy_mode_test.py's docstring still described the no-key case as x8 while its
own assertions had moved to x32.
No behavior change and no acceptance-catalog consequence: these are test
expectations and a bench harness, not image component sources. The coverage
lint is unchanged at 0 uncovered paths.
Acceptance baseline: XY_MODE_DEFAULT 8 -> 32, split out XY_MODE_BASE (8) for
the tick/ramp scaling, matching the driver. The mode-aware comparisons
(fixed_sysfs of the resolved mode) judge an unset machine at x32.
Host CI: xy_mode_test.py default and invalid cases expect x32/213.333/112640.
Bench tools: raster_dry.py and xy_pattern_accel.py set_mode() cleared the key
for mode 8, which relied on clear == the x8 default; clearing now yields x32,
so they set the mode explicitly and restore to x32. live_fire_drills
STREAM_RATE_HZ is the 28160 Hz laser reference tick, the same at every mode.
on: push had a paths filter but no branch restriction, so a tag push
(a release's v<version> tag) re-ran forgetest-ci and fixture-ci on the
commit master had already tested - a tag push bypasses the paths
filter. branches: [master] scopes the push trigger to the branch;
pull_request and workflow_dispatch are unchanged.
FORGEFIRM_RELEASE = 0.0.5 and the acceptance artifact that authorizes it:
campaign c-20260915231006 on the dev image 20260915225440 (grbl pin
0.1.17, forgefirm 52a9138), authorized, all required tests passed on the
bench reference.
The clean-parser-on-connect fix (ecebe9c): a soft-limit-rejected jog
held subsequent g-code until a blank line, and the hold outlived the
connection - a new sender got error:15 on its first g-code line.
laser.verdict-cut freezes the daemon so the verdict the controller
caches expires and the driver's pause tier holds the job, then resumes
and proves the job ran dark through the hold with the laser latch never
locked. The freeze was 3.5 s. The engine's report dead-man locks the
laser latch when the controller's report is older than 5.0 s while
armed, and the last report is up to 1 s old when the freeze starts, so
a 3.5 s freeze left only a half second of margin - a dead-man latch
lock reads exactly like the failure the test refuses.
The freeze is now 3.0 s: still past the 2.0 s the cached verdict takes
to expire (so the pause tier holds and the hold is seen), a full second
under the dead-man. No behavior changed, only the freeze the test
holds.
The fresh-controller button-lamp reset (a87837a): a controller ended
mid-arm by a fail tier left the button ring glowing 'press to arm', and
the respawned controller did not clear it.
Three acceptance tests broke on the WI-4/WI-5 firmware, all from a
behavior a change moved and no test tracked.
motion: soft limits are armed after a home (the driver's, since the bed
has no switches), so a jog past the bed is refused with error:15 - and
grblHAL then answers error:15 to every following G-code line, across a
fresh connection, until a blank line acknowledges it. A prior test's or
the baseline hand-back's rejected jog left that latch, and the next
test's first move failed with a stale error unrelated to the move.
clean_slate now clears it with a blank line before the first move. The
four "send G1, sleep, check Run once" sites are replaced by start_move,
which waits for Run on a bound and, when it does not come, names the
reply, the state, the drained messages and a forgectrl snapshot - the
capture that turned "the move did not start" into "answered error:15".
cooling.fire-watch-tiers: the FIRE fail tier now stops the controller
through the supervisor and starts it again, so leg 2's Grbl connection
dies mid-leg. It now records the pid, reads FIRE from the engine and
the latch from sysfs, waits out the restart, proves the supervisor
logged the fail-tier stop, and opens a fresh session for the legs that
follow; src/super.* added to its covers.
Proof: full forgetest host suite green; coverage lint clean (90 tests);
bench reference on the dev image, unattended queue: motion.* and
cooling.* pass, cooling.fire-watch-tiers and cooling.fail-tier-stop
pass with the button-lamp driver fix (grblHAL-glowforge).
The supervisor holds every controller spawn until the enclosure is
closed (forgectrl 0.1.25), and the test opened the lid before it asked
for the cloud controller: POST /mode answered "waiting, the lid is
open" and the controller never came up. The round trip now switches
with the lid closed, polls /mode five times a second, and opens the
lid the moment the controller is running. The client requests its
connect-time hunt a few seconds after its start, right behind its
session, so the hunt still finds the lid open. The order is recorded
and judged: the hunt's request line must not be in the client's log
when the lid reads open (hunt_before_lid_open), and the test refuses
to start with the lid open. The catalog text tells the operator to
open the lid at once, with a hand ready on it.
Proof. Host: test_cloud_suite drives the round trip with the hunt
landing only once the lid reads open, as on the bench, plus the lost
race (the hunt requested before the lid opened fails the test with
"before the lid was open") and the start with the lid open refused;
8 mode-switch cases green. Bench reference (dev image 20260915001814,
forgectrl 0.1.25): cloud.mode-switch PASS in 118 s, the lid open 4 s
before the client requested its hunt, no refusal before the hunt's
end, the lens homed, the exhaust row unjudged, 5 service motions
after the lid closed, $H under gfhome homed in 48.4 s with 9 motion
windows. No catalog consequence beyond the test itself: its covers
map is unchanged.
The driver at its AGENTS.md and header-comment revision (9bfaa98): no
code change since 0.1.14; the pinned source now matches the head of the
repository for the release.
The project-wide safety rule joins every repository: a failed read of
a safety input, a state file, or a verdict is never treated as still
running or keep waiting without a bound. The acceptance-coverage rules
add that a silence-while-armed injection and a crash during $H are
catalog drills, not one-off bench scripts.
The BSP layer at dd8f74c: python3-gfhardware pinned on the cloud
client's latch and feed changes (5ca279a), the same revision the
forgefirm-app recipes pin, and the layer's line endings normalized.
The cloud client: the laser latch unlocks at the run and nowhere
earlier, the warm-up is supervised, a live feed must land and finish,
fire needs a power byte first, the homing runner always stops
(python3-gfhardware 5ca279a; the pin also carries the "setup" naming
in the comments and the LF normalization). Bench-proven with
cloud.dark-print, cloud.verdict-refuse and motion.deadman on the bench
reference.
The cloud client changes (python3-gfhardware: the latch unlocks at the
run and nowhere earlier, the warm-up is supervised, a live feed must
land and finish, fire needs a power byte first, the homing runner
always stops). This commit carries the catalog tests that hold the
part the bench can see.
forgetest/forgetest/suite/cloud.py:
- cloud.dark-print (new, kind operator, one press, dark by
construction) replaces cloud.verdict-hold. The print arms on the
press, waits for the engine's acknowledgment and runs: the laser
latch is locked at the button and through the wait, unlocked only
for the run (immediately before it starts), locked again when the
job ends, and the print completes. The engine's own warm-up release
stays proven by cooling.floor-and-warm-up.
- cloud.verdict-refuse (new, kind operator, one press, dark): the
start gate far above the coolant and cloud_hold_max_s at its minimum
keep the armed print under the warm-up past the bound. The client
waits with the latch locked (sampled every two seconds), cancels at
the bound with its own log line, never runs, closes the armed window,
and the print ends ':cancelled'. The settings are restored.
- cloud.verdict-hold is retired: its release rode the loop heater at
the flow-check duty against a gate one degree above the coolant,
inside the upstream reading's noise band, and its run hovered for
minutes with the engine's "warm-up stalled" line in the log. The two
tests above prove the client's contract without the thermal race.
- cloud.oversize-stream already reads cnc/streaming back at both ends
of the run, which is the readback the client now insists on. A
forced streaming write failure has no seam on the board (the write
goes to sysfs as root) and stays a host test.
tests/test_cloud_suite.py follows: excerpts and failure cases for the
two new tests in place of the retired one's.
Proof. Host: the forgetest unit tests. Bench reference:
cloud.dark-print passed (locked at the button, unlocked in the run,
locked after, ':completed'), cloud.verdict-refuse passed (held 60 s,
31 latch samples all locked, ':cancelled', settings restored);
motion.deadman's kill during $H ended the runner on SIGTERM alone; and
the retired cloud.verdict-hold passed once more on the new client
before it went (the latch locked through an 8 minute warm-up hold, the
release ran the print to completion).
The supervisor: controller death as a signal, the homing runner and
the kernel before a respawn, the enclosure check before every spawn,
the engine's fail tiers ending the controller (forgectrl da3eddc; the
pin also carries the "setup" naming of the first run and the LF
normalization, whose advisory wording change asks every machine to
accept the advisories again). Bench-proven with motion.deadman,
motion.respawn-gate and cooling.fail-tier-stop on the bench reference.
The daemon's supervisor and engine change (forgectrl: controller death
as a signal, the homing runner and the kernel before a respawn, the
enclosure check before every spawn, the fail tiers ending the
controller). This commit carries the catalog tests that hold them.
forgetest/forgetest/suite/motion.py:
- motion.deadman gains a last phase: SIGKILL of the controller during
$H (the web-service homing, run only with cloud mode enabled, the
homing mode set and put back by the test). The homing runner must be
gone before the respawn, the kernel idle when the new controller
starts, no halt needed, and the pulse device held by the daemon and
one controller. The head ends wherever the homing was.
- motion.respawn-gate (kind operator, the lid through the fixture where
one is wired): the controller killed with the lid open. The
supervisor safes (latch locked), reports waiting with why naming the
lid, starts nothing while the lid stays open, and comes back verified
when it closes, without a second motion probe.
forgetest/forgetest/suite/laser.py:
- laser.armed-kill reads the latch and the kernel state from sysfs
every few milliseconds after the SIGKILL: the latch must lock within
300 ms of the kill and the kernel leave running within a second (a
death is a signal to the supervisor, not a poll). The emission bound
stays at 2.5 s: the witness counts a window.
forgetest/forgetest/suite/cooling.py:
- cooling.fail-tier-stop (kind operator, one press, no emission): the
crash watch's thresholds at their lowest make the head's own move
trip the abort generator inside an armed, dark (S0) job. The engine
logs the crash signal, the supervisor logs the stop and starts a new
controller, the latch is locked, the kernel idle, no emission, the
thresholds put back. The watch exists only inside the armed window,
so the press is the arm, not a fire.
Proof. Host: the forgetest unit tests. Bench reference: motion.deadman
passed with the new phase (the runner gone in 0.9 s, the kernel idle
at the respawn, holders forgectrl and grblHAL_glowforge),
motion.respawn-gate passed (waiting 1.3 s after the kill with "the lid
is open", running and verified the moment the lid closed, no probe
line), cooling.fail-tier-stop passed (the signal at +0.27 s, the
controller pid 21823 to 22273, latch locked, emission 0).
Grbl protocol: the welcome banner on every connect, a one-second TX
stall bound, a 2 KiB TX ring (grblHAL-glowforge e507e13). Bench-proven
on the bench reference: a LightBurn connect shows the banner and the
machine settings read completes without a disconnect.
Stream and motion robustness: the shipper writes outside the lock, a
clamp inside an armed window faults, the X/Y soft limits follow the
home, the machine's settings are pinned, the homing keys are clamped
(grblHAL-glowforge 511ff25). Bench-proven with motion.soft-limits and
motion.deadman on the bench reference.
The GRBL driver's stream engine and its motion envelope change
(grblHAL-glowforge: the shipper writes outside the lock, a clamp inside
an armed window faults, the X/Y soft limits follow the home, the
machine's settings are pinned, the homing keys are clamped). This commit
carries the host rules and the catalog tests that hold them; the driver
commit follows, because its CI fetches these harnesses unpinned.
scripts/bench/laser_stream_test.py:
- Rule 28: a 300 ms producer stall while armed faults the stream with
ALARM:17, the kernel sees no step burst (at most the planned steps per
100 ticks), the stream ends dark, the latch sideband ends on the lock.
The same stall unarmed is a warning: the move completes with every
step, the clamp visible as the burst the kernel counts.
- Rule 29: a 300 ms stall of the sink's write leaves the producer on
pace: no clamp, every step, lit through, dark at the end.
- The stand-in engine takes GFSINK_STALL_MS and GFSINK_WRITE_STALL_MS,
null-sink only.
scripts/bench/z_envelope_test.py:
- Rule 10: homed (a gfcloud home), a program move past X max, Y max or
the near edge alarms with ALARM:2 before any motion, a jog past the bed
is refused with error 15, a move inside the bed runs, and a $20 write
keeps the limits. The core repeats the last error for the line after a
refused jog until an empty line clears it, so the rule sends one.
forgetest/forgetest/suite/motion.py:
- motion.soft-limits (kind auto, no emission): homes through the cloud
suite's gfhome homing when the machine is not homed, then the three
refusals (ALARM:2, the kernel counters still), the refused jog, the
inside move, and the return to the corner read at rest.
- motion.deadman phase 2b: a 100 ms SIGSTOP mid-move, inside the
kernel's queue: no underrun, the controller's log warns of the clamped
late events, the move completes with every step (read at rest), the
latch stays locked. The armed clamp is proven on the host (rule 28).
forgetest/forgetest/suite/cloud.py:
- gfhome_homing drains the driver's answer to $H once the session ends:
it sits behind the status reports and passed for the reply to the
caller's next command (a setting read as None).
forgetest/forgetest/baseline.py:
- The hand-back reads the position counters at the kernel's own
microstep mode (cnc/x_mode, read before the sysfs restore puts the
settings' mode back). At the x8 constant, an x32 machine's 30 mm read
as 120 mm, beyond the return bound, and the displaced head was left in
place. The dead band scales the same way. tests/test_baseline.py holds
both.
Proof. Host: stream rules 1 to 29 and z_envelope rules 1 to 10 against
the null-sink driver, the forgetest unit tests. Bench reference:
motion.soft-limits passed (X 495 and Y 279 refused with ALARM:2 and
0.000 mm of kernel motion, X-1 the same, the jog error:15, the inside
move ran, back at the corner 0.0/0.0 mm); motion.deadman passed with
the new phase (92 late events clamped, max behind 92.3 ms, no underrun,
30.0 mm counted, latch locked) and the hand-back jogged the head back
under the x32 scale.
The emission gates: the per-tick fire gate, the latch with an owner,
the cooling verdict's two tiers, and the arm-flow gates
(grblHAL-glowforge 16d2b9e). Bench-proven with laser.verdict-cut and
laser.armed-kill on the bench reference.
The GRBL driver's emission gates change (grblHAL-glowforge: the per-tick
fire gate, the latch with an owner, the cooling verdict's two tiers, and
the arm-flow gates). This commit carries the host rules and the catalog
tests that hold them; the driver commit follows, because its CI fetches
these harnesses unpinned.
scripts/bench/laser_stream_test.py:
- Rule 24 is the verdict's pause tier: the client holds the job under the
open window, the first deceleration runs lit to the stop (the dark lead
before the stop is at most 1000 ticks: the producer's lead plus one
shipper period), a resume under the standing verdict moves dark and is
held again, the clean verdict resumes lit with no press, and the latch
sideband carries only the arm's unlock and the program end's lock.
- Rule 25 is the fail tier: AIRFLOW mid-cut ends the job with ALARM:3,
the stream ends dark well short of the line, the sideband ends on the
lock, and a resume under the clean verdict that follows resumes nothing.
- Rule 26: a sender change mid-M3 holds the job with the deceleration
dark: the gate follows the window on every tick.
- Rule 27: a verdict that goes stale holds the job at the cache's own
expiry, lit to the stop, never a poll later; the engine's return
resumes lit.
- The stand-in engine publishes the verdict name and has a stale mode;
the session steps gain expect_text, reconnect and a wait_state timeout;
every session reads the latch sideband (GFSINK_LATCH_LOG).
scripts/bench/laser_lifecycle_test.py:
- Rules 11 to 15: the pause tier resumes with no press and no prompt, the
fail tier ends the job and nothing resumes it, a sender change during a
re-arm cancels it, a jog does not hold the window open, and a press
counts only after the button has been seen up. The stand-in engine
takes a live verdict dict. start_armed_move waits for a fresh prompt
and a fresh armed message: a press that lands before the wait has
begun is not consent, and the old stale match let one land early.
forgetest/forgetest/suite/laser.py:
- laser.verdict-cut (kind live, one press): a 40 mm M3 line; the test
pauses the daemon for 3.5 s so the verdict goes stale (the settings
route is idle-gated and the engine reloads its gates at a session
start, so no setting can trip a pause mid-cut; the crash tiers need a
physical knock). The controller must hold with the SoC latch and the
hardware button latch both clear in every sample, emission must read 0
before the resume, and the clean verdict must resume the cut lit with
no press and no prompt; M2 disarms as usual.
- laser.armed-kill asserts that the respawned controller comes up with
the latch still locked.
Proof: both harnesses pass against the driver change on the host, and
the forgetest unit tests pass. On the bench reference, laser.verdict-cut
passed with no gap in the cut (held at +1.71 s after the pause began,
both latches clear in every sample, emission 0 at +2.06 s after the
hold, resumed lit at +4.0 s with the beam detector 678 counts over idle,
kernel drift 0.0 mm, disarmed 0.1 s after Idle) and laser.armed-kill
passed (emission 0 at +1.8 s after the supervisor's stop and +2.1 s
after the SIGKILL, latch locked, respawned with the latch locked, button
dark). Two earlier verdict-cut runs shaped the driver: a pause that
locked the latch resumed dark, because a lock sets the hardware button
latch, and a hold taken a poll after the gate closed left a several-mm
gap.
Catalog: one test added and one extended; both cover src/** of the
driver through the existing laser covers.
.gitattributes sets text=auto with eol=lf, so every text file is
stored and checked out with LF, and a patch keeps its bytes. The files
that carried CRLF from a Windows editor are renormalized. No content
changes.
The commission.* tests are setup.* (subsystem "setup"): suite/setup.py,
setup_dark.py, setup_sheet.py, and the host tests test_setup_*.py. The
coverage maps name src/setup.* in place of src/commission.*, the record
is setup.json, and the bench seed in forgetest.init creates
/run/forgefirm/setup-override.
setup.check-sensors follows the check as it is now: no question is
asked and no setting is written. The test reads the settings before
and after and fails on any change, and fails at once if the check
opens a prompt. The host test drives the fake daemon with no prompt
and proves both outcomes.
Proof: test_setup_dark.py, test_setup_sheet.py, and
test_setup_suite.py pass (46 tests). The coverage lint names
src/commission.c and .h as uncovered until the forgectrl pin moves to
the revision that carries the rename.
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.
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.
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.
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.
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.
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.
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.
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.
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.
FORGEFIRM_RELEASE takes 0.0.2. The file sits outside the layer content
hash, so the bump changes the version and invalidates no acceptance
result.
releases/v0.0.2 carries the artifact the bench exported for this image:
campaign c-20260910181641-11a2 on 20260910181308 (dev), manifest identity
a31c820d26d09155d2bf7c629389c5dba5e55d2b52dbbc0ccab5440b187b3b3c, 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.1: every machine named after its own MAC
with mDNS dropped, the fan run posture a diagnostic measures, the
hand-back rules the bench now enforces, the flow-load tail ending at the
coolant peak, and the stream flag a dead session leaves behind cleared by
the session that takes the device. Components: forgectrl 0.1.17,
grblhal-glowforge 0.1.11, forgefirm-app 0.1.28+git, kernel-module
0.0.5, meta-openglow ced2af2.
grblHAL-glowforge 0.1.11 (1f2ea9c): a controller taking the pulse device
clears a dead session's cnc/streaming flag, the way it already acks that
session's stale underrun. laser.armed-kill kills the controller mid-fire,
and the machine came back with the kernel still believing the killed
session was feeding it.
laser.disarm-in-hold streams a 40 mm move, feed-holds it two seconds in,
waits out the disarm grace, and then resets out of the hold. The recovery
put the laser and the controller back and left the head where the hold
had caught it: 11.34 mm along on the bench reference, 605 counts, which
the hand-back jogged out and reported.
The head goes back now, by the distance it actually travelled rather than
the distance the move asked for: the hold catches it at a slightly
different point every run - ten millimetres nominal at F300 for two
seconds, 11.34 measured - so the kernel counters before the move and
after the reset are what the return jog is built from. Where it stopped
and where it ended go into the evidence.
This is not the fault the M5 rapid job had. That job's moves netted plus
twenty by construction; this one stops part-way on purpose, and the
recovery simply never returned it.
laser.armed-kill has the same shape, a kill mid-fire that stops the head
where it stops, and is left alone until a run says whether it needs the
same treatment.
laser.m5-rapid-dark cut 20 mm out and then ran the two rapids the test is
about, one back and one out again. The three net to plus 20 mm, so the
job ended with the head 20 mm from where it started, every run: 1067
counts at 53.333 per mm, which the hand-back jogged out and reported.
That one is dirt, and the position dead band was right to leave it alone
- it is 20 mm, not the step a return rounds to.
A third rapid, back 20 mm with a short dwell, ends the job where it began.
It sits inside the sampling window and after the M5, so it is one more
rapid that must be dark, which is what the test already asks of the other
two: the assertion is wider, not narrower. The operator's clearance step
is unchanged, because the head still needs its 20 mm of +X.
Every job list in the suite that moves in G91 now nets zero on X
(cooling.py, laser.py twice).
cloud.verdict-hold printed to completion and then failed its hand-back on
head/z_mode=0. That job's header carried ZSmd 0, gfhardware's
set_mode_from_puls turns that into z_mode 0, and z_mode 0 is full step
(lenshome.c): the cloud client had set the lens the way the pulse file it
was playing asked. Nothing was left behind - the machine did what the job
said.
The baseline already skips nine attributes in cloud mode, for the reason
written above the list: the cloud client sets its own values for them
from every pulse header, and forcing the GRBL values under it would be
the baseline configuring another controller's machine. head/z_current and
head/z_mode are the same thing and were not on the list. They are now, in
cloud mode only; in GRBL mode the pair is still checked against 1 and 1,
which is the half of this an exemption could quietly break, so a test
holds it.
Host-proven: 89 baseline unit tests.
A leftover is dirt a run left behind. Four of the checks were reporting
something else: the machine part-way through work of its own, which it
finishes on its own a few seconds later. Each one failed a run that had
done nothing wrong.
laser.arm-wait-lid found it. The test passed every check it makes and
failed on cool=smoke/armed=False/hold=False. The smoke clear is the
engine's post-job work - run duty for smoke_s after an armed session,
timed, ending by itself (cool.c, Cool_Smoke) - and the machine was idle
fifteen seconds later without being asked. Armed and hold were both
false: nothing had been left.
The four, and what each was catching:
cool a phase alone (the smoke clear, a cooldown). Armed or
holding stays a leftover: the run left a job alive and the
engine is keeping the fans up for it.
controller the supervisor's own start. A takeover ends by starting
forgectrl again, and the respawn runs the liveness probe
and the lens reference before it reports running and
verified. The run had put it back.
state the ring draining to the end of a job. An underrun stays a
leftover and is still acknowledged with cnc/stop: that one
is the run's.
leds read_led read brightness, write_led writes target, and the
smooth trigger fades brightness toward target. An LED the
machine had already released still read lit mid-fade, and
the restore called itself done before the fade had moved.
Judged on target now: a run that left the button lit left
a target standing.
The waiting, the restoring and the reporting of anything a run actually
left are unchanged. What changed is which readings count as dirt.
Host-proven: 87 baseline unit tests, two of them new - a fading button
LED is not a leftover, a lit one is.
The airflow check reads the purge fan's off current with the fan off, and
the stand-down that follows commands it back on. The guard that proves
the machine was handed back whole read the draw immediately, so what it
got back was the off current the check had just measured: on the bench
reference, 74 against a 300 floor, with the fan drawing 631 a moment
later. The check failed for having worked.
The current follows the command; it does not arrive with it. The guard
now waits for the draw to reach the floor, up to PURGE_SPINUP_S, the way
the controller below it is already waited for, and logs how long it took.
It is no weaker: a fan that never reaches its floor still fails the
check, and the message now says how long it was given.
Pin: forgectrl 0.1.17 (a9e7073, the air assist and the purge in the
diagnostic run posture - the reason that check measured an idle fan and
wrote a floor from it).
The hand-back is the promise that a run leaves the machine where it found
it. Two of its checks reported instead of restoring, and the machine sat
in the state the run left it in.
The cooling engine: a run that ends without ending its job leaves one
alive behind it - the engine armed and holding for a job that is never
coming back, the fans at run duty. The check waited two minutes for that
to resolve itself, which it cannot, and wrote "failed: still
run/armed=True/hold=True". On the bench reference the fans then ran for
an hour.
The pulse ring: bytes the last job never played sit there, and the next
run replays them before its own. The check refused the return jog and
wrote "clear the ring (controller restart) before moving" - the
instruction, to a log, instead of the action.
Both end the same way: stop the controller and let the supervisor bring
it back. The job goes, the arm and the hold go with it, and the ring is
empty on the way in. stand_down() does that and proves it settled;
the cooling check calls it when the engine will not idle on its own, and
the return jog calls it when the ring has residue, refusing only if bytes
survive a restart. A leftover still reports what it found - the record of
what the run did is the point - but it reports having fixed it.
Host-proven: 62 baseline unit tests, including one that the hand-back
calls the stand-down for ring residue rather than describing it.
Run.snapshot() computed elapsed_s from the current time on every call,
whether the run had ended or not. The page shows the last run until the
next one starts, and it polls, so a finished run's figure went on
counting: the result badge said FAIL beside a number still climbing, and
the run read as still going. On the bench reference a test that ended
after 5082 s was showing 5242 s and rising.
The figure a finished run should carry was already recorded next to it:
finished["duration_s"], fixed when the result was written. snapshot()
now returns that once the run has ended, and the live count only while
it is running.
Host-proven: two unit tests - a finished run's clock reads its duration
and does not move across a poll, a running one's still climbs.
An empty form field never reaches the request, so POST /settings with an
empty value in the body carries no key at all and the daemon answers 400,
"no known setting in request". Only the query-string form gets an empty
value through. Measured on the bench reference:
POST /settings -d "lid_policy=" -> 400, the value unchanged
POST /settings?lid_policy= -> 200, the key cleared
Three places in the suite already knew this and say so in a comment; two
did not.
motion.lid-policy-hold restored with the form body, so on a machine whose
lid_policy has never been set the restore was refused and the test failed
its own check. It now uses the query-string form for the empty value, as
the others do. This is the same test as the commit before: that one
stopped it from writing the word "cancel" where the machine had nothing,
and this one lets it write the nothing.
cloud.mode-switch had the older shape of the same fault: it restored
homing_mode to the literal "none" where the machine had it unset. On the
bench reference the restore branch never ran, because that machine homes
through the cloud, so it has not fired yet; on a machine with homing_mode
unset it would have. It now restores exactly what it found.
The rest of the suite was audited for this and is clean: camera.key-read,
cooling.critical-tier, cooling.tec-drive, cooling.crash-watch-plumbing,
cooling.fire-watch-tiers, cooling.floor-and-warm-up, cloud.verdict-hold,
forgectrl.settings, logs.levels and motion.xy-microsteps all either guard
the empty value or go through the shared Restore helper, which has always
done it correctly. commission.cloud-header-capture uses that helper too.
motion.lid-policy-hold read the setting as
was = (fc.settings() or {}).get("lid_policy") or "cancel"
and wrote `was` back at the end. On a machine that has never set the
policy the setting reads as the empty string and behaves as cancel, so
the `or` turned "unset" into the word and the test handed the machine
back carrying a setting it did not arrive with. The hand-back reported
it, restored it, and failed the test.
The value is now captured exactly, empty included, and restored as
captured; the default belongs to reading the value, never to writing it
back. The read-back check gets the same default, so it no longer compares
None with the empty string. lid_policy_in_force carries the effective
policy into the evidence, which is what the old expression was reaching
for.
Found on the bench reference, on the run after the position dead band let
motion.lid-cancel-home through. The four other places in the suite that
read a setting with `or` are safe: two default to the empty string, which
is what unset is, and two feed a check rather than a restore. The shared
Restore helper captures raw values and writes the empty string back for
unset, as this now does.
Only this test's earlier passes are invalidated: the change is inside its
own body, and the per-test source hash of the other eleven motion tests
is unchanged (checked against the file before the edit).
The post-run pass compared the kernel position counters exactly, so a
test that put the head back within a hundredth of a millimeter failed
whenever that distance did not round to the same step.
motion.lid-cancel-home found it on the bench reference. The test cancels
a job with the lid twice and the controller returns the head to the job
start each time, landing 0.038 and 0.037 mm out - the same figures as the
run before it, which passed. This time the two returns left four steps on
X, 0.075 mm at 53.333 steps per mm, and the hand-back called it a
leftover. Twenty-three runs of that test, all passing, on returns of the
same accuracy: whether the residue rounds to zero is chance, not a
property of the machine or of the test.
A difference inside POSITION_DEADBAND_MM (0.1 mm, five steps at x8) on X
and Y is now the quantization rather than a leftover. The head is still
put back, so nothing accumulates over a campaign - only the failure goes.
Z stays exact: the return never moves the lens, so a Z difference is
still a leftover and still unrestorable.
Host-proven: four new unit tests on the boundary (four steps and a moved
Z on either side of it, and an unreadable reading), and the forgetest
suite.
The sheet handed the bench actuator every press after the operator's
presence press, and the actuator pressed every time - into nothing. On
the bench reference all six cards were pressed before the card asked:
the frame by 8 s, the focus by 33, the floor by 10, the dose curve by 8,
the corner by 9, and the flow-load card by 69. The operator then pressed
all six himself, which is the opposite of what the ready gate promises.
arm_press() waits for hw.button_lit(), which is true when any button LED
is on, and burn() started that wait before run_check had even started the
wizard. The button is lit through parts of a card that are not the arm -
the lens reference, the program on its way to the controller - so the
wait ended at once, the press landed before the job waited for it, and
the thread was gone by the time the real cue came. forgectrl uses the
same predicate but only inside the job's own sample callback, with the
tube still dark, where a lit button does mean the arm.
The machine already says when it wants the press: a live check opens a
`press` wait prompt at that moment, and run_check sees every prompt. It
now presses there, through a new Ctx.press_now() - no LED read, no
waiting thread, no timing guess. That retires the per-card lit-timeout
column of CARDS, which existed only to give the flow-load card's coolant
settle enough room for a wait that was reading the wrong thing.
The four grbl-driven arm_press() callers in laser.py and cooling.py are
left as they are: they call it with the machine idle and its LEDs dark,
so the level read is the edge they mean. The same shape would bite them
if that ever stopped being true.
Pin: forgectrl 0.1.15 (6e69479, the flow-load tail ending at the coolant
peak and the way out of a finished setup page).
Host-proven: 357 forgetest unit tests, including two new ones - the
actuator presses on the prompt with button_lit stubbed false throughout,
so the LED is provably not consulted, and the press falls to the operator
without a takeover.
One name for every machine was wrong: an operator with two of them on a
network had one forgefirm.local, and mDNS does not work on many networks
at all. The machine now calls itself forgefirm-<xxxx>, from the last four
hex digits of its WiFi MAC address, and sends that name with its DHCP
request, so a network with dynamic DNS publishes it and a router lists
the machine by name. The name is the same at every boot, two machines
take different names, and no serial number leaves the machine.
forgefirm-hostname (new): reads the wlan0 MAC address (eth0 on a machine
with no WiFi) at S38 in rcS, after udev has probed the network drivers
and before poky's hostname.sh reads the file and before the network
starts. The rootfs is read-only, so the name is written through a
bind-mounted copy under /run/forgefirm. A bounded wait covers a slow
probe. hostname:pn-base-files is "forgefirm": the name before S38, and
the fallback when no MAC address can be read.
avahi is deleted - the bbappend, the daemon configuration, the service
file, the image install and the distro block. The address is the way in
that works on every network, and the DHCP name covers the rest.
forgefirm-banner: the marker lines are gone. "# ForgeFIRM addresses" and
"# end" delimited the address block inside /etc/issue, and getty prints
every line of that file, so both markers were on the console. The script
now keeps the image's own text in a second copy under /run/forgefirm,
captured once per boot before the first write, and renders the whole
banner from it. The block is the addresses alone: no mDNS name.
forgefirm-image.bb: the ForgeFIRM mark, under the OpenGlow one the base
image carries, with the version on the mark's own last line,
right-justified to the mark's last column. The mark is written once and
rendered per reader, because /etc/issue is parsed by busybox getty (a
backslash or a percent sign starts an escape, so the art goes in with
every backslash doubled) while /etc/motd is written out as it is. Widths
are measured in columns, not bytes: the color sequences take no room on
the screen. /etc/issue.net stays unused - the machine tells a client that
has not logged in nothing.
Acceptance: commission.mdns-announce is replaced by
commission.machine-name, which checks the name against the MAC address,
the bind-mounted /etc/hostname, the DHCP client's hostname option, the
banner's addresses, and that no mDNS responder is on the image; it covers
nothing by design, like the test it replaces. forgectrl.auth gains the
own-name Host check and its refusal with a domain on it. image.health
checks the /etc/hostname mount and the version on the mark's last line in
both files. commission.ssh-until-reboot asserts there is no
pre-authentication banner. commission_dark's lens coverage widens to
src/lenshome.* so src/lenshome.h is covered; the lint is clean at 83
tests.
Pins: forgectrl 0.1.14 (9e5330f, the hostname certificate and the Host
rule), meta-openglow ced2af2 (the DHCP hostname option and the motd mark)
in the kas lock.
Proven on the bench reference, hot-deployed and rebooted (image
20260910000208 dev): hostname forgefirm-b00a from MAC 2c:6b:7d:0d:b0:0a,
live and in the bind-mounted file; the DHCP client running with
-x hostname:forgefirm-b00a; the console banner and the motd carrying both
marks with the version aligned to the mark's last column, no marker line
and no .local name; forgectrl regenerating its certificate for the new
name. Host tests: 357 forgetest unit tests, forgectrl clean under
-Werror, tls_test and sanitize_test.
The acceptance page shares theme.css with the panel byte for byte, and forgectrl's copy gained the setup header's Download logs button. CI caught the drift, which is what that check is for; the pinned revision decides, so the copy follows.
The baseline has always examined the machine after every run and recorded
what the run left behind. It did nothing else with it: the leftovers went
to the log and the evidence, and the test still reported PASS. So a check
could measure correctly, walk away with the machine in a state nobody
chose, and be recorded green.
That is how the purge fan came to be left off by the airflow check. The
leftover was not even watched, but had it been, it would have been noted
and the test would have passed anyway, and an operator would still have
met the airflow hold at their first fire.
A post-run leftover now fails the run. One the baseline put back fails it
too: the restore is the bench cleaning up after a defect, not the defect's
absence. The message names what was left.
The baseline watches the head as well as the motion side now: purge air
on, which is how the machine idles, and the lens motor at its hold current
in half step, which the lens checks and the sheet cards take and must hand
back. The airflow check proves the machine is whole rather than merely
measured: afterward the purge fan must read commanded-on and must draw
above the floor the check itself just wrote.
The pin takes forgectrl 0.1.13 (a2d73ef), which restores the idle posture
after a diagnostic, fixes the lens session's takeover flag, and gives the
setup a Download logs button, since the panel's Logs tab is unreachable
until the setup is complete.
Expect this to find things. A test that has been handing the machine back
imperfectly has been passing until now, and the first campaign under the
rule is where that shows.
The guard keeps scripts/manifest-from-tree.py and forgefirm-image-manifest.bbclass computing the same identity, so it pins the skip list by value and the new suffix failed it. It now expects the version file in both places, and a second case proves the point the change exists for: setting the release number leaves the layer hash where it was. My miss: I proved the identity by hand and pushed without running the unit suite first.
Setting the release number was a platform change. FORGEFIRM_RELEASE sat
in forgefirm-image.bb, the recipe hashes as content of meta-forgefirm,
and a change to the content of a layer invalidates every acceptance
result. So a version bump threw away the campaign that was meant to
authorize that very release, and the number therefore had to be decided
before the image the campaign ran on. Nothing said so: the release-flow
page went straight from the kas configuration to the artifact and the
pipeline, while the gate quietly required the recipe value, the rootfs
stamp, the archive's meta-version and the tag to agree. v0.0.1 was cut
on a tree whose number happened to be right; the next one would have
cost a second campaign to discover the rule.
The number moves to forgefirm-release.inc, which carries it and nothing
else, and the manifest leaves that file out of the layer content hash
exactly as it leaves out the component pin files
(FORGEFIRM_MANIFEST_VERSION_SUFFIX, and the same list in
scripts/manifest-from-tree.py, which computes the identity on a
workstation and must agree byte for byte). release.sh reads the number
from the new file.
The version is metadata, not platform content, and this only makes the
manifest say what it already meant: the version string was already
outside the identity hash, and it was the file carrying it that defeated
that. Nothing is weakened. release.sh still requires the number to equal
the rootfs stamp, the .fw meta-version and the release tag, and
image.health still compares the stamp on the running machine with the
manifest's.
Proven: the tree manifest is byte-identical across a bump from 0.0.1 to
0.0.2 (identity a64e51b8e5ecca0af683d4f0 either way, the meta-forgefirm
layer hash unchanged), where before the two differed. bitbake resolves
FORGEFIRM_RELEASE=0.0.1 and FORGEFIRM_VERSION_STRING=v0.0.1 for the
release image through the new require, and the dev image still overrides
the string with its build timestamp.
Campaign c-20260909204732-3fa9 on image 20260909193551, 83 tests, 83 satisfied (62 inherited), release authorized. It replaces the artifact of the release that was withdrawn: that one authorized an earlier rootfs, and the gate compares against the rootfs it is asked to sign.
ForgeFIRM's own files live under /data/forgefirm; two configuration
files did not. The machine settings sat at /data/forgefirm.conf, in the
root of /data beside the factory's own files, and the cloud-mode
configuration sat at /data/etc/gfhome.conf, inside a directory the
factory owns. Both move:
/data/forgefirm.conf -> /data/forgefirm/forgefirm.conf
/data/etc/gfhome.conf -> /data/forgefirm/gfhome.conf
There is no migration: only the bench has ever run this firmware.
/data/etc now holds only the factory's wpa_supplicant.conf.
The acceptance check of the file modes reads the settings file at its
new path, and the two bench tools that read it directly follow. The
pins move to the revisions that carry the change, forgectrl also
bringing the fix that reads the module's disabled state as idle:
forgectrl 468ee21 (0.1.12)
grblhal-glowforge 9ee624b (0.1.10)
forgefirm-app 56f134a (0.1.28+git)
The lock moves meta-openglow to b7ad6d9, which pins python3-gfhardware
on the same revision. The four upstream layers stay where they were:
`kas lock --update` moves every floating repository, and a release is
not the place to take poky, meta-openembedded and meta-freescale along
for the ride.
A machine out of the box sat in the module's power-on state, disabled, for the whole of its first run, and every idle gate read that as busy: the setup's sensors check refused to start, settings writes answered 409, and the cooling engine held cooldown airflow from boot. forgectrl now reads disabled as idle (the state means no program in progress), with fault and underrun still busy until acknowledged and an unreadable state still failing closed.
The yocto-cold-build workflow and its kas/ci.yml overlay built the release image on a hosted runner as a reproducibility probe. It never ran to completion, its first dispatch (2026-09-09) stopped on the runner's user-namespace rule, and a probe nobody runs is a trap. Every Yocto build, the release included, runs on the build host; the release proof is the local pipeline (release.sh) and the bench campaign. The pre-publish checklist loses its self-containment line to match.
BitBake isolates the network of its tasks with a user namespace, and the ubuntu-24.04 hosted runner's AppArmor profile refuses that to an unprivileged process, so the cold build stopped before its first task (run 34381825302). The workflow lifts the restriction for the run; nothing in the layers or the image changes.
The kas configuration takes the pinned-remote meta-openglow block, with
its commit in the lock file (d655e1e, the read-only rootfs), so a fresh
clone builds the release without a sibling checkout. The lock keeps the
upstream layers where they were.
releases/v0.0.1 carries the acceptance artifact the bench exported for
this image: campaign c-20260909160235-7649 on 20260909150456, 83 tests,
83 satisfied, none inherited, release authorized. The release gate
recomputes every test's fingerprint from the manifest inside the release
rootfs and signs only when the recorded results agree.
forgectrl now holds the motion check while a lid or the interlock is
open instead of starting the controller unverified: GET /mode reports
controller "waiting" with why, the button blinks amber, and the check
runs when the enclosure closes. motion.gate-waits-for-lid drives the
fixture's lid channel: the lid opens, forgectrl restarts, /mode must
read waiting with why naming the lid, no pid, motion unverified, the
button amber (sampled over a blink period: the smooth trigger's target
reads 0 through the off half) and no probe line in the log; the lid
closes, and the controller must come up verified with MOTION OK on the
first probe.
Proven on the bench reference: PASS, the controller verified 6.5 s
after the lid closed.
The rootfs mounted read-write, so a slot ran with its own files open to
change, and the factory-slot mounts rode along on the release image.
Both images now carry the read-only-rootfs feature: the ro root line and
the rcS default, the volatile links made at rootfs time, a writable copy
of /var/lib at boot, a build failure for a post-install that needs the
machine, and the removal of shadow, base-passwd, update-rc.d and
update-alternatives.
What must last or change at run time is handled file by file:
- forgefirm-users renders the four account files from the record into
/run/forgefirm/accounts and bind-mounts each copy over its /etc file
(useradd and the rest are gone with shadow); a render writes through
the mount, and the image's own files apply until the first render.
- forgefirm-banner bind-mounts a copy of /etc/issue and writes the
address block through it.
- sshd keeps its host keys under /data/forgefirm/ssh, so the fingerprint
survives updates; both sshd configs carry the same HostKey lines.
- forgefirm-logging passes logrotate a state file under /var/run
(logrotate refuses to run without one).
- forgefirm-persist points the boot timestamp and the random seed at
/data/forgefirm.
The dev image appends the /factory slot mounts, without nofail (busybox
mount hands it to the kernel, which rejects it). The rootfs command
entries lose their semicolons: on scarthgap the value is the task's
vardeps, split on whitespace, so "name;" left the function body out of
the signature and a changed body did not remake the rootfs; with the
bodies tracked, the dev image's DATETIME string needs a vardepsexclude.
release.sh gains the read-only gate (root ro, no /factory line,
ROOTFS_READ_ONLY=yes, host keys on /data). image.health checks the
mounts, the account binds, the banner bind, the host keys and the
dev-only /factory mounts.
Proven on the bench reference (dev image 20260909140901): / ro, /data
rw, /var/lib a tmpfs copy, the four account files and /etc/issue bound
from tmpfs, the host keys in /data/forgefirm/ssh, no "Read-only file
system" line in any log; forgectrl.auth and commission.account-login (a
temporary account rendered, logged in over HTTPS and removed again),
kernel.latch-locked-idle and motion.liveness-probe PASS; logrotate runs
with the volatile state. forgetest unit tests 335 OK; both images build
clean, and debugfs on the built rootfs shows every setting above.
forgectrl 6040e64 carries the three fixes behind the pinned 93fb22e: the
test header reached the way the sibling tests reach theirs, the key added
to /status carried in the panel dev-server mock, and the lens test's
carriage driven by the sweep's own steps rather than by a clock. The
shipping behavior is unchanged from 93fb22e; the pin names what passed.
Verified with bitbake -c fetch.
forgectrl 93fb22e fits the lens outcome text in the buffer the supervisor
shares with the probe. grblHAL-glowforge 97be92b takes the lens reference
from the realtime hook rather than settings-changed, so the controller no
longer overwrites the Z it just referenced.
Both verified with bitbake -c fetch at these revisions.
The Z session used to reference the lens itself with M103, which is gone:
the daemon sweeps the lens onto its hall edge before a controller starts
and leaves a marker, and the controller opens the Z envelope on that. No
daemon runs behind the harness, so nothing wrote the marker and every Z
move was refused, which is what the session's first move ran into.
The runner now writes the marker the daemon writes, and the session pins
lens_hall_edge_z_mm so the moves are counted from a known height: Z3 is 9
half-steps on the screw and Z4 is 12, so a 1 mm move up and back is 3
steps each way.
This is also the check that caught the controller overwriting its own
referenced Z at start, which the panel could not show.
forgectrl dd40dc8 takes the lens onto its hall edge in the motion-verify
window and gates the spawn when it cannot, and carries the per-axis
anchor, homed_axes on /status, and the panel reading Z from its own bit.
grblHAL-glowforge ef0f764 takes that reference as it loads its settings,
re-zeroes the kernel counters the daemon's GPIO steps never reached, and
drops M103.
Both verified with bitbake -c fetch at these revisions.
The lens now takes its hall-edge reference before any controller starts,
so Z is referenced on every start and M103 is gone. The laser-stream
harness opened its Z session by referencing the lens the way a
commissioning card did; it no longer has to, because Z is already open by
the time the session runs.
forgectrl.panel-serves gains the assertions for the per-axis reference:
homed_axes is an axis mask, homed agrees with it, and with a controller
running Z is referenced and reads inside the lens reach the same document
reports. That last check is the one that catches a panel showing nothing
for a Z the controller holds.
commission.check-motion already exercised the new path, because the
motion wizard's probe runs the same sequence the supervisor does, so its
covers map gains lenshome.c and its description names the lens reference
and the hard fault behind it.
The three components carry documentation-only commits: the README
becomes an index card, AGENTS.md lands, and the comments that pointed at
the retired BRINGUP.md now point at the documentation site.
The pins move because the tree is already past the image that
20260907224817 built: a comment in the kernel config fragment
glowforge.cfg changed the meta-glowforge-bsp layer content hash, which
the manifest counts as a platform change. Measured against that image's
manifest: meta-forgefirm and meta-openglow-core are unchanged,
meta-glowforge-bsp is not. A fresh image build and a full acceptance
campaign therefore precede any release, and holding these pins back
would buy nothing.
Verified: every pinned revision is on its public remote, and
bitbake -c fetch resolves all of them.
Every fact in the two documents is now on the documentation site, which
is the single source of truth. This repository carries no project
documentation any more: it is the build and release base plus the
acceptance tool, the bench tools and the fixture firmware.
BRINGUP.md was the runbook, the hardware facts bank and the open-work
list. CAMPAIGN-LOG.md was the dated record of how each result was
obtained. What replaces them: the site for present state, and the
commit message for the record of what a change did and how it was
proven, so the change and its record stay together. Local open work is
the developer's own file at the tree root and is not tracked here.
README.md becomes an index card: what this is, build, test, and where
the documentation is.
The release pipeline tags the documentation. Firmware on a machine
needs the documentation that agrees with it, so release.sh now tags the
forgefirm-docs checkout with the same v<version> as the release, and
prints the command that pushes the tag with the release. The checkout
must exist and be clean, which is a new gate before the signature.
FORGEFIRM_DOCS_DIR names the checkout (default: the sibling one) and
FORGEFIRM_DOCS_SKIP releases without a tag, loudly, and is never the
default. The tag is made at staging and pushed with the release, never
before: a documentation tag for a release that never shipped is worse
than no tag.
No catalog consequence. release.sh is host-side and is in no image.
The commission.py change is one sentence of a test description, not
behavior. accel_crash_probe.py and the kas header lose pointers to the
retired files.
Checks: bash -n and sh -n on release.sh, and the tracked trees carry no
reference to either retired file.
grblhal-glowforge e2ba043 carries the core fork at 362577d, whose block
ring index covers $398 up to 1000. Fetch-verified with bitbake -c fetch.
Pin bump only.
scripts/bench/planner_blocks_test.py restarts the null-sink controller
at $398=400 and at 1000 on one settings store and requires an answer on
the port, the depth in the status report and a move to Idle. It runs in
the grblHAL repo's CI; registered on the bench page and in the README.
BRINGUP: $398 runs over its whole range, the spin item is closed, and
the XY microstep item is committed, pushed and pinned. CAMPAIGN-LOG: the
landing and the index fix, with the host and bench proof.
No catalog consequence beyond the core submodule the motion tests
already cover; the coverage lint is clean.
forgectrl 0.1.6 (b1eee4d): the xy_microsteps setting and the quiet hold route.
grblhal-glowforge 0.1.5 (48d5f1d): the XY scale, tick and stop ramp derived
from the microstep mode, plus the producer lead ceiling.
forgefirm-app 0.1.25+git (d1c47b8): the checked header keys note; the
python3-gfhardware pin in meta-glowforge-bsp moves with it.
Fetch-verified with bitbake -c fetch. Pin bump only: the acceptance
consequence is the catalog invalidation the manifest already derives.
The baseline derives x/y_mode, step_freq, ramp_rate and the configured
markers from the xy_microsteps setting; ramp_rate joins the GRBL
controller's set (the driver writes it; the cloud client runs at the
module's). motion.microstep-modes cycles 8, 16 and 32: the save restarts
the idle controller, the kernel reads the mode with its tick and ramp,
$100/$101 are the mode's and a typed $100 is overwritten, a 40 mm jog at
top speed returns to Idle with the kernel counters over the mode agreeing
with the commanded travel and the accelerometer seeing the head move; the
setting is put back as found.
Bench tools: xy_mode_test.py (the null-sink harness the grblHAL CI runs),
raster_dry.py (a top-speed raster per mode), xy_pattern_accel.py (the
operator's pattern from home with the machine silent and the head
accelerometer listening), arc_tolerance_sweep.py (a $12 ladder on the 9
in circle: the chord rate the protocol loop feeds, about 300 a second, is
the ceiling, not the core), and the xymode and xycircle live drills.
BRINGUP carries the present state and the facts; CAMPAIGN-LOG the dated
record, including the planner-blocks spin (a $398 of 255 or more loops
forever at start, a core bug) and its recovery.
The open item "Idle before the kernel drains" asked to decide between
holding Idle until the kernel drains and stopping the continuation pads
growing the lag. Measured first: on the machine, four chained 50 mm jogs
against cnc/state give 171, 175, 177 and 176 ms after Idle. One queue
depth, flat. The growth the item described is gone, so neither driver
change was made and the item is closed.
Host side agrees and says where the mechanism lives. Stream bytes are the
time axis, so a dumped stream's length is how long the machine plays it:
chained jogs produce 35755 bytes each with no growth, and the churn
session holds at 64790 bytes at a producer lead of 2 or 10 ms. Only above
the lead ceiling does it inflate. Rule 17 now holds that stream to a
budget derived from the job rather than to a recorded number, so the
inflation regime cannot return unnoticed; it fails at 5899 and 8005 ms
with the ceiling lifted and passes at 2301 against 3800.
BRINGUP carried a second error. It said every forgectrl path that stops
the controller after motion waits for cnc/state idle. super.c says
outright that POST /controller/stop is not idle-gated, because it is also
the emergency lever, and safes the machine with cnc/stop and the latch
before the signal instead. The mode switch, the cooling gate and the
daemon shutdown do gate on machine_is_idle(). Both the tail figure and
the gating claim are corrected, and CAMPAIGN-LOG carries the measurements
and the decision not to hold Idle.
Acceptance: rule 17 is a host harness rule in the grblHAL repo's CI, not
a catalog case. The catalog is unchanged because no machine behavior is:
the driver change is a bound on an out-of-range knob.
A new scripts/bench python file must be entered in the bench registry as
well as in that directory's README: the registry is what the acceptance
page reads, and a file in neither is a tool nobody can find. The harness
joins the other two host-side CI harnesses, marked not a bench-page tool
for the same reason they are - it drives the host-built null-sink
controller, not the machine.
CAMPAIGN-LOG gets the dated record of the rebase onto core build
20260905: the settings-struct measurement taken before the machine was
touched, the harness and the two broken builds it was validated against,
and the bench results.
forgectrl carries the GRBL settings store into the data directory, which
the acceptance suite already asserts. grblhal-glowforge follows the core
off the deprecated settings-changed event and moves the core submodule to
the fork rebased onto build 20260905.
Both revisions fetch.
The Z soft limit belongs to the driver, not to $20. glowforge_homing.c
owns sys.work_envelope, sys.homed and sys.soft_limits for Z, because Z is
always referenced, to the lens hall edge or to where the lens stands, and
the core knows neither. The core recomputes both masks from the settings
and drops Z when it does: $20 clears the soft-limit mask inside its
setter, and a $13x write clears the homed bit for the axis as well.
z_envelope_test.py drives the null-sink controller over TCP and holds the
rule. It checks that an unreferenced Z is collapsed to where the lens
stands and refuses a move each way, that X and Y stay free so the
reassert is Z's alone, and that neither write frees Z. It restores $132.
The harness runs in the grblHAL repo's CI, next to the laser stream and
lifecycle harnesses.
The release build merges kas/source-bundle.yml, which turns on the Yocto
archiver: the upstream source of each recipe as upstream publishes it, the
patches with their series file, and the recipe with its includes. The
overlay adds tasks only, so the image manifest is unchanged and an
acceptance result still applies; proven on the build host, where the
archiver build and a plain rebuild of the same tree give the same
content_sha256.
scripts/source-bundle.py packs forgefirm-source-v<version>.tar.gz: the
archives, both license manifests, the license texts, the ForgeFIRM layers,
the kas configuration, the layer revisions and the build identity of the
image. What the bundle must hold comes from the image, not from a list in
the script: every recipe of license.manifest and image_license.manifest
whose license is in the include list must have an archive, or the release
stops with the recipe named. release.sh attaches the bundle and covers it
with sha256sums.txt; FORGEFIRM_SOURCE_SKIP=1 bypasses deliberately.
On the build host: 68 of the image's 111 recipes carry source, 313.7 MiB,
under the 2 GiB limit of a release asset.
No acceptance catalog consequence: the change is release tooling on the
build host and puts no file and no behavior on the machine. The host-side
proof is forgetest/tests/test_source_bundle.py, which holds the license
decision, the choice of archive and the refusal.
BRINGUP names the new path in the standalone start line and the
stored-settings note. forgectrl.panel-serves checks, in GRBL mode, that
the store is /data/forgefirm/EEPROM-glowforge.DAT and that nothing of it
remains at the top of /data. super.c is already in its covers map.
meta-forgefirm: the forgefirm-users init replays the account at boot;
sshd refuses root and empty passwords and runs only while the panel
turns it on; the release image keeps an empty root password for the
console; the console banner; avahi announces forgefirm.local; https in
libmicrohttpd and ulfius; the panel on 80 and 443; the license bundle on
the rootfs; release.sh checks the root policy on the built rootfs.
forgetest: the commission suites (commission, commission_dark,
commission_sheet: 23 cases); the runner turns cloud mode on with the
typed phrase for a test that declares it; the baseline's motor_lock is
0; the log-export test checks the bundle for the camera key; the record
helpers write bytes as given and join the daemon's paths as POSIX. The
stream harness gains rule 24: a hold verdict is held again after a
resume. Bench drills: lens_travel.py and lens_stop_accel.py.
Docs: BRINGUP carries the present state; CAMPAIGN-LOG carries the dated
record.
Its last finding, the request-body cap, ran on image 20260904131106 in
campaign c-20260904132654-d731. forgectrl.auth carried the case: a 4 MiB
body from a client with no token was refused with 403, and the daemon
answered /status in the same second. The campaign is 56 of 56,
authorized.
That was the only audit item left in Next work, so item 9 goes and the
list is items 1 to 8. The record of the campaign, and of the retirement,
is in CAMPAIGN-LOG. The audit file is deleted, as the 2026-07-03 and
2026-08-13 audits were before it.
The request-body cap is on the bench. Image 20260904131106 carried
forgectrl ff89288 through a build overlay, and campaign
c-20260904132654-d731 authorized it, 56 of 56.
The pin now says what the campaign ran.
Three items in Next work tracked the 2026-09-01 audit: the remediation
follow-through, the PIC readings, and the deferred six. All three are
done, on one image, and the campaign the release gate asks for passed on
it, so they belong in the record rather than the open list.
What remains of the audit is one finding, the request-body cap, which is
now fixed and host-proven and needs an image and a campaign. It takes
their place as item 9.
forgectrl.auth gains the case that guards it: an oversized body from an
unauthenticated client is refused and the daemon is still serving after.
forgectrl 8ef9509 at 0.1.2, grblhal-glowforge 2f5edee at 0.1.2, and the
forgefirm-app recipes at dd0ebf3, 0.1.23+git. These are the sources the
full campaign on image 20260903211413 passed against, now that their
repositories are pushed and the build no longer needs the local overlay.
The campaign the release gate asks for, run on one flash with nothing
inherited and nothing hot-deployed. The entry records how the pause test
passed, not only that it did: its emission trail drains to zero and stays
there, against the earlier run's second kernel run inside the pause, and
the difference between them is that the actuator made the presses.
Also recorded: the two harness faults found on the way, the actuator
dropping off the network twice and being asked for silently, and what
now goes in the record so a repeat is answerable.
BRINGUP moves to the campaign image and drops the campaign from the owed
list, leaving the push and the pin bumps.
The ready gate lives inside the arm-and-fire helper, and the kill drill
calls that helper twice, once for the expected stop and once for the
SIGKILL. So the presence gate asked the operator for a second press part
way through a test they had already proved themselves present for, with
the actuator standing by holding the presses. It is the only test in the
catalog with two ready gates.
The second gate now returns at once. Its setup line still goes up,
because the second half may want the scrap moved, but there is no press
to make.
The bench actuator is an ESP32 on wifi and it reports its own signal
strength, which reads -83 dBm here, close to where an association
starts dropping. It has vanished twice tonight and taken a live test
with it, and neither time did the record say anything a reader could
use: only that it was gone.
Both numbers now go into the run's evidence, so the next drop says
whether the link faded or the box restarted.
A live test asked a person to click Ready on a page and then make
timing-critical presses in the middle of a burning cut. That is how
tonight's pause test became unanswerable: the actuator had dropped off
the network, the harness fell back to the operator without saying so,
and afterwards nobody could tell a second press from the machine
resuming on its own.
Where an actuator is up and wired to the button, the ready gate now
takes a press on the machine's own button as the presence check, and the
actuator performs every press in that test. The button does nothing at
Idle, so the press is only a presence check, and the gate waits for the
release so it is never read as the arm press. With no actuator the
operator does the presses and answers on the page, as before.
An actuator lost after that takeover is now said out loud, in the log
and in the evidence, instead of quietly becoming a person's press.
The live-fire cue was four lines of machine-shaped prose. It is now what
a person needs: protection, exhaust, extinguisher, scrap, lid.
The whole catalog ran green, 56 of 56, but on a hot-deployed suite file,
so it authorizes nothing; the entry says so first. What it did was find
the one real defect, the pic-soc-load bound that decided on a count of
noise, and put the arm-acknowledgment fix under live fire: the emission
witness passed with its airflow witness finding nothing, on the same
instant button press that produced last night's burn.
Also recorded: the boot clock that never stepped, and the bench fixture
dropping off the network mid-queue with no address pinned to fall back
to.
The check decided on one count of noise. Measured over eleven runs on the
bench the settled reader moves 3 counts off the idle regime (once 2, once
4) against a control split of 6 (once 7), so a half-split bound sits
exactly on the median: two of those eleven runs failed while the machine
read identically to the nine that passed, and the queue stopped on one of
them.
The kernel's spin is its own load level, a couple of counts under a
Python spin, so a settled reader lands above the idle regime without
reaching the busy one. Asking it to reach halfway was asking for
something the mechanism does not promise. What the check has to catch is
a settle that overshoots and lets the conversion fall back to idle, which
reads as no move at all, and a third of the split catches that with a
count of margin either way. The split collapsing is still the primary
proof, unchanged above.
The record of what the first attended run really found and what it cost:
the verdict had no run-session identity, so the arm opened its window on
the verdict computed for the idle session before it. The build entry
lists the four components taken from local commits and the checks made
on the built images.
BRINGUP moves to the new campaign image and says why nothing inherits.
The cooling verdict now carries the engine's own armed flag, so the
stand-in engines in both null-sink harnesses publish it. The lifecycle
harness gains two cases: an engine that never takes the armed window
must produce a refused arm and no emission, and one that takes it a
couple of seconds late must produce a wait and then a normal arm. The
late case is the one that proves the controller keeps reading the
verdict while it is blocked in the arm; without that every job would
fail there.
The emission witness gains the bench form of the same rule: no sample
may show the laser firing while the cooling engine reports a phase that
runs the fans at their idle duty. That is what a burn with no airflow
looks like from the outside, and nothing in the catalog looked for it.
The record of the live-fire failure on 2026-09-03: a job fired for about a
second with idle airflow under a warm-up hold that arrived one tick later,
the leftover start-gate setting behind it, the engine's arm-time gate gap it
exposed, and what is owed before any further live fire.
The record of the campaign's unattended set on p32, the numbers the two new
kernel drills and the air-assist offset diagnostic gave under the settle,
and the forgetest restart that interrupted the last test, with the rule
that keeps it from recurring.
The kernel's spin is its own load level, a count or two under a Python
spin, so the third check no longer compares the settled level with the
Python-spin control; it requires the settled idle reader to have moved at
least half the control's split off the idle regime. Bench: PASS on image
20260903011655 (control split +6, settled split +1).
BRINGUP item 9: items 10 and 11 are on image 20260903011655; owed is the
flash, the fresh-boot baseline, and the full campaign, then the push and
the pin bumps. CAMPAIGN-LOG: the p32 build and its checks.
The PIC16F1713 converts its inputs in a free-running loop (10 channels,
about 0.30 ms a loop) and a read returns the last conversion of that
channel; the count follows the SoC's load at conversion time (idle 659,
busy 665 on the coolant thermistors, both tight; every channel shifts in
proportion to its count; the step lands within one PIC loop of the CPU
changing state, with a regulator's overshoot in each direction). The
kernel.pic-soc-load drill replaces kernel.pic-pacing: 200 reads after 3 ms
of sleep and 200 after 3 ms of spinning, with the module's settle off
(the control, reported) and on (the claim: the two agree). The catalog
counts 56 tests, 0 uncovered.
BRINGUP item 10 and the facts bullet describe the mechanism and the fix;
the CAMPAIGN-LOG entries record the first pass of the campaign, the PIC
study, and the mechanism's proof.
The sysfs state of watchdog0 says whether a process holds the device, and
none does: the kernel's core feeds the boot-armed hardware. The check
reads WCR through /dev/mem (WDE set, a 60 s period) and expects the state
to read inactive. Bench: PASS on image 20260903003213 with WCR 0x771f. The
BRINGUP facts bullet says the same.
BRINGUP item 9: items 10 and 11 are on image 20260903003213; owed is the
flash, the fresh-boot baseline, and the full campaign, then the push and
the pin bumps. CAMPAIGN-LOG: the p31 build and its checks.
kernel.pic-pacing reads a coolant thermistor twice back to back, 300
pairs, with the module's pacing off (the control, reported) and on (the
claim: the second read agrees with the first). The bench proof for the
module's pic_gap_us pacing; the catalog counts 56 tests, 0 uncovered.
BRINGUP item 10 and the facts bullet describe the pacing as it is; the
CAMPAIGN-LOG entry records the change and its proof.
BRINGUP item 9: the deferred batch is on image 20260903000529; owed is the
flash, the fresh-boot baseline, and the full campaign, then the push and
the pin bumps. CAMPAIGN-LOG: the p30 build and its checks.
B-16: BB_SIGNATURE_LOCAL_DIRS_EXCLUDE in the distro conf names __pycache__
and .pytest_cache, so a workstation's bytecode caches never enter a
file:// checksum (proven in the build VM: a cache under the package leaves
the fetch task alone, a source change reruns it).
kernel.resume-lead: two phases behind one takeover at a 1 kHz tick. E, a
resume whose lead is longer than the data ends at end-of-data within a few
ticks (a lost end-of-data would show as 255 ms). L, a 1000-byte lead over
FIRE bits with the latch unlocked and the chain unarmed keeps the FIRE
line low through the lead and drives it from the waypoint byte on. The
bench proof for the module's K-4 and K-8 fixes; the catalog counts 55
tests, 0 uncovered.
BRINGUP items 9 and 11 and the CAMPAIGN-LOG entry record the batch and the
campaign rule: no campaign until every audit finding is on one image.
CAMPAIGN-LOG: the push in CI order, the pin bumps, and build p29 with its
checks. BRINGUP item 9: the remediation is pushed, pinned, and built; owed
now is the flash, the fresh-boot baseline, and the full campaign on the
image, which the release gate asks for.
B-16: the forgetest recipe fetches the package directory whole, and the
file fetcher's checksum is taken before do_unpack drops __pycache__, so a
host `__pycache__` written by running the tests moves the recipe's task
hash with no source change. The three python steps run with -B now;
`src-sync` to the build VM excludes the caches as well. A developer who
runs the tests locally without -B still creates them; there is no exclude
on the file fetcher, so this is the floor, not a cure (BRINGUP item 11).
The audit remediation's commits and the bench session's fixes in the
three repositories, pushed and bench-proven on image 20260902144848
(built from the same commits through a local pin overlay). Each PV
moves with its SRCREV; the manifest records the PV.
The hardware watchdog is armed by the bootloader and kept fed by the
kernel core; nothing in userspace opens it. Until now nothing in the
catalog read that it is running. image.health reads watchdog0's state,
timeout and bootstatus (CONFIG_WATCHDOG_SYSFS, meta-openglow) and
requires active at 60 s; bootstatus goes into the evidence, so a campaign
that follows a watchdog reset says so.
Bench: on 2026-09-02 the forced hang ended in the factory recovery after
the 60 s timeout; the register read WCR 0x771f. The sysfs view itself
arrives with the next image, so this check is proven there.
Catalog consequence: image.health is always-run.
CAMPAIGN-LOG: the forced hang on image 20260902144848 with its times,
the watchdog register read back, U-Boot's recovery branch (the purple
button), the recovery image on the lease, and the power cycle back; the
pad measurement dropped by decision. BRINGUP item 9: both measurements
closed; the pushes, the pins, one build and the campaign on that image
remain.
CAMPAIGN-LOG: the attended set green on image 20260902144848, the three
defects the live runs and the takeover restarts found (the dwell-gap
latch rule, the log drops, the busy-start airflow) and their fixes, and
what is owed. BRINGUP item 9: both queues green; the measurements, the
pushes, the pins, one build, and the campaign on that image are what is
left.
A forgectrl started while the kernel is not idle takes the cooldown
airflow (forgectrl's busy-start rule), and on the bench it kept it: after
kernel.fire-line's takeover restarted the daemon with the kernel in the
drill's safe state, the exhaust ran at 6200 rpm on an idle machine until
the daemon was restarted by hand. cooling.fans-quiet-after-motion gains
the case: forgectrl stopped, cnc/disable written, forgectrl started, and
within 90 s the controller must be running with the idle duties applied.
The host replay stubs the init script and holds both outcomes: the idle
duties after the start, and a daemon that keeps the cooldown duties.
Bench: with forgectrl 522cdb2, the busy start logged, idle airflow one
tick later, the duties idle 15 s after the start, PASS.
Catalog consequence: the cooling.* implementation hashes move.
laser.emission-witness required the hardware button latch clear in every
sample the engine reported armed, and, after a first fix, in every sample
up to the last nonzero emission count. Both windows were drawn from
lagging signals: the engine's armed flag follows the controller's next
report, and the emission counter latches once per second and reads
nonzero about two seconds past the relock. Both reached into the tail
where the job-end relock sets the button latch by design, and the rule
refused three clean runs on image 20260902144848 (all four sides
burned; the trail shows the latch clear from the press to the relock,
emission through the fourth side, HV_ENABLE's dip in the dwell and its
return).
The rule now uses the window the hardware defines: from the first
emission, in every sample whose readback word shows the laser latch
unlocked, the button-latch bit of that same word must be clear. That
spans the kernel-run gap of the dwell and ends at the relock, and no
lagging flag can misplace it. dwell_gap() is a pure function;
tests/test_laser_dwell.py holds the relocked tail, a set inside the gap,
and a trail without emission. The recorded trail of the third run
replays to a pass (47 unlocked samples, none set).
The live runs keep a per-sample trail in the evidence (TRAIL_FIELDS: the
readback word, the switches, the lock flag, the controller's state and
messages), so a run's timeline can be read back without a rerun.
A fourth run then errored on a name the refactor had removed and one
later check still used; py_compile does not catch it and a live drill
never executes on the host, so the CI job now fails on any undefined
name in the harness (pyflakes).
Catalog consequence: the laser implementation hashes move.
The daemons log to /dev/log with non-blocking datagrams and drop what
the socket will not take. The kernel's default queue for a unix datagram
socket is 10; forgectrl's arm-time burst alone was about 20, so the
lines around every job start were lost, and the daemon reported 18 to
46 dropped per job on the bench. The logging init sets
net.unix.max_dgram_qlen to 512 before rsyslog and the daemons start;
nothing on the image applied sysctl files before.
Bench: set at runtime on 20260902144848 for the rest of the session.
Catalog consequence: a layer change; the meta-forgefirm content hash
moves and nothing inherits on the image that ships it.
CAMPAIGN-LOG: the unattended set on image 20260902144848, the six
harness and diagnostic defects it found and their fixes, the numbers,
and the board's state at the end of the pass. BRINGUP: item 9 names the
reset in the hang case and the session under way; the facts bank gains
the PIC read-pattern measurement; Next work item 10 is a pacing of PIC
reads in the kernel.
update.slots-and-signature's apply section required 200 from
POST /update/apply, where the daemon answers 202 with started, like
every job endpoint, so its first bench run on image 20260902144848
ended before the job did; the cleanup then deleted the staged archive
under the running job. The drill requires 202 and started, and looks
for the daemon's refusal ("archive is not signed with the ForgeFIRM
release key"). Bench: the apply started, the job ended with that
refusal, PASS.
A queue started 4 s after a forgetest restart ran 7 tests instead of
10. The bench page's /state poll had a fixture probe in flight (an mDNS
answer), probe_fixture stamped its time at its start, and the queue
start read the stale fixture, none, so the three operator tests the
fixture runs in the unattended queue were routed to nobody. The probe
now runs under a lock and is stamped when it completes: a caller that
arrives during a probe waits for its answer. tests/test_fixture.py
holds the race with a slow scripted probe; it fails on the old code.
Catalog consequence: the update implementation hash moves; the runner
change is dev-only.
A queue runs the catalog in registration order among tests with the
same prerequisites, and cooling.aa-offset-calibrate followed
cooling.flow-verify. A flow-verify trial heats the tube water (a no-flow
trial by 17 C on the bench) and the warm slug circulates past the
coolant sensors for minutes afterward; the calibration's stationary gate
passed 44 s after the trial on image 20260902144848 and the edges read
the wave as disagreement.
The calibration is registered first now, with the reason beside it, and
tests/test_cooling_order.py holds the order.
Catalog consequence: the cooling.* implementation hashes move (the suite
file changed).
The hang case resumed the controller and sent $X alone. The stream
fault raises Alarm 17 (motor fault), which the core treats as a critical
event: $X is refused with error:79 until a soft reset, and the reset is
what the stream takes as the operator's acknowledgment of the fault
(grblHAL 38b450e: the kernel stopped and re-armed, the stale ring
cleared, the producer armed again). The drill's first bench run on image
20260902144848 therefore ended in Alarm.
The drill now records $X before the reset and requires the error:79
refusal, sends the soft reset, unlocks, requires Idle, requires the ring
back at its idle free count (the stale bytes of the interrupted move are
gone), and then jogs. The final assertion read the state off the report
dict returned by wait_idle as a string; it never ran before because the
TIMEOUT test short-circuited it.
Bench: motion.deadman PASS on 20260902144848 (kill respawn 1.2 s, hang
to underrun 0.21 s, $X -> ALARM:17 error:79, reset + $X -> Idle, ring
33521664 of 33521664, jog Jog -> Idle, restart retook supervision).
Catalog consequence: the motion.* implementation hashes move (the suite
file changed) and the set re-ran and passed. The failed first run had
closed the campaign, so the always-required core ran again in the new
one, as the campaign rules require.
The gate that refuses a latch unlock while the chain may hold HV_ENABLE
up (charge_pump_alive or a pulse engine not idle) ran at the start of
phases B, U and K3 of kernel.fire-line, within a second of the previous
phase's run. A run feeds the charge-pump watchdog every 200 ms and the
one-shot holds ALIVE for 0.45 s after the last feed, so the gate read
alive=1 and refused: the first bench run of the gate (forgefirm
64f552fc; the laser_pgood gate before it was vacuous) failed phase B on
image 20260902144848.
wait_hv_off() polls the chain for up to 3 s before it refuses, logs the
release when it was not immediate and records every wait in the
evidence (hv_release_s). require_hv_off and check_hv_off use it. The
bench scripts that copy the gate (fire_test.py per phase,
gate_a_kernel_drills.py K3 after K2) get the same wait.
Bench: kernel.fire-line PASS on 20260902144848 with the chain released
after 0.41 s at each of the three phase boundaries. Host:
tests/test_kernel_suite.py covers release inside the window, a chain
held past it, and a chain already off.
Catalog consequence: the kernel.* implementation hashes move (the suite
file changed); the kernel set re-ran and passed.
The manifest lists the modules directory without the kernel's
LOCALVERSION_AUTO hash (the hash does not reproduce across a re-patch of
the same source, so the image manifest strips it). image.health still
compared the full running release against that list and failed on the
first post-flash run of image 20260902144848 with the kernel
6.12.20-fslc-fslc-g72a0b1431a9d against the manifest's 6.12.20-fslc-fslc.
kernel_ident() strips the same suffix from both sides, so a manifest with
or without the hash matches the running kernel, and a different base
release still fails. tests/test_image.py covers both forms.
Catalog consequence: only image.health's own implementation hash moves;
it is an always-run test, so no inherited result is affected.
BRINGUP describes the present: the 54-test catalog and its seven-test
always core, the tier counts, the shipped low-temperature gates, the
density floor ($35 = 10), the two local core commits, the ffboot env
write, the aa-offset route, the current bench image, and the bench
measurements the audit asks for (pooled into the next session). The
workstation shell notes and every em dash are gone.
forgetest: the takeover waits for the cloud client too (found by its
command line); the unauthenticated /boot probe names the endpoint's
parameter; the UI prose is American English. Recipes: forgetest
fetches its package directory and init script only and drops
__pycache__ at unpack; the dev image no longer re-adds forgectrl; the
release image's remove list drops the gfui-client the BSP no longer
has; the platform identity strips the kernel's local-version hash
from the modules directory name, so a re-patched kernel keeps its
fingerprints. grblhal restart is stop then start. release.sh --dev
packs the dev image. fixture.sh refuses a readable env file.
Bench tools: the live-fire drills measure the lid-IR baseline before
every run and point at the fire-watch thresholds the engine reads;
one thermistor conversion (gfbench.degc) serves every drill; the six
dated measurement records leave the tool directory; feeder.c names the
two sysfs writes its caller makes.
Host tests: forgetest 258 pass; the coverage lint reports no uncovered
path across 54 tests. Acceptance: forgectrl.auth covers the /boot
probe; update.* cover ffboot and the manifest identity; the runbook
and bench-tool changes have no catalog consequence.
A read-only mount of an ext4 slot still replays its journal, which
writes to the partition the probe was not meant to touch. The probe
mounts with noload: the journal is left as it is and the slot's
content is read as it stands.
Acceptance: update.slots covers ffboot -l (covers map ffboot/**).
A test's fingerprint covered its own text and its module's shared text
only, so a judge imported from a sibling suite module (laser.py takes
its motion judges from motion.py) could change without moving the
fingerprints of the tests that call it. The shared text of every sibling
module a module imports now rides along, transitively; unit test.
update.slots-and-signature claimed to refuse a tampered signature but
fed fwup one garbage file. It now makes a throwaway key pair on the
machine, signs a tiny archive, checks that the archive verifies with its
own key and fails against the shipped release key, and asks the update
job to apply it without confirm_unsigned: the job refuses it for its
signature before touching the slot.
BRINGUP named nine cool_* tunables (there are thirty), a cool_fire_ir_delta
key nothing reads, and panel source files that no longer exist; the
inventory is the present one. The bench page's help popovers pointed at a
documentation host and paths that do not exist; they open the site.
FORGEFIRM_RELEASE said 0.1.0, the first non-beta number by the settled
rule, so the first cut was either refused as 0.0.1 or shipped as a
non-beta. The recipe now says 0.0.1, and release.sh refuses a version at
or above 0.1.0 while the README carries the beta banner.
The faultpos live-fire drill armed and commanded a cut at an origin it
called stale to test a refusal the design decided not to gate: its only
outcome was an emission at an unknown position. Removed from the script
and the bench page.
live_fire_drills.py discarded every drill's return value, so the bench
page recorded a failed live-fire drill as OK. The exit status is the
drill's.
laser_pgood is the supply's power-good, high on every healthy machine;
fire_test.py and the K3 drill aborted on it and pgood_probe.py inverted
it. The latch-unlock drills now gate on the safety chain holding HV off
(charge-pump watchdog dead, pulse engine idle), as the kernel suite
does, and the probe reports the pin as the kernel publishes it.
motion.deadman: the controller resumed from its hang recovers on $X and
moves again without a restart (the stream's fault acknowledgment).
The archive was written straight to its final name and a rerun accepted
any non-empty file as complete, so a run interrupted mid-archive left a
truncated image that the next run kept and then overwrote the slot. The
archive is now written as .part and renamed on success; an existing
archive counts only with its manifest line present and a whole gzip
stream, otherwise it is archived again.
With the gate skipped the script still staged and attached whatever
acceptance.json existed, indistinguishable from a gated release, and
sha256sums.txt never listed the artifact. A skipped gate now stages
NO-ACCEPTANCE.txt instead and publishes as a prerelease; every attached
file is listed in the sums.
The inheritance walk skipped every record that was not a PASS on the
current fingerprint, so a FAIL or ERROR recorded after a PASS on the
same image was stepped over and the older PASS inherited into the next
campaign. The newest record on the fingerprint now decides: a PASS is
inherited, a FAIL or ERROR blocks it (reason failed-since), an ABORTED
run says nothing. Unit tests for all three orders.
ffboot, the tool that rewrites the boot environment on every install and
slot switch, was packaged from scripts/ outside every fingerprint. It
now lives in the recipe's files and the recipe inherits the manifest
class; the tree manifest tool fingerprints file components the same
way, and the update tests cover the component.
forgectrl.settings-bounds fell back to ui_units=mm, which the whitelist
refuses, so the always-required test failed on a fresh machine; the
fallback is metric.
Rule 22: M3 S1000 with the window open, then jogs from Idle (a sender's
Fire button plus its Move panel) ship every jog tick dark, and the cut
after them is lit. Rule 23: two cuts at S300 and S1000 queued together
render the S300 cruise at the S300 density; the rolloff shapes against
the segment's own velocity ratio, never the newest S over the block's.
Both fail on the driver before its fix.
The start gate set just above the coolant opens the armed session under
the warm-up; the cloud client waits it out after the button, nothing
runs, and the release starts the print, which completes. The mid-run
hold and its bound are host-tested: the gates apply at session open, so
no setting can produce a hold mid-run on the bench. Bench-excerpt unit
tests: the pass, and the failure when a run starts under the hold.
A pulse device closed while locked and running is the kernel dead man's
switch. The drill enables the lens driver at low current, runs a padded
stream under the takeover, drops the device with the lock held, and
checks that the engine halts at once, the latch is locked, and the head
is in its safe state (z_enable 1, z_current 1, measure laser and UV LED
off). Always required; the kernel covers map already names src/head.c.
A job longer than the ring keeps its feeder alive through the button
wait, so the cancel there has to stop the feeder before the park clears
the ring. The drill now loads such a job, requires the "longer than the
ring" line, and after the cancel reads the program total twice over the
feeder's retry period (zero both times) and cnc/streaming (zero). The
bench-excerpt unit test carries the long-job line and the new evidence.
Covers map unchanged: the drill already names gfhardware/machine.py.
The recipe URLs, the release and install URLs, the vendor check and the CI checkouts name openglow-org, and the grblHAL core fork is openglow-org/grblHAL-core. No catalog change is owed: the recipe edits move the meta-forgefirm content hash, which every test fingerprint folds in through the platform block, so the whole catalog re-runs on its own.
The supply's power-good line is active high, static across HV enable and emission, and driven; the facts bank and CAMPAIGN-LOG carry the measurement and the item closes. The kernel-drill latch-unlock guard read the old inverted value as HV not good, a check that was vacuous and would refuse every run once the module reads the line correctly; it now uses the chain's own witnesses, the charge-pump watchdog and the engine state. pgood_probe.py watches the line beside the chain through the kernel readbacks and is registered on the bench page. The dev image lists python3-mmap and python3-ctypes again for the pad-level bench tools the python trim had left without them.
Stream harness rule 21: a feed hold leaves no dark ground in either mode (lit into the hold, dark while held, lit from the first step out), with realtime and wait-state steps in the session runner. Lifecycle harness: the hold a sender change puts a running job into, the resume that re-arms a held job from the sender and from the button, a reset from a held job, and the resume after the grace closed the window in Hold. Live-fire drills: holdres (the pause as a corner in time, the re-arm after the grace); senderchg follows the hold. BRINGUP: the gapless-pause and sender-change items close, the facts bank gains the measured hold and resume behavior; CAMPAIGN-LOG records the proof and the bench runs. Acceptance: the pause-resume-lid-cancel text follows the behavior; the driver stays covered by src/**.
INSTALL.md, SERIAL.md, docs/COOLING.md, docs/LIGHTBURN.md,
docs/MOTION.md, docs/SAFETY.md, docs/UPDATE-SYSTEM.md, docs/VIDEO.md and
their images are pages on https://docs.forgefirm.org/ now. Every
reference in the README, BRINGUP, the kas config, the cold-build
workflow, forgetest, and the bench scripts points to the site page. The
README carries the beta banner. docs/ keeps BRINGUP.md and
CAMPAIGN-LOG.md.
No catalog consequence: the deleted files are documents, and the code
changes are comment and help-text repoints only.
forgectrl 161686f adds the head-accelerometer crash watch (two tiers
on the LIS2HH12's interrupt generators, armed inside the laser's
armed window). The catalog grows cooling.crash-watch-plumbing - the
unarmed half of the watch provable without a button press: accel_watch
stays at watch through an unarmed session, three zeros read as the
three crash gates off, an out-of-range threshold is refused - and
_COOL_COVERS widens to src/accel.*. The armed-tier bench drill is a
BRINGUP next-work item.
The de-risk drill script's CTRL7 write moves from bit 0 (4D_IG1) to
bit 2 (LIR1), the latch the datasheet actually puts there; the drill's
findings did not depend on latching (a continuous gravity signal).
The captured pulse headers carry the factory's IG programming: hunts
off, travels abort-only (HAar=133 at +/-4 g), the cut alert-only
(HAxr=132, HAyr=112), Z and idle never armed. The factory abort value
itself rules out an FS/128 LSB (4.16 g would sit over the measurable
range), and two Z-only bench windows bracket gravity between
thresholds 100 and 150, which only FS/256 explains. The drill script's
printed g conversion is corrected, and the stale motion-profile
reading of HAxr/HAyr/HAar as accel limits is corrected in the facts
bank.
No catalog consequence: a bench script's printed conversion and the
docs record; no shipped behavior changes.
The de-risk drill ran on the bench: the IG registers program and poll
over i2c-dev (I2C_SLAVE_FORCE) with st_accel bound, raw reads intact,
so the crash detector is forgectrl-only (BRINGUP item 6, CAMPAIGN-LOG
2026-08-31). The drill found the IG needs a running ODR: st_accel
leaves the part in power-down between one-shot reads. The script now
saves CTRL1, runs the window at 800 Hz, and restores it on exit; its
old ODR test checked the axis-enable bits instead of the ODR bits.
No catalog consequence: the change is a bench drill script, its bench
page text, and the docs record of the drill; no shipped behavior
changes.
accel_crash_probe.py arms the head LIS2HH12's on-chip interrupt
generator (IG_CFG1/IG_THS/IG_DUR1) and polls the latched IG_SRC1 for a
strike, reporting the axes and raw magnitude. It settles the bench fact
the crash detector rides on: coexist mode reaches the IG registers over
i2c-dev with I2C_SLAVE_FORCE while st_accel stays bound, so it proves
whether the detector can be forgectrl-only with the liveness path
untouched, or whether the accel must move under glowforge.ko. It touches
only the IG registers (0x30-0x35) plus the CTRL7 latch bit, never the
full scale, so st_accel's raw scaling is undisturbed; no emission, no
commanded motion by default.
Registered on the bench page (dry, board) and in the bench README.
BRINGUP item 6 now stages the drill as the committed first step, with
the readout path, per-state thresholds and the two-tier wiring owed
after it. Tooling only: no shipped component source changed, so no
acceptance-catalog consequence (the detector feature gets its case when
it is built); the bench registry test and coverage lint pass.
A firmware decode of the head MCU (recorded in CAMPAIGN-LOG) shows the
factory head crash detector and the head IRQ accel_irq source are one
mechanism: the head accelerometer (LIS2HH12) has an on-chip interrupt
generator the factory arms per job from the HA* header tags (bit-exact
onto its registers) and reads by polling IG_SRC1; its INT wires to the
head MCU and surfaces as reg 0x05 b1 behind HEAD_IRQ, which is
level-driven off the MCU's latched reg 0x02 and dormant until the SoC
arms an edge in reg 0x03/0x04.
Facts bank: add "The head MCU flag register and HEAD_IRQ", "The head
accelerometer", "Beam detect in the head MCU" (correcting the thinner
entry: reg 0x05 has a fourth input and a b7 processed verdict we do not
expose; beam_detect_digital is the raw comparator, not the verdict).
Next work: merge items 6 and 8, renumber 12 to 11, repoint the trailer.
CAMPAIGN-LOG: dated entry recording the decode. Docs only.
Facts bank: add the head-IRQ/beam-detect entry and the coolant-ADC
offset entry; point the SDIO CRC note at the closed watch record.
Next work: trim items 6-10 to the open work with facts-bank citations,
remove the stale bench-page sentence from item 3, and retitle item 10
to the second-machine re-measure (the sensor-line scope work is
dropped by decision). Docs only, no catalog consequence.
Load/unload under DEBUG_MUTEXES (three clean cycles) and the forced
-EPROBE_DEFER unwind both passed on the debug-kernel image, no lock
splat, machine recovered (CAMPAIGN-LOG has the run). BRINGUP: the item
closes, items 4 and up move down one.
Three fixes found running the drills: the splat regex no longer matches
the benign "RCU lockdep checking is enabled" boot banner; the 40 V
regulator search reaches its reg-fixed-voltage driver (regulators:40v)
so the forced-defer drill resolves the resource instead of skipping;
and require_idle waits out the run-through-'running' a forgectrl restart
passes through instead of refusing.
forgefirm-image-dev has one deploy slot, so the debug variant was
clobbering the normal dev image (and a dev rebuild clobbered the debug
one back). A distinct IMAGE_BASENAME (forgefirm-image-dev-debug) lets
both coexist in the deploy directory.
kas/forgefirm-glowforge-debug.yml builds one dev image on the debug
kernel (FORGEFIRM_KERNEL_DEBUG=1, tagged dev-debug) beside the closing
release and dev images; the debug options never touch either.
scripts/bench/debug_kernel_drills.py runs the two drills (three
load/unload cycles under DEBUG_MUTEXES, a forced -EPROBE_DEFER unwind),
each read against dmesg for lock splats, refusing on a non-debug kernel
or a non-idle machine; it is registered as a board bench tool. BRINGUP
item 3 names the mechanism and stays remaining work (run it on the
closing burn); the site's Build page documents the variant.
A head reset (its own 0xc9<-0x5a register) made the present head NAK
through its reboot; the witness reads errored (K-11 propagation) with no
spoofed positive, and the head recovered on a driver rebind
(CAMPAIGN-LOG has the drill). BRINGUP: the last physical-evidence
negative closes, items 4 and up move down one.
A busy capture node made the real head-image path fail with the measure
laser armed; the laser ended at 0 and the camera engine recovered
(CAMPAIGN-LOG has the drill). The STATE_FAULT-recovery note leaves the
item by operator decision. What remains is the K-11 case, one bench
slot.
The nRESET and nSLEEP lines are not strapped: glowforge.ko drives them
(reset-gpio gpio3 18, sleep-gpio gpio3 16), confirmed by pad traces to
U20/U21/U22 pins 16 and 17. They move only as a pair in every
enable/disable cycle, together with the rail, so the reset pulse is part
of every recovery attempt and has never shortened the ladder. A logic
reset alone does not clear the wedge.
Diagnostics run as an engine mode behind cool_diag_take/release, the
HTTP surface carries its accept caps with bounded camera setup
children, and the busy-state arbitration is declined with its reasoning
(CAMPAIGN-LOG has the proof runs and the two cross-check lessons the
queue taught). BRINGUP: the item closed, items 4 and up move down one.
97 boots across 16 days of persisted kernel logs carry zero SDIO CRC
failures against the pre-fix baseline of one in 49 minutes; the
factory-exact uSDHC pads hold and the 25 MHz cap stays unneeded.
Items 7 and up move down one.
Limit-switch homing is its one sentence. Cameras holds only the
untested 8 MP sensor's questions; the emulator homing-image smoke moves
to the acceptance follow-through, where the tool work lives. The cloud
item closes as a status recitation: its facts live in CLOUD.md, and the
one open question rides the 8 MP first light. Items 4 and up move down
one.
The factory-shaped watch is implemented, host-tested and bench-proven
with the lamp as the flame stand-in (CAMPAIGN-LOG has the run, the
fault-class mapping and the recorded interpretation). BRINGUP: the item
closed, items 2 to 18 are now 1 to 17, the references follow, and the
facts bank reads the armed posture. Pin forgectrl for its doc fix.
The engine acted correctly on the bench (FLAME, hold, fire blocked) but
the test read the forgectrl log tail one second after the trip, before
rsyslog put the line on disk. The two log checks poll the tail for up
to 8 s (_tail_wait) instead of reading it once.
Both tiers of the factory-shaped fire watch, proven with the lid lamp
as the flame stand-in: a q1 alert under the lamp's reading holds the
session as FLAME and does not survive into a fresh one; a q1 critical
latches FIRE with the laser latch locked; all four thresholds at zero
read as the four flame gates off; restored, the watch reads armed. Pin
forgectrl 77a643441c1c0c745a38aabebc68340d26204ce0 (the watch itself).
TEC handling is implemented, host-tested and proven at the GPIO
(CAMPAIGN-LOG has the run and the CMet/CMdt correction: readings, not
setpoints; the defaults are chosen). BRINGUP: the item closed, items 2
to 19 are now 1 to 18, the five cross-references follow.
The TEC policy at room temperature: declared fitted with its thresholds
under the loop, a run session drives the line on and the session's end
returns it to 0; declared not fitted the line stays 0; the cross-checks
refuse off over on and off under the floor. Pin forgectrl
7d8a58035ca7fadebcae47ea9fd53ef2dd810bac (the TEC drive).
The coolant floor and the warm-up gate are implemented, host-tested and
bench-proven (CAMPAIGN-LOG has both runs, the heater-slug lesson
included). BRINGUP: item 1 closed, items 2 to 20 are now 1 to 19, the
six cross-references follow, and the facts bank carries the slug fact.
The first bench run released in 15 s on the heater slug; the engine now
judges a one-minute rolling minimum, and the test refuses a release
under 60 s. Pin forgectrl 9d0b757f492281d2628da6800db61a1f490fd125.
The catalog case for the two low-side gates: at room temperature the
start gate 1 C above the loop holds the next session in warm-up (heater
on, fans idle, fire blocked) and releases into run at the gate; with the
start gate off, a floor above the loop holds COLD; both at 0 are off and
say so; restored, everything reads as before. Pin forgectrl
5a12f55db6ac632dfc720d9937ca581a7703b7c3 (the gates themselves).
The button latch is SET by lid-open or the SoC lock and RESET by the
button only; the charge-pump watchdog feeds HV_ENABLE, not the latch.
A kernel-run gap inside an armed job drops HV_ENABLE and leaves the
latch alone, and the next run has HV_ENABLE back before its first step.
No keepalive.
laser.emission-witness carries a G4 P2 between the second and third
sides of its square and checks cnc/button_latch clear in every armed
sample, HV_ENABLE dropped across the dwell and back with emission after
it; the operator confirms all four sides.
BRINGUP: item 1 removed, items 2 to 21 are now 1 to 20, the five
cross-references follow, the flow-band sentence is in the facts bank.
CAMPAIGN-LOG: the answer, the retired item, and the unattended set run
green on the hot-deployed board (campaign c-20260831151846).
cooling.aa-offset-calibrate gives the tool 540 s (its 420 s stationary
gate plus the six edges) and says so in its steps. Pin forgectrl
7dbb5e1e55a12e6bd1364d920f46c96a3ae2d4ce (the tool settles at the flow
tools' gate before its first edge).
The factory image carries a register loader that nothing calls, the app
references an apply script the rootfs does not have, and the OV5648
driver has no regs attribute. No shipped machine applies a per-unit
shading table. BRINGUP item 6 drops the claim; CAMPAIGN-LOG has the
search.
CAMPAIGN-LOG: the rail policy homing pass (gfhome homed, jogs after the
resume, one 40V on at boot) and the /cool/status drills (three dark
sessions and an M8/M9 session with no smoke phase, armed false).
CAMPAIGN-LOG: the rail policy, the /cool/status changes, the armed-kill
placement, the image trims with the 20260831130656 manifest, the lid IR
lamp step, the SDIO count, the lens-shading search, the debug-kernel
assessment, and the bench cleanup; the four closed items in the
superseded notes.
BRINGUP: item 18 closed (the trims landed), items 19 to 22 are now 18 to
21; the release rootfs size fact reads the measured 89 MiB.
The release rootfs carried python3-modules (tkinter, idle, 2to3, pydoc,
ensurepip, venv, asyncio, multiprocessing, xmlrpc) through the python3
meta-package, and libgnutls30, nettle, libgmp10, libunistring5 and
libtasn1 through libmicrohttpd (https) and ulfius (GnuTLS). Nothing on
the image uses either. Each Python recipe declares the module packages it
imports; forgetest declares its own, so the dev image carries no module
the release image lacks.
Pins: forgectrl 0e907f7db54b7a4c90b660f198d49e5c66b667b5 (armed shown from a
fresh report only, zero smoke phase for a dark session), grblhal-glowforge
fa9ed7834faab6f48367dc1ca5a5109b474f788d (rail enable only standalone).
BRINGUP: the lid IR lamp response measured over the full range; no camera
register file under /data; the rail policy and the cool status items
closed; laser.armed-kill stays in its domain; the debug-kernel drill
constraints.
Platform change (layer content): the full campaign is owed on the built
image.
The corner rolloff's right value is per machine (this bench settled at
1.5 against the shipped default of 2), so the commissioning item gains
a side-by-side chooser: the same corner-heavy pattern cut at several
settings, the operator picks the best by eye, Apply writes the winner -
with the dose-curve recorder as the first piece of that tool family and
its template. With that, everything the laser power model item held is
answered or homed: its conclusions live in the Laser control section
and the facts bank, its dated record in the CAMPAIGN-LOG, and the
tree-root working file is deleted per its own charter. Next work item
16 closes; the later items renumber down by one.
The stream harness gains rule 20: under M4 with the curve in force, the
accelerate-in head of a line renders less density at the default gamma
of 2 than at gamma 1 (0.317 against 0.537 on the host grid) while the
cruise middle renders identically - the exponent shapes only the
velocity-scaled rolloff, never the programmed level. The panel-serves
covers widen to the whole recorder (src/curverec.*). BRINGUP describes
the rolloff and the one-press recorder; the MOTION table gains
laser_corner_gamma; the panel help explains the knob.
The stream harness gains rule 19: with the bench-default curve in force
a ladder of S rungs renders the curve's densities exactly (half light
lands near 80 percent density), monotonic, floored and ceiled by
$35/$36; every other session pins laser_dose_curve = off so its
S-to-level arithmetic stays exact, and the arm-report checks carry the
curve name. The lifecycle harness asserts the published state file
names the curve in force. The catalog's forgectrl.panel-serves asserts
the curve field in the grbl report, /curve/status and the ladder
G-code, and its covers name the recorder.
BRINGUP describes S-commands-light through the measured curve and the
owner recorder; the MOTION table gains laser_dose_curve; LIGHTBURN
tells the operator power now means light and how to record their own
tube's curve from the panel.
The stream harness keeps the analog rendering as the host-test
conservatism reference (rule 13's mask, the duty ladders) and drops the
M101 switch sessions; rule 18 stays as the derived-floor proof, now
satisfied from boot by the precompute. The lifecycle harness's
state-files scenario asserts the derived floor is in $$ before any arm.
The catalog's laser.power-model-switch goes and laser.power-floor reads
the one floor key with no M-code needed. The mswitch drill goes; the
m4corner drill becomes a single density pass; the dpatch drill returns
to density only.
The CAMPAIGN-LOG records the first rasters (254 and 508 DPI grayscale
wedges: tonality held to ~14 pulse slots per pixel, no dither artifact,
one benign stale-verdict suppression under CPU starvation) and the
decision that ends the analog mode - the strike transient fires a spot
at every beam-on, and the finish comparison found no advantage. BRINGUP,
LIGHTBURN, MOTION and SAFETY describe the density-only present.
The M101 dose-model switch with its derived per-model floors, and the
controller's published state files with their /status echo and panel
card. Both proven host-side and, for the switch, on the bench (the
mswitch drill); the catalog covers the changes through the model-aware
laser.power-floor, laser.power-model-switch and the extended
forgectrl.panel-serves.
The lifecycle harness gains the state-files scenario: with GF_STATE_DIR
at the session workdir, grbl.settings carries the $$ view and follows
the derived floor through an M101 switch and its M2 revert, and
grbl.state follows the sender connection (a reconnect bumps the
generation), the armed window and the dose model, with ts_mono
advancing. The panel-serves catalog test asserts the /status grbl block
in GRBL mode with a live controller (fresh age, state, sender, laser,
modals) and GET /grbl/settings, and its covers name the publisher and
the serial layer; the glowforge_status.c covers entry selects nothing
until the grblHAL pin moves past the commit that adds the file. The
CAMPAIGN-LOG records the build and its proof; BRINGUP describes the
mechanism.
The stream harness gains rules 18 to 21: the floor is derived from the
selected model's config key at the arm and a typed $35 is overwritten;
M101 switches the rendering exactly at the boundary in both directions
with no continuous FIRE at full duty across it; a refused switch (the
spindle on) leaves the stream unchanged, and the harness resyncs with
an empty line because the core skips G-code after an error until the
sender resyncs; M2 reverts a program-scoped switch and Q1 holds. The
analog sessions pin laser_floor_analog at the density floor so the
existing duty expectations stand, and the density ladder's unfloored
run moves from a chained $35 write to the laser_floor_density key.
The catalog's laser.power-floor becomes model-aware: it reads the
configured model and the floor keys from forgectrl, switches to the
configured model with M101 so the derivation runs without a fire, and
expects $35 to be that model's floor. The new laser.power-model-switch
switches to each model with the spindle off, checks the reported
message and $35 after each switch, and checks the M2 revert. The new
mswitch bench drill runs the switch on the machine in one armed run.
Docs follow: BRINGUP's Laser control section describes the switch, the
derived floors and the measured dose response of both models; the
MOTION settings table gains the five keys; LIGHTBURN gains a Power
models section and drops the stale 30 percent floor advice; SAFETY
names the switch's refusal rule; the CAMPAIGN-LOG records the judged
depth-witness runs of 2026-08-30 and the switch's host and bench proof.
The dpatch drill reads laser_power_model and runs row B at 100/80/60/45/30
percent duty under analog, with the thermopile labels from the analog ladder,
so the analog dose response can be judged on material the same way as the
density one. The report names the model's unit in every line.
Bench-only tool; no acceptance catalog consequence.
flow_warm_validate.py warms in rounds (the heater at 50 percent for three
minutes, off, 45 s of circulation, the two sensors' mean read) and judges
the target on that mixed bulk, not on the upstream sensor beside the
heater, which reached any target within two minutes while the bulk had
barely moved. Run with a 28 C target the loop plateaus at 27.2 C in a
20 C room; six checks at 26.2 to 27.2 C read every verdict right, the flow
band under 11.9 C and the no-flow band over 18.0 C against the 14.4 C
limit. CAMPAIGN-LOG records the run; BRINGUP item 1 keeps only the
button-latch check, the flow check's bands holding from 19 to 27 C.
No catalog consequence: a bench tool and documentation.
CAMPAIGN-LOG: aa-offset-calibrate with its 3 s at 8 Hz sampling reads
16.2 counts with a spread of 4.5 over six edges, the value already
applied; the compensation's whole path stands on the bench. BRINGUP item
21 keeps only a second machine's value and the lit-tube toggling.
No catalog consequence: documentation.
The pin carries the calibrate tool's sampling fix (3 s at 8 Hz a side).
aa_offset_check.py proves the correction on a machine: M8 brings the fans
to the run profile, dark, while the raw coolant counts, /status and the
engine's readings are averaged before, during and after; it turns the
flow check off for its session and restores it. On the bench page as
aa-offset-check. CAMPAIGN-LOG records the tool's first run, the applied
value (16 counts) and the proof: the readings held within 0.1 C under
the run profile where they dropped 1.0 C uncorrected. BRINGUP item 21
names what remains.
No catalog consequence: a bench tool and records.
forgectrl 25cf969 lets status.c link without the cooling engine (the
image build compiles the status host tests), so the pin moves to it;
verified with bitbake -c fetch. offset_probe.py joins the bench-page
registry (survey, ladder and jog; the armed dwell stays a hand-run mode)
and takes "survey" as a mode word.
The pin carries the compensation (cool_aa_offset_counts, the
aa-offset-calibrate diagnostic, the panel's Apply); verified with
bitbake -c fetch. The catalog gains cooling.aa-offset-calibrate, which
runs the tool and checks its recommendation and spread.
scripts/bench/offset_probe.py is the differential probe that found the
source: one actuator switched at a time with both sensors at 25 Hz
(survey), the air-assist duty ladder, the gantry jogging under the fan,
and an armed dark dwell. CAMPAIGN-LOG records the four probes and the
result; BRINGUP item 21 names the setting and the calibrate as what
remains on this machine, and item 23 opens the initial commissioning
procedure.
No catalog consequence beyond the new case: a bench tool and records.
A live catalog case: two 30 x 4 mm fills at full power on the press, the
window held open until the engine's flow verdict lands in the forgectrl
log, then M2 and the head brought back. PASS needs a verified line with
the laser's share on it (at least 0.3 C: the window and the fire
overlapped) and the judged rise at least 1 C under cool_flow_rise; an arm
refused by a gate names the gate. Passed on image 20260829190323
(heater rise 11.9 C judged, laser 0.6 off 12.5).
CAMPAIGN-LOG records the run and the day's two air-assist gate holds (a
bench connection, reseated); BRINGUP item 21 names the case.
Catalog: the case covers forgectrl src/cool.* and its neighbors; a new
test, so the manifest's coverage widens rather than moves.
Two live-fire drills in live_fire_drills.py: senderchg drops the sender
mid-line with the tube lit, reconnects and expects the next laser-on to
prompt again; overrun writes a 93-line fill at once into a running job
and expects the report, the alarm, the disarm and a fresh prompt after
$X. Both read hv_current and the thermopile for the instant emission
ends and open their "nothing lit" window 2.5 s after the event, since
laser_on_sampled is a one-second window count. Both passed on image
20260829190323; CAMPAIGN-LOG records the runs.
BRINGUP item 20 (the arm skipped on a stale spindle state) closes: fix on
the image, host and bench proven, covers in place. Its text moves to the
superseded notes; items 21 to 23 are now 20 to 22.
No catalog consequence: bench drills and documentation.
CAMPAIGN-LOG: the three flowload t1 runs on the new image (judged rise
11.3, 11.1 and 11.8 C with the tube lit through most of each window, the
laser's share on the line), and Test 3, the flow check from a warm loop
(23.6 to 24.9 C baselines, 0 of 6 wrong, the 14.4 C limit 2.25 C above the
flow band and 3.67 C below the no-flow band).
flow_warm_validate.py takes the warm target and the warm-up budget as
arguments (defaults 28 C, 20 min), and the bench page passes them; the
warm-up still judges on the upstream sensor beside the heater, which is
why this run's baselines stopped near 25 C (BRINGUP item 1).
BRINGUP: item 1's open half is the loop above 25 C; item 22 holds what
remains of the flow check under load.
No catalog consequence: a bench tool's arguments and documentation; no
runtime behavior of the release image changes.
grblHAL-glowforge a7dcdca: the arm at the first laser-on is decided by the
window alone, and an RX overrun drops the overrunning line whole and stops
the job. forgectrl 2f18b16: the flow check's baseline and end are means,
and the tube's share of the rise is taken off before the limit. Both
verified with bitbake -c fetch.
laser_lifecycle_test.py gains sender-change-mid-job (a laser-on against a
window closed while the spindle was on must prompt again) and rx-overrun
(a job written past the RX ring is reported, stopped in alarm with the
window closed, and a clean job arms after it). The flowload drill's
verdict parser accepts the engine's laser-share suffix.
BRINGUP: item 20 holds only the owed work; item 21 opens the mid-job
sender-change discussion with the Grbl expectation; item 22 is the flow
check under a lit tube; item 23 is the power-good line's meaning.
CAMPAIGN-LOG records the driver fix and the flow-check reading, both
host-proven.
No catalog consequence: harness scenarios and documentation; no runtime
behavior of the release image changes in this commit.
The flowload drill in scripts/bench/live_fire_drills.py runs the two tests
of the flow-check plan: t1 fires two CW fills on the press with the check
at its defaults, t2 fires one fill of a chosen length with the check off,
and fit reads rise against dose over the t2 records with the ADC offset
steps masked. The sampler adds the heater output, /cool/status is polled
at 1 Hz with the fan gates, and every controller reply is kept. The job
is fed against the RX buffer's free count, M5 is acknowledged before a
run, a run is refused while the window is armed, and M2 is acknowledged
and the window's close is waited for.
BRINGUP gets item 20: the arm at the first laser-on is skipped while the
driver's spindle-state record reads on, the record is not cleared on
disarm, and the serial layer drops bytes on a full RX ring, so a job with
a lost M5 lets the next job run unarmed. CAMPAIGN-LOG records the Test 1
and Test 2 runs and their numbers.
No catalog consequence: a bench drill on the dev image and documentation;
no runtime behavior of the release image changes.
Comment only. The edit changes the content hash of meta-forgefirm, which
the acceptance manifest counts as a platform change, so the next image
needs a full campaign. Accepted: a full campaign is planned.
The documentation site, docs.forgefirm.org, is the one home for the
developer documentation. BUILD.md, kas/README.md, and docs/ACCEPTANCE.md
are on the site under Developers, and they are deleted here. Every
reference points at the site: README.md, BRINGUP.md, UPDATE-SYSTEM.md,
the kas configuration comments, release.sh, the CI workflow comments, and
the releases, forgetest, and bench READMEs.
BRINGUP.md: "Next work" item 16, step timing under CPU contention, is
closed; the video work resolved it. The items after it are renumbered.
CAMPAIGN-LOG.md records the closure.
Documentation and comments only. No layer content changes, so the
manifest identity of the next image does not change. No catalog
consequence.
live_fire_drills.py gains dpatch: two rows of small serpentine-filled
patches on scrap. Row A is CW at feeds that give relative doses from 1.0
to 0.25. Row B is density 100, 80, 60, 45, and 30 percent at F600. The
operator matches each row-B patch to the row-A patch of equal depth. That
reads the light fraction of a density off the material, next to the
prediction of the thermopile. The drill samples sysfs at 25 Hz, as pcurve
does, and writes a JSON record to the bench data directory.
Bench tool only, on the dev image; no catalog consequence.
The rapids after an M5 ship dark, and a job whose M3 runs at the level
the previous job ended at fires its first cut. Fetch-verified.
Catalog consequence: laser.m5-rapid-dark and the laser live tests cover
the driver; the pin lives in the pin file, so only those tests re-run.
Stream harness rules 16 and 17 with their sessions: an M5 executed with
the planner drained and the kernel run over must darken the rapids that
follow it (m5-idle), and a job whose M3 runs at the level the previous
job ended at must still fire its first cut (next-job). The second rule
is the core's contract: set_state records the rpm and the per-segment
update is skipped while it is unchanged, so the driver's set_state is
the only thing that can light that move. Both sessions run under both
dose models; the bench build that went dark on its second job fails
next-job with one fire span.
Bench drills: pcurve (a per-level ladder of 100 mm lines read from the
HV current and the head thermopile at 25 Hz, with the instrument checks
and the JSON record) and m5dark (one line, M5, two rapids, judged on the
current trace and laser_on_sampled until the armed window closes).
Catalog: laser.m5-rapid-dark, a live test of the M5 case (46 tests; the
counts in BRINGUP follow). CAMPAIGN-LOG carries the day's record: the
two curve ladders, the defect pair, the root cause, the host and bench
proof.
CAMPAIGN-LOG gains the 2026-08-24 drill: five minutes of openssl speed on
the one core over the live camera stream, 30 C chassis, plateau 70.8 C
with no throttle and every cooling device at state 0. BRINGUP's facts bank
drops its open question (whether ForgeFIRM's load wants the heatsink the
factory never fitted) for the measured fact: 14 C of headroom to the
passive trip under full load, a hot-chassis case above roughly 44 C.
Docs only, outside every layer; no acceptance consequence.
The attended nine passed after the unattended 36, so campaign
c-20260824231028-b7ca closed 45 of 45 from nothing and authorizes the
image. BRINGUP now describes the present: the 45-test catalog with the
bench actuator, the latest campaign, the board-only kernel's shape in the
facts bank (the performance governor, the SDMA clock ownership,
pstore/ramoops, MemTotal), the GPU stream path as the proven default.
Items 20 and 21 close; item 12 keeps only its leftovers and item 13 the
first release; item 16 records the hog case passing and the stream-live
drill still owed; a new item 20 carries the image trims the kernel review
left on the table.
CAMPAIGN-LOG gains the dated record of the attended run and, under the
superseded notes, the closed items as they stood plus the acceptance
burden plan and the kernel configuration review verbatim; both tree-root
working files are deleted.
No catalog consequence: documentation only.
The dual-stack listener reports every peer as a sockaddr_in6; ulfius
2.7.15 copied sixteen bytes of it, so forgectrl's loopback-only cooling
channel refused the controller's every report (403 loopback only) and
the engine never saw a run or an armed window. The recipe carries the
patch: a sockaddr_storage allocation and a copy of the family's length,
in the dispatcher and in ulfius_copy_request.
forgetest: forgectrl.auth asserts POST /cool/state from loopback -> 200
beside the LAN 403, and covers src/peer.*. BRINGUP item 21 and the
campaign log record how the campaign on dev 20260824215906 found it.
The check reads /sys/kernel/debug/clk/sdma/clk_enable_count and fails
below 1, naming the fault directly instead of the free-space mismatch it
surfaced as after the 150 s settle. BRINGUP item 21 describes the fault
and the fix; CAMPAIGN-LOG carries the record of how it was found.
Covers: image.health already maps kernel-module-glowforge/** and
linux-fslc/**.
With the access point's RA and DHCPv6 off, the board took the firewall's
lease; every service answered on the global address from another VLAN
and the board reached the IPv6 WAN gateway.
Docs only; no catalog consequence.
The missing GUA was not the firewall: its DHCPv6 server advertises an
address, but an access point on the bench VLAN still runs RA and DHCPv6
in server mode, its Advertise with nothing to give arrives first, and
busybox udhcpc6 keeps the first Advertise it sees. Dev 20260824201945
carries patch 0015 and the last stray dmesg line is gone.
Docs only; no catalog consequence.
The 2026-08-24 second-round record on dev 20260824200726: the dmesg
lines gone, the UP kernel on the performance governor, the two-file
WL18xx firmware set, every port answering over IPv6 on the ULA, the
export, and the two leftovers (no GUA from the DHCPv6 server, the NVS
loader line that patch 0015 removes). Item 21 says what remains.
Docs only; no catalog consequence.
Removal specs accumulate and apply after every append, so a dev-recipe
IMAGE_INSTALL:remove could not give nano back. The release recipe now
removes ${FORGEFIRM_RELEASE_TRIM} (default nano) and the dev recipe sets
it empty; the spec expands when it is applied.
No catalog consequence: image composition only.
IMAGE_INSTALL:remove applies after every append, so the release recipe's
removal of nano also stripped it from the dev image that appended it back.
The dev recipe now re-sets the removal list to gfui-client alone.
No catalog consequence: dev image composition only.
The distro keeps its ipv6 feature (busybox networking and ifupdown
inet6, openssh, ntp and rsyslog follow); forgetest binds "::" by
default, one dual-stack socket that still serves IPv4. The release
image drops nano (with it file and the libmagic database, 8.7 MB; the
dev image keeps nano), the udev hardware database (7.7 MB of USB and
PCI identities for buses the board lacks, via BAD_RECOMMENDATIONS) and
urllib3's pyOpenSSL/cryptography recommendation (6 MB; nothing imports
them, TLS goes through the standard library). BRINGUP item 21 records
the round and the bench checks it owes; item 16 gets its re-measure
plan on the UP kernel with the performance governor.
Coverage: image.* covers the rootfs composition and the distro conf
(platform); forgetest's own suite covers the server.
kas/README.md keeps the build, lockfile, pin and release procedure and a
short build-time facts list; its push/release checklist and Scarthgap
migration backlog are archived verbatim in CAMPAIGN-LOG ("Superseded
status notes"). The real-time argument (PREEMPT, why PREEMPT_RT is not
selectable or needed) joins the BRINGUP hardware facts bank next to the
ring measurements it rests on; the camera pointers go to the 0011-0013
patch headers; the image recipe's gfui-client comment drops its README
pointer.
Docs and a recipe comment only; no catalog consequence.
forgectrl's src/ipu_copy.{c,h} sit on the GPU render path that feeds
both the JPEG grab and the H.264 encoder, so every camera.* test already
exercises them; the coverage lint reported both files uncovered. Added
to _CAM_COVERS. Lint on the tree manifest: 0 uncovered paths, 45 tests.
BRINGUP gains Next-work item 21, the kernel trim and what it still owes
(a GRBL job and the acceptance campaign); CAMPAIGN-LOG records the
2026-08-24 review, the trim, and the bench drill on dev 20260824164619
through the forced-panic read-back from pstore.
Docs only; no catalog consequence.
The first campaign with the bench actuator wired failed
motion.button-hold-resume on the tool, not the machine: the second press
was asked while the first 200 ms pulse was still on, the fixture answered
409, the runner handed the step to an operator who was not in the room,
and the post pass could not jog a controller left in Hold.
- fixture.py: a press waits for the last pulse to end (the fixture's
pulse_ms) plus a 300 ms release, so the controller sees the edge; a
409 for a pulse in progress is waited out against button_pulsing and
retried once.
- runner.py: in an unattended run a fixture refusal ends the test at
once as ERROR naming the refusal; the operator fallback stays for
attended runs.
- baseline.py: a controller in Hold or Door gets a soft reset before the
return jog, position kept.
- tests: the fake fixture refuses a press while one is in progress and
reports button_pulsing; FakeGrbl records ^X and can land a reset in a
chosen state; five new tests.
- docs: ACCEPTANCE.md fixture rules, fixture/README.md tool's side.
No catalog consequence: tool-side change, no covers map moves.
Bench: campaign c-20260824174545-0bdc 25/25 with every action by the
fixture; the hold reset proven by a dry drill.
The acceptance page is assembled by page.py from forgetest/forgetest/ui/
(index.html, page.css, help.js, app.js) plus theme.css and the vendored
Bootstrap files, which are byte for byte the ones forgectrl's panel
carries, so the two pages look like one product and share the light and
dark themes (same localStorage key). A plain file is read in a checkout;
on the dev image the recipe installs ui/ gzipped and page.py reads the
.gz sibling, inflating once at first request: the rootfs is raw ext4, so
bytes in the package are bytes on the image. The explanatory prose
(campaign rules, the queues, the campaign actions, the prerequisites
switch, the bench intro) is a "?" popover with a link into the
documentation site; operator steps, prompts, notices and the live-laser
acknowledgment stay in the page, and confirmLive() stays a blocking
dialog. The page's own rules hold: rows, prompt buttons and tool entries
are built once and updated in place, and the popovers sit on static
markup only, so no rebuild orphans one. On a phone the Run pane goes to
the top for the duration of a run.
scripts/check-ui-vendor.py compares the shared files against forgectrl
at its pinned revision (or a local checkout with --forgectrl); it runs
in forgetest-ci.yml, so the copies cannot drift.
Tests: test_page.py (the gzipped install assembles to the same bytes as
a checkout, one self-contained response, the token placeholder once, a
missing marker refused); test_server asserts the served page's
invariants; test_responsiveness keeps its rules with needles pointed at
the new files, its ASCII rule applied to our own sources (Bootstrap's
CSS carries an em dash of its own), and its self-contained rule testing
asset tags rather than the presence of https:// (the documentation links
are meant to be there). forgectrl.panel-serves gains two needles for the
panel's theme attribute and save bar. Proof: the unit suite, and the
page in Chrome against a fake catalog (both themes, popovers, the bench
tab, a full operator run with its prompt, abort).
forgectrl pinned at 9d1f6f2 (the panel on Bootstrap, one save bar, help
popovers, themes, the gzipped page); PV unchanged. The pin moves only
forgectrl's fingerprint. The forgetest changes are the harness's own and
have no catalog consequence.
The pin moves to forgectrl d97cb35: zero-based fragment timestamps and
the panel's clamped live-edge seek, the two faults MSE playback in a
real browser surfaced. CAMPAIGN-LOG carries the session record,
including the coexistence drill (a jog at commanded feed under both
live streams, underruns unmoved, planner buffer full). BRINGUP item 20
is down to one line: the full acceptance campaign on an image carrying
this pin.
The pin moves to forgectrl deee6a1: the render overlaps the previous
frame' copies and encodes behind an EGL fence, measured 13.8 fps
single-viewer at ~14 percent CPU and 9.8 fps with both stream types
served at once, luma bit-clean throughout. CAMPAIGN-LOG carries the
dated record; BRINGUP item 20 is down to MSE playback, the
coexistence drill, and the campaign.
The pin moves to forgectrl 2d59d78: the chroma passes point-sample
instead of box-averaging, taking the GPU render from 140 ms to 64 and
the stream to ~9 fps at ~7 percent CPU, with luma measured bit-clean
against the CPU path (which also retired the bottom-row artifact).
The CSI hardware frame skip is live-proven with the GPU path and is
the recommended low-CPU configuration. CAMPAIGN-LOG carries the dated
second-session record; BRINGUP item 20 now lists the render-encode
overlap as the path to 15 fps, then MSE playback, the CPU measure,
coexistence, and the campaign.
The pin moves to forgectrl 6614833: the five hardware corrections from
the GPU demosaic's first bench session (surfaceless EGL config, ARGB
render targets, GR88 raw import, the ipu_copy stride-fix crop between
the GPU and the CODA, the chroma mirror width). CAMPAIGN-LOG carries
the dated record of how each was found; BRINGUP item 20 now separates
what that session proved (the full GPU -> IPU -> VPU path serving
correct frames, H.264 as valid fragmented MP4 on hardware, texture
limits, slot fit) from what remains (the 140 ms render, the bottom
row, MSE playback, the CPU measurement, frame skip, coexistence, the
campaign).
The stream conversion now runs on the GC880 when the image carries the
GL stack, and /cam/h264 serves the same picture as fragmented MP4 in a
fraction of MJPEG's bytes (forgectrl 6573abd). This side supplies what
that needs and holds it to account:
- Distro: opengl stays a feature, with mesa trimmed to exactly the
path used (gallium etnaviv, GLES/EGL/GBM, no GLX, no display
platforms). Image: libegl-mesa, libgles2-mesa, libgbm,
mesa-megadriver. forgectrl dlopens them, so an image without them
still streams on NEON. Platform change: the next campaign is full.
- forgetest: camera.h264-stream exercises the endpoint end to end
(codec header, init segment, media fragments, /cam/status encoder
state) and accepts a clean 503 on a machine without the stack;
the camera covers name the new forgectrl files.
- VIDEO.md 5.6 now describes both streams and why H.264 earns its
place (the WiFi transmit path measures about 7 percent of the core
per MB/s sent); BRINGUP records the bench-validation checklist as
Next work item 20, including the release-size watch: mesa must fit
the 200 MiB slot gate.
The pin picks up /status carrying CPU and memory utilization (sys:
cpu_pct over the interval since the previous read, mem_pct from
MemTotal against MemAvailable) with the panel rows under the board
temperatures. forgectrl.panel-serves now re-reads /status after a
beat and asserts both percents are numbers in range, so the catalog
exercises the new telemetry on the bench.
The mDNS lookup sends its query from every local IPv4 interface (a
host whose default route is a VPN or a VM adapter never reached the
bench network), binds the mDNS port and joins the group as two
separate steps (a group join that fails left the socket half bound),
and a failed send is no answer rather than an error. The runner says
"running without it" once per reason, not on every 30 s probe.
fixture.py: the bench's /data/forgetest/fixture.json (hostname, key,
optional ip, the channels wired, arm_press), a resolver for
<hostname>.local asked of the network directly (the image has no mDNS
resolver), and the client. The runner probes it before every run and
at most every 30 s otherwise; ctx.act asks it for a channel it covers
and still waits for the machine's own reading, falling back to the
operator's notice when the box fails. A test declares with hands=(...)
what it asks of a person beyond its typed actions; an operator test
with none, whose actions the fixture covers, is routed into the
unattended queue, its Ready gates pass, and a prompt it raises anyway
is a FAIL naming the undeclared step. Live tests never move; their arm
press stays a person's unless the bench opted in, in which case the
fixture presses when the button lights. Whatever the box still holds
after a run is released before the baseline's post pass and recorded.
The page shows what the fixture covers. Contract in ACCEPTANCE.md; the
wiring facts, with the interlock connector left to the bench to settle
(SAFETY.md and the sister map differ), in BRINGUP.
Catalog unchanged in its definitions; the cloud and laser suites'
shared code moved, so their implementation hashes move with it.
An ESP32-S3 DevKitC-1 on the bench network drives three relays at the
machine's connectors so the acceptance tool can open the lid loop, pull
the interlock loop and press the button with nobody in the room. Two
contacts are normally closed in loops the safety chain already reads,
the third is normally open across the button input and only ever
pulsed (20 to 500 ms, the end armed before the line rises); every line
is low at boot and after any reset, the task watchdog panics and
reboots, and the button channel needs a jumper. HTTP on port 80 under
a key in X-Fixture-Key; the hostname announced over DHCP and mDNS.
ESP-IDF v5.5 native, the mDNS component pinned in dependencies.lock;
fixture.env (git-ignored) is the one input: the wifi, the key, the
hostname. fixture.sh builds with idf.py or in the espressif/idf
container, flashes with esptool from pip. The decisions that need no
hardware live in policy.c with a gcc host test; CI runs it and builds
the firmware in the pinned container.
The coverage lint already allowed docs, CI, unit tests and licenses to
go uncovered; the same list now keeps them out of every fingerprint,
so a README edit in any component re-requires nothing. The list moves
to the manifest module as NON_BEHAVIORAL, the one place both uses read
it. And a coverage entry that selects no file of its component (a glob
without the recipe's subdirectory, a component the manifest lacks, a
glob naming docs only) fails the lint: such an entry covers nothing and
the test's fingerprint ignores the file it meant. The contract says
both. Every test whose maps reached a doc or a test file gets a new
fingerprint once.
The six job tests covered all three cloud components whole, so a
one-line change anywhere re-required one real print and five attended
tests. The maps now say what each test proves: the protocol test the
web session, the emulator and its fixtures; the offline tests the run
loop, the hardware it drives, the offline dispatch and the pulse path;
cloud.mode-switch the homing path; every one of them the client's
common ground. The one real print keeps the coarse maps as the
integration and the lint's floor. Two entries of the protocol test
named app files without the recipe's subdirectory and selected nothing;
the paths are spelled out now.
Dev image 20260823161333, campaign c-20260823161923-0dd7, 44 of 44,
release authorized. The cloud split of the acceptance plan is complete:
the protocol by the emulator with only the app to drive, the machine's
print behavior by the offline service, the two together by one real
print, and the service's hunt paid only where it is the subject.
POST /mode, /controller/start and /controller/stop answer only when the
switch is done: the old controller gone, the new one started after any
pending liveness probe, and its first job-state report in (15 s without
one). The client's 10 s timeout read a slow but honest switch as a dead
daemon and errored cloud.service-protocol on the bench; those three
paths now get 120 s. No catalog consequence: the tests and their covers
are unchanged, the client only waits longer.
A cloud client the tool starts for anything but homing comes up under
the /run/gfcloud-nohunt marker: the real client back after the
emulator, a mode the runner switches to or hands back, a controller it
restarts. The service keeps the head position it has. cloud.mode-switch
and cloud.service-protocol keep their hunts, and so does the one real
print: enter_cloud reuses a running session only when that client has
hunted the machine itself (session_hunted: never the emulator's, never
a no-hunt start), otherwise it restarts the client with the hunt, since
a print placed on a head position the service only believes can run the
gantry into a rail. The markers are one start, taken down by the client
that read them first thing; the tool's own removal stays for a start
that never happened. Catalog unchanged; the cloud tests' shared code
moved, so their implementation hashes move with it.
The service-protocol half of the cloud catalog on its own test: the cloud
client restarted as gfutilities' emulator under the /run/gfcloud-emulate
marker signs in, passes the firmware check, opens the WebSocket, answers
the connect-time hunt and the image requests with the dev image's canned
frames, and runs a print from the app through the real download path to
':completed' - nothing moves, nothing arms, and only the app has to be
driven, by a person or an agent through the prompt API. The real client
is restarted afterward and its hunt waited out. session_live now knows
the emulator's session is not the machine's, so enter_cloud restarts it
rather than reusing it; restart_client is the one restart the offline
and emulator entries share.
The dev image adds python3-gfutilities-emulator (the fixtures, packaged
on their own in meta-openglow); forgefirm-app moves to 12ad3b1 (gfcloud
--emulate). Catalog: 44 tests, 27 auto / 9 operator / 8 live; the new
test covers the gfutilities service layer and examples/, which step 4
will take off the other cloud tests. Replays over the prompt script;
contract and BRINGUP updated. A layer change (the dev image recipe):
everything re-requires on the next image.
Every offline test now removes the jobs it wrote under /tmp/forgetest
on its way out (the bench rule: nothing left behind in the session that
put it there). CAMPAIGN-LOG gets the 2026-08-22 entry for dev image
20260822232347: the offline service dry-checked, then campaign
c-20260822233344-08de, 13 run and 43 of 43 with the four offline tests in
5.5 minutes, and what the machine said under it. BRINGUP: the offline
service is done and bench-validated.
The same python3-gfhardware commit meta-openglow pins; the cloud client
learns the offline service (actions on /run/gfcloud-offline.sock, no
web session) for the acceptance tests.
Four cloud tests no longer need the app, an account, a network, or
anything on the bed: cloud.lid-interlock-abort, lid-during-button-wait,
paused-lid-cancel and oversize-stream run under the offline service
(enter_offline restarts the cloud client with the /run marker for that
one start; Offline is the socket; offline_job writes the job). The jobs
come from forgetest/puls.py: the header of a factory print of this
machine type (134 tags, MCsn 0, so the client's limits and settings come
from where a service job's do) over a square traced at a steady feed
with a leading power byte of zero and no LASER bit anywhere - the arm
unlocks the latch, the beam is never commanded, so the tests stay live
and need no scrap. A job longer than the ring (33 MiB of ticks, an hour
of squares) is an 87 kB gzip written in a tenth of a second, in place of
a full-bed raster designed in the app.
session_live reads the offline mark as "no web session"; enter_cloud
restarts an offline client with the service, so cloud.pause-resume (the
one real print left, with cloud.mode-switch the service-protocol half of
the catalog) follows the offline tests without the operator's hand.
Replays over a fake socket; the contract and BRINGUP say how the cloud
catalog splits. Catalog consequence: the four re-ported tests move;
nothing else is invalidated.
CAMPAIGN-LOG: the 2026-08-22 entry for dev image 20260822204234 - the
three harness findings (the jog witness, the fading button LED, the
duplicated app cancel), the implementation-hash change they exposed,
and campaign c-20260822220701-a1c0: 43 of 43 from nothing, the 16
attended tests in 19 minutes, with what each new witness read on the
machine. BRINGUP: bench-validated, the latest campaign, the hash rule.
The implementation half of a domain fingerprint was the whole suite
file, so a two-line witness fix in laser.py re-required every laser
test and a rename in cloud.py every cloud test: sixty attended minutes
for changes that touched two test bodies. Now the hash is the test's
own function (its decorator included) together with the module's text
outside every @test function. A body edit moves that test alone; a
helper edit moves the tests of its module, which is what a helper does;
a file that defines no test in the @test form hashes whole. The gate
computes it the same way, from the same code.
Every recorded fingerprint moves once with this, so the next campaign is
a full one: the price of every later fix costing one test.
Also carries the re-targeted cloud replays that the previous commit
left in the working tree (the CI failure on 296fd68).
laser.arm-wait-lid failed its first bench run on the witness, not the
machine: the cancel relocked, disarmed and emitted nothing, but the
"button dark" check read the LEDs' brightness the instant after, and
the smooth trigger fades it. The controller writes `target`, so that is
what hw.button_leds() reads now (brightness where no target exists),
and check_button_dark waits a few seconds for the command to land.
The operator's campaign showed cloud.oversize-stream and
cloud.pause-cancel-paths both cancelling a print from the app. A print
longer than the ring has to be ended that way, and the operator is at
the app for it anyway, so the app cancel lives there and is judged in
full (the abort tail: stop, park to the job start, relock, disarm, the
button dark, ':cancelled'); cloud.pause-cancel-paths loses its second
print and becomes cloud.paused-lid-cancel - one print, one arm press.
Replays follow. Catalog consequence: the two cloud tests and the laser
module are re-required; the count stays 43.
On the bench the head accelerometer's sysfs read lands two or three
samples in a one-second jog leg, and two samples on the constant-velocity
stretch read near idle with the head in full flight: the accelerometer
sees the ramps, not the travel. The first unattended queue on dev image
20260822204234 failed motion.jog-roundtrip on that (2 of 8 legs judged)
with the head plainly moving where a ramp was caught (p2p 3019, 1330,
1698, 1663). The verdict is now over the sequence: p2p across all the
legs at or above the liveness threshold, and motion on at least two
distinct legs (one jolt is not a gantry moving on every jog); the
sampler must have landed samples at all. Rerun: p2p 2897 over 17
samples, motion on 3 legs, PASS; the whole unattended queue passed (15,
then 9 re-required by the module hash) on that image.
camera.snapshot's PASS line names the half-res frame it compared (the
full-res size had been printed in its place). AccelSampler gets host
tests over a fake iio tree.
A campaign asked a person for about eighty things: lid, button and
interlock actions, app jobs, and sixteen confirmations by eye, most of
them as popups to read and answer while the head was already moving.
This is the forgetest-only step of cutting that down.
The operator channel. A test asks for its operator's part in four ways:
ctx.ready() pre-announces a timed step and waits for the click that
starts it; ctx.notice() is a standing instruction with no button, the
test watching the machine for the result; ctx.act(channel, state) is a
machine action by name (lid, interlock, button) - a notice for the
operator today, proven done by the switch reading or an `until`
condition, recorded in evidence.actions with who performed it, and the
seam a bench actuator plugs into through runner.fixture; ctx.confirm()
stays for the yes/no the evidence cannot answer. Tests declare
`actions`; a `precheck` refuses a start the machine cannot honor (a
reason, no result, a queue skips it and carries on).
The page shows what you will do before it is asked: the running test's
steps, a queue's attended tests still waiting, or the test whose title
you clicked while idle; notices and prompts sit under it. The campaign
card no longer carries baseline, queue and leftover notes: those go to
the runner journal (daemon.log, syslog as `forgetest`, and the run in
progress), with a Runner journal button in the footer.
The catalog, 43 tests (27 auto, 8 operator, 8 live; was 45: 25/12/8):
cloud.mode-switch absorbs cloud.hunt-lid-open and cloud.gfhome-homing
(the connect made with the lid open, the hunt judged lid-open with its
Z cycle, the re-hunt waited out after the close, the switch back, then
$H judged by gfhome's own "homing complete" line with its motion
windows; precheck homing_mode = gfcloud). kernel.fire-line is auto with
the HV-not-good precheck, camera.snapshot is auto (a second frame with
the lid lamp off differs and is smaller), motion.jog-roundtrip is auto
(the head accelerometer per leg, the supervisor's own witness). The
remaining attended tests use Ready gates and act(); the head's beam
detector and the button LEDs replace the eye, leaving two confirms: the
emission witness's mark and the app's display in cloud.pause-resume.
Proof: tests/test_operator.py (the channel, the precheck, the journal),
the cloud replays re-targeted to the merged round trip over a fake grbl
and the bench excerpts, 196 host tests green, coverage lint 0 uncovered.
Every re-ported attended test is owed one bench run on the next dev
image (BRINGUP). Catalog consequence: the merged and reclassified
tests' implementation hashes move; nothing else is invalidated.
Dev image 20260822182931 (forgectrl b27398a, python3-gfhardware e65cfc2):
every unattended test PASS. The record keeps what the instrumentation
said on the machine: the status temps with the SoC die, the run-end
range line with all three sensors, and the cloud client's first counted
gap line on a live hunt header (30 declared ignored, 49 undecided), so
the next decision on the undecided families starts from a number. Also
the stale-layer build that was stopped and rebuilt on the way.
forgectrl pin b27398a: the SoC die joins the watched board temperatures
(/status temps soc_c and the kernel's CPU cooling state soc_throttle,
the Status tab, the per-job range line naming a throttle), a throttle
starting or ending is logged, and the supply sensor stays a raw count by
decision (its heatsink cannot be reached with a thermometer while the
machine runs). forgefirm-app pin e65cfc2 (0.1.16+git): every pulse-header
key without an applier is declared with its reason or counted as
undecided in the job log, and CLOUD.md carries one disposition table for
the whole header.
BRINGUP: the pulse-header envelope item is closed. Its durable content is
in the facts bank ("The factory's envelope, decoded": the mandatory tags,
the empty tach windows, the factory's pause and fail tiers, the
per-sensor units, the unarmed flow controller, and ForgeFIRM's answer
with its catalog proofs) and in the "Deliberately not gated" paragraph,
which names every declared family. Item 19 is now the bench-measured
head crash and rail-contact detector; the fire-watch item holds the
header's lid IR thresholds as its prior. The facts bank also records the
SoC's own thermal guard (85 C passive, 90 C critical, no heatsink on the
factory board) and the board temperatures at idle.
Catalog: cooling.gate-off checks the die field, the unthrottled state at
idle and the widened run-end line (the unit fake mirrors it). COOLING
section 9 and the SERVICES verification status describe the present.
forgectrl pin 368fd0c: the critical-line cross-check binds only while the
ceiling is a gate. cooling.critical-tier pins that a POST setting the
ceiling to its off end is accepted with the default line (the unit fake
mirrors the exemption); CAMPAIGN-LOG records the gate-off failure on dev
image 20260822165832 that found it, and the critical-tier pass.
forgectrl pin 76115fd: the chassis LM75 and the supply sensor ride /status
as temps (degrees and a raw count), the engine ranges them over every run
session into one run-end line, and a critical fault that clears with its
session yields the reason to the standing hold.
Bench: critical_tier_drill.py (a bench tool now, registered as
critical-tier) sets the ceiling, the resume gate and the critical line a
few tenths above the live upstream reading and lets the engine's own
flow-check heater warm the loop through them inside one M8 session;
temp_calibrate.py gains supply-watch, supply-point and supply-fit for the
supply sensor against a thermometer on its heatsink, the fit shown beside
UAPI.md's unverified guess.
Catalog: cooling.gate-off checks the /status temps fields and the run-end
board-temperature line (the unit fake mirrors both, one new failure
case); cooling.critical-tier checks the reason after a faulted session.
Docs: CAMPAIGN-LOG entries for cooling.critical-tier on dev image
20260822154257 and the warm-loop drill (OVERTEMP at 10 s, CRITICAL at
14 s, the fault ending with the session); BRINGUP item 19, the facts bank
(board temperatures at idle), COOLING section 9, the bench README.
forgectrl pin a1875a8: cool_temp_critical_c (default 38 C) is the fail
tier above the coolant ceiling's pause: at or over it in a run session the
verdict is CRITICAL (fire blocked, hold, no resume this job), the fault
ends with the session, the settings API keeps it above the ceiling, and
its top turns the gate off.
Catalog: cooling.critical-tier (auto, 45 tests), driven through the
settings API like cooling.gate-off: a critical line at the ceiling is
refused (and undone should it ever be accepted); with the ceiling, the
resume gate and the critical line all under the coolant's temperature a
session reads CRITICAL rather than OVERTEMP with no resume and the reason
naming the tier and the coolant; after the session the ceiling alone
holds (OVERTEMP); with the critical line at its top the gate is off
(gates_off, the run-start log line) and the ceiling alone pauses;
restored, OK with nothing off. Four unit cases against a scripted engine.
_after_session takes the condition to wait for.
Docs: COOLING (verdict table, section 5, the settings table, the quick
reference), BRINGUP item 19 and the catalog count, the CAMPAIGN-LOG
paragraph for the fan-fault session rule on dev image 20260822145201.
The dated record of dev image 20260822135848: the campaign of every
non-operator, non-live test at 18 of 18 PASS with the measured floors and
the operating-point rule (cooling.fan-gate-trips and the hunt leg of
cloud.mode-switch as recorded), and the unplugged-exhaust-fan drill:
AIRFLOW at the grace plus three ticks with the exhaust dead, the other
fans held, the reason relayed on the Grbl port, the replugged fan ok
inside the next session's grace.
The drill showed the fault riding into idle, where the hold canceled
jogs and would have refused the cloud print that re-proves the fan.
forgectrl pin d51dbdb: the fault ends with its run session, and the next
session judges every fan afresh. cooling.fan-gate-trips checks the
verdict is OK with no hold once the tripped session is over (the unit
fake mirrors it); its covers, and the cooling tests' shared covers, gain
src/coolfmt.* (the tree manifest carries the new files at the bumped pin,
and the lint was right to ask). cloud.mode-switch samples the hunt's gate
rows twice a second: a hunt's run phase is a few seconds long.
Docs: COOLING 3a, BRINGUP item 19, CAMPAIGN-LOG.
forgectrl pin 47e4256: the airflow floors set from the bench measurement
(exhaust 6400, intake 2290, air assist 6000 rpm, purge current 300, grace
15 s) and the operating-point rule: a fan is judged while the laser is
armed, when a job's profile may raise it but never lower it below the run
duty, or whenever it is commanded at the run duty; the service's hunts,
sent with the extraction fans off, are measured and published unjudged.
Catalog: cloud.mode-switch now waits out the connect-time hunt sampling
/cool/status and requires no AIRFLOW, the exhaust row unjudged and the
exhaust actually off; its covers gain forgectrl src/cool.* and
src/airflow.*. cooling.fan-gate-trips uses an 8 s test grace (the
intakes take 7 s to 90 percent and tripped under the old 2 s at the new
floor) and its off leg waits for the row state as well as gates_off.
fan_floor_measure.py names a reply that is not JSON and calls the purge
readings idle and run (the pump is always on).
Docs: COOLING 3a and the settings table, BRINGUP item 19 and a facts-bank
entry with the measured fan speeds, the bench README, and a CAMPAIGN-LOG
entry for the measurement, the two status-document finds, the hunt find
and the hot-deployed bench runs of both tests.
The acceptance catalog gains the test behind forgectrl 5a31d66: an
exhaust floor at the legal maximum must trip AIRFLOW after the grace and
three ticks (hold, fire blocked, no resume, the exhaust gate TRIPPED and
named in the reason); a purge current floor at the ADC rail must trip
the same way; an exhaust floor of zero must read off in gates_off and
trip nothing; restored, the next session must run OK with every fan at
or above its floor. Three host cases against a scripted engine that
models the grace and the readings. The covers map names src/airflow.*.
COOLING.md gains 3a (the airflow gates) and the five settings with their
ranges and bands; SERVICES.md the gate rule, the rows, the AIRFLOW
verdict and fan_gates; SAFETY.md names the floors among the verdict's
inputs; BRINGUP item 19 records the gates as in with provisional floors
and the catalog at 44. Pins: forgectrl 5a31d66, forgefirm-app 81027ff
(0.1.15+git); fetch-verified.
Two modes. spinup opens a run session with M8 from the engine's idle
posture, samples the four tachometers and the purge-air current once a
second, ends the session with M9 and waits for the engine to leave run;
per fan it reports the steady speed at run duty, the time to 90 percent
of it and the spread over the steady window, the purge current off and
on, and candidate floors at 55 percent of steady. cut sends nothing and
samples while the operator runs a real cut, for the spread the debounce
has to ride over under the only load that matters. Registered on the
bench page as fan-floor. Tooling only; no catalog consequence.
cloud.pause-resume passed on dev image 20260821220926 with the service's
hunt windows (10 to 50 C) ignored as looser and the print's window (33 C,
5 C floor, 116 rpm air-assist floor) matched. The test now quotes the
print's job-limits line rather than the session's first (a hunt's), and
keeps the engine line that carries the header beside the last one.
forgectrl pin moves to e0b41b3 (the "not stricter" notice once per value).
The pulse header's envelope now reaches the cooling engine: the cloud
client (python3-gfhardware c34faa1) derives the coolant window and the
fans' minimum speeds from the header and rides them on every report,
and the engine (forgectrl 57f6064) resolves each as the stricter of its
setting and the job's, never looser and never overruling an off gate.
cloud.pause-resume, which runs a real print, now also reads the client's
"job limits from the header:" line and the engine's "effective limits:"
line from the two logs; two host cases cover the failure paths. The
needle guard lists the engine's phrases as not the app's.
COOLING.md section 2 explains what a cloud job brings with it; BRINGUP
item 19 records the pass-through as landed. Pins: forgectrl 57f6064,
forgefirm-app c34faa1 (0.1.14+git); fetch-verified.
First bench run of the gate-settings pattern: PASS on dev image
20260821210903 after the harness fix in f274eb1; the entry records the
failed first attempt and why it was the test's fault. BRINGUP item 19
carries the bench result.
On the bench the test tripped the gate and then failed its off leg: its
M9 and the next M8 were 300 ms apart, the GRBL client reports at 1 Hz
and the engine samples at 1 Hz, so the engine never saw the session
end, the next M8 was not a new run start, and the ceiling was never
re-read. Every M9 now waits for the engine's phase to leave run before
the next M8, and an M8 refuses to open on a session still running.
The failure path restores the settings and then cycles a run session
too: the engine reads settings at run start only, so restoring the file
alone left the bench holding OVERTEMP against the test's 6 C ceiling
until the operator's next job. The scripted engine in the host cases
models the report period, and two cases pin both rules.
The acceptance catalog gains the test behind forgectrl 9e44fdc: the
coolant ceiling set just over its legal minimum must trip OVERTEMP with
a hold and fire blocked at the next run start; set to its top the engine
must skip the gate (verdict OK), report it in gates_off on /status and
/cool/status, and log the run-start line; the original values are
restored, on failure too, and proven restored. Five host cases against a
scripted engine. The cooling covers map now names the files that carry
gate state (gates, settings, status, the panel) and corrects a glob that
matched nothing: the GRBL cooling client is src/glowforge_cooling.c, not
src/gfcool*. The fake forgectrl serves /logs/tail and keeps blank form
values as "clear", which is what the daemon does with them.
Docs: COOLING.md section 8 carries each setting's legal range and
recommended band and a new 8a on turning a gate off; SAFETY.md names
what no setting can reach; ACCEPTANCE.md records that gates are
exercised through the settings API, never GFCOOL_* env overrides;
BRINGUP item 19 records the pattern as landed and the catalog is 43.
forgectrl pin moves to 9e44fdc; fetch-verified.
The full acceptance campaign on dev image 20260821181036, the first built on
the <recipe>-pin.inc layout, satisfied 42 of 42 and its export authorizes a
release, so the status header, the acceptance section and item 12 describe
that rather than a campaign still owed; the catalog count is 42, not 35.
CAMPAIGN-LOG records the completion. Item 7 stops listing the cloud work the
campaign closed (the oversize live print, the progress bar watched from the
app, packaged-path cloud boot, the lid flash), records the operator's
declines (SPKI pinning, emulator parity, the ten-event pause machine) and the
reasoned memory guards, and item 19 no longer lists MCsn and PDfm as
unchecked: the client refuses both, so seventeen mandatory tags are dropped,
not nineteen.
forgefirm-app pin moves to b82d379 (python3-gfhardware): CLOUD.md and three
present-tense comments, no behavior change. No catalog consequence beyond the
pin bump itself, which the manifest scopes to the component.
cloud.pause-resume failed a print that paused, resumed with its laser
lead, completed and parked: the test waited for the single line "button
pressed while paused; resuming", and the app has logged that as two
lines since its feeder work ("button pressed while paused", then
"resuming (laser lead N ticks)" from _resume_retraced). The replay
fixture carried the old wording, so the host test kept passing.
The pause and resume are now judged on PAUSE_LINES + RESUME_LINES
through one checker shared by the three tests that drive a pause
(cloud.pause-resume, the streamed pause, the pause-then-lid test), which
also fails on the app's "resume refused" line with the reason. The
fixture carries the app's two lines.
So the wording cannot drift silently again: tests/test_cloud_needles.py
reads every log phrase the cloud suite greps for out of cloud.py (the
left side of each `x in ln`, every wait_log needle, the mark tuples, and
the phrases it builds) and checks each against the logger calls in the
app sources at the revisions the recipes pin, read from the manifest
cache the tree manifest builds (the sibling checkouts locally),
placeholder-aware under a rule that never lets a placeholder stand for
the phrase itself. CI now builds the tree manifest before the unit
tests so the cache is there. The old needle fails that check.
Replays added: the second press seen but no retraced restart, and a
refused resume. 159 unit tests pass; coverage lint clean. No catalog
consequence beyond the suite module's own hash.
cooling.fans-quiet-after-motion sat silent for four minutes on the bench
and then failed. Its idle reference was the tachs one second after the
baseline saw the engine go idle, while the previous test's fans were
still coasting at the cooldown level (exhaust 5030 rpm against a true
idle of 0), so it then waited for the fans to come back UP to a level
that was never idle. The cooldown wait printed nothing while it waited.
The reference now needs the engine idle, the idle duty applied to both
fan channels, and three consecutive tach samples that agree; the pass
condition is the idle duty back and the tachs at or below that reference
(lower is quieter, never a fault); M8 must visibly raise the duty; and
every sample of both waits is logged with the phase and the duty, so the
run pane shows the fans coasting down rather than a hang.
Proof: tests/test_cooling_suite.py replays the test under the real
Context against a scripted machine (fake forgectrl, fake sysfs duties,
fake Grbl port): an idle machine passes, the bench case (fans coasting
when the test starts) passes with the reference taken after the coast,
fans left on fail with the reference in the message, and a reference
that never settles fails before anything is jogged. No catalog
consequence beyond the suite module's own hash.
laser.pause-resume-lid-cancel failed on the bench with the head 14.925 mm
along the first leg after the lid cancel: the driver took every resume
from a feed hold as a new job start (the core restarts a held cycle
through Idle), so the cancel returned the head to the pause point and
said "returned to the job start" about it. The driver fix is in
grblHAL-glowforge; this is its proof and the record.
The null-sink lifecycle harness gains rule 9: a job paused and resumed by
the button, then cancelled by the lid, returns to where the job began
(reproduces the bench failure to the millimeter on the old driver,
X=13.088 instead of 0); and a job abandoned in a hold and soft-reset is
over, so the next job from that spot returns to that spot. The campaign
log carries the dated account.
No catalog consequence: the acceptance test measured exactly this and
stays as it is; the harness is the CI side of the same contract.
The cloud job tests enter cloud mode and stay there, by design, so a
queue (or an operator) that goes on to a motion test reaches it with
gfcloud as the controller and no grblHAL process to find:
motion.step-timing-under-load failed on exactly that, before it touched
the machine. Nothing in the runner put the machine into the mode a test
needed; the baseline only preserved the mode it found.
A test now declares `mode="grbl"` (or "cloud") in @test. The runner's pre
pass, after the leftovers are handled and before the preserved state is
captured, switches through POST /mode, waits for the supervisor to settle
(controller running, motion verified) and for the Grbl port to answer,
and fails the test with the reason when the mode cannot be established.
Capturing after the switch means the post pass keeps the mode the test
asked for, so the machine changes mode only where the next test asks for
it and never between tests of the same mode. The cloud job tests keep
managing their own entry (enter_cloud also waits for the service
session) and declare nothing.
Tagged: every motion.* test but the mode-agnostic liveness probe, the six
laser.* tests, cooling.fans-quiet-after-motion, cloud.mode-switch and
cloud.gfhome-homing (both start in GRBL mode). controller_pid() now says
what mode forgectrl reports when the process is missing. The page shows
the declared mode as a badge; the Grbl port probe moved to hw.
Proof: tests/test_mode.py (switch_mode against the fake forgectrl,
including a refused switch, a controller that never comes up and a port
that never opens; the runner end to end from cloud mode, from grbl mode,
an undeclared test, and a failed switch). 151 unit tests pass; the
coverage lint is clean. No catalog consequence beyond the suite modules'
own source hashes: the change is to how a test is started, not to what
it proves.
The subsystems are separate tables, so each sized its own columns from
its own content and no two lined up. Reading down the page meant reading
down six different layouts, which is what made it look busy. They now
share one colgroup and a fixed table layout: Kind, Status, Last result
and the Start button are the same width in every group, and the Test
column takes the remainder.
Two things had to come out of the columns first, because both are long
enough to stretch a cell and drag one group's grid out of step with the
rest. The details block moves to a full-width row of its own, where the
prose and the operator steps have room and opening one no longer widens
the Test column. The requires note moves from under the Start button to
under Status, which is sized for it: the catalog's longest names three
prerequisites in 73 characters, and beneath a button it wrapped into a
ragged stack. It reads better there anyway, next to the reason the test
is required rather than beneath the control it disables.
Also gives a test's description its own block in the details. Without
operator steps to separate them it ran straight into the Requires line.
A campaign is mostly waiting for the next Start. The page now offers two
queues, and each takes every test of its kinds the campaign does not
already count as satisfied: Unattended for the auto tests, which need
nobody in the room, and Operator and live for the ones that need somebody
at the machine, since they prompt and they fire the laser. The buttons
say how many they would run and ask before starting; the live queue names
the tests that fire and takes the acknowledgment once, for all of them.
A queue runs one test at a time through the runner's single slot, in
prerequisite order. Registration order otherwise, so a run reads down the
page, but a prerequisite inside the queue always goes first. It stops on
the first result that is not a PASS: a FAIL closes the campaign, and
carrying on would only open a second one behind the operator's back. A
test the runner refuses to start is skipped with the reason on the page
and the rest carry on, which is what happens to an auto test waiting on an
operator one: run the attended queue, then the unattended one again.
The queue lives in the runner, not in the tab, so reloading the page or
closing it leaves the run alone. While one is up it holds the machine
between its tests as well as during them, so a single Start and the bench
tools are refused rather than cutting in. Stop the queue cancels what is
still waiting and lets the run in progress finish; Abort ends that one
too, and lands as the non-PASS that stops the queue. Every run a queue
starts records which one put it there.
Also moves the /state ETag test to the end of its class. It invalidates,
timestamps are whole seconds, and a PASS stamped in the same second as an
invalidate is deliberately not inheritable, so on a fast run it decided
the inheritance an earlier test was checking.
forgetest is a dev-only component and can never appear in a coverage map,
so this has no acceptance catalog consequence.
Presses on Start and Continue were being swallowed. Every poll rebuilt
the whole test table and the prompt buttons with innerHTML, and a button
destroyed between mousedown and mouseup raises no click event at all:
the press simply vanished. Measured on the page as it stood, a Start
button node was replaced 13 times in 40 seconds, and with a poll landing
mid-press 8 presses out of 8 were lost. Rows, prompt buttons and tool
entries are now built once and afterwards only updated in place through
setters that skip the write when the value has not changed; under the
same test 8 presses out of 8 land.
The page also felt slow because each poll re-read and re-parsed the whole
result log and recomputed all 42 domain fingerprints. A result record
carries its run log, so the file reaches megabytes over a campaign and
the poll cost grew with it. The log now parses each line once and reads
only the bytes appended since, and a fingerprint is memoized against the
manifest's content hash. On the same manifest, catalog and log, one
/state goes from 8.89 ms to 0.10 ms at 0.89 MB and from 41.79 ms to
0.23 ms at 10.62 MB, and no longer grows with the log.
Actions now report on the press instead of on the next poll: Start greys
every Start and marks the row, Continue and Abort grey themselves, and
each pulls the next poll forward. /state carries an ETag so an idle page
polls for a 304, the poll ticks faster during a run, and the connection
is kept alive rather than handshaking per request.
Keeping the connection alive exposed a hazard worth naming: a POST
refused before its body was read left that body in the socket, where the
next read took it for a request line. A refusal now ends the connection.
forgetest is a dev-only component and can never appear in a coverage map,
so this has no acceptance catalog consequence; the host tests carry the
proof, including one that fails if a per-poll innerHTML rebuild ever
comes back.
The work ran from 2026-08-18 to 2026-08-20 and is finished, so the plan it ran
from is retired the way plans are retired here: what is durable moved into the
documents that describe the present, and the dated record of how the result was
obtained lands here.
The entry keeps the parts that live nowhere else. That the campaign started
from a wrong belief and was corrected by the factory itself, which stages a job
into its ring in free-checked segments and then appends to it for as long as
the job lasts. That the proof was already on the machine, a 107 MB job played
through a 32 MiB ring in this board's own factory logs. The two defects the
building surfaced, a dry ring reporting success and a pause that could not
retrace on a streamed job. And what it cost to be sure: a decoder 41 times too
slow to keep a ring fed, which only showed itself against a real 53 MB job.
They were open because the service has never sent them and cannot be asked
to. That is not a reason to leave them open: the factory application says
what each one does, and it says all four are hand-offs to programs this
machine does not have. BRINGUP's cloud item now records the disposition
rather than the question.
Two tests already run a print end to end, and progress is a property of a
running print, so the checks go there rather than into a test of their own
that would cost the operator another job.
cloud.pause-resume takes the job that fits the ring: the client names the
length it is reporting against, and the operator is asked the question only a
person can answer, whether the bar actually moved.
cloud.oversize-stream takes the job that does not fit, which is where a moving
denominator would show: the kernel's program total grows all run long under a
live feed, and the test already samples it growing, so the check is that the
figure progress divides by is larger than that - the job, not the count the
ring had swallowed when the run started.
The forgetest replay plays a captured log from a build that predates the line,
so it carries the line where the current build emits it, as it already does
for the warm-up and the rest.
BRINGUP's cloud item now says a print reports itself again, and what is left
on it is a print watched from the app.
The campaign log gains the F1 entry: the question was which of three carriers
the factory uses for the progress bar, and the answer is that two of them
collapse into one. Progress rides an outbound WSS type:"progress" frame that is
itself the periodic settings report, every 30 s. The write-up lives in CLOUD.md;
the log records how it was gotten and what else the capture proved, including a
factory progress total that grew 256 KiB per interval because the factory
live-appends to its own ring.
BRINGUP's cloud item now lists F2 among the open work with the carrier settled,
so what is owed is emitting the frame against the feeder's job total rather
than the kernel byte counter.
Documentation only, no behavior change, so no acceptance-catalog consequence.
cloud.pause-resume runs a print end to end, which is exactly what the job
lifecycle needs to be seen: a non-zero hold before the first fire, a non-zero
rest after the park, and neither on the connect-time hunt in the same
session. Folding the checks in there costs the operator nothing, where a
test of its own would cost another print.
The forgetest replay noticed first: it plays a real captured log from a build
that predates those lines. Rather than editing what the machine said that
day, the replay carries the two lines where the current build emits them.
BRINGUP's cloud item now names what is actually open on the header keys, the
park and the two periods, and records that the pause constants were looked
for in the wrong place.
BRINGUP's cloud item said a print is capped by the ring and listed the two
gaps that closed when it stopped being. It now says what bounds a print
(memory, through pulse_reject_threshold_bytes) and what catches a feed that
wedges (progress against room, not ring depth), and it names the two new
cases that cannot be induced from the bench: a body past the memory guard,
because the service has no such job to send, and a wedged feed, because a
healthy machine will not stall on request.
forgectrl.settings-bounds gains a probe at the far end of the new byte
range, so the validator behind those keys is exercised rather than assumed.
kernel.backtrack-bounds plays a program, stops it, and holds the readback
to the bytes it played less the deceleration tail: a step past the boundary
has to be refused rather than quietly shortened, and the run at the boundary
has to play out and come back idle. Motors locked, latch locked, duty zero,
so nothing moves and nothing fires.
cloud.oversize-stream pauses and resumes the live-fed print it already has
running. That is the pause the kernel change makes possible, and it costs a
minute of a job that is on the bed either way.
BRINGUP's pause bullet, its ring facts and item 18 all said a ring under a
live feed has nothing left to back into. The gap the writer keeps clear says
otherwise; what is still open for GRBL mode is the bookkeeping above the
ring, not the kernel below it.
cloud.oversize-stream drives a job the ring cannot hold and checks the
signature of a live feed: the load reporting the job as longer than the
ring, the device in live-feed mode, the kernel's program total growing
during the run, no underrun, and a clean cancel afterwards.
read_program_total gives the catalog the counter that growth is read
from.
The ring and the device-tree pool that backs it are now the size the
factory firmware runs. Docs follow, and the hardware facts bank gains the
reserved-memory map: 511 MiB usable, 96 MiB reserved for DMA, ~454 MiB
left to Linux.
image.health reads the pool size and ring_mb back and fails if they
disagree, so a pool the parameter does not use cannot ship unnoticed, and
checks that free never exceeds the ring less its gap.
Both recipes pin the same repository and the pin files say to move them
together. Documentation only on this side: the cloud doc now records what
the factory enforces from the pulse header, and what it does not.
Next-work item 19 carried three open questions about the factory's
operating envelope. All three are answered now, so the item states the
policy instead of the guess.
A fan tach alert during a cut pauses the print, taking the same transition
a user pause takes, and two of the factory's three tach monitors cannot
fire at all: they treat a zero limit as unconfigured and the limits arrive
zero anyway. So a stalled extraction fan is caught by the temperature it
causes, not by its tachometer. Every temperature alert pauses as well, and
a critical fails the machine outright, which is a different state and not a
pause. The scale question behind the header's ceilings is answered by the
coolant family, which the factory carries twice, once in raw ADC counts
where the tag named min is the hot limit, and once in millidegrees. And the
factory does not verify coolant flow at all, so the cooling engine's flow
check is ahead of the factory rather than behind it.
The crash, tilt and beam-detect bullets gain the same grading: an alert
threshold that pauses and a separate abort threshold that aborts.
Also drops references to paths that resolve outside this repo, in both
documents and in two bench scripts, naming the artifact instead. In
CAMPAIGN-LOG that is a wording substitution only; no date, claim or
measurement changes.
Documentation only, so no acceptance catalog consequence.
Add a "What this costs" banner to the README and INSTALL, below the
in-development notice. It states that the firmware is free in both
senses, that nothing is paywalled or held back, that the work happens in
public, and that anyone who wants to verify it can read the licenses and
the commit log instead of taking a stranger's word for it.
The banner also warns that a build bought from a third party is that
party's build, not this project's: unreviewed code driving a laser.
Documentation only. No behavior change, so no acceptance catalog
consequence.
The repo is public and the install path is fully written up, so a reader
who finds it can follow INSTALL.md end to end without ever learning that
the release it depends on does not exist. The banner sits directly under
the title in both documents: no images are published, nothing here is
installable, and anything found elsewhere claiming to be a ForgeFIRM
image did not come from this project.
Documentation only - no behavior change, so no acceptance-catalog
consequence.
The section describes two channels flowing opposite ways - the
controller's job-state reports and the engine's verdict - so "the report"
pointed at the wrong one directly under a lead that names the verdict.
MOTION said jogs, homing and hunts were all ungated. Jogs are:
gfsw_visible withholds the door signal while the core is idle, jogging or
homing, so a jog both starts and runs with the lid open. Homing is not.
With homing_mode = gfcloud - the only method that works today - $H hands
the cycle to a cloud homing session, and its move to the home corner is
an ordinary motion action taking the default lid_gated=True: refused with
the lid open, stopped on a lid edge mid-run. Only the lens/Z hunt inside
that session passes lid_gated=False, and that session is also the only
place a hunt happens in GRBL mode - there is no hunt outside one.
BRINGUP gains item 18: GRBL pause and resume should leave no gap in the
cut, the way the factory's does. The beam stops at the start of the hold
(disable_laser_during_hold, on by default), so the head travels the whole
deceleration dark and the resume restarts from a standstill where the
decel ended - an unburned length, then a dwell through the accel that M3
shows as a deeper spot. The kernel waypoint backtrack cloud mode uses is
refused with EPERM on a live-streamed ring, so the equivalent has to be
built above the ring, where grblHAL still holds the planned path the
kernel has already overwritten.
Two wording fixes: the cooling verdict is described as a report rather
than a file, and gfcloud homing as using the machine's builtin
credentials.
Documentation only - no behavior change, so no acceptance-catalog
consequence.
The shipped default changed, so the drill's precondition would have
refused to run on a stock machine - the one configuration it most needs
to be able to test. Only an explicit analog selection is a refusal now,
and the drill reports whether the model came from the config or the
driver default.
grblHAL-glowforge 03d70e0, pinned here, makes density the shipped model
and $35 the density floor at 10. Every analog session in the stream
harness now selects its model rather than inheriting it: without that the
flip would have quietly turned them into density runs and taken the
analog fallback's coverage with them. laser.power-floor carries the new
floor and its PWMSAR minimum, and its description no longer describes
$35 as a duty floor.
All ten stream sessions pass on the new defaults, with the analog duties
shifting exactly as the floor predicts - min_value 12, gradient 0.115 -
along with both C harnesses and the lifecycle harness.
Recorded in BRINGUP and the campaign log, including what the defaults
rest on and what they do not: the seven ladders ran at F300 and F100 at
constant power, so production feeds, M4 into corners and the raster path
remain unproven on hardware.
$35 = 10 under the density model is a density floor, not a duty floor. It
maps S onto 9.4-100 percent density, so a commanded 1 percent lands at
10.2 percent, just above the marking floor the earlier ladders measured.
A ladder reweighted to the bottom of the user scale - 1, 2, 5, 10, 20,
40, 70, 100 percent of S - marked on all eight rungs, with eight current
segments over a 42.0 s window against exactly 8 x 5.25, and segment means
climbing 136 to 968.
That meets the goal the ladders started from: a user's 1 percent is a
real visible mark rather than silence, and 100 percent is full power. It
took all three pieces - density so every level is real pulses, the
minimum pulse so they stay strikeable, the floor so the user's range sits
on the band that works.
dladder no longer tells the operator to re-run at other base periods to
choose one. The period cancels out of the low end, and what a failing
rung now indicates is a floor set too low.
min_ticks 6 broke 5 percent striking - seven current segments where 3 gave
eight, on a 36.5 s fire window against 41.4 s for eight rungs. Below the
minimum the model emits min ticks every min/on periods, so the interval
between pulses is min_ticks x tick / density and the base period cancels,
which is also why periods 10, 20 and 40 gave identical results earlier.
At 5 percent that is 2.26 ms at min 3, which struck, against 4.51 ms at 6,
which did not: doubling the minimum doubles the gap as well as the pulse,
and the discharge is re-struck each pulse.
min 3 sits at the factory's own operating point - its 6.5 percent engrave
jobs place 100 us pulses 1.54 ms apart against 1.64 ms for min 3 at that
density - and 6 is outside anything the factory does. The bench is back
at 3.
Measured band for this tube: strikes from ~5 percent density, marks from
~10 percent at F300. That closes the pulse-structure route to a usable
1 percent, since the interval grows as 1/density and 1 percent implies an
11 ms gap. The low end is a scaling problem, and $35 is the control.
The fifth ladder, the first with a minimum pulse, moved the floor down a
full rung: only 5 percent failed to mark, and 5 percent now strikes. The
trace carries eight current segments where the run before it had seven,
with fire beginning at 6.2 s exactly at rung 1 and the usual flat
saturated final segment anchoring the count from the other end.
That retracts what the previous entry concluded. Pulse length is not
irrelevant: 10 percent moved from no mark at F100, with three times the
dose per millimeter, to a mark at F300 at the same density, the only
change being its pulses growing from 36-71 us stubs to 106 us. The
matched-pairs argument was sound but drawn entirely from comparisons at
or above 20 percent density, where every pulse length in play was already
long enough - it generalized from the one regime where pulse length does
not bite. Above ~100 us dose governs; below it pulse length does; below
~36 us the supply does not strike. The factory's 100 us quantum sits on
that boundary.
dladder now reads laser_pulse_min_ticks and prints what is actually
emitted. Without that its table reports the pulse density alone would
give, which is wrong wherever the minimum applies - at min 6 the bottom
four rungs all emit 213 us and vary their rate instead, and the operator
reads that table to interpret the material.
Rule 15 in the stream harness holds both halves of the minimum
(grblHAL-glowforge f7e8c17, pinned here): no emitted burst falls below
laser_pulse_min_ticks, excepting one clipped by fire going off mid-burst,
and the levels too faint to fill a window still render their exact
average density. Checked against a run at minimum 1 so it cannot pass
vacuously - level 2 goes from 444 bursts of one tick to 147 of three at
the same density, and levels already above the minimum are unchanged.
Four bench ladders settle the base period at 20. The same six rungs
marked in all of them, and the matched pairs across periods separate the
variables: at identical pulse length, halving the density killed the
mark; at identical density, varying the pulse 3x changed nothing. Feed
does not move it either - 10 percent at F100 carries 44 percent more
energy per mm than 20 percent at F300, which marks, and still left
nothing. The low-end marking limit is average power, not dose per length
and not pulse length.
The F100 trace separates two failures that look alike on the material:
seven current segments for eight rungs, anchored by a flat saturated
final segment that can only be full density, put 5 percent at no
discharge at all and 10 percent at a full 15 seconds of current with no
mark. Only the first is ours, and the minimum pulse is the answer to it.
Also recorded: the factory's Precision Power 1 runs a 19.53 percent FIRE
duty cycle at full PWM duty, and its 1-100 scale maps onto density
18.9-79.5 percent, so its 1 percent is the bottom of the useful band
rather than 1 percent of the range. Under the density model $35 and $36
are that same control - a density floor and ceiling - which makes the
user-facing scale a settings choice rather than new code.
The trace is the per-rung witness: the laser-off G0 between rungs reads
0, so the runs of nonzero current count the rungs that struck and their
level tracks each rung's dose. Without it a run reports only a range,
and which rungs fired has to be read off the material alone.
Under the FIRE-bit dose model the duty is pinned at full and the level
is carried by how many ticks of each base period fire, so a rung's pulse
is density x period - and the base period is the one parameter the host
cannot settle. dladder walks 5 to 100 percent of dose on scrap at
constant power, printing what each rung actually emits: density, mean
on-ticks, and the pulse the tube sees in ticks and microseconds. Below
one tick per period the pulse stays one tick and whole periods are
skipped instead, which the table says outright rather than quoting a
sub-tick average.
Run it at 20, then 40, then 10 on the same material. At 20 percent dose
that is 107-142 us, 249-284 us and 36-71 us respectively, which brackets
the 100 us the factory never goes below. The material answers two
questions: whether depth tracks density linearly, or the low end marks
harder than its share because every burst restarts the discharge and
carries the strike transient; and how short a burst still marks, which
is the floor a given base period can reach.
Preconditions are refusals, not warnings: laser_power_model must already
be density and $35 must be 0, since a floor lifts every rung off the
bottom of the range the drill exists to explore. The $35 message says a
runtime write is not enough - the S to duty mapping is precomputed when
the spindle is enabled. The drill writes laser_pulse_ticks itself when
it can reach the machine config, preserving every other line, and
refuses with instructions when run from a LAN host where it cannot.
Four new sessions in the stream harness cover the model (grblHAL-glowforge
2bca017, pinned here). Density renders the commanded level exactly -
levels 2, 3, 7, 15, 25 and 38 came back as 0.0158, 0.0237, 0.0551,
0.1182, 0.1969 and 0.2993 against level/127 of 0.01575, 0.02362, 0.05512,
0.11811, 0.19685 and 0.29921 - S1000 renders 1.0000 and still ends dark,
every power byte carries full duty, and a level change inside a run costs
no stream byte where analog ships one per level.
Rule 13 is the one worth having: the same job run under both models
produces an identical motion grid tick for tick, and all 20051 density
FIRE ticks fall inside the 169776 the analog run fired. The model masks
the core's fire state and never sources one, measured rather than argued.
Rule 14 covers the idle-gap fix: a standalone S between moves, from a
sender slow enough to drain the planner, now fires each move at its own
level (28338 ticks each at duties 30, 52 and 84). Before the fix duty 30
held all 85014 and the other two levels never appeared. That closes
"Next work" item 18, which this work opened earlier today.
The harness now derives its expectations from the board's floor and
chains two launches over one settings file, because the core precomputes
the S to duty mapping once when the spindle is enabled: $35 written at
runtime persists and reports immediately but only enters force at the
next controller start. That is recorded in BRINGUP beside the existing
defaults note, and laser.power-floor's failure message now says so.
Acceptance: the density path shipping off by default is inert until
laser_power_model is set, and the laser tests' covers already name
grblhal-glowforge src/**; the model's own acceptance test waits on the
bench drill that picks the base period.
The pthresh ladder on scrap puts the tube's two thresholds far apart: the
discharge strikes between 2 and 3 percent duty, but nothing lases usefully
below 16 percent (PWMSAR 20), and the rungs between show only a spot at
each line start. $35 ships at 16 (grblhal-glowforge 9466f76, pinned here).
The drill said current lift-off and first mark share a rung; this run
falsifies that, so its docstring and read-the-material text now name both
thresholds and warn that a start-of-line spot is below the threshold, not
at it.
A start-of-line spot is also what a full-power leak at a kernel run start
would look like, so laser_stream_test gains a ladder session (rule 10):
every FIRE tick must ride a commanded duty, and the fire ticks must divide
evenly across rungs. Both hold exactly - six commanded duties, no others,
and 28296 fire ticks on every rung - so the spots are the tube, not the
stream. The harness now derives its expectations from the floor, which
moves the M4 session's S500 plateau from 63 to 73.
laser.power-floor is a new auto acceptance test, the suite's only
non-firing one: a machine must actually carry the commissioned floor,
since stored settings beat freshly baked defaults.
Three cloud cuts of one square at Precision Power 1, 100 and Full Power
show what the analog path is competing with: the power byte is pinned at
127 in all three, dose is FIRE-bit density on a fixed 7-tick period with
the on-count dithered between adjacent integers, and the power setting
never reaches the machine at all. Facts bank and item 17 carry the
numbers; CAMPAIGN-LOG carries both sessions.
The dated record for the jerky-at-2000-mm/min thread: what was wrong,
what changed, and what the bench measured.
Keeps the two results that matter. The A/B that isolated the cause -
two acceptance runs 90 s apart on one image, camera the only variable,
PASS with a nice-5 CPU hog and FAIL with the camera streaming - and the
LB-GF-OG-FM job re-run on the fix with the video live, clean at
min margin 3.1 ms of 10.
Also records that max behind is not the instrument: it reads 0.0 ms
through a passing run whose real margin fell to 4.9 ms, because the
producer never falls behind its own wakeup epoch. Only the measured
min margin shows the condition.
Bump the grblHAL pin to the producer lead and margin instrumentation.
Rewrite BRINGUP item 16 to the present state. The scheduling half is
done - the producer runs SCHED_FIFO below the shipper, core_mx carries
priority inheritance, and clamping is reported per run - and two bench
runs 90 s apart on one image show what that does and does not cover: a
nice-5 CPU hog passes clean while the camera streaming clamps 7 runs,
because per-frame cache maintenance over a 4.8 MB non-coherent capture
buffer is kernel-context work no userspace priority can preempt.
Record the margin finding, since it explains why a 4 ms stall was
enough: the queue depth cancels between the shipper's due index and the
producer's base, so the pacing lead is the only slack. Record the
cycle-churn ceiling that caps that lead at 10 ms, and that the re-base
fix is what unlocks more.
Camera gating stays owed, with capture resolution named as the lever
that shortens the stall rather than merely spacing stalls out.
The image has no `nice` binary - BusyBox ships renice only - so
motion.step-timing-under-load could not spawn its CPU hog at all. Set the
niceness from the parent with os.setpriority once the child exists, and
assert the value that actually took: a hog left at nice 0 would be a
harsher test than intended, and one left unset must not pass silently.
Add htop to the dev image for bench diagnostics. The release image is
unchanged - it carries neither forgetest nor htop.
Bump the grblHAL pin to the real-time producer change.
Add motion.step-timing-under-load: the catalog had nothing that
exercised step generation while userspace competed for the single core,
which is exactly the gap that let the condition go unnoticed - the ring
never runs dry, so cnc/underruns reads 0 through it. The test asserts
the producer and the shipper both hold SCHED_FIFO, then drives
2000 mm/min round trips against a deliberate nice-5 CPU hog and requires
the controller to report no clamped events.
Add the pthresh live-fire drill: a constant-power ladder from 2 % to
30 % of full on scrap. Because $35 is a percent of full duty and the
rungs are percents of $30 with $31 = 0, the lowest rung that marks reads
directly as the $35 value. It needs $35 = 0 for the run, or the floor
lifts every rung and hides the threshold.
Record both open items in BRINGUP. The laser one carries the finding
that the factory never uses duty as a power control - all five firing
jobs in the captured pulse files pin the power byte at 127 and modulate
dose by dithering the FIRE bit at 6.5-18.8 % density - so the captures
cannot supply a $35 default, and the duty to optical-power transfer
function of this supply has never been measured.
docs/VIDEO.md is the user-facing camera guide: what the endpoints return, what
the sensors can do that ForgeFIRM does not send and why, the privacy gate, and
the state of 8 MP support.
The 8 MP (OV8856) capture path now reaches the sensor's full 3264x2448 frame.
Its stock RAW10 full-resolution mode runs the link at 1.44 Gbps/lane and the
i.MX6 CSI-2 D-PHY stops at 1 Gbps; the BSP adds a RAW8 mode that carries the
same frame at half the rate, so an HD machine is no longer limited to the
binned quarter-pixel mode. kas/README.md carries the reasoning, the register
deltas and the factory configuration to fall back to if the receiver will not
lock at 720 Mbps/lane.
Acceptance: camera.sensor-profile expects the new OV8856 geometry, the camera
covers map names src/camhealth.* (the src/cam.* glob does not match it, so the
lint would have gone quiet on an uncovered file at the next pin bump), and a
new auto test camera.frame-health asserts a burst of captures with no frames
the capture queue flagged errored - a real signal about the camera ribbon even
though those frames never reach a client.
Two pages aimed at someone who owns the machine rather than works on it,
written for the documentation site. Nothing here is new behavior - it is
the behavior the machine already has, explained where an owner can find
it instead of spread across a kernel contract, a services contract and
three driver headers.
MOTION.md follows one thread: everything physical comes out of a single
fixed-tick byte stream, so the page starts there - the byte layout, speed
as step density rather than clock, the ring and the two ways to fill it,
and the hardware's own stop, halt and resume-with-waypoint. The laser is
presented as part of that stream rather than beside it, which is what
makes the three contract rules (power before fire, no consecutive power
bytes, end dark) and the persisting duty legible instead of arbitrary.
Then geometry and limits, device ownership and the liveness check the
operator sees, and the two modes in full: GRBL from connection through
the arming sequence, the stop/pause/fault table and homing-or-not; cloud
from the preloaded pulse file through the pause backtrack, the park that
ignores the lid, and the ring's cap on job length. A comparison table and
the motion-related settings close it.
COOLING.md explains the engine as what it is - one owner of the thermal
hardware answering a single question for whichever controller runs - and
gives the reasons behind the numbers rather than just the numbers: why
the flow check heats and measures the downstream rise, why 40 percent is
the duty (below it, convection mimics flow), why the settle gate uses a
split-half mean instead of peak-to-peak, and why one bad reading is a
suspicion rather than a fault. Over-temperature, the two diagnostics
tools and when to run them, the settings, and a situation-to-response
table. The fire watch is described honestly: the lid IR channels are
first of all a photometer for the lid lamp, the gate ships watch-only,
and it is not a fire alarm.
Both pages state what is not implemented - low-temperature gates, TEC
control, a fire watch that acts, limit-switch homing - so nobody plans
around them. Constants come from the sources that own them (the feeder
contract, cool.h, the board header, the services contract), not from
prose. README links both.
Documentation only, no behavior change and no catalog consequence: docs/
is outside every layer and .md is excluded from the layer content hash.
BRINGUP had grown to 3,122 lines in which the same subject was answered
differently depending on where the reader stopped: GATE A "stays open, no
live-fire" in the phase text and closed in the campaign record, the catalog
at 24 tests in one section and 35 in another, several "bench validation
pending" headings over bodies that recorded the pass.
Split by kind rather than by age. BRINGUP (907 lines) is the present state
only - status, bench runbook, laser, lid/interlock/button policy, homing,
forgectrl, diagnostics, logging, release acceptance, the measured facts
bank, and a Next work list of the 15 items that are actually open.
CAMPAIGN-LOG (2,657 lines) takes the dated blocks verbatim, in
chronological order, and is append-only: a correction is a later entry, not
an edit. Its two reading rules are stated up front, since moved text keeps
its original "above"/"below" and its pre-split item numbers.
Facts corrected against the tree while rewriting: the liveness probe gates
at p2p 800, not 500, with the real wedge, noise and jolt figures; the panel
has seven tabs including Logs and is built from src/ui/, not ui.c; the
devserver replaced tools/mock.py; /status reports real head presence; the
estop_halts_motion opt-in is gone; core PR #999 is merged, leaving only the
step_us_min commit fork-only; the update system stands at Phase 5, the
uSDHC pads and the 2026-08-08 kernel batch have shipped, and 8 MP camera
capture is tracked here for the first time.
SAFETY and UPDATE-SYSTEM point their drill records at the log. ACCEPTANCE
records that inheritance is local: there is no import of a published
artifact, so a second bench starts from a full campaign - one of the four
open items the retired tool plan held, the rest of which are now in
BRINGUP's acceptance item.
Documentation only, no behavior change and no catalog consequence: docs/
is outside every layer and .md is excluded from the layer content hash.
SAFETY: the chain de-energizes itself behind a pause without being asked -
the charge-pump feed ends with the run, so HV_ENABLE drops with the watchdog
about half a second in, and on the resume it is back ~216 ms before the first
step (pad measurements). A pause is deliberately not a cancel: the latch stays
unlocked and the window open, which is what lets the next press resume the
job, while emission still ends because the stream stops driving FIRE. The
disarm grace counts down through a hold, so a job left paused disarms itself,
and a lid or interlock open while paused takes the cancel path - nothing
resumes past an enclosure opening.
LIGHTBURN: the two things an operator needs before pausing a cut - the resume
restarts from where the deceleration ended and accelerates from a standstill,
which M3 shows as a deeper spot and M4 mostly hides, and a job left paused
disarms itself and asks for the button again. Stop leaves the head where it
stopped; returning to the job start belongs to the lid and interlock policy
alone.
Item 16 becomes the record of what the machine does rather than a list of
what is left: both modes cancel a job on a lid or interlock open - running
or paused - and return the head to where the job started with the lid still
open; the park ignores the lid; the arm wait cancels with the reason named;
the button pauses and resumes, with the factory's backtrack and lead in cloud
mode and feed hold / cycle start in GRBL; lid_policy = hold keeps the stock
door behavior for senders that want it. Item 4's mid-job Door hold is that
policy now, and item 12's LightBurn door handling is closed by the cancel -
LightBurn never lives in Door on the default path.
The GRBL resume dwell that was left open is decided against, on measurement
rather than on the mark: the chain re-arms within ~3 ms of the resume while
motion restarts ~219 ms later, so there is nothing to cover.
The two planning files at the tree root are merged and removed, as the audit
plans were. What was durable in them lives here now: the factory's own
reaction timings on 2.6.0-2228, the pause/resume behavior of the safety chain
at pad resolution, and why the hardware button latch is what makes the armed
window honest. The factory session log they were written from is archived
under _RESOURCES/.
Item 15 gains the catalog's current shape: 35 tests after the parity work and
the sweep that merged the tests sharing a setup, with the auto tests left
separate.
Bench (image 20260817124714): motion.lid-cancel-home FAILED on a machine that
did the right thing twice. Both phases logged the cancel, the reset banner and
"returned to the job start" with grbl drift 0.000, and the counters ended at
the true rest position - but the hold phase's REFERENCE was 2.87 mm off, so the
comparison measured a transient.
The reference was sampled straight after the jogs that close the running phase,
and those wait on grblHAL's Idle. grblHAL is Idle when its planner is empty; the
kernel is still playing the stream depth and the decel tail behind that. A
counter read in that window records a position the head is only passing through
- and the resulting drift reads exactly like the failure this check exists to
catch, a move counted by grbl but never played by the machine.
So the check stays as strict as it is (it is the one that caught the stranded
ring on 2026-08-17): what changes is where the reference comes from. kernel_start()
waits for the MACHINE to be idle - the helper the motion tests already end on -
before reading the counters, and every reference capture in the lid, interlock
and live cancel tests goes through it.
Bench (image 20260817124714): motion.button-hold-resume FAILED on a machine
that did exactly the right thing. The operator paused about 7 s into an 8 s
move, so the resume had a fraction of a second of travel left; the job was
already Idle by the next poll and the test - which insisted on catching the
Run state - called it "the second press did not resume the job". Its own
evidence contradicted the verdict: the kernel counters read 2133 = 40.000 mm,
the whole move, and the position check two lines below would have passed.
Catching the state a command moves INTO is a race whenever the remaining work
is short. What proves the press was acted on is the job LEAVING the hold, so
that is what the test waits for now (wait_left_state), with the state it left
into required to be Run or Idle - not Alarm or Door - and the existing "landed
on its target" check still doing the real work. laser.pause-resume-lid-cancel
had the same shape and gets the same treatment.
Second defect, visible in the same record as "message seen: False": the driver
DOES report "button pressed - job paused", but Grbl.status_report() began by
discarding the read buffer and then kept only what followed the report, so
every asynchronous [MSG:] line that landed during a poll was thrown away. It
now consumes status reports only - stale ones included, which is what that
discard was for - and leaves everything else for drain(). Tests that assert on
what the controller said open their window with an explicit drain() before the
prompt, so the text they judge is the text from the action.
M4 scales power with speed, which is exactly what hides a restart in the cut -
the thing the test asks the operator to look at. At constant power the resumed
cut accelerates from zero under full power and leaves the deeper spot where it
picked up, which is the artifact worth seeing (and the one the plan wanted
characterized for M3 and M4).
/mode reporting the controller running means the supervisor has spawned it,
not that the listener is bound. Connecting straight away would fail the test
on that race - after an arm press and a burn - instead of on the behavior it
is about.
A sweep of the whole catalog for the overlap the drill work found. The test
applied was "do these share a SETUP", not "do these share a subsystem":
combining only pays where a human waits - an arm press, scrap, a takeover,
a mode entry, a lid choreography - and it costs failure isolation, because
the campaign inherits per test and a merged test invalidates as a unit. The
17 auto tests were left alone for exactly that reason: they cost no operator
time and separate ids give the coverage map and the domain invalidation
finer teeth.
kernel.k3-unlock + kernel.fire-abu -> kernel.fire-line
Four phases behind ONE takeover of the pulse device instead of two:
A/B/U on the FIRE line, then the mid-ramp unlock. Same HV-not-good
gate, same zero duty, same safe state on the way out.
laser.expected-stop + laser.kill-mid-fire -> laser.armed-kill
Both ways an armed job is killed, on one scrap setup: the supervisor's
expected stop (with its separate operator-judged restart) and then a
SIGKILL of the restarted controller. The second phase re-reads the pid
after the restart, so it kills the process the supervisor just spawned.
laser.lid-cancel-mid-fire + laser.pause-resume-live -> laser.pause-resume-lid-cancel
One armed burn in the order the factory uses the machine's controls:
press (pause - emission stops, latch stays UNLOCKED, armed window
stays open), press (resume), lid (cancel, reset without alarm, return
to the job start, button latch SET). One arm press instead of two.
cloud.lid-abort + cloud.interlock-abort-park -> cloud.lid-interlock-abort
Two prints in one test: the lid, then the interlock with the lid opened
during the park. The tail both share - park complete, kernel counters
back at the job start, ':cancelled', latch locked, armed window closed -
is one helper now, so both triggers are judged the same way.
Not merged, though they share code: cloud.gfhome-homing and cloud.hunt-lid-open.
Both drive Machine._hunt (gfhome.py and gfcloud.py build the same gfhardware
machine, so the lid ungating is the same lines), but each is about the opposite
value of the same variable - gfhome's homing is camera-corrected and needs the
lid CLOSED in GRBL mode, the hunt test needs it OPEN through a cloud-mode
connect. Merging would put a mid-test mode switch back into the cloud tests.
Shared live-test prologue (the arm cue, the mark job, the wait for the emission
witness) and the post-kill trail/judging are helpers now.
Host proof: 104 unit tests - the merged cloud test replays the machine's own
lid-abort excerpt twice, once with the interlock substituted for the trigger,
and three negative cases hold it honest (loop already open, a park the lid can
interrupt, a stop that is not edge-driven) - and the coverage lint at 0
uncovered across 35 tests. Catalog time 137 -> 125 minutes, 96 of it attended.
In GRBL mode the return to the job start is a G0 rapid - a few tenths of a
second over the distance the job had travelled - so an operator cannot open
the lid during it, and a step that asks them to either races the cancel (with
the lid open at cancel time the reason reads "lid opened", not "interlock
open") or lands after the park is over and proves nothing.
The park's immunity to an open lid is covered where it is real: motion.lid-cancel-home
parks with the lid open from beginning to end, and cloud.interlock-abort-park
has a ~6 s park with room for a genuine mid-park lid edge. What is left here is
the interlock as its own trigger, with its own message, and the loop still open
when the head gets back.
Catalog 34 -> 39. Every remaining bench drill of the lid/button parity work
is now a test, with drills that exercise the same path combined:
motion.lid-cancel-home also cancels from a hold - a job paused on the
button is ended by the lid, never resumed - so the
armed window and the hardware button latch stay in
agreement by construction.
motion.cancel-abort also asserts what a sender abort must NOT do: it
stops where it stopped and never returns home. The
return-to-start belongs to the lid policy alone.
motion.interlock-cancel-park (new) the interlock loop cancels like the lid,
and the lid opened during the return home does not
interrupt it.
motion.lid-policy-hold (new) the other policy: Door park, no cancel, no
return, and a cycle start finishes the move. The
setting is restored on the way out.
laser.pause-resume-live (new) the button pause/resume during a live cut:
emission stops, the latch stays UNLOCKED and the
armed window open (a pause is not a cancel), the
next press resumes and the job finishes.
cloud.interlock-abort-park (new) the same interlock/park pair in cloud mode.
cloud.pause-cancel-paths (new) the two non-finishing ends of a print, each
from the state the factory ends it in: paused on the
button then cancelled by the lid, and cancelled from
the app while running.
The shared cancel tail (reason reported, reset without an alarm, position
kept, head back at the job start with the KERNEL counters confirming it) is
now one helper, so every trigger is judged the same way.
scripts/bench/resume_dark_lead.py: samples LASER_ON, FIRE, HV_ENABLE, the
charge-pump watchdog, the button and the doors off the SoC pads at ~2 kHz
through /dev/mem, with motion dated from the kernel step counters, across a
pause and a resume. Levels are taken at idle and everything after is reported
as a change from that baseline, so no polarity assumption is baked in. Dry by
default, with --auto driving the pause and resume through ! / ~ for an
unattended rehearsal; --run live adds the dark lead between FIRE and LASER_ON,
in milliseconds and in millimeters at the job's feed.
Bench registry: argument specs can name a flag (--feed 600) instead of being
positional, and an optional argument with an empty default is left off the
command line entirely.
Host proof: 104 unit tests (20 in the cloud suite - the two new cloud tests
replay the machine's own lid-abort excerpt, with the interlock and the app
cancel substituted for the trigger, and fail for the right reasons), coverage
lint 0 uncovered across 39 tests.
The cloud job tests (lid-abort, lid-during-button-wait, hunt-lid-open,
pause-resume) run in cloud mode and leave the machine there: enter_cloud
reuses a live session (pid-scoped from the client's own websocket state
lines) and switches once from GRBL mode, declaring the change to the
baseline; nothing switches back. Each test judges the log from its own
window, the print by its own "print [id]: finished" line, and waits the
service's follow-up moves out (wait_quiet) before it ends. hunt-lid-open
restarts the cloud client through the supervisor's stop/start lever for
a fresh connect. The former switch-back is what failed the last bench run
of hunt-lid-open (409 machine is not idle: the service was still
re-finding the head after the lid closed).
The baseline is mode-aware: in cloud mode the client owns the GRBL
controller's init values, the lid lamp, and the position counters; the
mode itself is preserved unless the run declared the change
(Context.mode_changed); controller_mode is never handed back as a bare
setting (a bare write left the persisted mode out of step with the live
one). The undeclared-change restore now uses the captured state, which
the post pass never saw before.
The acceptance page gets an "Ignore prerequisites" switch (remembered by
the browser): POST /start {ignore_requires} starts a test whose requires
are unmet, and the run records the unmet prerequisites in its evidence
and log; they stay required for the release. The cloud tests' requires
no longer chain through cloud.mode-switch.
Proof: tests/test_cloud_suite.py replays the four tests on the bench's
own gfcloud excerpts (fixtures/) and the run loop's emitted pause/resume
lines under the real runner Context against a fake forgectrl; baseline
mode tests and the server override test; the whole suite (98) and the
coverage lint pass. Catalog consequence: cloud.* fingerprints move with
the module; the catalog hash moves with the requires.
cloud.lid-during-button-wait failed on a healthy machine: it counted
every "starting run" since the session start, and a cloud session runs
the connect-time hunt and several service moves before the print. The
checks now use the print's own window: runs between "waiting for
button" and the print's ":cancelled" (none allowed); the print's run is
the "starting run" after its button wait (lid-abort, pause-resume); the
lid edge timed against the stop is the last edge before the stop line
(an earlier open to place the scrap is not the one); hunt-lid-open
judges the hunt's own terminal line and refusals before it (service
moves after the hunt are rightly refused with the lid open).
cloud.lid-abort now proves the park with the machine's own counters
(cloud clears them at every job start, so a completed park reads back
at (0,0)); stale ring bytes replayed ahead of the park would not.
The lid-cancel tests (motion.lid-cancel-home, laser.lid-cancel-mid-fire)
now check that the KERNEL position counters returned to the job start,
not only grbl's MPos - grbl's drift read 0.000 while the head had not
moved. The baseline reports unplayed bytes queued in the kernel ring
(cnc/position total minus processed) as a leftover and refuses its
return jog while any exist: a run started on top of them replays them
first, which is what put the head into the rail. BRINGUP item 16 records
the bench finding and the stream-engine fix.
/mode reports controller=running at the spawn, so the reference dump
raced grblHAL's init writes and captured the supervisor's motion-probe
values (motor_lock 0, step_freq 10000, y_mode at the module default)
instead of the resting state. boot_reference() now waits for the
controller's markers (step_freq/motor_lock/y_mode at their fixed values,
bounded 20 s, GRBL mode only) plus a 1 s settle before dumping, retakes
a saved reference that shows the pre-config state while the boot is
still fresh, and marks it otherwise. Tests cover the wait, its timeout,
the non-GRBL no-op, the pre-config recognition and the retake.
The lid/button/interlock parity change (BRINGUP item 16): the arm wait
and a running job cancel on lid or interlock the factory's way, the
button pauses/resumes, lid_policy and the cloud pause tick settings.
Cloud tests cloud.* and the new motion/laser lid tests cover the
components; the python3-gfhardware / gfutilities pins move with this in
meta-openglow.
laser_lifecycle_test.py: the button toggle (press = Hold, press = Run;
the arming press is not a pause), lid and interlock cancel mid-job with
the return to the job start and no alarm, and lid_policy=hold.
Acceptance catalog: motion.button-hold-resume, motion.lid-cancel-home
(operator: travel job, lid open -> cancel message, banner, no alarm,
autonomous return, Idle at the start), laser.lid-cancel-mid-fire (live:
emission stops in hardware, cancelled, returned, armed=false, latch
locked, hardware button latch SET). covers name the switch and laser
sources explicitly.
LIGHTBURN.md: what the lid, Stop and the button now do; SAFETY.md: the
door policy and the button as a software layer; BRINGUP.md: item 16
records the whole parity change (host-proven, bench validation pending)
and items 4/12 point at it.
Four cloud-mode catalog tests driven from the app with the operator,
proven from the client's log (the same record the wire gets), forgectrl
/status and /cool/status, and the kernel latch readback:
- cloud.lid-abort (live): the lid edge reaches the controlled stop
within 60 ms, the head parks with the lid still open, the latch
relocks, the armed window closes, the print ends ':cancelled';
- cloud.lid-during-button-wait (operator): the lid at the white-button
prompt relocks and cancels; no run starts;
- cloud.hunt-lid-open (operator): the connect-time hunt runs and
completes with the lid open;
- cloud.pause-resume (live): the button pauses (stop + backtrack) and
resumes; the job completes and parks; nothing relocks or cancels.
Shared helpers enter/leave cloud mode the way cloud.mode-switch does
and return the head afterward.
laser_lifecycle_test drives the controller's button wait through the
file-backed switch source (GF_SWITCH_FILE): a press with the lid closed
arms and nothing arms before it; the lid or the interlock loop opening
during the wait cancels the job (reason reported, alarm 3, never armed).
Acceptance catalog: laser.arm-wait-lid (operator kind - no press is
given, so nothing can fire) starts a laser job, has the operator open
the lid at the white-button prompt, and checks the cancel message,
alarm 3, armed=false, the kernel latch locked, no emission, and Idle
after $X. covers names glowforge_laser.c, glowforge_switches.c and
glowforge_switch_map.h explicitly.
LIGHTBURN.md: opening the lid or the interlock loop while the button is
lit cancels the job; a press with the lid open never arms.
The panel moved out of ui.c into src/ui/{index.html,panel.css,panel.js},
bundled into index_html[] by CMake. forgectrl.panel-serves now covers
src/ui/** and checks that the served page carries no external asset
reference - the bundle is what keeps the panel a single self-contained
response, and this is where that would break. Fetch-verified and
cross-built in the build tree (bitbake forgectrl).
The forgectrl commit adds .devcontainer/, .vscode/tasks.json and
.env.example (the panel dev server replaces tools/mock.py, which was
already allowed under tools/**). None of it ships or changes target
behavior, so the coverage lint allows those paths for every component
rather than demanding acceptance coverage for editor setup.
Fetch-verified in the build tree (bitbake -c fetch forgectrl).
The remaining bench diagnostics run from forgetest's #bench tab. The
tools that also run from a LAN host share scripts/bench/gfbench.py:
GF_HOST names a remote machine (host mode, sysfs through ssh, Grbl and
forgectrl over the LAN); unset, the tool runs on the board itself
(local mode, sysfs directly, everything on 127.0.0.1), which is how the
page runs them - with GF_HOST=127.0.0.1, the panel token in GF_TOKEN
and their data files under <data>/bench/ (FORGETEST_BENCH_DATA). The
helper also reads a machine setting from forgectrl, or from the settings
file on the board while forgectrl is stopped.
Ported: pwm_sweep / pwm_hold (scope = a takeover; the latch relocked,
the write refused if FIRE or LASER_ON reads active), pwm_stream_test
(PASS/FAIL exit), flow_characterize, flow_recheck_char,
flow_warm_validate and flow_matrix (takeovers: forgectrl owns the
thermal hardware, so the page's takeover replaces the tools' own
controller stop/restart, whose command line predated the supervisor;
results and logs in the bench data directory), flow_sustained,
fan_test, temp_calibrate (dry; watch bounded in seconds; the threshold
and the coolant conversion from the shared code), flow_escalate_drill
(cool_confirm_max_s shortened through forgectrl's settings for the
drill and restored; the setting's minimum is the default budget), and
live_fire_drills (<drill> [S] [F], all six drills, host from GF_HOST,
token from the board). flow_matrix joins the registry. What stays
unported cannot run against the machine at all: the two null-sink CI
harnesses and the .puls decoder.
Runner: a scope tool runs inside the takeover wrapper; the bench
environment above is passed to every tool. Tests: test_bench_registry
(registry <-> scripts/bench consistency, every ported tool builds its
command line, every script compiles, gfbench host/local modes) and the
server test (scope tool takeover, the environment reaching the tool).
Local mode smoke-run on the bench (temp_calibrate watch, setting, token)
from /tmp, removed after.
No catalog consequence: bench tools are not image components (dev-only
forgetest); the acceptance catalog is unchanged.
The dev image installs the bench tools under /usr/share/forgetest/bench/,
so flow_matrix.py and flow_warm_validate.py invoke the board-side sampler
from there instead of a hand-copied /data/flow_sampler.py; the README
follows. Bench-tool path change only, no machine behavior involved.
A component pin bump counted as a platform change: the layer content hash
in the platform identity covered the recipe carrying the SRCREV, the
platform is folded into every acceptance fingerprint, so every image
that carried any component update invalidated the whole catalog (dev
image 20260816191951: every test domain-changed after a one-line
forgectrl bump; the two manifests differ only in
platform.layers.meta-forgefirm). The component entry already identifies
the pinned source file by file; the pin double-counted it.
Component pins now live in <recipe>-pin.inc (SRCREV and the PV that
moves with it, nothing else) - forgectrl, grblhal-glowforge and
forgefirm-app here, the BSP components in meta-openglow - and
forgefirm-image-manifest.bbclass leaves *-pin.inc out of the layer
content (FORGEFIRM_MANIFEST_PIN_SUFFIX). Recipe bodies, patches, config
fragments, init scripts and third-party pins with no manifest entry stay
layer content; a pin written into a recipe body still hashes (the safe
direction). manifest-from-tree.py mirrors the rule and reads pins
through the recipe's requires; test_tree_manifest.py proves both
(pin bump: hash unchanged; recipe body or inline pin: changed).
Bitbake resolves the same SRCREV/PV for every pinned recipe.
Docs: ACCEPTANCE.md (what layer content is), kas/README.md (the pin
files in the push order), BRINGUP.md (the finding and the bench
consequence: the first image built with the pin files is itself a
platform change, so its campaign is a full one; pin bumps inherit
after it).
No catalog consequence: nothing in the image's behavior changes; the
change is to the acceptance identity computation, proven by the unit
tests and the CI lint on the tree manifest.
Fetch-verified and built in the forge-yocto tree; the binary hot-deployed
on the bench and proven by forgectrl.settings-bounds and
motion.liveness-probe (BRINGUP).
The lid lamp now has a resting policy in forgectrl (lid_lamp_idle,
default 236, asserted at start and at every spawn), so the baseline
expects it there instead of preserving whatever level a boot left, and
forgectrl.settings-bounds proves it: resting at the setting, 256 / -1 /
'bright' refused, a new level applied to the lamp at once, the cleared
key back to the default. Clearing a key goes through the query-string
form (an empty JSON value reads as no setting).
motion.liveness-probe adds the regression the bench needed: with every
axis masked (cnc/motor_lock=15, as a bench tool may leave it) forgectrl
is restarted and its fresh probe must read MOTION OK on the first try -
the probe unmasks the axes itself - and the controller comes up with the
mask cleared. Bench 2026-08-16: MOTION OK at p2p 2047/1341 against the
800 threshold, no ladder.
The baseline's settle no longer counts 'probe verified, spawn pending' as
settled (the post pass ran between the probe's own writes and the
controller's init writes and mis-flagged motor_lock/step_freq): it waits
for the controller to be running, with a bounded allowance for a respawn
backoff.
cloud.mode-switch took the optional connect-time firmware probe file as
the evidence of a live session and failed on a bench where that check is
off; the evidence is now gfcloud's own authenticate/ws-connect lines in
the unified log after the switch, the probe recorded when present. Cloud
mode's connect clears the kernel position counters at the head's start
and its hunt homes the head to the corner: the test tells the runner the
counters were re-zeroed (Context.counters_rezeroed) and jogs the head
back by the counter-measured displacement, and hands the lid lamp back
at the level it found. cloud.gfhome-homing documents that it leaves the
machine homed at the corner.
Baseline: a displaced head is jogged back along its own path by the
kernel-measured X/Y delta through the GRBL controller (bounded 100 mm,
waits out a controller respawn backoff); Z is never touched.
wait_idle returned on a stale Idle before a just-commanded move began,
and the tests declared PASS on grblHAL's Idle while the kernel still
played the stream depth and the decel tail (the baseline caught
state=running after motion.pacing). Every motion test now ends on
forgectrl's idle (machine_idle), and wait_idle ignores an Idle inside a
short grace unless a non-Idle state was seen.
status_report re-sends '?' every 0.5 s until a report arrives: a soft
reset (^X) flushes the controller's read buffer and eats a '?' that lands
in it - the bench saw motion.cancel-abort error with no report for 5 s
after the abort while the controller answered the next '?' at once.
motion.deadman: the SIGSTOP drill waited for a running controller and
got the killed pid back before the supervisor reaped it (drill 1 already
waited for a different pid); and the forgectrl-restart drill expected the
busy controller's pid to survive the retake, but a retake under the
broker is stop-at-idle, re-probe, start a supervised controller (the old
inherited fd cannot be adopted) - the check is now: the move finished
unmanaged, supervision came back running and verified. Bench-proven
2026-08-16: kill respawn 1.3 s, hang -> underrun 0.21 s, retake at idle.
K2 streamed 1000 masked +X steps; the counters advanced although nothing
moved, and forgectrl's position display carried the offset. The stream
now runs 1000 out and 1000 back, and the counters returning to start is
a check. Bench-proven 2026-08-16: (0,0,0) before and after.
A baseline pass brackets every test and bench tool: before the run the
machine is verified against the fresh-boot idle state and anything off it
is restored; after the run - pass, fail, or abort - it is restored again.
Fixed items are the resting values the boot establishes (module defaults,
forgectrl's start-up writes, the GRBL controller's init writes) and
forgectrl's idle picture (controller running with motion verified, no
diagnostic, camera and cooling engines idle); preserved items (lid lamp
level, position counters, settings map, controller mode) are captured
before and handed back after. Deviations are leftovers: in the run pane,
in the result's evidence, and on the page - attributed to the previous run
when found before, to the run itself when found after.
forgetest takes a fresh-boot reference once per boot (within ten minutes
of boot, after the supervisor settles) as the session's resting lid-lamp
level and the check on the fixed values; the values were confirmed
against a fresh boot of the dev image on the bench (step_freq rests at
28160, the controller's default tick, not the probe's 10000).
Takeover runs capture the controller-owned kernel attributes on entry and
write them back before forgectrl restarts: the bench found the kernel
tests leaving motor_lock=15 behind, which masked the supervisor's
liveness probe - no motion by construction, a false driver-wedge verdict,
the rail-off ladder, and finally motion-fault. The takeover wrapper also
waits for the supervisor to settle on both sides (moved into baseline).
Catalog consequence: none beyond the runner; the tests' own drills are
unchanged.
forgectrl's supervisor probes motion liveness on every start - a small
head move verified by the accelerometer, with a rail-off ladder of up to
about 70 s on a dead verdict. The takeover wrapper returned as soon as
forgectrl start succeeded, so a test that followed found the machine
busy (bench: cooling.flow-verify refused with 409 machine is not idle
13 s after kernel.fire-abu), and back-to-back takeover tests stopped
forgectrl mid-probe. The wrapper now waits for /mode to settle - motion
verified, motion-fault (logged as a warning), or standby - before it
stops forgectrl and again after it starts it, giving up after 10 s when
forgectrl does not answer at all (the host, or a daemon that is down).
No catalog consequence: the takeover tests' own drills are unchanged.
forgectrl answers a started diagnostic with 202 (an async job); the
cooling.flow-verify test asserted 200 and failed on the bench before the
diagnostic ran. No catalog consequence beyond the test's own source
hash.
The routing test proves the path every logger takes and stands for the
emitters and relays in every component (fflog in forgectrl and grblHAL,
the SysLogHandler in the Python apps, the supervisor's per-controller
relay, the daemon's fifo relay, the render step): one logger daemon,
rendered rules and the effective record consistent with /logs, the tree,
the daemon's own emitter line, `logger` probes routed by program name in
the ff_line format, a stray program only in system/, kernel lines, relay
processes, nothing written outside the tree. The level-settings test
covers the validators and the configured-vs-effective / pending_reboot
contract. Both PASS on the bench with the existing logs.tree-tail-export
through the real Runner. That test's export call now uses a 300 s client:
the sanitized export took 13.9 s on the target and tripped the 10 s
default. Pin: forgectrl 4d19e9d (the sanitizer skips passes a line
cannot match; 4x faster). Coverage lint enforced: 0 uncovered.
forgectrl reveals the fuse identity only to the token AND the physical
button held, so the token alone must answer 403 with the button message.
The test now asserts the no-token refusal and the token-without-button
refusal and never fetches the identity itself (a 200 would have carried
the fuse password into the result log). BRINGUP: the bench campaign on
the flashed dev image, 7 of 24 passed so far.
scripts/acceptance-gate.py recomputes every catalog test's domain fingerprint
from /etc/forgefirm-manifest.json inside the release rootfs and requires the
committed releases/v<version>/acceptance.json to carry a matching PASS
(inherited results not core and newer than the invalidate epoch; the artifact
self-hashed; the catalog identical to the tree). release.sh runs it after the
build and stages the artifact as a release asset; FORGEFIRM_ACCEPTANCE_SKIP=1
bypasses loudly. scripts/manifest-from-tree.py builds the same manifest from
the recipe pins with git for CI and the workstation; forgetest-ci.yml runs the
unit tests and enforces the coverage lint (every manifest path covered by some
test). docs/ACCEPTANCE.md is the contract; the coverage currency rule and the
status live in BRINGUP.
A stdlib-only daemon on the dev image (HTTP :8090) that runs the acceptance
catalog against the machine from a self-contained page, keeps the append-only
result log under /data/forgetest, and exports the release artifact the gate
reads. Tests declare kind (auto / operator / live), hardware (api / takeover),
coverage globs, prerequisites, and core membership; a test's domain
fingerprint is the hash of the manifest files its globs select plus the
platform and its own implementation, so a PASS stays valid exactly while
nothing it covers changed. Campaign rules: a FAIL ends the campaign, the core
(image health, kernel latch and drills, one live emission witness) is never
inherited, invalidate-all forces a full campaign, no SKIP. Live tests need the
operator acknowledgment and the physical arm press through the controller;
takeover tests stop forgectrl for the duration with a crash-recoverable
marker; the tool never touches the laser latch.
Catalog v1: 24 tests ported from the proven bench drills with their recorded
pass criteria (image, kernel K1-K3 and fire A/B/U, forgectrl API and logs,
motion incl. dead-man, cooling, live laser, camera, update, cloud). The bench
tab lists every scripts/bench tool and runs the board-side ones as
subprocesses (takeover tools wrapped). 44 host unit tests, including the gate
verification fixtures. Installed only by forgefirm-image-dev, with the bench
scripts under /usr/share/forgetest/bench.
Every image records the identity of its build inputs. forgefirm-manifest.bbclass
gives a component recipe a per-file source fingerprint (git ls-tree blob ids,
submodules recursed; file:// sources hashed with git hash-object); the kernel
and glowforge.ko publish theirs through do_deploy because kernel-module-split
leaves the module recipe's main package empty. forgefirm-image-manifest.bbclass
assembles the entries with the platform identity - machine, modules directory,
device tree hashes, layer content hashes (meta-forgefirm and the meta-openglow
layers by content, the kas-managed layers by revision) - into
/etc/forgefirm-manifest.json and deploys a copy next to the image. Layer
revisions and dirty flags stay outside the identity, so a rebuild from an
unchanged tree keeps its content hash and the release and dev images of one
build share it. The acceptance tool and the release gate read this file.
rsyslog flagged the fallback action after the include as unreachable (the rendered rules end in stop). The forgefirm-logging init script now writes a default rules file when the render leaves none, and the shipped rsyslog.conf carries only the include.
rsyslog replaces busybox syslogd/klogd (VIRTUAL-RUNTIME_base-utils-syslog,
trimmed PACKAGECONFIG) and becomes the only log writer: the appended
/etc/rsyslog.conf sets the inputs and the ff_line format and includes
the per-logger rules that `forgectrl --render-syslog` renders from the
machine settings at boot. forgefirm-logrotate becomes forgefirm-logging:
render before rsyslog starts (S19), sweep the pre-syslog log files into
/data/forgefirm/legacy-logs once, and rotate the tree at boot and hourly
by rename + HUP instead of copytruncate. Pins bumped to the pushed
forgectrl (syslog emitter, Logs tab, export), grblHAL-glowforge (syslog
emitter) and python3-gfhardware apps (syslog handlers, capture dir);
the CI harnesses set FFLOG_STDERR=1 so failure diagnostics keep the
controller's log lines. BRINGUP carries the bench validation checklist
(Next work item 14); this is an image change and rides the next flash.
SAFETY.md and the safing figure name GPIO4_06 for what it is - the
readback of the chain's HV_ENABLE output through U24 (the factory net
label E-STOP is kept as a note); BRINGUP records the rename, the
device-tree polarity flip that makes bit 4 read as HV_ENABLE itself,
the removal of the estop_halts_motion opt-in, and the bench check for
the flash that ships it. Recipe pins move to forgectrl 801f1f3,
grblHAL-glowforge b629c18 and python3-gfhardware c3d1790 (PV 0.1.5).
Loop pull sets interlock_latch_reset + interlock_latch within one 50 ms sample and both clear on reinsert; charge_pump_alive tracks the run and falls 0.45 s after the last charge-pump pulse (matches the measured R*C); estop follows it on both edges. Note that an interlock trip parks grblHAL in Door:0 until a cycle start.
docs/SAFETY.md documents the control board's laser safing chain as
reverse-engineered and bench-verified - parts, every net with its SoC pin,
Linux exposure and polarity, the logic, and the software layers ForgeFIRM
stacks on it - with docs/img/safety-chain.svg. Linked from the README.
BRINGUP: Next work item 11 records the interlock-latch finding (an open
loop never tripped the latch; the kernel now drives INTERLOCK_LATCH_RESET)
and the charge-pump watchdog readback, both code-complete and pinned,
bench validation on the next image.
- platform_drills.py (on the board, forgectrl stopped): dead-man trip
readback, rmmod/modprobe with concurrent attr reads, decay/microstep
readback, LED sequence.
- live_fire_drills.py: ircut (S/F selectable characterization job),
expstop (armed job + POST /controller/stop, controller left stopped),
ctrlstart (separate, operator-approved resume); the token header is sent
in exact case.
- laser_lifecycle_test.py: sigterm-mid-job - SIGTERM during an armed job
must stop it, relock the latch and exit promptly.
forgectrl ed2934b, grblHAL da4c8eb, forgefirm-app (gfhardware) 6c7534a -
all pushed and fetch-verified, license population checked for the
multi-license recipes.
- kas/README.md: the real-time rationale rests on the feeder's bounded
queue depth, not ring size; the ring is 16 MiB (~84 s at 200 kHz,
~28 min at the 10 kHz cloud tick), a capacity for cloud-mode preload.
- BUILD.md, kas config, release checklist, cold-build workflow: only
forgefirm and meta-openglow (branch scarthgap) are cloned as
siblings; every ForgeFIRM source repo is fetched by pinned SRCREV.
- UPDATE-SYSTEM.md reads as the present-state design: the cloud-mode
compatibility baseline is the cloud client's configured firmware
version, not release metadata; decisions and open items listed
plainly.
- README.md states what GRBL mode still needs the Glowforge service
for (camera-referenced homing) and what runs without it.
- BRINGUP.md: generic build-host and fwup-lab references, the retained
reproductions of the no-fire drill, the System tab.
- LIGHTBURN.md: the arm-window timeouts are machine settings.
- forgefirm-image.bb describes forgectrl as the machine-services
daemon and points at the right backlog entry.
- American spelling throughout.
The host-side coolant and fan tools call the ssh on PATH, or the client
named by GF_SSH (a wrapper such as a WSL distro's ssh), instead of a
fixed wrapper. The bench README lists every committed tool and data
file, and explains how the coolant-flow fire-gate threshold was
derived from the committed flow-matrix data and how to re-run that
derivation on another machine.
Pulls in the coarse-pacing fix for parked wait-for-operator states (a
machine left in Door or Hold no longer pins ~28% of the core), plus
P10's grblHAL CI/tests and the mlockall-root-only change. Fetch-verified.
Adds pacing_test.py (dry hold/resume + CPU measurement) and records the
diagnosis and bench validation in BRINGUP.
Pulls in the inverted-doors-bit fix in the liveness probe enclosure
guard (the probe was skipping on every spawn with the lid closed and
would have moved the gantry with it open) plus the P10 -Werror warning
cleanup. Source pushed to origin/main; bitbake -c fetch verified.
Phase 5 A-1 emission witness and A-5 HV telemetry pass on live burns;
A-2 lid-IR characterized as a weak signal at 40% (gate left watch-only);
pgood confirmed unusable as a witness on this PSU. Phase 4 X-3 (0.1s
job-based disarm on M2) and G-10 (disarm counts down in Hold) pass.
Adds live_fire_drills.py (arm-lifecycle sampler over TCP + HTTP).
K1/K2/K3 pass on image 20260814223300 with software witnesses
(fire_test A/B/U reproduce the scope-pinned reference); GATE A is
closed and live fire permitted. The campaign caught the liveness
probe's inverted doors-bit guard (forgectrl 424f185, hot-deployed,
probe live-verified MOTION OK).
gate_a_kernel_drills.py stages the three remaining GATE A kernel
drills (controlled-stop deceleration floor, resume waypoint with the
latch locked, mid-ramp latch unlock) with software witnesses and the
PSU-connector scope point. lid_ir_ambient_baseline.csv is the
fire-watch ambient anchor (600 samples, lid closed, machine idle);
BRINGUP records the baseline statistics and the idle verification of
the emission/fire/HV evidence plumbing.
grblHAL CI runs the emission harness, the new armed-window lifecycle
harness, and the switch-map truth table on every push; forgectrl and
the kernel module build -Werror in CI. All three pipelines green.
Four sessions against the reported messages: arm once per job with
M5/M3 persistence and the M2 close (grace pushed beyond the horizon so
only the program-end path can disarm); a displaced sender must re-arm;
the disarm grace counts down in Hold; a blocking cooling verdict
refuses the arm. Test-the-test proven: a build with the job-based
window reverted fails at the first discriminating assertion. Wired
into the controller repo CI alongside the emission harness.
forgectrl aa99d30, grblHAL-glowforge e74f53f, and the forgefirm-app
sources at python3-gfhardware c9d602a (PV 0.1.3) - the Phase 1-9
remediation work, pushed and pinned.
- Controllers stop at K80, before forgectrl at K90: runlevel 0/6 no
longer tears down the cooling engine, fire gates, and broker while a
controller may still be executing a job.
- The grblhal/gfcloud init scripts are real emergency levers: stop
routes through the supervisor (POST /controller/stop - a bare pkill
was safed and respawned seconds later), start resumes supervision,
status exists, and the pkill fallback matches full executable paths
instead of truncated names or bare substrings.
- slotmigrate: the partition grow gets the same 2048-sector tolerance
as the filesystem branch (an exact compare rewrote the MBR at S02 on
every boot on disks where the grow cannot land on the last sector),
verifies it made progress, and the resize2fs retry is bounded at
three attempts with the counter kept on p3 itself.
- Installer: archive product/platform are verified after the signature,
and a validly signed OLDER release now requires an explicit yes
instead of installing as a silent downgrade. All predictable /tmp
paths in the installer and ffboot are mktemp now.
- release.sh rejects multiple positional versions (the last one used to
win silently) and a release without factory-era verification dies
unless explicitly bypassed; mkfw.sh refuses to pack when the public
key for the post-sign self-check is missing.
- forgefirm-logrotate: size-capped rotation (boot + hourly) for the
/data logs - a full /data breaks settings, update staging, and the
controllers own writes.
- Bench build scripts derive every path from their own location or
FF_SRC_TOP/FF_BUILD_TOP and log to mktemp files.
Emission witness, lid IR fire watch (watch-only until characterized),
faults/HV telemetry, measure-laser cleanup, and the head I2C error fix
(rides the pending image flash); bench items listed.
BRINGUP gains the Phase 4 (stale-gate cluster) record - code-complete,
host-verified, hot-deployable, bench drills listed. LIGHTBURN.md now
describes the job-based armed window: relock at program end, on a
sender change, or after the spindle-off grace (counting even in Hold).
Code-complete and host-verified across forgectrl, the GRBL controller,
the cloud clients, and the kernel module; kernel rows ride the pending
image flash, bench drills listed.
- Move the passwordless-root debug-tweaks image feature out of the
shared kas config into forgefirm-image-dev.bb, so the release
forgefirm-image built from the same config is not passwordless-root.
release.sh gains a gate that reads the built rootfs /etc/shadow and
fails on an empty root password, plus a config-level guard that
debug-tweaks is not present in the resolved kas dump. (B-1)
- The installer copies ffboot out of the signature-verified new rootfs
it already mounts, instead of fetching and executing it from a mutable
GitHub raw ref. (B-2)
- Record audit remediation Phase 2 (GATE B) status in BRINGUP.md,
including the bench pass still required to close the gate.
- LIGHTBURN.md: mandatory "Before you cut" safety section; the
walkthrough now reflects the firing machine (dry runs need the
layer output off or M5; live first-cut instructions); the homing
entry documents homing_mode and the gfcloud method; the machine
address is a placeholder.
- README.md: condensed safety section linking the full text and the
regulatory notes.
- INSTALL.md: "Regulatory and legal" section ahead of the install
steps; routine updates route through the panel updater rather than
the installer.
- BRINGUP.md: the release signing key is described as held offline
(no on-disk path); bench address and credential notes removed;
Next-work item 7 corrected (the installer embeds the production
release key); status entry for audit remediation Phases 0-1; the
GATE A kernel drills join the pending image-flash checklist.
- bench scripts: the target host comes from GF_HOST (or argv) instead
of a hardcoded address.
- laser_stream_test.py: per-session controller runs with a hermetic
cooling-verdict publisher; new assertions that every stream
terminates with FIRE clear (including M3 held to stream end) and
that no FIRE bit rides a zero-step gap; a cycle-churn session
exercises the stop/start seams.
Audit findings D-1, D-2, D-3, D-5, D-10, D-12, B-10, and the harness
half of D-4/G-1.
Add Next work item 9: the panic handler, control_12v removal, decay-mode
state tracking, module build hygiene, SDMA/EPIT/GPIO probe guards and
head_make_safe are code-complete and build-verified, with the bench check
each one still needs and a note that the batch ships with a full image
flash rather than a module hot-swap.
kas/README: drop the control_12v residue (the node is gone), state that
the buzzer driver is not part of ForgeFIRM, and record that the pre-SPDX
LICENSE strings remain only in layers this build does not use.
The controller now maps the door pair and the interlock loop onto the core's
safety-door signal, with the e-stop bit behind a machine setting. Records what
is gated and what is deliberately not, the answer to the audit's latch-reset
question, and the bench items the change still needs.
The interlock_circuit bitmask is fully mapped: b0/b1/b3 from the scope
experiment already recorded in the gate section, b2/b4 from the factory decode
the attributes were ported from, and the armed kill-mid-FIRE drills read the
mask consistently across armed, firing, idle and disarmed states. Trip recovery
was exercised in commissioning runs.
The safety-mapping item now says what is actually left of it: door and e-stop
evdev into the controller's feed-hold and halt path, plus the question of a
software reset path for the laser latch.
README: replace the stalled REST/GUI roadmap with what the firmware does -
GRBL mode over TCP for LightBurn/UGS/cncjs, optional cloud mode, the web
control panel, cameras, A/B install beside the factory firmware - plus the
supported hardware incl. the 8 MP camera limitation and a real roadmap.
kas/README: xvclk is the board's 24 MHz oscillator; the pin/push rule covers
every source repo; camera and motion sections are hardware-validated, with the
OV8856 caveat spelled out; the device-tree item closes with the control_12v
residue named; forgectrl is the machine-services daemon with two supervised
controllers.
LIGHTBURN.md: drop the pre-first-light status line.
BUILD.md: state the NXP firmware licensing and where the EULA lives on the
machine. The image now installs firmware-imx-lic, so the EULA text ships beside
the VPU/EPDC blobs it covers; a bbappend declares that package at parse time,
which is what makes it installable from an image recipe.
Record the finished shared-services state: forgectrl is the one
machine-services daemon behind both controller modes, both controllers
are cooling-engine clients, and the drill and soak coverage that proves
it. Attribute the cooling policy records to the engine's present home.
Add the remaining polish as Next work item 8 - diagnostics as engine
modes, the rail-policy remainder, cloud per-job fan profile
confirmation, /cool/status cosmetics, and button edge detection.
The runbook operational sections now describe the machine as it runs:
forgectrl as the machine-services daemon (supervisor, pulse-device
broker, motion-liveness gate, cooling engine, cameras), controllers
spawned and supervised rather than init-started, standalone driver
runs as the bench/debug exception, diagnostics suspending the active
controller through the supervisor, the cooling engine as the thermal
owner with the drivers as thin clients, and the new /mode and /cool
endpoints. A dated correction closes the no-motion record with the
DRV8825 wedge diagnosis and the liveness/homing hardening; the
cold-start section gains the first-light and shared-services
milestones.
Hardware facts from the 2026-08-11 bench session: the DRV8825 drivers
can come out of a 40 V rail power-up unserviceable (playback and
counters run, motors dead; recovery is a longer true power-off, at
worst a machine power cycle) - so counters/anchors are never proof of
motion and the rail stays up. The head accelerometer is the motion
truth (device mapping, bench-characterized thresholds, read-rate
limits), and every probe move goes +X first - a cable lives at the
end of left travel.
The controller lifecycle belongs to the forgectrl supervisor (it
spawns the selected controller as a direct child); the init script no
longer starts grblHAL and remains only as a manual emergency stop.
BRINGUP item 2: grbl-mode laser software implemented + bench-verified
without fire (record in the gate list); first light pending.
LIGHTBURN.md: arming/button-press operation, S-max 1000, fire gates.
scripts/bench/laser_stream_test.py: host-side stream-dump contract
verification against the null-sink build.
The cloud-action-surface branches are merged: forgectrl builds from main
(same revision), and the forgefirm-app include pins the python3-gfhardware
master head (adds docs/CLOUD.md and the clean-shutdown gfhome).
gfhome.py, ffmachine.py, and gfcloud.py (with the gfcloud init script
and gfhome conf sample) live in the python3-gfhardware repository's
forgefirm-app/ directory. The three recipes fetch that repo through a
shared include (forgefirm-app.inc) carrying a single pinned SRCREV,
replacing the local file:// copies. Package names, dependencies, and
installed paths are unchanged.
ws_connect() returns the running WsClient, so gfhome no longer needs to
build the client by hand to get a clean disconnect; the exit path uses
WsClient.shutdown() to stop and join the socket thread.
Points the forgectrl recipe at the pushed cloud-action-surface commit
(controller_mode=cloud + the compatibility banner) so a test image
carries the cloud-mode UI. Repoint to main once the branch is merged.
Enable the read-only firmware-version probe and point it at
/data/forgefirm/gf-latest.json, where the web-service client records the
latest firmware Glowforge advertises and the version this release was
tested against. forgectrl reads that file for the cloud-mode
compatibility banner.
gfcloud runs the machine under the Glowforge web service (the factory
cloud experience: the app drives homing, framing, printing). Its init
service starts it only when controller_mode = cloud - mutually exclusive
with grblHAL, which already stands down for that mode - so it owns
/dev/glowforge exclusively. Persistent GFUIService loop with a clean
SIGTERM shutdown that safes the hardware; the init script mirrors
grblhal.init. The image installs gfcloud and python3-ffmachine.
Extract the shared-config identity overrides and the forgectrl-routed
ForgectrlMachine from gfhome into an installed module (python3-ffmachine);
gfhome imports it instead of carrying its own copy, so the coming gfcloud
daemon builds the same machine the same way.
Record the Glowforge service/firmware version each release validated
cloud mode against, kept distinct from the version cloud mode advertises;
forgectrl reads it to warn when the live service has moved past it.
Replace the local dispatch table with gfutilities' dispatch_action
(allow_print=False), so the homing runner and GFUIService share one
action surface and cannot drift.
b5c6d9c declares the shared config readers in glowforge_homing.c;
without the declaration the hard-float build read the homing-session
timeout from the wrong register and SIGTERMed every gfcloud homing
runner ~55 ms after spawn.
Bump to the forgectrl revision whose startup pass reloads
regulatory.db (required while the flashed kernel still has built-in
cfg80211 - its boot-time load fails pre-mount and stays failed
without a reload), hints a region only when one is set (unset =
automatic: the AP's 802.11d country, else world; a 00 hint over the
kernel's own world default reports the confusing intersection alias
"country 98"), and pins wlan0 power save off.
BRINGUP.md: bench record for the wireless-regulatory diagnosis and
the live-verified wifi_country / power-save flow; the kernel-batch
note now carries CFG80211/MAC80211=m, DEFAULT_PS off, and the lm75
vs-supply.
Bump to the forgectrl revision that adds the wifi_country setting
(System-tab region picker); the daemon applies it with iw reg set at
startup and on change, so iw joins RDEPENDS. regulatory.db itself
ships via the glowforge-image base (wireless-regdb-static).
Before writing the target slot, the installer now shows what it holds
(factory firmware v<ver>, ForgeFIRM, an unrecognized filesystem, or
unknown/unreadable content). Factory images are archived as before;
anything else requires the operator to type ERASE, since it is
overwritten without a backup. The archive manifest now records the
semantic FIRMWARE_VERSION (ver=), which the update manager displays in
the restore list. Bump forgectrl to the matching GUI change.
A factory slot's displayed version is now the semantic FIRMWARE_VERSION
from /etc/build (e.g. v2.6.0-2228) rather than the build datetime in
/etc/version - what the factory itself calls the release, and what the
update-manager GUI shows for each slot. The datetime still orders
releases for the -e newest-factory selection (a monotonic counter;
mashing the semantic version's digits would misorder a major-version
rollover). Bench: slot shows v2.6.0-2228, -e picks it correctly.
/etc/forgefirm/keys ships the ForgeFIRM release-signing public key
and the Glowforge factory keyring (public keys only) - the update
manager verifies release downloads/uploads against the former and
factory archives against the latter. forgectrl SRCREV bumped to the
update-manager commit; runtime deps on ffboot, fwup, the keyring, and
curl made explicit.
The embedded pubkey is the production key from the signing ceremony;
release.sh's key-match gate now refuses any other signer. Verified:
production-signed archives pass fwup 1.16 and the factory's 0.14.2;
dev-signed archives are rejected.
The runbook no longer records the bench machine's fuse identity (a
fuse identity cannot be rotated, so a public document must never
carry it); the forgectrl pin follows that repo's history rewrite.
Proves a fresh clone builds the release image on a hosted runner
(sibling checkouts for meta-openglow and the kernel-module externalsrc,
rm_work to fit the disk budget) and publishes artifact checksums for
comparison against locally built releases. Never produces release
artifacts - releases are built and signed on the maintainer's host.
Gates (clean tree, version single-source across FORGEFIRM_RELEASE /
rootfs stamp / .fw meta-version / tag, rootfs-vs-slot size with early
warning, installer-embedded pubkey must match the signing key,
factory-era fwup verification of the packed archive), then build,
pack, sign, checksum, and stage forgefirm.fw + sha256sums.txt +
forgefirm-image-glowforge.rootfs.wic.gz with the gh publish command
(--publish runs it where gh is authenticated). release.sh --dev packs
a dev-key-signed forgefirm-dev.fw from the release rootfs for the GUI
upload path. Signing keys are always passed explicitly - no defaults.
kas/README release order and the plan doc updated to match.
The image's fstab keeps the factory slots mounted under /factory, and
busybox mount's auto-type iteration against an already-mounted ext4
device provokes a cosmetic kernel 'Can't open blockdev' for each
foreign-type claim (reproduced and pinned on the bench: ext3-typed
mount of an ext4-held device prints it; ext4-typed does not). Probes
now reuse an existing mountpoint from /proc/mounts and mount fresh
targets with an explicit -t ext4.
Documents the new installer: A/B slot install with no repartitioning,
factory archives to /data, signature-verified forgefirm.fw, ffboot
switching, offline install, and the automatic legacy-layout
migration.
The image's busybox has no blockdev applet, so the grow path silently
skipped. Sector counts now come from /sys/class/block (no external
tool); log lines also go to /dev/kmsg so migration results are visible
in dmesg after boot.
Newer factory firmware's generic /etc/fw_env.config points at the
wrong device; its per-device /etc/fw_env_mmcblk2.config is the correct
one for the eMMC environment. The read-back verify caught the failed
write and aborted before the flip, as designed.
dd|gzip runs backgrounded while the installer prints compressed MB
every few seconds (old busybox dd has no status=progress); dd's exit
status is captured through a file so a device read failure is not
masked by gzip succeeding on truncated input.
The gf_hostname override is gone (the forgectrl UI no longer offers
it): the hostname is a pure derivation of the serial - base 23 over
the factory consonant alphabet - so a gf_serial override re-derives
MACHINE.HOSTNAME and the fuse derivation stands otherwise. BRINGUP
records the panel rework (units, fuse identity, always-on position)
as offline-verified; board deploy and pin bumps held during the
firmware-upgrade bench testing.
Newer factory firmware (2024) has no /factory/imgN mounts and a
read-only rootfs, so slot probing and post-write verification mount
under /tmp, with the active slot read from the running root. The
target-slot unmount sweeps /proc/mounts (older firmware DOES mount the
slots). A failed ffboot download keeps an existing /data/ffboot
instead of aborting, so a local-.fw install works fully offline.
install-forgefirm.sh is now single-stage and never repartitions: run
from factory firmware, it archives every factory slot version plus the
recovery boot partitions to /data/forgefirm/archive (manifest with
md5s), verifies the signed forgefirm.fw against the embedded ForgeFIRM
pubkey (raw 32-byte form for the factory's fwup 0.14.2; dev key until
the production key ceremony), applies it to the INACTIVE slot with the
factory's own fwup, post-verifies the written rootfs, installs
/data/ffboot, and flips the saved env with read-back verification. The
booted factory slot stays installed and bootable; /data is untouched
beyond the archive. Fixed release asset name forgefirm.fw (version in
the fwup metadata and release tag).
slotmigrate (new recipe, rcS before mountall) reclaims the legacy
layout on eMMC-slot boots: deletes p4, grows p3 to the end of the
disk (sfdisk + partx BLKPG - works with a sibling partition as root),
then e2fsck+resize2fs. Every step is keyed off the actual disk state,
so interrupted runs resume and factory-layout disks are a no-op; SD
boots never touch the eMMC.
Basic/Plus share the passive closed-loop cooling and the 60-75 F
window per Glowforge's tech specs and owners-forum consensus; the
Pro's solid-state TEC buys 60-81 F. Spec-level only - tec_present
stays a user setting regardless, since tec_on has no readback.
Low side: factory floors (CM* window minimums ~1-4 C, the ~16 C
'warming up' operating floor) and the planned cool_temp_min /
cool_temp_start keys with a heater-driven warm-up phase. TEC:
presence is undetectable (tec_on is write-only, Pro-only hardware on
a common board) so tec_present becomes a user setting, with
hysteresis control toward the factory ~18 C setpoints when enabled.
-l inventories every bootable partition (SD, eMMC slots 1/2, legacy
p4) as machine-parsable key=value lines: firmware type
(forgefirm/factory), version, kernel presence, booted/next markers,
plus the saved-env selection - the shared probe for the installer and
the forgectrl update manager.
Boot switching now writes all four selection variables (mmcdev,
mmchwpart, mmcpart, mmcroot) in one fw_setenv -s transaction and
read-back verifies, falling back from the libubootenv script format
to the classic u-boot-tools format to per-variable writes - the same
script works on factory firmware and ForgeFIRM. mmchwpart was
previously never set and a mixed env could survive a mid-flip power
cut. Switch targets must pass a content probe (rootfs mounts, kernel
present) unless -f. -e picks the newest factory slot by probing,
excluding slots occupied by ForgeFIRM.
The new ffboot recipe installs it as /usr/sbin/ffboot with
/etc/fw_env.config (factory-identical redundant env layout at eMMC
0x80000/0x82000) - previously the image shipped fw_setenv with no
config. Added to forgefirm-image.
Slot-agnostic boot proven on the bench: the same release ext4 boots
from SD and from eMMC p4 steered by the saved env alone. fwup
cross-version compatibility proven; slot-sized rootfs, size gate,
ext4 artifact and mkfw.sh in place. Found for Phase 1: the image
ships no /etc/fw_env.config.
The release image now targets the 200 MiB factory eMMC slot: content
plus 40 MiB working space, hard build failure past the slot size. The
raw ext4 is deployed alongside the wic; scripts/mkfw.sh packs it into
a signed .fw with factory-pattern upgrade.a/upgrade.b tasks. fwup
1.16.0 recipe (applies ForgeFIRM and Glowforge-signed archives on
device) is installed in both images. Dev images stay SD-sized with a
256 MiB working margin and no ceiling.
Verified on the 20260808153331 build: release ext4 180.8 MiB; signed
.fw applies byte-exact with fwup 1.16.0 and with the factory's 0.14.2
(raw-format pubkey), and 0.14.2 -V verifies the signature.
flow_confirm_drill.py walks the driver's suspicion/confirmation state
machine through every verdict with real pump-off transients in one M8
session; flow_escalate_drill.py exercises the starved-re-check
escalation against a short GFCOOL_CONFIRM_MAX_S. BRINGUP records the
triage resolution (the 2026-08-03 faults were a real transient
stagnation, probable pump airlock - the check was right), the slug/
circulation measurements, and the new check semantics.
Every image writes /etc/forgefirm-version and echoes "ForgeFIRM
<version>" on the serial-console login prompt (/etc/issue, beneath the
OpenGlow banner) and at SSH login (motd). Release images carry
v${FORGEFIRM_RELEASE}; the dev image stamps the build timestamp - the
same DATETIME as the artifact name - tagged (dev) so a bench machine
is never mistaken for a release.
The init script consults controller_mode in /data/forgefirm.conf:
'cloud' (once that mode exists, with its own service reading the same
key) keeps grblHAL down; grbl, unset, or a missing config starts it.
Board-verified both ways: cloud in the conf leaves the controller
stopped with a clear message, grbl starts and serves normally.
forgectrl ad0b441: OpenGlow branding, operational /status dashboard.
grblHAL-glowforge c472a13: position anchor at homing for the status
readers. Runbook updated.
Non-empty gf_serial / gf_password / gf_hostname in /data/forgefirm.conf
(set from the forgectrl GF Cloud tab) are applied with set_cfg before
Machine() is built, so they beat the OCOTP fuse identity - Machine sets
its fuse values with keep_value. The --timeout default comes from
GFHOME_TIMEOUT_S when the controller provides it, so one GUI setting
governs the whole session. Docs: control-panel runbook notes.
The runner's Machine subclass fetches lid/head images from the
forgectrl snapshot endpoint - forgectrl owns the imx-media pipeline
whenever a stream client (LightBurn) is connected, so direct V4L2
grabs fail busy. Head captures request lamp=0 (the cloud's focus
analysis needs torch-off images); the HCil measure-laser LED is still
driven directly. Direct capture remains the fallback when the daemon
is unreachable.
The sample config gains the factory-board settings: the estop motion
gate stays off (the board's estop sense reads low during any motion),
and the forgectrl URL is configurable.
New recipe installing /usr/sbin/gfhome.py and /etc/gfhome.conf.sample
(copied to /data/etc/gfhome.conf on first run). The runner signs the
machine in with its fused identity, opens the WSS control channel with
a held client reference (a clean disconnect is impossible through
ws_connect), and drives the GFUIService dispatch table itself - minus
print - so the service's camera homing sequence (settings -> hunt ->
lid image -> corner move -> lid image) runs against the real hardware
Machine. The service ends the sequence silently, so completion is a
hunt plus at least one motion followed by a configurable quiet window;
the lens is then re-referenced against the hall sensor for a
deterministic Z. Lid/e-stop are checked before the session, print
actions are refused, and exit codes distinguish configuration and
connection failures from an incomplete homing.
Installed in both images; invoked by the grblHAL controller for $H
when homing_mode = gfcloud in /data/forgefirm.conf.
gitsm-pinned build of the controller with a sysvinit script (defaults 92, after forgectrl), installed in both images. Reboot-verified on the bench: controller and forgectrl come up unattended and Grbl answers on TCP:23.
The head lis2hh12 (i2c-3 0x1e, direct-I2C at ~530 Hz - st_accel sysfs one-shots are ~6 Hz and the kernel has no IIO triggers) sees rail contact as a 20-40x jolt over the creep baseline within ~4 ms. bump_seek.py: 3/3 detected hits, zero false positives over ~180 mm of creep, jog-cancel stop and back-off. BRINGUP carries the full record and the driver integration design.
Sources live at github.com/ScottW514/forgectrl (history extracted, layout src/ + init/); the recipe pins SRCREV and installs the init script from the repo. Adds the build-forgectrl.sh bench cross-compile script.
NEON kernel for the YUV420 superpixel convert (vld2q deinterleave, rounding-halving greens, mlal/rshrn luma, pairwise-add chroma block sums), bit-identical to the scalar path and proven so on a live frame via the FORGECTRL_NEON_CHECK one-shot memcmp. Convert 75 to 18 ms; the stream now runs at the OV5648 frame rate. Motion coexistence re-proven at 15 fps (clamped 0).
Demosaic the superpixels straight to planar YUV420 and encode on the CODA960 (mainline coda V4L2 mem2mem, node found by personality); libjpeg stays as the automatic fallback and the snapshot path. All camera paths now demosaic from a cached bounce copy of the frame: the V4L2 MMAP capture buffers are uncached, and reading them in-place costs ~340 ms/frame vs 43 ms memcpy + 75 ms cached convert. Per-frame stats logged every 100 frames; /cam/status reports the encoder.
A stream request for the other camera kicks current stream clients via a generation counter (their streams end cleanly; viewers freeze on the last frame) and switches. The index page retry consults /cam/status first so a preempted view does not steal the camera back.
Snapshots of the non-streaming camera no longer fail 409: the worker borrows the mux for one frame (stream viewers see a short freeze). Stream switches wait up to 3 s for the previous pin to drain. Arbitration compares the engine home camera, not the transiently borrowed pipeline camera. Index page: single toggled stream with retry plus a head-peek button.
Adds the design matrix and its supporting tools, and records in BRINGUP
what the 60-run matrix overturned: sub-40-percent duty mimics flow
(three of five dead-pump trials looked healthier than a working pump),
the operating point and threshold now rest on 25 pooled observations,
periodic re-checks are thermally free with the fans running, and the
settle gate closes a bench-proven miss. Also records what is NOT yet
validated - warm-loop baselines and behaviour under laser heating - as
first-light commissioning items.
Records why the temperature formula changed (and that everything
derived from the old one had to be re-derived), the flow
characterization data at both heater duties, the false negative that
killed the dT-threshold design, and the downstream-rise check that
replaced it. Adds flow_characterize.py to the bench kit.
The UAPI raw->Celsius formula is an explicitly unverified best guess and
every absolute coolant threshold inherits it. temp_calibrate.py records
reference points (measured temperature paired with averaged raw ADC
readings) and least-squares fits the real line per sensor, printing it
against the guess with a difference table. Modes: watch / point / fit.
BRINGUP records the gate as complete with tach-verified results;
LIGHTBURN.md documents the per-layer Air Assist toggle driving the
cut-profile ventilation; fan_test.py joins the bench kit.
No factory X/Y home switches (unpopulated IO header only). Current
sensing is a dead end (PIC attrs are setpoints; stalled steppers do
not draw more current). Primary: head lis2hh12 accelerometer contact
detection (needs IIO bring-up). Fallback: low-current soft-bump
against the hard stops. Camera homing stays a future option.
Scope on GPIO2_IO30 (the SoC FIRE drive): two 2.000 s FIRE windows,
the second terminated only by the SDMA end-of-data backstop. Measured
2.0000 s exactly with clean edges on BOTH termination paths - normal
completion and true underrun (streaming=1, kernel underrun state
reached and acked; fire_test.py gains the U mode). The backstop drops
FIRE within one tick regardless of how the stream dies.
With the waveform, stream-path, latch, topology, and interlock-
semantics results from earlier today, every standing hardware
verification gate for live fire is now passed; what remains is the
laser-milestone software and a chain-armed first-light procedure.
fire_test.py phases A/B with the scope on the PSU LASER_ON pin, power
byte 0 throughout, HV unpowered, operator-executed unlock: latch LOCKED
severs FIRE entirely (pin flat + kernel laser_enable 0 under 40k
streamed fire bits); latch unlocked with the chain unarmed shows FIRE
live kernel-side while the PSU pin stays flat - the factory board
gates LASER_ON behind OK_2_FIRE exactly like the OpenGlow AND design.
interlock_circuit semantics pinned by the 13-to-7 transition: b0
LASER_ON monitor active low, b1 FIRE active high, b3 latch 1=locked.
The 1-tick FIRE drop measurement is deferred (SoC net inaccessible;
PSU pin needs an armed chain) - documented with both closure paths.
Power-bytes-only program played by the pulse engine with the scope on
LASER_PWM: full duty staircase observed, run-start 100 percent duty
reset confirmed on the pin, consecutive-power-byte drop confirmed
(second byte of a back-to-back pair discarded), and duty persists
after end-of-data - the laser-off guarantee rests entirely on FIRE.
Zero motion (motor_lock=15 + no step bytes; position counters pinned)
and the laser chain silent throughout. Adds pwm_stream_test.py to the
bench kit and records the FIRE/OK_2_FIRE/LASER_ON/HV_EN naming from
the OpenGlow LASER SAFING sheet.
Direct PWMSAR duty steps with the machine in the locked state
(controller stopped, cnc disabled so steppers are unpowered, laser
latch locked, lid closed; laser_on_sampled stayed 0 throughout).
Measured on the pin: 25.0 us period / 40 kHz stable across the full
duty range; 50/25/75 percent confirmed visually; low end
cursor-measured 6.4 vs 6.3 percent commanded (PWMSAR=8), clean pulse.
Matches the register-level audit numbers (divider 13 x 127 counts).
Adds pwm_sweep.py / pwm_hold.py to the bench kit and records the two
remaining pre-live-fire gates (1-tick laser drop at underrun via the
stream path with FIRE observable under a locked latch; interlock
readback semantics) in BRINGUP.
Full design job (rapid / M4 cut trace / return rapid) ran smoothly end
to end on grblHAL-glowforge over TCP:23, laser locked. Records the two
driver fixes the first attempts surfaced: locked laser spindle and
continuation-wakeup cursor alignment.
Operator confirmed smooth, near-silent motion on a full observation run
(slow/fast/diagonal/zigzag jogs to 200 mm/s) under grblHAL-glowforge
with the factory-true analog config. Milestone 2 closed.
The step backend now lives in github.com/ScottW514/grblHAL-glowforge
(grblHAL driver convention: core submodule, driver.c HAL, board header).
Add build-glowforge.sh, update the BRINGUP runbook (no -t throttle,
EEPROM path, kill-before-scp, producer stats line, reset/disconnect
semantics), and teach bench_m2.py to parse the F: status field (no
spindle registered = no FS: field). Settings-write crash fix is
upstream as grblHAL/core PR 999.
Extract the factory motion profile from the _RESOURCES pulse streams
(new puls_profile.py): 700/590 mm/s2 X/Y accel on v2.6.0 firmware,
202 mm/s travel peak at the 28160 Hz travel tick, PIC currents X 135/33
and Y 22/5 run/hold, decay mode 1. Add the bench_m2.py round-trip jog +
feed-hold suite (all green 2026-08-02: sustained 200 mm/s, exact
returns, clean hold/resume, zero underruns) and update BRINGUP.md:
board moved to a fixed lease at 172.16.1.97, analog config now applied
by the sink itself, $RST=$ note for stale stored settings.
BRINGUP.md is the cold-start reference: project status (audit phases
0-5 complete, Phase 6 spike achieved - first grblHAL-commanded motion
2026-07-26), bench/board access, build+deploy procedures, the step
backend runbook incl. the required analog machine config, the measured
hardware facts bank, and the ordered next-work list. scripts/bench/
preserves the hardware-verification tools (underrun feeder, end-of-
data protocol bench, PWM register check, cross-build scripts).
ulfius builds itself with -Werror -Wconversion; Yocto Scarthgap sets
-D_TIME_BITS=64 on arm32, making a long-long-to-long time conversion
in u_websocket.c fatal. Exclude conversion warnings from -Werror.
install-forgefirm.sh completed silently broken when the download, flash
write, mount or uEnv rewrite failed - add die() checks around every
critical step (audit N13/M12) and download the release asset under the
exact Scarthgap artifact name (forgefirm-image-glowforge.rootfs.wic.gz)
so uploads need no renaming. ffboot no longer depends on the never-
provisioned /etc/fw_env_mmcblk2.config: it falls back to
/etc/fw_env.config (which both the factory and ForgeFIRM images ship,
pointing at the eMMC env) and checks fw_setenv results (audit N16).
BUILD.md gets the real artifact name and marks the built u-boot
reference-only; INSTALL.md drops the stale script/ogboot names and the
cloud-connect promise, stating the image is bring-up-only (audit N11).
The dev image required the shared glowforge-image base directly, so it
was not a superset of forgefirm-image (it lacked v4l-utils, audit N12).
Require forgefirm-image and add forgectrl + debug tooling on top.
The ulfius recipe tracked AUTOREV master and could never build: master
requires orcania/yder versions that have no release tags, no recipe for
either existed in any layer, and curl (default-ON WITH_CURL) was
missing from DEPENDS (audit M10). Pin the coherent release set - ulfius
v2.7.15 + orcania v2.3.3 + yder v1.4.20 - add the two new recipes,
add curl, convert to SPDX LGPL-2.1-or-later, and rename the misspelled
recipes-extended/uflius directory.
CONFIG_PREEMPT=y (factory behavior); PREEMPT_RT unavailable on arm32
6.12 and unnecessary for the pulse feeder - the 128 MiB SDMA ring at
1 byte per EPIT tick holds minutes of stream, so deep buffering plus
SCHED_FIFO rides out any PREEMPT-scale scheduling latency. Documents
the audit M9/N15 arithmetic next to the migration backlog.
kernel-module-glowforge and python3-gfhardware are now pushed and pinned by
SRCREV in meta-openglow (no AUTOREV anywhere), and the meta-openglow
Scarthgap port is published on its scarthgap branch. Record the full
push/release sequencing in kas/README.md (incl. the release-time flip to the
pinned-remote meta-openglow block and the release-asset naming rule), and
commit the kas lockfile pinning poky/meta-openembedded/meta-freescale(-distro)
at the revisions the working build used.
The untracked kas/externalsrc-dev.yml overlay (local python3-gfhardware via
/mnt/c) is removed: the pinned SRCREV replaces it.
v4l2-ctl (alongside the already-present media-ctl) configures the
imx-media pipeline and grabs raw frames from /dev/video4 while the
gfhardware capture path is ported off the factory NXP V4L2 model.
gfui-client connects to Glowforge's servers; ForgeFIRM replaces it with a
local grblHAL controller (forgectrl). Remove it from the image via an
override, leaving the shared glowforge-image base untouched.
Replace the repo + default.xml + setup-environment workflow with kas:
- add kas/forgefirm-glowforge.yml (Scarthgap layers pinned, meta-openglow
as a local sibling) and kas/README.md
- remove default.xml, base/conf/forgefirm-bblayers.conf,
scripts/setup-environment
- .gitignore the kas-managed layers/ and build caches
- BUILD.md: kas host setup and build instructions
Instructions for AI coding agents that work in this repository. A human
contributor is welcome to read it too: it is the same set of rules.
## ForgeFIRM repository rules
### Safety on the machine
- The laser never fires with the coolant pump deliberately off. The heater-only
flow-calibrate and flow-verify tools are the only pump-off state, and the
tube stays dark during them.
- The 40 V motor rail stays up while the machine is on. Never cycle it for a
controller handover. A rail glitch can leave the stepper drivers
unserviceable.
### The iteration loop
- Edit, cross-build in the Yocto build environment, hot-deploy to the
machine, prove. Then one commit, one push, one pin bump for the proven
change.
- A full image build happens once, at the end of a work item, never per fix.
- Kernel and BSP changes ride one image flash, batched. Validate a `.ko` or
overlay change on the image that ships it. Never hot-swap a `.ko` onto a
board that is about to be reflashed.
### Acceptance coverage
- Evaluate every change against the acceptance catalog in
`forgetest/forgetest/suite/`. Does an existing test exercise the changed
behavior? Does its `covers` map name the files you touched? If not, add or
widen one in the same change. The coverage lint
(`python3 -m forgetest.coverage --enforce`) is the floor.
- The `SRCREV` of a component and the `PV` that moves with it live in
`<recipe>-pin.inc` next to the recipe. Nothing else goes in that file. The
image manifest keeps `*-pin.inc` out of the layer content hash. Thus a pin
bump invalidates only the acceptance tests that cover that component. A
pin in a recipe body counts as a platform change and needs a full
acceptance campaign.
- Test a harness change yourself, on the host, before you hand it to the
operator for a bench run. A harness that fails on its own defect costs the
operator a full re-run cycle.
- A test declares its `mode`, and the runner switches. Cloud tests stay in
cloud mode. Keep `requires` lists minimal, and never gate one test behind a
chain of others.
- Never exercise a gate or a limit through the `GFCOOL_*` environment
overrides in a test. Use the settings API, and restore it in the teardown.
- A silence-while-armed injection and a crash during `$H` are catalog
drills, not one-off bench scripts.
### CI couplings that set the push order
- The CI of `grblHAL-glowforge` gets the laser harnesses from `scripts/bench/`
at the head of `master`, unpinned. Push a harness change here before the
driver change that needs it.
- The acceptance page shares its theme and its vendored Bootstrap with the
`forgectrl` panel, byte for byte. Land a UI change in `forgectrl` first.
Push it and pin it. Only then push this repository.
- The CI of `kernel-module-glowforge` cross-builds against the BSP from
`meta-openglow`. Push `meta-openglow` before a module change that needs a
BSP change.
### Builds and images
- Builds run only in the dedicated Yocto build environment that the Build page
of the site defines (kas and bitbake). Never use a native host build of a
component as proof, not even for a compile check.
- Every build makes both images: `forgefirm-image` (the release image) and
`forgefirm-image-dev` (the bench image, with `forgetest` on port 8090). The
dev image needs an explicit `--target`. Report both artifact names.
- After a kernel-module pin bump, rebuild the kernel and the module in the
same run: `bitbake -c cleansstate linux-fslc kernel-module-glowforge`, then
the images. The kernel version suffix is not reproducible across
re-checkouts, and a rootfs whose kernel and module disagree fails.
- Read the layer HEAD lines at the top of a build log before you trust a build
that moves a pin.
### The record of the work
This repository carries no status document and no dated log. Both were
retired: every fact about the machine and the firmware is on the
documentation site, and **the record of a piece of work is the commit message
that carries it**: what was done, how it was proven, and what it replaced, in
the same place as the change itself.
A measurement session or a bench drill is written up in the commit that
lands its result. Nothing accumulates in a file.
### On the bench
These rules apply whenever an agent works with the operator at a machine.
- **One armed run per turn.** Start the run, let it finish, stop, report what
happened against what was expected, and wait for the operator's
confirmation before any further armed run or machine command. Never chain a
jog, a fix, or a re-run onto a burn in the same step. Dry runs (latch
locked, no emission) can be batched. Say which is which before you start.
- **Zero inference during live fire.** Report only what the combined
observations confirm: the operator's eyes, the drill output, the logs, the
sysfs readbacks. Everything else is labeled "unconfirmed" and is not acted
on.
- **One Grbl connection.** Never open a second connection to TCP port 23 while
a drill, an acceptance test, or a sender holds one. The last connection
wins, the displaced client's cleanup never arrives, and its queued commands
wait behind the arm gate. Read state through `forgectrl` (HTTP) and sysfs
only. After a displaced or aborted drill: soft reset, confirm that the RX
buffer reads full, `M5`, confirm that `armed` is false, before anyone
touches the button.
- **The operator touches the machine.** The operator runs the install, the
service restart, and the reflash on the machine. Never modify the software
of a running machine unless the operator asks for that exact step.
- **Bench hygiene.** Stage test files in `/tmp`. A file that must survive a
reboot goes in `/data/bench-scratch/`, and that directory is deleted whole
at the end of the session. Nothing goes loose in `/data`. Remove everything
you put on the board, in the same session, and list `/data` before you end.
A bench tool worth a second use goes in `scripts/bench/`. The dev image
installs those tools under `/usr/share/forgetest/bench/`. Run them from
there.
## Project-wide rules
The sections above are specific to this repository. The rules below apply to
every repository of the OpenGlow ForgeFIRM project: the firmware components,
the build base, the documentation, and the hardware designs. Where a
repository rule and a rule below conflict, the safety, proof, and hygiene
rules below win. The documentation site, <https://docs.forgefirm.org/>, is
the source of truth for every fact about the machine and the firmware. Read
its Developers section before you change code.
### What ForgeFIRM is
ForgeFIRM is open firmware for stock Glowforge lasers (Basic, Plus, Pro). It
runs on the factory NXP i.MX6 control board with no hardware modification. It
replaces the cloud-dependent factory userspace with an open Linux image that
controls the machine locally. In GRBL mode the machine is a standard grblHAL
controller for LightBurn and other senders on TCP port 23. Cloud mode runs the
factory experience through the Glowforge web service, and it is a deliberate,
maintained feature. The stack is hardware validated: GRBL mode cuts real jobs,
and cloud mode runs end to end.
One controller runs at a time under `forgectrl`, the machine-services daemon,
which owns the pulse device, the cooling engine, the cameras, and the control
panel. The kernel module plays the pulse stream into the stepper drivers and
owns the laser latch and the safety readbacks. The Technical section of the
site describes the whole stack.
### The repositories
All repositories live under the OpenGlow organization on GitHub,
<https://github.com/openglow-org>. For development, check every repository out
as a sibling in one base directory named `openglow-forgefirm`. The build
scripts expect that layout. Each repository is its own git repository. The
base directory is not.
| Repository | Role | Default branch | License |
|---|---|---|---|
| `forgefirm` | The build and release base: the `meta-forgefirm` Yocto layer, the kas configuration, the image recipes, the install and release scripts, the acceptance tool `forgetest/`, the bench tools `scripts/bench/`, and the bench actuator firmware `fixture/`. It carries no project documentation. | `master` | MIT (layer metadata) |
| `meta-openglow` | The BSP layers `meta-glowforge-bsp` and `meta-openglow-core`: kernel, device tree, U-Boot, board recipes. | `scarthgap` | MIT (layer metadata) |
| `forgectrl` | The machine-services daemon (C) on HTTPS port 443, with the read-only routes on HTTP port 80: supervisor, pulse-device broker, motion-liveness gate, cooling engine, cameras, telemetry, settings, diagnostics, control panel, A/B updates. | `main` | MIT |
| `grblHAL-glowforge` | The grblHAL driver for the stock board, the GRBL-mode controller. The core is a submodule at `src/grbl`. Machine constants are in `src/boards/glowforge.h`. | `main` | GPL-3.0-or-later |
| `grblHAL-core` | The fork of the grblHAL core that the driver uses. Minimum change, no rationale comments. | `forgefirm` | The upstream grblHAL license |
| `kernel-module-glowforge` | `glowforge.ko`: the SDMA + EPIT pulse engine, the laser latch, the safety readbacks, the sensors. | `master` | GPL-2.0-or-later |
| `python3-gfhardware` | The `gfhardware` hardware library and the cloud-mode applications in `forgefirm-app/`. | `master` | MIT, with one LGPL-2.1-or-later component |
| `Glowforge-Utilities` | `gfutilities` on PyPI: the factory protocol and service layer that cloud mode uses. | `master` | MIT |
| `forgefirm-docs` | The documentation site. | `main` | CC BY-SA 4.0 |
| `openglow-serial-adapter` | The USB-C serial-console adapter for the control board. | `main` | CC BY-NC-SA 4.0 |
The full table, the license details, and the component diagram are on the
site: <https://docs.forgefirm.org/developers/>.
### Safety first, and in that order
The machine is a laser, and the operator is the safety authority at it. These
rules bind every change, in every repository, that can affect what the machine
does.
- The hardware chain is the safety boundary. Software only adds gates on top
of it. `LASER_ON` is never a bare GPIO. The kernel laser latch is locked by
default, and every close of the pulse device locks it again.
- Never defeat a switch, an interlock, or a readback, in hardware or in
software, not even for a test.
- Never change a fire gate, a thermal limit, or a safety default to make a
test pass.
- The order of work is absolute: emission and motion first, robustness and
hygiene after. Review a change that touches emission or motion for that
before anything else.
- A change that can put energy where it was not commanded gets a regression
test with the fix, in the same commit, never after.
- Position counters, homing anchors, and a homed flag are not proof of
physical motion. The head accelerometer is, and so are the operator's eyes.
- Unreadable is fail-closed. A failed read of a safety input, a state file,
or a verdict is never treated as "still running" or "keep waiting" without
a bound.
### Proof before done
A feature or fix is not complete until it is proven. The order of preference:
1. A host test that runs in CI. The Test page of the site lists the host tests
of each repository.
2. A bench drill on the bench reference, recorded in the commit message that
carries the change.
3. Documented reasoning.
Also:
- Run the host tests of this repository before every commit. Where the build
uses `-Werror`, a warning is a failure.
- A component that ships in the image is also evaluated against the release
acceptance catalog in the `forgefirm` repository (Test page, "Coverage
currency"). A behavior change with no catalog consequence gets a sentence
of justification in the commit message.
- For a component that ships in the image, the proof build is the Yocto
cross-build that the Build page of the site defines, never a native host
build.
- Report outcomes faithfully. Quote failing output as it is. Name a skipped
step as skipped. Never narrate an expected result as an observed one.
### The workflow: prove locally, push when proven
- Nothing goes to a public repository until it is proven: in the host tests
for host-only changes, on the bench for changes that alter what the machine
does.
- Iterate locally. Then make one commit and one push for the proven change.
No chains of fix-up pushes. Every push runs CI, and CI minutes are a budget.
- Commit only finished work, and only when asked. Never commit intermediate
findings, working notes, plans, audits, or status files. If a conclusion is
still moving, it is not ready for a repository. Unpushed mistakes come out
with `git reset --mixed <base>`, which keeps the working tree.
- "Commit" and "commit and build" include the push of every touched
repository, in dependency order. Never hold a push back to protect earlier
unpushed commits on the same branch. The only exception is an explicit "do
not push" for that session.
- Never push, force-push, tag, cut a release, refresh a lockfile, bump a
submodule pointer, or bump a pin unless asked.
### Pins
A firmware component reaches the image only through its pin: an exact
`SRCREV` in `meta-forgefirm` (in the `forgefirm` repository) for ForgeFIRM
components, or in `meta-openglow` for BSP components. There is no `AUTOREV`.
A commit in a component repository changes nothing on a machine until its pin
moves. The order is always: push the source repository, bump its pin, then
run `bitbake -c fetch <recipe>` to make sure that the pin resolves. Some
pairs of repositories have a CI coupling that fixes the push order between
them. The Release flow page of the site lists them, and each affected
repository names its own in the section above.
### Git conventions
- A commit is attributed to the human who is responsible for it, in both the
author and the committer fields. Never list an AI assistant as an author or
a co-author. Never add an AI attribution trailer or a "Generated with" line
to a commit message or a pull request. In a fresh clone, set the local
`user.name` and `user.email` before the first commit.
- A commit message says what changed and why, in the present tense, in
Simplified Technical English. The "why" of a change to fork code lives here
and nowhere else.
- Keep line endings LF in every file that Linux tooling consumes: patches,
shell scripts, recipes, device trees. Check the bytes after an edit from a
Windows tool. A CRLF patch breaks `do_patch`.
- Never commit build output, `__pycache__`, virtual environments, editor
files, a local knowledge graph (`graphify-out/`), captured machine data that
carries an identity, tokens, keys, or passwords.
- Never rewrite published history unless the operator asks for it.
- A pull request to an upstream repository is never automatic. The developer
decides if and when to send one. You can remind them that upstream
candidates exist, but the decision is theirs.
### Writing rules
They apply to everything: documentation, README files, code comments,
docstrings, log and console text, commit messages, issue text, and replies.
- **American English.** analyze, behavior, color, center, gray, catalog,
The acceptance campaign asks a person for about eighty small things,
and most of them are the same three: open the lid, pull the interlock,
press the button. This is the box that does those on request, so the
tool can run an operator test with nobody in the room. An ESP32-S3
DevKitC-1 on the bench network drives three relays wired into the
machine's own connectors; the acceptance tool (`forgetest`, on the
machine) asks it over HTTP and then watches the machine for the result,
the way it watches an operator's hand today.
Nothing here touches the laser's safety chain. Two of the contacts are
normally closed and sit in series with loops the chain already reads;
the third is normally open across the button input and is only ever
pulsed. With the fixture unpowered, unplugged, rebooting or crashed, the
machine is a stock machine.
## Hardware
| Part | What |
|---|---|
| ESP32-S3-DevKitC-1 | any flash size; powered from its USB port by a **USB wall adapter** (see grounds) |
| 3 × 1-channel 3.3 V optocoupler relay modules, high-level trigger | VCC, GND, IN; coil ~100 mA each |
| a 2-pin header and jumper | the button channel's enable |
| the interposer harness at the machine | bench-local; described in the project's hardware facts bank, not here |
Pins on the DevKit (all plain GPIOs, no strapping, USB, flash or PSRAM
role):
| Signal | GPIO | Relay contact | Where in the machine |
|---|---|---|---|
| lid | 4 | **NC**, in series with the lid-switch loop | energized = the loop opens = lid open |
| interlock | 5 | **NC**, in series with the interlock loop | energized = the loop opens = interlock pulled |
| button | 6 | **NO**, across the button input | energized = pressed; pulsed only, 20 to 500 ms |
| button enable | 7 | input, internal pull-up | a jumper to GND enables the button channel; no jumper, no presses |
Relay coils from the machine's 3.3 V (the modules' VCC and GND), the
three IN pins from the GPIOs above, the DevKit's GND to the modules'
GND. The modules' opto inputs share GND with their coils, so the ESP32
and the machine share a ground: power the DevKit from a wall adapter,
not from a PC, unless you want that PC's ground on the machine.
## Build and flash
The only input is `fixture.env`: the wifi network, the API key, the
hostname. Everything else is pinned (ESP-IDF v5.5, the mDNS component in
`dependencies.lock`).
./fixture.sh env # fixture.env from the example, with a fresh key; fill in the wifi
./fixture.sh build # idf.py if installed, else the espressif/idf container (docker or podman)
./fixture.sh flash COM5 # or /dev/ttyUSB0; esptool from pip talks to the board directly
./fixture.sh monitor COM5 # the log
Either USB port of the DevKit flashes; the one marked UART shows the log
too. `fixture.env` is git-ignored and never leaves the bench. A build in
the container works anywhere docker or podman runs (Git Bash on Windows
included); `pip install esptool pyserial` is the whole host-side
requirement for flashing and watching.
## What it does on the network
It joins the wifi as `forgefixture` (the `HOSTNAME` in `fixture.env`),
sends that name in its DHCP request, and announces `forgefixture.local`
over mDNS with a `_forgefixture._tcp` service. It reconnects forever and
never sleeps the radio. The API is HTTP on port 80, JSON, LAN only, no
OTA (the flash happens over USB and nowhere else).
Every request carries the key in `X-Fixture-Key`; without it, or with
a wrong one, every path answers 401 and the attempt is logged with the
caller's address.
| Request | Does |
|---|---|
| `GET /` or `/state` | identity, firmware version, uptime, the three channels' states, whether the button is enabled, the wifi link |
| `POST /lid {"state":"open"}` | energizes the lid channel (the loop opens); `"close"` releases it |
| `POST /interlock {"state":"open"}` | the same for the interlock loop |
| `POST /button {"ms":200}` | one press, `ms` clamped into 20 to 500 (200 when absent); 409 while the enable jumper is out or a press is still in progress |
| `POST /release` | every channel released |
An action answers with the state as `GET /` shows it. The fixture does
not read the machine's switches; the tool verifies every action through
| `FORGETEST_BENCH_DIR` | `/usr/share/forgetest/bench` | the installed bench scripts |
| `FORGETEST_BENCH_DATA` | `<FORGETEST_DATA>/bench` | passed to bench tools: where they keep their data files (with `GF_HOST=127.0.0.1` and the panel token in `GF_TOKEN`) |
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.