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.
88 KiB
ForgeFIRM bring-up status & cold-start runbook
Last updated: 2026-08-26.
This is the present state of the machine, the bench runbook, the measured
hardware facts, and the authoritative list of open work. The dated record —
bench campaigns, drills, scope gates, the audit remediation, the acceptance
campaigns — is CAMPAIGN-LOG.md; come here for what is
true now, go there for how it was proven.
Read together with:
| Document | What it settles |
|---|---|
| docs.forgefirm.org | the documentation site: safety, install, usage, the machine as built, how ForgeFIRM works (the kernel module and the pulse feeder contract, the forgectrl machine-services contract, the cooling engine, the video pipeline, cloud mode), and the developer pages: build, release flow, acceptance, tests, the bench runbook |
Where the project stands
The machine works, in both controller modes, and the whole stack is hardware-validated.
- Platform bring-up: complete and hardware-verified. SDMA + EPIT pulse
playback out of a reserved DMA pool, live-fed during a run; laser PWM at
39.98 kHz;
CONFIG_PREEMPT=y; both OV5648 cameras on the mainline imx-media pipeline with VPU JPEG encode; A/B slot install, signed.fwreleases and factory restore. - GRBL mode cuts real jobs. grblHAL (the stock core plus one local fix, and the ForgeFIRM driver) speaks Grbl 1.1f over TCP:23; LightBurn drives motion, the laser and the camera stream. First light landed 2026-08-11.
- Cloud mode runs the factory experience end to end, deliberately kept and maintained: sign-in, camera homing, prints, pause/resume, cancel.
- forgectrl is the one machine-services daemon behind both modes — cooling engine, controller supervision, pulse-device broker, motion-liveness gate, cameras, telemetry, settings, diagnostics, logging, updates, web panel.
- Laser safety is hardware-first and bench-proven. The chain
(
LID_SW1 & LID_SW2 & INTERLOCK & HV_OK & supplies-OK → OK_2_FIRE,FIRE & OK_2_FIRE → LASER_ON) gates the beam; the kernel latch, the operator-armed window, the coolant fire gates and the dead-man chain sit on top. GATE A (uncommanded energy) and GATE B (control surface + release) are both closed. - Lid, interlock and button behave like the factory firmware in both modes (cancel-and-return on a lid or interlock open, button pause/resume), bench-validated 2026-08-17.
- Releases are gated by the acceptance tool (
forgetest, dev image only): a 46-test catalog, domain-scoped inheritance, an always-required safety core, a bench actuator that works the lid, the interlock and the button so most of the operator's part runs unattended, and a release gate that reads the exported artifact. The latest full campaign, on dev image20260824230512, satisfied 45 of 45 from nothing in 27 minutes (36 tests unattended) and authorizes a release; no release is cut yet.
Current bench state: dev image 20260824230512, the board resting on the SD
dev image (eMMC slot 1 = factory 2024, slot 2 = ForgeFIRM v0.1.0, archives in
/data/forgefirm/archive).
The bench
- Board: SSH
root@<machine-ip>(dev images permit passwordless root login). The bench machine is a Basic/Plus (the control board is common to Basic/Plus/Pro). Dev image (forgefirm-image-dev) on SD; BusyBox userland + python3 + gdb/strace. Serial console on ttymxc0 available at the bench. - Deploying kernels: re-burn the SD with the freshly built
forgefirm-image-dev-glowforge.rootfs.wic.gz(deploy dir below). Why this works: U-Boot (in eMMC boot0) reads the saved env at eMMC user-area 0x80000, which selects the boot device (bench board:mmcdev=0 mmcroot=/dev/mmcblk1p1= SD), then loads/boot/uEnv.txtand/boot/zImagefrom that rootfs partition — so the kernel always comes from the burned SD. Full map: "eMMC boot & recovery architecture" in the facts bank below. Module-only changes hot-swap: scpglowforge.koover/lib/modules/<kver>/extras/, thenrmmod glowforge && modprobe glowforge. NOTE: a module reload turns off the lid LED (relight via/sys/class/leds/lid_led*/target) and resets the analog configuration, and the first liveness probe after a reload can read NO MOTION until the ladder re-probes. - Module hot-swap vs kernel re-stamps: the hot-swap only loads if the
module was built against the FLASHED kernel's patch state. Any edit under the
kernel recipe's overlay (e.g.
glowforge.dts) re-stampsCONFIG_LOCALVERSION_AUTO— and the stamp does NOT reproduce by reverting the edit (the kernel patch tree is a fresh git commit eachdo_patch, not sstate-restored), so after any overlay edit the module can only ship with a full image flash. Kernel/BSP changes therefore ride one image flash, batched, and a.koor overlay change is validated on the image that ships it, never hot-swapped onto a board about to be reflashed. - Build host: a Linux build environment (a WSL2 distro works) holding the
forgefirm+meta-openglowsibling checkout (the site, Developers, "Build"); the ForgeFIRM source repos are fetched by pinnedSRCREV. Build:cd forgefirm && kas shell kas/forgefirm-glowforge.yml -c 'bitbake forgefirm-image forgefirm-image-dev'. Artifacts:forgefirm/build/tmp/deploy/images/glowforge/. - fwup lab (host): a host directory (
<fwup-lab>) holds host-builtfwup-0.14.2(factory-era) andfwup-v1.16.0underbin/and the DEV signing keypairdevkeys/fwup-key.{priv,pub}(fwup-key-raw.pub= raw 32-byte form — what fwup 0.14.2 expects; 1.x reads both). Cross-version compatibility is proven both ways (modern-packed signed archives apply with 0.14.2; modern fwup verifies and applies the factory.fw— signer key 2017-05-001.pub). The production release key is held offline by the operator — the installer embeds its public key, so releases sign with that key only. Pack releases withscripts/mkfw.sh; the full pipeline isscripts/release.sh, invoked as:FWUP=<fwup-lab>/bin/fwup-v1.16.0 FWUP_COMPAT=<fwup-lab>/bin/fwup-0.14.2 FORGEFIRM_DEV_KEY=<fwup-lab>/devkeys/fwup-key.priv FORGEFIRM_SIGNING_KEY=<release key> RELEASE_STAGING_DIR=<dir> ./scripts/release.sh <version>(the publish step needs an authenticatedgh; release.sh prints the exact command). - Bench hygiene: stage test files in
/tmp; anything that must survive a reboot goes in/data/bench-scratch/and that directory is deleted whole at the end of the session. Bench tools worth keeping live inforgefirm/scripts/bench/and ship on the dev image under/usr/share/forgetest/bench/. - Shell gotchas (cost real time): PowerShell mangles embedded double quotes
in git-commit here-strings (avoid
"in messages);wsl -- bash -c '...'eats$VARexpansions (use script files run via PowerShell, not Git Bash, which MSYS-mangles/mnt/cpaths).
Running the controller (grblHAL-glowforge on the board)
Source: the grblHAL-glowforge sibling repo — the canonical grblHAL driver
repo (github.com/openglow-org/grblHAL-glowforge, branch main): core as a
submodule at src/grbl (→ openglow-org/grblHAL-core fork, branch forgefirm = upstream
master plus one local commit, the step_us_min buffer sizing that keeps a
fortified build from aborting in settings_init; the settings-write crash fix
merged upstream 2026-08-04 as grblHAL/core PR #999). driver.c implements the
HAL; machine constants live in src/boards/glowforge.h.
The controller is spawned and supervised by forgectrl: the supervisor
starts the controller selected by controller_mode (grbl | cloud) as a direct
child, respawns it on a crash (after safing the machine), and switches modes
live via POST /mode / the Status-tab selector. The grblhal and gfcloud
init scripts defer to it (they remain as manual emergency levers, routed
through POST /controller/stop|start). The pulse device arrives as a
broker-inherited fd (GF_PULSE_FD) — the device never closes across mode
switches, homing handovers or respawns, so the 40 V rail never cycles as a side
effect — and the supervisor verifies physical motion (head-accelerometer
liveness probe) before the first controller spawn of each session.
Architecture: a wall-paced producer thread runs the core stepper ISR against a
virtual step clock (1000× machine tick) and maps step events to pulse bytes; a
SCHED_FIFO shipper feeds /dev/glowforge through a bounded queue; a recursive
core mutex stands in for interrupt masking. GFSINK unset = null-sink mode
(full engine, no hardware I/O — host testing and CI).
- Build:
bash <repo>/forgefirm/scripts/bench/build-glowforge.shin the build environment (from Windows, launch it through the WSL distro from PowerShell — Git Bash mangles/mnt/cpaths). Producesbuild-arm/grblHAL_glowforgein the checkout (-O1 -g; machine constants force-included into the core: 53.333 µsteps/mm XY @ ×8, 2.832 half-steps/mm Z, 0.417" Z travel, 12000 mm/min max, 700/590 mm/s² accel — factory-derived, seepuls_profile.py). - Deploy: move the new binary over
/usr/bin/grblHAL_glowforge(mv replaces the inode, so the running instance is untouched), then kill the running controller — the supervisor respawns it on the new binary within about a second. - Standalone start (bench/debug only — requires forgectrl stopped, since the
broker's exclusive hold on
/dev/glowforgemakes any self-open fail EBUSY):cd /data && GFSINK=/dev/glowforge grblHAL_glowforge -p 23 -e /data/EEPROM-glowforge.DAT. Env knobs:GFSINK_RATE(machine tick, default 28160 Hz = factory travel tick),GFSINK_DEPTH_MS(queue depth = feed-hold latency, default 200). Standalone, the driver opens the device itself and every takeover runs therail_settle_soff-period; under the broker it inherits the fd and skips the settle (the rail never dropped). The driver applies the full analog machine config at init either way (×8 modes, decay 1, motor_lock 8, laser latched, PIC hold currents) and swaps PIC run/hold currents around motion. Each motion run logs a producer-stats line (callbacks, µs/call, max-behind, clamped) —clampedshould stay 0. - Connect LightBurn/UGS to
<machine-ip>:23, or jog raw:$J=G91X40F1200.^Xmid-motion aborts via kernelcnc/stop(controlled decel) and raises an alarm; TCP disconnects never kill the process (the dead-man fd stays held).
Spindle $-settings take effect at controller start. The core
precomputes the S -> duty mapping once, when the spindle is enabled, and a
settings write does not re-run it: $35=16 persists to the eeprom
immediately and $$ reports it immediately, but the mapping in force is
still the one loaded at start until the controller restarts (a mode switch,
a POST /controller/stop + start, or a boot). Verified host-side: after a
runtime $35=0 the shipped duties stay floored.
Stored $-settings beat freshly baked defaults — after changing
GLOWFORGE_DEFAULTS values, run $RST=$ once on the board (settings persist
in the eeprom file in /data).
Protocol-loop pacing is fd-blocking. serial_wait() drains TX then
ppoll()s the listen/client fds with a state-dependent timeout: idle and alarm
at 10 ms (1 ms while a delay callback is pending), motion at 200 µs, and the
parked states — a completed feed hold, a parked door ajar or closed, and sleep
— at the coarse idle poll, while the motion sub-phases (Hold_Pending decel,
Parking_Retracting/Resuming) keep the tight pace. Measured on the bench:
idle 2.7 %, active move 34–35 %, parked 2.7–3.0 %. Client RX is armed only
while the ring has a full read's worth of room, so a flow-control-violating
sender is paced, not spun on.
Laser control (GRBL mode)
The real spindle lives in grblHAL-glowforge/src/glowforge_laser.c.
Per-segment spindle updates (the core's laser-mode path, on the stepper
producer thread at exact virtual-tick positions) map power and fire transitions
onto the pulse-byte grid via gf_stream_laser(), and the shipper emits them: a
power byte (0x80 | 7-bit duty, raw PWMSAR counts, 127 = 100 %) inserted ahead
of the first tick byte it covers, FIRE as bit 4 OR'd into tick bytes. The
spindle PWM is precomputed to a period of exactly 127, so computed values ARE
power bytes ($30 default 1000 → S1000 = 127).
Dose model: density, the only one. The shipper renders the
per-segment value the core computes by pinning the duty at full and
modulating the FIRE bit on a base period of laser_pulse_ticks (default
20 = 710 us at 28160 Hz, the factory's ~1.43 kHz) whose on-count is
dithered between adjacent integers with the remainder carried, so
densities finer than one tick per period average out. Every pulse is
full-power, so no commanded level can land in the tube's dead band, and
no beam-on ever dwells: the analog alternative (duty as the power byte,
continuous FIRE) fires the strike transient as a visible spot at every
turn-on whatever the power, and was removed as a product mode for it -
the rendering survives only as the host harness's conservatism
reference, selectable solely in the null-sink build.
laser_pulse_min_ticks (default 3 = 106 us) is the shortest pulse the
model emits: below it a period is skipped and its debt carried, so a
faint level arrives as fewer full-width pulses instead of stubs the
supply cannot strike - measured on the bench, a 36 us stub draws no
discharge at all, and the factory never emits below one of its 100 us
ticks. The debt is conserved, so the average density is unchanged: at
level 2 the stream goes from 444 one-tick bursts to 147 three-tick
bursts, same density to four decimals. Structurally the model is a mask
on the core's fire state and never a source of one, so emission stays
exactly where the core commanded it.
The floor is derived, never typed. laser_floor_density (default 10,
the lowest density that still marks, putting a commanded 1 % at 10.2 %
density) is loaded into $35 at every spindle precompute - boot
included - in RAM only: the stored $35 is never written, $$ reports
the floor in force, and a $35 typed by a sender is overwritten on the
spot. The arm report names it (laser armed (density, floor 10 %)), and
a floor of 0 is honored with a note (the ladders run that way). The
cooling report carries the model with the job state.
S commands light, through the measured curve. The tube's output is
convex in pulse density (this bench, 2026-08-30, by the head thermopile,
the tube current and the operator's eye: 80 % density delivers about
half the CW light, 60 % a third, 45 % a fifth, 30 % a fourteenth - the
same physics behind the factory's 18.9 to 79.5 % mapping with Full
Power kept apart). So the driver maps the commanded fraction through
the measured curve's inverse onto the density that delivers it:
laser_dose_curve in the machine config holds density:light percent
pairs, ships with the bench-measured default compiled in, accepts
off for the identity, falls back loudly on a bad value, and is
reloaded at every precompute with the arm naming it (laser armed (density, floor 10 %, curve bench-default)). $35/$36 still floor
and ceil the result. Under M4 the curve makes delivered light exactly
proportional to velocity - and heat still accumulates where the head
slows, so corners over-burned on the bench; laser_corner_gamma
(default 2, range 0.25 to 4) bends the rolloff to light proportional to
(v/v_programmed)^gamma, starving the slow spots the way the raw convex
mapping used to by accident, while the programmed level at speed stays
exactly the curve's. The knob is per machine and per taste: this bench
runs 1.5, and the commissioning side-by-side chooser (the "Initial
commissioning" item under Next work) is how a machine finds its own. An owner measures their own curve from the panel:
the dose-curve recorder streams the ladder job itself from one Record
press (absolute from X0 Y0, refused while a sender is connected; the
operator's button press starts the fire with every arm gate standing),
records the tube current and the head thermopile, fits the rungs, and
Apply writes the result (forgectrl). Rasters hold their tonality down
to ~14 pulse slots per pixel (508 DPI at 6000 mm/min): the dither
accumulator's cross-pixel averaging recovers the levels, with no
visible dither pattern.
An S word takes effect whether or not motion is in progress. Per-segment updates carry the level inside a laser block, but an S executed between blocks - with the planner drained, so nothing is streaming - arrives only through the synchronous spindle path, which publishes the duty without touching the fire state; and the next run re-asserts the laser state the core last asked for at its first byte, fire only inside an armed window. Without those two a standalone S from a sender slow enough to drain the planner left the following moves cutting at a stale duty, or dark.
Contract rules enforced structurally: a power byte leads every kernel run before any fire bit (a run start resets duty to ~100 %), transitions are coalesced per tick so power bytes are never consecutive, and power bytes cost no machine tick. Fire only ever rides motion segments of laser blocks — jogs, G0 and homing are fire-free by construction — and the end-of-data backstop covers every stream end. Duty persists after end-of-data (PWMSAR retains its last value): the laser-off guarantee rests entirely on FIRE.
Arming — the operator's button press is required. The first laser-on of a
job (M3/M4, planner-synced) refuses outright if a coolant fire gate stands or
if no head is detected (ALARM:3, "laser fire blocked: no head detected"),
else forces the run fan profile on, unlocks the kernel laser latch, lights the
button white and blocks the gcode stream — pumping real-time traffic — until
the operator presses the physical button (EV_SW bit 2), a soft reset aborts, a
lid or interlock open cancels, or laser_button_timeout_s (default 300 s)
expires into alarm 3. The coolant verdict is re-checked immediately after the
wait, before the window opens. The armed window survives S changes and M5/M3
toggles (no re-prompt mid-job) and closes — relocking the latch — at program
end (M2/M30/%), when the sender connection changes, after
laser_disarm_s (default 60 s) of spindle-off idle, or immediately on
alarm/homing/reset/stream fault. The disarm grace counts down in Hold, Door and
Tool Change too. Both keys live in the shared machine config, re-read per arm.
Underrun policy while armed: fail safe, no retry. The stop/run recovery restarts the kernel run, which resets duty to ~100 %, so replaying queued fire bits would fire at full power: an armed underrun acks the kernel and faults (alarm, latch relock, homing anchor unlinked). Motion-only streams keep the one-shot retry.
Coolant fire gates live (gfcool_fire_ok): a flow FAULT or an over-ceiling
coolant temperature blocks arming and suppresses fire mid-job with a loud
warning. While armed, the run fan profile and flow interrogation are forced on
regardless of the sender's M8/M9; a SUSPECT/FAULT verdict inside an armed
window takes the safe posture (feed hold + run airflow). SUSPECT auto-resumes
on a clean re-check; FAULT leaves the hold and the gate for the operator.
The controller publishes its state for the daemon. forgectrl can
never open the Grbl socket (a connection displaces the sender), so the
controller writes two files under /run/forgefirm, atomically, on
edges: grbl.settings (the $$ view, rewritten on every setting
change and whenever the derived floor moves) and grbl.state (JSON: machine state
and alarm, the sender session with peer and generation, the laser's
armed window and dose model with its floor, the exact [GC:...] modal
report, overrides, driver version, ts_mono for age; on change plus a
5 s heartbeat). forgectrl echoes the state file in GET /status as the
grbl block only while it supervises a live GRBL controller, serves
the settings file at GET /grbl/settings, and the panel's GRBL card
renders it. Position stays out: it changes per segment and is served
from the kernel counters. Contract: forgectrl.
Emission evidence. cnc/laser_on_sampled (surfaced as /status
laser.emission_samples) is the reliable live-emission witness; emission
sensed with no armed window relocks the latch and stops motion. pic/hv_current
is the only live HV telemetry on this PSU. cnc/laser_pgood is the supply's
power-good, driven high the whole time the supply is healthy (it follows
neither HV_ENABLE nor emission): a supply-fault witness, never an emission
witness (facts bank "Emission and HV witnesses").
Lid, interlock and button policy
Both controller modes react the way the factory daemon does (decoded from a factory 2.6.0-2228 session; measured numbers in the facts bank).
- Lid or interlock open during a job, running or paused: motion stops within milliseconds of the edge, the job is canceled and not resumable, the head returns to the position the job started from with the lid still open, the kernel latch relocks and the armed window closes. The return-home park ignores the lid and always runs to completion. The next job re-arms with a button press — the same press the hardware button latch needs, so software and hardware agree by construction.
- During the pre-run button wait: a lid or interlock open cancels the job with the reason named (clean soft reset, no alarm); a press with the lid open never arms.
- Ignored: a lid open during a hunt, homing, a jog, or at idle.
- The button pauses and resumes a job. Cloud mode uses the factory's
laser-off backtrack and resume lead (
cloud_pause_backtrack_ticks2000 /cloud_resume_lead_ticks1950), on a preloaded job and a live-fed one alike: the retrace is sized tocnc/max_backtrackand the lead follows it, so a pause with little history behind it shortens both rather than failing. GRBL mode uses feed hold / cycle start: the deceleration runs lit and velocity-scaled, the dwell is dark, and the resume is lit from its first step, so a pause is a sharp corner in time (facts bank "Feed hold and resume in GRBL mode"). A pause is not a cancel: the latch stays unlocked and the window open across it. There is no resume dwell: the safing chain re-arms ~216 ms before the first step (facts bank). A pause that outlives the disarm grace closes the window; the next cycle start (~, the button, or the cooling client's auto-resume) then re-arms first: the button lights and the press resumes the job. - A sender change while a job runs holds the job and closes the window (the consent belonged to the displaced session), so the next sender finds the cut in Hold where it stopped, with a short dark deceleration behind it, and resumes it through the same re-arm, or resets it.
lid_policy = holdselects stock grblHAL door behavior instead (park in Door, cycle start after the lid closes resumes with position intact).
Switch mapping (grblHAL-glowforge/src/glowforge_switches.c; the controller
reads EV_SW with EVIOCGSW from the protocol thread's realtime hook, no grab):
doors = bit 3 (the series combination the safety chain itself uses), remote
interlock = bit 5 (inverted sense), button = bit 2. The door signal is hidden
from the core while it is IDLE, JOG or HOMING (gfsw_visible) so a lid cycle at
idle cannot park the controller in Door:0 and leave a sender waiting.
hv_enable (bit 4) is never gated on — it is the readback of the chain's
HV_ENABLE output, telemetry only. The interlock latch (bit 6) is not gated on
either — the hardware chain enforces it. No switch device (host builds) = no
capability advertised. GF_SWITCH_FILE is the file-backed EV_SW word that lets
null-sink builds drive these edges in CI.
Homing
Runtime-selectable through homing_mode in /data/forgefirm.conf (forgectrl
GET/POST /settings, panel selector); the driver re-reads the file on every
$H:
gfcloud— factory camera homing via the Glowforge web service. Live verified; a full cycle runs in 50–65 s.switches— the planned limit-switch cycle (falls through to the core, still disabled$22=0).none—$Hrejects with error 5.
Architecture: glowforge_homing.c registers a driver $H that shadows the
core's; for gfcloud it suspends the stream engine (only from a fully idle
kernel — closing the flock'd fd mid-program is an e-stop), spawns
/usr/sbin/gfhome.py (config /data/etc/gfhome.conf, first-run copy from
/etc/gfhome.conf.sample), pumps the protocol so senders keep getting status,
then reacquires the device and re-applies the analog config and step_freq.
^X aborts the session (SIGTERM → SIGKILL); failure or timeout queues
ALARM:18 like a failed core cycle (gfcloud_home_timeout_s, default 300).
The runner drives the GFUIService dispatch itself (the stock run() loop can
neither stop nor close the socket) and treats hunt + ≥1
accelerometer-witnessed motion window + quiet (10 s) as complete — the modern
v2.6.0 sequence, captured from a live service session, is settings → hunt → lid_image
→ single corner move → lid_image → silence. It then re-homes
the lens against the hall for a deterministic Z. A quiet service without an
accel-witnessed motion window is a failure, not a homing.
Position semantics: factory home = machine origin (back-left corner, +Y =
FRONT, workspace all-positive 0..495 × 0..279); Z top-of-travel = 10.6.
gfcloud_home_x/y/z calibrate the post-home coordinates once measured
(defaults 0 / 0 / Z max). GRBL mode permits unhomed cutting — position shows
counters-only and painted red until anchored.
The machine-services daemon (forgectrl, port 8080)
Source: the forgectrl sibling repo (github.com/openglow-org/forgectrl, branch
main, MIT). It is the ForgeFIRM machine-services daemon: controller-mode
supervision, the pulse-device broker, the motion-liveness gate, the
cooling engine (single owner of fans/pump/TEC/heater for both modes), plus
cameras, telemetry, settings, diagnostics, the web panel, updates and the
logging tree. It runs under a respawn wrapper (its init script); a
restarted daemon retakes supervision once the machine is idle — an unmanaged
controller left running mid-move is replaced at idle, not adopted (the old
inherited fd cannot be taken over). The meta-forgefirm recipe pins its SRCREV
in forgectrl-pin.inc (bump deliberately after pushing) and installs the
sysvinit script from the repo's init/; bench builds cross-compile with
forgefirm/scripts/bench/build-forgectrl.sh. The machine-services
contract — EV_SW switch map, sensor conversions, hardware single-writer
ownership, cooling channels, mode supervision, pulse-device ownership, logging
— is forgectrl on the documentation site.
Every state-changing endpoint requires the first-boot bearer token in /data
(embedded in the panel), a Host address-literal check, and
Sec-Fetch-Site/Origin validation (CSRF and DNS-rebinding refusal).
/cool/state is loopback-only. /fuse-identity and unsigned-firmware installs
additionally require the physical button held.
One ulfius daemon serves it all:
GET /— the tabbed control panel (Status / Machine / GF Cloud / GRBL / Diagnostics / Logs / System; sources inforgectrl/src/ui/,index.html+panel.css+panel.js, bundled into the binary byembed.cmake;tools/devserver.pyand the repo's.devcontainer/serve the same panel on a workstation against a live board or a mock). Status carries the controller-mode selector, the operational dashboard, a scaled lid snapshot and an on-demand live stream; System carries A/B slot selection, ForgeFIRM updates, image install/restore, the wireless regulatory region and reboot. All settings controls disable (with a banner) while the machine is not idle or a diagnostic is running./?action=stream|snapshotremain the mjpg-streamer-compatible aliases (lid camera; LightBurn uses the stream one).GET /status— motion state and true machine position (kernel step counters anchored at homing via/run/grblhal.homed— the Grbl socket is never polled, a connection there displaces the sender), coolant temps, pump/TEC, all four fan tachs, the sensed laser evidence (emission samples, HV current, lid IR), faults, the safety switches viaEVIOCGSW(head = real presence, i.e. the head sysfs group exists), and adiagflag for the UI lock.GET/POST /settings— the shared machine settings store (/data/forgefirm.conf, 0600, validated keys, empty-value-clears via query params;gf_passwordwrite-only). Writes 409 unlesscnc/stateis idle and 409 while a diagnostic owns the hardware; a multi-key POST lands as one atomic replace. Keys:controller_mode,homing_mode,gfcloud_home_x/y/z,gfcloud_home_timeout_s,gf_serial,gf_password,ui_units,wifi_country, the ninecool_*tunables,laser_button_timeout_s,laser_disarm_s,rail_settle_s,lid_lamp_idle,lid_policy,cloud_pause_backtrack_ticks,cloud_resume_lead_ticks, the twelvelog_<logger>_disk|_remotelevels andsyslog_server|port|proto.cool_fire_ir_deltais a hand-edited conf key, not a panel setting.GET /mode,POST /mode?controller=grbl|cloud— the supervisor: current mode, controller state (running | stopped | standby | motion-fault), pid, and the motion-liveness verdict (verified | unverified | fault); the POST is the live idle-gated mode switch and the retry lever after a motion fault.POST /controller/stop|start— the routed emergency levers the init scripts use. Stop writescnc/stop+cnc/laser_latch=1before the SIGTERM (kernel-level, instantaneous) and holds supervision suspended.POST /cool/state(job-state reports from the active controller, level- triggered ~1 Hz) andGET /cool/status(engine phase, verdict, temps, report age). The verdict the controllers enforce is the/run/forgefirm/cooling.statefile.POST /diag/flow-verify|flow-calibrate|abort,GET /diag/status— the diagnostics runner (below).GET /cam/stream?cam=lid|head— multipart MJPEG at half the sensor's frame in each axis, 1296×972 on a 5 MP machine (2×2 Bayer-superpixel demosaic, JPEG q75;FORGECTRL_STREAM_Qoverrides,FORGECTRL_STREAM_FPScaps the frame rate, unset/0 = sensor max).GET /cam/snapshot?cam=lid|head&res=full|half&q=1..100— single JPEG, default the sensor's full frame, 2592×1944 on a 5 MP machine (own MIT bilinear demosaic).GET /cam/status— JSON (running/cam/clients/frames/fps/fps_cap/encoder/ buffers/sensor, the stream + snapshot geometry the fitted sensor implies, and the privacy gate'scapture_allowed/stopped_by_lid). Stream and snapshot answer 409 while the lid is open.GET /slots,POST /boot,POST /update/check|download|apply|upload,GET /update/status,POST /restore/factory,POST /system/reboot— the A/B update manager (install and update). Upload is auth + idle + job gated; a booted-slot write is refused under anyroot=spelling.GET /logs,GET /logs/tail,POST /logs/export— the logging tree (below).GET /fuse-identity— serial, derived hostname and the SRK password, behind the token AND the physical button; fetched on demand only.
Panel conventions: the header identifies the machine by its fuse
identity (the factory hostname derived from the OCOTP serial), regardless of
any cloud identity override. Units are a display-only preference
(ui_units): the backend stores metric, and saves post only fields whose
display string changed. Position always shows — counters-only and painted
red while unreferenced, normal once anchored.
Camera engine. One worker owns the V4L2 node persistently (media-ctl /
v4l2-ctl sequences identical to gfhardware/cam.py, factory exposure/gain/WB,
software hflip in the demosaic); it starts on demand and tears down fully after
10 s idle so gfhardware one-shot grabs still work. Privacy gate: neither
camera captures unless the lid is closed — machine_lid_closed() (EV_SW
bit 3, fail-closed) is checked at every entry point and once per frame, so an
open lid refuses stream and snapshot with HTTP 409 and a lid opened mid-capture
tears the pipeline down; gfhardware.cam.capture() enforces the same rule for
the cloud client's direct-V4L2 fallback and raises LidOpen. No setting
disables it, and the factory's lid-open focus hunt now fails as a result
(the video pipeline). Geometry, Bayer depth and
the manual control set come from a sensor profile chosen by whichever
driver bound on that camera's I2C bus, so one image serves both the 5 MP
OV5648 (2592×1944) and the 8 MP OV8856 (3264×2448) — both 8-bit BGGR, so the
capture word and the demosaic are the same and only the geometry changes;
/cam/status reports the model and the frame sizes that follow from it.
A frame the capture queue flags errored is dropped rather than demosaiced,
four in a row cycle the queue, and three cycles with no usable frame stop the
engine; /cam/status carries the running health counts (src/camhealth.c,
host test camhealth_test). The cameras share the
hardware video-mux and the NEWEST request wins it: streams preempt (the
current stream's clients end cleanly), snapshots borrow (pause, switch,
grab one frame, switch back — a ~1–2 s freeze). The per-camera lamp
(pic/lid_led / head/white_led) is raised to FORGECTRL_LAMP (default 132)
while capturing and restored to the resting level on idle. The resting lid lamp
is the lid_lamp_idle setting (0–255, default 236), asserted at daemon start,
on a live settings change, and at every controller spawn.
Measured performance (bench): 15.0 fps sustained at 1296×972, sensor-
limited — NEON superpixel→YUV420 convert (18–20 ms) plus CODA960 VPU JPEG
encode (7 ms) on cached (non-coherent) V4L2 capture buffers, so there is no
bounce copy; daemon ~41 % CPU with one viewer. Full-res snapshot 2.4 s warm /
2.7 s cold. Fallbacks, each bench-verified: FORGECTRL_NO_CACHED_BUFS (bounce
copy, needed on a kernel without the allow_cache_hints patch — detected via
the MMAP_CACHE_HINTS capability bit), FORGECTRL_NO_NEON (scalar convert,
bit-identical), FORGECTRL_NO_VPU (libjpeg; also the snapshot path).
The default path is newer and bench-proven: the GC880 GPU demosaic feeding the
/cam/h264 CODA960 H.264 stream (render, IPU stride-fix crop, VPU encode;
~14 fps for one viewer at ~14 % CPU, luma bit-clean against the CPU path) with
CSI hardware frame skip as the low-CPU setting; camera.h264-stream covers
it in the acceptance catalog. FORGECTRL_NO_GPU / FORGECTRL_NO_H264 /
FORGECTRL_NO_HW_SKIP strip them individually, each falling back to the
measured paths above.
/cam/status reports encoder and buffers. A CSI glitch frame can out-size
the coda driver's default JPEG capture buffer, so forgectrl requests 3 B/px and
drops error-flagged dequeues as single bad frames. LightBurn consumes the
stream directly while jogging from the same session; motion coexistence is
proven (clamped 0, max behind 4.5–7.2 ms of the 200 ms queue at 15 fps).
Run by hand: /usr/bin/forgectrl & after /etc/init.d/forgectrl stop (kill
before scp when redeploying — text-file-busy). It logs through syslog
(/data/log/forgefirm/forgectrl/forgectrl.log; a terminal, or FFLOG_STDERR=1,
echoes the lines).
Wireless region. wifi_country (System tab, full ISO 3166-1 alpha-2
dropdown, default 00 = world) is applied with iw reg reload + iw reg set
at daemon startup and on every change; the same pass pins wlan0 power_save off. With the regulatory db loaded and no user hint, cfg80211 follows the AP's
802.11d country IE; a user-set region overrides it. The startup pass hints a
region only when one is set (hinting 00 into the default world domain makes
cfg80211 report the confusing country 98 alias).
Diagnostics (forgectrl-owned hardware tests)
The Diagnostics tab runs tools that take the hardware over: the runner
(diag.c, one slot) suspends the active controller through the supervisor
(launch is gated on cnc idle + no diagnostic), drives the loop directly through
sysfs, and resumes the controller on every exit path (completion, tool error,
operator abort via POST /diag/abort, safety ceiling). The cooling engine
suspends its own writes for the duration and publishes fire-blocked.
/run/forgefirm-diag.active marks the ownership, and forgectrl startup recovers
a stale marker. The laser is untouched throughout (latch stays locked). While a
diagnostic runs, settings POSTs 409, /status reports diag:true, and the
panel locks with a banner. Live progress streams through GET /diag/status.
Both cooling tools run at the configured duty/window/threshold, with cut-profile chassis fans (the characterization condition); pump-off windows hard-abort at 48 °C downstream:
- flow-verify (~3 min): one check with the pump on, one with it commanded
off, judged against
cool_flow_rise. PASS = the threshold separates the readings; margins under 1.5 °C add a run-calibration warning. - flow-calibrate (~15–25 min): 3 trials per case, alternating, with settle gates between; reports both bands and recommends threshold = (flow max + no-flow min)/2 with an Apply button, or refuses when the gap is under 3 °C.
Cooling tunables are conf-backed: the nine cool_* keys (flow_rise,
flow_heater_pct, flow_check_s, recheck_s, confirm_max_s, temp_max,
temp_resume, cooldown_s, cooldown_max_s) live in /data/forgefirm.conf
(Machine tab, validated ranges), and the cooling engine re-reads them at every
run start (env GFCOOL_* > conf > compiled default; env stays the bench
override and wins for the process lifetime).
Flow verification, as it runs: a one-shot check at flood start (M8) heats
the loop at cool_flow_heater_pct for cool_flow_check_s and discriminates on
downstream temperature RISE, with periodic re-checks every cool_recheck_s —
a stopped pump is undetectable any other way. A check starts only once the
sensors agree and the downstream reading is stationary (split-half mean
difference, not peak-to-peak). An over-limit check is a suspicion, not a
fault: the next completed check decides it (over-limit again → FAULT, clean →
cleared, three cleared episodes in one job → aggregated warning), and a
suspicion that produces no verdict within cool_confirm_max_s escalates to
FAULT. Over-temp policy is the factory's: a CYCLE over cool_temp_max gets a
feed hold plus forced cooling airflow and auto-resumes under cool_temp_resume;
a JOG gets a jog-cancel.
Logging
rsyslog is the system logger and the only log writer. forgectrl and the grblHAL
driver emit through the shared non-blocking fflog emitter (drops, never waits
— a stalled log daemon can never park a controller thread), gfcloud/gfhome
through SysLogHandler, the kernel through imklog; a controller's stray
stdout/stderr rides a per-controller logger relay under its own name. Tree:
/data/log/forgefirm/{forgectrl,grblhal,gfcloud,gfhome,kernel,system}/,
size-capped and rotated at boot and hourly (the forgefirm-logging recipe
renders the rsyslog rules from the settings at S19 via
forgectrl --render-syslog). Levels (log_<logger>_disk /
_remote) and the remote target (syslog_server/port/proto) are machine
settings applied at reboot — the panel's Logs tab shows configured vs.
effective and offers the reboot, plus a live viewer and a sanitized tar.gz
export for issue reports (POST /logs/export; src/sanitize.c replaces
serial, hostname, cloud credentials, panel token, SSID/PSK, IPs, MACs and
e-mails with stable placeholders). Design and contract:
logging.
Release acceptance (forgetest, port 8090)
The release acceptance tool — catalog, campaigns, domain fingerprints,
inheritance, the always-required core, invalidate-all, the release gate and the
coverage currency rule — is specified on the site (Developers, "Acceptance"); the tool lives in
forgetest/ and ships only on the dev image (/etc/init.d/forgetest, HTTP
:8090). It is bench-validated: the full campaign on dev image
20260824230512 (c-20260824231028-b7ca) satisfied 45 of 45 from nothing,
36 of them unattended with the bench actuator in the loop, in 27 minutes, and
the export reads "Release authorized: YES" for that image's manifest. That
authorizes a release; it is not one until releases/v<version>/acceptance.json
is committed.
- Catalog: 46 tests in
forgetest/forgetest/suite/, every one a port of a proven bench drill or a bench-verified check: the always-required core (image.health,kernel.latch-locked-idle,kernel.k1-k2,kernel.fire-line),forgectrl.*,logs.*,update.*,motion.*(pacing, jog round-trip, liveness probe, cancel/abort, dead-man, the lid, interlock and button parity tests),cooling.*(flow verification, fans quiet after motion, a gate setting tripping and off by value, a fan under its floor),camera.*,laser.*(emission witness, arm-wait lid, disarm-in-hold, armed kill, pause/resume/lid-cancel, the rapids after an M5 shipping dark) andcloud.*(the service protocol answered by the emulator in this machine's identity, with only the app to drive; the mode round trip with the lid-open hunt and the web-service homing on it; one real print; and the job-behavior tests under the offline service: the cloud client driven from a local socket with a synthesized laser-free job, no account, no network, nothing on the bed). Tests that share a setup are merged; theautotests stay separate for failure isolation. 28 areauto, 9operator, 9live; with the bench actuator up, eight of the operator tests run in the unattended queue. - The operator's part is asked for by name, not by popup
(the site, Developers, "Acceptance", "The operator's part"): a Ready prompt before a
timed step, a standing notice the test takes down when the machine shows
the action done (
ctx.act("lid", "open")and its kin, the seam a bench actuator will plug into), and one confirm by eye left in the catalog (the emission witness's mark). The head accelerometer, the beam detector, the button LEDs, and a lid-lamp toggle between two snapshots replaced the other eyeball confirmations;kernel.fire-lineandcamera.snapshotareauto. With the bench actuator up the attended block is the ten tests that need a person (five laser live, five cloud): 12 minutes on dev image20260824230512. A test's implementation hash is its own function plus its module's shared code, so a fix inside one test re-requires that test alone. - Machine identity is content-defined. Every component recipe contributes
forgefirm-manifest.bbclassentries (the kernel and the module throughdo_deploy),forgefirm-image-manifest.bbclassassembles them plus the layer content hashes into/etc/forgefirm-manifest.json(also deployed beside the image as*.forgefirm-manifest.json), andscripts/manifest-from-tree.pycomputes the byte-identical thing on a workstation — the identity is content-defined, independent of the checkout's commit or dirty state. Component pins live in<recipe>-pin.inc(SRCREV + the PV that moves with it, nothing else) and are left out of the layer content hash, so a pin bump invalidates only the tests covering that component; a pin written into a recipe body counts as a platform change and invalidates everything. - Baseline rule: every test and bench tool is bracketed by a baseline pass
(
forgetest/baseline.py), against a fresh-boot reference taken once per boot after a power cycle (a soft reboot leaves the lid lamp dark — the PIC lights it at power-on). Takeover runs capture the controller-owned kernel attributes on entry and write them back before forgectrl restarts, so a leftovermotor_lockmask can never read as a wedged driver. - Coverage currency is enforced in CI (
python3 -m forgetest.coverage --enforceinforgetest-ci.yml, over the tree manifest): an uncovered manifest path fails the build, because it would let an inherited PASS survive a change that should have invalidated it. - Bench tab: every board-runnable tool in
scripts/benchis runnable from the page (scope tools, the flow characterization family, the escalation drill, the live drills,resume_dark_lead.py), with takeover tools bracketed by a forgectrl stop/start.scripts/bench/gfbench.pyresolvesGF_HOST(host mode, ssh) or the board itself (local mode). Not ported by nature: the two null-sink CI harnesses and the.pulsdecoder. - Releases are signed only when
releases/v<version>/acceptance.jsonauthorizes the built rootfs (scripts/release.sh).
Hardware facts bank (measured)
-
The bench actuator's wiring (
fixture/README.mdfor the box itself). Three 3.3 V optocoupler relay modules, high-level trigger, coils from the machine's 3.3 V (about 100 mA each), inputs from the ESP32-S3 DevKitC-1's GPIO 4 (lid), 5 (interlock), 6 (button), the button's enable jumper on GPIO 7 to GND. The lid contact (NC) goes in series with the lid-switch loop at J4.12/13; the button contact (NO) across the front button input at J5 (BTN and its 12 V); the interlock contact (NC) in the remote interlock loop at J8 (the safing chain; J6 is the speaker). The machine's 3.3 V rail carries the three coils with room to spare. The DevKit and the machine share a ground through the modules, so the DevKit is powered from a USB wall adapter. The interposer harness itself is bench-local and is not described in any repository. -
The factory's envelope, decoded (firmware 2.6.0-2228, the 23 captured headers, this board's own factory logs). The pulse header is the job's operating envelope and the factory refuses to cut without it: 29 tags are mandatory, 346 are header-legal, an unknown tag is logged and skipped. The service fills the fan duties, the coolant window, the per-sensor temperature ceilings, the lid IR thresholds, the accelerometer thresholds and an HV current cap per job; a cut job carries the real fan duties (air assist 1023, exhaust 65535, intake 43278, equal to ForgeFIRM's run profile) and a hunt or motion file carries air assist 204 with the extraction fans off. Every tach window is zero in every capture except
AArx64500 on cuts, and the factory's intake and exhaust tach monitors treat zero as not configured, so a stalled extraction fan is caught there by the temperature it causes; when a fan alert does fire during a cut the factory pauses the print on the same transition as a user pause. Temperature runs two tiers there: a plain alert pauses, a*_temp_criticalfails the machine; the units are per sensor (the coolant family is carried twice, raw counts with the NTC's hot end as "min", and millidegrees), and a stock machine's live coolant window is 10 to 30 C idle and 5 to 35 C warm-up and run,CMrx33000 on a cut being exactly the shipped 33 C ceiling. The factory does not verify coolant flow: the calorimetricCFcontroller in its firmware is never armed and its heater is written only at phase changes. ForgeFIRM's answer, all of it landed and bench-proven in the catalog (cooling.gate-off,cooling.fan-gate-trips,cooling.critical-tier, the hunt leg ofcloud.mode-switch) and in the CAMPAIGN-LOG drills: every gate a plain setting with an off end; a header value only ever tightening a local one; every fan held to a measured floor with a fault, not a pause, for the session; a coolant critical line above the ceiling's pause; the board temperatures watched per job; and the rest of the envelope declared, tag by tag, on the factory firmware under "The pulse header". -
Board temperatures at idle (room ~22 C, machine on for hours): the chassis LM75 reads 29.0 C,
pic/pwr_tempreads 589 raw (the unverified guessraw * 0.08715 - 21would make that 30.3 C), the SoC die 42.8 C. Ranged per job by the engine from here on. The supply stays a raw count by decision: its heatsink cannot be reached with a thermometer while the machine runs, so the conversion is not going to be verified on this bench, and a number nobody has checked is not published as degrees. The per-job range in raw counts is the record, and a ceiling, if one is ever wanted, is set in raw counts from it (temp_calibrate.py supply-*stays for a machine where the heatsink is reachable). -
The SoC guards itself. The i.MX6DL (rev 1.3) on-die monitor is
thermal_zone0(imx_thermal_zone, the same node ashwmon0), governorstep_wise, trips at 85 C passive and 90 C critical (the consumer-grade points the driver derives from the fuses: hot point 95, critical at hot minus 5, passive at hot minus 10). The passive trip is bound tocpufreq-cpu0(996 / 792 / 396 MHz OPPs;performanceis the only governor built, so the core sits at 996 MHz until the trip lowers it) and both GPU cooling devices; the critical trip is the kernel's orderly poweroff. A throttle slows the engine, the camera and the protocol thread before the step stream (the ring is in hand). The factory board carries no heatsink or fan on the SoC, only the mounting holes for one, and needs none: under a full core for five minutes on top of the live camera stream, in a 30 C chassis, the bare die plateaus at 70.8 C, 14 C under the passive trip, with the core at 996 MHz and no cooling device off state 0. The die-to-chassis delta at full load is about 41 C, so the passive trip is a hot-chassis case (above roughly 44 C), not a load case. The per-job SoC range and the throttle log line are the running record. -
Fan speeds at the cut profile (exhaust duty 65535, intake 43278, air assist 1023; sampled at 1 Hz over 120 s from idle, the exhaust duct's inline booster fan off): exhaust 11640 rpm steady (spread 11444 to 11947, 90 percent of steady in 5 s), intakes 4157 / 4158 rpm (spread under 100, 7 s), air assist 11050 rpm (spread 30, 1 s); at idle the exhaust and intakes read 0 / ~745 rpm and the air assist ~1900 rpm (idle duty 204). The purge-air fan is always on and reads ~625 counts of
head/purge_air_current(~1 when off). The airflow floors are 55 percent of these (bands 50 to 60 percent); an inline booster fan changes the exhaust's back pressure and can move its reading by a few percent either way, well inside the margin. -
DRV8825 stepper drivers wedge on 40 V rail glitches (factory board; the TMC2130s belong to the upgraded OpenGlow board only). A glitch can leave the drivers unserviceable: SDMA playback and the position counters run normally while the motors produce nothing. The supply itself is fine — this is a driver failure mode, not a marginal rail. The kernel drives their nRESET and nSLEEP pins (
reset-gpiogpio3 18,sleep-gpiogpio3 16), but only as a pair inside every enable/disable cycle, together with the rail: the reset pulse rides along on every recovery attempt and has never shortened the ladder, so a logic reset alone does not clear the state (only the rail-off duration matters, which fits a latched internal state that nRESET does not reach).cnc/faultsdoes not flag the state, and whether a given rail power-up wedges them is chance. Recovery: a longer true power-off (the forgectrl supervisor ladders 5/15/30 s) and, at worst, a full machine power cycle. Consequences: counters, anchors andH:1are never proof of motion; keep the rail up (every power-up is a wedge lottery), which is why the pulse-device broker exists and why there is no idle-rail-off policy. -
Motion liveness = the head accelerometer (
glowforge.dtshead-accel, i2c-3 @0x1e — resolve iio devices by bus path, never by index; lid = i2c-0 @0x1e, board = i2c-3 @0x1d). Signatures on an identical commanded move: real motion 1800–2900 counts peak-to-peak on X/Y (noise floor at 1 g ≈ 16384), a genuinely dead/wedged axis ≤ ~250, and the rail-on / current-step jolt up to ~700 — which is why the forgectrl probe gates controller start at p2p ≥ 800 (P2P_MOVING), writescnc/motor_lock=0for its own move (a leftover mask from any tool must not read as a wedge) and settles 300 ms after the run-current step before sampling. A masked axis reads 144–480. gfhome requires at least one accel-witnessed motion window before a quiet service counts as homed. Raw sysfs accel reads are slow (~150 ms each) — enough for a binary verdict over a multi-second window, not for waveforms. -
Any probe/liveness move goes RIGHT (+X) first, then back: a cable lives at the end of LEFT travel and must never be crushed.
-
Rail-contact signature (from the retired accelerometer-homing spike, relevant to any future contact sensing; tools
accel_fast.py,bump_seek.py): creep baseline ≈0.5–2 k counts, contact jumps to 29–42 k within ~4 ms (20–40×). But slow approaches are near-silent — belt compliance turns slow-speed skipping into sub-threshold grinding — so any contact-sensing scheme must strike fast. Direct I²C (unbind st-accel, CTRL1=0x6F = 800 Hz ODR) reads ~530 Hz from Python; st_accel sysfs one-shots are ~6 Hz and the kernel has no IIO triggers. -
WL1805 Wi-Fi rides uSDHC1 (mmc0, 4-bit, SD-high-speed at 49.5 MHz,
no-1-8-v; IRQ GPIO6_04, WLAN_EN GPIO5_26). Factory pad control, now ours too: CMD/DATA0x17069, CLK0x10069(SPEED_MED, DSE 48 Ω, fast slew, HYS; 47 kΩ pull-up on CMD/DATA only). eMMC (uSDHC3) and the SD slot (uSDHC2) use0x17059/0x10059(80 Ω), SD2_DAT30x13059. An SDIO CRC error surfaces assdio write failed (-84)and costs ~1 s of Wi-Fi (wlcore firmware recovery); with these pad values it does not recur (the closed watch record is inCAMPAIGN-LOG.md). -
SDMA pulse engine: ring size = the
ring_mbmodule parameter (default 32 MiB, the factory ring size; power of two, must fit the 32 MiBcnc-pulsebufno-map DT pool). Free = size − 32 KiB gap, so 33,521,664 bytes. Bench-verified on the 16 MiB ring the earlier images shipped, and the mechanism is size-independent: 20 MB streamed at 100 kHz through the wrapping ring, 0 ENOMEM, 0.4 ms max write latency, starve →underrunper protocol. The ring holds ~1 MiB per 100 s of 10 kHz stream, so ~56 min of a cloud print at a time; a longer job is fed live as it plays, and the grblHAL feed keeps only a few KB in flight. The 32 KiB gap is retained history: the writer stops that far short of the play head, so any fill leaves 3.2 s of played program (at the print tick) to back a pause into, which is whatcnc/max_backtrackreports less the deceleration tail. The engine'sipgandahbclocks are enabled only by a channel holder: imx-sdma leaves them off after probe, and glowforge.ko holds them itself throughsdma_get_channel()in the SDMA API patch for as long as it is loaded; nothing else on this board holds an SDMA channel (ecspi2 runs PIO). With the block gated every channel-0 transfer completes at once and moves nothing: the ring reads back its bounce page, the probe cannot start andcnc/freeexceeds the ring, which is whyimage.healthasserts the clock enable count directly. -
Reserved memory: 511 MiB usable DRAM (
0x10000000–0x2fefffff), of which 96 MiB is reserved for DMA: the 32 MiBcnc-pulsebufno-map pool (dynamically placed,alignment = size, so it lands at0x2c000000) plus 64 MiB of reusable CMA for camera/IPU/VPU buffers. no-map means the pulse pool is gone from the kernel's map whether a job uses it or not, which is what makesdma_alloc_coherent()deterministic for a late-probing out-of-tree module. The 1 MiB above the memory node (0x2ff00000, held back by the bootloader) is pstore/ramoops: 32 KiB dump records, a 256 KiB console record, 16-byte ECC, mounted at/sys/fs/pstorefrom fstab and staged by the log export. MemTotal ~464 MiB on the board-only kernel (linux-fslc6.12,SMPoff,CONFIG_PREEMPT=y, zImage 4.8 MB, 31 module packages, ROM SDMA scripts); measured idle use in GRBL mode with the daemon and controller up is ~100 MiB. The playback script is relocated to SDMA channel 26 at<26 0xF00>(halfword 7680) with a pre-run integrity guard; the probe lines to look for areEPIT clock 66000000 HzandSDMA channel 26 reserved for pulse playback (script at halfword 7680). Measured script ceiling ~165 kHz effective (~6 µs/byte); position counters (sdma_contextsc0/1/2 = X/Y/Z steps, sc3 = bytes) match grblHAL exactly. Underrun proof: 100 kHz × 120 s under full load, 150 ms queue, 0.2 ms worst write latency, zero underruns. Real time: the kernel runsCONFIG_PREEMPT=y(the factory behavior;imx_v6_v7_defconfigalone givesPREEMPT_VOLUNTARY). PREEMPT_RT is not selectable on arm32 6.12 (noARCH_SUPPORTS_RT) and is not needed: the ring drains at 1 byte per EPIT tick, at most 200 KB/s even at the 200 kHz ceiling, so the feeder's bounded queue depth of ~150 ms (a few KB in flight) rides out worst-case scheduling latency with orders of magnitude to spare. Bounded queue depth plusSCHED_FIFOfor the feeder is the design; RT is worth revisiting only if the underrun bench ever contradicts this arithmetic. -
Byte layout and stream rules: see the pulse feeder contract (authoritative).
-
Z: bit 6 SET = lens UP = +Z (hardware-verified). Home = hall trigger at TOP; usable travel ≈ 30 half-steps ≈ 10.6 mm ≈ 0.417"; 0.3534 mm/half-step. Never blind-drive Z — hall-supervised only.
-
XY: 0.15 mm per full step; DIR bit set = −X / +Y (Y1/Y2 complementary). +Y physically moves the gantry toward the FRONT. Home corner (convention, for the planned limit-switch homing) = back-left (X min, Y min), workspace all-positive from that corner.
-
Factory motion profile (measured from captured factory pulse streams with
puls_profile.py): accel ≈ 700 mm/s² X / 590 mm/s² Y on v2.6.0 firmware (2018 firmware used ≈1000). The HAxr/HAyr/HAar header tags are NOT motion accel limits; they are LIS2HH12 IG threshold register values (the crash detector, "The head accelerometer" below). Travel moves peak 202 mm/s vector (≈ 8 in/s) at STfr=28160 Hz; prints and hunts run STfr=10000. Cut feed in the sample print: 145 mm/s. Z cadence ≈ 61–115 ms per half-step (≈ 5.7 mm/s max). -
Factory analog config (constant across all captured jobs, 2018→2026): PIC currents X 135 run / 33 hold, Y 22 run / 5 hold (axis DAC scales differ by design); x/y_decay=1; ×8 microstepping; run currents applied only while motion plays, hold otherwise.
-
Laser PWM: 39.98 kHz register-verified (divider 13 × 127 counts), scope- confirmed at 25.0 µs period across the full duty range, clean at the low end (6.4 % measured vs 6.3 % commanded at PWMSAR=8).
-
Laser duty thresholds (ladder on scrap at F300, constant power): the tube has two thresholds, far apart. The discharge strikes between 2 % and 3 % duty — 2 % (PWMSAR 2) draws no measurable
hv_currentand leaves nothing at all, 3 % (PWMSAR 3) draws current — but it does not lase usefully until 16 % (PWMSAR 20), the lowest duty leaving a continuous mark. Between them (3–14 %) is a dead band: current flows and climbs, and each line shows only a spot at its start (the strike transient) with a dark line after it. So the usable analog range is ~16–100 %, and$35(DEFAULT_SPINDLE_PWM_MIN_VALUE) ships at 16 to hold every nonzero S above it. Rawhv_currentcounts are a presence/absence witness only: the per-rung means are non-monotonic at the top of the ladder and the signal has no characterized transfer function. -
Feed hold and resume in GRBL mode (stream-measured on the null-sink build at the 28160 Hz tick: a 100 mm/s cut at S500,
laser_dose_curve = off, floor 10 %,!mid-line,~afterHold:0): the planned deceleration runs lit in both modes. The core'sdisable_laser_during_holdacts instate_suspend_manager, which runs only once the hold has completed, so the beam goes off at the end of the deceleration, never at its start. UnderM4the density follows velocity down to the floor (fire per step 2.9 at cruise, 5.4 in the last 25 ms at 13 mm/s), the stream between the last step and the first step is dark, and the resume lights 9 ticks after the first step with the deceleration's profile in reverse: a pause underM4is a sharp corner in time, and the corner rolloff governs its mark. UnderM3the fire rate stays constant through the deceleration (fire per step rises 2.9 to 22, theM3corner dose) and the resume is lit from its first step as well: the segments the core prepares while held carry the spindle update (the core fork sets it at hold completion; without that theM3resume ran dark for 2453 ticks, 87 ms, one segment buffer). A hold whose window closed under the grace resumes through the resume gate: the sender's~, the button, or the cooling client's auto-resume lights the button and waits for the press from the poll, never inside the core's held state (a blocking arm wait there pumps the core's suspend loop, which spins until the hold ends, so the press is never read), and the cycle start is issued once the press has re-armed. -
Factory power model (three cloud cuts of one 1" square, same location, material and speed, only the UI power setting changed, pulse files captured from each): the power byte is pinned at 127 in all three runs — three occurrences each, one as the cut begins and a refresh every ~27 000 ticks (~2.7 s). Analog duty is never a power control. Dose is FIRE-bit density on a fixed 7-tick period (700 µs at
STfr= 10 000, ~1.43 kHz), the on-count dithered between adjacent integers to reach a fractional duty: Precision Power 1 = 1.371 of 7 (density 0.1953, runs of 1 and 2), PP 100 = 5.576 of 7 (0.7952, runs of 5 and 6), Full Power = 7 of 7 (0.9965, continuous). The period was exactly 7 in all 570 measured cycles of both dithered runs, and the mix of adjacent on-counts matches the fractional part exactly (PP 1 wants 1.371; 2-runs are 212 of 571 = 0.371). The three headers are identical — the power setting never reaches the machine, so the whole model is service-side. Motion is identical too: 5420 steps, 101.62 mm, 10.81 s at 9.44 mm/s. Density tracks velocity through corners, by the same relative factor at every power setting (corner/cruise 0.38, 0.38, 0.41), but only partly: fire ticks per step rise 3.89 → 7.00 as speed falls 9.44 → 1.22 mm/s, so dose per unit length rises ~1.8× at a corner instead of the ~7.7× it would rise with no compensation. On the UI scale, PP 1→100 is linear in density (~0.006 per unit, intercept ~0.189) and Full Power sits off that line, where PP ~134 would land. -
Density dose limits (measured on five ladders,
dladder): under the FIRE-density model the interval between pulses at a level below the minimum ismin_ticks x tick / density- the base period cancels, which is why periods 10, 20 and 40 gave identical results. The tube strikes down to ~5 % density at a 2.26 ms interval (min_ticks3, 106 us pulses) and fails to strike at 4.51 ms (min_ticks6, 213 us): lengthening the pulse at fixed density lengthens the gap in proportion, and the gap is what kills re-striking. It marks from ~10 % at F300 on scrap.min_ticks3 is essentially the factory's own structure - its 6.5 % engrave jobs put 100 us pulses 1.54 ms apart, against 1.64 ms formin_ticks3 at that density - and 6 is outside anything the factory does. Below ~5 % no pulse shape reaches the tube: the interval grows as 1/density, so 1 % implies an 11 ms gap, five times what already failed. The scale closes that gap instead:$35= 10 maps S onto 9.4-100 % density, putting a commanded 1 % at 10.2 %, and a ladder weighted to the bottom (1, 2, 5, 10, 20, 40, 70, 100 % of S) then marked on all eight rungs, with eight current segments and means rising 136 -> 968. So a user's 1 % is a real, visible mark rather than silence. -
Cooling operating point: 40 % heater duty, 50 s window, flow-rise threshold 14.4 °C, re-checks every 150 s. Below ~40 % duty the stagnant loop sheds the heater's output by convection well enough to mimic flow (at 30 %, three of five dead-pump trials looked healthier than a working pump). Record at 40 %: 25/25 correct classifications, plus all three settle cases. Settled- loop noise is 0.52 °C peak-to-peak but only 0.11 °C split-half, which is why the stationarity gate uses split-half means. Coolant windows: run ceiling 33 °C, resume 31 °C (factory job-header CMrx/…); the factory's low side (floors ≈1.0/4.0 °C, ~16 °C warm-up gate) is not implemented yet. The coolant thermistor conversion is the factory B-equation recovered from the v2.6.0 binary — derivation on sensors; the old UAPI "best guess" linear formula was 3–5 °C high and everything derived from it had to be re-derived. The flow check's bands hold from 19 to 27 C, the loop heater's ceiling in a 20 C room, with the margin widening warm; above that only a running tube warms the loop, and the check takes the tube's share off. With the pump on, a heater slug reaches the upstream sensor within seconds and inflates the instant reading by a degree; the warm-up release therefore judges a one-minute rolling minimum of that reading.
-
Coolant-ADC offsets around a lit tube. The air-assist fan's return current rides a ground path the thermistor reference shares, so both coolant sensors read about 1.2 C low at the run duty (proportional to the fan's current, both sensors alike; not crosstalk on the sensor cable and not HV).
cool_aa_offset_countscarries the machine's measured value (theaa-offset-calibratediagnostic measures it, the panel's Apply writes it; zero is the factory's uncorrected reading; the bench machine carries 16): without it the over-temperature gates read the coolant about 1.2 C cooler than it is while the air assist runs. The flow check is immune either way because it reads means and takes its baseline under the run profile. A lit CW window adds about 1.5 C to the check's rise (0.5 C at 45 percent density); the engine takes the tube's share off (cool_laser_heat_cw,cool_laser_heat_density, bench-measured, per machine). With the tube lit the offset also toggles between two levels mid-run (0.6 to 1.1 C, both sensors together; not with the fans alone, not under motion, not in an armed dark window). The toggling sits inside the ceiling's 2 C hysteresis and the flow check reads means, so it has no gate consequence; its source stays uncharacterized by decision. -
The four
pic/lid_ir_*channels are first of all a photometer for the lid lamp. Measured againstlid_led(sysfs brightness, 0 to 1023): all four channels follow it as a straight line, 2 counts dark, 32 to 35 at 128, 54 to 61 at 256, 96 to 105 at 512, 131 to 143 at 768 and 161 to 177 at 1023; channels 3 and 4 read about 7 percent above 1 and 2. Against that lamp-set level, a full-power cut raises them only +4 to +6 counts and a candle burning on the bed +3 to +6, with ±3 counts of ambient noise and ~+22 counts of day-to-day drift. forgectrl's camera engine drivespic/lid_ledfor every lid capture, and the resting level varies (131, 8 after a reboot, cloud mode sets its own). The armed fire-watch thresholds sit above the fully lit lamp (alert 275 against a 177 ceiling plus drift), so no lamp change can trip them; each job still logs baseline and peaks. -
Emission and HV witnesses:
cnc/laser_on_sampledgoes to its full 255 count on a commanded fire window and returns to 0 at Idle — the reliable witness.pic/hv_currenttracks the cut (0 idle → hundreds/1023 raw while firing) and is the only live HV telemetry on this PSU (hv_voltageis grounded).cnc/laser_pgood(J1_14, GPIO4_21) is the supply's power-good, active high, measured 2026-09-01 through the kernel readbacks at ~780 Hz: the pin is high at idle, through four HV_ENABLE cycles of a dry run, and through an armed S400 cut (714 laser pulses,hv_currentto 1023), with zero transitions in 225 s, and it stays high against a 100 kΩ pull-down switched in at the pad (IOMUXC0x020E03C4, restored to0x100b0), so the supply drives it. The supply's supervisor is a Weltrend WT7525 (PC-supply supervisor; open-drain PGO reports every DC output within spec, drops on an over/under-voltage or over-current fault, 300 ms delay after good), and the reverse-engineering pinout sheets label J1_14HV_PFC_STOP(TP_A2C). The factory app reports the line as theHVpg/HVpstags and read it the same way (0 at idle under the old inverted convention). The kernel now reads it active high:laser_pgood1 andlaser_pgood_sampled255 on a healthy supply; a drop during an armed window is the cooling engine's supply power-good warning, and it means a supply fault. The line has never been seen low on this machine. -
The head MCU flag register and HEAD_IRQ. The head MCU (a KL17 at i2c-3 @0x47, I²C-slave-only to the SoC) samples four head-local GPIO input levels once per main loop into the read-only flag register 0x05: b0
hall_sensor(Z home), b1accel_irq(the head accelerometer's INT pin, a bare level), b2beam_detect_digital(the raw beam comparator), and a fourth, unidentified input the driver does not expose (candidate second hall or head-present). A fifth flag, b7, is the processed beam-detect verdict (below). The EV_SWheadbit (GPIO3_22, factory pad HEAD_IRQ; the panel's "Head sense" row) is the MCU's PTC2 output driven back to the SoC: it is level-driven and mirrors reg 0x02 (the latched IRQ status) being nonzero. reg 0x02 latches edges on the reg-0x05 bits, but only those the SoC arms through reg 0x03 (rising) and reg 0x04 (falling) edge-enable masks, and it is read-to-clear. So the SoC chooses which head events raise the IRQ, answers it by reading reg 0x02 to identify and clear, and reads reg 0x05 for live levels. ForgeFIRM writes none of 0x03/0x04 and reads neither 0x02 nor the fourth-input and b7 flags, so the head IRQ is dormant by construction: GPIO3_22 sits idle low with a healthy head, pulses on a head reboot (hence the 60 ms DT debounce), and floats to the SoC pull-up with no head. The raw level is not a presence signal; presence is the head answering at I²C 0x47. -
The head accelerometer is an ST LIS2HH12 (i2c-3 @0x1e; the board and lid accels are the same part at i2c-3 @0x1d and i2c-0 @0x1e), read raw by the mainline
st,lis2hh12driver for motion liveness. The part has a full on-chip interrupt generator: per-axis 8-bit thresholds (IG_THS_X1/Y1/Z1, regs 0x32/0x33/0x34), a duration counter (IG_DUR1 0x35), a per-axis event register (IG_SRC1 0x31), full scale ±2/4/8 g (CTRL4 FS), two independent generators (IG1/IG2). The factory arms this generator per job from the pulse header's HA* accel tags, which map bit-exactly onto its registers (per-axis threshold → IG_THS, duration → IG_DUR1, full scale → CTRL4, period/decimator → CTRL5/ODR), and reads trips by polling IG_SRC1 over the accel's own bus (per-axis x/y/z alerts), running two tiers. So the factory head crash detector is the sensor's own interrupt generator, and the HA* thresholds are LIS2HH12 register values at the full scale the HAsr tag sets, not values in an unknown unit. The INT pin does not reach the SoC as a host interrupt: it wires to the head MCU's GPIO and surfaces only as reg 0x05 b1, and neither the factory DTS (head I²Cstatus = "disabled", the accel driven from userspace) nor ForgeFIRM's DTS gives the accel aninterruptsproperty. ForgeFIRM neither arms the generator nor reads b1; it only polls raw samples.st_accelleaves the part in power-down between one-shot reads (CTRL1 ODR bits 0) and the interrupt generator only samples at a running ODR, so an armed detector must set the ODR itself and re-assert it after any liveness read (each one-shot powers the part down again). The IG registers coexist with the bound driver over i2c-dev (I2C_SLAVE_FORCE): IG_SRC1 polls at ~166 Hz from Python withst_accelraw reads intact. The IG_THS LSB is full scale / 256 (bench-confirmed against gravity: at +/-2 g, threshold 100 = 0.78 g trips on the 1.03 g gravity reading and threshold 150 = 1.17 g does not; FS/128 would make the trip at 100 impossible). Gravity rides Z on the head (raw ~-16916), so Z trips any sub-1 g threshold at rest while X and Y stay silent at rest and through a jog at threshold 40 (0.31 g). The factory arms the IG per job from the HA* header tags: hunts all-zero (off), travel files abort-only (HAar=133atHAsr=4, +/-4 g, ~2.08 g), the cut job alert-only (HAxr=132~2.06 g,HAyr=112~1.75 g, +/-4 g), Z and the idle thresholds zero in every captured header. -
Beam detect in the head MCU. PTE16 into ADC0 gives reg 0x16, the raw analog level (
beam_detect_analog, a head sysfs attr): near 1834 dark, 2600 to 2890 during S300/S400 fire (the acceptance suite's mark witness), unmeasured at low fire energies. A float EWMA/CUSUM over the coefficientsLAMBDA_K(0x07ae),LAMBDA_T(0x1999 = 0.1),THETA_R(0x20),THETA_T(0x28),E_T(0x60) feeds an N-of-M sliding-window verdict in reg 0x05 b7; a DAC (reg 0x1e, default 0x3ff) sets a comparator threshold whose raw output is reg 0x05 b2 (beam_detect_digital). The head probe writes the five coefficients, but they are the firmware's own power-on defaults. The driver exposes b2 (raw comparator) and reg 0x16 (raw analog), not b7 (the processed verdict). Whether the factory enables beam detect in production is unknown: the v2.6.0 app carries a complete but config-gated subsystem. -
Switches: truthy = closed/OK for lid/doors/button. SW_INTERLOCK is INVERTED: the remote interlock (the regulatory 2-pin lockout connector) reads ACTIVE only when the loop is OPEN. Basic/Plus — including the bench machine — ship the connector factory-jumpered, so the bit reads 0 = satisfied; Pro brings it out for an external lockout chain. It must NOT gate motion (the beam is hardware-gated), but ForgeFIRM's kernel module does drive INTERLOCK_RESET high whenever the loop reads open, so the CD4043B latch blocks the LASER_ON gate in hardware until the loop is closed again (bench-verified: loop pulled →
interlock_latch=1,interlock_circuitb4 set, all within one 50 ms sample; reinserted → all clear). -
hv_enable(EV_SW bit 4, GPIO4_06) is the readback of the safety chain's HV_ENABLE output through the U24 inverter — not an input. Active for the whole duration of any run, inactive at idle, and it drops 454 ± 3 ms after the last charge-pump pulse (one-shot t_w measured pulse-to-drop withscripts/bench/cp_watchdog_timing.py: 451.8 / 455.6 ms; feed period 199.98 ms; matching the measured R·C ≈ 500 kΩ × ≈900 nF). It gates nothing — it is telemetry (/statusswitches.hv_enable, panel "HV enable"), read alongsidecnc/charge_pump_alive(interlock_circuitb5). Across a pause and a resume (measured at the pads withscripts/bench/resume_dark_lead.py, ~2 kHz through /dev/mem): a pause stops motion 317 ms after the command and HV_ENABLE drops with the watchdog 550 ms after it, so a pause shorter than about half a second never drops HV at all; on the resume HV_ENABLE and the watchdog are back within ~3 ms while motion only restarts at ~219 ms — the chain re-arms ~216 ms before the first step, so a resumed cut loses nothing and no dark dwell is warranted. Naming note: the factory design labels this net E-STOP; entries inCAMPAIGN-LOG.mdwritten before the 2026-08-15 rename call itestop/SW_ESTOPwith the pre-rename polarity (the DTS then declared the pin active-high, so the bit read HIGH at idle and LOW through a run — the same physical behavior, inverted). The DTS now declares it active-low, and the formerestop_halts_motion/MOTION.ESTOP_HALTS_MOTIONopt-in is gone: a real e-stop belongs in the lid-switch chain (the safing chain). Doors/door1/door2 stay stable during motion. -
Factory job behavior on the lid and the button, measured on 2.6.0-2228 (bench session 2026-08-16; this is what ForgeFIRM's parity policy reproduces). Lid open mid-print:
cnc/stop5–6 ms after the edge, decel to idle in 86–91 ms, the return-home park starting ~300–340 ms after the edge and running to completion with the lid still open, the job reported:cancelled. A cancel from the app takes the same path. The button pauses a print — controlled stop, then a 2000-tick laser-off backtrack — and resumes it with a 1950-tick laser-off lead; the button flashes white while paused. A lid open while paused cancels the job and parks from where it stands. The lens hunt is not lid-gated. -
The hardware button latch is what makes the armed window honest. A lid open SETs it (set-dominant), and it stays SET until the lid is closed, the SoC lock is released and the button is pressed (the safing chain). So a policy that cancels the job on a lid open and re-arms only through a fresh button press keeps software and hardware in agreement by construction; one that resumes a job after a lid open leaves the beam blocked in hardware while software believes it is armed.
-
interlock_circuitbitmask: b0 = SoC-side LASER_ON monitor, active LOW (1 = not lasing); b1 = FIRE, active high; b2 = button latch; b3 = latch, 1 = locked; b4 = interlock latch reset; b5 = charge-pump watchdog readback. b0/b1/b3 were pinned by scope experiment, b2/b4 come from the factory decode.cnc/laser_latchis write-only, so lock state is read from b3. -
Machine identity from OCOTP nvmem: HW_OCOTP_MAC0 is the serial, base-23-encoded to the factory hostname (
BCDFGHJKMQRTVWXY2346789, XXX-YYY) — fuse-verified against the factory label, and the C implementation matches gfhardwareid.pyover 200 k random serials. The bench machine's actual values are deliberately not recorded here: this is a public document and a fuse identity cannot be rotated.
eMMC boot & recovery architecture
- eMMC (
mmcblk2): 3.6 GiB user area + two 16 MiB hardware boot partitions (mmcblk2boot0/1). Factory user-area MBR (per the factory.fwmanifest): p1/p2 = 200 MiB rootfs A/B at blocks 8192/417792, p3 =/datafrom block 827392 to end of disk. (The bench board ran the legacy ForgeFIRM layout — p3 shrunk plus a p4 — untilslotmigratereclaimed it to the byte-exact factory geometry.) - U-Boot lives in boot0 at 1 KiB (IMX IVT header), not in the user area. Any boot0 rewrite below 0xC0000 risks the bootloader.
- Saved env: user area 0x80000 with a redundant copy at 0x82000 (what
ffboot/fw_setenvtarget; boot0's own 0x80000 region is zeros). Slot selection =mmcdev/mmchwpart/mmcpart/mmcroot. Gap:ffbootsets three of the four but nevermmchwpart— it relies on the saved 0. - Default (compiled-in) env boots recovery:
mmcdev=1 mmchwpart=1 boot_recovery=yes— a blank or corrupt env lands in recovery mode, not a brick.bootcmd: select mmc dev+hwpart → load and import/boot/uEnv.txtfrom the selected partition → ifboot_recovery=yes, boot kernel+DTB from raw boot0 sectors, else load/boot/zImagefrom the slot's rootfs. U-Boot polls the button at power-on for a recovery request. - boot0 map: MBR / U-Boot @1 KiB / zeros @0x80000 / recovery DTB @0xC0000
(
fdt_dev_addr=0x600, 64 KiB slot) / recovery zImage @0x100000 (image_dev_addr=0x800, 5 MiB slot, kernel 3.14.28) / recovery squashfs =boot0p1@6 MiB (10 MiB slot). boot1 map: MBR / squashfs @1 KiB =boot1p1, mounted as the recovery/usr(python runtime) byinit.d/recovery-usr. - Recovery userspace = the factory setup webapp (bottle): WiFi setup/AP,
log export,
/version, and.fwupload (→ tmpfs →glowforge-updater -f→ fwup signature check → writes slot A → flips env). It is never updated in the field, so every machine still runs its as-manufactured recovery. - Factory
.fwformat = signed fwup 0.14.2 archive (ZIP:meta.conf+meta.conf.ed25519+ payloads). Tasks:complete(MBR, U-Boot to user area, zero both env copies, rootfs → slot A, zero p2/p3 heads) andupgrade.a/upgrade.b(raw-writerootfs.ext4into a slot). Factory updater flow: authenticatedGET <server>/update/current→{version, download_url}→ resumable download to/data/glowforge.fw→ verify against/glowforge/pubkeys→ apply to the INACTIVE slot →fw_setenv mmcpart mmcroot→ reboot. Factoryrootfs.ext4is 65 MiB; the ForgeFIRM release rootfs uses about 89 MiB, so it fits a 200 MiB slot with headroom. - Facts about the factory 2024 firmware (learned during the slot install):
no
/factory/imgNmounts, the genericfw_env.configpoints at the WRONG device (use the per-devicefw_env_mmcblk2.config— ffboot's selection logic), no SSH (serial console only), and the factory kernel cannot see the SD card (ffboot -sneeds-ffrom factory). Factory/etc/versionis a numeric datetime stamp, so newest-slot selection is integer comparison. - Platform quirk: busybox
mount's auto-type iteration against an already-mounted ext4 device prints a kernel "Can't open blockdev" for each foreign-type (ext3/ext2) exclusive claim before the ext4 attempt joins the existing superblock. Cosmetic only; ffboot and the installer reuse existing mountpoints from/proc/mountsand mount fresh targets with explicit-t ext4.
Next work
Open items only. Anything closed is in CAMPAIGN-LOG.md. Open items (bugs,
feature requests, enhancements) will eventually be tracked as GitHub issues.
-
Limit-switch homing. The planned second homing method (
$22stays 0 until it lands). -
Cameras. First light on an 8 MP (OV8856) machine: the whole path is written but nothing has run on one, and only that hardware can answer whether the 2-lane RAW8 full-resolution mode locks the D-PHY at 720 Mbps/lane and what exposure/gain the sensor wants; the details, the reachable-mode reasoning and the factory fallback configuration are in the headers of kernel patches 0011-0013 (
meta-glowforge-bsp,recipes-kernel/linux/). -
Release acceptance follow-through. The campaign is the release gate and runs as designed: dev image
20260824230512, 45 of 45 from nothing, 36 of them unattended with the bench actuator in the loop, release authorized (the export is on the board at/data/forgetest/export/). What is left is small. Two catalog gaps from the tool's own plan,cooling.confirm-escalateandcooling.fire-gate-blocks-arm, are not ported (both need the pump switched by hand mid-run, so they are bench-tab material first). From the coverage maps: splitting gfutilities'websocket.pyinto transport and transfer helpers would take websocket-transport changes off the offline tests (a gfutilities refactor, not a map). Tools that genuinely need a second host (LAN flood, remote auth probes) stay host-side by design, and the registry marks them so. The deferred emulator homing-image smoke is tool work here too, now that the emulator can be pointed at live snapshots. The first release is item 4. -
Publish. The first release:
releases/v<version>/acceptance.jsonfrom the authorized export,scripts/release.sh, the kas flip and the first GitHub release, per the site (Developers, "Release flow"), once ready to publish. Repoint the core submodule to upstream if thestep_us_minsizing fix merges. -
Update system — recovery refresh. A refreshed recovery image in boot0; the design is on install and update.
-
Head IRQ (exploratory). Owed for the head IRQ, only if a coarse hardware interrupt is wanted instead of the poll: arm the accel bit in the head MCU (reg 0x03/0x04), watch GPIO3_22, and read reg 0x02 to identify and clear. The beam-detect verdict (reg 0x05 b7, not currently exposed) is the same mechanism for the emission question and stays exploratory: whether the factory enables beam detect is unknown, and detection at low fire energies is unverified. A cheap opportunistic check during live fire still stands: log GPIO3_22 edges plus
head/beam_detect_digital|_analogwhile firing. -
The flow check on a second machine. The lit-tube flow check is in place: the engine reads means, takes its baseline under the run profile, and takes the tube's share off (
cool_laser_heat_cw,cool_laser_heat_density);cooling.flow-under-loadis the catalog's case, and the coolant-ADC facts (the air-assist ground offset,cool_aa_offset_counts, the tube-lit toggling) are in the facts bank. Owed, when a second machine is on the bench (one tube, one supply so far): re-measure the two heat coefficients and the machine's air-assist offset; and if a lit check still trips, the void-on-emission design with the tube as its own flow tracer. -
Initial commissioning: measure and set the machine's own numbers methodically. Every tunable that was measured on the bench machine and shipped as a default varies from machine to machine: the flow check's bands and
cool_flow_rise, the tube's heat coefficients (cool_laser_heat_cw,cool_laser_heat_density), the air-assist ground offset on the coolant readings, the laser's striking and lasing thresholds and the duty floor, the fan floors, the thermistor curve itself. Owed: one commissioning procedure, run once on a new machine from the panel or the bench page, that measures each of these in order with the tube dark wherever it can be, fires only where it must, and writes the results as that machine's settings with a record; and a reading of what the cloud sets for the same machine, taken from cloud cuts (the pulse header carries the factory's per-machine values), so the commissioning can start from the factory's own numbers where they exist and note where they differ from the measured ones. The dose-curve recorder (the panel's one-press ladder, fit and apply) is the first piece of this tool family and the template for the rest. Next piece, from the corner work: a side-by-side chooser - the tool cuts the same corner-heavy pattern at several settings of a knob (the corner rolloff first: a row of passes at, say, 1.0 / 1.25 / 1.5 / 1.75 / 2.0), labels them, and the operator picks the best by eye; Apply writes the winner. The rolloff is the proof case (this bench settled at 1.5 and may go lower, so the shipped default of 2 is a starting point, not a truth), and the same shape fits any by-eye tunable the commissioning flow meets.
Deliberately not gated: an armed GRBL job after an underrun cuts at the stale origin unless homing is required (GRBL mode permits unhomed cutting; the underrun itself alarms and unlinks the anchor). Not in the acceptance catalog by design, for the same reason. From the pulse-header envelope, each by decision and each declared in the cloud client so every job's log counts it as decided rather than missed: the warm-up fan profile (the run profile covers the warm-up hold), the supply temperature window (the service sends the whole ADC range and the factory binds it to nothing; the supply is watched per job instead), the head, lid, interconnect and fused temperature ceilings (no sensor at those locations; the chassis is watched per job), the head accelerometer thresholds (item 6), the lid IR thresholds (the fire watch runs on local knobs; the header values stay ignored), the HV current caps (the sampled emission witness covers the idle case, and HV current is ranged per job), the thermal report upload conditions and the pump flag. Beam detect stays with item 6.