53 Commits
Author SHA1 Message Date
ScottW514 08b29fee01 Release v0.0.6 2026-09-19 18:35:29 -04:00
ScottW514 f306c9983f forgetest: the hand-back reads an engine hold again past the engine's next tick
The cooling engine publishes its state once a tick (1 Hz). A /cool/status
read inside the tick after a run ended still shows the run: while a
diagnostic owns the hardware every tick publishes phase "diag" with the
hold set, and a fail tier's hold stands until the tick that ends its
session. The baseline took one read, and by its rule an arm or a hold is
the run's doing, so a test that finished inside that second failed its
hand-back on a hold the engine's next tick cleared.

Seen on the bench reference twice. cooling.aa-offset-calibrate in campaign
c-20260919215024-c402: the diagnostic reported done at 22:07:14 with its
offset measured (15.7 counts, spread 0.7), and the hand-back at 22:07:15
read "cool=diag/armed=False/hold=True ... -> waited" and failed the run;
the test had passed on five images before, the last one earlier the same
day. cooling.fail-tier-stop in c-20260919202934-3d3a, the same way on the
crash fault's hold (0ceb4ab made that test wait for its own fault; this is
the general case).

The cooling check moves into Baseline._cool_side. An arm or a hold is read
again for up to COOL_PUBLISH_S (2.5 s: two ticks and a margin) before it is
called the run's doing. One the next tick cleared is logged as the engine's
last word on the run and judged on what the engine reads then: idle is
clean, a cooldown phase is waited out as the engine's own post-job work. A
hold a run did leave stands for a job that is never coming back, so it is
still there after the tick and is recorded, stood down and failed exactly
as before; a hold that clears only later is still a leftover ("waited").

Proven: tests/test_baseline.py CoolPublishTests - a diagnostic's hold the
next tick clears, a fail tier's hold clearing into the smoke phase, a hold
that outlives the tick (recorded, machine stood down), a hold that clears
only later (recorded as waited), an idle engine and a silent daemon; the
baseline, queue, operator, mode, responsiveness and server host tests
pass under Linux. baseline.py is not a suite module, so no test's
fingerprint moves with it.
2026-09-19 18:16:04 -04:00
ScottW514 dc7170d0d1 forgetest: cooling.flow-under-load finds the verdict by offset, not by a tail count
The test counted "heater rise" in the last 300 lines of the forgectrl log
before the job, then waited for the count to grow. A tail of fixed length
cannot show that: the new verdict line comes in at the bottom as an old one
leaves at the top, the count does not move, and a check that verified reads
as one that never judged.

Seen on the bench reference in campaign c-20260919210959-8780: the engine
logged "coolant flow verified (heater rise 11.5 C, dT 9.5 C; laser 1.6 off
13.1)" at 21:37:46, 70 s into the job and inside the wait, and the test
failed at 21:39:21 with "the engine published no flow verdict within 120 s".
The 21:17:27 verdict of an earlier test sat near the top of the 300-line
tail when the test began. Replayed against that log, the old method reads 4
before the job and 4 with the new verdict in the tail; the search from the
byte offset finds the 21:37:46 line.

The test now takes the log's byte offset before the job (_log_offset, as
the fail-tier and liveness tests do) and searches what the file gained
since with the verdict expression, stopping only on a line that matches.
_log_since reads a file that is now shorter than the offset from its start:
a rotation under the test leaves only newer lines. The /logs/tail helper
and its line count are gone with their one user.

Proven: tests/test_cooling_suite.py FlowVerdictLogTests - the text after the
offset alone, the real verdict line through the expression, an old verdict
before the offset not taken for the new one, a rotated log read whole, a
missing log read as nothing; the cooling host tests pass under Linux, 24
tests. No component source changed, so no pin moves.
2026-09-19 17:45:27 -04:00
ScottW514 0ceb4abc4a forgetest: cooling.fail-tier-stop waits for the crash fault to end with its session
The test raised a crash fault and handed the machine back with it still
standing. The engine trips the crash tier on one 1 Hz tick and ends the run
session on its next tick, which is when the fault and its hold clear ("head
crash fault cleared with the run session"). The supervisor has the new
controller up inside that second, and the test returned as soon as it saw
the new pid and the two log lines, so the runner's hand-back check could
read /cool/status before the clearing tick: phase run, hold true. A hold is
the run's doing by the baseline's rule, so the check recorded a leftover
and failed the test, though the wait that followed ended on its own.

Seen on the bench reference in campaign c-20260919202934-3d3a, the only
failure among 65 results: the trip at 20:54:44.871, the new controller at
20:54:45.094, the test's PASS line at 20:54:45, the session end at
20:54:45.863, and "cool=run/armed=False/hold=True ... -> waited" from the
hand-back check. The engine opened no session after that one; the 16 s the
check waited were the smoke phase that follows an armed session.

The test now asserts what the engine documents: after the restart checks it
waits up to 10 s for the verdict to leave CRASH, records how long that took
(fault_cleared_s) with the phase, verdict, hold and armed flags it found,
and fails when the fault does not end with the session. The machine is then
handed back in the engine's own post-job phase, which the baseline already
treats as the engine's work and not a leftover.

Proven: the cooling, artifact, campaign and queue host tests pass and the
catalog loads with the new source. No component source changed, so no pin
moves; the test's own source hash does, so its result comes from this
revision.
2026-09-19 17:04:49 -04:00
ScottW514 aa628d684c kas lock: meta-openglow onto the head that drops the DHCPv6 client
meta-openglow   31c52050bc70 -> 71fad52e8ff5

The layer head removes the wlan0 inet6 stanza and the busybox bbappend that
built udhcpc6, the client that held a field machine's boot in ifup. The
acceptance test image.network-boot fails on any image that still carries
that client, so the lock moves with it. poky, meta-openembedded,
meta-freescale and meta-freescale-distro keep their commits.

Proven: kas checks layers/meta-openglow out at 71fad52, the layer holds no
busybox recipe directory and its interfaces file names no udhcpc6, and
bitbake -c fetch busybox init-ifupdown succeeds against it.
2026-09-19 16:22:43 -04:00
ScottW514 29b00cc056 Pin forgectrl 0.1.27 (the export carries the installer's log)
forgectrl   7a9de005ede5 -> 0dae758cb93e  (PV 0.1.26 -> 0.1.27)

forgectrl 0dae758 stages logs/install/ into the log export, which is what
logs.tree-tail-export now requires of the bundle. The PV moves with the
SRCREV so the hash-derived package version stays monotonic.

Proven: bitbake -c fetch forgectrl resolves the pin (forgectrl-1_0.1.27-r0
do_fetch succeeded).
2026-09-19 16:22:43 -04:00
ScottW514 d482e76402 installer: a download that resumes and retries, and an install log
A field install failed on "firmware download failed", and worked after a
reboot. The download was one bare curl -fL: no retry, no resume, no bound
on a stalled transfer, and nothing on the machine recorded what had gone
wrong.

The download. download_fw makes up to five tries, 5, 15, 30 and 60 seconds
apart. Each try resumes the partial file (curl -C -) and is bounded: 20 s
to connect, and a transfer below 1 KB/s for 30 s ends the try. The file is
written as forgefirm.fw.part and takes its name only when curl finished;
the signature check that follows is what vouches for its content. A full
disk (curl 23) and a release that is not there (HTTP 404) end the tries at
once, because waiting cannot fix them. A partial file the server will not
resume (curl 33 or 36, HTTP 416) starts over. The loop is the installer's
own rather than curl --retry: the factory curl on the bench reference is
7.69.1, whose --retry does not count a resolver failure or a dropped
transfer as retryable, and older factory builds carry older curls. The
owner sees the reason in words with each retry, and the final failure says
that a re-run goes straight to the download, because the archives are kept.

The log. Every run appends to /data/log/forgefirm/install/install.log, in
the log tree's own line format (UTC, program "install"): the installer's
md5 (which revision ran), the factory version and the slots, the owner's
answers, each archive, each download try with curl's exit code, the HTTP
code and the reason, the machine's clock at each try (a wrong clock breaks
TLS), and after a failed try the address, the default route, the resolver
and whether github.com resolves; then the signature and identity checks,
the write, the boot selection, and the reason for any failure through
die(). The log is appended across runs, so the run that failed is still
there after the run that worked. Logging never fails the install.
forgectrl's log export carries the directory (forgectrl 0dae758).

Proven: tests/test_installer.py runs the installer's own functions under
sh against a scripted curl - a clean download, a resolver failure and a
dropped transfer that resume to the full file, the tries running out, 404
and a full disk ending them at once, a stale partial file starting over,
the TLS reason naming the clock, every log line in the tree format, die()
leaving its reason, and an unwritable log not failing the run. The whole
host suite, 409 tests, passes under Linux and the coverage lint is clean.
Bench: the same functions under the factory firmware's own shell (busybox
1.31.1 ash, the factory slot of the bench reference in a chroot) resumed,
retried, ran out of tries and logged exactly as under sh.

Acceptance: logs.tree-tail-export now plants a probe file in the install
directory and requires it back in the export bundle, its line intact and
its MAC and IPv4 address redacted, and requires an install log in the
bundle when the machine has one. The installer itself is not on the image:
the install page fetches it from master, so it is live with this push.
2026-09-19 16:20:32 -04:00
ScottW514 8232c8c9fe forgetest: image.network-boot - the boot does not wait on the network
A client that waits in ifup's foreground on a server's answer holds the
whole machine, because init starts the rest of the boot - sshd, forgectrl,
the console login - only after S01networking returns. A field machine sat
there forever on a router that refused DHCPv6 (the record is in the
meta-openglow commit that drops the DHCPv6 client). Nothing in the catalog
looked at the network boot path; this test does.

It asserts: wlan0 is in ifupdown's state file and no ifup is running; the
console getty is up; udhcpc runs with -b (it leaves ifup after three
unanswered discovers) and has been reparented to init; no DHCPv6 client is
named in /etc/network/interfaces, running, or on the image, and its hook
script is gone; IPv6 on wlan0 is the kernel's own - enabled, router
advertisements accepted, a link-local address up. A global address is
evidence only: a network whose router advertisement offers no SLAAC prefix
gives none. What a hostile server does to a client is a bench drill, not a
test.

covers is empty by design, as with setup.machine-name: the interfaces file
is layer content, in the platform identity of every fingerprint, so a
change there already makes every test necessary again.

Proven: the host tests for the parsers and the registration
(tests/test_image.py), and the whole host suite, 398 tests, under Linux.
The test's logic, run read-only on the bench reference against an image
that carries the client, fails exactly the four DHCPv6 checks and passes
every other one. The test fails on any image built from a meta-openglow
that still carries the client, so the kas lock moves to the layer head
that drops it.
2026-09-19 15:57:42 -04:00
ScottW514 260f466400 release.sh: no slot size gate on the dev path, which is SD-only
check_size guards the 200 MiB eMMC slot. The dev path called it against the
release rootfs until 88ec984 switched that path to the dev image, and the
call was left in place: the dev rootfs is about 368 MiB - forgetest and the
bench tools - so release.sh --dev has died on "too close to the 200 MiB
slot" ever since, which is why no forgefirm-dev.fw exists anywhere in the
tree.

The gate is not the real limit. A 368 MiB rootfs cannot be raw_written into
a 200 MiB slot at all, so the dev image is never installed into one: it runs
from an SD card, which the boot selector already carries as a first-class
location and which the bench has always used. The comment says so, and the
gate now applies to the release rootfs alone, which is what it was written
for.

The dev rootfs has been this size in every archived build back to
20260911172215 (386,465,792 bytes then, 386,727,936 today), so nothing here
is a regression - the gate simply outlived the image it was guarding.
2026-09-18 18:40:19 -04:00
ScottW514 cf7b51fb32 kas lock: meta-openglow onto the pinned head with the bumped component pins
The lock held meta-openglow at dd8f74c, three commits behind the pushed
scarthgap head, so a build from this repository resolved the old BSP and
none of the component pins just bumped there. Moves that one entry to
31c5205.

poky, meta-openembedded, meta-freescale and meta-freescale-distro keep their
commits: the platform is deliberately unchanged, so the build exercises the
pins and nothing else.

Proven: bitbake -c fetch and -c populate_lic over forgectrl,
grblhal-glowforge, gfcloud, gfhome, python3-ffmachine, python3-gfhardware,
python3-gfutilities and kernel-module-glowforge all succeed - the four
LIC_FILES_CHKSUM values written ahead of their pins now match the fetched
license text. A full bitbake forgefirm-image forgefirm-image-dev run
completed, 5841 tasks, no errors, and the release rootfs manifest carries
forgectrl 0.1.26, grblhal-glowforge 0.1.18, kernel-module-glowforge 0.0.6,
python3-gfutilities 0.9.19+git0+0a93ebf30b and
gfcloud/gfhome/python3-ffmachine 0.1.30+git0+6cc4f45d31. Release rootfs
125 MiB, inside the 195 MiB gate.
2026-09-18 18:27:25 -04:00
ScottW514 e2be3ca4c5 Pin forgectrl, grblHAL-glowforge and forgefirm-app on the pushed heads
forgectrl          da3eddcc0f43 -> 7a9de005ede5  (PV 0.1.25 -> 0.1.26)
  grblhal-glowforge  ecebe9c8eb8d -> f93aca89821a  (PV 0.1.17 -> 0.1.18)
  forgefirm-app      5ca279a1f600 -> 6cc4f45d311a  (PV 0.1.29 -> 0.1.30)

forgectrl brings the jobstream_test SIGPIPE fix and the x32 setting comment;
grblHAL-glowforge brings the x32 xy_microsteps default and the rewritten
COPYING this layer's LIC_FILES_CHKSUM already expects, which no longer fails
the fetch now that the pin resolves to it. forgefirm-app tracks the same
python3-gfhardware revision meta-openglow just pinned.

Each PV moves with its SRCREV so the hash-derived package version stays
monotonic.
2026-09-18 17:49:37 -04:00
ScottW514 cd4c176a87 Finish the x32 xy_microsteps default in the baseline test and the stream harness
The x32 default landed in forgetest/baseline.py and the driver, but two
callers still judged the machine at x8 and both failed on the host.

forgetest/tests/test_baseline.py: setUp seeded the fake machine from the x8
FIXED_SYSFS literals while enforce() compares against fixed_sysfs() of the
resolved mode, so x_mode, y_mode, step_freq and ramp_rate read as deviations
on a clean machine - 23 failures across BaselineTests and
TransientNotLeftoverTests. It seeds from fixed_sysfs() now, and the tick
expectations come from it (DEFAULT_TICK) rather than a typed 28160. The
xy_mode_of and ref_xy_mode unset/invalid cases expect 32, with an explicit
"8" case added that had no coverage. Two reference_preconfig dumps taken on
an x8 machine carry xy_microsteps = 8, because the markers are read at the
reference's own mode. wait_configured wrote the static CONFIGURED_MARKERS
where the function watches configured_markers() of the mode in force, and
the held-controller jog typed 221 steps for "4.144 mm", which is 1.036 mm at
x32; both derive from the mode now. 69 tests, all pass.

scripts/bench/laser_stream_test.py: STEPS_PER_MM was the x8 53.333, so the
X-peak check failed at 2133 steps against an expected 533. The whole harness
now derives from XY_MICROSTEPS_BASE/DEFAULT the way glowforge.h and
baseline.py do, which uncovered five more x8-only expectations behind the
first: the machine tick, the fire-gap limit (it grows as sqrt(k), not k - a
finer mode shortens the accel interval by sqrt(k) while speeding the tick by
k), the rung split in fire_spans, the density period and minimum burst
(laser_pulse_ticks is in x8 ticks and the stream scales it, so the config
keeps the x8 numbers and the measured lengths scale), and the decel/hold
budgets. Run against the null-sink build: all stream emission rules hold.

xy_mode_test.py's docstring still described the no-key case as x8 while its
own assertions had moved to x32.

No behavior change and no acceptance-catalog consequence: these are test
expectations and a bench harness, not image component sources. The coverage
lint is unchanged at 0 uncovered paths.
2026-09-18 17:47:53 -04:00
ScottW514 8850adcc5e Update release 2026-09-18 15:59:00 -04:00
ScottW514 f9f4de31c1 Fixed artifact exporter 2026-09-18 14:25:45 -04:00
ScottW514 9ddab969e0 Attribution 2026-09-18 14:22:14 -04:00
ScottW514 12e7d50746 Update contributing 2026-09-18 12:50:11 -04:00
ScottW514 b3f0cd54d9 Attribution 2026-09-18 12:30:39 -04:00
ScottW514 fbac17a485 Updated release to guard against publishing without source bundle 2026-09-18 12:26:12 -04:00
ScottW514 0eb764bf75 Added SPDX 2026-09-18 12:14:22 -04:00
ScottW514 07158e5d7f Update key prov 2026-09-18 11:21:44 -04:00
ScottW514 091702c26f Updated grblhal-glowforge license checksum 2026-09-18 10:52:53 -04:00
ScottW514 9a288b175b Track the x32 xy_microsteps default in forgetest and the bench tools
Acceptance baseline: XY_MODE_DEFAULT 8 -> 32, split out XY_MODE_BASE (8) for
the tick/ramp scaling, matching the driver. The mode-aware comparisons
(fixed_sysfs of the resolved mode) judge an unset machine at x32.

Host CI: xy_mode_test.py default and invalid cases expect x32/213.333/112640.

Bench tools: raster_dry.py and xy_pattern_accel.py set_mode() cleared the key
for mode 8, which relied on clear == the x8 default; clearing now yields x32,
so they set the mode explicitly and restore to x32. live_fire_drills
STREAM_RATE_HZ is the 28160 Hz laser reference tick, the same at every mode.
2026-09-16 15:39:20 -04:00
ScottW514 147393322f CI: run the push workflows on master only, not on tag pushes
on: push had a paths filter but no branch restriction, so a tag push
(a release's v<version> tag) re-ran forgetest-ci and fixture-ci on the
commit master had already tested - a tag push bypasses the paths
filter. branches: [master] scopes the push trigger to the branch;
pull_request and workflow_dispatch are unchanged.
2026-09-15 20:21:44 -04:00
ScottW514 7a9f655916 Release v0.0.5
FORGEFIRM_RELEASE = 0.0.5 and the acceptance artifact that authorizes it:
campaign c-20260915231006 on the dev image 20260915225440 (grbl pin
0.1.17, forgefirm 52a9138), authorized, all required tests passed on the
bench reference.
2026-09-15 20:07:22 -04:00
ScottW514 52a91385cd Pin grblHAL-glowforge 0.1.17
The clean-parser-on-connect fix (ecebe9c): a soft-limit-rejected jog
held subsequent g-code until a blank line, and the hold outlived the
connection - a new sender got error:15 on its first g-code line.
2026-09-15 18:52:25 -04:00
ScottW514 b17778f538 forgetest: verdict-cut holds the daemon under the report dead-man
laser.verdict-cut freezes the daemon so the verdict the controller
caches expires and the driver's pause tier holds the job, then resumes
and proves the job ran dark through the hold with the laser latch never
locked. The freeze was 3.5 s. The engine's report dead-man locks the
laser latch when the controller's report is older than 5.0 s while
armed, and the last report is up to 1 s old when the freeze starts, so
a 3.5 s freeze left only a half second of margin - a dead-man latch
lock reads exactly like the failure the test refuses.

The freeze is now 3.0 s: still past the 2.0 s the cached verdict takes
to expire (so the pause tier holds and the hold is seen), a full second
under the dead-man. No behavior changed, only the freeze the test
holds.
2026-09-15 18:51:59 -04:00
ScottW514 d5551d7e8a Pin grblHAL-glowforge 0.1.16
The fresh-controller button-lamp reset (a87837a): a controller ended
mid-arm by a fail tier left the button ring glowing 'press to arm', and
the respawned controller did not clear it.
2026-09-15 17:36:10 -04:00
ScottW514 41417f820b forgetest: clear a latched controller error, bound the move-start check, and follow the fail-tier restart
Three acceptance tests broke on the WI-4/WI-5 firmware, all from a
behavior a change moved and no test tracked.

motion: soft limits are armed after a home (the driver's, since the bed
has no switches), so a jog past the bed is refused with error:15 - and
grblHAL then answers error:15 to every following G-code line, across a
fresh connection, until a blank line acknowledges it. A prior test's or
the baseline hand-back's rejected jog left that latch, and the next
test's first move failed with a stale error unrelated to the move.
clean_slate now clears it with a blank line before the first move. The
four "send G1, sleep, check Run once" sites are replaced by start_move,
which waits for Run on a bound and, when it does not come, names the
reply, the state, the drained messages and a forgectrl snapshot - the
capture that turned "the move did not start" into "answered error:15".

cooling.fire-watch-tiers: the FIRE fail tier now stops the controller
through the supervisor and starts it again, so leg 2's Grbl connection
dies mid-leg. It now records the pid, reads FIRE from the engine and
the latch from sysfs, waits out the restart, proves the supervisor
logged the fail-tier stop, and opens a fresh session for the legs that
follow; src/super.* added to its covers.

Proof: full forgetest host suite green; coverage lint clean (90 tests);
bench reference on the dev image, unattended queue: motion.* and
cooling.* pass, cooling.fire-watch-tiers and cooling.fail-tier-stop
pass with the button-lamp driver fix (grblHAL-glowforge).
2026-09-15 17:35:25 -04:00
ScottW514 db6015dc81 forgetest: cloud.mode-switch opens the lid behind the controller's start, ahead of the hunt
The supervisor holds every controller spawn until the enclosure is
closed (forgectrl 0.1.25), and the test opened the lid before it asked
for the cloud controller: POST /mode answered "waiting, the lid is
open" and the controller never came up. The round trip now switches
with the lid closed, polls /mode five times a second, and opens the
lid the moment the controller is running. The client requests its
connect-time hunt a few seconds after its start, right behind its
session, so the hunt still finds the lid open. The order is recorded
and judged: the hunt's request line must not be in the client's log
when the lid reads open (hunt_before_lid_open), and the test refuses
to start with the lid open. The catalog text tells the operator to
open the lid at once, with a hand ready on it.

Proof. Host: test_cloud_suite drives the round trip with the hunt
landing only once the lid reads open, as on the bench, plus the lost
race (the hunt requested before the lid opened fails the test with
"before the lid was open") and the start with the lid open refused;
8 mode-switch cases green. Bench reference (dev image 20260915001814,
forgectrl 0.1.25): cloud.mode-switch PASS in 118 s, the lid open 4 s
before the client requested its hunt, no refusal before the hunt's
end, the lens homed, the exhaust row unjudged, 5 service motions
after the lid closed, $H under gfhome homed in 48.4 s with 9 motion
windows. No catalog consequence beyond the test itself: its covers
map is unchanged.
2026-09-15 15:41:35 -04:00
ScottW514 0c87927388 Pin grblHAL-glowforge 0.1.15
The driver at its AGENTS.md and header-comment revision (9bfaa98): no
code change since 0.1.14; the pinned source now matches the head of the
repository for the release.
2026-09-14 20:15:43 -04:00
ScottW514 ea8a94a7d1 AGENTS.md: unreadable is fail-closed, and the drills that are catalog tests
The project-wide safety rule joins every repository: a failed read of
a safety input, a state file, or a verdict is never treated as still
running or keep waiting without a bound. The acceptance-coverage rules
add that a silence-while-armed injection and a crash during $H are
catalog drills, not one-off bench scripts.
2026-09-14 20:08:14 -04:00
ScottW514 79c03641bd Lock meta-openglow at the python3-gfhardware pin
The BSP layer at dd8f74c: python3-gfhardware pinned on the cloud
client's latch and feed changes (5ca279a), the same revision the
forgefirm-app recipes pin, and the layer's line endings normalized.
2026-09-14 20:04:58 -04:00
ScottW514 ea6ac54ab3 Pin forgefirm-app 0.1.29
The cloud client: the laser latch unlocks at the run and nowhere
earlier, the warm-up is supervised, a live feed must land and finish,
fire needs a power byte first, the homing runner always stops
(python3-gfhardware 5ca279a; the pin also carries the "setup" naming
in the comments and the LF normalization). Bench-proven with
cloud.dark-print, cloud.verdict-refuse and motion.deadman on the bench
reference.
2026-09-14 19:45:44 -04:00
ScottW514 f080d5d9cb Catalog: the cloud client's latch at the run, and a hold that never clears
The cloud client changes (python3-gfhardware: the latch unlocks at the
run and nowhere earlier, the warm-up is supervised, a live feed must
land and finish, fire needs a power byte first, the homing runner
always stops). This commit carries the catalog tests that hold the
part the bench can see.

forgetest/forgetest/suite/cloud.py:
- cloud.dark-print (new, kind operator, one press, dark by
  construction) replaces cloud.verdict-hold. The print arms on the
  press, waits for the engine's acknowledgment and runs: the laser
  latch is locked at the button and through the wait, unlocked only
  for the run (immediately before it starts), locked again when the
  job ends, and the print completes. The engine's own warm-up release
  stays proven by cooling.floor-and-warm-up.
- cloud.verdict-refuse (new, kind operator, one press, dark): the
  start gate far above the coolant and cloud_hold_max_s at its minimum
  keep the armed print under the warm-up past the bound. The client
  waits with the latch locked (sampled every two seconds), cancels at
  the bound with its own log line, never runs, closes the armed window,
  and the print ends ':cancelled'. The settings are restored.
- cloud.verdict-hold is retired: its release rode the loop heater at
  the flow-check duty against a gate one degree above the coolant,
  inside the upstream reading's noise band, and its run hovered for
  minutes with the engine's "warm-up stalled" line in the log. The two
  tests above prove the client's contract without the thermal race.
- cloud.oversize-stream already reads cnc/streaming back at both ends
  of the run, which is the readback the client now insists on. A
  forced streaming write failure has no seam on the board (the write
  goes to sysfs as root) and stays a host test.

tests/test_cloud_suite.py follows: excerpts and failure cases for the
two new tests in place of the retired one's.

Proof. Host: the forgetest unit tests. Bench reference:
cloud.dark-print passed (locked at the button, unlocked in the run,
locked after, ':completed'), cloud.verdict-refuse passed (held 60 s,
31 latch samples all locked, ':cancelled', settings restored);
motion.deadman's kill during $H ended the runner on SIGTERM alone; and
the retired cloud.verdict-hold passed once more on the new client
before it went (the latch locked through an 8 minute warm-up hold, the
release ran the print to completion).
2026-09-14 19:45:20 -04:00
ScottW514 d465625a98 Pin forgectrl 0.1.25
The supervisor: controller death as a signal, the homing runner and
the kernel before a respawn, the enclosure check before every spawn,
the engine's fail tiers ending the controller (forgectrl da3eddc; the
pin also carries the "setup" naming of the first run and the LF
normalization, whose advisory wording change asks every machine to
accept the advisories again). Bench-proven with motion.deadman,
motion.respawn-gate and cooling.fail-tier-stop on the bench reference.
2026-09-14 18:44:02 -04:00
ScottW514 7089a25721 Catalog tests for the supervisor: a kill during $H, a respawn behind the lid, the fail-tier stop, the 300 ms relock
The daemon's supervisor and engine change (forgectrl: controller death
as a signal, the homing runner and the kernel before a respawn, the
enclosure check before every spawn, the fail tiers ending the
controller). This commit carries the catalog tests that hold them.

forgetest/forgetest/suite/motion.py:
- motion.deadman gains a last phase: SIGKILL of the controller during
  $H (the web-service homing, run only with cloud mode enabled, the
  homing mode set and put back by the test). The homing runner must be
  gone before the respawn, the kernel idle when the new controller
  starts, no halt needed, and the pulse device held by the daemon and
  one controller. The head ends wherever the homing was.
- motion.respawn-gate (kind operator, the lid through the fixture where
  one is wired): the controller killed with the lid open. The
  supervisor safes (latch locked), reports waiting with why naming the
  lid, starts nothing while the lid stays open, and comes back verified
  when it closes, without a second motion probe.

forgetest/forgetest/suite/laser.py:
- laser.armed-kill reads the latch and the kernel state from sysfs
  every few milliseconds after the SIGKILL: the latch must lock within
  300 ms of the kill and the kernel leave running within a second (a
  death is a signal to the supervisor, not a poll). The emission bound
  stays at 2.5 s: the witness counts a window.

forgetest/forgetest/suite/cooling.py:
- cooling.fail-tier-stop (kind operator, one press, no emission): the
  crash watch's thresholds at their lowest make the head's own move
  trip the abort generator inside an armed, dark (S0) job. The engine
  logs the crash signal, the supervisor logs the stop and starts a new
  controller, the latch is locked, the kernel idle, no emission, the
  thresholds put back. The watch exists only inside the armed window,
  so the press is the arm, not a fire.

Proof. Host: the forgetest unit tests. Bench reference: motion.deadman
passed with the new phase (the runner gone in 0.9 s, the kernel idle
at the respawn, holders forgectrl and grblHAL_glowforge),
motion.respawn-gate passed (waiting 1.3 s after the kill with "the lid
is open", running and verified the moment the lid closed, no probe
line), cooling.fail-tier-stop passed (the signal at +0.27 s, the
controller pid 21823 to 22273, latch locked, emission 0).
2026-09-14 18:44:02 -04:00
ScottW514 d65a7aa6aa Pin grblHAL-glowforge 0.1.14
Grbl protocol: the welcome banner on every connect, a one-second TX
stall bound, a 2 KiB TX ring (grblHAL-glowforge e507e13). Bench-proven
on the bench reference: a LightBurn connect shows the banner and the
machine settings read completes without a disconnect.
2026-09-14 17:52:18 -04:00
ScottW514 70e945a949 Pin grblHAL-glowforge 0.1.13
Stream and motion robustness: the shipper writes outside the lock, a
clamp inside an armed window faults, the X/Y soft limits follow the
home, the machine's settings are pinned, the homing keys are clamped
(grblHAL-glowforge 511ff25). Bench-proven with motion.soft-limits and
motion.deadman on the bench reference.
2026-09-14 17:28:48 -04:00
ScottW514 2f2a4160af Stream and motion robustness: harness rules, catalog tests, the hand-back's counter scale
The GRBL driver's stream engine and its motion envelope change
(grblHAL-glowforge: the shipper writes outside the lock, a clamp inside
an armed window faults, the X/Y soft limits follow the home, the
machine's settings are pinned, the homing keys are clamped). This commit
carries the host rules and the catalog tests that hold them; the driver
commit follows, because its CI fetches these harnesses unpinned.

scripts/bench/laser_stream_test.py:
- Rule 28: a 300 ms producer stall while armed faults the stream with
  ALARM:17, the kernel sees no step burst (at most the planned steps per
  100 ticks), the stream ends dark, the latch sideband ends on the lock.
  The same stall unarmed is a warning: the move completes with every
  step, the clamp visible as the burst the kernel counts.
- Rule 29: a 300 ms stall of the sink's write leaves the producer on
  pace: no clamp, every step, lit through, dark at the end.
- The stand-in engine takes GFSINK_STALL_MS and GFSINK_WRITE_STALL_MS,
  null-sink only.

scripts/bench/z_envelope_test.py:
- Rule 10: homed (a gfcloud home), a program move past X max, Y max or
  the near edge alarms with ALARM:2 before any motion, a jog past the bed
  is refused with error 15, a move inside the bed runs, and a $20 write
  keeps the limits. The core repeats the last error for the line after a
  refused jog until an empty line clears it, so the rule sends one.

forgetest/forgetest/suite/motion.py:
- motion.soft-limits (kind auto, no emission): homes through the cloud
  suite's gfhome homing when the machine is not homed, then the three
  refusals (ALARM:2, the kernel counters still), the refused jog, the
  inside move, and the return to the corner read at rest.
- motion.deadman phase 2b: a 100 ms SIGSTOP mid-move, inside the
  kernel's queue: no underrun, the controller's log warns of the clamped
  late events, the move completes with every step (read at rest), the
  latch stays locked. The armed clamp is proven on the host (rule 28).

forgetest/forgetest/suite/cloud.py:
- gfhome_homing drains the driver's answer to $H once the session ends:
  it sits behind the status reports and passed for the reply to the
  caller's next command (a setting read as None).

forgetest/forgetest/baseline.py:
- The hand-back reads the position counters at the kernel's own
  microstep mode (cnc/x_mode, read before the sysfs restore puts the
  settings' mode back). At the x8 constant, an x32 machine's 30 mm read
  as 120 mm, beyond the return bound, and the displaced head was left in
  place. The dead band scales the same way. tests/test_baseline.py holds
  both.

Proof. Host: stream rules 1 to 29 and z_envelope rules 1 to 10 against
the null-sink driver, the forgetest unit tests. Bench reference:
motion.soft-limits passed (X 495 and Y 279 refused with ALARM:2 and
0.000 mm of kernel motion, X-1 the same, the jog error:15, the inside
move ran, back at the corner 0.0/0.0 mm); motion.deadman passed with
the new phase (92 late events clamped, max behind 92.3 ms, no underrun,
30.0 mm counted, latch locked) and the hand-back jogged the head back
under the x32 scale.
2026-09-14 17:28:28 -04:00
ScottW514 ea458850f0 Pin grblHAL-glowforge 0.1.12
The emission gates: the per-tick fire gate, the latch with an owner,
the cooling verdict's two tiers, and the arm-flow gates
(grblHAL-glowforge 16d2b9e). Bench-proven with laser.verdict-cut and
laser.armed-kill on the bench reference.
2026-09-14 16:08:17 -04:00
ScottW514 e9d5f3c842 Laser harness rules and catalog tests for the emission gates
The GRBL driver's emission gates change (grblHAL-glowforge: the per-tick
fire gate, the latch with an owner, the cooling verdict's two tiers, and
the arm-flow gates). This commit carries the host rules and the catalog
tests that hold them; the driver commit follows, because its CI fetches
these harnesses unpinned.

scripts/bench/laser_stream_test.py:
- Rule 24 is the verdict's pause tier: the client holds the job under the
  open window, the first deceleration runs lit to the stop (the dark lead
  before the stop is at most 1000 ticks: the producer's lead plus one
  shipper period), a resume under the standing verdict moves dark and is
  held again, the clean verdict resumes lit with no press, and the latch
  sideband carries only the arm's unlock and the program end's lock.
- Rule 25 is the fail tier: AIRFLOW mid-cut ends the job with ALARM:3,
  the stream ends dark well short of the line, the sideband ends on the
  lock, and a resume under the clean verdict that follows resumes nothing.
- Rule 26: a sender change mid-M3 holds the job with the deceleration
  dark: the gate follows the window on every tick.
- Rule 27: a verdict that goes stale holds the job at the cache's own
  expiry, lit to the stop, never a poll later; the engine's return
  resumes lit.
- The stand-in engine publishes the verdict name and has a stale mode;
  the session steps gain expect_text, reconnect and a wait_state timeout;
  every session reads the latch sideband (GFSINK_LATCH_LOG).

scripts/bench/laser_lifecycle_test.py:
- Rules 11 to 15: the pause tier resumes with no press and no prompt, the
  fail tier ends the job and nothing resumes it, a sender change during a
  re-arm cancels it, a jog does not hold the window open, and a press
  counts only after the button has been seen up. The stand-in engine
  takes a live verdict dict. start_armed_move waits for a fresh prompt
  and a fresh armed message: a press that lands before the wait has
  begun is not consent, and the old stale match let one land early.

forgetest/forgetest/suite/laser.py:
- laser.verdict-cut (kind live, one press): a 40 mm M3 line; the test
  pauses the daemon for 3.5 s so the verdict goes stale (the settings
  route is idle-gated and the engine reloads its gates at a session
  start, so no setting can trip a pause mid-cut; the crash tiers need a
  physical knock). The controller must hold with the SoC latch and the
  hardware button latch both clear in every sample, emission must read 0
  before the resume, and the clean verdict must resume the cut lit with
  no press and no prompt; M2 disarms as usual.
- laser.armed-kill asserts that the respawned controller comes up with
  the latch still locked.

Proof: both harnesses pass against the driver change on the host, and
the forgetest unit tests pass. On the bench reference, laser.verdict-cut
passed with no gap in the cut (held at +1.71 s after the pause began,
both latches clear in every sample, emission 0 at +2.06 s after the
hold, resumed lit at +4.0 s with the beam detector 678 counts over idle,
kernel drift 0.0 mm, disarmed 0.1 s after Idle) and laser.armed-kill
passed (emission 0 at +1.8 s after the supervisor's stop and +2.1 s
after the SIGKILL, latch locked, respawned with the latch locked, button
dark). Two earlier verdict-cut runs shaped the driver: a pause that
locked the latch resumed dark, because a lock sets the hardware button
latch, and a hold taken a poll after the gate closed left a several-mm
gap.

Catalog: one test added and one extended; both cover src/** of the
driver through the existing laser covers.
2026-09-14 16:07:55 -04:00
ScottW514 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.
2026-09-14 10:39:08 -04:00
ScottW514 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.
2026-09-14 10:37:55 -04:00
ScottW514 c0d4302d73 ForgeFIRM v0.0.4
FORGEFIRM_RELEASE 0.0.4. The acceptance record that authorizes this
build: campaign c-20260912182427-e1ee on dev image 20260912180956
(manifest 8bb5b6f3), 85 tests, 85 satisfied, 0 required, exported
2026-09-12T19:21:35Z. The release notes in releases/v0.0.4/notes.md
name this the first public beta; the pipeline takes a release's
notes.md, when it carries one, in place of GitHub's generated notes.
2026-09-12 15:22:33 -04:00
ScottW514 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.
2026-09-12 14:08:34 -04:00
ScottW514 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.
2026-09-12 12:58:27 -04:00
ScottW514 2dde10cf43 Pin forgectrl 0.1.23 (the mirror test reads the release reply's packed keys)
forgectrl 0.1.23 = cb8c140: the dev-server mirror test holds the mock's
release replies to the keys reply_release packs; the daemon is the one
0.1.22 carried. CI is green on this revision.
2026-09-12 12:48:40 -04:00
ScottW514 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.
2026-09-12 12:41:56 -04:00
ScottW514 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.
2026-09-11 16:29:34 -04:00
ScottW514 b7a595ced7 Release v0.0.3: the version and the acceptance artifact for this tree
FORGEFIRM_RELEASE takes 0.0.3. The file sits outside the layer content
hash, so the bump changes the version and invalidates no acceptance
result.

releases/v0.0.3 carries the artifact the bench exported for this image:
campaign c-20260911182256-078f on 20260911172215 (dev), manifest identity
fb24c3f4508ae54592f8d0b98ed0b2a07a933274980d8c1e4e5a967618ef9bd8, 83
tests, 83 satisfied (57 inherited), 0 required, release authorized. The
release gate recomputes every catalog test's domain fingerprint from the
manifest inside the release rootfs and signs only when the recorded
results agree.

The release carries, since v0.0.2: the lens frame in one place, so every
Z a commissioning card sends comes from the settings the controller
opens its Z limit from (the tail at the head's reference, the focus
window written before the card's controller starts, every program judged
against the reach before its first line goes out); the commissioning
sheet acceptance test run as a fresh machine's; and the CPU percent on
/status that holds across a status read inside the same scheduler tick
as another reader's. Components: forgectrl 0.1.20, grblhal-glowforge
0.1.11, forgefirm-app 0.1.28+git, kernel-module 0.0.5, meta-openglow
ced2af2.
2026-09-11 14:55:25 -04:00
ScottW514 5c93e49c8c Pin forgectrl 0.1.20: the CPU percent holds across a same-tick status read
forgectrl 864b8da repeats the last CPU percent when a /status read finds
the /proc/stat counters unmoved, the race that failed
forgectrl.panel-serves on the release candidate when the test's second
read shared a scheduler tick with a panel poll. Every test covering
forgectrl re-runs.
2026-09-11 13:19:45 -04:00
ScottW514 650483c085 Pin forgectrl 0.1.19: the lens frame with a workflow file that parses
forgectrl 3e54612 renames the lens_test step in build.yml, whose name
held a colon that YAML read as a mapping, so CI on f8ddb17 ran nothing.
The source is f8ddb17's; the pin moves so the release names a commit
with a green build.
2026-09-11 12:55:02 -04:00
ScottW514 7b8f72b632 Run the commissioning sheet as a fresh machine, and pin forgectrl 0.1.18 (the lens frame)
forgectrl f8ddb17 puts the lens frame in one place and writes the focus
window before the card's controller starts, after two commissioning
cards ended in ALARM:2 on a Z the wizard sent from one source while the
controller's Z limit stood on another. The acceptance run had passed
only because the bench's settings already held the stops from an
earlier focus run, so the run is now a fresh machine's.

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

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

The forgectrl pin moves to f8ddb17 (0.1.18); every test covering
forgectrl re-runs.
2026-09-11 12:44:43 -04:00
184 changed files with 95465 additions and 1648 deletions
+4
View File
@@ -0,0 +1,4 @@
# Every text file is stored and checked out with LF; a patch keeps its bytes.
* text=auto eol=lf
*.patch -text
*.diff -text
+1
View File
@@ -11,6 +11,7 @@ name: fixture-ci
on: on:
push: push:
branches: [master]
paths: paths:
- 'fixture/**' - 'fixture/**'
- '.github/workflows/fixture-ci.yml' - '.github/workflows/fixture-ci.yml'
+1
View File
@@ -20,6 +20,7 @@ name: forgetest-ci
on: on:
push: push:
branches: [master]
paths: paths:
- 'forgetest/**' - 'forgetest/**'
- 'scripts/acceptance-gate.py' - 'scripts/acceptance-gate.py'
+10 -1
View File
@@ -45,6 +45,8 @@ contributor is welcome to read it too: it is the same set of rules.
chain of others. chain of others.
- Never exercise a gate or a limit through the `GFCOOL_*` environment - Never exercise a gate or a limit through the `GFCOOL_*` environment
overrides in a test. Use the settings API, and restore it in the teardown. overrides in a test. Use the settings API, and restore it in the teardown.
- A silence-while-armed injection and a crash during `$H` are catalog
drills, not one-off bench scripts.
### CI couplings that set the push order ### CI couplings that set the push order
@@ -186,6 +188,9 @@ does.
test with the fix, in the same commit, never after. test with the fix, in the same commit, never after.
- Position counters, homing anchors, and a homed flag are not proof of - Position counters, homing anchors, and a homed flag are not proof of
physical motion. The head accelerometer is, and so are the operator's eyes. physical motion. The head accelerometer is, and so are the operator's eyes.
- Unreadable is fail-closed. A failed read of a safety input, a state file,
or a verdict is never treated as "still running" or "keep waiting" without
a bound.
### Proof before done ### Proof before done
@@ -320,7 +325,11 @@ docstrings, log and console text, commit messages, issue text, and replies.
comment style of the file: `Copyright <year> 514 LLC d/b/a OpenGlow` and comment style of the file: `Copyright <year> 514 LLC d/b/a OpenGlow` and
`Written by Scott Wiederhold`. Keep the year, or the year range, that `Written by Scott Wiederhold`. Keep the year, or the year range, that
the file already has. The copyright notices of other holders stay the file already has. The copyright notices of other holders stay
unchanged. unchanged. Those two lines are for files this project authors: an
outside contributor who writes a new file puts their own name there, or
leaves the lines out, and keeps their copyright in what they wrote.
Editing an existing file changes no header - a contribution is recorded
by its `Signed-off-by` line and by git, not by a copyright line.
- **SPDX.** Every new source file carries an `SPDX-License-Identifier` line - **SPDX.** Every new source file carries an `SPDX-License-Identifier` line
under the license of its repository. under the license of its repository.
+54
View File
@@ -0,0 +1,54 @@
# Contributing
Patches are welcome. How the project works, what proof a change needs, and the
writing rules are on the documentation site:
<https://docs.forgefirm.org/developers/contributing/>.
## Sign your work
Every commit carries a `Signed-off-by` line:
Signed-off-by: Your Name <your.email@example.com>
`git commit -s` adds it, using your `user.name` and `user.email`. Use your real
name; an anonymous or pseudonymous sign-off certifies nothing.
The line certifies the Developer Certificate of Origin, version 1.1, below. It
is the same statement the Linux kernel and grblHAL use. It is not a copyright
assignment: you keep the copyright in what you write, and your contribution
comes in under the license of the repository you are contributing to.
You do not add a copyright line for a patch. A change to an existing file
leaves its header alone. If you write a whole new file, put your own name in
its copyright line, or leave the line out; either way the file carries an
`SPDX-License-Identifier` for the repository's license.
## Developer Certificate of Origin 1.1
By making a contribution to this project, I certify that:
(a) The contribution was created in whole or in part by me and I
have the right to submit it under the open source license
indicated in the file; or
(b) The contribution is based upon previous work that, to the best
of my knowledge, is covered under an appropriate open source
license and I have the right under that license to submit that
work with modifications, whether created in whole or in part
by me, under the same open source license (unless I am
permitted to submit under a different license), as indicated
in the file; or
(c) The contribution was provided directly to me by some other
person who certified (a), (b) or (c) and I have not modified
it.
(d) I understand and agree that this project and the contribution
are public and that a record of the contribution (including all
personal information I submit with it, including my sign-off) is
maintained indefinitely and may be redistributed consistent with
this project or the open source license(s) involved.
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
This DCO text is licensed under the terms of the Creative Commons
Attribution-ShareAlike 3.0 Unported license.
+9
View File
@@ -5,5 +5,14 @@ the associated recipe (.bb file) unless otherwise stated.
See: See:
COPYING.MIT (MIT) COPYING.MIT (MIT)
The code in this repository that is not layer metadata is MIT licensed:
forgetest/ the release acceptance tool (see forgetest/LICENSE)
scripts/ the release, install and bench scripts
fixture/ the bench actuator firmware, excluding
fixture/managed_components/, which carries its own licenses
Every file in those directories states MIT in its own header.
License information for any other files is either explicitly stated License information for any other files is either explicitly stated
or defaults to GPL version 2. or defaults to GPL version 2.
+2 -1
View File
@@ -83,4 +83,5 @@ before you install.
**This is experimental software. Use of it could seriously maim or kill you or **This is experimental software. Use of it could seriously maim or kill you or
others, and it may void your warranty. Use it at your own risk.** others, and it may void your warranty. Use it at your own risk.**
This project is not affiliated with or endorsed by Glowforge. Glowforge is a trademark of Glowforge, Inc. This project is not affiliated
with, authorized by, or endorsed by Glowforge, Inc.
+4
View File
@@ -1,4 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
# forgefixture: build, flash and watch the bench actuator's firmware. # forgefixture: build, flash and watch the bench actuator's firmware.
# #
# ./fixture.sh env fixture.env from the example, with a fresh API key # ./fixture.sh env fixture.env from the example, with a fresh API key
+5
View File
@@ -1,3 +1,8 @@
/*
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*/
/* forgefixture firmware version, reported by GET /. */ /* forgefixture firmware version, reported by GET /. */
#pragma once #pragma once
+4
View File
@@ -1,4 +1,8 @@
#!/bin/sh #!/bin/sh
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
# The host test of the fixture's policy: needs only a C compiler. # The host test of the fixture's policy: needs only a C compiler.
set -e set -e
cd "$(dirname "$0")" cd "$(dirname "$0")"
+23
View File
@@ -0,0 +1,23 @@
ForgeFIRM release acceptance tool (forgetest/) and the bench scripts it
drives (scripts/bench/).
Copyright 2026 514 LLC d/b/a OpenGlow
Written by Scott Wiederhold
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+6 -2
View File
@@ -1,4 +1,8 @@
#!/bin/sh #!/bin/sh
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
### BEGIN INIT INFO ### BEGIN INIT INFO
# Provides: forgetest # Provides: forgetest
# Required-Start: $network forgectrl # Required-Start: $network forgectrl
@@ -22,13 +26,13 @@ case "$1" in
start) start)
echo "Starting forgetest" echo "Starting forgetest"
mkdir -p "$DATA" mkdir -p "$DATA"
# The bench seed: the commissioning override lifts the wizard part of # The bench seed: the setup override lifts the wizard part of
# the controller gate until the next reboot (/run is tmpfs), so a # the controller gate until the next reboot (/run is tmpfs), so a
# campaign runs on a machine whose required wizards are not complete. # campaign runs on a machine whose required wizards are not complete.
# Nothing lifts the agreements or the account: the bench accepts them # Nothing lifts the agreements or the account: the bench accepts them
# once, and that stays in /data. # once, and that stays in /data.
mkdir -p /run/forgefirm mkdir -p /run/forgefirm
: > /run/forgefirm/commissioning-override : > /run/forgefirm/setup-override
start-stop-daemon -S -q -p $PIDFILE -m -b -x /bin/sh -- -c \ start-stop-daemon -S -q -p $PIDFILE -m -b -x /bin/sh -- -c \
"exec /usr/bin/python3 -m forgetest >> $LOG 2>&1" "exec /usr/bin/python3 -m forgetest >> $LOG 2>&1"
;; ;;
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""forgetest - the ForgeFIRM release acceptance tool. """forgetest - the ForgeFIRM release acceptance tool.
A daemon on the dev image (HTTP :8090) that runs the acceptance catalog A daemon on the dev image (HTTP :8090) that runs the acceptance catalog
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""forgetest daemon entry point. """forgetest daemon entry point.
python3 -m forgetest [--port 8090] [--host ::] [--manifest PATH] python3 -m forgetest [--port 8090] [--host ::] [--manifest PATH]
+12 -1
View File
@@ -1,5 +1,15 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The release artifact (acceptance.json / acceptance.md) and its verification. """The release artifact (acceptance.json / acceptance.md) and its verification.
Every test record is redacted before it enters the artifact (redact.py), so
the committed file carries no identity of the machine that ran the campaign.
It happens inside build(), ahead of the self-hash, because verify() rejects a
file edited after the fact. The gate reads none of the redacted fields.
The exporter serializes the campaign state with, for every catalog test, The exporter serializes the campaign state with, for every catalog test,
the winning result record (same-campaign PASS or inherited PASS, with its the winning result record (same-campaign PASS or inherited PASS, with its
origin) and the fingerprint it was recorded under. The gate origin) and the fingerprint it was recorded under. The gate
@@ -15,6 +25,7 @@ from . import VERSION
from . import campaign as _campaign from . import campaign as _campaign
from . import manifest as _manifest from . import manifest as _manifest
from .log import now_ts from .log import now_ts
from .redact import scrub
FORMAT = 1 FORMAT = 1
_RESULT_KEYS = ("ts", "campaign", "test", "result", "fingerprint", "manifest_sha", "image", _RESULT_KEYS = ("ts", "campaign", "test", "result", "fingerprint", "manifest_sha", "image",
@@ -47,7 +58,7 @@ def build(state, tests, manifest, records, catalog_hash):
"fingerprint": st["fingerprint"], "fingerprint": st["fingerprint"],
"satisfied": st["satisfied"], "satisfied": st["satisfied"],
"inherited": st["status"] == "inherited", "inherited": st["status"] == "inherited",
"record": _record(rec) if rec else None, "record": scrub(_record(rec)) if rec else None,
}) })
tests_out.append(entry) tests_out.append(entry)
+65 -21
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The fresh-boot idle state: what every test starts from and leaves behind. """The fresh-boot idle state: what every test starts from and leaves behind.
The runner checks the machine against this baseline before a run and The runner checks the machine against this baseline before a run and
@@ -48,7 +53,7 @@ from .log import now_ts
# in the pulse path (a job's Z moves the lens; the driver's Z soft limit # in the pulse path (a job's Z moves the lens; the driver's Z soft limit
# guards it), step_freq its machine tick and ramp_rate the kernel stop # guards it), step_freq its machine tick and ramp_rate the kernel stop
# ramp, both derived from the XY microstep mode (the values here are the # ramp, both derived from the XY microstep mode (the values here are the
# x8 default's; fixed_sysfs() gives the mode's); streaming is only ever 1 # x8 base; fixed_sysfs() gives the mode's); streaming is only ever 1
# inside a live job; the head white LED is a camera lamp, off at idle; the # inside a live job; the head white LED is a camera lamp, off at idle; the
# loop heater and TEC are the diagnostics' tools, off at idle. # loop heater and TEC are the diagnostics' tools, off at idle.
FIXED_SYSFS = [ FIXED_SYSFS = [
@@ -133,6 +138,7 @@ BOOT_MAX_AGE_S = 600 # a boot reference is taken only this soon a
SETTLE_S = 150 # the supervisor's probe + rail-off ladder SETTLE_S = 150 # the supervisor's probe + rail-off ladder
CAM_IDLE_S = 20 # camera engine idle stop is 10 s CAM_IDLE_S = 20 # camera engine idle stop is 10 s
COOL_IDLE_S = 120 # cooldown after motion COOL_IDLE_S = 120 # cooldown after motion
COOL_PUBLISH_S = 2.5 # the engine publishes once a tick (1 Hz): two ticks and a margin
IDLE_S = 30 # cnc/state back to idle after a job IDLE_S = 30 # cnc/state back to idle after a job
GRBL_PORT_S = 30 # the Grbl port after the supervisor reports grblHAL running GRBL_PORT_S = 30 # the Grbl port after the supervisor reports grblHAL running
@@ -147,11 +153,12 @@ RETURN_MAX_MM = 100.0 # a displaced head is jogged back at most th
POSITION_DEADBAND_MM = 0.1 POSITION_DEADBAND_MM = 0.1
# The XY microstep mode (the xy_microsteps setting: 8, 16 or 32; unset = # The XY microstep mode (the xy_microsteps setting: 8, 16 or 32; unset =
# 8). The GRBL controller reads it at its start and derives its scale, # 32). The GRBL controller reads it at its start and derives its scale,
# its machine tick and the kernel stop ramp from it (boards/glowforge.h): # its machine tick and the kernel stop ramp from it (boards/glowforge.h):
# the fixed values of x/y_mode, step_freq and ramp_rate are the mode's. # the fixed values of x/y_mode, step_freq and ramp_rate are the mode's.
XY_MODES = (8, 16, 32) XY_MODES = (8, 16, 32)
XY_MODE_DEFAULT = 8 XY_MODE_BASE = 8 # the factory x8 reference the tick and ramp scale from
XY_MODE_DEFAULT = 32 # the mode an unset or invalid setting reads as
XY_STEPS_PER_MM_OF = {8: 53.333, 16: 106.667, 32: 213.333} # $100/$101 per mode XY_STEPS_PER_MM_OF = {8: 53.333, 16: 106.667, 32: 213.333} # $100/$101 per mode
XY_TICK_X8_HZ = 28160 # the x8 machine tick XY_TICK_X8_HZ = 28160 # the x8 machine tick
XY_RAMP_X8_HZ_PER_S = 125000 # the kernel stop ramp at it XY_RAMP_X8_HZ_PER_S = 125000 # the kernel stop ramp at it
@@ -175,7 +182,7 @@ def fixed_sysfs(mode=XY_MODE_DEFAULT):
"""FIXED_SYSFS with the mode's own x/y_mode, step_freq and ramp_rate: """FIXED_SYSFS with the mode's own x/y_mode, step_freq and ramp_rate:
the tick and the ramp scale with the mode (x16 doubles both, x32 the tick and the ramp scale with the mode (x16 doubles both, x32
quadruples them).""" quadruples them)."""
k = mode // XY_MODE_DEFAULT k = mode // XY_MODE_BASE
own = {"cnc/x_mode": str(mode), "cnc/y_mode": str(mode), own = {"cnc/x_mode": str(mode), "cnc/y_mode": str(mode),
"cnc/step_freq": str(XY_TICK_X8_HZ * k), "cnc/step_freq": str(XY_TICK_X8_HZ * k),
"cnc/ramp_rate": str(XY_RAMP_X8_HZ_PER_S * k)} "cnc/ramp_rate": str(XY_RAMP_X8_HZ_PER_S * k)}
@@ -217,17 +224,25 @@ def read_position():
return None return None
def position_quantized(was, now): def counter_steps_per_mm():
"""The scale of the X/Y position counters: the kernel counts the
microsteps of the mode it runs (cnc/x_mode), so x16 and x32 count two
and four times the x8 steps for the same millimeter. The x8 scale when
the attribute is unreadable."""
return XY_STEPS_PER_MM_OF.get(hw.sysfs_int("cnc/x_mode"), XY_STEPS_PER_MM)
def position_quantized(was, now, spm=XY_STEPS_PER_MM):
"""True when two step-counter readings differ by no more than the """True when two step-counter readings differ by no more than the
dead band on X and Y and not at all on Z: the step quantization of a dead band on X and Y and not at all on Z: the step quantization of a
move that landed where it meant to, rather than a leftover. A cancel move that landed where it meant to, rather than a leftover. A cancel
that returns the head to the job start lands within a few hundredths that returns the head to the job start lands within a few hundredths
of a millimeter, which is a step or four, and whether that rounds to of a millimeter, which is a step or four, and whether that rounds to
the same integer is chance.""" the same integer is chance. spm is the counters' scale (the mode's)."""
if was is None or now is None or now[2] != was[2]: if was is None or now is None or now[2] != was[2]:
return False return False
return (abs(now[0] - was[0]) / XY_STEPS_PER_MM <= POSITION_DEADBAND_MM and return (abs(now[0] - was[0]) / spm <= POSITION_DEADBAND_MM and
abs(now[1] - was[1]) / XY_STEPS_PER_MM <= POSITION_DEADBAND_MM) abs(now[1] - was[1]) / spm <= POSITION_DEADBAND_MM)
def read_ring_residue(): def read_ring_residue():
@@ -321,7 +336,7 @@ class Baseline:
def wait_settled(self, timeout=SETTLE_S, unreachable_s=10): def wait_settled(self, timeout=SETTLE_S, unreachable_s=10):
"""Block until forgectrl reports a settled supervisor: motion """Block until forgectrl reports a settled supervisor: motion
verified (the probe passed), motion-fault (the ladder exhausted), verified (the probe passed), motion-fault (the ladder exhausted),
standby (the manual stop lever), or gated (the commissioning gate standby (the manual stop lever), or gated (the setup gate
is closed: no controller spawns until it opens). Gives up after is closed: no controller spawns until it opens). Gives up after
unreachable_s without an answer. Returns the last /mode body (None unreachable_s without an answer. Returns the last /mode body (None
if unreachable).""" if unreachable)."""
@@ -450,6 +465,9 @@ class Baseline:
preserved state to hand back (post) - None compares nothing.""" preserved state to hand back (post) - None compares nothing."""
left = [] left = []
self.mode = None self.mode = None
# the counters' scale is the mode the run counted under, read
# before the kernel side puts the settings' mode back
self._counter_spm = counter_steps_per_mm()
self._forgectrl_side(left, captured) self._forgectrl_side(left, captured)
self._kernel_side(left) self._kernel_side(left)
self._lamp_side(left) self._lamp_side(left)
@@ -596,13 +614,37 @@ class Baseline:
except OSError as e: except OSError as e:
act = "failed: %s" % e act = "failed: %s" % e
left.append(Leftover("laser_locked", False, True, act)) left.append(Leftover("laser_locked", False, True, act))
# cooling engine idle, unarmed self._cool_side(left)
def _cool_side(self, left):
"""The cooling engine idle, unarmed, holding nothing."""
st, c = self.fc_get("/cool/status") st, c = self.fc_get("/cool/status")
if st == 200 and isinstance(c, dict): if st != 200 or not isinstance(c, dict):
if c.get("phase") != "idle" or c.get("armed") or c.get("hold"): return
if c.get("phase") == "idle" and not c.get("armed") and not c.get("hold"):
return
found = "%s/armed=%s/hold=%s" % (c.get("phase"), c.get("armed"), c.get("hold")) found = "%s/armed=%s/hold=%s" % (c.get("phase"), c.get("armed"), c.get("hold"))
idle = lambda: (lambda x: x.get("phase") == "idle" and not x.get("armed") # noqa: E731 idle = lambda: (lambda x: x.get("phase") == "idle" and not x.get("armed") # noqa: E731
and not x.get("hold"))(self.fc_get("/cool/status")[1] or {}) and not x.get("hold"))(self.fc_get("/cool/status")[1] or {})
# The engine publishes once a tick (1 Hz). A read inside the tick
# after a run ended still shows the run: a diagnostic's hold until
# the engine has the hardware back, a fail tier's hold until the
# session ends with the controller. What the engine's next tick
# clears was never left behind, so an arm or a hold is read again
# past that tick before it is called the run's doing. One that a
# run did leave stands for a job that is never coming back, and it
# is still there.
if c.get("armed") or c.get("hold"):
free = lambda: (lambda x: bool(x) and not x.get("armed") # noqa: E731
and not x.get("hold"))(self.fc_get("/cool/status")[1] or {})
if self._wait("cool publish", free, COOL_PUBLISH_S) is not None:
st, c2 = self.fc_get("/cool/status")
c = c2 if st == 200 and isinstance(c2, dict) else {}
self.log("cool: %s was the engine's last word on the run; its next tick reads "
"%s/armed=%s/hold=%s" % (found, c.get("phase"), c.get("armed"), c.get("hold")))
if c.get("phase") == "idle":
return
found = "%s/armed=%s/hold=%s" % (c.get("phase"), c.get("armed"), c.get("hold"))
# Armed, or holding, is the run's doing. A phase alone is # Armed, or holding, is the run's doing. A phase alone is
# not: the engine clears smoke at run duty after an armed # not: the engine clears smoke at run duty after an armed
# session and cools down after a hot one, both timed and # session and cools down after a hot one, both timed and
@@ -614,7 +656,7 @@ class Baseline:
if w is not None and not dirt: if w is not None and not dirt:
self.log("cool: %s ended on its own after %.0f s; the engine's own post-job " self.log("cool: %s ended on its own after %.0f s; the engine's own post-job "
"work, not a leftover" % (found, w)) "work, not a leftover" % (found, w))
else: return
# The engine holds for a job. A run that ended without # The engine holds for a job. A run that ended without
# ending its job leaves one alive, and no amount of # ending its job leaves one alive, and no amount of
# waiting ends it: stand the machine down and let the # waiting ends it: stand the machine down and let the
@@ -693,13 +735,14 @@ class Baseline:
self.log("leds/%s: target 0, brightness %s; the fade from a level the machine " self.log("leds/%s: target 0, brightness %s; the fade from a level the machine "
"itself ended, not a leftover" % (name, read_led(name, "brightness"))) "itself ended, not a leftover" % (name, read_led(name, "brightness")))
def _return_head(self, was, now): def _return_head(self, was, now, spm=XY_STEPS_PER_MM):
"""Jog the head back along its own path by the kernel-measured X/Y """Jog the head back along its own path by the kernel-measured X/Y
delta (Z is never touched), through the GRBL controller. Bounded: delta (Z is never touched), through the GRBL controller. spm is
beyond RETURN_MAX_MM per axis, or without a running GRBL the counters' scale (the mode's). Bounded: beyond RETURN_MAX_MM
controller, the counters are reported and left.""" per axis, or without a running GRBL controller, the counters are
dx = (now[0] - was[0]) / XY_STEPS_PER_MM reported and left."""
dy = (now[1] - was[1]) / XY_STEPS_PER_MM dx = (now[0] - was[0]) / spm
dy = (now[1] - was[1]) / spm
if abs(dx) < 0.02 and abs(dy) < 0.02: if abs(dx) < 0.02 and abs(dy) < 0.02:
return "unrestorable (Z only)" if now[2] != was[2] else "restored" return "unrestorable (Z only)" if now[2] != was[2] else "restored"
if abs(dx) > RETURN_MAX_MM or abs(dy) > RETURN_MAX_MM: if abs(dx) > RETURN_MAX_MM or abs(dy) > RETURN_MAX_MM:
@@ -787,8 +830,9 @@ class Baseline:
was = captured.get("position") was = captured.get("position")
now = read_position() now = read_position()
if was is not None and now is not None and now != was and not self.cloud_mode(): if was is not None and now is not None and now != was and not self.cloud_mode():
act = self._return_head(was, now) spm = self._counter_spm
if position_quantized(was, now): act = self._return_head(was, now, spm)
if position_quantized(was, now, spm):
self.log("position: %s (expected %s) -> %s; inside the %.2f mm dead band, " self.log("position: %s (expected %s) -> %s; inside the %.2f mm dead band, "
"not a leftover" % (now, was, act, POSITION_DEADBAND_MM)) "not a leftover" % (now, was, act, POSITION_DEADBAND_MM))
else: else:
+10 -2
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The bench diagnostics page: registry of the bench tools and the """The bench diagnostics page: registry of the bench tools and the
subprocess runner behind the #bench tab. subprocess runner behind the #bench tab.
@@ -247,8 +252,11 @@ TOOLS = [
{"id": "z-envelope-test", "title": "Z envelope harness", "script": "z_envelope_test.py", {"id": "z-envelope-test", "title": "Z envelope harness", "script": "z_envelope_test.py",
"safety": "dry", "where": "host", "ported": False, "args": [], "safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "The Z soft limit belongs to the driver, not to $20: an unreferenced Z is collapsed to where the " "desc": "The Z soft limit belongs to the driver, not to $20: an unreferenced Z is collapsed to where the "
"lens stands, and neither a $20 nor a $132 write frees it. A CI harness (the grblHAL repo): needs " "lens stands, and neither a $20 nor a $132 write frees it. A referenced lens (forgectrl's marker "
"the host-built null-sink controller, not the machine, so it is not a bench-page tool."}, "and the lens settings) opens it to the window the settings hold, the fallback or the focus "
"card's stops: the ends of the reach run, two half-steps past either end alarms. A CI harness "
"(the grblHAL repo): needs the host-built null-sink controller, not the machine, so it is not a "
"bench-page tool."},
{"id": "raster-dry", "title": "Dry top-speed raster per XY microstep mode", "script": "raster_dry.py", {"id": "raster-dry", "title": "Dry top-speed raster per XY microstep mode", "script": "raster_dry.py",
"safety": "dry", "where": "board", "ported": True, "safety": "dry", "where": "board", "ported": True,
"args": [_arg("modes", "choice", "8 16 32", "the modes to run, in order", ["8", "16", "32", "8 16 32"])], "args": [_arg("modes", "choice", "8 16 32", "the modes to run, in order", ["8", "16", "32", "8 16 32"])],
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""Campaign rules: which results apply, what is required, and whether a """Campaign rules: which results apply, what is required, and whether a
release is authorized. release is authorized.
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The acceptance catalog: test definitions and the registry. """The acceptance catalog: test definitions and the registry.
A test is a function decorated with @test(...). The decorator records A test is a function decorated with @test(...). The decorator records
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""Coverage lint: every source path in the manifest must be selected by """Coverage lint: every source path in the manifest must be selected by
some test's coverage globs, except the allowlisted non-behavioral paths, some test's coverage globs, except the allowlisted non-behavioral paths,
and every coverage glob must select at least one path of its component. and every coverage glob must select at least one path of its component.
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The bench actuator (forgefixture): the box on the bench network that """The bench actuator (forgefixture): the box on the bench network that
opens the lid loop, pulls the interlock loop and presses the button on opens the lid loop, pulls the interlock loop and presses the button on
request, so an operator test can run with nobody in the room. request, so an operator test can run with nobody in the room.
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""Hardware and service access for the suite: forgectrl's HTTP API, the """Hardware and service access for the suite: forgectrl's HTTP API, the
kernel module's sysfs, the init scripts, and the Grbl TCP port. kernel module's sysfs, the init scripts, and the Grbl TCP port.
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The append-only result log (JSONL) and the export helpers. """The append-only result log (JSONL) and the export helpers.
One JSON object per line under the data directory (default One JSON object per line under the data directory (default
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The image manifest and the domain fingerprint. """The image manifest and the domain fingerprint.
/etc/forgefirm-manifest.json (written by forgefirm-image-manifest.bbclass) /etc/forgefirm-manifest.json (written by forgefirm-image-manifest.bbclass)
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The single page: acceptance tab + bench tab, assembled from ui/ into """The single page: acceptance tab + bench tab, assembled from ui/ into
one self-contained response (no external assets). Bootstrap and the one self-contained response (no external assets). Bootstrap and the
OpenGlow theme are inlined; theme.css and the vendor/ files are the same OpenGlow theme are inlined; theme.css and the vendor/ files are the same
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""Pulse files for the offline cloud service: a job the machine will run """Pulse files for the offline cloud service: a job the machine will run
without the service having cut it. without the service having cut it.
+135
View File
@@ -0,0 +1,135 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""Redaction for the release artifact.
The artifact is committed to a public repository, so it carries no
identity of the machine that ran the campaign. This applies the same two
layers forgectrl's log export applies (forgectrl/src/sanitize.c), in the
same order, to every value the exporter writes:
1. Known keys: a value held under a key that names identifying data -
the serial, the machine id, the hostname, the wireless credentials,
a token - is replaced whole. Naming the key is far more reliable
than guessing the shape of what is under it.
2. Pattern classes over every remaining string: MAC addresses, IPv4 and
IPv6 addresses (loopback and unspecified kept), e-mail addresses,
bearer and basic credentials, JWTs, and long hex blobs. Each distinct
value in a class keeps a stable number for the life of one export, so
"<IP-2>" is the same host wherever it appears and the artifact stays
readable as evidence.
Integrity values are never touched: the self-hash, the manifest and
catalog hashes, each test's source hash and fingerprint. Those are what
the gate recomputes, and a long hex blob is exactly what they look like.
Over-redaction is accepted where the alternative is a leak.
"""
import re
# Layer 1: the value under one of these keys is replaced whole.
REDACT_KEYS = frozenset((
"gf_serial", "machine_id", "serial", "serial_number",
"hostname", "hostname_file", "host",
"ssid", "psk", "passphrase", "password", "passwd",
"token", "panel_token", "secret", "api_key", "key",
"mac", "macaddr", "mac_suffix", "wlan0", "eth0",
"gf_username", "gf_password", "username", "user", "email",
))
# Never touched, by key: the gate recomputes these and they look like
# exactly what layer 2 hunts for.
KEEP_KEYS = frozenset((
"sha256", "manifest_sha", "identity_sha", "catalog_hash",
"source_sha", "fingerprint", "content_sha", "srcrev",
))
_KEEP_IP = frozenset(("0.0.0.0", "255.255.255.255"))
_CLASSES = (
("SECRET", re.compile(r"(?i)\b(?:bearer|basic)\s+[A-Za-z0-9._~+/=-]{8,}")),
("JWT", re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}")),
("EMAIL", re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")),
("MAC", re.compile(r"\b(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}\b")),
# Three or more colons, or a "::" run: two colons is a clock time, and
# tagging every timestamp would make the evidence unreadable.
("IP6", re.compile(
r"(?:[0-9A-Fa-f]{1,4}:){3,7}[0-9A-Fa-f]{1,4}"
r"|[0-9A-Fa-f]{0,4}(?::[0-9A-Fa-f]{1,4})*::(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?")),
("IP", re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")),
("HEX", re.compile(r"\b[0-9A-Fa-f]{32,}\b")),
)
class Redactor:
"""Stable per-export numbering, one instance per artifact."""
# A known value shorter than this is too generic to replace blind.
MIN_KNOWN = 4
def __init__(self):
self._n = {}
self._known = {}
def learn(self, obj, key=None):
"""Collect the values held under the layer-1 keys, so they are
replaced wherever else they appear - a hostname in a log line is
the same leak as a hostname in its own field."""
if key is not None and key in KEEP_KEYS:
return
if isinstance(obj, dict):
for k, v in obj.items():
self.learn(v, k)
elif isinstance(obj, list):
for v in obj:
self.learn(v)
elif key in REDACT_KEYS and isinstance(obj, str) and len(obj) >= self.MIN_KNOWN:
if obj not in self._known:
self._known[obj] = self._tag("REDACTED", "%s=%s" % (key, obj))
def _tag(self, cls, value):
seen = self._n.setdefault(cls, {})
if value not in seen:
seen[value] = len(seen) + 1
return "<%s-%d>" % (cls, seen[value])
def text(self, s):
for known in sorted(self._known, key=len, reverse=True):
if known in s:
s = s.replace(known, self._known[known])
for cls, rx in _CLASSES:
def sub(m, cls=cls):
v = m.group(0)
if cls == "IP" and (v.startswith("127.") or v in _KEEP_IP):
return v
if cls == "IP6" and v in ("::", "::1"):
return v
return self._tag(cls, v)
s = rx.sub(sub, s)
return s
def value(self, v, key=None):
if key is not None and key in KEEP_KEYS:
return v
if key is not None and key in REDACT_KEYS and not isinstance(v, (dict, list)):
if v is None:
return None
if isinstance(v, str) and v in self._known:
return self._known[v]
return self._tag("REDACTED", "%s=%s" % (key, v))
if isinstance(v, dict):
return {k: self.value(x, k) for k, x in v.items()}
if isinstance(v, list):
return [self.value(x) for x in v]
if isinstance(v, str):
return self.text(v)
return v
def scrub(obj):
"""Redact a value tree with one export's numbering."""
r = Redactor()
r.learn(obj)
return r.value(obj)
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The runner: executes one acceptance test or one bench tool at a time. """The runner: executes one acceptance test or one bench tool at a time.
Acceptance tests run in a worker thread with a Context: log lines, an Acceptance tests run in a worker thread with a Context: log lines, an
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""HTTP: the page, the JSON API, and the access rules. """HTTP: the page, the JSON API, and the access rules.
Access follows forgectrl's panel (auth.c): the Host header must be an Access follows forgectrl's panel (auth.c): the Host header must be an
+8 -3
View File
@@ -1,12 +1,17 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The acceptance suite: one module per subsystem, imported in display """The acceptance suite: one module per subsystem, imported in display
order. Each module registers its tests with @catalog.test.""" order. Each module registers its tests with @catalog.test."""
from . import image # noqa: F401,E402 from . import image # noqa: F401,E402
from . import kernel # noqa: F401,E402 from . import kernel # noqa: F401,E402
from . import forgectrl # noqa: F401,E402 from . import forgectrl # noqa: F401,E402
from . import commission # noqa: F401,E402 from . import setup # noqa: F401,E402
from . import commission_dark # noqa: F401,E402 from . import setup_dark # noqa: F401,E402
from . import commission_sheet # noqa: F401,E402 from . import setup_sheet # noqa: F401,E402
from . import logs # noqa: F401,E402 from . import logs # noqa: F401,E402
from . import motion # noqa: F401,E402 from . import motion # noqa: F401,E402
from . import cooling # noqa: F401,E402 from . import cooling # noqa: F401,E402
+6 -1
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""camera.* - the lid camera pipeline through forgectrl.""" """camera.* - the lid camera pipeline through forgectrl."""
import json import json
@@ -396,7 +401,7 @@ def lid_privacy(ctx):
"authorizes a write; a rotation from the panel route stops the old key and " "authorizes a write; a rotation from the panel route stops the old key and "
"the new one reads. The setting is put back after.") "the new one reads. The setting is put back after.")
def key_read(ctx): def key_read(ctx):
from .commission import request from .setup import request
from .forgectrl import lan_ip from .forgectrl import lan_ip
fc = ctx.forgectrl fc = ctx.forgectrl
ev = ctx.evidence ev = ctx.evidence
+152 -58
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""cloud.* - the controller mode switch and the optional Glowforge web-service """cloud.* - the controller mode switch and the optional Glowforge web-service
mode (gfcloud daemon, gfhome homing runner). The mode-switch test makes the mode (gfcloud daemon, gfhome homing runner). The mode-switch test makes the
grbl -> cloud -> grbl round trip with the connect-time hunt run lid-open and grbl -> cloud -> grbl round trip with the connect-time hunt run lid-open and
@@ -87,6 +92,9 @@ FORGECTRL_LOG = "/data/log/forgefirm/forgectrl/forgectrl.log"
LIMITS_MARK = "job limits from the header: " LIMITS_MARK = "job limits from the header: "
EFFECTIVE_MARK = "effective limits: coolant ceiling " EFFECTIVE_MARK = "effective limits: coolant ceiling "
SESSION_MARKS = ("authenticate_machine SUCCESS", "ws_connect ESTABLISHED") SESSION_MARKS = ("authenticate_machine SUCCESS", "ws_connect ESTABLISHED")
# The service's connect-time hunt, as the client logs its request: a few
# seconds after the controller starts, right behind the session.
HUNT_REQUEST = "service action request: hunt"
RETURN_MAX_MM = 600.0 # the head comes back from the home corner across the bed RETURN_MAX_MM = 600.0 # the head comes back from the home corner across the bed
@@ -145,7 +153,7 @@ def return_head(ctx, feed=2400):
"head not back at the start after the return jog: %s", pos) "head not back at the start after the return jog: %s", pos)
def wait_mode(ctx, fc, want_mode, want_controller="running", timeout=90): def wait_mode(ctx, fc, want_mode, want_controller="running", timeout=90, poll=1.0):
t0 = time.time() t0 = time.time()
last = None last = None
while time.time() - t0 < timeout: while time.time() - t0 < timeout:
@@ -157,7 +165,7 @@ def wait_mode(ctx, fc, want_mode, want_controller="running", timeout=90):
return m return m
if m.get("controller") == "motion-fault": if m.get("controller") == "motion-fault":
break break
time.sleep(1) time.sleep(poll)
return last return last
@@ -227,6 +235,11 @@ def gfhome_homing(ctx, ev, g):
# homed: the counters are re-anchored at the corner, where the head stays # homed: the counters are re-anchored at the corner, where the head stays
ctx.counters_rezeroed() ctx.counters_rezeroed()
ctx.check(ctx.forgectrl.wait_idle(15, abort=ctx.aborted), "machine not idle after homing") ctx.check(ctx.forgectrl.wait_idle(15, abort=ctx.aborted), "machine not idle after homing")
# The driver answers $H when the session ends; that ok (and the
# session's messages) sit in the buffer behind the status reports and
# would pass for the reply to the caller's next command.
ack = " | ".join(ln.strip() for ln in g.drain().splitlines() if ln.strip())
ctx.log("$H acknowledged: %s", ack[:160] or "(nothing buffered)")
@test("cloud.mode-switch", title="Controller mode switch grbl -> cloud -> grbl, the connect-time hunt " @test("cloud.mode-switch", title="Controller mode switch grbl -> cloud -> grbl, the connect-time hunt "
@@ -235,17 +248,22 @@ def gfhome_homing(ctx, ev, g):
covers=_HOMING_PATH + [("forgectrl", "src/cool.*"), ("forgectrl", "src/airflow.*"), covers=_HOMING_PATH + [("forgectrl", "src/cool.*"), ("forgectrl", "src/airflow.*"),
("grblhal-glowforge", "src/**")], ("grblhal-glowforge", "src/**")],
requires=["forgectrl.auth", "motion.pacing"], actions=["lid"], requires=["forgectrl.auth", "motion.pacing"], actions=["lid"],
steps=["Bed clear; cloud credentials configured; the machine on the network. The test " steps=["Bed clear, lid closed; cloud credentials configured; the machine on the network. "
"turns cloud mode and the gfcloud homing on itself when they are off, and puts " "The test turns cloud mode and the gfcloud homing on itself when they are off, and "
"the settings back at the end.", "puts the settings back at the end.",
"Open the lid when told and leave it open through the cloud client's connect and its " "Open the lid the moment you are told, with a hand ready on it: the cloud client's "
"hunt; close it when told. Nothing else: the switch back and the $H homing run on " "first hunt begins a few seconds after its controller starts and must find the lid "
"their own, and the head ends parked at the home corner."], "open. Leave it open through the connect and the hunt; close it when told. Nothing "
"else: the switch back and the $H homing run on their own, and the head ends parked "
"at the home corner."],
description="One round trip with the two service-driven motions on it. POST /mode switches " description="One round trip with the two service-driven motions on it. POST /mode switches "
"to the cloud controller: gfcloud comes up under supervision, authenticates and " "to the cloud controller with the lid closed (no controller starts with the "
"enclosure open): gfcloud comes up under supervision, authenticates and "
"establishes its service session (its own log lines are the evidence; the " "establishes its service session (its own log lines are the evidence; the "
"connect-time firmware probe is recorded when configured). Its connect-time hunt " "connect-time firmware probe is recorded when configured). The lid is opened the "
"runs with the lid OPEN, as the factory's does - it completes, nothing before its " "moment the controller is up, before the client requests its connect-time hunt "
"(a hunt requested before the lid was open fails the test), so the hunt runs "
"with the lid OPEN, as the factory's does - it completes, nothing before its "
"end is refused for the lid, the lens homes - and with the extraction fans off it " "end is refused for the lid, the lens homes - and with the extraction fans off it "
"is measured by the airflow gates but not judged (no AIRFLOW, the exhaust row " "is measured by the airflow gates but not judged (no AIRFLOW, the exhaust row "
"reads unjudged); the camera service survives the switch. The lid closed, the " "reads unjudged); the camera service survives the switch. The lid closed, the "
@@ -270,17 +288,32 @@ def mode_switch(ctx):
except OSError: except OSError:
pass pass
lamp0 = hw.sysfs_read("pic/lid_led") lamp0 = hw.sysfs_read("pic/lid_led")
ctx.check(ctx.switch("lid") is True, "close the lid first: no controller starts with it open")
ctx.act("lid", "open", text="Leave it open: the cloud client connects and hunts with the lid open.") # The switch is made with the enclosure closed (the supervisor holds
# every spawn until it is). The lid opens the moment the controller is
# up: the client requests its connect-time hunt a few seconds after
# its start, right behind its session, and the hunt must find the lid
# open. The poll is tight so the window is not spent waiting.
log_offset = log_size(GFCLOUD_LOG) log_offset = log_size(GFCLOUD_LOG)
st, body = fc.post("/mode", data={"controller": "cloud"}) st, body = fc.post("/mode", data={"controller": "cloud"})
ctx.log("POST /mode controller=cloud -> %s %s", st, body) ctx.log("POST /mode controller=cloud -> %s %s", st, body)
ctx.check(st == 200, "mode switch to cloud refused: %s %s", st, body) ctx.check(st == 200, "mode switch to cloud refused: %s %s", st, body)
m = wait_mode(ctx, fc, "cloud", timeout=90) m = wait_mode(ctx, fc, "cloud", timeout=90, poll=0.2)
ev["mode_cloud"] = m ev["mode_cloud"] = m
ctx.log("mode after switch: %s", m) ctx.log("mode after switch: %s", m)
ctx.check(m and m.get("mode") == "cloud" and m.get("controller") == "running", ctx.check(m and m.get("mode") == "cloud" and m.get("controller") == "running",
"cloud controller did not come up: %s", m) "cloud controller did not come up: %s", m)
ctx.act("lid", "open", text="Now, at once: the cloud client's first hunt begins a few seconds "
"after its controller starts and must find the lid open. Leave it open through the "
"connect and the hunt.")
# The order is the proof: the hunt's request is not in the log yet
# when the lid reads open.
ev["hunt_before_lid_open"] = log_has(log_offset, HUNT_REQUEST)
ctx.check(not ev["hunt_before_lid_open"],
"the client requested its hunt before the lid was open (the hunt follows the "
"controller's start by a few seconds): the lid opened too late, so a hunt with the "
"lid open is not proven")
# the client's own session lines are the evidence of a live cloud session # the client's own session lines are the evidence of a live cloud session
session = wait_session(ctx, log_offset) session = wait_session(ctx, log_offset)
probe = None probe = None
@@ -1295,42 +1328,96 @@ def lid_during_button_wait_body(ctx, ev, off, job, offset):
# ---------------------------------------------------------------- the cooling verdict # ---------------------------------------------------------------- the cooling verdict
HOLD_KEYS = ("cool_temp_min", "cool_temp_start") HOLD_KEYS = ("cool_temp_min", "cool_temp_start")
WARMUP_ABOVE_C = 1.0 # the start gate this far above the loop: a few heater minutes REFUSE_KEYS = HOLD_KEYS + ("cloud_hold_max_s",)
WARMUP_RELEASE_S = 720 # the flow heater warms the bulk 0.4 to 0.8 C a minute HOLD_REFUSE_S = 60 # the shortest bound the client takes
HOLD_REFUSE_LINE = "cooling hold held 60 s - relocking the laser"
WARMUP_WAIT_LINE = "waiting on the cooling engine: WARMUP" WARMUP_WAIT_LINE = "waiting on the cooling engine: WARMUP"
WARMUP_RELEASE_LINE = "cooling verdict clean after WARMUP; starting the run"
@test("cloud.verdict-hold", title="A cloud print armed under the warm-up gate waits for the engine", @test("cloud.dark-print", title="A dark cloud print: the latch locked until the run, unlocked "
subsystem="cloud", kind="operator", est_min=14, "for it, locked after it",
subsystem="cloud", kind="operator", est_min=3,
covers=_MACHINE_RUN + [("python3-gfhardware", "gfhardware/coolsvc.py"), covers=_MACHINE_RUN + [("python3-gfhardware", "gfhardware/coolsvc.py"),
("forgectrl", "src/cool.*"), ("forgectrl", "src/main.c")], ("forgectrl", "src/cool.*"), ("forgectrl", "src/main.c")],
requires=["cloud.lid-during-button-wait", "cooling.floor-and-warm-up"], actions=["button"], requires=["cloud.lid-during-button-wait", "cooling.floor-and-warm-up"], actions=["button"],
steps=[OFFLINE_STEP, steps=[OFFLINE_STEP,
"Coolant at room temperature (10 to 30 C). The test sets the warm-up gate just above " "Bed clear (the job is dark: a 30 s square at S0, nothing fires). Press the button "
"the coolant and restores it. Press the button when it lights: the print then waits " "when it lights: the print waits for the engine, runs, and finishes."],
"for the warm-up (a few minutes with the loop heater on), runs dark, and finishes."], description="The cooling-engine contract in cloud mode at its ordinary end, on the engine "
description="The cooling-engine contract in cloud mode, on the engine itself: the start gate " "itself: the print arms on the press, waits for the engine's acknowledgment (the "
"set just above the coolant makes the armed session open under the warm-up " "fans at their floors), and runs. The laser latch is locked at the button and "
"(WARMUP: fire blocked, hold), and the client waits it out after the button " "through the wait, unlocks only for the run (immediately before it starts), and "
"instead of canceling - nothing runs, the latch stays unlocked for the armed " "is locked again when the job ends; the print completes. A hold the engine never "
"window - until the engine releases into run, and the release starts the print, " "clears is cloud.verdict-refuse; the engine's own warm-up release is "
"which completes. The hold mid-run (the same laser-off pause as the button's, " "cooling.floor-and-warm-up.")
"resumed on the engine's resume_ok) and the bound on a hold are host-tested: the " def dark_print(ctx):
"gates apply at session open, so no setting can produce a hold mid-run here.") ev = ctx.evidence
def verdict_hold(ctx): fc = ctx.forgectrl
offset = enter_offline(ctx)
job = offline_job(ctx, "dark.puls", seconds=30)
off = Offline().__enter__()
try:
dark_print_body(ctx, ev, off, job, offset, fc)
finally:
off.__exit__(None, None, None)
offline_cleanup(ctx)
def dark_print_body(ctx, ev, off, job, offset, fc):
off.print_ready(9005, job)
got = wait_log(ctx, offset, ["waiting for button"], 120)
ctx.check(got["waiting for button"], "the print never reached the button wait")
ev["latch_locked_at_button"] = latch_locked()
ctx.check(ev["latch_locked_at_button"], "the latch is unlocked at the button wait")
ctx.act("button", "press", text="The button is lit: press it. The print runs dark and finishes.",
until=lambda: wait_print_running(ctx, offset, 0.1) is not None, timeout=PRESS_TIMEOUT_S)
run = wait_print_running(ctx, offset, 60)
ctx.check(run is not None, "the print did not start after the press")
ctx.sleep(1.0)
ev["latch_locked_in_run"] = latch_locked()
ctx.check(ev["latch_locked_in_run"] is False, "the latch is locked during the run")
fin = wait_action_finished(ctx, offset, "print", 180)
ev["print finished"] = message(fin)
ctx.check(fin and COMPLETED in fin, "the print did not complete: %s", message(fin) or "no finish line")
st, cs = fc.get("/cool/status")
ev["armed_after"] = cs.get("armed") if isinstance(cs, dict) else None
ctx.check(not ev["armed_after"], "armed window still open after the print")
ev["latch_locked_after"] = latch_locked()
ctx.check(ev["latch_locked_after"], "the latch is unlocked after the print")
ev["events"] = offline_events(off)
ctx.log("PASS: locked at the button, unlocked for the run, locked after it; the print completed")
@test("cloud.verdict-refuse", title="A cloud print held by the engine past the bound is canceled with "
"the latch never unlocked",
subsystem="cloud", kind="operator", est_min=4,
covers=_MACHINE_RUN + [("python3-gfhardware", "gfhardware/coolsvc.py"),
("forgectrl", "src/cool.*")],
requires=["cloud.dark-print"], actions=["button"],
steps=[OFFLINE_STEP,
"Coolant at room temperature (10 to 30 C). The test sets the warm-up gate well above "
"the coolant and the hold bound to its minimum, and restores both. Press the button "
"when it lights: nothing runs; a minute later the print cancels by itself."],
description="The other end of the cooling-engine contract before a run: a verdict that "
"refuses fire and never clears. The start gate far above the coolant keeps the "
"armed session under the warm-up for longer than the hold bound (cloud_hold_max_s "
"at its minimum, 60 s). The client waits with the laser latch locked the whole "
"time - it is never unlocked before the verdict passes, so there is nothing to "
"relock - then cancels the print at the bound: no run, the armed window closed, "
"the print ':cancelled'. Dark by construction.")
def verdict_refuse(ctx):
ev = ctx.evidence ev = ctx.evidence
fc = ctx.forgectrl fc = ctx.forgectrl
before = fc.settings() before = fc.settings()
orig = {k: before.get(k, "") for k in HOLD_KEYS} orig = {k: before.get(k, "") for k in REFUSE_KEYS}
ev["orig"] = orig ev["orig"] = orig
up = (fc.status().get("coolant") or {}).get("up_c") up = (fc.status().get("coolant") or {}).get("up_c")
ctx.check(up is not None and 10.0 <= up <= 30.0, "coolant outside the test's range (up_c %s)", up) ctx.check(up is not None and 10.0 <= up <= 30.0, "coolant outside the test's range (up_c %s)", up)
offset = enter_offline(ctx) offset = enter_offline(ctx)
job = offline_job(ctx, "hold.puls", seconds=30) job = offline_job(ctx, "refuse.puls", seconds=30)
off = Offline().__enter__() off = Offline().__enter__()
try: try:
verdict_hold_body(ctx, ev, off, job, offset, fc, up, orig) verdict_refuse_body(ctx, ev, off, job, offset, fc, up, orig)
finally: finally:
st, body = fc.post("/settings", params=orig) st, body = fc.post("/settings", params=orig)
ctx.log("restore: POST /settings %s -> %s", orig, st) ctx.log("restore: POST /settings %s -> %s", orig, st)
@@ -1338,46 +1425,53 @@ def verdict_hold(ctx):
offline_cleanup(ctx) offline_cleanup(ctx)
def verdict_hold_body(ctx, ev, off, job, offset, fc, up, orig): def verdict_refuse_body(ctx, ev, off, job, offset, fc, up, orig):
gate = round(up + WARMUP_ABOVE_C, 1) gate = round(up + 8.0, 1) # eight degrees: the heater cannot get there in a minute
st, body = fc.post("/settings", params={"cool_temp_start": str(gate), st, body = fc.post("/settings", params={"cool_temp_start": str(gate),
"cool_temp_min": orig["cool_temp_min"]}) "cool_temp_min": orig["cool_temp_min"],
ctx.check(st == 200, "POST /settings cool_temp_start=%s -> %s %s", gate, st, body) "cloud_hold_max_s": str(HOLD_REFUSE_S)})
ctx.check(st == 200, "POST /settings cool_temp_start=%s cloud_hold_max_s=%s -> %s %s",
gate, HOLD_REFUSE_S, st, body)
ev["gate"] = gate ev["gate"] = gate
off.print_ready(9005, job) off.print_ready(9006, job)
got = wait_log(ctx, offset, ["waiting for button"], 120) got = wait_log(ctx, offset, ["waiting for button"], 120)
ctx.check(got["waiting for button"], "the print never reached the button wait") ctx.check(got["waiting for button"], "the print never reached the button wait")
ctx.act("button", "press", text="The button is lit: press it. The print then waits for the warm-up.", ev["latch_locked_at_button"] = latch_locked()
ctx.check(ev["latch_locked_at_button"], "the latch is unlocked at the button wait")
ctx.act("button", "press", text="The button is lit: press it. Nothing runs; the print cancels "
"itself a minute later.",
until=lambda: log_has(offset, WARMUP_WAIT_LINE), timeout=PRESS_TIMEOUT_S) until=lambda: log_has(offset, WARMUP_WAIT_LINE), timeout=PRESS_TIMEOUT_S)
got = wait_log(ctx, offset, [WARMUP_WAIT_LINE], 30) got = wait_log(ctx, offset, [WARMUP_WAIT_LINE], 30)
ctx.check(got[WARMUP_WAIT_LINE], "the armed print did not wait on the warm-up") ctx.check(got[WARMUP_WAIT_LINE], "the armed print did not wait on the warm-up")
st, c = fc.get("/cool/status")
ev["warmup"] = c
ctx.check(isinstance(c, dict) and c.get("verdict") == "WARMUP" and c.get("hold") is True
and c.get("fire_ok") is False,
"the engine is not holding the armed session on the warm-up: %s", c)
ctx.check(wait_print_running(ctx, offset, 2) is None, "the run started under the warm-up hold")
ctx.notice("Warm-up: the loop heater brings the coolant to the gate; a few minutes. Do nothing.")
try:
t0 = time.time() t0 = time.time()
run = wait_print_running(ctx, offset, WARMUP_RELEASE_S) samples = []
ctx.notice("The engine holds the print; the client gives up at the bound. Do nothing.")
try:
while time.time() - t0 < HOLD_REFUSE_S + 30:
ctx.checkpoint()
samples.append(latch_locked())
if log_has(offset, HOLD_REFUSE_LINE):
break
ctx.sleep(2)
finally: finally:
ctx.clear_notice() ctx.clear_notice()
ev["release_s"] = round(time.time() - t0) ev["hold_s"] = round(time.time() - t0)
got = wait_log(ctx, offset, [WARMUP_RELEASE_LINE], 5) ev["latch_samples"] = {"n": len(samples), "unlocked": samples.count(False)}
ev["log"] = {WARMUP_WAIT_LINE: True, WARMUP_RELEASE_LINE: bool(got[WARMUP_RELEASE_LINE])} ctx.check(log_has(offset, HOLD_REFUSE_LINE), "the client did not cancel at the hold bound (%d s)",
ctx.check(got[WARMUP_RELEASE_LINE], "the warm-up did not release into the run within %d s", ev["hold_s"])
WARMUP_RELEASE_S) ctx.check(all(samples), "the latch unlocked during the hold (%d of %d samples)",
ctx.check(run is not None, "the print did not start after the warm-up") samples.count(False), len(samples))
fin = wait_action_finished(ctx, offset, "print", 180) ctx.check(wait_print_running(ctx, offset, 1) is None, "the run started under the refused verdict")
fin = wait_action_finished(ctx, offset, "print", 120)
ev["print finished"] = message(fin) ev["print finished"] = message(fin)
ctx.check(fin and COMPLETED in fin, "the print did not complete after the warm-up: %s", ctx.check(fin and CANCELLED in fin, "the held print did not end ':cancelled': %s",
message(fin) or "no finish line") message(fin) or "no finish line")
st, cs = fc.get("/cool/status") st, cs = fc.get("/cool/status")
ev["armed_after"] = cs.get("armed") if isinstance(cs, dict) else None ev["armed_after"] = cs.get("armed") if isinstance(cs, dict) else None
ctx.check(not ev["armed_after"], "armed window still open after the print") ctx.check(not ev["armed_after"], "armed window still open after the cancel")
ctx.check(latch_locked(), "the latch is unlocked after the cancel")
ev["events"] = offline_events(off) ev["events"] = offline_events(off)
ctx.log("PASS: the armed print waited %s s on the warm-up, then ran and completed", ev["release_s"]) ctx.log("PASS: held %s s with the latch locked throughout, then canceled at the bound", ev["hold_s"])
@test("cloud.pause-resume", title="Button pauses and resumes a cloud print (factory backtrack + lead)", @test("cloud.pause-resume", title="Button pauses and resumes a cloud print (factory backtrack + lead)",
+220 -17
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""cooling.* - the cooling engine: flow verification through forgectrl's """cooling.* - the cooling engine: flow verification through forgectrl's
diagnostics runner (the same check the fire gate runs), the fan profile diagnostics runner (the same check the fire gate runs), the fan profile
returning to idle after motion, the gate settings (a value inside the returning to idle after motion, the gate settings (a value inside the
@@ -5,6 +10,7 @@ legal range trips the gate, the far end of the range turns it off by
value, and both are said out loud: the settings reply, /status, the value, and both are said out loud: the settings reply, /status, the
engine's run-start log line), and the airflow gates (a fan under its engine's run-start log line), and the airflow gates (a fan under its
floor past the spin-up grace is a fault for the rest of the run).""" floor past the spin-up grace is a fault for the rest of the run)."""
import os
import time import time
from ..catalog import test from ..catalog import test
@@ -746,7 +752,7 @@ FIRE_WAIT_S = 25 # settings re-read at run start, two-tick breach, 1
@test("cooling.fire-watch-tiers", title="The lid-IR fire watch pauses at its alert and fails at its critical", @test("cooling.fire-watch-tiers", title="The lid-IR fire watch pauses at its alert and fails at its critical",
subsystem="cooling", kind="auto", mode="grbl", est_min=6, subsystem="cooling", kind="auto", mode="grbl", est_min=6,
covers=_COOL_COVERS, requires=["kernel.latch-locked-idle"], covers=_COOL_COVERS + [("forgectrl", "src/super.*")], requires=["kernel.latch-locked-idle"],
steps=["Machine idle, lid closed, lid lamp at its resting level. The test moves the flame " steps=["Machine idle, lid closed, lid lamp at its resting level. The test moves the flame "
"thresholds under the lamp's own reading and restores them; three M8/M9 sessions, " "thresholds under the lamp's own reading and restores them; three M8/M9 sessions, "
"no fire."], "no fire."],
@@ -759,6 +765,7 @@ FIRE_WAIT_S = 25 # settings re-read at run start, two-tick breach, 1
"read as the four flame gates off and the watch reads watch. Restored, the " "read as the four flame gates off and the watch reads watch. Restored, the "
"watch reads armed at OK.") "watch reads armed at OK.")
def fire_watch_tiers(ctx): def fire_watch_tiers(ctx):
from .motion import FORGECTRL_LOG, _log_lines, _log_offset
fc = ctx.forgectrl fc = ctx.forgectrl
ev = ctx.evidence ev = ctx.evidence
before = fc.settings() before = fc.settings()
@@ -810,7 +817,16 @@ def fire_watch_tiers(ctx):
ctx.check(c.get("verdict") == "OK", "the alert survived into a fresh session: %s", c) ctx.check(c.get("verdict") == "OK", "the alert survived into a fresh session: %s", c)
# Leg 2: the fail tier. The q1 critical under the lamp reading # Leg 2: the fail tier. The q1 critical under the lamp reading
# stops the session and latches FIRE until it ends. # latches FIRE and locks the laser; the fail tier then stops
# the controller through the supervisor and starts it again,
# so no run start can relight what was locked and the sender
# sees the job end. The Grbl socket dies with the restart -
# FIRE is read from the engine, the latch from sysfs, the stop
# from the supervisor's log, and a fresh session is opened for
# the legs that follow.
st, m0 = fc.get("/mode")
pid0 = m0.get("pid") if isinstance(m0, dict) else None
off = _log_offset(FORGECTRL_LOG)
_set_gates(ctx, fc, {"cool_fire_q1_critical": str(max(2, q1 - 5)), _set_gates(ctx, fc, {"cool_fire_q1_critical": str(max(2, q1 - 5)),
"cool_fire_q1_alert": str(max(1, q1 - 10))}) "cool_fire_q1_alert": str(max(1, q1 - 10))})
grbl.command("M8") grbl.command("M8")
@@ -821,19 +837,38 @@ def fire_watch_tiers(ctx):
c = _cool(fc) c = _cool(fc)
if c.get("verdict") == "FIRE": if c.get("verdict") == "FIRE":
break break
ilk = hw.sysfs_int("cnc/interlock_circuit")
ev["critical"] = c ev["critical"] = c
ctx.log("critical leg: verdict %s fire_watch %s", c.get("verdict"), c.get("fire_watch")) ev["interlock_circuit"] = ilk
ctx.log("critical leg: verdict %s fire_watch %s interlock_circuit %s",
c.get("verdict"), c.get("fire_watch"), ilk)
ctx.check(c.get("verdict") == "FIRE", "q1 critical under the lamp did not latch FIRE: %s", c) ctx.check(c.get("verdict") == "FIRE", "q1 critical under the lamp did not latch FIRE: %s", c)
ctx.check(c.get("fire_watch") == "ALARM", "fire_watch %r during FIRE, expected ALARM", c.get("fire_watch")) ctx.check(c.get("fire_watch") == "ALARM", "fire_watch %r during FIRE, expected ALARM", c.get("fire_watch"))
ilk = hw.sysfs_int("cnc/interlock_circuit")
ev["interlock_circuit"] = ilk
ctx.check(ilk is not None and (ilk & 8), "the laser latch is not locked under FIRE (interlock_circuit=%s)", ilk) ctx.check(ilk is not None and (ilk & 8), "the laser latch is not locked under FIRE (interlock_circuit=%s)", ilk)
ctx.check(_tail_wait(ctx, fc, "LID IR FIRE SIGNAL (quartiles "), ctx.check(_tail_wait(ctx, fc, "LID IR FIRE SIGNAL (quartiles "),
"the FIRE line is missing from the forgectrl log") "the FIRE line is missing from the forgectrl log")
# the fail tier stops the controller and starts it again; the
# Grbl socket dies with it. Wait out the restart, prove the
# supervisor logged the stop, restore the thresholds, and open
# a fresh session on the new controller.
grbl.close()
m1 = None
t0 = time.time()
while time.time() - t0 < 60:
st, m1 = fc.get("/mode")
if isinstance(m1, dict) and m1.get("controller") == "running" and m1.get("pid") != pid0:
break
ctx.sleep(0.5)
ev["restart"] = {"pid_before": pid0, "mode_after": m1}
ctx.log("after FIRE: %s", ev["restart"])
ctx.check(m1 and m1.get("controller") == "running" and m1.get("pid") != pid0,
"the controller was not stopped and started again after FIRE: %s", m1)
ctx.check(_log_lines(FORGECTRL_LOG, off, "lid IR fire signal - the controller is stopped"),
"the supervisor did not log the fail-tier stop after FIRE")
_set_gates(ctx, fc, {"cool_fire_q1_critical": orig["cool_fire_q1_critical"], _set_gates(ctx, fc, {"cool_fire_q1_critical": orig["cool_fire_q1_critical"],
"cool_fire_q1_alert": orig["cool_fire_q1_alert"]}) "cool_fire_q1_alert": orig["cool_fire_q1_alert"]})
grbl.command("M9") ctx.check(fc.wait_idle(30, abort=ctx.aborted), "machine not idle after the FIRE restart")
_session_ended(ctx, fc, "critical leg") grbl.connect() # a fresh session on the restarted controller
# Leg 3: all four at zero: the watch off, said so, session OK. # Leg 3: all four at zero: the watch off, said so, session OK.
_set_gates(ctx, fc, {k: "0" for k in FIRE_KEYS}) _set_gates(ctx, fc, {k: "0" for k in FIRE_KEYS})
@@ -891,7 +926,7 @@ CRASH_GATES = ["crash_abort", "crash_x_alert", "crash_y_alert"]
"read as the three crash gates off by value (gates_off names them) with the " "read as the three crash gates off by value (gates_off names them) with the "
"session OK; a threshold past the register range is refused; restored, the " "session OK; a threshold past the register range is refused; restored, the "
"gates read ok. The tiers themselves (BUMP, CRASH) trip only on a physical " "gates read ok. The tiers themselves (BUMP, CRASH) trip only on a physical "
"knock inside an armed window and are commissioned at the bench, not here.") "knock inside an armed window and are proven at the bench, not here.")
def crash_watch_plumbing(ctx): def crash_watch_plumbing(ctx):
fc = ctx.forgectrl fc = ctx.forgectrl
ev = ctx.evidence ev = ctx.evidence
@@ -1212,7 +1247,6 @@ LOAD_LINES = 27 # two 30 x 4 mm fills back to back, about 35
LOAD_SHARE_MIN_C = 0.3 # the tube was lit inside the window LOAD_SHARE_MIN_C = 0.3 # the tube was lit inside the window
LOAD_MARGIN_C = 1.0 # the judged rise this far under the limit LOAD_MARGIN_C = 1.0 # the judged rise this far under the limit
LOAD_VERDICT_WAIT_S = 120 # the window opens ~15 s into the session and runs 50 s LOAD_VERDICT_WAIT_S = 120 # the window opens ~15 s into the session and runs 50 s
LOAD_LOG_LINES = "300"
_LOAD_LINE_RX = None _LOAD_LINE_RX = None
@@ -1236,9 +1270,19 @@ def _load_fill():
return lines return lines
def _forgectrl_tail(fc): def _log_since(path, offset):
st, body = fc.get("/logs/tail", params={"name": "forgectrl", "lines": LOAD_LOG_LINES}) """The text a log gained since byte `offset`. A count of lines in a
return body.get("text", "") if st == 200 and isinstance(body, dict) else "" tail of fixed length cannot tell a new line from an old one: the new
line comes in at the bottom as an old one leaves at the top. A file now
shorter than the offset was rotated under the test, and all of it is
newer."""
try:
with open(path, "rb") as f:
if os.fstat(f.fileno()).st_size >= offset:
f.seek(offset)
return f.read().decode("utf-8", "replace")
except OSError:
return ""
@test("cooling.flow-under-load", title="The flow check reads true with the tube lit through its window", @test("cooling.flow-under-load", title="The flow check reads true with the tube lit through its window",
@@ -1256,6 +1300,7 @@ def _forgectrl_tail(fc):
"the tube's share off and read its baseline as a mean.") "the tube's share off and read its baseline as a mean.")
def flow_under_load(ctx): def flow_under_load(ctx):
from .laser import sample, prepare, LiveJob, ARM_CUE, run_and_sample, wait_disarm from .laser import sample, prepare, LiveJob, ARM_CUE, run_and_sample, wait_disarm
from .motion import FORGECTRL_LOG, _log_offset
fc = ctx.forgectrl fc = ctx.forgectrl
ev = ctx.evidence ev = ctx.evidence
state, check_s = _gate_state(fc, "cool_flow_check_s") state, check_s = _gate_state(fc, "cool_flow_check_s")
@@ -1263,7 +1308,7 @@ def flow_under_load(ctx):
ctx.check(state != "off" and check_s, "the flow check is off (cool_flow_check_s=%s)", check_s) ctx.check(state != "off" and check_s, "the flow check is off (cool_flow_check_s=%s)", check_s)
ctx.check(limit, "cool_flow_rise unreadable") ctx.check(limit, "cool_flow_rise unreadable")
ev.update({"check_s": check_s, "limit_c": limit}) ev.update({"check_s": check_s, "limit_c": limit})
before = _forgectrl_tail(fc).count("heater rise") off = _log_offset(FORGECTRL_LOG) # the verdict is a line written after this
# The fill ends one line length out and (LOAD_LINES - 1) pitches up; # The fill ends one line length out and (LOAD_LINES - 1) pitches up;
# the job brings the head back so the baseline finds it where it began. # the job brings the head back so the baseline finds it where it began.
back = "G0 X%g Y%g" % (-LOAD_WIDTH if LOAD_LINES % 2 else 0.0, -LOAD_PITCH * (LOAD_LINES - 1)) back = "G0 X%g Y%g" % (-LOAD_WIDTH if LOAD_LINES % 2 else 0.0, -LOAD_PITCH * (LOAD_LINES - 1))
@@ -1298,12 +1343,11 @@ def flow_under_load(ctx):
ctx.checkpoint() ctx.checkpoint()
if hw.sysfs_int("thermal/heater_pwm", 0) > 0: if hw.sysfs_int("thermal/heater_pwm", 0) > 0:
heater_seen = True heater_seen = True
text = _forgectrl_tail(fc)
if text.count("heater rise") > before:
m = None m = None
for m in _load_verdict_rx().finditer(text): for m in _load_verdict_rx().finditer(_log_since(FORGECTRL_LOG, off)):
pass pass
line = m.group(0) if m else None if m:
line = m.group(0)
break break
ctx.sleep(1) ctx.sleep(1)
ev["heater_seen"] = heater_seen ev["heater_seen"] = heater_seen
@@ -1329,3 +1373,162 @@ def flow_under_load(ctx):
ctx.check(dt is not None, "the window did not close after M2") ctx.check(dt is not None, "the window did not close after M2")
ctx.log("PASS: lit through the window, raw rise %.1f C, laser %.1f C off, judged %.1f C against %.1f " ctx.log("PASS: lit through the window, raw rise %.1f C, laser %.1f C off, judged %.1f C against %.1f "
"(dT %.1f), window closed %.1f s after M2", raw, share, rise, float(limit), float(dtc), dt) "(dT %.1f), window closed %.1f s after M2", raw, share, rise, float(limit), float(dtc), dt)
@test("cooling.fan-duty-readback", title="A fan duty a device lost is read back and put back inside a tick",
subsystem="cooling", kind="auto", mode="grbl", est_min=2,
covers=_COOL_COVERS + [("kernel-module-glowforge", "src/head*")],
requires=["kernel.latch-locked-idle"],
description="M8 opens a run session and every fan reads its run duty back from its device. "
"The head's air-assist register is then written back to the idle duty behind the "
"engine's back (what a head reset or a dropped transaction leaves) and the exhaust "
"PWM to zero: within a few ticks each reads its run duty again, the log names each "
"loss and its put-back, and the session stays OK with no airflow fault (a fan put "
"back gets its spin-up grace). Then M9 and the idle duties.")
def fan_duty_readback(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
run = {"head/air_assist_pwm": 1023, "thermal/exhaust_pwm": 65535, "thermal/intake_pwm": 43278}
with ctx.grbl() as g:
c = _hold_session(ctx, g, fc, lambda c: c.get("phase") == "run", "readback", 20)
try:
ctx.check(c.get("phase") == "run", "no run session opened: %s", c)
ctx.sleep(2)
before = {k: hw.sysfs_int(k) for k in run}
ctx.log("duties at run: %s", before)
ev["run_duties"] = before
ctx.check(all(before.get(k) == v for k, v in run.items()),
"not every fan reads its run duty: %s", before)
hw.sysfs_write("head/air_assist_pwm", 204)
hw.sysfs_write("thermal/exhaust_pwm", 0)
lost = {k: hw.sysfs_int(k) for k in ("head/air_assist_pwm", "thermal/exhaust_pwm")}
ctx.log("written behind the engine's back: %s", lost)
back = {}
t0 = time.time()
while time.time() - t0 < 6:
ctx.sleep(0.5)
back = {k: hw.sysfs_int(k) for k in ("head/air_assist_pwm", "thermal/exhaust_pwm")}
if back.get("head/air_assist_pwm") == 1023 and back.get("thermal/exhaust_pwm") == 65535:
break
took = round(time.time() - t0, 1)
ev["put_back"] = {"duties": back, "after_s": took}
ctx.log("put back after %s s: %s", took, back)
ctx.check(back.get("head/air_assist_pwm") == 1023, "the air-assist duty was not put back: %s", back)
ctx.check(back.get("thermal/exhaust_pwm") == 65535, "the exhaust duty was not put back: %s", back)
ctx.sleep(2)
ctx.check(_tail_has(fc, "head/air_assist_pwm read 204 with 1023 commanded: put back"),
"the air-assist loss is not named in the log")
ctx.check(_tail_has(fc, "thermal/exhaust_pwm read 0 with 65535 commanded: put back"),
"the exhaust loss is not named in the log")
for _ in range(8):
ctx.sleep(1)
c = _cool(fc)
if c.get("verdict") != "OK":
break
ev["verdict_after"] = {"verdict": c.get("verdict"), "hold": c.get("hold"), "reason": c.get("reason")}
ctx.check(c.get("verdict") == "OK" and not c.get("hold"),
"the session did not stay OK after the put-back: %s %s", c.get("verdict"), c.get("reason"))
finally:
g.command("M9")
_session_ended(ctx, fc, "readback")
idle = {k: hw.sysfs_int(k) for k in run}
ev["idle_duties"] = idle
ctx.log("duties at idle: %s", idle)
@test("cooling.fail-tier-stop", title="A fail-tier verdict ends the controller: CRASH stops motion, "
"locks the laser, and the supervisor restarts the controller",
subsystem="cooling", kind="operator", mode="grbl", est_min=4,
covers=_COOL_COVERS + [("forgectrl", "src/super.*")],
requires=["cooling.crash-watch-plumbing", "motion.deadman"], actions=["button"],
steps=["Bed clear, lid closed, 40 mm of free +X travel. Nothing fires: the job is dark (S0).",
"Press the physical button when it lights white, once. The head moves and stops on "
"its own; the controller restarts."],
description="The engine's fail tiers (a lid IR fire signal, a head crash signal) do not "
"leave the job to the controller: after the kernel writes (motion stopped, the "
"latch locked) the engine ends the controller through the supervisor, which "
"starts it again, so no run start can relight what was locked and the sender "
"sees the job end. Provable without emission or a physical crash: the crash "
"watch's thresholds at their lowest (1) make the head's own move trip the abort "
"generator inside an armed, dark (S0) job. Expected: the engine logs the crash "
"signal, the supervisor logs the stop and starts a new controller (new pid), the "
"latch is locked, the kernel idle, no emission, the crash fault ends with the run "
"session (the engine's next tick after the controller is gone), and the "
"thresholds are put back.")
def fail_tier_stop(ctx):
from .laser import prepare, stream, sample
from .motion import FORGECTRL_LOG, _log_lines, _log_offset
fc = ctx.forgectrl
ev = ctx.evidence
before = fc.settings()
orig = {k: before.get(k, "") for k in CRASH_KEYS}
ev["orig"] = orig
ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle at the start")
st, m0 = fc.get("/mode")
ctx.check(st == 200 and isinstance(m0, dict) and m0.get("controller") == "running", "controller not running: %s", m0)
pid0 = m0.get("pid")
_set_gates(ctx, fc, {k: "1" for k in CRASH_KEYS})
off = _log_offset(FORGECTRL_LOG)
t_arm = None
try:
try:
with ctx.grbl() as g:
prepare(ctx, g)
ctx.ready("DARK JOB (S0, nothing fires). Bed clear with 40 mm of free +X travel, lid closed. "
"The head moves 20 mm and the controller restarts on its own.")
stream(g, ["G91", "G21", "M3 S0", "G4 P1", "G1 X20 F1200", "G1 X-20 F1200", "M5", "G90", "M2"])
ctx.arm_press()
t_arm = ctx.wait_for(lambda: bool((sample(ctx) or {}).get("armed")), 240)
ctx.clear_notice()
ctx.check(t_arm is not None, "the window never opened (arm refused, or no press)")
t0 = time.time()
tripped = ctx.wait_for(lambda: _cool(fc).get("verdict") == "CRASH"
or (fc.get("/mode")[1] or {}).get("pid") not in (None, pid0), 30)
ctx.log("the crash tier tripped after %s s", tripped)
except (hw.HwError, OSError) as e:
ctx.log("the Grbl connection ended with the restart: %s", e)
t_kill = time.time()
m1 = None
while time.time() - t_kill < 60:
st, m1 = fc.get("/mode")
if isinstance(m1, dict) and m1.get("controller") == "running" and m1.get("pid") != pid0:
break
ctx.sleep(0.5)
# the daemon's lines reach the log file through syslog, a moment
# after the events they name
ctx.wait_for(lambda: bool(_log_lines(FORGECTRL_LOG, off, "HEAD CRASH SIGNAL"))
and bool(_log_lines(FORGECTRL_LOG, off, "head crash signal - the controller is stopped")), 15)
engine = _log_lines(FORGECTRL_LOG, off, "HEAD CRASH SIGNAL")
super_ = _log_lines(FORGECTRL_LOG, off, "head crash signal - the controller is stopped")
smp = sample(ctx)
ilk = hw.sysfs_int("cnc/interlock_circuit")
ev["result"] = {"mode_after": m1, "engine_lines": [ln[-160:] for ln in engine[-2:]],
"super_lines": [ln[-160:] for ln in super_[-2:]],
"emission": smp and smp["emission"], "kernel": smp and smp["kstate"],
"latch_locked": ilk is not None and bool(ilk & (1 << 3))}
ctx.log("after the crash tier: %s", ev["result"])
ctx.check(engine, "the engine did not log the crash signal")
ctx.check(super_, "the supervisor did not log the fail-tier stop")
ctx.check(m1 and m1.get("pid") != pid0, "the controller was not stopped and started again: %s", m1)
ctx.check(ev["result"]["latch_locked"], "latch not locked after the crash tier")
ctx.check(not (smp and smp["emission"]), "emission during a dark job: %s", smp and smp["emission"])
ctx.check(fc.wait_idle(30, abort=ctx.aborted), "machine not idle after the restart")
# The crash fault is the run session's: the engine ends the session
# on its next 1 Hz tick after the controller is gone, and the fault
# and its hold go with it. The supervisor has the new controller up
# inside that second, so a test that returned here would hand the
# machine back with the fault it raised still standing.
cleared = ctx.wait_for(lambda: _cool(fc).get("verdict") != "CRASH", 10)
ev["result"]["fault_cleared_s"] = cleared
ctx.log("the crash fault ended with the run session after %s s: %s", cleared,
{k: _cool(fc).get(k) for k in ("phase", "verdict", "hold", "armed")})
ctx.check(cleared is not None, "the crash fault did not end with the run session: %s", _cool(fc))
finally:
fc.wait_idle(30, abort=ctx.aborted)
st, body = fc.post("/settings", params=orig)
ctx.log("restore thresholds: POST /settings %s -> %s", orig, st)
after = fc.settings()
ctx.check(all(after.get(k, "") == orig[k] for k in CRASH_KEYS),
"thresholds not restored: %s", {k: after.get(k) for k in CRASH_KEYS})
ctx.log("PASS: the crash tier stopped the job, locked the latch, and the supervisor restarted "
"the controller (pid %s -> %s)", pid0, m1 and m1.get("pid"))
+7 -1
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""forgectrl.* - the machine-services daemon's API, access control, and panel.""" """forgectrl.* - the machine-services daemon's API, access control, and panel."""
import json import json
import os import os
@@ -56,6 +61,7 @@ def auth(ctx):
("/mode", {"controller": "grbl"}), ("/settings", {"ui_units": "mm"}), ("/mode", {"controller": "grbl"}), ("/settings", {"ui_units": "mm"}),
("/diag/flow-verify", None), ("/diag/abort", None), ("/diag/flow-verify", None), ("/diag/abort", None),
("/update/apply", None), ("/boot", {"target": "a"}), ("/update/apply", None), ("/boot", {"target": "a"}),
("/update/check", None), ("/update/dismiss", {"version": "v0.0.0"}),
("/system/reboot", None), ("/restore/factory", None), ("/system/reboot", None), ("/restore/factory", None),
("/restore/factory-return", {"confirm": "1"}), ("/system/ssh", {"enable": "1"}), ("/restore/factory-return", {"confirm": "1"}), ("/system/ssh", {"enable": "1"}),
("/wiz/advisories/accept", None), ("/wiz/account", None), ("/wiz/advisories/accept", None), ("/wiz/account", None),
@@ -151,7 +157,7 @@ def auth(ctx):
# HTTPS the route itself refuses the peer (403 loopback only). Neither # HTTPS the route itself refuses the peer (403 loopback only). Neither
# request follows the redirect, and the self-signed certificate is # request follows the redirect, and the self-signed certificate is
# not verified. # not verified.
from .commission import request, decode from .setup import request, decode
ip = lan_ip() ip = lan_ip()
ev["lan_ip"] = ip ev["lan_ip"] = ip
ctx.check(ip, "cannot determine the board's LAN address") ctx.check(ip, "cannot determine the board's LAN address")
+143
View File
@@ -1,8 +1,14 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""image.* - the post-flash health of the running image (always-required).""" """image.* - the post-flash health of the running image (always-required)."""
import glob import glob
import gzip import gzip
import mmap import mmap
import os import os
import shutil
import struct import struct
import re import re
import stat import stat
@@ -395,3 +401,140 @@ def license_bundle(ctx):
text = bytes(body).decode("utf-8", "replace") if body else "" text = bytes(body).decode("utf-8", "replace") if body else ""
ctx.check("/system/licenses" in text and "PACKAGE NAME: " in text, ctx.check("/system/licenses" in text and "PACKAGE NAME: " in text,
"the Licenses page lacks the download link or the manifest") "the Licenses page lacks the download link or the manifest")
# ------------------------------------------------------- the network boot
IFSTATE = "/var/run/ifstate" # busybox ifupdown's state file
INTERFACES = "/etc/network/interfaces"
DHCP6_CLIENTS = ("udhcpc6", "dhcp6c", "odhcp6c")
def ifstate_names(text):
"""The interfaces ifup finished configuring: one name=name line each
in busybox's state file, written when ifup returns for the name."""
return {line.split("=", 1)[0].strip() for line in (text or "").splitlines() if "=" in line}
def dhcp6_in_interfaces(text):
"""The lines of an interfaces file that start a DHCPv6 client: an
inet6 stanza with a method that runs one, or a command that names
one. A comment starts nothing."""
bad = []
for raw in (text or "").splitlines():
words = raw.split("#", 1)[0].split()
if not words:
continue
if words[0] == "iface":
if len(words) >= 4 and words[2] == "inet6" and words[3] in ("dhcp", "auto"):
bad.append(" ".join(words))
elif any(w.split("/")[-1] in DHCP6_CLIENTS for w in words[1:]):
bad.append(" ".join(words))
return bad
def parse_stat(text):
"""(pid, comm, ppid) from a /proc/<pid>/stat line, or None. The comm
sits in parentheses and may itself hold spaces and parentheses."""
try:
head, tail = text.split("(", 1)
comm, rest = tail.rsplit(")", 1)
return int(head), comm, int(rest.split()[1])
except (ValueError, IndexError):
return None
def _procs():
"""(pid, ppid, comm, command line) of every process."""
out = []
for name in os.listdir("/proc"):
if not name.isdigit():
continue
st = parse_stat(_read("/proc/%s/stat" % name, ""))
if st is None:
continue
cmd = (_read("/proc/%s/cmdline" % name, "") or "").replace("\0", " ").strip()
out.append((st[0], st[2], st[1], cmd))
return out
def link_addrs(text, iface):
"""(link-local, global) IPv6 addresses of one interface, as the hex
strings of /proc/net/if_inet6 (address, index, prefix length, scope,
flags, name; scope 20 is the link, 00 the world)."""
local, world = [], []
for line in (text or "").splitlines():
f = line.split()
if len(f) == 6 and f[5] == iface:
if f[3] == "20":
local.append(f[0])
elif f[3] == "00":
world.append(f[0])
return local, world
@test("image.network-boot", title="The boot does not wait on the network",
subsystem="image", kind="auto", est_min=1,
description="The network comes up without the boot waiting on a server. init starts the "
"rest of the boot, the console login last, only after the networking script "
"returns, so a client that waits in ifup's foreground on a server's answer "
"holds the whole machine: no login, no SSH, no control panel. ifup has "
"returned for wlan0 (it is in the state file and no ifup is running) and the "
"console login is up; the DHCP client is the bounded one (-b, which leaves "
"ifup after three unanswered discovers) and has left ifup; no DHCPv6 client "
"is named in the interfaces file, running, or on the image; and IPv6 is the "
"kernel's own: enabled on wlan0, router advertisements accepted, a link-local "
"address up. A global address is evidence only, because 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. No component covers "
"this: the interfaces file is layer content, in the platform identity of "
"every fingerprint.")
def network_boot(ctx):
ev = ctx.evidence
procs = _procs()
# 1. ifup came back, and the boot went on to the console login
state = ifstate_names(_read(IFSTATE, ""))
ev["ifstate"] = sorted(state)
ctx.check("wlan0" in state, "ifup has not finished wlan0: %s holds %s", IFSTATE, sorted(state))
stuck = [p for p in procs if p[2] in ("ifup", "ifdown")]
ev["ifup_running"] = [p[3] for p in stuck]
ctx.check(not stuck, "ifup is still running: %s", [p[3] for p in stuck])
logins = [p[3] for p in procs if p[2] == "getty"]
ev["getty"] = logins
ctx.check(any("ttymxc0" in c for c in logins), "no console login is running: %s", logins)
# 2. the DHCP client is the bounded one, and it has left ifup
v4 = [p for p in procs if p[2] == "udhcpc"]
ev["udhcpc"] = [p[3] for p in v4]
ctx.check(v4, "no DHCP client is running")
for pid, ppid, _comm, cmd in v4:
ctx.check("-b" in cmd.split(), "the DHCP client runs without -b: %s", cmd)
ctx.check(ppid == 1, "the DHCP client (pid %d) is still a child of pid %d", pid, ppid)
# 3. no DHCPv6 client: not started, not running, not on the image
text = _read(INTERFACES)
ctx.check(text is not None, "%s is missing", INTERFACES)
named = dhcp6_in_interfaces(text)
ev["interfaces_dhcp6"] = named
ctx.check(not named, "%s starts a DHCPv6 client: %s", INTERFACES, named)
alive = [p[3] for p in procs if p[2] in DHCP6_CLIENTS]
ev["dhcp6_running"] = alive
ctx.check(not alive, "a DHCPv6 client is running: %s", alive)
for name in DHCP6_CLIENTS:
ctx.check(shutil.which(name) is None, "%s is on the image", name)
ctx.check(not os.path.exists("/usr/share/udhcpc/default6.script"),
"the DHCPv6 hook script is on the image")
# 4. IPv6 is the kernel's own
conf = "/proc/sys/net/ipv6/conf/wlan0/"
ev["disable_ipv6"] = (_read(conf + "disable_ipv6", "") or "").strip()
ev["accept_ra"] = (_read(conf + "accept_ra", "") or "").strip()
ctx.check(ev["disable_ipv6"] == "0", "IPv6 is off on wlan0 (disable_ipv6=%r)", ev["disable_ipv6"])
ctx.check(ev["accept_ra"] not in ("", "0"),
"wlan0 does not accept router advertisements (accept_ra=%r)", ev["accept_ra"])
local, world = link_addrs(_read("/proc/net/if_inet6", ""), "wlan0")
ev["link_local"] = local
ev["global"] = world
ctx.check(local, "wlan0 has no link-local IPv6 address")
ctx.log("wlan0: %d link-local, %d global IPv6 address(es)", len(local), len(world))
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""kernel.* - glowforge.ko safety readbacks and the pulse-engine drills. """kernel.* - glowforge.ko safety readbacks and the pulse-engine drills.
The drills are the bench scripts `scripts/bench/gate_a_kernel_drills.py` The drills are the bench scripts `scripts/bench/gate_a_kernel_drills.py`
+214 -3
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""laser.* - LIVE laser tests, ported from `scripts/bench/live_fire_drills.py`. """laser.* - LIVE laser tests, ported from `scripts/bench/live_fire_drills.py`.
Every test here can emit, except `laser.power-floor`, which only reads Every test here can emit, except `laser.power-floor`, which only reads
@@ -252,6 +257,26 @@ def kill_trail(ctx, t0, seconds=5.0):
return trail return trail
def kill_fast_trail(t0, seconds=1.5):
"""The latch and the kernel state read from sysfs every few
milliseconds after a kill: the supervisor's own reaction, which the
HTTP samples of kill_trail cannot resolve. A controller death is a
signal to the supervisor, not a poll, so the relock lands within
milliseconds; the kernel leaves running once its stop ramp is done."""
locked_at = stopped_at = None
end = t0 + seconds
while time.time() < end and (locked_at is None or stopped_at is None):
now = time.time() - t0
ilk = hw.sysfs_int("cnc/interlock_circuit")
if locked_at is None and ilk is not None and ilk & IL_LASER_LATCH:
locked_at = round(now, 3)
st = hw.sysfs_read("cnc/state")
if stopped_at is None and st is not None and st != "running":
stopped_at = round(now, 3)
time.sleep(0.005)
return {"latch_locked_at_s": locked_at, "kernel_stopped_at_s": stopped_at}
def judge_kill(ctx, trail, what): def judge_kill(ctx, trail, what):
"""(first zero, tail stayed zero, kernel stopped running) from a trail.""" """(first zero, tail stayed zero, kernel stopped running) from a trail."""
for t in trail: for t in trail:
@@ -715,7 +740,9 @@ def disarm_in_hold(ctx):
"SIGTERM, so emission drops within 2.5 s and stays 0, the kernel is not running, " "SIGTERM, so emission drops within 2.5 s and stays 0, the kernel is not running, "
"and the restart is a separate operator-judged step. Unexpected: mid-burn SIGKILL " "and the restart is a separate operator-judged step. Unexpected: mid-burn SIGKILL "
"of the controller - the supervisor's exit safing must end the fire tail inside " "of the controller - the supervisor's exit safing must end the fire tail inside "
"the ring's in-flight window, leave the latch locked, and respawn the controller.") "the ring's in-flight window, leave the latch locked, and respawn the controller. "
"The death is a signal to the supervisor, not a poll: the latch relocks within "
"300 ms of the kill and the kernel is out of running within a second.")
def armed_kill(ctx): def armed_kill(ctx):
ev = ctx.evidence ev = ctx.evidence
fc = ctx.forgectrl fc = ctx.forgectrl
@@ -769,13 +796,20 @@ def armed_kill(ctx):
ctx.log("emission live (%s) - SIGKILL controller pid %s NOW", smp["emission"], pid) ctx.log("emission live (%s) - SIGKILL controller pid %s NOW", smp["emission"], pid)
t_kill = time.time() t_kill = time.time()
_os.kill(pid, _signal.SIGKILL) _os.kill(pid, _signal.SIGKILL)
fast = kill_fast_trail(t_kill)
trail = kill_trail(ctx, t_kill) trail = kill_trail(ctx, t_kill)
ctx.log("after the kill (sysfs): %s", fast)
zero_at, tail_zero, not_running = judge_kill(ctx, trail, "kill") zero_at, tail_zero, not_running = judge_kill(ctx, trail, "kill")
ilk = hw.sysfs_int("cnc/interlock_circuit") ilk = hw.sysfs_int("cnc/interlock_circuit")
locked = ilk is not None and bool(ilk & (1 << 3)) locked = ilk is not None and bool(ilk & (1 << 3))
ev["sigkill"] = {"pid": pid, "zero_at_s": zero_at, "tail_zero": tail_zero, ev["sigkill"] = {"pid": pid, "zero_at_s": zero_at, "tail_zero": tail_zero,
"kernel_not_running": not_running, "latch_locked": locked, "trail": trail} "kernel_not_running": not_running, "latch_locked": locked, "trail": trail,
"fast": fast}
ctx.log("latch locked after the kill: %s", locked) ctx.log("latch locked after the kill: %s", locked)
ctx.check(fast["latch_locked_at_s"] is not None and fast["latch_locked_at_s"] < 0.3,
"the latch was not locked within 300 ms of the kill: %s", fast)
ctx.check(fast["kernel_stopped_at_s"] is not None and fast["kernel_stopped_at_s"] < 1.0,
"the kernel was still running 1 s after the kill: %s", fast)
ctx.check(zero_at is not None and zero_at < 2.5, ctx.check(zero_at is not None and zero_at < 2.5,
"emission did not drop within 2.5 s of the kill (first 0 at %s)", zero_at) "emission did not drop within 2.5 s of the kill (first 0 at %s)", zero_at)
ctx.check(tail_zero, "emission returned after the kill") ctx.check(tail_zero, "emission returned after the kill")
@@ -792,10 +826,187 @@ def armed_kill(ctx):
ctx.log("/mode after the kill: %s", m1) ctx.log("/mode after the kill: %s", m1)
ctx.check(m1 and m1.get("controller") == "running" and m1.get("pid") != pid, ctx.check(m1 and m1.get("controller") == "running" and m1.get("pid") != pid,
"supervisor did not respawn the controller: %s", m1) "supervisor did not respawn the controller: %s", m1)
# The respawned controller starts locked and stays locked: a latch it
# did not unlock is never relit by a run start, and no arm has asked.
ctx.check(wait_grbl_port(ctx), "the respawned controller never accepted a Grbl connection")
ctx.sleep(2)
ilk = hw.sysfs_int("cnc/interlock_circuit")
ev["latch_locked_after_respawn"] = ilk is not None and bool(ilk & IL_LASER_LATCH)
ctx.check(ev["latch_locked_after_respawn"],
"the respawned controller unlocked the latch with no arm (interlock_circuit=%s)", ilk)
ev["beam_at_fire_kill"] = smp.get("beam") ev["beam_at_fire_kill"] = smp.get("beam")
check_button_dark(ctx, ev) check_button_dark(ctx, ev)
ctx.log("PASS: expected stop 0 at +%s s and SIGKILL 0 at +%s s, latch locked, controller " ctx.log("PASS: expected stop 0 at +%s s and SIGKILL 0 at +%s s, latch locked, controller "
"respawned, button dark", ev["expected"]["zero_at_s"], zero_at) "respawned with the latch still locked, button dark", ev["expected"]["zero_at_s"], zero_at)
@test("laser.verdict-cut", title="A blocked verdict mid-cut locks the latch and holds; the clean "
"verdict resumes with no press",
subsystem="laser", kind="live", mode="grbl", est_min=4,
covers=_LASER_COVERS,
requires=["laser.emission-witness"], actions=["button"],
steps=["Scrap under the head with 40 mm of free +X travel; lid closed; exhaust on.",
"Press the physical button when it lights white (the arm). Nothing else: the test "
"takes the cooling verdict away itself and gives it back."],
description="A 40 mm line at constant power (M3 S400/F300, 8 s). About 1.5 s in, the test "
"pauses the machine-services daemon for 3.5 s, so the cooling verdict the "
"controller reads goes stale: the controller's pause tier. The controller must "
"hold the job (grbl Hold) under the still-open armed window with the stream "
"masked dark, so the kernel's LASER_ON sample count reads 0 before the cut "
"resumes, and it must not write the latch: a lock sets the hardware button "
"latch, which only a press clears, so both bit 3 (the SoC lock) and bit 2 "
"(the button latch) of interlock_circuit stay clear through the whole run. "
"When the daemon returns (the verdict fresh, clean, resume_ok), the controller "
"must resume the cut lit from where it stopped, with no button press and no "
"prompt; the M2 program end then disarms as usual. The daemon's own dead-man "
"(5 s of silence) is not reached: the controller keeps reporting through the "
"pause. The beam detector witnesses the cut on both sides of the hold.")
def verdict_cut(ctx):
import os as _os
import signal as _signal
ev = ctx.evidence
fc = ctx.forgectrl
# The daemon is frozen long enough for the verdict the controller
# caches to expire (VERDICT_MAX_AGE 2.0 s), so the driver's pause tier
# holds the job - but not so long that the engine's report dead-man
# trips on resume. That dead-man locks the laser latch when the
# controller's report is older than REPORT_TIMEOUT_S (5.0 s) while
# armed, which is exactly the "latch stayed locked" this test refuses.
# The last report is up to 1 s old when the freeze starts, so the
# freeze must stay under 4.0 s; 3.0 s clears the stale floor with room
# to see the hold and stays a full second under the dead-man.
PAUSE_S = 3.0
pids = hw.pidof("forgectrl")
ctx.check(pids, "no forgectrl process found to pause")
ev["daemon_pids"] = pids
state_path = "/run/forgefirm/grbl.state"
def grbl_state_file():
try:
with open(state_path) as f:
return f.read()
except OSError:
return ""
def resume_daemon():
for p in pids:
try:
_os.kill(p, _signal.SIGCONT)
except OSError:
pass
trail = []
text = ""
with ctx.grbl() as g, LiveJob(ctx, g):
prepare(ctx, g)
k0 = kernel_start(ctx)
base = sample(ctx)
ctx.check(base, "forgectrl /status or /cool/status unavailable")
ctx.check(not base["emission"], "emission_samples nonzero before the job (%s)", base["emission"])
ctx.ready(ARM_CUE % "40 mm +X")
job = ["G91", "G21", "M3", "S400", "G1 X40 F300", "M5", "G0 X-40", "G90", "M2"]
smp = arm_and_fire(ctx, g, room="40 mm +X", job=job)
beams = [(smp.get("beam"), smp.get("beam_d"))]
ctx.log("emission live (%s); pausing the daemon in 1 s", smp["emission"])
ctx.sleep(1.0)
g.drain()
t0 = time.time()
try:
for p in pids:
_os.kill(p, _signal.SIGSTOP)
ctx.log("daemon paused (SIGSTOP %s) for %.1f s: the verdict goes stale", pids, PAUSE_S)
# No HTTP while the daemon is paused: the controller's socket,
# sysfs and the controller's state file are the witnesses.
while time.time() - t0 < PAUSE_S:
st = g.status_report()["state"]
text += g.drain()
il = hw.sysfs_int("cnc/interlock_circuit")
trail.append({"t": round(time.time() - t0, 2), "gstate": st, "il": il,
"emission": hw.sysfs_int("cnc/laser_on_sampled"),
"armed": '"armed":true' in grbl_state_file()})
time.sleep(0.12)
finally:
resume_daemon()
ctx.log("daemon resumed (SIGCONT) at +%.2f s", time.time() - t0)
# Through the resume and to the end of the cut, sampling the same
# witnesses (the daemon's own once it answers again).
resumed_at = None
end = time.time() + 30
while time.time() < end:
ctx.checkpoint()
st = g.status_report()["state"]
text += g.drain()
il = hw.sysfs_int("cnc/interlock_circuit")
row = {"t": round(time.time() - t0, 2), "gstate": st, "il": il,
"emission": hw.sysfs_int("cnc/laser_on_sampled"),
"armed": '"armed":true' in grbl_state_file()}
if time.time() - t0 > PAUSE_S + 1.0:
s = sample(ctx)
if s:
row["beam"] = s.get("beam")
beams.append((s.get("beam"), s.get("beam_d")))
trail.append(row)
if resumed_at is None and st.startswith("Run"):
resumed_at = time.time() - t0
if st.startswith("Idle") and resumed_at is not None and time.time() - t0 > resumed_at + 3.0:
break
time.sleep(0.12)
for r in trail:
ctx.log(" %s", r)
ev["trail"] = trail
ev["messages"] = [ln for ln in text.splitlines() if ln.startswith("[MSG:") or ln.startswith("ALARM")]
ctx.log("controller: %s", ev["messages"])
dt = wait_disarm(ctx, 75)
ev["disarm_after_idle_s"] = round(dt, 1) if dt is not None else None
ctx.check(ctx.forgectrl.wait_idle(15, abort=ctx.aborted), "machine not idle after the job")
check_kernel_returned(ctx, ev, k0)
def locked(r):
return r["il"] is not None and bool(r["il"] & IL_LASER_LATCH)
def button_latched(r):
return r["il"] is not None and bool(r["il"] & IL_BUTTON_LATCH)
held = [r for r in trail if r["gstate"].startswith("Hold")]
ev.update({"held_at_s": held[0]["t"] if held else None,
"resumed_at_s": round(resumed_at, 2) if resumed_at else None,
"latch_locked_samples": sum(1 for r in trail if locked(r)),
"button_latch_set_samples": sum(1 for r in trail if button_latched(r))})
ctx.check(held and held[0]["t"] < PAUSE_S, "the stale verdict did not hold the job during the "
"pause (first Hold at %s)", ev["held_at_s"])
ctx.check("fire masked, job held" in text, "the controller did not report the pause tier")
ctx.check(ev["latch_locked_samples"] == 0,
"the pause tier wrote the latch (locked in %d samples): a lock sets the hardware "
"button latch and the resume would need a press", ev["latch_locked_samples"])
ctx.check(ev["button_latch_set_samples"] == 0,
"the hardware button latch read SET in %d samples: the resume needed a press",
ev["button_latch_set_samples"])
ctx.check(all(r["armed"] for r in trail if r["t"] < (resumed_at or PAUSE_S + 5)),
"the armed window closed during the pause")
ctx.check("press the button" not in text, "the resume asked for a button press")
ctx.check(DISARMED_MSG not in text.split("resuming")[0], "the pause tier disarmed the job")
# Dark from the hold until the resume: the gate masked the stream.
span = [r for r in trail if held and r["t"] >= held[0]["t"]
and (resumed_at is None or r["t"] < resumed_at)]
zero = next((r for r in span if r["emission"] == 0), None)
ev["emission_zero_after_hold_s"] = round(zero["t"] - held[0]["t"], 2) if zero else None
ctx.check(zero is not None and zero["t"] - held[0]["t"] <= 2.5,
"emission did not read 0 within 2.5 s of the hold (before the resume)")
ctx.check(resumed_at is not None, "the clean verdict did not resume the cut")
ctx.check("resuming" in text, "the controller did not report the resume")
after = [r for r in trail if resumed_at and r["t"] >= resumed_at]
ctx.check(any(r["emission"] for r in after), "the resumed cut ran dark (no emission after the resume)")
ctx.check("ALARM" not in text, "an alarm was raised on the pause tier")
ctx.check(dt is not None and dt < 10.0, "the M2 job did not disarm promptly at Idle (%s s)", dt)
beam_witness(ctx, ev, [{"beam": b, "beam_d": d} for b, d in beams], base)
judge_beam(ctx, ev["beam"], "the cut")
check_button_dark(ctx, ev)
ctx.log("PASS: held at +%s s with the latch untouched, emission 0 %s s after the hold, resumed "
"lit at +%s s with no press, disarmed %.1f s after Idle", ev["held_at_s"],
ev["emission_zero_after_hold_s"], ev["resumed_at_s"], dt)
DISARMED_MSG = "laser disarmed - latch locked"
@test("laser.arm-wait-lid", title="Lid open during the arm wait cancels the job", @test("laser.arm-wait-lid", title="Lid open during the arm wait cancels the job",
+53 -2
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""logs.* - unified logging: the log tree, tail, and the sanitized export; """logs.* - unified logging: the log tree, tail, and the sanitized export;
the routing path (emitters, relays, rendered rules); the level settings.""" the routing path (emitters, relays, rendered rules); the level settings."""
import gzip import gzip
@@ -22,14 +27,46 @@ def _read(path, default=None):
_LOG_COVERS = [("forgectrl", "src/logs.*"), ("forgectrl", "src/fflog.*"), ("forgectrl", "src/sanitize.*"), _LOG_COVERS = [("forgectrl", "src/logs.*"), ("forgectrl", "src/fflog.*"), ("forgectrl", "src/sanitize.*"),
("forgectrl", "src/main.c")] ("forgectrl", "src/main.c")]
LOGS_ROOT = "/data/log/forgefirm"
# What the export test plants in the installer's directory: a line to find
# again, and two addresses (documentation ranges) the sanitizer must take.
_PROBE_MARK = "forgetest export probe"
_PROBE_MAC = "02:00:5e:10:20:30"
_PROBE_IP = "192.0.2.77"
@test("logs.tree-tail-export", title="Log tree, tail, and sanitized export", subsystem="logs", @test("logs.tree-tail-export", title="Log tree, tail, and sanitized export", subsystem="logs",
kind="auto", est_min=1, kind="auto", est_min=1,
covers=_LOG_COVERS, requires=["forgectrl.auth"], covers=_LOG_COVERS, requires=["forgectrl.auth"],
description="/logs lists the loggers with their levels and files, /logs/tail returns the " description="/logs lists the loggers with their levels and files, /logs/tail returns the "
"forgectrl logger's tail, and POST /logs/export streams a sanitized tar.gz " "forgectrl logger's tail, and POST /logs/export streams a sanitized tar.gz "
"bundle that contains neither the panel token nor the camera key.") "bundle that contains neither the panel token nor the camera key. The bundle "
"also carries the installer's directory of the tree (logs/install/), which no "
"logger feeds: a probe file planted there for the export comes back in the "
"bundle with its addresses redacted, and an install log that the installer "
"left is in the bundle too.")
def tree_tail_export(ctx): def tree_tail_export(ctx):
install_dir = os.path.join(LOGS_ROOT, "install")
probe = os.path.join(install_dir, "forgetest-probe.txt")
made_dir = not os.path.isdir(install_dir)
try:
os.makedirs(install_dir, exist_ok=True)
with open(probe, "w") as f:
f.write("%s from %s at %s\n" % (_PROBE_MARK, _PROBE_MAC, _PROBE_IP))
_tree_tail_export(ctx, probe)
finally:
try:
os.unlink(probe)
except OSError:
pass
if made_dir:
try:
os.rmdir(install_dir)
except OSError:
pass
def _tree_tail_export(ctx, probe):
fc = ctx.forgectrl fc = ctx.forgectrl
ev = ctx.evidence ev = ctx.evidence
st, body = fc.get("/logs") st, body = fc.get("/logs")
@@ -82,6 +119,21 @@ def tree_tail_export(ctx):
ev[what.replace(" ", "_") + "_leaks"] = leaked ev[what.replace(" ", "_") + "_leaks"] = leaked
ctx.check(not leaked, "the sanitized bundle contains the %s: %s", what, leaked) ctx.check(not leaked, "the sanitized bundle contains the %s: %s", what, leaked)
# the installer's directory: no logger feeds it, the export carries it
by_name = dict(contents)
want = "logs/install/" + os.path.basename(probe)
got = [name for name in by_name if name.endswith(want)]
ev["install_members"] = sorted(name for name in by_name if "/logs/install/" in name)
ctx.check(got, "the bundle lacks %s: %s", want, ev["install_members"])
if got:
text = by_name[got[0]].decode("utf-8", "replace")
ctx.check(_PROBE_MARK in text, "the probe file came back without its line: %r", text)
for what, value in (("MAC address", _PROBE_MAC), ("IPv4 address", _PROBE_IP)):
ctx.check(value not in text, "the install directory left the sanitizer with its %s", what)
if os.path.isfile(os.path.join(os.path.dirname(probe), "install.log")):
ctx.check(any(name.endswith("logs/install/install.log") for name in by_name),
"the machine has an install log and the bundle does not")
# The routing test proves the whole path every logger takes: emitter (or # The routing test proves the whole path every logger takes: emitter (or
# relay) -> /dev/log -> rsyslog rules rendered from the settings -> the # relay) -> /dev/log -> rsyslog rules rendered from the settings -> the
@@ -95,7 +147,6 @@ _ROUTING_COVERS = [("forgectrl", "src/logs.*"), ("forgectrl", "src/fflog.*"), ("
("forgefirm-app", "forgefirm-app/ffmachine.py"), ("forgefirm-app", "forgefirm-app/gfcloud.py"), ("forgefirm-app", "forgefirm-app/ffmachine.py"), ("forgefirm-app", "forgefirm-app/gfcloud.py"),
("forgefirm-app", "forgefirm-app/gfhome.py")] ("forgefirm-app", "forgefirm-app/gfhome.py")]
LOGS_ROOT = "/data/log/forgefirm"
LOGGERS =("forgectrl", "grblhal", "gfcloud", "gfhome", "kernel", "system") LOGGERS =("forgectrl", "grblhal", "gfcloud", "gfhome", "kernel", "system")
_LINE_RE = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?[+-]\d\d:\d\d (?P<prog>[A-Za-z0-9_.-]+)\[(?P<pid>[-\d]+)\] " _LINE_RE = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?[+-]\d\d:\d\d (?P<prog>[A-Za-z0-9_.-]+)\[(?P<pid>[-\d]+)\] "
r"(?P<sev>EMERG|ALERT|CRIT|ERR|WARNING|NOTICE|INFO|DEBUG) (?P<msg>.*)$") r"(?P<sev>EMERG|ALERT|CRIT|ERR|WARNING|NOTICE|INFO|DEBUG) (?P<msg>.*)$")
+345 -21
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""motion.* - the motion controller under grblHAL: dry motion, no emission. """motion.* - the motion controller under grblHAL: dry motion, no emission.
Ported from `scripts/bench/pacing_test.py` (protocol-loop pacing) and Ported from `scripts/bench/pacing_test.py` (protocol-loop pacing) and
@@ -136,9 +141,68 @@ def clean_slate(ctx, g):
ctx.log("unlock: %s", g.command("$X")) ctx.log("unlock: %s", g.command("$X"))
st = g.status_report() st = g.status_report()
ctx.check(st["state"].startswith("Idle"), "controller is %s, expected Idle", st["state"]) ctx.check(st["state"].startswith("Idle"), "controller is %s, expected Idle", st["state"])
clear_error(ctx, g)
return st return st
def clear_error(ctx, g):
"""Clear a latched line error before the test's first move.
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 ($ commands
and the ? report are unaffected, so a sender that queries on connect
self-clears; a bare G-code stream does not). A prior test's or the
baseline hand-back's rejected jog would otherwise make this test's
first move fail with a stale error unrelated to the move. A blank
line clears it and returns ok. Harmless and idempotent at Idle."""
r = g.command("")
ctx.check(r and r[-1] == "ok" and not any(x.startswith("error") for x in r),
"the controller answered %s to a blank line at the start - not a clean parser", r)
MOVE_START_S = 2.0 # Run is there within 100 ms; a loaded board gets this long
def start_move(ctx, g, line):
"""Start a travel job with `line` and see the controller in Run. The
wait is bounded, not a fixed sleep. When Run never comes, the failure
names what happened instead: the reply (a refused block answers
error), the state the controller sits in (Hold, Door, Alarm, or an
Idle that never took the block), every line it said meanwhile, and
the machine as forgectrl sees it - the evidence a bare "did not
start" leaves out. Recorded in the evidence either way."""
reply = g.command(line, timeout=1.0)
end = time.time() + MOVE_START_S
t0 = time.time()
said = ""
st = None
while time.time() < end:
ctx.checkpoint()
st = g.status_report()
if st["state"].startswith("Run"):
break
said += g.drain()
time.sleep(0.1)
rec = {"line": line, "reply": reply, "state": st["state"] if st else None,
"after_s": round(time.time() - t0, 2)}
ctx.evidence.setdefault("move_start", []).append(rec)
if st is not None and st["state"].startswith("Run"):
return st
msgs = [ln.strip() for ln in said.splitlines() if ln.strip()]
rec["said"] = msgs
s = ctx.forgectrl.status() or {}
gr = ((s.get("grbl") or {}).get("report") or {})
rec["forgectrl"] = {"state": s.get("state"), "switches": s.get("switches"),
"laser_locked": s.get("laser_locked"), "grbl": gr.get("state"),
"alarm": gr.get("alarm"), "sender": (gr.get("sender") or {}).get("connected")}
ctx.check(False, "the move did not start: %s answered %s; controller %s %.1f s later, said %s; "
"forgectrl sees kernel %s, grbl %s (alarm %s), switches %s, laser locked %s",
line, reply, st["state"] if st else "no report", rec["after_s"], msgs or "nothing",
s.get("state"), gr.get("state"), gr.get("alarm"), s.get("switches"), s.get("laser_locked"))
@test("motion.pacing", title="Protocol-loop pacing (idle, parked, moving) and hold/resume position", @test("motion.pacing", title="Protocol-loop pacing (idle, parked, moving) and hold/resume position",
subsystem="motion", kind="auto", mode="grbl", est_min=1, subsystem="motion", kind="auto", mode="grbl", est_min=1,
covers=_MOTION_COVERS, requires=["kernel.latch-locked-idle"], covers=_MOTION_COVERS, requires=["kernel.latch-locked-idle"],
@@ -753,21 +817,30 @@ def _return_x(ctx, delta_mm):
machine_idle(ctx) machine_idle(ctx)
@test("motion.deadman", title="Dead-man: controller kill, controller hang, forgectrl restart mid-move", @test("motion.deadman", title="Dead-man: controller kill, controller hang, forgectrl restart mid-move, "
subsystem="motion", kind="auto", mode="grbl", est_min=4, "a kill during the web-service homing",
subsystem="motion", kind="auto", mode="grbl", est_min=6,
covers=_MOTION_COVERS + [("forgectrl", "src/main.c"), ("forgectrl", "init/**")], covers=_MOTION_COVERS + [("forgectrl", "src/main.c"), ("forgectrl", "init/**")],
requires=["motion.cancel-abort", "kernel.k1-k2"], requires=["motion.cancel-abort", "kernel.k1-k2"],
steps=["Bed clear; the head needs 40 mm of free +X travel and must not be at the left rail."], steps=["Bed clear; the head needs 40 mm of free +X travel and must not be at the left rail. "
"With cloud mode enabled the test also homes through the web service and kills the "
"controller mid-homing (about a minute); the head ends wherever the homing was."],
description="SIGKILL of the controller mid-move: the supervisor reaps it, safes (cnc/stop, " description="SIGKILL of the controller mid-move: the supervisor reaps it, safes (cnc/stop, "
"latch relocked - it never unlocked), and respawns within seconds. SIGSTOP (a " "latch relocked - it never unlocked), and respawns within seconds. SIGSTOP (a "
"hang) mid-move: the ring drains into a kernel underrun (fast halt, latch " "hang) mid-move: the ring drains into a kernel underrun (fast halt, latch "
"locked); the process resumed from the hang recovers on a soft reset and an unlock " "locked); the process resumed from the hang recovers on a soft reset and an unlock "
"(the alarm is critical, so $X alone is refused; the reset acknowledges the " "(the alarm is critical, so $X alone is refused; the reset acknowledges the "
"fault to the stream and clears the stale ring) and moves again without a " "fault to the stream and clears the stale ring) and moves again without a "
"restart. forgectrl " "restart. A short SIGSTOP (100 ms, inside the kernel's 200 ms queue) mid-move: "
"restart mid-move: the busy controller finishes the move unmanaged and the new " "no underrun, but the step producer comes back later than its lead and its "
"daemon retakes supervision at idle. After each drill the head is jogged back " "late events clamp; unarmed that is a warning in the controller's log and the "
"by the kernel-measured distance.") "move completes with every step (the armed case faults, proven on the host). "
"forgectrl restart mid-move: the busy controller finishes the move unmanaged "
"and the new daemon retakes supervision at idle. After each drill the head is "
"jogged back by the kernel-measured distance. SIGKILL during $H (the web-service "
"homing, with cloud mode enabled): the homing runner the controller left behind "
"is ended before the respawn, the kernel is idle when the new controller starts, "
"and the pulse device has one controller on it.")
def deadman(ctx): def deadman(ctx):
import os as _os import os as _os
import signal as _signal import signal as _signal
@@ -897,6 +970,47 @@ def deadman(ctx):
x1 = _kernel_x_mm(ctx) x1 = _kernel_x_mm(ctx)
_return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None) _return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None)
# ---- 2b. a short hang (inside the kernel queue): late events clamp, a warning, the move completes
m2 = wait_running(10)
pid2 = m2["pid"]
x0 = _kernel_x_mm(ctx)
underruns0 = hw.sysfs_int("cnc/underruns", 0)
log0 = _log_offset(GRBLHAL_LOG)
with ctx.grbl() as g:
clean_slate(ctx, g)
g.command("G91")
g.command("G1X30F300", timeout=0.5) # ~6 s of motion
ctx.sleep(1.0)
_os.kill(pid2, _signal.SIGSTOP)
time.sleep(0.1)
_os.kill(pid2, _signal.SIGCONT)
ctx.log("SIGSTOP 100 ms sent to controller pid %d mid-move", pid2)
peak, states, st = wait_idle(ctx, g, 30)
g.command("G90")
machine_idle(ctx) # the kernel's tail plays out behind grbl's Idle
warned = None
t0 = time.time()
while time.time() - t0 < 5 and not warned:
lines = _log_lines(GRBLHAL_LOG, log0, "late events clamped")
warned = lines[-1] if lines else None
if not warned:
ctx.sleep(0.5)
x1 = _kernel_x_mm(ctx)
ev["short_stall"] = {"states": states, "underruns": hw.sysfs_int("cnc/underruns", 0) - underruns0,
"warning": warned, "kernel_dx_mm": round((x1 - x0), 3) if (x0 is not None and x1 is not None) else None,
"state_after": (st or {}).get("state")}
ctx.log("short stall: states %s, underruns +%s, warning %r, kernel dx %s mm", states,
ev["short_stall"]["underruns"], warned, ev["short_stall"]["kernel_dx_mm"])
ctx.check("TIMEOUT" not in states and str((st or {}).get("state", "")).startswith("Idle"),
"the move did not complete after the short stall (states %s)", states)
ctx.check(ev["short_stall"]["underruns"] == 0, "the short stall drained the ring (underruns +%s)",
ev["short_stall"]["underruns"])
ctx.check(warned, "the controller did not warn about the clamped events after the short stall")
ctx.check(ev["short_stall"]["kernel_dx_mm"] is not None and abs(ev["short_stall"]["kernel_dx_mm"] - 30.0) < 0.3,
"the kernel counted %s mm for a 30 mm move after the short stall", ev["short_stall"]["kernel_dx_mm"])
ctx.check(latch_locked(), "latch unlocked after the short stall")
_return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None)
# ---- 3. forgectrl restart mid-move: the move finishes, supervision retaken at idle # ---- 3. forgectrl restart mid-move: the move finishes, supervision retaken at idle
m2 = wait_running(10) m2 = wait_running(10)
pid2 = m2["pid"] pid2 = m2["pid"]
@@ -934,8 +1048,226 @@ def deadman(ctx):
x1 = _kernel_x_mm(ctx) x1 = _kernel_x_mm(ctx)
_return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None) _return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None)
machine_idle(ctx) machine_idle(ctx)
ctx.log("PASS: kill respawned in %s s, hang -> underrun in %s s, restart retook supervision (pid %s)",
respawn_s, halt_s, m3.get("pid")) # ---- 4. SIGKILL during $H: the homing runner is ended, the kernel idle, one writer
homing_kill(ctx, wait_running)
ctx.log("PASS: kill respawned in %s s, hang -> underrun in %s s, short stall warned and completed, "
"restart retook supervision (pid %s), the kill during $H ended the runner (%s)",
respawn_s, halt_s, m3.get("pid"), ev.get("homing_kill", {}).get("runner_gone_s", "skipped"))
def _pulse_holders():
"""The processes holding the pulse device open, by name."""
out = []
for pid in os.listdir("/proc"):
if not pid.isdigit():
continue
try:
for fd in os.listdir("/proc/%s/fd" % pid):
if os.readlink("/proc/%s/fd/%s" % (pid, fd)) == "/dev/glowforge":
with open("/proc/%s/comm" % pid) as f:
out.append(f.read().strip())
break
except OSError:
continue
return out
def homing_kill(ctx, wait_running):
"""SIGKILL the GRBL controller while gfhome (the web-service homing
runner, its own process group on the inherited pulse fd) is moving
the head. The supervisor must end the runner before it starts another
controller, or two processes write the ring. Skipped when cloud mode
is not enabled on the machine (the runner needs the service)."""
import os as _os
import signal as _signal
fc = ctx.forgectrl
ev = ctx.evidence
before = fc.settings()
if str(before.get("cloud_enabled", "")).lower() not in ("1", "true", "yes", "on"):
ctx.log("kill during $H: skipped, cloud mode is not enabled on this machine")
return
hm = before.get("homing_mode", "")
ev["homing_kill"] = {"homing_mode": hm}
if hm != "gfcloud":
st, body = fc.post("/settings", data={"homing_mode": "gfcloud"})
ctx.check(st == 200, "homing_mode=gfcloud -> %s %s", st, body)
try:
m4 = wait_running(10)
ctx.check(m4, "controller not running before the homing drill")
pid4 = m4["pid"]
off = _log_offset(FORGECTRL_LOG)
runner_up = None
try:
with ctx.grbl() as g:
clean_slate(ctx, g)
g.send_raw(b"$H\n")
runner_up = ctx.wait_for(lambda: bool(hw.pidof("gfhome.py")), 30)
ctx.check(runner_up is not None, "the homing runner never started for $H")
ctx.sleep(3.0) # into the session: the runner drives the head
ctx.log("SIGKILL sent to controller pid %d during $H (runner pid %s)",
pid4, hw.pidof("gfhome.py"))
_os.kill(pid4, _signal.SIGKILL)
except (hw.HwError, OSError) as e:
ctx.log("the Grbl connection ended with the kill: %s", e)
t_kill = time.time()
gone_s = ctx.wait_for(lambda: not hw.pidof("gfhome.py"), 15)
ev["homing_kill"]["runner_gone_s"] = gone_s
ctx.check(gone_s is not None, "the homing runner outlived its controller")
m5 = None
while time.time() - t_kill < 60:
st, m5 = fc.get("/mode")
if isinstance(m5, dict) and m5.get("controller") == "running" and m5.get("pid") != pid4:
break
ctx.sleep(0.5)
kstate = hw.sysfs_read("cnc/state")
holders = _pulse_holders()
lines = _log_lines(FORGECTRL_LOG, off, "homing runner")
halted = _log_lines(FORGECTRL_LOG, off, "halting it")
ev["homing_kill"].update({"respawn_s": round(time.time() - t_kill, 1), "mode_after": m5,
"kernel_state_at_respawn": kstate, "pulse_holders": holders,
"super_lines": [ln[-160:] for ln in lines[-3:]]})
ctx.log("kill during $H: runner gone in %s s, respawned as %s, kernel %s, pulse held by %s",
gone_s, m5 and m5.get("pid"), kstate, holders)
ctx.check(m5 and m5.get("pid") != pid4, "no respawn after the kill during $H: %s", m5)
ctx.check(lines, "the supervisor did not log the runner's end")
ctx.check(not halted, "the kernel had to be halted for the respawn: %s", halted[-1:])
ctx.check(kstate in ("idle", "disabled"), "the kernel was %s when the new controller started", kstate)
ctx.check("gfhome.py" not in holders and holders.count("grblHAL_glowfor") <= 1,
"more than one controller on the pulse device: %s", holders)
ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle after the drill")
finally:
if hm != "gfcloud":
st, body = (fc.post("/settings", params={"homing_mode": ""}) if not hm
else fc.post("/settings", data={"homing_mode": hm}))
ctx.log("restore homing_mode=%r -> %s", hm, st)
@test("motion.respawn-gate", title="A respawn waits for the lid the way a first spawn does",
subsystem="motion", kind="operator", mode="grbl", est_min=2,
covers=_MOTION_COVERS, requires=["motion.deadman", "motion.gate-waits-for-lid"], actions=["lid"],
steps=["Bed clear. Open the lid when told and close it when told; nothing moves."],
description="The enclosure check runs before every controller spawn, respawns included. "
"With the lid open, the controller is killed: the supervisor safes the machine "
"(latch locked), reports waiting with why naming the lid, and starts no controller "
"while the lid stays open. When the lid closes the controller comes back verified, "
"without a second motion probe (the probe is once per broker hold).")
def respawn_gate(ctx):
import os as _os
import signal as _signal
fc = ctx.forgectrl
ev = ctx.evidence
ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle at the start")
st, m0 = fc.get("/mode")
ctx.check(st == 200 and isinstance(m0, dict) and m0.get("controller") == "running", "controller not running: %s", m0)
pid0 = m0.get("pid")
off = _log_offset(FORGECTRL_LOG)
ctx.act("lid", "open")
try:
ctx.sleep(1.0)
_os.kill(pid0, _signal.SIGKILL)
t_kill = time.time()
ctx.log("SIGKILL sent to controller pid %d with the lid open", pid0)
waited = ctx.wait_for(lambda: (fc.get("/mode")[1] or {}).get("controller") == "waiting", 15)
st, m1 = fc.get("/mode")
ev["after_kill"] = {"waiting_s": waited, "mode": m1}
ctx.log("mode %.1f s after the kill: %s", time.time() - t_kill, m1)
ctx.check(waited is not None, "the supervisor did not report waiting for the lid: %s", m1)
ctx.check("lid" in (m1.get("why") or ""), "why does not name the lid: %r", m1.get("why"))
ilk = hw.sysfs_int("cnc/interlock_circuit")
ctx.check(ilk is not None and ilk & (1 << 3), "latch not locked after the kill")
ctx.sleep(4.0)
st, m2 = fc.get("/mode")
ctx.check(isinstance(m2, dict) and m2.get("controller") == "waiting" and not m2.get("pid"),
"the wait did not hold with the lid open: %s", m2)
finally:
ctx.act("lid", "close")
t_close = time.time()
came = ctx.wait_for(lambda: ((fc.get("/mode")[1] or {}).get("controller") == "running"
and (fc.get("/mode")[1] or {}).get("pid") != pid0), 60)
st, m3 = fc.get("/mode")
ev["after_close"] = {"running_s": came, "mode": m3}
ctx.log("mode %.1f s after the lid closed: %s", time.time() - t_close, m3)
ctx.check(came is not None and m3.get("motion") == "verified",
"the controller did not come back verified after the lid closed: %s", m3)
lines = _probe_lines(FORGECTRL_LOG, off)
ev["probe_lines"] = lines[-2:]
ctx.check(not lines, "the motion probe ran again for the respawn: %s", lines[:1])
ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle at the end")
ctx.log("PASS: the respawn waited %.1f s for the lid, then came back verified in %.1f s with no probe",
t_close - t_kill, came)
@test("motion.soft-limits", title="After a home the bed is the X/Y envelope",
subsystem="motion", kind="auto", mode="grbl", est_min=3,
covers=_MOTION_COVERS, requires=["motion.jog-roundtrip", "cloud.mode-switch"],
steps=["Bed clear, lid closed. The test homes the machine through the web service if it is "
"not homed (about a minute), then sends moves the controller must refuse."],
description="The machine has no limit switches, so the core's $20 cannot be turned on and "
"the X/Y soft limits are the driver's: off while the position is not trusted, on "
"after a home, when the envelope is the bed ($130 by $131 from the home corner). "
"Homed, a program move 5 mm past X max or Y max, or past the near edge, raises "
"ALARM:2 before any motion (the kernel counters do not move), a jog past the bed "
"is refused with error 15, and a move inside the bed runs. The Z envelope is the "
"lens window, as before.")
def soft_limits(ctx):
from .cloud import gfhome_homing
fc = ctx.forgectrl
ev = ctx.evidence
with ctx.grbl() as g:
clean_slate(ctx, g)
if not fc.status().get("homed"):
gfhome_homing(ctx, ev, g)
ctx.check(fc.status().get("homed"), "the machine is not homed")
x_travel = float(grbl_setting(g, "$130"))
y_travel = float(grbl_setting(g, "$131"))
ev["travel"] = {"x": x_travel, "y": y_travel}
k0 = kernel_xy_mm(ctx)
def refused(cmd):
lines = g.command(cmd, timeout=2)
ctx.sleep(0.5)
text = "\n".join(lines) + g.drain()
k1 = kernel_xy_mm(ctx)
moved = max(abs(k1[0] - k0[0]), abs(k1[1] - k0[1]))
st = g.status_report()["state"]
rec = {"cmd": cmd, "reply": lines, "alarm": "ALARM:2" in text, "moved_mm": round(moved, 3), "state": st}
ctx.log("%s", rec)
ctx.check(rec["alarm"], "%s was not refused with ALARM:2: %s", cmd, lines)
ctx.check(moved < 0.05, "%s moved the kernel %.3f mm before the alarm", cmd, moved)
g.realtime(0x18)
ctx.sleep(1.5)
g.drain()
unlock = g.command("$X")
ctx.check(unlock and unlock[-1] == "ok", "$X after the soft-limit alarm: %s", unlock)
st = g.status_report()["state"]
ctx.check(st.startswith("Idle"), "controller is %s after the recovery", st)
return rec
ev["refused"] = [refused("G90 G1 X%.1f F600" % (x_travel + 5)),
refused("G90 G1 Y%.1f F600" % (y_travel + 5)),
refused("G90 G1 X-1 F600")]
# Homed still: the soft-limit alarm and its reset keep the reference.
ctx.check(fc.status().get("homed"), "the soft-limit alarm and the reset un-homed the machine")
jog = g.command("$J=G91X%.1fF1200" % (x_travel + 5))
ev["jog"] = jog
ctx.check(any(l.startswith("error:15") for l in jog), "a jog past the bed was not refused with error 15: %s", jog)
# The core answers the line after an error with that error again
# until an empty line clears it (the sender's acknowledgment).
g.command("")
g.command("G90 G1 X10 Y10 F600", timeout=0.5)
peak, states, st = wait_idle(ctx, g, 20)
ev["inside"] = states
ctx.check("TIMEOUT" not in states, "a move inside the bed did not run")
g.command("G90 G1 X0 Y0 F600", timeout=0.5)
wait_idle(ctx, g, 20)
k1 = kernel_start(ctx) # at rest: grbl's Idle leads the kernel's tail
ev["kernel_back_mm"] = [round(k1[0] - k0[0], 3), round(k1[1] - k0[1], 3)]
ctx.check(max(abs(k1[0] - k0[0]), abs(k1[1] - k0[1])) < 0.1, "the head did not come back to the corner: %s",
ev["kernel_back_mm"])
ctx.log("PASS: X max, Y max and X min refused with ALARM:2 and no motion, the jog refused with "
"error 15, a move inside the bed ran")
# ------------------------------------------------- lid / button (the factory's) # ------------------------------------------------- lid / button (the factory's)
@@ -1066,9 +1398,7 @@ def button_hold_resume(ctx):
"moves; the job holds and the test sees it.") "moves; the job holds and the test sees it.")
g.command("M5") g.command("M5")
g.command("G91") g.command("G91")
g.command("G1X40F300", timeout=0.5) # an 8 s move start_move(ctx, g, "G1X40F300") # an 8 s move
ctx.sleep(0.5)
ctx.check(g.status_report()["state"].startswith("Run"), "the move did not start")
g.drain() # the message window opens here g.drain() # the message window opens here
w = Watch(g) w = Watch(g)
ctx.act("button", "press", until=w.in_state("Hold"), timeout=12, fail=False) ctx.act("button", "press", until=w.in_state("Hold"), timeout=12, fail=False)
@@ -1144,9 +1474,7 @@ def lid_cancel_home(ctx):
"leave it open until the head has come back on its own.") "leave it open until the head has come back on its own.")
g.command("M5") g.command("M5")
g.command("G91") g.command("G91")
g.command("G1X40F300", timeout=0.5) # an 8 s move start_move(ctx, g, "G1X40F300") # an 8 s move
ctx.sleep(0.5)
ctx.check(g.status_report()["state"].startswith("Run"), "the move did not start")
g.drain() # the message window opens here g.drain() # the message window opens here
ctx.act("lid", "open", text="Leave it open until the head has come back.", timeout=12) ctx.act("lid", "open", text="Leave it open until the head has come back.", timeout=12)
drift = expect_cancel_and_return(ctx, g, ev, start, k0, "lid opened", "running") drift = expect_cancel_and_return(ctx, g, ev, start, k0, "lid opened", "running")
@@ -1239,9 +1567,7 @@ def interlock_cancel_home(ctx):
"moves and leave it open until the head has come back on its own.") "moves and leave it open until the head has come back on its own.")
g.command("M5") g.command("M5")
g.command("G91") g.command("G91")
g.command("G1X60F300", timeout=0.5) # a 12 s move start_move(ctx, g, "G1X60F300") # a 12 s move
ctx.sleep(0.5)
ctx.check(g.status_report()["state"].startswith("Run"), "the move did not start")
g.drain() g.drain()
ctx.act("interlock", "open", text="Leave it open until the head has come back.", timeout=16) ctx.act("interlock", "open", text="Leave it open until the head has come back.", timeout=16)
sw = (ctx.forgectrl.status().get("switches") or {}) sw = (ctx.forgectrl.status().get("switches") or {})
@@ -1303,9 +1629,7 @@ def lid_policy_hold(ctx):
"the job parks in the door state and waits.") "the job parks in the door state and waits.")
g.command("M5") g.command("M5")
g.command("G91") g.command("G91")
g.command("G1X40F300", timeout=0.5) # an 8 s move start_move(ctx, g, "G1X40F300") # an 8 s move
ctx.sleep(0.5)
ctx.check(g.status_report()["state"].startswith("Run"), "the move did not start")
g.drain() g.drain()
ctx.act("lid", "open", timeout=12) ctx.act("lid", "open", timeout=12)
st, text = wait_state_text(ctx, g, "Door", 8) st, text = wait_state_text(ctx, g, "Door", 8)
@@ -1,9 +1,14 @@
"""commission.* - the first-run commissioning: the record and the # Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""setup.* - the first-run setup: the record and the
controller gate, the advisories, the account and its login, the HTTPS controller gate, the advisories, the account and its login, the HTTPS
boundary, SSH, the cloud switch, the machine's name, and the factory boundary, SSH, the cloud switch, the machine's name, and the factory
return. return.
The daemon reads the commissioning record (commissioning.json in the The daemon reads the setup record (setup.json in the
data directory) and the account record (users) once, at its start, and data directory) and the account record (users) once, at its start, and
keeps them in memory; only its own routes write them. A test that needs keeps them in memory; only its own routes write them. A test that needs
a different record installs the file under a forgectrl restart (a a different record installs the file under a forgectrl restart (a
@@ -34,13 +39,13 @@ from ..catalog import test
from .. import hw from .. import hw
from .forgectrl import lan_ip from .forgectrl import lan_ip
RECORD = "commissioning.json" RECORD = "setup.json"
USERS = "users" USERS = "users"
OVERRIDE = "commissioning-override" OVERRIDE = "setup-override"
SSH_FLAG = "ssh-enabled" SSH_FLAG = "ssh-enabled"
TLS_BASE = "https://127.0.0.1" # FORGECTRL_TLS_URL overrides (host tests) TLS_BASE = "https://127.0.0.1" # FORGECTRL_TLS_URL overrides (host tests)
# The image's required wizards, all at version 1 (commission.c). # The image's required wizards, all at version 1 (setup.c).
REQUIRED_WIZARDS = ("advisories", "account", "preferences", "machine", "cloud") REQUIRED_WIZARDS = ("advisories", "account", "preferences", "machine", "cloud")
ADVISORY_DOCS = ("safety-and-risk", "licenses", "privacy", "cloud-service") ADVISORY_DOCS = ("safety-and-risk", "licenses", "privacy", "cloud-service")
SAFETY_PHRASE = "I UNDERSTAND" SAFETY_PHRASE = "I UNDERSTAND"
@@ -176,8 +181,8 @@ def record_without_wizards(record):
def complete_record(status, base=None, account_name="bench"): def complete_record(status, base=None, account_name="bench"):
"""A complete commissioning record for a machine that must count as """A complete setup record for a machine that must count as
commissioned: every advisory at the hash GET /wiz reports, the set up: every advisory at the hash GET /wiz reports, the
press recorded, an account (the one of `base` when it has one), press recorded, an account (the one of `base` when it has one),
every wizard of the catalog at its version, and the run complete. every wizard of the catalog at its version, and the run complete.
`status` is a GET /wiz body; `base` is an existing record to keep `status` is a GET /wiz body; `base` is an existing record to keep
@@ -352,12 +357,12 @@ def gate_is_open():
# -------------------------------------------------------------- the gate # -------------------------------------------------------------- the gate
_GATE_COVERS = [("forgectrl", "src/commission.*"), ("forgectrl", "src/super.c"), _GATE_COVERS = [("forgectrl", "src/setup.*"), ("forgectrl", "src/super.c"),
("forgectrl", "src/main.c"), ("forgectrl", "src/paths.h")] ("forgectrl", "src/main.c"), ("forgectrl", "src/paths.h")]
@test("commission.gate-blocks-controllers", title="The commissioning gate spawns no controller", @test("setup.gate-blocks-controllers", title="The setup gate spawns no controller",
subsystem="commission", kind="auto", hardware="takeover", mode="grbl", est_min=5, subsystem="setup", kind="auto", hardware="takeover", mode="grbl", est_min=5,
covers=_GATE_COVERS, requires=["forgectrl.auth"], precheck=gate_is_open, covers=_GATE_COVERS, requires=["forgectrl.auth"], precheck=gate_is_open,
description="With the override removed and a record that lacks every required wizard " description="With the override removed and a record that lacks every required wizard "
"(installed under a forgectrl restart, the consent and the account kept), " "(installed under a forgectrl restart, the consent and the account kept), "
@@ -374,11 +379,11 @@ def gate_blocks_controllers(ctx):
ctx.log("before: gate %s, override %s, required %s", before.get("gate"), before.get("override"), ctx.log("before: gate %s, override %s, required %s", before.get("gate"), before.get("override"),
before.get("required")) before.get("required"))
raw = read_file(record_path()) raw = read_file(record_path())
ctx.check(raw, "no commissioning record at %s", record_path()) ctx.check(raw, "no setup record at %s", record_path())
try: try:
record = json.loads(raw.decode("utf-8")) record = json.loads(raw.decode("utf-8"))
except ValueError as e: except ValueError as e:
ctx.fail("the commissioning record is not JSON: %s", e) ctx.fail("the setup record is not JSON: %s", e)
lacking = json.dumps(record_without_wizards(record), indent=1, sort_keys=True).encode() + b"\n" lacking = json.dumps(record_without_wizards(record), indent=1, sort_keys=True).encode() + b"\n"
had_override = override_present() had_override = override_present()
ev["override_before"] = had_override ev["override_before"] = had_override
@@ -391,7 +396,7 @@ def gate_blocks_controllers(ctx):
ctx.log("/mode after the restart: %s (after %s s)", m, took) ctx.log("/mode after the restart: %s (after %s s)", m, took)
ctx.check(m.get("controller") == "gated" and m.get("gated") is True, ctx.check(m.get("controller") == "gated" and m.get("gated") is True,
"the supervisor did not report the gate: %s", m) "the supervisor did not report the gate: %s", m)
ctx.check("commissioning required" in (m.get("why") or ""), ctx.check("setup required" in (m.get("why") or ""),
"the reason does not name the missing wizards: %r", m.get("why")) "the reason does not name the missing wizards: %r", m.get("why"))
w = wiz(fc) w = wiz(fc)
ev["gated_wiz"] = wiz_summary(w) ev["gated_wiz"] = wiz_summary(w)
@@ -439,11 +444,11 @@ def gate_blocks_controllers(ctx):
ctx.log("restored: gate %s, controller %s", after.get("gate"), m.get("controller")) ctx.log("restored: gate %s, controller %s", after.get("gate"), m.get("controller"))
@test("commission.override-until-reboot", title="The override stands until the next reboot", @test("setup.override-until-reboot", title="The override stands until the next reboot",
subsystem="commission", kind="auto", est_min=1, subsystem="setup", kind="auto", est_min=1,
covers=_GATE_COVERS, requires=["forgectrl.auth"], covers=_GATE_COVERS, requires=["forgectrl.auth"],
description="The bench seed: the dev image's forgetest init script writes " description="The bench seed: the dev image's forgetest init script writes "
"/run/forgefirm/commissioning-override at boot. With it present, GET /wiz " "/run/forgefirm/setup-override at boot. With it present, GET /wiz "
"reports the override and an open gate, the reason names the wizards it lifts " "reports the override and an open gate, the reason names the wizards it lifts "
"when any is required, and GET /mode reports the controller running and not " "when any is required, and GET /mode reports the controller running and not "
"gated. The file lives on tmpfs (/proc/mounts), so a reboot removes it by " "gated. The file lives on tmpfs (/proc/mounts), so a reboot removes it by "
@@ -486,9 +491,9 @@ def override_until_reboot(ctx):
# ------------------------------------------------------------ advisories # ------------------------------------------------------------ advisories
@test("commission.advisories-rehash", title="An advisory is accepted at its current hash only", @test("setup.advisories-rehash", title="An advisory is accepted at its current hash only",
subsystem="commission", kind="auto", hardware="takeover", est_min=3, subsystem="setup", kind="auto", hardware="takeover", est_min=3,
covers=[("forgectrl", "src/advisories.*"), ("forgectrl", "src/commission.*"), covers=[("forgectrl", "src/advisories.*"), ("forgectrl", "src/setup.*"),
("forgectrl", "src/wiz.*"), ("forgectrl", "src/sha256.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/wiz.*"), ("forgectrl", "src/sha256.*"), ("forgectrl", "src/main.c"),
("forgectrl", "src/ui/md.js"), ("forgectrl", "src/ui/embed_docs.cmake")], ("forgectrl", "src/ui/md.js"), ("forgectrl", "src/ui/embed_docs.cmake")],
requires=["forgectrl.auth"], requires=["forgectrl.auth"],
@@ -504,7 +509,7 @@ def advisories_rehash(ctx):
fc = ctx.forgectrl fc = ctx.forgectrl
ev = ctx.evidence ev = ctx.evidence
raw = read_file(record_path()) raw = read_file(record_path())
ctx.check(raw, "no commissioning record at %s", record_path()) ctx.check(raw, "no setup record at %s", record_path())
before = wiz(fc) before = wiz(fc)
ev["before"] = wiz_summary(before) ev["before"] = wiz_summary(before)
docs = {d.get("id"): d for d in before.get("documents") or []} docs = {d.get("id"): d for d in before.get("documents") or []}
@@ -579,7 +584,7 @@ def advisories_rehash(ctx):
# -------------------------------------------------------------- the login # -------------------------------------------------------------- the login
@test("commission.account-login", title="The panel login over HTTPS", subsystem="commission", @test("setup.account-login", title="The panel login over HTTPS", subsystem="setup",
kind="auto", est_min=2, kind="auto", est_min=2,
covers=[("forgectrl", "src/auth.*"), ("forgectrl", "src/session.*"), ("forgectrl", "src/users.*"), covers=[("forgectrl", "src/auth.*"), ("forgectrl", "src/session.*"), ("forgectrl", "src/users.*"),
("forgectrl", "src/tls.*"), ("forgectrl", "src/peer.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/tls.*"), ("forgectrl", "src/peer.*"), ("forgectrl", "src/main.c"),
@@ -702,7 +707,7 @@ def login_checks(ctx, name, pw):
# ------------------------------------------------------- HTTPS boundary # ------------------------------------------------------- HTTPS boundary
@test("commission.https-only-writes", title="Writes from the LAN go to HTTPS", subsystem="commission", @test("setup.https-only-writes", title="Writes from the LAN go to HTTPS", subsystem="setup",
kind="auto", est_min=1, kind="auto", est_min=1,
covers=[("forgectrl", "src/auth.*"), ("forgectrl", "src/tls.*"), ("forgectrl", "src/peer.*"), covers=[("forgectrl", "src/auth.*"), ("forgectrl", "src/tls.*"), ("forgectrl", "src/peer.*"),
("forgectrl", "src/main.c")], ("forgectrl", "src/main.c")],
@@ -802,8 +807,8 @@ def restore_ssh(fc, before):
write_file(flag, b"" if before.get("enabled") else None) write_file(flag, b"" if before.get("enabled") else None)
@test("commission.ssh-until-reboot", title="SSH is switched from the panel until the next reboot", @test("setup.ssh-until-reboot", title="SSH is switched from the panel until the next reboot",
subsystem="commission", kind="auto", est_min=1, subsystem="setup", kind="auto", est_min=1,
covers=[("forgectrl", "src/main.c"), ("forgectrl", "src/paths.h")], covers=[("forgectrl", "src/main.c"), ("forgectrl", "src/paths.h")],
requires=["forgectrl.auth"], requires=["forgectrl.auth"],
description="GET /system/ssh reports enabled, running, and dev_image, and agrees with the " description="GET /system/ssh reports enabled, running, and dev_image, and agrees with the "
@@ -888,8 +893,8 @@ def ssh_until_reboot(ctx):
# ---------------------------------------------------------------- cloud # ---------------------------------------------------------------- cloud
@test("commission.cloud-disabled-surface", title="Nothing points at the cloud while it is off", @test("setup.cloud-disabled-surface", title="Nothing points at the cloud while it is off",
subsystem="commission", kind="auto", mode="grbl", est_min=1, subsystem="setup", kind="auto", mode="grbl", est_min=1,
covers=[("forgectrl", "src/main.c"), ("forgectrl", "src/super.c"), ("forgectrl", "src/settings.*"), covers=[("forgectrl", "src/main.c"), ("forgectrl", "src/super.c"), ("forgectrl", "src/settings.*"),
("forgectrl", "src/wiz.c"), ("forgectrl", "src/hooks.h")], ("forgectrl", "src/wiz.c"), ("forgectrl", "src/hooks.h")],
requires=["forgectrl.settings-bounds"], requires=["forgectrl.settings-bounds"],
@@ -971,8 +976,8 @@ def cloud_disabled_surface(ctx):
# ---------------------------------------------------------- the operator # ---------------------------------------------------------- the operator
@test("commission.factory-return", title="The return to the factory firmware is guarded", @test("setup.factory-return", title="The return to the factory firmware is guarded",
subsystem="commission", kind="auto", est_min=1, subsystem="setup", kind="auto", est_min=1,
covers=[("forgectrl", "src/update.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.js")], covers=[("forgectrl", "src/update.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.js")],
requires=["update.slots-and-signature"], requires=["update.slots-and-signature"],
description="The return itself reboots the machine as a Glowforge, so it never runs from the " description="The return itself reboots the machine as a Glowforge, so it never runs from the "
@@ -1034,8 +1039,8 @@ def cmdlines():
return out return out
@test("commission.machine-name", title="The machine names itself from its MAC address", @test("setup.machine-name", title="The machine names itself from its MAC address",
subsystem="commission", kind="auto", est_min=1, subsystem="setup", kind="auto", est_min=1,
requires=["forgectrl.auth"], requires=["forgectrl.auth"],
description="The machine calls itself forgefirm-<xxxx>, where xxxx is the last four hex " description="The machine calls itself forgefirm-<xxxx>, where xxxx is the last four hex "
"digits of its WiFi MAC address, so two machines on one network answer to " "digits of its WiFi MAC address, so two machines on one network answer to "
@@ -1213,12 +1218,12 @@ def finished_first_run(ctx):
"the controller did not come back after the setup: %s", m) "the controller did not come back after the setup: %s", m)
_FIRST_RUN_REQUIRES = ["commission.gate-blocks-controllers", "commission.advisories-rehash"] _FIRST_RUN_REQUIRES = ["setup.gate-blocks-controllers", "setup.advisories-rehash"]
@test("commission.first-run-flow", title="The first run, driven as the page drives it", subsystem="commission", @test("setup.first-run-flow", title="The first run, driven as the page drives it", subsystem="setup",
kind="operator", hardware="takeover", est_min=3, kind="operator", hardware="takeover", est_min=3,
covers=[("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"), ("forgectrl", "src/users.*"), covers=[("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"), ("forgectrl", "src/users.*"),
("forgectrl", "src/session.*"), ("forgectrl", "src/button.*"), ("forgectrl", "src/led.*"), ("forgectrl", "src/session.*"), ("forgectrl", "src/button.*"), ("forgectrl", "src/led.*"),
("forgectrl", "src/sheetid.*"), ("forgectrl", "src/advisories.*"), ("forgectrl", "src/hooks.h"), ("forgectrl", "src/sheetid.*"), ("forgectrl", "src/advisories.*"), ("forgectrl", "src/hooks.h"),
("forgectrl", "src/main.c")], ("forgectrl", "src/main.c")],
@@ -1309,11 +1314,11 @@ def first_run_flow(ctx):
ctx.log("the run completed: gate open, the controller back") ctx.log("the run completed: gate open, the controller back")
@test("commission.first-run-page", title="The first-run page, walked once", subsystem="commission", @test("setup.first-run-page", title="The first-run page, walked once", subsystem="setup",
kind="operator", hardware="takeover", est_min=6, kind="operator", hardware="takeover", est_min=6,
covers=[("forgectrl", "src/ui/wizard.*"), ("forgectrl", "src/ui/forms.js"), ("forgectrl", "src/ui/md.js"), covers=[("forgectrl", "src/ui/wizard.*"), ("forgectrl", "src/ui/forms.js"), ("forgectrl", "src/ui/md.js"),
("forgectrl", "src/ui/login.*"), ("forgectrl", "src/ui/embed_docs.cmake"), ("forgectrl", "src/tls.*")], ("forgectrl", "src/ui/login.*"), ("forgectrl", "src/ui/embed_docs.cmake"), ("forgectrl", "src/tls.*")],
requires=_FIRST_RUN_REQUIRES + ["commission.first-run-flow"], requires=_FIRST_RUN_REQUIRES + ["setup.first-run-flow"],
actions=["button"], hands=["workstation"], actions=["button"], hands=["workstation"],
steps=["From the workstation open https://<address>/ and accept the self-signed certificate.", steps=["From the workstation open https://<address>/ and accept the self-signed certificate.",
"Click through the setup: scroll each document to its end and accept it (type I " "Click through the setup: scroll each document to its end and accept it (type I "
@@ -1328,7 +1333,7 @@ def first_run_flow(ctx):
"clicks through (the documents accepted, the press step with the LEDs " "clicks through (the documents accepted, the press step with the LEDs "
"breathing teal, the account created, the run complete, the gate open, the " "breathing teal, the account created, the run complete, the gate open, the "
"controller back). The operator confirms the panel opened. Everything the run " "controller back). The operator confirms the panel opened. Everything the run "
"wrote is restored as commission.first-run-flow restores it.") "wrote is restored as setup.first-run-flow restores it.")
def first_run_page(ctx): def first_run_page(ctx):
fc = ctx.forgectrl fc = ctx.forgectrl
ev = ctx.evidence ev = ctx.evidence
@@ -1364,8 +1369,8 @@ def first_run_page(ctx):
ctx.confirm("Did the control panel open after Finish?") ctx.confirm("Did the control panel open after Finish?")
@test("commission.cert-page", title="The certificate is checkable before the warning", @test("setup.cert-page", title="The certificate is checkable before the warning",
subsystem="commission", kind="auto", est_min=1, subsystem="setup", kind="auto", est_min=1,
covers=[("forgectrl", "src/tls.*"), ("forgectrl", "src/main.c"), covers=[("forgectrl", "src/tls.*"), ("forgectrl", "src/main.c"),
("forgectrl", "src/ui/wizard.html"), ("forgectrl", "src/ui/wizard.js")], ("forgectrl", "src/ui/wizard.html"), ("forgectrl", "src/ui/wizard.js")],
requires=["forgectrl.auth"], requires=["forgectrl.auth"],
@@ -1423,9 +1428,9 @@ def flag_of(w, level, wid):
return None return None
@test("commission.what-changed", title="A replaced part asks for its checks again", @test("setup.what-changed", title="A replaced part asks for its checks again",
subsystem="commission", kind="auto", est_min=2, hardware="takeover", subsystem="setup", kind="auto", est_min=2, hardware="takeover",
covers=[("forgectrl", "src/commission.*"), ("forgectrl", "src/wiz.*"), ("forgectrl", "src/main.c"), covers=[("forgectrl", "src/setup.*"), ("forgectrl", "src/wiz.*"), ("forgectrl", "src/main.c"),
("forgectrl", "src/ui/panel.js"), ("forgectrl", "src/ui/index.html"), ("forgectrl", "src/ui/panel.js"), ("forgectrl", "src/ui/index.html"),
("forgectrl", "src/ui/help.js")], ("forgectrl", "src/ui/help.js")],
requires=["forgectrl.auth"], requires=["forgectrl.auth"],
@@ -1493,9 +1498,9 @@ def what_changed(ctx):
ctx.check(bool(mode(fc).get("gated")) == gated_before, "the gate does not read as before") ctx.check(bool(mode(fc).get("gated")) == gated_before, "the gate does not read as before")
@test("commission.record-export", title="The record: JSON, the printable page, and the log bundle", @test("setup.record-export", title="The record: JSON, the printable page, and the log bundle",
subsystem="commission", kind="auto", est_min=2, subsystem="setup", kind="auto", est_min=2,
covers=[("forgectrl", "src/commission.*"), ("forgectrl", "src/recordhtml.*"), ("forgectrl", "src/wiz.*"), covers=[("forgectrl", "src/setup.*"), ("forgectrl", "src/recordhtml.*"), ("forgectrl", "src/wiz.*"),
("forgectrl", "src/logs.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/ui/index.html"), ("forgectrl", "src/logs.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/ui/index.html"),
("forgectrl", "src/ui/panel.js"), ("forgectrl", "src/ui/wizard.html")], ("forgectrl", "src/ui/panel.js"), ("forgectrl", "src/ui/wizard.html")],
requires=["forgectrl.auth", "logs.tree-tail-export"], requires=["forgectrl.auth", "logs.tree-tail-export"],
@@ -1504,7 +1509,7 @@ def what_changed(ctx):
"named after the sheet id; GET /wiz/record.html is a page with no script that " "named after the sheet id; GET /wiz/record.html is a page with no script that "
"carries the sheet id and every completed wizard; without the token and without " "carries the sheet id and every completed wizard; without the token and without "
"a login both are refused (403); and the sanitized log export carries " "a login both are refused (403); and the sanitized log export carries "
"system/commissioning.json, parseable, with the same sheet id and no panel token.") "system/setup.json, parseable, with the same sheet id and no panel token.")
def record_export(ctx): def record_export(ctx):
import gzip import gzip
import io import io
@@ -1544,7 +1549,7 @@ def record_export(ctx):
ctx.check(st == 200 and "text/html" in (hdrs.get("content-type") or ""), "GET /wiz/record.html -> %s %s", ctx.check(st == 200 and "text/html" in (hdrs.get("content-type") or ""), "GET /wiz/record.html -> %s %s",
st, hdrs.get("content-type")) st, hdrs.get("content-type"))
ctx.check("<script" not in page.lower(), "the printable page carries a script") ctx.check("<script" not in page.lower(), "the printable page carries a script")
ctx.check(sid in page and "commissioning record" in page, "the page lacks the sheet id or its title") ctx.check(sid in page and "setup record" in page, "the page lacks the sheet id or its title")
titles = {z["id"]: z["title"] for z in w.get("wizards") or []} titles = {z["id"]: z["title"] for z in w.get("wizards") or []}
missing = [d for d in done if titles.get(d) and ("<h3>" + titles[d]) not in page] missing = [d for d in done if titles.get(d) and ("<h3>" + titles[d]) not in page]
ev["missing_on_page"] = missing ev["missing_on_page"] = missing
@@ -1565,11 +1570,11 @@ def record_export(ctx):
try: try:
tf = tarfile.open(fileobj=io.BytesIO(gzip.decompress(data))) tf = tarfile.open(fileobj=io.BytesIO(gzip.decompress(data)))
for m in tf.getmembers(): for m in tf.getmembers():
if m.isfile() and m.name.endswith("system/commissioning.json"): if m.isfile() and m.name.endswith("system/setup.json"):
member = tf.extractfile(m).read() member = tf.extractfile(m).read()
except (OSError, tarfile.TarError, EOFError) as e: except (OSError, tarfile.TarError, EOFError) as e:
ctx.fail("export is not a readable tar.gz: %s", e) ctx.fail("export is not a readable tar.gz: %s", e)
ctx.check(member is not None, "the bundle lacks system/commissioning.json") ctx.check(member is not None, "the bundle lacks system/setup.json")
if member is not None: if member is not None:
try: try:
inside = json.loads(member.decode("utf-8")) inside = json.loads(member.decode("utf-8"))
@@ -1587,12 +1592,12 @@ def record_export(ctx):
ctx.log("record %d wizards; page %d bytes; bundle carries the record", len(done), ev["page_bytes"]) ctx.log("record %d wizards; page %d bytes; bundle carries the record", len(done), ev["page_bytes"])
@test("commission.mirror", title="One browser drives a check; a second one follows", @test("setup.mirror", title="One browser drives a check; a second one follows",
subsystem="commission", kind="auto", est_min=2, hardware="takeover", subsystem="setup", kind="auto", est_min=2, hardware="takeover",
covers=[("forgectrl", "src/wizdark.*"), ("forgectrl", "src/wizcalc.*"), ("forgectrl", "src/wiz.*"), covers=[("forgectrl", "src/wizdark.*"), ("forgectrl", "src/wizcalc.*"), ("forgectrl", "src/wiz.*"),
("forgectrl", "src/session.*"), ("forgectrl", "src/ui/wizard.js"), ("forgectrl", "src/session.*"), ("forgectrl", "src/ui/wizard.js"),
("forgectrl", "src/ui/wizard.html"), ("forgectrl", "src/ui/wizard.css")], ("forgectrl", "src/ui/wizard.html"), ("forgectrl", "src/ui/wizard.css")],
requires=["forgectrl.auth", "commission.account-login", "commission.check-sensors"], requires=["forgectrl.auth", "setup.account-login", "setup.check-sensors"],
description="With a temporary account (made and removed as the login test does) and two " description="With a temporary account (made and removed as the login test does) and two "
"login sessions: the first session starts the sensors check; GET /wiz/dark " "login sessions: the first session starts the sensors check; GET /wiz/dark "
"reports the run as owned and its own to the first session, not to the second, " "reports the run as owned and its own to the first session, not to the second, "
@@ -1,4 +1,9 @@
"""commission.check-* - the setup's checks (the dark wizards), driven the # Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""setup.check-* - the setup's checks (the dark wizards), driven the
way the page drives them: POST /wiz/<id>/start, GET /wiz/dark polled, way the page drives them: POST /wiz/<id>/start, GET /wiz/dark polled,
the prompts answered from here (the bench fixture works the lid and the the prompts answered from here (the bench fixture works the lid and the
button; a confirmation is answered yes once the snapshot exists), the button; a confirmation is answered yes once the snapshot exists), the
@@ -12,7 +17,7 @@ import time
from ..catalog import test from ..catalog import test
from .. import hw from .. import hw
from ..baseline import read_position from ..baseline import read_position
from .commission import wiz, Restore # noqa: F401 - Restore is re-exported for the sheet from .setup import wiz, Restore # noqa: F401 - Restore is re-exported for the sheet
POLL_S = 1.0 POLL_S = 1.0
# What the machine's own press prompt says, used when it sends no text. # What the machine's own press prompt says, used when it sends no text.
@@ -22,7 +27,7 @@ PRESS_TEXT = "The button is lit white. Press it now: the laser fires after your
# (74) for the first seconds and settles near 630. # (74) for the first seconds and settles near 630.
PURGE_SPINUP_S = 30 PURGE_SPINUP_S = 30
DARK_COVERS = [("forgectrl", "src/wizdark.*"), ("forgectrl", "src/wizcalc.*"), DARK_COVERS = [("forgectrl", "src/wizdark.*"), ("forgectrl", "src/wizcalc.*"),
("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"), ("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"),
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*")] ("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*")]
@@ -113,8 +118,8 @@ def run_check(ctx, wid, on_prompt, timeout_s):
return last return last
@test("commission.check-switches", title="The switches check follows the lid and the button", @test("setup.check-switches", title="The switches check follows the lid and the button",
subsystem="commission", kind="operator", est_min=3, subsystem="setup", kind="operator", est_min=3,
covers=DARK_COVERS + [("forgectrl", "src/status.c")], covers=DARK_COVERS + [("forgectrl", "src/status.c")],
requires=["forgectrl.auth"], actions=["lid", "button"], requires=["forgectrl.auth"], actions=["lid", "button"],
description="POST /wiz/switches/start; the check asks for the lid to open and close and for " description="POST /wiz/switches/start; the check asks for the lid to open and close and for "
@@ -148,30 +153,40 @@ def check_switches(ctx):
ctx.log("switches: %s", json.dumps(r)) ctx.log("switches: %s", json.dumps(r))
@test("commission.check-sensors", title="The sensors check reads a plausible machine at rest", @test("setup.check-sensors", title="The sensors check reads a plausible machine at rest",
subsystem="commission", kind="auto", est_min=2, subsystem="setup", kind="auto", est_min=2,
covers=DARK_COVERS + [("forgectrl", "src/status.c"), ("forgectrl", "src/accel.c"), covers=DARK_COVERS + [("forgectrl", "src/status.c"), ("forgectrl", "src/accel.c"),
("forgectrl", "src/cool.c")], ("forgectrl", "src/cool.c")],
requires=["forgectrl.auth"], requires=["forgectrl.auth"],
description="POST /wiz/sensors/start; ten seconds of readings, then the room-temperature " description="POST /wiz/sensors/start; ten seconds of readings and no question asked. The "
"prompt, answered Skip so no offset is written. The result carries both coolant " "result carries both coolant temperatures, the chassis and SoC, the lid IR "
"temperatures, the chassis and SoC, the lid IR maxima, the accelerometer event " "maxima, the accelerometer event count, the supply power-good, the HV current, "
"count, the supply power-good, the HV current, and the idle fan speeds; the " "and the idle fan speeds; the record carries sensors at version 1 and the "
"record carries sensors at version 1 and cool_temp_offset_c reads as before.") "settings read as before (the check writes none).")
def check_sensors(ctx): def check_sensors(ctx):
with Restore(ctx, ["cool_temp_offset_c"]): fc = ctx.forgectrl
last = run_check(ctx, "sensors", lambda p: "Skip" if p.get("id") == "room-temp" else None, 120) ev = ctx.evidence
before = fc.settings() or {}
ev["settings_before"] = dict(before)
def on_prompt(p):
ctx.fail("the sensors check asked a question: %s", p)
last = run_check(ctx, "sensors", on_prompt, 120)
r = last.get("result") or {} r = last.get("result") or {}
for k in ("coolant_down_c", "coolant_up_c", "lid_ir_max", "laser_pgood", "hv_current_max", for k in ("coolant_down_c", "coolant_up_c", "lid_ir_max", "laser_pgood", "hv_current_max",
"exhaust_rpm_idle", "intake_rpm_idle"): "exhaust_rpm_idle", "intake_rpm_idle"):
ctx.check(k in r, "the result lacks %s: %s", k, r) ctx.check(k in r, "the result lacks %s: %s", k, r)
ctx.check(r.get("laser_pgood") == 1, "power-good read %s", r.get("laser_pgood")) ctx.check(r.get("laser_pgood") == 1, "power-good read %s", r.get("laser_pgood"))
after = fc.settings() or {}
changed = {k: (before.get(k), after.get(k)) for k in set(before) | set(after)
if before.get(k) != after.get(k)}
ctx.check(not changed, "the sensors check wrote a setting: %s", changed)
ctx.log("sensors: coolant %s/%s C, IR %s, HV %s", r.get("coolant_down_c"), r.get("coolant_up_c"), ctx.log("sensors: coolant %s/%s C, IR %s, HV %s", r.get("coolant_down_c"), r.get("coolant_up_c"),
r.get("lid_ir_max"), r.get("hv_current_max")) r.get("lid_ir_max"), r.get("hv_current_max"))
@test("commission.check-airflow", title="The airflow check measures the fans and sets the floors", @test("setup.check-airflow", title="The airflow check measures the fans and sets the floors",
subsystem="commission", kind="auto", hardware="takeover", est_min=3, subsystem="setup", kind="auto", hardware="takeover", est_min=3,
covers=DARK_COVERS + [("forgectrl", "src/cool.c"), ("forgectrl", "src/airflow.*"), covers=DARK_COVERS + [("forgectrl", "src/cool.c"), ("forgectrl", "src/airflow.*"),
("forgectrl", "src/gates.c"), ("forgectrl", "src/super.c")], ("forgectrl", "src/gates.c"), ("forgectrl", "src/super.c")],
requires=["forgectrl.auth", "cooling.fan-gate-trips"], requires=["forgectrl.auth", "cooling.fan-gate-trips"],
@@ -229,8 +244,8 @@ def check_airflow(ctx):
ctx.check(ok is not None, "the controller did not come back after the check") ctx.check(ok is not None, "the controller did not come back after the check")
@test("commission.check-cameras", title="The cameras check captures both cameras", @test("setup.check-cameras", title="The cameras check captures both cameras",
subsystem="commission", kind="operator", est_min=2, subsystem="setup", kind="operator", est_min=2,
covers=DARK_COVERS + [("forgectrl", "src/cam.c")], covers=DARK_COVERS + [("forgectrl", "src/cam.c")],
requires=["forgectrl.auth", "camera.snapshot"], actions=["lid"], requires=["forgectrl.auth", "camera.snapshot"], actions=["lid"],
description="POST /wiz/cameras/start with the lid closed: a lid snapshot, the question, a " description="POST /wiz/cameras/start with the lid closed: a lid snapshot, the question, a "
@@ -264,8 +279,8 @@ def check_cameras(ctx):
shots.get("lid", {}).get("bytes", 0), shots.get("head", {}).get("bytes", 0)) shots.get("lid", {}).get("bytes", 0), shots.get("head", {}).get("bytes", 0))
@test("commission.check-motion", title="The motion check proves the rail, the lens reference, and the jogs", @test("setup.check-motion", title="The motion check proves the rail, the lens reference, and the jogs",
subsystem="commission", kind="auto", hardware="takeover", est_min=5, subsystem="setup", kind="auto", hardware="takeover", est_min=5,
covers=DARK_COVERS + [("forgectrl", "src/super.c"), ("forgectrl", "src/liveness.c"), covers=DARK_COVERS + [("forgectrl", "src/super.c"), ("forgectrl", "src/liveness.c"),
("forgectrl", "src/lenshome.*"), ("forgectrl", "src/lenshome.*"),
("forgectrl", "src/accel.c"), ("forgectrl", "src/cool.c")], ("forgectrl", "src/accel.c"), ("forgectrl", "src/cool.c")],
@@ -305,8 +320,8 @@ def check_motion(ctx):
r.get("rest"), {k: (v.get("p2p_lp_x"), v.get("p2p_lp_y")) for k, v in moves.items()}) r.get("rest"), {k: (v.get("p2p_lp_x"), v.get("p2p_lp_y")) for k, v in moves.items()})
@test("commission.check-flow-verify", title="The flow check runs as a setup check", @test("setup.check-flow-verify", title="The flow check runs as a setup check",
subsystem="commission", kind="auto", hardware="takeover", est_min=5, subsystem="setup", kind="auto", hardware="takeover", est_min=5,
covers=DARK_COVERS + [("forgectrl", "src/diag.c")], covers=DARK_COVERS + [("forgectrl", "src/diag.c")],
requires=["forgectrl.auth", "cooling.aa-offset-calibrate", "cooling.flow-verify"], requires=["forgectrl.auth", "cooling.aa-offset-calibrate", "cooling.flow-verify"],
description="POST /wiz/cooling.flow-verify/start drives the flow-verify diagnostic through the " description="POST /wiz/cooling.flow-verify/start drives the flow-verify diagnostic through the "
@@ -323,8 +338,8 @@ def check_flow_verify(ctx):
r.get("flow_rise"), r.get("noflow_rise"), r.get("thin_margin")) r.get("flow_rise"), r.get("noflow_rise"), r.get("thin_margin"))
@test("commission.cloud-header-capture", title="The cloud header check takes one print's envelope", @test("setup.cloud-header-capture", title="The cloud header check takes one print's envelope",
subsystem="commission", kind="operator", hardware="takeover", mode="grbl", est_min=8, subsystem="setup", kind="operator", hardware="takeover", mode="grbl", est_min=8,
covers=DARK_COVERS + [("forgectrl", "src/super.c"), covers=DARK_COVERS + [("forgectrl", "src/super.c"),
("python3-gfhardware", "gfhardware/machine.py"), ("python3-gfhardware", "gfhardware/machine.py"),
("python3-gfhardware", "forgefirm-app/gfcloud.py"), ("python3-gfhardware", "forgefirm-app/gfcloud.py"),
@@ -1,4 +1,9 @@
"""commission.sheet - the setup's sheet (the live wizards) in one run, # Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""setup.sheet - the setup's sheet (the live wizards) in one run,
driven the way the page drives them: POST /wiz/<id>/start, GET /wiz/dark driven the way the page drives them: POST /wiz/<id>/start, GET /wiz/dark
polled, the prompts answered from here, each burn armed by a press, the polled, the prompts answered from here, each burn armed by a press, the
result judged, and every setting a wizard wrote put back as found. One result judged, and every setting a wizard wrote put back as found. One
@@ -18,13 +23,13 @@ restored. The page is where the real numbers go in.
import re import re
from ..catalog import test from ..catalog import test
from .commission_dark import run_check, Restore from .setup_dark import run_check, Restore
SHEET_COVERS = [("forgectrl", "src/wizlive.*"), ("forgectrl", "src/wizrun.h"), SHEET_COVERS = [("forgectrl", "src/wizlive.*"), ("forgectrl", "src/wizrun.h"),
("forgectrl", "src/sheet.*"), ("forgectrl", "src/lens.*"), ("forgectrl", "src/sheet.*"),
("forgectrl", "src/font_hershey.*"), ("forgectrl", "src/jobstream.*"), ("forgectrl", "src/font_hershey.*"), ("forgectrl", "src/jobstream.*"),
("forgectrl", "src/curverec.*"), ("forgectrl", "src/wizdark.*"), ("forgectrl", "src/curverec.*"), ("forgectrl", "src/wizdark.*"),
("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"), ("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"),
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*"),
("forgectrl", "src/super.c"), ("forgectrl", "src/super.c"),
("forgectrl", "src/status.c"), ("forgectrl", "src/cool.c"), ("forgectrl", "src/status.c"), ("forgectrl", "src/cool.c"),
@@ -71,6 +76,22 @@ def preview_ok(ctx, wid):
ctx.check(st == 200 and re.search(rb"^M[34] S\d", body or b"", re.M) is not None, ctx.check(st == 200 and re.search(rb"^M[34] S\d", body or b"", re.M) is not None,
"no program for %s (%s)", wid, st) "no program for %s (%s)", wid, st)
ctx.evidence["program_lines"] = body.count(b"\n") ctx.evidence["program_lines"] = body.count(b"\n")
# Every Z the program commands is a height the head reaches in the
# window the machine holds now: the focus card's own numbers once it
# has them, and the conservative fallback on a head whose travel is
# not known yet. The controller refuses a Z outside it with ALARM:2,
# and it refuses it while the body is still burning, so the card dies
# where the parser is and not where the bad line is.
lens = (fc.status() or {}).get("lens") or {}
lo, hi = lens.get("reach_min"), lens.get("reach_max")
zs = [float(m) for m in re.findall(rb"^G0 Z(-?[0-9.]+)", body or b"", re.M)]
ctx.evidence.setdefault("program_z", {})[wid] = {
"z": zs, "reach": [lo, hi], "stops_found": lens.get("stops_found")}
ctx.check(lo is not None and hi is not None, "/status carries no lens reach: %s", lens)
if lo is not None and hi is not None:
out = [z for z in zs if z < lo - 0.01 or z > hi + 0.01]
ctx.check(not out, "%s commands Z outside the lens window %.2f..%.2f: %s",
wid, lo, hi, out)
def emission_ok(ctx, last): def emission_ok(ctx, last):
@@ -89,6 +110,61 @@ def file_card(ctx, wid):
ev.setdefault("cards", {})[wid] = {k: ev.pop(k) for k in CARD_EVIDENCE if k in ev} ev.setdefault("cards", {})[wid] = {k: ev.pop(k) for k in CARD_EVIDENCE if k in ev}
# The lens settings: the head's numbers, which a fresh machine does not
# have. Cleared before the cards so the run is a fresh machine's.
LENS_SETTINGS = CARD_SETTINGS[:3]
FALLBACK_WINDOW = (10, 12)
def fresh_lens(ctx):
"""The lens as a fresh machine has it: the head's numbers unknown, so
the frame runs in the fallback window and the focus card has to find
the stops and write them before its ladder burns. The machine's own
numbers come back with the restore at the end of the run."""
fc = ctx.forgectrl
for k in LENS_SETTINGS:
st, _ = fc.post("/settings", params={k: ""})
ctx.check(st == 200, "clearing %s -> %s", k, st)
lens = (fc.status() or {}).get("lens") or {}
ctx.evidence["lens_fresh"] = lens
ctx.check(lens.get("stops_found") is False and (lens.get("below"), lens.get("above")) == FALLBACK_WINDOW,
"the lens window is not the fallback after the clear: %s", lens)
def focus_window_ok(ctx):
"""The window the focus card burned its ladder in is the one it wrote
before its controller started, and the one the machine holds now: the
result's window is the stops it found (or the fallback), the settings
hold that window, every ladder height lies in its reach, and the
program served now agrees with /status."""
fc = ctx.forgectrl
r = ((ctx.evidence.get("cards") or {}).get("laser.focus") or {}).get("result") or {}
w, stops = r.get("window") or {}, r.get("stops") or {}
s = fc.settings() or {}
held = (str(s.get("lens_stop_below_steps")), str(s.get("lens_stop_above_steps")))
ctx.check(held == (str(w.get("below")), str(w.get("above"))),
"the settings hold the window %s, the ladder ran in %s/%s", held, w.get("below"), w.get("above"))
if stops.get("found"):
ctx.check((w.get("below"), w.get("above")) == (stops.get("below"), stops.get("above")),
"the ladder's window %s is not the stops found %s", w, stops)
else:
ctx.check((w.get("below"), w.get("above")) == FALLBACK_WINDOW,
"stops not found (%s), but the ladder's window is %s", stops.get("why"), w)
zs = r.get("ladder_z") or []
lo, hi = w.get("reach_min"), w.get("reach_max")
out = [z for z in zs if lo is None or hi is None or z < lo - 0.01 or z > hi + 0.01]
ctx.check(len(zs) == 12 and not out, "ladder heights outside the reach %s..%s: %s", lo, hi, out or zs)
ev = ctx.evidence
before = dict(ev.get("program_z") or {}).get("laser.focus")
preview_ok(ctx, "laser.focus")
ev["focus_window"] = {"window": w, "stops": stops, "ladder_z": zs,
"settings": {k: s.get(k) for k in LENS_SETTINGS},
"program_z_before": before,
"program_z_after": (ev.get("program_z") or {}).get("laser.focus"),
"preview_bytes": ev.pop("preview_bytes", None),
"program_lines": ev.pop("program_lines", None)}
def thickness_answer(ctx): def thickness_answer(ctx):
"""The thickness the placement is answered with, in the machine's """The thickness the placement is answered with, in the machine's
units (the number prompts read ui_units), and the millimeters the units (the number prompts read ui_units), and the millimeters the
@@ -151,10 +227,10 @@ def burn(ctx, wid, answers, want):
file_card(ctx, wid) file_card(ctx, wid)
@test("commission.sheet", title="The commissioning sheet: the placement, the frame, and the five cards", @test("setup.sheet", title="The setup sheet: the placement, the frame, and the five cards",
subsystem="commission", kind="live", hardware="takeover", mode="grbl", est_min=35, subsystem="setup", kind="live", hardware="takeover", mode="grbl", est_min=35,
covers=SHEET_COVERS, covers=SHEET_COVERS,
requires=["forgectrl.auth", "commission.check-motion", "laser.emission-witness", "cooling.flow-verify"], requires=["forgectrl.auth", "setup.check-motion", "laser.emission-witness", "cooling.flow-verify"],
actions=["button"], hands=["scrap"], actions=["button"], hands=["scrap"],
steps=["A piece of wood at least 200 x 150 mm (8 x 6 in) on the bed, pushed as far left as " steps=["A piece of wood at least 200 x 150 mm (8 x 6 in) on the bed, pushed as far left as "
"it goes with its top edge at the top of the cut area (the head's home corner); " "it goes with its top edge at the top of the cut area (the head's home corner); "
@@ -167,15 +243,22 @@ def burn(ctx, wid, answers, want):
"Nothing else: the test answers every prompt itself and puts back every setting the " "Nothing else: the test answers every prompt itself and puts back every setting the "
"cards write. It runs about half an hour, mostly the coolant settle and the dark " "cards write. It runs about half an hour, mostly the coolant settle and the dark "
"tails."], "tails."],
description="One run over the sheet's seven wizards on one piece of wood. " description="One run over the sheet's seven wizards on one piece of wood, as a fresh "
"machine runs them: the three lens settings are cleared first, so the frame "
"runs in the fallback window and the focus card has to find the stops and write "
"them before its ladder burns. "
"POST /wiz/sheet.place/start references the lens, takes the controller in " "POST /wiz/sheet.place/start references the lens, takes the controller in "
"loopback posture, jogs +X 10 and -X 10, and sets the origin at the head " "loopback posture, jogs +X 10 and -X 10, and sets the origin at the head "
"(Full sheet; the thickness answered in the machine's units, 3.2 mm or " "(Full sheet; the thickness answered in the machine's units, 3.2 mm or "
"0.125 in). Then, each after the press and each with its preview " "0.125 in). Then, each after the press and each with its preview "
"and program served by GET /wiz/sheet.svg and /wiz/sheet.gcode: the frame " "and program served by GET /wiz/sheet.svg and /wiz/sheet.gcode, every Z the "
"program commands inside the reach /status reports: the frame "
"(180 x 130 mm) and the header band at the mark dose; the focus card (the " "(180 x 130 mm) and the header band at the mark dose; the focus card (the "
"lens homed on its bottom stop, the hall edge counted, twelve lines down the " "lens on its hall edge, the stops found by the head accelerometer and written "
"travel; the pick is line 11, the thickness kept); the floor card (twelve " "as the window before the controller starts, twelve lines from the top of the "
"window to the bottom; the pick is line 11, the thickness kept; after it the "
"settings, the result's window, and the served program must hold the window "
"the ladder ran in); the floor card (twelve "
"rungs from 2 to 24 percent with the floor and the curve off for the job; the " "rungs from 2 to 24 percent with the floor and the curve off for the job; the "
"pick is 8, the floor written is 10); the dose card (seven rungs sampled at " "pick is 8, the floor written is 10); the dose card (seven rungs sampled at "
"25 Hz, the curve fit on 20 s of dark); the corner card (five gammas reloaded " "25 Hz, the curve fit on 20 s of dark); the corner card (five gammas reloaded "
@@ -191,6 +274,9 @@ def sheet(ctx):
"each. The bench presses when the button lights.") "each. The bench presses when the button lights.")
place(ctx) place(ctx)
with Restore(ctx, CARD_SETTINGS): with Restore(ctx, CARD_SETTINGS):
fresh_lens(ctx)
for wid, answers, want in CARDS: for wid, answers, want in CARDS:
burn(ctx, wid, answers, want) burn(ctx, wid, answers, want)
ctx.log("PASS: the sheet's seven wizards ran on one piece; settings restored") if wid == "laser.focus":
focus_window_ok(ctx)
ctx.log("PASS: the sheet's seven wizards ran on one piece as a fresh machine's; settings restored")
+80
View File
@@ -1,5 +1,11 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""update.* - the A/B slot inventory and the firmware verification path.""" """update.* - the A/B slot inventory and the firmware verification path."""
import os import os
import re
import tempfile import tempfile
import time import time
import shutil import shutil
@@ -8,6 +14,7 @@ from ..catalog import test
from .. import hw from .. import hw
_UPDATE_COVERS = [("forgectrl", "src/update.c"), ("forgectrl", "src/update.h"), _UPDATE_COVERS = [("forgectrl", "src/update.c"), ("forgectrl", "src/update.h"),
("forgectrl", "src/relcheck.c"), ("forgectrl", "src/relcheck.h"),
("ffboot", "**")] ("ffboot", "**")]
@@ -117,3 +124,76 @@ def slots_and_signature(ctx):
except OSError: except OSError:
pass pass
shutil.rmtree(work, ignore_errors=True) shutil.rmtree(work, ignore_errors=True)
_VERSION_RX = re.compile(r"^v?\d+\.\d+\.\d+")
@test("update.release-check", title="Release check answers from the releases API; the alert dismissal is per release",
subsystem="update", kind="auto", est_min=1,
covers=_UPDATE_COVERS + [("forgectrl", "src/ui/panel.js"), ("forgectrl", "src/ui/index.html")],
requires=["forgectrl.auth"],
description="GET /update/release answers the kept answer of the daily check (available, version, "
"current, new, checked, dismissed). POST /update/check asks the releases API now and "
"answers the same shape; a machine with no route to the API answers 502, which the drill "
"records and steps over. A published release is a v<semver> tag with the firmware "
"file's size and its notes, and `new` follows the version order: a development build "
"sees every release as newer. POST /update/dismiss marks that version dismissed, an "
"empty version undoes it, and a version that is not a tag is refused. The dismissal "
"the machine had before the drill is put back.")
def release_check(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
keys = ("available", "version", "current", "new", "checked", "dismissed", "detail", "bytes")
st, before = fc.get("/update/release")
ctx.log("GET /update/release -> %s %s", st, {k: before.get(k) for k in keys} if isinstance(before, dict) else before)
ctx.check(st == 200 and isinstance(before, dict) and "available" in before and "checked" in before,
"GET /update/release -> %s", st)
ev["before"] = {k: before.get(k) for k in keys}
prior = before.get("version", "") if before.get("dismissed") else ""
st, now = fc.post("/update/check")
ctx.log("POST /update/check -> %s %s", st, {k: now.get(k) for k in keys} if isinstance(now, dict) else now)
if st == 502:
ctx.log("the machine has no route to the releases API; the kept answer stands")
now = before
else:
ctx.check(st == 200 and isinstance(now, dict) and "available" in now, "POST /update/check -> %s", st)
ctx.check(isinstance(now.get("checked"), int) and now["checked"] >= before.get("checked", 0),
"the check did not move `checked`: %s", now.get("checked"))
ev["after_check"] = {k: now.get(k) for k in keys}
if now.get("available"):
ctx.check(_VERSION_RX.match(str(now.get("version", ""))) is not None,
"the release version is not a v<semver> tag: %r", now.get("version"))
ctx.check(isinstance(now.get("bytes"), int) and now["bytes"] > 0,
"the firmware file's size is missing: %r", now.get("bytes"))
ctx.check(isinstance(now.get("notes"), str), "the notes are not a string")
ctx.check(isinstance(now.get("new"), bool), "`new` is not a bool")
if not _VERSION_RX.match(str(now.get("current", ""))):
ctx.check(now.get("new") is True,
"a development build (%r) must see release %s as newer", now.get("current"), now["version"])
else:
ctx.log("no release available: %s", now.get("detail"))
ctx.check(isinstance(now.get("detail"), str) and now["detail"], "an unavailable release names no reason")
try:
st, body = fc.post("/update/dismiss", params={"version": "v0.0.1; rm -rf /"})
ctx.log("POST /update/dismiss version=<not a tag> -> %s %s", st, body)
ctx.check(st == 400, "a version that is not a tag was not refused: %s", st)
tag = now["version"] if now.get("available") else "v0.0.0"
st, body = fc.post("/update/dismiss", params={"version": tag})
ctx.log("POST /update/dismiss version=%s -> %s %s", tag, st,
{k: body.get(k) for k in keys} if isinstance(body, dict) else body)
ctx.check(st == 200 and isinstance(body, dict), "POST /update/dismiss -> %s", st)
if now.get("available"):
ctx.check(body.get("dismissed") is True, "the dismissal of %s was not recorded: %r", tag, body.get("dismissed"))
st, body = fc.post("/update/dismiss", params={"version": ""})
ctx.log("POST /update/dismiss version= -> %s", st)
ctx.check(st == 200 and isinstance(body, dict) and body.get("dismissed") is False,
"the empty version did not undo the dismissal: %s %r", st, body.get("dismissed") if isinstance(body, dict) else body)
finally:
st, body = fc.post("/update/dismiss", params={"version": prior})
ctx.log("dismissal put back to %r -> %s", prior, st)
+94
View File
@@ -206,6 +206,100 @@
border-left: 4px solid var(--og-red); border-left: 4px solid var(--og-red);
} }
/* ---- The release dialog -------------------------------------------- */
#rel-view h3 {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--og-dim);
margin: 12px 0 4px;
}
.relnotes {
max-height: 40vh;
overflow: auto;
border: 1px solid var(--og-line);
border-radius: 6px;
padding: 8px 12px;
margin: 4px 0 8px;
font-size: 13px;
line-height: 1.5;
}
.relnotes h1,
.relnotes h2,
.relnotes h3 {
font-size: 14px;
font-weight: 600;
margin: 10px 0 4px;
}
.relnotes h1:first-child,
.relnotes h2:first-child,
.relnotes h3:first-child {
margin-top: 0;
}
.relnotes p,
.relnotes ul {
margin: 0 0 6px;
}
.relnotes ul {
padding-left: 20px;
}
.relnotes code {
font-size: 12px;
}
.relnotes table {
border-collapse: collapse;
margin: 4px 0 8px;
}
.relnotes th,
.relnotes td {
border: 1px solid var(--og-line);
padding: 2px 8px;
text-align: left;
}
.relwarn {
background: var(--og-warn-bg);
border: 1px solid var(--og-warn);
color: var(--og-warn-txt);
border-radius: 8px;
padding: 10px 12px;
margin: 0 0 12px;
font-size: 14px;
}
.relsteps {
margin: 0 0 12px;
padding-left: 22px;
font-size: 13px;
line-height: 1.7;
}
.relsteps li {
color: var(--og-dim);
}
.relsteps li.cur {
color: var(--og-txt);
font-weight: 600;
}
.relsteps li.done {
color: var(--og-ok);
}
.relsteps li.done::after {
content: ' \2713';
}
.progress {
--bs-progress-bg: var(--og-line-soft);
--bs-progress-bar-bg: var(--og-action);
--bs-progress-height: 10px;
margin: 0 0 8px;
}
#rel-phase {
min-height: 1.4em;
font-size: 12.5px;
margin: 0;
}
#rel-err {
margin: 10px 0 0;
}
/* ---- Chrome: header, tabs, footer ---------------------------------- */ /* ---- Chrome: header, tabs, footer ---------------------------------- */
body { body {
margin: 0; margin: 0;
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""Shared fixtures for the forgetest unit tests: synthetic manifests and """Shared fixtures for the forgetest unit tests: synthetic manifests and
catalog entries that never touch hardware.""" catalog entries that never touch hardware."""
import hashlib import hashlib
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
import copy import copy
import json import json
import unittest import unittest
+136 -17
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The baseline: fixed resting values are restored, preserved values are """The baseline: fixed resting values are restored, preserved values are
handed back, every deviation is a recorded leftover. Runs against a fake handed back, every deviation is a recorded leftover. Runs against a fake
sysfs tree; forgectrl is unreachable (service-side checks skip).""" sysfs tree; forgectrl is unreachable (service-side checks skip)."""
@@ -10,6 +15,11 @@ import unittest
from forgetest import baseline from forgetest import baseline
# The fixed machine tick at the mode an unset xy_microsteps reads as.
# Taken from baseline rather than typed, so a change to the default mode
# moves the expectations with it.
DEFAULT_TICK = dict(baseline.fixed_sysfs())["cnc/step_freq"]
class BaselineTests(unittest.TestCase): class BaselineTests(unittest.TestCase):
def setUp(self): def setUp(self):
@@ -21,8 +31,11 @@ class BaselineTests(unittest.TestCase):
for name in baseline.BUTTON_LEDS + ("lid_led",): for name in baseline.BUTTON_LEDS + ("lid_led",):
os.makedirs(self.leds + name) os.makedirs(self.leds + name)
self._led(name, "0") self._led(name, "0")
# a clean machine # A clean machine at the mode an unset xy_microsteps reads as:
for attr, val in baseline.FIXED_SYSFS + baseline.IDLE_READBACKS: # x/y_mode, step_freq and ramp_rate are the mode's, not the x8
# literals in FIXED_SYSFS, and that is what enforce() compares
# against.
for attr, val in baseline.fixed_sysfs() + baseline.IDLE_READBACKS:
self._attr(attr, val) self._attr(attr, val)
self._attr("cnc/interlock_circuit", "45") self._attr("cnc/interlock_circuit", "45")
self._attr("pic/lid_led", "0") self._attr("pic/lid_led", "0")
@@ -90,7 +103,7 @@ class BaselineTests(unittest.TestCase):
for x in left: for x in left:
self.assertEqual(x.action, "restored", str(x)) self.assertEqual(x.action, "restored", str(x))
self.assertEqual(self._read("cnc/motor_lock"), "0") self.assertEqual(self._read("cnc/motor_lock"), "0")
self.assertEqual(self._read("cnc/step_freq"), "28160") self.assertEqual(self._read("cnc/step_freq"), DEFAULT_TICK)
self.assertEqual(self._read("cnc/streaming"), "0") self.assertEqual(self._read("cnc/streaming"), "0")
self.assertEqual(items["cnc/motor_lock"].found, "15") self.assertEqual(items["cnc/motor_lock"].found, "15")
self.assertEqual(items["cnc/motor_lock"].expected, "0") self.assertEqual(items["cnc/motor_lock"].expected, "0")
@@ -126,6 +139,21 @@ class BaselineTests(unittest.TestCase):
self.assertTrue(items["position"].action.startswith("unrestorable"), items["position"].action) self.assertTrue(items["position"].action.startswith("unrestorable"), items["position"].action)
self.assertEqual(items["position"].found, [1000, 0, 0]) self.assertEqual(items["position"].found, [1000, 0, 0])
def test_the_counters_are_read_at_the_modes_scale(self):
# 6400 steps is 30 mm at x32 (a return within the bound; on the
# host it stops at the missing controller) and 120 mm at x8
# (beyond the bound). Read at the x8 scale, an x32 machine's
# displaced head is never jogged back.
b = self.bl()
cap = b.capture()
self._attr("cnc/x_mode", "32")
self._pos(6400, 0, 0)
items = {x.item: x for x in b.enforce("post", captured=cap)}
self.assertEqual(items["position"].action, "unrestorable: no running GRBL controller")
self._attr("cnc/x_mode", "8")
items = {x.item: x for x in b.enforce("post", captured=cap)}
self.assertEqual(items["position"].action, "unrestorable: 120.0/0.0 mm exceeds 100 mm")
def test_a_held_controller_is_reset_before_the_return_jog(self): def test_a_held_controller_is_reset_before_the_return_jog(self):
# a pause test that failed while held leaves the controller in # a pause test that failed while held leaves the controller in
# Hold, which refuses a jog: the baseline resets out of it first # Hold, which refuses a jog: the baseline resets out of it first
@@ -138,14 +166,16 @@ class BaselineTests(unittest.TestCase):
dev.on_command = lambda line: self._pos(0, 0, 0) if line.startswith("$J=") else None dev.on_command = lambda line: self._pos(0, 0, 0) if line.startswith("$J=") else None
b = self.bl() b = self.bl()
cap = b.capture() cap = b.capture()
self._pos(221, 0, 0) # 4.144 mm into the held move # 4.144 mm into the held move, in the steps of the mode in force
held_mm = 4.144
self._pos(round(held_mm * baseline.xy_steps_per_mm(baseline.XY_MODE_DEFAULT)), 0, 0)
left = b.enforce("post", captured=cap) left = b.enforce("post", captured=cap)
items = {x.item: x for x in left} items = {x.item: x for x in left}
self.assertTrue(items["position"].action.startswith("restored"), items["position"].action) self.assertTrue(items["position"].action.startswith("restored"), items["position"].action)
self.assertEqual(dev.sent[0], "^X") self.assertEqual(dev.sent[0], "^X")
jogs = [l for l in dev.sent if l.startswith("$J=")] jogs = [l for l in dev.sent if l.startswith("$J=")]
self.assertEqual(len(jogs), 1) self.assertEqual(len(jogs), 1)
self.assertIn("X-4.144", jogs[0]) self.assertIn("X-%.3f" % held_mm, jogs[0])
self.assertTrue(any("reset out of Hold:0" in l for l in self.lines), self.lines) self.assertTrue(any("reset out of Hold:0" in l for l in self.lines), self.lines)
finally: finally:
dev.stop() dev.stop()
@@ -230,7 +260,7 @@ class BaselineTests(unittest.TestCase):
def test_fixed_constants_checked_against_a_dump(self): def test_fixed_constants_checked_against_a_dump(self):
ref = {"sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000"}} ref = {"sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000"}}
diffs = baseline.check_fixed_against(ref, self.lines.append) diffs = baseline.check_fixed_against(ref, self.lines.append)
self.assertEqual(diffs, ["cnc/step_freq: boot=10000 constant=28160"]) self.assertEqual(diffs, ["cnc/step_freq: boot=10000 constant=%s" % DEFAULT_TICK])
# -- the reference is taken after the controller applied its config ----- # -- the reference is taken after the controller applied its config -----
@@ -249,7 +279,8 @@ class BaselineTests(unittest.TestCase):
def sleep(_s): def sleep(_s):
calls["n"] += 1 calls["n"] += 1
if calls["n"] == 3: # the controller's init writes land if calls["n"] == 3: # the controller's init writes land
for attr, val in baseline.CONFIGURED_MARKERS: # at the mode wait_controller_configured watches by default
for attr, val in baseline.configured_markers():
self._attr(attr, val) self._attr(attr, val)
ok = baseline.wait_controller_configured( ok = baseline.wait_controller_configured(
self.lines.append, {"controller": "running", "mode": "grbl", "motion": "verified"}, self.lines.append, {"controller": "running", "mode": "grbl", "motion": "verified"},
@@ -287,11 +318,15 @@ class BaselineTests(unittest.TestCase):
def test_preconfig_reference_is_recognized(self): def test_preconfig_reference_is_recognized(self):
self.assertTrue(baseline.reference_preconfig( self.assertTrue(baseline.reference_preconfig(
{"sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000", "cnc/y_mode": "1"}})) {"sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000", "cnc/y_mode": "1"}}))
# The markers are read at the reference's own mode, so a dump taken
# on an x8 machine carries the setting that says so.
self.assertFalse(baseline.reference_preconfig( self.assertFalse(baseline.reference_preconfig(
{"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "28160", "cnc/y_mode": "8"}})) {"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "28160", "cnc/y_mode": "8"},
"forgectrl": {"/settings": {"xy_microsteps": "8"}}}))
# a genuinely different single constant is a machine fact, not pre-config # a genuinely different single constant is a machine fact, not pre-config
self.assertFalse(baseline.reference_preconfig( self.assertFalse(baseline.reference_preconfig(
{"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "10000", "cnc/y_mode": "8"}})) {"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "10000", "cnc/y_mode": "8"},
"forgectrl": {"/settings": {"xy_microsteps": "8"}}}))
self.assertFalse(baseline.reference_preconfig({"sysfs": {}})) self.assertFalse(baseline.reference_preconfig({"sysfs": {}}))
# -- the XY microstep mode drives the fixed values ------------------------ # -- the XY microstep mode drives the fixed values ------------------------
@@ -310,16 +345,18 @@ class BaselineTests(unittest.TestCase):
self.assertEqual(x32["cnc/x_decay"], x8["cnc/x_decay"]) self.assertEqual(x32["cnc/x_decay"], x8["cnc/x_decay"])
def test_the_mode_is_read_from_the_settings_with_a_default(self): def test_the_mode_is_read_from_the_settings_with_a_default(self):
# An unset or unusable key reads as the driver's default, x32.
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "16"}), 16) self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "16"}), 16)
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "32"}), 32) self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "32"}), 32)
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": ""}), 8) self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "8"}), 8)
self.assertEqual(baseline.xy_mode_of({}), 8) self.assertEqual(baseline.xy_mode_of({"xy_microsteps": ""}), 32)
self.assertEqual(baseline.xy_mode_of(None), 8) self.assertEqual(baseline.xy_mode_of({}), 32)
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "24"}), 8) self.assertEqual(baseline.xy_mode_of(None), 32)
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "abc"}), 8) self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "24"}), 32)
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "abc"}), 32)
self.assertEqual(baseline.ref_xy_mode({"forgectrl": {"/settings": {"xy_microsteps": "16"}}}), 16) self.assertEqual(baseline.ref_xy_mode({"forgectrl": {"/settings": {"xy_microsteps": "16"}}}), 16)
self.assertEqual(baseline.ref_xy_mode({"forgectrl": {"/settings": None}}), 8) self.assertEqual(baseline.ref_xy_mode({"forgectrl": {"/settings": None}}), 32)
self.assertEqual(baseline.ref_xy_mode({}), 8) self.assertEqual(baseline.ref_xy_mode({}), 32)
def test_fixed_constants_are_checked_at_the_reference_mode(self): def test_fixed_constants_are_checked_at_the_reference_mode(self):
ref = {"sysfs": {"cnc/x_mode": "16", "cnc/step_freq": "56320", "cnc/ramp_rate": "250000"}, ref = {"sysfs": {"cnc/x_mode": "16", "cnc/step_freq": "56320", "cnc/ramp_rate": "250000"},
@@ -400,6 +437,81 @@ class TransientNotLeftoverTests(BaselineTests):
self.assertEqual(items[0].found, "1014") self.assertEqual(items[0].found, "1014")
class CoolPublishTests(unittest.TestCase):
"""The cooling engine publishes once a tick. A status read inside the
tick after a run ended is the engine's last word on the run, not the
machine as the run left it. Found on the bench reference: a diagnostic
that finished clean (cooling.aa-offset-calibrate) and a fail tier that
did its work (cooling.fail-tier-stop) both failed their hand-back on a
hold the engine's next tick cleared."""
IDLE = {"phase": "idle", "armed": False, "hold": False}
def setUp(self):
self.lines = []
def bl(self, statuses):
"""A baseline whose /cool/status answers play in order (the last
one repeats) and whose waits count reads, not seconds: three for
the engine's next tick, as the real wait makes, and enough for the
cooldown to let a slow script run out."""
b = baseline.Baseline(self.lines.append)
seq = list(statuses)
b.fc_get = lambda path: (200, dict(seq.pop(0) if len(seq) > 1 else seq[0]))
def wait(what, pred, timeout):
for i in range(3 if what == "cool publish" else 50):
if pred():
return float(i)
return None
b._wait = wait
b.stood_down = []
b.stand_down = b.stood_down.append
return b
def test_a_diagnostic_hold_the_next_tick_clears_is_not_a_leftover(self):
left = []
b = self.bl([{"phase": "diag", "armed": False, "hold": True}, self.IDLE])
b._cool_side(left)
self.assertEqual(left, [])
self.assertTrue(any("last word on the run" in ln for ln in self.lines), self.lines)
def test_a_fail_tier_hold_clears_into_the_engines_own_post_job_phase(self):
left = []
b = self.bl([{"phase": "run", "armed": False, "hold": True},
{"phase": "smoke", "armed": False, "hold": False},
{"phase": "smoke", "armed": False, "hold": False}, self.IDLE])
b._cool_side(left)
self.assertEqual(left, [])
self.assertTrue(any("not a leftover" in ln for ln in self.lines), self.lines)
def test_a_hold_that_outlives_the_tick_is_the_runs_doing(self):
left = []
b = self.bl([{"phase": "run", "armed": True, "hold": True}])
b._cool_side(left)
self.assertEqual([x.item for x in left], ["cool"])
self.assertEqual(left[0].found, "run/armed=True/hold=True")
self.assertTrue(left[0].action.startswith("failed: still"), left[0].action)
self.assertEqual(len(b.stood_down), 1)
def test_a_hold_that_clears_only_later_is_still_recorded(self):
left = []
held = {"phase": "run", "armed": False, "hold": True}
b = self.bl([held] * 10 + [self.IDLE])
b._cool_side(left)
self.assertEqual([x.item for x in left], ["cool"])
self.assertEqual(left[0].action, "waited")
self.assertEqual(b.stood_down, [])
def test_an_idle_engine_and_a_silent_daemon_leave_nothing(self):
left = []
self.bl([self.IDLE])._cool_side(left)
b = baseline.Baseline(self.lines.append)
b.fc_get = lambda path: (None, None)
b._cool_side(left)
self.assertEqual(left, [])
class CloudOwnedSysfsTests(unittest.TestCase): class CloudOwnedSysfsTests(unittest.TestCase):
"""In cloud mode the cloud client configures the machine from the """In cloud mode the cloud client configures the machine from the
pulse header it is playing, the lens included: z_mode comes from pulse header it is playing, the lens included: z_mode comes from
@@ -434,6 +546,13 @@ class PositionDeadbandTests(unittest.TestCase):
self.assertFalse(baseline.position_quantized([0, 0, 0], [over, 0, 0])) self.assertFalse(baseline.position_quantized([0, 0, 0], [over, 0, 0]))
self.assertFalse(baseline.position_quantized([0, 0, 0], [0, over, 0])) self.assertFalse(baseline.position_quantized([0, 0, 0], [0, over, 0]))
def test_the_dead_band_scales_with_the_counters(self):
# the same step count is a quarter of the distance at x32
over = int(baseline.POSITION_DEADBAND_MM * baseline.XY_STEPS_PER_MM) + 2
x32 = baseline.XY_STEPS_PER_MM_OF[32]
self.assertTrue(baseline.position_quantized([0, 0, 0], [over, 0, 0], x32))
self.assertFalse(baseline.position_quantized([0, 0, 0], [4 * over, 0, 0], x32))
def test_z_is_exact_because_the_return_never_moves_it(self): def test_z_is_exact_because_the_return_never_moves_it(self):
self.assertFalse(baseline.position_quantized([0, 0, 0], [0, 0, 1])) self.assertFalse(baseline.position_quantized([0, 0, 0], [0, 0, 1]))
@@ -549,7 +668,7 @@ class BaselineModeTests(BaselineTests):
self._attr("pic/lid_led", "77") self._attr("pic/lid_led", "77")
left = self.bl().enforce("pre", captured=None) left = self.bl().enforce("pre", captured=None)
self.assertEqual(sorted(x.item for x in left), ["cnc/step_freq", "pic/lid_led"]) self.assertEqual(sorted(x.item for x in left), ["cnc/step_freq", "pic/lid_led"])
self.assertEqual(self._read("cnc/step_freq"), "28160") self.assertEqual(self._read("cnc/step_freq"), DEFAULT_TICK)
self.assertEqual(self._read("pic/lid_led"), "236") self.assertEqual(self._read("pic/lid_led"), "236")
def test_cloud_mode_leaves_the_clients_config_lamp_and_counters(self): def test_cloud_mode_leaves_the_clients_config_lamp_and_counters(self):
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The bench registry against scripts/bench: every python tool is """The bench registry against scripts/bench: every python tool is
registered (or named as not-a-tool), every registered script exists, registered (or named as not-a-tool), every registered script exists,
every argument spec builds a command line, every script compiles, and every argument spec builds a command line, every script compiles, and
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
import unittest import unittest
import helpers import helpers
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""Every phrase the cloud suite greps for in the gfcloud log must be a """Every phrase the cloud suite greps for in the gfcloud log must be a
phrase the pinned cloud app can actually log. phrase the pinned cloud app can actually log.
+135 -42
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The cloud.* job-behavior tests against a fake forgectrl and a replayed """The cloud.* job-behavior tests against a fake forgectrl and a replayed
gfcloud log: the real test functions run under the real runner Context, gfcloud log: the real test functions run under the real runner Context,
the operator prompts answered - and the standing notices of the machine the operator prompts answered - and the standing notices of the machine
@@ -558,12 +563,16 @@ class CloudSuiteTests(unittest.TestCase):
HUNT_RUN_SAMPLE = {"phase": "run", "verdict": "ok", "armed": False, HUNT_RUN_SAMPLE = {"phase": "run", "verdict": "ok", "armed": False,
"fan_gates": {"exhaust": {"state": "unjudged", "reading": 0, "floor": 500}}} "fan_gates": {"exhaust": {"state": "unjudged", "reading": 0, "floor": 500}}}
def mode_switch_setup(self, hunt_lines=None, home_complete=True): def mode_switch_setup(self, hunt_lines=None, home_complete=True, lid_late=False):
"""The fakes a mode-switch run needs: grbl to answer $H, the lid """The fakes a mode-switch run needs: grbl to answer $H, the lid
lamp attr, homing_mode = gfcloud, the service lines landing on lamp attr, homing_mode = gfcloud, the service lines landing on
the switch to cloud (the hunt reads as a run to the cooling the switch to cloud - the client's start at once, its session and
engine while it lasts), the re-hunt on the lid close, and gfhome hunt once the lid reads open, as on the bench where the lid opens
finishing the homing after $H.""" behind the controller's start and ahead of the hunt (the hunt
reads as a run to the cooling engine while it lasts) - the re-hunt
on the lid close, and gfhome finishing the homing after $H. With
lid_late the hunt is requested before the lid opens: the race the
test must call."""
self.grbl = helpers.FakeGrbl().start() self.grbl = helpers.FakeGrbl().start()
os.makedirs(self.sysfs + "pic", exist_ok=True) os.makedirs(self.sysfs + "pic", exist_ok=True)
self._attr("pic/lid_led", "236") self._attr("pic/lid_led", "236")
@@ -575,22 +584,35 @@ class CloudSuiteTests(unittest.TestCase):
hunt_part = hunt_lines(hunt_part) hunt_part = hunt_lines(hunt_part)
fc = self.fc fc = self.fc
def hunt():
self.append(hunt_part, delay=0.0)
time.sleep(4.0) # the hunt outlasts the session wait on the bench
fc.state["cool"] = {"phase": "idle", "armed": False, "hold": False}
def on_post(path, form): def on_post(path, form):
if path == "/mode" and form.get("controller") == "cloud": if path == "/mode" and form.get("controller") == "cloud":
fc.state["cool"] = dict(self.HUNT_RUN_SAMPLE) fc.state["cool"] = dict(self.HUNT_RUN_SAMPLE)
def land(): def land():
self.append(pre, delay=0.0) self.append(pre, delay=0.0)
if lid_late:
return
t0 = time.time()
while fc.state["status"]["switches"]["lid"] and time.time() - t0 < 5:
time.sleep(0.02)
time.sleep(0.3) time.sleep(0.3)
self.append(hunt_part, delay=0.0) hunt()
time.sleep(4.0) # the hunt outlasts the session wait on the bench
fc.state["cool"] = {"phase": "idle", "armed": False, "hold": False}
threading.Thread(target=land, daemon=True).start() threading.Thread(target=land, daemon=True).start()
elif path == "/mode" and form.get("controller") == "grbl": elif path == "/mode" and form.get("controller") == "grbl":
self.grbl.state = "Idle" self.grbl.state = "Idle"
return None return None
self.fc.on_post = on_post self.fc.on_post = on_post
def lid_opened():
if lid_late:
self.append(hunt_part, delay=0.0)
self.lid(False)
def lid_closed(): def lid_closed():
self.lid(True) self.lid(True)
self.append(close_part, delay=0.2) self.append(close_part, delay=0.2)
@@ -605,7 +627,7 @@ class CloudSuiteTests(unittest.TestCase):
f.write(b"2026-08-17T09:46:00.000000+00:00 gfhome[2300] INFO homing complete " f.write(b"2026-08-17T09:46:00.000000+00:00 gfhome[2300] INFO homing complete "
b"(service quiet 8s, 3 motion windows)\n") b"(service quiet 8s, 3 motion windows)\n")
self.home_hook = homing self.home_hook = homing
return {"Open the lid.": lambda: self.lid(False), "Close the lid.": lid_closed} return {"Open the lid.": lid_opened, "Close the lid.": lid_closed}
def wait_home_command(self): def wait_home_command(self):
while "$H" not in self.grbl.sent: while "$H" not in self.grbl.sent:
@@ -633,6 +655,29 @@ class CloudSuiteTests(unittest.TestCase):
self.assertEqual(self.script.asked, []) self.assertEqual(self.script.asked, [])
self.assertTrue(any("PASS:" in l for l in run.lines), run.lines[-5:]) self.assertTrue(any("PASS:" in l for l in run.lines), run.lines[-5:])
def test_mode_switch_opens_the_lid_behind_the_controller_and_ahead_of_the_hunt(self):
# No controller starts with the enclosure open: the switch is made
# lid closed, the lid opens once the controller is up, and the
# hunt's request lands after that - the order the test records.
hooks = self.mode_switch_setup()
threading.Thread(target=self.wait_home_command, daemon=True).start()
run = self.run_test(cloud.mode_switch, hooks=hooks, test_id="cloud.mode-switch")
self.assertFalse(run.evidence["hunt_before_lid_open"])
acts = [(a["channel"], a["state"]) for a in run.evidence["actions"]]
self.assertEqual(acts, [("lid", "open"), ("lid", "close")])
i_post = next(i for i, l in enumerate(run.lines) if "POST /mode controller=cloud" in l)
i_open = next(i for i, l in enumerate(run.lines) if "ACT lid open" in l)
self.assertLess(i_post, i_open)
def test_mode_switch_refuses_to_start_with_the_lid_open(self):
hooks = self.mode_switch_setup()
self.lid(False)
self.assertFails(cloud.mode_switch, "close the lid first", hooks=hooks)
def test_mode_switch_fails_when_the_hunt_was_requested_before_the_lid_opened(self):
hooks = self.mode_switch_setup(lid_late=True)
self.assertFails(cloud.mode_switch, "before the lid was open", hooks=hooks)
def test_mode_switch_fails_when_the_hunt_is_refused_for_the_lid(self): def test_mode_switch_fails_when_the_hunt_is_refused_for_the_lid(self):
def refused(hunt_part): def refused(hunt_part):
i = next(i for i, l in enumerate(hunt_part) if "z_axis:home starting z homing cycle" in l) i = next(i for i, l in enumerate(hunt_part) if "z_axis:home starting z homing cycle" in l)
@@ -914,22 +959,63 @@ class CloudSuiteTests(unittest.TestCase):
self.assertEqual(self.fc.posts, []) self.assertEqual(self.fc.posts, [])
self.assertTrue(any("PASS: lid open at the button prompt" in l for l in run.lines)) self.assertTrue(any("PASS: lid open at the button prompt" in l for l in run.lines))
# -- the cooling verdict: the armed print waits on the warm-up ------------ # -- the cooling verdict: locked until the run, a hold that never clears --
def test_verdict_hold_on_the_bench_excerpt(self): def dark_parts(self):
self.in_offline() """(the print prologue through the button prompt, the run and the
self.fc.state["status"]["coolant"] = {"up_c": 21.5, "down_c": 21.4} print's end with no pause in it)."""
self.fc.state["settings"].update({"cool_temp_min": "5", "cool_temp_start": "16"})
self.fc.state["cool"].update({"verdict": "OK", "hold": False, "fire_ok": True, "armed": False})
lines = fixture("pause") lines = fixture("pause")
pre, rest = cut(lines, "waiting for button") pre, rest = cut(lines, "waiting for button")
pre = pre + [rest[0]] pre = pre + [rest[0]]
# the run and the print's end, without the pause in the middle
_, run = cut(rest, "machine:_run_loop starting run") _, run = cut(rest, "machine:_run_loop starting run")
run = [l for l in run if "paus" not in l and "resum" not in l] run = [l for l in run if "paus" not in l and "resum" not in l]
stamp = "2026-08-17T09:44:14.%06d+00:00 gfcloud[1927] INFO machine:_verdict_wait " return pre, run
wait = [stamp % 100000 + "waiting on the cooling engine: WARMUP (WARM-UP: coolant 21.5 C under "
"the 22.5 C start gate - heater on, hold)"] def test_dark_print_on_the_bench_excerpt(self):
release = [stamp % 200000 + "cooling verdict clean after WARMUP; starting the run"] self.in_offline()
self.fc.state["cool"].update({"verdict": "OK", "hold": False, "fire_ok": True, "armed": False})
pre, run = self.dark_parts()
self.offline_print_hooks(pre)
def press():
# the run: the client unlocks the latch right before it, and
# relocks when the job ends
self._attr("cnc/interlock_circuit", "37")
self.append(run[:1], delay=0.05)
def ended():
self._attr("cnc/interlock_circuit", "45")
self.append(run[1:], delay=0.0)
threading.Timer(2.0, ended).start()
hooks = {"press it. The print runs dark": press}
run_ = self.run_test(cloud.dark_print, hooks=hooks, test_id="cloud.dark-print")
ev = run_.evidence
self.assertTrue(ev["latch_locked_at_button"])
self.assertFalse(ev["latch_locked_in_run"])
self.assertTrue(ev["latch_locked_after"])
self.assertIn(":completed", ev["print finished"])
self.assertTrue(any("PASS: locked at the button" in l for l in run_.lines))
def test_dark_print_fails_when_the_latch_is_open_at_the_button(self):
self.in_offline()
pre, run = self.dark_parts()
self._attr("cnc/interlock_circuit", "37") # unlocked before the press
self.offline_print_hooks(pre)
hooks = {"press it. The print runs dark": lambda: self.append(run, delay=0.05)}
self.assertFails(cloud.dark_print, "the latch is unlocked at the button wait", hooks=hooks)
def test_verdict_refuse_on_the_bench_excerpt(self):
self.in_offline()
self.fc.state["status"]["coolant"] = {"up_c": 21.5, "down_c": 21.4}
self.fc.state["settings"].update({"cool_temp_min": "5", "cool_temp_start": "16",
"cloud_hold_max_s": ""})
self.fc.state["cool"].update({"verdict": "OK", "hold": False, "fire_ok": True, "armed": False})
pre, run = self.dark_parts()
stamp = "2026-08-17T09:44:14.%06d+00:00 gfcloud[1927] %s machine:_verdict_wait "
wait = [stamp % (100000, "INFO") + "waiting on the cooling engine: WARMUP (WARM-UP: coolant "
"21.5 C under the 29.5 C start gate - heater on, hold)"]
refuse = [stamp % (200000, "WARNING") + "cooling hold held 60 s - relocking the laser",
"2026-08-17T09:44:15.000000+00:00 gfcloud[1927] INFO basemachine:_finish_action print "
"[9006]: finished with event \":cancelled\""]
def on_post(path, form): def on_post(path, form):
if path == "/settings" and form.get("cool_temp_start") not in (None, "0", "16"): if path == "/settings" and form.get("cool_temp_start") not in (None, "0", "16"):
@@ -942,37 +1028,44 @@ class CloudSuiteTests(unittest.TestCase):
def press(): def press():
self.append(wait, delay=0.05) self.append(wait, delay=0.05)
def released(): def refused():
self.fc.state["cool"].update({"verdict": "OK", "hold": False, "fire_ok": True, self.fc.state["cool"].update({"verdict": "OK", "hold": False, "fire_ok": True,
"armed": False, "phase": "run"}) "armed": False, "phase": "idle"})
self.append(release + run, delay=0.0) self.append(refuse, delay=0.0)
threading.Timer(3.0, released).start() threading.Timer(2.5, refused).start()
hooks = {"press it. The print then waits": press} hooks = {"press it. Nothing runs": press}
run_ = self.run_test(cloud.verdict_hold, hooks=hooks, test_id="cloud.verdict-hold") run_ = self.run_test(cloud.verdict_refuse, hooks=hooks, test_id="cloud.verdict-refuse")
ev = run_.evidence ev = run_.evidence
self.assertEqual(ev["gate"], 22.5) self.assertEqual(ev["gate"], 29.5)
self.assertEqual(ev["warmup"]["verdict"], "WARMUP") self.assertTrue(ev["latch_locked_at_button"])
self.assertTrue(ev["log"]["cooling verdict clean after WARMUP; starting the run"]) self.assertEqual(ev["latch_samples"]["unlocked"], 0)
self.assertIn(":completed", ev["print finished"]) self.assertGreaterEqual(ev["latch_samples"]["n"], 1)
posted = [f for p, f in self.fc.posts if p == "/settings"] self.assertIn(":cancelled", ev["print finished"])
self.assertEqual(posted[0]["cool_temp_start"], "22.5") posted = [f for p_, f in self.fc.posts if p_ == "/settings"]
self.assertEqual(posted[-1], {"cool_temp_min": "5", "cool_temp_start": "16"}) self.assertEqual(posted[0]["cool_temp_start"], "29.5")
self.assertTrue(any("PASS: the armed print waited" in l for l in run_.lines)) self.assertEqual(posted[0]["cloud_hold_max_s"], "60")
self.assertEqual(posted[-1], {"cool_temp_min": "5", "cool_temp_start": "16", "cloud_hold_max_s": ""})
self.assertTrue(any("PASS: held" in l for l in run_.lines))
def test_verdict_hold_fails_when_the_print_runs_under_the_hold(self): def test_verdict_refuse_fails_when_the_latch_unlocks_during_the_hold(self):
self.in_offline() self.in_offline()
self.fc.state["status"]["coolant"] = {"up_c": 21.5, "down_c": 21.4} self.fc.state["status"]["coolant"] = {"up_c": 21.5, "down_c": 21.4}
self.fc.state["settings"].update({"cool_temp_min": "5", "cool_temp_start": "16"}) self.fc.state["settings"].update({"cool_temp_min": "5", "cool_temp_start": "16"})
self.fc.state["cool"].update({"verdict": "WARMUP", "hold": True, "fire_ok": False, "armed": True}) self.fc.state["cool"].update({"verdict": "WARMUP", "hold": True, "fire_ok": False, "armed": True})
lines = fixture("pause") pre, run = self.dark_parts()
pre, rest = cut(lines, "waiting for button") stamp = "2026-08-17T09:44:14.%06d+00:00 gfcloud[1927] %s machine:_verdict_wait "
pre = pre + [rest[0]] wait = [stamp % (100000, "INFO") + "waiting on the cooling engine: WARMUP (no reason given)"]
_, run = cut(rest, "machine:_run_loop starting run") refuse = [stamp % (200000, "WARNING") + "cooling hold held 60 s - relocking the laser",
stamp = "2026-08-17T09:44:14.%06d+00:00 gfcloud[1927] INFO machine:_verdict_wait " "2026-08-17T09:44:15.000000+00:00 gfcloud[1927] INFO basemachine:_finish_action print "
wait = [stamp % 100000 + "waiting on the cooling engine: WARMUP (no reason given)"] "[9006]: finished with event \":cancelled\""]
self.offline_print_hooks(pre) self.offline_print_hooks(pre)
hooks = {"press it. The print then waits": lambda: self.append(wait + run[:1], delay=0.05)}
self.assertFails(cloud.verdict_hold, "the run started under the warm-up hold", hooks=hooks) def press():
self.append(wait, delay=0.05)
self._attr("cnc/interlock_circuit", "37") # unlocked under the hold
threading.Timer(2.5, lambda: self.append(refuse, delay=0.0)).start()
hooks = {"press it. Nothing runs": press}
self.assertFails(cloud.verdict_refuse, "the latch unlocked during the hold", hooks=hooks)
# -- a paused print canceled by the lid, a running one by the app -------- # -- a paused print canceled by the lid, a running one by the app --------
def cancel_parts(self): def cancel_parts(self):
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""cooling.aa-offset-calibrate is registered before the heater tools, so """cooling.aa-offset-calibrate is registered before the heater tools, so
a queue runs it first: a heater trial leaves a warm slug circulating past a queue runs it first: a heater trial leaves a warm slug circulating past
the coolant sensors for minutes, and the tool's edges read it as the coolant sensors for minutes, and the tool's edges read it as
+53
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""cooling.fans-quiet-after-motion and cooling.gate-off replayed host-side """cooling.fans-quiet-after-motion and cooling.gate-off replayed host-side
under the real runner Context against a scripted machine: a fake under the real runner Context against a scripted machine: a fake
forgectrl (/status fans, /cool/status phase and verdict, /settings with forgectrl (/status fans, /cool/status phase and verdict, /settings with
@@ -729,3 +734,51 @@ class FanGateTests(unittest.TestCase):
self.run_test() self.run_test()
self.assertIn("lacks exhaust", str(cm.exception)) self.assertIn("lacks exhaust", str(cm.exception))
self.assertEqual(self.fc.state["settings"]["cool_tach_exhaust_min_rpm"], "") self.assertEqual(self.fc.state["settings"]["cool_tach_exhaust_min_rpm"], "")
class FlowVerdictLogTests(unittest.TestCase):
"""cooling.flow-under-load finds the engine's verdict in what the log
gained since the job began, not in a count over a tail of fixed length:
there a 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."""
VERDICT = ("2026-09-19T21:37:46.869674+00:00 forgectrl[2796] INFO cool: coolant flow verified "
"(heater rise 11.5 C, dT 9.5 C; laser 1.6 off 13.1)\n")
OLD = ("2026-09-19T21:17:27.200840+00:00 forgectrl[965] INFO cool: coolant flow verified "
"(heater rise 9.5 C, dT 8.7 C)\n")
def setUp(self):
self.dir = tempfile.mkdtemp(prefix="flowlog.")
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
self.path = os.path.join(self.dir, "forgectrl.log")
def write(self, text, mode="w"):
with open(self.path, mode, newline="\n") as f:
f.write(text)
def test_only_the_text_after_the_offset_comes_back(self):
self.write(self.OLD + "filler\n" * 400)
off = os.path.getsize(self.path)
self.assertEqual(cooling._log_since(self.path, off), "")
self.write(self.VERDICT, mode="a")
text = cooling._log_since(self.path, off)
self.assertEqual(text, self.VERDICT)
m = cooling._load_verdict_rx().search(text)
self.assertEqual(m.group(1), "coolant flow verified")
self.assertEqual((m.group(2), m.group(5)), ("11.5", "1.6"))
def test_an_old_verdict_before_the_offset_is_not_the_new_one(self):
self.write(self.OLD)
off = os.path.getsize(self.path)
self.write("2026-09-19T21:36:40+00:00 forgectrl[2796] INFO cool: crash watch armed\n", mode="a")
self.assertIsNone(cooling._load_verdict_rx().search(cooling._log_since(self.path, off)))
def test_a_log_rotated_under_the_test_is_read_whole(self):
self.write(self.OLD + "filler\n" * 400)
off = os.path.getsize(self.path)
self.write(self.VERDICT) # the rotation: a new, shorter file
self.assertEqual(cooling._log_since(self.path, off), self.VERDICT)
def test_a_missing_log_reads_as_nothing(self):
self.assertEqual(cooling._log_since(os.path.join(self.dir, "none.log"), 0), "")
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The bench actuator as the tool sees it. """The bench actuator as the tool sees it.
The fixture is a box on the network that opens the lid loop, pulls the The fixture is a box on the network that opens the lid loop, pulls the
+62
View File
@@ -1,5 +1,11 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
import unittest import unittest
from forgetest import catalog
from forgetest.suite import image from forgetest.suite import image
@@ -20,5 +26,61 @@ class KernelIdentTests(unittest.TestCase):
self.assertFalse(image.kernel_matches(rel, ["6.12.19-fslc-fslc"])) self.assertFalse(image.kernel_matches(rel, ["6.12.19-fslc-fslc"]))
class NetworkBootTests(unittest.TestCase):
STANZA = ("auto wlan0\n"
"iface wlan0 inet dhcp\n"
"\twpa-driver nl80211\n"
"\tudhcpc_opts -x hostname:$(hostname)\n")
def test_the_test_is_registered_and_covers_nothing_by_design(self):
t = catalog.load_suite()["image.network-boot"]
self.assertEqual(t.kind, "auto")
self.assertEqual(t.covers, ()) # the interfaces file is layer content
def test_ifstate_names_the_interfaces_ifup_finished(self):
self.assertEqual(image.ifstate_names("lo=lo\nwlan0=wlan0\n"), {"lo", "wlan0"})
self.assertEqual(image.ifstate_names("lo=lo\n"), {"lo"})
self.assertEqual(image.ifstate_names(""), set())
self.assertEqual(image.ifstate_names(None), set())
def test_an_interfaces_file_without_a_dhcpv6_client_is_clean(self):
self.assertEqual(image.dhcp6_in_interfaces(self.STANZA), [])
self.assertEqual(image.dhcp6_in_interfaces(None), [])
def test_a_command_that_starts_a_dhcpv6_client_is_named(self):
text = self.STANZA + ("iface wlan0 inet6 manual\n"
"\tup udhcpc6 -i wlan0 -b -S -p /run/udhcpc6.wlan0.pid\n")
self.assertEqual(image.dhcp6_in_interfaces(text),
["up udhcpc6 -i wlan0 -b -S -p /run/udhcpc6.wlan0.pid"])
detached = "\tup start-stop-daemon -S -b -x /usr/bin/udhcpc6 -- -f -i wlan0\n"
self.assertEqual(len(image.dhcp6_in_interfaces(self.STANZA + detached)), 1)
def test_an_inet6_method_that_runs_a_client_is_named(self):
self.assertEqual(image.dhcp6_in_interfaces("iface wlan0 inet6 dhcp\n"),
["iface wlan0 inet6 dhcp"])
self.assertEqual(image.dhcp6_in_interfaces("iface wlan0 inet6 static\n"), [])
self.assertEqual(image.dhcp6_in_interfaces("iface wlan0 inet dhcp\n"), [])
def test_a_comment_starts_nothing(self):
text = self.STANZA + "# No DHCPv6 client runs; udhcpc6 is not on the image.\n"
self.assertEqual(image.dhcp6_in_interfaces(text), [])
def test_parse_stat_reads_the_parent_past_an_awkward_comm(self):
self.assertEqual(image.parse_stat("371 (udhcpc) S 1 371 371 0 -1 4194624"),
(371, "udhcpc", 1))
self.assertEqual(image.parse_stat("9 (a (b) c) S 7 9 9 0"), (9, "a (b) c", 7))
self.assertIsNone(image.parse_stat(""))
self.assertIsNone(image.parse_stat("12 (x) S"))
def test_link_addrs_sorts_one_interface_by_scope(self):
text = ("fe800000000000002e6b7dfffe0db00a 02 40 20 80 wlan0\n"
"26001700000000000000000000001234 02 80 00 00 wlan0\n"
"00000000000000000000000000000001 01 80 10 80 lo\n")
local, world = image.link_addrs(text, "wlan0")
self.assertEqual(local, ["fe800000000000002e6b7dfffe0db00a"])
self.assertEqual(world, ["26001700000000000000000000001234"])
self.assertEqual(image.link_addrs(text, "eth0"), ([], []))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+194
View File
@@ -0,0 +1,194 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""scripts/install-forgefirm.sh - the download that retries, and the log.
The installer runs once per machine, on factory firmware, over whatever
network the owner has. These tests run its own functions under sh with a
scripted curl in front of them: a transfer that fails resumes and is
tried again, the failures that waiting cannot fix end the tries at once,
and every step leaves a line in the install log in the log tree's own
format."""
import os
import re
import shutil
import stat
import subprocess
import tempfile
import unittest
import helpers # noqa: F401 (sys.path)
REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
SCRIPT = os.path.join(REPO, "scripts", "install-forgefirm.sh")
MARK = "# The install log, before anything can fail."
# The log tree's line format (forgetest/suite/logs.py keeps the same one).
LINE_RE = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?[+-]\d\d:\d\d install\[\d+\] "
r"(EMERG|ALERT|CRIT|ERR|WARNING|NOTICE|INFO|DEBUG) .+$")
# A curl that plays a script: each call takes the next "rc http bytes"
# line of $CURL_PLAN, appends that many bytes to the --output file the way
# a resumed transfer does, prints the HTTP code as -w '%{http_code}'
# would, records its arguments, and exits rc.
FAKE_CURL = r"""#!/bin/sh
echo "$*" >> "$CURL_CALLS"
OUT=""
while [ $# -gt 0 ]; do
[ "$1" = "--output" ] && OUT="$2"
shift
done
LINE=$(sed -n 1p "$CURL_PLAN")
sed -i 1d "$CURL_PLAN"
set -- $LINE
[ "${3:-0}" -gt 0 ] && dd if=/dev/zero bs=1 count="$3" 2>/dev/null | tr '\0' 'x' >> "$OUT"
printf '%s' "$2"
exit "$1"
"""
def functions():
"""The installer up to its first action: constants and functions."""
with open(SCRIPT, encoding="utf-8") as f:
text = f.read()
head, mark, _ = text.partition(MARK)
assert mark, "the installer lost the line the tests cut it at"
return head
@unittest.skipUnless(shutil.which("sh") and os.name == "posix", "needs a POSIX sh")
class InstallerTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp(prefix="ffinstall-test.")
self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True)
self.bin = os.path.join(self.tmp, "bin")
os.mkdir(self.bin)
for name, body in (("curl", FAKE_CURL), ("nslookup", "#!/bin/sh\nexit 1\n"),
("ip", "#!/bin/sh\nexit 0\n"), ("sleep", "#!/bin/sh\nexit 0\n")):
path = os.path.join(self.bin, name)
with open(path, "w", newline="\n") as f:
f.write(body)
os.chmod(path, os.stat(path).st_mode | stat.S_IXUSR)
self.fw = os.path.join(self.tmp, "forgefirm.fw")
self.log = os.path.join(self.tmp, "log", "install.log")
self.calls = os.path.join(self.tmp, "curl.calls")
self.plan = os.path.join(self.tmp, "curl.plan")
def run_sh(self, body, plan=()):
with open(self.plan, "w", newline="\n") as f:
f.write("".join("%s\n" % line for line in plan))
open(self.calls, "w").close()
script = "%s\nFW_FILE='%s'\nLOG_DIR='%s'\nLOG_FILE='%s'\nmkdir -p \"$LOG_DIR\"\nLOG_OK=yes\n%s\n" % (
functions(), self.fw, os.path.dirname(self.log), self.log, body)
env = dict(os.environ, PATH=self.bin + os.pathsep + os.environ["PATH"],
CURL_PLAN=self.plan, CURL_CALLS=self.calls)
return subprocess.run(["sh", "-c", script], env=env, capture_output=True, text=True, timeout=60)
def download(self, plan):
r = self.run_sh('download_fw; echo "rc=$? why=$DL_WHY"', plan)
with open(self.calls) as f:
calls = f.read().splitlines()
return r.stdout, calls, self.log_lines()
def log_lines(self):
try:
with open(self.log) as f:
return f.read().splitlines()
except OSError:
return []
def test_a_clean_download_takes_one_try_and_its_name(self):
out, calls, log = self.download(["0 200 5000"])
self.assertIn("rc=0", out)
self.assertEqual(len(calls), 1)
self.assertEqual(os.path.getsize(self.fw), 5000)
self.assertFalse(os.path.exists(self.fw + ".part"))
self.assertTrue(any("complete on try 1, 5000 bytes" in line for line in log), log)
def test_a_failed_transfer_resumes_and_is_tried_again(self):
out, calls, log = self.download(["6 000 0", "56 200 3000", "0 206 2000"])
self.assertIn("rc=0", out)
self.assertEqual(len(calls), 3)
for c in calls: # every try resumes, bounded in time
self.assertIn("-C -", c)
self.assertIn("--connect-timeout", c)
self.assertIn("--speed-time", c)
self.assertEqual(os.path.getsize(self.fw), 5000) # 3000 kept, 2000 resumed
warns = [line for line in log if " WARNING download: try " in line]
self.assertEqual(len(warns), 2, log)
self.assertIn("did not resolve", warns[0])
self.assertIn("dropped mid-transfer (3000 bytes so far)", warns[1])
self.assertIn("Trying again in 5s (try 2 of 5)", out)
self.assertIn("Trying again in 15s (try 3 of 5)", out)
def test_the_tries_run_out(self):
out, calls, log = self.download(["28 000 0"] * 5)
self.assertIn("rc=1", out)
self.assertIn("timed out or stalled", out)
self.assertEqual(len(calls), 5)
self.assertFalse(os.path.exists(self.fw))
self.assertFalse(os.path.exists(self.fw + ".part"))
# each failed try leaves what the network looked like
self.assertEqual(sum("github.com does not resolve" in line for line in log), 5, log)
def test_a_missing_release_and_a_full_disk_end_the_tries_at_once(self):
out, calls, _ = self.download(["22 404 0"])
self.assertIn("rc=1", out)
self.assertIn("HTTP 404", out)
self.assertEqual(len(calls), 1)
out, calls, _ = self.download(["23 200 100"])
self.assertIn("rc=1", out)
self.assertIn("could not be written", out)
self.assertEqual(len(calls), 1)
def test_a_partial_file_the_server_will_not_resume_starts_over(self):
out, calls, _ = self.download(["56 200 4000", "22 416 0", "0 200 5000"])
self.assertIn("rc=0", out)
self.assertEqual(len(calls), 3)
self.assertEqual(os.path.getsize(self.fw), 5000) # not 9000: the stale part went
def test_a_tls_failure_names_the_clock(self):
out, _, _ = self.download(["60 000 0"] * 5)
self.assertRegex(out, r"TLS handshake failed \(a wrong clock does this: \d{4}-\d\d-\d\d")
def test_every_log_line_is_in_the_tree_format(self):
self.download(["7 000 0", "0 200 10"])
self.run_sh('log NOTICE "operator declined to continue; no changes made"')
lines = self.log_lines()
self.assertGreater(len(lines), 5)
for line in lines:
self.assertRegex(line, LINE_RE)
def test_die_leaves_the_reason_in_the_log(self):
r = self.run_sh('die "archiving slot 1 failed"; echo not-reached')
self.assertEqual(r.returncode, 1)
self.assertNotIn("not-reached", r.stdout)
self.assertTrue(any(" ERR install failed: archiving slot 1 failed" in line
for line in self.log_lines()))
def test_no_writable_log_never_fails_the_install(self):
r = self.run_sh('LOG_OK=""; log INFO "x"; LOG_OK=yes; LOG_FILE=/nonexistent/dir/x.log; '
'log INFO "y"; echo "still-running rc=$?"')
self.assertIn("still-running rc=0", r.stdout)
class InstallerShapeTests(unittest.TestCase):
"""What holds on any host, with no shell to run."""
def test_the_log_lives_in_the_tree_the_export_carries(self):
head = functions()
self.assertIn('LOG_DIR="/data/log/forgefirm/install"', head)
self.assertIn('LOG_FILE="$LOG_DIR/install.log"', head)
def test_the_download_is_the_retrying_one(self):
with open(SCRIPT, encoding="utf-8") as f:
text = f.read()
body = text.partition(MARK)[2]
self.assertIn("download_fw", body)
self.assertNotRegex(body, r"(?m)^\s*curl ") # no bare curl past the functions
if __name__ == "__main__":
unittest.main()
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""wait_hv_off against a scripted charge-pump readback: a chain that """wait_hv_off against a scripted charge-pump readback: a chain that
releases inside the window passes, a chain held past it is the refusal, releases inside the window passes, a chain held past it is the refusal,
and a chain already off costs no wait.""" and a chain already off costs no wait."""
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The dwell-gap verdict of laser.emission-witness over synthetic trails: """The dwell-gap verdict of laser.emission-witness over synthetic trails:
the button latch judged from the first emission through every sample the button latch judged from the first emission through every sample
whose readback word shows the laser latch unlocked, both bits from that whose readback word shows the laser latch unlocked, both bits from that
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
import os import os
import unittest import unittest
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The controller mode a test declares: the runner puts the machine there """The controller mode a test declares: the runner puts the machine there
before the test and the baseline keeps it there afterward. before the test and the baseline keeps it there afterward.
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The operator channel: notices, `ready`, `act`, and the precheck. """The operator channel: notices, `ready`, `act`, and the precheck.
A test asks for the operator's part in a way the page can show before A test asks for the operator's part in a way the page can show before
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The page assembly: the ui/ files inlined in order, the gzipped install """The page assembly: the ui/ files inlined in order, the gzipped install
(what the dev image carries) assembling to the same bytes as a checkout, (what the dev image carries) assembling to the same bytes as a checkout,
the token placeholder carried exactly once, and a missing marker refused the token placeholder carried exactly once, and a missing marker refused
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The two queues: what they select, the order they run in, and where """The two queues: what they select, the order they run in, and where
they stop. they stop.
+64
View File
@@ -0,0 +1,64 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The release artifact carries no machine identity (redact.py).
The artifact is committed to a public repository. These hold the exporter
to that, and to not redacting what the gate recomputes.
"""
import json
import unittest
from forgetest.redact import scrub
class RedactTests(unittest.TestCase):
def test_keyed_identity_is_replaced(self):
out = scrub({"gf_serial": "15232020", "machine_id": "JTY-876",
"hostname": "forgefirm-b00a"})
self.assertNotIn("15232020", json.dumps(out))
self.assertNotIn("JTY-876", json.dumps(out))
self.assertNotIn("forgefirm-b00a", json.dumps(out))
def test_a_known_value_is_replaced_in_free_text_too(self):
# A hostname in a log line is the same leak as one in its own field.
out = scrub({"evidence": {"hostname": "forgefirm-b00a"},
"log": ["set hostname 'forgefirm-b00a' ok"]})
self.assertNotIn("forgefirm-b00a", json.dumps(out))
def test_addresses_and_macs_go(self):
out = scrub(["ping 172.16.1.97", "wlan0 2C:6B:7D:0D:B0:0A up"])
t = json.dumps(out)
self.assertNotIn("172.16.1.97", t)
self.assertNotIn("2C:6B:7D:0D:B0:0A", t)
def test_loopback_is_kept(self):
self.assertIn("127.0.0.1", json.dumps(scrub(["curl http://127.0.0.1:8090/"])))
def test_numbering_is_stable_within_an_export(self):
out = scrub(["a 10.0.0.5", "b 10.0.0.6", "c 10.0.0.5"])
self.assertEqual(out[0].split()[1], out[2].split()[1])
self.assertNotEqual(out[0].split()[1], out[1].split()[1])
def test_integrity_values_survive(self):
# The gate recomputes these; a long hex blob is what they look like.
rec = {"fingerprint": "a" * 64, "manifest_sha": "b" * 64,
"source_sha": "c" * 64, "sha256": "d" * 64,
"catalog_hash": "e" * 64, "identity_sha": "f" * 64}
self.assertEqual(scrub(rec), rec)
def test_a_clock_time_is_not_an_address(self):
out = scrub(["2026-09-18T18:50:11Z baseline: pre: clean"])
self.assertIn("18:50:11", out[0])
def test_result_fields_the_gate_reads_are_untouched(self):
rec = {"result": "PASS", "ts": "2026-09-18T18:50:11Z",
"campaign": "c-1", "fingerprint": "a" * 64}
self.assertEqual(scrub(rec), rec)
if __name__ == "__main__":
unittest.main()
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""What keeps the page answering the operator instead of the timer. """What keeps the page answering the operator instead of the timer.
Two things went wrong on the bench and are pinned here: Two things went wrong on the bench and are pinned here:
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""Runner + HTTP API end to end on localhost with a fake catalog and a """Runner + HTTP API end to end on localhost with a fake catalog and a
fake bench tool. No hardware, no forgectrl.""" fake bench tool. No hardware, no forgectrl."""
import json import json
@@ -1,8 +1,13 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The setup's checks in the catalog: registered as automatic tests with """The setup's checks in the catalog: registered as automatic tests with
no precheck, the takeover ones marked so, every covers map naming the no precheck, the takeover ones marked so, every covers map naming the
check runner; and the check driver itself against the fake daemon: a check runner; and the check driver itself against the fake daemon: a
start, a prompt answered, the result taken, the record read at version start, the run polled to its end with no question asked, the result
1, and the settings a check may write put back as found.""" taken, the record read at version 1, and the settings read as before."""
import os import os
import sys import sys
import unittest import unittest
@@ -12,8 +17,8 @@ import helpers # noqa: E402
from forgetest import catalog # noqa: E402 from forgetest import catalog # noqa: E402
from forgetest.runner import Context, Run # noqa: E402 from forgetest.runner import Context, Run # noqa: E402
IDS = ["commission.check-switches", "commission.check-sensors", "commission.check-airflow", IDS = ["setup.check-switches", "setup.check-sensors", "setup.check-airflow",
"commission.check-cameras", "commission.check-motion", "commission.check-flow-verify"] "setup.check-cameras", "setup.check-motion", "setup.check-flow-verify"]
class RegistrationTests(unittest.TestCase): class RegistrationTests(unittest.TestCase):
@@ -25,11 +30,11 @@ class RegistrationTests(unittest.TestCase):
# bench fixture performs; the rest need no hands at all. # bench fixture performs; the rest need no hands at all.
for tid in IDS: for tid in IDS:
self.assertIn(tid, self.reg) self.assertIn(tid, self.reg)
for tid in ("commission.check-switches", "commission.check-cameras"): for tid in ("setup.check-switches", "setup.check-cameras"):
self.assertEqual(self.reg[tid].kind, "operator", tid) self.assertEqual(self.reg[tid].kind, "operator", tid)
self.assertTrue(self.reg[tid].fixture_runnable(("button", "lid", "interlock")), tid) self.assertTrue(self.reg[tid].fixture_runnable(("button", "lid", "interlock")), tid)
for tid in ("commission.check-sensors", "commission.check-airflow", "commission.check-motion", for tid in ("setup.check-sensors", "setup.check-airflow", "setup.check-motion",
"commission.check-flow-verify"): "setup.check-flow-verify"):
self.assertEqual(self.reg[tid].kind, "auto", tid) self.assertEqual(self.reg[tid].kind, "auto", tid)
def test_no_check_asks_the_operator_to_prepare_anything(self): def test_no_check_asks_the_operator_to_prepare_anything(self):
@@ -37,7 +42,7 @@ class RegistrationTests(unittest.TestCase):
self.assertIsNone(getattr(self.reg[tid], "precheck", None), tid) self.assertIsNone(getattr(self.reg[tid], "precheck", None), tid)
def test_the_checks_that_take_the_machine_say_so(self): def test_the_checks_that_take_the_machine_say_so(self):
for tid in ("commission.check-airflow", "commission.check-motion", "commission.check-flow-verify"): for tid in ("setup.check-airflow", "setup.check-motion", "setup.check-flow-verify"):
self.assertEqual(self.reg[tid].hardware, "takeover", tid) self.assertEqual(self.reg[tid].hardware, "takeover", tid)
def test_every_check_covers_the_runner_and_the_page(self): def test_every_check_covers_the_runner_and_the_page(self):
@@ -62,9 +67,17 @@ class SensorsCheckTests(unittest.TestCase):
"shots": {"lid": False, "head": False}} "shots": {"lid": False, "head": False}}
self.versions = {} self.versions = {}
self.answers = [] self.answers = []
self.polls = 0
def on_get(path, q): def on_get(path, q):
if path == "/wiz/dark": if path == "/wiz/dark":
# One poll sees the sampling; the next sees the result.
if self.dark["running"]:
self.polls += 1
if self.polls >= 2:
self.dark.update({"running": False, "prompt": None, "progress": 100,
"result": dict(self.RESULT)})
self.versions["sensors"] = 1
return 200, dict(self.dark) return 200, dict(self.dark)
if path == "/wiz": if path == "/wiz":
return 200, {"versions": dict(self.versions), "required": [], "completed": True} return 200, {"versions": dict(self.versions), "required": [], "completed": True}
@@ -74,19 +87,11 @@ class SensorsCheckTests(unittest.TestCase):
s = self.fake.state["settings"] s = self.fake.state["settings"]
if path == "/wiz/sensors/start": if path == "/wiz/sensors/start":
self.dark.update({"id": "sensors", "running": True, "phase": "sampling", self.dark.update({"id": "sensors", "running": True, "phase": "sampling",
"prompt": {"seq": 7, "id": "room-temp", "kind": "number", "prompt": None})
"text": "Optional: the room temperature.",
"options": ["Set", "Skip"]}})
return 200, {"started": True, "id": "sensors"} return 200, {"started": True, "id": "sensors"}
if path == "/wiz/sensors/answer": if path == "/wiz/sensors/answer":
self.answers.append(dict(form)) self.answers.append(dict(form))
if form.get("seq") != "7":
return 409, {"error": "no such prompt is open"} return 409, {"error": "no such prompt is open"}
if form.get("value") == "Skip":
self.dark.update({"running": False, "prompt": None, "progress": 100,
"result": dict(self.RESULT)})
self.versions["sensors"] = 1
return 200, {"ok": True}
if path == "/settings": if path == "/settings":
for k, v in form.items(): for k, v in form.items():
if v == "": if v == "":
@@ -101,15 +106,35 @@ class SensorsCheckTests(unittest.TestCase):
def tearDown(self): def tearDown(self):
self.fake.stop() self.fake.stop()
def test_start_prompt_answer_result_and_restore(self): def test_start_no_question_result_and_settings_untouched(self):
t = catalog.load_suite()["commission.check-sensors"] t = catalog.load_suite()["setup.check-sensors"]
run = Run("test", t.id, t.title) run = Run("test", t.id, t.title)
t.fn(Context(run, None, t)) t.fn(Context(run, None, t))
self.assertIn(("/wiz/sensors/start", {}), [(p, f) for p, f in self.fake.posts]) self.assertIn(("/wiz/sensors/start", {}), [(p, f) for p, f in self.fake.posts])
self.assertEqual(self.answers, [{"seq": "7", "value": "Skip"}]) self.assertEqual(self.answers, [])
self.assertEqual(run.evidence["result"]["laser_pgood"], 1) self.assertEqual(run.evidence["result"]["laser_pgood"], 1)
self.assertEqual(run.evidence["settings_before"], {"cool_temp_offset_c": "0.5"}) self.assertEqual(run.evidence["settings_before"].get("cool_temp_offset_c"), "0.5")
self.assertEqual(self.fake.state["settings"]["cool_temp_offset_c"], "0.5") self.assertEqual(self.fake.state["settings"]["cool_temp_offset_c"], "0.5")
self.assertNotIn("/settings", [p for p, _ in self.fake.posts])
def test_a_question_fails_the_check(self):
t = catalog.load_suite()["setup.check-sensors"]
run = Run("test", t.id, t.title)
real_get = self.fake.on_get
def on_get(path, q):
if path == "/wiz/dark" and self.dark["running"]:
d = dict(self.dark)
d["prompt"] = {"seq": 3, "id": "room-temp", "kind": "number", "text": "?",
"options": ["Set", "Skip"]}
return 200, d
return real_get(path, q)
self.fake.on_get = on_get
with self.assertRaises(Exception) as cm:
t.fn(Context(run, None, t))
self.assertIn("asked a question", str(cm.exception))
self.assertEqual(self.answers, [])
self.assertIn(("/wiz/sensors/abort", {}), [(p, f) for p, f in self.fake.posts])
if __name__ == "__main__": if __name__ == "__main__":
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The setup's sheet in the catalog: one live test on the button with a """The setup's sheet in the catalog: one live test on the button with a
piece of wood at hand, its covers map naming the live runner, the piece of wood at hand, its covers map naming the live runner, the
renderer, the sender, the page, and the driver's laser module; and the renderer, the sender, the page, and the driver's laser module; and the
@@ -14,10 +19,47 @@ sys.path.insert(0, os.path.dirname(__file__))
import helpers # noqa: E402 import helpers # noqa: E402
from forgetest import catalog # noqa: E402 from forgetest import catalog # noqa: E402
from forgetest.runner import Context, Run # noqa: E402 from forgetest.runner import Context, Run # noqa: E402
from forgetest.suite import commission_sheet # noqa: E402 from forgetest.suite import setup_sheet # noqa: E402
LIVE = ["sheet.frame", "laser.focus", "laser.floor", "laser.dose-curve", "laser.corner", "cooling.flow-load"] LIVE = ["sheet.frame", "laser.focus", "laser.floor", "laser.dose-curve", "laser.corner", "cooling.flow-load"]
# The focus card's program is read again after it ran: the served ladder
# must then lie in the window the card wrote.
PREVIEWS = ["sheet.frame", "laser.focus", "laser.focus", "laser.floor", "laser.dose-curve", "laser.corner",
"cooling.flow-load"]
EMISSION = {"hv_max": 610, "laser_on_samples": 900, "thermopile_delta": 600, "lit_s": 30.0} EMISSION = {"hv_max": 610, "laser_on_samples": 900, "thermopile_delta": 600, "lit_s": 30.0}
SPM = 2.922 # the lens screw, half-steps per mm ($102)
EDGE_DEFAULT = 3.35
def lens_block(s):
"""The /status lens block as the daemon builds it from the settings: a
stop count from 1 to 40 stands, anything else is the fallback."""
def count(key, fallback):
try:
n = float(s.get(key, ""))
except ValueError:
return fallback, False
return (int(n), True) if 1 <= n <= 40 else (fallback, False)
try:
edge = float(s.get("lens_hall_edge_z_mm", ""))
except ValueError:
edge = EDGE_DEFAULT
below, a = count("lens_stop_below_steps", 10)
above, b = count("lens_stop_above_steps", 12)
return {"edge_z": round(edge, 2), "below": below, "above": above, "stops_found": a and b,
"reach_min": round(edge - below / SPM, 2), "reach_max": round(edge + above / SPM, 2)}
def ladder_z(edge, below, above):
"""The focus ladder the daemon builds: twelve whole half-steps from
the top of the window to the bottom."""
return [round(edge + round(above - (above + below) * i / 11) / SPM, 2) for i in range(12)]
# The window the focus card finds and writes before its controller starts.
STOPS = {"lens_stop_below_steps": "14", "lens_stop_above_steps": "20"}
FOCUS_WINDOW = {"below": 14, "above": 20, "reach_min": round(EDGE_DEFAULT - 14 / SPM, 2),
"reach_max": round(EDGE_DEFAULT + 20 / SPM, 2)}
# Each wizard as the daemon runs it: its prompts in order (id, kind, # Each wizard as the daemon runs it: its prompts in order (id, kind,
# options) and the result it ends with. A "jog" prompt repeats until the # options) and the result it ends with. A "jog" prompt repeats until the
@@ -37,7 +79,9 @@ SCRIPT = {
"steps_per_mm": 2.922, "max_height_mm": 13.81, "steps_per_mm": 2.922, "max_height_mm": 13.81,
"focus_range_mm": {"min": 2.17, "max": 13.81}, "focus_range_mm": {"min": 2.17, "max": 13.81},
"stops": {"found": True, "below": 14, "above": 20, "contact_below": 16, "stops": {"found": True, "below": 14, "above": 20, "contact_below": 16,
"contact_above": 22, "why": ""}, "emission": EMISSION}), "contact_above": 22, "why": ""},
"window": dict(FOCUS_WINDOW), "ladder_z": ladder_z(EDGE_DEFAULT, 14, 20),
"emission": EMISSION}),
"laser.floor": ([("floor-arm", "continue", ["Continue"]), "laser.floor": ([("floor-arm", "continue", ["Continue"]),
("floor-pick", "choice", ["2", "4", "6", "8", "None"])], ("floor-pick", "choice", ["2", "4", "6", "8", "None"])],
{"faintest_density": 8.0, "floor_density": 10.0, "emission": EMISSION}), {"faintest_density": 8.0, "floor_density": 10.0, "emission": EMISSION}),
@@ -50,10 +94,11 @@ SCRIPT = {
{"lit_s": 30.0, "dose_raw_s": 12000, "peak_c": 0.72, "k_density": 2.14e-5, {"lit_s": 30.0, "dose_raw_s": 12000, "peak_c": 0.72, "k_density": 2.14e-5,
"k_cw": 2.78e-5, "emission": EMISSION}), "k_cw": 2.78e-5, "emission": EMISSION}),
} }
# What each card writes when it ends. # What a card writes when it starts (the focus card's window, before its
# controller starts) and when it ends.
START_WRITES = {"laser.focus": dict(STOPS)}
WRITES = { WRITES = {
"laser.focus": {"lens_hall_edge_z_mm": "6.96", "lens_stop_below_steps": "14", "laser.focus": {"lens_hall_edge_z_mm": "6.96"},
"lens_stop_above_steps": "20"},
"laser.floor": {"laser_floor_density": "10"}, "laser.floor": {"laser_floor_density": "10"},
"laser.dose-curve": {"laser_dose_curve": "10:0.44,100:100"}, "laser.dose-curve": {"laser_dose_curve": "10:0.44,100:100"},
"laser.corner": {"laser_corner_gamma": "1.5"}, "laser.corner": {"laser_corner_gamma": "1.5"},
@@ -67,7 +112,7 @@ FOUND = {"lens_hall_edge_z_mm": "3.35", "lens_stop_below_steps": "10", "lens_sto
class RegistrationTests(unittest.TestCase): class RegistrationTests(unittest.TestCase):
def setUp(self): def setUp(self):
self.t = catalog.load_suite()["commission.sheet"] self.t = catalog.load_suite()["setup.sheet"]
def test_one_live_test_on_the_button_with_the_sheet_at_hand(self): def test_one_live_test_on_the_button_with_the_sheet_at_hand(self):
t = self.t t = self.t
@@ -78,12 +123,12 @@ class RegistrationTests(unittest.TestCase):
self.assertTrue(t.hands) self.assertTrue(t.hands)
self.assertFalse(t.fixture_runnable(("button", "lid", "interlock"))) self.assertFalse(t.fixture_runnable(("button", "lid", "interlock")))
self.assertIsNone(getattr(t, "precheck", None)) self.assertIsNone(getattr(t, "precheck", None))
for want in ("commission.check-motion", "laser.emission-witness", "cooling.flow-verify"): for want in ("setup.check-motion", "laser.emission-witness", "cooling.flow-verify"):
self.assertIn(want, t.requires) self.assertIn(want, t.requires)
def test_no_other_sheet_test_remains(self): def test_no_other_sheet_test_remains(self):
ids = [i for i in catalog.load_suite() if i.startswith("commission.sheet")] ids = [i for i in catalog.load_suite() if i.startswith("setup.sheet")]
self.assertEqual(ids, ["commission.sheet"]) self.assertEqual(ids, ["setup.sheet"])
def test_covers_the_live_runner_the_renderer_the_sender_the_page_and_the_driver(self): def test_covers_the_live_runner_the_renderer_the_sender_the_page_and_the_driver(self):
covers = set(self.t.covers) covers = set(self.t.covers)
@@ -97,7 +142,7 @@ class RegistrationTests(unittest.TestCase):
text = " ".join(self.t.steps).lower() text = " ".join(self.t.steps).lower()
self.assertIn("press the machine's button once", text) self.assertIn("press the machine's button once", text)
self.assertNotIn("when the page", text) self.assertNotIn("when the page", text)
self.assertEqual(len(commission_sheet.CARDS), 6) self.assertEqual(len(setup_sheet.CARDS), 6)
class ScriptedDaemon: class ScriptedDaemon:
@@ -115,9 +160,11 @@ class ScriptedDaemon:
self.versions = {} self.versions = {}
self.answers = {} # wid -> [values] self.answers = {} # wid -> [values]
self.order = [] # the wizards as started self.order = [] # the wizards as started
self.settings_at_start = {} # wid -> the settings as the wizard found them
self.previews = [] self.previews = []
self.queue = [] self.queue = []
self.seq = 0 self.seq = 0
self.stray_z = None # a Z the served program commands beyond the reach, when set
fake.on_get = self.on_get fake.on_get = self.on_get
fake.on_post = self.on_post fake.on_post = self.on_post
@@ -130,6 +177,10 @@ class ScriptedDaemon:
return True return True
def on_get(self, path, q): def on_get(self, path, q):
s = self.fake.state["settings"]
if path == "/status":
# the lens block follows the settings, as the daemon's does
return 200, dict(self.fake.state["status"], lens=lens_block(s))
if path == "/wiz/dark": if path == "/wiz/dark":
return 200, dict(self.dark) return 200, dict(self.dark)
if path == "/wiz": if path == "/wiz":
@@ -140,8 +191,18 @@ class ScriptedDaemon:
return 404, {"error": "no such card"} return 404, {"error": "no such card"}
return 200, b"<svg xmlns='http://www.w3.org/2000/svg'></svg>", "image/svg+xml" return 200, b"<svg xmlns='http://www.w3.org/2000/svg'></svg>", "image/svg+xml"
if path == "/wiz/sheet.gcode": if path == "/wiz/sheet.gcode":
# the frame and the text are M4 at the mark dose, as the daemon serves them # the frame and the text are M4 at the mark dose, as the daemon
return 200, b"; mock\nM4 S400\nG0 X60 Y42\nG1 X120 Y42 F3000\nM5\n", "text/plain" # serves them; the head's Z is the edge, the tail goes back to
# it, and the focus card's ladder spans the window the settings
# hold now
lens = lens_block(s)
lines = ["; mock", "G0 Z%.2f" % lens["edge_z"], "M4 S400", "G0 X60 Y42", "G1 X120 Y42 F3000"]
if q.get("card") == "laser.focus":
lines += ["G0 Z%.2f" % z for z in ladder_z(lens["edge_z"], lens["below"], lens["above"])]
if self.stray_z is not None:
lines.append("G0 Z%.2f" % self.stray_z)
lines += ["M5", "G0 Z%.2f" % lens["edge_z"], "G0 X0 Y0", "M2"]
return 200, ("\n".join(lines) + "\n").encode(), "text/plain"
return None return None
def on_post(self, path, form): def on_post(self, path, form):
@@ -151,6 +212,8 @@ class ScriptedDaemon:
if wid not in SCRIPT: if wid not in SCRIPT:
return 404, {"error": "no such wizard"} return 404, {"error": "no such wizard"}
self.order.append(wid) self.order.append(wid)
self.settings_at_start[wid] = dict(s)
s.update(START_WRITES.get(wid, {}))
self.queue = list(SCRIPT[wid][0]) self.queue = list(SCRIPT[wid][0])
self.dark.update({"id": wid, "running": True, "phase": "starting", "result": None, "error": ""}) self.dark.update({"id": wid, "running": True, "phase": "starting", "result": None, "error": ""})
self.open_next() self.open_next()
@@ -188,7 +251,7 @@ class SheetRunTests(unittest.TestCase):
self.fake.stop() self.fake.stop()
def test_the_whole_sheet_on_one_piece(self): def test_the_whole_sheet_on_one_piece(self):
t = catalog.load_suite()["commission.sheet"] t = catalog.load_suite()["setup.sheet"]
run = Run("test", t.id, t.title) run = Run("test", t.id, t.title)
run.unattended = True # the ready gate passes; the arm cue is a notice run.unattended = True # the ready gate passes; the arm cue is a notice
t.fn(Context(run, None, t)) t.fn(Context(run, None, t))
@@ -199,7 +262,7 @@ class SheetRunTests(unittest.TestCase):
self.assertEqual(d.answers["laser.focus"], ["Continue", "11", "Keep"]) self.assertEqual(d.answers["laser.focus"], ["Continue", "11", "Keep"])
self.assertEqual(d.answers["laser.floor"], ["Continue", "8"]) self.assertEqual(d.answers["laser.floor"], ["Continue", "8"])
self.assertEqual(d.answers["laser.corner"], ["Continue", "1.50"]) self.assertEqual(d.answers["laser.corner"], ["Continue", "1.50"])
self.assertEqual(d.previews, LIVE) self.assertEqual(d.previews, PREVIEWS)
cards = run.evidence["cards"] cards = run.evidence["cards"]
self.assertEqual(sorted(cards), sorted(["sheet.place"] + LIVE)) self.assertEqual(sorted(cards), sorted(["sheet.place"] + LIVE))
for wid in LIVE: for wid in LIVE:
@@ -208,6 +271,24 @@ class SheetRunTests(unittest.TestCase):
self.assertNotIn("emission", run.evidence) self.assertNotIn("emission", run.evidence)
self.assertEqual(cards["sheet.place"]["result"]["origin_x"], 100) self.assertEqual(cards["sheet.place"]["result"]["origin_x"], 100)
self.assertEqual(run.evidence["settings_before"], FOUND) self.assertEqual(run.evidence["settings_before"], FOUND)
# A fresh machine's run: the lens settings were gone before the
# frame, the frame's program ran in the fallback window, the focus
# card's window was in the settings when the floor card started,
# and the window check filed what it compared.
for k in setup_sheet.LENS_SETTINGS:
self.assertNotIn(k, d.settings_at_start["sheet.frame"], k)
self.assertEqual(run.evidence["lens_fresh"]["stops_found"], False)
self.assertEqual(run.evidence["program_z"]["sheet.frame"]["reach"], [-0.07, 7.46])
floor_start = d.settings_at_start["laser.floor"]
self.assertEqual({k: floor_start[k] for k in setup_sheet.LENS_SETTINGS},
{"lens_hall_edge_z_mm": "6.96", "lens_stop_below_steps": "14", "lens_stop_above_steps": "20"})
fw = run.evidence["focus_window"]
self.assertEqual(fw["window"], FOCUS_WINDOW)
self.assertEqual(fw["settings"]["lens_stop_below_steps"], "14")
self.assertEqual(len(fw["ladder_z"]), 12)
self.assertEqual(fw["program_z_before"]["reach"], [-0.07, 7.46]) # the fallback ladder
self.assertEqual(fw["program_z_after"]["reach"], [2.17, 13.8]) # the written window at the new edge
self.assertEqual(len(fw["program_z_after"]["z"]), 14) # head, twelve lines, tail
for k, v in FOUND.items(): for k, v in FOUND.items():
self.assertEqual(self.fake.state["settings"][k], v, k) # restored self.assertEqual(self.fake.state["settings"][k], v, k) # restored
@@ -218,7 +299,7 @@ class SheetRunTests(unittest.TestCase):
place_prompts, place_result = SCRIPT["sheet.place"] place_prompts, place_result = SCRIPT["sheet.place"]
SCRIPT["sheet.place"] = (place_prompts, dict(place_result, thickness_mm=3.175)) SCRIPT["sheet.place"] = (place_prompts, dict(place_result, thickness_mm=3.175))
try: try:
t = catalog.load_suite()["commission.sheet"] t = catalog.load_suite()["setup.sheet"]
run = Run("test", t.id, t.title) run = Run("test", t.id, t.title)
run.unattended = True run.unattended = True
t.fn(Context(run, None, t)) t.fn(Context(run, None, t))
@@ -234,7 +315,7 @@ class SheetRunTests(unittest.TestCase):
place_prompts, place_result = SCRIPT["sheet.place"] place_prompts, place_result = SCRIPT["sheet.place"]
SCRIPT["sheet.place"] = (place_prompts, dict(place_result, thickness_mm=0.0)) SCRIPT["sheet.place"] = (place_prompts, dict(place_result, thickness_mm=0.0))
try: try:
t = catalog.load_suite()["commission.sheet"] t = catalog.load_suite()["setup.sheet"]
run = Run("test", t.id, t.title) run = Run("test", t.id, t.title)
run.unattended = True run.unattended = True
with self.assertRaises(Failed) as cm: with self.assertRaises(Failed) as cm:
@@ -250,7 +331,7 @@ class SheetRunTests(unittest.TestCase):
SCRIPT_DARK["emission"] = {"hv_max": 0, "laser_on_samples": 0, "thermopile_delta": 0} SCRIPT_DARK["emission"] = {"hv_max": 0, "laser_on_samples": 0, "thermopile_delta": 0}
SCRIPT["laser.floor"] = (SCRIPT["laser.floor"][0], SCRIPT_DARK) SCRIPT["laser.floor"] = (SCRIPT["laser.floor"][0], SCRIPT_DARK)
try: try:
t = catalog.load_suite()["commission.sheet"] t = catalog.load_suite()["setup.sheet"]
run = Run("test", t.id, t.title) run = Run("test", t.id, t.title)
run.unattended = True run.unattended = True
with self.assertRaises(Failed): with self.assertRaises(Failed):
@@ -261,6 +342,44 @@ class SheetRunTests(unittest.TestCase):
finally: finally:
SCRIPT["laser.floor"] = (SCRIPT["laser.floor"][0], dict(SCRIPT_DARK, emission=EMISSION)) SCRIPT["laser.floor"] = (SCRIPT["laser.floor"][0], dict(SCRIPT_DARK, emission=EMISSION))
def test_a_focus_window_the_settings_do_not_hold_fails_the_run(self):
# The defect the bench found: the focus card burned its ladder over
# the stops it found while the controller's Z limit stood on the
# settings, which did not hold them. A daemon whose focus result
# names a window the settings do not hold fails the run before the
# floor card, and the settings still go back.
from forgetest.runner import Failed
saved = dict(START_WRITES["laser.focus"])
START_WRITES["laser.focus"] = {}
try:
t = catalog.load_suite()["setup.sheet"]
run = Run("test", t.id, t.title)
run.unattended = True
with self.assertRaises(Failed) as cm:
t.fn(Context(run, None, t))
self.assertIn("the settings hold", str(cm.exception))
self.assertEqual(self.daemon.order, ["sheet.place", "sheet.frame", "laser.focus"])
for k, v in FOUND.items():
self.assertEqual(self.fake.state["settings"][k], v, k)
finally:
START_WRITES["laser.focus"] = saved
def test_a_program_z_beyond_the_reach_fails_before_the_burn(self):
# A served program that commands a Z the lens does not reach is
# refused before the card starts: nothing burns.
from forgetest.runner import Failed
self.daemon.stray_z = 10.6
t = catalog.load_suite()["setup.sheet"]
run = Run("test", t.id, t.title)
run.unattended = True
with self.assertRaises(Failed) as cm:
t.fn(Context(run, None, t))
self.assertIn("outside the lens window", str(cm.exception))
self.assertIn("10.6", str(cm.exception))
self.assertEqual(self.daemon.order, ["sheet.place"])
for k, v in FOUND.items():
self.assertEqual(self.fake.state["settings"][k], v, k)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -1,4 +1,9 @@
"""The commission.* suite on the host: the registration (ids, kinds, the # Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The setup.* suite on the host: the registration (ids, kinds, the
takeover tests, the operator tests' hands), the record builders, the takeover tests, the operator tests' hands), the record builders, the
machine's name, the cookie parsing, the LED cue, the settle rule for a machine's name, the cookie parsing, the LED cue, the settle rule for a
gated supervisor, and the cloud-off surface test driven end to end gated supervisor, and the cloud-off surface test driven end to end
@@ -12,15 +17,15 @@ import unittest
import helpers import helpers
from forgetest import baseline, catalog from forgetest import baseline, catalog
from forgetest.runner import Context, Run from forgetest.runner import Context, Run
from forgetest.suite import commission from forgetest.suite import setup
IDS = ("commission.gate-blocks-controllers", "commission.override-until-reboot", IDS = ("setup.gate-blocks-controllers", "setup.override-until-reboot",
"commission.advisories-rehash", "commission.account-login", "commission.https-only-writes", "setup.advisories-rehash", "setup.account-login", "setup.https-only-writes",
"commission.ssh-until-reboot", "commission.cloud-disabled-surface", "setup.ssh-until-reboot", "setup.cloud-disabled-surface",
"commission.factory-return", "commission.machine-name", "commission.first-run-flow", "setup.factory-return", "setup.machine-name", "setup.first-run-flow",
"commission.first-run-page", "commission.what-changed", "commission.record-export", "setup.first-run-page", "setup.what-changed", "setup.record-export",
"commission.mirror") "setup.mirror")
OPERATOR = ("commission.first-run-flow", "commission.first-run-page") OPERATOR = ("setup.first-run-flow", "setup.first-run-page")
class RegistrationTests(unittest.TestCase): class RegistrationTests(unittest.TestCase):
@@ -35,49 +40,49 @@ class RegistrationTests(unittest.TestCase):
for tid in IDS: for tid in IDS:
if tid not in OPERATOR: if tid not in OPERATOR:
self.assertEqual(kinds[tid], "auto", tid) self.assertEqual(kinds[tid], "auto", tid)
self.assertNotIn("commission.root-ssh-refused", self.reg) # folded into ssh-until-reboot self.assertNotIn("setup.root-ssh-refused", self.reg) # folded into ssh-until-reboot
self.assertNotIn("commission.wizard-first-run", self.reg) # split into the flow and the page self.assertNotIn("setup.wizard-first-run", self.reg) # split into the flow and the page
def test_the_record_swapping_tests_are_takeovers(self): def test_the_record_swapping_tests_are_takeovers(self):
for tid in ("commission.gate-blocks-controllers", "commission.advisories-rehash", for tid in ("setup.gate-blocks-controllers", "setup.advisories-rehash",
"commission.first-run-flow", "commission.first-run-page", "setup.first-run-flow", "setup.first-run-page",
"commission.what-changed", "commission.mirror"): "setup.what-changed", "setup.mirror"):
self.assertEqual(self.reg[tid].hardware, "takeover", tid) self.assertEqual(self.reg[tid].hardware, "takeover", tid)
self.assertEqual(self.reg["commission.record-export"].hardware, "api") self.assertEqual(self.reg["setup.record-export"].hardware, "api")
def test_the_lifecycle_tests_cover_their_files(self): def test_the_lifecycle_tests_cover_their_files(self):
changed = set(self.reg["commission.what-changed"].covers) changed = set(self.reg["setup.what-changed"].covers)
export = set(self.reg["commission.record-export"].covers) export = set(self.reg["setup.record-export"].covers)
mirror = set(self.reg["commission.mirror"].covers) mirror = set(self.reg["setup.mirror"].covers)
self.assertIn(("forgectrl", "src/ui/panel.js"), changed) self.assertIn(("forgectrl", "src/ui/panel.js"), changed)
self.assertIn(("forgectrl", "src/recordhtml.*"), export) self.assertIn(("forgectrl", "src/recordhtml.*"), export)
self.assertIn(("forgectrl", "src/logs.*"), export) self.assertIn(("forgectrl", "src/logs.*"), export)
self.assertIn(("forgectrl", "src/wizdark.*"), mirror) self.assertIn(("forgectrl", "src/wizdark.*"), mirror)
self.assertIn("commission.check-sensors", self.reg["commission.mirror"].requires) self.assertIn("setup.check-sensors", self.reg["setup.mirror"].requires)
def test_flag_of_reads_the_status_document(self): def test_flag_of_reads_the_status_document(self):
w = {"required": ["airflow", {"id": "laser.floor", "reason": "the tube was replaced"}], w = {"required": ["airflow", {"id": "laser.floor", "reason": "the tube was replaced"}],
"recommended": [{"id": "laser.focus", "reason": "the tray was replaced"}]} "recommended": [{"id": "laser.focus", "reason": "the tray was replaced"}]}
self.assertEqual(commission.flag_of(w, "required", "laser.floor"), "the tube was replaced") self.assertEqual(setup.flag_of(w, "required", "laser.floor"), "the tube was replaced")
self.assertEqual(commission.flag_of(w, "recommended", "laser.focus"), "the tray was replaced") self.assertEqual(setup.flag_of(w, "recommended", "laser.focus"), "the tray was replaced")
self.assertIsNone(commission.flag_of(w, "required", "airflow")) # a plain id is the table, not a flag self.assertIsNone(setup.flag_of(w, "required", "airflow")) # a plain id is the table, not a flag
self.assertIsNone(commission.flag_of(w, "required", "laser.focus")) self.assertIsNone(setup.flag_of(w, "required", "laser.focus"))
self.assertIsNone(commission.flag_of({}, "recommended", "x")) self.assertIsNone(setup.flag_of({}, "recommended", "x"))
def test_the_first_run_flow_runs_unattended_with_the_fixture_and_the_page_walk_does_not(self): def test_the_first_run_flow_runs_unattended_with_the_fixture_and_the_page_walk_does_not(self):
flow = self.reg["commission.first-run-flow"] flow = self.reg["setup.first-run-flow"]
self.assertEqual(list(flow.actions), ["button"]) self.assertEqual(list(flow.actions), ["button"])
self.assertFalse(flow.hands) self.assertFalse(flow.hands)
self.assertTrue(flow.fixture_runnable(("button", "lid", "interlock"))) self.assertTrue(flow.fixture_runnable(("button", "lid", "interlock")))
page = self.reg["commission.first-run-page"] page = self.reg["setup.first-run-page"]
self.assertTrue(page.hands) self.assertTrue(page.hands)
self.assertFalse(page.fixture_runnable(("button", "lid", "interlock"))) self.assertFalse(page.fixture_runnable(("button", "lid", "interlock")))
self.assertIn("commission.first-run-flow", page.requires) self.assertIn("setup.first-run-flow", page.requires)
def test_the_first_run_split_keeps_the_backend_and_the_page_apart(self): def test_the_first_run_split_keeps_the_backend_and_the_page_apart(self):
flow = set(self.reg["commission.first-run-flow"].covers) flow = set(self.reg["setup.first-run-flow"].covers)
page = set(self.reg["commission.first-run-page"].covers) page = set(self.reg["setup.first-run-page"].covers)
for want in (("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"), ("forgectrl", "src/users.*"), for want in (("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"), ("forgectrl", "src/users.*"),
("forgectrl", "src/button.*"), ("forgectrl", "src/led.*"), ("forgectrl", "src/advisories.*")): ("forgectrl", "src/button.*"), ("forgectrl", "src/led.*"), ("forgectrl", "src/advisories.*")):
self.assertIn(want, flow, want) self.assertIn(want, flow, want)
self.assertNotIn(want, page, want) self.assertNotIn(want, page, want)
@@ -85,17 +90,17 @@ class RegistrationTests(unittest.TestCase):
self.assertIn(want, page, want) self.assertIn(want, page, want)
def test_the_only_attended_tests_need_a_workstation_or_the_sheet(self): def test_the_only_attended_tests_need_a_workstation_or_the_sheet(self):
# With the bench actuator up, three commissioning tests keep a # With the bench actuator up, three setup tests keep a
# person: the page walk, the print from the Glowforge app, and # person: the page walk, the print from the Glowforge app, and
# the sheet. Everything else runs from the queue. # the sheet. Everything else runs from the queue.
attended = sorted(tid for tid, t in self.reg.items() if tid.startswith("commission.") attended = sorted(tid for tid, t in self.reg.items() if tid.startswith("setup.")
and t.kind != "auto" and not t.fixture_runnable(("button", "lid", "interlock"))) and t.kind != "auto" and not t.fixture_runnable(("button", "lid", "interlock")))
self.assertEqual(attended, ["commission.cloud-header-capture", "commission.first-run-page", self.assertEqual(attended, ["setup.cloud-header-capture", "setup.first-run-page",
"commission.sheet"]) "setup.sheet"])
def test_the_factory_return_never_runs_the_return(self): def test_the_factory_return_never_runs_the_return(self):
import inspect import inspect
t = self.reg["commission.factory-return"] t = self.reg["setup.factory-return"]
src = inspect.getsource(t.fn) src = inspect.getsource(t.fn)
self.assertNotIn('"confirm": "1"', src) # the only argument that starts the return self.assertNotIn('"confirm": "1"', src) # the only argument that starts the return
self.assertIn('"confirm": "0"', src) self.assertIn('"confirm": "0"', src)
@@ -103,15 +108,15 @@ class RegistrationTests(unittest.TestCase):
self.assertFalse(t.hands) self.assertFalse(t.hands)
def test_the_machine_name_covers_nothing_by_design(self): def test_the_machine_name_covers_nothing_by_design(self):
self.assertEqual(self.reg["commission.machine-name"].covers, ()) self.assertEqual(self.reg["setup.machine-name"].covers, ())
def test_the_login_test_makes_its_own_account(self): def test_the_login_test_makes_its_own_account(self):
# No bench credentials, no precheck: the test installs a temporary # No bench credentials, no precheck: the test installs a temporary
# account under a takeover and restores the real one. # account under a takeover and restores the real one.
t = self.reg["commission.account-login"] t = self.reg["setup.account-login"]
self.assertIsNone(getattr(t, "precheck", None)) self.assertIsNone(getattr(t, "precheck", None))
self.assertEqual(t.hardware, "takeover") self.assertEqual(t.hardware, "takeover")
with open(commission.__file__) as f: with open(setup.__file__) as f:
src = f.read() src = f.read()
for k in ("FORGETEST_LOGIN_NAME", "FORGETEST_LOGIN_PASSWORD"): for k in ("FORGETEST_LOGIN_NAME", "FORGETEST_LOGIN_PASSWORD"):
self.assertNotIn(k, src) self.assertNotIn(k, src)
@@ -124,8 +129,8 @@ class RecordTests(unittest.TestCase):
"wizards": [{"id": "advisories", "version": 1}, {"id": "account", "version": 1}, "wizards": [{"id": "advisories", "version": 1}, {"id": "account", "version": 1},
{"id": "machine", "version": 2}]} {"id": "machine", "version": 2}]}
def test_complete_record_counts_as_commissioned(self): def test_complete_record_counts_as_set_up(self):
rec = commission.complete_record(self.STATUS) rec = setup.complete_record(self.STATUS)
self.assertEqual(rec["schema"], 1) self.assertEqual(rec["schema"], 1)
self.assertEqual(rec["advisories"]["safety-and-risk"]["hash"], "a" * 64) self.assertEqual(rec["advisories"]["safety-and-risk"]["hash"], "a" * 64)
self.assertEqual(rec["advisories"]["safety-and-risk"]["method"], "typed") self.assertEqual(rec["advisories"]["safety-and-risk"]["method"], "typed")
@@ -139,16 +144,16 @@ class RecordTests(unittest.TestCase):
def test_complete_record_keeps_the_base_account_and_machine(self): def test_complete_record_keeps_the_base_account_and_machine(self):
base = {"account": {"name": "owner", "uid": 1000, "created": "x"}, "machine": {"model": "pro"}, base = {"account": {"name": "owner", "uid": 1000, "created": "x"}, "machine": {"model": "pro"},
"sheet_id": "KKKKK-LLLLL", "created": "then"} "sheet_id": "KKKKK-LLLLL", "created": "then"}
rec = commission.complete_record(self.STATUS, base) rec = setup.complete_record(self.STATUS, base)
self.assertEqual(rec["account"]["name"], "owner") self.assertEqual(rec["account"]["name"], "owner")
self.assertEqual(rec["machine"], {"model": "pro"}) self.assertEqual(rec["machine"], {"model": "pro"})
self.assertEqual(rec["sheet_id"], "KKKKK-LLLLL") self.assertEqual(rec["sheet_id"], "KKKKK-LLLLL")
self.assertEqual(rec["created"], "then") self.assertEqual(rec["created"], "then")
def test_without_wizards_keeps_consent_and_account(self): def test_without_wizards_keeps_consent_and_account(self):
rec = commission.complete_record(self.STATUS) rec = setup.complete_record(self.STATUS)
rec["flags"] = {"machine": {"level": "required", "reason": "x"}} rec["flags"] = {"machine": {"level": "required", "reason": "x"}}
out = commission.record_without_wizards(rec) out = setup.record_without_wizards(rec)
self.assertEqual(out["wizards"], {}) self.assertEqual(out["wizards"], {})
self.assertEqual(out["flags"], {}) self.assertEqual(out["flags"], {})
self.assertEqual(out["advisories"], rec["advisories"]) self.assertEqual(out["advisories"], rec["advisories"])
@@ -158,14 +163,14 @@ class RecordTests(unittest.TestCase):
def test_write_file_and_remove(self): def test_write_file_and_remove(self):
tmp = tempfile.mkdtemp(prefix="forgetest-comm-") tmp = tempfile.mkdtemp(prefix="forgetest-comm-")
try: try:
p = os.path.join(tmp, "sub", "commissioning.json") p = os.path.join(tmp, "sub", "setup.json")
commission.write_file(p, b"{}\n") setup.write_file(p, b"{}\n")
self.assertEqual(commission.read_file(p), b"{}\n") self.assertEqual(setup.read_file(p), b"{}\n")
if os.name == "posix": # a mode means nothing on a Windows host if os.name == "posix": # a mode means nothing on a Windows host
self.assertEqual(oct(os.stat(p).st_mode & 0o777), oct(0o600)) self.assertEqual(oct(os.stat(p).st_mode & 0o777), oct(0o600))
commission.write_file(p, None) setup.write_file(p, None)
self.assertIsNone(commission.read_file(p)) self.assertIsNone(setup.read_file(p))
commission.write_file(p, None) # a second remove is silent setup.write_file(p, None) # a second remove is silent
finally: finally:
shutil.rmtree(tmp, ignore_errors=True) shutil.rmtree(tmp, ignore_errors=True)
@@ -173,10 +178,10 @@ class RecordTests(unittest.TestCase):
os.environ["FORGECTRL_DATA_DIR"] = "/tmp/ffdata" os.environ["FORGECTRL_DATA_DIR"] = "/tmp/ffdata"
os.environ["GF_RUN_DIR"] = "/tmp/ffrun" os.environ["GF_RUN_DIR"] = "/tmp/ffrun"
try: try:
self.assertEqual(commission.record_path(), "/tmp/ffdata/commissioning.json") self.assertEqual(setup.record_path(), "/tmp/ffdata/setup.json")
self.assertEqual(commission.users_path(), "/tmp/ffdata/users") self.assertEqual(setup.users_path(), "/tmp/ffdata/users")
self.assertEqual(commission.override_path(), "/tmp/ffrun/commissioning-override") self.assertEqual(setup.override_path(), "/tmp/ffrun/setup-override")
self.assertEqual(commission.ssh_flag_path(), "/tmp/ffrun/ssh-enabled") self.assertEqual(setup.ssh_flag_path(), "/tmp/ffrun/ssh-enabled")
finally: finally:
os.environ.pop("FORGECTRL_DATA_DIR", None) os.environ.pop("FORGECTRL_DATA_DIR", None)
os.environ.pop("GF_RUN_DIR", None) os.environ.pop("GF_RUN_DIR", None)
@@ -188,14 +193,14 @@ class MachineNameTests(unittest.TestCase):
self.addCleanup(shutil.rmtree, self.root, ignore_errors=True) self.addCleanup(shutil.rmtree, self.root, ignore_errors=True)
def _net(self, **devs): def _net(self, **devs):
"""A /sys/class/net tree; commission.mac_suffix reads it through """A /sys/class/net tree; setup.mac_suffix reads it through
commission.read_file, which takes an absolute path.""" setup.read_file, which takes an absolute path."""
for dev, mac in devs.items(): for dev, mac in devs.items():
d = os.path.join(self.root, dev) d = os.path.join(self.root, dev)
os.makedirs(d) os.makedirs(d)
with open(os.path.join(d, "address"), "w") as f: with open(os.path.join(d, "address"), "w") as f:
f.write(mac + "\n") f.write(mac + "\n")
real = commission.read_file real = setup.read_file
def read(path): def read(path):
head = "/sys/class/net/" head = "/sys/class/net/"
@@ -203,52 +208,52 @@ class MachineNameTests(unittest.TestCase):
return real(os.path.join(self.root, path[len(head):])) return real(os.path.join(self.root, path[len(head):]))
return real(path) return real(path)
commission.read_file = read setup.read_file = read
self.addCleanup(setattr, commission, "read_file", real) self.addCleanup(setattr, setup, "read_file", real)
def test_the_wifi_address_names_the_machine(self): def test_the_wifi_address_names_the_machine(self):
self._net(wlan0="2C:6B:7D:0D:B0:0A", eth0="00:11:22:33:44:55") self._net(wlan0="2C:6B:7D:0D:B0:0A", eth0="00:11:22:33:44:55")
self.assertEqual(commission.mac_suffix(), "b00a") self.assertEqual(setup.mac_suffix(), "b00a")
def test_a_machine_with_no_wifi_falls_back_to_the_wired_address(self): def test_a_machine_with_no_wifi_falls_back_to_the_wired_address(self):
self._net(eth0="00:11:22:33:44:55") self._net(eth0="00:11:22:33:44:55")
self.assertEqual(commission.mac_suffix(), "4455") self.assertEqual(setup.mac_suffix(), "4455")
def test_an_unread_address_is_no_address(self): def test_an_unread_address_is_no_address(self):
self._net(wlan0="00:00:00:00:00:00") self._net(wlan0="00:00:00:00:00:00")
self.assertEqual(commission.mac_suffix(), "") self.assertEqual(setup.mac_suffix(), "")
def test_no_interface_is_no_address(self): def test_no_interface_is_no_address(self):
self._net() self._net()
self.assertEqual(commission.mac_suffix(), "") self.assertEqual(setup.mac_suffix(), "")
class SmallHelpersTests(unittest.TestCase): class SmallHelpersTests(unittest.TestCase):
def test_cookie_parsing(self): def test_cookie_parsing(self):
sid = "ab" * 32 sid = "ab" * 32
value = "ffsid=%s; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=43200" % sid value = "ffsid=%s; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=43200" % sid
self.assertEqual(commission.session_from_cookie(value), sid) self.assertEqual(setup.session_from_cookie(value), sid)
self.assertEqual(commission.cookie_flags(value), {"path", "httponly", "secure", "samesite", "max-age"}) self.assertEqual(setup.cookie_flags(value), {"path", "httponly", "secure", "samesite", "max-age"})
self.assertIsNone(commission.session_from_cookie("ffsid=; Path=/; Max-Age=0")) self.assertIsNone(setup.session_from_cookie("ffsid=; Path=/; Max-Age=0"))
self.assertIsNone(commission.session_from_cookie("")) self.assertIsNone(setup.session_from_cookie(""))
def test_the_teal_breathe_cue(self): def test_the_teal_breathe_cue(self):
self.assertTrue(commission.breathes_teal({"target": [0, 180, 200], "pulse_on": [0, 1400, 1400], self.assertTrue(setup.breathes_teal({"target": [0, 180, 200], "pulse_on": [0, 1400, 1400],
"pulse_off": [0, 1400, 1400]})) "pulse_off": [0, 1400, 1400]}))
self.assertFalse(commission.breathes_teal({"target": [0, 255, 40], "pulse_on": [0, 0, 0], self.assertFalse(setup.breathes_teal({"target": [0, 255, 40], "pulse_on": [0, 0, 0],
"pulse_off": [0, 0, 0]})) # solid green "pulse_off": [0, 0, 0]})) # solid green
self.assertFalse(commission.breathes_teal({"target": [200, 200, 200], "pulse_on": [1800, 1800, 1800], self.assertFalse(setup.breathes_teal({"target": [200, 200, 200], "pulse_on": [1800, 1800, 1800],
"pulse_off": [1800] * 3})) # white "pulse_off": [1800] * 3})) # white
self.assertFalse(commission.breathes_teal({"target": [None, None, None], "pulse_on": [None] * 3})) self.assertFalse(setup.breathes_teal({"target": [None, None, None], "pulse_on": [None] * 3}))
self.assertFalse(commission.breathes_teal({})) self.assertFalse(setup.breathes_teal({}))
def test_decode(self): def test_decode(self):
self.assertEqual(commission.decode(b'{"a": 1}'), {"a": 1}) self.assertEqual(setup.decode(b'{"a": 1}'), {"a": 1})
self.assertEqual(commission.decode(b"cloud mode is not enabled"), "cloud mode is not enabled") self.assertEqual(setup.decode(b"cloud mode is not enabled"), "cloud mode is not enabled")
class GatedSettleTests(unittest.TestCase): class GatedSettleTests(unittest.TestCase):
"""A supervisor that reports the commissioning gate closed is settled: """A supervisor that reports the setup gate closed is settled:
it spawns nothing until the gate opens, so a takeover that installs it spawns nothing until the gate opens, so a takeover that installs
a gating record must not wait the whole settle timeout for it.""" a gating record must not wait the whole settle timeout for it."""
@@ -261,7 +266,7 @@ class GatedSettleTests(unittest.TestCase):
def test_gated_returns_at_once(self): def test_gated_returns_at_once(self):
self.fake.state["mode"] = {"mode": "grbl", "controller": "gated", "pid": 0, "motion": "unverified", self.fake.state["mode"] = {"mode": "grbl", "controller": "gated", "pid": 0, "motion": "unverified",
"gated": True, "why": "commissioning required: machine"} "gated": True, "why": "setup required: machine"}
lines = [] lines = []
body = baseline.Baseline(lines.append).wait_settled(timeout=8) body = baseline.Baseline(lines.append).wait_settled(timeout=8)
self.assertEqual(body["controller"], "gated") self.assertEqual(body["controller"], "gated")
@@ -284,7 +289,7 @@ class FirstRunSeedTests(unittest.TestCase):
"advisories": {"safety-and-risk": {"hash": "x"}}, "acceptance": {"pressed_at": "t"}, "advisories": {"safety-and-risk": {"hash": "x"}}, "acceptance": {"pressed_at": "t"},
"account": {"name": "scott", "uid": 1000}, "flags": {"flow_thin": True}, "account": {"name": "scott", "uid": 1000}, "flags": {"flow_thin": True},
"wizards": {"advisories": {"version": 1}, "switches": {"version": 1, "result": {"lid": True}}}} "wizards": {"advisories": {"version": 1}, "switches": {"version": 1, "result": {"lid": True}}}}
rec = commission.first_run_record(self.STATUS, base) rec = setup.first_run_record(self.STATUS, base)
self.assertNotIn("completed", rec) self.assertNotIn("completed", rec)
self.assertNotIn("acceptance", rec) self.assertNotIn("acceptance", rec)
self.assertNotIn("account", rec) self.assertNotIn("account", rec)
@@ -296,7 +301,7 @@ class FirstRunSeedTests(unittest.TestCase):
self.assertEqual(rec["wizards"]["motion"]["version"], 2) # written at the catalog version self.assertEqual(rec["wizards"]["motion"]["version"], 2) # written at the catalog version
def test_without_a_base_record(self): def test_without_a_base_record(self):
rec = commission.first_run_record(self.STATUS, None) rec = setup.first_run_record(self.STATUS, None)
self.assertEqual(sorted(rec["wizards"]), ["motion", "sheet.frame", "switches"]) self.assertEqual(sorted(rec["wizards"]), ["motion", "sheet.frame", "switches"])
self.assertEqual(rec["wizards"]["sheet.frame"]["version"], 1) self.assertEqual(rec["wizards"]["sheet.frame"]["version"], 1)
@@ -312,7 +317,7 @@ class SshdPolicyTests(unittest.TestCase):
real = hw.run real = hw.run
hw.run = fake_run hw.run = fake_run
try: try:
self.assertEqual(commission.sshd_policy(), {"permitrootlogin": "no", "passwordauthentication": "yes", self.assertEqual(setup.sshd_policy(), {"permitrootlogin": "no", "passwordauthentication": "yes",
"permitemptypasswords": "no"}) "permitemptypasswords": "no"})
finally: finally:
hw.run = real hw.run = real
@@ -323,7 +328,7 @@ class SshdPolicyTests(unittest.TestCase):
real = hw.run real = hw.run
hw.run = lambda cmd, timeout=60: (1, "sshd: no hostkeys available") hw.run = lambda cmd, timeout=60: (1, "sshd: no hostkeys available")
try: try:
self.assertEqual(commission.sshd_policy(), {"error": "sshd: no hostkeys available"}) self.assertEqual(setup.sshd_policy(), {"error": "sshd: no hostkeys available"})
finally: finally:
hw.run = real hw.run = real
@@ -375,7 +380,7 @@ class CloudDisabledSurfaceTests(unittest.TestCase):
self.fake.stop() self.fake.stop()
def run_test(self): def run_test(self):
t = catalog.load_suite()["commission.cloud-disabled-surface"] t = catalog.load_suite()["setup.cloud-disabled-surface"]
run = Run("test", t.id, t.title) run = Run("test", t.id, t.title)
t.fn(Context(run, None, t)) t.fn(Context(run, None, t))
return run return run
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""scripts/source-bundle.py - the source that a release publishes. """scripts/source-bundle.py - the source that a release publishes.
The bundle must hold the source of every recipe of the image whose license The bundle must hold the source of every recipe of the image whose license
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""scripts/manifest-from-tree.py - the workstation/CI mirror of the image """scripts/manifest-from-tree.py - the workstation/CI mirror of the image
manifest: recipe pins are read through their pin files, and a layer's manifest: recipe pins are read through their pin files, and a layer's
content hash leaves the pin files out (a component bump is not a platform content hash leaves the pin files out (a component bump is not a platform
+1 -1
View File
@@ -11,4 +11,4 @@ overrides:
meta-freescale-distro: meta-freescale-distro:
commit: b9d6a5d9931922558046d230c1f5f4ef6ee72345 commit: b9d6a5d9931922558046d230c1f5f4ef6ee72345
meta-openglow: meta-openglow:
commit: ced2af274ba270fd8fc9a6601bbd3fc6247110a6 commit: 71fad52e8ff5695bce3b9c6bc8530a1468426819
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: MIT
# forgefirm-image-manifest.bbclass - assemble /etc/forgefirm-manifest.json # forgefirm-image-manifest.bbclass - assemble /etc/forgefirm-manifest.json
# #
# Inherited by the ForgeFIRM images. At rootfs postprocess it collects the # Inherited by the ForgeFIRM images. At rootfs postprocess it collects the
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: MIT
# forgefirm-manifest.bbclass - source fingerprint of one ForgeFIRM component # forgefirm-manifest.bbclass - source fingerprint of one ForgeFIRM component
# #
# A component recipe that inherits this class records what source it was # A component recipe that inherits this class records what source it was
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: MIT
require conf/distro/include/fslc-base.inc require conf/distro/include/fslc-base.inc
DISTRO = "forgefirm" DISTRO = "forgefirm"
+2
View File
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: MIT
# We have a conf and classes directory, add to BBPATH # We have a conf and classes directory, add to BBPATH
BBPATH .= ":${LAYERDIR}" BBPATH .= ":${LAYERDIR}"
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: MIT
# NXP distributes the i.MX VPU/EPDC firmware blobs under its firmware EULA # NXP distributes the i.MX VPU/EPDC firmware blobs under its firmware EULA
# (accepted with ACCEPT_FSL_EULA), so an image carrying those blobs has to # (accepted with ACCEPT_FSL_EULA), so an image carrying those blobs has to
# carry the license text with them. # carry the license text with them.
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: MIT
# ForgeFIRM SSH policy, set in the installed files so the release image # ForgeFIRM SSH policy, set in the installed files so the release image
# carries it as built: # carries it as built:
# PermitRootLogin no root logs in at the serial console only # PermitRootLogin no root logs in at the serial console only
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: MIT
# ForgeFIRM image manifest: source fingerprint of the hardware library # ForgeFIRM image manifest: source fingerprint of the hardware library
# (forgefirm-manifest.bbclass in this layer). # (forgefirm-manifest.bbclass in this layer).
inherit forgefirm-manifest inherit forgefirm-manifest
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: MIT
# ForgeFIRM image manifest: source fingerprint of the cloud-mode service # ForgeFIRM image manifest: source fingerprint of the cloud-mode service
# layer (forgefirm-manifest.bbclass in this layer). # layer (forgefirm-manifest.bbclass in this layer).
inherit forgefirm-manifest inherit forgefirm-manifest
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: MIT
# urllib3's optional pyOpenSSL/cryptography backend is not used here: the cloud # urllib3's optional pyOpenSSL/cryptography backend is not used here: the cloud
# client and the machine glue talk TLS through the standard library's ssl # client and the machine glue talk TLS through the standard library's ssl
# module (nothing imports OpenSSL or cryptography). Dropping the two runtime # module (nothing imports OpenSSL or cryptography). Dropping the two runtime
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: MIT
SUMMARY = "Configurable embedded Linux firmware update creator and runner" SUMMARY = "Configurable embedded Linux firmware update creator and runner"
DESCRIPTION = "Applies and creates signed .fw firmware archives. ForgeFIRM \ DESCRIPTION = "Applies and creates signed .fw firmware archives. ForgeFIRM \
uses the factory's own update format: fwup applies ForgeFIRM upgrades and \ uses the factory's own update format: fwup applies ForgeFIRM upgrades and \
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: MIT
DESCRIPTION = "Potluck with different functions for C programs (babelouest base library)" DESCRIPTION = "Potluck with different functions for C programs (babelouest base library)"
HOMEPAGE = "https://github.com/babelouest/orcania" HOMEPAGE = "https://github.com/babelouest/orcania"
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: MIT
# /etc/rsyslog.conf - ForgeFIRM # /etc/rsyslog.conf - ForgeFIRM
# #
# rsyslog is the only log writer on the machine. Every ForgeFIRM process # rsyslog is the only log writer on the machine. Every ForgeFIRM process
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: MIT
# rsyslog is the ForgeFIRM system logger: the only log writer, one # rsyslog is the ForgeFIRM system logger: the only log writer, one
# directory per logger under /data/log/forgefirm. This appends the # directory per logger under /data/log/forgefirm. This appends the
# ForgeFIRM /etc/rsyslog.conf (inputs, line format, and the include of # ForgeFIRM /etc/rsyslog.conf (inputs, line format, and the include of
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: MIT
DESCRIPTION = "Web Framework to build REST APIs, Webservices or any HTTP endpoint in C" DESCRIPTION = "Web Framework to build REST APIs, Webservices or any HTTP endpoint in C"
HOMEPAGE = "https://github.com/babelouest/ulfius" HOMEPAGE = "https://github.com/babelouest/ulfius"
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: MIT
DESCRIPTION = "Logging library for C applications (babelouest)" DESCRIPTION = "Logging library for C applications (babelouest)"
HOMEPAGE = "https://github.com/babelouest/yder" HOMEPAGE = "https://github.com/babelouest/yder"
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: MIT
SUMMARY = "Boot-slot selection and inventory for Glowforge factory hardware" SUMMARY = "Boot-slot selection and inventory for Glowforge factory hardware"
DESCRIPTION = "Inventories the bootable partitions (ffboot -l) and switches \ DESCRIPTION = "Inventories the bootable partitions (ffboot -l) and switches \
the boot target by rewriting the saved U-Boot environment with read-back \ the boot target by rewriting the saved U-Boot environment with read-back \
@@ -1,6 +1,8 @@
# SPDX-License-Identifier: MIT
# forgectrl pin. Bump deliberately after pushing forgectrl changes; keep # forgectrl pin. Bump deliberately after pushing forgectrl changes; keep
# only SRCREV and PV here - the image manifest leaves *-pin.inc out of the # only SRCREV and PV here - the image manifest leaves *-pin.inc out of the
# layer content hash because the component entry already identifies the # layer content hash because the component entry already identifies the
# pinned source (forgefirm-image-manifest.bbclass). # pinned source (forgefirm-image-manifest.bbclass).
SRCREV = "a9e7073b2325c8c9d89d490fe6d067abfac22da5" SRCREV = "0dae758cb93e1461034aaa58f4b9fc22edc68367"
PV = "0.1.17" PV = "0.1.27"
@@ -1,8 +1,10 @@
# SPDX-License-Identifier: MIT
DESCRIPTION = "System Control Daemon for ForgeFIRM powered Glowforge" DESCRIPTION = "System Control Daemon for ForgeFIRM powered Glowforge"
HOMEPAGE = "https://github.com/openglow-org/forgectrl" HOMEPAGE = "https://github.com/openglow-org/forgectrl"
LICENSE = "MIT" LICENSE = "MIT"
LIC_FILES_CHKSUM = "file://LICENSE;md5=fe9dfc6caad62544b4b1d0fa55c4b92f" LIC_FILES_CHKSUM = "file://LICENSE;md5=785c97b59b518e7ad943cd9e8b15fc97"
PE = "1" PE = "1"
@@ -1,12 +1,14 @@
# SPDX-License-Identifier: MIT
# forgefirm-app pin (python3-gfhardware repository, forgefirm-app/ tree). # forgefirm-app pin (python3-gfhardware repository, forgefirm-app/ tree).
# Bump deliberately (AUTOREV is not reproducible); keep only SRCREV and PV # Bump deliberately (AUTOREV is not reproducible); keep only SRCREV and PV
# here - the image manifest leaves *-pin.inc out of the layer content hash # here - the image manifest leaves *-pin.inc out of the layer content hash
# because the component entry already identifies the pinned source # because the component entry already identifies the pinned source
# (forgefirm-image-manifest.bbclass). The python3-gfhardware recipe in # (forgefirm-image-manifest.bbclass). The python3-gfhardware recipe in
# meta-glowforge-bsp pins the same repository; move both together. # meta-glowforge-bsp pins the same repository; move both together.
SRCREV = "56f134a1e6393a2096c16e8226027fbbd38d79c7" SRCREV = "6cc4f45d311ab451c7d72dee009103e55a749675"
# Bump PV with every SRCREV move: the hash-derived package version is not # Bump PV with every SRCREV move: the hash-derived package version is not
# monotonic on its own and buildhistory QA fails the build when it sorts # monotonic on its own and buildhistory QA fails the build when it sorts
# backwards. # backwards.
PV = "0.1.28+git" PV = "0.1.30+git"
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: MIT
# Shared fetch for the ForgeFIRM web-service apps (gfhome, python3-ffmachine, # Shared fetch for the ForgeFIRM web-service apps (gfhome, python3-ffmachine,
# gfcloud). Their sources live in the forgefirm-app/ directory of the # gfcloud). Their sources live in the forgefirm-app/ directory of the
# python3-gfhardware repository. # python3-gfhardware repository.
@@ -7,7 +9,7 @@ HOMEPAGE = "https://github.com/openglow-org/python3-gfhardware"
# repository's LGPL component (the Bayer decoder compiled into # repository's LGPL component (the Bayer decoder compiled into
# gfhardware._cam) is not part of them; python3-gfhardware declares it. # gfhardware._cam) is not part of them; python3-gfhardware declares it.
LICENSE = "MIT" LICENSE = "MIT"
LIC_FILES_CHKSUM = "file://LICENSE;md5=7029670f29302d9352647c6c89614bc2" LIC_FILES_CHKSUM = "file://LICENSE;md5=314d21c23b626bdadbb4801c47b16266"
SRC_URI = "git://github.com/openglow-org/python3-gfhardware.git;protocol=https;branch=master" SRC_URI = "git://github.com/openglow-org/python3-gfhardware.git;protocol=https;branch=master"
# SRCREV and PV live in the pin file (forgefirm-image-manifest.bbclass). # SRCREV and PV live in the pin file (forgefirm-image-manifest.bbclass).
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: MIT
DESCRIPTION = "Full Glowforge web-service controller daemon for ForgeFIRM (cloud mode)" DESCRIPTION = "Full Glowforge web-service controller daemon for ForgeFIRM (cloud mode)"
require forgefirm-app.inc require forgefirm-app.inc
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: MIT
DESCRIPTION = "One-shot Glowforge web-service homing for ForgeFIRM" DESCRIPTION = "One-shot Glowforge web-service homing for ForgeFIRM"
require forgefirm-app.inc require forgefirm-app.inc
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: MIT
DESCRIPTION = "Shared ForgeFIRM web-service hardware-machine glue (gfhome + gfcloud)" DESCRIPTION = "Shared ForgeFIRM web-service hardware-machine glue (gfhome + gfcloud)"
require forgefirm-app.inc require forgefirm-app.inc
@@ -1,4 +1,8 @@
#!/bin/sh #!/bin/sh
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
### BEGIN INIT INFO ### BEGIN INIT INFO
# Provides: forgefirm-banner # Provides: forgefirm-banner
# Required-Start: $network # Required-Start: $network
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: MIT
SUMMARY = "ForgeFIRM console banner: the control panel addresses in /etc/issue" SUMMARY = "ForgeFIRM console banner: the control panel addresses in /etc/issue"
DESCRIPTION = "Keeps an address block in the serial-console login banner \ DESCRIPTION = "Keeps an address block in the serial-console login banner \
(/etc/issue): one control panel URL per global address of wlan0 and eth0. \ (/etc/issue): one control panel URL per global address of wlan0 and eth0. \
@@ -1,4 +1,8 @@
#!/bin/sh #!/bin/sh
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
### BEGIN INIT INFO ### BEGIN INIT INFO
# Provides: forgefirm-hostname # Provides: forgefirm-hostname
# Required-Start: $local_fs # Required-Start: $local_fs
@@ -1,3 +1,5 @@
# SPDX-License-Identifier: MIT
SUMMARY = "ForgeFIRM hostname: forgefirm-<xxxx> from the MAC address" SUMMARY = "ForgeFIRM hostname: forgefirm-<xxxx> from the MAC address"
DESCRIPTION = "Names the machine forgefirm-<xxxx>, where xxxx is the last \ DESCRIPTION = "Names the machine forgefirm-<xxxx>, where xxxx is the last \
four hex digits of the wlan0 MAC address (eth0 on a machine with no WiFi). \ four hex digits of the wlan0 MAC address (eth0 on a machine with no WiFi). \
@@ -0,0 +1,27 @@
Glowforge factory keyring
=========================
Eleven Ed25519 *public* keys, 32 raw bytes each, recovered from Glowforge
factory firmware. They are the factory trust anchors: ForgeFIRM ships them so
the machine can verify the signature on a factory `.fw` archive during a
factory restore. They verify archives and nothing else.
Public key material only. No private or secret key belongs in this directory,
and nothing here can be used to sign anything. ForgeFIRM's own release-signing
public key is `../forgefirm-release.pub`; its private half is held offline by
the operator and is not in this repository, in CI, or in any cloud-synced
store.
Provenance and rights
---------------------
These keys are Glowforge, Inc.'s. They are bare cryptographic public keys --
algorithm output, carrying no authorship -- so no copyright subsists in them
and none is claimed. 514 LLC d/b/a OpenGlow asserts no rights over them and
grants none; the recipe's `LICENSE = "MIT"` covers the recipe, not this key
material. Glowforge has not published these keys itself, so there is no
upstream license to point to and none is implied.
For how the trust anchors are used, see "The update manager" and "Decisions"
on the documentation site:
https://docs.forgefirm.org/technical/forgefirm/install-and-update/

Some files were not shown because too many files have changed in this diff Show More