cloud.oversize-stream drives a job the ring cannot hold and checks the
signature of a live feed: the load reporting the job as longer than the
ring, the device in live-feed mode, the kernel's program total growing
during the run, no underrun, and a clean cancel afterwards.
read_program_total gives the catalog the counter that growth is read
from.
The ring and the device-tree pool that backs it are now the size the
factory firmware runs. Docs follow, and the hardware facts bank gains the
reserved-memory map: 511 MiB usable, 96 MiB reserved for DMA, ~454 MiB
left to Linux.
image.health reads the pool size and ring_mb back and fails if they
disagree, so a pool the parameter does not use cannot ship unnoticed, and
checks that free never exceeds the ring less its gap.
Both recipes pin the same repository and the pin files say to move them
together. Documentation only on this side: the cloud doc now records what
the factory enforces from the pulse header, and what it does not.
Next-work item 19 carried three open questions about the factory's
operating envelope. All three are answered now, so the item states the
policy instead of the guess.
A fan tach alert during a cut pauses the print, taking the same transition
a user pause takes, and two of the factory's three tach monitors cannot
fire at all: they treat a zero limit as unconfigured and the limits arrive
zero anyway. So a stalled extraction fan is caught by the temperature it
causes, not by its tachometer. Every temperature alert pauses as well, and
a critical fails the machine outright, which is a different state and not a
pause. The scale question behind the header's ceilings is answered by the
coolant family, which the factory carries twice, once in raw ADC counts
where the tag named min is the hot limit, and once in millidegrees. And the
factory does not verify coolant flow at all, so the cooling engine's flow
check is ahead of the factory rather than behind it.
The crash, tilt and beam-detect bullets gain the same grading: an alert
threshold that pauses and a separate abort threshold that aborts.
Also drops references to paths that resolve outside this repo, in both
documents and in two bench scripts, naming the artifact instead. In
CAMPAIGN-LOG that is a wording substitution only; no date, claim or
measurement changes.
Documentation only, so no acceptance catalog consequence.
Add a "What this costs" banner to the README and INSTALL, below the
in-development notice. It states that the firmware is free in both
senses, that nothing is paywalled or held back, that the work happens in
public, and that anyone who wants to verify it can read the licenses and
the commit log instead of taking a stranger's word for it.
The banner also warns that a build bought from a third party is that
party's build, not this project's: unreviewed code driving a laser.
Documentation only. No behavior change, so no acceptance catalog
consequence.
The repo is public and the install path is fully written up, so a reader
who finds it can follow INSTALL.md end to end without ever learning that
the release it depends on does not exist. The banner sits directly under
the title in both documents: no images are published, nothing here is
installable, and anything found elsewhere claiming to be a ForgeFIRM
image did not come from this project.
Documentation only - no behavior change, so no acceptance-catalog
consequence.
The section describes two channels flowing opposite ways - the
controller's job-state reports and the engine's verdict - so "the report"
pointed at the wrong one directly under a lead that names the verdict.
MOTION said jogs, homing and hunts were all ungated. Jogs are:
gfsw_visible withholds the door signal while the core is idle, jogging or
homing, so a jog both starts and runs with the lid open. Homing is not.
With homing_mode = gfcloud - the only method that works today - $H hands
the cycle to a cloud homing session, and its move to the home corner is
an ordinary motion action taking the default lid_gated=True: refused with
the lid open, stopped on a lid edge mid-run. Only the lens/Z hunt inside
that session passes lid_gated=False, and that session is also the only
place a hunt happens in GRBL mode - there is no hunt outside one.
BRINGUP gains item 18: GRBL pause and resume should leave no gap in the
cut, the way the factory's does. The beam stops at the start of the hold
(disable_laser_during_hold, on by default), so the head travels the whole
deceleration dark and the resume restarts from a standstill where the
decel ended - an unburned length, then a dwell through the accel that M3
shows as a deeper spot. The kernel waypoint backtrack cloud mode uses is
refused with EPERM on a live-streamed ring, so the equivalent has to be
built above the ring, where grblHAL still holds the planned path the
kernel has already overwritten.
Two wording fixes: the cooling verdict is described as a report rather
than a file, and gfcloud homing as using the machine's builtin
credentials.
Documentation only - no behavior change, so no acceptance-catalog
consequence.
The shipped default changed, so the drill's precondition would have
refused to run on a stock machine - the one configuration it most needs
to be able to test. Only an explicit analog selection is a refusal now,
and the drill reports whether the model came from the config or the
driver default.
grblHAL-glowforge 03d70e0, pinned here, makes density the shipped model
and $35 the density floor at 10. Every analog session in the stream
harness now selects its model rather than inheriting it: without that the
flip would have quietly turned them into density runs and taken the
analog fallback's coverage with them. laser.power-floor carries the new
floor and its PWMSAR minimum, and its description no longer describes
$35 as a duty floor.
All ten stream sessions pass on the new defaults, with the analog duties
shifting exactly as the floor predicts - min_value 12, gradient 0.115 -
along with both C harnesses and the lifecycle harness.
Recorded in BRINGUP and the campaign log, including what the defaults
rest on and what they do not: the seven ladders ran at F300 and F100 at
constant power, so production feeds, M4 into corners and the raster path
remain unproven on hardware.
$35 = 10 under the density model is a density floor, not a duty floor. It
maps S onto 9.4-100 percent density, so a commanded 1 percent lands at
10.2 percent, just above the marking floor the earlier ladders measured.
A ladder reweighted to the bottom of the user scale - 1, 2, 5, 10, 20,
40, 70, 100 percent of S - marked on all eight rungs, with eight current
segments over a 42.0 s window against exactly 8 x 5.25, and segment means
climbing 136 to 968.
That meets the goal the ladders started from: a user's 1 percent is a
real visible mark rather than silence, and 100 percent is full power. It
took all three pieces - density so every level is real pulses, the
minimum pulse so they stay strikeable, the floor so the user's range sits
on the band that works.
dladder no longer tells the operator to re-run at other base periods to
choose one. The period cancels out of the low end, and what a failing
rung now indicates is a floor set too low.
min_ticks 6 broke 5 percent striking - seven current segments where 3 gave
eight, on a 36.5 s fire window against 41.4 s for eight rungs. Below the
minimum the model emits min ticks every min/on periods, so the interval
between pulses is min_ticks x tick / density and the base period cancels,
which is also why periods 10, 20 and 40 gave identical results earlier.
At 5 percent that is 2.26 ms at min 3, which struck, against 4.51 ms at 6,
which did not: doubling the minimum doubles the gap as well as the pulse,
and the discharge is re-struck each pulse.
min 3 sits at the factory's own operating point - its 6.5 percent engrave
jobs place 100 us pulses 1.54 ms apart against 1.64 ms for min 3 at that
density - and 6 is outside anything the factory does. The bench is back
at 3.
Measured band for this tube: strikes from ~5 percent density, marks from
~10 percent at F300. That closes the pulse-structure route to a usable
1 percent, since the interval grows as 1/density and 1 percent implies an
11 ms gap. The low end is a scaling problem, and $35 is the control.
The fifth ladder, the first with a minimum pulse, moved the floor down a
full rung: only 5 percent failed to mark, and 5 percent now strikes. The
trace carries eight current segments where the run before it had seven,
with fire beginning at 6.2 s exactly at rung 1 and the usual flat
saturated final segment anchoring the count from the other end.
That retracts what the previous entry concluded. Pulse length is not
irrelevant: 10 percent moved from no mark at F100, with three times the
dose per millimeter, to a mark at F300 at the same density, the only
change being its pulses growing from 36-71 us stubs to 106 us. The
matched-pairs argument was sound but drawn entirely from comparisons at
or above 20 percent density, where every pulse length in play was already
long enough - it generalized from the one regime where pulse length does
not bite. Above ~100 us dose governs; below it pulse length does; below
~36 us the supply does not strike. The factory's 100 us quantum sits on
that boundary.
dladder now reads laser_pulse_min_ticks and prints what is actually
emitted. Without that its table reports the pulse density alone would
give, which is wrong wherever the minimum applies - at min 6 the bottom
four rungs all emit 213 us and vary their rate instead, and the operator
reads that table to interpret the material.
Rule 15 in the stream harness holds both halves of the minimum
(grblHAL-glowforge f7e8c17, pinned here): no emitted burst falls below
laser_pulse_min_ticks, excepting one clipped by fire going off mid-burst,
and the levels too faint to fill a window still render their exact
average density. Checked against a run at minimum 1 so it cannot pass
vacuously - level 2 goes from 444 bursts of one tick to 147 of three at
the same density, and levels already above the minimum are unchanged.
Four bench ladders settle the base period at 20. The same six rungs
marked in all of them, and the matched pairs across periods separate the
variables: at identical pulse length, halving the density killed the
mark; at identical density, varying the pulse 3x changed nothing. Feed
does not move it either - 10 percent at F100 carries 44 percent more
energy per mm than 20 percent at F300, which marks, and still left
nothing. The low-end marking limit is average power, not dose per length
and not pulse length.
The F100 trace separates two failures that look alike on the material:
seven current segments for eight rungs, anchored by a flat saturated
final segment that can only be full density, put 5 percent at no
discharge at all and 10 percent at a full 15 seconds of current with no
mark. Only the first is ours, and the minimum pulse is the answer to it.
Also recorded: the factory's Precision Power 1 runs a 19.53 percent FIRE
duty cycle at full PWM duty, and its 1-100 scale maps onto density
18.9-79.5 percent, so its 1 percent is the bottom of the useful band
rather than 1 percent of the range. Under the density model $35 and $36
are that same control - a density floor and ceiling - which makes the
user-facing scale a settings choice rather than new code.
The trace is the per-rung witness: the laser-off G0 between rungs reads
0, so the runs of nonzero current count the rungs that struck and their
level tracks each rung's dose. Without it a run reports only a range,
and which rungs fired has to be read off the material alone.
Under the FIRE-bit dose model the duty is pinned at full and the level
is carried by how many ticks of each base period fire, so a rung's pulse
is density x period - and the base period is the one parameter the host
cannot settle. dladder walks 5 to 100 percent of dose on scrap at
constant power, printing what each rung actually emits: density, mean
on-ticks, and the pulse the tube sees in ticks and microseconds. Below
one tick per period the pulse stays one tick and whole periods are
skipped instead, which the table says outright rather than quoting a
sub-tick average.
Run it at 20, then 40, then 10 on the same material. At 20 percent dose
that is 107-142 us, 249-284 us and 36-71 us respectively, which brackets
the 100 us the factory never goes below. The material answers two
questions: whether depth tracks density linearly, or the low end marks
harder than its share because every burst restarts the discharge and
carries the strike transient; and how short a burst still marks, which
is the floor a given base period can reach.
Preconditions are refusals, not warnings: laser_power_model must already
be density and $35 must be 0, since a floor lifts every rung off the
bottom of the range the drill exists to explore. The $35 message says a
runtime write is not enough - the S to duty mapping is precomputed when
the spindle is enabled. The drill writes laser_pulse_ticks itself when
it can reach the machine config, preserving every other line, and
refuses with instructions when run from a LAN host where it cannot.
Four new sessions in the stream harness cover the model (grblHAL-glowforge
2bca017, pinned here). Density renders the commanded level exactly -
levels 2, 3, 7, 15, 25 and 38 came back as 0.0158, 0.0237, 0.0551,
0.1182, 0.1969 and 0.2993 against level/127 of 0.01575, 0.02362, 0.05512,
0.11811, 0.19685 and 0.29921 - S1000 renders 1.0000 and still ends dark,
every power byte carries full duty, and a level change inside a run costs
no stream byte where analog ships one per level.
Rule 13 is the one worth having: the same job run under both models
produces an identical motion grid tick for tick, and all 20051 density
FIRE ticks fall inside the 169776 the analog run fired. The model masks
the core's fire state and never sources one, measured rather than argued.
Rule 14 covers the idle-gap fix: a standalone S between moves, from a
sender slow enough to drain the planner, now fires each move at its own
level (28338 ticks each at duties 30, 52 and 84). Before the fix duty 30
held all 85014 and the other two levels never appeared. That closes
"Next work" item 18, which this work opened earlier today.
The harness now derives its expectations from the board's floor and
chains two launches over one settings file, because the core precomputes
the S to duty mapping once when the spindle is enabled: $35 written at
runtime persists and reports immediately but only enters force at the
next controller start. That is recorded in BRINGUP beside the existing
defaults note, and laser.power-floor's failure message now says so.
Acceptance: the density path shipping off by default is inert until
laser_power_model is set, and the laser tests' covers already name
grblhal-glowforge src/**; the model's own acceptance test waits on the
bench drill that picks the base period.
The pthresh ladder on scrap puts the tube's two thresholds far apart: the
discharge strikes between 2 and 3 percent duty, but nothing lases usefully
below 16 percent (PWMSAR 20), and the rungs between show only a spot at
each line start. $35 ships at 16 (grblhal-glowforge 9466f76, pinned here).
The drill said current lift-off and first mark share a rung; this run
falsifies that, so its docstring and read-the-material text now name both
thresholds and warn that a start-of-line spot is below the threshold, not
at it.
A start-of-line spot is also what a full-power leak at a kernel run start
would look like, so laser_stream_test gains a ladder session (rule 10):
every FIRE tick must ride a commanded duty, and the fire ticks must divide
evenly across rungs. Both hold exactly - six commanded duties, no others,
and 28296 fire ticks on every rung - so the spots are the tube, not the
stream. The harness now derives its expectations from the floor, which
moves the M4 session's S500 plateau from 63 to 73.
laser.power-floor is a new auto acceptance test, the suite's only
non-firing one: a machine must actually carry the commissioned floor,
since stored settings beat freshly baked defaults.
Three cloud cuts of one square at Precision Power 1, 100 and Full Power
show what the analog path is competing with: the power byte is pinned at
127 in all three, dose is FIRE-bit density on a fixed 7-tick period with
the on-count dithered between adjacent integers, and the power setting
never reaches the machine at all. Facts bank and item 17 carry the
numbers; CAMPAIGN-LOG carries both sessions.
The dated record for the jerky-at-2000-mm/min thread: what was wrong,
what changed, and what the bench measured.
Keeps the two results that matter. The A/B that isolated the cause -
two acceptance runs 90 s apart on one image, camera the only variable,
PASS with a nice-5 CPU hog and FAIL with the camera streaming - and the
LB-GF-OG-FM job re-run on the fix with the video live, clean at
min margin 3.1 ms of 10.
Also records that max behind is not the instrument: it reads 0.0 ms
through a passing run whose real margin fell to 4.9 ms, because the
producer never falls behind its own wakeup epoch. Only the measured
min margin shows the condition.
Bump the grblHAL pin to the producer lead and margin instrumentation.
Rewrite BRINGUP item 16 to the present state. The scheduling half is
done - the producer runs SCHED_FIFO below the shipper, core_mx carries
priority inheritance, and clamping is reported per run - and two bench
runs 90 s apart on one image show what that does and does not cover: a
nice-5 CPU hog passes clean while the camera streaming clamps 7 runs,
because per-frame cache maintenance over a 4.8 MB non-coherent capture
buffer is kernel-context work no userspace priority can preempt.
Record the margin finding, since it explains why a 4 ms stall was
enough: the queue depth cancels between the shipper's due index and the
producer's base, so the pacing lead is the only slack. Record the
cycle-churn ceiling that caps that lead at 10 ms, and that the re-base
fix is what unlocks more.
Camera gating stays owed, with capture resolution named as the lever
that shortens the stall rather than merely spacing stalls out.
The image has no `nice` binary - BusyBox ships renice only - so
motion.step-timing-under-load could not spawn its CPU hog at all. Set the
niceness from the parent with os.setpriority once the child exists, and
assert the value that actually took: a hog left at nice 0 would be a
harsher test than intended, and one left unset must not pass silently.
Add htop to the dev image for bench diagnostics. The release image is
unchanged - it carries neither forgetest nor htop.
Bump the grblHAL pin to the real-time producer change.
Add motion.step-timing-under-load: the catalog had nothing that
exercised step generation while userspace competed for the single core,
which is exactly the gap that let the condition go unnoticed - the ring
never runs dry, so cnc/underruns reads 0 through it. The test asserts
the producer and the shipper both hold SCHED_FIFO, then drives
2000 mm/min round trips against a deliberate nice-5 CPU hog and requires
the controller to report no clamped events.
Add the pthresh live-fire drill: a constant-power ladder from 2 % to
30 % of full on scrap. Because $35 is a percent of full duty and the
rungs are percents of $30 with $31 = 0, the lowest rung that marks reads
directly as the $35 value. It needs $35 = 0 for the run, or the floor
lifts every rung and hides the threshold.
Record both open items in BRINGUP. The laser one carries the finding
that the factory never uses duty as a power control - all five firing
jobs in the captured pulse files pin the power byte at 127 and modulate
dose by dithering the FIRE bit at 6.5-18.8 % density - so the captures
cannot supply a $35 default, and the duty to optical-power transfer
function of this supply has never been measured.
docs/VIDEO.md is the user-facing camera guide: what the endpoints return, what
the sensors can do that ForgeFIRM does not send and why, the privacy gate, and
the state of 8 MP support.
The 8 MP (OV8856) capture path now reaches the sensor's full 3264x2448 frame.
Its stock RAW10 full-resolution mode runs the link at 1.44 Gbps/lane and the
i.MX6 CSI-2 D-PHY stops at 1 Gbps; the BSP adds a RAW8 mode that carries the
same frame at half the rate, so an HD machine is no longer limited to the
binned quarter-pixel mode. kas/README.md carries the reasoning, the register
deltas and the factory configuration to fall back to if the receiver will not
lock at 720 Mbps/lane.
Acceptance: camera.sensor-profile expects the new OV8856 geometry, the camera
covers map names src/camhealth.* (the src/cam.* glob does not match it, so the
lint would have gone quiet on an uncovered file at the next pin bump), and a
new auto test camera.frame-health asserts a burst of captures with no frames
the capture queue flagged errored - a real signal about the camera ribbon even
though those frames never reach a client.
Two pages aimed at someone who owns the machine rather than works on it,
written for the documentation site. Nothing here is new behavior - it is
the behavior the machine already has, explained where an owner can find
it instead of spread across a kernel contract, a services contract and
three driver headers.
MOTION.md follows one thread: everything physical comes out of a single
fixed-tick byte stream, so the page starts there - the byte layout, speed
as step density rather than clock, the ring and the two ways to fill it,
and the hardware's own stop, halt and resume-with-waypoint. The laser is
presented as part of that stream rather than beside it, which is what
makes the three contract rules (power before fire, no consecutive power
bytes, end dark) and the persisting duty legible instead of arbitrary.
Then geometry and limits, device ownership and the liveness check the
operator sees, and the two modes in full: GRBL from connection through
the arming sequence, the stop/pause/fault table and homing-or-not; cloud
from the preloaded pulse file through the pause backtrack, the park that
ignores the lid, and the ring's cap on job length. A comparison table and
the motion-related settings close it.
COOLING.md explains the engine as what it is - one owner of the thermal
hardware answering a single question for whichever controller runs - and
gives the reasons behind the numbers rather than just the numbers: why
the flow check heats and measures the downstream rise, why 40 percent is
the duty (below it, convection mimics flow), why the settle gate uses a
split-half mean instead of peak-to-peak, and why one bad reading is a
suspicion rather than a fault. Over-temperature, the two diagnostics
tools and when to run them, the settings, and a situation-to-response
table. The fire watch is described honestly: the lid IR channels are
first of all a photometer for the lid lamp, the gate ships watch-only,
and it is not a fire alarm.
Both pages state what is not implemented - low-temperature gates, TEC
control, a fire watch that acts, limit-switch homing - so nobody plans
around them. Constants come from the sources that own them (the feeder
contract, cool.h, the board header, the services contract), not from
prose. README links both.
Documentation only, no behavior change and no catalog consequence: docs/
is outside every layer and .md is excluded from the layer content hash.
BRINGUP had grown to 3,122 lines in which the same subject was answered
differently depending on where the reader stopped: GATE A "stays open, no
live-fire" in the phase text and closed in the campaign record, the catalog
at 24 tests in one section and 35 in another, several "bench validation
pending" headings over bodies that recorded the pass.
Split by kind rather than by age. BRINGUP (907 lines) is the present state
only - status, bench runbook, laser, lid/interlock/button policy, homing,
forgectrl, diagnostics, logging, release acceptance, the measured facts
bank, and a Next work list of the 15 items that are actually open.
CAMPAIGN-LOG (2,657 lines) takes the dated blocks verbatim, in
chronological order, and is append-only: a correction is a later entry, not
an edit. Its two reading rules are stated up front, since moved text keeps
its original "above"/"below" and its pre-split item numbers.
Facts corrected against the tree while rewriting: the liveness probe gates
at p2p 800, not 500, with the real wedge, noise and jolt figures; the panel
has seven tabs including Logs and is built from src/ui/, not ui.c; the
devserver replaced tools/mock.py; /status reports real head presence; the
estop_halts_motion opt-in is gone; core PR #999 is merged, leaving only the
step_us_min commit fork-only; the update system stands at Phase 5, the
uSDHC pads and the 2026-08-08 kernel batch have shipped, and 8 MP camera
capture is tracked here for the first time.
SAFETY and UPDATE-SYSTEM point their drill records at the log. ACCEPTANCE
records that inheritance is local: there is no import of a published
artifact, so a second bench starts from a full campaign - one of the four
open items the retired tool plan held, the rest of which are now in
BRINGUP's acceptance item.
Documentation only, no behavior change and no catalog consequence: docs/
is outside every layer and .md is excluded from the layer content hash.
SAFETY: the chain de-energizes itself behind a pause without being asked -
the charge-pump feed ends with the run, so HV_ENABLE drops with the watchdog
about half a second in, and on the resume it is back ~216 ms before the first
step (pad measurements). A pause is deliberately not a cancel: the latch stays
unlocked and the window open, which is what lets the next press resume the
job, while emission still ends because the stream stops driving FIRE. The
disarm grace counts down through a hold, so a job left paused disarms itself,
and a lid or interlock open while paused takes the cancel path - nothing
resumes past an enclosure opening.
LIGHTBURN: the two things an operator needs before pausing a cut - the resume
restarts from where the deceleration ended and accelerates from a standstill,
which M3 shows as a deeper spot and M4 mostly hides, and a job left paused
disarms itself and asks for the button again. Stop leaves the head where it
stopped; returning to the job start belongs to the lid and interlock policy
alone.
Item 16 becomes the record of what the machine does rather than a list of
what is left: both modes cancel a job on a lid or interlock open - running
or paused - and return the head to where the job started with the lid still
open; the park ignores the lid; the arm wait cancels with the reason named;
the button pauses and resumes, with the factory's backtrack and lead in cloud
mode and feed hold / cycle start in GRBL; lid_policy = hold keeps the stock
door behavior for senders that want it. Item 4's mid-job Door hold is that
policy now, and item 12's LightBurn door handling is closed by the cancel -
LightBurn never lives in Door on the default path.
The GRBL resume dwell that was left open is decided against, on measurement
rather than on the mark: the chain re-arms within ~3 ms of the resume while
motion restarts ~219 ms later, so there is nothing to cover.
The two planning files at the tree root are merged and removed, as the audit
plans were. What was durable in them lives here now: the factory's own
reaction timings on 2.6.0-2228, the pause/resume behavior of the safety chain
at pad resolution, and why the hardware button latch is what makes the armed
window honest. The factory session log they were written from is archived
under _RESOURCES/.
Item 15 gains the catalog's current shape: 35 tests after the parity work and
the sweep that merged the tests sharing a setup, with the auto tests left
separate.
Bench (image 20260817124714): motion.lid-cancel-home FAILED on a machine that
did the right thing twice. Both phases logged the cancel, the reset banner and
"returned to the job start" with grbl drift 0.000, and the counters ended at
the true rest position - but the hold phase's REFERENCE was 2.87 mm off, so the
comparison measured a transient.
The reference was sampled straight after the jogs that close the running phase,
and those wait on grblHAL's Idle. grblHAL is Idle when its planner is empty; the
kernel is still playing the stream depth and the decel tail behind that. A
counter read in that window records a position the head is only passing through
- and the resulting drift reads exactly like the failure this check exists to
catch, a move counted by grbl but never played by the machine.
So the check stays as strict as it is (it is the one that caught the stranded
ring on 2026-08-17): what changes is where the reference comes from. kernel_start()
waits for the MACHINE to be idle - the helper the motion tests already end on -
before reading the counters, and every reference capture in the lid, interlock
and live cancel tests goes through it.
Bench (image 20260817124714): motion.button-hold-resume FAILED on a machine
that did exactly the right thing. The operator paused about 7 s into an 8 s
move, so the resume had a fraction of a second of travel left; the job was
already Idle by the next poll and the test - which insisted on catching the
Run state - called it "the second press did not resume the job". Its own
evidence contradicted the verdict: the kernel counters read 2133 = 40.000 mm,
the whole move, and the position check two lines below would have passed.
Catching the state a command moves INTO is a race whenever the remaining work
is short. What proves the press was acted on is the job LEAVING the hold, so
that is what the test waits for now (wait_left_state), with the state it left
into required to be Run or Idle - not Alarm or Door - and the existing "landed
on its target" check still doing the real work. laser.pause-resume-lid-cancel
had the same shape and gets the same treatment.
Second defect, visible in the same record as "message seen: False": the driver
DOES report "button pressed - job paused", but Grbl.status_report() began by
discarding the read buffer and then kept only what followed the report, so
every asynchronous [MSG:] line that landed during a poll was thrown away. It
now consumes status reports only - stale ones included, which is what that
discard was for - and leaves everything else for drain(). Tests that assert on
what the controller said open their window with an explicit drain() before the
prompt, so the text they judge is the text from the action.
M4 scales power with speed, which is exactly what hides a restart in the cut -
the thing the test asks the operator to look at. At constant power the resumed
cut accelerates from zero under full power and leaves the deeper spot where it
picked up, which is the artifact worth seeing (and the one the plan wanted
characterized for M3 and M4).
/mode reporting the controller running means the supervisor has spawned it,
not that the listener is bound. Connecting straight away would fail the test
on that race - after an arm press and a burn - instead of on the behavior it
is about.
A sweep of the whole catalog for the overlap the drill work found. The test
applied was "do these share a SETUP", not "do these share a subsystem":
combining only pays where a human waits - an arm press, scrap, a takeover,
a mode entry, a lid choreography - and it costs failure isolation, because
the campaign inherits per test and a merged test invalidates as a unit. The
17 auto tests were left alone for exactly that reason: they cost no operator
time and separate ids give the coverage map and the domain invalidation
finer teeth.
kernel.k3-unlock + kernel.fire-abu -> kernel.fire-line
Four phases behind ONE takeover of the pulse device instead of two:
A/B/U on the FIRE line, then the mid-ramp unlock. Same HV-not-good
gate, same zero duty, same safe state on the way out.
laser.expected-stop + laser.kill-mid-fire -> laser.armed-kill
Both ways an armed job is killed, on one scrap setup: the supervisor's
expected stop (with its separate operator-judged restart) and then a
SIGKILL of the restarted controller. The second phase re-reads the pid
after the restart, so it kills the process the supervisor just spawned.
laser.lid-cancel-mid-fire + laser.pause-resume-live -> laser.pause-resume-lid-cancel
One armed burn in the order the factory uses the machine's controls:
press (pause - emission stops, latch stays UNLOCKED, armed window
stays open), press (resume), lid (cancel, reset without alarm, return
to the job start, button latch SET). One arm press instead of two.
cloud.lid-abort + cloud.interlock-abort-park -> cloud.lid-interlock-abort
Two prints in one test: the lid, then the interlock with the lid opened
during the park. The tail both share - park complete, kernel counters
back at the job start, ':cancelled', latch locked, armed window closed -
is one helper now, so both triggers are judged the same way.
Not merged, though they share code: cloud.gfhome-homing and cloud.hunt-lid-open.
Both drive Machine._hunt (gfhome.py and gfcloud.py build the same gfhardware
machine, so the lid ungating is the same lines), but each is about the opposite
value of the same variable - gfhome's homing is camera-corrected and needs the
lid CLOSED in GRBL mode, the hunt test needs it OPEN through a cloud-mode
connect. Merging would put a mid-test mode switch back into the cloud tests.
Shared live-test prologue (the arm cue, the mark job, the wait for the emission
witness) and the post-kill trail/judging are helpers now.
Host proof: 104 unit tests - the merged cloud test replays the machine's own
lid-abort excerpt twice, once with the interlock substituted for the trigger,
and three negative cases hold it honest (loop already open, a park the lid can
interrupt, a stop that is not edge-driven) - and the coverage lint at 0
uncovered across 35 tests. Catalog time 137 -> 125 minutes, 96 of it attended.
In GRBL mode the return to the job start is a G0 rapid - a few tenths of a
second over the distance the job had travelled - so an operator cannot open
the lid during it, and a step that asks them to either races the cancel (with
the lid open at cancel time the reason reads "lid opened", not "interlock
open") or lands after the park is over and proves nothing.
The park's immunity to an open lid is covered where it is real: motion.lid-cancel-home
parks with the lid open from beginning to end, and cloud.interlock-abort-park
has a ~6 s park with room for a genuine mid-park lid edge. What is left here is
the interlock as its own trigger, with its own message, and the loop still open
when the head gets back.
Catalog 34 -> 39. Every remaining bench drill of the lid/button parity work
is now a test, with drills that exercise the same path combined:
motion.lid-cancel-home also cancels from a hold - a job paused on the
button is ended by the lid, never resumed - so the
armed window and the hardware button latch stay in
agreement by construction.
motion.cancel-abort also asserts what a sender abort must NOT do: it
stops where it stopped and never returns home. The
return-to-start belongs to the lid policy alone.
motion.interlock-cancel-park (new) the interlock loop cancels like the lid,
and the lid opened during the return home does not
interrupt it.
motion.lid-policy-hold (new) the other policy: Door park, no cancel, no
return, and a cycle start finishes the move. The
setting is restored on the way out.
laser.pause-resume-live (new) the button pause/resume during a live cut:
emission stops, the latch stays UNLOCKED and the
armed window open (a pause is not a cancel), the
next press resumes and the job finishes.
cloud.interlock-abort-park (new) the same interlock/park pair in cloud mode.
cloud.pause-cancel-paths (new) the two non-finishing ends of a print, each
from the state the factory ends it in: paused on the
button then cancelled by the lid, and cancelled from
the app while running.
The shared cancel tail (reason reported, reset without an alarm, position
kept, head back at the job start with the KERNEL counters confirming it) is
now one helper, so every trigger is judged the same way.
scripts/bench/resume_dark_lead.py: samples LASER_ON, FIRE, HV_ENABLE, the
charge-pump watchdog, the button and the doors off the SoC pads at ~2 kHz
through /dev/mem, with motion dated from the kernel step counters, across a
pause and a resume. Levels are taken at idle and everything after is reported
as a change from that baseline, so no polarity assumption is baked in. Dry by
default, with --auto driving the pause and resume through ! / ~ for an
unattended rehearsal; --run live adds the dark lead between FIRE and LASER_ON,
in milliseconds and in millimeters at the job's feed.
Bench registry: argument specs can name a flag (--feed 600) instead of being
positional, and an optional argument with an empty default is left off the
command line entirely.
Host proof: 104 unit tests (20 in the cloud suite - the two new cloud tests
replay the machine's own lid-abort excerpt, with the interlock and the app
cancel substituted for the trigger, and fail for the right reasons), coverage
lint 0 uncovered across 39 tests.
The cloud job tests (lid-abort, lid-during-button-wait, hunt-lid-open,
pause-resume) run in cloud mode and leave the machine there: enter_cloud
reuses a live session (pid-scoped from the client's own websocket state
lines) and switches once from GRBL mode, declaring the change to the
baseline; nothing switches back. Each test judges the log from its own
window, the print by its own "print [id]: finished" line, and waits the
service's follow-up moves out (wait_quiet) before it ends. hunt-lid-open
restarts the cloud client through the supervisor's stop/start lever for
a fresh connect. The former switch-back is what failed the last bench run
of hunt-lid-open (409 machine is not idle: the service was still
re-finding the head after the lid closed).
The baseline is mode-aware: in cloud mode the client owns the GRBL
controller's init values, the lid lamp, and the position counters; the
mode itself is preserved unless the run declared the change
(Context.mode_changed); controller_mode is never handed back as a bare
setting (a bare write left the persisted mode out of step with the live
one). The undeclared-change restore now uses the captured state, which
the post pass never saw before.
The acceptance page gets an "Ignore prerequisites" switch (remembered by
the browser): POST /start {ignore_requires} starts a test whose requires
are unmet, and the run records the unmet prerequisites in its evidence
and log; they stay required for the release. The cloud tests' requires
no longer chain through cloud.mode-switch.
Proof: tests/test_cloud_suite.py replays the four tests on the bench's
own gfcloud excerpts (fixtures/) and the run loop's emitted pause/resume
lines under the real runner Context against a fake forgectrl; baseline
mode tests and the server override test; the whole suite (98) and the
coverage lint pass. Catalog consequence: cloud.* fingerprints move with
the module; the catalog hash moves with the requires.
cloud.lid-during-button-wait failed on a healthy machine: it counted
every "starting run" since the session start, and a cloud session runs
the connect-time hunt and several service moves before the print. The
checks now use the print's own window: runs between "waiting for
button" and the print's ":cancelled" (none allowed); the print's run is
the "starting run" after its button wait (lid-abort, pause-resume); the
lid edge timed against the stop is the last edge before the stop line
(an earlier open to place the scrap is not the one); hunt-lid-open
judges the hunt's own terminal line and refusals before it (service
moves after the hunt are rightly refused with the lid open).
cloud.lid-abort now proves the park with the machine's own counters
(cloud clears them at every job start, so a completed park reads back
at (0,0)); stale ring bytes replayed ahead of the park would not.
The lid-cancel tests (motion.lid-cancel-home, laser.lid-cancel-mid-fire)
now check that the KERNEL position counters returned to the job start,
not only grbl's MPos - grbl's drift read 0.000 while the head had not
moved. The baseline reports unplayed bytes queued in the kernel ring
(cnc/position total minus processed) as a leftover and refuses its
return jog while any exist: a run started on top of them replays them
first, which is what put the head into the rail. BRINGUP item 16 records
the bench finding and the stream-engine fix.
/mode reports controller=running at the spawn, so the reference dump
raced grblHAL's init writes and captured the supervisor's motion-probe
values (motor_lock 0, step_freq 10000, y_mode at the module default)
instead of the resting state. boot_reference() now waits for the
controller's markers (step_freq/motor_lock/y_mode at their fixed values,
bounded 20 s, GRBL mode only) plus a 1 s settle before dumping, retakes
a saved reference that shows the pre-config state while the boot is
still fresh, and marks it otherwise. Tests cover the wait, its timeout,
the non-GRBL no-op, the pre-config recognition and the retake.
The lid/button/interlock parity change (BRINGUP item 16): the arm wait
and a running job cancel on lid or interlock the factory's way, the
button pauses/resumes, lid_policy and the cloud pause tick settings.
Cloud tests cloud.* and the new motion/laser lid tests cover the
components; the python3-gfhardware / gfutilities pins move with this in
meta-openglow.
laser_lifecycle_test.py: the button toggle (press = Hold, press = Run;
the arming press is not a pause), lid and interlock cancel mid-job with
the return to the job start and no alarm, and lid_policy=hold.
Acceptance catalog: motion.button-hold-resume, motion.lid-cancel-home
(operator: travel job, lid open -> cancel message, banner, no alarm,
autonomous return, Idle at the start), laser.lid-cancel-mid-fire (live:
emission stops in hardware, cancelled, returned, armed=false, latch
locked, hardware button latch SET). covers name the switch and laser
sources explicitly.
LIGHTBURN.md: what the lid, Stop and the button now do; SAFETY.md: the
door policy and the button as a software layer; BRINGUP.md: item 16
records the whole parity change (host-proven, bench validation pending)
and items 4/12 point at it.
Four cloud-mode catalog tests driven from the app with the operator,
proven from the client's log (the same record the wire gets), forgectrl
/status and /cool/status, and the kernel latch readback:
- cloud.lid-abort (live): the lid edge reaches the controlled stop
within 60 ms, the head parks with the lid still open, the latch
relocks, the armed window closes, the print ends ':cancelled';
- cloud.lid-during-button-wait (operator): the lid at the white-button
prompt relocks and cancels; no run starts;
- cloud.hunt-lid-open (operator): the connect-time hunt runs and
completes with the lid open;
- cloud.pause-resume (live): the button pauses (stop + backtrack) and
resumes; the job completes and parks; nothing relocks or cancels.
Shared helpers enter/leave cloud mode the way cloud.mode-switch does
and return the head afterward.
laser_lifecycle_test drives the controller's button wait through the
file-backed switch source (GF_SWITCH_FILE): a press with the lid closed
arms and nothing arms before it; the lid or the interlock loop opening
during the wait cancels the job (reason reported, alarm 3, never armed).
Acceptance catalog: laser.arm-wait-lid (operator kind - no press is
given, so nothing can fire) starts a laser job, has the operator open
the lid at the white-button prompt, and checks the cancel message,
alarm 3, armed=false, the kernel latch locked, no emission, and Idle
after $X. covers names glowforge_laser.c, glowforge_switches.c and
glowforge_switch_map.h explicitly.
LIGHTBURN.md: opening the lid or the interlock loop while the button is
lit cancels the job; a press with the lid open never arms.
The panel moved out of ui.c into src/ui/{index.html,panel.css,panel.js},
bundled into index_html[] by CMake. forgectrl.panel-serves now covers
src/ui/** and checks that the served page carries no external asset
reference - the bundle is what keeps the panel a single self-contained
response, and this is where that would break. Fetch-verified and
cross-built in the build tree (bitbake forgectrl).
The forgectrl commit adds .devcontainer/, .vscode/tasks.json and
.env.example (the panel dev server replaces tools/mock.py, which was
already allowed under tools/**). None of it ships or changes target
behavior, so the coverage lint allows those paths for every component
rather than demanding acceptance coverage for editor setup.
Fetch-verified in the build tree (bitbake -c fetch forgectrl).