Commit Graph
140 Commits
Author SHA1 Message Date
ScottW514 2894269115 The deny rules: the machine itself is never a destination
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.
2026-09-20 19:53:05 -04:00
ScottW514 d627ee32f1 The extension sandbox platform: accounts, cgroups, and the deny rules
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.
2026-09-20 18:02:31 -04:00
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 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 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 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 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 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 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 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 e9d5f3c842 Laser harness rules and catalog tests for the emission gates
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.
2026-09-14 16:07:55 -04:00
ScottW514 c0d4302d73 ForgeFIRM v0.0.4
FORGEFIRM_RELEASE 0.0.4. The acceptance record that authorizes this
build: campaign c-20260912182427-e1ee on dev image 20260912180956
(manifest 8bb5b6f3), 85 tests, 85 satisfied, 0 required, exported
2026-09-12T19:21:35Z. The release notes in releases/v0.0.4/notes.md
name this the first public beta; the pipeline takes a release's
notes.md, when it carries one, in place of GitHub's generated notes.
2026-09-12 15:22:33 -04:00
ScottW514 7b8f72b632 Run the commissioning sheet as a fresh machine, and pin forgectrl 0.1.18 (the lens frame)
forgectrl f8ddb17 puts the lens frame in one place and writes the focus
window before the card's controller starts, after two commissioning
cards ended in ALARM:2 on a Z the wizard sent from one source while the
controller's Z limit stood on another. The acceptance run had passed
only because the bench's settings already held the stops from an
earlier focus run, so the run is now a fresh machine's.

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

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

The forgectrl pin moves to f8ddb17 (0.1.18); every test covering
forgectrl re-runs.
2026-09-11 12:44:43 -04:00
ScottW514 d898b5659d Give the release version its own file, outside the layer content hash
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.
2026-09-09 18:12:03 -04:00
ScottW514 64301d3221 Keep the configuration files inside /data/forgefirm; pin the three components
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.
2026-09-09 15:32:54 -04:00
ScottW514 0107c2cad4 Remove the cold-build workflow: Yocto builds on the build host only
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.
2026-09-09 13:28:05 -04:00
ScottW514 8af8b197ee Mount the rootfs read-only on both images
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.
2026-09-09 11:02:10 -04:00
ScottW514 093a7bbbde Stage the lens reference in the laser-stream harness
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.
2026-09-09 08:27:32 -04:00
ScottW514 5f19ae01cd Follow the lens reference into the harness and the catalog
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.
2026-09-09 08:06:04 -04:00
ScottW514 c2ca2686a7 Assignment 2026-09-08 16:21:37 -04:00
ScottW514 e36a322ec5 Retire BRINGUP.md and CAMPAIGN-LOG.md
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.
2026-09-08 13:29:43 -04:00
ScottW514 59c4516c00 The planner buffer depth harness; the XY microstep set is in
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.
2026-09-07 18:38:39 -04:00
ScottW514 8fc5250d6d XY microstep modes: the baseline, the catalog test and the bench tools
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.
2026-09-07 18:05:28 -04:00
ScottW514 bfb5cb27d8 The tail after Idle is one queue depth, and it does not grow
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.
2026-09-07 12:19:16 -04:00
ScottW514 79d2c07734 The Z envelope survives a settings write
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.
2026-09-07 11:02:54 -04:00
ScottW514 b0fa4ccaf5 A release publishes the source of the software it installs
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.
2026-09-07 09:49:18 -04:00
ScottW514 97287aa6a9 commissioning: the layer, the acceptance tests, the harness rule, the docs, and the bench drills
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.
2026-09-06 19:56:05 -04:00
ScottW514 db9acf9910 forgetest: witness the airflow behind the beam; harnesses carry the armed flag
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.
2026-09-03 12:26:59 -04:00
ScottW514 b3efab9c46 forgetest: the latch-unlock gate waits for the safety chain to release
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.
2026-09-02 15:39:24 -04:00
ScottW514 88ec984e28 Audit follow-through: runbook, bench tools, recipes, release tooling
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.
2026-09-02 09:51:23 -04:00
ScottW514 0f28427c22 bench and catalog: the controller's cancel messages spell canceled; the needles follow 2026-09-02 08:58:59 -04:00
ScottW514 d9a0990510 release: the first release is 0.0.1, and a beta cannot be numbered 0.1.0
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.
2026-09-02 08:08:20 -04:00
ScottW514 a2bc4233d5 bench: the stale-origin drill goes, drills report their verdict, the pgood gate follows the pin
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).
2026-09-02 08:08:19 -04:00
ScottW514 8cf2212e63 install: the factory archive is written aside and verified before it is trusted
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.
2026-09-02 08:00:51 -04:00
ScottW514 5b29a8f9d3 release: a skipped acceptance gate ships no acceptance artifact
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.
2026-09-02 08:00:51 -04:00
ScottW514 0e37b0812e acceptance: a later FAIL blocks inheritance, ffboot is a component, the units fallback is valid
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.
2026-09-02 08:00:51 -04:00
ScottW514 0ca6c4be9f bench: the stream harness pins jogs dark and the rolloff's ratio
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.
2026-09-02 07:49:36 -04:00
ScottW514 ef2a8607c2 repos: the sources live in the openglow-org organization
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.
2026-09-01 19:44:47 -04:00
ScottW514 758b17c5fe bench README: the power-good probe row names no login 2026-09-01 18:56:16 -04:00
ScottW514 64f552fc28 laser power-good: the line characterized, the kernel-drill guard, the probe, the dev image's mmap and ctypes
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.
2026-09-01 18:48:47 -04:00
ScottW514 8a9c6a9062 laser: feed hold and resume in GRBL mode, the sender-change hold
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/**.
2026-09-01 18:03:45 -04:00
ScottW514 6a48cd3969 docs: the moved documents live on the documentation site
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.
2026-09-01 15:33:17 -04:00
ScottW514 c7c96f9cbe Pin forgectrl: the crash watch; its catalog case and the drill's latch bit
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).
2026-08-31 18:47:49 -04:00
ScottW514 4a0338a03f bench+docs: IG threshold LSB is FS/256; factory HA* seed values recovered
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.
2026-08-31 18:26:58 -04:00
ScottW514 61b2913e3a bench: crash-probe drill run, coexist proven, detector is forgectrl-only
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.
2026-08-31 18:21:08 -04:00
ScottW514 765521455d bench: head-accel crash-detector de-risk drill (item 6, first step)
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.
2026-08-31 17:56:53 -04:00
ScottW514 2319735e60 bench: harden the debug-kernel drills against bench facts
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.
2026-08-31 16:42:34 -04:00
ScottW514 298abf8a12 Item 3: fold the debug kernel into the closing build
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.
2026-08-31 15:54:28 -04:00