Commit Graph
617 Commits
Author SHA1 Message Date
ScottW514 965f7c3ca9 The cooling report secret: a driver harness, and two catalog tests
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.
2026-09-20 16:46:01 -04:00
ScottW514 19ce4d78d2 forgetest: the built-in extensions in setup.cloud-disabled-surface
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.
2026-09-20 15:03:05 -04:00
ScottW514 ebc4222182 forgetest: forgectrl.tokens
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.
2026-09-20 13:57:15 -04:00
ScottW514 f0ba4b9249 forgetest: motion.job and laser.recorder-dark
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.
2026-09-20 11:21:14 -04:00
ScottW514 888b47abe8 forgetest: the hand-back never moves the head across a lost counter frame
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.
2026-09-20 09:00:55 -04:00
ScottW514 520bf6023d forgetest: forgectrl.lease
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.*.
2026-09-20 08:00:45 -04:00
ScottW514 25cc42b879 forgetest: events.stream
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.*.
2026-09-20 07:14:04 -04:00
ScottW514 8856fb0099 forgetest: the motor release, the manual home, and the controller port
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.
2026-09-20 07:14:03 -04:00
ScottW514 b29bb9e023 bench: the controller port and manual home harnesses
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 '?'.
2026-09-20 07:13:31 -04:00
ScottW514 11f4c42705 forgefirm-image: install python3-asyncio
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.
2026-09-20 07:13:10 -04:00
ScottW514 08b29fee01 Release v0.0.6 v0.0.6 2026-09-19 18:35:29 -04:00
ScottW514 f306c9983f forgetest: the hand-back reads an engine hold again past the engine's next tick
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.
2026-09-19 18:16:04 -04:00
ScottW514 dc7170d0d1 forgetest: cooling.flow-under-load finds the verdict by offset, not by a tail count
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.
2026-09-19 17:45:27 -04:00
ScottW514 0ceb4abc4a forgetest: cooling.fail-tier-stop waits for the crash fault to end with its session
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.
2026-09-19 17:04:49 -04:00
ScottW514 aa628d684c kas lock: meta-openglow onto the head that drops the DHCPv6 client
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.
2026-09-19 16:22:43 -04:00
ScottW514 29b00cc056 Pin forgectrl 0.1.27 (the export carries the installer's log)
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).
2026-09-19 16:22:43 -04:00
ScottW514 d482e76402 installer: a download that resumes and retries, and an install log
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.
2026-09-19 16:20:32 -04:00
ScottW514 8232c8c9fe forgetest: image.network-boot - the boot does not wait on the network
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.
2026-09-19 15:57:42 -04:00
ScottW514 260f466400 release.sh: no slot size gate on the dev path, which is SD-only
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.
2026-09-18 18:40:19 -04:00
ScottW514 cf7b51fb32 kas lock: meta-openglow onto the pinned head with the bumped component pins
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.
2026-09-18 18:27:25 -04:00
ScottW514 e2be3ca4c5 Pin forgectrl, grblHAL-glowforge and forgefirm-app on the pushed heads
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.
2026-09-18 17:49:37 -04:00
ScottW514 cd4c176a87 Finish the x32 xy_microsteps default in the baseline test and the stream harness
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.
2026-09-18 17:47:53 -04:00
ScottW514 8850adcc5e Update release 2026-09-18 15:59:00 -04:00
ScottW514 f9f4de31c1 Fixed artifact exporter 2026-09-18 14:25:45 -04:00
ScottW514 9ddab969e0 Attribution 2026-09-18 14:22:14 -04:00
ScottW514 12e7d50746 Update contributing 2026-09-18 12:50:11 -04:00
ScottW514 b3f0cd54d9 Attribution 2026-09-18 12:30:39 -04:00
ScottW514 fbac17a485 Updated release to guard against publishing without source bundle 2026-09-18 12:26:12 -04:00
ScottW514 0eb764bf75 Added SPDX 2026-09-18 12:14:22 -04:00
ScottW514 07158e5d7f Update key prov 2026-09-18 11:21:44 -04:00
ScottW514 091702c26f Updated grblhal-glowforge license checksum 2026-09-18 10:52:53 -04:00
ScottW514 9a288b175b Track the x32 xy_microsteps default in forgetest and the bench tools
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.
2026-09-16 15:39:20 -04:00
ScottW514 147393322f CI: run the push workflows on master only, not on tag pushes
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.
2026-09-15 20:21:44 -04:00
ScottW514 7a9f655916 Release v0.0.5
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.
v0.0.5
2026-09-15 20:07:22 -04:00
ScottW514 52a91385cd Pin grblHAL-glowforge 0.1.17
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.
2026-09-15 18:52:25 -04:00
ScottW514 b17778f538 forgetest: verdict-cut holds the daemon under the report dead-man
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.
2026-09-15 18:51:59 -04:00
ScottW514 d5551d7e8a Pin grblHAL-glowforge 0.1.16
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.
2026-09-15 17:36:10 -04:00
ScottW514 41417f820b forgetest: clear a latched controller error, bound the move-start check, and follow the fail-tier restart
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).
2026-09-15 17:35:25 -04:00
ScottW514 db6015dc81 forgetest: cloud.mode-switch opens the lid behind the controller's start, ahead of the hunt
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.
2026-09-15 15:41:35 -04:00
ScottW514 0c87927388 Pin grblHAL-glowforge 0.1.15
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.
2026-09-14 20:15:43 -04:00
ScottW514 ea8a94a7d1 AGENTS.md: unreadable is fail-closed, and the drills that are catalog tests
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.
2026-09-14 20:08:14 -04:00
ScottW514 79c03641bd Lock meta-openglow at the python3-gfhardware pin
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.
2026-09-14 20:04:58 -04:00
ScottW514 ea6ac54ab3 Pin forgefirm-app 0.1.29
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.
2026-09-14 19:45:44 -04:00
ScottW514 f080d5d9cb Catalog: the cloud client's latch at the run, and a hold that never clears
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).
2026-09-14 19:45:20 -04:00
ScottW514 d465625a98 Pin forgectrl 0.1.25
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.
2026-09-14 18:44:02 -04:00
ScottW514 7089a25721 Catalog tests for the supervisor: a kill during $H, a respawn behind the lid, the fail-tier stop, the 300 ms relock
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).
2026-09-14 18:44:02 -04:00
ScottW514 d65a7aa6aa Pin grblHAL-glowforge 0.1.14
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.
2026-09-14 17:52:18 -04:00
ScottW514 70e945a949 Pin grblHAL-glowforge 0.1.13
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.
2026-09-14 17:28:48 -04:00
ScottW514 2f2a4160af Stream and motion robustness: harness rules, catalog tests, the hand-back's counter scale
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.
2026-09-14 17:28:28 -04:00
ScottW514 ea458850f0 Pin grblHAL-glowforge 0.1.12
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.
2026-09-14 16:08:17 -04:00