mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-27 08:41:13 -07:00
f0ba4b9249bf13c2cabf52e3dbcb56733700d69f
198
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
f0ba4b9249 |
forgetest: motion.job and laser.recorder-dark
forgectrl's job runner (POST /job, and the dose-curve recorder and the sheet wizards moved onto it) needs a test of a posted program, and nothing in the catalog ran the recorder's start: only its refusal and its status. motion.job posts programs the way a client does, as a multipart form. A program with a $ line and a job with no name are refused with 400, and a job beside the suite's own Grbl client with 409, each with nothing moved. A dark program (out 20 mm, a dwell, back) then plays: /status names job:forgetest as the lease holder of kind sender, GET /job reports it running, and a second job, a port jog, and a settings write are each refused in the job's name. At its end the record says done with every line and the runner's own M2 acknowledged, no discharge and no LASER_ON sample, the kernel's counters saw the 20 mm and are back where they began, and the lease is free. POST /job/abort stops a 40 mm move short, into the controller's alarm state, with the lease free. A job sent into the alarm fails at its first line with nothing moved; the same job with unlock=1 clears the alarm and returns the head. laser.recorder-dark starts the recorder and never presses. While the controller waits at its arm: the recorder holds the machine as a sender, GET /job shows its run (not a posted program's), the floor and the curve read 0 and off, POST /job/abort refuses to stop a run it did not start, a posted job is refused in the recorder's name, and two seconds of witnesses stay dark. POST /curve/stop ends it: stopped before the ladder fired, the lease free, both laser keys as they were. The ladder begins at the controller's X0 Y0, so the head is returned by a posted job. covers: setup.sheet and forgectrl.lease name src/jobrun.*, since the sheet wizards stream through the runner and the recorder's refusal comes from it; setup.sheet names src/lease.* for the hold its jobs take inside the wizard's. Proven on the bench reference, the job runner's daemon hot-deployed over image 20260920130136. motion.job: PASS; the kernel saw 20.000 mm out and 0.000 back, 6 lines with 7 sent and acknowledged, every witness zero, the abort at 9.83 mm of 40 into Alarm, the unlocking job with its $X and its M2 the two lines more. Its first run failed, and found a defect in the daemon and not in the test: the record said done with the head 0.19 mm short of its end, because the controller acknowledges M2 before the pulse engine has played the last of its ring. The runner now waits for the kernel to be idle, and the test passes unchanged. laser.recorder-dark: PASS; the controller reached its arm wait, 16 samples dark, the stop left the controller Idle and the head where it was. setup.sheet: PASS with the operator at the machine. forgectrl.lease: PASS. The unit suite passes (420) with no undefined name. Acceptance. These two tests are the gate for forgectrl's job runner, with setup.sheet for the sheet wizards' side of it. |
||
|
|
888b47abe8 |
forgetest: the hand-back never moves the head across a lost counter frame
Seen on the bench reference, twice in one session: at the end of a passing run the hand-back jogged the head 30 mm into the back-left stop blocks, from a head that had not moved. The baseline compares the kernel's step counters at the end of a run with the start and jogs the head back by the difference. The GRBL controller zeroes those counters at every start (the lens's startup reference) and at every home (home_completed()), and rewrites its anchor, /run/grblhal.homed, each time. Across either event the difference between two counter readings is not a distance the head traveled. It stayed hidden because the counters normally read zero between tests. homing.manual broke that: a manual home zeroes the counters 30 mm out from where the test began, so after the test's own correct return they read -6400, and the next test that restarts the controller (setup.check-flow-verify, then motion.release) ended at 0, "expected -6400", and was "returned" by 30 mm. An operator who jogs the head from a Grbl client and then starts any takeover test from the page would have met the same thing, by whatever distance they had jogged. The baseline's capture() now records the counters' frame, the anchor's inode and mtime. If the frame changed during the run and the test did not vouch for the new one, the hand-back logs that the two readings share no frame, and moves nothing. ctx.counters_rezeroed() is how a test vouches: it now sets rezero_declared beside the position it expects. The start_reads argument it briefly took is gone, since it made the baseline accept counters that nothing after it could live with. homing.manual restarts the controller once more after returning the head, so it ends with the counters at zero where it began, and declares that. events.stream waits for its three places. A stream an earlier test closed keeps its place until the daemon's next write to it (its keep-alive), as documented, so run straight after forgectrl.lease the third stream drew 503. The test now opens the three once they can be opened, and says so in its log. Proven. test_baseline gains test_a_lost_counter_frame_never_moves_the_head: counters at -6400, a new anchor, counters at 0: no jog, no leftover, and the log says why; the same counters with the frame intact are still a displaced head; a declared re-zero is held to the position it declared. With the frame check unable to see the change (the first cut of the test reused an inode inside one clock tick) the case fails with ['position'], which is the old behavior. The unit suite passes. On the bench reference, arranged so a failure would move the head away from the stop: the head jogged to +60 mm (counters 12800), motion.release run, PASS, "the controller re-zeroed its counters during the run ... the head is not moved", and nothing moved. forgectrl.lease then events.stream: two logged waits, PASS. homing.manual then setup.check-flow-verify, the sequence that drove the head into the stop: both PASS with a clean hand-back. |
||
|
|
520bf6023d |
forgetest: forgectrl.lease
The acceptance test for forgectrl's machine lease. The switches check is started and left waiting at its first prompt: it moves nothing, and it holds the lease for as long as it waits. /status must name it as the holder (wizard:switches, kind hardware), and everything that asks the lease must be refused with 409 and the holder's name: a diagnostic, the dose-curve recorder, a log export, a mode switch to the mode already in force, POST /controller/start, and POST /settings, the last with "settings are locked". Only requests that would do no harm if the lease failed are made: no reboot, no boot-slot change, no update job; and whatever a failed refusal may have started is stopped on the way out. The check is then aborted: the lease must read free, POST /settings must be accepted again, and the event stream, open through all of it, must have reported lease.changed with the owner and then with null. Passes on the bench reference. The nested hold (a diagnostic under its cooling wizard) is setup.check-flow-verify's to exercise, and it passes there with /status reading diag:flow-verify under wizard:cooling.flow-verify through the run. The unit suite passes (418). It covers forgectrl's src/lease.*, src/main.c, src/status.*, src/wizdark.*, src/diag.*, src/curverec.*, src/update.*, src/logs.*, src/super.*, and src/events.*. |
||
|
|
25cc42b879 |
forgetest: events.stream
The acceptance test for forgectrl's GET /events. The daemon counts event streams per peer address, so the test is several peers at once: it binds its client sockets to 127.0.0.2 through 127.0.0.5, which are all this host. Three streams must each get 200, text/event-stream, and the hello event; a fourth address must get 503 with the reason while GET /settings still answers; a second stream from the first address must be served, and the older one must get bye and the end of its response; POST /motion/release and /motion/energize must arrive on a stream that was open all along as motors.released and motors.energized, in order, with ids that count up; and with every stream closed a place must come back within 25 s, since a closed client is only noticed at the daemon's next write to it (its keep-alive). The SSE client was run against a host-built daemon before any bench time was spent, which found two defects in it: its reads blocked for the socket timeout, not the window the caller asked for, and a replaced stream ends with the last chunk of the response, not with the socket closing. Passes on the bench reference: the fourth stream refused in words, the replacement told and ended, alarm, motors.released, and motors.energized as ids 1 to 3, and a place back 3 s after every stream closed. It covers forgectrl's src/events.*, src/main.c, src/status.*, and src/grblport.*. |
||
|
|
8856fb0099 |
forgetest: the motor release, the manual home, and the controller port
Four catalog tests for the controller and daemon change of the same area, and one hand-back rule they needed. motion.release (takeover, 8 min): $MD takes the X and Y step currents to 0 with the 40 V rail untouched (no supply line in the kernel log, cnc/state idle, no fault), drops the X and Y reference, and locks the machine in alarm; a jog, a G0, $X, and $X after a soft reset are refused; forgectrl refuses a switch to cloud mode with 409; the kernel's position record is the witness that nothing was shipped, since the accelerometer cannot say "still" across a release (the rotors relax and the head feels it); $ME restores 33 and 5 with no fault; and the drivers are proven alive the way the machine proves it to itself, by the Setup motion check's liveness probe and witnessed jogs. homing.manual: a manual $H after an outbound jog plays no pulse byte, clears the counters, declares manual_home_x and _y, keeps Z, turns the soft limits on (error 15 behind the home), tells the client the home was set by hand, and reads back through forgectrl as source manual. motion.port-jog: with the suite connected as the Grbl client, POST /motion/jog moves the head by what was asked on the kernel's counters, the client is told and not displaced, and /motion/state agrees. The client then polls the way LightBurn does, '?' with an end of line behind it: no port jog may be refused for it, a 30 mm jog must run whole with at least three polls landing inside it, and every poll must draw its ok. The cancel stops a long jog short; the client's own line stops a fast one (100 mm at F6000, fast on purpose: a slow jog stops at once and would pass with no hold) and must draw ok, never an error; the 100 mm bound holds with nothing moved; the release and the energize go through their routes with the currents read; and the head is returned, in requests of 100 mm at most. laser.port-dark (live, one button press): a 20 mm line at M3 S400 with no M5 and no program end after it leaves the armed window open with M3 modal and S above zero, the state in which an injected G1 would fire. The line must be witnessed lit or the case proves nothing, the window must read armed before and after, and through three port jogs back over the line the LASER_ON sample count stays 0, the HV current stays idle, and the head's beam detector does not rise over its level before the jogs (its own pre-jog level, because it may still be settling after the cut). The hand-back. The baseline compares the kernel's step counters at the end of a run with the start. A manual home clears them by design, and homing.manual takes its home 30 mm out from where it began, so after the test's own correct return the counters read -6400: on the bench reference the baseline "returned" the head 30 mm the wrong way and failed a run whose body had passed. ctx.counters_rezeroed() now takes start_reads, what the counters read in the new frame with the head at its starting position; homing.manual passes the start minus the counters at the home. Without the argument it means what it meant: re-zeroed at the start. All four pass on the bench reference: motion.release (the rail untouched, every refusal, the 409, the probe and four witnessed jogs), homing.manual with offsets 12.5 and 8 (declared 12.502, 8.002, a clean hand-back expecting -6400), motion.port-jog (10.000 mm for 10; 8 of 8 accepted under the poll, 30.000 mm with 5 polls inside the jog, 14 polls and 14 oks; the cancel at 10.3 of 40; the client's line at 33 of 100 with ok; drift 0.000), and laser.port-dark (emission peak 148 on the line; 20 samples across the jogs with 0 emission, HV 0, beam rise 5). The unit suite passes (418). Coverage: motion.port-jog names forgectrl's src/grblport.*, src/main.c, and src/status.*; motion.release adds src/status.* and src/wizdark.*; laser.port-dark adds src/grblport.*; the driver's new sources fall under src/**, which every motion and laser test already names. |
||
|
|
b29bb9e023 |
bench: the controller port and manual home harnesses
Two host harnesses for the grblHAL driver's null-sink build, in the bench
registry and the README with the others. The driver's CI runs them.
ctlport_test.py drives the controller port beside a scripted Grbl sender
that counts every ok and error it is sent, which is the only way to see a
status routed to the wrong source. 13 cases: the socket's mode; a port
jog's status going to the port with the sender's count exact and the jog
run whole under the sender's '?' polls; a port error the sender's next line
does not inherit; the sender's line canceling a fast port jog and drawing
its own ok (fast on purpose: a slow jog stops at once and would pass with no
hold at all); a sender line queued right behind the port's; the refusals;
one client, and five reconnects right after a close; the status hook across
a soft reset; the dead-man; a CR LF sender; a sender that polls the way
LightBurn does, '?' with an end of line behind it, LF and CR LF (every port
jog accepted, a 60 mm port jog run whole with the polls landing inside it,
one ok per poll, a real line still canceling a port jog); and every
operation of both sets under an open armed window with M3 modal and S500,
where the dump must hold no FIRE tick.
manual_home_test.py reads the stream dump (GFSINK_DUMP) and the attribute
log (GFSINK_ATTR_LOG), so it can say that nothing was shipped and which
current was written, and how many times. 9 cases: a manual $H ships no step
and no FIRE tick and declares the offsets with the soft limits on and Z
kept; $H refused in a cycle; $MD refused under an open armed window with
nothing written; every motion source and $X refused while released; each
energize written exactly once; the port's panel operations; both pairs of
home offsets, alone and at once; and a controller killed under a release,
whose replacement writes only 0 and 0.
Both pass against the driver's extensions tree. The poll cases fail against
the driver without its empty-line rule ("8 of 8 port jogs were refused under
a status poll"), which is the defect they were written from: it was found
on the bench reference with LightBurn connected, and the sender these
harnesses had until then polled a bare '?'.
|
||
|
|
f306c9983f |
forgetest: the hand-back reads an engine hold again past the engine's next tick
The cooling engine publishes its state once a tick (1 Hz). A /cool/status
read inside the tick after a run ended still shows the run: while a
diagnostic owns the hardware every tick publishes phase "diag" with the
hold set, and a fail tier's hold stands until the tick that ends its
session. The baseline took one read, and by its rule an arm or a hold is
the run's doing, so a test that finished inside that second failed its
hand-back on a hold the engine's next tick cleared.
Seen on the bench reference twice. cooling.aa-offset-calibrate in campaign
c-20260919215024-c402: the diagnostic reported done at 22:07:14 with its
offset measured (15.7 counts, spread 0.7), and the hand-back at 22:07:15
read "cool=diag/armed=False/hold=True ... -> waited" and failed the run;
the test had passed on five images before, the last one earlier the same
day. cooling.fail-tier-stop in c-20260919202934-3d3a, the same way on the
crash fault's hold (
|
||
|
|
dc7170d0d1 |
forgetest: cooling.flow-under-load finds the verdict by offset, not by a tail count
The test counted "heater rise" in the last 300 lines of the forgectrl log before the job, then waited for the count to grow. A tail of fixed length cannot show that: the new verdict line comes in at the bottom as an old one leaves at the top, the count does not move, and a check that verified reads as one that never judged. Seen on the bench reference in campaign c-20260919210959-8780: the engine logged "coolant flow verified (heater rise 11.5 C, dT 9.5 C; laser 1.6 off 13.1)" at 21:37:46, 70 s into the job and inside the wait, and the test failed at 21:39:21 with "the engine published no flow verdict within 120 s". The 21:17:27 verdict of an earlier test sat near the top of the 300-line tail when the test began. Replayed against that log, the old method reads 4 before the job and 4 with the new verdict in the tail; the search from the byte offset finds the 21:37:46 line. The test now takes the log's byte offset before the job (_log_offset, as the fail-tier and liveness tests do) and searches what the file gained since with the verdict expression, stopping only on a line that matches. _log_since reads a file that is now shorter than the offset from its start: a rotation under the test leaves only newer lines. The /logs/tail helper and its line count are gone with their one user. Proven: tests/test_cooling_suite.py FlowVerdictLogTests - the text after the offset alone, the real verdict line through the expression, an old verdict before the offset not taken for the new one, a rotated log read whole, a missing log read as nothing; the cooling host tests pass under Linux, 24 tests. No component source changed, so no pin moves. |
||
|
|
0ceb4abc4a |
forgetest: cooling.fail-tier-stop waits for the crash fault to end with its session
The test raised a crash fault and handed the machine back with it still
standing. The engine trips the crash tier on one 1 Hz tick and ends the run
session on its next tick, which is when the fault and its hold clear ("head
crash fault cleared with the run session"). The supervisor has the new
controller up inside that second, and the test returned as soon as it saw
the new pid and the two log lines, so the runner's hand-back check could
read /cool/status before the clearing tick: phase run, hold true. A hold is
the run's doing by the baseline's rule, so the check recorded a leftover
and failed the test, though the wait that followed ended on its own.
Seen on the bench reference in campaign c-20260919202934-3d3a, the only
failure among 65 results: the trip at 20:54:44.871, the new controller at
20:54:45.094, the test's PASS line at 20:54:45, the session end at
20:54:45.863, and "cool=run/armed=False/hold=True ... -> waited" from the
hand-back check. The engine opened no session after that one; the 16 s the
check waited were the smoke phase that follows an armed session.
The test now asserts what the engine documents: after the restart checks it
waits up to 10 s for the verdict to leave CRASH, records how long that took
(fault_cleared_s) with the phase, verdict, hold and armed flags it found,
and fails when the fault does not end with the session. The machine is then
handed back in the engine's own post-job phase, which the baseline already
treats as the engine's work and not a leftover.
Proven: the cooling, artifact, campaign and queue host tests pass and the
catalog loads with the new source. No component source changed, so no pin
moves; the test's own source hash does, so its result comes from this
revision.
|
||
|
|
d482e76402 |
installer: a download that resumes and retries, and an install log
A field install failed on "firmware download failed", and worked after a reboot. The download was one bare curl -fL: no retry, no resume, no bound on a stalled transfer, and nothing on the machine recorded what had gone wrong. The download. download_fw makes up to five tries, 5, 15, 30 and 60 seconds apart. Each try resumes the partial file (curl -C -) and is bounded: 20 s to connect, and a transfer below 1 KB/s for 30 s ends the try. The file is written as forgefirm.fw.part and takes its name only when curl finished; the signature check that follows is what vouches for its content. A full disk (curl 23) and a release that is not there (HTTP 404) end the tries at once, because waiting cannot fix them. A partial file the server will not resume (curl 33 or 36, HTTP 416) starts over. The loop is the installer's own rather than curl --retry: the factory curl on the bench reference is 7.69.1, whose --retry does not count a resolver failure or a dropped transfer as retryable, and older factory builds carry older curls. The owner sees the reason in words with each retry, and the final failure says that a re-run goes straight to the download, because the archives are kept. The log. Every run appends to /data/log/forgefirm/install/install.log, in the log tree's own line format (UTC, program "install"): the installer's md5 (which revision ran), the factory version and the slots, the owner's answers, each archive, each download try with curl's exit code, the HTTP code and the reason, the machine's clock at each try (a wrong clock breaks TLS), and after a failed try the address, the default route, the resolver and whether github.com resolves; then the signature and identity checks, the write, the boot selection, and the reason for any failure through die(). The log is appended across runs, so the run that failed is still there after the run that worked. Logging never fails the install. forgectrl's log export carries the directory (forgectrl 0dae758). Proven: tests/test_installer.py runs the installer's own functions under sh against a scripted curl - a clean download, a resolver failure and a dropped transfer that resume to the full file, the tries running out, 404 and a full disk ending them at once, a stale partial file starting over, the TLS reason naming the clock, every log line in the tree format, die() leaving its reason, and an unwritable log not failing the run. The whole host suite, 409 tests, passes under Linux and the coverage lint is clean. Bench: the same functions under the factory firmware's own shell (busybox 1.31.1 ash, the factory slot of the bench reference in a chroot) resumed, retried, ran out of tries and logged exactly as under sh. Acceptance: logs.tree-tail-export now plants a probe file in the install directory and requires it back in the export bundle, its line intact and its MAC and IPv4 address redacted, and requires an install log in the bundle when the machine has one. The installer itself is not on the image: the install page fetches it from master, so it is live with this push. |
||
|
|
8232c8c9fe |
forgetest: image.network-boot - the boot does not wait on the network
A client that waits in ifup's foreground on a server's answer holds the whole machine, because init starts the rest of the boot - sshd, forgectrl, the console login - only after S01networking returns. A field machine sat there forever on a router that refused DHCPv6 (the record is in the meta-openglow commit that drops the DHCPv6 client). Nothing in the catalog looked at the network boot path; this test does. It asserts: wlan0 is in ifupdown's state file and no ifup is running; the console getty is up; udhcpc runs with -b (it leaves ifup after three unanswered discovers) and has been reparented to init; no DHCPv6 client is named in /etc/network/interfaces, running, or on the image, and its hook script is gone; IPv6 on wlan0 is the kernel's own - enabled, router advertisements accepted, a link-local address up. A global address is evidence only: a network whose router advertisement offers no SLAAC prefix gives none. What a hostile server does to a client is a bench drill, not a test. covers is empty by design, as with setup.machine-name: the interfaces file is layer content, in the platform identity of every fingerprint, so a change there already makes every test necessary again. Proven: the host tests for the parsers and the registration (tests/test_image.py), and the whole host suite, 398 tests, under Linux. The test's logic, run read-only on the bench reference against an image that carries the client, fails exactly the four DHCPv6 checks and passes every other one. The test fails on any image built from a meta-openglow that still carries the client, so the kas lock moves to the layer head that drops it. |
||
|
|
cd4c176a87 |
Finish the x32 xy_microsteps default in the baseline test and the stream harness
The x32 default landed in forgetest/baseline.py and the driver, but two callers still judged the machine at x8 and both failed on the host. forgetest/tests/test_baseline.py: setUp seeded the fake machine from the x8 FIXED_SYSFS literals while enforce() compares against fixed_sysfs() of the resolved mode, so x_mode, y_mode, step_freq and ramp_rate read as deviations on a clean machine - 23 failures across BaselineTests and TransientNotLeftoverTests. It seeds from fixed_sysfs() now, and the tick expectations come from it (DEFAULT_TICK) rather than a typed 28160. The xy_mode_of and ref_xy_mode unset/invalid cases expect 32, with an explicit "8" case added that had no coverage. Two reference_preconfig dumps taken on an x8 machine carry xy_microsteps = 8, because the markers are read at the reference's own mode. wait_configured wrote the static CONFIGURED_MARKERS where the function watches configured_markers() of the mode in force, and the held-controller jog typed 221 steps for "4.144 mm", which is 1.036 mm at x32; both derive from the mode now. 69 tests, all pass. scripts/bench/laser_stream_test.py: STEPS_PER_MM was the x8 53.333, so the X-peak check failed at 2133 steps against an expected 533. The whole harness now derives from XY_MICROSTEPS_BASE/DEFAULT the way glowforge.h and baseline.py do, which uncovered five more x8-only expectations behind the first: the machine tick, the fire-gap limit (it grows as sqrt(k), not k - a finer mode shortens the accel interval by sqrt(k) while speeding the tick by k), the rung split in fire_spans, the density period and minimum burst (laser_pulse_ticks is in x8 ticks and the stream scales it, so the config keeps the x8 numbers and the measured lengths scale), and the decel/hold budgets. Run against the null-sink build: all stream emission rules hold. xy_mode_test.py's docstring still described the no-key case as x8 while its own assertions had moved to x32. No behavior change and no acceptance-catalog consequence: these are test expectations and a bench harness, not image component sources. The coverage lint is unchanged at 0 uncovered paths. |
||
|
|
f9f4de31c1 | Fixed artifact exporter | ||
|
|
9ddab969e0 | Attribution | ||
|
|
0eb764bf75 | Added SPDX | ||
|
|
9a288b175b |
Track the x32 xy_microsteps default in forgetest and the bench tools
Acceptance baseline: XY_MODE_DEFAULT 8 -> 32, split out XY_MODE_BASE (8) for the tick/ramp scaling, matching the driver. The mode-aware comparisons (fixed_sysfs of the resolved mode) judge an unset machine at x32. Host CI: xy_mode_test.py default and invalid cases expect x32/213.333/112640. Bench tools: raster_dry.py and xy_pattern_accel.py set_mode() cleared the key for mode 8, which relied on clear == the x8 default; clearing now yields x32, so they set the mode explicitly and restore to x32. live_fire_drills STREAM_RATE_HZ is the 28160 Hz laser reference tick, the same at every mode. |
||
|
|
b17778f538 |
forgetest: verdict-cut holds the daemon under the report dead-man
laser.verdict-cut freezes the daemon so the verdict the controller caches expires and the driver's pause tier holds the job, then resumes and proves the job ran dark through the hold with the laser latch never locked. The freeze was 3.5 s. The engine's report dead-man locks the laser latch when the controller's report is older than 5.0 s while armed, and the last report is up to 1 s old when the freeze starts, so a 3.5 s freeze left only a half second of margin - a dead-man latch lock reads exactly like the failure the test refuses. The freeze is now 3.0 s: still past the 2.0 s the cached verdict takes to expire (so the pause tier holds and the hold is seen), a full second under the dead-man. No behavior changed, only the freeze the test holds. |
||
|
|
41417f820b |
forgetest: clear a latched controller error, bound the move-start check, and follow the fail-tier restart
Three acceptance tests broke on the WI-4/WI-5 firmware, all from a behavior a change moved and no test tracked. motion: soft limits are armed after a home (the driver's, since the bed has no switches), so a jog past the bed is refused with error:15 - and grblHAL then answers error:15 to every following G-code line, across a fresh connection, until a blank line acknowledges it. A prior test's or the baseline hand-back's rejected jog left that latch, and the next test's first move failed with a stale error unrelated to the move. clean_slate now clears it with a blank line before the first move. The four "send G1, sleep, check Run once" sites are replaced by start_move, which waits for Run on a bound and, when it does not come, names the reply, the state, the drained messages and a forgectrl snapshot - the capture that turned "the move did not start" into "answered error:15". cooling.fire-watch-tiers: the FIRE fail tier now stops the controller through the supervisor and starts it again, so leg 2's Grbl connection dies mid-leg. It now records the pid, reads FIRE from the engine and the latch from sysfs, waits out the restart, proves the supervisor logged the fail-tier stop, and opens a fresh session for the legs that follow; src/super.* added to its covers. Proof: full forgetest host suite green; coverage lint clean (90 tests); bench reference on the dev image, unattended queue: motion.* and cooling.* pass, cooling.fire-watch-tiers and cooling.fail-tier-stop pass with the button-lamp driver fix (grblHAL-glowforge). |
||
|
|
db6015dc81 |
forgetest: cloud.mode-switch opens the lid behind the controller's start, ahead of the hunt
The supervisor holds every controller spawn until the enclosure is closed (forgectrl 0.1.25), and the test opened the lid before it asked for the cloud controller: POST /mode answered "waiting, the lid is open" and the controller never came up. The round trip now switches with the lid closed, polls /mode five times a second, and opens the lid the moment the controller is running. The client requests its connect-time hunt a few seconds after its start, right behind its session, so the hunt still finds the lid open. The order is recorded and judged: the hunt's request line must not be in the client's log when the lid reads open (hunt_before_lid_open), and the test refuses to start with the lid open. The catalog text tells the operator to open the lid at once, with a hand ready on it. Proof. Host: test_cloud_suite drives the round trip with the hunt landing only once the lid reads open, as on the bench, plus the lost race (the hunt requested before the lid opened fails the test with "before the lid was open") and the start with the lid open refused; 8 mode-switch cases green. Bench reference (dev image 20260915001814, forgectrl 0.1.25): cloud.mode-switch PASS in 118 s, the lid open 4 s before the client requested its hunt, no refusal before the hunt's end, the lens homed, the exhaust row unjudged, 5 service motions after the lid closed, $H under gfhome homed in 48.4 s with 9 motion windows. No catalog consequence beyond the test itself: its covers map is unchanged. |
||
|
|
f080d5d9cb |
Catalog: the cloud client's latch at the run, and a hold that never clears
The cloud client changes (python3-gfhardware: the latch unlocks at the run and nowhere earlier, the warm-up is supervised, a live feed must land and finish, fire needs a power byte first, the homing runner always stops). This commit carries the catalog tests that hold the part the bench can see. forgetest/forgetest/suite/cloud.py: - cloud.dark-print (new, kind operator, one press, dark by construction) replaces cloud.verdict-hold. The print arms on the press, waits for the engine's acknowledgment and runs: the laser latch is locked at the button and through the wait, unlocked only for the run (immediately before it starts), locked again when the job ends, and the print completes. The engine's own warm-up release stays proven by cooling.floor-and-warm-up. - cloud.verdict-refuse (new, kind operator, one press, dark): the start gate far above the coolant and cloud_hold_max_s at its minimum keep the armed print under the warm-up past the bound. The client waits with the latch locked (sampled every two seconds), cancels at the bound with its own log line, never runs, closes the armed window, and the print ends ':cancelled'. The settings are restored. - cloud.verdict-hold is retired: its release rode the loop heater at the flow-check duty against a gate one degree above the coolant, inside the upstream reading's noise band, and its run hovered for minutes with the engine's "warm-up stalled" line in the log. The two tests above prove the client's contract without the thermal race. - cloud.oversize-stream already reads cnc/streaming back at both ends of the run, which is the readback the client now insists on. A forced streaming write failure has no seam on the board (the write goes to sysfs as root) and stays a host test. tests/test_cloud_suite.py follows: excerpts and failure cases for the two new tests in place of the retired one's. Proof. Host: the forgetest unit tests. Bench reference: cloud.dark-print passed (locked at the button, unlocked in the run, locked after, ':completed'), cloud.verdict-refuse passed (held 60 s, 31 latch samples all locked, ':cancelled', settings restored); motion.deadman's kill during $H ended the runner on SIGTERM alone; and the retired cloud.verdict-hold passed once more on the new client before it went (the latch locked through an 8 minute warm-up hold, the release ran the print to completion). |
||
|
|
7089a25721 |
Catalog tests for the supervisor: a kill during $H, a respawn behind the lid, the fail-tier stop, the 300 ms relock
The daemon's supervisor and engine change (forgectrl: controller death as a signal, the homing runner and the kernel before a respawn, the enclosure check before every spawn, the fail tiers ending the controller). This commit carries the catalog tests that hold them. forgetest/forgetest/suite/motion.py: - motion.deadman gains a last phase: SIGKILL of the controller during $H (the web-service homing, run only with cloud mode enabled, the homing mode set and put back by the test). The homing runner must be gone before the respawn, the kernel idle when the new controller starts, no halt needed, and the pulse device held by the daemon and one controller. The head ends wherever the homing was. - motion.respawn-gate (kind operator, the lid through the fixture where one is wired): the controller killed with the lid open. The supervisor safes (latch locked), reports waiting with why naming the lid, starts nothing while the lid stays open, and comes back verified when it closes, without a second motion probe. forgetest/forgetest/suite/laser.py: - laser.armed-kill reads the latch and the kernel state from sysfs every few milliseconds after the SIGKILL: the latch must lock within 300 ms of the kill and the kernel leave running within a second (a death is a signal to the supervisor, not a poll). The emission bound stays at 2.5 s: the witness counts a window. forgetest/forgetest/suite/cooling.py: - cooling.fail-tier-stop (kind operator, one press, no emission): the crash watch's thresholds at their lowest make the head's own move trip the abort generator inside an armed, dark (S0) job. The engine logs the crash signal, the supervisor logs the stop and starts a new controller, the latch is locked, the kernel idle, no emission, the thresholds put back. The watch exists only inside the armed window, so the press is the arm, not a fire. Proof. Host: the forgetest unit tests. Bench reference: motion.deadman passed with the new phase (the runner gone in 0.9 s, the kernel idle at the respawn, holders forgectrl and grblHAL_glowforge), motion.respawn-gate passed (waiting 1.3 s after the kill with "the lid is open", running and verified the moment the lid closed, no probe line), cooling.fail-tier-stop passed (the signal at +0.27 s, the controller pid 21823 to 22273, latch locked, emission 0). |
||
|
|
2f2a4160af |
Stream and motion robustness: harness rules, catalog tests, the hand-back's counter scale
The GRBL driver's stream engine and its motion envelope change (grblHAL-glowforge: the shipper writes outside the lock, a clamp inside an armed window faults, the X/Y soft limits follow the home, the machine's settings are pinned, the homing keys are clamped). This commit carries the host rules and the catalog tests that hold them; the driver commit follows, because its CI fetches these harnesses unpinned. scripts/bench/laser_stream_test.py: - Rule 28: a 300 ms producer stall while armed faults the stream with ALARM:17, the kernel sees no step burst (at most the planned steps per 100 ticks), the stream ends dark, the latch sideband ends on the lock. The same stall unarmed is a warning: the move completes with every step, the clamp visible as the burst the kernel counts. - Rule 29: a 300 ms stall of the sink's write leaves the producer on pace: no clamp, every step, lit through, dark at the end. - The stand-in engine takes GFSINK_STALL_MS and GFSINK_WRITE_STALL_MS, null-sink only. scripts/bench/z_envelope_test.py: - Rule 10: homed (a gfcloud home), a program move past X max, Y max or the near edge alarms with ALARM:2 before any motion, a jog past the bed is refused with error 15, a move inside the bed runs, and a $20 write keeps the limits. The core repeats the last error for the line after a refused jog until an empty line clears it, so the rule sends one. forgetest/forgetest/suite/motion.py: - motion.soft-limits (kind auto, no emission): homes through the cloud suite's gfhome homing when the machine is not homed, then the three refusals (ALARM:2, the kernel counters still), the refused jog, the inside move, and the return to the corner read at rest. - motion.deadman phase 2b: a 100 ms SIGSTOP mid-move, inside the kernel's queue: no underrun, the controller's log warns of the clamped late events, the move completes with every step (read at rest), the latch stays locked. The armed clamp is proven on the host (rule 28). forgetest/forgetest/suite/cloud.py: - gfhome_homing drains the driver's answer to $H once the session ends: it sits behind the status reports and passed for the reply to the caller's next command (a setting read as None). forgetest/forgetest/baseline.py: - The hand-back reads the position counters at the kernel's own microstep mode (cnc/x_mode, read before the sysfs restore puts the settings' mode back). At the x8 constant, an x32 machine's 30 mm read as 120 mm, beyond the return bound, and the displaced head was left in place. The dead band scales the same way. tests/test_baseline.py holds both. Proof. Host: stream rules 1 to 29 and z_envelope rules 1 to 10 against the null-sink driver, the forgetest unit tests. Bench reference: motion.soft-limits passed (X 495 and Y 279 refused with ALARM:2 and 0.000 mm of kernel motion, X-1 the same, the jog error:15, the inside move ran, back at the corner 0.0/0.0 mm); motion.deadman passed with the new phase (92 late events clamped, max behind 92.3 ms, no underrun, 30.0 mm counted, latch locked) and the hand-back jogged the head back under the x32 scale. |
||
|
|
e9d5f3c842 |
Laser harness rules and catalog tests for the emission gates
The GRBL driver's emission gates change (grblHAL-glowforge: the per-tick fire gate, the latch with an owner, the cooling verdict's two tiers, and the arm-flow gates). This commit carries the host rules and the catalog tests that hold them; the driver commit follows, because its CI fetches these harnesses unpinned. scripts/bench/laser_stream_test.py: - Rule 24 is the verdict's pause tier: the client holds the job under the open window, the first deceleration runs lit to the stop (the dark lead before the stop is at most 1000 ticks: the producer's lead plus one shipper period), a resume under the standing verdict moves dark and is held again, the clean verdict resumes lit with no press, and the latch sideband carries only the arm's unlock and the program end's lock. - Rule 25 is the fail tier: AIRFLOW mid-cut ends the job with ALARM:3, the stream ends dark well short of the line, the sideband ends on the lock, and a resume under the clean verdict that follows resumes nothing. - Rule 26: a sender change mid-M3 holds the job with the deceleration dark: the gate follows the window on every tick. - Rule 27: a verdict that goes stale holds the job at the cache's own expiry, lit to the stop, never a poll later; the engine's return resumes lit. - The stand-in engine publishes the verdict name and has a stale mode; the session steps gain expect_text, reconnect and a wait_state timeout; every session reads the latch sideband (GFSINK_LATCH_LOG). scripts/bench/laser_lifecycle_test.py: - Rules 11 to 15: the pause tier resumes with no press and no prompt, the fail tier ends the job and nothing resumes it, a sender change during a re-arm cancels it, a jog does not hold the window open, and a press counts only after the button has been seen up. The stand-in engine takes a live verdict dict. start_armed_move waits for a fresh prompt and a fresh armed message: a press that lands before the wait has begun is not consent, and the old stale match let one land early. forgetest/forgetest/suite/laser.py: - laser.verdict-cut (kind live, one press): a 40 mm M3 line; the test pauses the daemon for 3.5 s so the verdict goes stale (the settings route is idle-gated and the engine reloads its gates at a session start, so no setting can trip a pause mid-cut; the crash tiers need a physical knock). The controller must hold with the SoC latch and the hardware button latch both clear in every sample, emission must read 0 before the resume, and the clean verdict must resume the cut lit with no press and no prompt; M2 disarms as usual. - laser.armed-kill asserts that the respawned controller comes up with the latch still locked. Proof: both harnesses pass against the driver change on the host, and the forgetest unit tests pass. On the bench reference, laser.verdict-cut passed with no gap in the cut (held at +1.71 s after the pause began, both latches clear in every sample, emission 0 at +2.06 s after the hold, resumed lit at +4.0 s with the beam detector 678 counts over idle, kernel drift 0.0 mm, disarmed 0.1 s after Idle) and laser.armed-kill passed (emission 0 at +1.8 s after the supervisor's stop and +2.1 s after the SIGKILL, latch locked, respawned with the latch locked, button dark). Two earlier verdict-cut runs shaped the driver: a pause that locked the latch resumed dark, because a lock sets the hardware button latch, and a hold taken a poll after the gate closed left a several-mm gap. Catalog: one test added and one extended; both cover src/** of the driver through the existing laser covers. |
||
|
|
7c0412075c |
Normalize line endings to LF
.gitattributes sets text=auto with eol=lf, so every text file is stored and checked out with LF, and a patch keeps its bytes. The files that carried CRLF from a Windows editor are renormalized. No content changes. |
||
|
|
d8b8adaef4 |
forgetest: the setup suite, and the sensors check asks nothing
The commission.* tests are setup.* (subsystem "setup"): suite/setup.py, setup_dark.py, setup_sheet.py, and the host tests test_setup_*.py. The coverage maps name src/setup.* in place of src/commission.*, the record is setup.json, and the bench seed in forgetest.init creates /run/forgefirm/setup-override. setup.check-sensors follows the check as it is now: no question is asked and no setting is written. The test reads the settings before and after and fails on any change, and fails at once if the check opens a prompt. The host test drives the fake daemon with no prompt and proves both outcomes. Proof: test_setup_dark.py, test_setup_sheet.py, and test_setup_suite.py pass (46 tests). The coverage lint names src/commission.c and .h as uncovered until the forgectrl pin moves to the revision that carries the rename. |
||
|
|
884d014d2c |
Pin forgectrl 0.1.24 (verified fan writes) and cover the lost-duty put-back
forgectrl 0.1.24 = 2380e07: every fan duty write is read back and retried, a duty a device lost is put back by the tick, and the airflow fault names the duty commanded and the duty in force. The corner card of the sheet on the bench reference was held as a slow air-assist fan after a run-duty write the head never took; nothing checked. Acceptance: cooling.fan-duty-readback (auto, grbl) opens an M8 session, reads every fan's run duty back, writes the head's air-assist register back to the idle duty and the exhaust PWM to zero behind the engine's back, and holds both to their run duties again within a few ticks, each loss named in the log, the session OK to its end, the idle duties after M9. Covers the head driver too. On the bench reference with forgectrl 0.1.24 hot-deployed it PASSED: both duties back after 0.5 s. |
||
|
|
a9f0ad0d51 |
Take forgectrl 0.1.23's theme.css into the forgetest page
The shared theme gained the release dialog's styles in forgectrl; the copy the acceptance page uses follows it, as check-ui-vendor requires. |
||
|
|
f41464cc58 |
Pin forgectrl 0.1.22 (the releases-API check and the release dialog) and cover its routes
forgectrl 0.1.22 = 0235a88: the release check reads the GitHub releases API and never requests the firmware file's URL, the daemon checks daily, and the panel raises a per-release dismissable alert and runs the install from one dialog. Acceptance: update.release-check (auto) exercises GET /update/release, POST /update/check (a machine with no route to the API answers 502, which the drill records and steps over), the v<semver> shape and `new` of a published release, and the dismissal round trip, and puts the dismissal back. forgectrl.auth's unauthenticated-write list gains /update/check and /update/dismiss. Coverage lint: 84 tests, 0 uncovered paths; the forgetest unit tests pass (373). On the bench reference (dev image 20260911203113 with forgectrl 0.1.22 hot-deployed) every check of both tests passed; the runs were marked FAIL only by the hand-back baseline, because the controller is gated until the changed privacy advisory is accepted again. |
||
|
|
20c6256527 |
forgectrl 0.1.21: the release check reads the tag from the first hop
Pin forgectrl at 92cead6 (0.1.21). The published-release check reads the release tag from the first redirect hop instead of the end of the chain, where the asset store's URL carries none; every image through v0.0.3 reported "release server error (HTTP 200)" against a published release. The forgectrl commit carries the proof (relcheck_test in its CI). The update.slots-and-signature covers map names the new src/relcheck.c and src/relcheck.h so the coverage lint stays whole. A catalog test of the check against a published release is held for a later change. |
||
|
|
7b8f72b632 |
Run the commissioning sheet as a fresh machine, and pin forgectrl 0.1.18 (the lens frame)
forgectrl f8ddb17 puts the lens frame in one place and writes the focus window before the card's controller starts, after two commissioning cards ended in ALARM:2 on a Z the wizard sent from one source while the controller's Z limit stood on another. The acceptance run had passed only because the bench's settings already held the stops from an earlier focus run, so the run is now a fresh machine's. commission.sheet clears the three lens settings inside its Restore before the cards, checks that the frame runs in the fallback window, and, after the focus card, that the settings hold the window the ladder ran in (the stops found or the fallback), that every ladder height lies in that window's reach, and that the program served now agrees with /status. Every served program's Z is checked against the reach /status reports before the card starts, so a stray Z fails the test with nothing burned. The test covers src/lens.*. The host mock in tests/test_commission_sheet.py mirrors the daemon (the /status lens block from the settings, the ladder served from the settings, the window written at the focus start); two regression tests reproduce the defects: a focus result naming a window the settings do not hold, and a served program with a Z beyond the reach. 10/10 green. scripts/bench/z_envelope_test.py, the grblHAL CI harness, adds the referenced-lens cases: with forgectrl's marker and the shared settings, the fallback window and a 14/20 window run to the ends of their reach and two half-steps past either end alarms, and a count of 41 falls back on its side alone. Passed on the null-sink build. The bench page's description of the harness follows. The forgectrl pin moves to f8ddb17 (0.1.18); every test covering forgectrl re-runs. |
||
|
|
c47f7d9a86 |
Put the head back after the hold in the disarm test
laser.disarm-in-hold streams a 40 mm move, feed-holds it two seconds in, waits out the disarm grace, and then resets out of the hold. The recovery put the laser and the controller back and left the head where the hold had caught it: 11.34 mm along on the bench reference, 605 counts, which the hand-back jogged out and reported. The head goes back now, by the distance it actually travelled rather than the distance the move asked for: the hold catches it at a slightly different point every run - ten millimetres nominal at F300 for two seconds, 11.34 measured - so the kernel counters before the move and after the reset are what the return jog is built from. Where it stopped and where it ended go into the evidence. This is not the fault the M5 rapid job had. That job's moves netted plus twenty by construction; this one stops part-way on purpose, and the recovery simply never returned it. laser.armed-kill has the same shape, a kill mid-fire that stops the head where it stops, and is left alone until a run says whether it needs the same treatment. |
||
|
|
c217e24905 |
Put the head back at the end of the M5 rapid job
laser.m5-rapid-dark cut 20 mm out and then ran the two rapids the test is about, one back and one out again. The three net to plus 20 mm, so the job ended with the head 20 mm from where it started, every run: 1067 counts at 53.333 per mm, which the hand-back jogged out and reported. That one is dirt, and the position dead band was right to leave it alone - it is 20 mm, not the step a return rounds to. A third rapid, back 20 mm with a short dwell, ends the job where it began. It sits inside the sampling window and after the M5, so it is one more rapid that must be dark, which is what the test already asks of the other two: the assertion is wider, not narrower. The operator's clearance step is unchanged, because the head still needs its 20 mm of +X. Every job list in the suite that moves in G91 now nets zero on X (cooling.py, laser.py twice). |
||
|
|
b94c990bbc |
Leave the lens to the cloud client in cloud mode
cloud.verdict-hold printed to completion and then failed its hand-back on head/z_mode=0. That job's header carried ZSmd 0, gfhardware's set_mode_from_puls turns that into z_mode 0, and z_mode 0 is full step (lenshome.c): the cloud client had set the lens the way the pulse file it was playing asked. Nothing was left behind - the machine did what the job said. The baseline already skips nine attributes in cloud mode, for the reason written above the list: the cloud client sets its own values for them from every pulse header, and forcing the GRBL values under it would be the baseline configuring another controller's machine. head/z_current and head/z_mode are the same thing and were not on the list. They are now, in cloud mode only; in GRBL mode the pair is still checked against 1 and 1, which is the half of this an exemption could quietly break, so a test holds it. Host-proven: 89 baseline unit tests. |
||
|
|
8d8ca2a8c6 |
Judge a hand-back on what a run left, not on the machine still working
A leftover is dirt a run left behind. Four of the checks were reporting
something else: the machine part-way through work of its own, which it
finishes on its own a few seconds later. Each one failed a run that had
done nothing wrong.
laser.arm-wait-lid found it. The test passed every check it makes and
failed on cool=smoke/armed=False/hold=False. The smoke clear is the
engine's post-job work - run duty for smoke_s after an armed session,
timed, ending by itself (cool.c, Cool_Smoke) - and the machine was idle
fifteen seconds later without being asked. Armed and hold were both
false: nothing had been left.
The four, and what each was catching:
cool a phase alone (the smoke clear, a cooldown). Armed or
holding stays a leftover: the run left a job alive and the
engine is keeping the fans up for it.
controller the supervisor's own start. A takeover ends by starting
forgectrl again, and the respawn runs the liveness probe
and the lens reference before it reports running and
verified. The run had put it back.
state the ring draining to the end of a job. An underrun stays a
leftover and is still acknowledged with cnc/stop: that one
is the run's.
leds read_led read brightness, write_led writes target, and the
smooth trigger fades brightness toward target. An LED the
machine had already released still read lit mid-fade, and
the restore called itself done before the fade had moved.
Judged on target now: a run that left the button lit left
a target standing.
The waiting, the restoring and the reporting of anything a run actually
left are unchanged. What changed is which readings count as dirt.
Host-proven: 87 baseline unit tests, two of them new - a fading button
LED is not a leftover, a lit one is.
|
||
|
|
4fbf14195b |
Wait for the purge fan's draw, do not read it in the same second
The airflow check reads the purge fan's off current with the fan off, and the stand-down that follows commands it back on. The guard that proves the machine was handed back whole read the draw immediately, so what it got back was the off current the check had just measured: on the bench reference, 74 against a 300 floor, with the fan drawing 631 a moment later. The check failed for having worked. The current follows the command; it does not arrive with it. The guard now waits for the draw to reach the floor, up to PURGE_SPINUP_S, the way the controller below it is already waited for, and logs how long it took. It is no weaker: a fan that never reaches its floor still fails the check, and the message now says how long it was given. Pin: forgectrl 0.1.17 (a9e7073, the air assist and the purge in the diagnostic run posture - the reason that check measured an idle fan and wrote a floor from it). |
||
|
|
d3fe1d90b9 |
Hand the machine back, do not describe what is wrong with it
The hand-back is the promise that a run leaves the machine where it found it. Two of its checks reported instead of restoring, and the machine sat in the state the run left it in. The cooling engine: a run that ends without ending its job leaves one alive behind it - the engine armed and holding for a job that is never coming back, the fans at run duty. The check waited two minutes for that to resolve itself, which it cannot, and wrote "failed: still run/armed=True/hold=True". On the bench reference the fans then ran for an hour. The pulse ring: bytes the last job never played sit there, and the next run replays them before its own. The check refused the return jog and wrote "clear the ring (controller restart) before moving" - the instruction, to a log, instead of the action. Both end the same way: stop the controller and let the supervisor bring it back. The job goes, the arm and the hold go with it, and the ring is empty on the way in. stand_down() does that and proves it settled; the cooling check calls it when the engine will not idle on its own, and the return jog calls it when the ring has residue, refusing only if bytes survive a restart. A leftover still reports what it found - the record of what the run did is the point - but it reports having fixed it. Host-proven: 62 baseline unit tests, including one that the hand-back calls the stand-down for ring residue rather than describing it. |
||
|
|
594b6990fd |
Stop a finished run's clock
Run.snapshot() computed elapsed_s from the current time on every call, whether the run had ended or not. The page shows the last run until the next one starts, and it polls, so a finished run's figure went on counting: the result badge said FAIL beside a number still climbing, and the run read as still going. On the bench reference a test that ended after 5082 s was showing 5242 s and rising. The figure a finished run should carry was already recorded next to it: finished["duration_s"], fixed when the result was written. snapshot() now returns that once the run has ended, and the live count only while it is running. Host-proven: two unit tests - a finished run's clock reads its duration and does not move across a poll, a running one's still climbs. |
||
|
|
57ba3454b4 |
Clear a setting the only way the daemon accepts, everywhere it is cleared
An empty form field never reaches the request, so POST /settings with an
empty value in the body carries no key at all and the daemon answers 400,
"no known setting in request". Only the query-string form gets an empty
value through. Measured on the bench reference:
POST /settings -d "lid_policy=" -> 400, the value unchanged
POST /settings?lid_policy= -> 200, the key cleared
Three places in the suite already knew this and say so in a comment; two
did not.
motion.lid-policy-hold restored with the form body, so on a machine whose
lid_policy has never been set the restore was refused and the test failed
its own check. It now uses the query-string form for the empty value, as
the others do. This is the same test as the commit before: that one
stopped it from writing the word "cancel" where the machine had nothing,
and this one lets it write the nothing.
cloud.mode-switch had the older shape of the same fault: it restored
homing_mode to the literal "none" where the machine had it unset. On the
bench reference the restore branch never ran, because that machine homes
through the cloud, so it has not fired yet; on a machine with homing_mode
unset it would have. It now restores exactly what it found.
The rest of the suite was audited for this and is clean: camera.key-read,
cooling.critical-tier, cooling.tec-drive, cooling.crash-watch-plumbing,
cooling.fire-watch-tiers, cooling.floor-and-warm-up, cloud.verdict-hold,
forgectrl.settings, logs.levels and motion.xy-microsteps all either guard
the empty value or go through the shared Restore helper, which has always
done it correctly. commission.cloud-header-capture uses that helper too.
|
||
|
|
d58ee39086 |
Hand lid_policy back unset where the machine had it unset
motion.lid-policy-hold read the setting as
was = (fc.settings() or {}).get("lid_policy") or "cancel"
and wrote `was` back at the end. On a machine that has never set the
policy the setting reads as the empty string and behaves as cancel, so
the `or` turned "unset" into the word and the test handed the machine
back carrying a setting it did not arrive with. The hand-back reported
it, restored it, and failed the test.
The value is now captured exactly, empty included, and restored as
captured; the default belongs to reading the value, never to writing it
back. The read-back check gets the same default, so it no longer compares
None with the empty string. lid_policy_in_force carries the effective
policy into the evidence, which is what the old expression was reaching
for.
Found on the bench reference, on the run after the position dead band let
motion.lid-cancel-home through. The four other places in the suite that
read a setting with `or` are safe: two default to the empty string, which
is what unset is, and two feed a check rather than a restore. The shared
Restore helper captures raw values and writes the empty string back for
unset, as this now does.
Only this test's earlier passes are invalidated: the change is inside its
own body, and the per-test source hash of the other eleven motion tests
is unchanged (checked against the file before the edit).
|
||
|
|
b2c42f0cc3 |
Do not fail a hand-back on the step the counters round to
The post-run pass compared the kernel position counters exactly, so a test that put the head back within a hundredth of a millimeter failed whenever that distance did not round to the same step. motion.lid-cancel-home found it on the bench reference. The test cancels a job with the lid twice and the controller returns the head to the job start each time, landing 0.038 and 0.037 mm out - the same figures as the run before it, which passed. This time the two returns left four steps on X, 0.075 mm at 53.333 steps per mm, and the hand-back called it a leftover. Twenty-three runs of that test, all passing, on returns of the same accuracy: whether the residue rounds to zero is chance, not a property of the machine or of the test. A difference inside POSITION_DEADBAND_MM (0.1 mm, five steps at x8) on X and Y is now the quantization rather than a leftover. The head is still put back, so nothing accumulates over a campaign - only the failure goes. Z stays exact: the return never moves the lens, so a Z difference is still a leftover and still unrestorable. Host-proven: four new unit tests on the boundary (four steps and a moved Z on either side of it, and an unreadable reading), and the forgetest suite. |
||
|
|
1dd6ad608b |
Press the button when the machine asks, not when its LED is on
The sheet handed the bench actuator every press after the operator's presence press, and the actuator pressed every time - into nothing. On the bench reference all six cards were pressed before the card asked: the frame by 8 s, the focus by 33, the floor by 10, the dose curve by 8, the corner by 9, and the flow-load card by 69. The operator then pressed all six himself, which is the opposite of what the ready gate promises. arm_press() waits for hw.button_lit(), which is true when any button LED is on, and burn() started that wait before run_check had even started the wizard. The button is lit through parts of a card that are not the arm - the lens reference, the program on its way to the controller - so the wait ended at once, the press landed before the job waited for it, and the thread was gone by the time the real cue came. forgectrl uses the same predicate but only inside the job's own sample callback, with the tube still dark, where a lit button does mean the arm. The machine already says when it wants the press: a live check opens a `press` wait prompt at that moment, and run_check sees every prompt. It now presses there, through a new Ctx.press_now() - no LED read, no waiting thread, no timing guess. That retires the per-card lit-timeout column of CARDS, which existed only to give the flow-load card's coolant settle enough room for a wait that was reading the wrong thing. The four grbl-driven arm_press() callers in laser.py and cooling.py are left as they are: they call it with the machine idle and its LEDs dark, so the level read is the edge they mean. The same shape would bite them if that ever stopped being true. Pin: forgectrl 0.1.15 (6e69479, the flow-load tail ending at the coolant peak and the way out of a finished setup page). Host-proven: 357 forgetest unit tests, including two new ones - the actuator presses on the prompt with button_lit stubbed false throughout, so the LED is provably not consulted, and the press falls to the operator without a takeover. |
||
|
|
f0c40e7d4f |
Name every machine after its own MAC address, and drop mDNS
One name for every machine was wrong: an operator with two of them on a network had one forgefirm.local, and mDNS does not work on many networks at all. The machine now calls itself forgefirm-<xxxx>, from the last four hex digits of its WiFi MAC address, and sends that name with its DHCP request, so a network with dynamic DNS publishes it and a router lists the machine by name. The name is the same at every boot, two machines take different names, and no serial number leaves the machine. forgefirm-hostname (new): reads the wlan0 MAC address (eth0 on a machine with no WiFi) at S38 in rcS, after udev has probed the network drivers and before poky's hostname.sh reads the file and before the network starts. The rootfs is read-only, so the name is written through a bind-mounted copy under /run/forgefirm. A bounded wait covers a slow probe. hostname:pn-base-files is "forgefirm": the name before S38, and the fallback when no MAC address can be read. avahi is deleted - the bbappend, the daemon configuration, the service file, the image install and the distro block. The address is the way in that works on every network, and the DHCP name covers the rest. forgefirm-banner: the marker lines are gone. "# ForgeFIRM addresses" and "# end" delimited the address block inside /etc/issue, and getty prints every line of that file, so both markers were on the console. The script now keeps the image's own text in a second copy under /run/forgefirm, captured once per boot before the first write, and renders the whole banner from it. The block is the addresses alone: no mDNS name. forgefirm-image.bb: the ForgeFIRM mark, under the OpenGlow one the base image carries, with the version on the mark's own last line, right-justified to the mark's last column. The mark is written once and rendered per reader, because /etc/issue is parsed by busybox getty (a backslash or a percent sign starts an escape, so the art goes in with every backslash doubled) while /etc/motd is written out as it is. Widths are measured in columns, not bytes: the color sequences take no room on the screen. /etc/issue.net stays unused - the machine tells a client that has not logged in nothing. Acceptance: commission.mdns-announce is replaced by commission.machine-name, which checks the name against the MAC address, the bind-mounted /etc/hostname, the DHCP client's hostname option, the banner's addresses, and that no mDNS responder is on the image; it covers nothing by design, like the test it replaces. forgectrl.auth gains the own-name Host check and its refusal with a domain on it. image.health checks the /etc/hostname mount and the version on the mark's last line in both files. commission.ssh-until-reboot asserts there is no pre-authentication banner. commission_dark's lens coverage widens to src/lenshome.* so src/lenshome.h is covered; the lint is clean at 83 tests. Pins: forgectrl 0.1.14 (9e5330f, the hostname certificate and the Host rule), meta-openglow ced2af2 (the DHCP hostname option and the motd mark) in the kas lock. Proven on the bench reference, hot-deployed and rebooted (image 20260910000208 dev): hostname forgefirm-b00a from MAC 2c:6b:7d:0d:b0:0a, live and in the bind-mounted file; the DHCP client running with -x hostname:forgefirm-b00a; the console banner and the motd carrying both marks with the version aligned to the mark's last column, no marker line and no .local name; forgectrl regenerating its certificate for the new name. Host tests: 357 forgetest unit tests, forgectrl clean under -Werror, tls_test and sanitize_test. |
||
|
|
d858a23f45 |
Carry the panel stylesheet into forgetest
The acceptance page shares theme.css with the panel byte for byte, and forgectrl's copy gained the setup header's Download logs button. CI caught the drift, which is what that check is for; the pinned revision decides, so the copy follows. |
||
|
|
c7b80ab2e8 |
A test that does not hand the machine back fails
The baseline has always examined the machine after every run and recorded what the run left behind. It did nothing else with it: the leftovers went to the log and the evidence, and the test still reported PASS. So a check could measure correctly, walk away with the machine in a state nobody chose, and be recorded green. That is how the purge fan came to be left off by the airflow check. The leftover was not even watched, but had it been, it would have been noted and the test would have passed anyway, and an operator would still have met the airflow hold at their first fire. A post-run leftover now fails the run. One the baseline put back fails it too: the restore is the bench cleaning up after a defect, not the defect's absence. The message names what was left. The baseline watches the head as well as the motion side now: purge air on, which is how the machine idles, and the lens motor at its hold current in half step, which the lens checks and the sheet cards take and must hand back. The airflow check proves the machine is whole rather than merely measured: afterward the purge fan must read commanded-on and must draw above the floor the check itself just wrote. The pin takes forgectrl 0.1.13 (a2d73ef), which restores the idle posture after a diagnostic, fixes the lens session's takeover flag, and gives the setup a Download logs button, since the panel's Logs tab is unreachable until the setup is complete. Expect this to find things. A test that has been handing the machine back imperfectly has been passing until now, and the first campaign under the rule is where that shows. |
||
|
|
468e92655a |
Teach the manifest guard test about the version file
The guard keeps scripts/manifest-from-tree.py and forgefirm-image-manifest.bbclass computing the same identity, so it pins the skip list by value and the new suffix failed it. It now expects the version file in both places, and a second case proves the point the change exists for: setting the release number leaves the layer hash where it was. My miss: I proved the identity by hand and pushed without running the unit suite first. |
||
|
|
64301d3221 |
Keep the configuration files inside /data/forgefirm; pin the three components
ForgeFIRM's own files live under /data/forgefirm; two configuration files did not. The machine settings sat at /data/forgefirm.conf, in the root of /data beside the factory's own files, and the cloud-mode configuration sat at /data/etc/gfhome.conf, inside a directory the factory owns. Both move: /data/forgefirm.conf -> /data/forgefirm/forgefirm.conf /data/etc/gfhome.conf -> /data/forgefirm/gfhome.conf There is no migration: only the bench has ever run this firmware. /data/etc now holds only the factory's wpa_supplicant.conf. The acceptance check of the file modes reads the settings file at its new path, and the two bench tools that read it directly follow. The pins move to the revisions that carry the change, forgectrl also bringing the fix that reads the module's disabled state as idle: forgectrl 468ee21 (0.1.12) grblhal-glowforge 9ee624b (0.1.10) forgefirm-app 56f134a (0.1.28+git) The lock moves meta-openglow to b7ad6d9, which pins python3-gfhardware on the same revision. The four upstream layers stay where they were: `kas lock --update` moves every floating repository, and a release is not the place to take poky, meta-openembedded and meta-freescale along for the ride. |
||
|
|
4a95595afd |
Pin forgectrl on the gate that waits for the enclosure; add its test
forgectrl now holds the motion check while a lid or the interlock is open instead of starting the controller unverified: GET /mode reports controller "waiting" with why, the button blinks amber, and the check runs when the enclosure closes. motion.gate-waits-for-lid drives the fixture's lid channel: the lid opens, forgectrl restarts, /mode must read waiting with why naming the lid, no pid, motion unverified, the button amber (sampled over a blink period: the smooth trigger's target reads 0 through the off half) and no probe line in the log; the lid closes, and the controller must come up verified with MOTION OK on the first probe. Proven on the bench reference: PASS, the controller verified 6.5 s after the lid closed. |
||
|
|
8af8b197ee |
Mount the rootfs read-only on both images
The rootfs mounted read-write, so a slot ran with its own files open to change, and the factory-slot mounts rode along on the release image. Both images now carry the read-only-rootfs feature: the ro root line and the rcS default, the volatile links made at rootfs time, a writable copy of /var/lib at boot, a build failure for a post-install that needs the machine, and the removal of shadow, base-passwd, update-rc.d and update-alternatives. What must last or change at run time is handled file by file: - forgefirm-users renders the four account files from the record into /run/forgefirm/accounts and bind-mounts each copy over its /etc file (useradd and the rest are gone with shadow); a render writes through the mount, and the image's own files apply until the first render. - forgefirm-banner bind-mounts a copy of /etc/issue and writes the address block through it. - sshd keeps its host keys under /data/forgefirm/ssh, so the fingerprint survives updates; both sshd configs carry the same HostKey lines. - forgefirm-logging passes logrotate a state file under /var/run (logrotate refuses to run without one). - forgefirm-persist points the boot timestamp and the random seed at /data/forgefirm. The dev image appends the /factory slot mounts, without nofail (busybox mount hands it to the kernel, which rejects it). The rootfs command entries lose their semicolons: on scarthgap the value is the task's vardeps, split on whitespace, so "name;" left the function body out of the signature and a changed body did not remake the rootfs; with the bodies tracked, the dev image's DATETIME string needs a vardepsexclude. release.sh gains the read-only gate (root ro, no /factory line, ROOTFS_READ_ONLY=yes, host keys on /data). image.health checks the mounts, the account binds, the banner bind, the host keys and the dev-only /factory mounts. Proven on the bench reference (dev image 20260909140901): / ro, /data rw, /var/lib a tmpfs copy, the four account files and /etc/issue bound from tmpfs, the host keys in /data/forgefirm/ssh, no "Read-only file system" line in any log; forgectrl.auth and commission.account-login (a temporary account rendered, logged in over HTTPS and removed again), kernel.latch-locked-idle and motion.liveness-probe PASS; logrotate runs with the volatile state. forgetest unit tests 335 OK; both images build clean, and debugfs on the built rootfs shows every setting above. |
||
|
|
5f19ae01cd |
Follow the lens reference into the harness and the catalog
The lens now takes its hall-edge reference before any controller starts, so Z is referenced on every start and M103 is gone. The laser-stream harness opened its Z session by referencing the lens the way a commissioning card did; it no longer has to, because Z is already open by the time the session runs. forgectrl.panel-serves gains the assertions for the per-axis reference: homed_axes is an axis mask, homed agrees with it, and with a controller running Z is referenced and reads inside the lens reach the same document reports. That last check is the one that catches a panel showing nothing for a Z the controller holds. commission.check-motion already exercised the new path, because the motion wizard's probe runs the same sequence the supervisor does, so its covers map gains lenshome.c and its description names the lens reference and the hard fault behind it. |
||
|
|
c2ca2686a7 | Assignment |