223 Commits
Author SHA1 Message Date
ScottW514 c40dc6c24c Catalog: setup.machine-name leaves for the rootfs lint and test_image.py
Its checks, and what holds each now:
- the name is forgefirm-<the last four hex digits of the MAC>, wlan0
  first and eth0 on a machine with no WiFi, and the console banner
  offers the addresses and nothing else: test_image.py's
  MachineNameTests run forgefirm-hostname and forgefirm-banner as the
  image runs them, on a scratch /sys/class/net;
- /etc/hostname is the bind-mounted copy: image.health;
- the DHCP request sends the name, and no mDNS responder is on the
  image: the rootfs lint's dhcp-name and mdns rules (08c7df9), which
  image 20261015142442 ran in do_rootfs and which passed campaign
  c-20261015143634-68f0, 48 of 48.
It covered no component path, so no HOST_TESTED row. Its helpers
mac_suffix and cmdlines had no other caller and go with it, with
test_setup_suite's cases of them and of its registration; setup.py's re
and lan_ip imports went with them.

Fingerprints: every test in setup.py moves with the module text.

Proof: the coverage lint over the tree manifest from the pins finds 0
uncovered paths and 0 host-tested row problems (47 tests); pyflakes on
the touched files is clean; forgetest's unit suite under Linux passes
(329).
2026-10-15 11:28:25 -04:00
ScottW514 08c7df9cbb rootfs-lint.sh: the image content fixed at build time, checked as the last rootfs step
A new script beside the image recipe, run by both images as the last
rootfs step (ROOTFS_POSTPROCESS_COMMAND:append, after every +=, the dev
recipe's and debug-tweaks' included); a refusal is bbfatal. It checks
every rule and prints every failure before it exits. FORGEFIRM_IMAGE_KIND
(release by default, dev in forgefirm-image-dev.bb) picks the sshd policy.
The rules:

- syslog: /usr/sbin/rsyslogd is on the image, /etc/init.d/syslog starts
  it and runlevel 5 starts that script; no syslogd or klogd is linked in
  a bin directory and no init script runs one. This is the image half of
  what logs.routing checked on the board; that test left in 01f5991 with
  nothing else holding it.
- sshd: sshd_config and sshd_config_readonly take passwords
  (PasswordAuthentication yes) and send no banner; on a release image
  PermitRootLogin no and PermitEmptyPasswords no; sshd_config.d sets none
  of the four, since an included value comes first. setup.ssh-until-
  reboot checked the first two on the board and left in 01f5991;
  release.sh gates the release pair at release time only.
- dhcp-name: the wlan0 dhcp stanza's udhcpc_opts carries
  -x hostname:$(hostname).
- mdns: no /etc/avahi, avahi-daemon or libnss_mdns, and no mdns source
  on nsswitch.conf's hosts line.
dhcp-name and mdns are the image half of setup.machine-name, whose
scripts test_image.py's MachineNameTests already run.

test_image.py's RootfsLintTests run it on a fixture tree: the tree that
holds every rule passes as either image, the BSP layer's own interfaces
file passes, and one drift per rule fails naming the rule (a busybox
syslogd link; a Banner, on either image; root and an empty password,
refused on a release image and passed on a dev one; a dhcp stanza with
no name; /etc/avahi).

Proof: test_image.py under Linux, 21 OK; pyflakes clean. On image
20261015134521's two rootfs trees from the build, the release tree
passes as release, the dev tree passes as dev, and the dev tree linted
as release is refused on PermitRootLogin and PermitEmptyPasswords in
both sshd files. bitbake -e places rootfs_lint last in both images'
ROOTFS_POSTPROCESS_COMMAND, with FORGEFIRM_IMAGE_KIND release and dev.
The layer content hash moves: the next image cannot inherit a campaign.
2026-10-15 10:19:54 -04:00
ScottW514 61652e237d Catalog: exthost.motion-job leaves, after it passed on forgeext 31cdb40
exthost.motion-job stayed in 01f5991 as the bench proof of
machine_host_token() on the default token path. forgeext 31cdb40, the
revision c177aab pins, makes the token file overridable
(FORGEEXT_HOST_TOKEN_FILE) and keeps /run/forgefirm/ext-host.token when
the variable is unset or empty. The recipe's binary from that revision
(sha256 15ca6003b59f69fb9eadf686094430fbbd25e7cc177230a0417738484fe3427e)
ran on the bench reference with the variable unset, and
exthost.platform, exthost.service, motion.jog-roundtrip and
exthost.motion-job passed on it (campaign c-20261015103714-0c91): the
package's job reached the machine as job:org.forgetest.reference through
the credential read from the default file.

Its paths, now:
- forgectrl src/jobpost.*: tests/jobpost_test.c, POST /job's multipart
  reader (row added).
- forgectrl src/tokens.*: tests/tokens_test.c, the scoped tokens and
  the extension host's credential file (row added).
- forgectrl src/jobrun.*: still covered by the burn tests.
- forgeext src/api.*, src/machine.*, src/run.*: still covered by
  exthost.service's forgeext/**.
Both rows name tests forgectrl's build.yml runs at the pinned 22a2cfc.
No other catalog test required exthost.motion-job. exthost.py's hw
import went with it.

Fingerprints: every exthost test moves with the module text. The next
image owes exthost.service and exthost.armed-freeze for forgeext's
source change.

Proof: the coverage lint over the tree manifest from the pins finds 0
uncovered paths and 0 host-tested row problems (48 tests); pyflakes on
the touched files is clean; forgetest's unit suite under Linux passes
(327).
2026-10-15 09:38:20 -04:00
ScottW514 c177aabefa Pin forgeext 0.1.2 on the pushed head
- forgeext 2d0391e -> 31cdb40 (0.1.1 -> 0.1.2): machine_host_token()
  opens FORGEEXT_HOST_TOKEN_FILE when it is set and not empty, and
  /run/forgefirm/ext-host.token otherwise (ebdbf59); the rest is host
  tests (run_test's sender claim, event stream, storage quota and
  memory.max; install_test's served page; the trimmed kit, ffx and
  allowlist cases).

Proof: the recipe's forgeext, built from 31cdb40 (bitbake forgeext
through an untracked externalsrc overlay; packaged /usr/bin/forgeext
sha256 15ca6003b59f69fb9eadf686094430fbbd25e7cc177230a0417738484fe3427e),
replaced /usr/bin/forgeext on the bench reference's image
20261015101725 and ran with no FORGEEXT_HOST_TOKEN_FILE in its
environment. exthost.platform, exthost.service, motion.jog-roundtrip
and exthost.motion-job passed on it, in campaign c-20261015103714-0c91:
the package's job reached the machine as job:org.forgetest.reference
through the host's credential read from the default token file, 6 lines
sent and acknowledged, the head moved, the latch locked and no emission
throughout. The flashed binary was put back after the run. bitbake -c
fetch forgeext resolves this pin.
2026-10-15 09:31:54 -04:00
ScottW514 01f59914e8 Catalog: forgectrl.panel-serves, setup.ssh-until-reboot, setup.first-run-flow and logs.routing leave
Each leaves for a host proof that forgectrl's build.yml runs at the
pinned 22a2cfc, and that run passed (38728407038):

- forgectrl.panel-serves: tests/host_routes.sh serves the panel page
  bundled, with the theme and the save bar and the policy frame-src
  'none', and answers /status, /cam/status and the dose-curve reads.
  Rows: src/ui.h.
- setup.ssh-until-reboot: tests/host_routes.sh, GET and POST
  /system/ssh with the flag in the run directory and sshd's init a
  stand-in told start and stop; enable=2 refused. Rows: src/paths.h.
- setup.first-run-flow: tests/host_setup.sh, the first run in the
  page's order: the four documents accepted at their hashes, the press
  step breathing teal on a scratch LED tree, the press taken as the
  acceptance, the account and its cookie, the gate's reason after each
  form step, the run completed. Rows: src/advisories.*, src/button.*,
  src/led.*, src/hooks.h and the four first-run documents, one row
  each (docs/advisories/extensions.md stays with exthost.service);
  src/sheetid.* to tests/sheetid_test.c, the golden ids, because
  host_setup.sh does not read the sheet id.
- logs.routing: tests/fflog_e2e.sh, the image's rsyslog.conf and the
  rendered rules, each logger in its own file at its level, a stray
  tag in system/, the kern facility in kernel/. Rows: src/fflog.*.

logs.py held logs.routing alone and goes with its import. The five
camera tests no longer require forgectrl.panel-serves.
test_setup_suite.py keeps setup.account-login and setup.machine-name in
its ids; the cases that described setup.first-run-flow (takeover,
fixture, covers) go, with the cases of the helpers that left with the
two tests (breathes_teal, first_run_record, sshd_policy).
test_manifest's behavioral-path case runs the report with no
host-tested rows: the real table now names the first-run documents,
and the case is about the allowlist.

exthost.motion-job stays. It is the only catalog test that grants
motion.job and posts /v0/motion/job, the path that reads
machine_host_token() on the default token file, and it stays the bench
proof of that path until an image carries forgeext's token-file change.
src/jobpost.* stays in its covers, with no row.

Fingerprints: every test in forgectrl.py, setup.py and camera.py moves
with the module text. The next image cannot inherit a campaign.

Proof: the coverage lint over the tree manifest from the pins finds 0
uncovered paths and 0 host-tested row problems (49 tests); pyflakes on
the touched files is clean; forgetest's unit suite under Linux passes.
2026-10-15 08:48:40 -04:00
ScottW514 87155be433 Pin forgectrl 0.1.32 on the pushed head
- forgectrl c9e3347 -> 22a2cfc (0.1.31 -> 0.1.32): fflog_e2e runs its
  private rsyslogd from a copy of the binary, out of reach of Ubuntu's
  AppArmor profile for /usr/sbin/rsyslogd. The diff is
  tests/fflog_e2e.sh alone; nothing the image installs changes.

At c9e3347 the CI run stopped at fflog_e2e, so host_routes.sh and
host_setup.sh, later in the same job, never ran there. The rows the
next catalog commit adds name those two scripts and fflog_e2e.sh, and
the coverage lint reads build.yml at this pin, so the pin is the
revision whose run executed them.

Proof: forgectrl's CI run on 22a2cfc (38728407038) is green;
fflog_e2e, host_routes, host_setup and jobpost_test passed in it.
2026-10-15 08:13:41 -04:00
ScottW514 893602e44c Pin grblHAL-glowforge 0.1.21 on the pushed head
- grblHAL-glowforge b34636f -> badc832 (0.1.20 -> 0.1.21): the host
  tests trimmed to one proof per rule, with the cases the cut dropped
  back (the tray, session-budget and envelope clamps, the switch bits
  as numbers, an M5 then an M3 inside the armed window); a host build
  may shorten the M-code answer wait (-DGFMCODE_WAIT_S, CI's 5 s, for
  mcode_test.py's no-answer case) while the image keeps 30 s; the
  homing comment covers a setup step's hand-back. The driver the image
  builds is unchanged in behavior.

It follows 205f9f2 on its own because the driver's CI runs this
repository's harnesses from master, and its mcode_test expects the
5 s host build.

Proof: image 20261015101725 carried the driver built from badc832 (its
manifest's srcrev), and campaign c-20261015103714-0c91 passed all 53
catalog tests on it with none inherited. bitbake -c fetch
grblhal-glowforge from this pin succeeds (2 tasks). The tree manifest
from the pins holds grblhal-glowforge at badc832 (176 files); the
coverage lint over it finds no uncovered path, no empty entry and no
host-tested row problem (53 tests); test_tree_manifest passes. The
driver's CI on badc832, with this repository's harnesses at 205f9f2,
passes.
2026-10-15 07:41:32 -04:00
ScottW514 205f9f22f2 Pin forgectrl 0.1.31 and forgefirm-app 0.1.32 on the pushed heads, and meta-openglow in the kas lock
- forgectrl c274f19 -> c9e3347 (0.1.30 -> 0.1.31): setup is required
  up to the intake fans and recommended after, the coolant flow
  calibration is a required step, the TEC check gives way to the
  operator's answer, the head's id is head/info's "id" line, the
  intake fan count is decided in wizcalc, and the burns hand the lens
  back referenced; every start logs its firmware and slot, and every
  update job is logged; the beam sensor is recorded and logged; the
  panel's emission help parses again. The daemon starts on a host with
  its machine paths, release URLs and controller binaries from the
  environment, and the host cases for the routes, the tokens, the
  setup, the logs, the extension host and the cooling engine run
  through it in CI.
- forgefirm-app 021323d -> 0f59c54 (0.1.31+git -> 0.1.32+git): the
  python3-gfhardware head, host tests only; it moves with the BSP's
  python3-gfhardware pin.
- kas lock: meta-openglow f012d8e -> b7626be, the head that pins the
  kernel module f924cd9, python3-gfhardware 0f59c54 and gfutilities
  993f9e1.

grblHAL-glowforge keeps b34636f here: its CI runs this repository's
harnesses from master, and its head's mcode_test expects the 5 s host
build, so the driver is pushed after this and pinned on its own.

Proof: image 20261015101725 carried forgectrl c9e3347, the module
f924cd9 and this tree's forgetest, and campaign c-20261015103714-0c91
passed all 53 catalog tests on it with none inherited. bitbake -c fetch
of forgectrl, gfcloud, gfhome, python3-ffmachine, python3-gfhardware,
python3-gfutilities and kernel-module-glowforge from these pins
succeeds (14 tasks). The tree manifest from the pins holds forgectrl at
c9e3347 (229 files); the coverage lint over it finds no uncovered path,
no empty entry and no host-tested row problem (53 tests);
check-ui-vendor finds the shared UI files identical at c9e3347;
test_tree_manifest passes, and the gate self-check refuses the empty
artifact (rc 1).
2026-10-15 07:24:51 -04:00
ScottW514 7f2da68e34 Catalog: image.license-bundle and image.network-boot leave; the network boot's board checks fold into image.health
Each left after its host proofs passed, with the coverage lint clean at
the pins, its only failure the known setup.check-lens entry on
src/lensfind.*:

- image.license-bundle: the pack half is test_image.py's
  PackLicensesTests on pack-licenses.sh (forgefirm 0d63f87); the serve
  half is host_routes.sh's license cases, the manifest, the bundle and
  the Licenses page read with no token (forgectrl c9e3347). Its
  src/main.c stays covered by image.health, and its three pages by
  forgectrl.panel-serves (src/ui/**). The 40-package and 5-text counts
  leave with it.
- image.network-boot: the interfaces file's DHCPv6 check is
  NetworkBootTests' case on the BSP layer's file (forgefirm 0d63f87).
  The board checks move into image.health, unchanged: wlan0 in ifup's
  state file and no ifup or ifdown running; a getty on ttymxc0; at least
  one udhcpc, each with -b as its own word and parent pid 1; no udhcpc6,
  dhcp6c or odhcp6c running or on the PATH, and no
  /usr/share/udhcpc/default6.script; on wlan0 disable_ipv6 0, accept_ra
  neither empty nor 0, and a link-local address, the global address
  logged. image.health's description says so. The interfaces file
  carries no -b, so -b is checked here, on the client that runs.

No HOST_TESTED row: network-boot covered nothing, and license-bundle's
paths stay covered by the two tests that keep them. image.health's
covers are unchanged. The helpers ifstate_names, dhcp6_in_interfaces,
parse_stat, link_addrs, _procs and _read, and the module's constants,
stay as they were; only the two functions and the inside of
image_health changed.

Fingerprints: image.health's moved and no other id's (the module's
shared text, which every exthost test reads through its import of
kernel_config, is unchanged). image.health is always-required, so the
next campaign reruns it.

Proof: the coverage lint, 53 tests (forgectrl, src/lensfind.* its one
entry); pyflakes; forgetest's unit suite on Linux (python3 -B -m
unittest discover -s tests), 335 OK.
2026-10-14 21:46:58 -04:00
ScottW514 0d63f8740e pack-licenses.sh: the image's license step refuses a manifest without the project's recipes; the layer's interfaces file starts no DHCPv6 client
The host proofs of image.license-bundle's pack half and of
image.network-boot's interfaces check. No catalog test is removed here;
suite/image.py is unchanged.

pack-licenses.sh, next to forgefirm-image.bb, is the license step.
pack_licenses calls it with the common-licenses directory and the
output tar path (forgefirm-image-dev.bb requires the release recipe, so
both images run the one call), and do_rootfs's file-checksums name it.
Before it writes the tar or removes the tree, it exits non-zero with a
message on stderr when the directory is missing, when the manifest is
missing, or when the manifest lacks any of the newline-terminated lines
"RECIPE NAME: forgectrl", "RECIPE NAME: grblhal-glowforge" and "RECIPE
NAME: kernel-module-glowforge". On success it packs as before: tar
--sort=name --mtime=@0 --owner=0 --group=0 --numeric-owner, gzip -9 -n,
the member common-licenses/license.manifest, and the loose tree removed
once the bundle holds the manifest.

test_image.py's PackLicensesTests drives that script on a fixture: a
missing directory, a missing manifest, and a manifest missing each one
of the three lines are refused with a message on stderr and no tar, the
tree left; a manifest naming all three packs, the tar holds
common-licenses/license.manifest, and the tree is gone.

NetworkBootTests reads the BSP layer's interfaces file from the
meta-openglow checkout beside forgefirm's (forgetest-ci checks out its
scarthgap branch there) and finds no line that starts a DHCPv6 client.
A missing file fails the case.

meta-forgefirm's content hash moved (forgefirm-image.bb and the new
script are layer content, and the platform identity of
forgefirm-image-manifest.bbclass leaves out only *.md, *-pin.inc and
forgefirm-release.inc): the next image cannot inherit a campaign.

Proof: python3 -B -m unittest discover -s tests -p test_image.py on
Linux (Ubuntu 24.04, GNU tar 1.35, meta-openglow at f012d8e): 14 OK, the
five new cases among them; pyflakes names no undefined name.
2026-10-14 21:42:12 -04:00
ScottW514 558889659c setup_burns: the module docstring describes setup.focus alone
setup.floor left the catalog in f9c298a, and the docstring still named it:
the second burn, the floor needing Focus on the record, the faintest
rung, and each wizard. It now describes the focus burn alone; the focus
burn's own description is unchanged.

The docstring is the module's shared text, so setup.focus's fingerprint
moves again; f9c298a had already moved it. No other test's fingerprint
moves (Test.source_sha compared before and after: setup.focus alone).

Proof: test_setup_burns OK; pyflakes; the coverage lint, 55 tests.
2026-10-14 21:19:46 -04:00
ScottW514 4c6ca8d701 test_setup_suite: only registered tests named
The file named setup.mirror (gone at 70e13bd) and setup.first-run-page,
setup.check-sensors, setup.check-intakes and setup.floor (gone at
f9c298a), so it failed in forgetest-ci's discover run. Every id, cover,
requires edge and attended list it asserts is now a registered test:

- IDS and OPERATOR: account-login, ssh-until-reboot, machine-name and
  first-run-flow; the four assertions on ids long folded into others go
  with them.
- the takeover check names first-run-flow; the mirror's covers check goes.
- the intake precheck: setup.check-airflow.
- first-run-flow: its fixture run and its backend covers, without the
  page's half.
- the attended setup tests: setup.cloud-header-capture and setup.focus.

It also puts tests/ on sys.path before importing helpers, as the other
suite tests do, so python -B -m unittest tests.test_setup_suite runs from
forgetest/.

Proof: python -B -m unittest tests.test_setup_suite and discover -p
test_setup_suite.py, 30 OK under Linux and on Windows; pyflakes.
tests/** is in no fingerprint.
2026-10-14 21:13:56 -04:00
ScottW514 f9c298a347 Catalog: eleven tests leave for their host proofs
Each left after its host cases passed (forgectrl 1dc85f6) and with
the coverage lint clean at the pins, its only failure the known
setup.check-lens entry on src/lensfind.*:

- forgectrl.tokens: host_routes.sh's scoped tokens (a route refused to a
  token not given it, neither token in the list or the store), with
  tokens_test and auth_scoped_test.
- update.product-gate: host_update.sh's product gate on the upload, with
  fwproduct_test.
- setup.first-run-page: tests/wizard_walk.py.
- setup.check-sensors, setup.check-intakes, setup.check-cameras,
  setup.check-flow-verify, setup.check-envelope, setup.floor:
  host_setup.sh's checks and the floor burn, with wizcalc_test.
  envelope_test.py's cases are unchanged. setup.floor's emission stays
  with setup.focus and laser.emission-witness.
- motion.job: jobpost_test, with jobrun_test and lease_test; jobpost.* is
  covered by exthost.motion-job, which stays.
- exthost.motion-jog: host_routes.sh's credential case.

Two HOST_TESTED rows, each a binary forgectrl's build.yml runs at the pin
c274f19 that proves the path the lint named: src/fwproduct.* to
fwproduct_test, src/grblport.* to grblport_test.

exthost.motion-job now requires motion.jog-roundtrip in place of
motion.job. bedsize.py held setup.check-envelope alone and goes;
forgectrl.py loses _words_of and its hw import, motion.py the job
runner's helpers, setup_burns.py FLOOR_KEY and FLOOR_KEYS.

forgetest's own tests: test_setup_dark.py loses the sensors and intakes
judges and the departed ids; test_setup_burns.py loses the floor's cases,
and the guards burn() and preview_ok() hold for the focus burn (the
program's tail and reach, the thickness in inches and the one refused, a
prompt not answered, the witnesses) run on setup.focus.

Stay: forgectrl.auth (the fuse view's button refusal on the real
button); forgectrl.settings-bounds (the lid lamp: cam.c writes
/sys/glowforge/pic/lid_led, not under GF_SYSFS_ROOT); forgectrl.panel-serves
(src/ui.h, which no binary at the pin proves; Z inside the lens reach is
setup.check-lens's); logs.routing; setup.account-login (the account's
render and its home, image content); setup.ssh-until-reboot
(src/paths.h); setup.machine-name (udhcpc -x hostname and no mDNS
responder, image content); setup.first-run-flow (src/button.*, led.*,
advisories.*, hooks.h and the four advisory documents); exthost.motion-job
(jobpost.*, which no binary at the pin proves).

Proof: the coverage lint, 55 tests; pyflakes; test_setup_burns,
test_setup_dark, test_takeover_position and test_image (54 OK).

Shared text moved the fingerprints of exthost.armed-freeze,
exthost.platform, exthost.service, forgectrl.auth,
forgectrl.panel-serves, forgectrl.settings-bounds, laser.armed-kill,
laser.emission-witness, laser.pause-resume-lid-cancel,
motion.cancel-abort, motion.deadman, motion.jog-roundtrip,
motion.liveness-probe, motion.microstep-modes, motion.pacing,
motion.release, motion.step-timing-under-load, setup.account-login,
setup.check-airflow, setup.check-lens, setup.check-motion,
setup.check-switches, setup.cloud-header-capture, setup.first-run-flow,
setup.focus, setup.machine-name, setup.ssh-until-reboot and
update.slots-and-signature; exthost.motion-job's own text moved with its
requires.
2026-10-14 21:03:58 -04:00
ScottW514 754c239e08 test_image: the hostname and the banner scripts, run over a scratch tree
MachineNameTests runs the layer's own forgefirm-hostname and
forgefirm-banner in private mount, UTS and network namespaces: /run a
fresh tmpfs, a scratch directory over /sys/class/net, /etc a read-only
overlay with the scratch hostname and issue over the host's (so each
script makes its copy under /run/forgefirm and writes through its own
bind mount, as at boot). unshare -Urmun where user namespaces are open,
sudo -n unshare -mun otherwise; skipped only where neither makes a
namespace.

- the name: wlan0's last four hex digits (forgefirm-12ab with eth0 beside
  it), eth0's with no wlan0, eth0's with a zero wlan0 MAC; the live name
  and /etc/hostname agree.
- the banner: the image's text, then the heading and one URL per global
  address of wlan0 (a veth carrying 192.0.2.5, 2001:db8::5 and fe80::5):
  https://192.0.2.5/ and https://[2001:db8::5]/, the link-local address
  left out, no marker line, no .local name.

Proof: test_image.py under Linux by both namespace routes (9 tests OK),
and on Windows with the two skipped. Mutants of scratch copies of the
scripts, each failing: eth0 read before wlan0, a zero MAC taken, the file
written without the script's bind mount, a marker line in the banner, a
.local URL, the link-local address kept.

setup.machine-name stays: the DHCP client's -x hostname and no mDNS
responder on the image are image content. Nothing under meta-forgefirm
changed.
2026-10-14 21:03:33 -04:00
ScottW514 b3a40b4aea Catalog: twelve tests leave for their host proofs; image.health reads the board temperatures
Each test below leaves for the host cases that now hold it, every one
run and passing before this commit:

- cooling.fans-quiet-after-motion, cooling.gate-off (its engine half),
  cooling.floor-and-warm-up, cooling.tec-drive, cooling.fire-watch-tiers,
  cooling.crash-watch-plumbing, cooling.critical-tier and
  cooling.fan-gate-trips: forgectrl's cool_flow_test and cool_gate_test
  (forgectrl 8e9cc0a). The jog and M8/M9 report stays in grblHAL's
  cooling_test.c and cool_report_test.py; real heater climb, a lit tube,
  tach spin-down and fans meeting their floors stay with
  cooling.flow-verify, cooling.flow-under-load, cooling.fail-tier-stop,
  cooling.aa-offset-calibrate and setup.check-airflow.
- motion.gate-waits-for-lid: super_test D2 (forgectrl 8e9cc0a). The
  physical lid read stays with setup.check-switches.
- motion.lid-cancel-home: laser_lifecycle_test's hold-lid-cancel (forgefirm
  107676b). check_kernel_returned stays in laser.pause-resume-lid-cancel's
  live lid phase.
- homing.manual: manual_home_test.py cases 1 and 7, and status_test's
  anchor readback (forgectrl 8e9cc0a).
- laser.recorder-dark: curverec_test (forgectrl 8e9cc0a). The dark arm
  wait stays test_button_wait_arms.

cooling.gate-off's board half moves into image.health: /status
temps.chassis_c from 5 through 80, temps.soc_c from 10 through 100,
temps.supply_raw an integer from 0 through 1023, and temps.soc_throttle
0. image.health's covers gain src/status.*, which serves them. The next
campaign reruns image.health (it is always run).

Stays: motion.cancel-abort (its host case fails on the abort's cnc/stop:
the null-sink controller writes cnc/stop only with a device open),
exthost.service (its init-script case has not passed on unmodified code
here), exthost.armed-freeze (its armed-capture decision has no host
proof), exthost.motion-jog (the package's jog through the host credential
has no host proof), exthost.motion-job (its POST /job reader is step 4's
jobpost_test.c).

Requires re-pointed off the tests that left: setup.check-airflow and
setup.check-intakes no longer require cooling.fan-gate-trips,
laser.pause-resume-lid-cancel no longer requires motion.lid-cancel-home,
cooling.fail-tier-stop no longer requires cooling.crash-watch-plumbing,
and exthost.armed-freeze no longer requires cooling.floor-and-warm-up.
cooling.py's docstring now describes the four cooling tests that stay.
The helpers only the departed tests used go with them, and laser.py
imports only the motion helpers it still calls.

Proof: the coverage lint on the tree manifest reports 0 uncovered paths
and 0 host-tested row problems across 66 tests; its one failure is
setup.check-lens's src/lensfind.* entry, which clears with the forgectrl
pin. No HOST_TESTED row was needed. The catalog validates; pyflakes
reports only the two unused locals that were there before
(cooling.fail-tier-stop's t0, update.py's plain);
test_cooling_order, test_cooling_suite, test_image, test_laser_dwell,
test_camera_home_return, test_setup_dark, test_setup_burns,
test_kernel_suite and test_cloud_suite pass.

Acceptance cost: the catalog goes from 78 tests to 66. Implementation
hashes move for cooling.aa-offset-calibrate, cooling.flow-verify,
cooling.flow-under-load and cooling.fail-tier-stop (cooling.py's shared
text); motion.pacing, motion.jog-roundtrip, motion.microstep-modes,
motion.liveness-probe, motion.cancel-abort, motion.deadman,
motion.step-timing-under-load, motion.release, motion.job,
laser.armed-kill, laser.emission-witness, laser.pause-resume-lid-cancel
and setup.check-envelope (motion.py's and laser.py's shared text, and the
modules that import motion.py); image.health, setup.check-airflow,
setup.check-intakes and exthost.armed-freeze (their own text).
2026-10-14 19:33:43 -04:00
ScottW514 107676b77d laser_lifecycle_test: the lid cancels a job held on the button
Rule 16, hold-lid-cancel: a travel job held by a press, then the lid
opened. The controller reports "lid opened - job canceled", prints the
reset banner, raises no alarm, and returns the head to the job start
(MPos X within 0.05 mm of where the job began); a press once the lid is
closed again resumes nothing (Idle, the position unchanged).

This is the part of motion.lid-cancel-home no host test held: the lid
during Run is test_lid_cancels_and_returns, and the kernel playing the
return stays with laser.pause-resume-lid-cancel's live lid phase
(check_kernel_returned).

Proof: the whole harness passes on the null-sink build as grblHAL's CI
builds it (-DGFMCODE_WAIT_S=5.0): every earlier case, then
hold-lid-cancel, lid-hold and sigterm-mid-job.

Acceptance cost: none. scripts/bench/ is outside every fingerprint.
2026-10-14 19:09:00 -04:00
ScottW514 6594f57362 Catalog: motion.port-jog and motion.tray leave; two leftovers of the audit
Two catalog tests leave for the host proofs that carry them; the catalog
goes from 80 to 78 (78 @test in forgetest/forgetest/suite/).

- motion.port-jog. forgectrl's tests/host_routes.sh (7b2f629 on e69d967)
  runs the controller port's routes against the port's stand-in: a jog
  refused without the token, a jog that is no number 400 with nothing on
  the port, a numeric jog on the port as G91 G21, cancel, release and
  energize each one line, GET /motion/state passing the port's state on
  with state and sender, GET /motion/sender 200. The port beside a
  sender, its polls, the cancel, the client first and the bound are
  grblHAL's ctlport_test.py and forgectrl's grblport_test; motion.release
  keeps the physical currents. The helpers before it (_port_state,
  _port_idle, _words) stay: bedsize, laser and motion.job read them.
- motion.tray. host_routes.sh holds POST /motion/tray (tray=in on the
  port, tray=up 400) and the panel page's switch (id="traybtn",
  trayToggle, /motion/tray); grblHAL's tray_test.py the mode, its offset,
  its persistence and its frame; forgectrl's tray_test the daemon's side.
  suite/tray.py held only it: the module and its import go.

The coverage lint then named forgectrl src/tray.c and src/tray.h, and
nothing else; grblhal-glowforge's src/glowforge_tray.* stays covered by
another catalog test, so it gets no row. One HOST_TESTED row takes
forgectrl's, to forgectrl:tests/tray_test.c on
forgectrl:.github/workflows/build.yml, which the pin c274f19 runs
(./build/tray_test).

Two of the audit's leftovers, in the same change:
- test_manifest.py's ImplementationHashTests sat after the __main__
  guard, so running the file directly skipped it. It is above the guard
  now, byte for byte: python3 -m unittest test_manifest.py and python3
  test_manifest.py (from tests/, where its helpers import resolves) each
  run its 7 cases, and 34 in all; before, the direct run ran none of
  them.
- laser_lifecycle_test.py's test_lid_open_in_wait said an alarm is
  raised; the docstring now says what the test asserts: the lid-open
  cancel is reported and no alarm is raised. The assertions are
  unchanged and the harness was not run.

The coverage lint on the tree manifest finds no uncovered path and no
host-tested row problem across the 78 tests; its one finding is
setup.check-lens's empty src/lensfind.* entry. pyflakes reports nothing
on motion.py, manifest.py, test_manifest.py or laser_lifecycle_test.py.
The full forgetest suite was not run.

Fingerprints, measured against the parent: 17 tests move with
motion.py's shared text: the 11 left in motion.py, and homing.manual,
laser's four and setup.check-envelope, whose modules read it. The
pending pin bumps from forgectrl c274f19 and forgeext 2d0391e already
owe all 17; this deletion adds none to the owed list. The two leftovers
move no fingerprint. releases/ is unchanged.
2026-10-14 18:21:59 -04:00
ScottW514 40c478d56c Catalog: forgectrl.lease leaves
forgectrl.lease leaves for forgectrl's tests/host_lease.sh (0086e65),
which passed as build.yml runs it: with the gate open and cnc/state idle,
the switches check held at its first wait names wizard:switches, kind
hardware, as the holder; POST /diag/flow-verify, /curve/record,
/logs/export, /mode with the mode in force, /controller/start and
/settings are each 409 in the holder's name, none for an idle machine or
a closed gate; the abort frees the lease and the same settings write is
200; lease.changed carries the take before the free. lease_test and
super_test case K hold the lease's own rules. The catalog goes from 81 to
80 (80 @test in forgetest/forgetest/suite/).

_EventStream, _lease and _text, which only it read, go with it, and so
does urllib.parse, which only _EventStream used; socket stays for lan_ip.
No test requires the id, and no unit test names it.

The coverage lint on the tree manifest then named forgectrl src/events.c
and src/events.h, and nothing else beside setup.check-lens's empty
src/lensfind.* entry. One HOST_TESTED row takes them, to
forgectrl:tests/events_test.c on forgectrl:.github/workflows/build.yml,
which the pin c274f19 runs (./build/events_test). The lint then finds
no uncovered path and no host-tested row problem across the 80 tests;
its one finding is setup.check-lens's. test_manifest.py (34) passes, and
pyflakes reports nothing on forgectrl.py or manifest.py. The full
forgetest suite was not run.

Fingerprints, measured against the parent: 26 tests move with
forgectrl.py's shared text: the 4 left in it (auth, panel-serves,
settings-bounds, tokens), the 5 left in exthost.py and the 17 of the
setup family, whose modules import lan_ip from it (exthost.py and
setup.py, and through setup.py the rest). The pending pin bumps from
forgectrl c274f19 and forgeext 2d0391e already owe 24 of them;
exthost.platform has been owed since 30c0a05 and setup.machine-name
since 70e13bd. This deletion adds none to the owed list. releases/ and
TEST-AUDIT.md are unchanged.
2026-10-14 17:33:16 -04:00
ScottW514 70e13bdd88 Catalog: setup.mirror and logs.tree-tail-export leave; the catalog's address in forgetest-ci
Two catalog tests leave for the host proofs that carry them; the catalog
goes from 83 to 81 (81 @test in forgetest/forgetest/suite/).

- setup.mirror, for forgectrl's tests/host_ext.sh (0086e65): with a
  temporary account and two logins, a package's check that session A
  starts is owned and A's, owned and not B's, and the token's with no
  session; B's abort is 409 and the run goes on; B's takeover makes it
  B's; B's abort ends it aborted. Its section header, "the lifecycle",
  held only it. setup.check-sensors and setup.account-login stay.
- logs.tree-tail-export, for forgectrl's tests/host_guard.sh (0086e65):
  GET /logs names forgectrl, the tail carries a line just written, an
  unknown logger is 404, and the export carries README.txt, the
  installer's directory (a probe without its MAC or IP address, and
  install.log) and system/extensions.json, with neither the panel token
  nor the camera key in any member. _LOG_COVERS, the _PROBE_ constants,
  and the gzip, io, and tarfile imports, which only it read, go, and so
  does logs.routing's requires on it. logs.routing stays.

forgectrl.lease stays. Its proof passed (forgectrl's tests/host_lease.sh,
0086e65), but its deletion left forgectrl src/events.c and src/events.h
uncovered, a path outside the two this chunk may row, so it does not
land.

The coverage lint then named forgectrl src/sanitize.c, src/sanitize.h,
src/extpkg.c and src/extpkg.h, and nothing else. Two HOST_TESTED rows
take them, to forgectrl:tests/sanitize_test.c and
forgectrl:tests/extpkg_test.c on forgectrl:.github/workflows/build.yml,
which the pin c274f19 runs (./build/sanitize_test, ./build/extpkg_test).

forgetest-ci, after the tree-manifest step: the key comparison moves
there unchanged, and a step beside it checks the catalog's address.
From forgectrl-pin.inc's SRCREV it reads src/extpkg.h out of the
manifest cache (git show) and takes EXTPKG_INDEX_URL; from the
checked-out catalog's tools/publish.py it takes the basename of the file
passed to gh release create; the define must be exactly
https://github.com/openglow-org/forgefirm-extensions-catalog/releases/latest/download/
and that basename. A missing pin, define, or basename fails. Nothing
requests the address or fetches the asset. The step's run text, taken
from the workflow, passes from the tree root (c274f19's address and
index-1.ffi) and on a /tmp copy of the tree; controls on that copy,
publish.py passing index-2.ffi and extpkg.h naming another repository:
each fails the comparison. The key step's text is unchanged and was not
run again.

test_manifest.py (34) passes. The coverage lint on the tree manifest
finds no uncovered path and no host-tested row problem across the 81
tests; its one finding is setup.check-lens's empty src/lensfind.*
entry. pyflakes reports nothing on setup.py, logs.py or manifest.py.
The full forgetest suite was not run.

Fingerprints, measured against the parent: 23 tests move with
setup.py's and logs.py's shared text: the 5 left in setup.py, the 12 in
the modules that read setup.py (setup_dark.py's 9, setup_burns.py's 2,
bedsize.py's setup.check-envelope), the 5 left in exthost.py, which
reads it too, and logs.routing. The pending pin bumps from forgectrl
c274f19 and forgeext 2d0391e already owe 21 of them, and
exthost.platform has been owed since 30c0a05. This adds one:
setup.machine-name, which no pinned change selects. releases/ and
TEST-AUDIT.md are unchanged.
2026-10-14 17:09:32 -04:00
ScottW514 b90299acdc Catalog: the package routes, the catalog, a package's check and the camera key leave
Four catalog tests leave for the host proofs that carry them; the
catalog goes from 87 to 83 (83 @test in forgetest/forgetest/suite/).
Each proof passed; none skipped.

- exthost.package-routes, for forgectrl's tests/host_ext.sh (05848ac):
  against a compiled stand-in that is a live host, GET /ext/status and
  POST /ext/package are 403 without the token with the host not run,
  the status passes the host's list through with host.running true and
  the stand-in's pid, enable runs as the host's command, a bad action
  and a bad id are 400 with nothing run, and a package that is not
  installed is 409 in the host's words. extpkg_test holds the relay's
  argv; the state file, the required-holds directory and removal are
  forgeext's install_test.py and run_test.py; the page card is
  check_ui_js.py. REQUIRED_HOLDS, which only it read, and the "the hold"
  section header over it go.
- exthost.wizard, for host_ext.sh (05848ac): the check listed, run to its
  prompt with no lease holder, answered yes, ended with its summary and
  listed done; the setup record's wizards and the gate as they were; a
  second run aborted; a check of a package that is not installed 404.
  wizpkg_test holds each step to its form. suite/extwizard.py held only
  it: the module and its import go.
- exthost.catalog: install_test.py and extpkg_test already assert its
  behavioral claims, and the one fact the bench test did not prove, that
  the image's extension key is the catalog's published key, is now a
  forgetest-ci step: the catalog is checked out at main as a sibling and
  meta-forgefirm/recipes-forgefirm/forgefirm-keys/files/ext/forgefirm-ext.pub
  is cmp -s'd against its keys/forgefirm-ext.pub, either one missing a
  failure. suite/extcat.py held only it: the module and its import go.
- camera.key-read, for forgectrl's tests/host_lan.sh (05848ac): with
  panel_open_reads=0, GET /cam/status from the LAN client over HTTP and
  HTTPS is 403 with no key or a wrong one and 200 with the key as ?key=
  or the header, the key alone writes no setting, and a rotation stops
  the old key and the new one reads. camera.py's json import, which only
  it used, goes.

The coverage lint then named forgectrl src/wizpkg.c, src/wizpkg.h,
src/camkey.c and src/camkey.h, and nothing else. Two HOST_TESTED rows
take them, to forgectrl:tests/wizpkg_test.c and
forgectrl:tests/camkey_test.c on forgectrl:.github/workflows/build.yml,
which the pin c274f19 runs (./build/wizpkg_test, ./build/camkey_test).
src/extpkg.* stays covered (logs.tree-tail-export, logs.routing), and
forgeext/** by exthost.service.

The key step's run text, taken from the workflow, passes from the tree
root with both files present; controls on a /tmp copy, a published key
that differs and one that is missing: each fails. test_manifest.py (34)
passes. The coverage lint on the tree manifest finds no uncovered path
and no host-tested row problem across the 83 tests; its one finding is
setup.check-lens's empty src/lensfind.* entry. pyflakes reports nothing
on exthost.py, camera.py or manifest.py. The full forgetest suite was
not run.

Fingerprints, measured: 10 tests move, the 5 left in exthost.py
(armed-freeze, motion-jog, motion-job, platform, service) and the 5 left
in camera.py (frame-health, h264-stream, lid-privacy, sensor-profile,
snapshot). The pending pin bumps from forgectrl c274f19 and forgeext
2d0391e already owe 9 of them; exthost.platform is owed already since
30c0a05. This adds none. releases/ and TEST-AUDIT.md are unchanged.
2026-10-14 16:24:45 -04:00
ScottW514 30c0a05b86 Catalog: exthost.panel-install leaves
exthost.panel-install leaves for the host proofs that carry it; the
catalog goes from 88 to 87.

- The upload sink is forgectrl's tests/host_ext.sh (005b159): 403
  without the token, 409 and not idle while cnc/state reads running with
  the host never run, the host's refusal at 400 in its words with the
  staging file gone, an archive the host takes staged byte for byte as
  tier unverified with consent button, the discard, an install with
  nothing staged at 409, and a key without the button at 409 with no key
  written. The consent each tier takes, the phrase, the grants' form,
  the button the install and a key are passed, and the key's name and
  the host's refusal of it are forgectrl's extpkg_test; the tiers
  themselves are forgeext's install_test.py.
- Its section header, "installing through the panel", held only it, and
  EXT_STAGE and the contextlib import, which only it read, go with it. Its
  requires line goes with the function.

Three stay. exthost.package-routes: extpkg_test has the status
document, the holds, disable and enable, remove, a bad action and a bad
id at 400 with nothing run, and the host's refusal at 409, but not the
403 without the login on GET /ext/status and POST /ext/package, which is
main.c's guard and no case of extpkg_test's. exthost.wizard: a package's
check takes no lease (wizdark.c), and the lease is held in memory, so
there is no lease file to assert absent at the prompt. exthost.catalog:
install_test.py and extpkg_test assert every listed claim, and what is
left is the image's own key.

The coverage lint names no uncovered path, so no HOST_TESTED row is
added: forgectrl's src/auth.* stays covered by other catalog tests, and
src/extpkg.* by the three that stay.

test_setup_suite.py (31) and test_manifest.py (34) pass. The coverage
lint on the tree manifest finds no uncovered path and no host-tested row
problem across the 87 tests; its one finding is setup.check-lens's
empty src/lensfind.* entry. pyflakes reports nothing on exthost.py. The
full forgetest suite was not run.

Fingerprints: exthost.py's shared text moves, so 8 tests move: the 6
left in exthost.py, and exthost.catalog and exthost.wizard in the
modules that read it. The pending pin bumps from forgectrl c274f19 and
forgeext 2d0391e already owe 7 of them; exthost.platform is the one this
adds. releases/ and TEST-AUDIT.md are unchanged.
2026-10-14 15:42:34 -04:00
ScottW514 cfa6557847 Catalog: the sender keep-out, the event stream and the page's call leave
Three catalog tests leave for the host proofs that carry them; the
catalog goes from 91 to 88. Each proof passed; none skipped.

- exthost.sender-keep-out, for forgeext's run_test.py (ebdbf59): a
  package granted sender.keep_out posts /v0/sender {"out": true}, the
  stand-in takes it only with the host's token, and the claim is kept
  fresh beside the holds, raised in the host's words, until the package
  is disabled. The machine's side is forgectrl's senderout_test.
  suite/extsender.py held only it: the module and its import go.
- exthost.events, for forgeext's run_test.py (ebdbf59) and forgectrl's
  host_lan.sh (b272b3a): the host holds one stream while a package wants
  events and lets it go when none does, a poll at the head comes back
  empty at its one-second deadline, a waiting poll is answered 0.10 s
  after an event; and the host's slot is a loopback peer's alone, the
  same header from a LAN address leaving the host's stream open.
  forgeext's evfeed_test and forgectrl's events_test hold the two
  daemons' own sides. Its section header, the operator's door, held
  only it, and EVENTS_MAX_STREAMS, EVENTS_HOST_HEADER and _stream, which
  only it read, go with it.
- exthost.page-call, for forgeext's sdk_test.py (ebdbf59): disabled, the
  page package's socket name is gone and its call is refused in the
  host's words; enabled, the same call answers 200. sdk_test.py already
  held the calls, their form and refusals, and the frozen service;
  forgectrl's extpkg_test holds the relay. suite/extcall.py held only
  it: the module and its import go.

The coverage lint then named forgectrl src/senderout.c and
src/senderout.h, and nothing else; one HOST_TESTED row takes them, to
forgectrl:tests/senderout_test.c on forgectrl:.github/workflows/build.yml,
which the pin c274f19 runs. grblhal-glowforge's src/serial.* stays
covered by a catalog test and gets no row; holdkeep, evfeed, call and
sandbox stay under exthost.service's forgeext/**, and forgectrl's
src/events.* under forgectrl.lease.

test_setup_suite.py (31) and test_manifest.py (34) pass. The coverage
lint on the tree manifest finds no uncovered path and no host-tested row
problem across the 88 tests; its one finding is setup.check-lens's
empty src/lensfind.* entry. pyflakes reports nothing on exthost.py or
manifest.py. The full forgetest suite was not run.

Fingerprints: exthost.py's shared text moves, so 9 tests move: the 7
left in exthost.py, and exthost.catalog and exthost.wizard in the
modules that read it. The pending pin bump from c274f19 already owes 8
of them; exthost.platform is the one it adds, as before. releases/ and
TEST-AUDIT.md are unchanged.
2026-10-14 14:07:21 -04:00
ScottW514 b5f355cb61 Catalog: exthost.ui-delivery leaves
exthost.ui-delivery leaves for the host proofs that carry it; the
catalog goes from 92 to 91.

- forgeext ui, in forgeext's tests/install_test.py (23156a4): an
  installed package's page comes back byte for byte as the html string,
  with bytes its length; an id that is not installed and a package the
  operator disabled are each refused in words.
- forgectrl's relay of the page and the settings, in tests/extpkg_test.c
  on forgectrl's build.yml: the page served, a disabled package's page
  404, the settings read, a value over its bound 400.
The install-time mismatch between the ui capability and a page stays
with forgeext's own tests.

It has no glob of its own, so no HOST_TESTED row. exthost.sender-keep-out
stays: forgeext carries a package's claim on the Grbl sender to the
machine with the token it reads only from /run/forgefirm/ext-host.token,
so run_test.py does not prove it, and suite/extsender.py is unchanged.

test_setup_suite.py passes (31 tests). The coverage lint on the tree
manifest finds no uncovered path and no host-tested row problem across
the 91 tests; its one finding is setup.check-lens's empty src/lensfind.*
entry. pyflakes reports nothing new in exthost.py. The full forgetest
suite was not run.

Fingerprints: exthost.py's shared text moves, so 12 tests move: the 8
left in exthost.py, and exthost.page-call, exthost.catalog,
exthost.wizard and exthost.sender-keep-out in the modules that read it. The pending pin bump
from c274f19 already owes 11 of them; exthost.platform is the one it
adds, as before. releases/ and TEST-AUDIT.md are unchanged.
2026-10-14 12:41:15 -04:00
ScottW514 b447ae77f1 Catalog: the LAN redirect, the release check, and the update job's lease leave
Three catalog tests leave for the host proofs that carry them; the
catalog goes from 95 to 92.

- setup.https-only-writes, for forgectrl's tests/host_lan.sh (13273a0):
  from a LAN peer in a network namespace, reads served over HTTPS and
  HTTP, three writes over HTTP each a 302 to the same path on HTTPS that
  acts on nothing, the same write served from loopback, and the token
  writing over HTTPS with no account.
- update.release-check and update.job-locks, together, for forgectrl's
  tests/host_update.sh (acc3dcf), against a release server of its own:
  GET /update/release's keys, POST /update/check with a v<semver> tag, a
  size above 0, notes and new; the dismissal refused for a version that
  is not a tag, taken for the tag, cleared by an empty one; and a probe
  download that holds the machine (a settings write and a G21 job each
  409 in the job's name, update:download of kind system the holder),
  ends refused by the signature check leaving no file, and lets the same
  settings write through after.

update.release-check was the last test in suite/update.py; _VERSION_RX
and the re import, which only it used, go with it. update.job-locks was
the only test in suite/updlock.py: the module and its import in
suite/__init__.py go. setup.https-only-writes was the only test of
setup.py's "HTTPS boundary" section. tests/test_setup_suite.py drops its
id.

update.product-gate stays: fwup is on neither this host's PATH nor CI's,
so host_update.sh does not prove the five uploads, and no HOST_TESTED
row is added for src/fwproduct.*. No other HOST_TESTED row either.

test_setup_suite.py passes (31 tests). The coverage lint on the tree
manifest finds no uncovered path and no host-tested row problem across
the 92 tests; its one finding is setup.check-lens's empty src/lensfind.*
entry. pyflakes reports only the unused local plain in
update.slots-and-signature, which HEAD already has. The full forgetest
suite was not run.

Fingerprints: setup.py's and update.py's shared text move, so 33 tests
move: the 6 left in setup.py, the 25 in the modules that read it, and
update.slots-and-signature and update.product-gate. The pending pin bump
from c274f19 already owes 31 of them; setup.machine-name and
exthost.platform are the two it adds, as before. releases/ and
TEST-AUDIT.md are unchanged.
2026-10-14 12:06:11 -04:00
ScottW514 21046cd6ff Catalog: setup.cert-page leaves
setup.cert-page leaves for forgectrl's tests/host_guard.sh (4d2d109):
GET /settings reports a 95-character tls_fingerprint, and over HTTP and
over HTTPS, with no token, GET /cert is 200 with no redirect, that
fingerprint and the host's name, and GET /cert.pem is a PEM certificate.
The catalog test reached the board by its LAN address; the page is what
it proved. The catalog goes from 96 to 95.

It called no helper of its own. src/tls.* stays on the login, HTTPS,
first-run and TLS-record tests, src/ui/wizard.html on
image.license-bundle and setup.mirror, and src/ui/wizard.js on
forgectrl.auth, exthost.wizard and setup.mirror. No unit test names it.
No HOST_TESTED row: the pinned workflow does not run host_guard.sh.

test_setup_suite.py passes (31 tests). The coverage lint on the tree
manifest finds no uncovered path and no host-tested row problem across
the 95 tests; its one finding is setup.check-lens's empty src/lensfind.*
entry. pyflakes is clean on setup.py. The full forgetest suite was not
run.

Fingerprints: the deletion moves suite/setup.py's shared text, so 32
tests move: the 7 left in setup.py and the 25 in the modules that read
it. The pending pin bump from c274f19 already owes 30 of them;
setup.machine-name and exthost.platform are the two it adds, as before.
releases/ is unchanged.
2026-10-14 11:25:28 -04:00
ScottW514 098cfdf193 Catalog: setup.record-export leaves
setup.record-export leaves for forgectrl's tests/host_guard.sh (84779c8
on d21e200): the record as JSON with the token (schema 1, the sheet id
GET /wiz reports, no password hash, a plain read not an attachment), the
download an attachment of the record named with that id, the printable
page text/html with no script, the sheet id, "setup record" and an <h3>
per completed wizard, both routes 403 without a token or a login, and
the sanitized export carrying system/setup.json with the same id and
wizards, no $6$, and no panel token anywhere in the archive. The catalog
goes from 97 to 96.

One HOST_TESTED row: forgectrl src/recordhtml.*, the one path no other
catalog test covers, to tests/recordhtml_test.c (no script, the sheet
id, the step headings, the escaping), which forgectrl's build.yml runs
at the pinned revision. src/setup.* and src/wiz.* stay on the setup
family, src/logs.* on exthost.service, forgectrl.lease and the two log
tests, and src/main.c, src/ui/index.html, src/ui/panel.js and
src/ui/wizard.html on the tests that already name them.
tests/host_guard.sh is not named: the pinned workflow does not run it.

tests/test_setup_suite.py drops the id, the record-export hardware
assertion, and the export lines of the lifecycle covers test (the
covers set and its two assertions); the mirror's assertions stay.

test_setup_suite.py passes (31 tests). The coverage lint on the tree
manifest finds no uncovered path and no host-tested row problem across
the 96 tests; its one finding is setup.check-lens's empty src/lensfind.*
entry. pyflakes is clean on setup.py and test_setup_suite.py. The full
forgetest suite was not run.

Fingerprints: the deletion moves suite/setup.py's shared text, so 33
tests move: the 8 left in setup.py and the 25 in the modules that read
it (setup_dark, setup_burns, bedsize, exthost, extcall, extcat,
extsender, extwizard). The pending pin bump from c274f19 already owes 31
of them; setup.machine-name and exthost.platform are the two it adds, as
before. releases/ is unchanged.
2026-10-14 11:10:16 -04:00
ScottW514 1e28eb92cd Catalog: setup.factory-return leaves
setup.factory-return leaves for forgectrl's tests/host_guard.sh
(d21e200): POST /restore/factory-return with the token and no confirm
is 400 and names confirm, confirm=0 is 400, GET /update/status shows no
job running, GET /slots lists the archives (run only where
/usr/sbin/ffboot is not on the host), and GET /setup carries the
confirmed call; confirm=1 is never sent. The catalog goes from 98 to 97.

It was the only test in the "the operator" section of suite/setup.py,
and called no helper of its own. tests/test_setup_suite.py drops its id
and test_the_factory_return_never_runs_the_return. src/update.* stays
covered by the update tests, src/ui/wizard.js by the wizard and mirror
tests, and src/main.c by the tests that already name it.

test_setup_suite.py passes (31 tests). The coverage lint on the tree
manifest finds no uncovered path and no host-tested row problem across
the 97 tests; its one finding is setup.check-lens's empty src/lensfind.*
entry. pyflakes is clean on both files. The full forgetest suite was not
run. No HOST_TESTED row: the pinned workflow does not run host_guard.sh.

Fingerprints: the deletion changes suite/setup.py's shared text, so the
34 tests that read it move again: the 9 left in setup.py, setup_dark 9,
setup_burns 2, bedsize 1, exthost 9, extcall, extcat, extsender and
extwizard 1 each. The pending pin bump from c274f19 already owes 32 of
them; setup.machine-name and exthost.platform are the two it adds, as
before. releases/ is unchanged.
2026-10-14 10:48:52 -04:00
ScottW514 33e8c6e43f Catalog: logs.level-settings leaves
logs.level-settings leaves for forgectrl's tests/logs_test.c (9317b54):
the six levels taken and verbose and 7 refused, port 70000 refused,
udp and tcp taken and tls refused, a server name with spaces refused,
and a remote level changed since boot listed as configured against
effective with pending_reboot raised, and lowered once they match. The
settings route's refusal of a value its validator rejects, with the file
left byte for byte, is tests/host_routes.sh's bounds case. The catalog
goes from 99 to 98.

It was the last test in suite/logs.py and called no helper of its own,
so nothing else goes. logs.tree-tail-export and logs.routing stay: they
are the only catalog covers of forgectrl src/fflog.*, and the pinned
workflow runs neither fflog_e2e.sh nor the new log tests.
setup.record-export keeps its requirement on logs.tree-tail-export.

The coverage lint on the tree manifest finds no uncovered path and no
host-tested row problem across the 98 tests; its one finding is
setup.check-lens's empty src/lensfind.* entry, which the forgectrl pin
bump clears. pyflakes is clean on logs.py. The full forgetest suite was
not run.

Fingerprints: the deletion changes suite/logs.py's shared text (the two
separator lines), so logs.tree-tail-export and logs.routing move; no
other module imports it. The pending forgectrl pin bump from c274f19
already owes both, so this commit adds none to it. releases/ is
unchanged.
2026-10-14 10:20:32 -04:00
ScottW514 71556b2480 Catalog: the setup gate, the advisories, the extensions' consent and cloud-off leave
Four catalog tests leave for the host proofs that carry them; the
catalog goes from 103 to 99.

- setup.gate-blocks-controllers, setup.advisories-rehash,
  setup.extensions-consent and setup.cloud-disabled-surface. forgectrl's
  tests/host_setup.sh (91d30f1) runs the four HTTP proofs against the
  built daemon with the controller up: each reason the gate holds on and
  the live fan-count close and reopen; the first-run advisories, their
  hashes, and an acceptance that takes the press back; the extensions'
  advisory, its five refusals, and the consent; and every cloud selection
  refused while cloud mode is off, POST /mode included. builtin_test.c
  holds the cloud sweep, sha256_test.c the digest, and
  settings_dir_test.c the search bit the consent adds to the data
  directory.
- gate_is_open and _GATE_COVERS go with the gate test, ADVISORY_DOCS and
  the hashlib import with the rehash; nothing else calls them. The record
  builders, REQUIRED_STEPS, SAFETY_PHRASE, Restore and request stay.
- The requires that named them: _FIRST_RUN_REQUIRES held only the two
  deleted ids and goes, so setup.first-run-flow requires nothing from it
  and setup.first-run-page requires setup.first-run-flow alone;
  exthost.service keeps exthost.platform.
- Coverage: HOST_TESTED rows for forgectrl src/sha256.* (tests/sha256_test.c)
  and src/builtin.* (tests/builtin_test.c), both run by forgectrl's
  build.yml at the pinned revision. docs/advisories/** stays behavioral:
  setup.first-run-flow, which accepts them, covers safety-and-risk.md,
  licenses.md, privacy.md and cloud-service.md, and exthost.service,
  which turns extensions on over it, covers extensions.md.
- tests/test_setup_suite.py drops the four ids from the id list and the
  takeover list, the gate test's precheck assertion (the method now names
  the intake prechecks only), and CloudDisabledSurfaceTests, with the
  imports only that class used; the docstring no longer describes it.

test_setup_suite.py passes (32 tests). The coverage lint on the tree
manifest finds no uncovered path and no host-tested row problem across
the 99 tests; its one finding is setup.check-lens's empty src/lensfind.*
entry, which the forgectrl pin bump clears. pyflakes is clean on the
four files. The full forgetest suite was not run.

Fingerprints: deleting the four functions changes suite/setup.py's
shared text, so 35 of the 99 tests' implementation hashes move: every
remaining test in setup.py (10), and the modules that import it at
module level, setup_dark (9), setup_burns (2), bedsize (1), exthost (9),
extcall (1), extsender (1), extwizard (1), and extcat (1, through its
import of exthost). The next forgectrl pin bump already owes 33 of them;
exthost.platform and setup.machine-name are the two it adds. releases/
and TEST-AUDIT.md are unchanged.
2026-10-14 09:26:41 -04:00
ScottW514 7172bba92b Catalog: the report channel, the event stream and what-changed leave
Three catalog tests leave for the host proofs that now carry them; the
catalog goes from 106 to 103.

- cooling.report-channel. forgectrl's tests/host_routes.sh (e69d967)
  runs the channel against a live daemon: a run report with the
  controller's secret writes exhaust 65535; no secret, a wrong secret
  and a short secret are 403 and the duty file is unchanged; an accepted
  idle while the ring still reads running writes 32768; the secret dies
  with the controller. cool_report_test.py and super_test case L own the
  secret, and cooling.fans-quiet-after-motion owns the live M8/M9 return
  to the idle duty. The forged Host: localhost was the same secret
  refusal.
- events.stream. events_test.c owns the hello, the replacement, and
  motors.released; host_routes.sh owns the live cap (three peers held,
  a fourth 503, GET /settings 200 beside them, closing one admitting the
  next). forgectrl.lease and exthost.events still cover src/events.*,
  and _EventStream stays for forgectrl.lease.
- setup.what-changed. setup_test holds tray -> laser.focus and fan ->
  airflow under the image's table beside tube -> laser.floor;
  host_routes.sh holds /wiz/changed (403, 400) and that every step the
  menu names is one GET /wiz lists. src/setup.* and src/wiz.* stay
  covered by the other setup tests, and help.js, panel.js and index.html
  by camera.key-read. preserved() and flag_of() go with it; recommended()
  stays.

tests/test_setup_suite.py drops setup.what-changed from the id list, the
takeover list and the covers lookup, and its flag_of case keeps only
the recommended() assertion.

test_setup_suite.py passes (36 tests). The coverage lint on the tree
manifest finds no uncovered path across the 103 tests; its one finding
is setup.check-lens's empty src/lensfind.* entry, which the forgectrl
pin bump clears. pyflakes on the four files reports only the unused t0
in cooling.py that HEAD already has. The full forgetest suite was not
run.

Fingerprints: the cuts change the shared text of suite/cooling.py,
suite/forgectrl.py and suite/setup.py, so 56 of the 103 tests'
implementation hashes move (cooling 12, setup 26, forgectrl 5, and
exthost 13 through its import of suite/forgectrl.py). The next forgectrl
pin bump already owes 54 of them; exthost.platform and
setup.machine-name are the two it adds. releases/ is unchanged.
2026-10-14 07:56:36 -04:00
ScottW514 8e1fe5090d Harnesses: the null-sink cases other tests prove leave
The grblHAL harnesses keep what only a running controller shows; the
cases a host unit test in grblHAL-glowforge proves, and the log and
reply wording, leave.

- laser_lifecycle_test: test_arm_waits_for_the_fans goes. laser_arm_test
  case A4 (grblHAL-glowforge) proves the fan grace stretching the arm's
  acknowledgment budget, on the same arm path. The docstring names it.
- xy_mode_test: case_x32, case_invalid and the log checks go; the
  default, x16 and the low tick stay. xy_scale_test holds each mode's
  tick and the not-a-mode parse.
- planner_blocks_test: one restart, at $398 1000; the 400 pass and the
  fresh-store probe go.
- ctlport_test: test_poll_lf and test_crlf go (the CR LF poll stays); in
  test_sender_out the network turn-away goes (serial_test holds it), and
  the busy gating, the grant and the outlives-client part stay.
  test_port_dark is whole.
- mcode_test: the timeout case runs on the host build's 5 s
  GFMCODE_WAIT_S (grblHAL-glowforge's CI configures it; the image keeps
  30 s) and still shows the hold, at 5.1 s; 5 s outlasts the 3 s
  test_dark watches the wait; test_table refuses one form per
  parser branch. test_timeout_holds and test_dark stay.
- envelope_test: test_unset and test_bounds go (lens_home_test holds the
  clamp); test_armed and the open, apply and close cases stay.
- manual_home_test: the out-of-range offset block goes, a repeat of
  gfhome_clamp_home_mm (lens_home_test); the modal-M3 FIRE check and
  every release and energize case stay.
- cool_report_test: one non-hex secret and one CR LF injection stay;
  runner-malformed and the exact header-set checks go. A bad secret is
  never sent and a good one is.
- tray_test: test_offset keeps 20 mm; test_errors keeps a bad P and a
  bad axis; the frame shift, envelope, barrier, persistence, port op,
  unreferenced lens and camera home stay.

Proof: with grblHAL-glowforge's matching commit, the eleven harnesses
its CI runs pass under Linux against the null-sink build: nine on a
build with a 1 s M-code wait, and mcode_test and tray_test, the two
that wait at an M-code, on the 5 s build CI configures
(-DGFMCODE_WAIT_S=5.0). pyflakes reports nothing in the nine changed
files.

Push this before grblHAL-glowforge's commit.

Acceptance: none; host harnesses only.
2026-10-13 21:20:31 -04:00
ScottW514 520d62ac05 forgetest tests: the confirmed trims of the tool's own unit tests
The cases that tested a mock, a timer, a wording or a table, or that
replayed a judgment a host test already holds, leave forgetest/tests.
What catches a false PASS, keeps the bench safe, or guards the gate
stays.

- test_baseline: the fake sysfs tree is a mixin with no test_ methods,
  so ExtensionFreeTests and BaselineModeTests no longer re-run
  BaselineTests' 25 cases (133 runs, now 45). CoolPublishTests, the
  lamp-fade cases and TransientNotLeftoverTests go; PositionDeadbandTests
  keeps a reading past the band and an unreadable reading, both never
  excused. Both latch-relock cases stay.
- test_fixture: MdnsTests, the three timing cases, the probe's log
  wording, and nine prompt-routing variants go. A live test never runs
  unattended, the arm-press opt-in, the short-press edge, the presence
  press handing over the arm press, and the arm notice dropping at the
  press stay (35 cases, now 21).
- test_cloud_suite: three false-PASS guards stay: the latch open at a
  dark print's button, a lid stop that is not edge-driven, the kernel
  refusing the resume. The guards for the latch unlocking in the hold
  and a paused print resuming left with their catalog tests in 6a434bb.
  The rest go, with the harness pieces and the hunt-lid fixture only
  they read (41 cases, now 3).
- test_cooling_suite: GateOffTests, CriticalTierTests, FanGateTests and
  FansQuietTests go with their harness; the flow verdict's log reads
  stay: only the text after the offset, never an older verdict before it
  (the flow-under-load false-PASS guard), a rotated log read whole.
- test_queue: RunClockTests and three ordering cases go, and refusals
  are judged by a reason given, not its words; both live
  acknowledgment cases stay.
- test_takeover_position: the six cases that judge the head and move
  nothing stay.
- test_camera_home_return: the five cases where the head must not move
  stay, and so do the three that check the hand-back jog's direction and
  distance (homed, unhomed with the counters not counted twice, after
  cloud mode): a wrong sign or a double count drives the head into the
  gantry stops (15 cases, now 8).
- test_server: the keep-alive and ETag cases, the takeover and scope
  half of the bench case, and the page's self-contained asserts
  (test_page.py's) go.
- test_operator: the wording cases, the fixture case (test_fixture.py's)
  and JournalTests go.
- test_mode: the supervisor timeout table goes.
- test_takeover_client: the listener, the offline service back, the
  online client without the hunt, and GRBL under no marker stay.
- test_responsiveness: PageTests and ImplementationCostTests go; the
  log cache keeps appends, a partial line, a replaced file and a fresh
  list, and the fingerprint cache a changed manifest and one with no
  content hash.
- test_bench_registry: GfbenchTests and two description and argument
  pins go.
- test_installer: the log-format and TLS-wording cases and
  InstallerShapeTests go; the die case checks the reason is in the log,
  not its format.
- test_image: KernelIdentTests and the registration pin go.

Each file's __main__ guard now ends the file, and the docstrings say what
each still proves.

Proof: each of the fifteen files passes under Linux as forgetest-ci
discovers it, and pyflakes reports nothing new in them.

Acceptance: none; test-only.
2026-10-13 20:29:25 -04:00
ScottW514 8536ae7076 laser_stream_test: the analog copies, the in-run levels and the curve run leave
The analog dose model runs on no machine; the density sessions beside
these prove the same rules on the shipped model.

- Sessions J and K (M5 at idle, the next job's first cut) run under
  density only.
- Session C (churn, analog) goes; its pad budget (no runaway pad across
  cycle churn) moves onto session G, density-churn, which already held
  FIRE, termination and the gaps on the same job.
- Session H (a level change inside a run costs no byte) goes: every
  density power byte is already held at full duty by check_density;
  the byte count was a cost, not an energy rule.
- Rule 19's end-to-end curve run goes: laser_arm_test.c case P pins the
  curve's mapping. The rolloff sessions keep the curve in force.
- The constants only those sessions read go (CURVE_DEFAULT,
  curve_density_for, CURVE_S, JOB_CURVE, JOB_LEVELS); the header's rules
  11, 16 and 19 say what the harness now holds.

Proof: laser_stream_test passes whole on a null-sink build of the
grblHAL-glowforge working tree under Linux (23 sessions). pyflakes is
clean.

Acceptance: none. The harness runs in grblHAL-glowforge's CI.
2026-10-13 19:10:00 -04:00
ScottW514 09cf9242a9 laser_lifecycle_test: the sessions the unit tests and the stream prove leave
Each case below repeated a rule another test in CI already proves, read
assertion by assertion next to it in the 2026-10-13 test audit:

- test_job_window: laser_arm_test.c case I (S changes, and now M5 then
  M3, inside the window never re-prompt; df7087b) and laser_stream_test
  session K (M2 closes the window, the next job re-arms).
- test_hold_grace: test_resume_after_grace makes the same assertion.
- test_verdict_blocks_arm, test_arm_waits_for_engine_ack,
  test_arm_proceeds_on_a_late_ack: laser_arm_test.c cases C, A2, A3 and
  cooling_test.c's arm-time gate. test_arm_waits_for_the_fans stays.
- test_sender_change: laser_arm_test.c case H; the disarm on a client
  change is unconditional.
- test_jog_does_not_extend_window: laser_arm_test.c case S10.
- test_verdict_fail_tier_ends_job: laser_stream_test rule 25 and
  laser_arm_test.c case V2.
- test_verdict_pause_resumes_without_press: laser_stream_test rule 24,
  which now counts one arm across the pause, and case V1.
- test_sender_change_mid_job: merged into
  test_sender_change_holds_and_rearms, which takes its "held inside the
  move" position check.
- test_sender_change_then_reset: the window is closed and the latch
  locked by the sender change itself (case H); a reset from Hold is not
  energy.

The header's rules 1, 2, 5, 11, 12 and 14 keep their numbers and name
the tests that now prove them.

Proof: laser_lifecycle_test passes whole on a null-sink build of the
grblHAL-glowforge working tree under Linux (17 sessions), and
laser_stream_test with it. pyflakes is clean; no helper was left
without a caller.

Acceptance: none. The harness runs in grblHAL-glowforge's CI.
2026-10-13 19:10:00 -04:00
ScottW514 280ceee849 laser_stream_test: rule 24's pause holds the window open: one arm
Rule 24 proved the pause tier holds lit to the stop, masks FIRE while
held, never writes the latch and resumes lit on the clean verdict. What
only laser_lifecycle_test's verdict-pause session held was that the
window stayed open through it: the resume armed nothing new. Rule 24 now
counts the arms, so that session can leave.

- verdict-rehold: exactly one "laser armed" across the pause, the
  re-hold and the resume.

Proof: laser_stream_test passes whole on a null-sink build of the
grblHAL-glowforge working tree under Linux. pyflakes is clean.

Acceptance: none. The harness runs in grblHAL-glowforge's CI.
2026-10-13 19:10:00 -04:00
ScottW514 8149a44f93 laser_stream_test: session B's FIRE-gap check moves to its density twin
Session B ran JOB_M3_TERM under the analog model; session F runs the
same job under density and checked its span, density and termination,
but not check_fire_gaps (no stepless run carrying FIRE), which B alone
held. F now runs it, and B goes: analog never runs on a machine, and
the analog ladder keeps its own termination and gap checks.

- check_fire_gaps on density-full, its worst gap in the PASS line.
- Session B's run and checks go; the JOB_M3_TERM comment names F.

Proof: the harness passes whole on a null-sink build of the
grblHAL-glowforge working tree under Linux, density-full with a worst
fire gap of 270 ticks. pyflakes is clean.

Acceptance: none. The harness runs in grblHAL-glowforge's CI.
2026-10-13 19:01:30 -04:00
ScottW514 c91f6daa9e laser_stream_test: rule 14 runs under the density model
Rule 14 (a level set while the stream is idle still cuts at that level)
ran only under the analog model, which no machine runs: its check
counted fire ticks per analog duty. Under the shipped density model the
level is the FIRE density at full duty, and nothing proved a stale
level there.

- Session I runs JOB_IDLE_S under DENSITY_CONF_FLOORED and judges it
  with rule 18's check_density: every power byte at full duty, and each
  move's span at its own S's density (23/127, 46/127, 81/127), not the
  one before it. The stream still ends dark.
- The header's rule 14 says the level is judged as the FIRE density.

Proof: the harness passes whole on a null-sink build of the
grblHAL-glowforge working tree under Linux (densities 0.1812, 0.3624,
0.638 against 0.1811, 0.3622, 0.6378). A control judging the same
stream against the levels in reverse order fails. pyflakes is clean.

Acceptance: none. The harness runs in grblHAL-glowforge's CI.
2026-10-13 19:01:30 -04:00
ScottW514 f8dc21b309 forgetest: motion.button-hold-resume leaves the catalog
motion.button-hold-resume held on the machine that the button holds a
travel job and the next press resumes it to its target. The host proves
it now: laser_lifecycle_test rule 7 runs the same job (M5, G91, G1 X40
F300) through the toggle and lands it within 0.05 mm of +40 (a0a4edb),
beside the armed half that owns the pause and resume messages. Real
stepping stays with the kept motion tests; the physical button with
setup.check-switches.

- The test goes; no helper was its alone (start_move and Watch serve
  motion.lid-cancel-home, which keeps _LID_COVERS).
- laser.pause-resume-lid-cancel no longer requires it. The removed test
  required only motion.pacing, which motion.lid-cancel-home still
  brings.

Proof: the coverage lint reports 0 uncovered paths and 0 host-tested
row problems over 106 tests (its one failure is setup.check-lens's
src/lensfind.* entry, which the forgectrl pin bump clears); the gate
self-check refuses an empty artifact cleanly; pyflakes adds no finding.
The unit modules that load the whole catalog pass under Linux:
test_artifact, test_campaign, test_cooling_order, test_fixture,
test_manifest, test_operator.

Acceptance: motion.py's shared text and laser.pause-resume-lid-cancel's
own text move. Against the image on the bench that adds no run:
6a434bb already changed both laser.py's and motion.py's shared text.
2026-10-13 18:48:38 -04:00
ScottW514 a0a4edb322 laser_lifecycle_test: a travel job takes the button toggle and lands on its target
Rule 7 proved the button's pause/resume toggle on an armed job only, and
nothing checked that the move resumed from the hold reaches its target.
The catalog's motion.button-hold-resume held both on the machine with a
travel job; it can leave the catalog once the host does.

- test_button_pause_resume, after its armed half, runs the catalog's
  job: M5, G91, G1 X40 F300 (8 s). It must reach Run without asking for
  the arm press; a press holds it part-way; a second press resumes it to
  Idle within 0.05 mm of +40.
- Rule 7's line in the header and the case's docstring say so.

Proof: the case passes on a null-sink build of the grblHAL-glowforge
working tree under Linux (the armed half as before; the travel job held
at 8.747 mm and resumed to 39.999 mm). A control, the same job held and
soft-reset instead of resumed, ends at 8.747 mm, which the landing check
fails. pyflakes is clean.

Acceptance: none. The harness runs in grblHAL-glowforge's CI.
2026-10-13 18:42:23 -04:00
ScottW514 67ac8f4064 forgetest: events.button-telemetry leaves the catalog
events.button-telemetry held on the machine that one physical press
reaches the event stream as pressed then released through its 25 Hz
switch poll, and the telemetry event's fields. The host proves the
rules now:

- the press between two 5 Hz gathers, idle snapshot, scripted switch
  word: forgectrl events_test test_short_press (d8d07fc);
- the telemetry field string, and that a press the machine is waiting
  for (arming, armed, leased, a button wait) is no event: events_test
  test_button.

The physical button reaching the switch word stays in the catalog:
setup.check-switches.

- evmore.py, the test's own module, goes with its import in
  suite/__init__.py; nothing else imported it.
- Its covers (forgectrl src/events.*, main.c, status.*) stay covered:
  events.stream holds all three, and other catalog tests hold main.c
  and status.*. No HOST_TESTED row is needed.

Proof: the coverage lint reports 0 uncovered paths and 0 host-tested
row problems over 107 tests (its one failure is setup.check-lens's
src/lensfind.* entry, which the forgectrl pin bump clears); the gate
self-check refuses an empty artifact cleanly. The unit modules that load
the whole catalog pass under Linux: test_artifact, test_campaign,
test_cooling_order, test_fixture, test_manifest, test_operator.

Acceptance: the catalog hash moves; no remaining test's fingerprint
moves (the module goes whole).
2026-10-13 18:34:04 -04:00
ScottW514 0350b943d7 forgetest: laser.py's unused motion imports, and the two helpers no test calls
laser.py imported wait_state, wait_state_text, wait_left_state and
drain_text from motion.py and used none of them. wait_state and
drain_text stay in motion.py, which uses them; wait_state_text and
wait_left_state had no caller anywhere, so they go.

Proof: pyflakes no longer reports laser.py's four unused imports and
adds no finding. The coverage lint reports 0 uncovered paths and 0
host-tested row problems over 108 tests (its one failure is
setup.check-lens's src/lensfind.* entry, which the forgectrl pin bump
clears); the gate self-check refuses an empty artifact cleanly; the
forgetest host suite passes under Linux (539 tests).

Acceptance: the shared text of laser.py and motion.py moves, and with
motion.py's the tests of every module that imports it (motion, laser,
tray, updlock, and bedsize through homeoff). Against the image on the
bench that adds no run: 6a434bb already changed both files' shared
text.
2026-10-13 18:14:26 -04:00
ScottW514 35add7cad0 forgetest: laser.power-floor leaves the catalog; laser.py's dead names go
laser.power-floor read $$ off the controller and checked $35 is the
floor, that it lands at PWMSAR 12 or above, and that $31 is 0. The host
proves all of it now: laser_stream_test rule 18 reads $$ on a fresh
controller and after the arm (d7379d9), and laser_arm_test.c case J
holds a set floor key (honored, an out-of-range key falling back to the
default, 0 honored). forgectrl keeps no default for laser_floor_density:
an absent key is the controller's own floor.

- Its own lines go, and the names only it or the removed tests read:
  _floor, _grbl_settings, FLOOR_DEFAULT with the comment above it,
  PWM_PERIOD, PWMSAR_FLOOR_MIN, DISARMED_MSG, and the extra blank lines
  the earlier own-lines removals left.
- The module docstring no longer carves laser.power-floor out as the one
  test that does not emit; it says the live tests here emit, since
  laser.recorder-dark never fires.
- HV_DARK_MAX stays: it is the emission witness's limit.

Proof: the fingerprint parts of laser.py before and after: the shared
text changed, and every remaining test's own text is unchanged. The
coverage lint reports 0 uncovered paths and 0 host-tested row problems
over 108 tests (its one failure is setup.check-lens's src/lensfind.*
entry, which the forgectrl pin bump clears); the gate self-check refuses
an empty artifact cleanly; pyflakes adds no finding; the forgetest host
suite passes under Linux (539 tests).

Acceptance: laser.py's shared text moves the fingerprints of
laser.emission-witness, laser.armed-kill, laser.pause-resume-lid-cancel
and laser.recorder-dark. Against the image on the bench that adds no
run: 6a434bb already changed laser.py's shared text, so all four re-run
in the next campaign either way.
2026-10-13 18:03:51 -04:00
ScottW514 d7379d9fc0 laser_stream_test: rule 18 reads $31 and the floor off the controller
Rule 18 proved a typed $35 is overwritten at the arm and the ladder
renders through the floor key, but took the shipped settings on faith:
the harness mirrors $31 = 0 and the board's 10 % floor as constants. The
catalog's laser.power-floor read them off the machine; it can leave the
catalog once the host does.

- The floor-derived session reads $$ on the fresh controller (no floor
  key: the shipped default) and again after the arm overwrote the typed
  $35. Both reads must show $31 = 0 (the floor, not $31, sets the bottom
  of the S range) and $35 = the board's floor, and that floor must land
  at PWMSAR_FLOOR_MIN (12) or above.
- A set floor key is laser_arm_test.c case J's (12.5 honored, an
  out-of-range key falling back to 10, 0 honored). forgectrl keeps no
  default for laser_floor_density: an absent key is the controller's
  own floor, so there is no second default to compare.

Proof: laser_stream_test passes whole on a null-sink build of the
grblHAL-glowforge working tree under Linux ("$31=0 and $35=10 (PWMSAR
12) fresh and after the arm"). A control session with a floor key of 5
reads back $35 = 5 both times through the same parse, which rule 18
would fail. pyflakes is clean.

Acceptance: none. The harness runs in grblHAL-glowforge's CI.
2026-10-13 17:49:33 -04:00
ScottW514 21f53e4e83 forgetest: laser.verdict-cut leaves the catalog; the stream rules hold its claim
laser.verdict-cut held, on the machine, that a stale cooling verdict
mid-cut holds the job dark under the open window with the latch
untouched, and that the clean verdict resumes it lit with no press.
Every part of that is a rule the host now proves:

- dark while held: laser_stream_test rules 24 and 27, check_hold_dark on
  every verdict hold's stationary stretch, and check_fire_gaps (ef15c55);
- the hold, the message, the window kept, the latch never written, the
  resume lit with no press: rules 24 and 27 (latch sideband [unlock,
  lock]), laser_lifecycle_test rule 11, cooling_test.c;
- the physical dwell gap (a kernel-run gap inside the unlocked window,
  the button latch clear, HV_ENABLE back lit): laser.emission-witness,
  unchanged.

- Its own lines go, decorator through body, and nothing else:
  DISARMED_MSG, the module constant only its check read, stays with the
  blank lines around it, so laser.py's shared text is unchanged.
- test_laser_verdict.py goes: every case loaded dark_span out of
  verdict_cut's body.

Proof: the fingerprint parts of laser.py before and after: the shared
text unchanged, the other five tests' own text unchanged. The coverage
lint reports 0 uncovered paths and 0 host-tested row problems over 109
tests (its one failure is setup.check-lens's src/lensfind.* entry, which
the forgectrl pin bump clears); the gate self-check refuses an empty
artifact cleanly; pyflakes adds no finding; the forgetest host suite
passes under Linux (539 tests).

Acceptance: the catalog hash moves; no remaining test's fingerprint
moves.
2026-10-13 17:14:13 -04:00
ScottW514 ef15c55577 laser_stream_test: the verdict's holds ship no FIRE while held
Rules 24 (the pause tier, held and held again) and 27 (a stale verdict)
asserted the deceleration into each hold ran lit to the stop, the
stretch between the holds dark, and the cut lit after the clear, but not
that the stream is dark inside a hold: the central claim of the
catalog's laser.verdict-cut, which can leave the catalog once the host
proves it. Rule 21 proved it only for a plain feed hold.

- check_hold_dark: no FIRE tick inside a hold's stationary stretch, its
  edges aside (one step pulse straddles each edge: rule 21's margin,
  HOLD_EDGE_TICKS, 120 ticks at x8 scaled to the mode: 480 at the x32
  default).
- Rule 24 checks both of its holds; rule 27 checks its one hold.
- check_fire_gaps runs on both sessions, as on rule 21's.
- The header's rules 24 and 27 say so; their PASS lines report the held
  stretches.

Proof: laser_stream_test passes whole on a null-sink build of the
grblHAL-glowforge working tree under Linux: verdict-rehold dark while
held for 22620 and 22622 ticks, verdict-stale for 22621, check_fire_gaps
clean on both. A negative control on check_hold_dark: a dark 5000-tick
hold passes, and one FIRE tick inside it fails. pyflakes is clean.

Acceptance: none. The harness runs in grblHAL-glowforge's CI.
2026-10-13 16:55:50 -04:00
ScottW514 571f702113 forgetest: the emission witness samples a rapid after its M5; m5-rapid-dark leaves
The rule that a rapid after an M5 ships no FIRE is the host's (laser
stream rule 16). What only the machine shows is LASER_ON staying off
with FIRE clear while the chain can fire; kernel.fire-line's dark
sample runs with the chain unarmed. laser.m5-rapid-dark held that
sample in a test of its own, with its own arm and press. The emission
witness, which every campaign runs, now holds it.

- The square ends at M5 with no program end, so the window stays open.
- The dark point: the LASER_ON count reads 0 after the M5, the window
  still armed, and the SoC lock line released (laser_locked false: the
  engine's armed flag can read true after the latch has locked, and a
  dark reading with the latch locked is the unarmed case). All three
  are checked before anything else is sent.
- Only then is the rapid sent: G0 X20, a 2.5 s dwell, G0 X-20, a 0.5 s
  dwell, the program end. Sending it after the dark point keeps the
  mid-square dwell's own zero from being taken for the dark point.
- The test samples until the window closes (a dwell reports Idle, so
  Idle cannot end it), with the controller's machine X beside each
  sample.
- Every sample from the dark point until the window closes reads no
  emission, and the HV current stays at its idle reading (HV_DARK_MAX);
  the reports show the head out at +20 mm and back at the start inside
  that stretch, and the latch reads released on the sample at +20 mm.
- The square still has to show emission and an HV rise, judged on the
  samples before the M5; the beam detector is judged there too.
- The job-based disarm is timed from the head being back: under 10 s,
  where the idle grace is 60 s.
- The rapid's list and its sampler live inside the test, and the
  deletion takes only m5-rapid-dark's own lines (decorator through
  body), so laser.py's shared text is byte-for-byte unchanged.
- live_fire_drills.py's m5dark note and the bench README say the
  emission witness now carries the drill's check.

Proof: the fingerprint parts of laser.py before and after: the shared
text unchanged, every other test's own text unchanged. In the
controller, glowforge_laser.c keeps the latch unlocked from the job's
first laser-on through M5/M3 toggles and relocks it at program end or
after the laser_disarm_s spindle-off grace (60 s by default), so the
dark point and the rapid fall inside the window. The coverage lint
reports 0 uncovered paths over 110 tests (its one failure is
setup.check-lens's src/lensfind.* entry, which the forgectrl pin bump
clears); pyflakes adds no finding; the forgetest host suite passes
under Linux.

Acceptance: only laser.emission-witness moves, and as an always-required
test it must pass on the bench in the next campaign before a release
can inherit its PASS.
2026-10-13 16:30:45 -04:00
ScottW514 6a434bbf77 forgetest: nineteen tests a host test already proves leave the catalog
The catalog is for what needs the machine or an operator. Each test
below asserted software rules that a host test in CI already proves,
read assertion by assertion next to that host test before it left; the
record of that reading is kept with the audit.

Safety rules (each approved for deletion by the owner on that reading):
- laser.disarm-in-hold: laser_lifecycle_test test_resume_after_grace.
- laser.arm-wait-lid: laser_arm_test E-G, laser_lifecycle_test
  test_lid_open_in_wait.
- laser.port-dark: ctlport_test test_port_dark (port jogs, a long jog
  and its cancel, four injected words, the panel ops: no FIRE tick
  under an open window with M3 modal).
- motion.respawn-gate: super_test A and D.
- motion.interlock-cancel-home: the lifecycle loop cases, laser_arm_test G.
- motion.lid-policy-hold: laser_lifecycle_test test_lid_policy_hold.
- cloud.lid-during-button-wait, cloud.dark-print, cloud.verdict-refuse,
  cloud.paused-lid-cancel: test_machine_lid_button.py.
- cooling.fan-duty-readback: cool_fanwrite_test.
- cooling.intake-one-fan: cool_intake_test A-E.
- exthost.hold-pause-tier: holdkeep_test, run_test.py, cool_flow_test P,
  holds_test.

Other rules:
- homing.cloud-offsets: manual_home_test [own-offsets],
  test_gfhome_motion; the real session stays in cloud.mode-switch.
- exthost.core-range: version_test, install_test.
- exthost.operator-destinations: run_test.py, netrules_test,
  install_test, extpkg_test.
- exthost.lifecycle: run_test.py's ext.shutdown read before the stop.
- exthost.mcode: the null-sink mcode_test, forgectrl mcode_test,
  sdk_test, install_test.
- motion.soft-limits: z_envelope_test's homed cases, which now assert
  no step before the alarm, and envelope_test.

- cooling_intake.py, extcore.py, extdest.py, extlife.py and extmcode.py
  go whole; homeoff.py keeps only the camera-home helpers that
  cloud.mode-switch, setup.check-envelope and their meta-tests use.
- The helpers only the removed tests used go with them.
- exthost.armed-freeze still runs the cloud suite's dark-print helper,
  whose latch checks still run; its description says so. It now
  requires cooling.floor-and-warm-up, the dark print's own prerequisite,
  in place of the removed cloud.dark-print.
- HOST_TESTED: forgectrl src/holds.* (holds_test) and src/mcode.*
  (mcode_test), whose only catalog home left with these tests.
- The meta-tests that replayed the removed tests go:
  test_cooling_intake_suite.py, the cloud replay cases and their
  fixture. The one that catches a false PASS stays and runs the dark
  print as armed-freeze does: a latch open at the button fails.
- Text that named a removed test (exthost.motion-job's description and
  comment, test_baseline's docstring) no longer does.

Proof: the coverage lint on a manifest from the recipe pins reports 0
uncovered paths and 0 host-tested row problems over 111 tests (its one
failure is setup.check-lens's src/lensfind.* entry, which the forgectrl
pin bump clears); pyflakes adds no finding; the gate self-check refuses
an empty artifact cleanly; the forgetest host suite passes under Linux
(547 tests); the catalog loads with 111 tests after armed-freeze's
description edit.

Acceptance: the catalog hash moves, so the next image opens a new
campaign. Every remaining test in laser.py, motion.py, cloud.py,
cooling.py, exthost.py, homeoff.py and bedsize.py moves its
fingerprint (their modules' shared text changed); exthost.armed-freeze
moves by its requires.
2026-10-13 16:05:14 -04:00
ScottW514 bef1bf9902 z_envelope_test: a move refused past the bed ships no step
The homed cases asserted that a move past X max, Y max or X min draws
ALARM:2, and the harness's own description said "before any motion",
but nothing checked the motion. The catalog's motion.soft-limits held
that half on the machine, by the kernel counters; it leaves the catalog
with this harness as its proof.

- Every session mirrors the shipped stream (GFSINK_DUMP) into its work
  directory; steps() counts the tick bytes with an X, Y or Z step bit,
  as ctlport_test.py does.
- Each refused move is judged by the step count across it: unchanged,
  or the case fails with the ticks that shipped.
- The move inside the bed must ship steps: the control that shows the
  count sees motion at all.

Proof: z_envelope_test passes on a null-sink build of the
grblHAL-glowforge working tree under Linux, every case including the
three refusals before any motion and the control.

Acceptance: none. The harness runs in grblHAL-glowforge's CI.
2026-10-13 16:05:08 -04:00
ScottW514 7246707df5 forgetest: the gate test leaves the step lists to forgectrl's setup_test
setup.gate-blocks-controllers asserted on the machine which steps the
image's table requires (GET /wiz's missing list) and recommends (each
not run yet, never the gate's parts or the optional steps). Both are
the setup table's decisions over a record, software with no hardware in
them, and forgectrl's setup_test now proves both on the image's own
table: the required steps in order (10b), the missing list emptying
when they are done (4b), and the recommended steps in order, each not
run yet (10b). The catalog keeps what needs the machine: the supervisor
holding and spawning the controller, the process, and the Grbl port.

- The two missing-list checks and the recommended-list check leave the
  test's body; its description says where they are proven.
- RECOMMENDED_STEPS and NEVER_RECOMMENDED go with them (no test read
  them any more), and the meta-test case that only compared them.

Proof: the forgetest host suite passes under Linux (test_setup_suite
with the case removed among it); the coverage lint is unchanged.

Acceptance: every setup.py test (the constants were the module's shared
text), setup.gate-blocks-controllers among them.
2026-10-13 09:45:16 -04:00
ScottW514 ed1322518b forgetest: the lint takes python -m unittest given a test's file or module
forgectrl's CI runs its dev-server mock test as
`python3 -B -m unittest -v tests/test_devserver_mock.py`, and the
workflow judge counted python -m unittest only as `unittest discover`:
a HOST_TESTED row for that test was refused as not run.

_unittest_names: an argument to python -m unittest (not discover) runs
the test when it is the test's file (resolved against the step's
working-directory) or a dotted name whose module is that file
(tests.test_x, tests.test_x.Case.test_one); a -k pattern's value and an
argument with a shell variable are not names.

Proof: host tests: the CI's own command line, a module, a module with
a class and a method; not a module beside it, nor a -k pattern naming
it. The manifest tests (34) pass, and the forgetest host suite passes
under Linux. Against forgectrl's pinned workflow a row for
tests/test_devserver_mock.py is accepted; every earlier verdict on the
real workflows is unchanged.

Acceptance: none. forgetest is dev-only and in no fingerprint; the
lint runs in CI, not in a campaign.
2026-10-13 09:45:16 -04:00
ScottW514 8003d6f18c forgetest: the burns cover only what the burns run
BURN_COVERS named src/accel.* and src/wizcalc.*, carried over from the
sheet test the burns replaced. The burns' path is wizlive.c, then
wiz_lens_rehome() and lenshome_run(); neither file is on it, and the
focus burn holds the lens window as found rather than measuring stops.
An accelerometer or dark-wizard change re-required both burns for
nothing. Both files stay covered: src/accel.* by the cooling tests,
src/wizcalc.* by the setup checks that run it (lens travel among them).

Proof: the coverage lint on a manifest from the recipe pins reports 0
uncovered paths, as before. test_setup_burns, test_manifest,
test_campaign and test_artifact pass under Linux, and the forgetest
host suite passes under Linux.

Acceptance: setup.focus and setup.floor (BURN_COVERS is setup_burns.py's
shared text, so both fingerprints move; the catalog hash changes with
the covers).
2026-10-13 09:16:08 -04:00
ScottW514 a9c794be34 forgetest: a path a host test proves needs no catalog test
The coverage lint failed CI on any behavioral path no catalog test
covered, so every software change pulled a new or wider acceptance test
in with it, though the catalog is for what needs the machine or an
operator. A path whose behavior a host test in CI proves now has a row
in HOST_TESTED (manifest.py): the component, a glob, the host test, and
the CI workflow that runs it, the last two as "<repository>:<path>" - a
file of a component in the manifest (its tests/ and .github/ files are
listed there), or "forgefirm:" and a path in this repository (the
null-sink harnesses, which grblhal-glowforge's workflow runs).

- coverage_report counts a host-tested path as covered. Fingerprints
  are unchanged: a path no catalog test covers is in no fingerprint,
  so a change there makes no acceptance run necessary.
- host_row_problems reports a row whose glob selects no behavioral
  file; a host test or workflow that does not exist (a component file
  missing from the manifest, a "forgefirm:" file missing from the
  checkout, the dev-only forgetest, no repository named); a workflow
  that should be readable and is not; one that does not check out the
  test's repository; and one with no step that runs the test.
- The workflow is read at the pinned revision: read_source takes a
  component's blob from the bare repo manifest-from-tree.py fetched for
  its source URL (the checkout's .manifest-cache, or --sources), a
  "forgefirm:" file from the checkout. With no sources (an image) it is
  not read and the lint says so; with sources, a read that fails is a
  problem, never a pass.
- workflow_steps reads the workflow's steps by indentation (uses, run,
  working-directory, with), comments dropped, whole lines and from
  " #"; a quoted scalar loses its quotes, a quote inside a command
  stays. checkout_path finds where the test's repository is checked out
  (its own: the checkout naming no repository; another: the checkout
  of <owner>/<name>, the name from the component's source URL).
- workflow_runs looks at what a step's run commands execute, split into
  simple commands (;, &&, ||, |, newlines, then/do/else; a backslash
  continuation joined), with sudo and its flags, env, time, exec and
  variable assignments passed over, each path resolved against the
  step's working-directory and compared with the test's workspace path:
  the command itself (a script run directly, a C test's built binary
  <dir>/<stem>); the script an interpreter runs (python, bash, sh) or a
  for loop's list, by path or a glob matching the whole path segment by
  segment; unittest discover starting in the test's own directory with
  a pattern its name matches (test*.py unless -p); pytest given the test
  or its own directory, for a name it collects. An argument to anything
  else (echo, cat, a build target), a step's name, a comment, a
  trigger's paths filter, or a path that only ends like the test's does
  not count.
- The lint prints the row problems and fails --enforce on them, as on
  an uncovered path or an empty covers entry; --json carries them as
  host_tested, with workflows_read (false when a workflow that should
  have been read was not). It reads HOST_TESTED when it runs.
- The table starts empty: rows come with the tests that leave the
  catalog.

Proof: host tests: a host-tested row takes its paths out of the report
and moves no fingerprint; good rows report nothing; each kind of bad
row is reported with its reason; a workflow that runs one harness and
names another only in its paths filter, a comment line and an inline
comment runs the first and not the second; workflow_runs on a built
binary, sudo and a binary, a script, a harness under another checkout,
pytest on a directory, a for-loop glob and unittest discover under a
step's working-directory, and not under another one; and not on a
paths entry, an inline comment, a glob or a discover that the path
only ends like (tools/tests/..., scripts/bench/tests/...), a longer
name, another file, a step's name, or a commented line in a block;
not on echo with the stem or the binary's path, a build target, cat
of a script, unittest discover from a directory above the test or
with a pattern its name misses, or pytest on a directory above it;
on discover with -p '*_test.py', pytest given the file, a script run
directly and by bash, and sudo -E with assignments continued onto the
next line; checkout_path for its own and another repository and none;
repo_name from a source URL; a component's workflow read from a bare
repo's blob, judged, not judged without the sources, and a blob that
cannot be read reported; the command line with a workflow it cannot
read reports workflows_read false and the read failure, and fails
--enforce. The manifest tests (34) pass on Windows and under Linux;
the forgetest host suite passes under Linux. Against the recipe
pins and their fetched workflows: rows for forgectrl's tray_test,
forgeext's holdkeep_test, sandbox_test (under sudo) and run_test.py
(sudo, env and assignments over three lines), the laser_stream_test
harness (grblhal-glowforge's checkout of forgefirm), gfhardware's
test_machine_lid_button (a for-loop glob under its working-directory),
gfutilities' test_frame (pytest on tests) and forgetest's own
test_manifest (unittest discover under forgefirm/forgetest) pass;
rows for forgectrl's fflog_e2e.sh, the bench script fire_test.py and
kas/source-bundle.yml (only in forgetest-ci's paths filter) are
refused. The lint on that manifest reports 0 uncovered paths and 0
host-tested row problems.

Acceptance: none. forgetest is dev-only and in no fingerprint; the
lint runs in CI, not in a campaign.
2026-10-13 09:04:28 -04:00
ScottW514 1191389a2b forgetest: no press is ever held; the arm notice goes down at the press
- The presence press at the Ready gate polled forgectrl's /status over
  HTTPS every 0.25 s plus the request's own time, so a short press fell
  between two reads and the operator held the button for seconds. It is
  read as an edge from the switches' input events (hw.ButtonEdges, no
  grab), opened before the notice goes up: a press of any length counts.
  Events read and not yet looked at are kept, so a short press whose
  release arrives in the same read gives the release to the next wait.
  With no switches device (the host harnesses) the status is polled at
  50 ms.
- An operator's arm notice (arm_press, press_now) stood until the test's
  next step, over a press already taken. A watcher takes it down the
  moment the controller puts the button out at the press; a notice put
  up meanwhile is left alone.

Proof: host tests: a press and release written back to back on a FIFO
standing in for the switches' device pass the Ready gate in well under a
second with the status never showing the button down (the first version
dropped the release with the press and waited 10 s: the test caught it);
the arm notice stays while the button is dark or lit and goes down when
it goes out, and a notice put up meanwhile stays. The fixture (35),
setup (87), manifest (26) and operator (22) host tests pass.

Acceptance: these are the harness's own paths; every live and operator
test with a press runs through them.
2026-10-12 21:51:45 -04:00
ScottW514 4c5dde2549 forgetest: the burns, the lens and the intake checks follow forgectrl's review fixes
- baseline: the lid lamp fades to a new level (the kernel's smooth
  trigger), so the hand-back reads it until it holds the wanted level
  twice in a row, for up to 3 s, before it is a leftover.
  motion.deadman failed its hand-back reading 232 against 236 0.05 s
  after the supervisor re-applied 236 to a respawned controller.
- setup.check-lens and setup.focus: grblHAL reads the lens window and
  the edge only at its start, and their restore came after the wizard
  had restarted the controller on the test's values. Each test now waits
  for the step to end, puts the settings back, and restarts the
  controller (stop, start, running and verified with a new pid) on every
  exit path; cloud mode restarts nothing.
- setup.focus and setup.floor: the served program ends with G0 Z<edge>,
  G0 X0 Y0, G92.1, M2; setup.floor requires setup.focus and checks the
  scrap's thickness against the lens reach; the burns cover lenshome.
- setup.check-intakes: the two assertions that could not fail are gone;
  an intake-count prompt fails the test unanswered (the bench
  reference's intakes both turn). The precheck's refusal names no
  setting.
- setup.check-airflow: the machine's count rules (intake 2 turning
  writes 2; silent on a one-fan machine, the count is not written).

Shared text: setup_dark.py's precheck string (all setup_dark tests,
setup_burns, setup.check-envelope), setup_burns.py's imports, covers,
docstring and preview check, cooling_intake.py's docstring; every test
they move covers paths forgectrl changed in the same work.

Proof: the forgetest host suite passes (548), with new host tests for
the lamp's fade, the restarts on pass, fail and abort, the floor's Focus
refusal and reach, the tail without G92.1, the intakes prompt, and the
airflow count rules; pyflakes is clean on the changed modules.

Acceptance: these are the tests.
2026-10-12 19:53:20 -04:00
ScottW514 005ff8008b forgetest: the coolant flow calibration is a required step; the gate's missing list
forgectrl requires cooling.flow after preferences, machine, and cloud,
and GET /wiz lists the required steps the gate waits for as missing.

- REQUIRED_STEPS is the form steps plus cooling.flow, in the gate's
  order; REQUIRED_FORM_STEPS are the form steps. cooling.flow leaves
  RECOMMENDED_STEPS.
- setup.gate-blocks-controllers expects "setup required: preferences,
  machine, cloud, cooling.flow" on a record with no step done, with all
  four named in missing, and an empty missing list once they are done
  and the gate is open.
- setup.first-run-flow walks the form steps alone: its seed keeps the
  checks done, the calibration among them, so the gate's reason
  narrows from the three form steps to open.

Proof: the setup host tests (74) and the manifest tests pass; the host
test of record_with_required_steps keeps the four.

Acceptance: these are the tests.
2026-10-12 16:58:58 -04:00
ScottW514 748d28686d forgetest: the setup burns need two witnesses and record the beam sensor
setup.focus and setup.floor follow forgectrl: a burn counts on the tube
current and the kernel's LASER_ON samples, and its result records the
beam sensor's rise and beam_sensor_seen, never requiring a rise (a head
may give no output from the sensor). The check reads that both are
recorded and logs them; the descriptions name the two witnesses.

Proof: the setup host tests (74) and the manifest tests pass, two of
them new: a burn whose sensor gave no output passes, and a result that
does not record the sensor fails, each with the settings put back.

Acceptance: these are the tests.
2026-10-12 16:15:24 -04:00
ScottW514 ecbfe64cef forgetest: update.slots-and-signature reads the apply job's log lines
The foreign-signature drill's refused apply now also checks the forgectrl
log, from the size it had before the apply: "update: writing the
uploaded archive into slot <x>" and "update: the apply failed: archive is
not signed". The edit sits inside the test's own function.

Proof: the manifest tests pass and the module imports clean; on the bench
reference with the logging forgectrl, the same drill by hand logged both
lines.

Acceptance: this is the test; it covers src/update.c already.
2026-10-12 16:15:24 -04:00
ScottW514 a729197a8f forgetest: the first-run flow checks the machine facts see the head
setup.first-run-flow, after the machine step, checks that GET /wiz's
machine facts read the head as present with a hardware id. The facts had
read every head as not detected (forgectrl looked for "hw_id=" where the
module prints "id="); the bench reference always has a head, so the check
holds on every campaign.

Proof: the setup host tests and the manifest tests pass; on the bench
reference with the fixed forgectrl, GET /wiz reads the head as present
with hw_id 044c.

Acceptance: this is the test; it covers src/wiz.* already.
2026-10-12 15:32:11 -04:00
ScottW514 ed52e752ee forgetest: the setup required up to the intake fans; the burns replace the sheet
The catalog follows forgectrl's setup (openglow-org/forgefirm#4): the
gate is the advisories with the press, the account, the required steps
(preferences, machine, cloud), and the intake fan count; every other
step is recommended; the setup sheet is retired for two self-contained
burns and a dark lens travel check; the override file is gone.

- setup.gate-blocks-controllers installs four records: without consent,
  without the account, and without the three required steps the
  controller is gated with the reason naming the cause; with them done
  the gate opens, the controller runs, and the other steps are
  recommended as not run. Clearing cool_intake_fans closes the gate live
  with its reason, and putting it back as found reopens it.
- setup.override-until-reboot is deleted with its helpers, and
  forgetest.init no longer writes the override file at boot.
- setup.first-run-flow follows the reason from the three required steps
  to open; setup.first-run-page walks the choice screen after them;
  setup.what-changed reads the one-level map with the gate unmoved.
- New setup.check-intakes (the count by the tachs equals the machine's,
  the setting put back as found) and setup.check-lens (the window
  written equals the stops found or the fallback, and the settings and
  /status hold it; both keys restored). A precheck refuses the intake
  and airflow checks while cool_intake_fans is unset, so neither can
  leave it unset in the middle of a campaign.
- setup_burns.py replaces setup_sheet.py: setup.focus and setup.floor
  answer the gantry stops, the home, the scrap and the thickness, and
  keep the preview, emission and window checks; motion.tray starts the
  focus burn for its tray-out refusal.

Proof: the forgetest host suite (529 before the required-steps edits,
and the full suite after them) and the manifest load pass; pyflakes
reports no undefined names. The coverage lint is clean against
forgectrl's working tree; against the current pin, setup.check-lens's
src/lensfind.* glob selects no file until the forgectrl pin moves to the
commit that adds it.

Acceptance: these are the tests themselves; the campaign runs them with
cool_intake_fans set on the bench.
2026-10-12 15:21:06 -04:00
ScottW514 dfd32d5fd1 Release v0.0.8
FORGEFIRM_RELEASE is 0.0.8, and releases/v0.0.8 carries the release notes
and the acceptance artifact that forgetest exported on the bench reference.

The artifact is from campaign c-20261010224522-439d on the pins-only dev
image 20261010224118: authorized, 128 of 128 tests satisfied, 48 run on that
image and 80 inherited. scripts/acceptance-gate.py against that build's
release manifest gives AUTHORIZED, with inputs identical to the bench image.
2026-10-10 19:45:32 -04:00
ScottW514 35221de791 forgetest: every machine action a test takes is declared
The queues route a test by its kind and its declared actions: an auto
test runs unattended with or without the bench fixture, and an operator
test whose actions the fixture covers runs unattended only while it
does. exthost.events opens and closes the lid through Context.act but
was declared auto with no actions, so the unattended queue ran it with
no fixture on the network, and it waited 180 s for a person who was not
there: the fx29 campaign (image 20261010213702) ended on "lid open was
not seen on the machine within 180 s" with forgefixture.local silent to
mDNS and 172.16.1.171 silent to ping.

- exthost.events: operator, actions lid, with the operator's steps.
- events.button-telemetry: the same defect with the button; operator,
  actions button, with the operator's steps.
- setup.check-switches: declares interlock beside lid and button; the
  check works the interlock loop on a Pro.

test_fixture's new CatalogTests case reads every catalog test's source
and fails a test that calls Context.act on a channel it does not
declare, or that is auto and calls it at all. It found these three and
no other.

Fingerprints: the three decorators are each test's own text, so these
three move and no other (diffed across the change, 128 tests).

Proof: the whole host suite passes (509 tests) and pyflakes finds no
undefined name. RoutingTests already prove the routing these
declarations feed: an operator test the fixture covers moves to the
unattended queue, one it does not stays attended, and with no fixture
the routing is the old one.

Acceptance: the change is the three tests' declarations; with the
fixture up each runs unattended through it as before, and with the
fixture down each stays in the attended list instead of stranding the
unattended queue.
2026-10-10 18:40:10 -04:00
ScottW514 46b6df1c2d Pin forgectrl 0.1.30 on the pushed head
- forgectrl c274f19 (0.1.29 -> 0.1.30): a machine built with one intake
  fan. cool_intake_fans 1 publishes the second intake as not fitted and
  never judges it, and the setup's airflow check writes the count it
  finds (openglow-org/forgefirm#3).

Proof: bitbake -c fetch forgectrl from this pin succeeds (2 tasks). The
tree manifest from the pins holds forgectrl at c274f19 (218 files), and
the coverage lint over it finds no uncovered path and no empty entry
(128 tests).

Acceptance: cooling.intake-one-fan and setup.check-airflow gate the
change, with every test whose coverage names src/cool.*, src/wizdark.*,
src/wizcalc.*, src/main.c or src/ui/**. The forgectrl and forgetest trees
of this pin passed cooling.fan-gate-trips, cooling.intake-one-fan and
setup.check-airflow bind-mounted on image 20260928194539; the campaign
runs on the image built from these pins.
2026-10-10 17:34:52 -04:00
ScottW514 16a8ae9ba3 forgetest: cooling.intake-one-fan, and setup.check-airflow reads the intake fan count
forgectrl c274f19 adds cool_intake_fans: at 1, a machine built with one
intake fan, the second intake is published not fitted and never judged,
and the setup's airflow check writes the count it finds.

cooling.intake-one-fan, in a suite module of its own, proves both halves
on a machine with two intake fans with the setting alone. With
cool_intake_fans 1 and an 8 s grace, a session at the cut fan profile
must publish intake_2 as not fitted with no floor while it reads a real
speed, run OK, and judge intake_1 ok at the intake floor; with the intake
floor at its legal maximum it must trip AIRFLOW (hold, fire blocked) with
intake_1 TRIPPED and intake_2 still not fitted, and the fault must end
with the session; restored, every fan reads ok at its floor. It starts
only from a machine with both intakes judged, requires
cooling.fan-gate-trips, and puts the count, the intake floor and the
grace back as found on every exit path.

setup.check-airflow now reads the count from the result: 2 needs all
four fans at least 1000 rpm, 1 needs intake 2 reported {"fitted": false}
and the other three ok; either way the setting must read what the check
found, and the count is put back with the five floors.

Fingerprints: the new test has a module of its own, so no cooling test
moves; the setup.check-airflow edit is inside its function. Diffed across
the change: 127 -> 128 tests, one added, setup.check-airflow the only
one moved.

Proof: the touched host tests pass (test_cooling_intake_suite 4,
test_setup_dark 10, test_cooling_suite 25) and pyflakes is clean.
test_cooling_intake_suite replays the test against a scripted engine,
and two broken engines fail it with the settings put back: one that
ignores the setting, and one that takes intake 1 out with intake 2.
test_setup_dark's new AirflowCheckTests take a two-fan and a one-fan
result, and fail a count the setting does not match and a result with
no count. On the bench reference, image 20260928194539 (dev) with these
files and forgectrl c274f19 bind-mounted, cooling.fan-gate-trips,
cooling.intake-one-fan and setup.check-airflow pass with a clean
baseline before and after each (forgectrl's commit has the readings).
2026-10-10 17:33:43 -04:00
ScottW514 72907f311a Update README 2026-10-06 15:59:31 -04:00
ScottW514 3873013383 v0.0.7 release notes: the text as revised after publishing
The notes were rewritten after v0.0.7 was published: the extensions
paragraph and every item under "Also new". The release page on GitHub
now carries the same text, followed by its "Installing your own build"
section. The v0.0.7 tag keeps the notes as first published.
2026-09-29 06:58:40 -04:00
ScottW514 2ea501f975 Release v0.0.7
FORGEFIRM_RELEASE is 0.0.7, and releases/v0.0.7 carries the release notes
and the acceptance artifact that forgetest exported on the bench reference.

The artifact is from campaign c-20260929000044-e1ea on the pins-only dev
image 20260928194539: authorized, 127 of 127 tests satisfied, 7 run on that
image and 120 inherited. scripts/acceptance-gate.py against that build's
release manifest gives AUTHORIZED, with inputs identical to the bench image.
2026-09-28 20:09:15 -04:00
ScottW514 1bc15fe70a forgetest: theme.css from forgectrl at its new pin
The pin to forgectrl 4b39663 brings theme.css's rules for the Extensions
tab and a package's page in the panel's theme (94d8af5), which
forgetest's shared copy did not have, so check-ui-vendor would fail
forgetest-ci. The copy is forgectrl's file at 4b39663, byte for byte; the
rules only add classes forgetest's page does not use.

Proof: scripts/check-ui-vendor.py, fetching forgectrl's files from GitHub
at the pinned revision, reports the shared UI files identical.
2026-09-28 15:32:29 -04:00
ScottW514 66b39c6f2b Pin forgectrl 0.1.29, forgeext 0.1.1, grblHAL-glowforge 0.1.20 on the pushed heads, and meta-openglow in the kas lock
The work since the last pins, pushed to each component's default branch,
is what the recipes now fetch:

- forgectrl 4b39663 (0.1.28 -> 0.1.29): the manual home offset and the
  gantry stops by name in the panel, the Extensions tab and ui.background,
  the Grbl sender keep-out, the head camera for local viewers and live
  video on a package's page, the display units in the bridge's self
  answer, the crumb tray's switch and offset, the Lens card's fields
  saved, the listener's cipher order (ChaCha20-Poly1305 first), and the
  camera pipeline (coherent capture buffers on the GPU path, the two-pass
  NV12 render, the frame gate, the /cam/status timing, one switch fd).
- forgeext 2d0391e (0.1.0 -> 0.1.1): ui.background, sender.keep_out and
  live video in the SDK, a package removed and installed again started
  from the new install, and the same ffx archive for the same input.
- grblHAL-glowforge b34636f (0.1.19 -> 0.1.20): the manual home offset,
  the gantry stops by name, the controller port's sender out and in with
  the keep-out, and the crumb tray's M103 P0 and P1.
- kas lock: meta-openglow f1f7f58 -> f012d8e, kernel TLS with the NEON
  crypto and the TX records in cache lines of their own (patch 0016).

Proof: bitbake -c fetch of forgectrl, forgeext, grblhal-glowforge,
linux-fslc and kernel-module-glowforge from these pins succeeds (10
tasks), with kas moving layers/meta-openglow to f012d8e. The tree
manifest from these pins holds forgectrl at 4b39663 (217 files), forgeext
at 2d0391e (90) and the driver at b34636f (177), and the coverage lint
over it finds no uncovered path and no empty entry (127 tests).

Acceptance: the same trees, through the externalsrc overlay and the
mirrored meta-openglow at f012d8e, are image 20260928182434, on which all
127 tests are satisfied (the unattended campaign and the attended tests
the operator ran). The meta-openglow move is a platform change; the
image built from these pins alone is next.
2026-09-28 15:32:29 -04:00
ScottW514 83686fc712 forgectrl.tls-records: a window another client connected in is measured again
The test judges each connection by the kernel's TLS counters and the
crypto engine's interrupt, and both are the machine's, not the
connection's. On image 20260928175056's campaign the test before it
restarted forgectrl, the operator's panel (six HTTPS connections from
the bench PC) reconnected in the same second as aes-1.3#1, and the
kernel counted 7 sessions each way where the test wanted exactly 1; the
connection itself chose AES-128-GCM, carried the page byte for byte, and
counted no decrypt error. _fetch now also reads the machine's accepted
TCP connections (PassiveOpens, shared by both families); a case whose
window saw any connection but its own is measured again, up to 30 times
after a pause drawn from 0.1 to 0.9 s (so no steady poller, such as a
page polling this suite once a second, stays in step with it), and
fails if every window was contested. The judgments are unchanged: exactly one session each way for a cipher the kernel seals,
none for CBC, the engine's interrupt counting the AES records and not
the ChaCha20 ones.

Proof: pyflakes clean. On the bench reference, image 20260928175056,
with an openssl client connecting to :443 every 0.25 s for the first
6 s of the run and the drill polling this suite once a second: the
image's test fails (desktop-1.2 counted 2 sessions each way), the fixed
test passes with contested windows measured again (at most 4 attempts a
case), and passes again without the extra clients (at most 4).

Acceptance: the change is forgectrl.tls-records itself; tlsrec.py holds
no other test, so no other fingerprint moves.
2026-09-28 14:23:04 -04:00
ScottW514 f47ddad839 exthost.platform: the frozen window opens once the freeze has landed
The test took its first reading of the frozen probe's cpu.stat the moment
cgroup.events said frozen 1, and wanted 0 us over the next 1.5 s. The
kernel reports a task frozen before its last way into schedule() is
charged to the group, so a reading at the event can come before that
charge lands. On image 20260928170058's campaign the frozen probe was
charged 16 us and the test failed. The first reading now comes 0.2 s
after the event; the window and its judgment (0 us) are unchanged.

Proof: pyflakes clean. On the bench reference, a spinner under cpu.max
5% in a cgroup of its own, frozen 520 times with the first reading taken
both ways: read at the event (polled every 50 ms, as the test does), the
next 0.5 s was charged 4 times (13 to 20 us); read after the 0.2 s
settle, never.

Acceptance: the change is exthost.platform itself, inside its own
function, so no other test's fingerprint moves.
2026-09-28 13:49:30 -04:00
ScottW514 eb9e61d3c6 motion.soft-limits: past the envelope the home armed, and stopped before it fails
After a home the driver's X/Y envelope reaches to the far edges the bed
check measured (envelope_x_mm, envelope_y_mm, held by
gfhome_clamp_envelope_mm to 50 mm up to the travel plus 30 mm), and to
$130 by $131 only on a machine that was never measured. The test aimed
its refusals at $130 + 5 and $131 + 5, which holds only on an unmeasured
machine. On the bench reference, measured at X 508 and Y 288.925, image
20260928153805's campaign sent G90 G1 X500 F600, the controller rightly
ran it, and the failed check ended the test before its soft reset, so
the head drove the whole 476.6 mm while the restores were refused with
409 for 40 s; the jog check after it would have run a 500 mm jog the
same way. The test now takes the far edges as the driver does and sends
each move and the jog 5 mm past them, and it stops a move that was not
refused (the soft reset, now ahead of the checks) and a jog that was not
refused (the jog cancel) before it fails. The judgment is unchanged:
ALARM:2 with no kernel motion, error 15 for the jog, a move inside the
bed runs.

Proof: pyflakes clean. On the bench reference, image 20260928153805 with
this suite bind-mounted, the test passes: the far edges 508 and 288.925,
G90 G1 X513, Y293.9 and X-1 each refused with ALARM:2 and 0.0 mm of
kernel motion, the jog refused with error 15, the move inside the bed
run, the head handed back where it was found, the baseline clean.

Acceptance: the change is motion.soft-limits itself; its own function and
description carry it, so no other test's fingerprint moves.
2026-09-28 12:58:34 -04:00
ScottW514 55e789bc34 camera.frame-health: five seconds of stream, none withheld, and its timing
forgectrl now publishes a stream frame only once the frame after it has
come back clean, withholds both neighbors of a frame the receiver
flagged, and reports health.withheld and a per-stage timing block in
/cam/status. camera.frame-health keeps its snapshot burst and adds five
seconds of MJPEG: at least 30 frames dequeued, none flagged, none
withheld, a withheld count and a timing block present while the viewer
watches, and capture end to publication above 0 and under 250 ms.

Proof: the test fails on image 20260928000227's forgectrl, which reports
neither the withheld count nor the timing block, and passes on forgectrl
with the frame gate (75 frames, none withheld, 67.3 ms capture to
publication, on the bench reference). The unit suite (500 tests),
pyflakes, and the coverage lint (0 uncovered paths) pass on the host.
2026-09-28 11:35:35 -04:00
ScottW514 11031ad87f GnuTLS with kernel TLS, and forgectrl.tls-records
The gnutls bbappend builds GnuTLS with --enable-ktls and installs
/etc/gnutls/config with ktls = true, so after the handshake the kernel
seals and opens forgectrl's HTTPS records (the kernel side is
meta-openglow's CONFIG_TLS and patch 0016). It also backports GnuTLS
dc016daf: 3.8.4 hands the kernel the record sequence number where a TLS
1.2 ChaCha20-Poly1305 connection's IV belongs, so every such connection
failed the kernel's first decryption. forgectrl is the only program on
the image that links GnuTLS.

forgectrl.tls-records, in its own module (suite/tlsrec.py), reads the
result from the outside with openssl s_client on loopback: a desktop
offer (AES-GCM first) gets ChaCha20-Poly1305 over TLS 1.3 and 1.2 and the
kernel takes both directions' keys; an AES-128-GCM-only offer, three
times over each protocol, gets it, the kernel takes its keys, and the
CAAM's job-ring interrupt counts the records; a TLS 1.2 CBC-only offer
connects and stays in GnuTLS (the control for the kernel's counters);
every copy of the panel page equals the plain-HTTP copy byte for byte,
with no decrypt error; the kernel's drivers are
rfc7539(chacha20-neon,poly1305-neon) and ctr-aes-caam with ghash-ce.

Proof: on the image before these fixes the test failed on both bugs it
names (the TLS 1.2 ChaCha20 page arrived empty with a decrypt error; the
AES-GCM pages arrived damaged); on image 20260927224418 it passes. The
unit suite (500 tests) and the coverage lint (0 uncovered paths) pass on
the host.
2026-09-27 20:01:31 -04:00
ScottW514 cd84786fc5 tray_test.py and motion.tray: the crumb tray's mode on the null-sink controller and on the machine
- scripts/bench/tray_test.py, run by the driver's CI: M103 P1 and P0
  move Z and its envelope by tray_offset_mm on the lens step grid, tell
  the sender, write and remove the marker beside the shared config, and
  ship no step; a missing P, P2, P0.5, P-1, and an axis word are errors
  that change nothing; the switch behind a move waits for the move; the
  port's tray op with a sender connected, its count exact, refused out of
  form, during a program, and at a package's M-code; the mode across a
  soft reset and a killed controller, a start over the marker taking the
  lens reference in the tray-out frame; the offset's range; an
  unreferenced lens; a camera home in both modes handing the runner the
  same park. The kernel-idle half of the barrier needs the pulse device:
  a null sink is always idle.
- scripts/bench/ctlport_test.py: the dark case carries tray out and in
  under an open armed window.
- forgetest: motion.tray in its own module, suite/tray.py, so no other
  test's fingerprint moves. It switches the mode both ways through the
  panel's route and through M103 from a Grbl client and judges each
  switch: /status's mode, Z, and reach moved by the offset's grid, the
  port's state, the marker, the reference and its source unchanged, the
  client told, the kernel's counters unmoved. A jog to the Z the lens
  read before a switch is refused in the other mode and taken back in
  the mode found; a setup card started with the tray out is refused in
  the operator's words; the panel page carries the switch. The mode goes
  back as found in a finally. It does not restart the controller, which
  would drop the X and Y reference: persistence is tray_test.py's.
- The bench registry and README list tray_test.py.

Proof: tray_test.py ALL PASS against the driver with the mode, and six
driver copies each missing one part of it fail. The driver's whole CI
harness list passes. The forgetest unit suite: 500 tests OK. The
coverage lint over the working trees: nothing uncovered, 126 tests. On
the bench reference, with the binaries hot-deployed on the fx18 image:
motion.tray PASS, and FAIL against the driver whose anchor lost its
source word on the second switch.

Acceptance. motion.tray is new; the suite and bench files are in no
other test's fingerprint.
2026-09-27 15:23:02 -04:00
ScottW514 6e71ac86f0 forgetest: exthost.sender-keep-out, camera.lid-privacy for local viewers, and ctlport_test's keep-out
- suite/extsender.py (new module, so no other test's fingerprint moves):
  exthost.sender-keep-out. A reference package whose service claims the
  Grbl sender out as it starts, installed with the sender.keep_out
  grant (and refused without it), on an idle GRBL-mode machine with no
  sender connected. It checks the holder and the ext: lease of the
  extension kind, the host's claim file fresh past forgectrl's grace, a
  sender from the machine's LAN address turned away with the message
  and one from the loopback admitted, the operator's release (lease
  gone, network welcomed, the package reading released), a new claim
  after a restart, and the stale end with the notice after a disable;
  everything is put back as found.
- camera.lid-privacy: the head camera answers the panel's token and the
  extension host's header with the lid open, and refuses the cloud
  client's form (no token, no header) and a stream for no local viewer;
  the lid camera stays refused to everyone. covers gains src/auth.*.
- scripts/bench/ctlport_test.py: test_sender_out on the null-sink
  controller build (Sender(host=) and closed, own_address(),
  generation()).

Proof: forgetest's unit tests pass; the coverage lint passes against the
working trees' file lists. Against the current pins it reports
src/senderout.* as selecting nothing until forgectrl's pin moves to the
revision that adds it, which lands with this. On the bench reference
with the suite mounted: exthost.sender-keep-out PASS (first run found
the check racing the host's first write; the test now waits for the
file and holds past the grace), and camera.lid-privacy passed every
check of its body (its hand-back failed only on the controller gated by
the privacy advisory's Revision 5 before it was accepted).
ctlport_test.py passes in the driver's CI.
2026-09-26 18:35:53 -04:00
ScottW514 1727b819cb homing.manual and the harnesses: the gantry stops by name
The parts the manual home rests against are the gantry stops in
homing.manual's title and comments, in manual_home_test.py's
docstrings, and in a comment of the baseline's hand-back. No check
changes.

Acceptance: homing.manual's own text changed, inside its function, so
no other test's fingerprint moves. manual_home_test.py passes against
the driver.
2026-09-26 12:08:07 -04:00
ScottW514 b494bbecb5 homing.manual and the host harness: the manual home jogs to an offset origin
homing.manual runs two homes. With manual_home_x and _y unset, $H
plays no byte and declares X0 Y0 (the checks it had). With 5 and 3, $H
declares the head at minus them and jogs it to X0 Y0 before it answers:
the sender is told the declared position, the kernel plays the move,
Grbl and forgectrl read X0 Y0, and a jog behind the origin draws
error:15. The test restores homing_mode and both offsets as found, and
returns the head by what the machine read, so a move cut short still
comes home. The change stays inside the test's own function.

manual_home_test.py: case 7 is the offset's new meaning (declared at
minus the offset, the move dark under a modal M3, the port's home, the
clamps); a new case cancels the move with a jog cancel and expects it
to stop short with $H saying where; case 8 reads the declared position
from the sender's message. The bench README says so.

Proof: manual_home_test.py ALL PASS against the driver change (ten
cases), and its cancel case fails against a build with the driver's
cancel hook left out. On the bench reference, fx14 with the driver
change and this suite file in place of the image's, homing.manual PASS
in campaign c-20260926000059-e731: with no offset no byte and X0 Y0;
with 5 and 3 the sender was told X-5.002 Y-3.000, the kernel played
1067 and 640 steps, Grbl and forgectrl read X0 Y0, error:15 behind the
origin; settings restored and the baseline clean. The forgetest unit
tests pass.

Acceptance: homing.manual is the test; its covers are unchanged (the
driver's src/**, forgectrl's status and main.c).
2026-09-26 10:02:10 -04:00
ScottW514 b2e0f91342 forgetest: theme.css from forgectrl at its new pin
The pin to forgectrl 55bfae9 (9f7744b) brought theme.css's extension
rules - the advisory's document box and the capability lists (cf140a9),
and a package's frame (7543eea) - that forgetest's shared copy did not
have, so check-ui-vendor failed forgetest-ci. The copy is forgectrl's file
at 55bfae9, byte for byte; the rules only add classes forgetest's page
does not use.

Proof: scripts/check-ui-vendor.py reports the shared UI files identical
at the pinned revision.
2026-09-25 19:46:25 -04:00
ScottW514 a05a226305 kas lock: meta-openglow onto the head that pins python3-gfhardware 021323d
meta-openglow 2978522 -> f1f7f58. The library's pin lives in the BSP
layer and still named 6cc4f45, so the image built from 9f7744b carried
forgefirm-app 021323d beside python3-gfhardware 6cc4f45: without the
report secret in coolsvc, which forgectrl 0.1.28 requires of every
cooling report. f1f7f58 moves the library to 021323d with the app.
2026-09-25 19:32:33 -04:00
ScottW514 9f7744b459 Pin forgectrl 0.1.28, forgeext 0.1.0, grblHAL-glowforge 0.1.19, forgefirm-app 0.1.31 on the pushed heads, and meta-openglow in the kas lock
The extensions work, merged into each component's default branch and
pushed, is what the recipes now fetch:

- forgectrl 55bfae9 (0.1.27 -> 0.1.28): the machine lease, the job runner,
  the controller port client and motion routes, scoped tokens, the event
  stream, the built-in registry, the operator's door to extension packages
  and the Extension packages card, the catalog from its own repository,
  and privacy advisory revision 4.
- forgeext 711f099 (first pin, 0.1.0): the extension host, and ffx new
  making a package's repository.
- grblHAL-glowforge 0da0fe6 (0.1.18 -> 0.1.19): manual homing, the X and Y
  motor release, the controller port, custom M-codes, the envelope rule,
  and the homing runner's report secret.
- forgefirm-app 021323d (0.1.30 -> 0.1.31): the report secret, and a
  homing motion cut short failing the home.
- kas lock: meta-openglow 71fad52 -> 2978522, the kernel's sandbox options
  (NF_TABLES, LANDLOCK, MEMCG, the cgroup scheduler) and their notes.

Proof: bitbake -c fetch of forgectrl, forgeext, grblhal-glowforge,
python3-gfhardware, gfhome, gfcloud and python3-ffmachine from these pins
succeeds (14 tasks), with kas moving layers/meta-openglow to 2978522. The
tree manifest from these pins holds forgectrl at 55bfae9 (211 files),
forgeext at 711f099 (90) and the driver at 0da0fe6 (175), and the coverage
lint over it finds no uncovered path (124 tests).

Acceptance: the meta-openglow move is a platform change, so the whole
catalog runs again on the image built from these pins.
2026-09-25 19:27:37 -04:00
ScottW514 8dc7c0bc56 forgetest: the stale boot reference judged at a held uptime, on both sides of the limit
test_stale_preconfig_reference_is_retaken_on_a_fresh_boot read the host's
uptime after boot_reference() had read it to decide, and expected the
decision its own reading implied. On a CI runner the uptime crossed
BOOT_MAX_AGE_S (600 s) between the two readings: boot_reference retook
the reference and the test expected the stale one (forgetest-ci on
0eb0a16). The test now holds uptime_s() at 60 s under and over the limit
and judges both answers: retaken with the fresh sysfs under it, the stale
reference kept and marked over it.

Proof: test_baseline passes on Windows and on Linux; with
boot_reference() ignoring the uptime (a planted fault) the test fails in
all three classes that run it.
2026-09-25 19:05:17 -04:00
ScottW514 0eb0a16452 forgetest: forgeext's packages/ exemption goes with the packages
OpenGlow's packages left forgeext for repositories of their own, so the
non-behavioral entry ("forgeext", "packages/**") names nothing. The
manifest test checks the shared package workflow's kit action as
non-behavioral in its place (".github/**" covers it). The manifest tests
pass (26); manifest.py is in no fingerprint.
2026-09-25 18:43:32 -04:00
ScottW514 bfcfd7a376 forgetest: exthost.catalog on the catalog's own repository and its judged index
The address is the catalog repository's index-1.ffi. On the scratch root
the index lists the probe in two versions: 1.1.0 asks for a capability no
firmware has, and read back it is kept and not offered, the offer being
1.0.0. An older index is refused however well signed, and the one kept
stays. An index withdrawing the probe's 1.0.0 makes inspect refuse it in
OpenGlow's words. GET /ext/catalog carries core_checked. The covers widen
from forgeext src/install.c to src/install.*. The test still never
requests the index's address.

Its own module (suite/extcat.py), so only exthost.catalog's fingerprint
moves. forgetest's 500 unit tests pass; the function was read against
_forgeext, fc.get and fc.post, and ctx.check, since no host test runs a
suite function.
2026-09-25 18:22:31 -04:00
ScottW514 d2307a899e forgetest: the cooling tests judge the log line their own run wrote
Six checks in five cooling tests read forgectrl's log tail once for the
line the engine writes when it acts: the TEC-on line (cooling.tec-drive),
the off gate's line and the run-end temperature line (cooling.gate-off),
the warm-up release and the floor's off line (cooling.floor-and-warm-up),
the critical line's off line (cooling.critical-tier), and the two
put-back lines (cooling.fan-duty-readback). The engine writes to the
device first and logs after, and the line reaches the file through
rsyslog a moment later still: on the bench reference cooling.tec-drive
read thermal/tec_on = 1 and then the tail a few milliseconds before
"TEC on: coolant 26.3 C over 24.5 C, airflow up" landed, and failed. A
tail read also takes a line an earlier run left behind for this run's.

Each check now marks forgectrl's log before the action that makes the
line (the settings write, the M8, the duty written behind the engine's
back) and waits up to 5 s for the line among those written after the
mark. Every line was checked against where cool.c writes it: gates_apply
for an off gate, the run's end for the temperatures, the warm-up release,
fans_verify for a put-back, the TEC policy for the TEC. The helpers
are motion's log offset pair, imported inside each test's function as the
fire-watch tests already do; the tests that poll the tail already
(_tail_wait) are unchanged.

Proof: tests/test_cooling_suite.py's fake engine writes its lines to a
scratch forgectrl log as well as to the fake tail, and a new case puts
an earlier run's off-gate line in the log with the engine writing none
this run: it fails as it must, and against the checks before this it
passes. 25 cooling cases pass; forgetest 500 OK. On the bench reference,
image 20260925183749 with this file mounted: cooling.tec-drive,
cooling.gate-off, cooling.floor-and-warm-up, cooling.critical-tier and
cooling.fan-duty-readback PASS, each baseline clean.

Acceptance: the five tests are the change; their fingerprints move and
no other test's does.
2026-09-25 15:57:08 -04:00
ScottW514 8570c0141e forgetest: motion.soft-limits runs its own camera home and hands the head back
motion.soft-limits homed only when the machine was not homed already,
and then with whatever homing_mode the machine had: it counted on the
home cloud.mode-switch used to leave behind. cloud.mode-switch now drops
its camera home when it hands the head back, so the test started
unhomed, and under the operator's homing_mode = manual its $H was a
manual home, declared at once where the head stood (2.2 s, no web-service
session), and the test failed on gfhome's missing completion line. When
it did home, it left the head at the corner and the machine homed.

The test now sets homing_mode = gfcloud and unsets the camera-home
offsets for its run, so the envelope is the bed from the home corner,
and always runs its own camera home, every motion of it judged whole
(homeoff.judge_whole_motions). A setting is written when the controller
takes it (it refuses one for a moment after a Grbl client closes). At
the end the settings are put back as found and homeoff.camera_home_return
drops the home and jogs the head back to where the test found it. The
helpers are imported inside the function: homeoff imports this module.

Proof: on the bench reference, image 20260925183749 with this file
mounted, motion.soft-limits PASS: the camera home in 52.6 s, every
motion completed; X max, Y max and X min refused with ALARM:2 and no
motion, the jog past the bed refused with error 15, the move inside the
bed ran and came back; the settings put back; the head jogged back
28.95/13.03 mm to where the test found it; the baseline clean. forgetest
499 OK.

Acceptance: the test is the change; its fingerprint moves and no other
test's does.
2026-09-25 15:21:45 -04:00
ScottW514 800863fb09 forgetest: the cloud tests wait out the service's thinking
wait_quiet took the machine as quiet after 8 s without the start of a
motion, a park, a run or a lens homing. Between a lid image and its next
move the service is working on the image and the log is silent, and on
the bench reference the re-hunt's moves came 9.6 s apart: cloud.mode-switch
switched back to GRBL mode in the middle of the re-hunt, twice, the first
time canceling a motion at 988 of its 1002 steps.

Every line of a service action now counts as activity: the requests, the
image uploads, the action ends, the runs, the parks, the motions and the
lens homing. The quiet is 30 s. Over 1901 motion, lid-image and hunt
requests in the bench reference's logs, half came within 1.6 s of the
line before, 99 percent within 12.8 s, and two after more than 30 s
(32.8 and 62.2 s).

return_head goes: cloud.mode-switch hands its cloud stretch back through
the cloud client's log (homeoff.cloud_mode_return), and nothing else
called it. RETURN_MAX_MM moves to homeoff, its one user.

Proof: tests/test_cloud_suite.py's new case lands a motion, a lid image
0.8 s later and the next move 1.6 s after the motion, with the quiet at
1 s: the quiet comes after the move. With the old activity marks it is
declared after the image, before the move. forgetest 499 OK.

Acceptance: cloud.mode-switch gates the change on a machine, its re-hunt
waited out before the switch back. The fingerprints of every test in
suite/cloud.py and of every module that imports from it move: 29 tests,
the cloud tests, events.button-telemetry, the exthost tests,
homing.cloud-offsets and setup.check-envelope.
2026-09-25 14:22:51 -04:00
ScottW514 cfef6a1b84 forgetest: the camera-home tests hand the head back where they found it
homing.cloud-offsets, setup.check-envelope and cloud.mode-switch let the
service move the head, and ended with it where the service left it: at
the camera home, or, in cloud.mode-switch, under the camera, where the
service's re-hunt had taken it. Each told the baseline the counters had
been re-zeroed at the starting position, so the hand-back saw nothing to
do. No counter reading can say where the head was found: every service
motion zeroes the counters at its start, and the home and every
controller start zero them again.

suite/homeoff.py now holds the helpers for a test that lets the service
move the head:

- session_travel sums a client's own record of each motion's end ("end
  positions (x, y, z)", the counters the motion zeroed at its start, in
  x8 steps, the one mode a service motion runs at). A motion with no end
  on record, or a log rotated under the run, leaves the travel unknown.
- camera_home_return drops the camera home, jogs the head back by the
  session's travel plus what the counters read since the home, starts
  the controller once more so the counters read zero where the head
  began, and tells the baseline. cloud_mode_return does the same for a
  stay in cloud mode, from the cloud client's log.
- A travel that cannot be known fails the run and moves nothing. A
  hand-back that fails while the test is already failing is logged, and
  the test's own failure is the one reported.
- judge_whole_motions fails a homing motion that stopped short: the
  position declared after it is false.

cloud.mode-switch imports the helpers inside its function, so no other
cloud test's fingerprint moves. Its cloud stretch no longer goes through
return_head, which read 0/0 after the controller's start and left the
head under the camera.

Proof: tests/test_camera_home_return.py, 15 cases on the machine's own
log lines (a session stopped short, a whole three-motion one, a cut log,
a refused motion, homed and unhomed counters, a restart since the home,
the first failure winning). tests/test_cloud_suite.py's mode-switch fakes
now zero the counters and remove the anchor at a controller start, write
the anchor at the home, and move the counters on a jog: 10 cases, a
failed hand-back that must not hide the test's failure among them.
forgetest 498 OK. On the bench reference, with the driver and the runner
fixes: homing.cloud-offsets, setup.check-envelope and cloud.mode-switch
PASS, each ending with the head where it was found; cloud.mode-switch
jogged its cloud stretch 246.06/139.01 mm back with the counters across
the jog agreeing, and every baseline was clean.

Acceptance: the three tests are the change; their fingerprints move and
no other test's does.
2026-09-25 14:22:50 -04:00
ScottW514 d40ebb090e cool_report_test: the homing runner is handed the report secret
The runner case asserted that the homing runner the controller starts
does not inherit GF_REPORT_SECRET. The runner is the reporter while a
gfcloud $H holds the machine (the controller sends no report then), so
the assertion described the defect: every camera home ran with nobody
reporting, and the cooling engine's hung-controller dead-man stopped the
session's first motion.

The case now requires the runner to be handed exactly the controller's
secret, and two new cases require that it be handed none when the
controller was started with no secret (runner-none) or with one that is
not 32 hex digits (runner-malformed). The harness's descriptions in
scripts/bench/README.md and in the bench page's catalog say so.

Proof: all 11 cases pass on the driver that hands the secret over
(grblHAL-glowforge, "gfhome: the homing runner is handed the report
secret"); the driver before it fails the runner case: "the homing
runner was handed [], not '0123456789abcdef0123456789abcdef'".

Acceptance: a host harness in the driver's CI; no catalog test runs it.
forgetest/forgetest/bench.py changes one description string, which no
test judges.
2026-09-25 14:22:50 -04:00
ScottW514 ef2390f7d4 forgetest: a takeover hands back the cloud client it found
In cloud mode forgectrl's start at the end of a takeover starts a cloud
client, and what that client is comes only from gfcloud's one-start
markers, which the client that starts reads and takes down. The takeover
started it bare: the online client, with the service's connect-time
hunt. Seen on the bench with exthost.armed-freeze, which runs its print
under the offline service and puts the setup record back under a
takeover: the machine came back online in cloud mode, the hunt homed the
head at 14:21:01, the run's result was written at 14:21:02, and the
service sent a head move at 14:21:08, after the run had ended, where the
next test's switch to GRBL mode would have cut it off. It also broke the
harness's own rule that every cloud client start it makes comes up
without the hunt.

The takeover now reads, on entry, whether the running cloud client is
the offline service: the client itself holds the listening socket at
/run/gfcloud-offline.sock (hw.listens_on reads /proc/net/unix and the
process's own descriptors, so a socket file another process left behind
does not count). forgectrl's start is made under the no-hunt marker in
cloud mode, and under the offline marker as well when the offline
service was found. After the start the client that came up must listen
on the offline socket within 60 s; a miss is recorded on the run's
baseline capture and the post pass turns it into a leftover ("cloud
client at the takeover end", not restored: a cloud test starts the
client it needs), which fails the run. A marker no client read (the
gate, standby, a fault) is taken down, so a later start never comes up
under it. The judge never breaks the exit path.

Proof: forgetest's unit tests pass on the host (481 OK). The new
tests/test_takeover_client.py drives a real enter and exit against the
fake forgectrl, a fake /proc, and an init script that plays gfcloud's
start: the offline service comes back offline, an online client after
it fails the run, a start that never happened takes the markers down,
an online client comes back without the hunt, a stale socket file is not
the offline service, GRBL mode starts under no marker. Negative
controls: without the markers four of them fail, without the judge
three. Bench drill on the bench reference with this package from /tmp:
GRBL mode, the offline service started as enter_offline starts it (pid
4834), a takeover; the client forgectrl brought back (pid 4898) read
both markers, logged "OFFLINE service", listened on the socket, and in
the 10 s after had no web session, no hunt, and no motion. The bench was
then handed back to GRBL mode as found.

Acceptance: runner.py, baseline.py and hw.py are in no test's
fingerprint, so no result moves; the catalog test that exercises the
change is exthost.armed-freeze, whose takeover now fails the run unless
the offline service comes back.
2026-09-25 10:45:24 -04:00
ScottW514 82869cc45c No acceptance test requests a counted release file
update.job-locks started a real download in every campaign: POST
/update/download fetched the published release's forgefirm.fw, whose
GitHub download count is how installs are tracked. ext.catalog asked
POST /ext/catalog/refresh, which fetches the extension index's fixed
address, index.ffi, counted the same way.

update.job-locks now starts the download job with probe=1, forgectrl's
probe download: the release's acceptance.json through the download's
own path, refused by the signature check and discarded. The record is
small and the job can end within a second, so the test writes the
settings at once after the 202 (the lease is taken before it) and runs
the probe again, up to three times, when the job ended first. A posted
job is refused in the job's name beside it, /status names the holder
when it is read while the job runs, the job must end refused by the
signature check, and the staged download must be as found (size and
mtime) with no probe file left. Its covers gain forgectrl's
src/relcheck.*.

ext.catalog no longer asks the refresh. The refusals before a fetch
stay, and the index kept must be unchanged after them. The refresh
(curl, https alone and bounded, 502 in curl's words, 409 in the host's,
the file removed) is proven on the host by forgectrl's extpkg_test with
a stand-in for curl, and the fetch of a listed package and the tiers an
install takes from it by forgeext's install_test, as the description
now says.

Nothing else in the tree requests a counted release file: the
installer's own request of forgefirm.fw is an install, and its unit
test puts a stand-in curl first in PATH.

Proof: forgetest's unit tests pass on the host (474 OK, 4 skipped) and
pyflakes finds no undefined name.

Acceptance: the change is the two catalog tests themselves; their
fingerprints move, and update.job-locks now covers src/relcheck.* too.
2026-09-24 14:20:06 -04:00
ScottW514 b7dd23d539 forgetest: a takeover judges where the head stands
forgectrl's start at the end of a takeover is a controller start, which
re-zeroes the step counters wherever the head then stands. After it a
head left out reads as home, so the baseline's position check after the
run could not see a head a test left out before its takeover.
setup.check-envelope did exactly that on 2026-09-23 and passed (fixed by
56af6ea).

The takeover now judges the head at the two moments it can, and moves
nothing: as the controller goes away, against the position the run's
baseline expects (when the counters are still in the frame the run began
in), and as the takeover ends, before forgectrl comes back, against where
it found the head (when the microstep mode has not changed under it). A
miss past the dead band is recorded on the run's baseline capture, and
the post pass turns it into an unrestorable leftover ("a controller
restart re-zeroed the counters there, so the head is not moved"), which
fails the run. A frame the test did not declare, cloud mode (its counters
are the cloud client's), a bench tool without a run, and a reading that
fails are not judged and never keep forgectrl down. ctx.counters_rezeroed()
now records the frame in force, so a takeover after a declared re-zero
can still judge.

The frame is read before the controller is stopped: forgectrl unlinks
/run/grblhal.homed when the controller exits (super.c), which the
bench's negative control found.

Proof: tests/test_takeover_position.py, 12 cases against a fake sysfs
tree and a fake anchor file (a head in place, a head left out at the
start, the frame read before the stop, the dead band, an undeclared
frame, cloud mode, a bench tool, a head left out by a drill at the end,
a changed microstep mode, the post pass failing the run and moving
nothing, a failed reading, a declared re-zero). forgetest's unit tests
pass on the host (474 OK, 4 skipped). On the bench reference, image
20260923232513 with the files bind-mounted: the old bedsize.py (56af6ea^)
fails as it must, "position at the takeover start=[2134, 2133, 0]
(expected [0, 0, 0])"; then 18 unattended tests that take over the
controller PASS in sequence (setup.check-envelope, kernel.k1-k2,
kernel.deadman-close, kernel.backtrack-bounds, kernel.fire-line,
kernel.resume-lead, setup.gate-blocks-controllers,
setup.advisories-rehash, setup.extensions-consent, setup.what-changed,
setup.mirror, setup.account-login, setup.first-run-flow,
exthost.service, exthost.events, exthost.hold-pause-tier,
exthost.motion-job, exthost.motion-jog). A second takeover in one run meets the frame the
first one's restart made, which no test declared, so its start is logged
and not judged.

Acceptance: forgetest is the dev-only harness, outside the catalog's
coverage; no test's fingerprint moves (runner.py and baseline.py are not
suite modules).
2026-09-24 14:20:06 -04:00
ScottW514 308a033146 laser.verdict-cut judges the hold by the gated output; laser.disarm-in-hold presses at the arm
Both tests failed for the operator on the bench reference on
2026-09-24, image 20260923232513, from the harness and not the machine.

laser.verdict-cut (00:10:52) needed the kernel's sampled LASER_ON count
to read 0 within 2.5 s of the hold. That count latches once a second
over the second before, so it reads 0 only once a whole window has
closed inside the hold, up to 2 s in, and this hold lasted 1.37 s. Every
sample of the hold read bit 0 of interlock_circuit as 1: the gated
LASER_ON, active low, dark.

The dark judge now reads the gated output itself, cnc/laser_on (1 = on),
about 3300 times a second on the bench reference, yielding the CPU
between reads (the stream threads run SCHED_FIFO). Its witness is the
same reads over the second before the pause, which must see the cut lit
(10 reads or more, none failed). The hold is dark when every read that
falls wholly inside it, from 0.3 s after the first Hold:0 to the last
Hold row, reads off, none failed, and there are at least 500. The 0.3 s
is the pause tier's first deceleration, lit on purpose, still playing
out of the driver's 200 ms queue and its 10 ms lead when the controller
reports Hold:0. The daemon's pause is 3.0 s, as the code already had
and the description now says. dark_span() is pure, kept inside the
test's function, and tests/test_laser_verdict.py runs it through the
function's code object over synthetic trails (8 cases: a dark hold, the
lit deceleration inside the drain, emission after it caught, a burst the
resume ends, failed reads counted, the drain counted from the first
Hold:0, no Hold:0, and a hold too short for the drain).

laser.disarm-in-hold (00:06:23, 180 s) pressed the button through
ctx.act at once, before the job reached its arm wait, so the press was
lost and the move never started. It now uses ctx.arm_press(), which
presses when the button lights, and waits for Run.

Proof: forgetest's unit tests pass on the host (474 OK, 4 skipped). Only the
fingerprints of laser.verdict-cut and laser.disarm-in-hold move.

Acceptance: the change is the two catalog tests themselves; both are
attended (laser emission, the operator present) and a campaign runs
both again.
2026-09-24 14:20:06 -04:00
ScottW514 dc9bede034 forgetest: the first /state parses each suite module once
The first GET /state after forgetest starts hashes every test's
implementation, and it took from 81 s to more than 12 minutes on the
bench reference. Two causes:

- sibling_imports() read and parsed a test's module and every sibling it
  imports, transitively, once per test: 280 ast.parse calls for 26 suite
  modules. module_parts() kept its own parse, sibling_imports() kept
  none.
- The page gives up on a poll after 20 s and polls again; the server
  thread goes on computing. Each new poll started the same cold work
  beside the first, all of it on the one CPU, so the longer the first
  took the more copies ran. That is the spread between 81 s and 12
  minutes.

Each module's text and tree are now read and parsed once and kept, as
are its direct sibling imports, and the implementation hash is filled
by one thread at a time: a poll that arrives while it is computed waits
for it instead of repeating it. catalog.forget(path) drops what is kept
about a file, for the unit tests that edit their modules.

The hashes do not move: every test's implementation hash and domain
fingerprint on the image manifest of 20260923220034 is byte-identical
before and after, so no result is invalidated. On the host the cold
computation went from 7.00 s to 0.29 s (280 parses to 25). On the bench
reference (the file bind-mounted on image 20260923220034, the page
open) the first /state answered in 8 to 12 s after a restart, where the
same restart earlier in the evening had not answered after 7 minutes.

tests/test_responsiveness.py pins both: each suite module parsed at most
once while every test's implementation hash is computed, and three
threads reading one test's hash compute it once. With the old behavior
put back by a patch both fail (280 parses for 26 modules; the hash
computed 3 times). forgetest's unit tests 454 OK. forgetest is the
dev-only harness, outside the catalog's coverage; its catalog
consequence is none, since no fingerprint moves.
2026-09-23 19:22:10 -04:00
ScottW514 56af6eaab2 setup.check-envelope: the head goes back to the home, or the run fails
The test's cleanup jogged the head back to the camera home through the
panel's relative jog, the increment taken from the port's position. The
core refused it every time (error:15, "the move would leave the work
envelope"), eight tries each run, and the test logged "the head is not
back at the home" and passed. The takeover that puts the setup record
back restarts the controller, which zeroes the kernel counters where the
head stands, so the baseline read (0, 0, 0) and called the machine
clean. On the bench reference the head was left 10 mm from the home in X
and in Y by every run of 2026-09-23 that got that far (three PASSes and
one FAIL, images 20260922225653, 20260923084705, 20260923220034).

Why the jog was refused: the port reports the step counters, while the
core adds a relative jog to its own parser position. The bed check's
last jogs, sized from the counters, leave the two apart by up to half a
step. The arithmetic reproduces every logged value: the parser held X
60.1 after the answers, the port read 60.099, the check jogged -50.099
toward X 10, so the parser took 10.001, which the step grid (213.33
steps/mm) turned into 2134 steps, read back as 10.003. The test's
return of -10.003 then targeted X -0.002, and a camera home's envelope
begins at exactly 0, so the core refused the whole jog. No product path returns the head by a relative
jog sized from the counters; the panel's Jog card sends fixed steps.

Now the head goes back in one jog from a Grbl client to the home in
machine coordinates ($J=G90 G53, 0.5 um inside the envelope's start,
far under half a step), and the run fails unless the port reads the head
on the home's step. That reading is taken before the restart, because
nothing after it can see a head left out.

Bench reference, image 20260923220034 with the suite file bind-mounted:
a negative control that aims the return 1 mm off the home FAILs with
"the head is not back at the home (0.0, 0.0, 3.08): [0.998, 0.998,
3.08]" while the baseline still says clean; the fix PASSes with
"$J=G90 G53 X0.0005 Y0.0005 F1200 -> ok" and the head at [0.0, 0.0,
3.08]. forgetest's unit tests 454 OK; the coverage lint passes with
--enforce on the image's manifest.
2026-09-23 19:22:10 -04:00
ScottW514 c95b5aa771 update.job-locks: ask the releases API when nothing was checked
The test took the release to download from GET /update/release, the
kept answer of the daily check. That answer does not outlive a restart
of forgectrl, and a campaign restarts it many times after
update.release-check has run, so on the bench reference (image
20260923084705) the test found "not checked yet" and failed with "no
release is published to download". When the kept answer was never
checked, the test now asks POST /update/check itself, the way
update.release-check does.

PASS on the bench in the unattended queue (the check asked, 200; the
download of v0.0.6 held the lease and ended; no archive left behind);
forgetest's unit tests 452 OK.
2026-09-23 17:50:48 -04:00
ScottW514 d45520c218 exthost.motion-jog: the test needs GRBL mode
The package's jog goes through the controller port, which only the GRBL
controller serves. The test declared no mode, so it ran in whatever mode
it found. Alone it always found GRBL mode; in the unattended queue it
runs after the cloud tests, which leave the machine in cloud mode, and on
the bench reference (image 20260923084705) the jog came back 409 "the
GRBL controller is not running". It now declares mode="grbl", so the
runner switches before it starts.

The other tests without a mode were checked for the same need: none of
them moves the machine or reaches the port (update.job-locks posts a
job, and the job route refuses on the lease before anything else, in
either mode).

exthost.motion-jog PASS on the bench in the queue after the cloud tests;
forgetest's unit tests 452 OK.
2026-09-23 17:32:33 -04:00
ScottW514 2870daa2aa logs.routing: forgeext is one of the loggers
The extension host has had its own logger (log_forgeext_disk and
log_forgeext_remote, a block in the rendered rules, and
/data/log/forgefirm/forgeext/) since it joined the image, and GET /logs
reports it. The test's list of loggers never gained it, so the first run
of logs.routing on an image that carries forgeext failed on the bench
reference (image 20260923084705): "loggers reported: [... 'forgeext'
...]". The list now names forgeext, so the test also holds its rules
block, its effective levels, and its directory, and a stray program's
line must not land in forgeext.log either.

logs.routing PASS on the bench with this file mounted; forgetest's unit
tests 452 OK.
2026-09-23 16:59:11 -04:00
ScottW514 82c354d70e setup suite: the port wait, and the first run's accounts on a failure
Two harness faults found on the bench reference in the campaign on image
20260923084705.

setup.gate-blocks-controllers probed the Grbl port once, at the moment
GET /mode first read the controller running. The supervisor reports
running from the fork and the controller binds TCP 23 a few tens of
milliseconds later, so the probe failed whenever the 0.25 s poll landed
inside that window ("the Grbl port is closed with the controller
running"). Timed on the board with the controller restarted through
forgectrl: /mode read running at 0.066 s, the port accepted at 0.087 s.
The test now waits up to 10 s for the port (50 ms polls) and records
how long it took (port_open_after_s). PASS on the bench (0.001 s).

first_run() replayed the system accounts (forgefirm-users reload) and
removed the temporary home only after its block succeeded. When the
block failed, the records came back but /etc/passwd kept the temporary
account in place of the operator's, and image.health failed after it
(the record's account missing from /etc/passwd). The replay and
the home removal now run in a finally. Proof on the bench: the failing
case run on purpose (setup.first-run-flow against the image's own
forgectrl, which refuses a home by hand at the cloud step) leaves the
operator's account the one account from uid 1000 up and no temporary
home; the same failure before the fix left the temporary account in
/etc/passwd and its home in /data/forgefirm/home.

forgetest's unit tests: 452 OK. setup.first-run-flow and
setup.gate-blocks-controllers PASS on the bench with this file mounted.
2026-09-23 16:29:55 -04:00
ScottW514 853f862f03 Update release 2026-09-23 07:33:33 -04:00
ScottW514 ac2456261e The measured envelope's harness, and setup.check-envelope
scripts/bench/envelope_test.py drives the null-sink controller with the
scripted sender and port client of ctlport_test.py, homing manually:
unset keys are the axis travel; envelope_x_mm and envelope_y_mm are the
far edges at a home and are held to 50 mm up to the travel plus 30 mm;
the port's envelope open is refused before a home and under an open
armed window, and lets the port's jogs reach the travel plus 30 mm;
envelope apply puts the keys' edges back without a home; a status poll
and an empty line leave an open envelope open, while the sender's first
line closes it before the core reads it (a jog into the margin draws
error:15), a running port jog canceled first; a soft reset and the port
client going away each close it. It is registered in the bench
registry as a CI harness, not a bench-page tool.

setup.check-envelope (suite/bedsize.py, its own module): with cloud mode
on, the machine is homed with the camera, and the Bed size check runs
with its jog prompts answered as the page would, out 60.1 mm in X and
61.1 mm in Y from the home. The ends it reports are the home plus
those, the far edges 1 mm short of them are written as envelope_x_mm
and envelope_y_mm, and the controller holds them at once: a panel jog
0.5 mm short of each edge runs, and one 0.5 mm past it is refused with
nothing moved. A second run is opened, and while its first prompt waits
a Grbl client sends one line, which draws the controller's word that
the envelope closed and then ok; the port then says the envelope is
closed, the next answer ends the check in words, and the keys are as
the first run left them. The head is jogged back to the home, and the
keys, the homing mode, and the setup record are put back as found, the
record under a restart.

Proof: envelope_test.py ALL PASS on the host build of the controller of
this change. On the bench reference, with that controller and forgectrl
of the same change bind-mounted: the first two campaigns found flaws in
the test, not the check (jogs of 20 mm made an edge below the 50 mm
forgectrl refuses; the second run started while the machine was still
busy; the head was not returned to the home), then setup.check-envelope
PASS, with forgectrl.auth, kernel.latch-locked-idle, motion.pacing,
motion.jog-roundtrip, and motion.port-jog PASS in the same campaign, and
cloud.mode-switch, motion.soft-limits, and homing.cloud-offsets PASS
after it. forgetest's unit tests pass (452), and the coverage lint
passes with --enforce.
2026-09-23 04:39:06 -04:00
ScottW514 8fc07c47ee forgetest: update.job-locks and homing.cloud-offsets
update.job-locks (suite/updlock.py, its own module): with a release
published on the releases API, POST /update/download starts the
download job (202), which fetches and checks the release's archive and
applies nothing. While it runs, /status names update:download, of kind
system, as the lease's holder, and a settings write and a posted job are
each refused in its name (409). When it ends, the lease is free and the
same settings write is taken. The downloaded archive is removed when it
was not there before.

homing.cloud-offsets (suite/homeoff.py, its own module): with cloud mode
on, homing_mode = gfcloud, and the camera-home offsets at (4.5, -3.25),
$H runs the web-service homing session, and when it ends the controller
declares (4.5, -3.25) on the step grid. The work envelope reaches back
to the home on the negative axis: a jog 2 mm out and 2 mm back in Y and
then in X is accepted and ends at the home. The homing mode and the
offsets are put back as found, each retried until the daemon takes it,
since a settings write is refused for a moment after the suite's Grbl
client closes.

Proof: on the bench reference, update.job-locks PASS after
update.release-check, and homing.cloud-offsets PASS in a campaign with
forgectrl.auth, kernel.latch-locked-idle, and motion.pacing, which also
PASS. forgetest's unit tests pass (452), and the coverage lint passes
with --enforce.
2026-09-23 03:44:22 -04:00
ScottW514 69b56ec1ff forgetest: exthost.wizard, a package's own check on the Setup page
exthost.wizard (suite/extwizard.py, its own module): a package with the
reference id that asks for wizard, whose service runs a check on its
call socket and keeps whether it passed. GET /wiz lists it among the
extensions as pkg:<id> with the package's title, not done. POST
/wiz/pkg:<id>/start runs it on the wizard runner: GET /wiz/dark shows
the package's log line, phase, progress, and confirm prompt, and the
machine lease stays free while it waits. The operator's Yes reaches the
service as yes, the run ends complete with the package's summary, and
GET /wiz then lists the check as done from the package's own answer. The
machine's setup record is the same before and after, with no pkg: wizard
in it and the gate as it was. A second run is aborted from its prompt,
and the service is told. A check of a package that is not installed is
404.

Proof: on the bench reference, with forgectrl and forgeext of the wizard
change bind-mounted, exthost.wizard PASS, beside setup.check-sensors,
exthost.service, and exthost.package-routes PASS on the same daemons.
forgetest's unit tests pass (452), and the coverage lint passes with
--enforce.
2026-09-23 03:44:22 -04:00
ScottW514 99d9830991 The M-code barrier's harness, and exthost.mcode
scripts/bench/mcode_test.py drives the null-sink controller with the
scripted sender and port client of ctlport_test.py: the port's mcodes
table and its refusals, error:20 for a number nothing answers, an
answered M-code's wait (the head still, the port's state naming it with
its words, a port jog busy:mcode, stale and out-of-form answers refused,
the answer's words on the console), a refusal and a timeout each holding
the job, a soft reset ending the wait, and a wait under an open armed
window with M3 S500 shipping no FIRE tick. It is registered in the bench
registry as a CI harness, not a bench-page tool.

exthost.mcode (suite/extmcode.py, its own module): a package with the
reference id that asks for mcode:160 and job_time.run, the one granted,
and whose service answers POST /mcode on its call socket. The host's
status names it as the one that answers M160. A dark job (out 5 mm, M160
P1, back) through POST /job waits at the M-code with the port's state
naming it and the head still over 1 s (the kernel's counters and the
port's position), the service answering after 2 s; the job then ends
done, every line acknowledged, the head back, the service asked once with
the code and its words, no discharge. A job naming M161 fails at that
line with error:20 and nothing moved. A job at M160 P2, which the service
refuses, is held, and is aborted from there. A job left running by a
failed check is aborted before the put-back.

Proof: on the bench reference, with the controller, forgectrl, and
forgeext of this change bind-mounted, exthost.mcode first FAILED and
found the controller announcing the M-code before the kernel had played
the last move (the driver now waits for the kernel to go idle), then
PASS; exthost.service, motion.job, exthost.page-call, exthost.motion-jog,
motion.port-jog, and exthost.package-routes PASS on the same daemons.
mcode_test.py ALL PASS on the host build. forgetest's unit tests pass,
and the coverage lint passes with --enforce.
2026-09-23 02:50:34 -04:00
ScottW514 4e47b7fbf1 forgetest: exthost.catalog, and the coverage lint no longer allows an advisory away
exthost.catalog (suite/extcat.py, its own module): GET /ext/catalog
answers the index the host keeps and the one address it is fetched from.
On a scratch root under /tmp, with a throwaway key standing in for the
OpenGlow extension key, the machine's own forgeext keeps an index signed
with it, and the author key it names for one id makes a package of that
id read as community and endorsed, where before it was unverified; the
same key on another id counts for nothing. On the machine's own root that
index is refused in words, a package handed over as an index is refused
by the product gate, and the index kept is left as it was. The relay
refuses an id with no such form (400) and one the kept index does not
list (404, or 409 with none kept) before anything is fetched, and a
refresh from the fixed address keeps OpenGlow's index when one is
published there and is 502 in curl's words when none is, the kept index
left as it was. Nothing is left in the staging directory.

The coverage lint had a gap: coverage_report() let the allowlist's
docs/** and **/*.md take out a path the BEHAVIORAL list keeps in every
fingerprint, so the four first-run advisory documents were covered by no
test and the lint passed. A change to the privacy advisory would have
invalidated nothing. A behavioral path is now never allowed away, and
setup.advisories-rehash, which accepts every first-run document at its
current hash, covers the four.

Proof: on the bench reference, with forgectrl 848ccc1 and forgeext
a64b933 bind-mounted and the privacy document accepted again at its new
hash with the fixture's press, exthost.catalog PASS (the refresh was 502:
GitHub answered 404, nothing is published at the address yet), and
setup.advisories-rehash PASS with the rest of the campaign. The lint's
new unit test reports the uncovered advisory, and the old reading (the
override ignored) reports nothing for it, which is the gap. forgetest's
unit tests pass (452), and the coverage lint passes with --enforce.
2026-09-23 01:56:57 -04:00
ScottW514 675fc11cb1 forgetest: events.button-telemetry and exthost.lifecycle
Two new tests, each in its own module so that no other test's
fingerprint moves.

events.button-telemetry (suite/evmore.py): a stream opened at
forgectrl's read-only listener, from a loopback source of its own, gets
a telemetry.tick within 14 s with exactly its fields (phase, verdict,
fire_ok, down_c, up_c, state, lid). With the machine idle and nothing
waiting for the button, one press (the fixture's, or the operator's)
arrives as pressed true and then false, in that order. That a press the
machine is waiting for is no event, and the update and setup edges, are
events_test's.

exthost.lifecycle (suite/extlife.py): a package that follows its feed is
running when extensions are turned off. It reads ext.shutdown with the
host's reason and writes it to its data directory before its process
ends, the host stops it a second later, and nothing is frozen in that
second. Its covers name forgeext's run, machine (the armed question
asked during the grace), evfeed, and api.

Proof: on the bench reference, with forgectrl a9c45fc and forgeext
dbb00e5 bind-mounted, both PASS. exthost.lifecycle FAILed on the host
before its grace fix (the service frozen for want of an armed reading)
and PASSed after it. forgetest's unit tests pass (451), and the coverage
lint passes with --enforce.
2026-09-23 00:39:13 -04:00
ScottW514 71b44598eb test_cloud_suite: the quiet wait's deadline is no longer the test
The cloud suite's host tests replay a print's log from threads, and the
suite's quiet wait gave up after 3 s (QUIET_TIMEOUT_S in setUp). On a
loaded host the last replayed lines landed after that, and a test failed
with "still running service moves after 3 s" instead of its own finding:
about one run of the module in five, a different test each time, alone
as well as under the full suite. The deadline is now 15 s. It is a
deadline and not a wait: a quiet machine is seen at once, so a passing
run is no slower. The one test that waits the deadline out on purpose (a
motion that never goes idle) sets 3 s for itself; tearDown puts the
module's value back.

Proof: before, test_cloud_suite failed 2 of 10 runs by itself, every
failure the quiet deadline (test_lid_during_button_wait_on_the_bench_excerpt,
test_pause_resume_fails_without_the_retraced_restart,
test_mode_switch_fails_when_gfhome_never_saw_the_head_move,
test_pause_resume_fails_when_the_kernel_refuses_the_resume). After, 10 of
10. forgetest's whole unit suite: 451 tests OK, 4 skipped, in 492 s
against 491 s before. A host-test change: no catalog consequence.
2026-09-22 23:54:12 -04:00
ScottW514 fab8920cd9 exthost.operator-destinations: the operator names where a package may connect
A new catalog test in a module of its own (suite/extdest.py). It installs
the reference package's id and key asking for net.outbound.operator, with
a service that reports at every start what GET /v0/self says it may
reach and what one dial to the machine's DNS resolver on TCP port 53 does,
and one to the port above it. The resolver is an address every network
gives the machine, that is not the machine, and that answers; named by
address, it gives the service no resolver of its own. It holds: before
anything is named, no destination and the dial refused inside the
sandbox (EACCES), and no rule for it in the package's chain; the machine's
LAN address and loopback refused in the host's words, a destination out
of form 400, an unnamed removal refused; named through POST /ext/dest,
the service started again with the destination, connected, still refused
one port up, and the chain carrying the rule, with no crash; taken away,
started again and refused again, the rule gone. A resolver that takes no
TCP connection even from root fails the test in words rather than passing
it on nothing. covers: forgeext src/install.*, state.*, run.*, super.*,
api.*, caps.*, manifest.*, netrules.*, main.c; forgectrl src/extpkg.*,
main.c.

Proof: on the bench reference (image 20260922225653, with forgeext
28f829e and forgectrl e2d0d05 cross-built and bind-mounted, and this
suite bind-mounted over the installed one), exthost.operator-destinations
PASS in 22 s, with exthost.service, exthost.page-call,
exthost.ui-delivery, exthost.package-routes, exthost.events, and
exthost.panel-install PASS in the same campaign. The first run of it
failed in its own reading of /etc/resolv.conf (bytes against text); the
fix is in the test's module alone, and the PASS is on the fixed module.
2026-09-22 23:15:37 -04:00
ScottW514 861f5597e6 exthost.page-call: a package's page asks its own service
A new catalog test, in a module of its own (suite/extcall.py) so that no
other test's fingerprint moves. It installs the reference package's id
and key with a page and a service of its own, whose service reports what
it was handed and answers its page's calls on the listening end the
host gave it. It holds: FFX_CALL_FD=4 with descriptor 4 a listening
socket and 3 and 5 closed; the socket root's 0600 in root's 0700
directory; POST /ext/call relaying a POST and a GET, the service's own
refusal passed through as its status and words, and a service asking the
API while it answers; five calls out of form refused, 400 by the relay
or 409 in the host's words; forgeext call answering alike; and a disabled
package refused in words, its service stopped and its socket's name
gone, then answering again once enabled. The package, the key, the
setting, and the setup record are put back as found, and no socket name
is left. covers: forgeext src/call.*, src/run.*, src/sandbox.*, src/main.c;
forgectrl src/extpkg.*, src/main.c.

Proof: on the bench reference (image 20260922225653, with forgeext
5c13f1c and forgectrl 2cf6b12 cross-built and bind-mounted, and this
suite bind-mounted over the installed one), exthost.page-call PASS in
16 s, and exthost.service, exthost.ui-delivery, and
exthost.package-routes PASS in the same campaign. forgetest's unit tests:
451, one failure in test_cloud_suite, whose replayed prints flake on
their timing alone as well (1 of 3 runs of that module by itself, in a
different test each time) and touch nothing of this change.
2026-09-22 22:43:51 -04:00
ScottW514 b7e7c0fb2c Manifest: the advisories are behavior, and forgeext's kit and packages are not
forgectrl embeds its advisory documents in the binary, serves them, and
records the operator's consent to one by its hash, so an edited advisory
is a changed consent. They are Markdown under docs/, which the
non-behavioral list takes out of every fingerprint, and that left
setup.extensions-consent's covers entry for docs/advisories/extensions.md
selecting nothing: the enforced coverage lint fails on it (exit 1 on the
dev image's manifest of 20260922225653), and an edited advisory moved no
fingerprint at all. A BEHAVIORAL list now keeps forgectrl's
docs/advisories/** in, ahead of the non-behavioral one.

forgeext's recipe installs the binary and its init script and nothing
else, so packages/ (the official packages, which carry their own
acceptance artifact), sdk/ (the author's kit), template/ and tools/ are
non-behavioral for the image: without that, an edit to the alignment
page or the kit would make every exthost test stale on the next image.

Proof: test_manifest passes its 25 cases, the new ones among them; the
enforced coverage lint on the dev image's manifest of 20260922225653
exits 0 with no empty entry and nothing uncovered, where it exited 1
before. The whole forgetest suite ran its 451 tests; one,
test_cloud_suite's test_pause_resume_passes_on_the_machines_lines,
errored under the suite's load and passes 16 runs of 16 alone, at HEAD
and on this tree alike: it replays a print against timed hooks.
2026-09-22 21:41:30 -04:00
ScottW514 2f51e38178 exthost.service: a package's frame with the lamp named
The reference service takes two more lid-camera frames through its API
socket, with the camera's lamp at 0 and at 1023, and one with a lamp
past its range. The test holds both named frames to being JPEGs, the lit
one the larger (the lamp reached the camera and changed the picture),
and the out-of-range one to a 400 that names the lamp. The service's
text is shared by every exthost test, and every one of them already
covers forgeext's api.c and run.c, which this change moves, so no test
is made stale that was not stale already.

Proof: on the bench reference, image 20260922225653 with forgeext
cd8b471b and forgectrl 9c9ee557 bind-mounted, exthost.platform and
exthost.service PASS: 51,561 bytes at lamp 0, 109,850 at lamp 1023,
and lamp 5000 refused.
2026-09-22 20:21:31 -04:00
ScottW514 d9cc61f198 forgectrl.panel-serves: the page lets no frame navigate
The panel page now carries Content-Security-Policy: frame-src 'none'
(forgectrl 95970cf), the one thing that keeps a package's page from
navigating its own frame to another address with what it was shown in
the URL. What the header does in each browser is the frame-isolation
harness's to prove (forgectrl tools/frame_isolation.py); this test holds
the machine's own served page to carrying it, read with the headers from
the loopback listener. The edit is inside the test's own function, so no
other test's fingerprint moves.

Proof: on the bench reference, image 20260922225653, PASS with forgectrl
95970cf bind-mounted ("GET / Content-Security-Policy: frame-src 'none'"),
and FAIL against the image's own daemon: "the panel page lets a frame
navigate: 200 None".
2026-09-22 20:00:38 -04:00
ScottW514 1bdc11f6fe exthost.ui-delivery: the refusals in the host's words, and the settings
The test held a missing package's page to any status of 400 or more, and
the relay's 502 passed it: the host's refusal lost on the way to the
panel looked the same as a refusal. It is 404 in the host's words now.
The same relay carries a package's settings, which no test asked for
through forgectrl: the test reads them, sends a value over its bound,
and holds the answer to 400 in the host's words with the stored value
unmoved.

The edits are inside the test's own function, so no other test's
fingerprint moves.

Proof: on the bench reference, image 20260922152358, the image's own
forgectrl fails the test (a disabled package's page 502, "the extension
host did not answer"); with forgectrl 8055000 and this file bind-mounted
it passes: 404 "that package is not installed", 404 "this package is
disabled: its interface is not served", and 400 '"threshold" is at most
100' with the threshold still 40.
2026-09-22 18:50:21 -04:00
ScottW514 625d0acec7 exthost.core-range: the evidence is a dict
The test wrote its evidence through ev.note(), which the run's evidence
does not have: it is a plain dict, as every other test uses it. On the
bench the test stopped at its first record with an AttributeError, an
ERROR before anything was asked of the host. The records are keys now,
and the three ranges are one list. The description said a package asking
for a range was installed; the test only inspects, and it says accepted.

No host test runs a suite function, which is how this reached the image.

Proof: on the bench reference, image 20260922152358 with this file
bind-mounted over the installed one, exthost.core-range PASS: the image
stamps "20260922152358", which is no version; inspect answers
core_checked false with no range, with a minimum of 99.0.0, a maximum of
0.0.1, and both ends, and installs nothing.
2026-09-22 18:50:21 -04:00
ScottW514 d410c6f696 manifest-from-tree: forgeext is a component
forgeext ships from its own repository with its own recipe and pin, and
62 covers entries across the catalog name it - but the tree manifest the
coverage lint runs on did not know the component existed, so every one of
those entries selected nothing. The lint exits 1 on an empty entry, so
the merge would have failed on it; worse, until then forgeext's sources
were not coverage-checked at all.

A pin nothing has been pushed to yet (SRCREV all zeros, which is how a
work branch carries this component until the merge) names the component
with no files instead of failing the whole manifest. The lint then
reports its entries as empty and exits 1, which is what an unbumped pin
should do, and the script stays usable in the meantime.

Proof: the script names forgeext and says its pin is not set; with the
component's file list stood in, the 31 empty (forgeext, ...) entries
become zero, and the 32 that remain are forgectrl and grblHAL paths that
do not exist at their pinned commits yet - the pre-push state the work
branches are in.
2026-09-22 10:15:07 -04:00
ScottW514 a552541acd logs.tree-tail-export: the bundle's extension listing
The bundle carries system/extensions.json now. The case reads it out of
the tar, holds it to being the status document, and holds every listed
package to naming its id, version, tier, whether it is enabled, and what
the operator granted it - which is what a report from a machine running
extensions has to be read against. covers gains forgectrl's src/extpkg.*,
where the document is built.
2026-09-22 10:12:25 -04:00
ScottW514 5915408147 exthost: the effective capability list, and a disabled package's page
exthost.package-routes reads the new `effective` field beside the
manifest's own list: what the package may use is never wider than what it
asked for, holds everything the operator granted, and the panel's list
and the host's own command line say the same thing. That is the list the
panel's bridge decides on, so it is the one the case has to hold.

exthost.ui-delivery gains the disabled package: its page is refused in
words, and served again once the operator enables it. Disabling a package
is the way out of everything it does, and its interface was the one door
that stayed open.

Both are changes inside the tests' own functions, so no other case's
fingerprint moves.
2026-09-22 09:57:20 -04:00
ScottW514 59887ac742 exthost.armed-freeze: the camera is shut for the window too
The case proved the freeze and stopped there. A package with the
operator's job_time.run grant is not frozen, and a capture costs
kernel-side work beside the step stream, so the window has a second half:
no program takes a picture during a cut.

From inside the same real armed window the case already opens, the
sampler now takes one background capture and keeps what the machine said.
It must be refused (409, in words that name the window), and a second one
after the window closes must come back a JPEG. The refusal is decided
before any frame is taken, so the probe costs the print nothing - and if
it were served instead, the capture that happened is the one the rule
forbids, which is what the case is here to catch.

covers gains forgectrl's src/cam.* and src/main.c, where the refusal is.
2026-09-22 09:32:13 -04:00
ScottW514 4388d69816 exthost.core-range: the host reads the image's own version file
A package's core range is judged against the version the host reads
from /etc/forgefirm-version, and forgeext read no version at all out of
the form a release image writes it in, so a release image judged no
range. That fault is fixed and proven in forgeext 4fefdb6.

What a campaign can add to that is narrower than it first looks. The
release image does not ship this suite, so a campaign only ever runs on
a dev image, and a dev image stamps ${DATETIME} (dev), which is no
version. The branch where a range is actually judged is therefore
unreachable from here on every machine this suite can run on, and it is
not this test's to claim: version_test in forgeext holds that branch,
over the file as each image build writes it.

So this test proves the half a machine can, which is the half that ties
the daemon to the image it is on: the host reads the image's own file,
finds a build stamp, and judges nothing rather than guessing a version
out of it. A package asking for a minimum far above or a maximum far
below installs all the same, and inspect answers core_checked false. A
dev image that began stamping something version-shaped would fail here,
and that is the regression this test is for; it says so in its own
words rather than leaving a reader to wonder why it only ever takes one
branch.

It is a module of its own, and that is deliberate. A test's fingerprint
holds the shared text of the module it lives in, and the shared text is
every line outside a @test function, the blank lines between them
included - so a test cannot be added to exthost.py without moving the
fingerprint of every test there. Adding it there moved all ten,
exthost.platform (which covers linux-fslc alone) and the operator drill
exthost.panel-install among them. In suite/extcore.py it moves none:
measured across the change, 113 tests before and 114 after, no
fingerprint moved. The helpers come from exthost by import, which puts
that module's shared text in this test's fingerprint and not the other
way round.

Proven on the host: the catalog loads and registers 114 tests, the
coverage lint passes with 0 uncovered paths, pyflakes reports nothing,
and the unit tests run 451 and pass, as they do on the revision before
this one.
2026-09-22 08:28:48 -04:00
ScottW514 77e4c03369 exthost.motion-job: a package runs a program, dark and witnessed
The reference package is installed with motion.job granted, writes a
program into its own data directory, and asks the host to run it.

What it proves on the machine: a program named with a path and one that
is not there are refused by the host, with the machine never asked; the
real one is taken by the machine as the package, every line sent and
acknowledged; the head moves and the head accelerometer sees it; and the
laser latch stays locked with every emission witness at zero.

The program commands no laser, so the runner ends the job as one that
ended without a discharge. That is what a dark program is, and the test
asserts it rather than glossing it: the whole program ran and nothing
fired. A job that does fire is cloud.dark-print's and
laser.emission-witness's.

Three things the first runs taught. A button press needs an "until" - what
the press is expected to do - and a dark program never opens an armed
window, so there is nothing for a press to arm and the test does not
press. The job's lease owner is prefixed "job:". And the job record's
emission.samples counts samples taken, not samples that saw light; the
witnesses are laser_on_samples, lit_s and thermopile_delta.

Proven. The unit suite: 451 tests; install_test: 225 checks. On the
bench reference, image 20260922014545 with the cross-built daemons
mounted over the image's: exthost.motion-job PASS, with exthost.service,
exthost.motion-jog and motion.job PASS beside it. Against the image's
own daemons the route is 404.
2026-09-21 23:02:28 -04:00
ScottW514 0ba0fb112b exthost.ui-delivery: a package's page, served and refused
A package that asks for ui must ship ui/index.html and one that ships it
must ask: the test packs an archive each way and checks the install
refuses both in words. With a proper one it checks GET /ext/ui hands the
page back byte for byte, and that a package which is not installed is
refused.

Each control archive is packed in a directory of its own, because the
packer makes a signing key there and fwup will not remake one over an
existing file - the first version shared a directory and errored.

Proven. The unit suite: 451 tests. On the bench reference, image
20260921225820 with the cross-built daemons mounted over the image's:
exthost.ui-delivery PASS, with exthost.service and exthost.package-routes
PASS beside it. Against the image's own daemons it FAILS: the older host
installs a package that asks for ui and ships no file.
2026-09-21 21:42:13 -04:00
ScottW514 f74382984c exthost.motion-jog: a package moves the machine, watched
A new catalog test for the one capability that moves the head. The
reference package is installed with motion.jog and jogs on the test's
word, through a file the service watches, so the motion happens at a
moment the test chose and can be witnessed while it does.

What it proves on the machine: the host's credential is 0600 and root's
and holds motion.jog and not a camera; a jog past the bounds is refused
by the host with the machine never asked; the package's jog moves the
head and the head accelerometer sees it; and across the whole of it the
laser latch stays locked and the emission witness stays at zero. The
head is jogged back to where it started.

The laser check reads the field names the machine actually publishes
(laser_locked, laser.emission_samples). The first version of it asked
for names that are not in /status, so it compared None to None and could
not have failed.

Proven. The unit suite: 451 tests. On the bench reference, image
20260921220023 with the cross-built daemons mounted over the image's:
exthost.motion-jog PASS, and exthost.service and motion.port-jog PASS
beside it. Against the image's own daemons it FAILS: the route is 404.
2026-09-21 18:57:33 -04:00
ScottW514 75ef8bbc95 exthost.service: a package's camera, and how it yields
The reference package asks for the lid camera and not the head's. From
inside its sandbox the test checks that the granted camera comes back as
a JPEG with the image type to match, that the camera it does not hold is
403, and that a camera there is none of is 400.

It then holds a stream open and asks the service for a capture through a
file the service watches, so the capture happens at a moment of the
test's choosing: it is refused in the machine's own words while somebody
is watching, and served once the viewer stops.

Proven. The unit suite: 451 tests. On the bench reference, image
20260921204711 with the cross-built forgectrl and extension host mounted
over the image's: exthost.service PASS, and exthost.events,
exthost.hold-pause-tier and camera.snapshot PASS beside it.

Acceptance. exthost.service already covers forgeext's **; a package's
camera is part of what a confined service can reach.
2026-09-21 17:59:36 -04:00
ScottW514 2216e5d3f2 camera.snapshot: a background capture yields to a viewer
The test opens a stream, holds it, and checks that a capture marked
background=1 is refused in words while somebody is watching, that the
operator's own unmarked capture is still served, and that the background
capture is served again once the viewer stops. A background value that
is neither 0 nor 1 is refused.

Proven. On the bench reference, image 20260921204711 with the
cross-built forgectrl mounted over the image's: camera.snapshot PASS.
Against the image's own daemon it FAILS at the first of the new checks,
answering a parameter it does not know with a JPEG.

Acceptance. camera.snapshot already covers the camera sources and
src/main.c.
2026-09-21 17:20:37 -04:00
ScottW514 bb28c7855a exthost.service: the storage quota, on the machine
The reference package declares storage:1. The test writes 2 MiB into its
data directory and watches the host measure it, stop it, and show the
quota's reason, then clears the data, enables it again, and sees the
service come back. That is the operator's way out, so the test walks it.

Proven. The unit suite: 451 tests. On the bench reference, image
20260921190848 with the cross-built extension host mounted over the
image's: exthost.service PASS, and exthost.events and
exthost.hold-pause-tier PASS beside it. The first run of this check
caught a real bug: the reason arrived empty, because the host built it
in the buffer the supervisor overwrites when it stops a service.

Acceptance. exthost.service already covers forgeext's **; the quota is
part of what the host does to a confined service, so it is exercised
there.
2026-09-21 16:46:24 -04:00
ScottW514 447f90205d exthost.service: the reference package has settings of its own
The package now declares three settings and, from inside its sandbox,
reads them, sets two, and is refused an undeclared key and a value out of
bounds. The store on disk is checked for its mode and its contents, so
the test proves the host keeps them and not only that it answered.

Proven. The unit suite: 451 tests. On the bench reference, image
20260921190848 with the cross-built forgectrl and extension host mounted
over the image's: exthost.service PASS. Against the image's own daemons
it FAILS where it must, the older parser refusing the manifest with
unknown key "settings".

Acceptance. exthost.service already covers forgeext's ** and forgectrl's
src/main.c; the settings are part of what a confined service can reach,
so they are exercised there rather than in a test of their own.
2026-09-21 16:12:19 -04:00
ScottW514 952e7dc32d exthost.events: the machine's events reach a package
A new catalog test for the one subscription the extension host holds and
the poll a package reads it with. The reference service learns from
GET /v0/self whether it was granted events and polls POST /v0/events only
then, so every other test keeps the half-second heartbeat those tests
watch; what it polled and what it saw go in its data directory.

What it proves on the machine: the host holds the stream while one package
wants it and lets it go when none does; a poll that names its place and
has nothing to be told comes back at its own deadline and not before,
while the first poll, which only asks where the present is, comes back at
once; the lid opened and closed on the fixture reaches the package as lid
events, in order and numbered, which also proves a waiting poll is woken
rather than left to time out; and with the three ordinary streams taken
the host's subscription is still there while a LAN client that asks for
the host's slot by name is refused, because only a loopback peer may claim
it.

The three ordinary streams each bind a source address of their own. The
cap counts peer addresses, and the kernel sends every loopback connection
from 127.0.0.1 whatever address it was aimed at, so three streams without
that would replace each other and leave two slots free. The first run of
this test said a LAN client had been admitted; it had been, correctly, to
a slot the test had failed to fill.

Proven. The unit suite: 451 tests, and the coverage lint passes. On the
bench reference, image 20260921171224 with the cross-built forgectrl and
extension host mounted over the image's: exthost.events PASS, and beside
it exthost.service, exthost.armed-freeze, exthost.hold-pause-tier and
events.stream PASS. Against the image's own daemons exthost.events FAILS
at its first new check, as it should. The bench was left as found, GRBL
mode, no mounts.

Acceptance. exthost.events covers forgeext's src/evfeed.*, src/api.* and
src/run.*, and forgectrl's src/events.* and src/main.c.
2026-09-21 15:07:56 -04:00
ScottW514 eb0a1e7838 exthost.panel-install: the key goes in through the panel, and the box holds the button
The test added the owner's key by copying the file. It now adds it the way
an operator does: POST /ext/key with the machine's button held. Without the
button it is 409 and no file lands; a name with a space, a name that is a
path, and no key at all are 400; a key that is no key is 409 from the host;
with the button held it is added and listed with its id. Removed again, the
same archive reads unverified, and removing a key that is not there is 409.

The bench actuator's press was 200 ms, the firmware's default, and a
request that must reach the machine while the button is down often missed
it: the unverified install took three presses on one run and all ten on
another. ctx.act() now takes `ms`, the fixture client passes it to the box
(the firmware clamps it into 20 to 500), and the two button-held steps ask
for 500. Both landed on the first or second press afterward.

Proven. The unit suite: 451 tests, 0 undefined names (both stub fixtures
take the new argument, and the fixture test pins that the box is asked for
the longest press). On the bench reference, image 20260921161446 with the
cross-built forgectrl and extension host mounted over the image's:
exthost.panel-install PASS, exthost.package-routes PASS,
exthost.hold-pause-tier PASS. Against the image's own daemons the install
test FAILS, as it should.

Acceptance. exthost.panel-install covers forgectrl's src/extpkg.*,
src/main.c, src/auth.* and forgeext's src/main.c, src/install.*,
src/pkg.*; the runner and the fixture client are harness, outside the
suite and outside every fingerprint.
2026-09-21 13:11:29 -04:00
ScottW514 963bded9b3 exthost.package-routes: the panel page carries the card, and its script is in order
The page the image serves must hold the card (extpkgs, extswitch, extfile,
extstaged, extinstallphrase, extAct, loadExt) and must define loadExt
before the call that runs while panel.js parses. That second check is the
bug the browser found: ext.js loaded after panel.js is an undefined
loadExt, and it takes the rest of the System tab's loads down with it.

Proven. The unit suite: 451 tests, 0 undefined names. On the bench
reference, image 20260921154037 with the cross-built forgectrl mounted over
the image's: PASS. Against a forgectrl built with the scripts in the old
order the order check FAILS, as it should.

Acceptance. exthost.package-routes gains forgectrl's src/ui/ext.js,
src/ui/index.html, and src/ui/embed.cmake in its covers map.
2026-09-21 12:13:39 -04:00
ScottW514 9c36df9ee9 exthost.panel-install: a package installed through the panel at each tier's consent
Kind operator with the button as its action, GRBL mode, nothing moves and
nothing fires; extensions stay as found and the packages never run. The
reference package signed with a key nobody trusts uploads as unverified
with consent button: refused without the button held (the phrase is no
substitute, the staged file stays), installed while it is held, listed
unverified with the hold the request granted. A person holds the button; the
bench actuator's press is a half-second pulse, so the test presses again
until a request has landed inside one. With the same key added as the
owner's the upload reads community with consent typed, and goes through
no phrase, the phrase in another case, no grant (the host's words), a grant
with a shell's words, a grant that is an option of the host's, and then
installs. An upload without the login, bytes that are no archive, an install
with nothing staged, and a discarded upload are refused as they should be.
Both packages are removed through the route and the extension root is as
found, the staged file included.

Proven. The unit suite: 451 tests, 0 undefined names. On the bench
reference, image 20260921150233 with the cross-built forgectrl mounted
over the image's: PASS (the third press landed); against the image's own
forgectrl FAIL at its first request.

Acceptance. The test is new. It covers forgectrl's src/extpkg.*,
src/main.c, src/auth.* and forgeext's src/main.c, src/install.*, src/pkg.*,
and requires exthost.package-routes.
2026-09-21 11:39:25 -04:00
ScottW514 121d7d3f81 exthost.package-routes: the panel's package routes against the host's own state
The reference package is installed with the hold grant through the host's
command line and never runs (extensions stay as found, so the test needs
no takeover). GET /ext/status is refused without the login and, with it,
lists the package as the host does beside enabled, safe_mode, and the
host's own status with running true and the living host's pid. POST
/ext/package: hold-required and hold-advisory name and un-name the package
under required-holds; disable and enable change the host's state file; an
action outside the closed list, an action with a shell's words in it, an id
that has not the form of one, and an id with a path in it are 400 and leave
the state file byte for byte; a package that is not installed is 409 in the
host's words; without the login 403 with the package still there; remove
takes the package, its data, and its name away. The key and the work
directory are removed and the extension root is as found.

Proven. The unit suite: 451 tests, 0 undefined names. On the bench
reference, image 20260921140920 with the cross-built forgectrl and
extension host mounted over the image's: PASS; against the image's own
forgectrl, FAIL at its first request.

Acceptance. The test is new: auto. It covers forgectrl's src/extpkg.* and
src/main.c and forgeext's src/main.c, src/install.*, and src/state.*, and
requires exthost.service and forgectrl.auth.
2026-09-21 11:01:40 -04:00
ScottW514 68fd35b8b7 exthost: the reference package uses its API socket, and raises its own hold
The reference service talks to the host over the socket named in FFX_API
and takes machine.read. exthost.service adds, from the inside: GET /v0/self
names the package and what it may use; GET /v0/machine/mode is forgectrl's
answer; a hold it was not granted is 403, a path the API does not have
404, a path with .. 400; FFX_API is in the fixed environment. From the
outside: the socket is root's and the account's at 0660, and another pool
account that connects to it gets EACCES (a new probe mode).

exthost.hold-pause-tier adds the package's own word. The test leaves what
to say in the service's data directory and the service passes it on as
POST /v0/hold: raised, the verdict is EXT with the package's words and fire
withheld; words the form does not take are refused and the hold reads as
before; cleared, the verdict is OK. Then the rest as before: the host's own
raise for a package that ends at every start, the operator's exits, the
stale file under a suspended host.

Proven. The unit suite: 451 tests, 0 undefined names. On the bench
reference, image 20260921130558 with the cross-built host mounted over
the image's: exthost.service PASS, exthost.hold-pause-tier PASS (the
package's hold stood 1.5 s after it raised it; held 2.0 s after the
service was killed; safe mode released it in 0.8 s, extensions off in
1.1 s), exthost.armed-freeze PASS with the changed reference service.
Against the image's own host, which gives a service no socket,
exthost.service FAILS, as it should.

Acceptance. Both tests already cover forgeext whole (exthost.service) and
the hold's files by name (exthost.hold-pause-tier); neither covers map
changes.
2026-09-21 10:08:03 -04:00
ScottW514 ee89b31c07 exthost.hold-pause-tier: a required hold from the grant to the verdict and out
The reference package takes the hold capability (its service ends at once
when the test leaves a file in its data directory: a package that cannot
speak for itself). The install is refused without the operator's grant; a
granted hold is advisory until `forgeext hold <id> required` names the
package under required-holds. With extensions on, the required hold stands
in the host's words until the service has run healthy, then the host keeps
its file fresh and clear and GET /cool/status reads OK. The test then makes
the service end at every start: verdict EXT, fire_ok false, hold true, the
reason naming the package, and never clear in 24 looks over the crash loop.
Safe mode ends it within four seconds and leaving safe mode brings it
back; marked advisory it is dropped; marked required again and the host
suspended, the reason becomes that the host is not answering, and resumed
it is the package's again; ext_enabled=0 ends it. The verdict is read at
idle: nothing moves and nothing fires. Everything is put back as
exthost.service puts it back, the required holds are empty, and the
verdict is OK at the end.

Proven. The unit suite: 451 tests, 0 undefined names. On the bench
reference, image 20260921121235 with the cross-built forgectrl and
extension host mounted over the image's: exthost.hold-pause-tier PASS
(held 1.0 s after the service was killed; safe mode released it in 0.8 s,
extensions off in 0.8 s); against the image's own forgectrl, whose engine
knows no holds, it FAILS with the verdict OK, as it should.

Acceptance. The test is new: auto, takeover. It covers forgeext's
src/holdkeep.*, src/run.*, src/install.*, src/state.*, src/main.c, and
forgectrl's src/holds.* and src/cool.*, and requires exthost.service.
2026-09-21 09:05:02 -04:00
ScottW514 9ee277f977 forgefirm-users: an operator account reaches its home
On the bench reference /data/forgefirm is 0700, and the operator account
could not enter its own home under it (su scott -c "cd ~": permission
denied). forgefirm-users.init makes the directory of homes 0755 on purpose
("a login traverses it to reach its home") and said nothing about the data
directory above it. No script in the tree makes that directory 0700:
settings.c makes it 0755, forgefirm-logging makes it under rcS's umask, and
this script's own mkdir -p under umask 077 never makes it, because the
record it waits for lives inside it (forgectrl 35684ca's message names
that mkdir as a possible author, and it cannot be). The bench reference's
directory dates from 2026-09-11. The render does not depend on who made it.

replay() now puts the search bit for group and others on the data directory
at every render (boot, and every reload forgectrl asks for), whoever made
it and under whatever umask. Nothing is taken away and nothing in it
becomes listable; what is private there is closed file by file. The same
bit is what lets an extension package's account walk to its files.

setup.account-login sets the data directory to 0700 before it makes its
temporary account, as a strict umask leaves it, and then tries the account
from the inside (a child that becomes it): the render that made the account
left the directory at 0711, the home can be entered and written, the data
directory cannot be listed, and the account record cannot be read. Its
put-back (the found mode, the replay, the temporary home) now runs on every
exit path; before, a failure inside the account's lifetime left the
temporary home and its passwd line behind. image.health asks for the search
bit on a machine with an account record.

Proven. On the bench reference, image 20260921022220, the suite files
mounted over the image's: with the image's own init script
setup.account-login FAILS (cd EACCES, write EACCES, the directory still
0700), as it should; with this script mounted over /etc/init.d it PASSES
(0700 to 0711 at the account's render, cd ok, write ok, the listing and the
record EACCES), image.health PASSES reading mode 711, and the operator
account enters /data/forgefirm/home/scott and is refused the listing of
/data/forgefirm. The mounts were taken away and /tmp cleared; the
directory stays at 0711, which is the repair. The unit suite passes (451
tests, 0 undefined names).

Also on image 20260921022220 as flashed, before any of this was mounted:
image.health (its extension host section on a machine for the first time),
exthost.platform, setup.extensions-consent, exthost.service (a killed
host's service gone in 0.05 s, the host back after 5.7 s),
update.product-gate, and exthost.armed-freeze (the window open 38.6 s,
frozen 0.21 s after it opened and 6.02 s before the latch unlocked for the
run, in all 177 samples; thawed 0.41 s after the close) all PASS, and the
host logs under its own logger after a plain boot.

Acceptance. setup.account-login is the regression test, and image.health
proves the boot-time render on every campaign's first test. The init
script is layer content, in the platform identity of every fingerprint.
2026-09-21 08:11:43 -04:00
ScottW514 800d6890a4 exthost.armed-freeze: a package's service is frozen for a real armed window
The freeze is the rule that keeps a package off the core while a job can
fire, and it had a host test against a stand-in forgectrl and one drill by
hand. This is its test on the machine, against the real engine.

The reference package runs, its heartbeat advancing twice a second.
cloud.dark-print's own job and body (a 30 s square at S0, the fixture's
press, the latch locked at the button, unlocked for the run, locked after)
open a real armed window over it. A sampler reads five times a second: the
engine's armed flag (GET /cool/status), the group's frozen state as the
kernel reports it (cgroup.events), the heartbeat, the service's pid, and
the latch. The test asks for a window of 10 s or more; frozen in every
sample from 2 s in to the close, with the heartbeat still; the first frozen
sample no later than the first unlocked-latch sample, so the freeze is in
place before the run; thawed, with the heartbeat moving again, 3 s after
the close; and one process throughout.

exthost.service's put-back and its as-found checks became _put_back() and
_as_found(), shared by both tests. exthost's top-level imports pulled the
setup suite (and now the cloud suite) in ahead of it and reordered the
catalog, so exthost is imported last: image, kernel, forgectrl, setup,
logs, motion, cooling, laser, camera, update, cloud, exthost; 106 tests.

Proven. The unit suite: 451 tests, 0 undefined names. On the bench
reference, image 20260920211625 with the cross-built forgectrl and host
from /tmp, the coolant taken from 27.2 to 25.4 C with an M8 session first
(the cloud client starts no print above 27): exthost.armed-freeze PASS, the
window open 37.5 s, the group frozen 0.42 s after it opened and 4.97 s
before the latch unlocked for the run, frozen in all 172 samples from 2 s
in to the close with the heartbeat still, thawed 0.62 s after the close.
With the host suspended (SIGSTOP) across the window, so that nobody
freezes anything, it FAILS: not frozen in 177 of 177 samples. exthost.service
PASS again after the refactor (a killed host's service gone in 0.05 s, the
host back and the service running after 5.6 s). The bench was left as
found.

Acceptance. exthost.armed-freeze is new: kind operator (the fixture
presses), takeover; it covers forgeext's src/super.*, src/run.*,
src/machine.*, src/cgroup.*, and forgectrl's src/cool.*, and requires
exthost.service and cloud.dark-print.
2026-09-20 22:21:35 -04:00
ScottW514 1a306d5d8d forgetest: a campaign's machine is extension-free
An extension package is software the image does not carry, and a result
taken beside one is not a result about the image. Two places hold the line.

The baseline (_ext_side, in every pre and post pass): a package under the
tests' own prefix (org.forgetest.) and an owner key named forgetest-*.pub
are what a test made and left behind; they are removed (forgeext remove,
the key's file), recorded as restored, and the host stops the service on
its next turn. A process that still runs under a pool account after that
belongs to the operator's own packages: it is recorded as unrestorable and
never touched. An installed package that does not run is nobody's
leftover. hw.pool_pids() reads each process's Uid line; hw.ext_packages()
lists the package directory.

image.health (5b): the extension host is one process (/usr/bin/forgeext
run), its start link sorts after forgectrl's and its kill link before it,
no package is installed, and nothing runs under a pool account.

Proven. test_baseline.py, ExtensionFreeTests, over a stand-in forgeext and
a fake package tree: an installed package that does not run leaves
nothing; a test's package and key are removed and the operator's package
and key stay; a removal that fails says so in the host's own words;
running extensions are reported and left alone. The unit suite passes (451
tests, 0 undefined names). The link order image.health asks for is the one
the built root filesystems of image 20260921014201 have: S90forgectrl
before S91forgeext, K09forgeext before K90forgectrl.

Acceptance. image.health gains forgeext's init/** in its covers map; it
runs first in every campaign and is the on-image proof of 5b. The
baseline is harness, outside the suite and outside every fingerprint.
2026-09-20 21:56:54 -04:00
ScottW514 1e98b37e45 forgeext on the image, and the tests of the host and of the consent
The image installs forgeext beside forgefirm-sandbox. The recipe installs
the init script from forgeext's own tree (start 91, after forgectrl at 90,
whose read-only routes the host takes the machine's state from; stop 9, down
before it). The host runs nothing while ext_enabled is 0, the default.

setup.extensions-consent (takeover): the Extensions advisory is served and
is no first-run document; ext_enabled=1 is refused without the advisory's
hash, with a stale one, without the phrase, with the phrase in another
case, and whole beside a write the daemon refuses, each leaving the setting
and the record on disk untouched; with the hash and the phrase it is
accepted, recorded under on_demand.extensions, and the data directory gains
the search bit and nothing else. The setting, the directory's mode, and the
record are put back, the record under a forgectrl restart.

exthost.service (takeover): a reference package built on the board, signed
with a key made there and added as an owner key. It is unverified before
the key is the owner's and community after; the install is refused without
the consent. Turned on over the advisory, the service is looked at from
outside (account, no_new_privs, seccomp, group, limits, chain) and from
inside (what it can read, write, dial, and open), its first line is looked
for in the forgeext logger's file, safe mode stops it, a host killed with
the service in its quiet loop takes it along within 2 s and comes back, and
ext_enabled=0 leaves no group and no chain. The package, the key, the
setting, the mode, and the record are put back, and the extension root is
compared with how it was found. The reference package exists only while the
test runs: no image carries it, and no image trusts its key.

Proven. forgetest's unit suite: 422 tests pass, 0 undefined names. On the
bench reference, image 20260920211625 with the cross-built forgectrl and
host from /tmp: setup.extensions-consent PASS and, against the image's own
daemon, FAIL at its first request; exthost.service PASS and, with the init
wrapper's kill taken out, FAIL at the killed-host check. The first runs of
exthost.service found what the host tests could not: /data/forgefirm is
0700 on the bench reference, and the service ended with EACCES on its own
entry point until forgectrl opened the directory for search with the
consent.

Acceptance. Both tests are new. exthost.service covers forgeext whole and
forgectrl's src/main.c and src/logs.*; setup.extensions-consent covers
forgectrl's consent path. The recipe, the image line, and the init
script's install are layer content, in the platform identity of every
fingerprint. forgeext joins scripts/manifest-from-tree.py with its first
pin.
2026-09-20 21:41:21 -04:00
ScottW514 2894269115 The deny rules: the machine itself is never a destination
The way through the extension sandbox's deny rules is an allowlist, and an
allowlist names addresses. The machine's own LAN address is not a fact
anybody can pin: a new DHCP lease can turn a peer's address on some
package's list into the machine's, and with it open the Grbl port or
forgectrl's listeners to that package.

ffx.nft now refuses it structurally. Everything a host sends to one of its
own addresses, the LAN one included, leaves through lo, so chain pool
refuses `oifname "lo"` before it looks at the allow map; the two refusals
(a reset for TCP, a drop for the rest, both counted) move into chain
refuse, which pool jumps to from both places. No kernel option is new:
oifname is in the nf_tables core.

scripts/sandbox-rules-test.py gets a destination that is not the machine:
a second network namespace joined by a veth pair, with listeners of its
own. A pool uid is refused on loopback, IPv6 loopback, its own LAN address,
and the peer; an allow chain opens one port of the peer to one uid and
nothing else; with loopback, IPv6 loopback, and the machine's own address
added to that list the uid is still refused at all three while the peer
still answers; a reload closes it. It needs ip and nsenter now.

exthost.platform reads its counters from chain refuse, holds the rule's
place ahead of the map, and adds the case on the machine: an allow chain
for the last pool uid that names forgectrl on loopback and on the LAN
address opens neither, and the chain is removed.

Proven. The rules test passes with nft 1.0.9, and four controls each fail
it: the range one uid short, the TCP reject turned to accept, the
delete-table line removed, and the lo rule removed (the uid then reaches
all three of the machine's addresses). On the bench reference, image
20260920211625, this rule file loaded from /tmp with nft -f and this suite
file mounted: exthost.platform PASS, uid 831 refused at 127.0.0.1:443 and
172.16.1.97:443 with both on its allowlist, the counters [0, 0] to
[12, 4]. Against the image's own rules the same test fails on the rule's
absence, which is the control. The image's rules were reloaded after. The
unit suite passes (422).

Acceptance. exthost.platform gates the rule on the machine; sandbox-ci
gates the file. The rule file is layer content, in the platform identity
of every fingerprint.
2026-09-20 19:53:05 -04:00
ScottW514 5ad7ee6faa forgeext: the recipe, and the extension-signing key in the keyring
forgeext is the extension host, a component of its own: recipe
recipes-forgefirm/forgeext (cmake, pkgconfig, forgefirm-manifest, so it is
a manifest component with its own pin file). It links jansson, libarchive,
and libsodium, and runs with fwup, the keyring, and forgefirm-sandbox.
libarchive and jansson are on the image already; libsodium comes with it.
The pin is all zeros because the repository has no pushed commit to name:
the recipe builds from a working tree through externalsrc, and a build
from pins cannot fetch it until the first push sets the pin. It is not in
the image's install list.

forgefirm-keys installs a third trust anchor,
/etc/forgefirm/keys/ext/forgefirm-ext.pub: the OpenGlow extension-signing
public key, the official tier of extension packages. It is a different key
from the release key on purpose: it signs more often, and its loss must
not sign firmware. forgeext refuses an extension archive whose only valid
signature is the release key's or a factory key's.

Proven. The recipe cross-builds forgeext from the working tree, and that
binary ran the verify-and-install cases on the bench reference (image
20260920211625, from /tmp, with the board's own fwup 1.16.0 and the
image's keyring) with the results of the host test. forgefirm-keys builds
and packages the key 0644 under ext/ (0755), byte-identical to the file
here; it is 32 key bytes and is not the release key.

Acceptance. No catalog test reads either yet: forgeext's tests are the
exthost suite that comes with its daemon, and the key is layer content, in
the platform identity of every fingerprint.
2026-09-20 19:34:45 -04:00
ScottW514 9128f0e98e update.product-gate: extension packages at the firmware upload
forgectrl's firmware paths now read an archive's product and take firmware
only (fwproduct.c). The upload route was not exercised by any catalog test;
this one is its door.

Archives made on the spot with the machine's own fwup go to
POST /update/upload as a multipart form: an extension package unsigned,
one signed with a throwaway key, and one that carries a task are each
refused with 400 "this archive is an extension package, not firmware" and
nothing is staged; an archive of another product is refused as not
firmware; one whose product is ForgeFIRM firmware is taken as the unsigned
upload it is, and the staged file is removed. Nothing is applied: the
apply calls the same gate before fwup -a, and its proof is the daemon's
host test, because a catalog test that could fail there would write a slot.

Proven. On the bench reference, image 20260920211625 with the gated daemon
and this file mounted over the image's: update.product-gate PASS, and
update.slots-and-signature PASS beside it (the signature refusal still
comes first). Against the image's own daemon, which has no gate, the test
fails on its first upload (200), and its cleanup leaves nothing staged.
The unit suite passes (422) with no undefined name.

Acceptance. update.product-gate covers forgectrl's src/fwproduct.* with
the update sources.
2026-09-20 19:34:45 -04:00
ScottW514 619414ed2b cloud suite: a print turned away before the button fails at once, with the reason
The cloud client turns a print away before the button wait for four
reasons of its own (machine._safe_to_move): the lid or the interlock, a
machine that is not idle, the coolant above its start ceiling
(THERMAL.max_start_temp), a coolant sensor that reads nothing. It logs the
reason and finishes the print ':cancelled' within a millisecond. The four
tests that wait for the button looked only for the wait, so on the bench
reference, with the loop at 27.4 C against the ceiling of 27,
cloud.dark-print sat out its 120 s and said "the print never reached the
button wait".

wait_button_wait replaces the four waits: it ends on the print's finish
line as well, and fails with the client's own lines, for example "the
client turned the print away before the button wait: INFO
machine:_safe_to_move machine temp is too high, temp: 27.4 (... finished
with event ":cancelled")". The ceiling is the client's and stays where it
is: the remedy for a warm loop is airflow, a run session for a minute or
two.

Proven. test_a_print_turned_away_before_the_button_fails_at_once_with_the_reason
replays the bench reference's lines and fails in under 30 s with that
reason; with the early exit disabled it fails after the full 120 s with the
old words. Each reason is a line the pinned cloud library can log, so the
phrase check passes. The unit suite passes (422) with no undefined name. On
the bench reference, image 20260920211625 with this cloud.py over the
image's, cloud.dark-print passes through the new wait with the loop at
25.8 C.

Acceptance. cloud.dark-print, cloud.verdict-refuse,
cloud.lid-during-button-wait, and every test that starts an offline print
go through the new wait. The helper is module text, so every cloud.py
test's fingerprint moves; no product behavior changes.
2026-09-20 18:02:31 -04:00
ScottW514 474bf9db09 motion.job: measure the job's reach from where the test began
_job_wait read its own starting X when it was called, which is after the
lease wait and three refused requests beside the running job. On image
20260920204744 those took long enough that the head was already 2.55 mm
out, and the test failed with "the kernel saw 17.450 mm of the 20" on a
job whose record said done, 7 of 7 acknowledged, no emission sample, and
the counters back at 0.0. It now takes the caller's X, read before the job
was posted; laser.recorder-dark, the helper's other caller, reads no
distance and passes none.

Proven. On the bench reference with the fixed file over image
20260920204744's: motion.job PASS three times running, laser.recorder-dark
PASS. The file is in image 20260920211625. The unit suite passes (422).

Acceptance. motion.job is the test. The helper is module text, so every
motion.py test's fingerprint moves; no product behavior changes.
2026-09-20 18:02:31 -04:00
ScottW514 d627ee32f1 The extension sandbox platform: accounts, cgroups, and the deny rules
What the image holds ready before any extension package exists, so that
the first one starts inside it.

forgefirm-sandbox (new recipe, on both images):
  - the account pool: ffx0 to ffx31, uid and gid 800 to 831, one group
    each, /nonexistent, /bin/false, locked. Below 1000 on purpose: the
    forgefirm-users render replaces only the accounts from 1000 up, so an
    account reset leaves the pool alone and the read-only rootfs never
    needs an account made at run time. The image's dynamic system ids
    count down from 999 and stop at 997.
  - an rcS script at S30: cgroup v2 mounted at /sys/fs/cgroup, the cpu,
    memory, and pids controllers handed down to /sys/fs/cgroup/ffx, and
    ffx marked idle-class (cpu.idle; the kernel refuses a cpu.weight on
    top of it, so none is written). The firmware's processes stay in the
    root group. `status` reports both halves and exits nonzero when
    either is missing.
  - /etc/forgefirm/ffx.nft, loaded by the same script, before the network
    starts in rc5: table inet ffx, an output-hook filter with policy
    accept that sends uid 800-831 to chain pool; pool looks the uid up in
    the verdict map `allow`, then answers TCP with a reset (a drop would
    leave a connect to time out) and drops the rest (the sender sees
    EPERM), both counted. The map is the one way through: a uid mapped to
    a chain of that package's destinations. Loading the file again
    replaces the table, allowlists included: it fails closed.

nftables comes in as its runtime dependency, trimmed in the distro config
to the binary and its library with JSON output: no interactive shell, no
Python binding. gmp and jansson were on the image; libmnl and libnftnl are
new. The release rootfs goes from 34.5 to 34.1 MiB free.

scripts/sandbox-rules-test.py, and the workflow sandbox-ci that runs it:
the rule file loaded into a network namespace of its own and sent at from
real uids. Root, 799, and 832 are not touched; 800, 815, and 831 are
refused on 127.0.0.1 and ::1 at once, UDP with EPERM, and a receiver hears
nobody from the pool; an allow chain opens one port on one address to one
uid and nothing else; a reload closes it.

exthost.platform (new suite module exthost.py): the platform proven on a
probe process, not read off a config. In a probe group under ffx, as the
last pool uid: held to cpu.max, stopped by cgroup.freeze and running again
after, stopped at pids.max, killed by the group's own OOM at memory.max
while forgectrl keeps its pid. The 32 accounts as the boot's render left
them. Pool uids 800 and 831 refused TCP to forgectrl on loopback (both
ports, IPv4 and IPv6), to the LAN address, and to the Grbl port, at once,
UDP EPERM, with the rules' counters moving by at least the attempts, while
root reaches the same listeners. A root probe under landlock loses /etc
and TCP connects and keeps /usr; a seccomp filter returns EPERM for the
filtered call. The probe group is removed whatever happens.

Proven. The rules test passes with nft 1.0.9, the image's version, and
three controls each fail it: the range one uid short, the TCP reject
turned to accept, the delete-table line removed. The unit suite passes
(422) with no undefined name. Image 20260920211625 carries all of it (read
back from both rootfs images: 32 accounts in passwd, group, and shadow,
S30forgefirm-sandbox, the rule file and the script byte-identical, nft
with its libraries and no Python binding). On the bench reference, that
image: exthost.platform PASS (5.0 percent of the core under a 5 percent
cpu.max, 0 us frozen and 87358 us thawed over 1.5 s each, 5 of 12 forks
then EAGAIN, rc -9 with oom_kill 1 at a 24 MiB memory.max, the counters
[0, 0] to [12, 4], landlock ABI 6), and forgefirm-sandbox status reports
both halves in place.

Acceptance. exthost.platform gates the platform; sandbox-ci gates the rule
file. The recipe, the rules, and the distro option are layer content, in
the platform identity of every fingerprint.
2026-09-20 18:02:31 -04:00
ScottW514 965f7c3ca9 The cooling report secret: a driver harness, and two catalog tests
forgectrl's POST /cool/state now asks for the secret the supervisor hands
the running controller at its spawn; both controllers' cooling clients
send it.

scripts/bench/cool_report_test.py is the driver's host harness for it
(null-sink controller, a stand-in for forgectrl's listener on
FORGECTRL_PORT): every report carries the secret and no other new header;
none does when there is none; a value that is not 32 hex digits, a CR LF
with a header behind it included, never reaches the wire; the homing
runner the controller starts does not inherit the secret. It is in the
bench registry and the bench README, and the driver's CI runs it.

forgectrl.auth: the loopback case used to assert that any local peer is
accepted. It now asserts the three answers: no secret 403, a made-up
secret 403, and the running controller's own secret 200, read as only root
on the machine can read it, out of the controller's environment, and never
logged. The LAN cases carry the secret too and are still refused.

cooling.report-channel is new, the drill the change exists for: M8 opens a
run session, three forged idle reports from this host (no secret, a
made-up one, a made-up one with a forged Host) are each refused, and over
the next five seconds the engine stays in phase run and no commanded fan
duty drops; M9 ends the session on the controller's own report.

Proven. The harness passes on the host-built controller, with three
negative controls that each fail as they should. The unit suite passes
(421) with no undefined name. On the bench reference, forgectrl and both
clients hot-deployed over image 20260920152153: forgectrl.auth PASS,
cooling.report-channel PASS (phase run throughout, the exhaust at 65535 and
the intake at 43278), and cooling.fans-quiet-after-motion and motion.job
PASS on the same binaries.

Acceptance. forgectrl.auth and cooling.report-channel are the gate for the
report channel's secret; cloud.dark-print gates the cloud client's side.
2026-09-20 16:46:01 -04:00
ScottW514 19ce4d78d2 forgetest: the built-in extensions in setup.cloud-disabled-surface
forgectrl gains a table of built-in extensions, with cloud mode as entry
one, GET /extensions to serve it, and POST /settings asking it which
selections point at the cloud, how to refuse them, and what they fall back
to. It also gains an example client under examples/, and its test tokens
lose the names of clients nobody is building.

setup.cloud-disabled-surface is the gate for "nothing points at the cloud
while it is off", so it now holds the list to the settings three times: as
found (enabled as cloud_enabled says, the two roles with their providers
and fallbacks, each active exactly when its setting selects it), with the
cloud off (not enabled, no role active), and as restored. The two refusals
are held to the table's words. Its covers name src/builtin.*. The helper
lives inside the test's own function, so no other test of the module
changes its fingerprint.

The unit test's fake daemon serves the route the way builtin.c does, and
gains a case with two lists that lie (enabled after the cloud went off; a
role that stays active), each of which must fail the test.

forgectrl.tokens: the jog token is "forgetest jogger".

manifest: forgectrl's examples/** joins the non-behavioral paths. They are
clients that run on another computer: outside the image, outside every
fingerprint, and outside the coverage lint.

Proven. The unit suite passes (421) with no undefined name; the two lying
lists fail the test as they should. On the bench reference, forgectrl's
registry daemon hot-deployed over image 20260920152153, on a machine with
cloud mode on: setup.cloud-disabled-surface PASS through the whole path
(off, the sweep, the refusals, the restore), and forgectrl.tokens PASS with
the renamed token.

Acceptance. setup.cloud-disabled-surface is the gate for forgectrl's
built-in table through the settings route.
2026-09-20 15:03:05 -04:00
ScottW514 ebc4222182 forgetest: forgectrl.tokens
forgectrl gains scoped API tokens: a credential made in the panel that
reaches the routes it was granted and nothing else, with no login session.

forgectrl.tokens creates three through the panel's route (machine.read with
camera.lid; motion.jog; camera.lid alone) and uses each the way a client on
the network does: over HTTPS to the machine's own LAN address, so the
daemon sees a peer that is not this host, and the dev image's and the
loopback's allowances do not apply. The first reads /status as a bearer
token, /mode in the panel token's header, and /cam/status. The camera-only
token reads /cam/status as ?key=. Refused with 403 and the reason in
words: the head camera, the first token in a URL (it holds more than a
camera), a jog, a settings write, a mode switch, the token list, a token
of its own, the panel's ?token= form, a stranger's token, and /status for
the jog token. The jog token then moves the head 1 mm with no session and
no panel token, the kernel's counters as the witness. A token over plain
HTTP from the LAN is refused and the daemon's log names it. With
panel_open_reads=0 the LAN reads /status with machine.read and with
nothing else. The list names the three with a last-used time and carries
neither a token nor a hash; the store is mode 0600 and holds no token. A
revoked token is refused at once, with the reads open again, and the other
tokens live on. Every token is revoked, the setting restored, and the head
returned on the way out, and tokens a broken run left behind are revoked at
the start. No token is written to the log or the evidence. The refused
requests are ones that would do no harm if the guard failed: a settings
write of the value in force, a mode switch to the mode in force.

Proven on the bench reference, forgectrl's scoped-token daemon hot-deployed
over image 20260920152153: PASS with a clean hand-back, the jog at 1.000
mm. The unit suite passes (420) with no undefined name.

Acceptance. This test is the gate for forgectrl's scoped tokens.
2026-09-20 13:57:15 -04:00
ScottW514 f0ba4b9249 forgetest: motion.job and laser.recorder-dark
forgectrl's job runner (POST /job, and the dose-curve recorder and the
sheet wizards moved onto it) needs a test of a posted program, and nothing
in the catalog ran the recorder's start: only its refusal and its status.

motion.job posts programs the way a client does, as a multipart form. A
program with a $ line and a job with no name are refused with 400, and a
job beside the suite's own Grbl client with 409, each with nothing moved. A
dark program (out 20 mm, a dwell, back) then plays: /status names
job:forgetest as the lease holder of kind sender, GET /job reports it
running, and a second job, a port jog, and a settings write are each
refused in the job's name. At its end the record says done with every line
and the runner's own M2 acknowledged, no discharge and no LASER_ON sample,
the kernel's counters saw the 20 mm and are back where they began, and the
lease is free. POST /job/abort stops a 40 mm move short, into the
controller's alarm state, with the lease free. A job sent into the alarm
fails at its first line with nothing moved; the same job with unlock=1
clears the alarm and returns the head.

laser.recorder-dark starts the recorder and never presses. While the
controller waits at its arm: the recorder holds the machine as a sender,
GET /job shows its run (not a posted program's), the floor and the curve
read 0 and off, POST /job/abort refuses to stop a run it did not start, a
posted job is refused in the recorder's name, and two seconds of witnesses
stay dark. POST /curve/stop ends it: stopped before the ladder fired, the
lease free, both laser keys as they were. The ladder begins at the
controller's X0 Y0, so the head is returned by a posted job.

covers: setup.sheet and forgectrl.lease name src/jobrun.*, since the sheet
wizards stream through the runner and the recorder's refusal comes from it;
setup.sheet names src/lease.* for the hold its jobs take inside the
wizard's.

Proven on the bench reference, the job runner's daemon hot-deployed over
image 20260920130136. motion.job: PASS; the kernel saw 20.000 mm out and
0.000 back, 6 lines with 7 sent and acknowledged, every witness zero, the
abort at 9.83 mm of 40 into Alarm, the unlocking job with its $X and its M2
the two lines more. Its first run failed, and found a defect in the daemon
and not in the test: the record said done with the head 0.19 mm short of
its end, because the controller acknowledges M2 before the pulse engine has
played the last of its ring. The runner now waits for the kernel to be
idle, and the test passes unchanged. laser.recorder-dark: PASS; the
controller reached its arm wait, 16 samples dark, the stop left the
controller Idle and the head where it was. setup.sheet: PASS with the
operator at the machine. forgectrl.lease: PASS. The unit suite passes (420)
with no undefined name.

Acceptance. These two tests are the gate for forgectrl's job runner, with
setup.sheet for the sheet wizards' side of it.
2026-09-20 11:21:14 -04:00
ScottW514 888b47abe8 forgetest: the hand-back never moves the head across a lost counter frame
Seen on the bench reference, twice in one session: at the end of a passing
run the hand-back jogged the head 30 mm into the back-left stop blocks,
from a head that had not moved.

The baseline compares the kernel's step counters at the end of a run with
the start and jogs the head back by the difference. The GRBL controller
zeroes those counters at every start (the lens's startup reference) and at
every home (home_completed()), and rewrites its anchor, /run/grblhal.homed,
each time. Across either event the difference between two counter readings
is not a distance the head traveled. It stayed hidden because the counters
normally read zero between tests. homing.manual broke that: a manual home
zeroes the counters 30 mm out from where the test began, so after the
test's own correct return they read -6400, and the next test that restarts
the controller (setup.check-flow-verify, then motion.release) ended at 0,
"expected -6400", and was "returned" by 30 mm. An operator who jogs the
head from a Grbl client and then starts any takeover test from the page
would have met the same thing, by whatever distance they had jogged.

The baseline's capture() now records the counters' frame, the anchor's
inode and mtime. If the frame changed during the run and the test did not
vouch for the new one, the hand-back logs that the two readings share no
frame, and moves nothing. ctx.counters_rezeroed() is how a test vouches: it
now sets rezero_declared beside the position it expects. The start_reads
argument it briefly took is gone, since it made the baseline accept
counters that nothing after it could live with.

homing.manual restarts the controller once more after returning the head,
so it ends with the counters at zero where it began, and declares that.

events.stream waits for its three places. A stream an earlier test closed
keeps its place until the daemon's next write to it (its keep-alive), as
documented, so run straight after forgectrl.lease the third stream drew
503. The test now opens the three once they can be opened, and says so in
its log.

Proven. test_baseline gains test_a_lost_counter_frame_never_moves_the_head:
counters at -6400, a new anchor, counters at 0: no jog, no leftover, and the
log says why; the same counters with the frame intact are still a displaced
head; a declared re-zero is held to the position it declared. With the
frame check unable to see the change (the first cut of the test reused an
inode inside one clock tick) the case fails with ['position'], which is the
old behavior. The unit suite passes. On the bench reference, arranged so a
failure would move the head away from the stop: the head jogged to +60 mm
(counters 12800), motion.release run, PASS, "the controller re-zeroed its
counters during the run ... the head is not moved", and nothing moved.
forgectrl.lease then events.stream: two logged waits, PASS. homing.manual
then setup.check-flow-verify, the sequence that drove the head into the
stop: both PASS with a clean hand-back.
2026-09-20 09:00:55 -04:00
ScottW514 520bf6023d forgetest: forgectrl.lease
The acceptance test for forgectrl's machine lease. The switches check is
started and left waiting at its first prompt: it moves nothing, and it
holds the lease for as long as it waits. /status must name it as the holder
(wizard:switches, kind hardware), and everything that asks the lease must be
refused with 409 and the holder's name: a diagnostic, the dose-curve
recorder, a log export, a mode switch to the mode already in force, POST
/controller/start, and POST /settings, the last with "settings are locked".
Only requests that would do no harm if the lease failed are made: no
reboot, no boot-slot change, no update job; and whatever a failed refusal
may have started is stopped on the way out. The check is then aborted: the
lease must read free, POST /settings must be accepted again, and the event
stream, open through all of it, must have reported lease.changed with the
owner and then with null.

Passes on the bench reference. The nested hold (a diagnostic under its
cooling wizard) is setup.check-flow-verify's to exercise, and it passes
there with /status reading diag:flow-verify under
wizard:cooling.flow-verify through the run. The unit suite passes (418).

It covers forgectrl's src/lease.*, src/main.c, src/status.*, src/wizdark.*,
src/diag.*, src/curverec.*, src/update.*, src/logs.*, src/super.*, and
src/events.*.
2026-09-20 08:00:45 -04:00
ScottW514 25cc42b879 forgetest: events.stream
The acceptance test for forgectrl's GET /events. The daemon counts event
streams per peer address, so the test is several peers at once: it binds its
client sockets to 127.0.0.2 through 127.0.0.5, which are all this host. Three
streams must each get 200, text/event-stream, and the hello event; a fourth
address must get 503 with the reason while GET /settings still answers; a
second stream from the first address must be served, and the older one must
get bye and the end of its response; POST /motion/release and
/motion/energize must arrive on a stream that was open all along as
motors.released and motors.energized, in order, with ids that count up; and
with every stream closed a place must come back within 25 s, since a closed
client is only noticed at the daemon's next write to it (its keep-alive).

The SSE client was run against a host-built daemon before any bench time was
spent, which found two defects in it: its reads blocked for the socket
timeout, not the window the caller asked for, and a replaced stream ends
with the last chunk of the response, not with the socket closing.

Passes on the bench reference: the fourth stream refused in words, the
replacement told and ended, alarm, motors.released, and motors.energized as
ids 1 to 3, and a place back 3 s after every stream closed. It covers
forgectrl's src/events.*, src/main.c, src/status.*, and src/grblport.*.
2026-09-20 07:14:04 -04:00
ScottW514 8856fb0099 forgetest: the motor release, the manual home, and the controller port
Four catalog tests for the controller and daemon change of the same area,
and one hand-back rule they needed.

motion.release (takeover, 8 min): $MD takes the X and Y step currents to 0
with the 40 V rail untouched (no supply line in the kernel log, cnc/state
idle, no fault), drops the X and Y reference, and locks the machine in
alarm; a jog, a G0, $X, and $X after a soft reset are refused; forgectrl
refuses a switch to cloud mode with 409; the kernel's position record is the
witness that nothing was shipped, since the accelerometer cannot say "still"
across a release (the rotors relax and the head feels it); $ME restores 33
and 5 with no fault; and the drivers are proven alive the way the machine
proves it to itself, by the Setup motion check's liveness probe and
witnessed jogs.

homing.manual: a manual $H after an outbound jog plays no pulse byte, clears
the counters, declares manual_home_x and _y, keeps Z, turns the soft limits
on (error 15 behind the home), tells the client the home was set by hand,
and reads back through forgectrl as source manual.

motion.port-jog: with the suite connected as the Grbl client, POST
/motion/jog moves the head by what was asked on the kernel's counters, the
client is told and not displaced, and /motion/state agrees. The client then
polls the way LightBurn does, '?' with an end of line behind it: no port jog
may be refused for it, a 30 mm jog must run whole with at least three polls
landing inside it, and every poll must draw its ok. The cancel stops a long
jog short; the client's own line stops a fast one (100 mm at F6000, fast on
purpose: a slow jog stops at once and would pass with no hold) and must draw
ok, never an error; the 100 mm bound holds with nothing moved; the release
and the energize go through their routes with the currents read; and the
head is returned, in requests of 100 mm at most.

laser.port-dark (live, one button press): a 20 mm line at M3 S400 with no
M5 and no program end after it leaves the armed window open with M3 modal
and S above zero, the state in which an injected G1 would fire. The line
must be witnessed lit or the case proves nothing, the window must read armed
before and after, and through three port jogs back over the line the
LASER_ON sample count stays 0, the HV current stays idle, and the head's
beam detector does not rise over its level before the jogs (its own
pre-jog level, because it may still be settling after the cut).

The hand-back. The baseline compares the kernel's step counters at the end
of a run with the start. A manual home clears them by design, and
homing.manual takes its home 30 mm out from where it began, so after the
test's own correct return the counters read -6400: on the bench reference
the baseline "returned" the head 30 mm the wrong way and failed a run whose
body had passed. ctx.counters_rezeroed() now takes start_reads, what the
counters read in the new frame with the head at its starting position;
homing.manual passes the start minus the counters at the home. Without the
argument it means what it meant: re-zeroed at the start.

All four pass on the bench reference: motion.release (the rail untouched,
every refusal, the 409, the probe and four witnessed jogs), homing.manual
with offsets 12.5 and 8 (declared 12.502, 8.002, a clean hand-back
expecting -6400), motion.port-jog (10.000 mm for 10; 8 of 8 accepted under
the poll, 30.000 mm with 5 polls inside the jog, 14 polls and 14 oks; the
cancel at 10.3 of 40; the client's line at 33 of 100 with ok; drift 0.000),
and laser.port-dark (emission peak 148 on the line; 20 samples across the
jogs with 0 emission, HV 0, beam rise 5). The unit suite passes (418).

Coverage: motion.port-jog names forgectrl's src/grblport.*, src/main.c, and
src/status.*; motion.release adds src/status.* and src/wizdark.*;
laser.port-dark adds src/grblport.*; the driver's new sources fall under
src/**, which every motion and laser test already names.
2026-09-20 07:14:03 -04:00
ScottW514 b29bb9e023 bench: the controller port and manual home harnesses
Two host harnesses for the grblHAL driver's null-sink build, in the bench
registry and the README with the others. The driver's CI runs them.

ctlport_test.py drives the controller port beside a scripted Grbl sender
that counts every ok and error it is sent, which is the only way to see a
status routed to the wrong source. 13 cases: the socket's mode; a port
jog's status going to the port with the sender's count exact and the jog
run whole under the sender's '?' polls; a port error the sender's next line
does not inherit; the sender's line canceling a fast port jog and drawing
its own ok (fast on purpose: a slow jog stops at once and would pass with no
hold at all); a sender line queued right behind the port's; the refusals;
one client, and five reconnects right after a close; the status hook across
a soft reset; the dead-man; a CR LF sender; a sender that polls the way
LightBurn does, '?' with an end of line behind it, LF and CR LF (every port
jog accepted, a 60 mm port jog run whole with the polls landing inside it,
one ok per poll, a real line still canceling a port jog); and every
operation of both sets under an open armed window with M3 modal and S500,
where the dump must hold no FIRE tick.

manual_home_test.py reads the stream dump (GFSINK_DUMP) and the attribute
log (GFSINK_ATTR_LOG), so it can say that nothing was shipped and which
current was written, and how many times. 9 cases: a manual $H ships no step
and no FIRE tick and declares the offsets with the soft limits on and Z
kept; $H refused in a cycle; $MD refused under an open armed window with
nothing written; every motion source and $X refused while released; each
energize written exactly once; the port's panel operations; both pairs of
home offsets, alone and at once; and a controller killed under a release,
whose replacement writes only 0 and 0.

Both pass against the driver's extensions tree. The poll cases fail against
the driver without its empty-line rule ("8 of 8 port jogs were refused under
a status poll"), which is the defect they were written from: it was found
on the bench reference with LightBurn connected, and the sender these
harnesses had until then polled a bare '?'.
2026-09-20 07:13:31 -04:00
ScottW514 11f4c42705 forgefirm-image: install python3-asyncio
The release image carries only the standard-library module packages its own
Python recipes declare, and none of them imports asyncio, so it was left
out. An extension runs on the image's interpreter and brings no
standard-library module of its own: the set it may import is the set
installed. Its event-stream and socket clients are written on asyncio, so
the image installs python3-asyncio on purpose, with the reason in the
recipe. The other modules the trim leaves out (multiprocessing, xmlrpc, the
debugger, venv, ensurepip) stay out until something asks for them.

Proven: an image pair built from this recipe has
/usr/lib/python3.12/asyncio/__init__.py in the release rootfs, read out of
the ext4 image; on the bench reference, running the dev image of that pair,
python3 -c "import asyncio" succeeds.

Acceptance: the change is recipe body, so it is platform content and takes
the full campaign. No catalog test can hold the module itself: forgetest
runs on the dev image, which installs python3-asyncio with the rest of the
standard library whatever this recipe says, so the release rootfs is the
thing to read, as it was here.
2026-09-20 07:13:10 -04:00
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
ScottW514 e8d4560ff0 Release v0.0.2: the version and the acceptance artifact for this tree
FORGEFIRM_RELEASE takes 0.0.2. The file sits outside the layer content
hash, so the bump changes the version and invalidates no acceptance
result.

releases/v0.0.2 carries the artifact the bench exported for this image:
campaign c-20260910181641-11a2 on 20260910181308 (dev), manifest identity
a31c820d26d09155d2bf7c629389c5dba5e55d2b52dbbc0ccab5440b187b3b3c, 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.1: every machine named after its own MAC
with mDNS dropped, the fan run posture a diagnostic measures, the
hand-back rules the bench now enforces, the flow-load tail ending at the
coolant peak, and the stream flag a dead session leaves behind cleared by
the session that takes the device. Components: forgectrl 0.1.17,
grblhal-glowforge 0.1.11, forgefirm-app 0.1.28+git, kernel-module
0.0.5, meta-openglow ced2af2.
2026-09-10 14:34:33 -04:00
ScottW514 00cc009871 Pin the stream-flag fix in the controller
grblHAL-glowforge 0.1.11 (1f2ea9c): a controller taking the pulse device
clears a dead session's cnc/streaming flag, the way it already acks that
session's stale underrun. laser.armed-kill kills the controller mid-fire,
and the machine came back with the kernel still believing the killed
session was feeding it.
2026-09-10 14:11:08 -04:00
ScottW514 c47f7d9a86 Put the head back after the hold in the disarm test
laser.disarm-in-hold streams a 40 mm move, feed-holds it two seconds in,
waits out the disarm grace, and then resets out of the hold. The recovery
put the laser and the controller back and left the head where the hold
had caught it: 11.34 mm along on the bench reference, 605 counts, which
the hand-back jogged out and reported.

The head goes back now, by the distance it actually travelled rather than
the distance the move asked for: the hold catches it at a slightly
different point every run - ten millimetres nominal at F300 for two
seconds, 11.34 measured - so the kernel counters before the move and
after the reset are what the return jog is built from. Where it stopped
and where it ended go into the evidence.

This is not the fault the M5 rapid job had. That job's moves netted plus
twenty by construction; this one stops part-way on purpose, and the
recovery simply never returned it.

laser.armed-kill has the same shape, a kill mid-fire that stops the head
where it stops, and is left alone until a run says whether it needs the
same treatment.
2026-09-10 13:58:23 -04:00
ScottW514 c217e24905 Put the head back at the end of the M5 rapid job
laser.m5-rapid-dark cut 20 mm out and then ran the two rapids the test is
about, one back and one out again. The three net to plus 20 mm, so the
job ended with the head 20 mm from where it started, every run: 1067
counts at 53.333 per mm, which the hand-back jogged out and reported.
That one is dirt, and the position dead band was right to leave it alone
- it is 20 mm, not the step a return rounds to.

A third rapid, back 20 mm with a short dwell, ends the job where it began.
It sits inside the sampling window and after the M5, so it is one more
rapid that must be dark, which is what the test already asks of the other
two: the assertion is wider, not narrower. The operator's clearance step
is unchanged, because the head still needs its 20 mm of +X.

Every job list in the suite that moves in G91 now nets zero on X
(cooling.py, laser.py twice).
2026-09-10 13:46:34 -04:00
ScottW514 b94c990bbc Leave the lens to the cloud client in cloud mode
cloud.verdict-hold printed to completion and then failed its hand-back on
head/z_mode=0. That job's header carried ZSmd 0, gfhardware's
set_mode_from_puls turns that into z_mode 0, and z_mode 0 is full step
(lenshome.c): the cloud client had set the lens the way the pulse file it
was playing asked. Nothing was left behind - the machine did what the job
said.

The baseline already skips nine attributes in cloud mode, for the reason
written above the list: the cloud client sets its own values for them
from every pulse header, and forcing the GRBL values under it would be
the baseline configuring another controller's machine. head/z_current and
head/z_mode are the same thing and were not on the list. They are now, in
cloud mode only; in GRBL mode the pair is still checked against 1 and 1,
which is the half of this an exemption could quietly break, so a test
holds it.

Host-proven: 89 baseline unit tests.
2026-09-10 13:08:33 -04:00
ScottW514 8d8ca2a8c6 Judge a hand-back on what a run left, not on the machine still working
A leftover is dirt a run left behind. Four of the checks were reporting
something else: the machine part-way through work of its own, which it
finishes on its own a few seconds later. Each one failed a run that had
done nothing wrong.

laser.arm-wait-lid found it. The test passed every check it makes and
failed on cool=smoke/armed=False/hold=False. The smoke clear is the
engine's post-job work - run duty for smoke_s after an armed session,
timed, ending by itself (cool.c, Cool_Smoke) - and the machine was idle
fifteen seconds later without being asked. Armed and hold were both
false: nothing had been left.

The four, and what each was catching:

  cool         a phase alone (the smoke clear, a cooldown). Armed or
               holding stays a leftover: the run left a job alive and the
               engine is keeping the fans up for it.
  controller   the supervisor's own start. A takeover ends by starting
               forgectrl again, and the respawn runs the liveness probe
               and the lens reference before it reports running and
               verified. The run had put it back.
  state        the ring draining to the end of a job. An underrun stays a
               leftover and is still acknowledged with cnc/stop: that one
               is the run's.
  leds         read_led read brightness, write_led writes target, and the
               smooth trigger fades brightness toward target. An LED the
               machine had already released still read lit mid-fade, and
               the restore called itself done before the fade had moved.
               Judged on target now: a run that left the button lit left
               a target standing.

The waiting, the restoring and the reporting of anything a run actually
left are unchanged. What changed is which readings count as dirt.

Host-proven: 87 baseline unit tests, two of them new - a fading button
LED is not a leftover, a lit one is.
2026-09-10 12:51:05 -04:00
ScottW514 4fbf14195b Wait for the purge fan's draw, do not read it in the same second
The airflow check reads the purge fan's off current with the fan off, and
the stand-down that follows commands it back on. The guard that proves
the machine was handed back whole read the draw immediately, so what it
got back was the off current the check had just measured: on the bench
reference, 74 against a 300 floor, with the fan drawing 631 a moment
later. The check failed for having worked.

The current follows the command; it does not arrive with it. The guard
now waits for the draw to reach the floor, up to PURGE_SPINUP_S, the way
the controller below it is already waited for, and logs how long it took.
It is no weaker: a fan that never reaches its floor still fails the
check, and the message now says how long it was given.

Pin: forgectrl 0.1.17 (a9e7073, the air assist and the purge in the
diagnostic run posture - the reason that check measured an idle fan and
wrote a floor from it).
2026-09-10 11:59:47 -04:00
ScottW514 d3fe1d90b9 Hand the machine back, do not describe what is wrong with it
The hand-back is the promise that a run leaves the machine where it found
it. Two of its checks reported instead of restoring, and the machine sat
in the state the run left it in.

The cooling engine: a run that ends without ending its job leaves one
alive behind it - the engine armed and holding for a job that is never
coming back, the fans at run duty. The check waited two minutes for that
to resolve itself, which it cannot, and wrote "failed: still
run/armed=True/hold=True". On the bench reference the fans then ran for
an hour.

The pulse ring: bytes the last job never played sit there, and the next
run replays them before its own. The check refused the return jog and
wrote "clear the ring (controller restart) before moving" - the
instruction, to a log, instead of the action.

Both end the same way: stop the controller and let the supervisor bring
it back. The job goes, the arm and the hold go with it, and the ring is
empty on the way in. stand_down() does that and proves it settled;
the cooling check calls it when the engine will not idle on its own, and
the return jog calls it when the ring has residue, refusing only if bytes
survive a restart. A leftover still reports what it found - the record of
what the run did is the point - but it reports having fixed it.

Host-proven: 62 baseline unit tests, including one that the hand-back
calls the stand-down for ring residue rather than describing it.
2026-09-10 11:34:27 -04:00
ScottW514 594b6990fd Stop a finished run's clock
Run.snapshot() computed elapsed_s from the current time on every call,
whether the run had ended or not. The page shows the last run until the
next one starts, and it polls, so a finished run's figure went on
counting: the result badge said FAIL beside a number still climbing, and
the run read as still going. On the bench reference a test that ended
after 5082 s was showing 5242 s and rising.

The figure a finished run should carry was already recorded next to it:
finished["duration_s"], fixed when the result was written. snapshot()
now returns that once the run has ended, and the live count only while
it is running.

Host-proven: two unit tests - a finished run's clock reads its duration
and does not move across a poll, a running one's still climbs.
2026-09-10 10:43:46 -04:00
ScottW514 57ba3454b4 Clear a setting the only way the daemon accepts, everywhere it is cleared
An empty form field never reaches the request, so POST /settings with an
empty value in the body carries no key at all and the daemon answers 400,
"no known setting in request". Only the query-string form gets an empty
value through. Measured on the bench reference:

    POST /settings -d "lid_policy="   -> 400, the value unchanged
    POST /settings?lid_policy=        -> 200, the key cleared

Three places in the suite already knew this and say so in a comment; two
did not.

motion.lid-policy-hold restored with the form body, so on a machine whose
lid_policy has never been set the restore was refused and the test failed
its own check. It now uses the query-string form for the empty value, as
the others do. This is the same test as the commit before: that one
stopped it from writing the word "cancel" where the machine had nothing,
and this one lets it write the nothing.

cloud.mode-switch had the older shape of the same fault: it restored
homing_mode to the literal "none" where the machine had it unset. On the
bench reference the restore branch never ran, because that machine homes
through the cloud, so it has not fired yet; on a machine with homing_mode
unset it would have. It now restores exactly what it found.

The rest of the suite was audited for this and is clean: camera.key-read,
cooling.critical-tier, cooling.tec-drive, cooling.crash-watch-plumbing,
cooling.fire-watch-tiers, cooling.floor-and-warm-up, cloud.verdict-hold,
forgectrl.settings, logs.levels and motion.xy-microsteps all either guard
the empty value or go through the shared Restore helper, which has always
done it correctly. commission.cloud-header-capture uses that helper too.
2026-09-10 10:26:25 -04:00
ScottW514 d58ee39086 Hand lid_policy back unset where the machine had it unset
motion.lid-policy-hold read the setting as

    was = (fc.settings() or {}).get("lid_policy") or "cancel"

and wrote `was` back at the end. On a machine that has never set the
policy the setting reads as the empty string and behaves as cancel, so
the `or` turned "unset" into the word and the test handed the machine
back carrying a setting it did not arrive with. The hand-back reported
it, restored it, and failed the test.

The value is now captured exactly, empty included, and restored as
captured; the default belongs to reading the value, never to writing it
back. The read-back check gets the same default, so it no longer compares
None with the empty string. lid_policy_in_force carries the effective
policy into the evidence, which is what the old expression was reaching
for.

Found on the bench reference, on the run after the position dead band let
motion.lid-cancel-home through. The four other places in the suite that
read a setting with `or` are safe: two default to the empty string, which
is what unset is, and two feed a check rather than a restore. The shared
Restore helper captures raw values and writes the empty string back for
unset, as this now does.

Only this test's earlier passes are invalidated: the change is inside its
own body, and the per-test source hash of the other eleven motion tests
is unchanged (checked against the file before the edit).
2026-09-10 10:13:16 -04:00
ScottW514 b2c42f0cc3 Do not fail a hand-back on the step the counters round to
The post-run pass compared the kernel position counters exactly, so a
test that put the head back within a hundredth of a millimeter failed
whenever that distance did not round to the same step.

motion.lid-cancel-home found it on the bench reference. The test cancels
a job with the lid twice and the controller returns the head to the job
start each time, landing 0.038 and 0.037 mm out - the same figures as the
run before it, which passed. This time the two returns left four steps on
X, 0.075 mm at 53.333 steps per mm, and the hand-back called it a
leftover. Twenty-three runs of that test, all passing, on returns of the
same accuracy: whether the residue rounds to zero is chance, not a
property of the machine or of the test.

A difference inside POSITION_DEADBAND_MM (0.1 mm, five steps at x8) on X
and Y is now the quantization rather than a leftover. The head is still
put back, so nothing accumulates over a campaign - only the failure goes.
Z stays exact: the return never moves the lens, so a Z difference is
still a leftover and still unrestorable.

Host-proven: four new unit tests on the boundary (four steps and a moved
Z on either side of it, and an unreadable reading), and the forgetest
suite.
2026-09-10 09:53:27 -04:00
ScottW514 1dd6ad608b Press the button when the machine asks, not when its LED is on
The sheet handed the bench actuator every press after the operator's
presence press, and the actuator pressed every time - into nothing. On
the bench reference all six cards were pressed before the card asked:
the frame by 8 s, the focus by 33, the floor by 10, the dose curve by 8,
the corner by 9, and the flow-load card by 69. The operator then pressed
all six himself, which is the opposite of what the ready gate promises.

arm_press() waits for hw.button_lit(), which is true when any button LED
is on, and burn() started that wait before run_check had even started the
wizard. The button is lit through parts of a card that are not the arm -
the lens reference, the program on its way to the controller - so the
wait ended at once, the press landed before the job waited for it, and
the thread was gone by the time the real cue came. forgectrl uses the
same predicate but only inside the job's own sample callback, with the
tube still dark, where a lit button does mean the arm.

The machine already says when it wants the press: a live check opens a
`press` wait prompt at that moment, and run_check sees every prompt. It
now presses there, through a new Ctx.press_now() - no LED read, no
waiting thread, no timing guess. That retires the per-card lit-timeout
column of CARDS, which existed only to give the flow-load card's coolant
settle enough room for a wait that was reading the wrong thing.

The four grbl-driven arm_press() callers in laser.py and cooling.py are
left as they are: they call it with the machine idle and its LEDs dark,
so the level read is the edge they mean. The same shape would bite them
if that ever stopped being true.

Pin: forgectrl 0.1.15 (6e69479, the flow-load tail ending at the coolant
peak and the way out of a finished setup page).

Host-proven: 357 forgetest unit tests, including two new ones - the
actuator presses on the prompt with button_lit stubbed false throughout,
so the LED is provably not consulted, and the press falls to the operator
without a takeover.
2026-09-10 08:41:53 -04:00
ScottW514 f0c40e7d4f Name every machine after its own MAC address, and drop mDNS
One name for every machine was wrong: an operator with two of them on a
network had one forgefirm.local, and mDNS does not work on many networks
at all. The machine now calls itself forgefirm-<xxxx>, from the last four
hex digits of its WiFi MAC address, and sends that name with its DHCP
request, so a network with dynamic DNS publishes it and a router lists
the machine by name. The name is the same at every boot, two machines
take different names, and no serial number leaves the machine.

forgefirm-hostname (new): reads the wlan0 MAC address (eth0 on a machine
with no WiFi) at S38 in rcS, after udev has probed the network drivers
and before poky's hostname.sh reads the file and before the network
starts. The rootfs is read-only, so the name is written through a
bind-mounted copy under /run/forgefirm. A bounded wait covers a slow
probe. hostname:pn-base-files is "forgefirm": the name before S38, and
the fallback when no MAC address can be read.

avahi is deleted - the bbappend, the daemon configuration, the service
file, the image install and the distro block. The address is the way in
that works on every network, and the DHCP name covers the rest.

forgefirm-banner: the marker lines are gone. "# ForgeFIRM addresses" and
"# end" delimited the address block inside /etc/issue, and getty prints
every line of that file, so both markers were on the console. The script
now keeps the image's own text in a second copy under /run/forgefirm,
captured once per boot before the first write, and renders the whole
banner from it. The block is the addresses alone: no mDNS name.

forgefirm-image.bb: the ForgeFIRM mark, under the OpenGlow one the base
image carries, with the version on the mark's own last line,
right-justified to the mark's last column. The mark is written once and
rendered per reader, because /etc/issue is parsed by busybox getty (a
backslash or a percent sign starts an escape, so the art goes in with
every backslash doubled) while /etc/motd is written out as it is. Widths
are measured in columns, not bytes: the color sequences take no room on
the screen. /etc/issue.net stays unused - the machine tells a client that
has not logged in nothing.

Acceptance: commission.mdns-announce is replaced by
commission.machine-name, which checks the name against the MAC address,
the bind-mounted /etc/hostname, the DHCP client's hostname option, the
banner's addresses, and that no mDNS responder is on the image; it covers
nothing by design, like the test it replaces. forgectrl.auth gains the
own-name Host check and its refusal with a domain on it. image.health
checks the /etc/hostname mount and the version on the mark's last line in
both files. commission.ssh-until-reboot asserts there is no
pre-authentication banner. commission_dark's lens coverage widens to
src/lenshome.* so src/lenshome.h is covered; the lint is clean at 83
tests.

Pins: forgectrl 0.1.14 (9e5330f, the hostname certificate and the Host
rule), meta-openglow ced2af2 (the DHCP hostname option and the motd mark)
in the kas lock.

Proven on the bench reference, hot-deployed and rebooted (image
20260910000208 dev): hostname forgefirm-b00a from MAC 2c:6b:7d:0d:b0:0a,
live and in the bind-mounted file; the DHCP client running with
-x hostname:forgefirm-b00a; the console banner and the motd carrying both
marks with the version aligned to the mark's last column, no marker line
and no .local name; forgectrl regenerating its certificate for the new
name. Host tests: 357 forgetest unit tests, forgectrl clean under
-Werror, tls_test and sanitize_test.
2026-09-10 07:18:27 -04:00
ScottW514 d858a23f45 Carry the panel stylesheet into forgetest
The acceptance page shares theme.css with the panel byte for byte, and forgectrl's copy gained the setup header's Download logs button. CI caught the drift, which is what that check is for; the pinned revision decides, so the copy follows.
2026-09-09 19:53:09 -04:00
ScottW514 c7b80ab2e8 A test that does not hand the machine back fails
The baseline has always examined the machine after every run and recorded
what the run left behind. It did nothing else with it: the leftovers went
to the log and the evidence, and the test still reported PASS. So a check
could measure correctly, walk away with the machine in a state nobody
chose, and be recorded green.

That is how the purge fan came to be left off by the airflow check. The
leftover was not even watched, but had it been, it would have been noted
and the test would have passed anyway, and an operator would still have
met the airflow hold at their first fire.

A post-run leftover now fails the run. One the baseline put back fails it
too: the restore is the bench cleaning up after a defect, not the defect's
absence. The message names what was left.

The baseline watches the head as well as the motion side now: purge air
on, which is how the machine idles, and the lens motor at its hold current
in half step, which the lens checks and the sheet cards take and must hand
back. The airflow check proves the machine is whole rather than merely
measured: afterward the purge fan must read commanded-on and must draw
above the floor the check itself just wrote.

The pin takes forgectrl 0.1.13 (a2d73ef), which restores the idle posture
after a diagnostic, fixes the lens session's takeover flag, and gives the
setup a Download logs button, since the panel's Logs tab is unreachable
until the setup is complete.

Expect this to find things. A test that has been handing the machine back
imperfectly has been passing until now, and the first campaign under the
rule is where that shows.
2026-09-09 19:44:24 -04:00
ScottW514 468e92655a Teach the manifest guard test about the version file
The guard keeps scripts/manifest-from-tree.py and forgefirm-image-manifest.bbclass computing the same identity, so it pins the skip list by value and the new suffix failed it. It now expects the version file in both places, and a second case proves the point the change exists for: setting the release number leaves the layer hash where it was. My miss: I proved the identity by hand and pushed without running the unit suite first.
2026-09-09 18:28:47 -04:00
ScottW514 d898b5659d Give the release version its own file, outside the layer content hash
Setting the release number was a platform change. FORGEFIRM_RELEASE sat
in forgefirm-image.bb, the recipe hashes as content of meta-forgefirm,
and a change to the content of a layer invalidates every acceptance
result. So a version bump threw away the campaign that was meant to
authorize that very release, and the number therefore had to be decided
before the image the campaign ran on. Nothing said so: the release-flow
page went straight from the kas configuration to the artifact and the
pipeline, while the gate quietly required the recipe value, the rootfs
stamp, the archive's meta-version and the tag to agree. v0.0.1 was cut
on a tree whose number happened to be right; the next one would have
cost a second campaign to discover the rule.

The number moves to forgefirm-release.inc, which carries it and nothing
else, and the manifest leaves that file out of the layer content hash
exactly as it leaves out the component pin files
(FORGEFIRM_MANIFEST_VERSION_SUFFIX, and the same list in
scripts/manifest-from-tree.py, which computes the identity on a
workstation and must agree byte for byte). release.sh reads the number
from the new file.

The version is metadata, not platform content, and this only makes the
manifest say what it already meant: the version string was already
outside the identity hash, and it was the file carrying it that defeated
that. Nothing is weakened. release.sh still requires the number to equal
the rootfs stamp, the .fw meta-version and the release tag, and
image.health still compares the stamp on the running machine with the
manifest's.

Proven: the tree manifest is byte-identical across a bump from 0.0.1 to
0.0.2 (identity a64e51b8e5ecca0af683d4f0 either way, the meta-forgefirm
layer hash unchanged), where before the two differed. bitbake resolves
FORGEFIRM_RELEASE=0.0.1 and FORGEFIRM_VERSION_STRING=v0.0.1 for the
release image through the new require, and the dev image still overrides
the string with its build timestamp.
2026-09-09 18:12:03 -04:00
276 changed files with 190579 additions and 9752 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:
push:
branches: [master]
paths:
- 'fixture/**'
- '.github/workflows/fixture-ci.yml'
+81 -3
View File
@@ -11,15 +11,24 @@
# release with source missing
# - shared UI files: theme.css and the vendored Bootstrap the page
# carries are byte-identical to forgectrl's at its pinned revision
# - the extension key: the public key the image installs for the official
# tier is byte-identical to the one the extension catalog publishes
# (keys/forgefirm-ext.pub on its main branch)
# - the catalog's address: EXTPKG_INDEX_URL in the pinned forgectrl's
# src/extpkg.h is the catalog's latest-release download of the file its
# tools/publish.py passes to gh release create; read, never requested
# (every request of it is counted as a download)
# - coverage lint: every source path of every component pinned by the
# recipes must be selected by some catalog test's coverage globs (the
# tree manifest is generated from the pins with git - no Yocto build);
# enforced: an uncovered path fails the job (https://docs.forgefirm.org/developers/acceptance/).
# recipes must be selected by some catalog test's coverage globs or by a
# host-tested row naming the host test that proves it (the tree manifest
# is generated from the pins with git - no Yocto build); enforced: an
# uncovered path fails the job (https://docs.forgefirm.org/developers/testing/).
name: forgetest-ci
on:
push:
branches: [master]
paths:
- 'forgetest/**'
- 'scripts/acceptance-gate.py'
@@ -48,6 +57,13 @@ jobs:
ref: scarthgap
path: meta-openglow
- name: Checkout forgefirm-extensions-catalog (sibling)
uses: actions/checkout@v4
with:
repository: openglow-org/forgefirm-extensions-catalog
ref: main
path: forgefirm-extensions-catalog
- name: Set up Python
uses: actions/setup-python@v5
with:
@@ -57,6 +73,68 @@ jobs:
working-directory: forgefirm
run: python scripts/manifest-from-tree.py --out tree-manifest.json
- name: The image's extension key is the catalog's published key
run: |
image=forgefirm/meta-forgefirm/recipes-forgefirm/forgefirm-keys/files/ext/forgefirm-ext.pub
published=forgefirm-extensions-catalog/keys/forgefirm-ext.pub
for f in "$image" "$published"; do
test -f "$f" || { echo "missing: $f"; exit 1; }
done
cmp -s "$image" "$published" || { echo "$image is not $published"; exit 1; }
- name: The catalog's address in the pinned forgectrl is the file the catalog publishes (read, never requested)
run: |
python3 - <<'EOF'
import ast, glob, os, re, subprocess, sys
PREFIX = "https://github.com/openglow-org/forgefirm-extensions-catalog/releases/latest/download/"
def fail(why):
print(why)
sys.exit(1)
# forgectrl's pin, and its src/extpkg.h from the tree manifest's cache
pin = open("forgefirm/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl-pin.inc").read()
m = re.search(r'^SRCREV = "([0-9a-f]{40})"$', pin, re.M)
if not m:
fail("forgectrl-pin.inc names no SRCREV")
rev = m.group(1)
header = None
for repo in sorted(glob.glob("forgefirm/.manifest-cache/*")):
p = subprocess.run(["git", "-C", repo, "show", rev + ":src/extpkg.h"], capture_output=True, text=True)
if p.returncode == 0:
header = p.stdout
break
if header is None:
fail("forgectrl %s is not in forgefirm/.manifest-cache" % rev)
m = re.search(r'#define\s+EXTPKG_INDEX_URL\s*(?:\\\n\s*)?"([^"]*)"', header)
if not m:
fail("forgectrl %s's src/extpkg.h defines no EXTPKG_INDEX_URL" % rev[:7])
url = m.group(1)
# the file the catalog's publish tool passes to gh release create
tree = ast.parse(open("forgefirm-extensions-catalog/tools/publish.py").read())
assigned = {n.targets[0].id: n.value for n in ast.walk(tree)
if isinstance(n, ast.Assign) and len(n.targets) == 1 and isinstance(n.targets[0], ast.Name)}
def file_name(v):
if isinstance(v, ast.Name):
v = assigned.get(v.id)
if isinstance(v, ast.Call) and v.args:
v = v.args[-1]
return os.path.basename(v.value) if isinstance(v, ast.Constant) and isinstance(v.value, str) else None
names = [file_name(n.args[3]) if len(n.args) > 3 else None for n in ast.walk(tree)
if isinstance(n, ast.Call) and isinstance(n.func, ast.Name) and n.func.id == "gh"
and [a.value for a in n.args[:2] if isinstance(a, ast.Constant)] == ["release", "create"]]
if len(names) != 1 or not names[0]:
fail("tools/publish.py passes no one file to gh release create: %s" % names)
print("forgectrl %s: %s; the catalog publishes %s" % (rev[:7], url, names[0]))
if url != PREFIX + names[0]:
fail("the address is not %s" % (PREFIX + names[0]))
EOF
- name: Unit tests
working-directory: forgefirm/forgetest
# -B: no bytecode caches under the package directory (the recipe's fetch
+39
View File
@@ -0,0 +1,39 @@
# The extension sandbox's deny rules, proven on real traffic.
#
# scripts/sandbox-rules-test.py loads meta-forgefirm's ffx.nft into a network
# namespace of its own, joins a second namespace to it with a veth pair (a
# peer that is not the machine), and sends from real uids: a pool uid is
# refused on loopback, at its own LAN address, and at the peer (TCP at once,
# UDP with EPERM), the uids on either side of the pool and root are not
# touched, an allowlist chain opens one port of the peer to one uid, the
# machine's own addresses stay shut even on an allowlist, and loading the
# file again takes the allowlist away. The
# runner's nft is the version the image carries or newer; the kernel side is
# the image's own business and is proven on the machine (exthost.platform).
name: sandbox-ci
on:
push:
branches: [master]
paths:
- 'meta-forgefirm/recipes-forgefirm/forgefirm-sandbox/**'
- 'scripts/sandbox-rules-test.py'
- '.github/workflows/sandbox-ci.yml'
pull_request:
workflow_dispatch:
jobs:
rules:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: nft
run: sudo apt-get update && sudo apt-get install -y nftables
- name: The deny rules on real traffic
run: |
# 77 is the script's "could not run here": on this runner that is a failure.
sudo python3 -B scripts/sandbox-rules-test.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.
- 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.
- 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
@@ -186,6 +188,9 @@ does.
test with the fix, in the same commit, never after.
- 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.
- 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
@@ -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
`Written by Scott Wiederhold`. Keep the year, or the year range, that
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
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:
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
or defaults to GPL version 2.
+3 -2
View File
@@ -3,7 +3,7 @@
> ### BETA
>
> **ForgeFIRM is in beta.** Every release below 0.1.0 is a beta release.
> Expect problems, and expect frequent updates. Upgrade whenever a newer
> It's stable, but you may still hit the occasional bug, and expect frequent updates. Upgrade whenever a newer
> release is available, and report what you find on the
> [community forum](https://community.openglow.org).
@@ -83,4 +83,5 @@ before you install.
**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.**
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
# 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.
#
# ./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 /. */
#pragma once
+4
View File
@@ -1,4 +1,8 @@
#!/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.
set -e
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.
@@ -0,0 +1 @@
{"seq":268,"ts_mono":1268.000,"fire_ok":true,"verdict":"OK","hold":false,"resume_ok":true,"armed":true,"reason":"","down_c":22.30,"up_c":22.30}
@@ -0,0 +1 @@
0
@@ -0,0 +1 @@
0
@@ -0,0 +1 @@
255
@@ -0,0 +1 @@
idle
@@ -0,0 +1 @@
1023
@@ -0,0 +1 @@
300
@@ -0,0 +1 @@
629
@@ -0,0 +1 @@
0
@@ -0,0 +1 @@
700
@@ -0,0 +1 @@
700
@@ -0,0 +1 @@
65535
@@ -0,0 +1 @@
26214
@@ -0,0 +1 @@
43278
@@ -0,0 +1 @@
300
@@ -0,0 +1 @@
300
@@ -0,0 +1 @@
300
@@ -0,0 +1 @@
{"seq":1517,"ts_mono":3127.000,"fire_ok":true,"verdict":"OK","hold":false,"resume_ok":true,"armed":false,"reason":"HEAD CRASH SIGNAL (axes x) - motion stopped, laser locked","down_c":22.30,"up_c":22.30}
+1
View File
@@ -0,0 +1 @@
0
@@ -0,0 +1 @@
1
@@ -0,0 +1 @@
0
@@ -0,0 +1 @@
0
+1
View File
@@ -0,0 +1 @@
idle
+1
View File
@@ -0,0 +1 @@
1
@@ -0,0 +1 @@
204
@@ -0,0 +1 @@
300
@@ -0,0 +1 @@
629
@@ -0,0 +1 @@
0
@@ -0,0 +1 @@
100
@@ -0,0 +1 @@
100
@@ -0,0 +1 @@
100
@@ -0,0 +1 @@
100
@@ -0,0 +1 @@
700
@@ -0,0 +1 @@
700
@@ -0,0 +1 @@
0
@@ -0,0 +1 @@
0
@@ -0,0 +1 @@
0
@@ -0,0 +1 @@
300
@@ -0,0 +1 @@
300
@@ -0,0 +1 @@
300
+4 -7
View File
@@ -1,4 +1,8 @@
#!/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
# Provides: forgetest
# Required-Start: $network forgectrl
@@ -22,13 +26,6 @@ case "$1" in
start)
echo "Starting forgetest"
mkdir -p "$DATA"
# The bench seed: the commissioning override lifts the wizard part of
# the controller gate until the next reboot (/run is tmpfs), so a
# campaign runs on a machine whose required wizards are not complete.
# Nothing lifts the agreements or the account: the bench accepts them
# once, and that stays in /data.
mkdir -p /run/forgefirm
: > /run/forgefirm/commissioning-override
start-stop-daemon -S -q -p $PIDFILE -m -b -x /bin/sh -- -c \
"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.
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.
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.
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 winning result record (same-campaign PASS or inherited PASS, with its
origin) and the fingerprint it was recorded under. The gate
@@ -15,6 +25,7 @@ from . import VERSION
from . import campaign as _campaign
from . import manifest as _manifest
from .log import now_ts
from .redact import scrub
FORMAT = 1
_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"],
"satisfied": st["satisfied"],
"inherited": st["status"] == "inherited",
"record": _record(rec) if rec else None,
"record": scrub(_record(rec)) if rec else None,
})
tests_out.append(entry)
+328 -33
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 runner checks the machine against this baseline before a run and
@@ -14,7 +19,9 @@ restores it after (on every exit path), so a test cannot hand the next one
restored where the interface allows.
The lid lamp is fixed too, at forgectrl's `lid_lamp_idle` setting (unset =
236): the daemon asserts it at start and at every controller spawn.
236): the daemon asserts it at start and at every controller spawn. The
lamp fades toward a level written to it, so a level written a moment
before the check is read again until it holds before it is judged.
The controller mode in force decides what the baseline owns. In GRBL mode
everything above applies. In cloud mode the cloud client owns what it
@@ -36,6 +43,7 @@ that made it, not a reason to hand the dirt on.
import json
import os
import struct
import subprocess
import time
from . import hw
@@ -48,7 +56,7 @@ from .log import now_ts
# 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
# 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
# loop heater and TEC are the diagnostics' tools, off at idle.
FIXED_SYSFS = [
@@ -65,6 +73,21 @@ FIXED_SYSFS = [
("head/white_led", "0"),
("thermal/heater_pwm", "0"),
("thermal/tec_on", "0"),
# Purge air runs continuously, as on the factory machine: the engine
# turns it on at start and only a listening (the quiet hold) switches
# it off, which puts it back on release. A check that switches it off
# to measure it and forgets leaves the machine one job away from an
# airflow hold mid-cut, judged against the floor that same check just
# wrote, and nothing notices until the daemon restarts: the engine's
# idle phase never re-applies its own duties. The airflow check did
# exactly that, and it reached an operator's first fire.
("head/purge_air", "1"),
# The lens motor at rest: hold current, half step. The lens checks and
# the sheet cards take it to the run current in full or half step and
# must hand it back, or the motor sits hot and the next reference
# starts from a state nobody chose.
("head/z_current", "1"),
("head/z_mode", "1"),
]
# The subset the GRBL controller writes at its start: checked and restored
@@ -72,9 +95,14 @@ FIXED_SYSFS = [
# every pulse header (step_freq 10 kHz, the run currents), runs at the
# service's own x8 with the module's ramp whatever xy_microsteps says, and
# hands the hold currents back at idle; forcing the GRBL values under it
# would be the baseline configuring another controller's machine.
# would be the baseline configuring another controller's machine. The Z
# pair belongs with them: the cloud client takes the lens microstep mode
# from the pulse header it is playing (ZSmd, gfhardware z_axis
# set_mode_from_puls) and the current with it, so a job whose header asks
# for full step leaves z_mode 0 behind by doing what it was told.
GRBL_CONTROLLER_SYSFS = ("cnc/motor_lock", "cnc/x_mode", "cnc/y_mode", "cnc/x_decay", "cnc/y_decay",
"cnc/step_freq", "cnc/ramp_rate", "pic/x_step_current", "pic/y_step_current")
"cnc/step_freq", "cnc/ramp_rate", "pic/x_step_current", "pic/y_step_current",
"head/z_current", "head/z_mode")
# Settings the baseline never hands back as bare settings: controller_mode
# is the persisted mirror of the live mode (the mode item restores it
@@ -91,6 +119,12 @@ UNPRESERVED_SETTINGS = ("controller_mode",)
# service starts its own client with the hunt.
NOHUNT_MARKER = os.environ.get("FORGETEST_NOHUNT_MARKER", "/run/gfcloud-nohunt")
# The offline service: gfcloud's one-start offline marker and the socket
# the service listens on while it runs. A takeover that finds it running
# makes forgectrl's start at its end under the marker again (runner.Takeover).
OFFLINE_MARKER = os.environ.get("FORGETEST_OFFLINE_MARKER", "/run/gfcloud-offline")
OFFLINE_SOCKET = os.environ.get("FORGETEST_OFFLINE_SOCKET", "/run/gfcloud-offline.sock")
# Read-only readbacks with their idle values (no direct restore: the state
# comes right through forgectrl - see restore_forgectrl - or is fatal).
IDLE_READBACKS = [
@@ -108,23 +142,41 @@ PRESERVED_SYSFS = [] # sysfs attrs captured before, restored afte
LID_LAMP_ATTR = "pic/lid_led"
LID_LAMP_DEFAULT = "236" # forgectrl's lid_lamp_idle default
# The lid LED runs on the kernel's "smooth" trigger: a level written to it
# is reached by a fade over a fraction of a second, so a read right after
# a write (the supervisor re-applies the idle level at every controller
# spawn) finds the lamp part-way there: on the bench reference, 232 read
# 0.05 s after the supervisor re-applies 236. The lamp is read again every
# LID_LAMP_POLL_S until it holds the level, for at most LID_LAMP_SETTLE_S,
# before it is called a leftover.
LID_LAMP_SETTLE_S = 3.0
LID_LAMP_POLL_S = 0.1
BOOT_MAX_AGE_S = 600 # a boot reference is taken only this soon after boot
SETTLE_S = 150 # the supervisor's probe + rail-off ladder
CAM_IDLE_S = 20 # camera engine idle stop is 10 s
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
GRBL_PORT_S = 30 # the Grbl port after the supervisor reports grblHAL running
XY_STEPS_PER_MM = 53.333 # boards/glowforge.h (x8 microstepping)
RETURN_MAX_MM = 100.0 # a displaced head is jogged back at most this far
# The counters count steps; the controller's own returns land within a
# tenth of a millimeter of where they started, not on the step. A
# difference under this is that quantization, not a leftover: the head is
# still put back, so nothing accumulates over a campaign, but the test
# that made it does not fail for it. X and Y only - Z is the lens, which
# the return never moves, so any Z difference is still a leftover.
POSITION_DEADBAND_MM = 0.1
# 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):
# the fixed values of x/y_mode, step_freq and ramp_rate are the mode's.
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_TICK_X8_HZ = 28160 # the x8 machine tick
XY_RAMP_X8_HZ_PER_S = 125000 # the kernel stop ramp at it
@@ -148,7 +200,7 @@ def fixed_sysfs(mode=XY_MODE_DEFAULT):
"""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
quadruples them)."""
k = mode // XY_MODE_DEFAULT
k = mode // XY_MODE_BASE
own = {"cnc/x_mode": str(mode), "cnc/y_mode": str(mode),
"cnc/step_freq": str(XY_TICK_X8_HZ * k),
"cnc/ramp_rate": str(XY_RAMP_X8_HZ_PER_S * k)}
@@ -160,11 +212,18 @@ def leds_root():
return r if r.endswith("/") else r + "/"
def read_led(name):
def read_led(name, attr="target"):
"""A button LED's commanded level. The trigger fades brightness toward
target, so brightness is where the fade has reached and target is what
the machine was told: a run that left the button lit left a target, and
a run that ended a moment ago may still be fading from one. Falls back
to brightness where no target exists."""
for a in (attr, "brightness"):
try:
with open(leds_root() + name + "/brightness") as f:
with open(leds_root() + name + "/" + a) as f:
return f.read().strip()
except OSError:
continue
return None
@@ -173,6 +232,23 @@ def write_led(name, value):
f.write(str(value))
# The GRBL controller zeroes the kernel's step counters at every start (the
# lens reference) and at every home, and rewrites its home anchor each time.
# The anchor's identity is therefore the counters' frame: if it changed
# during a run, the counters at the end are not in the frame of the counters
# at the start, and their difference is not a distance the head traveled.
ANCHOR_PATH = os.environ.get("FORGETEST_ANCHOR", "/run/grblhal.homed")
def counter_frame():
"""What identifies the frame the step counters are in, or None."""
try:
st = os.stat(ANCHOR_PATH)
return [st.st_ino, st.st_mtime_ns]
except OSError:
return None
def read_position():
"""(x, y, z) step counters, or None when unreadable."""
try:
@@ -183,6 +259,27 @@ def read_position():
return None
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
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
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
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]:
return False
return (abs(now[0] - was[0]) / spm <= POSITION_DEADBAND_MM and
abs(now[1] - was[1]) / spm <= POSITION_DEADBAND_MM)
def read_ring_residue():
"""Unplayed bytes queued in the kernel pulse ring: total written minus
processed (the cnc/position byte counters), or None when unreadable.
@@ -210,6 +307,11 @@ def read_program_total():
return None
# The extension host stops a removed package's service on its next
# one-second turn; its group is killed and waited for inside that turn.
EXT_STOP_S = 8
class Leftover:
def __init__(self, item, found, expected, action):
self.item = item
@@ -274,7 +376,7 @@ class Baseline:
def wait_settled(self, timeout=SETTLE_S, unreachable_s=10):
"""Block until forgectrl reports a settled supervisor: motion
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
unreachable_s without an answer. Returns the last /mode body (None
if unreachable)."""
@@ -383,7 +485,8 @@ class Baseline:
# -- capture -------------------------------------------------------
def capture(self):
"""Record the preserved state before a run."""
cap = {"sysfs": {}, "position": read_position(), "settings": None, "mode": None}
cap = {"sysfs": {}, "position": read_position(), "frame": counter_frame(),
"settings": None, "mode": None}
for attr in PRESERVED_SYSFS:
cap["sysfs"][attr] = hw.sysfs_read(attr)
st, body = self.fc_get("/settings")
@@ -403,9 +506,13 @@ class Baseline:
preserved state to hand back (post) - None compares nothing."""
left = []
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._kernel_side(left)
self._lamp_side(left)
self._ext_side(left)
self._preserved(left, captured)
if left:
self.log("%s: %d leftover(s): %s" % (phase, len(left), "; ".join(str(x) for x in left)))
@@ -421,6 +528,37 @@ class Baseline:
time.sleep(1.0)
return None
def stand_down(self, why):
"""End whatever the machine is still doing, and prove it ended.
A run that dies mid-job leaves the job alive behind it: the
cooling engine armed and holding for a job nothing will finish,
the fans at run duty, and the rest of the program still queued in
the pulse ring. Waiting does not end any of that - the engine is
holding correctly, for a job that is never coming back.
Stopping the controller ends it. The supervisor brings the
controller back, the arm and the hold go with the job, and the
ring is empty on the way in. This is what the ring-residue
message has always told an operator to do; the hand-back does it
instead of saying it.
Returns the /mode body the machine settled on, or None."""
self.log("baseline: standing the machine down (%s): stopping the controller" % why)
st, _b = self.fc_post("/controller/stop")
if st != 200:
self.log("baseline: stand-down: /controller/stop -> %s" % st)
deadline = time.time() + 30
while time.time() < deadline and not self.abort():
body = self.fc_get("/mode")[1] or {}
if body.get("controller") != "running":
break
time.sleep(1.0)
st, _b = self.fc_post("/controller/start")
if st != 200:
self.log("baseline: stand-down: /controller/start -> %s" % st)
return self.wait_settled()
def cloud_mode(self):
return self.mode == "cloud"
@@ -479,7 +617,14 @@ class Baseline:
before = (mode.get("controller"), mode.get("motion"))
mode = self.wait_settled() or mode
if mode.get("controller") == "running" and mode.get("motion") == "verified":
left.append(Leftover("controller", "%s/%s" % before, "running/verified", "waited"))
# The supervisor's own work: a takeover ends by starting
# forgectrl again, and the respawn runs the liveness probe
# and the lens reference before the controller is up and
# motion is verified. Waiting for that is right; calling
# it a leftover is not, because the run did put it back -
# the machine was still doing what it was asked.
self.log("controller: %s/%s settled to running/verified on its own; "
"the supervisor's own start, not a leftover" % before)
else:
left.append(Leftover("controller", "%s/%s" % before, "running/verified",
"failed: %s/%s" % (mode.get("controller"), mode.get("motion"))))
@@ -487,12 +632,21 @@ class Baseline:
st, s = self.fc_get("/status")
if st == 200 and isinstance(s, dict):
if s.get("state") != "idle":
if s.get("state") == "underrun":
# An underrun is the run's: the engine ran out of bytes and
# the fault is acknowledged here. Any other state is the
# machine finishing what it was given - the ring draining
# to the end of a job - and it reaches idle on its own.
underrun = s.get("state") == "underrun"
if underrun:
try:
hw.sysfs_write("cnc/stop", "1") # ack
except OSError:
pass
w = self._wait("idle", lambda: (self.fc_get("/status")[1] or {}).get("state") == "idle", IDLE_S)
if w is not None and not underrun:
self.log("state: %s reached idle on its own after %.0f s; the machine "
"finishing, not a leftover" % (s.get("state"), w))
else:
left.append(Leftover("state", s.get("state"), "idle",
"waited" if w is not None else "failed: not idle"))
if s.get("laser_locked") is False:
@@ -502,16 +656,91 @@ class Baseline:
except OSError as e:
act = "failed: %s" % e
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")
if st == 200 and isinstance(c, dict):
if c.get("phase") != "idle" or c.get("armed") or c.get("hold"):
if st != 200 or not isinstance(c, dict):
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"))
w = self._wait("cool idle", lambda: (lambda x: x.get("phase") == "idle" and not x.get("armed")
and not x.get("hold"))(self.fc_get("/cool/status")[1] or {}),
COOL_IDLE_S)
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 {})
# 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
# not: the engine clears smoke at run duty after an armed
# session and cools down after a hot one, both timed and
# both ending on their own. Waiting for that is right;
# calling it a leftover is not, because nothing was left -
# the machine was still finishing.
dirt = bool(c.get("armed") or c.get("hold"))
w = self._wait("cool idle", idle, COOL_IDLE_S)
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 "
"work, not a leftover" % (found, w))
return
# The engine holds for a job. A run that ended without
# ending its job leaves one alive, and no amount of
# waiting ends it: stand the machine down and let the
# supervisor bring the controller back clean.
act = "waited"
if w is None:
self.stand_down("the cooling engine is still %s" % found)
w = self._wait("cool idle", idle, COOL_IDLE_S)
act = "restored (stood the machine down)"
left.append(Leftover("cool", found, "idle/unarmed/no hold",
"waited" if w is not None else "failed: still %s" % found))
act if w is not None else "failed: still %s" % found))
def _ext_side(self, left):
"""A campaign's machine is extension-free. What a test made and
left behind (a package under the tests' own prefix, the owner key
it was signed with) is removed, and the host stops its service.
The operator's own packages are theirs: running ones are reported
and not touched."""
for id_ in [p for p in hw.ext_packages() if p.startswith(hw.EXT_TEST_PREFIX)]:
try:
p = subprocess.run([hw.FORGEEXT, "remove", id_], capture_output=True, text=True, timeout=120)
ok = p.returncode == 0 and id_ not in hw.ext_packages()
act = "restored" if ok else "failed: %s" % (p.stdout.strip()[-120:] or p.returncode)
except (OSError, subprocess.SubprocessError) as e:
act = "failed: %s" % e
left.append(Leftover("ext.package", id_, "not installed", act))
keys = hw.EXT_ROOT + "/keys"
for name in sorted(os.listdir(keys)) if os.path.isdir(keys) else []:
if not name.startswith("forgetest-"):
continue
try:
os.remove(os.path.join(keys, name))
act = "restored"
except OSError as e:
act = "failed: %s" % e
left.append(Leftover("ext.owner_key", name, "absent", act))
# the host stops a removed package's service on its next turn
running = self._wait("no test package running", lambda: not hw.pool_pids() or None, EXT_STOP_S) is None \
and hw.pool_pids()
if running:
left.append(Leftover("ext.running", "%d process(es) under pool accounts, packages %s"
% (len(running), hw.ext_packages()), "an extension-free machine", "unrestorable"))
def _lamp_side(self, left):
"""The lid lamp at forgectrl's idle level (the lid_lamp_idle setting).
@@ -525,13 +754,34 @@ class Baseline:
got = hw.sysfs_read(LID_LAMP_ATTR)
if got is None or got == want:
return
held, now = self._lamp_holds(want)
if held:
self.log("lid lamp: read %s, then held %s; the fade toward a level just written, "
"not a leftover" % (got, want))
return
found = now if now is not None else got
try:
hw.sysfs_write(LID_LAMP_ATTR, want)
back = hw.sysfs_read(LID_LAMP_ATTR)
act = "restored" if back == want else "failed: reads %s" % back
held, back = self._lamp_holds(want)
act = "restored" if held else "failed: reads %s" % back
except OSError as e:
act = "failed: %s" % e
left.append(Leftover(LID_LAMP_ATTR, got, want, act))
left.append(Leftover(LID_LAMP_ATTR, found, want, act))
def _lamp_holds(self, want):
"""Read the lid lamp every LID_LAMP_POLL_S, for at most
LID_LAMP_SETTLE_S, until it holds `want`: two reads in a row at
it, so a fade that only passes through the level does not count.
Returns (held, the last read)."""
last = prev = None
for _ in range(max(2, int(round(LID_LAMP_SETTLE_S / LID_LAMP_POLL_S)))):
if self.abort():
break
time.sleep(LID_LAMP_POLL_S)
prev, last = last, hw.sysfs_read(LID_LAMP_ATTR)
if last == want and prev == want:
return True, last
return False, last
def _kernel_side(self, left):
if hw.sysfs_read("cnc/state") is None:
@@ -575,14 +825,18 @@ class Baseline:
except OSError as e:
act = "failed: %s" % e
left.append(Leftover("leds/" + name, got, "0", act))
elif (read_led(name, "brightness") or "0") != "0":
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")))
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
delta (Z is never touched), through the GRBL controller. Bounded:
beyond RETURN_MAX_MM per axis, or without a running GRBL
controller, the counters are reported and left."""
dx = (now[0] - was[0]) / XY_STEPS_PER_MM
dy = (now[1] - was[1]) / XY_STEPS_PER_MM
delta (Z is never touched), through the GRBL controller. spm is
the counters' scale (the mode's). Bounded: beyond RETURN_MAX_MM
per axis, or without a running GRBL controller, the counters are
reported and left."""
dx = (now[0] - was[0]) / spm
dy = (now[1] - was[1]) / spm
if abs(dx) < 0.02 and abs(dy) < 0.02:
return "unrestorable (Z only)" if now[2] != was[2] else "restored"
if abs(dx) > RETURN_MAX_MM or abs(dy) > RETURN_MAX_MM:
@@ -590,10 +844,18 @@ class Baseline:
# Never jog on top of stale bytes: a run started now would replay
# whatever the ring still holds before the jog, in a direction and
# for a distance nobody asked for. Report and leave the head.
# Stale bytes in the ring are the last job's, and a jog now would
# replay them before its own. The ring empties when the
# controller restarts, so the hand-back restarts it rather than
# leaving the head where it is and the bytes where they are.
residue = read_ring_residue()
if residue:
return ("unrestorable: %d unplayed bytes queued in the kernel ring - a jog would "
"replay them; clear the ring (controller restart) before moving" % residue)
self.log("baseline: %d unplayed bytes in the kernel ring before the return" % residue)
self.stand_down("%d unplayed bytes in the kernel ring" % residue)
residue = read_ring_residue()
if residue:
return ("unrestorable: %d unplayed bytes still queued in the kernel ring after a "
"controller restart - a jog would replay them" % residue)
# a controller may be inside a respawn backoff (seconds): wait for it
mode = None
deadline = time.time() + 30
@@ -661,8 +923,41 @@ class Baseline:
# action re-zeroes them at its start, so they preserve nothing.
was = captured.get("position")
now = read_position()
if was is not None and now is not None and now != was and not self.cloud_mode():
left.append(Leftover("position", now, was, self._return_head(was, now)))
# A controller start or a home during the run re-zeroed the counters
# wherever the head then stood. Unless the test said where that was
# (ctx.counters_rezeroed), the two readings share no frame: on the
# bench reference a head that had not moved was "returned" 30 mm into
# the gantry stops this way, twice. Nothing is moved on a guess.
frame_lost = ("frame" in captured and captured["frame"] != counter_frame()
and not captured.get("rezero_declared"))
if was is not None and now is not None and now != was and frame_lost and not self.cloud_mode():
self.log("position: %s, and %s when the run began, but the controller re-zeroed its "
"counters during the run (a controller start or a home): the two are not in "
"one frame, so the head's position cannot be judged and the head is not moved"
% (now, was))
elif was is not None and now is not None and now != was and not self.cloud_mode():
spm = self._counter_spm
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, "
"not a leftover" % (now, was, act, POSITION_DEADBAND_MM))
else:
left.append(Leftover("position", now, was, act))
# A controller restart during the run (a takeover's) re-zeroed the
# counters wherever the head then stood, so a head left out before
# it reads as home from here. The takeover judged it on the spot
# (runner.Takeover); what it recorded is a leftover, never moved.
for r in captured.get("restart_positions") or []:
left.append(Leftover("position at the %s" % r["where"], r["found"], r["expected"],
"unrestorable: a controller restart re-zeroed the counters there, "
"so the head is not moved"))
# The cloud client forgectrl's start brought back at the end of a
# takeover, when it is not the one the takeover found (the same
# judge, runner.Takeover). The client that came up is left as it
# is: the cloud test that needs a client starts its own.
for r in captured.get("restart_clients") or []:
left.append(Leftover("cloud client at the %s" % r["where"], r["found"], r["expected"],
"not restored: a cloud test starts the client it needs"))
was = captured.get("settings")
if was:
st, body = self.fc_get("/settings")
+67 -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
"""The bench diagnostics page: registry of the bench tools and the
subprocess runner behind the #bench tab.
@@ -247,8 +252,69 @@ TOOLS = [
{"id": "z-envelope-test", "title": "Z envelope harness", "script": "z_envelope_test.py",
"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 "
"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 "
"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": "ctlport-test", "title": "Controller port and line multiplexer harness", "script": "ctlport_test.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "A scripted sender that counts every response, beside the controller port's one client, on the "
"null-sink controller: a port jog's status goes to the port and never to the sender, a port "
"error does not reach the sender's parser, a sender line cancels a port jog and gets its own ok "
"(a line queued right behind the port's included), the refusals, the single client, a CR LF "
"sender, a soft reset, and the client as the dead-man. 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": "cool-report-test", "title": "Cooling report secret harness", "script": "cool_report_test.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "The controller's cooling reports on the null-sink controller, read by a stand-in for "
"forgectrl's listener: every report carries the secret the supervisor hands the controller at "
"its spawn (GF_REPORT_SECRET), none does when there is none, a value that is not 32 hex digits "
"(a CR LF with a header behind it included) never reaches the wire, and the homing runner the "
"controller starts is handed it (it reports in the controller's place during a gfcloud $H), "
"and none when the controller has none. 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": "mcode-test", "title": "Package M-code barrier harness", "script": "mcode_test.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "The M-codes extension packages answer (M160 to M179), on the null-sink controller with a "
"scripted sender and a stand-in for the machine daemon on the port: the table and its refusals, "
"a number nothing answers is error:20 where it is parsed, an answered one waits with the head "
"still and a port jog refused while the port's state names it, the answer's words, an answer "
"that the work was not done and no answer in time each hold the job, a soft reset ends the "
"wait, and a wait under an open armed window with M3 S500 ships no FIRE tick. Imports its "
"sender and port client from ctlport_test.py. 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": "envelope-test", "title": "Measured envelope harness", "script": "envelope_test.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "The measured work envelope and the bed check's port op, on the null-sink controller with a "
"scripted sender and a port client: unset keys are the axis travel, envelope_x_mm and _y are the "
"far edges at a home and are held to 50 mm up to the travel plus 30 mm, the port's envelope open "
"is refused before a home and under an open armed window and lets the port's jogs reach the "
"travel plus 30 mm, envelope apply puts the keys' edges back, and the sender's first line (never "
"a status poll or an empty line), a soft reset, and the port client going away each close an "
"open envelope, the sender's line only after it closed. Imports its sender and port client from "
"ctlport_test.py. 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": "manual-home-test", "title": "Manual home and motor release harness", "script": "manual_home_test.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "The manual homing provider and the motor release on the null-sink controller: $H under manual "
"ships nothing and declares X0 Y0 with the soft limits on and Z kept; while the motors are "
"released every motion source is refused and ships nothing, $X and a soft reset do not unlock "
"it, and only $ME and a manual $H write the energize, once each; the port's panel operations "
"do the same with the sender's count exact. Imports its sender and port client from "
"ctlport_test.py. 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": "tray-test", "title": "Crumb tray mode harness", "script": "tray_test.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "The crumb tray's mode on the null-sink controller: M103 P1 and P0 move Z and its envelope by "
"tray_offset_mm on the lens step grid, tell the sender, write and remove the marker beside the "
"shared config, and ship no step; a missing P, a P other than 0 or 1 and an axis word are "
"errors; the switch waits for the move before it; the port's tray op, and its refusals during "
"a program and at a package's M-code; the mode survives a soft reset and a restart, and a start "
"over the marker references Z in the tray-out frame; the offset's range; an unreferenced lens; "
"and a camera home in both modes hands the runner the same park. Imports its sender and port "
"client from ctlport_test.py. 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",
"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"])],
+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
release is authorized.
+61 -19
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.
A test is a function decorated with @test(...). The decorator records
@@ -69,6 +74,10 @@ class Test:
that test's earlier passes and no other; a change to a helper
invalidates the tests of that module. A test the module does not
define in the @test form hashes its whole file."""
if self._source_sha is None:
# One filler at a time: a second poll that arrives while the
# first computes waits for it rather than repeating the work.
with _IMPL_LOCK:
if self._source_sha is None:
path = inspect.getsourcefile(self.fn) or inspect.getfile(self.fn)
self._source_sha = implementation_sha(path, self.id)
@@ -135,8 +144,38 @@ def source_file_sha(path):
return hashlib.sha256(data).hexdigest()
_PARTS = {} # path -> (shared sha, {test id: own sha}); the files never change under a run
_PARTS_LOCK = threading.Lock()
# The files never change under a run, so each is read and parsed once:
# path -> (text, tree or None), path -> (shared sha, {test id: own sha}),
# path -> the sibling modules it imports itself.
_PARSED = {}
_PARTS = {}
_SIBLINGS = {}
_PARTS_LOCK = threading.RLock()
_IMPL_LOCK = threading.Lock()
def forget(path):
"""Drop what is known about a module: its file changed (the unit tests
edit their modules between readings)."""
with _PARTS_LOCK:
for cache in (_PARSED, _PARTS, _SIBLINGS):
cache.pop(path, None)
def _parsed(path):
"""(text, tree) of a suite module, line endings normalized; the tree
is None when the file does not parse."""
with _PARTS_LOCK:
hit = _PARSED.get(path)
if hit is None:
with open(path, "rb") as f:
text = f.read().replace(b"\r\n", b"\n").decode("utf-8")
try:
tree = ast.parse(text)
except SyntaxError:
tree = None
hit = _PARSED[path] = (text, tree)
return hit
def module_parts(path):
@@ -149,14 +188,9 @@ def module_parts(path):
hit = _PARTS.get(path)
if hit is not None:
return hit
with open(path, "rb") as f:
text = f.read().replace(b"\r\n", b"\n").decode("utf-8")
text, tree = _parsed(path)
lines = text.split("\n")
spans = {}
try:
tree = ast.parse(text)
except SyntaxError:
tree = None
for node in (tree.body if tree is not None else []):
if not isinstance(node, ast.FunctionDef):
continue
@@ -183,22 +217,30 @@ def sibling_imports(path, seen=None):
test's fingerprint."""
if seen is None:
seen = []
with open(path, "rb") as f:
text = f.read().replace(b"\r\n", b"\n").decode("utf-8")
try:
tree = ast.parse(text)
except SyntaxError:
return seen
here = os.path.dirname(path)
for node in tree.body:
if isinstance(node, ast.ImportFrom) and node.level == 1 and node.module:
sib = os.path.join(here, node.module.replace(".", os.sep) + ".py")
if os.path.isfile(sib) and sib not in seen and sib != path:
for sib in _direct_siblings(path):
if sib not in seen and sib != path:
seen.append(sib)
sibling_imports(sib, seen)
return seen
def _direct_siblings(path):
"""The suite modules one module imports itself, in import order."""
with _PARTS_LOCK:
hit = _SIBLINGS.get(path)
if hit is None:
_text, tree = _parsed(path)
here = os.path.dirname(path)
hit = []
for node in (tree.body if tree is not None else []):
if isinstance(node, ast.ImportFrom) and node.level == 1 and node.module:
sib = os.path.join(here, node.module.replace(".", os.sep) + ".py")
if os.path.isfile(sib):
hit.append(sib)
_SIBLINGS[path] = hit
return hit
def implementation_sha(path, test_id):
"""The implementation hash of one test (see Test.source_sha): the
test's own text, the shared text of its module, and the shared text
+43 -12
View File
@@ -1,22 +1,36 @@
# 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
some test's coverage globs, except the allowlisted non-behavioral paths,
and every coverage glob must select at least one path of its component.
some test's coverage globs or by a host-tested row (manifest.HOST_TESTED),
except the allowlisted non-behavioral paths; every coverage glob and every
host-tested glob must select at least one path of its component; and every
host-tested row must name a host test that exists and a CI workflow that
runs it.
Why a hard rule: under the domain model an uncovered file is worse than an
untested one - a change there leaves every inherited PASS valid when it
should have invalidated them. The lint is the floor (the file is
fingerprinted by at least one test); whether that test exercises the
change stays with the change author. An entry that selects nothing is
the same defect from the other side: the test's fingerprint ignores the
file it named.
fingerprinted by at least one test, or a named host test that CI runs
answers for it); whether that test exercises the change stays with the
change author. An entry that selects nothing is the same defect from the
other side: the test's fingerprint ignores the file it named.
python3 -m forgetest.coverage [--manifest PATH] [--enforce] [--json]
Whether a workflow runs its host test is read from the workflow's text at
the pinned revision, in the sources scripts/manifest-from-tree.py fetched
(--sources, default the checkout's .manifest-cache). Without them (on an
image) that part is not checked, and the report says so.
python3 -m forgetest.coverage [--manifest PATH] [--sources DIR] [--enforce] [--json]
Exit 0 when nothing is uncovered (or when only reporting), 1 under
--enforce with uncovered paths, 2 on a usage or load error.
"""
import argparse
import json
import os
import sys
from . import catalog as _catalog
@@ -28,16 +42,22 @@ ALLOW = _manifest.NON_BEHAVIORAL
def run(manifest, tests, allow=ALLOW):
return _manifest.coverage_report(manifest, tests, allow)
return _manifest.coverage_report(manifest, tests, allow, host=_manifest.HOST_TESTED)
def empty(manifest, tests):
return _manifest.empty_covers(manifest, tests)
def host_problems(manifest, sources=_manifest.SOURCES):
return _manifest.host_row_problems(manifest, host=_manifest.HOST_TESTED, sources=sources)
def main(argv=None):
ap = argparse.ArgumentParser(description="forgetest coverage lint")
ap.add_argument("--manifest", default=None, help="manifest JSON (default: the running image's)")
ap.add_argument("--sources", default=_manifest.SOURCES,
help="the pinned sources manifest-from-tree.py fetched (default: the checkout's .manifest-cache)")
ap.add_argument("--enforce", action="store_true", help="exit 1 when any path is uncovered")
ap.add_argument("--json", action="store_true", help="machine-readable report")
args = ap.parse_args(argv)
@@ -51,19 +71,30 @@ def main(argv=None):
report = run(manifest, tests)
total = sum(len(v) for v in report.values())
hollow = empty(manifest, tests)
sources = args.sources if args.sources and os.path.isdir(args.sources) else None
rows = host_problems(manifest, sources)
if args.json:
print(json.dumps({"uncovered": report, "total": total, "tests": len(tests),
"empty": [list(e) for e in hollow]}, indent=1, sort_keys=True))
"empty": [list(e) for e in hollow],
"host_tested": [list(r) for r in rows],
"workflows_read": sources is not None and not any("cannot be read" in r[3] for r in rows)},
indent=1, sort_keys=True))
else:
if _manifest.HOST_TESTED and sources is None:
print("host-tested: no sources, so whether a workflow runs each host test is not checked")
for comp in sorted(report):
print("%s: %d uncovered path(s)" % (comp, len(report[comp])))
for p in report[comp]:
print(" %s" % p)
for tid, comp, pat in hollow:
print("%s: covers (%s, %s) selects nothing" % (tid, comp, pat))
print("coverage: %d uncovered path(s) across %d component(s), %d empty entr%s, %d tests"
% (total, len(report), len(hollow), "y" if len(hollow) == 1 else "ies", len(tests)))
if args.enforce and (total or hollow):
for comp, pat, ref, why in rows:
print("host-tested (%s, %s) by %s: %s" % (comp, pat, ref, why))
print("coverage: %d uncovered path(s) across %d component(s), %d empty entr%s, "
"%d host-tested row problem(s), %d tests"
% (total, len(report), len(hollow), "y" if len(hollow) == 1 else "ies", len(rows),
len(tests)))
if args.enforce and (total or hollow or rows):
return 1
return 0
+14 -6
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
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.
@@ -292,11 +297,11 @@ class Fixture:
return bool(st and st.get("button_enabled"))
return True
def act(self, channel, state):
def act(self, channel, state, ms=None):
if channel == "button":
if state != "press":
raise FixtureError("the button is only ever pressed")
st, body = self._press()
st, body = self._press(ms)
else:
if state not in ("open", "close"):
raise FixtureError("%s: unknown state %r" % (channel, state))
@@ -307,8 +312,11 @@ class Fixture:
self.last_state = body
return body
def _press(self):
"""One press, spaced from the last one. The controller detects
def _press(self, ms=None):
"""One press, spaced from the last one. `ms` is how long the box
holds it (the firmware clamps it into 20 to 500, and takes 200 when
it is not said): a check that must reach the machine *while* the
button is down asks for the longest press the box can make. The controller detects
the button's rising edge, so a press must follow a release it has
seen: the next press waits for the last pulse to end (the
fixture's own pulse_ms) plus BUTTON_GAP_S. A 409 for a pulse in
@@ -317,7 +325,7 @@ class Fixture:
wait = self._pulse_end + BUTTON_GAP_S - time.time()
if wait > 0:
time.sleep(wait)
st, body = self._request("POST", "/button", {})
st, body = self._request("POST", "/button", {"ms": int(ms)} if ms else {})
if st == 409 and "in progress" in str(body.get("error", "")):
deadline = time.time() + PULSE_WAIT_S
while time.time() < deadline:
@@ -326,7 +334,7 @@ class Fixture:
if s == 200 and not b.get("button_pulsing"):
break
time.sleep(BUTTON_GAP_S)
st, body = self._request("POST", "/button", {})
st, body = self._request("POST", "/button", {"ms": int(ms)} if ms else {})
if st == 200:
self._pulse_end = time.time() + float(body.get("pulse_ms") or 500) / 1000.0
return st, body
+163
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
kernel module's sysfs, the init scripts, and the Grbl TCP port.
@@ -9,9 +14,12 @@ exercised against a mock on a host:
GRBL_HOST / GRBL_PORT default 127.0.0.1 / 23
FORGETEST_INITD default /etc/init.d
"""
import glob
import json
import os
import select
import socket
import struct
import subprocess
import threading
import time
@@ -305,6 +313,89 @@ def button_lit():
return any(v > 0 for v in vals)
# ----------------------------------------------------------- the button
EV_SW = 0x05
SW_BUTTON = 2 # the button's EV_SW code (bit 2 of the switch word)
def switches_device():
"""The machine's switches as forgectrl and the controller find them:
GF_SWITCH_DEV, else the input device the device tree names
"switches". None when there is none."""
dev = os.environ.get("GF_SWITCH_DEV")
if dev:
return dev
for d in sorted(glob.glob("/sys/class/input/event*")):
try:
with open(os.path.join(d, "device", "name")) as f:
if f.read().strip() == "switches":
return "/dev/input/" + os.path.basename(d)
except OSError:
continue
return None
class ButtonEdges:
"""The button as edges, from the switches' input events. A press is an
event however short it is, so nothing has to be held long enough for a
poll to land on it: an operator's press is never longer than a quarter
second. Open it before the press is asked for. It reads without a
grab, so forgectrl and the controller see the switches as always."""
EVENT = struct.Struct("llHHi") # struct input_event: time, type, code, value
def __init__(self):
self.fd = None
self.pending = b"" # events read and not yet looked at
dev = switches_device()
if dev:
try:
self.fd = os.open(dev, os.O_RDONLY | os.O_NONBLOCK | getattr(os, "O_CLOEXEC", 0))
except OSError:
self.fd = None
@property
def available(self):
return self.fd is not None
def wait(self, value, timeout, checkpoint=None):
"""The seconds until the button's next edge to `value` (1 pressed,
0 released), or None at the timeout. checkpoint() is called about
every 0.1 s (an abort raises out of it)."""
size = self.EVENT.size
t0 = time.time()
while True:
# The events already read come first: a short press arrives
# with its release in one read, and the release is the next
# wait's.
while len(self.pending) >= size:
_sec, _usec, typ, code, val = self.EVENT.unpack_from(self.pending, 0)
self.pending = self.pending[size:]
if typ == EV_SW and code == SW_BUTTON and val == value:
return time.time() - t0
if time.time() - t0 >= timeout:
return None
if checkpoint:
checkpoint()
ready, _, _ = select.select([self.fd], [], [], 0.1)
if not ready:
continue
try:
data = os.read(self.fd, size * 64)
except BlockingIOError:
continue
if not data:
time.sleep(0.02) # a FIFO with no writer (the host tests) reads empty
continue
self.pending += data
def close(self):
if self.fd is not None:
os.close(self.fd)
self.fd = None
# --------------------------------------------------------------- init.d
def initd(service, action, timeout=60):
@@ -319,6 +410,40 @@ def initd(service, action, timeout=60):
return p.returncode, p.stdout.decode("utf-8", "replace")
# The extension host's side of the machine (forgefirm-sandbox, forgeext).
EXT_POOL_FIRST, EXT_POOL_LAST = 800, 831
EXT_ROOT = "/data/forgefirm/ext"
EXT_TEST_PREFIX = "org.forgetest." # a package a test made; every other one is the operator's
FORGEEXT = "/usr/bin/forgeext"
def pool_pids():
"""{pid: uid} of every process that runs under an extension pool account."""
out = {}
try:
pids = [p for p in os.listdir("/proc") if p.isdigit()]
except OSError:
return out
for pid in pids:
try:
with open("/proc/%s/status" % pid) as f:
line = next((x for x in f if x.startswith("Uid:")), "")
uid = int(line.split()[1]) # the real uid; a service has all four alike
except (OSError, IndexError, ValueError):
continue
if EXT_POOL_FIRST <= uid <= EXT_POOL_LAST:
out[int(pid)] = uid
return out
def ext_packages():
"""The ids of the installed extension packages."""
try:
return sorted(d for d in os.listdir(EXT_ROOT + "/pkg") if os.path.isdir(os.path.join(EXT_ROOT, "pkg", d)))
except OSError:
return []
def pidof(comm):
"""PIDs whose /proc/<pid>/comm equals comm (15-char kernel limit applies)."""
out = []
@@ -356,6 +481,44 @@ def pgrep_f(needle):
return out
def listens_on(pid, path):
"""Process pid holds a listening UNIX socket bound at path: one of its
own descriptors is that socket, so a socket file another process left
behind does not count. Reads /proc (FORGETEST_PROC_ROOT for tests)."""
if not pid:
return False
proc = os.environ.get("FORGETEST_PROC_ROOT") or "/proc"
inodes = set()
try:
with open(os.path.join(proc, "net", "unix")) as f:
next(f, None) # the header line
for ln in f:
# Num RefCount Protocol Flags Type St Inode Path
parts = ln.split()
try:
if len(parts) >= 8 and parts[7] == path and int(parts[3], 16) & 0x10000:
inodes.add(parts[6]) # __SO_ACCEPTCON: a listener
except ValueError:
continue
except OSError:
return False
if not inodes:
return False
fd_dir = os.path.join(proc, str(pid), "fd")
try:
fds = os.listdir(fd_dir)
except OSError:
return False
for fd in fds:
try:
link = os.readlink(os.path.join(fd_dir, fd))
except OSError:
continue
if link.startswith("socket:[") and link[8:-1] in inodes:
return True
return False
def run(cmd, timeout=60):
"""Run a command list; returns (rc, combined output)."""
try:
+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.
One JSON object per line under the data directory (default
+456 -6
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.
/etc/forgefirm-manifest.json (written by forgefirm-image-manifest.bbclass)
@@ -14,6 +19,7 @@ import functools
import hashlib
import json
import os
import posixpath
import re
DEFAULT_PATH = "/etc/forgefirm-manifest.json"
@@ -88,10 +94,136 @@ NON_BEHAVIORAL = [
("*", ".vscode/**"),
("*", ".env.example"),
("forgectrl", "tools/**"), # host-side dev tools (panel dev server)
("forgectrl", "examples/**"), # clients of the remote API, run on another computer
# forgeext's recipe installs the binary and its init script, and nothing of these: the author's kit
# and the host tools run off the image (OpenGlow's own packages have repositories of their own).
("forgeext", "sdk/**"),
("forgeext", "template/**"),
("forgeext", "tools/**"),
]
# Paths the list above would take out that are behavior all the same: forgectrl embeds its advisory
# documents in the binary, serves them, and records the operator's consent to a document by its hash, so
# an edited advisory is a changed consent and must move every fingerprint that covers it.
BEHAVIORAL = [
("forgectrl", "docs/advisories/**"),
]
# Paths a host test in CI proves, which no catalog test covers. The catalog
# is for what needs the machine or an operator; software whose inputs can be
# emulated is proven on a host. A row is (component, glob, host test, CI
# workflow), the last two named as "<repository>:<path>": a component of
# the manifest (its tests/ and .github/ files are listed there), or
# "forgefirm" for this repository (the null-sink harnesses under
# scripts/bench/, which grblhal-glowforge's workflow runs). The workflow
# must run the host test: name it, or discover it (a glob, unittest
# discover, pytest on its directory). A host-tested path counts as covered
# in the coverage lint, and like every path no test covers it is in no
# fingerprint: a change there makes no acceptance run necessary. A path a
# catalog test exercises on the hardware belongs in that test's covers, not
# here.
HOST_TESTED = [
# An extension package's holds as the engine reads them (forgeext's
# holdkeep_test and run_test prove the writer's side).
("forgectrl", "src/holds.*", "forgectrl:tests/holds_test.c", "forgectrl:.github/workflows/build.yml"),
# The M-code relay to the package that answers (the controller's side is
# the null-sink mcode_test harness, under grblhal-glowforge's src/**).
("forgectrl", "src/mcode.*", "forgectrl:tests/mcode_test.c", "forgectrl:.github/workflows/build.yml"),
# The digest primitives (SHA-256, HMAC, the constant-time compare) that
# the advisories' hashes, the tokens and the camera key are made with.
("forgectrl", "src/sha256.*", "forgectrl:tests/sha256_test.c", "forgectrl:.github/workflows/build.yml"),
# The built-in extensions' table: the cloud entry, its refusals while it
# is off, the sweep when it goes off, the list.
("forgectrl", "src/builtin.*", "forgectrl:tests/builtin_test.c", "forgectrl:.github/workflows/build.yml"),
# The setup record as a printable page: no script, the sheet id, a
# heading for each completed step, everything escaped.
("forgectrl", "src/recordhtml.*", "forgectrl:tests/recordhtml_test.c", "forgectrl:.github/workflows/build.yml"),
# An extension package's claim on the Grbl sender as the machine keeps
# it: the claim and its refusals, its end by the package, the operator
# or a stale keeper (forgeext's run_test proves the writer's side).
("forgectrl", "src/senderout.*", "forgectrl:tests/senderout_test.c", "forgectrl:.github/workflows/build.yml"),
# A package's check on the Setup page: each step held to its form, the
# check run through the host, the result the package's own (the routes
# against a live host's stand-in are tests/host_ext.sh).
("forgectrl", "src/wizpkg.*", "forgectrl:tests/wizpkg_test.c", "forgectrl:.github/workflows/build.yml"),
# The camera key: made, kept, checked and rotated (the reads it opens
# from the LAN with the reads closed are tests/host_lan.sh).
("forgectrl", "src/camkey.*", "forgectrl:tests/camkey_test.c", "forgectrl:.github/workflows/build.yml"),
# The log export's sanitizer: every identifying class taken out, the
# ordinary log text left as it was (the export's routes and the
# installer's directory on a host are tests/host_guard.sh).
("forgectrl", "src/sanitize.*", "forgectrl:tests/sanitize_test.c", "forgectrl:.github/workflows/build.yml"),
# The package routes' relay to the extension host: the argument vector,
# the closed list of actions, the host's words, the catalog's fetch (the
# routes against a live host's stand-in are tests/host_ext.sh).
("forgectrl", "src/extpkg.*", "forgectrl:tests/extpkg_test.c", "forgectrl:.github/workflows/build.yml"),
# The event stream: a button edge, a lease changing hands, telemetry
# (the live order, the take before the free, is tests/host_lease.sh).
("forgectrl", "src/events.*", "forgectrl:tests/events_test.c", "forgectrl:.github/workflows/build.yml"),
# The crumb tray's mode as the daemon keeps it: its offset and what it
# refuses (the route and the panel's switch are tests/host_routes.sh).
("forgectrl", "src/tray.*", "forgectrl:tests/tray_test.c", "forgectrl:.github/workflows/build.yml"),
# The product gate on the firmware paths: firmware only, an extension
# package refused in words that say what it is (the upload route over
# a stand-in fwup is tests/host_update.sh).
("forgectrl", "src/fwproduct.*", "forgectrl:tests/fwproduct_test.c", "forgectrl:.github/workflows/build.yml"),
# The controller port's client and its two operation sets (the motion
# routes and a package's jog through the host's credential are
# tests/host_routes.sh).
("forgectrl", "src/grblport.*", "forgectrl:tests/grblport_test.c", "forgectrl:.github/workflows/build.yml"),
# The panel page as the daemon serves it: bundled, the theme and the
# save bar in it, and its policy frame-src 'none'.
("forgectrl", "src/ui.h", "forgectrl:tests/host_routes.sh", "forgectrl:.github/workflows/build.yml"),
# The data and run directories from the environment: the SSH flag in the
# run directory, written and removed by POST /system/ssh.
("forgectrl", "src/paths.h", "forgectrl:tests/host_routes.sh", "forgectrl:.github/workflows/build.yml"),
# The first run in the page's order: the four documents at their hashes,
# the press step breathing teal, the press taken as the acceptance, the
# form steps through main.c's hooks (extensions.md stays with
# exthost.service, which covers it).
("forgectrl", "src/advisories.*", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
("forgectrl", "docs/advisories/safety-and-risk.md", "forgectrl:tests/host_setup.sh",
"forgectrl:.github/workflows/build.yml"),
("forgectrl", "docs/advisories/licenses.md", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
("forgectrl", "docs/advisories/privacy.md", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
("forgectrl", "docs/advisories/cloud-service.md", "forgectrl:tests/host_setup.sh",
"forgectrl:.github/workflows/build.yml"),
("forgectrl", "src/button.*", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
("forgectrl", "src/led.*", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
("forgectrl", "src/hooks.h", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
# The setup sheet id from the fuse serial, against golden ids.
("forgectrl", "src/sheetid.*", "forgectrl:tests/sheetid_test.c", "forgectrl:.github/workflows/build.yml"),
# The logging path: the image's rsyslog.conf and the rendered rules, each
# logger in its own file at its level, a stray tag in system/, the kern
# facility in kernel/.
("forgectrl", "src/fflog.*", "forgectrl:tests/fflog_e2e.sh", "forgectrl:.github/workflows/build.yml"),
# POST /job's multipart reader: the form's parts reach the runner's
# start; a missing name, a $ line and a malformed boundary are refused
# with nothing started.
("forgectrl", "src/jobpost.*", "forgectrl:tests/jobpost_test.c", "forgectrl:.github/workflows/build.yml"),
# The scoped tokens: the closed capability list, the judgment, the
# store that holds only hashes, and the extension host's credential
# (mode 0600, exactly the host's capabilities).
("forgectrl", "src/tokens.*", "forgectrl:tests/tokens_test.c", "forgectrl:.github/workflows/build.yml"),
]
# This repository's root, when the package runs from a checkout of it; a
# "forgefirm:" file is looked for there. Installed on an image, the package
# is not in a checkout, and such a reference is not checked.
_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
REPO_ROOT = _ROOT if os.path.isdir(os.path.join(_ROOT, "meta-forgefirm")) else None
# The pinned sources scripts/manifest-from-tree.py fetches (one bare repo
# per source URL); a component's workflow is read from there. Without them
# (on an image) whether a workflow runs a host test is not checked.
SOURCES = os.path.join(REPO_ROOT, ".manifest-cache") if REPO_ROOT else None
def non_behavioral(comp, path, allow=NON_BEHAVIORAL):
for c, pat in BEHAVIORAL:
if c == comp and glob_to_regex(pat).match(path):
return False
for c, pat in allow:
if c in ("*", comp) and glob_to_regex(pat).match(path):
return True
@@ -194,17 +326,332 @@ def empty_covers(manifest, tests):
return out
def coverage_report(manifest, tests, allow=NON_BEHAVIORAL):
"""Which manifest paths no test covers.
def _ref_problem(manifest, ref, what, root):
"""Why a "<repository>:<path>" reference names no file, or None. A
component's file must be in the manifest; a "forgefirm:" one must exist
under `root`, and is not checked without one."""
repo, sep, path = ref.partition(":")
if not sep or not path:
return "names no repository:path for its %s" % what
if repo == "forgefirm":
if root is not None and not os.path.isfile(os.path.join(root, path)):
return "no such %s" % what
return None
if repo in DEV_ONLY_COMPONENTS or manifest.files(repo) is None:
return "no repository %r for its %s" % (repo, what)
if path not in {p for p, _b in manifest.files(repo)}:
return "no such %s" % what
return None
def read_source(manifest, ref, root=REPO_ROOT, sources=SOURCES):
"""(text, why) for a "<repository>:<path>" file: a "forgefirm:" file
from `root`, a component's from its pinned blob in the bare repo
`sources` holds for its source URL. (None, None) when it cannot be read
here (no root, no sources); (None, why) when it should have been and
was not, which is a defect, never a pass."""
import subprocess
repo, _sep, path = ref.partition(":")
if repo == "forgefirm":
if root is None:
return None, None
try:
with open(os.path.join(root, path), encoding="utf-8") as f:
return f.read(), None
except (OSError, UnicodeDecodeError) as e:
return None, "cannot be read: %s" % e
if not sources:
return None, None
comp = manifest.components.get(repo) or {}
blob = dict(manifest.files(repo) or []).get(path)
url = comp.get("source")
if not (blob and url):
return None, "cannot be read: no blob or source URL in the manifest"
cache = os.path.join(sources, hashlib.sha1(url.encode()).hexdigest()[:16])
try:
return subprocess.run(["git", "cat-file", "-p", blob], cwd=cache, capture_output=True,
check=True).stdout.decode("utf-8", "replace"), None
except (OSError, subprocess.CalledProcessError) as e:
return None, "cannot be read from %s: %s" % (cache, e)
def repo_name(manifest, repo):
"""A repository's GitHub name: its source URL's last part, or this
repository's."""
if repo == "forgefirm":
return "forgefirm"
url = ((manifest.components.get(repo) or {}).get("source") or "").rstrip("/")
name = url.rsplit("/", 1)[-1]
return name[:-4] if name.endswith(".git") else name
_STEP_KEYS = ("name", "uses", "run", "id", "if", "with", "env", "working-directory", "shell",
"continue-on-error", "timeout-minutes")
_STEP_RX = re.compile(r"^(\s*)-\s+(%s)\s*:" % "|".join(re.escape(k) for k in _STEP_KEYS))
_KEY_RX = re.compile(r"^(\s*)([\w-]+)\s*:\s*(.*)$")
def _indent(line):
return len(line) - len(line.lstrip(" "))
def workflow_steps(text):
"""A workflow's steps, each a dict of its keys: "uses", "run" (the
command text, a block's lines joined), "working-directory", and "with"
(a dict). YAML read by its indentation, as far as these workflows use
it; comments, whole lines and from ' #' to the end of a line, are not
read. Anything outside a step (a trigger's paths, a job's name) is not
a step."""
lines = []
for ln in text.splitlines():
if ln.lstrip().startswith("#"):
continue
lines.append(re.sub(r"\s+#.*$", "", ln).rstrip())
steps, i = [], 0
while i < len(lines):
m = _STEP_RX.match(lines[i])
if not m:
i += 1
continue
dash = len(m.group(1))
body = [" " * (dash + 2) + lines[i][dash + 1:].lstrip()]
i += 1
while i < len(lines) and (not lines[i].strip() or _indent(lines[i]) > dash):
body.append(lines[i])
i += 1
step, k = {}, 0
key_col = _indent(body[0])
while k < len(body):
km = _KEY_RX.match(body[k])
if not km or _indent(body[k]) != key_col:
k += 1
continue
key, val = km.group(2), km.group(3).strip()
if len(val) > 1 and val[0] == val[-1] and val[0] in "'\"":
val = val[1:-1] # a quoted scalar, not a quote inside a command
k += 1
child = []
while k < len(body) and (not body[k].strip() or _indent(body[k]) > key_col):
child.append(body[k])
k += 1
if key == "with":
step["with"] = {c.group(2): c.group(3).strip().strip("'\"")
for c in (_KEY_RX.match(x) for x in child) if c}
elif key == "run":
step["run"] = "\n".join(x.strip() for x in child) if val[:1] in ("|", ">") else val
else:
step[key] = val
steps.append(step)
return steps
def checkout_path(steps, name, own):
"""Where a workflow checks the repository `name` out (its path, "" for
the workspace), or None when it does not: its own repository is the
checkout that names no repository, another one the checkout whose
repository is <owner>/<name>."""
for s in steps:
if not s.get("uses", "").startswith("actions/checkout"):
continue
w = s.get("with", {})
repo = w.get("repository")
if (repo is None and own) or (repo is not None and repo.rsplit("/", 1)[-1].lower() == name.lower()):
return posixpath.normpath(w.get("path") or ".")
return None
_SPLIT_RX = re.compile(r"&&|\|\||[;|\n]|(?:^|(?<=\s))(?:then|do|else)(?=\s|$)")
_PREFIXES = ("sudo", "env", "time", "exec", "command", "nice")
_INTERPRETER_RX = re.compile(r"^(?:python[0-9.]*|bash|sh)$")
def _commands(run):
"""The simple commands of a step's run text, each as its words, with
what only starts the real command taken off: sudo and its flags, env,
time, exec, and variable assignments."""
import shlex
out = []
for part in _SPLIT_RX.split(run.replace("\\\n", " ")):
try:
words = shlex.split(part)
except ValueError:
words = part.split()
while words:
if words[0] in _PREFIXES:
words = words[1:]
while words and words[0].startswith("-"):
words = words[1:]
elif re.match(r"^[A-Za-z_]\w*=", words[0]):
words = words[1:]
else:
break
if words:
out.append(words)
return out
def _segments_match(path, pattern):
"""A path against a path or a glob, one segment against one segment
('*' never crosses a '/')."""
import fnmatch
a, b = path.split("/"), pattern.split("/")
return len(a) == len(b) and all(fnmatch.fnmatchcase(x, y) for x, y in zip(a, b))
def _discovers(args, full, at):
"""Whether `python -m unittest discover ARGS` finds the test: its
directory is the start directory (-s, default the working directory)
and its file name matches the pattern (-p, default test*.py)."""
import fnmatch
start, pattern = ".", "test*.py"
for i, a in enumerate(args):
if a in ("-s", "--start-directory") and i + 1 < len(args):
start = args[i + 1]
elif a in ("-p", "--pattern") and i + 1 < len(args):
pattern = args[i + 1]
return posixpath.dirname(full) == at(start) and fnmatch.fnmatchcase(posixpath.basename(full), pattern)
def _unittest_names(args, full, at):
"""Whether `python -m unittest ARGS` (not discover) runs the test: an
argument is its file (tests/test_x.py), or a dotted name whose module
is its file (tests.test_x, tests.test_x.Case.test_one)."""
skip = False
for a in args:
if skip:
skip = False
continue
if a in ("-k",):
skip = True
continue
if a.startswith("-") or "$" in a:
continue
if a.endswith(".py"):
if at(a) == full:
return True
continue
parts = a.split(".")
if any(at("/".join(parts[:k]) + ".py") == full for k in range(len(parts), 0, -1)):
return True
return False
def _pytest_runs(args, full, at):
"""Whether pytest ARGS collects the test: named, or its directory given
and its file name one pytest collects (test_*.py, *_test.py)."""
import fnmatch
name = posixpath.basename(full)
collected = fnmatch.fnmatchcase(name, "test_*.py") or fnmatch.fnmatchcase(name, "*_test.py")
for a in args:
if a.startswith("-") or "$" in a:
continue
if at(a) == full or (collected and at(a) == posixpath.dirname(full)):
return True
return False
def workflow_runs(steps, test_path):
"""Whether a step of the workflow runs the test at test_path (its path
in the workspace: the checkout's path joined to the test's path in its
repository). Only what a step's run commands execute counts, each path
resolved against the step's working-directory: the command itself
(a script run directly, or a C test's built binary, <dir>/<stem>);
the script an interpreter runs (python, bash, sh), a glob matching the
whole path; a for loop's list over the test; python -m unittest given
the test's file or a dotted name whose module is that file
(tests.test_x, tests.test_x.Case.test_one); unittest discover from the
test's own directory with a pattern its name matches; pytest on the
test or its own directory. An argument to anything else (echo, a build,
an install) does not run the test."""
full = posixpath.normpath(test_path)
stem, ext = posixpath.splitext(posixpath.basename(full))
compiled = ext in (".c", ".cc", ".cpp")
for s in steps:
run = s.get("run")
if not run:
continue
wd = s.get("working-directory") or "."
def at(p):
return posixpath.normpath(posixpath.join(wd, p))
def names(p):
return "$" not in p and _segments_match(full, at(p))
for words in _commands(run):
cmd, args = words[0], words[1:]
if cmd == "for" and "in" in args:
if any(names(p) for p in args[args.index("in") + 1:]):
return True
elif _INTERPRETER_RX.match(posixpath.basename(cmd)):
if "-m" in args:
i = args.index("-m")
mod, rest = (args[i + 1], args[i + 2:]) if i + 1 < len(args) else ("", [])
if mod == "unittest" and rest[:1] == ["discover"]:
if _discovers(rest[1:], full, at):
return True
elif (mod == "unittest" and _unittest_names(rest, full, at)) or \
(mod == "pytest" and _pytest_runs(rest, full, at)):
return True
else:
script = next((a for a in args if not a.startswith("-")), None)
if script and names(script):
return True
elif posixpath.basename(cmd) == "pytest":
if _pytest_runs(args, full, at):
return True
elif names(cmd) or (compiled and "/" in cmd and posixpath.basename(cmd) == stem):
return True
return False
def host_row_problems(manifest, host=HOST_TESTED, root=REPO_ROOT, sources=SOURCES):
"""HOST_TESTED rows that prove nothing: a glob that selects no
behavioral file of its component (the same defect as an empty covers
entry), a host test or a workflow that does not exist, a workflow that
cannot be read where it should be, one that does not check out the
test's repository, or one with no step that runs the test. Whether a
workflow runs its test is not checked where it cannot be read (see
read_source). Returns [(component, glob, host test, why)]."""
out = []
for comp, pat, ref, ci in host:
files = manifest.files(comp)
if files is None or not [p for p, _b in match_files(files, pat) if not non_behavioral(comp, p)]:
out.append((comp, pat, ref, "selects nothing"))
why = _ref_problem(manifest, ref, "host test", root) or _ref_problem(manifest, ci, "workflow", root)
if why:
out.append((comp, pat, ref, why))
continue
text, why = read_source(manifest, ci, root, sources)
if why:
out.append((comp, pat, ref, "%s %s" % (ci, why)))
continue
if text is None:
continue
steps = workflow_steps(text)
test_repo, _sep, test_path = ref.partition(":")
prefix = checkout_path(steps, repo_name(manifest, test_repo), test_repo == ci.partition(":")[0])
if prefix is None:
out.append((comp, pat, ref, "%s does not check out %s" % (ci, test_repo)))
elif not workflow_runs(steps, posixpath.join(prefix, test_path)):
out.append((comp, pat, ref, "%s does not run it" % ci))
return out
def coverage_report(manifest, tests, allow=NON_BEHAVIORAL, host=HOST_TESTED):
"""Which manifest paths neither a test nor a host test covers.
tests: iterable with .covers. allow: iterable of (component, glob)
that need no coverage (docs, CI, licenses...). Returns
that need no coverage (docs, CI, licenses...); a BEHAVIORAL path is
never allowed away, as it is never taken out of a fingerprint. host:
the HOST_TESTED rows, whose paths count as covered. Returns
{component: [uncovered paths]} for the non-dev-only components.
"""
covered = {}
for t in tests:
for comp, pat in t.covers:
covered.setdefault(comp, []).append(glob_to_regex(pat))
for comp, pat, _ref, _ci in host:
covered.setdefault(comp, []).append(glob_to_regex(pat))
allowed = {}
for comp, pat in allow:
allowed.setdefault(comp, []).append(glob_to_regex(pat))
@@ -212,11 +659,14 @@ def coverage_report(manifest, tests, allow=NON_BEHAVIORAL):
for comp in manifest.component_names():
if comp in DEV_ONLY_COMPONENTS:
continue
rxs = covered.get(comp, []) + allowed.get(comp, [])
star = allowed.get("*", [])
rxs = covered.get(comp, [])
free = allowed.get(comp, []) + allowed.get("*", [])
behavior = [glob_to_regex(pat) for c, pat in BEHAVIORAL if c == comp]
missing = []
for p, _b in manifest.files(comp):
if any(rx.match(p) for rx in rxs) or any(rx.match(p) for rx in star):
if any(rx.match(p) for rx in rxs):
continue
if any(rx.match(p) for rx in free) and not any(rx.match(p) for rx in behavior):
continue
missing.append(p)
if missing:
+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
one self-contained response (no external assets). Bootstrap and the
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
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)
+284 -11
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.
Acceptance tests run in a worker thread with a Context: log lines, an
@@ -120,6 +125,8 @@ ACT_TIMEOUT_S = 180
# machine by pressing its button. Generous: they may be setting up the
# scrap, and nothing fires until it lands.
PRESENCE_TIMEOUT_S = 600
ARM_LIT_WAIT_S = 1800 # an arm can wait minutes (a coolant settle) before it lights
ARM_PRESS_WAIT_S = 3600 # the controller's own button timeout, at most
class Run:
@@ -164,7 +171,12 @@ class Run:
notice = dict(self.notice) if self.notice else None
return {
"kind": self.kind, "id": self.id, "title": self.title,
"started": self.started_ts, "elapsed_s": int(time.time() - self.started),
# A finished run's clock stops: the page shows the last run
# until the next one starts, and a live figure there counts
# the time since, not the time it took.
"started": self.started_ts,
"elapsed_s": (self.finished["duration_s"] if self.finished
else int(time.time() - self.started)),
"log": lines, "dropped": self.dropped, "prompt": prompt, "notice": notice,
"finished": self.finished, "aborting": self.aborted.is_set(),
}
@@ -306,17 +318,31 @@ class Context:
fixture = getattr(self.runner, "fixture", None) if self.runner is not None else None
if fixture is not None and fixture.covers("button"):
# The press is read as an edge from the switches' events, opened
# before the notice goes up: a press of any length counts, and
# nobody holds the button for a poll to land on it.
edges = hw.ButtonEdges()
self.notice(text + " Then press the button on the machine to start.")
self.log("READY: waiting for the operator's press on the machine "
"(the bench then does every press in this test)")
pressed = self.wait_for(lambda: self.switch("button") is True, PRESENCE_TIMEOUT_S)
try:
if edges.available:
pressed = edges.wait(1, PRESENCE_TIMEOUT_S, self.checkpoint)
# The release, so this press is never read as the arm
# press or a pause toggle; a short press has queued it.
if pressed is not None:
edges.wait(0, 10, self.checkpoint)
else:
pressed = self.wait_for(lambda: self.switch("button") is True, PRESENCE_TIMEOUT_S,
poll=0.05)
if pressed is not None:
self.wait_for(lambda: self.switch("button") is False, 10, poll=0.05)
finally:
edges.close()
self.clear_notice()
if pressed is None:
raise Failed("no press on the machine within %d s: %s"
% (PRESENCE_TIMEOUT_S, text))
# Let the button come back up, so this press is never read as
# the arm press or a pause toggle.
self.wait_for(lambda: self.switch("button") is False, 10)
self.run.fixture_takeover = True
self.evidence.setdefault("actions", []).append(
{"channel": "button", "state": "presence", "by": "operator",
@@ -356,7 +382,7 @@ class Context:
"""One of forgectrl's switch readings (lid, interlock_ok, ...)."""
return (self.forgectrl.status().get("switches") or {}).get(name)
def act(self, channel, state, until=None, timeout=ACT_TIMEOUT_S, text="", fail=True):
def act(self, channel, state, until=None, timeout=ACT_TIMEOUT_S, text="", fail=True, ms=None):
"""A machine action by name: ("lid", "open"|"close"),
("interlock", "open"|"close"), ("button", "press"). The bench's
actuator performs it when one covers the channel (the runner's
@@ -395,7 +421,7 @@ class Context:
self.log("ACT %s %s (fixture)", channel, state)
rec["by"] = "fixture"
try:
fixture.act(channel, state)
fixture.act(channel, state, ms)
except _fixture.FixtureError as e:
rec["fixture_error"] = str(e)
if self.run.unattended:
@@ -424,6 +450,76 @@ class Context:
self.log("ACT %s %s: done after %.1f s", channel, state, dt)
return dt
def press_now(self, text="The button is lit white. Press it now: the laser fires after your press."):
"""The arm press, at the moment the machine says it is waiting.
A check that runs a job opens a `press` prompt of its own when
it sees the button lit and the tube still dark, which is the
machine's own word that the press it wants is this one. Pressing
on that is exact; pressing on a button LED read from here is
not, because the button is lit through parts of a check that are
not the arm (the lens reference, the program on its way to the
controller), and a press then lands before the job waits for it
and is lost.
The fixture presses where the bench took this test's presses (or
opted in standing); otherwise the notice goes up for a person,
who is looking at the same prompt. Returns True when the fixture
pressed."""
fixture = getattr(self.runner, "fixture", None) if self.runner is not None else None
rec = {"channel": "button", "state": "arm", "by": "operator", "ts": now_ts()}
self.evidence.setdefault("actions", []).append(rec)
opted_in = fixture is not None and fixture.covers("button") and \
(fixture.arm_press or self.run.fixture_takeover)
if not opted_in:
if self.run.fixture_takeover:
rec["fixture_lost"] = True
self.log("ARM: WARNING the bench actuator took this test's presses and is now "
"gone - asking the operator for the arm press")
self.notice(text)
self._clear_when_taken()
return False
try:
fixture.act("button", "press")
except _fixture.FixtureError as e:
rec["fixture_error"] = str(e)
self.log("ARM press: fixture failed (%s) - asking the operator", e)
self.notice(text)
return False
rec["by"] = "fixture"
self.log("ARM press by the fixture, at the machine's own press prompt")
return True
def _clear_when_taken(self, lit_timeout=ARM_LIT_WAIT_S):
"""Take the arm notice down the moment the press is taken: the
controller lights the button white for its arm wait and puts it out
at the press. Without this the notice stood until the test's next
step, and an operator who still saw it pressed again, which pauses
the job. A notice replaced meanwhile is left alone."""
with self.run._lock:
nid = (self.run.notice or {}).get("id")
if nid is None:
return
def watch():
t0 = time.time()
while hw.button_lit() is not True:
if time.time() - t0 > lit_timeout:
return
time.sleep(0.05)
while hw.button_lit() is not False:
if time.time() - t0 > lit_timeout + ARM_PRESS_WAIT_S:
return
time.sleep(0.05)
with self.run._lock:
same = (self.run.notice or {}).get("id") == nid
if same:
self.run.notice = None
if same:
self.log("ARM: the button went out at the press; the notice is down")
threading.Thread(target=watch, daemon=True, name="forgetest-arm-notice").start()
def arm_press(self, text="The button lights white: press it to arm. The machine fires after your press.",
lit_timeout=60):
"""The arm cue of a live test. A person's press by default: a
@@ -450,6 +546,7 @@ class Context:
self.log("ARM: WARNING the bench actuator took this test's presses and is now "
"gone - asking the operator for the arm press")
self.notice(text)
self._clear_when_taken()
return False
self.log("ARM: the fixture presses when the button lights (the bench's arm_press opt-in)")
@@ -490,7 +587,7 @@ class Context:
return hw.Grbl()
def takeover(self):
return Takeover(self.run.log, self.test.id)
return Takeover(self.run.log, self.test.id, run=self.run)
def counters_rezeroed(self):
"""Tell the baseline the kernel position counters were re-zeroed
@@ -500,6 +597,10 @@ class Context:
cap = self.run.baseline_captured
if cap and cap.get("position") is not None:
cap["position"] = [0, 0, 0]
cap["rezero_declared"] = True # the test vouches for the new frame
# ... and it is the frame now in force, so a takeover later in
# the run can still judge where the head stands against it
cap["frame"] = _baseline.counter_frame()
self.log("position counters re-zeroed at the starting position; the baseline "
"expects (0,0,0) at the end")
@@ -529,11 +630,16 @@ class Takeover:
"cnc/x_mode", "cnc/y_mode", "cnc/x_decay", "cnc/y_decay",
"pic/x_step_current", "pic/y_step_current")
def __init__(self, log, who):
def __init__(self, log, who, run=None):
self.log = log # callable(str)
self.who = who
self.marker = marker_path()
self.saved = {}
self.run = run # the test's run, whose baseline the head is judged against
self.cloud = False
self.offline = False # the cloud client found is the offline service
self.frame = None # the counters' frame, read before the controller is stopped
self.found = None # (counters, microstep mode) once the controller is gone
def wait_settled(self):
return _baseline.Baseline(self.log).wait_settled()
@@ -560,6 +666,18 @@ class Takeover:
self.saved[attr] = v
if self.saved:
log("takeover: preserving %s" % ", ".join("%s=%s" % kv for kv in self.saved.items()))
try:
st, mode = hw.Forgectrl().get("/mode")
self.cloud = st == 200 and isinstance(mode, dict) and mode.get("mode") == "cloud"
self.offline = self.cloud and hw.listens_on(mode.get("pid"), _baseline.OFFLINE_SOCKET)
except hw.HwError:
pass
if self.offline:
log("takeover: the cloud client is the offline service (pid %s); forgectrl's start "
"brings it back offline" % mode.get("pid"))
# The frame is read while the controller runs: its exit takes the
# anchor with it (forgectrl unlinks it), though not the counters.
self.frame = _baseline.counter_frame()
log("takeover: stopping the controller through forgectrl")
try:
st, body = hw.Forgectrl().post("/controller/stop")
@@ -582,10 +700,146 @@ class Takeover:
self.__exit__(None, None, None)
raise Failed("takeover: processes still alive after stop: %s" % left)
log("takeover: pulse device free")
try:
self.position_at_start()
except Exception as e: # noqa: BLE001 - a reading never keeps forgectrl down
log("takeover: WARNING the head's position could not be judged: %s: %s" % (type(e).__name__, e))
return self
# A controller start re-zeroes the step counters where the head then
# stands, and forgectrl's start after a takeover is one, so the
# baseline's check after the run cannot see a head left out before it.
# The takeover judges it at the two moments it can: as the controller
# goes away (against where the run expects the head) and before it
# comes back (against where the takeover found it). A miss is recorded
# for the baseline's post pass, which fails the run; nothing is moved,
# because a jog sized from these counters is only as good as the frame
# they are in. Cloud mode is not judged: its counters are the cloud
# client's, as in the baseline.
def _record(self, where, found, expected):
cap = self.run.baseline_captured if self.run is not None else None
if cap is not None:
cap.setdefault("restart_positions", []).append(
{"where": where, "found": found, "expected": expected})
self.log("takeover: the head is not where it belongs at the %s: counters %s, expected %s; "
"recorded as a leftover, nothing moved" % (where, found, expected))
def position_at_start(self):
cap = self.run.baseline_captured if self.run is not None else None
if not cap or self.cloud:
return
now = _baseline.read_position()
self.found = (now, hw.sysfs_read("cnc/x_mode"))
was = cap.get("position")
if now is None or was is None:
return
if self.frame is None or cap.get("frame") != self.frame:
self.log("takeover: the counters are not in the frame the run began in (a controller "
"start or a home the test did not declare), so the head is not judged here")
return
if now != was and not _baseline.position_quantized(was, now, _baseline.counter_steps_per_mm()):
self._record("takeover start", now, was)
def position_at_end(self):
if self.found is None:
return
was, mode = self.found
now = _baseline.read_position()
if was is None or now is None:
return
if hw.sysfs_read("cnc/x_mode") != mode:
self.log("takeover: the microstep mode changed under the takeover, so the counters "
"before and after it are not one scale; the head is not judged here")
return
if now != was and not _baseline.position_quantized(was, now, _baseline.counter_steps_per_mm()):
self._record("takeover end", now, was)
# In cloud mode forgectrl's start at the end starts a cloud client, and
# what that client is comes only from the one-start markers gfcloud
# reads and takes down as it starts. Started bare, it is the online
# client with the service's connect-time hunt, and the moves the
# service sends after the hunt are still running as the run ends and
# the next one begins, whatever the takeover found. The start is made
# under the no-hunt marker, as every client start the runner makes is,
# and under the offline marker as well when the takeover found the
# offline service; that client is then proven to be the offline
# service, and a miss is recorded for the baseline's post pass, which
# fails the run.
OFFLINE_BACK_S = 60 # the start to the offline service's listener
def client_markers(self):
"""(path, name) of the markers forgectrl's start is made under."""
if not self.cloud:
return []
marks = [(_baseline.NOHUNT_MARKER, "no-hunt")]
if self.offline:
marks.append((_baseline.OFFLINE_MARKER, "offline"))
return marks
def markers_on(self):
for path, name in self.client_markers():
try:
with open(path, "w") as f:
f.write("forgetest takeover %s\n" % self.who)
except OSError as e:
self.log("takeover: WARNING the %s marker could not be written: %s" % (name, e))
def wait_offline(self):
"""The cloud client's pid once it listens on the offline socket, or
None after OFFLINE_BACK_S."""
deadline = time.time() + self.OFFLINE_BACK_S
while True:
try:
st, m = hw.Forgectrl().get("/mode")
except hw.HwError:
st, m = None, None
pid = m.get("pid") if st == 200 and isinstance(m, dict) else None
if hw.listens_on(pid, _baseline.OFFLINE_SOCKET):
return pid
if time.time() >= deadline:
return None
time.sleep(0.5)
def client_back(self, mode):
"""After forgectrl's start: a marker no client read is taken down
(no client started - the gate, standby, a fault - and a later
start must not come up under it), and the offline service found is
judged against the client that came up."""
if not self.cloud:
return
mode = mode if isinstance(mode, dict) else {}
running = mode.get("mode") == "cloud" and mode.get("controller") == "running"
pid = self.wait_offline() if self.offline and running else None
for path, name in self.client_markers():
if (not running or name == "offline") and os.path.exists(path):
try:
os.remove(path)
self.log("takeover: the %s marker was never read - taken down" % name)
except OSError:
pass
if not self.offline:
return
if pid:
self.log("takeover: the cloud client is back as the offline service (pid %s)" % pid)
return
found = ("a client that is not the offline service (pid %s)" % mode.get("pid") if running
else "no cloud client running (mode %s, controller %s)"
% (mode.get("mode"), mode.get("controller")))
cap = self.run.baseline_captured if self.run is not None else None
if cap is not None:
cap.setdefault("restart_clients", []).append(
{"where": "takeover end", "found": found, "expected": "the offline service"})
self.log("takeover: the offline service did not come back: %s; recorded as a leftover" % found)
def __exit__(self, exc_type, exc, tb):
try:
self.position_at_end()
except Exception as e: # noqa: BLE001 - a reading never keeps forgectrl down
self.log("takeover: WARNING the head's position could not be judged: %s: %s"
% (type(e).__name__, e))
self.restore_attrs()
self.markers_on()
rc, out = hw.initd("forgectrl", "start")
self.log("takeover: forgectrl start -> rc %s" % rc)
try:
@@ -594,7 +848,12 @@ class Takeover:
pass
# leave the machine settled for whatever runs next: the probe
# move done, the controller back (or the ladder's verdict logged)
self.wait_settled()
mode = self.wait_settled()
try:
self.client_back(mode)
except Exception as e: # noqa: BLE001 - the judge never breaks the exit path
self.log("takeover: WARNING the cloud client could not be judged: %s: %s"
% (type(e).__name__, e))
return False
@@ -994,7 +1253,21 @@ class Runner:
run.log(traceback.format_exc().rstrip())
self.fixture_release(run)
try:
self._baseline_post(run, captured)
left = self._baseline_post(run, captured)
# A test hands the machine back as it found it. Anything left
# behind was recorded here and nothing more, so a check that
# measured correctly and walked away with the machine in a
# state nobody chose still passed: that is how the airflow
# check came to leave the purge fan off, and an operator met
# it at their first fire instead of the bench meeting it here.
# The pass is now conditional on the machine being whole. A
# leftover the baseline put back still fails: the restore is
# the bench cleaning up after a defect, not the defect's
# absence.
if left and result == _campaign.PASS:
result = _campaign.FAIL
message = ("the machine was not handed back as found: %s"
% "; ".join(str(x) for x in left))
except Exception as e: # noqa: BLE001 - never lose the result over the cleanup
run.log("baseline: post pass errored: %s: %s" % (type(e).__name__, e))
duration = int(time.time() - run.started)
+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.
Access follows forgectrl's panel (auth.c): the Host header must be an
+12 -4
View File
@@ -1,16 +1,24 @@
# 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
order. Each module registers its tests with @catalog.test."""
from . import image # noqa: F401,E402
from . import kernel # noqa: F401,E402
from . import forgectrl # noqa: F401,E402
from . import commission # noqa: F401,E402
from . import commission_dark # noqa: F401,E402
from . import commission_sheet # noqa: F401,E402
from . import logs # noqa: F401,E402
from . import setup # noqa: F401,E402
from . import setup_dark # noqa: F401,E402
from . import setup_burns # noqa: F401,E402
from . import motion # noqa: F401,E402
from . import cooling # noqa: F401,E402
from . import laser # noqa: F401,E402
from . import camera # noqa: F401,E402
from . import update # noqa: F401,E402
from . import cloud # noqa: F401,E402
# Last: it stands on the setup record's consent and on the cloud suite's dark print.
from . import exthost # noqa: F401,E402
from . import homeoff # noqa: F401,E402
from . import tlsrec # noqa: F401,E402
+133 -84
View File
@@ -1,6 +1,9 @@
"""camera.* - the lid camera pipeline through forgectrl."""
import json
# 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."""
from ..catalog import test
from .. import hw
from ..baseline import LID_LAMP_ATTR
@@ -52,13 +55,16 @@ def _jpeg_size(data):
@test("camera.snapshot", title="Lid camera snapshot and stream", subsystem="camera",
kind="auto", est_min=2,
covers=_CAM_COVERS, requires=["forgectrl.panel-serves"],
covers=_CAM_COVERS,
steps=["Setup: lid closed."],
description="/cam/snapshot returns a JPEG of a plausible size (a black frame compresses "
"far smaller), a second snapshot with the lid lamp switched off differs from it "
"and is smaller (the capture is live, not a frame the pipeline is re-serving, "
"and the lamp lights what the camera sees), and the MJPEG stream starts and "
"stops.")
"stops. A capture marked background=1 yields to a viewer: while the stream is "
"open it is refused in words, the operator's own unmarked capture is still "
"served, and once the viewer stops the background capture is served again. A "
"background value that is neither 0 nor 1 is refused.")
def snapshot(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
@@ -111,6 +117,48 @@ def snapshot(ctx):
ev["stream_ctype"] = ctype
ctx.log("stream: %s, first %d bytes", ctype, len(chunk))
ctx.check("multipart" in ctype and b"\xff\xd8" in chunk, "stream is not an MJPEG multipart")
# A capture nobody is waiting for yields to one somebody is: a
# snapshot borrows the mux and stutters a running stream, which is a
# price worth paying for an operator and not for a program. The
# stream above is closed by now, so one is opened and held here.
bad = fc.get("/cam/snapshot", params={"cam": "lid", "background": "maybe"})
ev["background_bad_value"] = bad
ctx.check(bad[0] == 400, "background=maybe -> %s", bad)
held = urllib.request.urlopen(
urllib.request.Request(fc.base + "/cam/stream", headers={"Host": fc.host_header()}), timeout=10)
try:
held.read(8192)
watching = None
for _ in range(40):
st_, body_ = fc.get("/cam/status")
if isinstance(body_, dict) and body_.get("clients"):
watching = body_
break
ctx.sleep(0.25)
ev["clients_while_watching"] = (watching or {}).get("clients")
ctx.check(watching, "no viewer was counted while the stream was held: %s", body_)
st_, why = fc.get("/cam/snapshot", params={"cam": "lid", "res": "half", "background": "1"})
ev["background_while_watched"] = [st_, why]
ctx.log("background capture while a viewer watches -> %s %s", st_, why)
ctx.check(st_ == 409, "a background capture did not yield to a viewer: %s %s", st_, why)
st_, data_ = fc.get("/cam/snapshot", params={"cam": "lid", "res": "half"}, raw=True)
ev["operator_while_watched"] = [st_, len(data_ or b"")]
ctx.check(st_ == 200 and data_ and data_[:2] == b"\xff\xd8",
"the operator's own capture was refused while watching: %s", st_)
finally:
held.close()
for _ in range(40):
st_, body_ = fc.get("/cam/status")
if isinstance(body_, dict) and not body_.get("clients"):
break
ctx.sleep(0.25)
st_, data_ = fc.get("/cam/snapshot", params={"cam": "lid", "res": "half", "background": "1"}, raw=True)
ev["background_after_watching"] = [st_, len(data_ or b"")]
ctx.check(st_ == 200 and data_ and data_[:2] == b"\xff\xd8",
"a background capture was still refused after the viewer stopped: %s", st_)
ctx.sleep(2)
st, body = fc.get("/cam/status")
ev["cam_status_after"] = body
@@ -121,7 +169,7 @@ def snapshot(ctx):
@test("camera.sensor-profile", title="Camera geometry follows the fitted sensor", subsystem="camera",
kind="auto", est_min=1,
covers=_CAM_COVERS, requires=["forgectrl.panel-serves"],
covers=_CAM_COVERS,
description="/cam/status names the sensor that bound (OV5648 on a 5 MP machine, OV8856 on an "
"8 MP 'HD' one) and reports the geometry that sensor implies, and the JPEGs it "
"actually returns are that size. A machine that reports 'unknown', or whose frames "
@@ -164,7 +212,7 @@ def sensor_profile(ctx):
@test("camera.h264-stream", title="H.264 live stream", subsystem="camera",
kind="auto", est_min=2,
covers=_CAM_COVERS, requires=["forgectrl.panel-serves"],
covers=_CAM_COVERS,
steps=["Setup: lid closed."],
description="/cam/h264 answers a fragmented MP4: a codec header naming the SPS profile, an "
"init segment (ftyp+moov) followed by media fragments (moof+mdat) within a few "
@@ -226,13 +274,15 @@ def h264_stream(ctx):
@test("camera.frame-health", title="Capture delivers whole frames", subsystem="camera",
kind="auto", est_min=1,
covers=_CAM_COVERS, requires=["forgectrl.panel-serves"],
covers=_CAM_COVERS,
description="The capture queue flags a frame errored when it is short, torn, or arrived "
"after the CSI-2 receiver lost sync; forgectrl drops those rather than "
"demosaicing them, and restarts the stream if they persist. A healthy machine "
"captures a burst with none of them. A nonzero corrupt count here is a real "
"signal - a marginal camera ribbon, a mistimed D-PHY - even though the frames "
"themselves never reach a client.")
"demosaicing them, restarts the stream if they persist, and withholds the "
"stream frames on either side of one. A healthy machine captures a burst of "
"snapshots and a few seconds of stream with none of them flagged and none "
"withheld, and reports the stream's per-stage timing while a viewer watches. "
"A nonzero corrupt count here is a real signal - a marginal camera ribbon, a "
"mistimed D-PHY - even though the frames themselves never reach a client.")
def frame_health(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
@@ -269,18 +319,63 @@ def frame_health(ctx):
ctx.check(corrupt == 0, "%d of %d captured frames came back errored", corrupt, captured)
ctx.check(restarts == 0, "the capture stream was restarted %d time(s)", restarts)
# The stream path: five seconds of MJPEG, so the frame gate judges a
# few dozen frames and the timing covers two whole windows. A frame is
# withheld only next to one the receiver flagged, so a healthy machine
# withholds none, and the per-stage timing is reported while the
# viewer watches.
import time
import urllib.request
s0 = health()
stream = urllib.request.urlopen(
urllib.request.Request(fc.base + "/cam/stream", headers={"Host": fc.host_header()}),
timeout=10)
try:
got = 0
t_end = time.monotonic() + 5.0
while time.monotonic() < t_end:
got += len(stream.read(65536))
st, body = fc.get("/cam/status")
timing = body.get("timing") if isinstance(body, dict) else None
finally:
stream.close()
s1 = health()
ev["stream_bytes"] = got
ev["stream_timing"] = timing
ev["stream_health"] = [s0, s1]
ctx.log("stream: %d bytes in 5 s, timing %s", got, timing)
s_captured = (s1.get("captured") or 0) - (s0.get("captured") or 0)
s_corrupt = (s1.get("corrupt") or 0) - (s0.get("corrupt") or 0)
s_withheld = (s1.get("withheld") or 0) - (s0.get("withheld") or 0)
ev["stream_captured"] = s_captured
ev["stream_withheld"] = s_withheld
ctx.check(s_captured >= 30, "only %d frames were dequeued in 5 s of stream", s_captured)
ctx.check(s_corrupt == 0, "%d stream frames came back errored", s_corrupt)
ctx.check("withheld" in s1, "/cam/status health carries no withheld count: %s", s1)
ctx.check(s_withheld == 0, "%d stream frames were withheld with none flagged", s_withheld)
ctx.check(isinstance(timing, dict) and
all(k in timing for k in ("latency_ms", "convert_ms", "copy_ms", "encode_ms")),
"/cam/status carries no timing block while a viewer watches: %s", body)
latency = (timing or {}).get("latency_ms") or 0
ctx.check(0 < latency < 250, "capture to publication took %.1f ms while streaming", latency)
@test("camera.lid-privacy", title="Cameras capture only with the lid closed", subsystem="camera",
@test("camera.lid-privacy", title="Cameras see the room only with the lid closed", subsystem="camera",
kind="operator", est_min=3,
covers=_PRIVACY_COVERS, requires=["forgectrl.panel-serves"], actions=["lid"],
covers=_PRIVACY_COVERS + [("forgectrl", "src/auth.*")],
actions=["lid"],
steps=["Start with the lid closed; you will be told to open it, then close it again.",
"Nothing moves and the laser is not involved."],
description="The privacy gate: with the lid open neither camera captures. A running stream "
"stops within a frame or so of the lid opening, /cam/status reports capture as "
"not allowed, and both the snapshot and the stream are refused with 409 and a "
"reason naming the lid. Closing the lid restores all of it. This is what stops "
"the machine - and in cloud mode the Glowforge service - from imaging the room "
"through an open lid.")
description="The privacy gate: with the lid open the lid camera does not capture, and the head "
"camera captures only for a local viewer. A running lid-camera stream stops within a "
"frame or so of the lid opening, /cam/status reports capture as not allowed, and the "
"lid camera's snapshot and stream are refused with 409 and a reason naming the lid, "
"to the panel too. The head camera, which looks down at the bed, still answers the "
"panel (its token) and the extension host (its client header from this host), while "
"the same snapshot and stream asked without either - the cloud client's form - are "
"refused for the lid. Closing the lid restores all of it. This is what stops the "
"machine - and in cloud mode the Glowforge service - from imaging the room through "
"an open lid.")
def lid_privacy(ctx):
import urllib.error
import urllib.request
@@ -361,9 +456,26 @@ def lid_privacy(ctx):
ctx.check(b"lid" in data.lower(), "the refusal should name the lid: %r", data[:120])
ctx.check(data[:2] != b"\xff\xd8", "a JPEG was returned with the lid open")
# The head camera looks at the bed: a local viewer still has it, the cloud client's form does not.
st, data = fc.get("/cam/snapshot", params={"cam": "head", "res": "half"}, raw=True)
ctx.log("lid open: head snapshot -> %s", st)
ctx.check(st == 409, "the head camera should be refused too, got %s", st)
ctx.log("lid open: the panel's head snapshot -> %s", st)
ctx.check(st == 200 and data[:2] == b"\xff\xd8", "the panel's head snapshot with the lid open -> %s", st)
st, data = fc.get("/cam/snapshot", params={"cam": "head", "res": "half"}, raw=True, auth=False,
headers={"X-ForgeFIRM-Client": "extension-host"})
ctx.log("lid open: the extension host's head snapshot -> %s", st)
ctx.check(st == 200 and data[:2] == b"\xff\xd8", "the extension host's head snapshot with the lid open -> %s", st)
st, data = fc.get("/cam/snapshot", params={"cam": "head", "res": "half"}, raw=True, auth=False)
ev["head_cloud_form_lid_open"] = [st, (data or b"")[:120].decode("utf-8", "replace")]
ctx.log("lid open: head snapshot without a token or the header -> %s %s", st, (data or b"")[:120])
ctx.check(st == 409 and b"lid" in (data or b"").lower(),
"the head camera without a token or the header should be refused for the lid, got %s", st)
try:
hreq = urllib.request.Request(fc.base + "/cam/stream?cam=head", headers={"Host": fc.host_header()})
with urllib.request.urlopen(hreq, timeout=10) as r:
ctx.fail("the head stream opened with the lid open, for no local viewer (%s)", r.status)
except urllib.error.HTTPError as e:
ctx.log("lid open: head stream for no local viewer -> %s", e.code)
ctx.check(e.code == 409, "the head stream for no local viewer should be refused with 409, got %s", e.code)
try:
with urllib.request.urlopen(req, timeout=10) as r:
@@ -382,66 +494,3 @@ def lid_privacy(ctx):
ctx.check(st == 200 and data[:2] == b"\xff\xd8",
"snapshot after closing the lid -> %s", st)
ctx.log("lid closed again: snapshot %d bytes", len(data))
@test("camera.key-read", title="The camera key reads without a login", subsystem="camera",
kind="auto", est_min=1,
covers=[("forgectrl", "src/camkey.*"), ("forgectrl", "src/auth.c"),
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/panel.js"),
("forgectrl", "src/ui/index.html"), ("forgectrl", "src/ui/help.js")],
requires=["forgectrl.auth"],
description="With the read routes closed to the network (panel_open_reads=0), a camera "
"status read from the board's LAN address is refused without the key and "
"served with it, on HTTP and on HTTPS; a wrong key is refused; the key never "
"authorizes a write; a rotation from the panel route stops the old key and "
"the new one reads. The setting is put back after.")
def key_read(ctx):
from .commission import request
from .forgectrl import lan_ip
fc = ctx.forgectrl
ev = ctx.evidence
ip = lan_ip()
ctx.check(ip, "cannot determine the board's LAN address")
http_base = "http://%s" % ip
tls_base = "https://%s" % ip
token = {"X-ForgeFIRM-Token": fc.token}
st, body, _ = request(fc.base, "GET", "/system/camera-key", headers=token)
ctx.check(st == 200, "GET /system/camera-key from loopback -> %s", st)
key = json.loads(body)["key"]
ctx.check(len(key) == 32, "the key is not 32 characters: %r", key)
ev["key_len"] = len(key)
before = fc.settings().get("panel_open_reads", "")
st, _ = fc.post("/settings", params={"panel_open_reads": "0"})
ctx.check(st == 200, "closing the reads -> %s", st)
try:
for base in (http_base, tls_base):
st, _, _ = request(base, "GET", "/cam/status")
ctx.log("GET %s/cam/status (no key) -> %s", base, st)
ctx.check(st == 403, "a closed read without the key -> %s, expected 403", st)
st, _, _ = request(base, "GET", "/cam/status?key=" + key)
ctx.log("GET %s/cam/status?key=... -> %s", base, st)
ctx.check(st == 200, "a read with the key -> %s, expected 200", st)
st, _, _ = request(base, "GET", "/cam/status", headers={"X-ForgeFIRM-Camera-Key": key})
ctx.check(st == 200, "a read with the key header -> %s, expected 200", st)
wrong = ("0" if key[0] != "0" else "1") + key[1:]
st, _, _ = request(base, "GET", "/cam/status?key=" + wrong)
ctx.check(st == 403, "a wrong key -> %s, expected 403", st)
# the key is a read credential only
st, _, _ = request(tls_base, "POST", "/settings", data={"panel_open_reads": "0"},
headers={"X-ForgeFIRM-Camera-Key": key})
ctx.log("POST /settings with the camera key -> %s", st)
ctx.check(st == 403, "the camera key authorized a write (%s)", st)
# rotation
st, body, _ = request(fc.base, "POST", "/system/camera-key?rotate=1", headers=token)
ctx.check(st == 200, "rotate -> %s", st)
new = json.loads(body)["key"]
ctx.check(new != key, "rotation kept the same key")
st, _, _ = request(http_base, "GET", "/cam/status?key=" + key)
ctx.check(st == 403, "the old key still reads after rotation (%s)", st)
st, _, _ = request(http_base, "GET", "/cam/status?key=" + new)
ctx.check(st == 200, "the new key does not read (%s)", st)
ev["rotated"] = True
finally:
fc.post("/settings", params={"panel_open_reads": before})
+135 -286
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
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
@@ -87,7 +92,9 @@ FORGECTRL_LOG = "/data/log/forgefirm/forgectrl/forgectrl.log"
LIMITS_MARK = "job limits from the header: "
EFFECTIVE_MARK = "effective limits: coolant ceiling "
SESSION_MARKS = ("authenticate_machine SUCCESS", "ws_connect ESTABLISHED")
RETURN_MAX_MM = 600.0 # the head comes back from the home corner across the bed
# 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"
def log_size(path):
@@ -112,40 +119,7 @@ def session_established(lines):
return all(any(m in ln for ln in lines) for m in SESSION_MARKS)
def return_head(ctx, feed=2400):
"""Jog the head back to where the run found it: cloud mode re-zeroed the
kernel counters at the starting position, so the counters now read the
displacement (the home corner). Ends on the machine idle."""
fc = ctx.forgectrl
pos = fc.status().get("pos") or {}
x, y = float(pos.get("x", 0.0)), float(pos.get("y", 0.0))
ctx.log("head displacement since the switch: X %.3f Y %.3f mm", x, y)
ctx.check(abs(x) <= RETURN_MAX_MM and abs(y) <= RETURN_MAX_MM,
"displacement %.1f/%.1f mm exceeds %.0f mm - not jogging back", x, y, RETURN_MAX_MM)
if abs(x) < 0.05 and abs(y) < 0.05:
return
with ctx.grbl() as g:
st = g.status_report()["state"]
if st.startswith("Alarm"):
g.command("$X")
r = g.command("$J=G91X%.3fY%.3fF%d" % (-x, -y, feed))
ctx.check(not any(k.startswith("error") for k in r), "return jog refused: %s", r)
t0 = time.time()
while time.time() - t0 < 120:
ctx.checkpoint()
st = g.status_report()["state"]
if st.startswith("Idle") and time.time() - t0 > 0.5:
break
time.sleep(0.2)
g.command("G90")
ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle after the return jog")
pos = fc.status().get("pos") or {}
ctx.log("head returned: counters X %.3f Y %.3f mm", float(pos.get("x", 0)), float(pos.get("y", 0)))
ctx.check(abs(float(pos.get("x", 0))) < 0.1 and abs(float(pos.get("y", 0))) < 0.1,
"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()
last = None
while time.time() - t0 < timeout:
@@ -157,7 +131,7 @@ def wait_mode(ctx, fc, want_mode, want_controller="running", timeout=90):
return m
if m.get("controller") == "motion-fault":
break
time.sleep(1)
time.sleep(poll)
return last
@@ -227,6 +201,11 @@ def gfhome_homing(ctx, ev, g):
# homed: the counters are re-anchored at the corner, where the head stays
ctx.counters_rezeroed()
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 "
@@ -235,26 +214,38 @@ def gfhome_homing(ctx, ev, g):
covers=_HOMING_PATH + [("forgectrl", "src/cool.*"), ("forgectrl", "src/airflow.*"),
("grblhal-glowforge", "src/**")],
requires=["forgectrl.auth", "motion.pacing"], actions=["lid"],
steps=["Bed clear; cloud credentials configured; the machine on the network. The test "
"turns cloud mode and the gfcloud homing on itself when they are off, and puts "
"the settings back at the end.",
"Open the lid when told and leave it open through the cloud client's connect and its "
"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."],
steps=["Bed clear, lid closed; cloud credentials configured; the machine on the network. "
"The test turns cloud mode and the gfcloud homing on itself when they are off, and "
"puts the settings back at the end.",
"Open the lid the moment you are told, with a hand ready on it: 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; close it when told. Nothing "
"else: the switch back and the $H homing run on their own, and the head ends where the "
"test found it."],
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 "
"connect-time firmware probe is recorded when configured). Its connect-time hunt "
"runs with the lid OPEN, as the factory's does - it completes, nothing before its "
"connect-time firmware probe is recorded when configured). The lid is opened the "
"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 "
"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 "
"service's re-hunt is waited out; switching back brings grblHAL up with the Grbl "
"port open and Idle, and the head returns to its start. Then $H with "
"port open and Idle, and the head returns to its start by the travel the "
"client logged for the service's motions. Then $H with "
"homing_mode = gfcloud runs gfhome, the web-service homing session with the "
"head-accelerometer motion witness: the controller returns to Idle with "
"homed:true and gfhome reports the homing complete with the motion it saw.")
"homed:true, gfhome reports the homing complete with the motion it saw, and "
"every service motion of the session ran whole. The camera home is then "
"dropped and the head goes back to where the test found it, by the travel the "
"session's motions logged.")
def mode_switch(ctx):
# The hand-back helpers, imported here so that no other cloud test's
# fingerprint moves.
from .homeoff import camera_home_return, cloud_mode_return, judge_whole_motions, session_mark
fc = ctx.forgectrl
ev = ctx.evidence
st, m0 = fc.get("/mode")
@@ -270,17 +261,32 @@ def mode_switch(ctx):
except OSError:
pass
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)
st, body = fc.post("/mode", data={"controller": "cloud"})
ctx.log("POST /mode controller=cloud -> %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
ctx.log("mode after switch: %s", m)
ctx.check(m and m.get("mode") == "cloud" and m.get("controller") == "running",
"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
session = wait_session(ctx, log_offset)
probe = None
@@ -350,10 +356,9 @@ def mode_switch(ctx):
st, cam2 = fc.get("/cam/status")
ev["cam_after"] = cam2
ctx.check(st == 200, "camera status lost after the switch back")
# cloud mode's connect cleared the kernel counters at the starting position
# and its hunt homed the head: bring it back
ctx.counters_rezeroed()
return_head(ctx)
# The service moved the head in cloud mode, and the controller's start
# zeroed the counters where it left it: the client's record brings it back.
cloud_mode_return(ctx, ev, log_offset)
# cloud mode sets its own lid-lamp level (LLvl) and leaves it: hand back the level found
lamp1 = hw.sysfs_read("pic/lid_led")
ev["lid_lamp"] = {"before": lamp0, "after_cloud": lamp1}
@@ -367,13 +372,22 @@ def mode_switch(ctx):
st, body = fc.post("/settings", data={"homing_mode": "gfcloud"})
ctx.log("homing_mode=gfcloud for the homing -> %s %s", st, body if isinstance(body, str) else "")
ctx.check(st == 200, "homing_mode=gfcloud -> %s %s", st, body)
session_at = session_mark()
try:
with ctx.grbl() as g:
gfhome_homing(ctx, ev, g)
judge_whole_motions(ctx, ev, session_at)
finally:
if ev["homing_mode"] != "gfcloud":
st, body = fc.post("/settings", data={"homing_mode": ev["homing_mode"] or "none"})
# Back to exactly what the machine had: unset is the empty
# string, and clearing a key needs the query-string form.
# "none" is a value, and writing it where the machine had
# nothing is a leftover the hand-back reports.
st, body = (fc.post("/settings", params={"homing_mode": ""})
if not ev["homing_mode"]
else fc.post("/settings", data={"homing_mode": ev["homing_mode"]}))
ctx.log("restore homing_mode=%r -> %s", ev["homing_mode"], st)
camera_home_return(ctx, ev, session_at)
ctx.log("PASS: grbl -> cloud (session, hunt with the lid open, lens homed, airflow unjudged) -> "
"grbl (port open, %s), then $H homed in %.1f s", ev["grbl_state"], ev["homing_s"])
@@ -488,9 +502,18 @@ NOHUNT_MARK = "NO-HUNT:"
NOHUNT_MARKER = "/run/gfcloud-nohunt"
HUNT_DONE = "hunt ["
WS_MARKS = ("RX-EVENT: ready", "RX-EVENT: closed", "RECONNECTING", "CLOSING", OFFLINE_MARK)
ACTIVITY_MARKS = ("start motion", "start return home", "starting run", "starting z homing cycle")
# The service at work: every line of a service action, the requests, the image
# uploads and the ends included. Between a lid image and its next move the
# service is thinking and the log is silent, so the ends count: the quiet is
# measured from the last line of any action.
ACTIVITY_MARKS = ("service action request:", "img_upload COMPLETE", "finished with event",
"start motion", "end motion", "start return home", "return home complete",
"starting run", "finished run", "starting z homing cycle")
LOG_TAIL_BYTES = 4 << 20
QUIET_S = 8 # the re-hunt's motions are ~4 s apart (a lid image between them)
# The service's think time after an image, on the bench reference: over 1901
# motion, lid-image and hunt requests, half came within 1.6 s of the line before,
# 99 percent within 12.8 s, and two after more than 30 s (32.8 and 62.2 s).
QUIET_S = 30
QUIET_TIMEOUT_S = 180
HUNT_TIMEOUT_S = 180
@@ -547,6 +570,42 @@ def wait_log(ctx, offset, needles, timeout, poll=0.5):
return found
# What the client logs when it turns a print away before the button wait
# (machine._safe_to_move): the lid or the interlock, a machine that is not
# idle, the coolant above the start ceiling of its own configuration
# (THERMAL.max_start_temp), a coolant sensor that reads nothing.
REFUSED_BEFORE_THE_BUTTON = ("unsafe to move", "machine is not idle", "machine temp is too high",
"coolant sensor reads invalid")
def wait_button_wait(ctx, offset, timeout, also=()):
"""Wait for the client's button wait (and the `also` needles). A print
the client turns away never gets there: it logs why and finishes at
once, so the wait ends on that finish line too and the failure carries
the client's own reason, not two minutes of silence. Returns wait_log's
result."""
needles = list(also) + ["waiting for button"]
found = {n: None for n in needles}
t0 = time.time()
while time.time() - t0 < timeout:
ctx.checkpoint()
lines = log_lines_since(GFCLOUD_LOG, offset)
for ln in lines:
for n in needles:
if found[n] is None and n in ln:
found[n] = ln
if all(found.values()):
break
if found["waiting for button"] is None and action_finish_index(lines, "print") is not None:
why = [message(ln) for ln in lines if any(r in ln for r in REFUSED_BEFORE_THE_BUTTON)]
fin = lines[action_finish_index(lines, "print")]
ctx.fail("the client turned the print away before the button wait: %s (%s)",
"; ".join(why[-3:]) or "it logged no reason", message(fin))
time.sleep(0.5)
ctx.check(found["waiting for button"], "the print never reached the button wait")
return found
def log_has(offset, needle):
"""True when the gfcloud log carries needle since offset (one read;
the condition an `act` waits on)."""
@@ -679,9 +738,9 @@ def client_offline(pid):
def wait_quiet(ctx, offset, quiet_s=None, timeout=None):
"""The service's moves are over: the machine idle and no new service
activity in the log (a motion, a park, a run, a lens homing) for
quiet_s (default QUIET_S; timeout QUIET_TIMEOUT_S). False on timeout."""
"""The service's moves are over: the machine idle and no new line of any
service action in the log (ACTIVITY_MARKS) for quiet_s (default QUIET_S;
timeout QUIET_TIMEOUT_S). False on timeout."""
quiet_s = QUIET_S if quiet_s is None else quiet_s
timeout = QUIET_TIMEOUT_S if timeout is None else timeout
fc = ctx.forgectrl
@@ -1004,9 +1063,7 @@ def offline_start_print(ctx, off, action_id, path, offset, settings=None):
client loads the job, lights the button and waits; the operator's
press arms it and the run starts. Returns the 'starting run' line."""
off.print_ready(action_id, path, settings)
got = wait_log(ctx, offset, ["waiting for button"], 120)
ctx.check(got["waiting for button"], "the offline print never reached the button wait (refused, "
"or the job did not load)")
wait_button_wait(ctx, offset, 120)
ctx.act("button", "press", text="The button is lit white: the press arms the print and the "
"head starts to move. Nothing fires: the job commands no laser.",
until=lambda: wait_print_running(ctx, offset, 0.1) is not None, timeout=180)
@@ -1205,173 +1262,30 @@ def lid_interlock_abort_body(ctx, ev, off, job, offset):
ev.get("edge_to_stop_ms"))
@test("cloud.lid-during-button-wait", title="Lid open at the cloud button prompt cancels the print",
subsystem="cloud", kind="operator", est_min=8,
covers=_MACHINE_RUN, requires=[], actions=["lid"],
steps=[OFFLINE_STEP, LID_STEP,
"The job is longer than the ring holds, so the ring is filled before the button "
"lights; that takes a minute. When the button lights white, do NOT press it - open "
"the lid, and close it when told. Nothing moves and nothing fires."],
description="A cloud print waiting for the button is canceled by the lid: the wait ends "
"with the lid named as the reason, the laser latch relocks, the armed window "
"closes, no run starts, and the job ends ':cancelled'. The job is longer than "
"the ring, so its feeder is alive through the wait with the rest of the print "
"in hand: the cancel has to stop that feeder before the park clears the ring, "
"and the ring has to stay empty afterward. A feeder left alive would refill "
"it and the park, which ignores the lid, would play the print's path.")
def lid_during_button_wait(ctx):
ev = ctx.evidence
offset = enter_offline(ctx)
job = offline_job(ctx, "wait.puls", seconds=3500)
off = Offline().__enter__()
try:
lid_during_button_wait_body(ctx, ev, off, job, offset)
finally:
off.__exit__(None, None, None)
offline_cleanup(ctx)
# ---------------------------------------------------------------- the dark print (exthost.armed-freeze)
def lid_during_button_wait_body(ctx, ev, off, job, offset):
off.print_ready(9003, job)
got = wait_log(ctx, offset, ["job is longer than the ring", "waiting for button"], 180)
ctx.check(got["job is longer than the ring"],
"the job fit the ring: this test needs a job the ring cannot hold")
ctx.check(got["waiting for button"], "the print never reached the button wait")
ctx.act("lid", "open", text="The button is lit: do NOT press it.", timeout=120)
relock = "button wait lid opened - relocking the laser"
got = wait_log(ctx, offset, [relock], 60)
fin = wait_action_finished(ctx, offset, "print", 60)
ev["log"] = {relock: bool(got[relock]), "print finished": message(fin)}
ctx.check(got[relock], "the lid did not end the button wait")
ctx.check(fin and CANCELLED in fin, "the print did not end ':cancelled': %s",
message(fin) or "no finish line")
# No run may start between the button wait and the cancel: the print
# itself, or a park (the head never moved, there is nothing to park).
# The service's moves BEFORE the print are legitimate runs and are
# outside this window.
lines = log_lines_since(GFCLOUD_LOG, offset)
wait_i = next((i for i, ln in enumerate(lines) if "waiting for button" in ln), None)
end_i = action_finish_index(lines, "print")
if end_i is None or wait_i is None or end_i < wait_i:
end_i = len(lines)
ran = ([ln for ln in lines[wait_i:end_i] if "starting run" in ln]
if wait_i is not None else [])
ev["runs_started_after_wait"] = len(ran)
ctx.check(not ran, "a run started after the lid-open cancel (%d)", len(ran))
st, cs = ctx.forgectrl.get("/cool/status")
ev["armed_after"] = cs.get("armed") if isinstance(cs, dict) else None
ev["latch_locked"] = latch_locked()
ctx.check(not ev["armed_after"], "armed window still open after the cancel")
ctx.check(ev["latch_locked"], "kernel latch not locked after the cancel")
ev["button_dark"] = hw.button_lit()
ctx.check(ev["button_dark"] is False, "the button is still lit after the cancel (%s)", ev["button_dark"])
# The park cleared the ring and nothing refilled it: the feeder is gone,
# the device is out of live-feed mode, and the program total stands at
# zero and stays there. A feeder left alive shows here as a total that
# comes back within its retry period.
total = read_program_total()
ctx.sleep(2)
later = read_program_total()
streaming = hw.sysfs_int("cnc/streaming", 0)
ev["program_total_after"] = {"first": total, "later": later}
ev["streaming_after"] = streaming
ctx.check(total == 0 and later == 0,
"the ring is not empty after the cancel (program total %s, then %s): the feeder "
"outlived the job", total, later)
ctx.check(streaming == 0, "cnc/streaming is %s after the cancel", streaming)
ctx.act("lid", "close")
settle_cloud(ctx, offset)
ev["events"] = offline_events(off)
ctx.log("PASS: lid open at the button prompt canceled the print; latch locked, armed=false, "
"button dark, ring empty with the feed stopped")
# ---------------------------------------------------------------- the cooling verdict
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
WARMUP_RELEASE_S = 720 # the flow heater warms the bulk 0.4 to 0.8 C a minute
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",
subsystem="cloud", kind="operator", est_min=14,
covers=_MACHINE_RUN + [("python3-gfhardware", "gfhardware/coolsvc.py"),
("forgectrl", "src/cool.*"), ("forgectrl", "src/main.c")],
requires=["cloud.lid-during-button-wait", "cooling.floor-and-warm-up"], actions=["button"],
steps=[OFFLINE_STEP,
"Coolant at room temperature (10 to 30 C). The test sets the warm-up gate just above "
"the coolant and restores it. Press the button when it lights: the print then waits "
"for the warm-up (a few minutes with the loop heater on), runs dark, and finishes."],
description="The cooling-engine contract in cloud mode, on the engine itself: the start gate "
"set just above the coolant makes the armed session open under the warm-up "
"(WARMUP: fire blocked, hold), and the client waits it out after the button "
"instead of canceling - nothing runs, the latch stays unlocked for the armed "
"window - until the engine releases into run, and the release starts the print, "
"which completes. The hold mid-run (the same laser-off pause as the button's, "
"resumed on the engine's resume_ok) and the bound on a hold are host-tested: the "
"gates apply at session open, so no setting can produce a hold mid-run here.")
def verdict_hold(ctx):
ev = ctx.evidence
fc = ctx.forgectrl
before = fc.settings()
orig = {k: before.get(k, "") for k in HOLD_KEYS}
ev["orig"] = orig
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)
offset = enter_offline(ctx)
job = offline_job(ctx, "hold.puls", seconds=30)
off = Offline().__enter__()
try:
verdict_hold_body(ctx, ev, off, job, offset, fc, up, orig)
finally:
st, body = fc.post("/settings", params=orig)
ctx.log("restore: POST /settings %s -> %s", orig, st)
off.__exit__(None, None, None)
offline_cleanup(ctx)
def verdict_hold_body(ctx, ev, off, job, offset, fc, up, orig):
gate = round(up + WARMUP_ABOVE_C, 1)
st, body = fc.post("/settings", params={"cool_temp_start": str(gate),
"cool_temp_min": orig["cool_temp_min"]})
ctx.check(st == 200, "POST /settings cool_temp_start=%s -> %s %s", gate, st, body)
ev["gate"] = gate
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")
ctx.act("button", "press", text="The button is lit: press it. The print then waits for the warm-up.",
until=lambda: log_has(offset, WARMUP_WAIT_LINE), timeout=PRESS_TIMEOUT_S)
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")
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()
run = wait_print_running(ctx, offset, WARMUP_RELEASE_S)
finally:
ctx.clear_notice()
ev["release_s"] = round(time.time() - t0)
got = wait_log(ctx, offset, [WARMUP_RELEASE_LINE], 5)
ev["log"] = {WARMUP_WAIT_LINE: True, WARMUP_RELEASE_LINE: bool(got[WARMUP_RELEASE_LINE])}
ctx.check(got[WARMUP_RELEASE_LINE], "the warm-up did not release into the run within %d s",
WARMUP_RELEASE_S)
ctx.check(run is not None, "the print did not start after the warm-up")
wait_button_wait(ctx, offset, 120)
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 after the warm-up: %s",
message(fin) or "no finish line")
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: the armed print waited %s s on the warm-up, then ran and completed", ev["release_s"])
ctx.log("PASS: locked at the button, unlocked for the run, locked after it; the print completed")
@test("cloud.pause-resume", title="Button pauses and resumes a cloud print (factory backtrack + lead)",
@@ -1607,68 +1521,3 @@ def oversize_stream_body(ctx, ev, off, job, offset, before):
"stopped it, parked, relocked and reported ':cancelled'")
@test("cloud.paused-lid-cancel", title="A paused cloud print is canceled by the lid",
subsystem="cloud", kind="live", est_min=6,
covers=_MACHINE_RUN, requires=["cloud.pause-resume", "cloud.lid-interlock-abort"],
actions=["button", "lid"],
steps=[OFFLINE_STEP, ARM_STEP, LID_STEP,
"The head needs 40 mm of free +X and +Y travel.",
"Press the button to start; when asked, press it again (pause), then open the lid and "
"leave it open until the head is back; close it when told."],
description="A job paused on the button is canceled by the lid, from the state the factory "
"ends it in - there is no resume past a lid open. The same tail as every abort: "
"the motion stops, the head parks back at the job start, the latch relocks and "
"the armed window closes, the button goes dark, and the print ends ':cancelled'. "
"(The app's own cancel of a running print is exercised where a print has to be "
"ended that way: cloud.oversize-stream.)")
def paused_lid_cancel(ctx):
ev = ctx.evidence
offset = enter_offline(ctx)
job = offline_job(ctx, "paused.puls", seconds=60)
off = Offline().__enter__()
try:
paused_lid_cancel_body(ctx, ev, off, job, offset)
finally:
off.__exit__(None, None, None)
offline_cleanup(ctx)
def paused_lid_cancel_body(ctx, ev, off, job, offset):
# -- paused on the button, then the lid ---------------------------------
offline_start_print(ctx, off, 9005, job, offset)
ctx.act("button", "press", text="The head is moving: the press pauses the print.",
until=lambda: log_has(offset, PAUSE_LINES[0]), timeout=PRESS_TIMEOUT_S, fail=False)
got = wait_log(ctx, offset, list(PAUSE_LINES), 10)
ev["paused"] = {k: bool(v) for k, v in got.items()}
ctx.check(got["button pressed mid-run; pausing"], "the press did not pause the print")
ctx.check(got["paused at"], "the pause did not settle (no 'paused at')")
ctx.act("lid", "open", text="The print is paused: leave the lid open until the head has returned "
"to the corner.", timeout=60)
lid_stop = "lid opened mid-run; stopping motion"
got = wait_log(ctx, offset, [lid_stop, "start return home", "return home complete"], 120)
fin1 = wait_action_finished(ctx, offset, "print", 60)
ev["lid_from_pause"] = {k: message(v) for k, v in got.items()}
ev["lid_from_pause"]["print finished"] = message(fin1)
for k, v in ev["lid_from_pause"].items():
ctx.log(" [print] %s: %s", k, "seen" if v else "MISSING")
ctx.check(got[lid_stop], "the lid did not end the paused print")
ctx.check(got["return home complete"], "the paused print did not park to completion")
ctx.check(fin1 and CANCELLED in fin1, "the print did not end ':cancelled': %s",
message(fin1) or "no finish line")
ctx.check(ctx.forgectrl.wait_idle(15, abort=ctx.aborted), "machine not idle after the park")
kpos = read_position()
ev["counters_after"] = kpos
ctx.check(kpos is not None and abs(kpos[0]) <= 3 and abs(kpos[1]) <= 3,
"the head did not come back to the job start (kernel counters %s)", kpos)
st, cs = ctx.forgectrl.get("/cool/status")
ev["armed_after"] = cs.get("armed") if isinstance(cs, dict) else None
ev["latch_locked_after"] = latch_locked()
ctx.check(not ev["armed_after"], "armed window still open after the paused print was canceled")
ctx.check(ev["latch_locked_after"],
"kernel latch not locked after the paused print was canceled")
ev["button_dark"] = hw.button_lit()
ctx.check(ev["button_dark"] is False, "the button is still lit after the cancel (%s)", ev["button_dark"])
ctx.act("lid", "close")
settle_cloud(ctx, offset)
ev["events"] = offline_events(off)
ctx.log("PASS: a paused print canceled by the lid stopped, parked, relocked and reported ':cancelled'")
File diff suppressed because it is too large Load Diff
@@ -1,196 +0,0 @@
"""commission.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
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
piece of wood at least 200 x 150 mm (8 x 6 in) carries the placement,
the frame, and the five cards. Each wizard re-runs on the real record,
so a completed one completes again at the same version.
The operator's part is one press on the machine at the start (the
presence check of the ready gate; with the bench actuator up it then
makes every arm press) and nothing else. The judgments a person makes
by eye on the sheet (the thinnest line, the faintest rung, the best
corner, the frame's fit) are answered here with a middle option or the
witnesses: the test proves each wizard's mechanics and the emission
witnesses, not the operator's eye, and the settings it writes are
restored. The page is where the real numbers go in.
"""
import re
from ..catalog import test
from .commission_dark import run_check, Restore
SHEET_COVERS = [("forgectrl", "src/wizlive.*"), ("forgectrl", "src/wizrun.h"),
("forgectrl", "src/sheet.*"),
("forgectrl", "src/font_hershey.*"), ("forgectrl", "src/jobstream.*"),
("forgectrl", "src/curverec.*"), ("forgectrl", "src/wizdark.*"),
("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"),
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*"),
("forgectrl", "src/super.c"),
("forgectrl", "src/status.c"), ("forgectrl", "src/cool.c"),
("forgectrl", "src/accel.*"), ("forgectrl", "src/wizcalc.*"),
("grblhal-glowforge", "src/glowforge_laser.c"),
("grblhal-glowforge", "src/glowforge_status.c"),
("grblhal-glowforge", "src/glowforge_homing.*"),
("grblhal-glowforge", "src/glowforge_io.*")]
# The live wizards in the order the sheet burns them: the wizard id, the
# answers to its prompts (a middle pick where a person would look at the
# sheet), the keys its result must carry, and how long the bench actuator
# waits for the button to light before the arm press goes to a person.
# The flow-load card settles the coolant loop before it lights the button
# (60 to 240 s); the others light within seconds of the lens reference.
CARDS = [
("sheet.frame", {"frame-arm": "Continue", "frame-ok": "Yes"}, ["mark_s", "mark_feed"], 120),
("laser.focus", {"focus-arm": "Continue", "focus-pick": "11", "thickness": "Keep"},
["pick", "thickness_mm", "pick_half_steps", "edge_z_mm", "steps_per_mm", "max_height_mm",
"focus_range_mm", "stops"], 120),
("laser.floor", {"floor-arm": "Continue", "floor-pick": "8"}, ["faintest_density", "floor_density"], 120),
("laser.dose-curve", {"dose-arm": "Continue"}, ["points", "curve"], 120),
("laser.corner", {"corner-arm": "Continue", "corner-pick": "1.50"}, ["gamma"], 120),
("cooling.flow-load", {"load-arm": "Continue"}, ["lit_s", "dose_raw_s", "peak_c", "k_density", "k_cw"], 420),
]
# Every setting a card writes, restored as found when the run ends.
CARD_SETTINGS = ["lens_hall_edge_z_mm", "lens_stop_below_steps", "lens_stop_above_steps",
"laser_floor_density", "laser_dose_curve", "laser_corner_gamma",
"cool_laser_heat_density", "cool_laser_heat_cw"]
# The evidence run_check and the witnesses write at the top level, moved
# under the card's own key once it is done.
CARD_EVIDENCE = ("started", "elapsed_s", "phases", "log", "error", "result",
"preview_bytes", "program_lines", "emission")
def preview_ok(ctx, wid):
"""The page's preview and the program for the wizard are served."""
fc = ctx.forgectrl
st, body = fc.get("/wiz/sheet.svg?card=%s" % wid, raw=True)
ctx.check(st == 200 and body.lstrip().startswith(b"<svg"), "no preview for %s (%s)", wid, st)
ctx.evidence["preview_bytes"] = len(body)
st, body = fc.get("/wiz/sheet.gcode?card=%s" % wid, raw=True)
# The text and the frame are M4 at the mark dose; the patterns bring
# their own M3 or M4 lines. A program is one with a laser-on command.
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)
ctx.evidence["program_lines"] = body.count(b"\n")
def emission_ok(ctx, last):
"""The three witnesses of the burn, from the wizard's result."""
e = (last.get("result") or {}).get("emission") or {}
ctx.evidence["emission"] = e
ctx.check(e.get("hv_max", 0) > 30, "the tube current never rose (%s)", e.get("hv_max"))
ctx.check(e.get("laser_on_samples", 0) > 0, "the kernel never sampled LASER_ON")
ctx.check(e.get("thermopile_delta", 0) >= 100, "the thermopile did not see the beam (%s)",
e.get("thermopile_delta"))
def file_card(ctx, wid):
"""Move the card's evidence under its own key."""
ev = ctx.evidence
ev.setdefault("cards", {})[wid] = {k: ev.pop(k) for k in CARD_EVIDENCE if k in ev}
def thickness_answer(ctx):
"""The thickness the placement is answered with, in the machine's
units (the number prompts read ui_units), and the millimeters the
record must carry for it."""
imperial = (ctx.forgectrl.settings() or {}).get("ui_units") == "imperial"
return ("0.125", 3.175) if imperial else ("3.2", 3.2)
def place(ctx):
"""The placement: the lens reference, two jogs, the origin at the
head's position, the full sheet, a thickness. Nothing fires."""
jogs = ["X+10", "X-10"]
answer, want_mm = thickness_answer(ctx)
ctx.evidence["thickness_answer"] = answer
def on_prompt(p):
# Each jog answer closes the prompt and the wizard asks again with
# the next sequence number, so the pad is answered one move at a
# time until the origin is set.
if p.get("id") == "place":
return jogs.pop(0) if jogs else "Set origin"
if p.get("id") == "sheet-kind":
return "Full sheet"
if p.get("id") == "thickness":
return answer
return None
last = run_check(ctx, "sheet.place", on_prompt, 300)
r = last.get("result") or {}
ctx.check("origin_x" in r and "origin_y" in r, "no origin in the result: %s", r)
ctx.check((r.get("steps_per_mm") or {}).get("x", 0) > 0, "no steps per mm: %s", r)
ctx.check(r.get("z_referenced") is True, "the lens was not referenced")
ctx.check(abs((r.get("thickness_mm") or 0) - want_mm) < 0.01,
"thickness %s mm, expected %s (answered %s)", r.get("thickness_mm"), want_mm, answer)
ctx.check(r.get("alone") is False, "alone %s", r.get("alone"))
ctx.log("placed: datum at %s, %s; steps per mm %s", r.get("origin_x"), r.get("origin_y"),
r.get("steps_per_mm"))
file_card(ctx, "sheet.place")
def burn(ctx, wid, answers, want, lit_s):
"""One live card: the preview, the burn after the press with the
prompts answered from `answers`, the witnesses, the keys of the
result in `want`. The actuator waits `lit_s` for the button to light."""
preview_ok(ctx, wid)
ctx.arm_press(lit_timeout=lit_s)
def on_prompt(p):
return answers.get(p.get("id"))
try:
last = run_check(ctx, wid, on_prompt, 900)
finally:
ctx.clear_notice()
emission_ok(ctx, last)
r = last.get("result") or {}
missing = [k for k in want if k not in r]
ctx.check(not missing, "the result lacks %s: %s", missing, sorted(r))
ctx.log("%s: %s, emission %s", wid, {k: r.get(k) for k in want}, ctx.evidence.get("emission"))
file_card(ctx, wid)
@test("commission.sheet", title="The commissioning sheet: the placement, the frame, and the five cards",
subsystem="commission", kind="live", hardware="takeover", mode="grbl", est_min=35,
covers=SHEET_COVERS,
requires=["forgectrl.auth", "commission.check-motion", "laser.emission-witness", "cooling.flow-verify"],
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 "
"it goes with its top edge at the top of the cut area (the head's home corner); "
"lid closed. Eye protection on, exhaust on, extinguisher in reach.",
"Start the test and press the machine's button once. That press says you are at the "
"machine, and the bench actuator makes every arm press after it. With no actuator "
"the start is the Ready answer instead, and you press the button each time it lights "
"white: six times, the frame and the five cards.",
"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 "
"tails."],
description="One run over the sheet's seven wizards on one piece of wood. "
"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 "
"(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 "
"and program served by GET /wiz/sheet.svg and /wiz/sheet.gcode: the frame "
"(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 "
"travel; the pick is line 11, the thickness kept); the floor card (twelve "
"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 "
"25 Hz, the curve fit on 20 s of dark); the corner card (five gammas reloaded "
"through M102 inside one armed job; the pick is 1.50); and the flow-load card "
"(the coolant loop settled, the box and the patch at 60 percent, the "
"downstream peak over 100 s of dark as the coefficient). Every burn must be "
"seen by the three witnesses (the tube current, the kernel's LASER_ON samples, "
"the thermopile rise); every result must carry its keys; the record must "
"carry each wizard at version 1; the seven settings the cards write are "
"restored at the end.")
def sheet(ctx):
ctx.ready("The sheet: the placement, then the frame and the five cards burn after a press "
"each. The bench presses when the button lights.")
place(ctx)
with Restore(ctx, CARD_SETTINGS):
for wid, answers, want, lit_s in CARDS:
burn(ctx, wid, answers, want, lit_s)
ctx.log("PASS: the sheet's seven wizards ran on one piece; settings restored")
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+54 -142
View File
@@ -1,11 +1,14 @@
# 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."""
import json
import os
import socket
import time
from ..catalog import test
from .. import hw
# The write guard reads the account and the session store (a machine with
# an account needs a session from the LAN; this host and the dev image
@@ -35,8 +38,9 @@ def lan_ip():
covers=_COVERS_AUTH,
description="Every state-changing endpoint refuses an unauthenticated write (the factory "
"return, the SSH switch and the wizard's own routes included); a non-literal "
"Host, a non-literal Origin and a cross-site Sec-Fetch-Site are refused; the "
"cooling report channel accepts the loopback peer and refuses a non-loopback "
"Host, a non-literal Origin and a cross-site Sec-Fetch-Site are refused, while "
"the machine's own hostname passes and that name with a domain on it does not; "
"the cooling report channel accepts the loopback peer and refuses a non-loopback "
"one (over HTTP the write is sent to HTTPS first, 302; over HTTPS the route "
"answers 403 loopback only); the fuse view is two-factor "
"(token and the physical button) and refused without either; "
@@ -55,6 +59,7 @@ def auth(ctx):
("/mode", {"controller": "grbl"}), ("/settings", {"ui_units": "mm"}),
("/diag/flow-verify", None), ("/diag/abort", None),
("/update/apply", None), ("/boot", {"target": "a"}),
("/update/check", None), ("/update/dismiss", {"version": "v0.0.0"}),
("/system/reboot", None), ("/restore/factory", None),
("/restore/factory-return", {"confirm": "1"}), ("/system/ssh", {"enable": "1"}),
("/wiz/advisories/accept", None), ("/wiz/account", None),
@@ -96,6 +101,17 @@ def auth(ctx):
ev["host_name"] = st
ctx.log("GET /status Host=evil.example.net -> %s", st)
ctx.check(st == 403, "a DNS-name Host was accepted (%s)", st)
# the machine's own name passes; the same name with a domain on it
# does not, because anyone can register one
own = socket.gethostname()
st, body = fc.get("/status", headers={"Host": own})
ev["host_own_name"] = st
ctx.log("GET /status Host=%s -> %s", own, st)
ctx.check(st == 200, "the machine's own hostname was refused as a Host (%s)", st)
st, body = fc.get("/status", headers={"Host": own + ".example.net"})
ev["host_own_name_domain"] = st
ctx.log("GET /status Host=%s.example.net -> %s", own, st)
ctx.check(st == 403, "a domain name built on the machine's name was accepted (%s)", st)
st, body = fc.get("/status", headers={"Origin": "http://evil.example.net"})
ev["origin_name"] = st
ctx.log("GET /status Origin=http://evil.example.net -> %s", st)
@@ -124,14 +140,39 @@ def auth(ctx):
"GET /fuse-identity with the token but no button -> %s %r (expected the two-factor refusal)",
st, body)
# the cooling report channel: the loopback peer is accepted. An idle
# report is what the controller sends every period; the engine is idle
# here, so it changes nothing. A dual-stack listener reports this peer
# as ::ffff:127.0.0.1, which the check must recognize in full.
st, body = fc.post("/cool/state", params={"mode": "idle", "armed": "0"})
# the cooling report channel is the running controller's alone. A
# loopback peer is not enough: anything on this host is one. The
# supervisor hands the controller a secret at its spawn, and the route
# asks for it. An idle report is what the controller sends every
# period; the engine is idle here, so the accepted one changes nothing.
# A dual-stack listener reports this peer as ::ffff:127.0.0.1, which
# the loopback check must recognize in full.
report = {"mode": "idle", "armed": "0"}
st, body = fc.post("/cool/state", params=report)
ev["cool_state_from_loopback_no_secret"] = st
ctx.log("POST /cool/state from loopback, no secret -> %s %s", st, body if isinstance(body, str) else "")
ctx.check(st == 403 and "controller's alone" in str(body),
"/cool/state from loopback without the secret -> %s %r", st, body)
st, body = fc.post("/cool/state", params=report, headers={"X-ForgeFIRM-Report": "0" * 32})
ctx.check(st == 403, "/cool/state with a secret that is not the controller's -> %s %r", st, body)
# The secret itself, as only root on this host can read it: out of
# the running controller's environment. It is never logged.
secret = None
pid = (fc.get("/mode")[1] or {}).get("pid")
if pid:
try:
with open("/proc/%d/environ" % int(pid), "rb") as f:
for item in f.read().split(b"\0"):
if item.startswith(b"GF_REPORT_SECRET="):
secret = item.split(b"=", 1)[1].decode("ascii", "replace")
except OSError:
pass
ev["controller_has_a_secret"] = bool(secret)
ctx.check(secret and len(secret) == 32, "the running controller (pid %s) was handed no report secret", pid)
st, body = fc.post("/cool/state", params=report, headers={"X-ForgeFIRM-Report": secret})
ev["cool_state_from_loopback"] = st
ctx.log("POST /cool/state from loopback -> %s %s", st, body if isinstance(body, dict) else "")
ctx.check(st == 200, "/cool/state refused the loopback peer (%s %r): the controller's "
ctx.log("POST /cool/state from loopback with the controller's secret -> %s", st)
ctx.check(st == 200, "/cool/state refused the controller's own secret (%s %r): the controller's "
"reports never reach the engine", st, body)
# ...and a non-loopback peer is refused, even with a token. Over HTTP
@@ -139,12 +180,11 @@ def auth(ctx):
# HTTPS the route itself refuses the peer (403 loopback only). Neither
# request follows the redirect, and the self-signed certificate is
# not verified.
from .commission import request, decode
from .setup import request, decode
ip = lan_ip()
ev["lan_ip"] = ip
ctx.check(ip, "cannot determine the board's LAN address")
token = {"X-ForgeFIRM-Token": fc.token}
report = {"mode": "idle", "armed": "0"}
token = {"X-ForgeFIRM-Token": fc.token, "X-ForgeFIRM-Report": secret}
st, body, hdrs = request("http://%s" % ip, "POST", "/cool/state", data=report, headers=token)
loc = hdrs.get("location", "")
ev["cool_state_from_lan_http"] = {"status": st, "location": loc}
@@ -271,131 +311,3 @@ def settings_bounds(ctx):
ctx.check(applied == try_level, "lamp did not follow lid_lamp_idle=%s (reads %s)", try_level, applied)
ctx.check(back == want, "lamp did not return to %s after the restore (reads %s)", want, back)
ctx.log("lid lamp follows the setting live and returns to %s", want)
@test("forgectrl.panel-serves", title="Control panel and status endpoints", subsystem="forgectrl",
kind="auto", est_min=1,
covers=[("forgectrl", "src/ui.*"), ("forgectrl", "src/ui/**"), ("forgectrl", "src/status.*"),
("forgectrl", "src/cam.c"), ("forgectrl", "src/main.c"), ("forgectrl", "src/super.c"),
("grblhal-glowforge", "src/glowforge_status.c"), ("grblhal-glowforge", "src/serial.c"),
("forgectrl", "src/curverec.*")],
description="The panel page is served, /status carries the machine telemetry the panel and "
"the acceptance tool read (including the sys block: CPU busy percent over the "
"interval since the previous read, memory used percent; and homed_axes, "
"the axes that carry a reference, Z alone once the lens has taken its "
"own at the controller's start), and /cam/status "
"answers. In GRBL mode with a live controller, /status also echoes the "
"controller's published state file as the grbl block (fresh age, machine "
"state, sender session, laser window and dose model, modal report), "
"GET /grbl/settings serves the published $$ view, and the controller's "
"settings store is /data/forgefirm/EEPROM-glowforge.DAT with nothing of "
"it at the top of /data.")
def panel_serves(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
st, body = fc.get("/", raw=True)
ev["panel_status"] = st
ctx.log("GET / -> %s (%d bytes)", st, len(body) if body else 0)
ctx.check(st == 200, "GET / -> %s", st)
text = body.decode("utf-8", "replace")
ctx.check("<html" in text.lower() and "ForgeFIRM" in text, "the panel does not look like the panel")
ctx.check(fc.token and fc.token in text, "the panel does not embed the bearer token")
ctx.check("<link " not in text and "<script src=" not in text,
"the panel references an external asset (the build did not bundle src/ui/)")
# The daemon stores the page gzipped and inflates it once at first
# request; what it serves is the plain page with the theme attribute
# the head script sets and the one save bar every settings tab shares.
ctx.check("data-bs-theme" in text, "the panel lacks the theme attribute (inflate failed?)")
ctx.check('id="savebar"' in text, "the panel lacks the save bar")
s = fc.status()
for key in ("state", "switches", "coolant", "fans"):
ctx.check(key in s, "/status lacks %r", key)
ev["state"] = s.get("state")
ev["switches"] = s.get("switches")
ctx.log("/status state=%s switches=%s", s.get("state"), s.get("switches"))
for key in ("lid", "button", "interlock_ok", "head", "hv_enable"):
ctx.check(key in (s.get("switches") or {}), "/status switches lacks %r", key)
# SoC utilization rides /status next to the temperatures. The CPU
# number is a delta over the interval since the previous read, so
# the read above primes it; after a beat both percents must be
# numbers in range.
ctx.sleep(1)
sys_ = ctx.forgectrl.status().get("sys") or {}
ev["sys"] = sys_
ctx.log("/status sys=%s", sys_)
ctx.check(isinstance(sys_.get("cpu_pct"), (int, float)) and 0.0 <= sys_["cpu_pct"] <= 100.0,
"/status sys.cpu_pct is not a percent: %s", sys_)
ctx.check(isinstance(sys_.get("mem_pct"), (int, float)) and 0.0 < sys_["mem_pct"] < 100.0,
"/status sys.mem_pct is not a percent: %s", sys_)
# The lens takes its own reference on the hall edge at every
# controller start, so with a controller running Z is referenced on
# its own while X and Y wait for a home: homed_axes names the axes
# that carry one, and Z reads inside the lens reach rather than the
# zero an unreferenced axis would show.
axes = s.get("homed_axes")
ev["homed_axes"] = axes
ctx.check(isinstance(axes, int) and 0 <= axes <= 7,
"/status homed_axes is not an axis mask: %s", axes)
ctx.check(bool(s.get("homed")) == (axes == 7),
"/status homed (%s) disagrees with homed_axes (%s)",
s.get("homed"), axes)
st, mode0 = fc.get("/mode")
if isinstance(mode0, dict) and mode0.get("controller") == "running":
lens = s.get("lens") or {}
pos = s.get("pos") or {}
ctx.log("/status homed_axes=%s pos.z=%s reach=%s..%s", axes,
pos.get("z"), lens.get("reach_min"), lens.get("reach_max"))
ctx.check(axes is not None and axes & 4,
"Z is not referenced with a controller running: %s", axes)
ctx.check(isinstance(pos.get("z"), (int, float))
and lens.get("reach_min") is not None
and lens["reach_min"] <= pos["z"] <= lens["reach_max"],
"Z %s is outside the lens reach %s..%s", pos.get("z"),
lens.get("reach_min"), lens.get("reach_max"))
st, cam = fc.get("/cam/status")
ev["cam_status"] = st
ctx.log("GET /cam/status -> %s %s", st, cam)
ctx.check(st == 200 and isinstance(cam, dict) and "running" in cam, "GET /cam/status -> %s", st)
# The controller's published state, echoed only while a live GRBL
# controller runs (glowforge_status.c -> /run/forgefirm -> /status).
st, mode = fc.get("/mode")
if isinstance(mode, dict) and mode.get("mode") == "grbl" and mode.get("controller") == "running":
g = ctx.forgectrl.status().get("grbl") or {}
ev["grbl"] = g
ctx.log("/status grbl=%s", g)
ctx.check(isinstance(g.get("age_s"), (int, float)) and g["age_s"] < 30,
"/status grbl block missing or stale: %s", g)
rep = g.get("report") or {}
for key in ("state", "sender", "laser", "modals"):
ctx.check(key in rep, "/status grbl.report lacks %r", key)
ctx.check((rep.get("laser") or {}).get("model") in ("density", "analog"),
"grbl.report.laser carries no model: %s", rep.get("laser"))
ctx.check((rep.get("laser") or {}).get("curve"),
"grbl.report.laser carries no dose curve: %s", rep.get("laser"))
st, text = fc.get("/grbl/settings", raw=True)
ev["grbl_settings_status"] = st
ctx.check(st == 200 and b"$35=" in (text or b""),
"GET /grbl/settings -> %s without the $$ view", st)
# The $-settings persist in the data directory, never loose in /data.
nvs = "/data/forgefirm/EEPROM-glowforge.DAT"
ev["grbl_nvs"] = os.path.isfile(nvs)
ctx.check(os.path.isfile(nvs), "the controller's settings store is not at %s", nvs)
ctx.check(not os.path.exists("/data/EEPROM-glowforge.DAT"),
"a settings store remains at the top of /data")
else:
ctx.log("no live GRBL controller (%s); grbl block checks skipped", mode)
# The dose-curve recorder's read surface answers in any mode.
st, cs = fc.get("/curve/status")
ev["curve_status"] = cs
ctx.check(st == 200 and isinstance(cs, dict) and cs.get("state") in
("idle", "waiting", "recording", "done", "failed"),
"GET /curve/status -> %s %s", st, cs)
st, text = fc.get("/curve/ladder.gcode", raw=True)
ctx.check(st == 200 and b"S1000" in (text or b"") and b"M5" in (text or b""),
"GET /curve/ladder.gcode -> %s without the ladder", st)
+168
View File
@@ -0,0 +1,168 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The helpers for a test that lets the web service move the head.
The homing is the web-service session cloud.mode-switch runs. These judge
that its motions ran whole, and take the head back to where the test found
it by the travel the service's motions logged. No counter reading can say
where that was: every
service motion zeroes the step counters at its start, and a home and every
controller start zero them again.
"""
import re
import sys
from ..baseline import XY_STEPS_PER_MM, counter_frame, counter_steps_per_mm, read_position
from ..runner import Failed
from . import cloud as logs # the log paths, read at each call
from .cloud import log_lines_since, log_size
from .motion import _drop_reference, clean_slate, machine_idle, wait_idle
RETURN_MAX_MM = 600.0 # no hand-back jogs farther on an axis: the bed is smaller
# The client's own record of a motion's end: the kernel counters, which
# the motion zeroed at its start (the "(actual/expected)" line of the same
# motion is not this one). A print's park keeps the counters and drives
# them back, so the line after it is the print's net travel.
MOTION_END = re.compile(r"end positions \((-?\d+), (-?\d+), (-?\d+)\)")
def session_mark(path=None):
"""Where a client's log ends now (gfhome's by default): the start of
the next session's record."""
return log_size(path or logs.GFHOME_LOG)
def session_travel(offset, path=None):
"""The X/Y travel of the service motions logged in a client's log
(gfhome's by default) since offset, in steps, or None when it cannot be
known. Every service motion zeroes the kernel counters at its start and
logs where they ended, so the travel is the sum of those ends. A service
motion runs at x8 or not at all (the client refuses any other mode), so
the steps are x8 steps. A motion refused before it moved logs no end
and moved nothing; a motion with no "end motion" (one that raised after
it moved, or a client killed inside it) leaves the travel unknown, and
so does a log rotated under the run."""
path = path or logs.GFHOME_LOG
if log_size(path) < offset:
return None
x = y = 0
inside, end = False, None
for ln in log_lines_since(path, offset):
ln = ln.rstrip()
m = MOTION_END.search(ln)
if ln.endswith(" start motion"):
if inside:
return None
inside, end = True, None
elif m and inside:
end = (int(m.group(1)), int(m.group(2)))
elif ln.endswith(" end motion") and inside:
if end:
x, y = x + end[0], y + end[1]
inside = False
return None if inside else (x, y)
def judge_whole_motions(ctx, ev, offset):
"""Every service motion of the homing session since offset ran whole.
One that stopped short (the cooling engine's stop, a fault) left the
head short of the camera home, and the position declared there is
false."""
short = [ln.strip()[:160] for ln in log_lines_since(logs.GFHOME_LOG, offset)
if "run stopped short" in ln
or ("_finish_action motion [" in ln and 'event ":completed"' not in ln)]
ev["short_motions"] = short
ctx.check(not short, "a service motion of the homing did not run whole, so the head stopped short of "
"the camera home and the declared position is false: %s", short)
def camera_home_return(ctx, ev, offset):
"""Hand the head back to where the test found it after gfhome_homing
ran from gfhome's log offset, whatever the session decided. The head is
the session's logged travel from there, plus, once homed, whatever the
counters read since: the home zeroed them where the session left the
head and wrote the anchor, and a controller start since would have
removed it. Unhomed, the counters still hold the last motion's end,
which the travel already counts. The camera home is dropped first,
since its envelope need not hold the spot the head came from."""
def back():
now = [0, 0, 0]
if ev.get("homed"):
now = read_position() if counter_frame() is not None else None
_drop_reference(ctx, ctx.forgectrl)
# Read after the stop: a runner the stop ended has written its last line.
travel = session_travel(offset)
rec = ev["return"] = {"session_travel_steps": travel, "counters_since_home": now}
ctx.check(travel is not None and now is not None,
"the homing session's travel cannot be known (gfhome log %s, counters since the home %s): "
"the head is left where it stands and the hand-back moves nothing", travel, now)
_jog_back(ctx, rec, travel, now)
_hand_back(ctx, ev, "return", back)
def cloud_mode_return(ctx, ev, offset):
"""Hand the head back to where the test found it after a stay in cloud
mode that began at the cloud client's log offset, on the GRBL
controller the switch back started: that start zeroed the counters
where the service left the head, and they hold whatever moved since.
The client's record of its motions is the rest (a hunt's lens travel is
Z, which a hand-back never touches)."""
def back():
travel = session_travel(offset, logs.GFCLOUD_LOG)
now = read_position()
rec = ev["cloud_return"] = {"session_travel_steps": travel, "counters_since_start": now}
ctx.check(travel is not None and now is not None,
"the travel in cloud mode cannot be known (gfcloud log %s, counters %s): the head is left "
"where it stands and the hand-back moves nothing", travel, now)
_jog_back(ctx, rec, travel, now)
_hand_back(ctx, ev, "cloud_return", back)
def _hand_back(ctx, ev, key, back):
"""Run a hand-back from a test's finally. The first failure wins: when
the test has failed already (the exception in flight), a hand-back that
fails as well is logged and recorded, and the test's own failure is the
one reported."""
first = sys.exc_info()[1]
try:
back()
except Failed as e:
if first is None:
raise
ev.setdefault(key, {})["failed"] = str(e)
ctx.log("the head is not back, and the run has failed already: %s", e)
def _jog_back(ctx, rec, travel, since):
"""Jog the head back by the service's travel (x8 steps) and what the
counters read since (the controller's own steps), on a controller
without a home. One more start then zeroes the counters where the head
began, which the rest of the catalog counts on."""
spm = counter_steps_per_mm()
dx = travel[0] / XY_STEPS_PER_MM + since[0] / spm
dy = travel[1] / XY_STEPS_PER_MM + since[1] / spm
rec["mm"] = [round(dx, 3), round(dy, 3)]
ctx.log("the head is %.3f/%.3f mm from where the test found it", dx, dy)
ctx.check(abs(dx) <= RETURN_MAX_MM and abs(dy) <= RETURN_MAX_MM,
"a travel of %.1f/%.1f mm exceeds %.0f mm - not jogging back", dx, dy, RETURN_MAX_MM)
if abs(dx) >= 0.05 or abs(dy) >= 0.05:
with ctx.grbl() as g:
clean_slate(ctx, g)
start = read_position()
r = g.command("$J=G91 G21 X%.3f Y%.3f F2400" % (-dx, -dy))
ctx.check(not any(x.startswith("error") for x in r), "the return jog was refused: %s", r)
states = wait_idle(ctx, g, timeout=60)[1]
ctx.check(states[-1:] != ["TIMEOUT"], "the return jog did not end: %s", states)
machine_idle(ctx)
end = read_position()
rec["counters_across_jog"] = [start, end]
ctx.check(start is not None and end is not None and abs((end[0] - start[0]) / spm + dx) < 0.1
and abs((end[1] - start[1]) / spm + dy) < 0.1,
"the return jog did not travel %.3f/%.3f mm: counters %s -> %s", -dx, -dy, start, end)
_drop_reference(ctx, ctx.forgectrl)
ctx.counters_rezeroed()
ctx.log("head returned to where the test found it (%.3f/%.3f mm)", -dx, -dy)
+226 -59
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)."""
import glob
import gzip
import mmap
import os
import shutil
import struct
import re
import stat
@@ -28,6 +34,12 @@ def _wdog1_wcr():
return None
def _plain(line):
"""A line without its ANSI color sequences: what a terminal shows,
counted in columns rather than in bytes."""
return re.sub(r"\x1b\[[0-9;]*m", "", line)
def _read(path, default=None):
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
@@ -99,25 +111,66 @@ def fds_of(pid):
@test("image.health", title="Post-flash image health", subsystem="image", kind="auto",
always=True, est_min=1,
covers=[("forgectrl", "init/**"), ("forgectrl", "src/main.c"), ("forgectrl", "src/auth.c"),
covers=[("forgectrl", "init/**"), ("forgeext", "init/**"), ("forgectrl", "src/main.c"), ("forgectrl", "src/auth.c"),
("forgectrl", "src/status.*"),
("forgectrl", "CMakeLists.txt"), ("grblhal-glowforge", "src/boards/**"),
("grblhal-glowforge", "CMakeLists.txt"), ("kernel-module-glowforge", "**"),
("linux-fslc", "**")],
description="The image that is running is the image the manifest describes, with the "
"kernel options, the module, the pulse ring it maps and the SDMA clocks it holds, "
"the daemon ownership, "
"the init ordering, the file modes the release depends on, and the mounts: the "
"version stamped in /etc/forgefirm-version and under the machine mark in the "
"console banner and the motd, "
"the kernel options, the module, the pulse ring it maps and the SDMA clocks it holds, "
"the daemon ownership, the board temperatures /status reports (the chassis and "
"the SoC die in a plausible range, the supply as a 10-bit count, the CPU "
"unthrottled), "
"the init ordering, the extension host as one process that starts after forgectrl and "
"stops before it on a machine with no package installed and nothing running under a "
"pool account, the data directory open for search on a machine with an account, the file modes the release depends on, and the mounts: the "
"rootfs read-only, /data writable, the account files and the banner rendered "
"into tmpfs, the sshd host keys on /data, the factory slots on the dev image only.")
"into tmpfs, the sshd host keys on /data, the factory slots on the dev image only. "
"And the boot does not wait on the network: ifup has returned for wlan0 (it is "
"in the state file and no ifup is running) and the console login is up; each "
"DHCP client runs with -b and has left ifup (its parent is pid 1); no DHCPv6 "
"client is running or on the image, nor its hook script; and IPv6 is the "
"kernel's own: enabled on wlan0, router advertisements accepted, a link-local "
"address up (a global address is logged, not checked).")
def image_health(ctx):
ev = ctx.evidence
manifest = ctx.runner.manifest
# 1. version stamp
# 1. version stamp: the machine-readable file, and the two a person
# reads - the console banner (/etc/issue, which forgefirm-banner
# renders from the image's own text) and the motd a login prints.
# Each carries the machine mark with the version on the mark's own
# last line, right-justified to the mark's last column.
ver = (_read("/etc/forgefirm-version", "") or "").strip()
ev["forgefirm_version"] = ver
ctx.log("forgefirm-version: %s (manifest: %s)", ver, manifest.version)
ctx.check(ver == manifest.version, "/etc/forgefirm-version %r != manifest %r", ver, manifest.version)
for path in ("/etc/issue", "/etc/motd"):
text = _read(path, "") or ""
ev[path] = text
lines = text.splitlines()
at = [i for i, line in enumerate(lines) if line.rstrip().endswith(ver)]
ctx.check(at, "%s carries no line ending in the version %r", path, ver)
if not at:
continue
i = at[0]
ctx.check(lines[i].rstrip() == lines[i], "%s pads the version line: %r", path, lines[i])
ctx.check(_plain(lines[i]).strip() != ver,
"%s puts the version on a line of its own, not on the mark's last line", path)
# Right-justified to the mark's last column: the stamped line is
# exactly as wide as the widest line above it. Only the motd is
# measured, because /etc/issue holds the mark with its
# backslashes doubled for the getty that reads it, so its bytes
# are wider than the columns a person sees.
if path == "/etc/motd":
above = [len(_plain(line)) for line in lines[:i] if line.strip()]
width = max(above) if above else 0
ev["motd_columns"] = [len(_plain(lines[i])), width]
ctx.check(len(_plain(lines[i])) == width,
"/etc/motd stamped line is %d columns, the mark above is %d",
len(_plain(lines[i])), width)
# 2. kernel options
cfg = kernel_config()
@@ -216,6 +269,21 @@ def image_health(ctx):
"controller is %r (expected running or standby)", mode.get("controller"))
ctx.check(mode.get("motion") != "fault", "supervisor reports a motion fault")
# 4b. the board temperatures /status carries, watched and not gated:
# the chassis and the SoC die in degrees, the supply as its raw count,
# and the CPU unthrottled at idle.
temps = (ctx.forgectrl.status() or {}).get("temps") or {}
ev["temps"] = temps
ctx.log("temps: %s", temps)
ctx.check(isinstance(temps.get("chassis_c"), (int, float)) and 5.0 <= temps["chassis_c"] <= 80.0,
"/status temps.chassis_c is not a plausible chassis temperature: %s", temps)
ctx.check(isinstance(temps.get("soc_c"), (int, float)) and 10.0 <= temps["soc_c"] <= 100.0,
"/status temps.soc_c is not a plausible die temperature: %s", temps)
ctx.check(isinstance(temps.get("supply_raw"), int) and 0 <= temps["supply_raw"] <= 1023,
"/status temps.supply_raw is not a 10-bit count: %s", temps)
ctx.check(temps.get("soc_throttle") == 0,
"the CPU is throttled (or its cooling device is missing) at idle: %s", temps)
# 5. init ordering: controllers stop before the daemon
k = sorted(os.path.basename(p) for p in glob.glob("/etc/rc6.d/K*"))
ev["rc6_kill"] = k
@@ -225,6 +293,29 @@ def image_health(ctx):
ctx.check(kg and kf, "rc6.d lacks the grblhal/forgectrl kill links")
ctx.check(kg[0] < kf[0], "controller kill link %s must sort before forgectrl's %s", kg[0], kf[0])
# 5b. the extension host: one process, up after forgectrl and down
# before it, and a campaign's machine is extension-free
host = []
for pid in hw.pidof("forgeext"):
argv = _read("/proc/%d/cmdline" % pid, "").split("\0")
if argv[:2] == [hw.FORGEEXT, "run"]:
host.append(pid)
ev["extension_host_pids"] = host
ctx.check(len(host) == 1, "the extension host is not one running process: %s", host)
s5 = sorted(os.path.basename(x) for x in glob.glob("/etc/rc5.d/S*"))
sx = [x for x in s5 if x.endswith("forgeext")]
sf = [x for x in s5 if x.endswith("forgectrl")]
kx = [x for x in k if x.endswith("forgeext")]
ev["forgeext_links"] = {"start": sx, "kill": kx}
ctx.check(sx and sf and sf[0] < sx[0], "the extension host must start after forgectrl: %s, %s", sf, sx)
ctx.check(kx and kx[0] < kf[0], "the extension host must stop before forgectrl: %s, %s", kx, kf)
ev["extension_packages"] = hw.ext_packages()
ev["pool_processes"] = len(hw.pool_pids())
ctx.log("extension host pid %s, packages %s, pool processes %d", host, ev["extension_packages"], ev["pool_processes"])
ctx.check(not ev["extension_packages"], "a campaign wants an extension-free machine: installed %s",
ev["extension_packages"])
ctx.check(not ev["pool_processes"], "%d process(es) run under extension pool accounts", ev["pool_processes"])
# 6. logging lever present, no userspace watchdog daemon
logging = [n for n in ("forgefirm-logging", "forgefirm-logrotate") if os.path.exists("/etc/init.d/" + n)]
ev["logging_init"] = logging
@@ -242,6 +333,14 @@ def image_health(ctx):
ev[path] = "%o" % m
ctx.log("%s mode %o", path, m)
ctx.check(m == 0o600, "%s mode %o, expected 600", path, m)
# an operator account walks through the data directory to its home, and a
# package's account to its files: forgefirm-users opens it for search at
# every render, whoever made it and under whatever umask
if os.path.exists("/data/forgefirm/users"):
m = stat.S_IMODE(os.stat("/data/forgefirm").st_mode)
ev["/data/forgefirm"] = "%o" % m
ctx.log("/data/forgefirm mode %o", m)
ctx.check(m & 0o011 == 0o011, "/data/forgefirm mode %o: no account can walk through it to its home", m)
if os.path.isdir("/data"):
s = os.statvfs("/data")
free_mb = s.f_bavail * s.f_frsize // (1024 * 1024)
@@ -251,7 +350,8 @@ def image_health(ctx):
# 8. the mounts: the rootfs read-only, /data the writable partition,
# the state a read-only rootfs hands off (the read-only-rootfs image
# feature, forgefirm-users, forgefirm-banner, the sshd host keys). The
# feature, forgefirm-users, forgefirm-hostname, forgefirm-banner, the
# sshd host keys). The
# dev image alone mounts the factory slots under /factory.
mounts = {}
for line in (_read("/proc/mounts", "") or "").splitlines():
@@ -262,7 +362,8 @@ def image_health(ctx):
def mount_opts(path):
return (mounts.get(path) or {}).get("opts") or []
ev["mounts"] = {p: mounts[p] for p in ("/", "/data", "/var/lib", "/etc/passwd", "/etc/issue") if p in mounts}
ev["mounts"] = {p: mounts[p] for p in ("/", "/data", "/var/lib", "/etc/passwd",
"/etc/hostname", "/etc/issue") if p in mounts}
ctx.log("/ mounted %s; /data %s; /var/lib %s", ",".join(mount_opts("/")) or "(absent)",
",".join(mount_opts("/data")) or "(absent)", ",".join(mount_opts("/var/lib")) or "(absent)")
ctx.check("ro" in mount_opts("/"), "the rootfs is not mounted read-only: %s", mounts.get("/"))
@@ -292,6 +393,7 @@ def image_health(ctx):
ctx.check(f in mounts, "%s is not the tmpfs render of the account record", f)
for n in names:
ctx.check(n in passwd_names, "record account %r is missing from /etc/passwd", n)
ctx.check("/etc/hostname" in mounts, "/etc/hostname is not the bind-mounted name copy")
ctx.check("/etc/issue" in mounts, "/etc/issue is not the bind-mounted banner copy")
if hw.pidof("sshd"):
key = "/data/forgefirm/ssh/ssh_host_ed25519_key"
@@ -303,56 +405,121 @@ def image_health(ctx):
ctx.check("linux-fslc" in manifest.components, "manifest lacks the kernel entry")
ctx.log("manifest %s: %d components", manifest.content_sha[:12], len(manifest.components))
# 10. the network boot. 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. The interfaces file starts no DHCPv6 client
# (test_image.py reads the layer's file); the bounded DHCP client
# (-b, which leaves ifup after three unanswered discovers) is checked
# here, on the client that runs.
procs = _procs()
up = ifstate_names(_read(IFSTATE, ""))
ev["ifstate"] = sorted(up)
ctx.check("wlan0" in up, "ifup has not finished wlan0: %s holds %s", IFSTATE, sorted(up))
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)
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)
# no DHCPv6 client: not running, not on the image
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")
# IPv6 is the kernel's own. A global address is evidence only: a
# network whose router advertisement offers no SLAAC prefix gives none.
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))
# ------------------------------------------------------- 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.license-bundle", title="The license texts travel with the image",
subsystem="image", kind="auto", est_min=1,
covers=[("forgectrl", "src/main.c"), ("forgectrl", "src/ui/index.html"),
("forgectrl", "src/ui/wizard.html"), ("forgectrl", "src/ui/login.html")],
requires=["forgectrl.panel-serves"],
description="The image carries /usr/share/forgefirm/licenses.tar.gz, a gzip tar with "
"the license manifest (every installed package with its license) and the "
"license texts, and no loose common-licenses tree; the panel serves the "
"bundle, the manifest as text, and the Licenses page every footer links, "
"each without a login.")
def license_bundle(ctx):
import io
import tarfile
fc = ctx.forgectrl
ev = ctx.evidence
path = "/usr/share/forgefirm/licenses.tar.gz"
ctx.check(os.path.isfile(path), "%s is missing", path)
ctx.check(not os.path.isdir("/usr/share/common-licenses"),
"the loose common-licenses tree is still on the rootfs")
size = os.path.getsize(path)
ev["bundle_bytes"] = size
ctx.log("%s: %d bytes", path, size)
with tarfile.open(path, "r:gz") as t:
names = t.getnames()
ctx.check("common-licenses/license.manifest" in names, "no license.manifest in the bundle")
manifest = t.extractfile("common-licenses/license.manifest").read().decode("utf-8", "replace")
packages = manifest.count("PACKAGE NAME: ")
texts = sum(1 for n in names if "/generic_" in n and not n.endswith("/"))
ev["packages"] = packages
ev["generic_texts"] = texts
ctx.log("manifest lists %d packages; %d generic license entries", packages, texts)
ctx.check(packages >= 40, "only %d packages in the manifest", packages)
ctx.check(texts >= 5, "only %d generic license entries", texts)
for name in ("forgectrl", "grblhal-glowforge", "kernel-module-glowforge"):
ctx.check("RECIPE NAME: %s\n" % name in manifest, "%s is not in the manifest", name)
st, body = fc.get("/system/licenses/manifest", raw=True)
ctx.check(st == 200, "GET /system/licenses/manifest -> %s", st)
ctx.check(isinstance(body, (bytes, bytearray)) and b"PACKAGE NAME: " in body,
"the served manifest is not the manifest")
st, body = fc.get("/system/licenses", raw=True)
ctx.check(st == 200, "GET /system/licenses -> %s", st)
ctx.check(isinstance(body, (bytes, bytearray)) and len(body) == size,
"the served bundle is %s bytes, the file %d", len(body) if body else None, size)
with tarfile.open(fileobj=io.BytesIO(bytes(body)), mode="r:gz") as t:
ctx.check("common-licenses/license.manifest" in t.getnames(),
"the served bundle holds no manifest")
st, body = fc.get("/licenses", raw=True)
ctx.check(st == 200, "GET /licenses -> %s", st)
text = bytes(body).decode("utf-8", "replace") if body else ""
ctx.check("/system/licenses" in text and "PACKAGE NAME: " in text,
"the Licenses page lacks the download link or the manifest")
+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.
The drills are the bench scripts `scripts/bench/gate_a_kernel_drills.py`
+163 -310
View File
@@ -1,14 +1,18 @@
# 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`.
Every test here can emit, except `laser.power-floor`, which only reads
settings. The page starts a firing one only with the operator's
eye-protection / fire-watch / exhaust acknowledgment; the test then
prompts for the scrap and the button, streams a small job through the
controller, and the machine fires only after the operator presses the
physical arm button - nothing here defeats that gate, and forgetest
never touches the laser latch. One emission per test; on abort or error
the job is soft-reset (`^X`: controlled stop, latch relocked). The
witnesses are forgectrl's `/status` (`laser.emission_samples` = the
The live tests here emit. The page starts a firing one only with the
operator's eye-protection / fire-watch / exhaust acknowledgment; the
test then prompts for the scrap and the button, streams a small job
through the controller, and the machine fires only after the operator
presses the physical arm button - nothing here defeats that gate, and
forgetest never touches the laser latch. One emission per test; on abort
or error the job is soft-reset (`^X`: controlled stop, latch relocked).
The witnesses are forgectrl's `/status` (`laser.emission_samples` = the
kernel's LASER_ON sample count, `hv_current_raw`, `lid_ir`),
`/cool/status` (`armed`), the head's beam detector
(`head/beam_detect_analog`, the tube's own emission seen from the head:
@@ -24,41 +28,12 @@ import time
from ..catalog import test
from .. import hw
from ..runner import Failed
from .motion import (kernel_xy_mm, kernel_start, check_kernel_returned, wait_state,
wait_state_text, wait_left_state, wait_idle, drain_text, Watch)
from .motion import kernel_start, check_kernel_returned, Watch
_LASER_COVERS = [("grblhal-glowforge", "src/**"), ("kernel-module-glowforge", "**"),
("forgectrl", "src/super.c"), ("forgectrl", "src/cool.c"),
("forgectrl", "src/status.c"), ("forgectrl", "src/main.c")]
# The S-range floor the controller derives $35 from at every precompute
# (glowforge_laser.c: laser_floor_density, this default when the config
# carries no key): the lowest pulse density that still marks, against
# the hardware's 127-count PWM period. Density is the only dose model.
FLOOR_DEFAULT = 10.0
PWM_PERIOD = 127
PWMSAR_FLOOR_MIN = 12
def _floor(fc):
"""The configured floor percent, default applied."""
raw = (fc.settings().get("laser_floor_density") or "").strip()
try:
return float(raw) if raw else FLOOR_DEFAULT
except ValueError:
return FLOOR_DEFAULT
def _grbl_settings(lines):
vals = {}
for ln in lines:
key, _, val = ln.partition("=")
if key.startswith("$"):
try:
vals[key] = float(val)
except ValueError:
pass
return vals
ARM_CUE = ("LIVE FIRE. Eye protection on. Exhaust on. Extinguisher in reach. "
"Put scrap under the head with %s clear, and close the lid.")
@@ -252,6 +227,26 @@ def kill_trail(ctx, t0, seconds=5.0):
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):
"""(first zero, tail stayed zero, kernel stopped running) from a trail."""
for t in trail:
@@ -379,45 +374,6 @@ def wait_disarm(ctx, timeout):
return None
@test("laser.power-floor", title="The derived floor is the one in force",
subsystem="laser", kind="auto", mode="grbl", est_min=1,
covers=[("grblhal-glowforge", "src/**"), ("forgectrl", "src/main.c")],
description="The controller derives $35 from the floor key (laser_floor_density) at "
"every spindle precompute, boot included, and never from a typed value. "
"Unfloored, the low end of S asks for pulses too far apart to re-strike, "
"so a commanded 1 % would emit nothing. Reads the floor key from forgectrl "
"and $$ from the controller, and checks $35 is that floor, that it lands at "
"or above the measured minimum, and that $31 is 0 (the floor, not $31, sets "
"the bottom of the range).")
def power_floor(ctx):
ev = ctx.evidence
want = _floor(ctx.forgectrl)
ev["configured_floor"] = want
with ctx.grbl() as g:
lines = g.command("$$", timeout=5)
vals = _grbl_settings(lines)
ctx.check(vals, "no $-settings in the response to $$")
floor_pct = vals.get("$35")
rpm_min = vals.get("$31")
ev["settings"] = {k: vals.get(k) for k in ("$30", "$31", "$32", "$35", "$36")}
ctx.check(floor_pct is not None, "$35 missing from the settings report")
counts = int(PWM_PERIOD * floor_pct / 100.0)
ev["pwmsar_floor"] = counts
ctx.log("$35=%.1f%% -> PWMSAR %d of %d ($31=%s, $32=%s, $36=%s)", floor_pct, counts,
PWM_PERIOD, rpm_min, vals.get("$32"), vals.get("$36"))
ctx.check(abs(floor_pct - want) < 0.05,
"$35 is %.1f %%, expected the derived floor %.1f %% from the machine config",
floor_pct, want)
ctx.check(counts >= PWMSAR_FLOOR_MIN,
"the floor lands at PWMSAR %d, below the %d the tube needs",
counts, PWMSAR_FLOOR_MIN)
ctx.check(rpm_min == 0, "$31 is %s, not 0: the bottom of the S range is no longer the floor",
rpm_min)
ctx.log("PASS: nonzero S never commands less than PWMSAR %d", counts)
@test("laser.emission-witness", title="Live emission witness (S400 vector mark) and job-based disarm",
subsystem="laser", kind="live", mode="grbl", always=True, est_min=5,
covers=_LASER_COVERS,
@@ -431,15 +387,45 @@ def power_floor(ctx):
"(G4) between its second and third sides: emission_samples (the kernel's "
"LASER_ON sample count) goes nonzero during the fire window and returns to 0 "
"at Idle, HV current rises during the burn, the head's beam detector sees the "
"beam, the armed window is observed, the M2 program end disarms promptly at "
"Idle (job-based, not the 60 s idle grace) and the button goes dark. Across "
"the dwell the kernel run ends and restarts: HV_ENABLE drops with the "
"charge-pump watchdog and is back for the third side, while the hardware "
"button latch stays clear (cnc/button_latch 0 in every armed sample), so the "
"second half of the square marks without a second press. The operator "
"confirms all four sides.")
"beam, and the armed window is observed. Across the dwell the kernel run ends "
"and restarts: HV_ENABLE drops with the charge-pump watchdog and is back for "
"the third side, while the hardware button latch stays clear (cnc/button_latch "
"0 in every armed sample), so the second half of the square marks without a "
"second press. The square ends at M5 with the window still open; once the "
"LASER_ON count reads 0 (the dark point), a rapid runs 20 mm out over the "
"first side and back, then the program ends. From the dark point until the "
"window closes, every sample reads no emission and the HV current at its idle "
"reading, and the head's reports place the rapid inside that stretch: LASER_ON "
"stays off with FIRE clear while the chain can fire. The M2 program end "
"disarms promptly (job-based, not the 60 s idle grace) and the button goes "
"dark. The operator confirms all four sides.")
def emission_witness(ctx):
ev = ctx.evidence
# The rapid after the M5 (G91 is modal from the square): out over the
# first side and back, then the program end.
rapid_tail = ["G0 X20", "G4 P2.5", "G0 X-20", "G4 P0.5", "G90", "M2"]
def sample_to_disarm(g, timeout=20.0, sample_hz=8):
"""Sample until the armed window closes, or the timeout, with the
controller's state and machine X beside each sample ("x"). A G4
dwell reports Idle, so Idle cannot end this; the window closing at
the program end does."""
samples = []
period = 1.0 / sample_hz
t0 = time.time()
while time.time() - t0 < timeout:
ctx.checkpoint()
smp = sample(ctx)
rep = g.status_report()
if smp:
smp["gstate"] = rep.get("state")
smp["x"] = (rep.get("MPos") or (None,))[0]
samples.append(smp)
if not smp["armed"]:
break
time.sleep(period)
return samples
with ctx.grbl() as g, LiveJob(ctx, g):
prepare(ctx, g)
base = sample(ctx)
@@ -449,21 +435,46 @@ def emission_witness(ctx):
base["armed"], base["verdict"])
ctx.check(not base["emission"], "emission_samples nonzero before the job (%s)", base["emission"])
ctx.ready(ARM_CUE % "20 mm +X and +Y")
# The square ends at M5 with no program end: the window stays open
# for the rapid that follows.
job = ["G91", "G21", "M4", "S400",
"G1 X20 F600", "G1 Y20 F600", "G4 P2", "G1 X-20 F600", "G1 Y-20 F600",
"M5", "G90", "M2"]
"M5"]
ctx.arm_press()
try:
samples = run_and_sample(ctx, g, job)
cut = run_and_sample(ctx, g, job)
# The dark point: the M5 has run, the LASER_ON count reads 0, and
# the window is still open. The rapid is sent only after it, so
# the dark stretch covers the rapid by construction, and the
# mid-square dwell's own zero cannot be taken for it.
ctx.wait_for(lambda: (sample(ctx) or {}).get("emission") == 0, 10, poll=0.125)
dark = sample(ctx)
ctx.check(dark and dark["emission"] == 0,
"the LASER_ON count did not return to 0 after the M5 (%s)", dark and dark["emission"])
ctx.check(dark["armed"], "the armed window closed after the M5, before the rapid "
"(phase %s): the rapid would not run under it", dark["phase"])
# The engine's armed flag can still read true after the latch has
# locked, and a dark reading with the latch locked is the unarmed
# case this sample is here to exclude: the SoC lock line must read
# released.
ctx.check(dark["locked"] is False, "the laser latch is locked at the dark point "
"(laser_locked %s): the rapid would run unarmed", dark["locked"])
rep = g.status_report()
dark["gstate"] = rep.get("state")
dark["x"] = (rep.get("MPos") or (None,))[0]
ctx.check(dark["x"] is not None, "the controller reports no machine position: %s", rep)
stream(g, rapid_tail)
tail = sample_to_disarm(g)
finally:
ctx.clear_notice()
samples = cut + [dark] + tail
ev["trail_fields"] = list(TRAIL_FIELDS)
ev["trail"] = trail(samples)
beam = beam_witness(ctx, ev, samples, base)
emis = [s["emission"] for s in samples if s["emission"] is not None]
beam = beam_witness(ctx, ev, cut, base)
emis = [s["emission"] for s in cut if s["emission"] is not None]
peak = max(emis) if emis else 0
end = emis[-1] if emis else None
hv = [s["hv"] for s in samples if s["hv"] is not None]
end = next((s["emission"] for s in reversed(samples) if s["emission"] is not None), None)
hv = [s["hv"] for s in cut if s["hv"] is not None]
ir_peak = [0, 0, 0, 0]
for s in samples:
if s["ir"] and len(s["ir"]) == 4:
@@ -507,12 +518,34 @@ def emission_witness(ctx):
ev["exhaust_floor"] = floor
ctx.log("exhaust at the first emission sample: %s rpm (floor %.0f)",
ev["exhaust_at_first_fire"], floor)
# X-3: job-based disarm at Idle after M2
dt = wait_disarm(ctx, 75)
# The rapid after the M5, with the window open and FIRE clear: every
# sample from the dark point until the window closes reads no emission
# and the HV current at its idle reading. The head's reports place the
# rapid inside that stretch: out to +20 mm, then back at the start.
stretch = [dark] + tail
x0 = dark["x"]
i_out = next((i for i, s in enumerate(stretch) if s.get("x") is not None and s["x"] - x0 >= 19.5), None)
i_back = (next((i for i in range(i_out + 1, len(stretch))
if stretch[i].get("x") is not None and abs(stretch[i]["x"] - x0) < 0.05), None)
if i_out is not None else None)
closed = stretch[-1] if not stretch[-1]["armed"] else None
relit = [s for s in stretch if s["emission"]]
hv_dark = max((s["hv"] for s in stretch if s["hv"] is not None), default=0)
dt = (closed["t"] - stretch[i_back]["t"]) if (closed and i_back is not None) else None
ev["rapid"] = {"samples": len(stretch), "span_s": round(stretch[-1]["t"] - dark["t"], 2),
"x_start": x0, "x_max": max((s["x"] for s in stretch if s.get("x") is not None), default=None),
"out_at_s": round(stretch[i_out]["t"] - dark["t"], 2) if i_out is not None else None,
"back_at_s": round(stretch[i_back]["t"] - dark["t"], 2) if i_back is not None else None,
"relit": [(round(s["t"] - dark["t"], 2), s["emission"]) for s in relit][:8],
"hv_max": hv_dark, "locked_at_dark": dark["locked"],
"locked_at_out": stretch[i_out]["locked"] if i_out is not None else None}
ev["disarm_after_idle_s"] = round(dt, 1) if dt is not None else None
ctx.log("time-to-disarm after Idle: %s s", ev["disarm_after_idle_s"])
ctx.log("the rapid after the M5: %d samples over %.1f s from the dark point, out at +%s s, back at +%s s, "
"%d with emission, HV max %s; the window closed %s s after the head was back", len(stretch),
ev["rapid"]["span_s"], ev["rapid"]["out_at_s"], ev["rapid"]["back_at_s"], len(relit), hv_dark,
ev["disarm_after_idle_s"])
ctx.check(armed_seen, "the armed window was never observed (arm refused, or no button press)")
ctx.check(peak > 0, "no emission witnessed (emission_samples stayed 0)")
ctx.check(peak > 0, "no emission witnessed on the square (emission_samples stayed 0)")
first_idle_fire = ev["idle_airflow_fire"][0] if idle_fire else (None, None, None)
ctx.check(not idle_fire,
"the laser fired while the cooling engine had no run session, so the fans were at "
@@ -524,8 +557,18 @@ def emission_witness(ctx):
"emission sample, floor %.0f)", ev["exhaust_at_first_fire"], floor)
ctx.check(hv and max(hv) > min(hv), "HV current did not rise during the burn (%s..%s)",
ev["hv_min"], ev["hv_max"])
ctx.check(i_out is not None and i_back is not None,
"the rapid did not run inside the dark stretch: out at %s, back at %s (X from %s, max %s)",
ev["rapid"]["out_at_s"], ev["rapid"]["back_at_s"], x0, ev["rapid"]["x_max"])
ctx.check(stretch[i_out]["locked"] is False, "the laser latch was locked with the head at +20 mm "
"(laser_locked %s): the rapid ran unarmed", stretch[i_out]["locked"])
ctx.check(not relit, "the laser emitted after the M5: %d samples, first at +%s s (emission_samples %s) "
"- a rapid after M5 fired", len(relit), relit[0]["t"] - dark["t"] if relit else None,
relit[0]["emission"] if relit else None)
ctx.check(hv_dark <= HV_DARK_MAX, "HV current %s after the M5 (idle reads ~0): the discharge ran "
"through the rapid", hv_dark)
ctx.check(dt is not None and dt < 10.0,
"the M2 job did not disarm promptly at Idle (%s s; the idle grace is ~60 s)",
"the M2 job did not disarm promptly (%s s after the head was back; the idle grace is ~60 s)",
ev["disarm_after_idle_s"])
ctx.check(gap["button_latch_unlocked_samples"] and gap["button_latch_unlocked_max"] == 0,
"the hardware button latch read SET while the laser latch was unlocked (max %s over %d "
@@ -537,149 +580,14 @@ def emission_witness(ctx):
judge_beam(ctx, beam)
check_button_dark(ctx, ev)
ctx.confirm("Did the laser mark a 20 mm square outline on the scrap, all four sides?")
ctx.log("PASS: emission peak %s -> 0, HV %s..%s, beam +%s, disarmed %.1f s after Idle, button "
"dark, mark confirmed", peak, ev["hv_min"], ev["hv_max"], beam["delta"], dt)
ctx.log("PASS: emission peak %s -> 0, HV %s..%s, beam +%s; the rapid after the M5 dark (HV max %s); "
"disarmed %.1f s after the head was back, button dark, mark confirmed", peak, ev["hv_min"],
ev["hv_max"], beam["delta"], hv_dark, dt)
HV_DARK_MAX = 20 # hv_current_raw reads 0 with the tube off; hundreds under fire
@test("laser.m5-rapid-dark", title="The rapids after an M5 ship dark",
subsystem="laser", kind="live", mode="grbl", est_min=3,
covers=_LASER_COVERS,
requires=["laser.emission-witness"], actions=["button"],
steps=["Scrap under the head with 20 mm of free +X travel; lid closed; exhaust on.",
"Press the physical button when it lights white (the arm)."],
description="A 20 mm line at constant power (M3 S400/F600), then M5, a dwell, a rapid back "
"over the line, a dwell, a rapid forward, a dwell, and the program end. M5 "
"executes with the planner drained and the kernel run over, and the core "
"issues no per-segment laser update for moves made with the spindle off, so "
"only the stream's own wanted state decides whether those rapids fire; a stale "
"true there lights them at the last level, full duty under the density model. "
"The kernel's LASER_ON sample count must go to 0 after the M5 and stay 0 "
"through both rapids, and the HV current must stay at its idle reading.")
def m5_rapid_dark(ctx):
ev = ctx.evidence
with ctx.grbl() as g, LiveJob(ctx, g):
prepare(ctx, g)
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 % "20 mm +X")
job = ["G91", "G21", "M3", "S400",
"G1 X20 F600",
"M5", "G4 P2.5",
"G0 X-20", "G4 P2.5",
"G0 X20", "G4 P2.5",
"G90", "M2"]
ctx.arm_press()
try:
samples = run_and_sample(ctx, g, job)
# The controller reports Idle inside a G4 dwell, so the sampler
# above can return before the rapids; the window closing at M2
# is the end of the job. Keep sampling until then.
t0 = time.time()
while time.time() - t0 < 30:
ctx.checkpoint()
smp = sample(ctx)
if smp:
samples.append(smp)
if not smp["armed"]:
break
time.sleep(0.125)
finally:
ctx.clear_notice()
emis = [(s["t"], s["emission"], s["hv"]) for s in samples if s["emission"] is not None]
peak = max((e for _t, e, _hv in emis), default=0)
ctx.check(peak > 0, "no emission witnessed on the G1 (emission_samples stayed 0)")
# The first zero after the peak is the dark window the M5 and its dwell
# produce; everything after it is the two rapids and their dwells.
i_peak = max(range(len(emis)), key=lambda i: emis[i][1])
after = [x for x in emis[i_peak:] if x[1] == 0]
ctx.check(after, "emission_samples never returned to 0 after the M5")
t_dark = after[0][0]
tail = [x for x in emis if x[0] >= t_dark]
relit = [x for x in tail if x[1]]
hv_tail = max((hv for _t, _e, hv in tail if hv is not None), default=0)
ev.update({"samples": len(samples), "emission_peak": peak, "tail_samples": len(tail),
"relit_samples": len(relit), "hv_tail_max": hv_tail,
"relit_first": relit[0] if relit else None})
ctx.log("emission peak %s; dark from +%.1f s; %d samples after it spanning %.1f s, %d with "
"emission, HV max after dark %s", peak, t_dark - emis[0][0], len(tail),
tail[-1][0] - t_dark, len(relit), hv_tail)
ctx.check(tail[-1][0] - t_dark >= 5.0, "sampling ended %.1f s after the dark point, before "
"both rapids and their dwells (~5.2 s) had run", tail[-1][0] - t_dark)
ctx.check(not relit, "the laser emitted after the M5: %d samples, first at +%.1f s "
"(emission_samples %s) - a rapid after M5 fired at the last level",
len(relit), (relit[0][0] - t_dark) if relit else 0, relit[0][1] if relit else None)
ctx.check(hv_tail <= HV_DARK_MAX, "HV current %s after the M5 (idle reads ~0): the discharge "
"ran through a rapid", hv_tail)
ctx.log("PASS: emission peak %s on the G1, 0 through both rapids, HV %s after the M5",
peak, hv_tail)
@test("laser.disarm-in-hold", title="Disarm grace counts down in Hold", 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. Nothing else: the test holds the "
"job itself and waits about a minute for the disarm."],
description="Arm and start a +X move at S400/F300, feed-hold it after ~2 s of motion, and "
"hold: the disarm grace must count down while held and close the armed window "
"(armed -> false) without the job resuming, and the button goes dark with it.")
def disarm_in_hold(ctx):
ev = ctx.evidence
with ctx.grbl() as g, LiveJob(ctx, g):
prepare(ctx, g)
ctx.ready(ARM_CUE % "40 mm +X")
stream(g, ["G91", "G21", "M4", "S400", "G1 X40 F300"])
ctx.log("armed; waiting for motion to start (arm + your button press)...")
w = Watch(g)
ctx.act("button", "press", text="The button is lit white: the press arms the job and the "
"move starts.", until=w.in_state("Run"), timeout=180, fail=False)
st = w.last["state"] if w.last else None
ctx.check(st and st.startswith("Run"), "motion never started (state=%s) - arm refused or no press", st)
ctx.log("moving under laser: %s; feed-hold in 2 s", st)
ctx.sleep(2)
g.realtime(ord("!"))
t1 = time.time()
while time.time() - t1 < 5:
st = g.status_report()["state"]
if st.startswith("Hold"):
break
time.sleep(0.1)
ev["held_state"] = st
ctx.log("feed-held mid-move: %s; watching the disarm grace count down IN HOLD", st)
ctx.check(st.startswith("Hold"), "feed hold did not park (state %s)", st)
t0 = time.time()
disarmed_at = None
left_hold = None
while time.time() - t0 < 120:
ctx.checkpoint()
s = sample(ctx)
held = g.status_report()["state"].startswith("Hold")
if s and not s["armed"]:
disarmed_at = time.time() - t0
break
if not held and left_hold is None:
left_hold = g.status_report()["state"]
ctx.log("note: left Hold (state=%s) before disarm", left_hold)
time.sleep(1)
ev["disarmed_after_s"] = round(disarmed_at, 1) if disarmed_at is not None else None
ev["left_hold"] = left_hold
# recover: laser off, abort out of hold
g.command("M5", timeout=1)
g.realtime(0x18)
ctx.sleep(1)
if "Alarm" in g.status_report()["state"]:
g.command("$X")
ctx.check(disarmed_at is not None, "still armed after 120 s in Hold")
ctx.check(left_hold is None, "the job left Hold (%s) before the disarm", left_hold)
check_button_dark(ctx, ev)
ctx.log("PASS: disarmed in Hold after %.1f s, button dark", disarmed_at)
@test("laser.armed-kill", title="Armed kill mid-fire: the expected stop, then a SIGKILL",
subsystem="laser", kind="live", mode="grbl", est_min=6,
covers=_LASER_COVERS + [("forgectrl", "src/main.c")],
@@ -692,7 +600,9 @@ def disarm_in_hold(ctx):
"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 "
"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):
ev = ctx.evidence
fc = ctx.forgectrl
@@ -746,13 +656,20 @@ def armed_kill(ctx):
ctx.log("emission live (%s) - SIGKILL controller pid %s NOW", smp["emission"], pid)
t_kill = time.time()
_os.kill(pid, _signal.SIGKILL)
fast = kill_fast_trail(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")
ilk = hw.sysfs_int("cnc/interlock_circuit")
locked = ilk is not None and bool(ilk & (1 << 3))
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.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,
"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")
@@ -769,82 +686,18 @@ def armed_kill(ctx):
ctx.log("/mode after the kill: %s", m1)
ctx.check(m1 and m1.get("controller") == "running" and m1.get("pid") != pid,
"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")
check_button_dark(ctx, ev)
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)
@test("laser.arm-wait-lid", title="Lid open during the arm wait cancels the job",
subsystem="laser", kind="operator", mode="grbl", est_min=3,
covers=_LASER_COVERS + [("grblhal-glowforge", "src/glowforge_laser.c"),
("grblhal-glowforge", "src/glowforge_switches.c"),
("grblhal-glowforge", "src/glowforge_switch_map.h")],
requires=["kernel.latch-locked-idle", "motion.jog-roundtrip"], actions=["lid"],
steps=["Lid closed; nothing under the head needs to be in place - the machine will not fire.",
"When the white button lights, do NOT press it: open the lid instead, then close it "
"when told."],
description="Start a laser job so the controller unlocks the latch and lights the button, "
"then open the lid while it waits. The wait must abort with the lid named as the "
"reason, canceled with a soft reset (no alarm - nothing to unlock), the armed "
"window closed (armed -> false), the kernel latch back to locked, and no emission; "
"the controller ends Idle. No press is given, so nothing can fire.")
def arm_wait_lid(ctx):
ev = ctx.evidence
with ctx.grbl() as g, LiveJob(ctx, g):
prepare(ctx, g)
base = sample(ctx)
ctx.check(base, "forgectrl /status or /cool/status unavailable")
ctx.check(not base["armed"], "armed window already open before the job")
stream(g, ["G91", "G21", "M4", "S400", "G1 X5 F600"])
# The prompt: the latch is unlocked and the button lit from here on.
t0 = time.time()
text = ""
while time.time() - t0 < 30 and "press the button" not in text:
ctx.checkpoint()
text += g.drain()
time.sleep(0.1)
ctx.check("press the button" in text, "no arm prompt within 30 s (job did not reach the arm)")
s = sample(ctx)
ev["armed_during_wait"] = s["armed"] if s else None
ctx.log("arm prompt seen; armed=%s; asking the operator to open the lid", ev["armed_during_wait"])
ctx.act("lid", "open", text="The button is lit white: do NOT press it.", timeout=60)
t1 = time.time()
while time.time() - t1 < 15:
ctx.checkpoint()
text += g.drain()
if "job canceled" in text and "help]" in text:
break
time.sleep(0.1)
ev["messages"] = [ln for ln in text.splitlines() if ln.startswith("[MSG:") or ln.startswith("ALARM")]
ctx.log("controller: %s", ev["messages"])
ctx.check("lid opened during arm - job canceled" in text,
"the lid open was not reported as canceling the arm")
ctx.check("help]" in text, "no reset banner after the lid-open cancel (it must be a clean cancel)")
ctx.check("ALARM" not in text, "an alarm was raised on the lid-open cancel")
# Armed window closed and the kernel latch locked.
t2 = time.time()
s = None
while time.time() - t2 < 10:
s = sample(ctx)
if s and not s["armed"]:
break
time.sleep(0.25)
ev["armed_after"] = s["armed"] if s else None
ilk = hw.sysfs_int("cnc/interlock_circuit")
locked = ilk is not None and bool(ilk & (1 << 3))
ev["latch_locked"] = locked
ev["emission"] = s["emission"] if s else None
ctx.check(s and not s["armed"], "the armed window stayed open after the lid-open cancel")
ctx.check(locked, "kernel latch not locked after the lid-open cancel (interlock_circuit=%s)", ilk)
ctx.check(not ev["emission"], "emission_samples nonzero (%s) - nothing may have fired", ev["emission"])
check_button_dark(ctx, ev)
ctx.act("lid", "close")
ctx.sleep(1)
st = g.status_report()["state"]
ev["state_after"] = st
ctx.check(st.startswith("Idle"), "controller is %s after the cancel, expected Idle (no unlock needed)", st)
ctx.log("PASS: lid open during the arm wait canceled the job (clean reset, no alarm), armed=false, latch locked")
"respawned with the latch still locked, button dark", ev["expected"]["zero_at_s"], zero_at)
@test("laser.pause-resume-lid-cancel", title="One live cut: the button pauses and resumes it, the lid "
@@ -852,7 +705,7 @@ def arm_wait_lid(ctx):
subsystem="laser", kind="live", mode="grbl", est_min=7,
covers=_LASER_COVERS + [("grblhal-glowforge", "src/glowforge_switches.c"),
("grblhal-glowforge", "src/glowforge_switch_map.h")],
requires=["laser.emission-witness", "motion.lid-cancel-home", "motion.button-hold-resume"],
requires=["laser.emission-witness"],
actions=["button", "lid"],
steps=["Scrap under the head with 40 mm of free +X and +Y travel; lid closed; exhaust on.",
"Press the physical button when it lights white (arm). Once the cut is under way the "
-306
View File
@@ -1,306 +0,0 @@
"""logs.* - unified logging: the log tree, tail, and the sanitized export;
the routing path (emitters, relays, rendered rules); the level settings."""
import gzip
import io
import os
import re
import tarfile
import time
from ..catalog import test
from .. import hw
def _read(path, default=None):
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
return f.read()
except OSError:
return default
_LOG_COVERS = [("forgectrl", "src/logs.*"), ("forgectrl", "src/fflog.*"), ("forgectrl", "src/sanitize.*"),
("forgectrl", "src/main.c")]
@test("logs.tree-tail-export", title="Log tree, tail, and sanitized export", subsystem="logs",
kind="auto", est_min=1,
covers=_LOG_COVERS, requires=["forgectrl.auth"],
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 "
"bundle that contains neither the panel token nor the camera key.")
def tree_tail_export(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
st, body = fc.get("/logs")
ctx.log("GET /logs -> %s", st)
ctx.check(st == 200 and isinstance(body, dict), "GET /logs -> %s", st)
loggers = body.get("loggers") or []
names = [l.get("name") for l in loggers] if isinstance(loggers, list) else list(loggers)
ev["loggers"] = names
ctx.log("loggers: %s", names)
ctx.check("forgectrl" in names, "/logs lacks the forgectrl logger: %s", names)
st, tail = fc.get("/logs/tail", params={"name": "forgectrl", "lines": "20"})
ctx.log("GET /logs/tail?name=forgectrl -> %s", st)
ctx.check(st == 200, "GET /logs/tail -> %s %s", st, tail if isinstance(tail, dict) else "")
st, tail = fc.get("/logs/tail", params={"name": "no-such-logger"})
ctx.check(st == 404, "unknown logger -> %s, expected 404", st)
# the sanitizer walks every log file; a full tree takes tens of
# seconds on the target, so this call gets its own client timeout
slow = hw.Forgectrl(token=fc.token, timeout=300.0)
t0 = time.time()
st, data = slow.post("/logs/export", raw=True)
ev["export_s"] = round(time.time() - t0, 1)
ctx.log("POST /logs/export -> %s (%d bytes, %.1f s)", st, len(data) if data else 0, ev["export_s"])
ctx.check(st == 200, "POST /logs/export -> %s", st)
ev["export_bytes"] = len(data)
try:
raw = gzip.decompress(data)
tf = tarfile.open(fileobj=io.BytesIO(raw))
members = tf.getnames()
except (OSError, tarfile.TarError, EOFError) as e:
ctx.fail("export is not a readable tar.gz: %s", e)
ev["members"] = len(members)
ctx.log("bundle: %d members, e.g. %s", len(members), members[:5])
ctx.check(members, "empty bundle")
ctx.check(any(m.endswith("README.txt") for m in members), "sanitized bundle lacks README.txt")
# the two secrets the sanitizer knows by value and a bundle could
# carry: the panel token and the camera key (a URL parameter that
# lands in sender logs)
secrets = {}
if fc.token:
secrets["panel token"] = fc.token
st, body = fc.get("/system/camera-key")
if st == 200 and isinstance(body, dict) and body.get("key"):
secrets["camera key"] = body["key"]
ctx.check("camera key" in secrets, "GET /system/camera-key -> %s: no key to check for", st)
contents = [(m.name, tf.extractfile(m).read()) for m in tf.getmembers() if m.isfile()]
for what, value in secrets.items():
leaked = [name for name, content in contents if value.encode() in content]
ev[what.replace(" ", "_") + "_leaks"] = leaked
ctx.check(not leaked, "the sanitized bundle contains the %s: %s", what, leaked)
# The routing test proves the whole path every logger takes: emitter (or
# relay) -> /dev/log -> rsyslog rules rendered from the settings -> the
# logger's own file in the ff_line format. It stands for the emitters in
# every component (fflog in forgectrl and grblHAL, the SysLogHandler in the
# Python apps), the supervisor's per-controller relay, the daemon's fifo
# relay in its init script, and the render step.
_ROUTING_COVERS = [("forgectrl", "src/logs.*"), ("forgectrl", "src/fflog.*"), ("forgectrl", "src/super.c"),
("forgectrl", "init/**"),
("grblhal-glowforge", "src/fflog.*"), ("grblhal-glowforge", "src/main.c"),
("forgefirm-app", "forgefirm-app/ffmachine.py"), ("forgefirm-app", "forgefirm-app/gfcloud.py"),
("forgefirm-app", "forgefirm-app/gfhome.py")]
LOGS_ROOT = "/data/log/forgefirm"
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]+)\] "
r"(?P<sev>EMERG|ALERT|CRIT|ERR|WARNING|NOTICE|INFO|DEBUG) (?P<msg>.*)$")
_SEV_RANK = {"off": -1, "error": 3, "warning": 4, "notice": 5, "info": 6, "debug": 7}
def _tail_lines(path, n=400):
try:
with open(path, "rb") as f:
f.seek(0, 2)
size = f.tell()
f.seek(max(0, size - 256 * 1024))
data = f.read()
except OSError:
return []
return data.decode("utf-8", "replace").splitlines()[-n:]
def _wait_line(path, needle, timeout=6.0):
"""Poll a log file for a line containing needle; the matched line or None."""
deadline = time.time() + timeout
while time.time() < deadline:
for line in _tail_lines(path, 200):
if needle in line:
return line
time.sleep(0.25)
return None
@test("logs.routing", title="Log routing, relays, and the rendered rules", subsystem="logs",
kind="auto", est_min=1,
covers=_ROUTING_COVERS, requires=["logs.tree-tail-export"],
description="rsyslog is the only logger (no busybox syslogd/klogd), the rules rendered from "
"the settings and the effective-levels record exist and agree with /logs, every "
"logger has its directory, the daemon's own emitter and the per-controller "
"`logger` relay both land in the right file in the ff_line format, a stray "
"program's line goes to system/ and nowhere else, kernel lines reach kernel/, "
"and nothing of ours is written outside the tree.")
def routing(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
# 1. one logger daemon
rs = hw.pidof("rsyslogd")
ev["rsyslogd_pids"] = rs
ctx.check(rs, "rsyslogd is not running")
for legacy in ("syslogd", "klogd"):
pids = hw.pidof(legacy)
ctx.check(not pids, "busybox %s is running (%s) next to rsyslog", legacy, pids)
ctx.log("rsyslogd %s; no syslogd/klogd", rs)
# 2. the render step: rules + effective levels, consistent with what
# the API reports as effective
ctx.check(os.path.isfile("/data/forgefirm/rsyslog-forgefirm.conf"),
"rendered rules missing: /data/forgefirm/rsyslog-forgefirm.conf")
rules = _read("/data/forgefirm/rsyslog-forgefirm.conf") or ""
for name in LOGGERS:
ctx.check(("# %s:" % name) in rules, "rendered rules carry no block for %s", name)
ctx.check(os.path.isfile("/var/run/forgefirm-loglevels"), "effective-levels record missing")
st, body = fc.get("/logs")
ctx.check(st == 200 and isinstance(body, dict), "GET /logs -> %s", st)
ctx.check(body.get("effective_known") is True, "/logs does not know the effective levels")
eff = {}
for l in body.get("loggers") or []:
eff[l["name"]] = (l.get("effective_disk"), l.get("effective_remote"))
ev["effective"] = eff
ctx.log("effective levels: %s", eff)
ctx.check(set(eff) == set(LOGGERS), "loggers reported: %s", sorted(eff))
levels = _read("/var/run/forgefirm-loglevels") or ""
for name, (disk, _remote) in eff.items():
ctx.check(("log_%s_disk=%s" % (name, disk)) in levels,
"effective record and /logs disagree on %s (%s)", name, disk)
# 3. the tree
for name in LOGGERS:
ctx.check(os.path.isdir(os.path.join(LOGS_ROOT, name)), "no directory for %s", name)
# 4. the daemon's own emitter (fflog): a line from the running pid, if
# its level admits info
pids = hw.pidof("forgectrl")
ctx.check(pids, "forgectrl is not running")
disk_rank = _SEV_RANK.get(eff.get("forgectrl", ("info", "off"))[0], 6)
fpath = os.path.join(LOGS_ROOT, "forgectrl", "forgectrl.log")
if disk_rank >= 6:
own = [l for l in _tail_lines(fpath, 2000)
if any((" forgectrl[%d] " % p) in l for p in pids)]
ev["own_lines"] = len(own)
ctx.log("forgectrl[%s] lines in its file: %d", pids, len(own))
ctx.check(own, "no line from the running daemon in %s (emitter path broken?)", fpath)
m = _LINE_RE.match(own[-1])
ctx.check(m and m.group("prog") == "forgectrl", "not in the ff_line format: %r", own[-1])
else:
ctx.log("forgectrl disk level %s: own-emitter check skipped", eff["forgectrl"][0])
# 5. the relay path: a `logger` probe under a controller's name lands
# in that controller's file with the ff_line format
nonce = "forgetest-routing-%d" % int(time.time())
for name in ("grblhal", "gfhome"):
rank = _SEV_RANK.get(eff.get(name, ("info", "off"))[0], 6)
if rank < 3:
ctx.log("%s disk level off: relay probe skipped", name)
continue
rc, out = hw.run(["logger", "-t", name, "-p", "daemon.err", "%s %s" % (nonce, name)])
ctx.check(rc == 0, "logger -t %s failed: %s", name, out)
path = os.path.join(LOGS_ROOT, name, name + ".log")
line = _wait_line(path, nonce)
ev["probe_" + name] = line
ctx.check(line, "%s probe did not reach %s", name, path)
m = _LINE_RE.match(line)
ctx.check(m and m.group("prog") == name and m.group("sev") == "ERR",
"%s probe line not in the ff_line format: %r", name, line)
ctx.log("%s: %s", name, line)
# a probe under another name must NOT leak into these files
rc, out = hw.run(["logger", "-t", "forgetest-stray", "-p", "daemon.err", "%s stray" % nonce])
time.sleep(1.5)
for name in ("grblhal", "gfhome", "forgectrl"):
path = os.path.join(LOGS_ROOT, name, name + ".log")
ctx.check(not any((nonce + " stray") in l for l in _tail_lines(path, 100)),
"a stray program's line landed in %s", path)
sys_rank = _SEV_RANK.get(eff.get("system", ("info", "off"))[0], 6)
if sys_rank >= 3:
line = _wait_line(os.path.join(LOGS_ROOT, "system", "system.log"), nonce + " stray")
ctx.check(line, "the stray program's line did not reach system/")
# 6. relay processes: the supervisor spawns one `logger` per controller
# (its stdin is the controller's output pipe); the daemon's init
# script keeps one on the fifo
st, mode = fc.get("/mode")
relays = hw.pidof("logger")
ev["logger_relays"] = relays
ev["controller"] = mode.get("controller") if isinstance(mode, dict) else None
ctx.log("logger relays: %s (controller %s)", relays, ev["controller"])
if isinstance(mode, dict) and mode.get("controller") == "running":
ctx.check(relays, "controller running but no `logger` relay process")
if os.path.exists("/var/run/forgectrl.stderr"):
ctx.check(len(relays) >= 2 or ev["controller"] != "running",
"fifo relay present but only %s logger process(es)", relays)
# 7. the kernel reaches kernel/ (unless filtered off)
if _SEV_RANK.get(eff.get("kernel", ("info", "off"))[0], 6) >= 6:
klines = _tail_lines(os.path.join(LOGS_ROOT, "kernel", "kernel.log"), 50)
ev["kernel_lines"] = len(klines)
ctx.check(klines and any(" kernel[" in l for l in klines), "no kernel lines in kernel/")
# 8. nothing outside the tree
for stray in ("/data/forgectrl.log", "/data/gfcloud.log", "/data/gfhome.log",
"/data/log/gfcloud", "/data/log/gfhome", "/var/log/messages"):
ctx.check(not os.path.exists(stray), "pre-syslog log path still present: %s", stray)
ctx.log("no ForgeFIRM log outside %s", LOGS_ROOT)
@test("logs.level-settings", title="Log level and remote-target settings", subsystem="logs",
kind="auto", est_min=1,
covers=[("forgectrl", "src/logs.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/settings.*")],
requires=["logs.tree-tail-export", "forgectrl.settings-bounds"],
description="A log level outside off..debug, a bad remote port, a bad protocol and a bad "
"server name are refused (400); a valid remote level is accepted and /logs shows "
"it as configured but not effective (pending_reboot); the setting is restored to "
"its prior value.")
def level_settings(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle: settings are locked")
for key, val in (("log_grblhal_disk", "verbose"), ("log_kernel_remote", "7"),
("syslog_port", "70000"), ("syslog_proto", "tls"),
("syslog_server", "bad host name")):
st, body = fc.post("/settings", data={key: val})
ev["refused %s=%s" % (key, val)] = st
ctx.log("POST /settings %s=%s -> %s", key, val, st)
ctx.check(st == 400, "%s=%s -> %s, expected 400", key, val, st)
st, before = fc.get("/logs")
ctx.check(st == 200 and isinstance(before, dict), "GET /logs -> %s", st)
prior = {l["name"]: l for l in before.get("loggers") or []}
ctx.check("system" in prior, "/logs lacks the system logger")
was = prior["system"].get("remote") or "off"
was_pending = bool(before.get("pending_reboot"))
new = "warning" if was != "warning" else "error"
ev["system_remote_before"] = was
ev["pending_before"] = was_pending
try:
st, body = fc.post("/settings", data={"log_system_remote": new})
ctx.log("POST /settings log_system_remote=%s -> %s", new, st)
ctx.check(st == 200, "valid level refused: %s", st)
st, after = fc.get("/logs")
now = {l["name"]: l for l in after.get("loggers") or []}
ctx.check(now["system"].get("remote") == new, "configured level reads %r", now["system"].get("remote"))
ctx.check(now["system"].get("effective_remote") == prior["system"].get("effective_remote"),
"effective level changed without a reboot")
ctx.check(after.get("pending_reboot") is True, "pending_reboot not raised by a level change")
ctx.log("configured %s, effective %s, pending_reboot %s",
now["system"].get("remote"), now["system"].get("effective_remote"), after.get("pending_reboot"))
finally:
# restore: a value that was stored goes back as a value; a default
# is restored by clearing the key (a query parameter - empty
# values never reach the form body)
stored = fc.settings().get("log_system_remote", "")
if was == "off" and stored in ("", None, new):
st, body = fc.post("/settings", params={"log_system_remote": ""})
else:
st, body = fc.post("/settings", data={"log_system_remote": was})
ctx.log("restore log_system_remote -> %s", st)
st, final = fc.get("/logs")
fin = {l["name"]: l for l in final.get("loggers") or []}
ctx.check(fin["system"].get("remote") == was, "system remote level not restored (%r)", fin["system"].get("remote"))
ctx.check(bool(final.get("pending_reboot")) == was_pending,
"pending_reboot did not return to %s after the restore", was_pending)
+369 -468
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.
Ported from `scripts/bench/pacing_test.py` (protocol-loop pacing) and
@@ -5,7 +10,9 @@ Ported from `scripts/bench/pacing_test.py` (protocol-loop pacing) and
and round-trip; the laser stays latched (the tests never touch it); the
suite is the only Grbl client while a test runs.
"""
import json
import os
import struct
import time
from ..catalog import test
@@ -56,39 +63,6 @@ def wait_state(ctx, g, prefix, timeout):
return None
def wait_state_text(ctx, g, prefix, timeout):
"""wait_state, keeping everything the controller said while polling.
The driver reports a press with a [MSG:] line the moment it acts on it -
inside the poll window - so a test that wants both the state and the
message has to collect them together."""
end = time.time() + timeout
text = ""
while time.time() < end:
ctx.checkpoint()
st = g.status_report()
text += g.drain()
if st["state"].startswith(prefix):
return st, text
time.sleep(0.1)
return None, text + g.drain()
def wait_left_state(ctx, g, prefix, timeout):
"""The first report whose state is no longer `prefix`, with the text.
Leaving a state is what proves a command was acted on; catching the
state it moves INTO is a race whenever the remaining work is short."""
end = time.time() + timeout
text = ""
while time.time() < end:
ctx.checkpoint()
st = g.status_report()
text += g.drain()
if not st["state"].startswith(prefix):
return st, text
time.sleep(0.1)
return None, text + g.drain()
def wait_idle(ctx, g, timeout=30.0, poll=0.05, grace=0.3):
"""Poll until Idle; returns (peak_feed_mm_min, states_seen, final_report).
An Idle report inside the first `grace` seconds counts only once a
@@ -136,9 +110,27 @@ def clean_slate(ctx, g):
ctx.log("unlock: %s", g.command("$X"))
st = g.status_report()
ctx.check(st["state"].startswith("Idle"), "controller is %s, expected Idle", st["state"])
clear_error(ctx, g)
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)
@test("motion.pacing", title="Protocol-loop pacing (idle, parked, moving) and hold/resume position",
subsystem="motion", kind="auto", mode="grbl", est_min=1,
covers=_MOTION_COVERS, requires=["kernel.latch-locked-idle"],
@@ -567,99 +559,6 @@ def _wait_controller(ctx, fc, states, timeout, motion=None):
return m if isinstance(m, dict) else {}
def _button_led(ctx, window_s=1.2):
"""The commanded level of the button's red, green, and blue channels
(ledtrig_smooth's target), sampled over a blink period or two and
reduced to the maximum per channel: with a pulse set, target reads 0
through the off half of every blink. None when the LEDs are not
readable."""
best = None
t0 = time.time()
while time.time() - t0 < window_s:
out = []
for ch in (1, 2, 3):
try:
with open("/sys/class/leds/button_led_%d/target" % ch, "r", encoding="utf-8") as f:
out.append(int(f.read().strip()))
except (OSError, ValueError):
return None
best = out if best is None else [max(a, b) for a, b in zip(best, out)]
ctx.sleep(0.1)
return best
@test("motion.gate-waits-for-lid", title="The motion gate waits for the lid", subsystem="motion",
kind="operator", est_min=2,
covers=[("forgectrl", "src/super.*"), ("forgectrl", "src/liveness.*"), ("forgectrl", "src/led.*"),
("forgectrl", "src/ui/panel.js")],
requires=["motion.liveness-probe"], actions=["lid"],
steps=["The lid opens, forgectrl is restarted (the gate runs at the first spawn of a "
"session: power-on, a daemon restart), the lid closes: the head does not move "
"while the lid is open, then makes the 15 mm probe move (+X first)."],
description="With the lid open when forgectrl starts (power-on with the lid up, or a "
"daemon restart) the supervisor starts nothing: GET /mode reports controller "
"waiting with why naming the lid, motion unverified, no pid, the button blinks "
"amber, and no probe line reaches the log. When the lid closes the probe runs "
"(MOTION OK), the lens takes its reference, and the controller comes up "
"verified with the button handed back.")
def gate_waits_for_lid(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle at the start")
try:
ctx.act("lid", "open")
off = _log_offset(FORGECTRL_LOG)
rc, out = hw.initd("forgectrl", "restart")
ctx.check(rc == 0, "forgectrl restart -> rc %s", rc)
m = _wait_controller(ctx, fc, ("waiting", "running", "motion-fault"), 60)
ev["waiting"] = m
ctx.log("mode with the lid open: %s", m)
ctx.check(m.get("controller") == "waiting",
"controller is %r with the lid open, expected waiting", m.get("controller"))
ctx.check("lid" in (m.get("why") or ""), "why does not name the lid: %r", m.get("why"))
ctx.check(m.get("motion") == "unverified", "motion reads %r while waiting", m.get("motion"))
ctx.check(not m.get("pid"), "a controller (pid %s) runs while the gate waits", m.get("pid"))
led = _button_led(ctx)
ev["led_waiting"] = led
ctx.log("button LED targets while waiting (r, g, b): %s", led)
ctx.check(led is not None and led[0] > 0 and led[1] > 0 and led[2] == 0,
"the button does not blink amber while the gate waits: %s", led)
ctx.sleep(3)
st, m2 = fc.get("/mode")
ctx.check(isinstance(m2, dict) and m2.get("controller") == "waiting", "the wait did not hold: %s", m2)
lines = _probe_lines(FORGECTRL_LOG, off)
ctx.check(not lines, "the probe ran with the lid open: %s", lines[:1])
t_close = time.time()
ctx.act("lid", "close")
m = _wait_controller(ctx, fc, ("running", "motion-fault"), 150, motion="verified")
ev["after_close"] = m
ev["close_to_running_s"] = round(time.time() - t_close, 1)
ctx.log("mode %.1f s after the lid closed: %s", time.time() - t_close, m)
ctx.check(m.get("controller") == "running" and m.get("motion") == "verified",
"the controller did not come up verified after the lid closed: %s", m)
t1 = time.time()
lines = _probe_lines(FORGECTRL_LOG, off)
while not lines and time.time() - t1 < 10:
ctx.sleep(0.5)
lines = _probe_lines(FORGECTRL_LOG, off)
ev["probe_lines"] = lines[-3:]
ctx.check(lines and "MOTION OK" in lines[0],
"the probe after the lid closed was not MOTION OK: %s", lines[:1])
led = _button_led(ctx)
ev["led_running"] = led
ctx.check(led is None or led[0] == 0 or led[2] > 0,
"the amber wait pattern is still on the button after the start: %s", led)
finally:
if ctx.switch("lid") is False:
ctx.act("lid", "close", fail=False)
m = _wait_controller(ctx, fc, ("running", "motion-fault"), 150)
if m.get("controller") != "running":
fc.post("/controller/start")
_wait_controller(ctx, fc, ("running", "motion-fault"), 150)
ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle after the drill")
# ---------------------------------------------------------------- cancel / abort
@test("motion.cancel-abort", title="Jog cancel and controlled abort recover cleanly", subsystem="motion",
@@ -753,21 +652,30 @@ def _return_x(ctx, delta_mm):
machine_idle(ctx)
@test("motion.deadman", title="Dead-man: controller kill, controller hang, forgectrl restart mid-move",
subsystem="motion", kind="auto", mode="grbl", est_min=4,
@test("motion.deadman", title="Dead-man: controller kill, controller hang, forgectrl restart mid-move, "
"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/**")],
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, "
"latch relocked - it never unlocked), and respawns within seconds. SIGSTOP (a "
"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 "
"(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 "
"restart. 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.")
"restart. A short SIGSTOP (100 ms, inside the kernel's 200 ms queue) mid-move: "
"no underrun, but the step producer comes back later than its lead and its "
"late events clamp; unarmed that is a warning in the controller's log and the "
"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):
import os as _os
import signal as _signal
@@ -897,6 +805,47 @@ def deadman(ctx):
x1 = _kernel_x_mm(ctx)
_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
m2 = wait_running(10)
pid2 = m2["pid"]
@@ -934,17 +883,104 @@ def deadman(ctx):
x1 = _kernel_x_mm(ctx)
_return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None)
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)
# ------------------------------------------------- lid / button (the factory's)
_LID_COVERS = _MOTION_COVERS + [("grblhal-glowforge", "src/glowforge_switches.c"),
("grblhal-glowforge", "src/glowforge_switch_map.h"),
("grblhal-glowforge", "src/glowforge_laser.c")]
def kernel_xy_mm(ctx):
"""The kernel's own position counters, in mm (forgectrl /status pos):
what the machine physically did, independent of what grbl believes."""
@@ -1012,334 +1048,6 @@ def drain_text(g, seconds):
return text
def expect_cancel_and_return(ctx, g, ev, start, k0, why, tag):
"""The cancel policy's whole tail, shared by every trigger that ends a
job this way (lid or interlock, from Run or from a hold): the reason is
reported, the controller resets without an alarm and with the position
kept, and the head goes back to where the job started - which the KERNEL
counters have to confirm, not grbl's belief about them."""
text = drain_text(g, 3.0)
msgs = [ln for ln in text.splitlines()
if ln.startswith("[MSG:") or "help]" in ln or ln.startswith("ALARM")]
ev[tag + "_messages"] = msgs
ctx.log("[%s] controller: %s", tag, msgs)
cancel_msg = "%s - job canceled" % why
ctx.check(cancel_msg in text, "[%s] the job was not canceled with %r as the reason", tag, why)
ctx.check("help]" in text, "[%s] no reset banner after the cancel (the sender must see the job end)", tag)
ctx.check("ALARM" not in text, "[%s] an alarm was raised on the cancel (position should be kept)", tag)
t0 = time.time()
returned = "returned to the job start" in text
while not returned and time.time() - t0 < 30:
ctx.checkpoint()
text += g.drain()
returned = "returned to the job start" in text
time.sleep(0.2)
ev[tag + "_returned_message"] = returned
ctx.check(returned, "[%s] the head did not report returning to the job start within 30 s", tag)
st = wait_state(ctx, g, "Idle", 5)
ctx.check(st is not None, "[%s] not Idle after the return (state %s)", tag,
g.status_report()["state"])
drift = max(abs(st["MPos"][i] - start[i]) for i in range(2))
ev[tag + "_drift_mm"] = round(drift, 3)
ctx.log("[%s] back at the job start: drift %.3f mm", tag, drift)
ctx.check(drift <= 0.05, "[%s] head not back at the job start (drift %.3f mm)", tag, drift)
machine_idle(ctx, 10)
check_kernel_returned(ctx, ev, k0, tag=tag)
return drift
@test("motion.button-hold-resume", title="The button pauses and resumes a job",
subsystem="motion", kind="operator", mode="grbl", est_min=2,
covers=_LID_COVERS, requires=["motion.pacing"], actions=["button"],
steps=["Bed clear; the head needs 40 mm of free +X travel. No laser is involved.",
"On Ready the head starts an 8 s move: press the button once while it moves (the "
"job holds), then once more when told (it resumes and finishes)."],
description="A travel job is running; one press of the big button feed-holds it (the sender "
"sees Hold), the next press resumes it (Run) and the move completes with its "
"position intact - the factory's pause/resume on the machine.")
def button_hold_resume(ctx):
ev = ctx.evidence
with ctx.grbl() as g:
clean_slate(ctx, g)
start = g.status_report()["MPos"]
ctx.ready("On Ready the head starts an 8 s move along +X. Press the button ONCE while it "
"moves; the job holds and the test sees it.")
g.command("M5")
g.command("G91")
g.command("G1X40F300", timeout=0.5) # 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
w = Watch(g)
ctx.act("button", "press", until=w.in_state("Hold"), timeout=12, fail=False)
st, text = w.last, w.text
ctx.check(st is not None and st["state"].startswith("Hold"),
"the press did not hold the job (state %s)", st["state"] if st else "?")
ev["held_state"] = st["state"]
ev["held_at_mm"] = round(st["MPos"][0] - start[0], 3)
ev["pause_message"] = "job paused" in text
ctx.log("held: %s at %.3f mm of 40; message seen: %s", st["state"], ev["held_at_mm"],
ev["pause_message"])
# The press is proven by the job LEAVING the hold. Catching it in Run
# is a race: a pause late in the move leaves a fraction of a second of
# travel, which can be over before the next poll - the machine did
# exactly the right thing and the test would still have called it a
# failure.
w = Watch(g)
ctx.act("button", "press", text="The head is stopped: the press resumes the move.",
until=w.left_state("Hold"), timeout=60, fail=False)
st, text = w.last, w.text
if st is not None and st["state"].startswith("Hold"):
st = None
ev["state_after_resume"] = st["state"] if st else None
ev["resume_message"] = "job resumed" in text
ctx.check(st is not None, "the second press did not resume the job (still held: %s)",
g.status_report()["state"])
# Leaving the hold for Run or Idle is the resume; leaving it for Alarm
# or Door is something else entirely, and must not read as a pass.
ctx.check(st["state"].startswith(("Run", "Idle")),
"the job left the hold into %s, not into motion", st["state"])
ctx.log("resumed: %s; message seen: %s", ev["state_after_resume"], ev["resume_message"])
peak, states, st = wait_idle(ctx, g, 40)
ctx.check("TIMEOUT" not in states, "the resumed move did not complete: %s", states)
moved = st["MPos"][0] - start[0]
ev["moved_mm"] = round(moved, 3)
ctx.log("move completed after pause/resume: %.3f mm of 40", moved)
ctx.check(abs(moved - 40.0) <= 0.05, "the resumed move did not land on its target (%.3f mm)", moved)
g.command("$J=G91X-40F2400")
wait_idle(ctx, g, 30)
g.command("G90")
machine_idle(ctx)
ctx.log("PASS: button press held the job (%s), the next press resumed it, target reached", ev["held_state"])
@test("motion.lid-cancel-home", title="Lid open during a job - running or paused - cancels it and returns "
"to the job start",
subsystem="motion", kind="operator", mode="grbl", est_min=5,
covers=_LID_COVERS, requires=["motion.pacing", "motion.cancel-abort"],
actions=["lid", "button"],
steps=["Bed clear; the head needs 40 mm of free +X travel. No laser is involved.",
"Twice, on Ready the head starts an 8 s move. First: open the lid while it moves and "
"leave it open until the head has come back, then close it. Second: press the button "
"once while it moves (pause), then open the lid, leave it open until the head is back, "
"and close it."],
description="A travel job is running when the lid opens: the job parks (planned deceleration), "
"the reason is reported, the controller resets (position kept, no alarm - the "
"sender's job is over), and the head returns on its own to where the job started "
"with the lid still open; the controller ends Idle at the start position. The same "
"job paused on the button takes the same path - a lid open from the hold cancels "
"and returns, it never resumes. With lid_policy=cancel (the default).")
def lid_cancel_home(ctx):
ev = ctx.evidence
policy = (ctx.forgectrl.settings() or {}).get("lid_policy") or "cancel"
ev["lid_policy"] = policy
ctx.check(policy == "cancel", "lid_policy is %r; this test needs cancel", policy)
with ctx.grbl() as g:
clean_slate(ctx, g)
k0 = kernel_start(ctx)
start = g.status_report()["MPos"]
ev["kernel_start"] = k0
ev["start"] = start
ctx.ready("On Ready the head starts an 8 s move along +X. Open the lid while it moves and "
"leave it open until the head has come back on its own.")
g.command("M5")
g.command("G91")
g.command("G1X40F300", timeout=0.5) # 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
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")
sw = (ctx.forgectrl.status().get("switches") or {})
ev["lid_at_return"] = sw.get("lid")
ctx.act("lid", "close")
ctx.sleep(1)
# a jog afterward proves the controller is usable without $X
r = g.command("$J=G91X5F1200")
ctx.check(not any(x.startswith("error") for x in r), "jog refused after the cancel: %s", r)
wait_idle(ctx, g, 15)
g.command("$J=G91X-5F1200")
wait_idle(ctx, g, 15)
# -- the same cancel, entered from a hold ---------------------------
# A job paused on the button must not be resumable past a lid open:
# the armed window and the hardware button latch have to agree, so
# the lid ends the job here exactly as it does from Run.
# The reference for this phase is taken only once the MACHINE is at
# rest: the jogs above waited for grblHAL's Idle, which arrives while
# the kernel is still playing their tail.
k1 = kernel_start(ctx)
start2 = g.status_report()["MPos"]
ev["hold_start"] = start2
ctx.ready("On Ready the head starts the 8 s move again. Press the button ONCE while it "
"moves (the job pauses); then open the lid and leave it open until the head has "
"come back.")
g.command("G91") # the reset restored G90
g.command("G1X40F300", timeout=0.5)
ctx.sleep(0.5)
ctx.check(g.status_report()["state"].startswith("Run"), "the second move did not start")
g.drain()
w = Watch(g)
ctx.act("button", "press", text="The job pauses.", until=w.in_state("Hold"), timeout=12,
fail=False)
st, held = w.last, w.text
ctx.check(st is not None and st["state"].startswith("Hold"),
"the press did not hold the job (state %s)", st["state"] if st else "?")
ev["hold_state"] = st["state"]
ev["hold_pause_message"] = "job paused" in held
ctx.log("paused: %s; message seen: %s", st["state"], ev["hold_pause_message"])
g.drain()
ctx.act("lid", "open", text="The job is paused: leave the lid open until the head has come "
"back.", timeout=60)
hold_drift = expect_cancel_and_return(ctx, g, ev, start2, k1, "lid opened", "hold")
ctx.check(not g.status_report()["state"].startswith("Hold"),
"the controller is still holding after the lid canceled the paused job")
ctx.act("lid", "close")
ctx.sleep(1)
r = g.command("$J=G91X5F1200")
ctx.check(not any(x.startswith("error") for x in r), "jog refused after the paused cancel: %s", r)
wait_idle(ctx, g, 15)
g.command("$J=G91X-5F1200")
wait_idle(ctx, g, 15)
g.command("G90")
machine_idle(ctx)
ctx.log("PASS: lid open canceled the job from Run (drift %.3f mm) and from the hold (drift %.3f mm), "
"reset without alarm, head returned to the start both times", drift, hold_drift)
@test("motion.interlock-cancel-home", title="The interlock loop cancels a job like the lid and returns to "
"the job start",
subsystem="motion", kind="operator", mode="grbl", est_min=4,
covers=_LID_COVERS, requires=["motion.lid-cancel-home"], actions=["interlock"],
steps=["Bed clear; the head needs 60 mm of free +X travel. No laser is involved.",
"Be able to open the remote-interlock loop: unplug the Pro's interlock plug, or pull the "
"jumper at J8 on a Basic/Plus.",
"On Ready the head starts a 12 s move: open the interlock while it moves and leave it "
"open until the head has come back, then restore it."],
description="The remote-interlock loop is the lid's equal in the cancel policy: opening it mid-job "
"cancels the job with 'interlock open' named as the reason - the lid's own message would "
"be wrong here - and sends the head back to the job start with the loop still open. "
"(An open lid not stopping the park is the lid test's park, which runs with the lid open "
"throughout; in GRBL mode the park is a rapid too short to open a lid during, so the "
"mid-park lid edge is the cloud test's.)")
def interlock_cancel_home(ctx):
ev = ctx.evidence
policy = (ctx.forgectrl.settings() or {}).get("lid_policy") or "cancel"
ev["lid_policy"] = policy
ctx.check(policy == "cancel", "lid_policy is %r; this test needs cancel", policy)
sw = (ctx.forgectrl.status().get("switches") or {})
ctx.check(sw.get("interlock_ok"), "the interlock loop already reads open - close it before this test")
with ctx.grbl() as g:
clean_slate(ctx, g)
k0 = kernel_start(ctx)
start = g.status_report()["MPos"]
ev["start"] = start
ev["kernel_start"] = k0
ctx.ready("On Ready the head starts a 12 s move along +X. Open the interlock loop while it "
"moves and leave it open until the head has come back on its own.")
g.command("M5")
g.command("G91")
g.command("G1X60F300", timeout=0.5) # a 12 s move
ctx.sleep(0.5)
ctx.check(g.status_report()["state"].startswith("Run"), "the move did not start")
g.drain()
ctx.act("interlock", "open", text="Leave it open until the head has come back.", timeout=16)
sw = (ctx.forgectrl.status().get("switches") or {})
ev["interlock_ok_after_pull"] = sw.get("interlock_ok")
ctx.check(sw.get("interlock_ok") is False,
"the interlock still reads closed - the loop was not opened (switches: %s)", sw)
drift = expect_cancel_and_return(ctx, g, ev, start, k0, "interlock open", "interlock")
sw = (ctx.forgectrl.status().get("switches") or {})
ev["switches_at_return"] = {"lid": sw.get("lid"), "interlock_ok": sw.get("interlock_ok")}
ctx.log("at the end of the park: %s", ev["switches_at_return"])
ctx.check(sw.get("interlock_ok") is False,
"the interlock was closed again before the park finished - the park ran with the loop "
"restored, not open")
ctx.act("interlock", "close")
ctx.sleep(1)
sw = (ctx.forgectrl.status().get("switches") or {})
ev["restored"] = {"lid": sw.get("lid"), "interlock_ok": sw.get("interlock_ok")}
ctx.check(sw.get("interlock_ok"), "the interlock loop is still open - restore it before continuing")
r = g.command("$J=G91X5F1200")
ctx.check(not any(x.startswith("error") for x in r), "jog refused after the cancel: %s", r)
wait_idle(ctx, g, 15)
g.command("$J=G91X-5F1200")
wait_idle(ctx, g, 15)
g.command("G90")
machine_idle(ctx)
ctx.log("PASS: interlock open canceled the job with its own reason and the head returned to the "
"start (drift %.3f mm) with the loop still open", drift)
@test("motion.lid-policy-hold", title="lid_policy=hold parks the job in Door and a cycle start resumes it",
subsystem="motion", kind="operator", mode="grbl", est_min=4,
covers=_LID_COVERS + [("forgectrl", "src/settings.*")], requires=["motion.lid-cancel-home"],
actions=["lid"],
steps=["Bed clear; the head needs 40 mm of free +X travel. No laser is involved.",
"On Ready the head starts an 8 s move: open the lid while it moves (the job parks in "
"Door), then close it when told; the job finishes after that."],
description="The other lid policy, kept for senders that expect stock grblHAL: with "
"lid_policy=hold a lid open parks the job in the door state and holds it there - "
"no cancel, no return home - and once the lid is closed a cycle start finishes the "
"move with its position intact. The setting is restored to cancel at the end.")
def lid_policy_hold(ctx):
ev = ctx.evidence
fc = ctx.forgectrl
was = (fc.settings() or {}).get("lid_policy") or "cancel"
ev["lid_policy_before"] = was
st, _b = fc.post("/settings", data={"lid_policy": "hold"})
ctx.check(st == 200, "could not set lid_policy=hold (%s)", st)
ctx.check(((fc.settings() or {}).get("lid_policy")) == "hold", "lid_policy did not take")
try:
with ctx.grbl() as g:
clean_slate(ctx, g)
start = g.status_report()["MPos"]
ctx.ready("On Ready the head starts an 8 s move along +X. Open the lid while it moves; "
"the job parks in the door state and waits.")
g.command("M5")
g.command("G91")
g.command("G1X40F300", timeout=0.5) # an 8 s move
ctx.sleep(0.5)
ctx.check(g.status_report()["state"].startswith("Run"), "the move did not start")
g.drain()
ctx.act("lid", "open", timeout=12)
st, text = wait_state_text(ctx, g, "Door", 8)
ev["door_state"] = st["state"] if st else g.status_report()["state"]
ctx.check(st is not None, "the lid did not park the job in Door (state %s)", ev["door_state"])
ev["messages"] = [ln for ln in text.splitlines() if ln.startswith("[MSG:")]
ctx.check("job canceled" not in text, "the job was canceled under lid_policy=hold: %s", ev["messages"])
ctx.check("returned to the job start" not in text,
"the head returned to the job start under lid_policy=hold")
held = g.status_report()["MPos"]
ev["parked_at"] = held
ctx.log("parked in %s at %s", ev["door_state"], held)
ctx.act("lid", "close")
ctx.sleep(1)
ev["state_after_close"] = g.status_report()["state"]
ctx.log("after the lid closed: %s (a cycle start is needed)", ev["state_after_close"])
g.realtime(0x7E) # ~ cycle start
peak, states, st = wait_idle(ctx, g, 30)
ev["states_after_resume"] = states
ctx.check("TIMEOUT" not in states, "the job did not finish after the resume: %s", states)
final = st["MPos"]
moved = final[0] - start[0]
ev["moved_mm"] = round(moved, 3)
ctx.log("finished: moved %.3f mm of 40 (states %s)", moved, states)
ctx.check(abs(moved - 40.0) <= 0.05,
"the resumed job did not finish its move (%.3f mm of 40)", moved)
g.command("$J=G91X-40F1200")
wait_idle(ctx, g, 30)
g.command("G90")
machine_idle(ctx)
finally:
st, _b = fc.post("/settings", data={"lid_policy": was})
ev["lid_policy_restored"] = (fc.settings() or {}).get("lid_policy")
ctx.log("lid_policy restored to %s", ev["lid_policy_restored"])
ctx.check(ev["lid_policy_restored"] == was, "lid_policy was not restored to %r", was)
ctx.log("PASS: lid_policy=hold parked the job in Door and the cycle start finished it (%.3f mm)",
ev["moved_mm"])
GRBLHAL_LOG = "/data/log/forgefirm/grblhal/grblhal.log"
SCHED_FIFO = 1
@@ -1445,3 +1153,196 @@ def step_timing_under_load(ctx):
"; ".join(clamped))
ctx.log("PASS: %d legs at 2000 mm/min against a nice-5 CPU hog, no clamped events",
ev["legs"])
# ------------------------------------------------- motor release
HOLD_CURRENTS = ("33", "5") # pic/x_step_current, pic/y_step_current at idle
def _step_currents():
return (hw.sysfs_read("pic/x_step_current"), hw.sysfs_read("pic/y_step_current"))
def _rail_lines():
"""How many times the kernel has logged the 40 V supply going on or off."""
rc, out = hw.run(["dmesg"])
if rc != 0:
return -1
return sum(1 for line in out.splitlines() if "40V on" in line or "40V off" in line)
def _kernel_position():
"""The kernel's position record: (x, y, z) in steps, then the bytes it
has played and the bytes it was handed. Nothing moves without the last
two moving, which makes them the witness for "nothing was shipped": the
head accelerometer cannot say it, since the rotors relax off their held
microstep when the drivers let go, when the currents drop to hold, and at
a reset, and the head feels each of those."""
with open(hw.sysfs_root() + "cnc/position", "rb") as f:
raw = f.read(32)
return struct.unpack_from("<3i", raw, 0) + struct.unpack_from("<2I", raw, 12)
def _refused(ctx, g, line):
"""Send a line that must be refused; acknowledge the parser error it
leaves (clear_error has the why) and return the error."""
r = g.command(line)
err = next((x for x in r if x.startswith("error")), None)
if err is not None:
g.command("")
return err
def _drop_reference(ctx, fc):
"""A false home is not left behind: a fresh controller starts unhomed."""
fc.post("/controller/stop")
_wait_controller(ctx, fc, ("stopped", "standby"), 30)
fc.post("/controller/start")
m = _wait_controller(ctx, fc, ("running",), 120, motion="verified")
ctx.check(m.get("controller") == "running", "the controller did not come back: %s", m)
@test("motion.release", title="Motor release: nothing moves a released gantry, only the operator energizes it",
subsystem="motion", kind="auto", hardware="takeover", mode="grbl", est_min=8,
covers=_MOTION_COVERS + [("forgectrl", "src/status.*"), ("forgectrl", "src/wizdark.*")],
requires=["motion.jog-roundtrip"],
steps=["Bed clear; the head needs 50 mm of free travel each way on X and Y. Nobody touches the gantry."],
description="$MD releases X and Y by taking their step currents to 0, with the 40 V rail "
"untouched (no supply line in the kernel log, cnc/state idle, no fault), drops the "
"X and Y reference and locks the machine in alarm. While released a jog, a G0 and "
"$X are refused, a soft reset does not unlock it, and forgectrl refuses a switch "
"to cloud mode; the kernel's position record "
"is the witness that nothing was shipped (no step and no byte played), and the head "
"accelerometer stays under the moving threshold in the window after the release. "
"$ME energizes with no fault. The drivers are then proven alive the way the machine "
"proves it to itself: the Setup motion check runs the liveness probe on the held "
"device and the witnessed 50 mm jogs.")
def motor_release(ctx):
from .setup_dark import run_check
ev = ctx.evidence
fc = hw.Forgectrl()
accel = hw.AccelSampler()
ctx.check(accel.available, "no head accelerometer found (i2c %s): the motion witness is missing",
hw.HEAD_ACCEL_I2C)
rail0 = _rail_lines()
ctx.check(rail0 >= 0, "the kernel log is unreadable: the rail witness is missing")
with ctx.grbl() as g, accel:
clean_slate(ctx, g)
r = g.command("$MD")
ctx.check(not any(x.startswith("error") for x in r), "$MD refused at idle: %s", r)
text = "\n".join(r) + drain_text(g, 1.0)
ev["released_currents"] = list(_step_currents())
ev["state_released"] = g.status_report()["state"]
ev["kernel_state_released"] = hw.sysfs_read("cnc/state")
ev["faults_released"] = hw.sysfs_int("cnc/faults")
ev["homed_axes_released"] = fc.status().get("homed_axes")
ctx.log("$MD: step currents %s, %s, cnc/state %s, faults %s, homed_axes %s",
ev["released_currents"], ev["state_released"], ev["kernel_state_released"],
ev["faults_released"], ev["homed_axes_released"])
ctx.check(ev["released_currents"] == ["0", "0"],
"the step currents read %s after $MD, not 0 and 0", ev["released_currents"])
ctx.check(ev["state_released"].startswith("Alarm"), "not locked in alarm: %s", ev["state_released"])
ctx.check(ev["kernel_state_released"] == "idle", "the kernel left idle: %s", ev["kernel_state_released"])
ctx.check(ev["faults_released"] == 0, "a driver fault on release: %s", ev["faults_released"])
ctx.check(not (ev["homed_axes_released"] or 0) & 3, "X or Y still referenced while released")
ctx.check("Motors released" in text, "the sender was not told the motors are released")
# The rotors have relaxed by now: from here on the head is still.
ctx.sleep(1.0)
k0 = _kernel_position()
t0 = time.time()
refusals = {}
for line in ("$J=G91X10F2400", "G0X10", "$X"):
refusals[line] = _refused(ctx, g, line)
ctx.check(refusals[line], "%s was accepted while the motors are released", line)
ctx.sleep(1.0)
p2px, p2py, n = accel.p2p(t0)
ev["released_accel_p2p"] = [p2px, p2py, n]
ctx.check(n > 0, "no accelerometer samples in the released window")
ctx.check(max(p2px, p2py) < hw.ACCEL_P2P_MOVING,
"the head moved while released (accel p2p x=%d y=%d)", p2px, p2py)
g.realtime(0x18)
ctx.sleep(2)
g.drain()
refusals["^X then $X"] = _refused(ctx, g, "$X")
ctx.check(refusals["^X then $X"], "a soft reset and $X unlocked a released machine")
ev["state_after_reset"] = g.status_report()["state"]
ctx.check(ev["state_after_reset"].startswith("Alarm"),
"a soft reset unlocked a released machine: %s", ev["state_after_reset"])
k1 = _kernel_position()
ev["refusals"] = refusals
ev["kernel_position"] = [list(k0), list(k1)]
ctx.log("refused while released: %s; accel p2p x=%d y=%d over %d samples; kernel record %s -> %s",
refusals, p2px, p2py, n, k0, k1)
ctx.check(k1 == k0, "the kernel played something while released: %s -> %s", k0, k1)
# forgectrl reads the release too: cloud homing moves the head, so the
# switch is refused (for the release where cloud mode is enabled, for
# cloud mode being off where it is not; the mode stays either way).
st, body = fc.post("/mode", data={"controller": "cloud"})
text = body if isinstance(body, str) else json.dumps(body)
cloud_on = str(fc.settings().get("cloud_enabled", "")).lower() in ("1", "true", "yes", "on")
ev["mode_cloud_released"] = {"status": st, "body": text, "cloud_enabled": cloud_on}
ctx.log("POST /mode controller=cloud while released -> %s %s", st, text)
ctx.check(st == 409, "a switch to cloud mode while released -> %s, expected 409", st)
if cloud_on:
ctx.check("released" in text, "the refusal does not name the release: %r", text)
ctx.check(fc.get("/mode")[1].get("mode") == "grbl", "the mode changed under a released gantry")
ctx.check(_step_currents() == ("0", "0"),
"something energized the motors: step currents %s", _step_currents())
r = g.command("$ME")
ctx.check(not any(x.startswith("error") for x in r), "$ME refused: %s", r)
ctx.sleep(0.5)
ev["energized_currents"] = list(_step_currents())
ev["faults_energized"] = hw.sysfs_int("cnc/faults")
ev["state_energized"] = g.status_report()["state"]
ctx.check(tuple(ev["energized_currents"]) == HOLD_CURRENTS,
"the step currents read %s after $ME, not the hold currents", ev["energized_currents"])
ctx.check(ev["faults_energized"] == 0, "a driver fault on energize: %s", ev["faults_energized"])
ctx.check(ev["state_energized"].startswith("Idle"), "not idle after $ME: %s", ev["state_energized"])
# The drivers are alive: the machine's own witness says so. The liveness
# probe is built for the accelerometer (a slow, rough move sampled for
# seconds); a quick smooth jog is not, and reads as nothing half the time.
ctx.notice("The head moves 50 mm each way on X and on Y. Keep the bed clear.")
last = run_check(ctx, "motion", lambda p: "Continue" if p.get("id") == "jogs" else None, 420)
ctx.clear_notice()
res = last.get("result") or {}
ev["probe"] = res.get("probe")
ev["moves"] = res.get("moves")
ctx.log("after energize: probe '%s', moves %s", res.get("probe"),
{k: v.get("witnessed") for k, v in (res.get("moves") or {}).items()})
ctx.check(res.get("probe") and last.get("result") is not None and not last.get("error"),
"the motion check did not complete after the energize: %s", last.get("error") or last)
for name in ("+X", "-X", "+Y", "-Y"):
m = (res.get("moves") or {}).get(name) or {}
ctx.check(m.get("witnessed") is True,
"the %s jog was not witnessed after the energize: the drivers did not come back (%s)", name, m)
ok = ctx.wait_for(lambda: (fc.get("/mode")[1] or {}).get("local") is False
and (fc.get("/mode")[1] or {}).get("controller") == "running", 90)
ctx.check(ok is not None, "the controller is not back in its normal posture")
ev["rail_lines"] = [rail0, _rail_lines()]
ctx.check(ev["rail_lines"][0] == ev["rail_lines"][1],
"the kernel logged the 40 V supply changing across a release: %s", ev["rail_lines"])
machine_idle(ctx)
ctx.log("PASS: released with the rail untouched, every motion refused and nothing shipped, "
"energized with no fault, and the liveness probe and the witnessed jogs say the drivers are alive")
def _port_state(fc):
st, body = fc.get("/motion/state")
if st != 200 or not isinstance(body, dict):
raise hw.HwError("forgectrl /motion/state -> %s %s" % (st, body))
return body
def _port_idle(ctx, fc, timeout=30.0):
"""The port's own word that its jog is over. The suite's Grbl client
sends no line while a port jog runs: a line from it is what cancels one."""
ok = ctx.wait_for(lambda: (lambda s: s["state"] == "Idle" and not s["port_jog"])(_port_state(fc)),
timeout, poll=0.1)
ctx.check(ok is not None, "the port jog did not end: %s", _port_state(fc))
machine_idle(ctx)
+535
View File
@@ -0,0 +1,535 @@
# 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
boundary, SSH, the cloud switch, the machine's name, and the factory
return.
The daemon reads the setup record (setup.json in the
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
a different record installs the file under a forgectrl restart (a
takeover) and puts the previous file back the same way. Thus the daemon
never saves a test record over the real one, and a restore is complete
when the daemon is up again.
The layer content of this work (the account replay init script, the
sshd policy, the hostname and console banner scripts) is part of the
platform identity that every fingerprint carries, not of a component.
No coverage map names it: a change there makes every test necessary
again.
"""
import contextlib
import posixpath
import http.client
import json
import os
import random
import socket
import ssl
import time
import urllib.parse
from ..catalog import test
from .. import hw
RECORD = "setup.json"
USERS = "users"
SSH_FLAG = "ssh-enabled"
TLS_BASE = "https://127.0.0.1" # FORGECTRL_TLS_URL overrides (host tests)
# The first run's form wizards, all at version 1 (wiz.c).
FORM_WIZARDS = ("advisories", "account", "preferences", "machine", "cloud")
# The steps the gate requires after the account, all at version 1, in
# the order the gate's reason names them (setup.c): the form steps, then
# the coolant flow calibration. The first-run tests seed the checks done,
# the calibration among them, so the form steps are what they walk.
REQUIRED_FORM_STEPS = ("preferences", "machine", "cloud")
REQUIRED_STEPS = REQUIRED_FORM_STEPS + ("cooling.flow",)
# The setting that counts the intake fans: the gate holds without it.
INTAKE_KEY = "cool_intake_fans"
SAFETY_PHRASE = "I UNDERSTAND"
LOGIN_FAILS = 5
LOGIN_LOCK_S = 30
# ----------------------------------------------------------------- files
def data_dir():
return os.environ.get("FORGECTRL_DATA_DIR") or "/data/forgefirm"
def run_dir():
return os.environ.get("GF_RUN_DIR") or "/run/forgefirm"
# The daemon's own paths on the board, POSIX on every host that names them.
def record_path():
return posixpath.join(data_dir(), RECORD)
def users_path():
return posixpath.join(data_dir(), USERS)
def ssh_flag_path():
return posixpath.join(run_dir(), SSH_FLAG)
def read_file(path):
"""The bytes of a file, or None when it does not exist."""
try:
with open(path, "rb") as f:
return f.read()
except OSError:
return None
def write_file(path, data):
"""Write data to path (temp file and rename, mode 0600), or remove
the file when data is None."""
if data is None:
try:
os.remove(path)
except OSError:
pass
return
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = path + ".forgetest"
# the bytes as given: O_BINARY keeps a Windows host (the unit tests)
# from translating newlines; it does not exist elsewhere
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_BINARY", 0), 0o600)
try:
os.write(fd, data)
finally:
os.close(fd)
os.replace(tmp, path)
# --------------------------------------------------------------- records
class Restore:
"""Settings a test or a wizard writes, put back as found at the end,
in the order of `keys` (a value that depends on another goes after
it)."""
def __init__(self, ctx, keys):
self.ctx = ctx
self.keys = keys
self.before = {}
def __enter__(self):
s = self.ctx.forgectrl.settings() or {}
self.before = {k: s.get(k) for k in self.keys}
self.ctx.evidence["settings_before"] = dict(self.before)
return self
def __exit__(self, *exc):
fc = self.ctx.forgectrl
for k, v in self.before.items():
if v is None or v == "":
st, _ = fc.post("/settings", params={k: ""})
else:
st, _ = fc.post("/settings", data={k: v})
self.ctx.log("restore %s=%r -> %s", k, v, st)
return False
def now_iso():
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
def record_without_wizards(record):
"""A copy of a record with no completed wizard and no flag: the
consent and the account stay, so the gate closes on the required
steps."""
out = json.loads(json.dumps(record))
out["wizards"] = {}
out["flags"] = {}
return out
def record_with_required_steps(record):
"""A copy of a record with the required steps done (kept from the
record, or written at version 1) and nothing else, and no flag: on a
machine whose intake fans are counted the gate is open and every
other step is recommended as not run yet."""
out = record_without_wizards(record)
have = record.get("wizards") or {}
for wid in REQUIRED_STEPS:
out["wizards"][wid] = have.get(wid) or {"version": 1, "completed": now_iso(), "result": {},
"applied": {}}
return out
def record_without_consent(record):
"""A copy of a record with no advisory accepted and no press: the
gate closes on the consent, whatever else the record holds."""
out = json.loads(json.dumps(record))
out["advisories"] = {}
out.pop("acceptance", None)
return out
def record_without_account(record):
"""A copy of a record with the consent and no account: the gate
closes on the account."""
out = json.loads(json.dumps(record))
out.pop("account", None)
return out
def complete_record(status, base=None, account_name="bench"):
"""A complete setup record for a machine that must count as
set up: every advisory at the hash GET /wiz reports, the
press recorded, an account (the one of `base` when it has one),
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
the account, the machine facts, and the sheet id from."""
base = base or {}
ts = now_iso()
rec = {"schema": 1, "created": base.get("created") or ts, "completed": ts,
"advisories": {}, "wizards": {}, "flags": {}}
if base.get("sheet_id"):
rec["sheet_id"] = base["sheet_id"]
elif status.get("sheet_id"):
rec["sheet_id"] = status["sheet_id"]
for d in status.get("documents") or []:
rec["advisories"][d["id"]] = {"hash": d["hash"], "accepted": ts,
"method": d.get("consent") or "check"}
rec["acceptance"] = {"pressed_at": ts}
acct = base.get("account") if isinstance(base.get("account"), dict) else None
rec["account"] = acct or {"name": account_name, "uid": 1000, "created": ts}
if isinstance(base.get("machine"), dict):
rec["machine"] = base["machine"]
wizards = status.get("wizards") or [{"id": w, "version": 1} for w in FORM_WIZARDS]
for w in wizards:
rec["wizards"][w["id"]] = {"version": int(w.get("version") or 1), "completed": ts,
"result": {}, "applied": {}}
return rec
@contextlib.contextmanager
def installed(ctx, files):
"""Install files ({path: bytes, or None to remove}) under a forgectrl
restart, and put the previous content back under another one when
the block ends, on every exit path. Yields the saved content. The
restart is the runner's takeover: the controller is stopped through
the supervisor, the marker makes a crash recoverable, and the exit
waits for the supervisor to settle."""
saved = {p: read_file(p) for p in files}
with ctx.takeover():
for p, data in files.items():
write_file(p, data)
ctx.log("installed under a restart: %s",
", ".join("%s=%s" % (os.path.basename(p), "removed" if d is None else "%d B" % len(d))
for p, d in files.items()))
try:
yield saved
finally:
with ctx.takeover():
for p, data in saved.items():
write_file(p, data)
ctx.log("restored under a restart: %s", ", ".join(os.path.basename(p) for p in saved))
# ---------------------------------------------------------------- client
def request(base, method, path, data=None, headers=None, timeout=10.0):
"""One HTTP or HTTPS request without redirect following: (status,
body bytes, {header: value}). HTTPS is served with a self-signed
certificate, so the peer is not verified. The Host header is the
address literal of base, which the origin check requires."""
u = urllib.parse.urlsplit(base)
port = u.port or (443 if u.scheme == "https" else 80)
if u.scheme == "https":
cx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
cx.check_hostname = False
cx.verify_mode = ssl.CERT_NONE
conn = http.client.HTTPSConnection(u.hostname, port, timeout=timeout, context=cx)
else:
conn = http.client.HTTPConnection(u.hostname, port, timeout=timeout)
hdrs = {"Host": u.netloc}
hdrs.update(headers or {})
body = None
if isinstance(data, dict):
body = urllib.parse.urlencode(data).encode()
hdrs.setdefault("Content-Type", "application/x-www-form-urlencoded")
elif isinstance(data, str):
body = data.encode()
elif data is not None:
body = data
if body is not None:
hdrs.setdefault("Content-Length", str(len(body)))
try:
conn.request(method, path, body=body, headers=hdrs)
r = conn.getresponse()
content = r.read()
return r.status, content, {k.lower(): v for k, v in r.getheaders()}
except (OSError, http.client.HTTPException) as e:
raise hw.HwError("%s %s%s: %s" % (method, base, path, e))
finally:
conn.close()
def decode(content):
"""JSON when the body parses as JSON, else text."""
text = content.decode("utf-8", "replace")
try:
return json.loads(text)
except ValueError:
return text
def tls_base():
return (os.environ.get("FORGECTRL_TLS_URL") or TLS_BASE).rstrip("/")
def tcp_open(host, port, timeout=2.0):
try:
s = socket.create_connection((host, port), timeout=timeout)
s.close()
return True
except OSError:
return False
def session_from_cookie(value):
"""The ffsid session id out of a Set-Cookie value, or None."""
for part in (value or "").split(";"):
part = part.strip()
if part.startswith("ffsid="):
sid = part[len("ffsid="):]
return sid or None
return None
def cookie_flags(value):
return set(p.strip().split("=", 1)[0].lower() for p in (value or "").split(";")[1:])
def wiz(fc):
st, body = fc.get("/wiz")
if st != 200 or not isinstance(body, dict):
raise hw.HwError("GET /wiz -> %s" % st)
return body
def mode(fc):
st, body = fc.get("/mode")
return body if (st == 200 and isinstance(body, dict)) else {}
def press_status(fc):
"""GET /wiz/advisories/press, the page's poll at the press step: the
daemon takes a pressed button and records the acceptance on this
read, so a press nobody polls for stays 'pressed' and unaccepted."""
st, body = fc.get("/wiz/advisories/press")
return body if (st == 200 and isinstance(body, dict)) else {}
def wiz_summary(w):
return {k: w.get(k) for k in ("first_run", "gate", "why", "recommended",
"advisories_complete", "acceptance_done", "account",
"fans_counted", "completed", "versions")}
def recommended(w):
"""The steps a GET /wiz body recommends: {id: reason}."""
return {x.get("id"): x.get("reason") or "" for x in w.get("recommended") or [] if isinstance(x, dict)}
def wait_settled(ctx):
"""The supervisor settled (the runner's own wait): the /mode body."""
return ctx.takeover().wait_settled()
# -------------------------------------------------------------- the login
@test("setup.account-login", title="The panel login over HTTPS", subsystem="setup",
kind="auto", est_min=2,
covers=[("forgectrl", "src/auth.*"), ("forgectrl", "src/session.*"), ("forgectrl", "src/users.*"),
("forgectrl", "src/tls.*"), ("forgectrl", "src/peer.*"), ("forgectrl", "src/main.c"),
("forgectrl", "src/ui/login.*")],
requires=["forgectrl.auth"], hardware="takeover",
description="The test makes its own account: the account record is moved aside under a "
"forgectrl restart, the account route creates a temporary one with a password "
"only the test knows, and the real record comes back under another restart at "
"the end, the system accounts replayed and the temporary home removed. The data "
"directory is set to 0700 first, as a strict umask leaves it: the render that makes "
"the account opens it for search and nothing else (0711), and tried as the account, "
"its home can be entered and written while the data directory cannot be listed and "
"the account record cannot be read. Over "
"HTTPS (self-signed, so unverified): GET /wiz reports no session and the "
"certificate fingerprint; POST /login with a wrong password is refused (401); "
"five failures lock the address (429, wait at most 30 s) and the right password "
"is refused inside the lock too; after the lock a wrong name is 401 and the "
"right pair sets the ffsid cookie (HttpOnly, Secure, SameSite=Strict); GET /wiz "
"with the cookie reports the session; POST /logout ends it.")
def account_login(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
name = "forgetest"
pw = "".join(random.choice("abcdefghjkmnpqrstuvwxyz23456789") for _ in range(16))
before = wiz(fc)
ev["account_before"] = (before.get("users") or {}).get("name")
homes = "/data/forgefirm/home"
dir_mode = os.stat(data_dir()).st_mode & 0o7777
ev["data_dir_mode_found"] = "%04o" % dir_mode
try:
with installed(ctx, {users_path(): None}):
w = wiz(fc)
ctx.check(not (w.get("users") or {}).get("exists"), "an account still exists: %s", w.get("users"))
# the data directory as a strict umask leaves it: the account's render has to open it
os.chmod(data_dir(), 0o700)
st, body = fc.post("/wiz/account", data={"name": name, "password": pw})
ctx.log("POST /wiz/account (temporary %r) -> %s %s", name, st, body if isinstance(body, dict) else "")
ctx.check(st == 200, "the temporary account was not created: %s %s", st, body)
w = wiz(fc)
ctx.check((w.get("users") or {}).get("name") == name, "the account reads %s", w.get("users"))
reach = home_reach(name)
ev["home_reach"] = reach
ev["data_dir_mode_after_render"] = "%04o" % (os.stat(data_dir()).st_mode & 0o7777)
ctx.log("as %s, from a data directory found 0700 (now %s): %s", name, ev["data_dir_mode_after_render"], reach)
ctx.check(reach.get("cd") == "ok" and reach.get("write") == "ok",
"the account cannot use its own home %s: %s", reach.get("home"), reach)
ctx.check(ev["data_dir_mode_after_render"] == "0711", "the render left the data directory at %s, expected "
"0711 (the search bit and nothing else)", ev["data_dir_mode_after_render"])
ctx.check(reach.get("list_data_dir") == "EACCES" and reach.get("read_record") == "EACCES",
"the account reads what is not its own: %s", reach)
login_checks(ctx, name, pw)
finally:
# as found; the replay below is the product's own say on the mode
os.chmod(data_dir(), dir_mode)
rc, out = hw.initd("forgefirm-users", "reload")
ev["users_reload_rc"] = rc
ctx.log("forgefirm-users reload -> rc %s %s", rc, out.strip()[:200])
if name != ev["account_before"]:
import shutil
shutil.rmtree(os.path.join(homes, name), ignore_errors=True)
after = wiz(fc)
ctx.check((after.get("users") or {}).get("name") == ev["account_before"],
"the account reads %r after the restore, was %r", (after.get("users") or {}).get("name"),
ev["account_before"])
# Run as root: becomes the account and tries its own home, and what is
# not its own. One JSON line.
HOME_REACH = r'''
import errno, json, os, pwd, sys
p = pwd.getpwnam(sys.argv[1])
os.setgroups([])
os.setgid(p.pw_gid)
os.setuid(p.pw_uid)
def tried(fn):
try:
fn()
return "ok"
except OSError as e:
return errno.errorcode.get(e.errno, str(e.errno))
def write():
path = os.path.join(p.pw_dir, ".forgetest-reach")
with open(path, "w") as f:
f.write("x")
os.remove(path)
print(json.dumps({"uid": os.getuid(), "home": p.pw_dir, "cd": tried(lambda: os.chdir(p.pw_dir)), "write": tried(write),
"list_data_dir": tried(lambda: os.listdir(sys.argv[2])),
"read_record": tried(lambda: open(os.path.join(sys.argv[2], "users")).read())}))
'''
def home_reach(name):
"""What the account can do with its own home and with the data
directory around it, tried as the account."""
import subprocess
import sys
p = subprocess.run([sys.executable, "-c", HOME_REACH, name, data_dir()], capture_output=True, text=True, timeout=30)
try:
return json.loads(p.stdout)
except ValueError:
return {"error": (p.stderr or p.stdout).strip()[-200:]}
def login_checks(ctx, name, pw):
"""The login rules against an account whose password the test knows."""
ev = ctx.evidence
base = tls_base()
st, body, hdrs = request(base, "GET", "/wiz")
w = decode(body)
ctx.log("GET %s/wiz -> %s", base, st)
ctx.check(st == 200 and isinstance(w, dict), "GET /wiz over HTTPS -> %s", st)
ctx.check(w.get("session") is False, "a session is reported without a cookie")
ev["tls_fingerprint"] = w.get("tls_fingerprint")
ctx.check(w.get("tls_fingerprint"), "GET /wiz carries no certificate fingerprint")
def attempt(n, p):
st, body, hdrs = request(base, "POST", "/login", data={"name": n, "password": p})
return st, decode(body), hdrs
# a lock left by an earlier run: wait it out first
st, body, hdrs = attempt(name, "not-" + pw)
if st == 429:
wait = int((body or {}).get("wait") or LOGIN_LOCK_S) if isinstance(body, dict) else LOGIN_LOCK_S
ctx.log("the address is locked from before (%s s): waiting", wait)
ctx.sleep(min(wait, LOGIN_LOCK_S) + 1)
st, body, hdrs = attempt(name, "not-" + pw)
codes = [st]
ctx.check(st == 401, "a wrong password -> %s, expected 401", st)
for i in range(LOGIN_FAILS + 1):
st, body, hdrs = attempt(name, "not-%s-%d" % (pw, i))
codes.append(st)
if st == 429:
break
ev["wrong_codes"] = codes
ctx.log("wrong password attempts -> %s", codes)
ctx.check(codes[-1] == 429 and all(c == 401 for c in codes[:-1]),
"the throttle did not lock after wrong attempts: %s", codes)
ctx.check(len(codes) - 1 <= LOGIN_FAILS, "the lock came after %d failures, the rule is %d",
len(codes) - 1, LOGIN_FAILS)
wait = body.get("wait") if isinstance(body, dict) else None
ev["wait"] = wait
ctx.check(isinstance(wait, int) and 0 < wait <= LOGIN_LOCK_S, "the lock reports wait=%r", wait)
st, body, hdrs = attempt(name, pw)
ev["right_inside_lock"] = st
ctx.check(st == 429, "the right password inside the lock -> %s, expected 429", st)
ctx.log("locked for %s s; the right password is refused inside the lock", wait)
ctx.sleep(wait + 1)
st, body, hdrs = attempt("not-" + name, pw)
ev["wrong_name"] = st
ctx.check(st == 401, "a wrong name with the right password -> %s, expected 401", st)
st, body, hdrs = attempt(name, pw)
cookie = hdrs.get("set-cookie") or ""
sid = session_from_cookie(cookie)
ev["login"] = st
ev["cookie_flags"] = sorted(cookie_flags(cookie))
ctx.log("POST /login (right) -> %s, cookie flags %s", st, ev["cookie_flags"])
ctx.check(st == 200, "the right name and password -> %s, expected 200", st)
ctx.check(sid and len(sid) == 64 and all(c in "0123456789abcdef" for c in sid),
"no ffsid session id in Set-Cookie: %r", cookie)
ctx.check({"httponly", "secure", "samesite"} <= cookie_flags(cookie),
"the cookie lacks HttpOnly, Secure, or SameSite: %r", cookie)
ctx.check("samesite=strict" in cookie.lower(), "the cookie is not SameSite=Strict: %r", cookie)
st, body, hdrs = request(base, "GET", "/wiz", headers={"Cookie": "ffsid=" + sid})
w = decode(body)
ctx.check(st == 200 and isinstance(w, dict) and w.get("session") is True,
"GET /wiz with the cookie does not report the session (%s %s)", st,
w.get("session") if isinstance(w, dict) else w)
st, body, hdrs = request(base, "POST", "/logout", headers={"Cookie": "ffsid=" + sid})
ev["logout"] = st
ctx.check(st == 200, "POST /logout -> %s", st)
ctx.check("max-age=0" in (hdrs.get("set-cookie") or "").lower(), "the logout did not clear the cookie")
st, body, hdrs = request(base, "GET", "/wiz", headers={"Cookie": "ffsid=" + sid})
w = decode(body)
ctx.check(isinstance(w, dict) and w.get("session") is False, "the session survives the logout")
ctx.log("session set by the login, seen by GET /wiz, ended by the logout")
+317
View File
@@ -0,0 +1,317 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""setup.focus - the setup's focus burn (a live wizard), driven the way
the page drives it: POST /wiz/<id>/start, GET /wiz/dark polled, the
prompts answered from here, the burn armed by a press, the result
judged, and the setting the burn wrote put back as found. The burn
stands alone on its own scrap of wood in the back-left corner, drawn
from the head's home against the gantry stops, and re-runs on the real
record, so a completed burn completes again at the same version.
The operator's part is the scrap and one press on the machine at the
start (the presence check of the ready gate; with the bench actuator up
it then makes the arm press). The judgment a person makes by eye on the
scrap (the narrowest line) is answered here with a fixed pick: the test
proves the wizard's mechanics and the emission witnesses, not the
operator's eye, and the setting it writes is restored. The page is where
the real numbers go in.
"""
import re
import time
from .. import hw
from ..catalog import test
from .setup_dark import run_check, Restore, FALLBACK_WINDOW, LENS_WINDOW_KEYS, dark
BURN_COVERS = [("forgectrl", "src/wizlive.*"), ("forgectrl", "src/wizrun.h"),
("forgectrl", "src/lens.*"), ("forgectrl", "src/sheet.*"),
("forgectrl", "src/lenshome.*"),
("forgectrl", "src/font_hershey.*"), ("forgectrl", "src/jobstream.*"),
("forgectrl", "src/jobrun.*"), ("forgectrl", "src/lease.*"),
("forgectrl", "src/curverec.*"), ("forgectrl", "src/wizdark.*"),
("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"),
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*"),
("forgectrl", "src/super.c"),
("forgectrl", "src/status.c"), ("forgectrl", "src/cool.c"),
("grblhal-glowforge", "src/glowforge_laser.c"),
("grblhal-glowforge", "src/glowforge_status.c"),
("grblhal-glowforge", "src/glowforge_homing.*"),
("grblhal-glowforge", "src/glowforge_io.*")]
# Every burn asks first that the gantry stops are installed and the head
# is at its home against them; the bench's head stands there, and every
# burn's tail brings it back.
HOME = "The head is home"
EDGE_KEY = "lens_hall_edge_z_mm"
FOCUS_KEYS = ["thickness_mm", "z_passes", "ladder_z", "window", "emission", "pick", "picked_lines",
"pick_half_steps", "edge_z_mm", "steps_per_mm", "max_height_mm", "focus_range_mm", "summary"]
def preview_ok(ctx, wid):
"""The page's preview and the program for the burn are served."""
fc = ctx.forgectrl
st, body = fc.get("/wiz/sheet.svg?card=%s" % wid, raw=True)
ctx.check(st == 200 and body.lstrip().startswith(b"<svg"), "no preview for %s (%s)", wid, st)
ctx.evidence["preview_bytes"] = len(body)
st, body = fc.get("/wiz/sheet.gcode?card=%s" % wid, raw=True)
# The text is M4 at the mark dose; the ladders bring their own M3 or
# M4 lines. A program is one with a laser-on command.
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)
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 Lens travel check's, or the
# 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 burn 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)
# The tail the wizard streams after the body: the lens back on the
# reference the program's head declared, the head back to X0 Y0, the
# G92 origin cleared (the controller keeps a G92 offset and restores it
# at its next start, where it would shift a sender's own work offsets),
# and the program end.
lines = [ln.strip() for ln in (body or b"").decode("utf-8", "replace").splitlines()]
tail = [ln for ln in lines if ln and not ln.startswith(";")][-4:]
edge = lens.get("edge_z")
want = ["G0 Z%.2f" % edge, "G0 X0 Y0", "G92.1", "M2"] if isinstance(edge, (int, float)) else None
ctx.evidence.setdefault("program_tail", {})[wid] = tail
ctx.check(tail == want, "the %s program ends %s, not the tail %s", wid, tail, want)
def emission_ok(ctx, last):
"""The two witnesses of the burn, from the wizard's result. The head's
beam sensor is recorded, never required: the result reports its rise
and whether it read as the beam seen."""
e = (last.get("result") or {}).get("emission") or {}
ctx.evidence["emission"] = e
ctx.check(e.get("hv_max", 0) > 30, "the tube current never rose (%s)", e.get("hv_max"))
ctx.check(e.get("laser_on_samples", 0) > 0, "the kernel never sampled LASER_ON")
ctx.check(isinstance(e.get("thermopile_delta"), int) and isinstance(e.get("beam_sensor_seen"), bool),
"the result does not record the beam sensor: %s", e)
ctx.log("the beam sensor rose %s (seen: %s)", e.get("thermopile_delta"), e.get("beam_sensor_seen"))
def thickness_answer(ctx):
"""The thickness the burn is answered with, in the machine's units
(the number prompts read ui_units), and the millimeters the record
must carry for it."""
imperial = (ctx.forgectrl.settings() or {}).get("ui_units") == "imperial"
return ("0.125", 3.175) if imperial else ("3.2", 3.2)
def burn(ctx, wid, arm, pick, want):
"""One burn: the preview, then the burn after the press with every
prompt answered (the gantry stops installed, the head at home, the
scrap in the corner, its thickness in the machine's units, the arm,
and the pick in `pick`), the witnesses, and the keys of the result in
`want`. run_check makes the arm press when the burn asks for it. A
prompt the test does not answer, or one asked again because its
answer was refused, ends the burn. Returns the result."""
answer, want_mm = thickness_answer(ctx)
ctx.evidence["thickness_answer"] = answer
answers = {"gantry-stops": "Yes", "home": HOME, "scrap": "Continue", "thickness": answer,
arm: "Continue"}
answers.update(pick)
preview_ok(ctx, wid)
asked = set()
def on_prompt(p):
pid = p.get("id")
if pid == "press":
return None
if pid not in answers:
# the motors found released ask for the head pushed home by
# hand, which the bench's head never needs
ctx.fail("the %s burn asked %s, which the test does not answer: %s", wid, pid, p.get("text"))
if pid in asked:
ctx.fail("the %s burn asked %s again: it refused %r", wid, pid, answers[pid])
asked.add(pid)
return answers[pid]
try:
last = run_check(ctx, wid, on_prompt, 900)
finally:
ctx.clear_notice()
emission_ok(ctx, last)
r = last.get("result") or {}
missing = [k for k in want if k not in r]
ctx.check(not missing, "the result lacks %s: %s", missing, sorted(r))
ctx.check(abs((r.get("thickness_mm") or 0) - want_mm) < 0.01,
"thickness %s mm, expected %s (answered %s)", r.get("thickness_mm"), want_mm, answer)
ctx.check(len(r.get("z_passes") or []) >= 1, "the lens was not referenced: %s", r.get("z_passes"))
return r
def window_held(ctx):
"""The lens window the machine holds: the two settings, and the lens
block of /status, which reads them (the fallback where they are
unset)."""
fc = ctx.forgectrl
s = fc.settings() or {}
lens = (fc.status() or {}).get("lens") or {}
return {"settings": {k: s.get(k) for k in LENS_WINDOW_KEYS},
"window": (lens.get("below"), lens.get("above")), "stops_found": lens.get("stops_found")}
def focus_window_ok(ctx, r, before):
"""The ladder burned in the window the machine held before the burn
(`before`, from window_held): the Lens travel check's, or the fallback
while the settings are unset. The burn left that window as it found
it and wrote the edge alone, the one its result reports; every ladder
height lies in the reach, and the program served now agrees with
/status."""
fc = ctx.forgectrl
ev = ctx.evidence
w = r.get("window") or {}
window = (w.get("below"), w.get("above"))
after = window_held(ctx)
ctx.check(after["settings"] == before["settings"], "the focus burn wrote the lens window: %s, was %s",
after["settings"], before["settings"])
ctx.check(window == before["window"], "the ladder ran in %s/%s, the machine holds %s",
window[0], window[1], before["window"])
held = before["settings"]
if before["stops_found"]:
ctx.check(tuple(str(held[k]) for k in LENS_WINDOW_KEYS) == tuple(str(v) for v in window),
"the ladder ran in %s/%s, the settings hold %s", window[0], window[1], held)
else:
ctx.check(window == FALLBACK_WINDOW, "the settings hold no window (%s), but the ladder ran in %s/%s",
held, window[0], window[1])
edge = (fc.settings() or {}).get(EDGE_KEY)
try:
edge_ok = abs(float(edge) - float(r.get("edge_z_mm"))) < 0.006
except (TypeError, ValueError):
edge_ok = False
ctx.check(edge_ok, "%s reads %r, the burn reports %s", EDGE_KEY, edge, r.get("edge_z_mm"))
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)
first = dict(ev.get("program_z") or {}).get("laser.focus")
preview_ok(ctx, "laser.focus")
ev["focus_window"] = {"window": w, "ladder_z": zs, "held": before, "edge": edge,
"program_z_before": first,
"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)}
@test("setup.focus", title="The focus burn: the ladder in the lens window, and the edge it writes",
subsystem="setup", kind="live", hardware="takeover", mode="grbl", est_min=10,
covers=BURN_COVERS,
requires=["forgectrl.auth", "setup.check-motion", "laser.emission-witness", "setup.check-lens"],
actions=["button"], hands=["scrap"],
steps=["The gantry stops installed and the head at its home against them. A fresh flat scrap "
"of wood about 3 mm (1/8 in) thick and at least 78 x 68 mm (3.1 x 2.7 in), pushed into "
"the back-left corner of the cut area: as far left as it goes, its top edge at the top "
"of the cut area; lid closed. Eye protection on, exhaust on, extinguisher in reach.",
"Start the test and press the machine's button once. That press says you are at the "
"machine, and the bench actuator makes the arm press after it, when the burn itself "
"asks for it. With no actuator the start is the Ready answer instead, and you press "
"the button again at the arm, as the page asks.",
"Nothing else: the test answers every prompt itself and puts back the setting the "
"burn writes."],
description="The focus burn as a fresh head runs it: the edge setting is cleared first, so "
"the burn starts from the bench reference's edge, while the lens window stays "
"the one the Lens travel check wrote (or the fallback). POST "
"/wiz/laser.focus/start is answered that the gantry stops are installed, "
"references the lens, takes the controller in loopback posture, is answered that "
"the head is home and that the scrap is in the corner, and takes the thickness "
"in the machine's units (3.2 mm or 0.125 in); its program declares X0 Y0 at the "
"head, and its tail puts the lens back on the reference, returns the head there, "
"clears the G92 origin (G92.1), and ends (M2). With its preview and program "
"served by GET /wiz/sheet.svg and /wiz/sheet.gcode, every Z the program commands "
"inside the reach /status reports, and the program ending in that tail at the "
"edge /status reports, it burns twelve lines after the press, from the top of "
"the window to the bottom, seen by the two witnesses (the tube current and the "
"kernel's LASER_ON samples), with the beam sensor's rise recorded and not "
"required. The pick is line 11. The "
"result carries its keys; the ladder ran in the window the machine held, which "
"the burn leaves as found (it writes the edge alone, the one its result "
"reports); every ladder height lies in the reach; the program served afterward "
"agrees with /status; the record carries laser.focus at version 1. The edge and "
"the window are restored at the end. grblHAL reads them only at its start, and "
"the burn started it before the restore, so the controller is stopped and "
"started again on the settings as found (on every exit path), and it must be "
"running with motion verified under a new process.")
def focus(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
restart = {}
def restart_on_found():
"""A fresh controller start on the settings as found, once the
burn has ended. Plain waits, not the run's, so an abort gets it
too; a burn still ending starts its own controller after the
restore, which reads them already."""
try:
t_end = time.time() + 120
while time.time() < t_end and dark(fc).get("running"):
time.sleep(1)
if dark(fc).get("running"):
restart["skipped"] = "the burn is still ending"
ctx.log("the burn is still ending: its own controller start reads the settings as found")
return
st, m = fc.get("/mode")
m = m if isinstance(m, dict) else {}
restart.update(mode=m.get("mode"), pid=m.get("pid"))
if m.get("mode") != "grbl":
return # the cloud client reads neither the edge nor the window
restart["stop"], body = fc.post("/controller/stop")
ctx.log("restarting the controller on the edge and the window as found: POST "
"/controller/stop -> %s %s", restart["stop"], body)
for _ in range(10):
restart["start"], body = fc.post("/controller/start")
if restart["start"] != 409:
break
time.sleep(1) # the burn's lease goes just after its end
ctx.log("POST /controller/start -> %s %s", restart["start"], body)
except hw.HwError as e:
restart["error"] = str(e)
ctx.log("the controller could not be restarted on the settings as found: %s", e)
ctx.ready("The focus burn: twelve lines on the scrap after a press. The bench presses when the "
"button lights.")
try:
with Restore(ctx, [EDGE_KEY] + LENS_WINDOW_KEYS):
# A fresh head's focus: the edge unknown, so the burn runs from the
# bench reference's number and writes the head's own. The window
# stays: it is the Lens travel check's.
st, _ = fc.post("/settings", params={EDGE_KEY: ""})
ctx.check(st == 200, "clearing %s -> %s", EDGE_KEY, st)
before = window_held(ctx)
ev["lens_fresh"] = (fc.status() or {}).get("lens") or {}
r = burn(ctx, "laser.focus", "focus-arm", {"focus-pick": "11"}, FOCUS_KEYS)
ctx.check(r.get("pick") == 11 and r.get("picked_lines") == [11, 11],
"the pick reads %s (lines %s), answered 11", r.get("pick"), r.get("picked_lines"))
ctx.check("stops" not in r, "the focus burn searched for the stops: %s", r.get("stops"))
focus_window_ok(ctx, r, before)
ctx.log("focus: line %s, the edge at %s mm, the window %s/%s", r.get("pick"), r.get("edge_z_mm"),
(r.get("window") or {}).get("below"), (r.get("window") or {}).get("above"))
finally:
restart_on_found()
ev["restart"] = dict(restart)
if restart.get("mode") == "grbl":
ctx.check(restart.get("stop") == 200 and restart.get("start") == 200,
"the controller was not restarted on the settings as found: %s", restart)
def back():
st, m = fc.get("/mode")
m = m if isinstance(m, dict) else {}
return (m.get("controller") == "running" and m.get("motion") == "verified"
and (restart.get("start") != 200 or m.get("pid") != restart.get("pid")))
ok = ctx.wait_for(back, 150)
ctx.check(ok is not None, "the controller did not come back on the settings as found")
ctx.log("PASS: the focus burn ran in the window the machine holds and wrote the edge alone; "
"settings restored and the controller restarted on them")
@@ -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,
the prompts answered from here (the bench fixture works the lid and the
button; a confirmation is answered yes once the snapshot exists), the
@@ -12,12 +17,39 @@ import time
from ..catalog import test
from .. import hw
from ..baseline import read_position
from .commission import wiz, Restore # noqa: F401 - Restore is re-exported for the sheet
from .setup import wiz, Restore, INTAKE_KEY
POLL_S = 1.0
# What the machine's own press prompt says, used when it sends no text.
PRESS_TEXT = "The button is lit white. Press it now: the laser fires after your press."
# How long the purge fan's current is given to come up after the airflow
# check switches it back on. The bench reference draws its off current
# (74) for the first seconds and settles near 630.
PURGE_SPINUP_S = 30
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.*")]
# The lens window: the free half-steps below and above the hall edge the
# Lens travel check writes, and the fallback the lens frame holds while
# they are unset (lens.h).
LENS_WINDOW_KEYS = ["lens_stop_below_steps", "lens_stop_above_steps"]
FALLBACK_WINDOW = (10, 12)
def intakes_counted():
"""Precheck: the intake fan count is set. The setup gate holds every
controller without it, and a test that puts the count back as found
would put an unset count back and close the gate on the tests after
it."""
try:
s = hw.Forgectrl().settings() or {}
except hw.HwError as e:
return "forgectrl unreachable: %s" % e
if not s.get(INTAKE_KEY):
return ("the intake fans are not counted, so the setup gate holds every controller; this "
"test puts the count back as it finds it, and an uncounted machine handed back "
"would hold the controllers of every test after it")
return None
def dark(fc):
@@ -76,6 +108,12 @@ def run_check(ctx, wid, on_prompt, timeout_s):
if value is not None:
st, body = answer(fc, wid, p, value)
ctx.check(st == 200, "the answer to %s -> %s %s", p.get("id"), st, body)
elif p.get("id") == "press":
# The arm press, asked for by the machine itself: the
# check opens this prompt when the button is lit and
# the tube is still dark. Nothing answers it - the
# press does, and the discharge closes it.
ctx.press_now(p.get("text") or PRESS_TEXT)
if not d.get("running"):
last = d
break
@@ -101,10 +139,10 @@ def run_check(ctx, wid, on_prompt, timeout_s):
return last
@test("commission.check-switches", title="The switches check follows the lid and the button",
subsystem="commission", kind="operator", est_min=3,
@test("setup.check-switches", title="The switches check follows the lid and the button",
subsystem="setup", kind="operator", est_min=3,
covers=DARK_COVERS + [("forgectrl", "src/status.c")],
requires=["forgectrl.auth"], actions=["lid", "button"],
requires=["forgectrl.auth"], actions=["lid", "button", "interlock"],
description="POST /wiz/switches/start; the check asks for the lid to open and close and for "
"a press, each as a 'wait' prompt that the switch edge itself answers; the bench "
"fixture works the lid and the button. On a Basic or Plus the interlock loop reads "
@@ -136,47 +174,48 @@ def check_switches(ctx):
ctx.log("switches: %s", json.dumps(r))
@test("commission.check-sensors", title="The sensors check reads a plausible machine at rest",
subsystem="commission", kind="auto", est_min=2,
covers=DARK_COVERS + [("forgectrl", "src/status.c"), ("forgectrl", "src/accel.c"),
("forgectrl", "src/cool.c")],
requires=["forgectrl.auth"],
description="POST /wiz/sensors/start; ten seconds of readings, then the room-temperature "
"prompt, answered Skip so no offset is written. The result carries both coolant "
"temperatures, the chassis and SoC, the lid IR maxima, the accelerometer event "
"count, the supply power-good, the HV current, and the idle fan speeds; the "
"record carries sensors at version 1 and cool_temp_offset_c reads as before.")
def check_sensors(ctx):
with Restore(ctx, ["cool_temp_offset_c"]):
last = run_check(ctx, "sensors", lambda p: "Skip" if p.get("id") == "room-temp" else None, 120)
r = last.get("result") or {}
for k in ("coolant_down_c", "coolant_up_c", "lid_ir_max", "laser_pgood", "hv_current_max",
"exhaust_rpm_idle", "intake_rpm_idle"):
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.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"))
@test("commission.check-airflow", title="The airflow check measures the fans and sets the floors",
subsystem="commission", kind="auto", hardware="takeover", est_min=3,
@test("setup.check-airflow", title="The airflow check measures the fans and sets the floors",
subsystem="setup", kind="auto", hardware="takeover", est_min=3,
covers=DARK_COVERS + [("forgectrl", "src/cool.c"), ("forgectrl", "src/airflow.*"),
("forgectrl", "src/gates.c"), ("forgectrl", "src/super.c")],
requires=["forgectrl.auth", "cooling.fan-gate-trips"],
requires=["forgectrl.auth"], precheck=intakes_counted,
description="POST /wiz/airflow/start: the controller stops, the fans run at the cut profile "
"for 35 s, the purge is measured on and off, and five settings are written: the "
"four floors at 55 percent of steady and the grace from the slowest spin-up. Each "
"fan must read at least 1000 rpm. The five settings are put back as found at the "
"end and the controller must be running again.")
"for 35 s, the purge is measured on and off, and the five floors are written: the "
"four at 55 percent of steady and the grace from the slowest spin-up. The intake "
"fan count is the machine's: intake 2 turning proves two fans, both intakes are "
"judged, and the count is written as 2; intake 2 silent on a machine counted with "
"one is reported not fitted and not turning, intake 1 alone sets the intake "
"floor, and the count is not written; intake 2 silent on a machine counted with "
"two fails the check. Both intakes turn on the bench reference, so the bench "
"path writes 2. Each fan judged must read at least 1000 rpm. The settings are put "
"back as found at the end, the purge fan is on again and drawing over the floor "
"the check just wrote, and the controller must be running again.")
def check_airflow(ctx):
keys = ["cool_tach_exhaust_min_rpm", "cool_tach_intake_min_rpm", "cool_tach_air_assist_min_rpm",
"cool_purge_min_current", "cool_fan_grace_s"]
with Restore(ctx, keys):
with Restore(ctx, keys + ["cool_intake_fans"]) as held:
counted = held.before.get("cool_intake_fans")
last = run_check(ctx, "airflow", lambda p: None, 240)
r = last.get("result") or {}
fans = r.get("fans") or {}
intakes = r.get("intake_fans")
silent2 = (fans.get("intake 2") or {}).get("turning") is False
ctx.evidence["intakes"] = {"counted": counted, "found": intakes, "intake_2_silent": silent2}
ctx.check(intakes in (1, 2), "the result carries no intake fan count: %r", intakes)
if silent2:
# one intake fan, as the machine is counted (on a machine
# counted with two the check itself fails, and run_check says so)
ctx.check(counted == "1" and intakes == 1,
"intake 2 gave no reading on a machine counted with %r intake fans, and the check "
"found %r", counted, intakes)
else:
ctx.check(intakes == 2, "intake 2 turned, and the check found %r intake fans", intakes)
ctx.log("intake fans: %s (the machine counted %s)", intakes, counted)
for name in ("exhaust", "intake 1", "intake 2", "air assist"):
f = fans.get(name) or {}
if name == "intake 2" and silent2:
ctx.check(f == {"fitted": False, "turning": False}, "intake 2 with one intake fan: %s", f)
continue
ctx.check(f.get("ok") is True, "%s: %s", name, f)
ctx.log("%s: %s rpm steady, 90 percent at %s s", name, f.get("steady_rpm"), f.get("spinup_s"))
floors = r.get("floors") or {}
@@ -185,50 +224,47 @@ def check_airflow(ctx):
s = ctx.forgectrl.settings() or {}
for k in keys:
ctx.check(s.get(k) == floors.get(k), "%s reads %r, the check wrote %r", k, s.get(k), floors.get(k))
# The count: 2 written when intake 2 turned; left as counted when it
# was silent.
want = counted if silent2 else "2"
ctx.check(s.get("cool_intake_fans") == want,
"cool_intake_fans reads %r, expected %r (intake 2 %s, the machine counted %r)",
s.get("cool_intake_fans"), want, "silent" if silent2 else "turning", counted)
ctx.log("floors: %s", json.dumps(floors))
# The check switches purge air off to read its off current. Leaving
# it off costs the next job an airflow hold mid-cut, judged against
# the very floor written above, and nothing puts it back until the
# daemon restarts: the engine's idle phase never re-applies its own
# duties. Prove the machine is whole, not just measured.
purge_on = ctx.sysfs("head/purge_air")
ctx.check(purge_on == "1", "purge air reads %r after the check, expected 1 (on)", purge_on)
def purge_gate():
return ((ctx.forgectrl.get("/cool/status")[1] or {}).get("fan_gates") or {}).get("purge") or {}
# The check read its off current with the fan off, and the stand-down
# commands it back on. The current follows the command, it does not
# arrive with it: read in the same second and what comes back is the
# off current the check just measured. Wait for the draw the way the
# controller below is waited for.
took = ctx.wait_for(lambda: (purge_gate().get("reading") or 0) >= (purge_gate().get("floor") or 0),
PURGE_SPINUP_S)
gate = purge_gate()
ctx.evidence["purge_after"] = {"purge_air": purge_on, "gate": gate, "took_s": took}
ctx.log("purge after the check: commanded %s, drawing %s against the %s floor (%s)",
purge_on, gate.get("reading"), gate.get("floor"),
"after %.1f s" % took if took is not None else "never reached the floor")
ctx.check(took is not None,
"purge draws %s after %d s, under the %s floor the check just wrote: the next job "
"would be held", gate.get("reading"), PURGE_SPINUP_S, gate.get("floor"))
ok = ctx.wait_for(lambda: (ctx.forgectrl.get("/mode")[1] or {}).get("controller") == "running", 60)
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",
subsystem="commission", kind="operator", est_min=2,
covers=DARK_COVERS + [("forgectrl", "src/cam.c")],
requires=["forgectrl.auth", "camera.snapshot"], actions=["lid"],
description="POST /wiz/cameras/start with the lid closed: a lid snapshot, the question, a "
"head snapshot, the question. Each question is answered yes once GET /wiz/shot "
"serves the snapshot as image/jpeg. The result names the sensor and says both "
"views were accepted.")
def check_cameras(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
ctx.act("lid", "close")
shots = {}
def on_prompt(p):
pid = p.get("id", "")
if pid in ("lid-view", "head-view"):
cam = "lid" if pid == "lid-view" else "head"
st, body = fc.get("/wiz/shot", params={"cam": cam}, raw=True)
shots[cam] = {"status": st, "bytes": len(body) if body else 0,
"jpeg": bool(body) and body[:2] == b"\xff\xd8"}
ctx.check(st == 200 and shots[cam]["jpeg"], "no %s snapshot to judge: %s", cam, shots[cam])
return "Yes"
if pid == "lid-close":
ctx.act("lid", "close", until=lambda: (dark(fc).get("prompt") or {}).get("seq") != p["seq"])
return None
last = run_check(ctx, "cameras", on_prompt, 180)
ev["shots"] = shots
r = last.get("result") or {}
ctx.check(r.get("lid_ok") is True and r.get("head_ok") is True, "a view was not accepted: %s", r)
ctx.check(bool(r.get("sensor")) and r.get("sensor") != "unknown", "no sensor named: %s", r)
ctx.log("cameras: %s, lid %d bytes, head %d bytes", r.get("sensor"),
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",
subsystem="commission", kind="auto", hardware="takeover", est_min=5,
@test("setup.check-motion", title="The motion check proves the rail, the lens reference, and the jogs",
subsystem="setup", kind="auto", hardware="takeover", est_min=5,
covers=DARK_COVERS + [("forgectrl", "src/super.c"), ("forgectrl", "src/liveness.c"),
("forgectrl", "src/lenshome.c"),
("forgectrl", "src/lenshome.*"),
("forgectrl", "src/accel.c"), ("forgectrl", "src/cool.c")],
requires=["forgectrl.auth", "motion.pacing"],
description="POST /wiz/motion/start: the controller stops, the liveness probe runs and the "
@@ -266,26 +302,107 @@ def check_motion(ctx):
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",
subsystem="commission", kind="auto", hardware="takeover", est_min=5,
covers=DARK_COVERS + [("forgectrl", "src/diag.c")],
requires=["forgectrl.auth", "cooling.aa-offset-calibrate", "cooling.flow-verify"],
description="POST /wiz/cooling.flow-verify/start drives the flow-verify diagnostic through the "
"check runner: the diagnostic's phases show as the check's, its result becomes the "
"check's result with pass true, and the record carries cooling.flow-verify at "
"version 1. A thin margin recommends the calibration in the record's flags. The "
"air-assist offset calibration is named first among the prerequisites so a queue "
"keeps it ahead of every heater tool this check pulls forward.")
def check_flow_verify(ctx):
last = run_check(ctx, "cooling.flow-verify", lambda p: None, 600)
@test("setup.check-lens", title="The lens travel check finds the stops and writes the window",
subsystem="setup", kind="auto", hardware="takeover", est_min=4,
covers=DARK_COVERS + [("forgectrl", "src/lensfind.*"), ("forgectrl", "src/lens.*"),
("forgectrl", "src/lenshome.*"), ("forgectrl", "src/accel.c"),
("forgectrl", "src/cool.c"), ("forgectrl", "src/super.c")],
requires=["forgectrl.auth", "setup.check-motion"],
description="POST /wiz/lens.travel/start: the controller stops, the lens takes its hall-edge "
"reference, the fans stop and the head accelerometer listens while the lens "
"steps to each stop and back (nothing fires), the two window settings are "
"written, and the controller comes back. No question is asked. The result "
"carries the reference passes, the stops (found, the free half-steps each way, "
"the contact counts, or why not), and the window: the stops found, or the "
"fallback (10 half-steps below, 12 above) when they were not. The settings and "
"the lens block of /status hold that window, the record carries lens.travel at "
"version 1, and the two settings are put back as found. grblHAL reads the "
"window only at its start, and the check started it before the put-back, so in "
"GRBL mode the controller is stopped and started again on the window as found "
"(on every exit path), and it must be running with motion verified under a new "
"process.")
def check_lens(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
restart = {}
def on_prompt(p):
ctx.fail("the lens travel check asked a question: %s", p)
def restart_on_found():
"""A fresh controller start on the settings as found, once the
check has ended. Plain waits, not the run's, so an abort gets it
too; a check still ending starts its own controller after the
put-back, which reads them already."""
try:
t_end = time.time() + 120
while time.time() < t_end and dark(fc).get("running"):
time.sleep(1)
if dark(fc).get("running"):
restart["skipped"] = "the check is still ending"
ctx.log("the check is still ending: its own controller start reads the settings as found")
return
st, m = fc.get("/mode")
m = m if isinstance(m, dict) else {}
restart.update(mode=m.get("mode"), pid=m.get("pid"))
if m.get("mode") != "grbl":
return # the cloud client reads no lens window
restart["stop"], body = fc.post("/controller/stop")
ctx.log("restarting the controller on the window as found: POST /controller/stop -> %s %s",
restart["stop"], body)
for _ in range(10):
restart["start"], body = fc.post("/controller/start")
if restart["start"] != 409:
break
time.sleep(1) # the check's lease goes just after its end
ctx.log("POST /controller/start -> %s %s", restart["start"], body)
except hw.HwError as e:
restart["error"] = str(e)
ctx.log("the controller could not be restarted on the window as found: %s", e)
try:
with Restore(ctx, LENS_WINDOW_KEYS):
last = run_check(ctx, "lens.travel", on_prompt, 300)
r = last.get("result") or {}
ctx.check(r.get("pass") is True, "the flow check did not pass: %s", r)
ctx.log("flow check: threshold %s, flow %s, no-flow %s, thin %s", r.get("threshold"),
r.get("flow_rise"), r.get("noflow_rise"), r.get("thin_margin"))
ctx.check(len(r.get("z_passes") or []) >= 1, "the lens was not referenced: %s", r.get("z_passes"))
stops, w = r.get("stops") or {}, r.get("window") or {}
ctx.check(isinstance(stops.get("found"), bool), "the result files no stops: %s", r)
window = (w.get("below"), w.get("above"))
if stops.get("found"):
ctx.check(window == (stops.get("below"), stops.get("above")) and w.get("found") is True,
"the window %s is not the stops found %s", w, stops)
else:
ctx.check(window == FALLBACK_WINDOW and w.get("found") is False,
"the stops were not found (%s), but the window is %s", stops.get("why"), w)
s = fc.settings() or {}
held = tuple(str(s.get(k)) for k in LENS_WINDOW_KEYS)
lens = (fc.status() or {}).get("lens") or {}
ev["window"] = {"stops": stops, "window": w, "settings": held, "status": lens}
ctx.check(held == tuple(str(v) for v in window), "the settings hold %s, the check wrote %s",
held, window)
ctx.check((lens.get("below"), lens.get("above")) == window,
"/status reads the window %s/%s, the check wrote %s", lens.get("below"), lens.get("above"),
window)
ctx.log("lens travel: %s half-steps below the edge, %s above (%s); contact %s/%s", window[0],
window[1], "the stops found" if stops.get("found") else "the fallback: %s" % stops.get("why"),
stops.get("contact_below"), stops.get("contact_above"))
finally:
restart_on_found()
ev["restart"] = dict(restart)
if restart.get("mode") == "grbl":
ctx.check(restart.get("stop") == 200 and restart.get("start") == 200,
"the controller was not restarted on the window as found: %s", restart)
def back():
st, m = fc.get("/mode")
m = m if isinstance(m, dict) else {}
return (m.get("controller") == "running" and m.get("motion") == "verified"
and (restart.get("start") != 200 or m.get("pid") != restart.get("pid")))
ok = ctx.wait_for(back, 150)
ctx.check(ok is not None, "the controller did not come back after the check")
@test("commission.cloud-header-capture", title="The cloud header check takes one print's envelope",
subsystem="commission", kind="operator", hardware="takeover", mode="grbl", est_min=8,
@test("setup.cloud-header-capture", title="The cloud header check takes one print's envelope",
subsystem="setup", kind="operator", hardware="takeover", mode="grbl", est_min=8,
covers=DARK_COVERS + [("forgectrl", "src/super.c"),
("python3-gfhardware", "gfhardware/machine.py"),
("python3-gfhardware", "forgefirm-app/gfcloud.py"),
+218
View File
@@ -0,0 +1,218 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The cipher forgectrl's HTTPS listener chooses, and where its records
are sealed.
The listener puts ChaCha20-Poly1305 first, whatever the client prefers,
and the image seals the records in the kernel: GnuTLS hands each
connection's keys to the socket after the handshake. ChaCha20-Poly1305
runs in the kernel's NEON code; AES-GCM, for a client without ChaCha20,
runs its AES on the CAAM crypto engine and its GHASH in NEON. This test
reads all of it from the outside: the cipher the listener picks for a
client that prefers AES-GCM, the kernel's TLS counters, the crypto
engine's interrupt, and the drivers the kernel resolved, with each
connection carrying the same page, which must arrive identical to the
plain-HTTP copy.
Its own module: a test's fingerprint holds the shared text of the module
it lives in, so a test added to forgectrl.py would move the fingerprint
of every test there.
"""
import os
import random
import shutil
import subprocess
import tempfile
import urllib.request
from ..catalog import test
OPENSSL = "/usr/bin/openssl"
ENGINE_IRQ = "2101000.jr" # the CAAM's first job ring
RECORD = 16384 # the largest TLS record's plaintext
PAGE = "/" # the panel page: large, and the same bytes every time
CHACHA_DRIVER = "rfc7539(chacha20-neon,poly1305-neon)"
# A desktop browser's offer: AES-GCM first, because its CPU has AES instructions.
DESKTOP_13 = "TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256"
DESKTOP_12 = "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305"
def _tls_stat():
"""The kernel's TLS counters, by name."""
out = {}
with open("/proc/net/tls_stat") as f:
for line in f:
p = line.split()
if len(p) == 2 and p[1].isdigit():
out[p[0]] = int(p[1])
return out
def _passive_opens():
"""The TCP connections the machine has accepted, on every port and both
families (the TCP counters are shared)."""
with open("/proc/net/snmp") as f:
rows = [line.split() for line in f if line.startswith("Tcp:")]
return int(rows[1][rows[0].index("PassiveOpens")])
def _engine_irqs():
with open("/proc/interrupts") as f:
for line in f:
p = line.split()
if p and p[-1] == ENGINE_IRQ and len(p) > 1 and p[1].isdigit():
return int(p[1])
return None
def _driver(name):
"""The driver answering an algorithm name: the entry of that name with
the highest priority in /proc/crypto."""
best, drv, entry = -1, None, {}
with open("/proc/crypto") as f:
lines = f.read().splitlines() + [""]
for line in lines:
if not line.strip():
if entry.get("name") == name and int(entry.get("priority", -1)) > best:
best, drv = int(entry["priority"]), entry.get("driver")
entry = {}
continue
k, _, v = line.partition(":")
entry[k.strip()] = v.strip()
return drv
def _fetch(version, offer, work):
"""GET the page over HTTPS on loopback with the given offer. Returns
the suite, the status line, the body, the kernel counters' change,
the crypto engine's interrupts during the connection, and how many
other connections the machine accepted meanwhile."""
req = os.path.join(work, "req")
with open(req, "wb") as f:
f.write(b"GET " + PAGE.encode() + b" HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n")
opt = ["-tls1_3", "-ciphersuites", offer] if version == "1.3" else ["-tls1_2", "-cipher", offer]
s0, i0, p0 = _tls_stat(), _engine_irqs(), _passive_opens()
with open(req, "rb") as stdin:
r = subprocess.run([OPENSSL, "s_client", "-connect", "127.0.0.1:443", "-brief", "-ign_eof"] + opt,
stdin=stdin, capture_output=True, timeout=60)
s1, i1, p1 = _tls_stat(), _engine_irqs(), _passive_opens()
info = {}
for line in r.stderr.decode("utf-8", "replace").splitlines():
k, _, v = line.partition(":")
info[k.strip()] = v.strip()
head, _, body = r.stdout.partition(b"\r\n\r\n")
delta = {k: s1.get(k, 0) - s0.get(k, 0) for k in ("TlsTxSw", "TlsRxSw", "TlsDecryptError")}
return (info.get("Ciphersuite"), head.split(b"\r\n")[0].decode("latin-1"), body, delta,
(i1 or 0) - (i0 or 0), p1 - p0 - 1)
@test("forgectrl.tls-records", title="HTTPS: ChaCha20 chosen, records sealed in the kernel",
subsystem="forgectrl", kind="auto", est_min=1,
covers=[("forgectrl", "src/tls.*"), ("forgectrl", "src/main.c")],
description="forgectrl's HTTPS listener chooses the cipher, ChaCha20-Poly1305 first, and the "
"image seals the records in the kernel: GnuTLS hands each connection's keys to the "
"socket after the handshake (/etc/gnutls/config turns it on). A client that lists "
"AES-GCM first, as a desktop browser does, gets ChaCha20-Poly1305 over TLS 1.3 and "
"TLS 1.2, and the kernel takes both directions' keys; the kernel's rfc7539 is the "
"NEON ChaCha20 and Poly1305. A client offering AES-GCM alone gets it, the kernel "
"takes its keys, and the crypto engine's interrupt counts the records: the AES runs "
"on the CAAM (gcm(aes) is its ctr(aes) with the NEON GHASH), which writes each "
"record by DMA while the CPU fills the next, so those connections run three times "
"over each protocol and every copy must be exact. A TLS 1.2 client "
"offering a CBC suite alone still connects and stays in GnuTLS, which is the control "
"for the kernel's counters; the ChaCha20 runs are the control for the engine's "
"interrupt. Every copy of the panel page must be the plain-HTTP page byte for byte, "
"and the kernel must count no decrypt error. The counters and the interrupt are the "
"machine's, so a connection during which another client connected (a panel "
"reconnecting after a restart) is measured again. Nothing on the machine changes.")
def tls_records(ctx):
ev = ctx.evidence
conf = ""
if os.path.exists("/etc/gnutls/config"):
with open("/etc/gnutls/config") as f:
conf = f.read()
ctx.check(any(line.split() == ["ktls", "=", "true"] for line in conf.splitlines()),
"/etc/gnutls/config does not turn kernel TLS on: %r", conf)
with open("/proc/sys/net/ipv4/tcp_available_ulp") as f:
ulp = f.read().split()
ev["tcp_available_ulp"] = ulp
ctx.check("tls" in ulp, "the kernel has no TLS layer (tcp_available_ulp %s)", ulp)
ctx.check(_engine_irqs() is not None, "/proc/interrupts has no %s", ENGINE_IRQ)
with urllib.request.urlopen("http://127.0.0.1" + PAGE, timeout=30) as r:
plain = r.read()
ev["page_bytes"] = len(plain)
ctx.check(len(plain) > 8 * RECORD, "the page is %d bytes, too small to span records", len(plain))
records = len(plain) // RECORD
work = tempfile.mkdtemp(prefix="forgetest-tls-")
try:
runs = {}
# The AES-GCM connections run three times each: their records are written by the crypto
# engine's DMA while the CPU fills the next one, which is where a record's last bytes were
# once lost.
cases = [("desktop-1.3", "1.3", DESKTOP_13, "TLS_CHACHA20_POLY1305_SHA256", True),
("desktop-1.2", "1.2", DESKTOP_12, "ECDHE-ECDSA-CHACHA20-POLY1305", True)]
for n in (1, 2, 3):
cases += [("aes-1.3#%d" % n, "1.3", "TLS_AES_128_GCM_SHA256", "TLS_AES_128_GCM_SHA256", True),
("aes-1.2#%d" % n, "1.2", "ECDHE-ECDSA-AES128-GCM-SHA256", "ECDHE-ECDSA-AES128-GCM-SHA256",
True)]
cases.append(("cbc-1.2", "1.2", "ECDHE-ECDSA-AES128-SHA", "ECDHE-ECDSA-AES128-SHA", False))
for label, version, offer, want, kernel in cases:
# The kernel's counters and the engine's interrupt are the machine's, not the
# connection's: a window in which another client connected (a panel reconnecting
# after a restart, a page polling this suite once a second) says nothing about this
# one, and is measured again, after a pause that no steady poller stays in step with.
for attempt in range(1, 31):
suite, status, body, delta, irqs, others = _fetch(version, offer, work)
if others <= 0:
break
ctx.log("%s: %d other connection(s) during the window; measured again", label, others)
ctx.sleep(random.uniform(0.1, 0.9))
ctx.check(others <= 0, "%s: another client connected during each of %d windows", label, attempt)
run = {"offer": offer, "suite": suite, "status": status, "bytes": len(body),
"same_as_http": body == plain, "tls_stat": delta, "engine_irqs": irqs, "attempts": attempt}
runs[label] = run
ctx.log("%s: offer %s -> %s, %s, %d bytes, same %s, tls_stat %s, %s +%d",
label, offer, suite, status, len(body), body == plain, delta, ENGINE_IRQ, irqs)
ctx.check(suite == want, "%s: offered %s, the listener chose %s, not %s", label, offer, suite, want)
ctx.check(status.endswith("200 OK") and body == plain,
"%s: the page arrived %s, %d bytes, not the plain-HTTP %d", label, status, len(body),
len(plain))
ctx.check(delta["TlsDecryptError"] == 0, "%s: the kernel counted %d decrypt errors", label,
delta["TlsDecryptError"])
if kernel:
ctx.check(delta["TlsTxSw"] == 1 and delta["TlsRxSw"] == 1,
"%s: the kernel did not take both directions' keys (tls_stat %s)", label, delta)
else:
ctx.check(delta["TlsTxSw"] == 0 and delta["TlsRxSw"] == 0,
"%s: the kernel took keys for a cipher it does not seal (tls_stat %s)", label, delta)
ev["runs"] = runs
# The AES of AES-GCM runs on the crypto engine: its interrupt counts
# the records. ChaCha20 never touches the engine, so those runs are
# the control.
for label in ("aes-1.3#1", "aes-1.2#1"):
ctx.check(runs[label]["engine_irqs"] >= records // 4,
"%s: %d records crossed and the crypto engine interrupted %d times", label, records,
runs[label]["engine_irqs"])
for label in ("desktop-1.3", "desktop-1.2"):
ctx.check(runs[label]["engine_irqs"] < max(2, records // 8),
"%s: the crypto engine interrupted %d times for ChaCha20 records", label,
runs[label]["engine_irqs"])
finally:
shutil.rmtree(work, ignore_errors=True)
# The drivers the kernel resolved for the two record ciphers (both
# exist now: the connections above instantiated them).
chacha, gcm = _driver("rfc7539(chacha20,poly1305)"), _driver("gcm(aes)")
ev["drivers"] = {"rfc7539(chacha20,poly1305)": chacha, "gcm(aes)": gcm}
ctx.log("rfc7539(chacha20,poly1305) is %s; gcm(aes) is %s", chacha, gcm)
ctx.check(chacha == CHACHA_DRIVER, "the kernel's ChaCha20-Poly1305 is %s, not %s", chacha, CHACHA_DRIVER)
ctx.check(gcm and "ctr-aes-caam" in gcm and "ghash-ce" in gcm,
"the kernel's gcm(aes) is %s: not the crypto engine's AES with the NEON GHASH", gcm)
+31 -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
"""update.* - the A/B slot inventory and the firmware verification path."""
import os
import tempfile
@@ -8,6 +13,7 @@ from ..catalog import test
from .. import hw
_UPDATE_COVERS = [("forgectrl", "src/update.c"), ("forgectrl", "src/update.h"),
("forgectrl", "src/relcheck.c"), ("forgectrl", "src/relcheck.h"),
("ffboot", "**")]
@@ -18,7 +24,8 @@ _UPDATE_COVERS = [("forgectrl", "src/update.c"), ("forgectrl", "src/update.h"),
"answers; `fwup` refuses a garbage archive. A tiny archive signed with a "
"throwaway key made on the spot verifies with its own key and fails against the "
"shipped release key, and an apply of it without confirm_unsigned is refused by "
"the update job before anything is written. Nothing is written to any slot.")
"the update job before anything is written, and the forgectrl log records the "
"job's start and its refusal. Nothing is written to any slot.")
def slots_and_signature(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
@@ -89,6 +96,11 @@ def slots_and_signature(ctx):
target = name
ctx.check(target is not None, "no inactive firmware slot in /slots: %s", slots)
shutil.copyfile(signed, staged)
log_path = "/data/log/forgefirm/forgectrl/forgectrl.log"
try:
log_from = os.path.getsize(log_path)
except OSError:
log_from = 0
st, body = fc.post("/update/apply", params={"slot": target, "file": "upload"})
ev["apply"] = {"status": st, "body": body}
ctx.log("POST /update/apply slot=%s file=upload (no confirm_unsigned) -> %s %s", target, st, body)
@@ -111,6 +123,24 @@ def slots_and_signature(ctx):
ctx.check(isinstance(result, dict) and result.get("ok") is False
and "not signed" in str(result.get("error", "")),
"the apply of a foreign-signed archive was not refused for its signature: %s", result)
# The forgectrl log records the job: what it set out to write
# and why it stopped.
want = ["update: writing the uploaded archive into slot %s" % target,
"update: the apply failed: archive is not signed"]
def log_text():
try:
with open(log_path, "r", encoding="utf-8", errors="replace") as f:
f.seek(log_from)
return f.read()
except OSError:
return ""
ctx.wait_for(lambda: all(w in log_text() for w in want), 10, poll=0.5)
text = log_text()
ev["apply_log"] = [ln for ln in text.splitlines() if "update: " in ln][-4:]
for w in want:
ctx.check(w in text, "the forgectrl log lacks %r: %s", w, ev["apply_log"])
finally:
try:
os.unlink(staged)
+146
View File
@@ -206,6 +206,100 @@
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 ---------------------------------- */
body {
margin: 0;
@@ -238,6 +332,7 @@ header {
margin-left: auto;
}
#themebtn,
#logsbtn,
#logoutbtn {
background: transparent;
color: rgba(255, 255, 255, 0.75);
@@ -249,6 +344,7 @@ header {
white-space: nowrap;
}
#themebtn:hover,
#logsbtn:hover,
#logoutbtn:hover {
color: #fff;
border-color: rgba(255, 255, 255, 0.6);
@@ -355,6 +451,23 @@ footer a {
font-size: 11.5px;
margin-left: 2px;
}
.extadvdoc {
max-height: 22rem;
overflow-y: auto;
margin-bottom: 0.5rem;
}
.extadvdoc h1,
.extadvdoc h2 {
font-size: 1rem;
margin: 0.6rem 0 0.3rem;
}
.extcaps {
margin: 0.2rem 0 0.2rem 1.1rem;
padding: 0;
}
.extcaps li {
margin: 0;
}
.hint {
color: var(--og-dim);
font-size: 12.5px;
@@ -692,3 +805,36 @@ footer a {
padding-right: 12px;
}
}
/* The setup's log-bundle line, under the header: the result of the
header's Download logs, or why the machine refused it. */
.wz-logs-msg {
max-width: 1100px;
margin: 6px auto 0;
padding: 0 16px;
min-height: 16px;
text-align: right;
}
/* A package's card on the Extensions tab: the card and its heading (the
label) are the panel's, the frame is the package's, and the frame is
given a size rather than taking one. The frame takes the panel's color
scheme, which the page inside reads as its prefers-color-scheme; it is
transparent, so a page that paints no background sits on the card. */
.extcard h2 .mono {
text-transform: none;
letter-spacing: 0;
font-weight: 400;
color: var(--og-dim);
}
.extframe {
display: block;
width: 100%;
height: 360px;
border: 0;
background: transparent;
color-scheme: light;
}
[data-bs-theme='dark'] .extframe {
color-scheme: dark;
}
View File
+1
View File
@@ -0,0 +1 @@
0
+1
View File
@@ -0,0 +1 @@
1
+1
View File
@@ -0,0 +1 @@
0
+1
View File
@@ -0,0 +1 @@
1
+1
View File
@@ -0,0 +1 @@
1
-41
View File
@@ -1,41 +0,0 @@
2026-08-17T09:44:00.417337+00:00 gfcloud[1927] INFO cnc:set_step_freq 28160
2026-08-17T09:44:00.418627+00:00 gfcloud[1927] INFO cnc:set_x_decay 1
2026-08-17T09:44:00.419858+00:00 gfcloud[1927] INFO cnc:set_x_mode 8
2026-08-17T09:44:00.421083+00:00 gfcloud[1927] INFO cnc:set_x_current 135
2026-08-17T09:44:00.422535+00:00 gfcloud[1927] INFO cnc:set_y_decay 1
2026-08-17T09:44:00.423794+00:00 gfcloud[1927] INFO cnc:set_y_mode 8
2026-08-17T09:44:00.425014+00:00 gfcloud[1927] INFO cnc:set_y_current 22
2026-08-17T09:44:00.432591+00:00 gfcloud[1927] INFO machine:_run_loop starting run
2026-08-17T09:44:00.434005+00:00 gfcloud[1927] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:44:00.438277+00:00 gfcloud[1927] INFO machine:_run_loop current state: MachineState.RUNNING
2026-08-17T09:44:01.785230+00:00 gfcloud[1927] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:44:01.797728+00:00 gfcloud[1927] INFO machine:_run_loop finished run
2026-08-17T09:44:01.800661+00:00 gfcloud[1927] INFO cnc:laser_latch 1
2026-08-17T09:44:01.808504+00:00 gfcloud[1927] INFO machine:_motion_locked end positions (actual/expected): X (-10313/-10313), Y (-649/-649), Z (0/0)
2026-08-17T09:44:01.809326+00:00 gfcloud[1927] INFO machine:_motion_locked motion bytes actual:34952, expected: 34952
2026-08-17T09:44:01.810066+00:00 gfcloud[1927] INFO machine:_motion_locked start idle
2026-08-17T09:44:01.811352+00:00 gfcloud[1927] INFO cnc:set_x_current 33
2026-08-17T09:44:01.812874+00:00 gfcloud[1927] INFO cnc:set_y_current 5
2026-08-17T09:44:01.820631+00:00 gfcloud[1927] INFO machine:_motion_locked end positions (-10313, -649, 0)
2026-08-17T09:44:01.822062+00:00 gfcloud[1927] INFO machine:_motion end motion
2026-08-17T09:44:01.822829+00:00 gfcloud[1927] INFO basemachine:_finish_action motion [1576550614]: finished with event ":completed"
2026-08-17T09:44:01.824200+00:00 gfcloud[1927] INFO cnc:laser_latch 1
2026-08-17T09:44:07.993647+00:00 gfcloud[1927] INFO gfuiservice:run service action request: print (ready)
2026-08-17T09:44:07.998655+00:00 gfcloud[1927] INFO machine:_motion start motion
2026-08-17T09:44:07.999643+00:00 gfcloud[1927] INFO gfuiservice:run print (ready) service action print
2026-08-17T09:44:14.199666+00:00 gfcloud[1927] INFO cnc:laser_latch 0
2026-08-17T09:44:14.208291+00:00 gfcloud[1927] INFO machine:_button_wait waiting for button
2026-08-17T09:44:21.270846+00:00 gfcloud[1927] INFO machine:_switch_event lid opened
2026-08-17T09:44:21.275703+00:00 gfcloud[1927] WARNING machine:_button_wait button wait lid opened - relocking the laser
2026-08-17T09:44:21.278615+00:00 gfcloud[1927] INFO cnc:laser_latch 1
2026-08-17T09:44:21.288305+00:00 gfcloud[1927] INFO machine:_return_home start return home
2026-08-17T09:44:21.291373+00:00 gfcloud[1927] INFO machine:_return_home return home: already at the job start
2026-08-17T09:44:21.292501+00:00 gfcloud[1927] INFO machine:_motion_locked start cool down
2026-08-17T09:44:21.305578+00:00 gfcloud[1927] INFO machine:_motion_locked start idle
2026-08-17T09:44:21.307322+00:00 gfcloud[1927] INFO cnc:set_x_current 33
2026-08-17T09:44:21.309185+00:00 gfcloud[1927] INFO cnc:set_y_current 5
2026-08-17T09:44:21.316340+00:00 gfcloud[1927] INFO machine:_motion_locked end positions (0, 0, 0)
2026-08-17T09:44:21.318139+00:00 gfcloud[1927] INFO machine:_motion end motion
2026-08-17T09:44:21.318860+00:00 gfcloud[1927] INFO basemachine:_finish_action print [1576550621]: finished with event ":cancelled"
2026-08-17T09:44:21.320214+00:00 gfcloud[1927] INFO cnc:laser_latch 1
2026-08-17T09:44:34.058320+00:00 gfcloud[1927] INFO machine:_switch_event lid closed
-207
View File
@@ -1,207 +0,0 @@
2026-08-17T09:44:35.789556+00:00 gfcloud[1927] INFO websocket:img_upload START
2026-08-17T09:44:36.105516+00:00 gfcloud[1927] INFO websocket:img_upload COMPLETE
2026-08-17T09:44:36.109421+00:00 gfcloud[1927] INFO cnc:laser_latch 1
2026-08-17T09:44:38.461442+00:00 gfcloud[1927] INFO gfcloud:_shutdown shutdown requested
2026-08-17T09:44:38.910494+00:00 gfcloud[1927] INFO machine:_shutdown shutting down
2026-08-17T09:44:38.913049+00:00 gfcloud[1927] INFO cnc:laser_latch 1
2026-08-17T09:44:38.950354+00:00 gfcloud[1927] INFO machine:_shutdown joining switch thread
2026-08-17T09:44:38.956909+00:00 gfcloud[1927] INFO machine:_shutdown shut down complete
2026-08-17T09:44:38.986287+00:00 gfcloud[1927] INFO websocket:_on_close RX-EVENT: closed (None, None)
2026-08-17T09:44:38.989003+00:00 gfcloud[1927] INFO websocket:run CLOSING
2026-08-17T09:44:38.992724+00:00 gfcloud[1927] INFO gfcloud:main gfcloud exit
2026-08-17T09:45:08.449553+00:00 gfcloud[2278] INFO gfuiservice:__init__ INITIALIZED
2026-08-17T09:45:08.451929+00:00 gfcloud[2278] INFO authentication:authenticate_machine START
2026-08-17T09:45:08.965893+00:00 gfcloud[2278] INFO authentication:authenticate_machine SUCCESS
2026-08-17T09:45:08.967947+00:00 gfcloud[2278] INFO websocket:ws_connect CONNECTING
2026-08-17T09:45:09.421918+00:00 gfcloud[2278] INFO _logging:info Websocket connected
2026-08-17T09:45:09.424224+00:00 gfcloud[2278] INFO websocket:_on_open RX-EVENT: ready
2026-08-17T09:45:09.973615+00:00 gfcloud[2278] INFO websocket:ws_connect ESTABLISHED
2026-08-17T09:45:09.994287+00:00 gfcloud[2278] INFO cnc:set_ignored_faults 0
2026-08-17T09:45:09.996281+00:00 gfcloud[2278] INFO cnc:set_step_freq 10000
2026-08-17T09:45:09.998803+00:00 gfcloud[2278] INFO cnc:set_x_mode Microstep.M_8
2026-08-17T09:45:09.999688+00:00 gfcloud[2278] INFO cnc:set_y_mode Microstep.M_8
2026-08-17T09:45:10.000483+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
2026-08-17T09:45:10.001229+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
2026-08-17T09:45:10.002000+00:00 gfcloud[2278] INFO cnc:set_x_current 33
2026-08-17T09:45:10.007267+00:00 gfcloud[2278] INFO cnc:set_y_current 33
2026-08-17T09:45:10.008695+00:00 gfcloud[2278] INFO z_axis:reset resetting z
2026-08-17T09:45:10.009124+00:00 gfcloud[2278] INFO z_axis:configure configuring z_enable: False, current: ZCur.LOW, mode: Microstep.HALF
2026-08-17T09:45:10.021592+00:00 gfcloud[2278] INFO gfuiservice:run service action request: settings (ready)
2026-08-17T09:45:10.024928+00:00 gfcloud[2278] INFO settings:send_report START
2026-08-17T09:45:10.039856+00:00 gfcloud[2278] INFO settings:send_report COMPLETE
2026-08-17T09:45:10.040650+00:00 gfcloud[2278] INFO gfuiservice:run settings (ready) service action dispatched
2026-08-17T09:45:10.591813+00:00 gfcloud[2278] INFO gfuiservice:run service action request: hunt (ready)
2026-08-17T09:45:10.596213+00:00 gfcloud[2278] INFO z_axis:home starting z homing cycle
2026-08-17T09:45:10.599109+00:00 gfcloud[2278] INFO gfuiservice:run hunt (ready) service action hunt
2026-08-17T09:45:10.600433+00:00 gfcloud[2278] INFO z_axis:configure configuring z_enable: True, current: ZCur.HIGH, mode: Microstep.FULL
2026-08-17T09:45:10.607559+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
2026-08-17T09:45:11.160422+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
2026-08-17T09:45:11.161195+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
2026-08-17T09:45:11.714663+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
2026-08-17T09:45:11.717907+00:00 gfcloud[2278] INFO z_axis:home pass: 1
2026-08-17T09:45:11.719134+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
2026-08-17T09:45:12.272747+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
2026-08-17T09:45:12.274301+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
2026-08-17T09:45:12.829585+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
2026-08-17T09:45:12.831305+00:00 gfcloud[2278] INFO z_axis:home pass: 2
2026-08-17T09:45:12.832786+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
2026-08-17T09:45:13.385567+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
2026-08-17T09:45:13.397106+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
2026-08-17T09:45:13.947958+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
2026-08-17T09:45:13.949754+00:00 gfcloud[2278] INFO z_axis:home pass: 3
2026-08-17T09:45:13.951244+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
2026-08-17T09:45:14.504840+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
2026-08-17T09:45:14.506743+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
2026-08-17T09:45:15.060854+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
2026-08-17T09:45:15.062536+00:00 gfcloud[2278] INFO z_axis:home pass: 4
2026-08-17T09:45:15.063941+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
2026-08-17T09:45:15.617907+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
2026-08-17T09:45:15.619505+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
2026-08-17T09:45:16.175270+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
2026-08-17T09:45:16.177421+00:00 gfcloud[2278] INFO z_axis:home pass: 5
2026-08-17T09:45:16.179808+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
2026-08-17T09:45:16.733698+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
2026-08-17T09:45:16.735355+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
2026-08-17T09:45:17.289377+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
2026-08-17T09:45:17.291109+00:00 gfcloud[2278] INFO z_axis:configure configuring z_enable: None, current: ZCur.LOW, mode: Microstep.HALF
2026-08-17T09:45:17.296301+00:00 gfcloud[2278] INFO z_axis:home homing cycle complete: [0, 0, 0, 0, 0]
2026-08-17T09:45:17.298209+00:00 gfcloud[2278] INFO machine:_motion start motion
2026-08-17T09:45:17.968494+00:00 gfcloud[2278] INFO cnc:set_step_freq 10000
2026-08-17T09:45:17.969759+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
2026-08-17T09:45:17.970993+00:00 gfcloud[2278] INFO cnc:set_x_mode 8
2026-08-17T09:45:17.972200+00:00 gfcloud[2278] INFO cnc:set_x_current 135
2026-08-17T09:45:17.973616+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
2026-08-17T09:45:17.974870+00:00 gfcloud[2278] INFO cnc:set_y_mode 8
2026-08-17T09:45:17.976077+00:00 gfcloud[2278] INFO cnc:set_y_current 22
2026-08-17T09:45:17.983820+00:00 gfcloud[2278] INFO machine:_run_loop starting run
2026-08-17T09:45:17.985159+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:45:17.989360+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.RUNNING
2026-08-17T09:45:19.630807+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:45:19.636283+00:00 gfcloud[2278] INFO machine:_run_loop finished run
2026-08-17T09:45:19.637979+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:45:19.651159+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (actual/expected): X (0/0), Y (0/0), Z (-4/-4)
2026-08-17T09:45:19.652103+00:00 gfcloud[2278] INFO machine:_motion_locked motion bytes actual:15874, expected: 15874
2026-08-17T09:45:19.652852+00:00 gfcloud[2278] INFO machine:_motion_locked start idle
2026-08-17T09:45:19.653964+00:00 gfcloud[2278] INFO cnc:set_x_current 33
2026-08-17T09:45:19.655305+00:00 gfcloud[2278] INFO cnc:set_y_current 5
2026-08-17T09:45:19.664032+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (0, 0, -4)
2026-08-17T09:45:19.664853+00:00 gfcloud[2278] INFO machine:_motion end motion
2026-08-17T09:45:19.665575+00:00 gfcloud[2278] INFO basemachine:_finish_action hunt [1576550660]: finished with event ":completed"
2026-08-17T09:45:19.667114+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:45:20.027338+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
2026-08-17T09:45:20.030024+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
2026-08-17T09:45:20.040337+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
2026-08-17T09:45:21.195560+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
2026-08-17T09:45:21.196529+00:00 gfcloud[2278] INFO websocket:img_upload START
2026-08-17T09:45:21.721382+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
2026-08-17T09:45:21.725394+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:45:23.732857+00:00 gfcloud[2278] INFO gfuiservice:run service action request: motion (ready)
2026-08-17T09:45:23.736044+00:00 gfcloud[2278] INFO machine:_motion start motion
2026-08-17T09:45:23.737995+00:00 gfcloud[2278] INFO gfuiservice:run motion (ready) service action motion
2026-08-17T09:45:23.738941+00:00 gfcloud[2278] INFO machine:_safe_to_move lid opened, unsafe to move
2026-08-17T09:45:23.739814+00:00 gfcloud[2278] INFO machine:_motion end motion
2026-08-17T09:45:23.740605+00:00 gfcloud[2278] INFO basemachine:_finish_action motion [1576550667]: finished with event ":cancelled"
2026-08-17T09:45:23.742169+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:45:42.629674+00:00 gfcloud[2278] INFO machine:_switch_event lid closed
2026-08-17T09:45:42.920194+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
2026-08-17T09:45:42.924152+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
2026-08-17T09:45:42.927934+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
2026-08-17T09:45:43.989152+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
2026-08-17T09:45:43.990022+00:00 gfcloud[2278] INFO websocket:img_upload START
2026-08-17T09:45:44.312060+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
2026-08-17T09:45:44.315952+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:45:46.652633+00:00 gfcloud[2278] INFO gfuiservice:run service action request: motion (ready)
2026-08-17T09:45:46.654950+00:00 gfcloud[2278] INFO machine:_motion start motion
2026-08-17T09:45:46.657522+00:00 gfcloud[2278] INFO gfuiservice:run motion (ready) service action motion
2026-08-17T09:45:48.089456+00:00 gfcloud[2278] INFO cnc:set_step_freq 10000
2026-08-17T09:45:48.090706+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
2026-08-17T09:45:48.091970+00:00 gfcloud[2278] INFO cnc:set_x_mode 8
2026-08-17T09:45:48.093155+00:00 gfcloud[2278] INFO cnc:set_x_current 135
2026-08-17T09:45:48.094587+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
2026-08-17T09:45:48.095866+00:00 gfcloud[2278] INFO cnc:set_y_mode 8
2026-08-17T09:45:48.097182+00:00 gfcloud[2278] INFO cnc:set_y_current 22
2026-08-17T09:45:48.104758+00:00 gfcloud[2278] INFO machine:_run_loop starting run
2026-08-17T09:45:48.106128+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:45:48.110477+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.RUNNING
2026-08-17T09:45:50.393091+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:45:50.398748+00:00 gfcloud[2278] INFO machine:_run_loop finished run
2026-08-17T09:45:50.399629+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:45:50.423949+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (actual/expected): X (12939/12939), Y (7402/7402), Z (0/0)
2026-08-17T09:45:50.425534+00:00 gfcloud[2278] INFO machine:_motion_locked motion bytes actual:22740, expected: 22740
2026-08-17T09:45:50.426258+00:00 gfcloud[2278] INFO machine:_motion_locked start idle
2026-08-17T09:45:50.427835+00:00 gfcloud[2278] INFO cnc:set_x_current 33
2026-08-17T09:45:50.429387+00:00 gfcloud[2278] INFO cnc:set_y_current 5
2026-08-17T09:45:50.437659+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (12939, 7402, 0)
2026-08-17T09:45:50.438474+00:00 gfcloud[2278] INFO machine:_motion end motion
2026-08-17T09:45:50.439167+00:00 gfcloud[2278] INFO basemachine:_finish_action motion [1576550679]: finished with event ":completed"
2026-08-17T09:45:50.440481+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:45:50.770056+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
2026-08-17T09:45:50.772800+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
2026-08-17T09:45:50.782017+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
2026-08-17T09:45:51.427848+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
2026-08-17T09:45:51.428456+00:00 gfcloud[2278] INFO websocket:img_upload START
2026-08-17T09:45:51.757479+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
2026-08-17T09:45:51.760692+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:45:54.222123+00:00 gfcloud[2278] INFO gfuiservice:run service action request: motion (ready)
2026-08-17T09:45:54.224786+00:00 gfcloud[2278] INFO machine:_motion start motion
2026-08-17T09:45:54.226855+00:00 gfcloud[2278] INFO gfuiservice:run motion (ready) service action motion
2026-08-17T09:45:54.557705+00:00 gfcloud[2278] INFO cnc:set_step_freq 10000
2026-08-17T09:45:54.562210+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
2026-08-17T09:45:54.571212+00:00 gfcloud[2278] INFO cnc:set_x_mode 8
2026-08-17T09:45:54.573373+00:00 gfcloud[2278] INFO cnc:set_x_current 135
2026-08-17T09:45:54.596668+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
2026-08-17T09:45:54.597612+00:00 gfcloud[2278] INFO cnc:set_y_mode 8
2026-08-17T09:45:54.598479+00:00 gfcloud[2278] INFO cnc:set_y_current 22
2026-08-17T09:45:54.622488+00:00 gfcloud[2278] INFO machine:_run_loop starting run
2026-08-17T09:45:54.624223+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:45:54.629307+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.RUNNING
2026-08-17T09:45:54.835600+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:45:54.838848+00:00 gfcloud[2278] INFO machine:_run_loop finished run
2026-08-17T09:45:54.839726+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:45:54.849011+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (actual/expected): X (159/159), Y (5/5), Z (0/0)
2026-08-17T09:45:54.849946+00:00 gfcloud[2278] INFO machine:_motion_locked motion bytes actual:1222, expected: 1222
2026-08-17T09:45:54.850790+00:00 gfcloud[2278] INFO machine:_motion_locked start idle
2026-08-17T09:45:54.852317+00:00 gfcloud[2278] INFO cnc:set_x_current 33
2026-08-17T09:45:54.854073+00:00 gfcloud[2278] INFO cnc:set_y_current 5
2026-08-17T09:45:54.880450+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (159, 5, 0)
2026-08-17T09:45:54.881925+00:00 gfcloud[2278] INFO machine:_motion end motion
2026-08-17T09:45:54.882641+00:00 gfcloud[2278] INFO basemachine:_finish_action motion [1576550683]: finished with event ":completed"
2026-08-17T09:45:54.884500+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:45:55.137070+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
2026-08-17T09:45:55.139449+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
2026-08-17T09:45:55.142346+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
2026-08-17T09:45:55.809276+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
2026-08-17T09:45:55.810155+00:00 gfcloud[2278] INFO websocket:img_upload START
2026-08-17T09:45:56.158888+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
2026-08-17T09:45:56.162644+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:45:58.467590+00:00 gfcloud[2278] INFO gfuiservice:run service action request: motion (ready)
2026-08-17T09:45:58.469426+00:00 gfcloud[2278] INFO machine:_motion start motion
2026-08-17T09:45:58.471091+00:00 gfcloud[2278] INFO gfuiservice:run motion (ready) service action motion
2026-08-17T09:46:00.721960+00:00 gfcloud[2278] INFO cnc:set_step_freq 28160
2026-08-17T09:46:00.725688+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
2026-08-17T09:46:00.727477+00:00 gfcloud[2278] INFO cnc:set_x_mode 8
2026-08-17T09:46:00.728738+00:00 gfcloud[2278] INFO cnc:set_x_current 135
2026-08-17T09:46:00.730198+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
2026-08-17T09:46:00.731506+00:00 gfcloud[2278] INFO cnc:set_y_mode 8
2026-08-17T09:46:00.732735+00:00 gfcloud[2278] INFO cnc:set_y_current 22
2026-08-17T09:46:00.740459+00:00 gfcloud[2278] INFO machine:_run_loop starting run
2026-08-17T09:46:00.741865+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:46:00.745983+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.RUNNING
2026-08-17T09:46:02.491897+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:46:02.494137+00:00 gfcloud[2278] INFO machine:_run_loop finished run
2026-08-17T09:46:02.495155+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:46:02.530565+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (actual/expected): X (-13096/-13096), Y (-7400/-7400), Z (0/0)
2026-08-17T09:46:02.531413+00:00 gfcloud[2278] INFO machine:_motion_locked motion bytes actual:47243, expected: 47243
2026-08-17T09:46:02.538168+00:00 gfcloud[2278] INFO machine:_motion_locked start idle
2026-08-17T09:46:02.539603+00:00 gfcloud[2278] INFO cnc:set_x_current 33
2026-08-17T09:46:02.541147+00:00 gfcloud[2278] INFO cnc:set_y_current 5
2026-08-17T09:46:02.549578+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (-13096, -7400, 0)
2026-08-17T09:46:02.550385+00:00 gfcloud[2278] INFO machine:_motion end motion
2026-08-17T09:46:02.551153+00:00 gfcloud[2278] INFO basemachine:_finish_action motion [1576550685]: finished with event ":completed"
2026-08-17T09:46:02.552496+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:46:03.007623+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
2026-08-17T09:46:03.009488+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
2026-08-17T09:46:03.037155+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
2026-08-17T09:46:03.758520+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
2026-08-17T09:46:03.760049+00:00 gfcloud[2278] INFO websocket:img_upload START
2026-08-17T09:46:04.128126+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
+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
catalog entries that never touch hardware."""
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 json
import unittest

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