142 Commits
Author SHA1 Message Date
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
274 changed files with 151161 additions and 1951 deletions
+4
View File
@@ -0,0 +1,4 @@
# Every text file is stored and checked out with LF; a patch keeps its bytes.
* text=auto eol=lf
*.patch -text
*.diff -text
+1
View File
@@ -11,6 +11,7 @@ name: fixture-ci
on: on:
push: push:
branches: [master]
paths: paths:
- 'fixture/**' - 'fixture/**'
- '.github/workflows/fixture-ci.yml' - '.github/workflows/fixture-ci.yml'
+1
View File
@@ -20,6 +20,7 @@ name: forgetest-ci
on: on:
push: push:
branches: [master]
paths: paths:
- 'forgetest/**' - 'forgetest/**'
- 'scripts/acceptance-gate.py' - 'scripts/acceptance-gate.py'
+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. chain of others.
- Never exercise a gate or a limit through the `GFCOOL_*` environment - Never exercise a gate or a limit through the `GFCOOL_*` environment
overrides in a test. Use the settings API, and restore it in the teardown. overrides in a test. Use the settings API, and restore it in the teardown.
- A silence-while-armed injection and a crash during `$H` are catalog
drills, not one-off bench scripts.
### CI couplings that set the push order ### CI couplings that set the push order
@@ -186,6 +188,9 @@ does.
test with the fix, in the same commit, never after. test with the fix, in the same commit, never after.
- Position counters, homing anchors, and a homed flag are not proof of - Position counters, homing anchors, and a homed flag are not proof of
physical motion. The head accelerometer is, and so are the operator's eyes. physical motion. The head accelerometer is, and so are the operator's eyes.
- Unreadable is fail-closed. A failed read of a safety input, a state file,
or a verdict is never treated as "still running" or "keep waiting" without
a bound.
### Proof before done ### Proof before done
@@ -320,7 +325,11 @@ docstrings, log and console text, commit messages, issue text, and replies.
comment style of the file: `Copyright <year> 514 LLC d/b/a OpenGlow` and comment style of the file: `Copyright <year> 514 LLC d/b/a OpenGlow` and
`Written by Scott Wiederhold`. Keep the year, or the year range, that `Written by Scott Wiederhold`. Keep the year, or the year range, that
the file already has. The copyright notices of other holders stay the file already has. The copyright notices of other holders stay
unchanged. unchanged. Those two lines are for files this project authors: an
outside contributor who writes a new file puts their own name there, or
leaves the lines out, and keeps their copyright in what they wrote.
Editing an existing file changes no header - a contribution is recorded
by its `Signed-off-by` line and by git, not by a copyright line.
- **SPDX.** Every new source file carries an `SPDX-License-Identifier` line - **SPDX.** Every new source file carries an `SPDX-License-Identifier` line
under the license of its repository. under the license of its repository.
+54
View File
@@ -0,0 +1,54 @@
# Contributing
Patches are welcome. How the project works, what proof a change needs, and the
writing rules are on the documentation site:
<https://docs.forgefirm.org/developers/contributing/>.
## Sign your work
Every commit carries a `Signed-off-by` line:
Signed-off-by: Your Name <your.email@example.com>
`git commit -s` adds it, using your `user.name` and `user.email`. Use your real
name; an anonymous or pseudonymous sign-off certifies nothing.
The line certifies the Developer Certificate of Origin, version 1.1, below. It
is the same statement the Linux kernel and grblHAL use. It is not a copyright
assignment: you keep the copyright in what you write, and your contribution
comes in under the license of the repository you are contributing to.
You do not add a copyright line for a patch. A change to an existing file
leaves its header alone. If you write a whole new file, put your own name in
its copyright line, or leave the line out; either way the file carries an
`SPDX-License-Identifier` for the repository's license.
## Developer Certificate of Origin 1.1
By making a contribution to this project, I certify that:
(a) The contribution was created in whole or in part by me and I
have the right to submit it under the open source license
indicated in the file; or
(b) The contribution is based upon previous work that, to the best
of my knowledge, is covered under an appropriate open source
license and I have the right under that license to submit that
work with modifications, whether created in whole or in part
by me, under the same open source license (unless I am
permitted to submit under a different license), as indicated
in the file; or
(c) The contribution was provided directly to me by some other
person who certified (a), (b) or (c) and I have not modified
it.
(d) I understand and agree that this project and the contribution
are public and that a record of the contribution (including all
personal information I submit with it, including my sign-off) is
maintained indefinitely and may be redistributed consistent with
this project or the open source license(s) involved.
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
This DCO text is licensed under the terms of the Creative Commons
Attribution-ShareAlike 3.0 Unported license.
+9
View File
@@ -5,5 +5,14 @@ the associated recipe (.bb file) unless otherwise stated.
See: See:
COPYING.MIT (MIT) COPYING.MIT (MIT)
The code in this repository that is not layer metadata is MIT licensed:
forgetest/ the release acceptance tool (see forgetest/LICENSE)
scripts/ the release, install and bench scripts
fixture/ the bench actuator firmware, excluding
fixture/managed_components/, which carries its own licenses
Every file in those directories states MIT in its own header.
License information for any other files is either explicitly stated License information for any other files is either explicitly stated
or defaults to GPL version 2. or defaults to GPL version 2.
+3 -2
View File
@@ -3,7 +3,7 @@
> ### BETA > ### BETA
> >
> **ForgeFIRM is in beta.** Every release below 0.1.0 is a beta release. > **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 > release is available, and report what you find on the
> [community forum](https://community.openglow.org). > [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 **This is experimental software. Use of it could seriously maim or kill you or
others, and it may void your warranty. Use it at your own risk.** others, and it may void your warranty. Use it at your own risk.**
This project is not affiliated with or endorsed by Glowforge. Glowforge is a trademark of Glowforge, Inc. This project is not affiliated
with, authorized by, or endorsed by Glowforge, Inc.
+4
View File
@@ -1,4 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
# forgefixture: build, flash and watch the bench actuator's firmware. # forgefixture: build, flash and watch the bench actuator's firmware.
# #
# ./fixture.sh env fixture.env from the example, with a fresh API key # ./fixture.sh env fixture.env from the example, with a fresh API key
+5
View File
@@ -1,3 +1,8 @@
/*
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*/
/* forgefixture firmware version, reported by GET /. */ /* forgefixture firmware version, reported by GET /. */
#pragma once #pragma once
+4
View File
@@ -1,4 +1,8 @@
#!/bin/sh #!/bin/sh
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
# The host test of the fixture's policy: needs only a C compiler. # The host test of the fixture's policy: needs only a C compiler.
set -e set -e
cd "$(dirname "$0")" cd "$(dirname "$0")"
+23
View File
@@ -0,0 +1,23 @@
ForgeFIRM release acceptance tool (forgetest/) and the bench scripts it
drives (scripts/bench/).
Copyright 2026 514 LLC d/b/a OpenGlow
Written by Scott Wiederhold
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -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
+6 -2
View File
@@ -1,4 +1,8 @@
#!/bin/sh #!/bin/sh
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
### BEGIN INIT INFO ### BEGIN INIT INFO
# Provides: forgetest # Provides: forgetest
# Required-Start: $network forgectrl # Required-Start: $network forgectrl
@@ -22,13 +26,13 @@ case "$1" in
start) start)
echo "Starting forgetest" echo "Starting forgetest"
mkdir -p "$DATA" mkdir -p "$DATA"
# The bench seed: the commissioning override lifts the wizard part of # The bench seed: the setup override lifts the wizard part of
# the controller gate until the next reboot (/run is tmpfs), so a # the controller gate until the next reboot (/run is tmpfs), so a
# campaign runs on a machine whose required wizards are not complete. # campaign runs on a machine whose required wizards are not complete.
# Nothing lifts the agreements or the account: the bench accepts them # Nothing lifts the agreements or the account: the bench accepts them
# once, and that stays in /data. # once, and that stays in /data.
mkdir -p /run/forgefirm mkdir -p /run/forgefirm
: > /run/forgefirm/commissioning-override : > /run/forgefirm/setup-override
start-stop-daemon -S -q -p $PIDFILE -m -b -x /bin/sh -- -c \ start-stop-daemon -S -q -p $PIDFILE -m -b -x /bin/sh -- -c \
"exec /usr/bin/python3 -m forgetest >> $LOG 2>&1" "exec /usr/bin/python3 -m forgetest >> $LOG 2>&1"
;; ;;
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""forgetest - the ForgeFIRM release acceptance tool. """forgetest - the ForgeFIRM release acceptance tool.
A daemon on the dev image (HTTP :8090) that runs the acceptance catalog A daemon on the dev image (HTTP :8090) that runs the acceptance catalog
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""forgetest daemon entry point. """forgetest daemon entry point.
python3 -m forgetest [--port 8090] [--host ::] [--manifest PATH] python3 -m forgetest [--port 8090] [--host ::] [--manifest PATH]
+12 -1
View File
@@ -1,5 +1,15 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The release artifact (acceptance.json / acceptance.md) and its verification. """The release artifact (acceptance.json / acceptance.md) and its verification.
Every test record is redacted before it enters the artifact (redact.py), so
the committed file carries no identity of the machine that ran the campaign.
It happens inside build(), ahead of the self-hash, because verify() rejects a
file edited after the fact. The gate reads none of the redacted fields.
The exporter serializes the campaign state with, for every catalog test, The exporter serializes the campaign state with, for every catalog test,
the winning result record (same-campaign PASS or inherited PASS, with its the winning result record (same-campaign PASS or inherited PASS, with its
origin) and the fingerprint it was recorded under. The gate origin) and the fingerprint it was recorded under. The gate
@@ -15,6 +25,7 @@ from . import VERSION
from . import campaign as _campaign from . import campaign as _campaign
from . import manifest as _manifest from . import manifest as _manifest
from .log import now_ts from .log import now_ts
from .redact import scrub
FORMAT = 1 FORMAT = 1
_RESULT_KEYS = ("ts", "campaign", "test", "result", "fingerprint", "manifest_sha", "image", _RESULT_KEYS = ("ts", "campaign", "test", "result", "fingerprint", "manifest_sha", "image",
@@ -47,7 +58,7 @@ def build(state, tests, manifest, records, catalog_hash):
"fingerprint": st["fingerprint"], "fingerprint": st["fingerprint"],
"satisfied": st["satisfied"], "satisfied": st["satisfied"],
"inherited": st["status"] == "inherited", "inherited": st["status"] == "inherited",
"record": _record(rec) if rec else None, "record": scrub(_record(rec)) if rec else None,
}) })
tests_out.append(entry) tests_out.append(entry)
+156 -23
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The fresh-boot idle state: what every test starts from and leaves behind. """The fresh-boot idle state: what every test starts from and leaves behind.
The runner checks the machine against this baseline before a run and The runner checks the machine against this baseline before a run and
@@ -36,6 +41,7 @@ that made it, not a reason to hand the dirt on.
import json import json
import os import os
import struct import struct
import subprocess
import time import time
from . import hw from . import hw
@@ -48,7 +54,7 @@ from .log import now_ts
# in the pulse path (a job's Z moves the lens; the driver's Z soft limit # in the pulse path (a job's Z moves the lens; the driver's Z soft limit
# guards it), step_freq its machine tick and ramp_rate the kernel stop # guards it), step_freq its machine tick and ramp_rate the kernel stop
# ramp, both derived from the XY microstep mode (the values here are the # ramp, both derived from the XY microstep mode (the values here are the
# x8 default's; fixed_sysfs() gives the mode's); streaming is only ever 1 # x8 base; fixed_sysfs() gives the mode's); streaming is only ever 1
# inside a live job; the head white LED is a camera lamp, off at idle; the # inside a live job; the head white LED is a camera lamp, off at idle; the
# loop heater and TEC are the diagnostics' tools, off at idle. # loop heater and TEC are the diagnostics' tools, off at idle.
FIXED_SYSFS = [ FIXED_SYSFS = [
@@ -111,6 +117,12 @@ UNPRESERVED_SETTINGS = ("controller_mode",)
# service starts its own client with the hunt. # service starts its own client with the hunt.
NOHUNT_MARKER = os.environ.get("FORGETEST_NOHUNT_MARKER", "/run/gfcloud-nohunt") 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 # Read-only readbacks with their idle values (no direct restore: the state
# comes right through forgectrl - see restore_forgectrl - or is fatal). # comes right through forgectrl - see restore_forgectrl - or is fatal).
IDLE_READBACKS = [ IDLE_READBACKS = [
@@ -133,6 +145,7 @@ BOOT_MAX_AGE_S = 600 # a boot reference is taken only this soon a
SETTLE_S = 150 # the supervisor's probe + rail-off ladder SETTLE_S = 150 # the supervisor's probe + rail-off ladder
CAM_IDLE_S = 20 # camera engine idle stop is 10 s CAM_IDLE_S = 20 # camera engine idle stop is 10 s
COOL_IDLE_S = 120 # cooldown after motion COOL_IDLE_S = 120 # cooldown after motion
COOL_PUBLISH_S = 2.5 # the engine publishes once a tick (1 Hz): two ticks and a margin
IDLE_S = 30 # cnc/state back to idle after a job IDLE_S = 30 # cnc/state back to idle after a job
GRBL_PORT_S = 30 # the Grbl port after the supervisor reports grblHAL running GRBL_PORT_S = 30 # the Grbl port after the supervisor reports grblHAL running
@@ -147,11 +160,12 @@ RETURN_MAX_MM = 100.0 # a displaced head is jogged back at most th
POSITION_DEADBAND_MM = 0.1 POSITION_DEADBAND_MM = 0.1
# The XY microstep mode (the xy_microsteps setting: 8, 16 or 32; unset = # The XY microstep mode (the xy_microsteps setting: 8, 16 or 32; unset =
# 8). The GRBL controller reads it at its start and derives its scale, # 32). The GRBL controller reads it at its start and derives its scale,
# its machine tick and the kernel stop ramp from it (boards/glowforge.h): # its machine tick and the kernel stop ramp from it (boards/glowforge.h):
# the fixed values of x/y_mode, step_freq and ramp_rate are the mode's. # the fixed values of x/y_mode, step_freq and ramp_rate are the mode's.
XY_MODES = (8, 16, 32) XY_MODES = (8, 16, 32)
XY_MODE_DEFAULT = 8 XY_MODE_BASE = 8 # the factory x8 reference the tick and ramp scale from
XY_MODE_DEFAULT = 32 # the mode an unset or invalid setting reads as
XY_STEPS_PER_MM_OF = {8: 53.333, 16: 106.667, 32: 213.333} # $100/$101 per mode XY_STEPS_PER_MM_OF = {8: 53.333, 16: 106.667, 32: 213.333} # $100/$101 per mode
XY_TICK_X8_HZ = 28160 # the x8 machine tick XY_TICK_X8_HZ = 28160 # the x8 machine tick
XY_RAMP_X8_HZ_PER_S = 125000 # the kernel stop ramp at it XY_RAMP_X8_HZ_PER_S = 125000 # the kernel stop ramp at it
@@ -175,7 +189,7 @@ def fixed_sysfs(mode=XY_MODE_DEFAULT):
"""FIXED_SYSFS with the mode's own x/y_mode, step_freq and ramp_rate: """FIXED_SYSFS with the mode's own x/y_mode, step_freq and ramp_rate:
the tick and the ramp scale with the mode (x16 doubles both, x32 the tick and the ramp scale with the mode (x16 doubles both, x32
quadruples them).""" quadruples them)."""
k = mode // XY_MODE_DEFAULT k = mode // XY_MODE_BASE
own = {"cnc/x_mode": str(mode), "cnc/y_mode": str(mode), own = {"cnc/x_mode": str(mode), "cnc/y_mode": str(mode),
"cnc/step_freq": str(XY_TICK_X8_HZ * k), "cnc/step_freq": str(XY_TICK_X8_HZ * k),
"cnc/ramp_rate": str(XY_RAMP_X8_HZ_PER_S * k)} "cnc/ramp_rate": str(XY_RAMP_X8_HZ_PER_S * k)}
@@ -207,6 +221,23 @@ def write_led(name, value):
f.write(str(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(): def read_position():
"""(x, y, z) step counters, or None when unreadable.""" """(x, y, z) step counters, or None when unreadable."""
try: try:
@@ -217,17 +248,25 @@ def read_position():
return None return None
def position_quantized(was, now): def counter_steps_per_mm():
"""The scale of the X/Y position counters: the kernel counts the
microsteps of the mode it runs (cnc/x_mode), so x16 and x32 count two
and four times the x8 steps for the same millimeter. The x8 scale when
the attribute is unreadable."""
return XY_STEPS_PER_MM_OF.get(hw.sysfs_int("cnc/x_mode"), XY_STEPS_PER_MM)
def position_quantized(was, now, spm=XY_STEPS_PER_MM):
"""True when two step-counter readings differ by no more than the """True when two step-counter readings differ by no more than the
dead band on X and Y and not at all on Z: the step quantization of a dead band on X and Y and not at all on Z: the step quantization of a
move that landed where it meant to, rather than a leftover. A cancel move that landed where it meant to, rather than a leftover. A cancel
that returns the head to the job start lands within a few hundredths that returns the head to the job start lands within a few hundredths
of a millimeter, which is a step or four, and whether that rounds to of a millimeter, which is a step or four, and whether that rounds to
the same integer is chance.""" the same integer is chance. spm is the counters' scale (the mode's)."""
if was is None or now is None or now[2] != was[2]: if was is None or now is None or now[2] != was[2]:
return False return False
return (abs(now[0] - was[0]) / XY_STEPS_PER_MM <= POSITION_DEADBAND_MM and return (abs(now[0] - was[0]) / spm <= POSITION_DEADBAND_MM and
abs(now[1] - was[1]) / XY_STEPS_PER_MM <= POSITION_DEADBAND_MM) abs(now[1] - was[1]) / spm <= POSITION_DEADBAND_MM)
def read_ring_residue(): def read_ring_residue():
@@ -257,6 +296,11 @@ def read_program_total():
return None 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: class Leftover:
def __init__(self, item, found, expected, action): def __init__(self, item, found, expected, action):
self.item = item self.item = item
@@ -321,7 +365,7 @@ class Baseline:
def wait_settled(self, timeout=SETTLE_S, unreachable_s=10): def wait_settled(self, timeout=SETTLE_S, unreachable_s=10):
"""Block until forgectrl reports a settled supervisor: motion """Block until forgectrl reports a settled supervisor: motion
verified (the probe passed), motion-fault (the ladder exhausted), verified (the probe passed), motion-fault (the ladder exhausted),
standby (the manual stop lever), or gated (the commissioning gate standby (the manual stop lever), or gated (the setup gate
is closed: no controller spawns until it opens). Gives up after is closed: no controller spawns until it opens). Gives up after
unreachable_s without an answer. Returns the last /mode body (None unreachable_s without an answer. Returns the last /mode body (None
if unreachable).""" if unreachable)."""
@@ -430,7 +474,8 @@ class Baseline:
# -- capture ------------------------------------------------------- # -- capture -------------------------------------------------------
def capture(self): def capture(self):
"""Record the preserved state before a run.""" """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: for attr in PRESERVED_SYSFS:
cap["sysfs"][attr] = hw.sysfs_read(attr) cap["sysfs"][attr] = hw.sysfs_read(attr)
st, body = self.fc_get("/settings") st, body = self.fc_get("/settings")
@@ -450,9 +495,13 @@ class Baseline:
preserved state to hand back (post) - None compares nothing.""" preserved state to hand back (post) - None compares nothing."""
left = [] left = []
self.mode = None self.mode = None
# the counters' scale is the mode the run counted under, read
# before the kernel side puts the settings' mode back
self._counter_spm = counter_steps_per_mm()
self._forgectrl_side(left, captured) self._forgectrl_side(left, captured)
self._kernel_side(left) self._kernel_side(left)
self._lamp_side(left) self._lamp_side(left)
self._ext_side(left)
self._preserved(left, captured) self._preserved(left, captured)
if left: if left:
self.log("%s: %d leftover(s): %s" % (phase, len(left), "; ".join(str(x) for x in left))) self.log("%s: %d leftover(s): %s" % (phase, len(left), "; ".join(str(x) for x in left)))
@@ -596,13 +645,37 @@ class Baseline:
except OSError as e: except OSError as e:
act = "failed: %s" % e act = "failed: %s" % e
left.append(Leftover("laser_locked", False, True, act)) left.append(Leftover("laser_locked", False, True, act))
# cooling engine idle, unarmed self._cool_side(left)
def _cool_side(self, left):
"""The cooling engine idle, unarmed, holding nothing."""
st, c = self.fc_get("/cool/status") st, c = self.fc_get("/cool/status")
if st == 200 and isinstance(c, dict): if st != 200 or not isinstance(c, dict):
if c.get("phase") != "idle" or c.get("armed") or c.get("hold"): return
if c.get("phase") == "idle" and not c.get("armed") and not c.get("hold"):
return
found = "%s/armed=%s/hold=%s" % (c.get("phase"), c.get("armed"), c.get("hold")) found = "%s/armed=%s/hold=%s" % (c.get("phase"), c.get("armed"), c.get("hold"))
idle = lambda: (lambda x: x.get("phase") == "idle" and not x.get("armed") # noqa: E731 idle = lambda: (lambda x: x.get("phase") == "idle" and not x.get("armed") # noqa: E731
and not x.get("hold"))(self.fc_get("/cool/status")[1] or {}) and not x.get("hold"))(self.fc_get("/cool/status")[1] or {})
# The engine publishes once a tick (1 Hz). A read inside the tick
# after a run ended still shows the run: a diagnostic's hold until
# the engine has the hardware back, a fail tier's hold until the
# session ends with the controller. What the engine's next tick
# clears was never left behind, so an arm or a hold is read again
# past that tick before it is called the run's doing. One that a
# run did leave stands for a job that is never coming back, and it
# is still there.
if c.get("armed") or c.get("hold"):
free = lambda: (lambda x: bool(x) and not x.get("armed") # noqa: E731
and not x.get("hold"))(self.fc_get("/cool/status")[1] or {})
if self._wait("cool publish", free, COOL_PUBLISH_S) is not None:
st, c2 = self.fc_get("/cool/status")
c = c2 if st == 200 and isinstance(c2, dict) else {}
self.log("cool: %s was the engine's last word on the run; its next tick reads "
"%s/armed=%s/hold=%s" % (found, c.get("phase"), c.get("armed"), c.get("hold")))
if c.get("phase") == "idle":
return
found = "%s/armed=%s/hold=%s" % (c.get("phase"), c.get("armed"), c.get("hold"))
# Armed, or holding, is the run's doing. A phase alone is # Armed, or holding, is the run's doing. A phase alone is
# not: the engine clears smoke at run duty after an armed # not: the engine clears smoke at run duty after an armed
# session and cools down after a hot one, both timed and # session and cools down after a hot one, both timed and
@@ -614,7 +687,7 @@ class Baseline:
if w is not None and not dirt: if w is not None and not dirt:
self.log("cool: %s ended on its own after %.0f s; the engine's own post-job " self.log("cool: %s ended on its own after %.0f s; the engine's own post-job "
"work, not a leftover" % (found, w)) "work, not a leftover" % (found, w))
else: return
# The engine holds for a job. A run that ended without # The engine holds for a job. A run that ended without
# ending its job leaves one alive, and no amount of # ending its job leaves one alive, and no amount of
# waiting ends it: stand the machine down and let the # waiting ends it: stand the machine down and let the
@@ -627,6 +700,37 @@ class Baseline:
left.append(Leftover("cool", found, "idle/unarmed/no hold", left.append(Leftover("cool", found, "idle/unarmed/no hold",
act 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): def _lamp_side(self, left):
"""The lid lamp at forgectrl's idle level (the lid_lamp_idle setting). """The lid lamp at forgectrl's idle level (the lid_lamp_idle setting).
In cloud mode the cloud client owns the lamp (its lid-image level).""" In cloud mode the cloud client owns the lamp (its lid-image level)."""
@@ -693,13 +797,14 @@ class Baseline:
self.log("leds/%s: target 0, brightness %s; the fade from a level the machine " self.log("leds/%s: target 0, brightness %s; the fade from a level the machine "
"itself ended, not a leftover" % (name, read_led(name, "brightness"))) "itself ended, not a leftover" % (name, read_led(name, "brightness")))
def _return_head(self, was, now): def _return_head(self, was, now, spm=XY_STEPS_PER_MM):
"""Jog the head back along its own path by the kernel-measured X/Y """Jog the head back along its own path by the kernel-measured X/Y
delta (Z is never touched), through the GRBL controller. Bounded: delta (Z is never touched), through the GRBL controller. spm is
beyond RETURN_MAX_MM per axis, or without a running GRBL the counters' scale (the mode's). Bounded: beyond RETURN_MAX_MM
controller, the counters are reported and left.""" per axis, or without a running GRBL controller, the counters are
dx = (now[0] - was[0]) / XY_STEPS_PER_MM reported and left."""
dy = (now[1] - was[1]) / XY_STEPS_PER_MM dx = (now[0] - was[0]) / spm
dy = (now[1] - was[1]) / spm
if abs(dx) < 0.02 and abs(dy) < 0.02: if abs(dx) < 0.02 and abs(dy) < 0.02:
return "unrestorable (Z only)" if now[2] != was[2] else "restored" return "unrestorable (Z only)" if now[2] != was[2] else "restored"
if abs(dx) > RETURN_MAX_MM or abs(dy) > RETURN_MAX_MM: if abs(dx) > RETURN_MAX_MM or abs(dy) > RETURN_MAX_MM:
@@ -786,13 +891,41 @@ class Baseline:
# action re-zeroes them at its start, so they preserve nothing. # action re-zeroes them at its start, so they preserve nothing.
was = captured.get("position") was = captured.get("position")
now = read_position() now = read_position()
if was is not None and now is not None and now != was and not self.cloud_mode(): # A controller start or a home during the run re-zeroed the counters
act = self._return_head(was, now) # wherever the head then stood. Unless the test said where that was
if position_quantized(was, now): # (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, " self.log("position: %s (expected %s) -> %s; inside the %.2f mm dead band, "
"not a leftover" % (now, was, act, POSITION_DEADBAND_MM)) "not a leftover" % (now, was, act, POSITION_DEADBAND_MM))
else: else:
left.append(Leftover("position", now, was, act)) 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") was = captured.get("settings")
if was: if was:
st, body = self.fc_get("/settings") st, body = self.fc_get("/settings")
+63
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The bench diagnostics page: registry of the bench tools and the """The bench diagnostics page: registry of the bench tools and the
subprocess runner behind the #bench tab. subprocess runner behind the #bench tab.
@@ -252,6 +257,64 @@ TOOLS = [
"card's stops: the ends of the reach run, two half-steps past either end alarms. A CI harness " "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 " "(the grblHAL repo): needs the host-built null-sink controller, not the machine, so it is not a "
"bench-page tool."}, "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", {"id": "raster-dry", "title": "Dry top-speed raster per XY microstep mode", "script": "raster_dry.py",
"safety": "dry", "where": "board", "ported": True, "safety": "dry", "where": "board", "ported": True,
"args": [_arg("modes", "choice", "8 16 32", "the modes to run, in order", ["8", "16", "32", "8 16 32"])], "args": [_arg("modes", "choice", "8 16 32", "the modes to run, in order", ["8", "16", "32", "8 16 32"])],
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""Campaign rules: which results apply, what is required, and whether a """Campaign rules: which results apply, what is required, and whether a
release is authorized. release is authorized.
+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. """The acceptance catalog: test definitions and the registry.
A test is a function decorated with @test(...). The decorator records A test is a function decorated with @test(...). The decorator records
@@ -69,6 +74,10 @@ class Test:
that test's earlier passes and no other; a change to a helper 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 invalidates the tests of that module. A test the module does not
define in the @test form hashes its whole file.""" 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: if self._source_sha is None:
path = inspect.getsourcefile(self.fn) or inspect.getfile(self.fn) path = inspect.getsourcefile(self.fn) or inspect.getfile(self.fn)
self._source_sha = implementation_sha(path, self.id) self._source_sha = implementation_sha(path, self.id)
@@ -135,8 +144,38 @@ def source_file_sha(path):
return hashlib.sha256(data).hexdigest() return hashlib.sha256(data).hexdigest()
_PARTS = {} # path -> (shared sha, {test id: own sha}); the files never change under a run # The files never change under a run, so each is read and parsed once:
_PARTS_LOCK = threading.Lock() # 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): def module_parts(path):
@@ -149,14 +188,9 @@ def module_parts(path):
hit = _PARTS.get(path) hit = _PARTS.get(path)
if hit is not None: if hit is not None:
return hit return hit
with open(path, "rb") as f: text, tree = _parsed(path)
text = f.read().replace(b"\r\n", b"\n").decode("utf-8")
lines = text.split("\n") lines = text.split("\n")
spans = {} spans = {}
try:
tree = ast.parse(text)
except SyntaxError:
tree = None
for node in (tree.body if tree is not None else []): for node in (tree.body if tree is not None else []):
if not isinstance(node, ast.FunctionDef): if not isinstance(node, ast.FunctionDef):
continue continue
@@ -183,22 +217,30 @@ def sibling_imports(path, seen=None):
test's fingerprint.""" test's fingerprint."""
if seen is None: if seen is None:
seen = [] seen = []
with open(path, "rb") as f: for sib in _direct_siblings(path):
text = f.read().replace(b"\r\n", b"\n").decode("utf-8") if sib not in seen and sib != path:
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:
seen.append(sib) seen.append(sib)
sibling_imports(sib, seen) sibling_imports(sib, seen)
return 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): def implementation_sha(path, test_id):
"""The implementation hash of one test (see Test.source_sha): the """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 test's own text, the shared text of its module, and the shared text
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""Coverage lint: every source path in the manifest must be selected by """Coverage lint: every source path in the manifest must be selected by
some test's coverage globs, except the allowlisted non-behavioral paths, some test's coverage globs, except the allowlisted non-behavioral paths,
and every coverage glob must select at least one path of its component. and every coverage glob must select at least one path of its component.
+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 """The bench actuator (forgefixture): the box on the bench network that
opens the lid loop, pulls the interlock loop and presses the button on opens the lid loop, pulls the interlock loop and presses the button on
request, so an operator test can run with nobody in the room. request, so an operator test can run with nobody in the room.
@@ -292,11 +297,11 @@ class Fixture:
return bool(st and st.get("button_enabled")) return bool(st and st.get("button_enabled"))
return True return True
def act(self, channel, state): def act(self, channel, state, ms=None):
if channel == "button": if channel == "button":
if state != "press": if state != "press":
raise FixtureError("the button is only ever pressed") raise FixtureError("the button is only ever pressed")
st, body = self._press() st, body = self._press(ms)
else: else:
if state not in ("open", "close"): if state not in ("open", "close"):
raise FixtureError("%s: unknown state %r" % (channel, state)) raise FixtureError("%s: unknown state %r" % (channel, state))
@@ -307,8 +312,11 @@ class Fixture:
self.last_state = body self.last_state = body
return body return body
def _press(self): def _press(self, ms=None):
"""One press, spaced from the last one. The controller detects """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 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 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 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() wait = self._pulse_end + BUTTON_GAP_S - time.time()
if wait > 0: if wait > 0:
time.sleep(wait) 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", "")): if st == 409 and "in progress" in str(body.get("error", "")):
deadline = time.time() + PULSE_WAIT_S deadline = time.time() + PULSE_WAIT_S
while time.time() < deadline: while time.time() < deadline:
@@ -326,7 +334,7 @@ class Fixture:
if s == 200 and not b.get("button_pulsing"): if s == 200 and not b.get("button_pulsing"):
break break
time.sleep(BUTTON_GAP_S) 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: if st == 200:
self._pulse_end = time.time() + float(body.get("pulse_ms") or 500) / 1000.0 self._pulse_end = time.time() + float(body.get("pulse_ms") or 500) / 1000.0
return st, body return st, body
+77
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""Hardware and service access for the suite: forgectrl's HTTP API, the """Hardware and service access for the suite: forgectrl's HTTP API, the
kernel module's sysfs, the init scripts, and the Grbl TCP port. kernel module's sysfs, the init scripts, and the Grbl TCP port.
@@ -319,6 +324,40 @@ def initd(service, action, timeout=60):
return p.returncode, p.stdout.decode("utf-8", "replace") 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): def pidof(comm):
"""PIDs whose /proc/<pid>/comm equals comm (15-char kernel limit applies).""" """PIDs whose /proc/<pid>/comm equals comm (15-char kernel limit applies)."""
out = [] out = []
@@ -356,6 +395,44 @@ def pgrep_f(needle):
return out 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): def run(cmd, timeout=60):
"""Run a command list; returns (rc, combined output).""" """Run a command list; returns (rc, combined output)."""
try: 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. """The append-only result log (JSONL) and the export helpers.
One JSON object per line under the data directory (default One JSON object per line under the data directory (default
+30 -4
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The image manifest and the domain fingerprint. """The image manifest and the domain fingerprint.
/etc/forgefirm-manifest.json (written by forgefirm-image-manifest.bbclass) /etc/forgefirm-manifest.json (written by forgefirm-image-manifest.bbclass)
@@ -88,10 +93,27 @@ NON_BEHAVIORAL = [
("*", ".vscode/**"), ("*", ".vscode/**"),
("*", ".env.example"), ("*", ".env.example"),
("forgectrl", "tools/**"), # host-side dev tools (panel dev server) ("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/**"),
] ]
def non_behavioral(comp, path, allow=NON_BEHAVIORAL): 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: for c, pat in allow:
if c in ("*", comp) and glob_to_regex(pat).match(path): if c in ("*", comp) and glob_to_regex(pat).match(path):
return True return True
@@ -198,7 +220,8 @@ def coverage_report(manifest, tests, allow=NON_BEHAVIORAL):
"""Which manifest paths no test covers. """Which manifest paths no test covers.
tests: iterable with .covers. allow: iterable of (component, glob) 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. Returns
{component: [uncovered paths]} for the non-dev-only components. {component: [uncovered paths]} for the non-dev-only components.
""" """
covered = {} covered = {}
@@ -212,11 +235,14 @@ def coverage_report(manifest, tests, allow=NON_BEHAVIORAL):
for comp in manifest.component_names(): for comp in manifest.component_names():
if comp in DEV_ONLY_COMPONENTS: if comp in DEV_ONLY_COMPONENTS:
continue continue
rxs = covered.get(comp, []) + allowed.get(comp, []) rxs = covered.get(comp, [])
star = allowed.get("*", []) free = allowed.get(comp, []) + allowed.get("*", [])
behavior = [glob_to_regex(pat) for c, pat in BEHAVIORAL if c == comp]
missing = [] missing = []
for p, _b in manifest.files(comp): 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 continue
missing.append(p) missing.append(p)
if missing: 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 """The single page: acceptance tab + bench tab, assembled from ui/ into
one self-contained response (no external assets). Bootstrap and the one self-contained response (no external assets). Bootstrap and the
OpenGlow theme are inlined; theme.css and the vendor/ files are the same OpenGlow theme are inlined; theme.css and the vendor/ files are the same
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""Pulse files for the offline cloud service: a job the machine will run """Pulse files for the offline cloud service: a job the machine will run
without the service having cut it. without the service having cut it.
+135
View File
@@ -0,0 +1,135 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""Redaction for the release artifact.
The artifact is committed to a public repository, so it carries no
identity of the machine that ran the campaign. This applies the same two
layers forgectrl's log export applies (forgectrl/src/sanitize.c), in the
same order, to every value the exporter writes:
1. Known keys: a value held under a key that names identifying data -
the serial, the machine id, the hostname, the wireless credentials,
a token - is replaced whole. Naming the key is far more reliable
than guessing the shape of what is under it.
2. Pattern classes over every remaining string: MAC addresses, IPv4 and
IPv6 addresses (loopback and unspecified kept), e-mail addresses,
bearer and basic credentials, JWTs, and long hex blobs. Each distinct
value in a class keeps a stable number for the life of one export, so
"<IP-2>" is the same host wherever it appears and the artifact stays
readable as evidence.
Integrity values are never touched: the self-hash, the manifest and
catalog hashes, each test's source hash and fingerprint. Those are what
the gate recomputes, and a long hex blob is exactly what they look like.
Over-redaction is accepted where the alternative is a leak.
"""
import re
# Layer 1: the value under one of these keys is replaced whole.
REDACT_KEYS = frozenset((
"gf_serial", "machine_id", "serial", "serial_number",
"hostname", "hostname_file", "host",
"ssid", "psk", "passphrase", "password", "passwd",
"token", "panel_token", "secret", "api_key", "key",
"mac", "macaddr", "mac_suffix", "wlan0", "eth0",
"gf_username", "gf_password", "username", "user", "email",
))
# Never touched, by key: the gate recomputes these and they look like
# exactly what layer 2 hunts for.
KEEP_KEYS = frozenset((
"sha256", "manifest_sha", "identity_sha", "catalog_hash",
"source_sha", "fingerprint", "content_sha", "srcrev",
))
_KEEP_IP = frozenset(("0.0.0.0", "255.255.255.255"))
_CLASSES = (
("SECRET", re.compile(r"(?i)\b(?:bearer|basic)\s+[A-Za-z0-9._~+/=-]{8,}")),
("JWT", re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}")),
("EMAIL", re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")),
("MAC", re.compile(r"\b(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}\b")),
# Three or more colons, or a "::" run: two colons is a clock time, and
# tagging every timestamp would make the evidence unreadable.
("IP6", re.compile(
r"(?:[0-9A-Fa-f]{1,4}:){3,7}[0-9A-Fa-f]{1,4}"
r"|[0-9A-Fa-f]{0,4}(?::[0-9A-Fa-f]{1,4})*::(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?")),
("IP", re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")),
("HEX", re.compile(r"\b[0-9A-Fa-f]{32,}\b")),
)
class Redactor:
"""Stable per-export numbering, one instance per artifact."""
# A known value shorter than this is too generic to replace blind.
MIN_KNOWN = 4
def __init__(self):
self._n = {}
self._known = {}
def learn(self, obj, key=None):
"""Collect the values held under the layer-1 keys, so they are
replaced wherever else they appear - a hostname in a log line is
the same leak as a hostname in its own field."""
if key is not None and key in KEEP_KEYS:
return
if isinstance(obj, dict):
for k, v in obj.items():
self.learn(v, k)
elif isinstance(obj, list):
for v in obj:
self.learn(v)
elif key in REDACT_KEYS and isinstance(obj, str) and len(obj) >= self.MIN_KNOWN:
if obj not in self._known:
self._known[obj] = self._tag("REDACTED", "%s=%s" % (key, obj))
def _tag(self, cls, value):
seen = self._n.setdefault(cls, {})
if value not in seen:
seen[value] = len(seen) + 1
return "<%s-%d>" % (cls, seen[value])
def text(self, s):
for known in sorted(self._known, key=len, reverse=True):
if known in s:
s = s.replace(known, self._known[known])
for cls, rx in _CLASSES:
def sub(m, cls=cls):
v = m.group(0)
if cls == "IP" and (v.startswith("127.") or v in _KEEP_IP):
return v
if cls == "IP6" and v in ("::", "::1"):
return v
return self._tag(cls, v)
s = rx.sub(sub, s)
return s
def value(self, v, key=None):
if key is not None and key in KEEP_KEYS:
return v
if key is not None and key in REDACT_KEYS and not isinstance(v, (dict, list)):
if v is None:
return None
if isinstance(v, str) and v in self._known:
return self._known[v]
return self._tag("REDACTED", "%s=%s" % (key, v))
if isinstance(v, dict):
return {k: self.value(x, k) for k, x in v.items()}
if isinstance(v, list):
return [self.value(x) for x in v]
if isinstance(v, str):
return self.text(v)
return v
def scrub(obj):
"""Redact a value tree with one export's numbering."""
r = Redactor()
r.learn(obj)
return r.value(obj)
+172 -5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The runner: executes one acceptance test or one bench tool at a time. """The runner: executes one acceptance test or one bench tool at a time.
Acceptance tests run in a worker thread with a Context: log lines, an Acceptance tests run in a worker thread with a Context: log lines, an
@@ -361,7 +366,7 @@ class Context:
"""One of forgectrl's switch readings (lid, interlock_ok, ...).""" """One of forgectrl's switch readings (lid, interlock_ok, ...)."""
return (self.forgectrl.status().get("switches") or {}).get(name) 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"), """A machine action by name: ("lid", "open"|"close"),
("interlock", "open"|"close"), ("button", "press"). The bench's ("interlock", "open"|"close"), ("button", "press"). The bench's
actuator performs it when one covers the channel (the runner's actuator performs it when one covers the channel (the runner's
@@ -400,7 +405,7 @@ class Context:
self.log("ACT %s %s (fixture)", channel, state) self.log("ACT %s %s (fixture)", channel, state)
rec["by"] = "fixture" rec["by"] = "fixture"
try: try:
fixture.act(channel, state) fixture.act(channel, state, ms)
except _fixture.FixtureError as e: except _fixture.FixtureError as e:
rec["fixture_error"] = str(e) rec["fixture_error"] = str(e)
if self.run.unattended: if self.run.unattended:
@@ -534,7 +539,7 @@ class Context:
return hw.Grbl() return hw.Grbl()
def takeover(self): 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): def counters_rezeroed(self):
"""Tell the baseline the kernel position counters were re-zeroed """Tell the baseline the kernel position counters were re-zeroed
@@ -544,6 +549,10 @@ class Context:
cap = self.run.baseline_captured cap = self.run.baseline_captured
if cap and cap.get("position") is not None: if cap and cap.get("position") is not None:
cap["position"] = [0, 0, 0] 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 " self.log("position counters re-zeroed at the starting position; the baseline "
"expects (0,0,0) at the end") "expects (0,0,0) at the end")
@@ -573,11 +582,16 @@ class Takeover:
"cnc/x_mode", "cnc/y_mode", "cnc/x_decay", "cnc/y_decay", "cnc/x_mode", "cnc/y_mode", "cnc/x_decay", "cnc/y_decay",
"pic/x_step_current", "pic/y_step_current") "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.log = log # callable(str)
self.who = who self.who = who
self.marker = marker_path() self.marker = marker_path()
self.saved = {} 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): def wait_settled(self):
return _baseline.Baseline(self.log).wait_settled() return _baseline.Baseline(self.log).wait_settled()
@@ -604,6 +618,18 @@ class Takeover:
self.saved[attr] = v self.saved[attr] = v
if self.saved: if self.saved:
log("takeover: preserving %s" % ", ".join("%s=%s" % kv for kv in self.saved.items())) 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") log("takeover: stopping the controller through forgectrl")
try: try:
st, body = hw.Forgectrl().post("/controller/stop") st, body = hw.Forgectrl().post("/controller/stop")
@@ -626,10 +652,146 @@ class Takeover:
self.__exit__(None, None, None) self.__exit__(None, None, None)
raise Failed("takeover: processes still alive after stop: %s" % left) raise Failed("takeover: processes still alive after stop: %s" % left)
log("takeover: pulse device free") 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 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): 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.restore_attrs()
self.markers_on()
rc, out = hw.initd("forgectrl", "start") rc, out = hw.initd("forgectrl", "start")
self.log("takeover: forgectrl start -> rc %s" % rc) self.log("takeover: forgectrl start -> rc %s" % rc)
try: try:
@@ -638,7 +800,12 @@ class Takeover:
pass pass
# leave the machine settled for whatever runs next: the probe # leave the machine settled for whatever runs next: the probe
# move done, the controller back (or the ladder's verdict logged) # 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 return False
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""HTTP: the page, the JSON API, and the access rules. """HTTP: the page, the JSON API, and the access rules.
Access follows forgectrl's panel (auth.c): the Host header must be an Access follows forgectrl's panel (auth.c): the Host header must be an
+25 -3
View File
@@ -1,16 +1,38 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The acceptance suite: one module per subsystem, imported in display """The acceptance suite: one module per subsystem, imported in display
order. Each module registers its tests with @catalog.test.""" order. Each module registers its tests with @catalog.test."""
from . import image # noqa: F401,E402 from . import image # noqa: F401,E402
from . import kernel # noqa: F401,E402 from . import kernel # noqa: F401,E402
from . import forgectrl # noqa: F401,E402 from . import forgectrl # noqa: F401,E402
from . import commission # noqa: F401,E402 from . import setup # noqa: F401,E402
from . import commission_dark # noqa: F401,E402 from . import setup_dark # noqa: F401,E402
from . import commission_sheet # noqa: F401,E402 from . import setup_sheet # noqa: F401,E402
from . import logs # noqa: F401,E402 from . import logs # noqa: F401,E402
from . import motion # noqa: F401,E402 from . import motion # noqa: F401,E402
from . import cooling # noqa: F401,E402 from . import cooling # noqa: F401,E402
from . import cooling_intake # noqa: F401,E402
from . import laser # noqa: F401,E402 from . import laser # noqa: F401,E402
from . import camera # noqa: F401,E402 from . import camera # noqa: F401,E402
from . import update # noqa: F401,E402 from . import update # noqa: F401,E402
from . import cloud # 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 extcore # noqa: F401,E402
from . import extcall # noqa: F401,E402
from . import extdest # noqa: F401,E402
from . import extlife # noqa: F401,E402
from . import evmore # noqa: F401,E402
from . import extcat # noqa: F401,E402
from . import extmcode # noqa: F401,E402
from . import extwizard # noqa: F401,E402
from . import updlock # noqa: F401,E402
from . import homeoff # noqa: F401,E402
from . import bedsize # noqa: F401,E402
from . import extsender # noqa: F401,E402
from . import tray # noqa: F401,E402
from . import tlsrec # noqa: F401,E402
+287
View File
@@ -0,0 +1,287 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The Setup page's bed check, on the machine.
Its own module, so that no other test's fingerprint moves. The check is
driven from here inside the travel the machine already knows: a few jogs
out from the camera home, never toward an end, so it needs no operator.
Measuring the real ends is the operator's own run of the check. The
homing is the web-service session homing.cloud-offsets runs.
"""
import json
import time
from ..catalog import test
from .cloud import _HOMING_PATH, gfhome_homing
from .homeoff import camera_home_return, judge_whole_motions, session_mark
from .setup import read_file, record_path, write_file
from .setup_dark import DARK_COVERS, answer, dark, start
WID = "motion.envelope"
END = "This is the end"
# The operator's answers, as the page sends them: out 60.1 mm in X and
# 61.1 mm in Y from the home, the fine steps included. A far edge is 50 mm
# at the least, so the ends are well past that and well inside the bed.
X_STEPS = ["X+10"] * 6 + ["X+1", "X+0.1", "X-1"]
Y_STEPS = ["Y+10"] * 6 + ["Y+1", "Y+0.1"]
X_OUT, Y_OUT = 60.1, 61.1
MARGIN_MM = 1.0
# The head is back on the home's step: half a step (213.3 steps/mm) and
# the port's three decimals.
HOME_TOL_MM = 0.003
KEYS =("envelope_x_mm", "envelope_y_mm", "homing_mode")
CLOSED = "closed the check's envelope"
CLOSED_MSG = "Bed check envelope closed: a sender line"
def _port(fc):
st, body = fc.get("/motion/state")
return body if st == 200 and isinstance(body, dict) else {}
def _drive(ctx, fc, x_steps, y_steps, hook=None, timeout=300):
"""Start the check and answer each prompt from the steps in turn, then
This is the end; hook(prompt_id, n) runs before the answer to the n-th
prompt. The final status document, and the answers given."""
# The daemon calls the machine busy for a moment after the last jog.
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "the machine is not idle: %s", fc.status().get("state"))
start(ctx, fc, WID)
queue = {"x-end": list(x_steps) + [END], "y-end": list(y_steps) + [END]}
seen, given = set(), []
t0 = time.time()
try:
while time.time() - t0 < timeout:
ctx.checkpoint()
d = dark(fc)
p = d.get("prompt")
if d.get("running") and p and p.get("seq") not in seen:
seen.add(p["seq"])
pid = p.get("id")
ctx.check(pid in queue and queue[pid], "the check asked %s: %s (the lid must be closed)",
pid, p.get("text"))
ctx.check(p.get("kind") == "jog" and END in (p.get("options") or []),
"the %s prompt is not a jog with %r: %s", pid, END, p)
if hook:
hook(pid, len(given))
value = queue[pid].pop(0)
st, body = answer(fc, WID, p, value)
ctx.check(st == 200, "the answer %s to %s -> %s %s", value, pid, st, body)
given.append(value)
if not d.get("running"):
return d, given
time.sleep(0.3)
except BaseException:
fc.post("/wiz/%s/abort" % WID)
raise
fc.post("/wiz/%s/abort" % WID)
ctx.fail("the bed check did not end within %d s", timeout)
def _jog(fc, dx=0.0, dy=0.0):
params = {"feed": "1200"}
if dx:
params["x"] = "%.3f" % dx
if dy:
params["y"] = "%.3f" % dy
return fc.post("/motion/jog", params=params)
def _settled(ctx, fc):
"""After the suite's Grbl client closes, the daemon calls the machine
busy for a moment: the check and a settings write each wait for it."""
ctx.wait_for(lambda: _port(fc).get("sender") is False, 20, poll=0.5)
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "the machine is not idle: %s", fc.status().get("state"))
def _put_back(ctx, fc, found):
for k in KEYS:
v = found.get(k) or ""
def put():
st_, body_ = fc.post("/settings", params={k: ""}) if v == "" else fc.post("/settings", data={k: v})
if st_ != 200:
ctx.log("restore %s=%r -> %s %s", k, v, st_, body_)
return st_ == 200 or None
took = ctx.wait_for(put, 20, poll=0.5)
ctx.log("restore %s=%r -> %s", k, v, "taken" if took is not None else "not taken")
def _back_home(ctx, fc, home):
"""The head back where the home put it: one jog to the home in machine
coordinates, from a Grbl client. A relative jog aimed at the home from
the port's position can be refused whole: the port reports the step
counters, the core adds a relative jog to its own position, which the
check's jogs leave up to half a step off the counters, and the envelope
begins at the home, so the target can land microns behind it (error:15).
Where the head stands after, from the port; None when unread."""
# 0.5 um inside an envelope that begins at the home (a camera home
# behind the origin), far under half a step: the head lands on the
# home's own step.
line = "$J=G90 G53 X%.4f Y%.4f F1200" % (home[0] + 0.0005, home[1] + 0.0005)
try:
with ctx.grbl() as g:
reply = g.command(line, timeout=10)
ctx.log("the jog back to the home: %s -> %s", line, " | ".join(reply))
if reply[-1:] == ["ok"]:
t0 = time.time()
ctx.wait_for(lambda: time.time() - t0 > 0.5 and g.status_report()["state"].startswith("Idle"),
60, poll=0.2)
except (OSError, ValueError) as e:
ctx.log("the jog back to the home: %s", e)
ctx.wait_for(lambda: _port(fc).get("sender") is False and _port(fc).get("state") == "Idle", 20, poll=0.5)
at = _port(fc).get("mpos")
ctx.log("the head after the jog back: %s (the home %s)", at, home)
return at
def _port_idle(ctx, fc):
ok = ctx.wait_for(lambda: (lambda s: s.get("state") == "Idle" and not s.get("port_jog"))(_port(fc)), 30,
poll=0.1)
ctx.check(ok is not None, "the port jog did not end: %s", _port(fc))
def _edge(ctx, fc, ev, axis, edge):
"""From where the head stands, a jog 0.5 mm short of the far edge runs
and comes back, and one 0.5 mm past it is refused with nothing moved."""
s = _port(fc)
at = s["mpos"]["XY".index(axis)]
short, past = edge - 0.5 - at, edge + 0.5 - at
kw = (lambda d: {"dx": d}) if axis == "X" else (lambda d: {"dy": d})
st1, b1 = _jog(fc, **kw(short))
_port_idle(ctx, fc)
st2, b2 = _jog(fc, **kw(-short))
_port_idle(ctx, fc)
st3, b3 = _jog(fc, **kw(past))
_port_idle(ctx, fc)
back = _port(fc)["mpos"]["XY".index(axis)]
ev["edge_" + axis] = {"edge": edge, "short": [st1, st2], "past": [st3, b3], "at": [at, back]}
ctx.check(st1 == 200 and st2 == 200, "a jog 0.5 mm short of the %s edge %.2f and back -> %s %s, %s %s",
axis, edge, st1, b1, st2, b2)
ctx.check(st3 == 409 and "envelope" in json.dumps(b3) and abs(back - at) < 0.02,
"a jog 0.5 mm past the %s edge %.2f -> %s %s, the head at %.3f from %.3f", axis, edge, st3, b3, back, at)
@test("setup.check-envelope", title="The bed check measures the far edges from the operator's jogs, and a Grbl "
"client's line ends it with nothing written",
subsystem="setup", kind="auto", mode="grbl", est_min=10,
covers=DARK_COVERS + _HOMING_PATH + [("forgectrl", "src/grblport.*"),
("grblhal-glowforge", "src/glowforge_homing.*"),
("grblhal-glowforge", "src/ctlport.*"),
("grblhal-glowforge", "src/driver.c")],
requires=["forgectrl.auth", "motion.pacing"],
steps=["Bed clear, lid closed; cloud credentials configured; the machine on the network. The head moves "
"at most about 62 mm out from the camera home in X and in Y, and ends where it was found."],
description="With cloud mode on, the machine is homed with the camera (the web-service session), every "
"service motion of it run whole. "
"POST /wiz/motion.envelope/start runs the Bed size check; its prompts are jogs with This is the "
"end among the options, and are answered as the page would, out 60.1 mm in X and 61.1 mm in Y "
"(steps of 10, 1, and 0.1 mm). The check ends complete: 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, without a home: 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 it "
"closed the envelope and then ok; the port's state then says the envelope is closed, and the "
"next answer ends the check in words, with the keys as the "
"first run left them and the edges still in force. The keys, the homing mode, and the setup "
"record are put back as found, the record under a restart, and the head goes back to the "
"home by a Grbl client's jog in machine coordinates; the run fails unless the port reads "
"the head on the home's step before the restart. Then the camera home is dropped and the "
"head goes back to where the test found it, by the travel the session's motions logged.")
def check_envelope(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
s0 = fc.settings() or {}
ctx.check(s0.get("cloud_enabled") == "1", "cloud mode is off: a camera home needs it on")
raw = read_file(record_path())
found = {k: s0.get(k) or "" for k in KEYS}
ev["found"] = found
home = back = session_at = None
try:
st, body = fc.post("/settings", data={"homing_mode": "gfcloud"})
ctx.check(st == 200, "homing_mode=gfcloud -> %s %s", st, body)
with ctx.grbl() as g:
session_at = session_mark()
gfhome_homing(ctx, ev, g)
home = g.status_report().get("MPos")
judge_whole_motions(ctx, ev, session_at)
ev["home"] = home
ctx.log("the camera home declared %s", home)
ctx.check(home and home[0] is not None, "no position after the home: %s", home)
_settled(ctx, fc)
last, given = _drive(ctx, fc, X_STEPS, Y_STEPS)
r = last.get("result") or {}
ev["run1"] = {"answers": given, "error": last.get("error"), "result": r, "log": last.get("log", [])[-8:]}
ctx.log("the bed check ended: %s", json.dumps(r))
ctx.check(not last.get("error"), "the bed check failed: %s", last.get("error"))
want_x, want_y = home[0] + X_OUT, home[1] + Y_OUT
ctx.check(abs((r.get("x_end_mm") or -1) - want_x) < 0.02 and abs((r.get("y_end_mm") or -1) - want_y) < 0.02,
"the ends are not the home plus the jogs (%.2f, %.2f): %s", want_x, want_y, r)
edge_x, edge_y = round(want_x - MARGIN_MM, 2), round(want_y - MARGIN_MM, 2)
s1 = fc.settings() or {}
ev["keys_after_run1"] = {k: s1.get(k) for k in ("envelope_x_mm", "envelope_y_mm")}
ctx.check(abs(float(s1.get("envelope_x_mm") or 0) - edge_x) < 0.011 and
abs(float(s1.get("envelope_y_mm") or 0) - edge_y) < 0.011,
"the keys are not the ends less %.1f mm (%.2f, %.2f): %s", MARGIN_MM, edge_x, edge_y,
ev["keys_after_run1"])
ctx.check(_port(fc).get("envelope_open") is False, "the envelope is still open after the check: %s",
_port(fc))
_edge(ctx, fc, ev, "X", edge_x)
_edge(ctx, fc, ev, "Y", edge_y)
# A Grbl client's line while the envelope is open ends the check.
said = {}
def client_line(pid, n):
if n:
return
ctx.check(_port(fc).get("envelope_open") is True, "the second run did not open the envelope: %s",
_port(fc))
with ctx.grbl() as g2:
said["reply"] = g2.command("G90", timeout=10)
said["text"] = g2.drain()
said["port"] = _port(fc)
last2, given2 = _drive(ctx, fc, ["X+1"], [], hook=client_line)
ev["run2"] = {"answers": given2, "said": said, "error": last2.get("error"), "log": last2.get("log", [])[-6:]}
ctx.log("the second run: %s", json.dumps(ev["run2"]))
ctx.check((said.get("reply") or [])[-1:] == ["ok"], "the client's line did not draw ok: %s", said)
ctx.check(any(CLOSED_MSG in l for l in (said.get("reply") or [])) or CLOSED_MSG in (said.get("text") or ""),
"the client was not told its line closed the envelope: %s", said)
ctx.check((said.get("port") or {}).get("envelope_open") is False,
"the client's line did not close the envelope: %s", said.get("port"))
ctx.check(CLOSED in (last2.get("error") or ""), "the second run did not end on the closed envelope: %s",
last2.get("error"))
s2 = fc.settings() or {}
ctx.check({k: s2.get(k) for k in ("envelope_x_mm", "envelope_y_mm")} == ev["keys_after_run1"],
"the second run wrote the keys: %s", s2)
_settled(ctx, fc)
_edge(ctx, fc, ev, "X", edge_x)
finally:
fc.post("/wiz/%s/abort" % WID)
if home and home[0] is not None:
ctx.wait_for(lambda: not dark(fc).get("running"), 30, poll=0.5)
back = _back_home(ctx, fc, home)
_put_back(ctx, fc, found)
try:
# Before the record's restart: the counters since the home say
# where the head stands until a controller start zeroes them.
if session_at is not None:
camera_home_return(ctx, ev, session_at)
finally:
# The check records itself when it completes; the record goes back
# as found under a restart, which also ends the envelope it set.
if read_file(record_path()) != raw:
with ctx.takeover():
write_file(record_path(), raw)
ctx.log("the previous setup record is back under a restart")
# Judged from the reading before the restart: the restart zeroes the
# counters where the head stands, so the baseline's position check
# after it cannot see a head left out.
ev["back"] = back
ctx.check(back and all(abs(back[i] - home[i]) < HOME_TOL_MM for i in (0, 1)),
"the head is not back at the home %s: %s", home, back)
+130 -16
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""camera.* - the lid camera pipeline through forgectrl.""" """camera.* - the lid camera pipeline through forgectrl."""
import json import json
@@ -58,7 +63,10 @@ def _jpeg_size(data):
"far smaller), a second snapshot with the lid lamp switched off differs from it " "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 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 " "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): def snapshot(ctx):
fc = ctx.forgectrl fc = ctx.forgectrl
ev = ctx.evidence ev = ctx.evidence
@@ -111,6 +119,48 @@ def snapshot(ctx):
ev["stream_ctype"] = ctype ev["stream_ctype"] = ctype
ctx.log("stream: %s, first %d bytes", ctype, len(chunk)) 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") 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) ctx.sleep(2)
st, body = fc.get("/cam/status") st, body = fc.get("/cam/status")
ev["cam_status_after"] = body ev["cam_status_after"] = body
@@ -229,10 +279,12 @@ def h264_stream(ctx):
covers=_CAM_COVERS, requires=["forgectrl.panel-serves"], covers=_CAM_COVERS, requires=["forgectrl.panel-serves"],
description="The capture queue flags a frame errored when it is short, torn, or arrived " 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 " "after the CSI-2 receiver lost sync; forgectrl drops those rather than "
"demosaicing them, and restarts the stream if they persist. A healthy machine " "demosaicing them, restarts the stream if they persist, and withholds the "
"captures a burst with none of them. A nonzero corrupt count here is a real " "stream frames on either side of one. A healthy machine captures a burst of "
"signal - a marginal camera ribbon, a mistimed D-PHY - even though the frames " "snapshots and a few seconds of stream with none of them flagged and none "
"themselves never reach a client.") "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): def frame_health(ctx):
fc = ctx.forgectrl fc = ctx.forgectrl
ev = ctx.evidence ev = ctx.evidence
@@ -269,18 +321,63 @@ def frame_health(ctx):
ctx.check(corrupt == 0, "%d of %d captured frames came back errored", corrupt, captured) 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) 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, kind="operator", est_min=3,
covers=_PRIVACY_COVERS, requires=["forgectrl.panel-serves"], actions=["lid"], covers=_PRIVACY_COVERS + [("forgectrl", "src/auth.*")], requires=["forgectrl.panel-serves"],
actions=["lid"],
steps=["Start with the lid closed; you will be told to open it, then close it again.", 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."], "Nothing moves and the laser is not involved."],
description="The privacy gate: with the lid open neither camera captures. A running stream " description="The privacy gate: with the lid open the lid camera does not capture, and the head "
"stops within a frame or so of the lid opening, /cam/status reports capture as " "camera captures only for a local viewer. A running lid-camera stream stops within a "
"not allowed, and both the snapshot and the stream are refused with 409 and a " "frame or so of the lid opening, /cam/status reports capture as not allowed, and the "
"reason naming the lid. Closing the lid restores all of it. This is what stops " "lid camera's snapshot and stream are refused with 409 and a reason naming the lid, "
"the machine - and in cloud mode the Glowforge service - from imaging the room " "to the panel too. The head camera, which looks down at the bed, still answers the "
"through an open lid.") "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): def lid_privacy(ctx):
import urllib.error import urllib.error
import urllib.request import urllib.request
@@ -361,9 +458,26 @@ def lid_privacy(ctx):
ctx.check(b"lid" in data.lower(), "the refusal should name the lid: %r", data[:120]) 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") 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) st, data = fc.get("/cam/snapshot", params={"cam": "head", "res": "half"}, raw=True)
ctx.log("lid open: head snapshot -> %s", st) ctx.log("lid open: the panel's head snapshot -> %s", st)
ctx.check(st == 409, "the head camera should be refused too, got %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: try:
with urllib.request.urlopen(req, timeout=10) as r: with urllib.request.urlopen(req, timeout=10) as r:
@@ -396,7 +510,7 @@ def lid_privacy(ctx):
"authorizes a write; a rotation from the panel route stops the old key and " "authorizes a write; a rotation from the panel route stops the old key and "
"the new one reads. The setting is put back after.") "the new one reads. The setting is put back after.")
def key_read(ctx): def key_read(ctx):
from .commission import request from .setup import request
from .forgectrl import lan_ip from .forgectrl import lan_ip
fc = ctx.forgectrl fc = ctx.forgectrl
ev = ctx.evidence ev = ctx.evidence
+219 -110
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""cloud.* - the controller mode switch and the optional Glowforge web-service """cloud.* - the controller mode switch and the optional Glowforge web-service
mode (gfcloud daemon, gfhome homing runner). The mode-switch test makes the mode (gfcloud daemon, gfhome homing runner). The mode-switch test makes the
grbl -> cloud -> grbl round trip with the connect-time hunt run lid-open and grbl -> cloud -> grbl round trip with the connect-time hunt run lid-open and
@@ -87,7 +92,9 @@ FORGECTRL_LOG = "/data/log/forgefirm/forgectrl/forgectrl.log"
LIMITS_MARK = "job limits from the header: " LIMITS_MARK = "job limits from the header: "
EFFECTIVE_MARK = "effective limits: coolant ceiling " EFFECTIVE_MARK = "effective limits: coolant ceiling "
SESSION_MARKS = ("authenticate_machine SUCCESS", "ws_connect ESTABLISHED") SESSION_MARKS = ("authenticate_machine SUCCESS", "ws_connect ESTABLISHED")
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): 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) return all(any(m in ln for ln in lines) for m in SESSION_MARKS)
def return_head(ctx, feed=2400): def wait_mode(ctx, fc, want_mode, want_controller="running", timeout=90, poll=1.0):
"""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):
t0 = time.time() t0 = time.time()
last = None last = None
while time.time() - t0 < timeout: while time.time() - t0 < timeout:
@@ -157,7 +131,7 @@ def wait_mode(ctx, fc, want_mode, want_controller="running", timeout=90):
return m return m
if m.get("controller") == "motion-fault": if m.get("controller") == "motion-fault":
break break
time.sleep(1) time.sleep(poll)
return last return last
@@ -227,6 +201,11 @@ def gfhome_homing(ctx, ev, g):
# homed: the counters are re-anchored at the corner, where the head stays # homed: the counters are re-anchored at the corner, where the head stays
ctx.counters_rezeroed() ctx.counters_rezeroed()
ctx.check(ctx.forgectrl.wait_idle(15, abort=ctx.aborted), "machine not idle after homing") ctx.check(ctx.forgectrl.wait_idle(15, abort=ctx.aborted), "machine not idle after homing")
# The driver answers $H when the session ends; that ok (and the
# session's messages) sit in the buffer behind the status reports and
# would pass for the reply to the caller's next command.
ack = " | ".join(ln.strip() for ln in g.drain().splitlines() if ln.strip())
ctx.log("$H acknowledged: %s", ack[:160] or "(nothing buffered)")
@test("cloud.mode-switch", title="Controller mode switch grbl -> cloud -> grbl, the connect-time hunt " @test("cloud.mode-switch", title="Controller mode switch grbl -> cloud -> grbl, the connect-time hunt "
@@ -235,26 +214,38 @@ def gfhome_homing(ctx, ev, g):
covers=_HOMING_PATH + [("forgectrl", "src/cool.*"), ("forgectrl", "src/airflow.*"), covers=_HOMING_PATH + [("forgectrl", "src/cool.*"), ("forgectrl", "src/airflow.*"),
("grblhal-glowforge", "src/**")], ("grblhal-glowforge", "src/**")],
requires=["forgectrl.auth", "motion.pacing"], actions=["lid"], requires=["forgectrl.auth", "motion.pacing"], actions=["lid"],
steps=["Bed clear; cloud credentials configured; the machine on the network. The test " steps=["Bed clear, lid closed; cloud credentials configured; the machine on the network. "
"turns cloud mode and the gfcloud homing on itself when they are off, and puts " "The test turns cloud mode and the gfcloud homing on itself when they are off, and "
"the settings back at the end.", "puts the settings back at the end.",
"Open the lid when told and leave it open through the cloud client's connect and its " "Open the lid the moment you are told, with a hand ready on it: the cloud client's "
"hunt; close it when told. Nothing else: the switch back and the $H homing run on " "first hunt begins a few seconds after its controller starts and must find the lid "
"their own, and the head ends parked at the home corner."], "open. Leave it open through the connect and the hunt; close it when told. Nothing "
"else: the switch back and the $H homing run on their own, and the head ends where the "
"test found it."],
description="One round trip with the two service-driven motions on it. POST /mode switches " description="One round trip with the two service-driven motions on it. POST /mode switches "
"to the cloud controller: gfcloud comes up under supervision, authenticates and " "to the cloud controller with the lid closed (no controller starts with the "
"enclosure open): gfcloud comes up under supervision, authenticates and "
"establishes its service session (its own log lines are the evidence; the " "establishes its service session (its own log lines are the evidence; the "
"connect-time firmware probe is recorded when configured). Its connect-time hunt " "connect-time firmware probe is recorded when configured). The lid is opened the "
"runs with the lid OPEN, as the factory's does - it completes, nothing before its " "moment the controller is up, before the client requests its connect-time hunt "
"(a hunt requested before the lid was open fails the test), so the hunt runs "
"with the lid OPEN, as the factory's does - it completes, nothing before its "
"end is refused for the lid, the lens homes - and with the extraction fans off it " "end is refused for the lid, the lens homes - and with the extraction fans off it "
"is measured by the airflow gates but not judged (no AIRFLOW, the exhaust row " "is measured by the airflow gates but not judged (no AIRFLOW, the exhaust row "
"reads unjudged); the camera service survives the switch. The lid closed, the " "reads unjudged); the camera service survives the switch. The lid closed, the "
"service's re-hunt is waited out; switching back brings grblHAL up with the Grbl " "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 " "homing_mode = gfcloud runs gfhome, the web-service homing session with the "
"head-accelerometer motion witness: the controller returns to Idle with " "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): 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 fc = ctx.forgectrl
ev = ctx.evidence ev = ctx.evidence
st, m0 = fc.get("/mode") st, m0 = fc.get("/mode")
@@ -270,17 +261,32 @@ def mode_switch(ctx):
except OSError: except OSError:
pass pass
lamp0 = hw.sysfs_read("pic/lid_led") lamp0 = hw.sysfs_read("pic/lid_led")
ctx.check(ctx.switch("lid") is True, "close the lid first: no controller starts with it open")
ctx.act("lid", "open", text="Leave it open: the cloud client connects and hunts with the lid open.") # The switch is made with the enclosure closed (the supervisor holds
# every spawn until it is). The lid opens the moment the controller is
# up: the client requests its connect-time hunt a few seconds after
# its start, right behind its session, and the hunt must find the lid
# open. The poll is tight so the window is not spent waiting.
log_offset = log_size(GFCLOUD_LOG) log_offset = log_size(GFCLOUD_LOG)
st, body = fc.post("/mode", data={"controller": "cloud"}) st, body = fc.post("/mode", data={"controller": "cloud"})
ctx.log("POST /mode controller=cloud -> %s %s", st, body) ctx.log("POST /mode controller=cloud -> %s %s", st, body)
ctx.check(st == 200, "mode switch to cloud refused: %s %s", st, body) ctx.check(st == 200, "mode switch to cloud refused: %s %s", st, body)
m = wait_mode(ctx, fc, "cloud", timeout=90) m = wait_mode(ctx, fc, "cloud", timeout=90, poll=0.2)
ev["mode_cloud"] = m ev["mode_cloud"] = m
ctx.log("mode after switch: %s", m) ctx.log("mode after switch: %s", m)
ctx.check(m and m.get("mode") == "cloud" and m.get("controller") == "running", ctx.check(m and m.get("mode") == "cloud" and m.get("controller") == "running",
"cloud controller did not come up: %s", m) "cloud controller did not come up: %s", m)
ctx.act("lid", "open", text="Now, at once: the cloud client's first hunt begins a few seconds "
"after its controller starts and must find the lid open. Leave it open through the "
"connect and the hunt.")
# The order is the proof: the hunt's request is not in the log yet
# when the lid reads open.
ev["hunt_before_lid_open"] = log_has(log_offset, HUNT_REQUEST)
ctx.check(not ev["hunt_before_lid_open"],
"the client requested its hunt before the lid was open (the hunt follows the "
"controller's start by a few seconds): the lid opened too late, so a hunt with the "
"lid open is not proven")
# the client's own session lines are the evidence of a live cloud session # the client's own session lines are the evidence of a live cloud session
session = wait_session(ctx, log_offset) session = wait_session(ctx, log_offset)
probe = None probe = None
@@ -350,10 +356,9 @@ def mode_switch(ctx):
st, cam2 = fc.get("/cam/status") st, cam2 = fc.get("/cam/status")
ev["cam_after"] = cam2 ev["cam_after"] = cam2
ctx.check(st == 200, "camera status lost after the switch back") ctx.check(st == 200, "camera status lost after the switch back")
# cloud mode's connect cleared the kernel counters at the starting position # The service moved the head in cloud mode, and the controller's start
# and its hunt homed the head: bring it back # zeroed the counters where it left it: the client's record brings it back.
ctx.counters_rezeroed() cloud_mode_return(ctx, ev, log_offset)
return_head(ctx)
# cloud mode sets its own lid-lamp level (LLvl) and leaves it: hand back the level found # cloud mode sets its own lid-lamp level (LLvl) and leaves it: hand back the level found
lamp1 = hw.sysfs_read("pic/lid_led") lamp1 = hw.sysfs_read("pic/lid_led")
ev["lid_lamp"] = {"before": lamp0, "after_cloud": lamp1} ev["lid_lamp"] = {"before": lamp0, "after_cloud": lamp1}
@@ -367,9 +372,11 @@ def mode_switch(ctx):
st, body = fc.post("/settings", data={"homing_mode": "gfcloud"}) 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.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) ctx.check(st == 200, "homing_mode=gfcloud -> %s %s", st, body)
session_at = session_mark()
try: try:
with ctx.grbl() as g: with ctx.grbl() as g:
gfhome_homing(ctx, ev, g) gfhome_homing(ctx, ev, g)
judge_whole_motions(ctx, ev, session_at)
finally: finally:
if ev["homing_mode"] != "gfcloud": if ev["homing_mode"] != "gfcloud":
# Back to exactly what the machine had: unset is the empty # Back to exactly what the machine had: unset is the empty
@@ -380,6 +387,7 @@ def mode_switch(ctx):
if not ev["homing_mode"] if not ev["homing_mode"]
else fc.post("/settings", data={"homing_mode": ev["homing_mode"]})) else fc.post("/settings", data={"homing_mode": ev["homing_mode"]}))
ctx.log("restore homing_mode=%r -> %s", ev["homing_mode"], st) 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) -> " 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"]) "grbl (port open, %s), then $H homed in %.1f s", ev["grbl_state"], ev["homing_s"])
@@ -494,9 +502,18 @@ NOHUNT_MARK = "NO-HUNT:"
NOHUNT_MARKER = "/run/gfcloud-nohunt" NOHUNT_MARKER = "/run/gfcloud-nohunt"
HUNT_DONE = "hunt [" HUNT_DONE = "hunt ["
WS_MARKS = ("RX-EVENT: ready", "RX-EVENT: closed", "RECONNECTING", "CLOSING", OFFLINE_MARK) 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 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 QUIET_TIMEOUT_S = 180
HUNT_TIMEOUT_S = 180 HUNT_TIMEOUT_S = 180
@@ -553,6 +570,42 @@ def wait_log(ctx, offset, needles, timeout, poll=0.5):
return found 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): def log_has(offset, needle):
"""True when the gfcloud log carries needle since offset (one read; """True when the gfcloud log carries needle since offset (one read;
the condition an `act` waits on).""" the condition an `act` waits on)."""
@@ -685,9 +738,9 @@ def client_offline(pid):
def wait_quiet(ctx, offset, quiet_s=None, timeout=None): def wait_quiet(ctx, offset, quiet_s=None, timeout=None):
"""The service's moves are over: the machine idle and no new service """The service's moves are over: the machine idle and no new line of any
activity in the log (a motion, a park, a run, a lens homing) for service action in the log (ACTIVITY_MARKS) for quiet_s (default QUIET_S;
quiet_s (default QUIET_S; timeout QUIET_TIMEOUT_S). False on timeout.""" timeout QUIET_TIMEOUT_S). False on timeout."""
quiet_s = QUIET_S if quiet_s is None else quiet_s quiet_s = QUIET_S if quiet_s is None else quiet_s
timeout = QUIET_TIMEOUT_S if timeout is None else timeout timeout = QUIET_TIMEOUT_S if timeout is None else timeout
fc = ctx.forgectrl fc = ctx.forgectrl
@@ -1010,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 client loads the job, lights the button and waits; the operator's
press arms it and the run starts. Returns the 'starting run' line.""" press arms it and the run starts. Returns the 'starting run' line."""
off.print_ready(action_id, path, settings) off.print_ready(action_id, path, settings)
got = wait_log(ctx, offset, ["waiting for button"], 120) wait_button_wait(ctx, offset, 120)
ctx.check(got["waiting for button"], "the offline print never reached the button wait (refused, "
"or the job did not load)")
ctx.act("button", "press", text="The button is lit white: the press arms the print and the " 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.", "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) until=lambda: wait_print_running(ctx, offset, 0.1) is not None, timeout=180)
@@ -1239,10 +1290,9 @@ def lid_during_button_wait(ctx):
def lid_during_button_wait_body(ctx, ev, off, job, offset): def lid_during_button_wait_body(ctx, ev, off, job, offset):
off.print_ready(9003, job) off.print_ready(9003, job)
got = wait_log(ctx, offset, ["job is longer than the ring", "waiting for button"], 180) got = wait_button_wait(ctx, offset, 180, also=["job is longer than the ring"])
ctx.check(got["job is longer than the ring"], ctx.check(got["job is longer than the ring"],
"the job fit the ring: this test needs a job the ring cannot hold") "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) ctx.act("lid", "open", text="The button is lit: do NOT press it.", timeout=120)
relock = "button wait lid opened - relocking the laser" relock = "button wait lid opened - relocking the laser"
got = wait_log(ctx, offset, [relock], 60) got = wait_log(ctx, offset, [relock], 60)
@@ -1295,42 +1345,95 @@ def lid_during_button_wait_body(ctx, ev, off, job, offset):
# ---------------------------------------------------------------- the cooling verdict # ---------------------------------------------------------------- the cooling verdict
HOLD_KEYS = ("cool_temp_min", "cool_temp_start") HOLD_KEYS = ("cool_temp_min", "cool_temp_start")
WARMUP_ABOVE_C = 1.0 # the start gate this far above the loop: a few heater minutes REFUSE_KEYS = HOLD_KEYS + ("cloud_hold_max_s",)
WARMUP_RELEASE_S = 720 # the flow heater warms the bulk 0.4 to 0.8 C a minute HOLD_REFUSE_S = 60 # the shortest bound the client takes
HOLD_REFUSE_LINE = "cooling hold held 60 s - relocking the laser"
WARMUP_WAIT_LINE = "waiting on the cooling engine: WARMUP" WARMUP_WAIT_LINE = "waiting on the cooling engine: WARMUP"
WARMUP_RELEASE_LINE = "cooling verdict clean after WARMUP; starting the run"
@test("cloud.verdict-hold", title="A cloud print armed under the warm-up gate waits for the engine", @test("cloud.dark-print", title="A dark cloud print: the latch locked until the run, unlocked "
subsystem="cloud", kind="operator", est_min=14, "for it, locked after it",
subsystem="cloud", kind="operator", est_min=3,
covers=_MACHINE_RUN + [("python3-gfhardware", "gfhardware/coolsvc.py"), covers=_MACHINE_RUN + [("python3-gfhardware", "gfhardware/coolsvc.py"),
("forgectrl", "src/cool.*"), ("forgectrl", "src/main.c")], ("forgectrl", "src/cool.*"), ("forgectrl", "src/main.c")],
requires=["cloud.lid-during-button-wait", "cooling.floor-and-warm-up"], actions=["button"], requires=["cloud.lid-during-button-wait", "cooling.floor-and-warm-up"], actions=["button"],
steps=[OFFLINE_STEP, steps=[OFFLINE_STEP,
"Coolant at room temperature (10 to 30 C). The test sets the warm-up gate just above " "Bed clear (the job is dark: a 30 s square at S0, nothing fires). Press the button "
"the coolant and restores it. Press the button when it lights: the print then waits " "when it lights: the print waits for the engine, runs, and finishes."],
"for the warm-up (a few minutes with the loop heater on), runs dark, and finishes."], description="The cooling-engine contract in cloud mode at its ordinary end, on the engine "
description="The cooling-engine contract in cloud mode, on the engine itself: the start gate " "itself: the print arms on the press, waits for the engine's acknowledgment (the "
"set just above the coolant makes the armed session open under the warm-up " "fans at their floors), and runs. The laser latch is locked at the button and "
"(WARMUP: fire blocked, hold), and the client waits it out after the button " "through the wait, unlocks only for the run (immediately before it starts), and "
"instead of canceling - nothing runs, the latch stays unlocked for the armed " "is locked again when the job ends; the print completes. A hold the engine never "
"window - until the engine releases into run, and the release starts the print, " "clears is cloud.verdict-refuse; the engine's own warm-up release is "
"which completes. The hold mid-run (the same laser-off pause as the button's, " "cooling.floor-and-warm-up.")
"resumed on the engine's resume_ok) and the bound on a hold are host-tested: the " def dark_print(ctx):
"gates apply at session open, so no setting can produce a hold mid-run here.") ev = ctx.evidence
def verdict_hold(ctx): fc = ctx.forgectrl
offset = enter_offline(ctx)
job = offline_job(ctx, "dark.puls", seconds=30)
off = Offline().__enter__()
try:
dark_print_body(ctx, ev, off, job, offset, fc)
finally:
off.__exit__(None, None, None)
offline_cleanup(ctx)
def dark_print_body(ctx, ev, off, job, offset, fc):
off.print_ready(9005, job)
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: %s", message(fin) or "no finish line")
st, cs = fc.get("/cool/status")
ev["armed_after"] = cs.get("armed") if isinstance(cs, dict) else None
ctx.check(not ev["armed_after"], "armed window still open after the print")
ev["latch_locked_after"] = latch_locked()
ctx.check(ev["latch_locked_after"], "the latch is unlocked after the print")
ev["events"] = offline_events(off)
ctx.log("PASS: locked at the button, unlocked for the run, locked after it; the print completed")
@test("cloud.verdict-refuse", title="A cloud print held by the engine past the bound is canceled with "
"the latch never unlocked",
subsystem="cloud", kind="operator", est_min=4,
covers=_MACHINE_RUN + [("python3-gfhardware", "gfhardware/coolsvc.py"),
("forgectrl", "src/cool.*")],
requires=["cloud.dark-print"], actions=["button"],
steps=[OFFLINE_STEP,
"Coolant at room temperature (10 to 30 C). The test sets the warm-up gate well above "
"the coolant and the hold bound to its minimum, and restores both. Press the button "
"when it lights: nothing runs; a minute later the print cancels by itself."],
description="The other end of the cooling-engine contract before a run: a verdict that "
"refuses fire and never clears. The start gate far above the coolant keeps the "
"armed session under the warm-up for longer than the hold bound (cloud_hold_max_s "
"at its minimum, 60 s). The client waits with the laser latch locked the whole "
"time - it is never unlocked before the verdict passes, so there is nothing to "
"relock - then cancels the print at the bound: no run, the armed window closed, "
"the print ':cancelled'. Dark by construction.")
def verdict_refuse(ctx):
ev = ctx.evidence ev = ctx.evidence
fc = ctx.forgectrl fc = ctx.forgectrl
before = fc.settings() before = fc.settings()
orig = {k: before.get(k, "") for k in HOLD_KEYS} orig = {k: before.get(k, "") for k in REFUSE_KEYS}
ev["orig"] = orig ev["orig"] = orig
up = (fc.status().get("coolant") or {}).get("up_c") up = (fc.status().get("coolant") or {}).get("up_c")
ctx.check(up is not None and 10.0 <= up <= 30.0, "coolant outside the test's range (up_c %s)", up) ctx.check(up is not None and 10.0 <= up <= 30.0, "coolant outside the test's range (up_c %s)", up)
offset = enter_offline(ctx) offset = enter_offline(ctx)
job = offline_job(ctx, "hold.puls", seconds=30) job = offline_job(ctx, "refuse.puls", seconds=30)
off = Offline().__enter__() off = Offline().__enter__()
try: try:
verdict_hold_body(ctx, ev, off, job, offset, fc, up, orig) verdict_refuse_body(ctx, ev, off, job, offset, fc, up, orig)
finally: finally:
st, body = fc.post("/settings", params=orig) st, body = fc.post("/settings", params=orig)
ctx.log("restore: POST /settings %s -> %s", orig, st) ctx.log("restore: POST /settings %s -> %s", orig, st)
@@ -1338,46 +1441,52 @@ def verdict_hold(ctx):
offline_cleanup(ctx) offline_cleanup(ctx)
def verdict_hold_body(ctx, ev, off, job, offset, fc, up, orig): def verdict_refuse_body(ctx, ev, off, job, offset, fc, up, orig):
gate = round(up + WARMUP_ABOVE_C, 1) gate = round(up + 8.0, 1) # eight degrees: the heater cannot get there in a minute
st, body = fc.post("/settings", params={"cool_temp_start": str(gate), st, body = fc.post("/settings", params={"cool_temp_start": str(gate),
"cool_temp_min": orig["cool_temp_min"]}) "cool_temp_min": orig["cool_temp_min"],
ctx.check(st == 200, "POST /settings cool_temp_start=%s -> %s %s", gate, st, body) "cloud_hold_max_s": str(HOLD_REFUSE_S)})
ctx.check(st == 200, "POST /settings cool_temp_start=%s cloud_hold_max_s=%s -> %s %s",
gate, HOLD_REFUSE_S, st, body)
ev["gate"] = gate ev["gate"] = gate
off.print_ready(9005, job) off.print_ready(9006, job)
got = wait_log(ctx, offset, ["waiting for button"], 120) wait_button_wait(ctx, offset, 120)
ctx.check(got["waiting for button"], "the print never reached the button wait") ev["latch_locked_at_button"] = latch_locked()
ctx.act("button", "press", text="The button is lit: press it. The print then waits for the warm-up.", ctx.check(ev["latch_locked_at_button"], "the latch is unlocked at the button wait")
ctx.act("button", "press", text="The button is lit: press it. Nothing runs; the print cancels "
"itself a minute later.",
until=lambda: log_has(offset, WARMUP_WAIT_LINE), timeout=PRESS_TIMEOUT_S) until=lambda: log_has(offset, WARMUP_WAIT_LINE), timeout=PRESS_TIMEOUT_S)
got = wait_log(ctx, offset, [WARMUP_WAIT_LINE], 30) got = wait_log(ctx, offset, [WARMUP_WAIT_LINE], 30)
ctx.check(got[WARMUP_WAIT_LINE], "the armed print did not wait on the warm-up") ctx.check(got[WARMUP_WAIT_LINE], "the armed print did not wait on the warm-up")
st, c = fc.get("/cool/status")
ev["warmup"] = c
ctx.check(isinstance(c, dict) and c.get("verdict") == "WARMUP" and c.get("hold") is True
and c.get("fire_ok") is False,
"the engine is not holding the armed session on the warm-up: %s", c)
ctx.check(wait_print_running(ctx, offset, 2) is None, "the run started under the warm-up hold")
ctx.notice("Warm-up: the loop heater brings the coolant to the gate; a few minutes. Do nothing.")
try:
t0 = time.time() t0 = time.time()
run = wait_print_running(ctx, offset, WARMUP_RELEASE_S) samples = []
ctx.notice("The engine holds the print; the client gives up at the bound. Do nothing.")
try:
while time.time() - t0 < HOLD_REFUSE_S + 30:
ctx.checkpoint()
samples.append(latch_locked())
if log_has(offset, HOLD_REFUSE_LINE):
break
ctx.sleep(2)
finally: finally:
ctx.clear_notice() ctx.clear_notice()
ev["release_s"] = round(time.time() - t0) ev["hold_s"] = round(time.time() - t0)
got = wait_log(ctx, offset, [WARMUP_RELEASE_LINE], 5) ev["latch_samples"] = {"n": len(samples), "unlocked": samples.count(False)}
ev["log"] = {WARMUP_WAIT_LINE: True, WARMUP_RELEASE_LINE: bool(got[WARMUP_RELEASE_LINE])} ctx.check(log_has(offset, HOLD_REFUSE_LINE), "the client did not cancel at the hold bound (%d s)",
ctx.check(got[WARMUP_RELEASE_LINE], "the warm-up did not release into the run within %d s", ev["hold_s"])
WARMUP_RELEASE_S) ctx.check(all(samples), "the latch unlocked during the hold (%d of %d samples)",
ctx.check(run is not None, "the print did not start after the warm-up") samples.count(False), len(samples))
fin = wait_action_finished(ctx, offset, "print", 180) ctx.check(wait_print_running(ctx, offset, 1) is None, "the run started under the refused verdict")
fin = wait_action_finished(ctx, offset, "print", 120)
ev["print finished"] = message(fin) ev["print finished"] = message(fin)
ctx.check(fin and COMPLETED in fin, "the print did not complete after the warm-up: %s", ctx.check(fin and CANCELLED in fin, "the held print did not end ':cancelled': %s",
message(fin) or "no finish line") message(fin) or "no finish line")
st, cs = fc.get("/cool/status") st, cs = fc.get("/cool/status")
ev["armed_after"] = cs.get("armed") if isinstance(cs, dict) else None ev["armed_after"] = cs.get("armed") if isinstance(cs, dict) else None
ctx.check(not ev["armed_after"], "armed window still open after the print") ctx.check(not ev["armed_after"], "armed window still open after the cancel")
ctx.check(latch_locked(), "the latch is unlocked after the cancel")
ev["events"] = offline_events(off) ev["events"] = offline_events(off)
ctx.log("PASS: the armed print waited %s s on the warm-up, then ran and completed", ev["release_s"]) ctx.log("PASS: held %s s with the latch locked throughout, then canceled at the bound", ev["hold_s"])
@test("cloud.pause-resume", title="Button pauses and resumes a cloud print (factory backtrack + lead)", @test("cloud.pause-resume", title="Button pauses and resumes a cloud print (factory backtrack + lead)",
+318 -23
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""cooling.* - the cooling engine: flow verification through forgectrl's """cooling.* - the cooling engine: flow verification through forgectrl's
diagnostics runner (the same check the fire gate runs), the fan profile diagnostics runner (the same check the fire gate runs), the fan profile
returning to idle after motion, the gate settings (a value inside the returning to idle after motion, the gate settings (a value inside the
@@ -5,6 +10,7 @@ legal range trips the gate, the far end of the range turns it off by
value, and both are said out loud: the settings reply, /status, the value, and both are said out loud: the settings reply, /status, the
engine's run-start log line), and the airflow gates (a fan under its engine's run-start log line), and the airflow gates (a fan under its
floor past the spin-up grace is a fault for the rest of the run).""" floor past the spin-up grace is a fault for the rest of the run)."""
import os
import time import time
from ..catalog import test from ..catalog import test
@@ -369,6 +375,7 @@ def _tail_has(fc, needle):
"board temperatures (chassis and SoC die in degrees, supply as a raw count, the " "board temperatures (chassis and SoC die in degrees, supply as a raw count, the "
"CPU unthrottled) and every run session ends with them ranged into one log line.") "CPU unthrottled) and every run session ends with them ranged into one log line.")
def gate_off(ctx): def gate_off(ctx):
from .motion import FORGECTRL_LOG, _log_lines, _log_offset
fc = ctx.forgectrl fc = ctx.forgectrl
ev = ctx.evidence ev = ctx.evidence
before = fc.settings() before = fc.settings()
@@ -422,6 +429,7 @@ def gate_off(ctx):
# Leg 2: the ceiling at its top. Off by value: no gate, verdict # Leg 2: the ceiling at its top. Off by value: no gate, verdict
# back to OK at the next run start, and said out loud. # back to OK at the next run start, and said out loud.
at_off = _log_offset(FORGECTRL_LOG) # the off gate's line is one written after this
_set_gates(ctx, fc, {"cool_temp_max": str(top), "cool_temp_resume": orig["cool_temp_resume"]}) _set_gates(ctx, fc, {"cool_temp_max": str(top), "cool_temp_resume": orig["cool_temp_resume"]})
state, val = _gate_state(fc, "cool_temp_max") state, val = _gate_state(fc, "cool_temp_max")
ev["off_state"] = state ev["off_state"] = state
@@ -433,13 +441,17 @@ def gate_off(ctx):
c.get("gates_off")) c.get("gates_off"))
s_off = fc.status().get("gates_off") s_off = fc.status().get("gates_off")
ctx.check(s_off == ["coolant_max"], "/status gates_off %s, expected [coolant_max]", s_off) ctx.check(s_off == ["coolant_max"], "/status gates_off %s, expected [coolant_max]", s_off)
ctx.check(_tail_has(fc, "gate coolant_max OFF: cool_temp_max = %g" % top), # A log line reaches the file a moment after the engine acts.
line = "gate coolant_max OFF: cool_temp_max = %g" % top
ctx.wait_for(lambda: bool(_log_lines(FORGECTRL_LOG, at_off, line)), 5, poll=0.25)
ctx.check(_log_lines(FORGECTRL_LOG, at_off, line),
"the run-start log line for the off gate is missing from the forgectrl log") "the run-start log line for the off gate is missing from the forgectrl log")
# Restore, and prove the restore: the next run start reloads. # Restore, and prove the restore: the next run start reloads.
_set_gates(ctx, fc, orig) _set_gates(ctx, fc, orig)
restored = True restored = True
state, val = _gate_state(fc, "cool_temp_max") state, val = _gate_state(fc, "cool_temp_max")
at_restore = _log_offset(FORGECTRL_LOG) # the session's run-end line is one written after this
c = _run_session(ctx, grbl, fc, lambda c: c.get("verdict") == "OK" and not c.get("gates_off"), c = _run_session(ctx, grbl, fc, lambda c: c.get("verdict") == "OK" and not c.get("gates_off"),
"restored") "restored")
ev["restored"] = c ev["restored"] = c
@@ -448,7 +460,10 @@ def gate_off(ctx):
ctx.log("restored ceiling %s reports state %s", val, state) ctx.log("restored ceiling %s reports state %s", val, state)
# Every run session ends with the board temperatures ranged # Every run session ends with the board temperatures ranged
# into one line. # into one line.
ctx.check(_tail_has(fc, "temps this job: chassis ") and _tail_has(fc, ", soc "), ctx.wait_for(lambda: bool(_log_lines(FORGECTRL_LOG, at_restore, "temps this job: chassis ")), 5, poll=0.25)
temps = _log_lines(FORGECTRL_LOG, at_restore, "temps this job: chassis ")
ev["temps_line"] = temps[-1] if temps else None
ctx.check(temps and ", soc " in temps[-1],
"the run-end board-temperature line (chassis, soc, supply) is missing from the " "the run-end board-temperature line (chassis, soc, supply) is missing from the "
"forgectrl log") "forgectrl log")
finally: finally:
@@ -513,6 +528,7 @@ def _hold_session(ctx, g, fc, until, what, wait):
"run-start log line, and the session reads OK. Restored, everything reads as " "run-start log line, and the session reads OK. Restored, everything reads as "
"before.") "before.")
def floor_and_warm_up(ctx): def floor_and_warm_up(ctx):
from .motion import FORGECTRL_LOG, _log_lines, _log_offset
fc = ctx.forgectrl fc = ctx.forgectrl
ev = ctx.evidence ev = ctx.evidence
before = fc.settings() before = fc.settings()
@@ -538,6 +554,7 @@ def floor_and_warm_up(ctx):
# Leg 1: the warm-up hold and its release. The gate sits just # Leg 1: the warm-up hold and its release. The gate sits just
# above the loop, so the heater reaches it in a few minutes. # above the loop, so the heater reaches it in a few minutes.
gate = round(up + WARMUP_ABOVE_C, 1) gate = round(up + WARMUP_ABOVE_C, 1)
at_warmup = _log_offset(FORGECTRL_LOG) # the release line is one written after this
_set_gates(ctx, fc, {"cool_temp_start": str(gate), "cool_temp_min": orig["cool_temp_min"]}) _set_gates(ctx, fc, {"cool_temp_start": str(gate), "cool_temp_min": orig["cool_temp_min"]})
c = _hold_session(ctx, grbl, fc, lambda c: c.get("verdict") == "WARMUP", "warm-up hold", VERDICT_WAIT_S) c = _hold_session(ctx, grbl, fc, lambda c: c.get("verdict") == "WARMUP", "warm-up hold", VERDICT_WAIT_S)
ev["warmup_hold"] = c ev["warmup_hold"] = c
@@ -576,7 +593,11 @@ def floor_and_warm_up(ctx):
ev["run_duty"] = duty ev["run_duty"] = duty
ctx.check(heater == 0, "the loop heater stayed on after the release (%s=%s)", HEATER_PWM, heater) ctx.check(heater == 0, "the loop heater stayed on after the release (%s=%s)", HEATER_PWM, heater)
ctx.check(duty != IDLE_DUTY, "the run fans did not come up at the release: %s", duty) ctx.check(duty != IDLE_DUTY, "the run fans did not come up at the release: %s", duty)
ctx.check(_tail_has(fc, "warm-up complete: coolant "), "the warm-up release line is missing from the forgectrl log") # A log line reaches the file a moment after the engine acts.
line = "warm-up complete: coolant "
ctx.wait_for(lambda: bool(_log_lines(FORGECTRL_LOG, at_warmup, line)), 5, poll=0.25)
ctx.check(_log_lines(FORGECTRL_LOG, at_warmup, line),
"the warm-up release line is missing from the forgectrl log")
grbl.command("M9") grbl.command("M9")
_session_ended(ctx, fc, "warm-up") _session_ended(ctx, fc, "warm-up")
@@ -594,6 +615,7 @@ def floor_and_warm_up(ctx):
c.get("gates_off")) c.get("gates_off"))
# Leg 3: both at zero: off, said so, and the session reads OK. # Leg 3: both at zero: off, said so, and the session reads OK.
at_min_off = _log_offset(FORGECTRL_LOG) # the floor's off line is one written after this
_set_gates(ctx, fc, {"cool_temp_start": "0", "cool_temp_min": "0"}) _set_gates(ctx, fc, {"cool_temp_start": "0", "cool_temp_min": "0"})
c = _run_session(ctx, grbl, fc, lambda c: c.get("verdict") == "OK" and len(c.get("gates_off") or []) == 2, c = _run_session(ctx, grbl, fc, lambda c: c.get("verdict") == "OK" and len(c.get("gates_off") or []) == 2,
"both off") "both off")
@@ -601,7 +623,9 @@ def floor_and_warm_up(ctx):
ctx.check(c.get("verdict") == "OK", "gates at 0 did not clear the hold: %s", c) ctx.check(c.get("verdict") == "OK", "gates at 0 did not clear the hold: %s", c)
ctx.check(sorted(c.get("gates_off") or []) == ["coolant_min", "warm_up"], ctx.check(sorted(c.get("gates_off") or []) == ["coolant_min", "warm_up"],
"gates_off %s, expected [coolant_min, warm_up]", c.get("gates_off")) "gates_off %s, expected [coolant_min, warm_up]", c.get("gates_off"))
ctx.check(_tail_has(fc, "gate coolant_min OFF: cool_temp_min = 0"), line = "gate coolant_min OFF: cool_temp_min = 0"
ctx.wait_for(lambda: bool(_log_lines(FORGECTRL_LOG, at_min_off, line)), 5, poll=0.25)
ctx.check(_log_lines(FORGECTRL_LOG, at_min_off, line),
"the run-start log line for the floor off is missing from the forgectrl log") "the run-start log line for the floor off is missing from the forgectrl log")
# Restore, and prove the restore at the next run start. # Restore, and prove the restore at the next run start.
@@ -650,6 +674,7 @@ TEC_WAIT_S = 20 # settings re-read at run start, 1 Hz ticks
"The cross-check keeps off under on. The line has no readback, so the last written value is " "The cross-check keeps off under on. The line has no readback, so the last written value is "
"what the test reads; the drive rule is the engine's, never the cloud's.") "what the test reads; the drive rule is the engine's, never the cloud's.")
def tec_drive(ctx): def tec_drive(ctx):
from .motion import FORGECTRL_LOG, _log_lines, _log_offset
fc = ctx.forgectrl fc = ctx.forgectrl
ev = ctx.evidence ev = ctx.evidence
before = fc.settings() before = fc.settings()
@@ -675,6 +700,7 @@ def tec_drive(ctx):
_set_gates(ctx, fc, {"cool_tec_present": "1", "cool_tec_on_c": str(on_c), _set_gates(ctx, fc, {"cool_tec_present": "1", "cool_tec_on_c": str(on_c),
"cool_tec_off_c": str(off_c)}) "cool_tec_off_c": str(off_c)})
ctx.check(_cool(fc).get("phase") != "run", "a run session is already open") ctx.check(_cool(fc).get("phase") != "run", "a run session is already open")
off = _log_offset(FORGECTRL_LOG) # the TEC-on line is one written after this
grbl.command("M8") grbl.command("M8")
t0 = time.time() t0 = time.time()
tec = 0 tec = 0
@@ -686,7 +712,11 @@ def tec_drive(ctx):
ev["tec_on_after_s"] = round(time.time() - t0) ev["tec_on_after_s"] = round(time.time() - t0)
ctx.log("fitted, in session: %s=%s after %s s", TEC_ATTR, tec, ev["tec_on_after_s"]) ctx.log("fitted, in session: %s=%s after %s s", TEC_ATTR, tec, ev["tec_on_after_s"])
ctx.check(tec == 1, "the TEC did not come on in a run session with its threshold under the loop") ctx.check(tec == 1, "the TEC did not come on in a run session with its threshold under the loop")
ctx.check(_tail_has(fc, "TEC on: coolant "), "the TEC-on line is missing from the forgectrl log") # The engine writes the line first and logs it after, and the log
# line reaches the file a moment later still.
ctx.wait_for(lambda: bool(_log_lines(FORGECTRL_LOG, off, "TEC on: coolant ")), 5, poll=0.25)
ev["tec_on_line"] = (_log_lines(FORGECTRL_LOG, off, "TEC on: coolant ") or [None])[0]
ctx.check(ev["tec_on_line"], "the TEC-on line is missing from the forgectrl log")
grbl.command("M9") grbl.command("M9")
_session_ended(ctx, fc, "tec leg 1") _session_ended(ctx, fc, "tec leg 1")
t0 = time.time() t0 = time.time()
@@ -746,7 +776,7 @@ FIRE_WAIT_S = 25 # settings re-read at run start, two-tick breach, 1
@test("cooling.fire-watch-tiers", title="The lid-IR fire watch pauses at its alert and fails at its critical", @test("cooling.fire-watch-tiers", title="The lid-IR fire watch pauses at its alert and fails at its critical",
subsystem="cooling", kind="auto", mode="grbl", est_min=6, subsystem="cooling", kind="auto", mode="grbl", est_min=6,
covers=_COOL_COVERS, requires=["kernel.latch-locked-idle"], covers=_COOL_COVERS + [("forgectrl", "src/super.*")], requires=["kernel.latch-locked-idle"],
steps=["Machine idle, lid closed, lid lamp at its resting level. The test moves the flame " steps=["Machine idle, lid closed, lid lamp at its resting level. The test moves the flame "
"thresholds under the lamp's own reading and restores them; three M8/M9 sessions, " "thresholds under the lamp's own reading and restores them; three M8/M9 sessions, "
"no fire."], "no fire."],
@@ -759,6 +789,7 @@ FIRE_WAIT_S = 25 # settings re-read at run start, two-tick breach, 1
"read as the four flame gates off and the watch reads watch. Restored, the " "read as the four flame gates off and the watch reads watch. Restored, the "
"watch reads armed at OK.") "watch reads armed at OK.")
def fire_watch_tiers(ctx): def fire_watch_tiers(ctx):
from .motion import FORGECTRL_LOG, _log_lines, _log_offset
fc = ctx.forgectrl fc = ctx.forgectrl
ev = ctx.evidence ev = ctx.evidence
before = fc.settings() before = fc.settings()
@@ -810,7 +841,16 @@ def fire_watch_tiers(ctx):
ctx.check(c.get("verdict") == "OK", "the alert survived into a fresh session: %s", c) ctx.check(c.get("verdict") == "OK", "the alert survived into a fresh session: %s", c)
# Leg 2: the fail tier. The q1 critical under the lamp reading # Leg 2: the fail tier. The q1 critical under the lamp reading
# stops the session and latches FIRE until it ends. # latches FIRE and locks the laser; the fail tier then stops
# the controller through the supervisor and starts it again,
# so no run start can relight what was locked and the sender
# sees the job end. The Grbl socket dies with the restart -
# FIRE is read from the engine, the latch from sysfs, the stop
# from the supervisor's log, and a fresh session is opened for
# the legs that follow.
st, m0 = fc.get("/mode")
pid0 = m0.get("pid") if isinstance(m0, dict) else None
off = _log_offset(FORGECTRL_LOG)
_set_gates(ctx, fc, {"cool_fire_q1_critical": str(max(2, q1 - 5)), _set_gates(ctx, fc, {"cool_fire_q1_critical": str(max(2, q1 - 5)),
"cool_fire_q1_alert": str(max(1, q1 - 10))}) "cool_fire_q1_alert": str(max(1, q1 - 10))})
grbl.command("M8") grbl.command("M8")
@@ -821,19 +861,38 @@ def fire_watch_tiers(ctx):
c = _cool(fc) c = _cool(fc)
if c.get("verdict") == "FIRE": if c.get("verdict") == "FIRE":
break break
ilk = hw.sysfs_int("cnc/interlock_circuit")
ev["critical"] = c ev["critical"] = c
ctx.log("critical leg: verdict %s fire_watch %s", c.get("verdict"), c.get("fire_watch")) ev["interlock_circuit"] = ilk
ctx.log("critical leg: verdict %s fire_watch %s interlock_circuit %s",
c.get("verdict"), c.get("fire_watch"), ilk)
ctx.check(c.get("verdict") == "FIRE", "q1 critical under the lamp did not latch FIRE: %s", c) ctx.check(c.get("verdict") == "FIRE", "q1 critical under the lamp did not latch FIRE: %s", c)
ctx.check(c.get("fire_watch") == "ALARM", "fire_watch %r during FIRE, expected ALARM", c.get("fire_watch")) ctx.check(c.get("fire_watch") == "ALARM", "fire_watch %r during FIRE, expected ALARM", c.get("fire_watch"))
ilk = hw.sysfs_int("cnc/interlock_circuit")
ev["interlock_circuit"] = ilk
ctx.check(ilk is not None and (ilk & 8), "the laser latch is not locked under FIRE (interlock_circuit=%s)", ilk) ctx.check(ilk is not None and (ilk & 8), "the laser latch is not locked under FIRE (interlock_circuit=%s)", ilk)
ctx.check(_tail_wait(ctx, fc, "LID IR FIRE SIGNAL (quartiles "), ctx.check(_tail_wait(ctx, fc, "LID IR FIRE SIGNAL (quartiles "),
"the FIRE line is missing from the forgectrl log") "the FIRE line is missing from the forgectrl log")
# the fail tier stops the controller and starts it again; the
# Grbl socket dies with it. Wait out the restart, prove the
# supervisor logged the stop, restore the thresholds, and open
# a fresh session on the new controller.
grbl.close()
m1 = None
t0 = time.time()
while time.time() - t0 < 60:
st, m1 = fc.get("/mode")
if isinstance(m1, dict) and m1.get("controller") == "running" and m1.get("pid") != pid0:
break
ctx.sleep(0.5)
ev["restart"] = {"pid_before": pid0, "mode_after": m1}
ctx.log("after FIRE: %s", ev["restart"])
ctx.check(m1 and m1.get("controller") == "running" and m1.get("pid") != pid0,
"the controller was not stopped and started again after FIRE: %s", m1)
ctx.check(_log_lines(FORGECTRL_LOG, off, "lid IR fire signal - the controller is stopped"),
"the supervisor did not log the fail-tier stop after FIRE")
_set_gates(ctx, fc, {"cool_fire_q1_critical": orig["cool_fire_q1_critical"], _set_gates(ctx, fc, {"cool_fire_q1_critical": orig["cool_fire_q1_critical"],
"cool_fire_q1_alert": orig["cool_fire_q1_alert"]}) "cool_fire_q1_alert": orig["cool_fire_q1_alert"]})
grbl.command("M9") ctx.check(fc.wait_idle(30, abort=ctx.aborted), "machine not idle after the FIRE restart")
_session_ended(ctx, fc, "critical leg") grbl.connect() # a fresh session on the restarted controller
# Leg 3: all four at zero: the watch off, said so, session OK. # Leg 3: all four at zero: the watch off, said so, session OK.
_set_gates(ctx, fc, {k: "0" for k in FIRE_KEYS}) _set_gates(ctx, fc, {k: "0" for k in FIRE_KEYS})
@@ -891,7 +950,7 @@ CRASH_GATES = ["crash_abort", "crash_x_alert", "crash_y_alert"]
"read as the three crash gates off by value (gates_off names them) with the " "read as the three crash gates off by value (gates_off names them) with the "
"session OK; a threshold past the register range is refused; restored, the " "session OK; a threshold past the register range is refused; restored, the "
"gates read ok. The tiers themselves (BUMP, CRASH) trip only on a physical " "gates read ok. The tiers themselves (BUMP, CRASH) trip only on a physical "
"knock inside an armed window and are commissioned at the bench, not here.") "knock inside an armed window and are proven at the bench, not here.")
def crash_watch_plumbing(ctx): def crash_watch_plumbing(ctx):
fc = ctx.forgectrl fc = ctx.forgectrl
ev = ctx.evidence ev = ctx.evidence
@@ -967,6 +1026,7 @@ def crash_watch_plumbing(ctx):
"value (gates_off names it) and the ceiling alone pauses; restored, the next " "value (gates_off names it) and the ceiling alone pauses; restored, the next "
"session runs OK with nothing off.") "session runs OK with nothing off.")
def critical_tier(ctx): def critical_tier(ctx):
from .motion import FORGECTRL_LOG, _log_lines, _log_offset
fc = ctx.forgectrl fc = ctx.forgectrl
ev = ctx.evidence ev = ctx.evidence
before = fc.settings() before = fc.settings()
@@ -1043,6 +1103,7 @@ def critical_tier(ctx):
# Leg 2: the critical line at its top is the gate off; the # Leg 2: the critical line at its top is the gate off; the
# ceiling alone pauses, and gates_off says so. # ceiling alone pauses, and gates_off says so.
at_off = _log_offset(FORGECTRL_LOG) # the off gate's line is one written after this
_set_gates(ctx, fc, {"cool_temp_critical_c": str(top)}) _set_gates(ctx, fc, {"cool_temp_critical_c": str(top)})
c = _run_session(ctx, grbl, fc, c = _run_session(ctx, grbl, fc,
lambda c: c.get("verdict") == "OVERTEMP" and "coolant_critical" in (c.get("gates_off") or []), lambda c: c.get("verdict") == "OVERTEMP" and "coolant_critical" in (c.get("gates_off") or []),
@@ -1052,7 +1113,10 @@ def critical_tier(ctx):
"with the critical line off the ceiling did not pause (verdict %s): %s", c.get("verdict"), c) "with the critical line off the ceiling did not pause (verdict %s): %s", c.get("verdict"), c)
ctx.check("coolant_critical" in (c.get("gates_off") or []), ctx.check("coolant_critical" in (c.get("gates_off") or []),
"gates_off %s lacks coolant_critical", c.get("gates_off")) "gates_off %s lacks coolant_critical", c.get("gates_off"))
ctx.check(_tail_has(fc, "gate coolant_critical OFF: cool_temp_critical_c = %g" % top), # A log line reaches the file a moment after the engine acts.
line = "gate coolant_critical OFF: cool_temp_critical_c = %g" % top
ctx.wait_for(lambda: bool(_log_lines(FORGECTRL_LOG, at_off, line)), 5, poll=0.25)
ctx.check(_log_lines(FORGECTRL_LOG, at_off, line),
"the run-start log line for the off gate is missing from the forgectrl log") "the run-start log line for the off gate is missing from the forgectrl log")
# Restore, and prove it: OK, nothing off. # Restore, and prove it: OK, nothing off.
@@ -1212,7 +1276,6 @@ LOAD_LINES = 27 # two 30 x 4 mm fills back to back, about 35
LOAD_SHARE_MIN_C = 0.3 # the tube was lit inside the window LOAD_SHARE_MIN_C = 0.3 # the tube was lit inside the window
LOAD_MARGIN_C = 1.0 # the judged rise this far under the limit LOAD_MARGIN_C = 1.0 # the judged rise this far under the limit
LOAD_VERDICT_WAIT_S = 120 # the window opens ~15 s into the session and runs 50 s LOAD_VERDICT_WAIT_S = 120 # the window opens ~15 s into the session and runs 50 s
LOAD_LOG_LINES = "300"
_LOAD_LINE_RX = None _LOAD_LINE_RX = None
@@ -1236,9 +1299,19 @@ def _load_fill():
return lines return lines
def _forgectrl_tail(fc): def _log_since(path, offset):
st, body = fc.get("/logs/tail", params={"name": "forgectrl", "lines": LOAD_LOG_LINES}) """The text a log gained since byte `offset`. A count of lines in a
return body.get("text", "") if st == 200 and isinstance(body, dict) else "" tail of fixed length cannot tell a new line from an old one: the new
line comes in at the bottom as an old one leaves at the top. A file now
shorter than the offset was rotated under the test, and all of it is
newer."""
try:
with open(path, "rb") as f:
if os.fstat(f.fileno()).st_size >= offset:
f.seek(offset)
return f.read().decode("utf-8", "replace")
except OSError:
return ""
@test("cooling.flow-under-load", title="The flow check reads true with the tube lit through its window", @test("cooling.flow-under-load", title="The flow check reads true with the tube lit through its window",
@@ -1256,6 +1329,7 @@ def _forgectrl_tail(fc):
"the tube's share off and read its baseline as a mean.") "the tube's share off and read its baseline as a mean.")
def flow_under_load(ctx): def flow_under_load(ctx):
from .laser import sample, prepare, LiveJob, ARM_CUE, run_and_sample, wait_disarm from .laser import sample, prepare, LiveJob, ARM_CUE, run_and_sample, wait_disarm
from .motion import FORGECTRL_LOG, _log_offset
fc = ctx.forgectrl fc = ctx.forgectrl
ev = ctx.evidence ev = ctx.evidence
state, check_s = _gate_state(fc, "cool_flow_check_s") state, check_s = _gate_state(fc, "cool_flow_check_s")
@@ -1263,7 +1337,7 @@ def flow_under_load(ctx):
ctx.check(state != "off" and check_s, "the flow check is off (cool_flow_check_s=%s)", check_s) ctx.check(state != "off" and check_s, "the flow check is off (cool_flow_check_s=%s)", check_s)
ctx.check(limit, "cool_flow_rise unreadable") ctx.check(limit, "cool_flow_rise unreadable")
ev.update({"check_s": check_s, "limit_c": limit}) ev.update({"check_s": check_s, "limit_c": limit})
before = _forgectrl_tail(fc).count("heater rise") off = _log_offset(FORGECTRL_LOG) # the verdict is a line written after this
# The fill ends one line length out and (LOAD_LINES - 1) pitches up; # The fill ends one line length out and (LOAD_LINES - 1) pitches up;
# the job brings the head back so the baseline finds it where it began. # the job brings the head back so the baseline finds it where it began.
back = "G0 X%g Y%g" % (-LOAD_WIDTH if LOAD_LINES % 2 else 0.0, -LOAD_PITCH * (LOAD_LINES - 1)) back = "G0 X%g Y%g" % (-LOAD_WIDTH if LOAD_LINES % 2 else 0.0, -LOAD_PITCH * (LOAD_LINES - 1))
@@ -1298,12 +1372,11 @@ def flow_under_load(ctx):
ctx.checkpoint() ctx.checkpoint()
if hw.sysfs_int("thermal/heater_pwm", 0) > 0: if hw.sysfs_int("thermal/heater_pwm", 0) > 0:
heater_seen = True heater_seen = True
text = _forgectrl_tail(fc)
if text.count("heater rise") > before:
m = None m = None
for m in _load_verdict_rx().finditer(text): for m in _load_verdict_rx().finditer(_log_since(FORGECTRL_LOG, off)):
pass pass
line = m.group(0) if m else None if m:
line = m.group(0)
break break
ctx.sleep(1) ctx.sleep(1)
ev["heater_seen"] = heater_seen ev["heater_seen"] = heater_seen
@@ -1329,3 +1402,225 @@ def flow_under_load(ctx):
ctx.check(dt is not None, "the window did not close after M2") ctx.check(dt is not None, "the window did not close after M2")
ctx.log("PASS: lit through the window, raw rise %.1f C, laser %.1f C off, judged %.1f C against %.1f " ctx.log("PASS: lit through the window, raw rise %.1f C, laser %.1f C off, judged %.1f C against %.1f "
"(dT %.1f), window closed %.1f s after M2", raw, share, rise, float(limit), float(dtc), dt) "(dT %.1f), window closed %.1f s after M2", raw, share, rise, float(limit), float(dtc), dt)
@test("cooling.fan-duty-readback", title="A fan duty a device lost is read back and put back inside a tick",
subsystem="cooling", kind="auto", mode="grbl", est_min=2,
covers=_COOL_COVERS + [("kernel-module-glowforge", "src/head*")],
requires=["kernel.latch-locked-idle"],
description="M8 opens a run session and every fan reads its run duty back from its device. "
"The head's air-assist register is then written back to the idle duty behind the "
"engine's back (what a head reset or a dropped transaction leaves) and the exhaust "
"PWM to zero: within a few ticks each reads its run duty again, the log names each "
"loss and its put-back, and the session stays OK with no airflow fault (a fan put "
"back gets its spin-up grace). Then M9 and the idle duties.")
def fan_duty_readback(ctx):
from .motion import FORGECTRL_LOG, _log_lines, _log_offset
fc = ctx.forgectrl
ev = ctx.evidence
run = {"head/air_assist_pwm": 1023, "thermal/exhaust_pwm": 65535, "thermal/intake_pwm": 43278}
with ctx.grbl() as g:
c = _hold_session(ctx, g, fc, lambda c: c.get("phase") == "run", "readback", 20)
try:
ctx.check(c.get("phase") == "run", "no run session opened: %s", c)
ctx.sleep(2)
before = {k: hw.sysfs_int(k) for k in run}
ctx.log("duties at run: %s", before)
ev["run_duties"] = before
ctx.check(all(before.get(k) == v for k, v in run.items()),
"not every fan reads its run duty: %s", before)
at_loss = _log_offset(FORGECTRL_LOG) # the put-back lines are ones written after this
hw.sysfs_write("head/air_assist_pwm", 204)
hw.sysfs_write("thermal/exhaust_pwm", 0)
lost = {k: hw.sysfs_int(k) for k in ("head/air_assist_pwm", "thermal/exhaust_pwm")}
ctx.log("written behind the engine's back: %s", lost)
back = {}
t0 = time.time()
while time.time() - t0 < 6:
ctx.sleep(0.5)
back = {k: hw.sysfs_int(k) for k in ("head/air_assist_pwm", "thermal/exhaust_pwm")}
if back.get("head/air_assist_pwm") == 1023 and back.get("thermal/exhaust_pwm") == 65535:
break
took = round(time.time() - t0, 1)
ev["put_back"] = {"duties": back, "after_s": took}
ctx.log("put back after %s s: %s", took, back)
ctx.check(back.get("head/air_assist_pwm") == 1023, "the air-assist duty was not put back: %s", back)
ctx.check(back.get("thermal/exhaust_pwm") == 65535, "the exhaust duty was not put back: %s", back)
ctx.sleep(2)
line = "head/air_assist_pwm read 204 with 1023 commanded: put back"
ctx.wait_for(lambda: bool(_log_lines(FORGECTRL_LOG, at_loss, line)), 5, poll=0.25)
ctx.check(_log_lines(FORGECTRL_LOG, at_loss, line),
"the air-assist loss is not named in the log")
line = "thermal/exhaust_pwm read 0 with 65535 commanded: put back"
ctx.wait_for(lambda: bool(_log_lines(FORGECTRL_LOG, at_loss, line)), 5, poll=0.25)
ctx.check(_log_lines(FORGECTRL_LOG, at_loss, line),
"the exhaust loss is not named in the log")
for _ in range(8):
ctx.sleep(1)
c = _cool(fc)
if c.get("verdict") != "OK":
break
ev["verdict_after"] = {"verdict": c.get("verdict"), "hold": c.get("hold"), "reason": c.get("reason")}
ctx.check(c.get("verdict") == "OK" and not c.get("hold"),
"the session did not stay OK after the put-back: %s %s", c.get("verdict"), c.get("reason"))
finally:
g.command("M9")
_session_ended(ctx, fc, "readback")
idle = {k: hw.sysfs_int(k) for k in run}
ev["idle_duties"] = idle
ctx.log("duties at idle: %s", idle)
@test("cooling.fail-tier-stop", title="A fail-tier verdict ends the controller: CRASH stops motion, "
"locks the laser, and the supervisor restarts the controller",
subsystem="cooling", kind="operator", mode="grbl", est_min=4,
covers=_COOL_COVERS + [("forgectrl", "src/super.*")],
requires=["cooling.crash-watch-plumbing", "motion.deadman"], actions=["button"],
steps=["Bed clear, lid closed, 40 mm of free +X travel. Nothing fires: the job is dark (S0).",
"Press the physical button when it lights white, once. The head moves and stops on "
"its own; the controller restarts."],
description="The engine's fail tiers (a lid IR fire signal, a head crash signal) do not "
"leave the job to the controller: after the kernel writes (motion stopped, the "
"latch locked) the engine ends the controller through the supervisor, which "
"starts it again, so no run start can relight what was locked and the sender "
"sees the job end. Provable without emission or a physical crash: the crash "
"watch's thresholds at their lowest (1) make the head's own move trip the abort "
"generator inside an armed, dark (S0) job. Expected: the engine logs the crash "
"signal, the supervisor logs the stop and starts a new controller (new pid), the "
"latch is locked, the kernel idle, no emission, the crash fault ends with the run "
"session (the engine's next tick after the controller is gone), and the "
"thresholds are put back.")
def fail_tier_stop(ctx):
from .laser import prepare, stream, sample
from .motion import FORGECTRL_LOG, _log_lines, _log_offset
fc = ctx.forgectrl
ev = ctx.evidence
before = fc.settings()
orig = {k: before.get(k, "") for k in CRASH_KEYS}
ev["orig"] = orig
ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle at the start")
st, m0 = fc.get("/mode")
ctx.check(st == 200 and isinstance(m0, dict) and m0.get("controller") == "running", "controller not running: %s", m0)
pid0 = m0.get("pid")
_set_gates(ctx, fc, {k: "1" for k in CRASH_KEYS})
off = _log_offset(FORGECTRL_LOG)
t_arm = None
try:
try:
with ctx.grbl() as g:
prepare(ctx, g)
ctx.ready("DARK JOB (S0, nothing fires). Bed clear with 40 mm of free +X travel, lid closed. "
"The head moves 20 mm and the controller restarts on its own.")
stream(g, ["G91", "G21", "M3 S0", "G4 P1", "G1 X20 F1200", "G1 X-20 F1200", "M5", "G90", "M2"])
ctx.arm_press()
t_arm = ctx.wait_for(lambda: bool((sample(ctx) or {}).get("armed")), 240)
ctx.clear_notice()
ctx.check(t_arm is not None, "the window never opened (arm refused, or no press)")
t0 = time.time()
tripped = ctx.wait_for(lambda: _cool(fc).get("verdict") == "CRASH"
or (fc.get("/mode")[1] or {}).get("pid") not in (None, pid0), 30)
ctx.log("the crash tier tripped after %s s", tripped)
except (hw.HwError, OSError) as e:
ctx.log("the Grbl connection ended with the restart: %s", e)
t_kill = time.time()
m1 = None
while time.time() - t_kill < 60:
st, m1 = fc.get("/mode")
if isinstance(m1, dict) and m1.get("controller") == "running" and m1.get("pid") != pid0:
break
ctx.sleep(0.5)
# the daemon's lines reach the log file through syslog, a moment
# after the events they name
ctx.wait_for(lambda: bool(_log_lines(FORGECTRL_LOG, off, "HEAD CRASH SIGNAL"))
and bool(_log_lines(FORGECTRL_LOG, off, "head crash signal - the controller is stopped")), 15)
engine = _log_lines(FORGECTRL_LOG, off, "HEAD CRASH SIGNAL")
super_ = _log_lines(FORGECTRL_LOG, off, "head crash signal - the controller is stopped")
smp = sample(ctx)
ilk = hw.sysfs_int("cnc/interlock_circuit")
ev["result"] = {"mode_after": m1, "engine_lines": [ln[-160:] for ln in engine[-2:]],
"super_lines": [ln[-160:] for ln in super_[-2:]],
"emission": smp and smp["emission"], "kernel": smp and smp["kstate"],
"latch_locked": ilk is not None and bool(ilk & (1 << 3))}
ctx.log("after the crash tier: %s", ev["result"])
ctx.check(engine, "the engine did not log the crash signal")
ctx.check(super_, "the supervisor did not log the fail-tier stop")
ctx.check(m1 and m1.get("pid") != pid0, "the controller was not stopped and started again: %s", m1)
ctx.check(ev["result"]["latch_locked"], "latch not locked after the crash tier")
ctx.check(not (smp and smp["emission"]), "emission during a dark job: %s", smp and smp["emission"])
ctx.check(fc.wait_idle(30, abort=ctx.aborted), "machine not idle after the restart")
# The crash fault is the run session's: the engine ends the session
# on its next 1 Hz tick after the controller is gone, and the fault
# and its hold go with it. The supervisor has the new controller up
# inside that second, so a test that returned here would hand the
# machine back with the fault it raised still standing.
cleared = ctx.wait_for(lambda: _cool(fc).get("verdict") != "CRASH", 10)
ev["result"]["fault_cleared_s"] = cleared
ctx.log("the crash fault ended with the run session after %s s: %s", cleared,
{k: _cool(fc).get(k) for k in ("phase", "verdict", "hold", "armed")})
ctx.check(cleared is not None, "the crash fault did not end with the run session: %s", _cool(fc))
finally:
fc.wait_idle(30, abort=ctx.aborted)
st, body = fc.post("/settings", params=orig)
ctx.log("restore thresholds: POST /settings %s -> %s", orig, st)
after = fc.settings()
ctx.check(all(after.get(k, "") == orig[k] for k in CRASH_KEYS),
"thresholds not restored: %s", {k: after.get(k) for k in CRASH_KEYS})
ctx.log("PASS: the crash tier stopped the job, locked the latch, and the supervisor restarted "
"the controller (pid %s -> %s)", pid0, m1 and m1.get("pid"))
@test("cooling.report-channel", title="A forged cooling report inside a run session is refused and changes nothing",
subsystem="cooling", kind="auto", mode="grbl", est_min=2,
covers=_COOL_COVERS + [("forgectrl", "src/super.*"), ("forgectrl", "src/main.c"),
("forgectrl", "src/auth.*")],
requires=["forgectrl.auth"],
description="The report channel (POST /cool/state) is what tells the engine a job is running: a "
"forged idle report would stand the fans down under a cut. M8 opens a run session "
"from the suite's Grbl client, and once the engine is in phase run with its run fan "
"profile commanded, a process on this host that is not the controller reports "
"mode=idle, armed=0: with no secret, with a made-up secret, and with a made-up "
"secret and a forged Host header. Each is refused (403). Over the next five seconds "
"the engine stays in phase run and no commanded fan duty drops. M9 "
"ends the session, and the engine leaves phase run on the controller's own report.")
def report_channel(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
forged = {"mode": "idle", "armed": "0"}
with ctx.grbl() as g:
def in_run(c):
return c.get("phase") == "run"
ctx.check(_cool(fc).get("phase") != "run", "a run session is already open")
g.command("M8")
try:
ok = ctx.wait_for(lambda: in_run(_cool(fc)), VERDICT_WAIT_S, poll=0.5)
ctx.check(ok is not None, "M8 did not open a run session: %s", _cool(fc).get("phase"))
ctx.sleep(2) # the run fan profile is commanded
before = _cool(fc)
duties0 = _duties()
ev["before"] = {"phase": before.get("phase"), "duties": duties0}
refused = {}
for name, headers in (("no secret", {}),
("a made-up secret", {"X-ForgeFIRM-Report": "0123456789abcdef" * 2}),
("a made-up secret and a forged Host",
{"X-ForgeFIRM-Report": "f" * 32, "Host": "localhost"})):
st, body = fc.post("/cool/state", params=forged, headers=headers, auth=False)
refused[name] = [st, str(body)[:100]]
ctx.check(st == 403, "a forged report (%s) -> %s %s", name, st, str(body)[:100])
ev["refused"] = refused
held = []
for _ in range(5):
ctx.sleep(1)
c = _cool(fc)
held.append([c.get("phase"), _duties()])
ev["after"] = held
ctx.log("phase and duties over 5 s after three forged idle reports: %s", held)
ctx.check(all(p == "run" for p, _d in held), "the engine left phase run: %s", [p for p, _d in held])
ctx.check(all(d[k] >= duties0[k] for _p, d in held for k in duties0),
"a commanded fan duty dropped under the forged reports: %s -> %s", duties0, held)
finally:
g.command("M9")
_session_ended(ctx, fc, "report channel")
ctx.check(_cool(fc).get("phase") != "run", "the session did not end on the controller's own report")
ctx.log("PASS: three forged idle reports from this host were refused inside a run session, and the "
"engine held its phase and its fan duties")
+113
View File
@@ -0,0 +1,113 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""cooling.intake-one-fan - a machine built with one intake fan
(cool_intake_fans 1, which the setup's airflow check writes): the second
intake tach is published as not fitted and never judged, and intake 1
still holds the intake floor. On a machine with two intake fans the
setting alone proves both halves: intake 2 turns and is not judged, and
an intake floor no fan can meet trips with intake 2 still out of it."""
from ..catalog import test
from .cooling import _COOL_COVERS, _after_session, _cool, _fan_gate, _run_session, _set_gates
INTAKE_KEYS = ("cool_intake_fans", "cool_tach_intake_min_rpm", "cool_fan_grace_s")
INTAKE_GRACE_S = "8" # past the intakes' 7 s to 90 percent, so only the leg's floor trips
INTAKE_TRIP_WAIT_S = 20 # grace + three ticks, with slack for the 1 Hz pipeline
@test("cooling.intake-one-fan", title="With one intake fan set, intake 2 is not judged and intake 1 still is",
subsystem="cooling", kind="auto", mode="grbl", est_min=3,
covers=_COOL_COVERS + [("forgectrl", "src/main.c")], requires=["cooling.fan-gate-trips"],
steps=["Machine idle, fans quiet. The test sets one intake fan, the intake floor and the spin-up "
"grace and restores all three; three short M8/M9 cycles spin the fans."],
description="cool_intake_fans 1 must take intake 2 out of the airflow gates and leave intake 1 in "
"them. With it set, a session at the cut fan profile must publish intake_2 as not fitted "
"with no floor while it reads its real speed, run OK, and judge intake_1 ok at the intake "
"floor; an intake floor at its legal maximum must then trip AIRFLOW (hold, fire blocked) "
"with intake_1 TRIPPED and intake_2 still not fitted, and the fault must end with the "
"session; restored, the next session judges intake_2 again and every fan reads at or "
"above its floor.")
def intake_one_fan(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
before = fc.settings()
orig = {k: before.get(k, "") for k in INTAKE_KEYS}
ev["orig"] = orig
ctx.log("original: %s", orig)
inl = (before.get("gates") or {}).get("cool_tach_intake_min_rpm") or {}
ctx.check(inl.get("gate") == "intake" and inl.get("off") == "low",
"settings reply does not describe the intake floor as the intake gate, off at zero: %s", inl)
ctx.check(orig["cool_intake_fans"] != "1",
"cool_intake_fans is already 1: the test starts from a machine with both intakes judged")
c0 = _cool(fc)
ctx.check(c0.get("verdict") == "OK" and c0.get("gates_off") == [],
"engine is not at OK with every gate on before the test: %s", c0)
restored = False
with ctx.grbl() as grbl:
st = grbl.status_report()
ctx.check(st["state"].startswith("Idle"), "controller is %s", st["state"])
try:
# Leg 1: one intake fan. Intake 2 still turns on this machine; it
# is measured and published not fitted, and nothing judges it.
_set_gates(ctx, fc, {"cool_intake_fans": "1", "cool_fan_grace_s": INTAKE_GRACE_S})
c = _run_session(ctx, grbl, fc,
lambda c: c.get("verdict") == "OK" and _fan_gate(c, "intake_1").get("state") == "ok",
"one intake fan leg", wait=INTAKE_TRIP_WAIT_S + 10)
g1, g2 = _fan_gate(c, "intake_1"), _fan_gate(c, "intake_2")
ev["one_fan"] = {"cool": c, "intake_1": g1, "intake_2": g2}
ctx.check(g2.get("state") == "not fitted" and (g2.get("floor") or 0) == 0,
"intake_2 is not published not fitted with no floor: %s", g2)
ctx.check((g2.get("reading") or 0) > 0,
"intake_2 reads %s: the test needs a machine whose second intake turns", g2.get("reading"))
ctx.check(c.get("verdict") == "OK", "the one intake fan session is not OK: %s", c)
ctx.check(g1.get("state") == "ok" and (g1.get("floor") or 0) > 0,
"intake_1 is not judged ok at the intake floor: %s", g1)
# Leg 2: the intake floor at its legal maximum. Intake 1 is the
# one intake left in the gates; intake 2 stays out of them.
_set_gates(ctx, fc, {"cool_tach_intake_min_rpm": str(inl.get("hi"))})
c = _run_session(ctx, grbl, fc, lambda c: c.get("verdict") == "AIRFLOW", "intake 1 trip leg",
wait=INTAKE_TRIP_WAIT_S)
g1, g2 = _fan_gate(c, "intake_1"), _fan_gate(c, "intake_2")
ev["intake_1_trip"] = {"cool": c, "intake_1": g1, "intake_2": g2}
ctx.check(c.get("verdict") == "AIRFLOW", "an intake floor of %s rpm did not trip: %s", inl.get("hi"), c)
ctx.check(c.get("fire_ok") is False and c.get("hold") is True,
"AIRFLOW without fire blocked and a hold: %s", c)
ctx.check(g1.get("state") == "TRIPPED", "intake_1 does not read TRIPPED: %s", g1)
ctx.check(g2.get("state") == "not fitted", "intake_2 was judged with one intake fan set: %s", g2)
ctx.check("intake_1" in (c.get("reason") or ""), "the reason does not name intake_1: %r", c.get("reason"))
c = _after_session(ctx, fc)
ev["intake_1_trip_after"] = c
ctx.check(c.get("verdict") == "OK" and c.get("hold") is False,
"the fan fault outlived its run session: %s", c)
# Restore, and prove it: intake 2 judged again, every fan at or
# above its floor. The restored grace is the shipped one.
_set_gates(ctx, fc, orig)
restored = True
c = _run_session(ctx, grbl, fc,
lambda c: c.get("verdict") == "OK" and not c.get("gates_off")
and all(g.get("state") == "ok" for g in (c.get("fan_gates") or {}).values()),
"restored", wait=INTAKE_TRIP_WAIT_S + 20)
ev["restored"] = c.get("fan_gates")
ctx.check(c.get("verdict") == "OK" and c.get("gates_off") == [],
"engine did not return to OK with every gate on after the restore: %s", c)
bad = {k: g for k, g in (c.get("fan_gates") or {}).items() if g.get("state") != "ok"}
ctx.check(not bad, "fans not at or above their floors after the restore: %s", bad)
finally:
if not restored:
st, body = fc.post("/settings", params=orig)
ctx.log("restore on failure: POST /settings %s -> %s", orig, st)
try:
c = _run_session(ctx, grbl, fc, lambda c: c.get("verdict") == "OK" and not c.get("gates_off"),
"restore on failure")
ctx.log("restore on failure: engine %s gates_off %s", c.get("verdict"), c.get("gates_off"))
except Exception as e: # the original failure is the one to report
ctx.log("restore on failure: run session did not complete (%s)", e)
after = fc.settings()
ctx.check(all(after.get(k, "") == orig[k] for k in INTAKE_KEYS),
"settings not restored: %s", {k: after.get(k) for k in INTAKE_KEYS})
ctx.check(fc.wait_idle(60, abort=ctx.aborted), "machine did not return to idle")
+103
View File
@@ -0,0 +1,103 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The event stream's button and its telemetry, on the machine.
Its own module, so that no other test's fingerprint moves. The stream is
opened straight at forgectrl's read-only listener, as a client off the
machine would, from a loopback source address of its own so that it takes
a place of its own among the three.
"""
import json
import threading
import time
from ..catalog import test
from .exthost import _stream
SOURCE = "127.0.0.9"
def _reader(sock, seen, stop):
"""Every event on the stream into seen, as (name, data), until stop."""
buf = b""
sock.settimeout(1.0)
while not stop.is_set():
try:
k = sock.recv(4096)
except OSError:
continue
if not k:
break
buf += k
while b"\n\n" in buf:
block, buf = buf.split(b"\n\n", 1)
name, data = None, None
for line in block.decode("utf-8", "replace").split("\n"):
if line.startswith("event: "):
name = line[7:]
elif line.startswith("data: "):
data = line[6:]
if name:
try:
seen.append((name, json.loads(data or "null"), time.time()))
except ValueError:
seen.append((name, data, time.time()))
@test("events.button-telemetry", title="The event stream tells a press of the button, and the telemetry",
subsystem="forgectrl", kind="operator", mode="grbl", est_min=2,
covers=[("forgectrl", "src/events.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/status.*")],
actions=["button"],
steps=["The machine idle; you will be told to press the button once.",
"Nothing moves and nothing fires: nothing is waiting for the press."],
description="A stream opened at forgectrl's read-only listener gets a telemetry.tick within 12 s, "
"and it is what the daemon already holds: the cooling phase, the verdict, fire_ok, the "
"two coolant temperatures, the controller's state, and the lid, with no sensor read of "
"its own. The machine idle and nothing waiting for the button, one press (the bench "
"fixture's, or the operator's) arrives as two button events, pressed true and then false, "
"in that order: the button is looked at 25 times a second, so a press shorter than the "
"rest of the stream's 5 Hz look is not lost. That a press the machine is waiting for - a "
"job arming or armed, a wizard under the lease, the daemon's own wait - is no event is "
"forgectrl's events_test's, as are update.available and setup.flag, which a machine in "
"use cannot be made to show on demand.")
def button_telemetry(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle")
code, why, sock = _stream("127.0.0.1", keep=True, source=SOURCE)
ctx.check(code == 200 and sock, "the event stream: %s %s", code, why)
seen, stop = [], threading.Event()
th = threading.Thread(target=_reader, args=(sock, seen, stop), daemon=True)
th.start()
try:
ctx.wait_for(lambda: any(n == "telemetry.tick" for n, _d, _t in seen), 14)
tick = next((d for n, d, _t in seen if n == "telemetry.tick"), None)
ev["telemetry"] = tick
ctx.log("telemetry.tick: %s", tick)
ctx.check(isinstance(tick, dict) and set(tick) == {"phase", "verdict", "fire_ok", "down_c", "up_c", "state", "lid"},
"the telemetry's fields: %s", tick)
ctx.check(tick.get("lid") in ("closed", "open") and isinstance(tick.get("down_c"), (int, float)),
"its values: %s", tick)
mark = len(seen)
def both():
got = [d for n, d, _t in seen[mark:] if n == "button"]
return got if len(got) >= 2 else None
ctx.act("button", "press", until=both, text="Press the button once: nothing is waiting for it, and "
"the press is only reported.")
presses = [d for n, d, _t in seen[mark:] if n == "button"]
ev["button"] = presses
ctx.log("button events: %s", presses)
ctx.check(presses[:2] == [{"pressed": True}, {"pressed": False}], "a press is pressed, then released: %s",
presses)
finally:
stop.set()
try:
sock.close()
except OSError:
pass
th.join(3)
+265
View File
@@ -0,0 +1,265 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""A package's page asking its own service, through the panel's relay.
Its own module rather than a test in exthost.py, for the reason extcore.py
gives: a test added there moves the fingerprint of every test in that
module. The helpers come from exthost, and importing them puts that
module's shared text in this test's fingerprint and not the other way
round. The package here is the reference package's id and key with a
service of its own, so exthost's put-back takes it away like the others.
"""
import json
import os
import stat
from ..catalog import test
from .exthost import (EXT_ROOT, FWUP, REF_ID, REF_KEY, _as_found, _forgeext, _put_back, _svc, _tree, _until,
_write)
from .setup import SAFETY_PHRASE, read_file, record_path, request
CALL_DIR = "/run/forgefirm/ext/call"
# The service: it says what it was handed, then answers its page's calls
# on the listening end the host gave it, one connection at a time.
CALL_SERVICE = r'''
import json, os, socket, stat, time
data = os.environ["FFX_DATA"]
def api(method, path):
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(5)
try:
s.connect(os.environ["FFX_API"])
s.sendall(("%s %s HTTP/1.1\r\nHost: forgeext\r\n\r\n" % (method, path)).encode())
buf = b""
while True:
c = s.recv(65536)
if not c:
break
buf += c
return json.loads(buf.partition(b"\r\n\r\n")[2] or b"null")
finally:
s.close()
def kind(n):
try:
return "socket" if stat.S_ISSOCK(os.fstat(n).st_mode) else "other"
except OSError:
return "closed"
fd = os.environ.get("FFX_CALL_FD")
listener = socket.socket(fileno=int(fd)) if fd else None
report = {"call_env": fd, "fds": {str(n): kind(n) for n in (3, 4, 5)},
"listening": listener.getsockopt(socket.SOL_SOCKET, socket.SO_ACCEPTCONN) if listener else None}
with open(os.path.join(data, "report.json.new"), "w") as f:
json.dump(report, f)
os.rename(os.path.join(data, "report.json.new"), os.path.join(data, "report.json"))
print("page-call service up", flush=True)
def answer(conn, status, doc):
body = json.dumps(doc).encode()
conn.sendall(("HTTP/1.1 %d Answer\r\nContent-Type: application/json\r\nContent-Length: %d\r\n\r\n"
% (status, len(body))).encode() + body)
while listener:
conn, _ = listener.accept()
try:
conn.settimeout(5)
buf = b""
while b"\r\n\r\n" not in buf:
c = conn.recv(4096)
if not c:
break
buf += c
head, _, body = buf.partition(b"\r\n\r\n")
lines = head.decode("latin-1").split("\r\n")
method, path = lines[0].split()[:2]
want = 0
for line in lines[1:]:
k, _, v = line.partition(":")
if k.strip().lower() == "content-length":
want = int(v)
while len(body) < want:
c = conn.recv(4096)
if not c:
break
body += c
if path == "/echo":
answer(conn, 200, {"method": method, "path": path, "body": json.loads(body or b"{}")})
elif path == "/refuse":
answer(conn, 409, {"error": "not now"})
elif path == "/me":
answer(conn, 200, (api("GET", "/v0/self") or {}).get("id"))
else:
answer(conn, 404, {"error": "there is no " + path})
except (OSError, ValueError):
pass
finally:
conn.close()
while True:
time.sleep(60)
'''
CALL_UI = "<!doctype html><title>forgetest page call</title>\n<p>the page that calls its own service</p>\n"
def _pack_caller(work):
"""The reference package's id and key, with a page and the service above: (archive, public key)."""
import io
import subprocess
import tarfile
manifest = {"manifest": 1, "id": REF_ID, "name": "forgetest page call", "version": "1.0.0",
"author": "forgetest", "license": "MIT", "api": "0.1", "runtime": "python",
"service": {"exec": "bin/caller.py"}, "capabilities": ["ui", "machine.read"]}
payload = os.path.join(work, "payload.tar.gz")
with tarfile.open(payload, "w:gz") as t:
for name, text, mode in (("manifest.json", json.dumps(manifest), 0o644),
("bin/caller.py", CALL_SERVICE, 0o755), ("ui/index.html", CALL_UI, 0o644)):
info = tarfile.TarInfo(name)
data = text.encode()
info.size, info.mode = len(data), mode
t.addfile(info, io.BytesIO(data))
conf = os.path.join(work, "fwup.conf")
_write(conf, 'meta-product = "ForgeFIRM extension"\nmeta-description = "%s"\nmeta-version = "1.0.0"\n'
'meta-platform = "forgefirm-ext"\nfile-resource payload.tar.gz {\n host-path = "%s"\n}\n'
% (REF_ID, payload))
key = os.path.join(work, REF_KEY)
raw, signed = os.path.join(work, "raw.ffx"), os.path.join(work, "caller.ffx")
for cmd in ([FWUP, "-g", "-o", key], [FWUP, "-c", "-f", conf, "-o", raw],
[FWUP, "-S", "-s", key + ".priv", "-i", raw, "-o", signed]):
subprocess.run(cmd, check=True, capture_output=True, timeout=60, cwd=work)
return signed, key + ".pub"
@test("exthost.page-call", title="A package's page asks its own service, through the panel",
subsystem="exthost", kind="auto", hardware="api", est_min=4,
covers=[("forgeext", "src/call.*"), ("forgeext", "src/run.*"), ("forgeext", "src/sandbox.*"),
("forgeext", "src/main.c"), ("forgectrl", "src/extpkg.*"), ("forgectrl", "src/main.c")],
requires=["exthost.service"],
description="A package with a page and a service may have the one ask the other. The host binds the "
"service's call socket in /run/forgefirm/ext/call, a directory that is root's alone "
"(0700), as root's 0600 socket, and hands the service only the listening end, at "
"descriptor 4 (FFX_CALL_FD=4), with nothing at 3 or 5. POST /ext/call relays a call "
"through the host's command line: a POST's JSON object reaches the service and its "
"answer comes back as its status and its JSON; a GET carries no body; the service's own "
"refusal is its status and its words, not the relay's; a service may ask the machine "
"while it answers. The form is held before anything runs: a method that is not GET or "
"POST, a GET with a body, and a body that is not a JSON object are 400, and a path with "
".. in it and a package that is not installed are the host's refusals, 409 in its words. "
"The command line answers alike. Disabled, the package's service stops, a call is refused "
"in words, and the socket's name is gone; enabled again, the service answers again. A "
"call to a frozen service and the relay's bridge in a browser are not this test's: "
"forgeext's sdk_test and the frame-isolation harness hold those. The package, the key, "
"the setting, and the setup record are put back as found.")
def page_call(ctx):
import shutil
import tempfile
fc = ctx.forgectrl
ev = ctx.evidence
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle: settings are locked")
prior = fc.settings().get("ext_enabled") or ""
raw = read_file(record_path())
found_tree = _tree(EXT_ROOT)
dir_mode = os.stat(os.path.dirname(EXT_ROOT)).st_mode & 0o7777
st, body, hdrs = request(fc.base, "GET", "/advisories/extensions", headers={"Host": fc.host_header()})
etag = hdrs.get("etag")
work = tempfile.mkdtemp(prefix="forgetest-call.")
owner_key = os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub")
sock = os.path.join(CALL_DIR, REF_ID + ".sock")
report = os.path.join(EXT_ROOT, "data", REF_ID, "report.json")
def call(method, path, body=None, id_=REF_ID):
form = {"id": id_, "method": method, "path": path}
if body is not None:
form["body"] = body
return fc.post("/ext/call", data=form)
try:
archive, pub = _pack_caller(work)
shutil.copy(pub, owner_key)
os.chmod(owner_key, 0o644)
r = _forgeext("install", archive, "--consent-community")
ctx.check(r.get("ok") is True, "the install of a package with a page and a service -> %s", r.get("error"))
st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE})
ctx.check(st == 200, "ext_enabled=1 -> %s %r", st, reply)
up = _until(ctx, lambda: _svc(REF_ID).get("state") == "running" and os.path.exists(report), 60)
ctx.check(up, "the service did not come up: %s", _svc(REF_ID))
# What the service was handed, from inside, and the socket from outside.
with open(report) as f:
inside = json.load(f)
ev["inside"] = inside
ctx.log("inside: %s", inside)
ctx.check(inside.get("call_env") == "4", "FFX_CALL_FD is %r, not 4", inside.get("call_env"))
ctx.check(inside.get("fds") == {"3": "closed", "4": "socket", "5": "closed"},
"its descriptors 3, 4, 5: %s", inside.get("fds"))
ctx.check(inside.get("listening") == 1, "descriptor 4 is not a listening socket: %s", inside.get("listening"))
d, s = os.lstat(CALL_DIR), os.lstat(sock)
ev["socket"] = {"dir": [oct(d.st_mode & 0o7777), d.st_uid], "sock": [oct(s.st_mode & 0o7777), s.st_uid]}
ctx.check(stat.S_ISDIR(d.st_mode) and d.st_uid == 0 and d.st_mode & 0o7777 == 0o700,
"%s is not root's 0700 directory: %s", CALL_DIR, ev["socket"]["dir"])
ctx.check(stat.S_ISSOCK(s.st_mode) and s.st_uid == 0 and s.st_mode & 0o777 == 0o600,
"%s is not root's 0600 socket: %s", sock, ev["socket"]["sock"])
# Calls through the relay, as the panel's bridge makes them.
st_, doc = call("POST", "/echo", '{"a": 1, "b": "two"}')
ev["post"] = [st_, doc]
ctx.log("POST /ext/call /echo -> %s %s", st_, doc)
ctx.check(st_ == 200 and (doc or {}).get("status") == 200
and doc.get("body") == {"method": "POST", "path": "/echo", "body": {"a": 1, "b": "two"}},
"a POST -> %s %s", st_, doc)
st_, doc = call("GET", "/echo")
ev["get"] = [st_, doc]
ctx.check(st_ == 200 and (doc or {}).get("body") == {"method": "GET", "path": "/echo", "body": {}},
"a GET -> %s %s", st_, doc)
st_, doc = call("GET", "/refuse")
ev["refuse"] = [st_, doc]
ctx.check(st_ == 200 and (doc or {}).get("status") == 409 and doc.get("body") == {"error": "not now"},
"the service's own refusal -> %s %s", st_, doc)
st_, doc = call("GET", "/me")
ev["me"] = [st_, doc]
ctx.check(st_ == 200 and (doc or {}).get("body") == REF_ID, "a service asking the machine while it answers "
"-> %s %s", st_, doc)
for args, want, words in ((("PUT", "/echo"), 400, "a call is GET or POST"),
(("GET", "/echo", "{}"), 400, "a GET call has no body"),
(("POST", "/echo", "[1]"), 400, "a call's body is a JSON object"),
(("GET", "/../etc"), 409, "a call's path starts with"),
(("GET", "/echo", None, "org.forgetest.nothere"), 409, "that package is not installed")):
st_, why = call(*args)
ev.setdefault("refusals", []).append([list(args), st_, why])
ctx.check(st_ == want and words in json.dumps(why), "%s -> %s %s, wanted %s %s", args, st_, why, want, words)
# The command line answers alike.
r = _forgeext("call", REF_ID, "POST", "/echo", '{"from": "the console"}')
ev["cli"] = r
ctx.check(r.get("ok") is True and r.get("status") == 200
and (r.get("body") or {}).get("body") == {"from": "the console"}, "forgeext call -> %s", r)
# Disabled: no service, no socket, a refusal in words; and back.
r = _forgeext("disable", REF_ID)
ctx.check(r.get("ok") is True, "disable -> %s", r.get("error"))
st_, why = call("GET", "/echo")
ev["disabled"] = [st_, why]
ctx.check(st_ == 409 and "disabled" in json.dumps(why), "a call to a disabled package -> %s %s", st_, why)
gone = _until(ctx, lambda: not os.path.exists(sock) and _svc(REF_ID).get("state") != "running", 30)
ctx.check(gone, "disabled, the socket's name is still there or the service still runs: %s", _svc(REF_ID))
r = _forgeext("enable", REF_ID)
ctx.check(r.get("ok") is True, "enable -> %s", r.get("error"))
back = _until(ctx, lambda: _svc(REF_ID).get("state") == "running" and os.path.exists(sock), 60)
ctx.check(back, "enabled again, the service did not come back: %s", _svc(REF_ID))
st_, doc = _until(ctx, lambda: (lambda a: a if a[0] == 200 else None)(call("GET", "/echo")), 20) or (0, None)
ctx.check(st_ == 200 and (doc or {}).get("status") == 200, "enabled again, it answers -> %s %s", st_, doc)
finally:
_put_back(ctx, fc, work, prior, etag, raw, dir_mode)
ctx.check(not os.path.exists(sock), "the call socket's name is left after the package went")
_as_found(ctx, fc, prior, raw, dir_mode, found_tree)
+188
View File
@@ -0,0 +1,188 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The catalog: the signed index, on the machine.
Its own module, for the reason extcore.py gives. The endorsement, the
judgment of each listed version, the index that never goes back, and a
withdrawn version are shown on a scratch root under /tmp with a throwaway
key standing in for the OpenGlow extension key, because only OpenGlow holds
the real one; the machine's own root is where every index that key did not
sign is refused.
"""
import hashlib
import io
import json
import os
import shutil
import subprocess
import tarfile
import tempfile
from ..catalog import test
from .exthost import FWUP, _forgeext, _write
INDEX_URL = "https://github.com/openglow-org/forgefirm-extensions-catalog/releases/latest/download/index-1.ffi"
STAGE_DIR = "/data/forgefirm/tmp"
PROBE_ID, OTHER_ID = "org.example.catprobe", "org.example.catother"
def _key(work, name):
subprocess.run([FWUP, "-g", "-o", os.path.join(work, name)], check=True, capture_output=True, timeout=60, cwd=work)
return os.path.join(work, name)
def _archive(work, name, product, members, version, key):
"""An fwup archive of the extension container's form: payload.tar.gz of members {name: text}, signed with key."""
payload = os.path.join(work, name + ".tar.gz")
with tarfile.open(payload, "w:gz") as t:
for member, text in members.items():
info = tarfile.TarInfo(member)
data = text.encode()
info.size, info.mode = len(data), 0o644
t.addfile(info, io.BytesIO(data))
conf = os.path.join(work, name + ".conf")
_write(conf, 'meta-product = "%s"\nmeta-description = "%s"\nmeta-version = "%s"\nmeta-platform = "forgefirm-ext"\n'
'file-resource payload.tar.gz {\n host-path = "%s"\n}\n' % (product, name, version, payload))
raw, signed = os.path.join(work, name + ".raw"), os.path.join(work, name + ".signed")
subprocess.run([FWUP, "-c", "-f", conf, "-o", raw], check=True, capture_output=True, timeout=60, cwd=work)
subprocess.run([FWUP, "-S", "-s", key + ".priv", "-i", raw, "-o", signed], check=True, capture_output=True,
timeout=60, cwd=work)
return signed
def _package(work, id_, key):
m = {"manifest": 1, "id": id_, "name": "catalog probe", "version": "1.0.0", "author": "forgetest", "license": "MIT",
"api": "0.1", "runtime": "data", "capabilities": []}
return _archive(work, id_, "ForgeFIRM extension", {"manifest.json": json.dumps(m)}, "1.0.0", key)
def _index(work, doc, key, version):
return _archive(work, "index-" + version, "ForgeFIRM extension index", {"index.json": json.dumps(doc)}, version, key)
def _staged():
try:
return sorted(n for n in os.listdir(STAGE_DIR) if n.startswith("ext-"))
except OSError:
return []
@test("exthost.catalog", title="The catalog: the signed index verified, kept, judged, and endorsing one key for one id",
subsystem="exthost", kind="auto", hardware="api", est_min=2,
covers=[("forgectrl", "src/extpkg.*"), ("forgectrl", "src/main.c"), ("forgeext", "src/index.*"),
("forgeext", "src/pkg.*"), ("forgeext", "src/install.*"), ("forgeext", "src/main.c")],
description="GET /ext/catalog answers the index the host keeps (or null), judged against this firmware, and "
"the one address it is fetched from. On a scratch root, 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 signed with it read as community and "
"endorsed, where before it was unverified; the same key on another id counts for nothing. Read "
"back, each listed version is judged on the machine: one that asks for a capability this "
"firmware does not have is kept and not offered, and the offer is the newest it runs. An index "
"older than the one kept is refused however well signed, and one that withdraws the probe's "
"version makes the machine refuse to install it, in OpenGlow's words. On the machine's own root, "
"the stand-in's index (signed by a key that is not the OpenGlow extension key) 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. POST /ext/catalog/get refuses an id with no such form (400), and one the "
"kept index does not list (404, or 409 with no index kept), before anything is fetched. Nothing "
"is left in the staging directory. POST /ext/catalog/refresh is not asked here: GitHub counts "
"every request of the index's address as a download, and that count is the operators'. The "
"refresh (curl, https alone and bounded, 502 in curl's words, 409 in the host's, the file "
"removed), the fetch of the offered version held to its size and SHA-256, and the tiers an "
"install takes from it, are forgectrl's extpkg_test and forgeext's install_test.")
def catalog(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle")
staged_before = _staged()
kept = _forgeext("index")
ctx.check(kept.get("ok") is True, "forgeext index -> %s", kept.get("error"))
ev["kept_before"] = (kept.get("index") or {}).get("version")
work = tempfile.mkdtemp(prefix="ft-catalog-", dir="/tmp")
try:
st, doc = fc.get("/ext/catalog")
ctx.check(st == 200 and isinstance(doc, dict) and doc.get("url") == INDEX_URL and "index" in doc
and doc.get("index") == kept.get("index") and "core_checked" in doc,
"GET /ext/catalog -> %s %s", st, doc)
# The endorsement, on a scratch root with a stand-in for the OpenGlow extension key.
root = os.path.join(work, "root")
os.makedirs(os.path.join(root, "keys"))
og, author = _key(work, "standin"), _key(work, "author")
probe, other = _package(work, PROBE_ID, author), _package(work, OTHER_ID, author)
with open(probe, "rb") as f:
data = f.read()
with open(author + ".pub") as f:
author_pub = f.read().strip()
listed = {"version": "1.0.0", "url": "https://example.org/catprobe.ffx", "sha256": hashlib.sha256(data).hexdigest(),
"size": len(data), "capabilities": [], "api": "0.1"}
later = dict(listed, version="1.1.0", url="https://example.org/catprobe-1.1.0.ffx",
capabilities=["forgetest.not-a-capability"])
entry = {"id": PROBE_ID, "name": "catalog probe", "author": "forgetest", "key": author_pub,
"versions": [later, listed]}
idx = _index(work, {"index": 1, "packages": [entry]}, og, "2026.923.2")
def scratch(*args):
return _forgeext("--root", root, "--official-key", og + ".pub", "--no-reserve", *args)
r = scratch("inspect", probe)
ctx.check(r.get("ok") is True and r.get("tier") == "unverified", "before an index: %s %s", r.get("tier"), r.get("error"))
r = scratch("index-verify", idx)
ev["scratch_verify"] = r
ctx.check(r.get("ok") is True and r.get("packages") == 1 and r.get("version") == "2026.923.2",
"the stand-in's index is kept on the scratch root: %s", r)
read = scratch("index")
mine = ((read.get("index") or {}).get("packages") or [{}])[0]
vs = {v.get("version"): v for v in mine.get("versions", [])}
ev["judged"] = {"offer": mine.get("offer"), "1.1.0": vs.get("1.1.0", {}).get("why"),
"core_checked": read.get("core_checked")}
ctx.check(mine.get("id") == PROBE_ID and len(mine.get("key_id", "")) == 64, "and read back: %s", mine)
ctx.check(vs.get("1.1.0", {}).get("usable") is False and "not a capability" in vs.get("1.1.0", {}).get("why", "")
and vs.get("1.0.0", {}).get("usable") is True and mine.get("offer") == "1.0.0",
"judged on the machine: 1.1.0 kept and not offered, 1.0.0 offered: %s", ev["judged"])
r = scratch("inspect", probe)
ev["endorsed"] = {"tier": r.get("tier"), "endorsed": r.get("endorsed")}
ctx.check(r.get("tier") == "community" and r.get("endorsed") is True,
"signed with the key it endorses for its id: community, endorsed (%s %s)", r.get("tier"), r.get("endorsed"))
r = scratch("inspect", other)
ctx.check(r.get("tier") == "unverified" and r.get("endorsed") is False,
"the same key on another id: %s %s", r.get("tier"), r.get("endorsed"))
# Never back: an older index is refused, and the one kept stays.
r = scratch("index-verify", _index(work, {"index": 1, "packages": []}, og, "2026.923.1"))
ev["older"] = r.get("error")
ctx.check(r.get("ok") is False and "never goes back" in (r.get("error") or ""), "an older index -> %s", r.get("error"))
ctx.check((scratch("index").get("index") or {}).get("version") == "2026.923.2", "and the one kept stays")
# A withdrawn version does not install, from the catalog or from anywhere else.
gone = dict(entry, versions=[later], withdrawn=[{"version": "1.0.0", "reason": "forgetest withdrew it"}])
r = scratch("index-verify", _index(work, {"index": 1, "packages": [gone]}, og, "2026.923.3"))
ctx.check(r.get("ok") is True, "an index withdrawing the probe's version is kept: %s", r.get("error"))
r = scratch("inspect", probe)
ev["withdrawn"] = r.get("error")
ctx.check(r.get("ok") is False and "OpenGlow withdrew %s 1.0.0 from its catalog: forgetest withdrew it" % PROBE_ID
in (r.get("error") or ""), "the withdrawn version -> %s", r.get("error"))
# The machine's own root keeps no index but OpenGlow's.
refused = {}
for name, path, words in (("the stand-in's index", idx, "not signed with the OpenGlow extension key"),
("a package as an index", probe, "product")):
r = _forgeext("index-verify", path)
refused[name] = r.get("error")
ctx.check(r.get("ok") is False and words in (r.get("error") or ""), "%s -> %s", name, r.get("error"))
ev["refused"] = refused
ctx.check(_forgeext("index").get("index") == kept.get("index"), "a refused index changed the one kept")
# The relay's refusals, before anything is fetched.
st, why = fc.post("/ext/catalog/get", data={"id": "org.example;reboot"})
ctx.check(st == 400, "an id with no such form -> %s %s", st, why)
st, why = fc.post("/ext/catalog/get", data={"id": PROBE_ID})
want = (404, "not in the catalog") if kept.get("index") else (409, "fetch it first")
ev["get_unlisted"] = [st, why]
ctx.check(st == want[0] and want[1] in json.dumps(why), "an id the kept index does not list -> %s %s", st, why)
ctx.check(_forgeext("index").get("index") == kept.get("index"), "the refusals changed the index kept")
ctx.check(_staged() == staged_before, "the staging directory holds %s, and held %s before", _staged(), staged_before)
finally:
shutil.rmtree(work, ignore_errors=True)
+117
View File
@@ -0,0 +1,117 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""What the extension host makes of the firmware's own version file.
A campaign only ever runs on a dev image - the release image does not
ship this suite - and a dev image stamps ${DATETIME} (dev), which is no
version at all. So the branch where a core range is actually judged
cannot be reached from here by any machine this suite runs on, and it is
not this module's to prove: that is forgeext's version_test, over the
file as each image build writes it. What is left here is the half a
machine can show, and it is the half that ties the daemon to the image:
that the host reads the image's own file, finds a build stamp, and
judges nothing rather than guessing.
Its own module rather than a test in exthost.py: a test's fingerprint
holds the shared text of the module it lives in, and the blank lines
between tests are shared text, so a test added to exthost.py moves the
fingerprint of every other test there and costs a campaign a re-run of
each - exthost.panel-install among them, which is an operator drill with
the button held. The helpers come from exthost, and importing them puts
that module's shared text in this test's fingerprint and not the other
way round.
"""
import json
import os
import subprocess
from ..catalog import test
from .exthost import FWUP, _forgeext, _read, _write
@test("exthost.core-range", title="The host reads the image's own version file and judges by it",
subsystem="exthost", kind="auto", est_min=2,
covers=[("forgeext", "src/install.*"), ("forgeext", "src/manifest.*"), ("forgeext", "src/main.c")],
description="The firmware's version reaches the host from /etc/forgefirm-version, which the image "
"build writes. This test asks the host what it made of that file rather than reading the "
"file and believing it. On the dev image a campaign runs on, the first word is a build "
"stamp and no version: inspect answers core_checked false, and a package asking for a "
"minimum far above or a maximum far below is accepted all the same, because a range "
"cannot be judged against a stamp and the host does not guess one. A dev image that "
"began stamping something version-shaped would fail here, which is the regression this "
"test is for; the judging branch belongs to forgeext's version_test, since no image "
"carrying this suite can reach it. Nothing is installed, nothing runs, and nothing "
"moves: inspect changes nothing and the work directory goes at the end.")
def core_range(ctx):
import io
import re
import shutil
import tarfile
import tempfile
ev = ctx.evidence
stamp = (_read("/etc/forgefirm-version", "").split() or [""])[0]
ctx.check(stamp, "/etc/forgefirm-version says nothing: the image did not stamp itself")
version = stamp[1:] if stamp[:1] == "v" and stamp[1:2].isdigit() else stamp
looks_like_a_version = bool(re.match(r"^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$", version))
ctx.log("/etc/forgefirm-version: %r -> %r", stamp, version)
ev["firmware_version"] = {"file": stamp, "version": version, "looks_like_a_version": looks_like_a_version}
# The suite ships on the dev image alone, so this is the only shape a
# campaign can meet. If it ever is not, the image changed and the
# judging path needs a proof that is not here.
ctx.check(not looks_like_a_version,
"this image stamped %r, which is a version: a campaign cannot run on a release image, so "
"either the dev image's stamp changed or this is not the image this suite belongs on", stamp)
work = tempfile.mkdtemp(prefix="forgetest-core-")
try:
def packed(core):
"""A data package that asks for nothing, with the core range given."""
m = {"manifest": 1, "id": "org.forgetest.corerange", "name": "forgetest core range",
"version": "1.0.0", "author": "forgetest", "license": "MIT", "api": "0.1",
"runtime": "data", "capabilities": []}
if core:
m["core"] = core
payload = os.path.join(work, "payload.tar.gz")
with tarfile.open(payload, "w:gz") as t:
data = json.dumps(m).encode()
info = tarfile.TarInfo("manifest.json")
info.size, info.mode = len(data), 0o644
t.addfile(info, io.BytesIO(data))
conf = os.path.join(work, "fwup.conf")
_write(conf, 'meta-product = "ForgeFIRM extension"\nmeta-description = "core range"\n'
'meta-version = "1.0.0"\nmeta-platform = "forgefirm-ext"\n'
'file-resource payload.tar.gz {\n host-path = "%s"\n}\n' % payload)
out = os.path.join(work, "corerange.ffx")
if os.path.exists(out):
os.remove(out)
subprocess.run([FWUP, "-c", "-f", conf, "-o", out], check=True, capture_output=True,
timeout=60, cwd=work)
return out
r = _forgeext("inspect", packed(None))
ctx.check(r.get("ok") is True, "a package with no core range was refused: %s", r.get("error"))
ctx.check(r.get("core_checked") is False,
"the host judged a range against the stamp %r: core_checked %s", stamp, r.get("core_checked"))
ev["no_range"] = r
# A stamp is no version, so neither end of a range is judged and
# neither refuses the package. The host says so rather than
# refusing on a comparison it cannot make.
ev["ranges"] = []
for core in ({"min": "99.0.0"}, {"max": "0.0.1"}, {"min": "0.0.1", "max": "0.0.2"}):
r = _forgeext("inspect", packed(core))
ctx.check(r.get("ok") is True and r.get("core_checked") is False,
"the host judged %s against the stamp anyway: %s", core, r)
ev["ranges"].append({"core": core, "inspect": r})
ctx.check("org.forgetest.corerange" not in
[x.get("id") for x in (_forgeext("list").get("packages") or [])],
"inspect installed the package: it must change nothing")
finally:
shutil.rmtree(work, ignore_errors=True)
+237
View File
@@ -0,0 +1,237 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The destinations the operator names for a package that asks for them.
Its own module rather than a test in exthost.py, for the reason extcore.py
gives. The package here is the reference package's id and key with a
service of its own, so exthost's put-back takes it away like the others.
What it dials is the machine's own DNS resolver on TCP port 53: an address
every machine on a network has, that is not the machine, and that answers.
Named by address, it gives the service no resolver of its own, so the
only way through to it is the one the operator named.
"""
import json
import os
import socket
import subprocess
from ..catalog import test
from .exthost import (EXT_ROOT, FWUP, NFT, REF_ID, REF_KEY, _as_found, _forgeext, _put_back, _read, _svc, _tree,
_until, _write)
from .forgectrl import lan_ip
from .setup import SAFETY_PHRASE, read_file, record_path, request
# The service: at every start, where it may connect and what one dial does.
DEST_SERVICE = r'''
import errno, json, os, socket, sys, time
data, target, port = os.environ["FFX_DATA"], sys.argv[1], int(sys.argv[2])
def api(method, path):
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(5)
try:
s.connect(os.environ["FFX_API"])
s.sendall(("%s %s HTTP/1.1\r\nHost: forgeext\r\n\r\n" % (method, path)).encode())
buf = b""
while True:
c = s.recv(65536)
if not c:
break
buf += c
return json.loads(buf.partition(b"\r\n\r\n")[2] or b"null")
finally:
s.close()
def dial(p):
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(4)
try:
s.connect((target, p))
return "connected"
except socket.timeout:
return "timeout"
except OSError as e:
return errno.errorcode.get(e.errno, str(e.errno))
finally:
s.close()
report = {"pid": os.getpid(), "destinations": (api("GET", "/v0/self") or {}).get("destinations"),
"dial": dial(port), "dial_other_port": dial(port + 1)}
with open(os.path.join(data, "report.json.new"), "w") as f:
json.dump(report, f)
os.rename(os.path.join(data, "report.json.new"), os.path.join(data, "report.json"))
print("destinations service up: %s" % report, flush=True)
while True:
time.sleep(60)
'''
def _resolver():
"""The first IPv4 nameserver of /etc/resolv.conf, or None."""
for line in _read("/etc/resolv.conf").splitlines():
parts = line.split()
if len(parts) >= 2 and parts[0] == "nameserver" and parts[1].count(".") == 3:
return parts[1]
return None
def _pack_dest(work, target, port):
"""The reference package's id and key, asking for the operator's destinations: (archive, public key)."""
import io
import tarfile
manifest = {"manifest": 1, "id": REF_ID, "name": "forgetest destinations", "version": "1.0.0",
"author": "forgetest", "license": "MIT", "api": "0.1", "runtime": "python",
"service": {"exec": "bin/dest.py", "args": [target, str(port)]},
"capabilities": ["net.outbound.operator"]}
payload = os.path.join(work, "payload.tar.gz")
with tarfile.open(payload, "w:gz") as t:
for name, text, mode in (("manifest.json", json.dumps(manifest), 0o644), ("bin/dest.py", DEST_SERVICE, 0o755)):
info = tarfile.TarInfo(name)
data = text.encode()
info.size, info.mode = len(data), mode
t.addfile(info, io.BytesIO(data))
conf = os.path.join(work, "fwup.conf")
_write(conf, 'meta-product = "ForgeFIRM extension"\nmeta-description = "%s"\nmeta-version = "1.0.0"\n'
'meta-platform = "forgefirm-ext"\nfile-resource payload.tar.gz {\n host-path = "%s"\n}\n'
% (REF_ID, payload))
key = os.path.join(work, REF_KEY)
raw, signed = os.path.join(work, "raw.ffx"), os.path.join(work, "dest.ffx")
for cmd in ([FWUP, "-g", "-o", key], [FWUP, "-c", "-f", conf, "-o", raw],
[FWUP, "-S", "-s", key + ".priv", "-i", raw, "-o", signed]):
subprocess.run(cmd, check=True, capture_output=True, timeout=60, cwd=work)
return signed, key + ".pub"
@test("exthost.operator-destinations", title="The operator names where a package may connect, and takes it away",
subsystem="exthost", kind="auto", hardware="api", est_min=4,
covers=[("forgeext", "src/install.*"), ("forgeext", "src/state.*"), ("forgeext", "src/run.*"),
("forgeext", "src/super.*"), ("forgeext", "src/api.*"), ("forgeext", "src/caps.*"),
("forgeext", "src/manifest.*"), ("forgeext", "src/netrules.*"), ("forgeext", "src/main.c"),
("forgectrl", "src/extpkg.*"), ("forgectrl", "src/main.c")],
requires=["exthost.service"],
description="A package that asks for net.outbound.operator may reach the places the operator names for it "
"and nothing else. Installed, it has none: its own GET /v0/self names no destination and its "
"dial to the machine's DNS resolver on TCP port 53 is refused inside the sandbox (EACCES). "
"The operator names that resolver through POST /ext/dest; the host starts the service again "
"(said in its log, and no crash), its answer names the destination, its dial connects, the "
"next port up is still refused, and the account's chain in the rule table carries the "
"address. The machine's own LAN address and loopback are refused in words, a destination "
"out of form is 400, and taking away one that was never named is refused. Taken away, the "
"service starts again with no destination, the dial is refused again, and the chain no longer "
"carries the address. The package, the key, the setting, and the setup record are put back as "
"found.")
def operator_destinations(ctx):
import shutil
import tempfile
fc = ctx.forgectrl
ev = ctx.evidence
target = _resolver()
ctx.check(target, "/etc/resolv.conf names no IPv4 resolver: there is nothing to dial")
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(4)
try:
s.connect((target, 53))
reachable = True
except OSError as e:
reachable = str(e)
finally:
s.close()
ev["resolver"] = {"address": target, "tcp_53_from_root": reachable}
ctx.check(reachable is True, "the resolver %s does not take a TCP connection on port 53 even from outside the "
"sandbox (%s): this network cannot show a way out opening", target, reachable)
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle: settings are locked")
prior = fc.settings().get("ext_enabled") or ""
raw = read_file(record_path())
found_tree = _tree(EXT_ROOT)
dir_mode = os.stat(os.path.dirname(EXT_ROOT)).st_mode & 0o7777
st, body, hdrs = request(fc.base, "GET", "/advisories/extensions", headers={"Host": fc.host_header()})
etag = hdrs.get("etag")
work = tempfile.mkdtemp(prefix="forgetest-dest.")
owner_key = os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub")
report = os.path.join(EXT_ROOT, "data", REF_ID, "report.json")
where = "%s:53" % target
def seen():
try:
with open(report) as f:
return json.load(f)
except (OSError, ValueError):
return None
def fresh(than):
r = seen()
return r if r and r.get("pid") != than and _svc(REF_ID).get("state") == "running" else None
def chain_has(addr):
"""Does the package's own chain let it through to addr on TCP 53? No chain is no."""
acct = _svc(REF_ID).get("account") or ""
if not acct.startswith("ffx"):
return False
out = subprocess.run([NFT, "list", "chain", "inet", "ffx", "u%d" % (800 + int(acct[3:]))], capture_output=True,
text=True, timeout=30).stdout
return ("daddr %s tcp dport 53 accept" % addr) in out
try:
archive, pub = _pack_dest(work, target, 53)
shutil.copy(pub, owner_key)
os.chmod(owner_key, 0o644)
r = _forgeext("install", archive, "--consent-community")
ctx.check(r.get("ok") is True, "the install of a package asking for the operator's destinations -> %s",
r.get("error"))
st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE})
ctx.check(st == 200, "ext_enabled=1 -> %s %r", st, reply)
first = _until(ctx, lambda: fresh(None), 60)
ctx.check(first, "the service did not come up: %s", _svc(REF_ID))
ev["none_named"] = first
ctx.log("none named: %s", first)
ctx.check(first.get("destinations") == [] and first.get("dial") == "EACCES",
"with nothing named it may reach nothing, and the sandbox refuses the dial: %s", first)
ctx.check(not chain_has(target), "the rule table carries %s before the operator named it", target)
# Refusals, in the host's words or the relay's.
refusals = []
for dest, want, words in (("%s:80" % lan_ip(), 409, "is this machine"), ("127.0.0.1:80", 409, "is this machine"),
("--root", 400, "host:port"), ("%s" % target, 409, "host:port")):
st_, why = fc.post("/ext/dest", data={"id": REF_ID, "action": "add", "dest": dest})
refusals.append([dest, st_, why])
ctx.check(st_ == want and words in json.dumps(why), "add %s -> %s %s, wanted %s %s", dest, st_, why, want, words)
st_, why = fc.post("/ext/dest", data={"id": REF_ID, "action": "remove", "dest": where})
refusals.append(["remove " + where, st_, why])
ctx.check(st_ == 409 and "is not one of" in json.dumps(why), "taking away one never named -> %s %s", st_, why)
ev["refusals"] = refusals
# Named: started again with its way out.
st_, doc = fc.post("/ext/dest", data={"id": REF_ID, "action": "add", "dest": where})
mine = [p for p in (doc or {}).get("packages", []) if p.get("id") == REF_ID] if isinstance(doc, dict) else []
ctx.check(st_ == 200 and mine and mine[0].get("destinations") == [where], "POST /ext/dest add %s -> %s %s",
where, st_, doc if st_ != 200 else mine)
named = _until(ctx, lambda: fresh(first.get("pid")), 60)
ev["named"] = named
ctx.log("named: %s", named)
ctx.check(named and named.get("destinations") == [where] and named.get("dial") == "connected"
and named.get("dial_other_port") == "EACCES",
"named, it is started again, names it, reaches it, and still not the next port: %s", named)
ctx.check(chain_has(target), "the rule table does not carry %s once named", target)
svc = _svc(REF_ID)
ctx.check(svc.get("state") == "running" and "quarantined" not in (svc.get("reason") or ""),
"the restart was no crash: %s", svc)
# Taken away: started again with none.
st_, doc = fc.post("/ext/dest", data={"id": REF_ID, "action": "remove", "dest": where})
ctx.check(st_ == 200, "POST /ext/dest remove %s -> %s %s", where, st_, doc)
gone = _until(ctx, lambda: fresh(named.get("pid") if named else None), 60)
ev["taken_away"] = gone
ctx.check(gone and gone.get("destinations") == [] and gone.get("dial") == "EACCES",
"taken away, it starts again and reaches nothing: %s", gone)
ctx.check(not chain_has(target), "the rule table still carries %s after it was taken away", target)
finally:
_put_back(ctx, fc, work, prior, etag, raw, dir_mode)
_as_found(ctx, fc, prior, raw, dir_mode, found_tree)
File diff suppressed because it is too large Load Diff
+154
View File
@@ -0,0 +1,154 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The host's own events: a service is told before it is stopped.
Its own module, for the reason extcore.py gives. The package here is the
reference package's id and key with a service of its own, so exthost's
put-back takes it away like the others.
"""
import json
import os
import subprocess
import time
from ..catalog import test
from .exthost import (EXT_ROOT, FWUP, REF_ID, REF_KEY, _as_found, _forgeext, _put_back, _svc, _tree, _until, _write)
from .setup import SAFETY_PHRASE, read_file, record_path, request
# The service: it follows the host's event feed and keeps every host event it reads, with the
# monotonic time it read it, in its data directory.
LIFE_SERVICE = r'''
import json, os, socket, time
data = os.environ["FFX_DATA"]
def api(body):
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(40)
try:
s.connect(os.environ["FFX_API"])
payload = json.dumps(body).encode()
s.sendall(b"POST /v0/events HTTP/1.1\r\nHost: forgeext\r\nContent-Type: application/json\r\n"
b"Content-Length: %d\r\n\r\n" % len(payload) + payload)
buf = b""
while True:
c = s.recv(65536)
if not c:
break
buf += c
return json.loads(buf.partition(b"\r\n\r\n")[2] or b"null")
finally:
s.close()
def keep(name, doc):
with open(os.path.join(data, name + ".new"), "w") as f:
json.dump(doc, f)
os.rename(os.path.join(data, name + ".new"), os.path.join(data, name))
place = api({})["next"]
keep("ready.json", {"place": place, "t": time.monotonic()})
got = []
while True:
ans = api({"since": place, "wait": 20})
for e in ans.get("events") or []:
if e.get("event", "").startswith("ext."):
got.append({"event": e["event"], "data": e.get("data"), "t": time.monotonic()})
keep("host-events.json", got)
place = ans.get("next", place)
'''
def _pack_life(work):
import io
import tarfile
manifest = {"manifest": 1, "id": REF_ID, "name": "forgetest lifecycle", "version": "1.0.0",
"author": "forgetest", "license": "MIT", "api": "0.1", "runtime": "python",
"service": {"exec": "bin/life.py"}, "capabilities": ["events"]}
payload = os.path.join(work, "payload.tar.gz")
with tarfile.open(payload, "w:gz") as t:
for name, text, mode in (("manifest.json", json.dumps(manifest), 0o644), ("bin/life.py", LIFE_SERVICE, 0o755)):
info = tarfile.TarInfo(name)
data = text.encode()
info.size, info.mode = len(data), mode
t.addfile(info, io.BytesIO(data))
conf = os.path.join(work, "fwup.conf")
_write(conf, 'meta-product = "ForgeFIRM extension"\nmeta-description = "%s"\nmeta-version = "1.0.0"\n'
'meta-platform = "forgefirm-ext"\nfile-resource payload.tar.gz {\n host-path = "%s"\n}\n'
% (REF_ID, payload))
key = os.path.join(work, REF_KEY)
raw, signed = os.path.join(work, "raw.ffx"), os.path.join(work, "life.ffx")
for cmd in ([FWUP, "-g", "-o", key], [FWUP, "-c", "-f", conf, "-o", raw],
[FWUP, "-S", "-s", key + ".priv", "-i", raw, "-o", signed]):
subprocess.run(cmd, check=True, capture_output=True, timeout=60, cwd=work)
return signed, key + ".pub"
@test("exthost.lifecycle", title="A package is told, in the feed it reads, before the host stops it",
subsystem="exthost", kind="auto", hardware="api", est_min=3,
covers=[("forgeext", "src/run.*"), ("forgeext", "src/machine.*"), ("forgeext", "src/evfeed.*"),
("forgeext", "src/api.*")],
requires=["exthost.service"],
description="A package that holds events reads the host's own events beside the machine's. With the "
"package running and following its feed, extensions are turned off: the host puts "
"ext.shutdown into the feed with the reason (\"extensions are off (ext_enabled)\"), and "
"stops the service a second later, so that the package has read it - the event is in its "
"data directory, read before its process ended. Holds do not wait for that second: they "
"follow extensions off at once, as exthost.hold-pause-tier holds. ext.will_freeze and "
"ext.thawed, which an armed window brings, are the automation package's host test's "
"(automation_test.py, where a service with job_time.run reads both). The package, the "
"key, the setting, and the setup record are put back as found.")
def lifecycle(ctx):
import shutil
import tempfile
fc = ctx.forgectrl
ev = ctx.evidence
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle: settings are locked")
prior = fc.settings().get("ext_enabled") or ""
raw = read_file(record_path())
found_tree = _tree(EXT_ROOT)
dir_mode = os.stat(os.path.dirname(EXT_ROOT)).st_mode & 0o7777
st, body, hdrs = request(fc.base, "GET", "/advisories/extensions", headers={"Host": fc.host_header()})
etag = hdrs.get("etag")
work = tempfile.mkdtemp(prefix="forgetest-life.")
owner_key = os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub")
data = os.path.join(EXT_ROOT, "data", REF_ID)
def doc(name):
try:
with open(os.path.join(data, name)) as f:
return json.load(f)
except (OSError, ValueError):
return None
try:
archive, pub = _pack_life(work)
shutil.copy(pub, owner_key)
os.chmod(owner_key, 0o644)
r = _forgeext("install", archive, "--consent-community")
ctx.check(r.get("ok") is True, "the install -> %s", r.get("error"))
st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE})
ctx.check(st == 200, "ext_enabled=1 -> %s %r", st, reply)
ready = _until(ctx, lambda: _svc(REF_ID).get("state") == "running" and doc("ready.json"), 60)
ctx.check(ready, "the service did not come up and follow its feed: %s", _svc(REF_ID))
time.sleep(1.0)
pid = _svc(REF_ID).get("pid")
st, reply = fc.post("/settings", data={"ext_enabled": "0"})
ctx.check(st == 200, "ext_enabled=0 -> %s %r", st, reply)
stopped = _until(ctx, lambda: _svc(REF_ID).get("state") != "running" and time.monotonic(), 20)
ctx.check(stopped, "the service was not stopped: %s", _svc(REF_ID))
ctx.check(not os.path.exists("/proc/%s" % pid), "its process %s outlived the stop", pid)
got = doc("host-events.json") or []
ev["host_events"] = got
ctx.log("what it read: %s", got)
ctx.check(len(got) >= 1 and got[0].get("event") == "ext.shutdown"
and (got[0].get("data") or {}).get("reason") == "extensions are off (ext_enabled)",
"it read ext.shutdown, with the reason, before it was stopped: %s", got)
finally:
_put_back(ctx, fc, work, prior, etag, raw, dir_mode)
_as_found(ctx, fc, prior, raw, dir_mode, found_tree)
+238
View File
@@ -0,0 +1,238 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""An M-code a package answers, on the machine.
Its own module, for the reason extcore.py gives. The package is the
reference package's id and key with a service of its own that answers
M160 on its call socket, so exthost's put-back takes it away like the
others. The jobs are dark: they move and never command the laser.
"""
import json
import os
import subprocess
from ..catalog import test
from .exthost import (EXT_ROOT, FWUP, REF_ID, REF_KEY, _as_found, _forgeext, _put_back, _svc, _tree, _until, _write)
from .motion import _job, _job_post, _job_wait, _words, kernel_start, kernel_xy_mm, machine_idle
from .setup import SAFETY_PHRASE, read_file, record_path, request
# The service: it answers M160 on the call socket the host hands it (fd 4), and keeps every call it was
# asked, with the monotonic time it was asked, in its data directory. P1 takes 2 s and is done; any
# other P is refused.
MC_SERVICE = r'''
import json, os, socket, time
data = os.environ["FFX_DATA"]
lst = socket.socket(fileno=int(os.environ["FFX_CALL_FD"]))
calls = []
while True:
c, _ = lst.accept()
try:
buf = b""
while b"\r\n\r\n" not in buf:
k = c.recv(4096)
if not k:
break
buf += k
head, _, body = buf.partition(b"\r\n\r\n")
n = 0
for line in head.split(b"\r\n")[1:]:
k, _, v = line.partition(b":")
if k.strip().lower() == b"content-length":
n = int(v)
while len(body) < n:
k = c.recv(4096)
if not k:
break
body += k
req = json.loads(body[:n] or b"{}")
path = head.split(b" ")[1].decode()
t0 = time.monotonic()
words = req.get("words", {})
if path == "/mcode" and words.get("P") == 1:
time.sleep(2.0)
status, ans = 200, {"message": "forgetest P1"}
elif path == "/mcode":
status, ans = 409, {"error": "the stand-in refused"}
else:
status, ans = 404, {"error": "no such call"}
calls.append({"path": path, "req": req, "t": t0, "status": status})
with open(os.path.join(data, "calls.json.new"), "w") as f:
json.dump(calls, f)
os.rename(os.path.join(data, "calls.json.new"), os.path.join(data, "calls.json"))
out = json.dumps(ans).encode()
c.sendall(b"HTTP/1.1 %d X\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n"
% (status, len(out)) + out)
except Exception:
pass
finally:
c.close()
'''
def _pack_mcode(work):
import io
import tarfile
manifest = {"manifest": 1, "id": REF_ID, "name": "forgetest M-code", "version": "1.0.0",
"author": "forgetest", "license": "MIT", "api": "0.1", "runtime": "python",
"service": {"exec": "bin/mcode.py"}, "capabilities": ["mcode:160", "job_time.run"]}
payload = os.path.join(work, "payload.tar.gz")
with tarfile.open(payload, "w:gz") as t:
for name, text, mode in (("manifest.json", json.dumps(manifest), 0o644), ("bin/mcode.py", MC_SERVICE, 0o755)):
info = tarfile.TarInfo(name)
data = text.encode()
info.size, info.mode = len(data), mode
t.addfile(info, io.BytesIO(data))
conf = os.path.join(work, "fwup.conf")
_write(conf, 'meta-product = "ForgeFIRM extension"\nmeta-description = "%s"\nmeta-version = "1.0.0"\n'
'meta-platform = "forgefirm-ext"\nfile-resource payload.tar.gz {\n host-path = "%s"\n}\n'
% (REF_ID, payload))
key = os.path.join(work, REF_KEY)
raw, signed = os.path.join(work, "raw.ffx"), os.path.join(work, "mcode.ffx")
for cmd in ([FWUP, "-g", "-o", key], [FWUP, "-c", "-f", conf, "-o", raw],
[FWUP, "-S", "-s", key + ".priv", "-i", raw, "-o", signed]):
subprocess.run(cmd, check=True, capture_output=True, timeout=60, cwd=work)
return signed, key + ".pub"
def _port_state(fc):
st, body = fc.get("/motion/state")
return body if st == 200 and isinstance(body, dict) else {}
@test("exthost.mcode", title="A job waits at an M-code a package answers, dark and still",
subsystem="exthost", kind="auto", mode="grbl", est_min=4,
covers=[("grblhal-glowforge", "src/glowforge_mcode.*"), ("grblhal-glowforge", "src/ctlport.*"),
("forgectrl", "src/mcode.*"), ("forgectrl", "src/extpkg.*"), ("forgectrl", "src/grblport.*"),
("forgeext", "src/main.c"), ("forgeext", "src/run.*"), ("forgeext", "src/caps.*"),
("forgeext", "src/manifest.*"), ("forgeext", "src/call.*")],
requires=["exthost.service", "motion.job"],
steps=["Bed clear; the head needs 10 mm of free travel toward +X. Nobody touches the gantry."],
description="A package that asks for mcode:160 and job_time.run, the one granted, is installed and "
"turned on; its 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) plays through POST "
"/job: at the M-code the controller's port state names M160 with P 1, the head stands "
"still across the wait (the kernel's counters and the port's position unchanged over "
"1 s), and the service answers after 2 s; the job then goes on and ends done, every line "
"acknowledged, the head back where it began, the service asked once with the code and "
"its words, no discharge. A job that names M161, which nothing answers, fails at that "
"line with nothing moved. A job at M160 P2, which the service refuses, is held: the "
"port's state goes to Hold with the head still, and the job is aborted from there. "
"Everything is put back as exthost.service puts it back; the refusals the controller "
"makes of a table or an answer out of form, the timeout, the reset, and the barrier's "
"darkness under an open armed window are the driver's mcode_test harness's.")
def mcode(ctx):
import tempfile
fc = ctx.forgectrl
ev = ctx.evidence
machine_idle(ctx)
prior = fc.settings().get("ext_enabled") or ""
raw = read_file(record_path())
found_tree = _tree(EXT_ROOT)
dir_mode = os.stat(os.path.dirname(EXT_ROOT)).st_mode & 0o7777
st, body, hdrs = request(fc.base, "GET", "/advisories/extensions", headers={"Host": fc.host_header()})
etag = hdrs.get("etag")
work = tempfile.mkdtemp(prefix="forgetest-ffx.")
owner_key = os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub")
calls_file = os.path.join(EXT_ROOT, "data", REF_ID, "calls.json")
def calls():
try:
return json.loads(read_file(calls_file) or "[]")
except ValueError:
return []
try:
archive, pub = _pack_mcode(work)
import shutil
shutil.copy(pub, owner_key)
os.chmod(owner_key, 0o644)
r = _forgeext("install", archive, "--consent-community", "--grant", "job_time.run")
ctx.check(r.get("ok") is True, "the install with job_time.run granted -> %s", r.get("error"))
st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE})
ctx.check(st == 200, "ext_enabled=1 -> %s %r", st, reply)
x = _until(ctx, lambda: _svc(REF_ID) if _svc(REF_ID).get("state") == "running" else None, 90, poll=0.5)
ctx.check(x, "the service is not running: %s", _svc(REF_ID))
def listed():
try:
doc = json.loads(read_file("/run/forgefirm/ext/status.json") or "{}")
except ValueError:
return None
return doc.get("mcodes") if doc.get("mcodes") == [{"code": 160, "id": REF_ID}] else None
ev["status_mcodes"] = _until(ctx, listed, 30, poll=0.5)
ctx.check(ev["status_mcodes"], "the host's status does not name the package as the one that answers M160")
ctx.sleep(3.0) # the relay tells the controller within its 2 s
# A dark job that waits at M160 P1 for 2 s.
x0, _y0 = kernel_start(ctx)
st, body = _job_post(fc, "(forgetest exthost.mcode)\nG21\nG91\nG1 X5 F1200\nM160 P1\nG1 X-5 F1200\nG90\n")
ctx.check(st == 200 and isinstance(body, dict) and body.get("state") == "running",
"POST /job -> %s %s", st, _words(body)[:200])
waiting = _until(ctx, lambda: _port_state(fc) if isinstance(_port_state(fc).get("mcode"), dict) else None,
20, poll=0.1)
ctx.check(waiting and waiting["mcode"].get("code") == 160 and waiting["mcode"].get("words") == {"P": 1},
"the port's state does not name M160 P1 while the job waits: %s", waiting)
k1 = kernel_xy_mm(ctx)
ctx.sleep(1.0)
k2 = kernel_xy_mm(ctx)
still = _port_state(fc)
ev["wait"] = {"port": waiting, "kernel": [k1, k2], "port_after_1s": still}
ctx.log("the job waits at M160: port %s, kernel %s -> %s", waiting, k1, k2)
ctx.check(abs(k2[0] - k1[0]) < 0.001 and abs(k2[1] - k1[1]) < 0.001,
"the head moved while the job waited: %s -> %s", k1, k2)
ctx.check(still.get("mpos") == waiting.get("mpos") and still.get("state") == "Idle",
"the port's position or state moved while the job waited: %s -> %s", waiting, still)
ctx.check(abs(k1[0] - x0 - 5.0) < 0.05, "the job did not wait at the M-code 5 mm out: %.3f", k1[0] - x0)
rec, far = _job_wait(ctx, fc, 40, x0)
x1 = kernel_xy_mm(ctx)[0]
got = calls()
ev["job"] = {"record": rec, "farthest_mm": round(far, 3), "end_mm": round(x1 - x0, 3), "calls": got}
ctx.log("the job at M160 P1: %s", ev["job"])
ctx.check(rec["state"] == "done" and rec["reason"] == "", "the job did not end well: %s", rec)
ctx.check(rec["sent"] == rec["acked"] == rec["lines"] + 1, "lines %s, sent %s, acked %s",
rec["lines"], rec["sent"], rec["acked"])
ctx.check(rec["lit"] is False and rec["emission"]["laser_on_samples"] == 0, "a dark job's witnesses: %s",
rec["emission"])
ctx.check(abs(x1 - x0) < 0.1, "the job did not end where it began: %.3f mm off", x1 - x0)
ctx.check(len(got) == 1 and got[0]["path"] == "/mcode" and got[0]["req"] == {"code": 160, "words": {"P": 1}}
and got[0]["status"] == 200, "the service was not asked M160 P1 once: %s", got)
# M161: nothing answers it, and the job stops where it is parsed.
x0 = kernel_xy_mm(ctx)[0]
st, body = _job_post(fc, "G21\nG91\nM161\nG1 X5 F1200\nG1 X-5 F1200\nG90\n")
rec, far = _job_wait(ctx, fc, 20, x0)
ev["unanswered"] = {"record": rec, "farthest_mm": round(far, 3)}
ctx.log("the job at M161: %s", ev["unanswered"])
ctx.check(rec["state"] == "failed" and "answered error:20" in (rec.get("reason") or ""),
"a job at M161 did not fail with error:20: %s", rec)
ctx.check(far < 0.01, "a job at an M-code nothing answers moved the head %.3f mm", far)
# M160 P2: the service refuses it, and the job is held there.
x0 = kernel_xy_mm(ctx)[0]
st, body = _job_post(fc, "G21\nG91\nM160 P2\nG1 X5 F1200\nG1 X-5 F1200\nG90\n")
held = _until(ctx, lambda: _port_state(fc) if _port_state(fc).get("state") == "Hold" else None, 30, poll=0.2)
ev["refused"] = {"port": held, "calls": calls()}
ctx.log("the job at M160 P2: %s", ev["refused"])
ctx.check(held and held.get("mcode") is None, "a job at a refused M160 is not held: %s", _port_state(fc))
ctx.sleep(1.0)
ctx.check(abs(kernel_xy_mm(ctx)[0] - x0) < 0.01, "the held job moved the head")
st, body = fc.post("/job/abort")
ctx.check(st == 200, "POST /job/abort -> %s %s", st, _words(body)[:160])
rec, _far = _job_wait(ctx, fc, 20, x0)
ctx.check(rec["state"] == "failed", "the held job did not end aborted: %s", rec)
if _port_state(fc).get("state") == "Alarm":
st, body = _job_post(fc, "G21\n", unlock="1")
_job_wait(ctx, fc, 20, x0)
ctx.check(_port_state(fc).get("state") == "Idle", "the controller is not idle after the abort: %s",
_port_state(fc))
finally:
# A check that failed mid-job leaves the job running; it holds the settings until it ends.
if _job(fc).get("state") == "running":
fc.post("/job/abort")
_job_wait(ctx, fc, 20)
_put_back(ctx, fc, work, prior, etag, raw, dir_mode)
_as_found(ctx, fc, prior, raw, dir_mode, found_tree)
+286
View File
@@ -0,0 +1,286 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""An extension package keeps the Grbl sender out, by the operator's grant.
Its own module rather than a test in exthost.py, for the reason extcore.py
gives: a test added there moves the fingerprint of every test in that
module. The package is the reference package's id and key with a service
that claims the sender out as it starts and says how its claim stands
every half second, so exthost's put-back takes it away like the others.
"""
import json
import os
import socket
import time
from ..catalog import test
from .exthost import (EXT_ROOT, FWUP, REF_ID, REF_KEY, _as_found, _forgeext, _put_back, _svc, _tree, _until,
_write)
from .setup import SAFETY_PHRASE, read_file, record_path, request
CLAIM_DIR = "/run/forgefirm/sender-out"
GRBL_STATE = "/run/forgefirm/grbl.state"
KEEP_OUT_MSG = b"[MSG:The machine is in use: senders are kept out for now]"
# The service: it claims the sender out once, as it starts, writes the
# machine's answer, and then writes how its claim stands, twice a second.
KEEPER_SERVICE = r'''
import json, os, socket, time
data = os.environ["FFX_DATA"]
def api(method, path, body=None):
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(10)
try:
s.connect(os.environ["FFX_API"])
payload = b"" if body is None else json.dumps(body).encode()
head = "%s %s HTTP/1.1\r\nHost: forgeext\r\n" % (method, path)
if body is not None:
head += "Content-Type: application/json\r\nContent-Length: %d\r\n" % len(payload)
s.sendall(head.encode() + b"\r\n" + payload)
buf = b""
while True:
c = s.recv(65536)
if not c:
break
buf += c
top, _, content = buf.partition(b"\r\n\r\n")
return int(top.split()[1]), json.loads(content or b"null")
finally:
s.close()
def write(name, doc):
p = os.path.join(data, name)
with open(p + ".new", "w") as f:
json.dump(doc, f)
os.rename(p + ".new", p)
status, doc = api("POST", "/v0/sender", {"out": True})
write("claim.json", {"status": status, "doc": doc})
print("keeper up: %s %s" % (status, doc), flush=True)
while True:
try:
write("state.json", dict(zip(("status", "doc"), api("GET", "/v0/sender"))))
except OSError:
pass
time.sleep(0.5)
'''
def _pack_keeper(work):
"""The reference package's id and key, with the service above: (archive, public key)."""
import io
import subprocess
import tarfile
manifest = {"manifest": 1, "id": REF_ID, "name": "forgetest sender keeper", "version": "1.0.0",
"author": "forgetest", "license": "MIT", "api": "0.1", "runtime": "python",
"service": {"exec": "bin/keeper.py"}, "capabilities": ["sender.keep_out"]}
payload = os.path.join(work, "payload.tar.gz")
with tarfile.open(payload, "w:gz") as t:
for name, text, mode in (("manifest.json", json.dumps(manifest), 0o644), ("bin/keeper.py", KEEPER_SERVICE, 0o755)):
info = tarfile.TarInfo(name)
data = text.encode()
info.size, info.mode = len(data), mode
t.addfile(info, io.BytesIO(data))
conf = os.path.join(work, "fwup.conf")
_write(conf, 'meta-product = "ForgeFIRM extension"\nmeta-description = "%s"\nmeta-version = "1.0.0"\n'
'meta-platform = "forgefirm-ext"\nfile-resource payload.tar.gz {\n host-path = "%s"\n}\n'
% (REF_ID, payload))
key = os.path.join(work, REF_KEY)
raw, signed = os.path.join(work, "raw.ffx"), os.path.join(work, "keeper.ffx")
for cmd in ([FWUP, "-g", "-o", key], [FWUP, "-c", "-f", conf, "-o", raw],
[FWUP, "-S", "-s", key + ".priv", "-i", raw, "-o", signed]):
subprocess.run(cmd, check=True, capture_output=True, timeout=60, cwd=work)
return signed, key + ".pub"
def _lan_address():
"""This machine's own address on the network: a sender that connects to it comes from the network
as the controller sees it, and not from the loopback."""
u = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
u.connect(("192.0.2.1", 9))
return u.getsockname()[0]
except OSError:
return ""
finally:
u.close()
def _sender(addr, secs=2.0):
"""A Grbl sender that connects and listens: (what it read, whether the controller closed it)."""
s = socket.create_connection((addr, 23), timeout=5)
s.settimeout(0.3)
got, closed, end = b"", False, time.time() + secs
try:
while time.time() < end:
try:
c = s.recv(4096)
except socket.timeout:
continue
if not c:
closed = True
break
got += c
finally:
s.close()
return got, closed
def _read_json(path):
try:
with open(path) as f:
return json.load(f)
except (OSError, ValueError):
return None
@test("exthost.sender-keep-out", title="An extension package keeps the Grbl sender out, by the operator's grant",
subsystem="exthost", kind="auto", hardware="api", est_min=4,
covers=[("forgectrl", "src/senderout.*"), ("forgectrl", "src/lease.*"), ("forgectrl", "src/grblport.*"),
("forgectrl", "src/main.c"), ("forgectrl", "src/status.*"), ("forgeext", "src/api.*"),
("forgeext", "src/run.*"), ("forgeext", "src/machine.*"), ("forgeext", "src/holdkeep.*"),
("forgeext", "src/caps.*"), ("grblhal-glowforge", "src/ctlport.*"), ("grblhal-glowforge", "src/serial.*")],
requires=["exthost.service"],
description="A package the operator granted sender.keep_out keeps the Grbl sender out while it uses the "
"machine: the test installs a reference package whose service asks POST /v0/sender "
"{\"out\": true} as it starts, on an idle machine with no sender connected. The machine "
"grants it: /status names the package in sender_out and holds the machine lease as "
"ext:<id>, of the extension kind; the host keeps the claim fresh under "
"/run/forgefirm/sender-out; a Grbl sender that connects from the network (the machine's "
"own LAN address) reads the controller's message and is closed at once, and one from "
"this host still connects. The operator lets the sender back in from the panel "
"(POST /motion/sender out=0): the holder and the lease go, a sender from the network is "
"welcomed again, and the package's service reads released. Turned off and on, the "
"package's service claims again; turned off with its claim standing, forgectrl ends the "
"claim within seconds by itself, with the notice that names it, and lets senders in; the "
"notice is cleared from the panel. The package, the key, the setting, and the setup "
"record are put back as found.")
def sender_keep_out(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle: settings are locked")
st, m0 = fc.get("/mode")
ctx.check(st == 200 and isinstance(m0, dict) and m0.get("mode") == "grbl" and m0.get("controller") == "running",
"start this test in grbl mode with the controller running (now %s)", m0)
grbl = _read_json(GRBL_STATE) or {}
ctx.check(not (grbl.get("sender") or {}).get("connected"),
"a Grbl sender is connected: close it first (this test would disconnect it)")
lan = _lan_address()
ctx.check(lan and not lan.startswith("127."), "the machine has no address on the network: %r", lan)
prior = fc.settings().get("ext_enabled") or ""
raw = read_file(record_path())
found_tree = _tree(EXT_ROOT)
dir_mode = os.stat(os.path.dirname(EXT_ROOT)).st_mode & 0o7777
st, body, hdrs = request(fc.base, "GET", "/advisories/extensions", headers={"Host": fc.host_header()})
etag = hdrs.get("etag")
import shutil
import tempfile
work = tempfile.mkdtemp(prefix="forgetest-sender.")
owner_key = os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub")
data = os.path.join(EXT_ROOT, "data", REF_ID)
claim_file = os.path.join(CLAIM_DIR, REF_ID + ".json")
def sender_out():
return fc.status().get("sender_out") or {}
def holder():
return (sender_out().get("holder") or {}).get("id")
try:
got, closed = _sender(lan)
ev["before"] = [got[:80].decode(errors="replace"), closed]
ctx.check(b"Grbl" in got and not closed, "before: a sender from the network is not welcomed: %r", got[:80])
archive, pub = _pack_keeper(work)
shutil.copy(pub, owner_key)
os.chmod(owner_key, 0o644)
r = _forgeext("install", archive, "--consent-community")
ctx.check(r.get("ok") is False and "sender.keep_out" in (r.get("error") or ""),
"an install without the operator's grant is refused -> %s", r.get("error"))
r = _forgeext("install", archive, "--consent-community", "--grant", "sender.keep_out")
ctx.check(r.get("ok") is True, "the install with the grant -> %s", r.get("error"))
st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE})
ctx.check(st == 200, "ext_enabled=1 -> %s %r", st, reply)
up = _until(ctx, lambda: _svc(REF_ID).get("state") == "running" and holder() == REF_ID, 60)
claim = _read_json(os.path.join(data, "claim.json"))
ev["claim"] = claim
ctx.log("the service's claim: %s; sender_out %s", claim, sender_out())
ctx.check(up and (claim or {}).get("status") == 200 and (claim.get("doc") or {}).get("out") is True,
"the service's claim was not granted: %s, %s", claim, sender_out())
doc = fc.status()
ev["kept"] = {"sender_out": doc.get("sender_out"), "lease": (doc.get("lease") or {}).get("holder")}
lease = (doc.get("lease") or {}).get("holder") or {}
ctx.check(lease.get("owner") == "ext:" + REF_ID and lease.get("kind") == "extension",
"the lease is not the package's: %s", lease)
def fresh():
k = _read_json(claim_file) or {}
return k if (k.get("id") == REF_ID and k.get("raised") is True and
time.clock_gettime(time.CLOCK_MONOTONIC) - float(k.get("ts_mono") or 0) < 2.0) else None
# The host writes the claim at its next turn; past forgectrl's 3 s grace the claim stands only
# because the host keeps it fresh.
kept = _until(ctx, fresh, 5)
ev["claim_file"] = kept
ctx.check(kept, "the host does not write the claim: %s", _read_json(claim_file))
time.sleep(5.0)
ctx.check(holder() == REF_ID and fresh(), "past the grace, the claim did not stand fresh: %s, %s",
sender_out(), _read_json(claim_file))
got, closed = _sender(lan)
ev["kept_out"] = [got[:120].decode(errors="replace"), closed]
ctx.check(closed and KEEP_OUT_MSG in got and b"Grbl" not in got,
"a sender from the network was not turned away: %r closed=%s", got[:120], closed)
got, closed = _sender("127.0.0.1", 1.0)
ev["loopback"] = [got[:80].decode(errors="replace"), closed]
ctx.check(b"Grbl" in got, "a sender from this host was not let in: %r", got[:80])
# The operator lets it back in, from the panel.
st, reply = fc.post("/motion/sender", data={"out": "0"})
ev["released"] = [st, reply]
ctx.check(st == 200 and holder() is None, "the operator's release -> %s %s", st, reply)
ctx.check(not ((fc.status().get("lease") or {}).get("holder")), "the lease stayed after the release")
got, closed = _sender(lan)
ctx.check(b"Grbl" in got and not closed, "released, a sender from the network is not welcomed: %r", got[:80])
saw = _until(ctx, lambda: ((_read_json(os.path.join(data, "state.json")) or {}).get("doc") or {}).get("released"), 10)
ctx.check(saw is True, "the package's service did not read released: %s",
_read_json(os.path.join(data, "state.json")))
# Off and on: the operator's release lasts until the package stops claiming, and the
# service claims again as it starts.
ctx.check(_forgeext("disable", REF_ID).get("ok") is True, "disable")
ctx.check(_until(ctx, lambda: not os.path.exists(claim_file), 15), "disabled, the host kept the claim file")
time.sleep(3.0) # past forgectrl's freshness window, so the release is forgotten
ctx.check(_forgeext("enable", REF_ID).get("ok") is True, "enable")
again = _until(ctx, lambda: holder() == REF_ID, 60)
ctx.check(again, "enabled again, the service's claim was not granted: %s", sender_out())
# Turned off with its claim standing: forgectrl ends it by itself.
t0 = time.time()
ctx.check(_forgeext("disable", REF_ID).get("ok") is True, "disable with the claim standing")
gone = _until(ctx, lambda: holder() is None, 15)
ev["stale_s"] = round(time.time() - t0, 1)
notice = sender_out().get("notice") or {}
ev["notice"] = notice
ctx.log("the claim ended %.1f s after the disable; notice %s", time.time() - t0, notice)
ctx.check(gone is True and notice.get("id") == REF_ID and notice.get("why") == "stopped",
"a claim its keeper stopped keeping did not end with the notice: %s", sender_out())
ctx.check(not os.path.exists(claim_file), "the claim file stayed after the service stopped")
got, closed = _sender(lan)
ctx.check(b"Grbl" in got and not closed, "after it ended, a sender from the network is not welcomed: %r", got[:80])
st, reply = fc.post("/motion/sender", data={"notice": "clear"})
ctx.check(st == 200 and not sender_out().get("notice"), "the notice was not cleared -> %s %s", st, reply)
finally:
if holder():
fc.post("/motion/sender", data={"out": "0"})
if sender_out().get("notice"):
fc.post("/motion/sender", data={"notice": "clear"})
_put_back(ctx, fc, work, prior, etag, raw, dir_mode)
ctx.check(holder() is None, "the sender is still kept out at the end")
_as_found(ctx, fc, prior, raw, dir_mode, found_tree)
+224
View File
@@ -0,0 +1,224 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""A package's own check on the Setup page, on the machine.
Its own module, for the reason extcore.py gives. The package is the
reference package's id and key with a service of its own that runs the
check on its call socket, so exthost's put-back takes it away like the
others.
"""
import json
import os
import subprocess
from ..catalog import test
from .exthost import (EXT_ROOT, FWUP, REF_ID, REF_KEY, _as_found, _forgeext, _put_back, _svc, _tree, _until, _write)
from .setup import SAFETY_PHRASE, read_file, record_path, request
# The service: the check's steps on the call socket the host hands it (fd 4), its own record of whether the
# check passed, and every request it was asked, kept in its data directory.
WZ_SERVICE = r'''
import json, os, socket
data = os.environ["FFX_DATA"]
lst = socket.socket(fileno=int(os.environ["FFX_CALL_FD"]))
state = {"done": False, "calls": []}
while True:
c, _ = lst.accept()
try:
buf = b""
while b"\r\n\r\n" not in buf:
k = c.recv(4096)
if not k:
break
buf += k
head, _, body = buf.partition(b"\r\n\r\n")
n = 0
for line in head.split(b"\r\n")[1:]:
k, _, v = line.partition(b":")
if k.strip().lower() == b"content-length":
n = int(v)
while len(body) < n:
k = c.recv(4096)
if not k:
break
body += k
req = json.loads(body[:n] or b"{}")
method, path = head.split(b" ")[0].decode(), head.split(b" ")[1].decode()
if method == "GET" and path == "/wizard":
ans = {"title": "forgetest check", "done": state["done"]}
elif path == "/wizard/start":
ans = {"log": ["the check begins"], "phase": "asking", "progress": 50,
"prompt": {"kind": "confirm", "id": "ready", "text": "Is the machine ready?"}}
elif path == "/wizard/answer":
ok = req.get("id") == "ready" and req.get("answer") == "yes"
state["done"] = ok
ans = {"log": ["answered " + str(req.get("answer"))], "result": {"ok": ok, "summary": "ready" if ok else "not ready"}}
elif path == "/wizard/abort":
ans = {}
else:
ans = {"error": "no such call"}
state["calls"].append([method, path, req])
with open(os.path.join(data, "check.json.new"), "w") as f:
json.dump(state, f)
os.rename(os.path.join(data, "check.json.new"), os.path.join(data, "check.json"))
out = json.dumps(ans).encode()
c.sendall(b"HTTP/1.1 200 X\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n"
% len(out) + out)
except Exception:
pass
finally:
c.close()
'''
def _pack_check(work):
import io
import tarfile
manifest = {"manifest": 1, "id": REF_ID, "name": "forgetest check", "version": "1.0.0",
"author": "forgetest", "license": "MIT", "api": "0.1", "runtime": "python",
"service": {"exec": "bin/check.py"}, "capabilities": ["wizard"]}
payload = os.path.join(work, "payload.tar.gz")
with tarfile.open(payload, "w:gz") as t:
for name, text, mode in (("manifest.json", json.dumps(manifest), 0o644), ("bin/check.py", WZ_SERVICE, 0o755)):
info = tarfile.TarInfo(name)
data = text.encode()
info.size, info.mode = len(data), mode
t.addfile(info, io.BytesIO(data))
conf = os.path.join(work, "fwup.conf")
_write(conf, 'meta-product = "ForgeFIRM extension"\nmeta-description = "%s"\nmeta-version = "1.0.0"\n'
'meta-platform = "forgefirm-ext"\nfile-resource payload.tar.gz {\n host-path = "%s"\n}\n'
% (REF_ID, payload))
key = os.path.join(work, REF_KEY)
raw, signed = os.path.join(work, "raw.ffx"), os.path.join(work, "check.ffx")
for cmd in ([FWUP, "-g", "-o", key], [FWUP, "-c", "-f", conf, "-o", raw],
[FWUP, "-S", "-s", key + ".priv", "-i", raw, "-o", signed]):
subprocess.run(cmd, check=True, capture_output=True, timeout=60, cwd=work)
return signed, key + ".pub"
@test("exthost.wizard", title="A package's own check runs on the Setup page, and the machine's setup is untouched",
subsystem="exthost", kind="auto", est_min=3,
covers=[("forgectrl", "src/wizpkg.*"), ("forgectrl", "src/wizdark.c"), ("forgectrl", "src/wiz.c"),
("forgectrl", "src/extpkg.*"), ("forgectrl", "src/ui/wizard.js"), ("forgeext", "src/main.c"),
("forgeext", "src/run.*"), ("forgeext", "src/caps.*"), ("forgeext", "src/manifest.*")],
requires=["exthost.service"],
description="A package that asks for wizard is installed and turned on; its 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 title the package gives it, 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 its "
"confirm prompt, and the machine lease stays free while it waits (a package's check holds the "
"machine no more than the package does). The operator's Yes reaches the service as the answer "
"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: "
"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. Everything is put back "
"as exthost.service puts it back.")
def wizard(ctx):
import shutil
import tempfile
fc = ctx.forgectrl
ev = ctx.evidence
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle")
prior = fc.settings().get("ext_enabled") or ""
raw = read_file(record_path())
found_tree = _tree(EXT_ROOT)
dir_mode = os.stat(os.path.dirname(EXT_ROOT)).st_mode & 0o7777
st, body, hdrs = request(fc.base, "GET", "/advisories/extensions", headers={"Host": fc.host_header()})
etag = hdrs.get("etag")
work = tempfile.mkdtemp(prefix="forgetest-ffx.")
owner_key = os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub")
wid = "pkg:" + REF_ID
check_file = os.path.join(EXT_ROOT, "data", REF_ID, "check.json")
def own():
try:
return json.loads(read_file(check_file) or "{}")
except ValueError:
return {}
def listed():
st_, w = fc.get("/wiz")
return next((e for e in (w.get("extensions") or []) if e.get("id") == wid), None) if isinstance(w, dict) else None
def dark():
st_, d = fc.get("/wiz/dark")
return d if isinstance(d, dict) else {}
def record_view():
try:
rec = json.loads(read_file(record_path()) or "{}")
except ValueError:
return None
return {"wizards": sorted((rec.get("wizards") or {}).keys()), "completed": rec.get("completed")}
before = record_view()
gate_before = fc.get("/wiz")[1].get("gate")
try:
archive, pub = _pack_check(work)
shutil.copy(pub, owner_key)
os.chmod(owner_key, 0o644)
r = _forgeext("install", archive, "--consent-community")
ctx.check(r.get("ok") is True, "the install -> %s", r.get("error"))
st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE})
ctx.check(st == 200, "ext_enabled=1 -> %s %r", st, reply)
x = _until(ctx, lambda: _svc(REF_ID) if _svc(REF_ID).get("state") == "running" else None, 90, poll=0.5)
ctx.check(x, "the service is not running: %s", _svc(REF_ID))
e = _until(ctx, listed, 30, poll=1.0)
ev["listed"] = e
ctx.check(e and e.get("title") == "forgetest check" and e.get("done") is False,
"GET /wiz does not list the package's check, not done: %s", e)
st, body = fc.post("/wiz/%s/start" % wid)
ctx.check(st == 200, "POST /wiz/%s/start -> %s %s", wid, st, body)
d = _until(ctx, lambda: dark() if (dark().get("prompt") or {}).get("id") == "ready" else None, 20, poll=0.3)
ev["prompt"] = d
ctx.check(d and d.get("running") and d["prompt"].get("kind") == "confirm"
and any(l.endswith("the check begins") for l in (d.get("log") or [])) and d.get("progress") == 50,
"the check's prompt, log, and progress: %s", d)
lease = (fc.status().get("lease") or {}).get("holder")
ev["lease_while_waiting"] = lease
ctx.check(not lease, "a package's check holds the machine: %s", lease)
st, body = fc.post("/wiz/%s/answer" % wid, data={"seq": str(d["prompt"]["seq"]), "value": "Yes"})
ctx.check(st == 200, "the answer -> %s %s", st, body)
end = _until(ctx, lambda: dark() if not dark().get("running") else None, 20, poll=0.3)
ev["end"] = end
ctx.log("the check ended: %s", end)
ctx.check(end and not end.get("error") and (end.get("result") or {}).get("summary") == "ready",
"the check did not end complete with the package's summary: %s", end)
mine = own()
ctx.check(mine.get("done") is True and ["POST", "/wizard/answer", {"id": "ready", "answer": "yes"}] in
(mine.get("calls") or []), "the service was not answered yes: %s", mine)
ctx.sleep(11.0) # the Setup page's list is asked again at most every 10 s
e = listed()
ev["listed_after"] = e
ctx.check(e and e.get("done") is True, "GET /wiz does not list the check as done: %s", e)
after = record_view()
ev["record"] = {"before": before, "after": after}
ctx.check(after == before and not any(w.startswith("pkg:") for w in (after or {}).get("wizards", [])),
"the machine's setup record changed: %s -> %s", before, after)
ctx.check(fc.get("/wiz")[1].get("gate") == gate_before, "the setup gate moved")
# A second run, aborted from its prompt.
st, body = fc.post("/wiz/%s/start" % wid)
d = _until(ctx, lambda: dark() if (dark().get("prompt") or {}).get("id") == "ready" else None, 20, poll=0.3)
ctx.check(st == 200 and d, "the second run did not reach its prompt: %s %s", st, body)
fc.post("/wiz/%s/abort" % wid)
end = _until(ctx, lambda: dark() if not dark().get("running") else None, 20, poll=0.3)
ctx.check(end and end.get("error") == "aborted", "the abort: %s", end)
ctx.check(["POST", "/wizard/abort", {}] in (own().get("calls") or []), "the service was not told of the abort")
st, body = fc.post("/wiz/pkg:org.forgetest.nothere/start")
ctx.check(st == 404, "a check of a package that is not installed -> %s %s", st, body)
finally:
# A check that failed mid-run leaves the package's check waiting at its prompt.
if dark().get("running") and dark().get("id") == wid:
fc.post("/wiz/%s/abort" % wid)
_until(ctx, lambda: not dark().get("running"), 20, poll=0.3)
_put_back(ctx, fc, work, prior, etag, raw, dir_mode)
_as_found(ctx, fc, prior, raw, dir_mode, found_tree)
+505 -11
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
"""forgectrl.* - the machine-services daemon's API, access control, and panel.""" """forgectrl.* - the machine-services daemon's API, access control, and panel."""
import json import json
import os import os
import socket import socket
import time import time
import urllib.parse
from ..catalog import test from ..catalog import test
from .. import hw from .. import hw
@@ -56,6 +62,7 @@ def auth(ctx):
("/mode", {"controller": "grbl"}), ("/settings", {"ui_units": "mm"}), ("/mode", {"controller": "grbl"}), ("/settings", {"ui_units": "mm"}),
("/diag/flow-verify", None), ("/diag/abort", None), ("/diag/flow-verify", None), ("/diag/abort", None),
("/update/apply", None), ("/boot", {"target": "a"}), ("/update/apply", None), ("/boot", {"target": "a"}),
("/update/check", None), ("/update/dismiss", {"version": "v0.0.0"}),
("/system/reboot", None), ("/restore/factory", None), ("/system/reboot", None), ("/restore/factory", None),
("/restore/factory-return", {"confirm": "1"}), ("/system/ssh", {"enable": "1"}), ("/restore/factory-return", {"confirm": "1"}), ("/system/ssh", {"enable": "1"}),
("/wiz/advisories/accept", None), ("/wiz/account", None), ("/wiz/advisories/accept", None), ("/wiz/account", None),
@@ -136,14 +143,39 @@ def auth(ctx):
"GET /fuse-identity with the token but no button -> %s %r (expected the two-factor refusal)", "GET /fuse-identity with the token but no button -> %s %r (expected the two-factor refusal)",
st, body) st, body)
# the cooling report channel: the loopback peer is accepted. An idle # the cooling report channel is the running controller's alone. A
# report is what the controller sends every period; the engine is idle # loopback peer is not enough: anything on this host is one. The
# here, so it changes nothing. A dual-stack listener reports this peer # supervisor hands the controller a secret at its spawn, and the route
# as ::ffff:127.0.0.1, which the check must recognize in full. # asks for it. An idle report is what the controller sends every
st, body = fc.post("/cool/state", params={"mode": "idle", "armed": "0"}) # 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 ev["cool_state_from_loopback"] = st
ctx.log("POST /cool/state from loopback -> %s %s", st, body if isinstance(body, dict) else "") ctx.log("POST /cool/state from loopback with the controller's secret -> %s", st)
ctx.check(st == 200, "/cool/state refused the loopback peer (%s %r): the controller's " ctx.check(st == 200, "/cool/state refused the controller's own secret (%s %r): the controller's "
"reports never reach the engine", st, body) "reports never reach the engine", st, body)
# ...and a non-loopback peer is refused, even with a token. Over HTTP # ...and a non-loopback peer is refused, even with a token. Over HTTP
@@ -151,12 +183,11 @@ def auth(ctx):
# HTTPS the route itself refuses the peer (403 loopback only). Neither # HTTPS the route itself refuses the peer (403 loopback only). Neither
# request follows the redirect, and the self-signed certificate is # request follows the redirect, and the self-signed certificate is
# not verified. # not verified.
from .commission import request, decode from .setup import request, decode
ip = lan_ip() ip = lan_ip()
ev["lan_ip"] = ip ev["lan_ip"] = ip
ctx.check(ip, "cannot determine the board's LAN address") ctx.check(ip, "cannot determine the board's LAN address")
token = {"X-ForgeFIRM-Token": fc.token} token = {"X-ForgeFIRM-Token": fc.token, "X-ForgeFIRM-Report": secret}
report = {"mode": "idle", "armed": "0"}
st, body, hdrs = request("http://%s" % ip, "POST", "/cool/state", data=report, headers=token) st, body, hdrs = request("http://%s" % ip, "POST", "/cool/state", data=report, headers=token)
loc = hdrs.get("location", "") loc = hdrs.get("location", "")
ev["cool_state_from_lan_http"] = {"status": st, "location": loc} ev["cool_state_from_lan_http"] = {"status": st, "location": loc}
@@ -291,7 +322,12 @@ def settings_bounds(ctx):
("forgectrl", "src/cam.c"), ("forgectrl", "src/main.c"), ("forgectrl", "src/super.c"), ("forgectrl", "src/cam.c"), ("forgectrl", "src/main.c"), ("forgectrl", "src/super.c"),
("grblhal-glowforge", "src/glowforge_status.c"), ("grblhal-glowforge", "src/serial.c"), ("grblhal-glowforge", "src/glowforge_status.c"), ("grblhal-glowforge", "src/serial.c"),
("forgectrl", "src/curverec.*")], ("forgectrl", "src/curverec.*")],
description="The panel page is served, /status carries the machine telemetry the panel and " description="The panel page is served, with a policy that lets no frame navigate anywhere "
"(frame-src 'none': a package's page renders in a frame of it, and a frame that "
"could go to another address could carry what it was shown in the URL; what the "
"header does in each browser is the frame-isolation harness's to prove, and this "
"holds the machine's own page to carrying it), /status carries the machine "
"telemetry the panel and "
"the acceptance tool read (including the sys block: CPU busy percent over the " "the acceptance tool read (including the sys block: CPU busy percent over the "
"interval since the previous read, memory used percent; and homed_axes, " "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 " "the axes that carry a reference, Z alone once the lens has taken its "
@@ -319,6 +355,12 @@ def panel_serves(ctx):
# the head script sets and the one save bar every settings tab shares. # 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("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") ctx.check('id="savebar"' in text, "the panel lacks the save bar")
from .setup import request
st_h, _page, hdrs = request(fc.base, "GET", "/", headers={"Host": fc.host_header()})
ev["page_policy"] = hdrs.get("content-security-policy")
ctx.log("GET / Content-Security-Policy: %s", ev["page_policy"])
ctx.check(st_h == 200 and "frame-src 'none'" in (ev["page_policy"] or ""),
"the panel page lets a frame navigate: %s %r", st_h, ev["page_policy"])
s = fc.status() s = fc.status()
for key in ("state", "switches", "coolant", "fans"): for key in ("state", "switches", "coolant", "fans"):
@@ -411,3 +453,455 @@ def panel_serves(ctx):
st, text = fc.get("/curve/ladder.gcode", raw=True) 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""), 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) "GET /curve/ladder.gcode -> %s without the ladder", st)
class _EventStream:
"""GET /events over a raw socket bound to a chosen loopback source
address. Every address in 127.0.0.0/8 is this host, so the test can be
several peers at once, and the daemon counts streams per peer address."""
def __init__(self, base, source, timeout=5.0):
u = urllib.parse.urlsplit(base)
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.bind((source, 0))
self.sock.settimeout(timeout)
self.sock.connect((u.hostname or "127.0.0.1", u.port or 80))
self.sock.sendall(b"GET /events HTTP/1.1\r\nHost: %s\r\nAccept: text/event-stream\r\n"
b"Connection: close\r\n\r\n" % (u.netloc or "127.0.0.1").encode())
self.buf = b""
self.eof = False # the response is over: the last chunk, or the socket closed
head = self._until(b"\r\n\r\n")
self.status = int(head.split(b" ", 2)[1]) if head.startswith(b"HTTP/") else 0
self.head = head.decode("latin-1", "replace")
def _fill(self):
try:
chunk = self.sock.recv(4096)
except socket.timeout:
return False
if not chunk:
self.eof = True
return False
self.buf += chunk
return True
def _until(self, mark):
while mark not in self.buf:
if not self._fill():
break
i = self.buf.find(mark)
if i < 0:
out, self.buf = self.buf, b""
return out
out, self.buf = self.buf[:i], self.buf[i + len(mark):]
return out
def text(self, seconds):
"""Everything the stream says in the next `seconds` (chunk framing
and all: the checks look for event names inside it)."""
end = time.time() + seconds
self.sock.settimeout(0.2) # the window is the caller's, not one long read
while time.time() < end and not self.eof:
self._fill()
if self.buf.endswith(b"\r\n0\r\n\r\n"):
self.eof = True
out, self.buf = self.buf, b""
return out.decode("utf-8", "replace")
def close(self):
try:
self.sock.close()
except OSError:
pass
@test("events.stream", title="The event stream: edges arrive, and the cap holds",
subsystem="forgectrl", kind="auto", mode="grbl", est_min=2,
covers=[("forgectrl", "src/events.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/status.*"),
("forgectrl", "src/grblport.*")],
description="GET /events from three loopback source addresses: each gets 200, "
"text/event-stream, and the hello event. A fourth address gets 503 with the "
"reason, and GET /settings still answers while the three are held. A second "
"stream from the first address is served, and the older one gets bye and the "
"end of its response. An edge arrives on an open stream: POST /motion/release "
"and /motion/energize show up as motors.released and motors.energized, with "
"ids that count up (X and Y lose their reference, as after any release). With "
"every stream closed, the places come back within two keep-alive intervals.")
def events_stream(ctx):
ev = ctx.evidence
fc = hw.Forgectrl()
streams = []
try:
# A stream an earlier test closed keeps its place until the daemon's
# next write to it (its keep-alive), so the three places may not be
# free yet: that is the daemon as documented, and the last part of
# this test measures it. The three are opened once they can be.
deadline = time.time() + 25
while True:
for src in ("127.0.0.2", "127.0.0.3", "127.0.0.4"):
streams.append(_EventStream(fc.base, src))
if all(s.status == 200 for s in streams) or time.time() > deadline:
break
ctx.log("an earlier stream still holds a place (%s): waiting for it to be given back",
[s.status for s in streams])
for s in streams:
s.close()
streams = []
ctx.sleep(3)
for s, src in zip(streams, ("127.0.0.2", "127.0.0.3", "127.0.0.4")):
hello = s.text(1.0)
ctx.check(s.status == 200 and "text/event-stream" in s.head and "event: hello" in hello,
"GET /events from %s -> %s, %r", src, s.status, hello[:120])
fourth = _EventStream(fc.base, "127.0.0.5")
body = fourth.text(1.0)
fourth.close()
ev["fourth"] = [fourth.status, body.strip()[-120:]]
ctx.log("the fourth stream -> %s %s", fourth.status, ev["fourth"][1])
ctx.check(fourth.status == 503 and "every event stream is taken" in body,
"the fourth stream -> %s %r, expected 503 and the reason", fourth.status, body[:160])
st, _body = fc.get("/settings")
ctx.check(st == 200, "GET /settings -> %s with three event streams held", st)
# One per address, by replacement.
newer = _EventStream(fc.base, "127.0.0.2")
hello = newer.text(1.0)
ctx.check(newer.status == 200 and "event: hello" in hello,
"a second stream from one address -> %s %r", newer.status, hello[:120])
bye = streams[0].text(2.0)
ev["replaced"] = {"bye": "event: bye" in bye, "ended": streams[0].eof}
ctx.check("event: bye" in bye and "replaced" in bye, "the older stream was not told: %r", bye[-160:])
ctx.check(streams[0].eof, "the older stream was not ended")
streams[0].close()
streams[0] = newer
# An edge, on a stream that has been open all along.
st, body = fc.post("/motion/release")
ctx.check(st == 200, "POST /motion/release -> %s %s", st, body)
ctx.sleep(1.0)
st, body = fc.post("/motion/energize")
ctx.check(st == 200, "POST /motion/energize -> %s %s", st, body)
text = streams[1].text(2.0)
ev["edges"] = [l for l in text.splitlines() if l.startswith(("id:", "event:"))]
ctx.log("the stream carried: %s", ev["edges"])
i_rel, i_en = text.find("event: motors.released"), text.find("event: motors.energized")
ctx.check(0 <= i_rel < i_en, "the release and the energize did not arrive in order: %r", text[-300:])
ids = [int(l.split(":")[1]) for l in text.splitlines() if l.startswith("id:")]
ctx.check(ids and ids == sorted(ids) and len(set(ids)) == len(ids), "the ids do not count up: %s", ids)
finally:
for s in streams:
s.close()
if fc.status().get("motors_released"):
fc.post("/motion/energize")
# A closed client is noticed at the daemon's next write to it.
def reopened():
s = _EventStream(fc.base, "127.0.0.5")
try:
return s.status == 200
finally:
s.close()
took = ctx.wait_for(reopened, 25, poll=1.0)
ev["place_back_s"] = took
ctx.check(took is not None, "no stream could be opened 25 s after every stream was closed")
ctx.log("PASS: three streams, the fourth refused in words with the settings route answering, a "
"replacement told and ended, two edges in order, and a place back after %.0f s", took)
def _lease(fc):
return (fc.status().get("lease") or {})
def _text(body):
return body if isinstance(body, str) else json.dumps(body)
@test("forgectrl.lease", title="The machine lease: a holder refuses everything else, by name",
subsystem="forgectrl", kind="auto", est_min=2,
covers=[("forgectrl", "src/lease.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/status.*"),
("forgectrl", "src/wizdark.*"), ("forgectrl", "src/diag.*"), ("forgectrl", "src/curverec.*"),
("forgectrl", "src/jobrun.*"), ("forgectrl", "src/update.*"), ("forgectrl", "src/logs.*"), ("forgectrl", "src/super.*"),
("forgectrl", "src/events.*")],
description="The switches check is started and left waiting at its first prompt: it moves "
"nothing, and it holds the machine lease for as long as it waits. /status must "
"name it as the holder (wizard:switches, kind hardware), the event stream must "
"report lease.changed, 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 (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. The "
"check is then aborted, and the lease must read free, lease.changed must say so, "
"and POST /settings must be accepted again. Whatever a failed refusal started is "
"stopped on the way out.")
def lease(ctx):
from .setup_dark import dark
ev = ctx.evidence
fc = hw.Forgectrl()
wid, owner = "switches", "wizard:switches"
rest = _lease(fc)
ctx.check("holder" in rest and rest["holder"] is None, "the machine is not free at the start: %s", rest)
ctx.check(set(rest.get("observed") or {}) == {"sender", "motors_released"},
"what is observed outside the lease is not reported: %s", rest)
mode = (fc.get("/mode")[1] or {}).get("mode")
units = fc.settings().get("ui_units") or "metric"
stream = _EventStream(fc.base, "127.0.0.6")
ctx.check(stream.status == 200, "GET /events -> %s", stream.status)
stream.text(0.5)
started = False
try:
st, body = fc.post("/wiz/%s/start" % wid)
ctx.check(st == 200, "the %s check did not start (%s %s)", wid, st, _text(body))
started = True
ok = ctx.wait_for(lambda: (_lease(fc).get("holder") or {}).get("owner") == owner, 10, poll=0.2)
held = _lease(fc).get("holder") or {}
ev["holder"] = held
ctx.log("the lease while the check waits: %s", held)
ctx.check(ok is not None and held.get("kind") == "hardware" and "switches" in (held.get("words") or ""),
"the lease does not name the running check: %s", held)
refused = {}
for name, path, kw in (
("a diagnostic", "/diag/flow-verify", {}),
("the recorder", "/curve/record", {}),
("a log export", "/logs/export", {}),
("a mode switch", "/mode", {"params": {"controller": mode}}),
("the controller's start", "/controller/start", {}),
("a settings write", "/settings", {"params": {"ui_units": units}})):
st, body = fc.post(path, **kw)
refused[name] = [st, _text(body)[:160]]
ctx.check(st == 409 and "holds the machine" in _text(body) and "switches" in _text(body),
"%s under the wizard's hold -> %s %s", name, st, _text(body)[:160])
ctx.check("settings are locked" in refused["a settings write"][1],
"the settings refusal does not say they are locked: %s", refused["a settings write"])
ev["refused"] = refused
ctx.check((_lease(fc).get("holder") or {}).get("owner") == owner,
"a refused request took the lease away: %s", _lease(fc))
finally:
if started:
fc.post("/wiz/%s/abort" % wid)
ctx.wait_for(lambda: not dark(fc).get("running"), 30)
# Nothing a failed refusal may have started is left running.
fc.post("/diag/abort")
fc.post("/curve/stop")
ok = ctx.wait_for(lambda: _lease(fc).get("holder") is None, 30, poll=0.5)
ctx.check(ok is not None, "the lease was not given back after the abort: %s", _lease(fc))
st, body = fc.post("/settings", params={"ui_units": units})
ctx.check(st == 200, "POST /settings with the machine free again -> %s %s", st, _text(body)[:120])
text = stream.text(2.0)
stream.close()
changes = [l for l in text.splitlines() if l.startswith("data:") and "owner" in l]
ev["lease_changed"] = changes
ctx.log("the event stream: %s", changes)
i_take, i_free = text.find('"owner":"%s"' % owner), text.find('"owner":null')
ctx.check("event: lease.changed" in text and 0 <= i_take < i_free,
"lease.changed did not report the hold and then its end: %r", text[-300:])
ctx.log("PASS: %s held the machine, six requests were refused by name, the abort freed it, and the "
"event stream said both", owner)
# ---------------------------------------------------------------- scoped tokens
def _words_of(body):
b = body if isinstance(body, (dict, str)) else ""
return b.get("error", "") if isinstance(b, dict) else b
@test("forgectrl.tokens", title="Scoped API tokens: a token reaches what it was granted, and nothing else",
subsystem="forgectrl", kind="auto", mode="grbl", est_min=2,
covers=_COVERS_AUTH + [("forgectrl", "src/tokens.*"), ("forgectrl", "src/grblport.*"),
("forgectrl", "src/settings.*")],
steps=["Bed clear; the head needs 2 mm of free travel toward +X."],
description="Three tokens are created through the panel's route: one holding machine.read "
"and camera.lid, one holding motion.jog, one holding camera.lid alone. Each is then "
"used the way a client on the network uses it, over HTTPS to the machine's own LAN "
"address, so the daemon sees a peer that is not this host. The first reads /status "
"as a bearer token, /mode in the panel token's header, and /cam/status as a bearer "
"token; the camera-only token reads /cam/status as ?key=. Refused, each with 403 "
"and the reason in words: the first token in a URL (it holds more than a camera), "
"the camera-only token on the head camera, and for the first token a jog, a "
"settings write, a mode switch, and the token routes. The second jogs the head 1 mm with no "
"session and no panel token (the kernel's counters are the witness) and is refused "
"/status. A token presented over plain HTTP from the LAN is refused, and the "
"daemon's log names it. A token that is not this machine's is refused. With "
"panel_open_reads=0 the LAN reads /status with machine.read and with nothing else. "
"The list names both tokens with a last-used time and carries neither a token nor "
"a hash, and the store is mode 0600 and holds neither token. A revoked token is "
"refused at once, with reads open again. Both tokens are revoked, the setting "
"restored, and the head returned on the way out. No token is written to the log "
"or the evidence.")
def scoped_tokens(ctx):
from .setup import request, decode
from .motion import kernel_xy_mm, machine_idle
ev = ctx.evidence
fc = hw.Forgectrl()
ip = lan_ip()
ctx.check(ip, "cannot determine the board's LAN address")
tls, plain = "https://%s" % ip, "http://%s" % ip
machine_idle(ctx)
x0 = kernel_xy_mm(ctx)[0]
reads_were = str(fc.settings().get("panel_open_reads", "1"))
mode = (fc.get("/mode")[1] or {}).get("mode")
units = fc.settings().get("ui_units") or "metric"
made = {}
def create(name, caps):
st, body = fc.post("/tokens", data={"name": name, "caps": caps})
ctx.check(st == 200 and isinstance(body, dict) and str(body.get("token", "")).startswith("fft_")
and len(body["token"]) == 36, "POST /tokens %s -> %s", name, st)
made[name] = body
return body["token"]
def lan(token, method, path, how="bearer", base=None, data=None):
headers = {}
if token and how == "bearer":
headers["Authorization"] = "Bearer " + token
elif token and how == "header":
headers["X-ForgeFIRM-Token"] = token
elif token and how == "key":
path += ("&" if "?" in path else "?") + "key=" + token
st, content, _h = request(base or tls, method, path, data=data, headers=headers)
return st, decode(content)
try:
# Whatever an earlier, interrupted run left behind.
st, listed = fc.get("/tokens")
ctx.check(st == 200 and isinstance(listed, dict), "GET /tokens -> %s", st)
for t in listed.get("tokens", []):
if t.get("name", "").startswith("forgetest "):
fc.post("/tokens/revoke", data={"id": t["id"]})
st, body = fc.post("/tokens", data={"name": "forgetest bad", "caps": "settings.write"})
ev["outside_the_list"] = [st, _words_of(body)[:120]]
ctx.check(st == 400 and "not a capability" in _words_of(body),
"a capability outside the list -> %s %s", st, _words_of(body)[:120])
hub = create("forgetest hub", "machine.read,camera.lid")
pend = create("forgetest jogger", "motion.jog")
view = create("forgetest viewer", "camera.lid")
passed = {}
for name, call in (("bearer GET /status", lambda: lan(hub, "GET", "/status")),
("header GET /mode", lambda: lan(hub, "GET", "/mode", how="header")),
("bearer GET /cam/status", lambda: lan(hub, "GET", "/cam/status")),
("camera-only ?key= GET /cam/status",
lambda: lan(view, "GET", "/cam/status", how="key"))):
st, body = call()
passed[name] = st
ctx.check(st == 200 and isinstance(body, dict), "the hub token, %s -> %s %s", name, st,
_words_of(body)[:120])
ev["hub_passed"] = passed
refused = {}
for name, want, call in (
("the head camera", "does not hold camera.head",
lambda: lan(view, "GET", "/cam/snapshot?cam=head", how="key")),
("a token with more than a camera, in a URL", "in a URL may hold camera capabilities only",
lambda: lan(hub, "GET", "/cam/status", how="key")),
("a jog", "does not hold motion.jog", lambda: lan(hub, "POST", "/motion/jog", data={"x": "1"})),
("a settings write", "no scoped token reaches", lambda: lan(hub, "POST", "/settings",
data={"ui_units": units})),
("a mode switch", "no scoped token reaches", lambda: lan(hub, "POST", "/mode",
data={"controller": mode})),
("the token list", "no scoped token reaches", lambda: lan(hub, "GET", "/tokens")),
("a token of its own", "no scoped token reaches",
lambda: lan(hub, "POST", "/tokens", data={"name": "forgetest child", "caps": "machine.read"})),
("the panel's ?token= form", "authentication required",
lambda: lan(None, "POST", "/settings?token=" + hub, data={"ui_units": units})),
("a stranger's token", "authentication required",
lambda: lan("fft_" + "0" * 32, "GET", "/status")),
("the jog token on /status", "does not hold machine.read", lambda: lan(pend, "GET", "/status"))):
st, body = call()
refused[name] = [st, _words_of(body)[:120]]
ctx.check(st == 403 and want in _words_of(body), "%s -> %s %s (wanted 403 %r)", name, st,
_words_of(body)[:120], want)
ev["refused"] = refused
ctx.check(abs(kernel_xy_mm(ctx)[0] - x0) < 0.01, "a refused jog moved the head")
# A write with a scoped token and nothing else: no session, no panel token.
st, body = lan(pend, "POST", "/motion/jog", data={"x": "1", "feed": "1200"})
ctx.check(st == 200, "the jog token's jog -> %s %s", st, _words_of(body)[:120])
ctx.wait_for(lambda: abs(kernel_xy_mm(ctx)[0] - x0 - 1.0) < 0.02, 10, poll=0.1)
machine_idle(ctx)
moved = kernel_xy_mm(ctx)[0] - x0
ev["jog_mm"] = round(moved, 3)
ctx.log("the jog token jogged the head %.3f mm from the LAN with no session", moved)
ctx.check(abs(moved - 1.0) < 0.02, "the jog moved %.3f mm, not 1", moved)
# Plain HTTP from the LAN: refused, and the log says which token crossed in the clear.
log_path = "/data/log/forgefirm/forgectrl/forgectrl.log"
try:
log_from = os.path.getsize(log_path)
except OSError:
log_from = 0
st, body = lan(hub, "GET", "/status", base=plain)
ev["plain_http"] = [st, _words_of(body)[:120]]
ctx.check(st == 403 and "HTTPS only" in _words_of(body), "a token over plain HTTP -> %s %s", st,
_words_of(body)[:120])
def logged():
try:
with open(log_path, "r", encoding="utf-8", errors="replace") as f:
f.seek(log_from)
return "scoped token %s was presented over plain HTTP" % made["forgetest hub"]["id"] in f.read()
except OSError:
return False
ctx.check(ctx.wait_for(logged, 10, poll=0.5) is not None, "the log does not name the token sent in the clear")
# Reads closed to the LAN: machine.read opens them, nothing else does.
st, body = fc.post("/settings", params={"panel_open_reads": "0"})
ctx.check(st == 200, "closing the reads -> %s", st)
closed = {"nobody": lan(None, "GET", "/status")[0], "machine.read": lan(hub, "GET", "/status")[0],
"motion.jog": lan(pend, "GET", "/status")[0]}
ev["reads_closed"] = closed
ctx.check(closed == {"nobody": 403, "machine.read": 200, "motion.jog": 403},
"with the reads closed: %s", closed)
st, body = fc.post("/settings", params={"panel_open_reads": reads_were})
ctx.check(st == 200, "restoring panel_open_reads=%s -> %s", reads_were, st)
# The list and the store: names and times, never a token or a hash.
st, listed = fc.get("/tokens")
mine = {t["name"]: t for t in listed.get("tokens", []) if t.get("name", "").startswith("forgetest ")}
ev["listed"] = {n: {k: t[k] for k in ("id", "caps", "last_used")} for n, t in mine.items()}
ctx.check(set(mine) == {"forgetest hub", "forgetest jogger", "forgetest viewer"},
"the list: %s", sorted(mine))
ctx.check(mine["forgetest hub"]["caps"] == ["machine.read", "camera.lid"] and
mine["forgetest jogger"]["caps"] == ["motion.jog"], "the capabilities listed: %s", ev["listed"])
ctx.check(all(t["last_used"] > 0 for t in mine.values()), "a used token reads as never used: %s", ev["listed"])
text = json.dumps(listed)
ctx.check(hub[4:] not in text and pend[4:] not in text and view[4:] not in text and "sha" not in text and
all(set(t) == {"id", "name", "caps", "created", "last_used"} for t in mine.values()),
"the list carries more than names, capabilities, and times")
store = "/data/forgefirm/tokens"
mode_bits = os.stat(store).st_mode & 0o777
with open(store, "r", encoding="utf-8", errors="replace") as f:
kept = f.read()
ev["store"] = {"mode": oct(mode_bits),
"holds_a_token": hub[4:] in kept or pend[4:] in kept or view[4:] in kept}
ctx.check(mode_bits == 0o600 and not ev["store"]["holds_a_token"], "the store: %s", ev["store"])
# A revoke ends it at once, and the open reads are no way around it.
st, body = fc.post("/tokens/revoke", data={"id": made["forgetest hub"]["id"]})
ctx.check(st == 200, "the revoke -> %s %s", st, _words_of(body)[:120])
st, body = lan(hub, "GET", "/status")
ev["after_revoke"] = [st, _words_of(body)[:120]]
ctx.check(st == 403 and "authentication required" in _words_of(body),
"a revoked token -> %s %s", st, _words_of(body)[:120])
ctx.check(lan(pend, "POST", "/motion/cancel")[0] == 200, "the revoke took the other token too")
finally:
fc.post("/settings", params={"panel_open_reads": reads_were})
for t in made.values():
fc.post("/tokens/revoke", data={"id": t.get("id", "")})
back = x0 - kernel_xy_mm(ctx)[0]
if abs(back) > 0.02:
machine_idle(ctx)
fc.post("/motion/jog", params={"x": "%.3f" % back, "feed": "1200"})
ctx.wait_for(lambda: abs(kernel_xy_mm(ctx)[0] - x0) < 0.02, 10, poll=0.1)
machine_idle(ctx)
ctx.check(abs(kernel_xy_mm(ctx)[0] - x0) < 0.05, "the head was not returned: %.3f mm off",
kernel_xy_mm(ctx)[0] - x0)
st, listed = fc.get("/tokens")
ctx.check(not [t for t in listed.get("tokens", []) if t.get("name", "").startswith("forgetest ")],
"a test token was left on the machine")
ctx.log("PASS: each token reached what it was granted from the LAN with no session, was refused "
"everything else in words, was refused over plain HTTP and once revoked, and the machine "
"keeps no token")
+241
View File
@@ -0,0 +1,241 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""A camera home's offset pair, on the machine.
Its own module, so that no other test's fingerprint moves. The homing is
the web-service session cloud.mode-switch runs. The helpers for a test
that lets the service move the head live here too: the motions ran whole,
and the head goes 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 ..catalog import test
from ..runner import Failed
from . import cloud as logs # the log paths, read at each call
from .cloud import _HOMING_PATH, gfhome_homing, log_lines_since, log_size
from .motion import _drop_reference, clean_slate, machine_idle, wait_idle, wait_state
HOME_X, HOME_Y = 4.5, -3.25
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)
@test("homing.cloud-offsets", title="A camera home declares its offset pair, and the envelope reaches back to a "
"negative one",
subsystem="homing", kind="auto", mode="grbl", est_min=7,
covers=_HOMING_PATH + [("grblhal-glowforge", "src/glowforge_homing.*"),
("grblhal-glowforge", "src/glowforge_cooling.*")],
requires=["forgectrl.auth", "motion.pacing"],
steps=["Bed clear, lid closed; cloud credentials configured; the machine on the network. The head "
"travels to the camera home and back to where it was."],
description="With cloud mode on, homing_mode = gfcloud and the camera-home offsets set to (4.5, -3.25), "
"$H runs the web-service homing session. Every service motion of the session runs whole: "
"the runner reports to the cooling engine in the controller's place, and one stopped short "
"(the engine's stop, a fault) leaves the head short of the home and the position declared "
"there false. When the session ends, the controller declares the position (4.5, -3.25): a "
"camera-home offset may be negative, and the declared position is snapped to the step "
"grid. The work envelope reaches back to the home on the negative axis, since the head "
"stands there: a jog 2 mm out in +Y and 2 mm back ends at the home, accepted, and the same "
"in X. The homing mode and the offsets are put back as found, the camera home is dropped, "
"and the head is jogged back to where the test found it by the travel the session's "
"motions logged; a travel that cannot be known fails the test and moves nothing. That an "
"offset past the axis travel is refused, and the envelope's edge on the grid, are the "
"driver's unit and harness cases.")
def cloud_offsets(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
s0 = fc.settings() or {}
ctx.check(s0.get("cloud_enabled") == "1", "cloud mode is off: a camera home needs it on")
found = {k: s0.get(k) or "" for k in ("homing_mode", "gfcloud_home_x", "gfcloud_home_y")}
ev["found"] = found
session_at = None
try:
for k, v in (("homing_mode", "gfcloud"), ("gfcloud_home_x", str(HOME_X)), ("gfcloud_home_y", str(HOME_Y))):
st, body = fc.post("/settings", data={k: v})
ctx.check(st == 200, "%s=%s -> %s %s", k, v, st, body)
with ctx.grbl() as g:
session_at = session_mark()
gfhome_homing(ctx, ev, g)
judge_whole_motions(ctx, ev, session_at)
mpos = g.status_report().get("MPos") or (None, None, None)
ev["declared"] = mpos
ctx.log("the camera home declared %s", mpos)
ctx.check(mpos[0] is not None and abs(mpos[0] - HOME_X) < 0.02 and abs(mpos[1] - HOME_Y) < 0.02,
"the camera home did not declare (%.2f, %.2f): %s", HOME_X, HOME_Y, mpos)
jogs = {}
for axis, home in (("Y", HOME_Y), ("X", HOME_X)):
out = g.command("$J=G91 G21 %s2 F1000" % axis, timeout=10)
wait_state(ctx, g, "Idle", 20)
back = g.command("$J=G91 G21 %s-2 F1000" % axis, timeout=10)
wait_state(ctx, g, "Idle", 20)
at = g.status_report().get("MPos")
jogs[axis] = {"out": out[-1:], "back": back[-1:], "at": at}
ctx.check(out[-1:] == ["ok"] and back[-1:] == ["ok"],
"a jog out and back to the home in %s was refused: %s", axis, jogs[axis])
i = "XY".index(axis)
ctx.check(at and abs(at[i] - home) < 0.02, "the jog back in %s did not end at the home: %s", axis, at)
ev["jogs"] = jogs
ctx.log("out and back to the home: %s", jogs)
finally:
# The settings are refused for a moment after the suite's Grbl client closes; each is put back when taken.
for k, v in found.items():
def put():
st_, body_ = (fc.post("/settings", params={k: ""}) if v == "" else fc.post("/settings", data={k: v}))
if st_ != 200:
ctx.log("restore %s=%r -> %s %s", k, v, st_, body_)
return st_ == 200 or None
took = ctx.wait_for(put, 20, poll=0.5)
ctx.log("restore %s=%r -> %s", k, v, "taken" if took is not None else "not taken")
if session_at is not None:
camera_home_return(ctx, ev, session_at)
+178 -2
View File
@@ -1,8 +1,14 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""image.* - the post-flash health of the running image (always-required).""" """image.* - the post-flash health of the running image (always-required)."""
import glob import glob
import gzip import gzip
import mmap import mmap
import os import os
import shutil
import struct import struct
import re import re
import stat import stat
@@ -105,7 +111,7 @@ def fds_of(pid):
@test("image.health", title="Post-flash image health", subsystem="image", kind="auto", @test("image.health", title="Post-flash image health", subsystem="image", kind="auto",
always=True, est_min=1, 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", "CMakeLists.txt"), ("grblhal-glowforge", "src/boards/**"), ("forgectrl", "CMakeLists.txt"), ("grblhal-glowforge", "src/boards/**"),
("grblhal-glowforge", "CMakeLists.txt"), ("kernel-module-glowforge", "**"), ("grblhal-glowforge", "CMakeLists.txt"), ("kernel-module-glowforge", "**"),
("linux-fslc", "**")], ("linux-fslc", "**")],
@@ -114,7 +120,9 @@ def fds_of(pid):
"console banner and the motd, " "console banner and the motd, "
"the kernel options, the module, the pulse ring it maps and the SDMA clocks it holds, " "the kernel options, the module, the pulse ring it maps and the SDMA clocks it holds, "
"the daemon ownership, " "the daemon ownership, "
"the init ordering, the file modes the release depends on, and the mounts: the " "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 " "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.")
def image_health(ctx): def image_health(ctx):
@@ -261,6 +269,29 @@ def image_health(ctx):
ctx.check(kg and kf, "rc6.d lacks the grblhal/forgectrl kill links") 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]) 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 # 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)] logging = [n for n in ("forgefirm-logging", "forgefirm-logrotate") if os.path.exists("/etc/init.d/" + n)]
ev["logging_init"] = logging ev["logging_init"] = logging
@@ -278,6 +309,14 @@ def image_health(ctx):
ev[path] = "%o" % m ev[path] = "%o" % m
ctx.log("%s mode %o", path, m) ctx.log("%s mode %o", path, m)
ctx.check(m == 0o600, "%s mode %o, expected 600", 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"): if os.path.isdir("/data"):
s = os.statvfs("/data") s = os.statvfs("/data")
free_mb = s.f_bavail * s.f_frsize // (1024 * 1024) free_mb = s.f_bavail * s.f_frsize // (1024 * 1024)
@@ -395,3 +434,140 @@ def license_bundle(ctx):
text = bytes(body).decode("utf-8", "replace") if body else "" text = bytes(body).decode("utf-8", "replace") if body else ""
ctx.check("/system/licenses" in text and "PACKAGE NAME: " in text, ctx.check("/system/licenses" in text and "PACKAGE NAME: " in text,
"the Licenses page lacks the download link or the manifest") "the Licenses page lacks the download link or the manifest")
# ------------------------------------------------------- the network boot
IFSTATE = "/var/run/ifstate" # busybox ifupdown's state file
INTERFACES = "/etc/network/interfaces"
DHCP6_CLIENTS = ("udhcpc6", "dhcp6c", "odhcp6c")
def ifstate_names(text):
"""The interfaces ifup finished configuring: one name=name line each
in busybox's state file, written when ifup returns for the name."""
return {line.split("=", 1)[0].strip() for line in (text or "").splitlines() if "=" in line}
def dhcp6_in_interfaces(text):
"""The lines of an interfaces file that start a DHCPv6 client: an
inet6 stanza with a method that runs one, or a command that names
one. A comment starts nothing."""
bad = []
for raw in (text or "").splitlines():
words = raw.split("#", 1)[0].split()
if not words:
continue
if words[0] == "iface":
if len(words) >= 4 and words[2] == "inet6" and words[3] in ("dhcp", "auto"):
bad.append(" ".join(words))
elif any(w.split("/")[-1] in DHCP6_CLIENTS for w in words[1:]):
bad.append(" ".join(words))
return bad
def parse_stat(text):
"""(pid, comm, ppid) from a /proc/<pid>/stat line, or None. The comm
sits in parentheses and may itself hold spaces and parentheses."""
try:
head, tail = text.split("(", 1)
comm, rest = tail.rsplit(")", 1)
return int(head), comm, int(rest.split()[1])
except (ValueError, IndexError):
return None
def _procs():
"""(pid, ppid, comm, command line) of every process."""
out = []
for name in os.listdir("/proc"):
if not name.isdigit():
continue
st = parse_stat(_read("/proc/%s/stat" % name, ""))
if st is None:
continue
cmd = (_read("/proc/%s/cmdline" % name, "") or "").replace("\0", " ").strip()
out.append((st[0], st[2], st[1], cmd))
return out
def link_addrs(text, iface):
"""(link-local, global) IPv6 addresses of one interface, as the hex
strings of /proc/net/if_inet6 (address, index, prefix length, scope,
flags, name; scope 20 is the link, 00 the world)."""
local, world = [], []
for line in (text or "").splitlines():
f = line.split()
if len(f) == 6 and f[5] == iface:
if f[3] == "20":
local.append(f[0])
elif f[3] == "00":
world.append(f[0])
return local, world
@test("image.network-boot", title="The boot does not wait on the network",
subsystem="image", kind="auto", est_min=1,
description="The network comes up without the boot waiting on a server. init starts the "
"rest of the boot, the console login last, only after the networking script "
"returns, so a client that waits in ifup's foreground on a server's answer "
"holds the whole machine: no login, no SSH, no control panel. ifup has "
"returned for wlan0 (it is in the state file and no ifup is running) and the "
"console login is up; the DHCP client is the bounded one (-b, which leaves "
"ifup after three unanswered discovers) and has left ifup; no DHCPv6 client "
"is named in the interfaces file, running, or on the image; and IPv6 is the "
"kernel's own: enabled on wlan0, router advertisements accepted, a link-local "
"address up. A global address is evidence only, because a network whose "
"router advertisement offers no SLAAC prefix gives none. What a hostile "
"server does to a client is a bench drill, not a test. No component covers "
"this: the interfaces file is layer content, in the platform identity of "
"every fingerprint.")
def network_boot(ctx):
ev = ctx.evidence
procs = _procs()
# 1. ifup came back, and the boot went on to the console login
state = ifstate_names(_read(IFSTATE, ""))
ev["ifstate"] = sorted(state)
ctx.check("wlan0" in state, "ifup has not finished wlan0: %s holds %s", IFSTATE, sorted(state))
stuck = [p for p in procs if p[2] in ("ifup", "ifdown")]
ev["ifup_running"] = [p[3] for p in stuck]
ctx.check(not stuck, "ifup is still running: %s", [p[3] for p in stuck])
logins = [p[3] for p in procs if p[2] == "getty"]
ev["getty"] = logins
ctx.check(any("ttymxc0" in c for c in logins), "no console login is running: %s", logins)
# 2. the DHCP client is the bounded one, and it has left ifup
v4 = [p for p in procs if p[2] == "udhcpc"]
ev["udhcpc"] = [p[3] for p in v4]
ctx.check(v4, "no DHCP client is running")
for pid, ppid, _comm, cmd in v4:
ctx.check("-b" in cmd.split(), "the DHCP client runs without -b: %s", cmd)
ctx.check(ppid == 1, "the DHCP client (pid %d) is still a child of pid %d", pid, ppid)
# 3. no DHCPv6 client: not started, not running, not on the image
text = _read(INTERFACES)
ctx.check(text is not None, "%s is missing", INTERFACES)
named = dhcp6_in_interfaces(text)
ev["interfaces_dhcp6"] = named
ctx.check(not named, "%s starts a DHCPv6 client: %s", INTERFACES, named)
alive = [p[3] for p in procs if p[2] in DHCP6_CLIENTS]
ev["dhcp6_running"] = alive
ctx.check(not alive, "a DHCPv6 client is running: %s", alive)
for name in DHCP6_CLIENTS:
ctx.check(shutil.which(name) is None, "%s is on the image", name)
ctx.check(not os.path.exists("/usr/share/udhcpc/default6.script"),
"the DHCPv6 hook script is on the image")
# 4. IPv6 is the kernel's own
conf = "/proc/sys/net/ipv6/conf/wlan0/"
ev["disable_ipv6"] = (_read(conf + "disable_ipv6", "") or "").strip()
ev["accept_ra"] = (_read(conf + "accept_ra", "") or "").strip()
ctx.check(ev["disable_ipv6"] == "0", "IPv6 is off on wlan0 (disable_ipv6=%r)", ev["disable_ipv6"])
ctx.check(ev["accept_ra"] not in ("", "0"),
"wlan0 does not accept router advertisements (accept_ra=%r)", ev["accept_ra"])
local, world = link_addrs(_read("/proc/net/if_inet6", ""), "wlan0")
ev["link_local"] = local
ev["global"] = world
ctx.check(local, "wlan0 has no link-local IPv6 address")
ctx.log("wlan0: %d link-local, %d global IPv6 address(es)", len(local), len(world))
+5
View File
@@ -1,3 +1,8 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""kernel.* - glowforge.ko safety readbacks and the pulse-engine drills. """kernel.* - glowforge.ko safety readbacks and the pulse-engine drills.
The drills are the bench scripts `scripts/bench/gate_a_kernel_drills.py` The drills are the bench scripts `scripts/bench/gate_a_kernel_drills.py`
+509 -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
"""laser.* - LIVE laser tests, ported from `scripts/bench/live_fire_drills.py`. """laser.* - LIVE laser tests, ported from `scripts/bench/live_fire_drills.py`.
Every test here can emit, except `laser.power-floor`, which only reads Every test here can emit, except `laser.power-floor`, which only reads
@@ -25,7 +30,8 @@ from ..catalog import test
from .. import hw from .. import hw
from ..runner import Failed from ..runner import Failed
from .motion import (kernel_xy_mm, kernel_start, check_kernel_returned, wait_state, from .motion import (kernel_xy_mm, kernel_start, check_kernel_returned, wait_state,
wait_state_text, wait_left_state, wait_idle, drain_text, Watch) wait_state_text, wait_left_state, wait_idle, drain_text, Watch, machine_idle,
_job, _job_post, _job_wait, _lease_holder, _port_state, _words)
_LASER_COVERS = [("grblhal-glowforge", "src/**"), ("kernel-module-glowforge", "**"), _LASER_COVERS = [("grblhal-glowforge", "src/**"), ("kernel-module-glowforge", "**"),
("forgectrl", "src/super.c"), ("forgectrl", "src/cool.c"), ("forgectrl", "src/super.c"), ("forgectrl", "src/cool.c"),
@@ -252,6 +258,26 @@ def kill_trail(ctx, t0, seconds=5.0):
return trail return trail
def kill_fast_trail(t0, seconds=1.5):
"""The latch and the kernel state read from sysfs every few
milliseconds after a kill: the supervisor's own reaction, which the
HTTP samples of kill_trail cannot resolve. A controller death is a
signal to the supervisor, not a poll, so the relock lands within
milliseconds; the kernel leaves running once its stop ramp is done."""
locked_at = stopped_at = None
end = t0 + seconds
while time.time() < end and (locked_at is None or stopped_at is None):
now = time.time() - t0
ilk = hw.sysfs_int("cnc/interlock_circuit")
if locked_at is None and ilk is not None and ilk & IL_LASER_LATCH:
locked_at = round(now, 3)
st = hw.sysfs_read("cnc/state")
if stopped_at is None and st is not None and st != "running":
stopped_at = round(now, 3)
time.sleep(0.005)
return {"latch_locked_at_s": locked_at, "kernel_stopped_at_s": stopped_at}
def judge_kill(ctx, trail, what): def judge_kill(ctx, trail, what):
"""(first zero, tail stayed zero, kernel stopped running) from a trail.""" """(first zero, tail stayed zero, kernel stopped running) from a trail."""
for t in trail: for t in trail:
@@ -645,8 +671,11 @@ def disarm_in_hold(ctx):
stream(g, ["G91", "G21", "M4", "S400", "G1 X40 F300"]) stream(g, ["G91", "G21", "M4", "S400", "G1 X40 F300"])
ctx.log("armed; waiting for motion to start (arm + your button press)...") ctx.log("armed; waiting for motion to start (arm + your button press)...")
w = Watch(g) w = Watch(g)
ctx.act("button", "press", text="The button is lit white: the press arms the job and the " # The press waits for the button to light: pressed before the job
"move starts.", until=w.in_state("Run"), timeout=180, fail=False) # reaches its arm wait, it is lost, and the move never starts.
ctx.arm_press()
ctx.wait_for(w.in_state("Run"), 180)
ctx.clear_notice()
st = w.last["state"] if w.last else None 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.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.log("moving under laser: %s; feed-hold in 2 s", st)
@@ -715,7 +744,9 @@ def disarm_in_hold(ctx):
"SIGTERM, so emission drops within 2.5 s and stays 0, the kernel is not running, " "SIGTERM, so emission drops within 2.5 s and stays 0, the kernel is not running, "
"and the restart is a separate operator-judged step. Unexpected: mid-burn SIGKILL " "and the restart is a separate operator-judged step. Unexpected: mid-burn SIGKILL "
"of the controller - the supervisor's exit safing must end the fire tail inside " "of the controller - the supervisor's exit safing must end the fire tail inside "
"the ring's in-flight window, leave the latch locked, and respawn the controller.") "the ring's in-flight window, leave the latch locked, and respawn the controller. "
"The death is a signal to the supervisor, not a poll: the latch relocks within "
"300 ms of the kill and the kernel is out of running within a second.")
def armed_kill(ctx): def armed_kill(ctx):
ev = ctx.evidence ev = ctx.evidence
fc = ctx.forgectrl fc = ctx.forgectrl
@@ -769,13 +800,20 @@ def armed_kill(ctx):
ctx.log("emission live (%s) - SIGKILL controller pid %s NOW", smp["emission"], pid) ctx.log("emission live (%s) - SIGKILL controller pid %s NOW", smp["emission"], pid)
t_kill = time.time() t_kill = time.time()
_os.kill(pid, _signal.SIGKILL) _os.kill(pid, _signal.SIGKILL)
fast = kill_fast_trail(t_kill)
trail = kill_trail(ctx, t_kill) trail = kill_trail(ctx, t_kill)
ctx.log("after the kill (sysfs): %s", fast)
zero_at, tail_zero, not_running = judge_kill(ctx, trail, "kill") zero_at, tail_zero, not_running = judge_kill(ctx, trail, "kill")
ilk = hw.sysfs_int("cnc/interlock_circuit") ilk = hw.sysfs_int("cnc/interlock_circuit")
locked = ilk is not None and bool(ilk & (1 << 3)) locked = ilk is not None and bool(ilk & (1 << 3))
ev["sigkill"] = {"pid": pid, "zero_at_s": zero_at, "tail_zero": tail_zero, ev["sigkill"] = {"pid": pid, "zero_at_s": zero_at, "tail_zero": tail_zero,
"kernel_not_running": not_running, "latch_locked": locked, "trail": trail} "kernel_not_running": not_running, "latch_locked": locked, "trail": trail,
"fast": fast}
ctx.log("latch locked after the kill: %s", locked) ctx.log("latch locked after the kill: %s", locked)
ctx.check(fast["latch_locked_at_s"] is not None and fast["latch_locked_at_s"] < 0.3,
"the latch was not locked within 300 ms of the kill: %s", fast)
ctx.check(fast["kernel_stopped_at_s"] is not None and fast["kernel_stopped_at_s"] < 1.0,
"the kernel was still running 1 s after the kill: %s", fast)
ctx.check(zero_at is not None and zero_at < 2.5, ctx.check(zero_at is not None and zero_at < 2.5,
"emission did not drop within 2.5 s of the kill (first 0 at %s)", zero_at) "emission did not drop within 2.5 s of the kill (first 0 at %s)", zero_at)
ctx.check(tail_zero, "emission returned after the kill") ctx.check(tail_zero, "emission returned after the kill")
@@ -792,10 +830,264 @@ def armed_kill(ctx):
ctx.log("/mode after the kill: %s", m1) ctx.log("/mode after the kill: %s", m1)
ctx.check(m1 and m1.get("controller") == "running" and m1.get("pid") != pid, ctx.check(m1 and m1.get("controller") == "running" and m1.get("pid") != pid,
"supervisor did not respawn the controller: %s", m1) "supervisor did not respawn the controller: %s", m1)
# The respawned controller starts locked and stays locked: a latch it
# did not unlock is never relit by a run start, and no arm has asked.
ctx.check(wait_grbl_port(ctx), "the respawned controller never accepted a Grbl connection")
ctx.sleep(2)
ilk = hw.sysfs_int("cnc/interlock_circuit")
ev["latch_locked_after_respawn"] = ilk is not None and bool(ilk & IL_LASER_LATCH)
ctx.check(ev["latch_locked_after_respawn"],
"the respawned controller unlocked the latch with no arm (interlock_circuit=%s)", ilk)
ev["beam_at_fire_kill"] = smp.get("beam") ev["beam_at_fire_kill"] = smp.get("beam")
check_button_dark(ctx, ev) check_button_dark(ctx, ev)
ctx.log("PASS: expected stop 0 at +%s s and SIGKILL 0 at +%s s, latch locked, controller " ctx.log("PASS: expected stop 0 at +%s s and SIGKILL 0 at +%s s, latch locked, controller "
"respawned, button dark", ev["expected"]["zero_at_s"], zero_at) "respawned with the latch still locked, button dark", ev["expected"]["zero_at_s"], zero_at)
@test("laser.verdict-cut", title="A blocked verdict mid-cut locks the latch and holds; the clean "
"verdict resumes with no press",
subsystem="laser", kind="live", mode="grbl", est_min=4,
covers=_LASER_COVERS,
requires=["laser.emission-witness"], actions=["button"],
steps=["Scrap under the head with 40 mm of free +X travel; lid closed; exhaust on.",
"Press the physical button when it lights white (the arm). Nothing else: the test "
"takes the cooling verdict away itself and gives it back."],
description="A 40 mm line at constant power (M3 S400/F300, 8 s). About 1.5 s in, the test "
"pauses the machine-services daemon for 3.0 s, so the cooling verdict the "
"controller reads goes stale: the controller's pause tier. The controller must "
"hold the job (grbl Hold) under the still-open armed window with the stream "
"masked dark: the gated LASER_ON output (cnc/laser_on), read about 3300 times a "
"second, reads the laser off in every read that falls inside the hold from 0.3 s "
"after the first Hold:0 (the pause tier's lit deceleration still playing out of "
"the kernel's 200 ms queue) until the cut resumes, at least 500 reads, and the "
"same reads saw the cut lit in the second before the pause. It must not write "
"the latch: a lock sets the hardware button "
"latch, which only a press clears, so both bit 3 (the SoC lock) and bit 2 "
"(the button latch) of interlock_circuit stay clear through the whole run. "
"When the daemon returns (the verdict fresh, clean, resume_ok), the controller "
"must resume the cut lit from where it stopped, with no button press and no "
"prompt; the M2 program end then disarms as usual. The daemon's own dead-man "
"(5 s of silence) is not reached: the controller keeps reporting through the "
"pause. The beam detector witnesses the cut on both sides of the hold.")
def verdict_cut(ctx):
import os as _os
import signal as _signal
ev = ctx.evidence
fc = ctx.forgectrl
# The daemon is frozen long enough for the verdict the controller
# caches to expire (VERDICT_MAX_AGE 2.0 s), so the driver's pause tier
# holds the job - but not so long that the engine's report dead-man
# trips on resume. That dead-man locks the laser latch when the
# controller's report is older than REPORT_TIMEOUT_S (5.0 s) while
# armed, which is exactly the "latch stayed locked" this test refuses.
# The last report is up to 1 s old when the freeze starts, so the
# freeze must stay under 4.0 s; 3.0 s clears the stale floor with room
# to see the hold and stays a full second under the dead-man.
PAUSE_S = 3.0
# The dark judge's witness is the gated LASER_ON output itself
# (cnc/laser_on, 1 = on), read fast. The kernel's sampled 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 the hold can be
# shorter than that. About 3300 reads a second on the bench reference;
# each read yields the CPU to anything runnable (the controller).
FAST_S = 0.1
# The pause tier's first deceleration is lit on purpose (a pause leaves
# no gap in the cut), and the controller reports Hold:0 once it has
# produced the deceleration, not once the kernel has played it: the
# driver's queue depth (200 ms, GFSINK_DEPTH_MS's default) and its
# producer lead (10 ms) are still to play. The dark span starts that
# long after the first Hold:0, with margin.
DRAIN_S = 0.3
MIN_DARK_READS = 500
MIN_LIT_READS = 10
def fast_reads(seconds, t_ref):
"""Reads of cnc/laser_on for `seconds`: [start, end] in seconds
from t_ref, then the reads taken, the reads that saw the laser on,
and the reads that failed."""
start = time.time()
n = on = bad = 0
while time.time() - start < seconds:
v = hw.sysfs_int("cnc/laser_on")
n += 1
if v is None:
bad += 1
elif v:
on += 1
_os.sched_yield()
return [round(start - t_ref, 3), round(time.time() - t_ref, 3), n, on, bad]
def dark_span(rows, drain_s):
"""The fast reads that fell wholly inside the hold, drain_s or more
after the first Hold:0 row: a burst counts when the rows either side
of it both read Hold, so the hold held through it. [reads, on,
failed, bursts, from_s, to_s], or None without a Hold:0 row. Pure:
tests/test_laser_verdict.py runs it over synthetic trails."""
hold0 = next((r["t"] for r in rows if r["gstate"].startswith("Hold:0")), None)
if hold0 is None:
return None
reads = on = bad = bursts = 0
first = last = None
for a, b in zip(rows, rows[1:]):
f = a.get("fast")
if not f or not a["gstate"].startswith("Hold") or not b["gstate"].startswith("Hold"):
continue
if f[0] < hold0 + drain_s:
continue
reads, on, bad, bursts = reads + f[2], on + f[3], bad + f[4], bursts + 1
first = f[0] if first is None else first
last = f[1]
return [reads, on, bad, bursts, first, last]
pids = hw.pidof("forgectrl")
ctx.check(pids, "no forgectrl process found to pause")
ev["daemon_pids"] = pids
state_path = "/run/forgefirm/grbl.state"
def grbl_state_file():
try:
with open(state_path) as f:
return f.read()
except OSError:
return ""
def resume_daemon():
for p in pids:
try:
_os.kill(p, _signal.SIGCONT)
except OSError:
pass
trail = []
text = ""
with ctx.grbl() as g, LiveJob(ctx, g):
prepare(ctx, g)
k0 = kernel_start(ctx)
base = sample(ctx)
ctx.check(base, "forgectrl /status or /cool/status unavailable")
ctx.check(not base["emission"], "emission_samples nonzero before the job (%s)", base["emission"])
ctx.ready(ARM_CUE % "40 mm +X")
job = ["G91", "G21", "M3", "S400", "G1 X40 F300", "M5", "G0 X-40", "G90", "M2"]
smp = arm_and_fire(ctx, g, room="40 mm +X", job=job)
beams = [(smp.get("beam"), smp.get("beam_d"))]
ctx.log("emission live (%s); pausing the daemon in 1 s", smp["emission"])
# The witness's own proof: over the lit cut the same reads see it lit.
lit_ctl = fast_reads(1.0, time.time())
ctx.log("fast reads of cnc/laser_on over the lit cut: %d of %d on, %d failed", lit_ctl[3],
lit_ctl[2], lit_ctl[4])
g.drain()
t0 = time.time()
try:
for p in pids:
_os.kill(p, _signal.SIGSTOP)
ctx.log("daemon paused (SIGSTOP %s) for %.1f s: the verdict goes stale", pids, PAUSE_S)
# No HTTP while the daemon is paused: the controller's socket,
# sysfs and the controller's state file are the witnesses.
while time.time() - t0 < PAUSE_S:
st = g.status_report()["state"]
text += g.drain()
il = hw.sysfs_int("cnc/interlock_circuit")
trail.append({"t": round(time.time() - t0, 2), "gstate": st, "il": il,
"emission": hw.sysfs_int("cnc/laser_on_sampled"),
"armed": '"armed":true' in grbl_state_file()})
if st.startswith("Hold"):
trail[-1]["fast"] = fast_reads(FAST_S, t0)
else:
time.sleep(0.12)
finally:
resume_daemon()
ctx.log("daemon resumed (SIGCONT) at +%.2f s", time.time() - t0)
# Through the resume and to the end of the cut, sampling the same
# witnesses (the daemon's own once it answers again).
resumed_at = None
end = time.time() + 30
while time.time() < end:
ctx.checkpoint()
st = g.status_report()["state"]
text += g.drain()
il = hw.sysfs_int("cnc/interlock_circuit")
row = {"t": round(time.time() - t0, 2), "gstate": st, "il": il,
"emission": hw.sysfs_int("cnc/laser_on_sampled"),
"armed": '"armed":true' in grbl_state_file()}
if time.time() - t0 > PAUSE_S + 1.0:
s = sample(ctx)
if s:
row["beam"] = s.get("beam")
beams.append((s.get("beam"), s.get("beam_d")))
trail.append(row)
if resumed_at is None and st.startswith("Run"):
resumed_at = time.time() - t0
if st.startswith("Idle") and resumed_at is not None and time.time() - t0 > resumed_at + 3.0:
break
if resumed_at is None and st.startswith("Hold"):
row["fast"] = fast_reads(FAST_S, t0)
else:
time.sleep(0.12)
for r in trail:
ctx.log(" %s", r)
ev["trail"] = trail
ev["messages"] = [ln for ln in text.splitlines() if ln.startswith("[MSG:") or ln.startswith("ALARM")]
ctx.log("controller: %s", ev["messages"])
dt = wait_disarm(ctx, 75)
ev["disarm_after_idle_s"] = round(dt, 1) if dt is not None else None
ctx.check(ctx.forgectrl.wait_idle(15, abort=ctx.aborted), "machine not idle after the job")
check_kernel_returned(ctx, ev, k0)
def locked(r):
return r["il"] is not None and bool(r["il"] & IL_LASER_LATCH)
def button_latched(r):
return r["il"] is not None and bool(r["il"] & IL_BUTTON_LATCH)
held = [r for r in trail if r["gstate"].startswith("Hold")]
ev.update({"held_at_s": held[0]["t"] if held else None,
"resumed_at_s": round(resumed_at, 2) if resumed_at else None,
"latch_locked_samples": sum(1 for r in trail if locked(r)),
"button_latch_set_samples": sum(1 for r in trail if button_latched(r))})
ctx.check(held and held[0]["t"] < PAUSE_S, "the stale verdict did not hold the job during the "
"pause (first Hold at %s)", ev["held_at_s"])
ctx.check("fire masked, job held" in text, "the controller did not report the pause tier")
ctx.check(ev["latch_locked_samples"] == 0,
"the pause tier wrote the latch (locked in %d samples): a lock sets the hardware "
"button latch and the resume would need a press", ev["latch_locked_samples"])
ctx.check(ev["button_latch_set_samples"] == 0,
"the hardware button latch read SET in %d samples: the resume needed a press",
ev["button_latch_set_samples"])
ctx.check(all(r["armed"] for r in trail if r["t"] < (resumed_at or PAUSE_S + 5)),
"the armed window closed during the pause")
ctx.check("press the button" not in text, "the resume asked for a button press")
ctx.check(DISARMED_MSG not in text.split("resuming")[0], "the pause tier disarmed the job")
# Dark through the hold: the gate masked the stream. First the witness:
# the reads that judge the dark saw the cut lit before the pause.
ev["fast_lit_control"] = {"reads": lit_ctl[2], "on": lit_ctl[3], "failed": lit_ctl[4]}
ctx.check(lit_ctl[4] == 0 and lit_ctl[3] >= MIN_LIT_READS,
"the fast reads of cnc/laser_on did not see the lit cut before the pause (%d of %d on, "
"%d failed): they cannot witness the dark", lit_ctl[3], lit_ctl[2], lit_ctl[4])
dark = dark_span(trail, DRAIN_S)
ev["fast_dark"] = (dict(zip(("reads", "on", "failed", "bursts", "from_s", "to_s"), dark))
if dark else None)
ctx.check(dark is not None, "no Hold:0 was seen, so the hold cannot be judged dark")
ctx.check(dark[2] == 0, "cnc/laser_on could not be read %d times inside the hold", dark[2])
ctx.check(dark[1] == 0, "the laser was on in %d of %d reads of cnc/laser_on inside the hold "
"(%s s to %s s, %.1f s or more after the first Hold:0)", dark[1], dark[0], dark[4], dark[5],
DRAIN_S)
ctx.check(dark[0] >= MIN_DARK_READS, "only %d reads of cnc/laser_on fell inside the hold after the "
"drain: too few to judge it dark (at least %d)", dark[0], MIN_DARK_READS)
ctx.check(resumed_at is not None, "the clean verdict did not resume the cut")
ctx.check("resuming" in text, "the controller did not report the resume")
after = [r for r in trail if resumed_at and r["t"] >= resumed_at]
ctx.check(any(r["emission"] for r in after), "the resumed cut ran dark (no emission after the resume)")
ctx.check("ALARM" not in text, "an alarm was raised on the pause tier")
ctx.check(dt is not None and dt < 10.0, "the M2 job did not disarm promptly at Idle (%s s)", dt)
beam_witness(ctx, ev, [{"beam": b, "beam_d": d} for b, d in beams], base)
judge_beam(ctx, ev["beam"], "the cut")
check_button_dark(ctx, ev)
ctx.log("PASS: held at +%s s with the latch untouched, the laser off in all %d reads inside the "
"hold (%s s to %s s), resumed lit at +%s s with no press, disarmed %.1f s after Idle",
ev["held_at_s"], dark[0], dark[4], dark[5], ev["resumed_at_s"], dt)
DISARMED_MSG = "laser disarmed - latch locked"
@test("laser.arm-wait-lid", title="Lid open during the arm wait cancels the job", @test("laser.arm-wait-lid", title="Lid open during the arm wait cancels the job",
@@ -1033,3 +1325,214 @@ def pause_resume_lid_cancel(ctx):
ctx.log("PASS: button paused the burn (emission 0, armed kept, latch unlocked) and resumed it; " ctx.log("PASS: button paused the burn (emission 0, armed kept, latch unlocked) and resumed it; "
"the lid then canceled it - emission 0 at +%s s, reset without alarm, returned (drift " "the lid then canceled it - emission 0 at +%s s, reset without alarm, returned (drift "
"%.3f mm), button latch SET", zero_at, drift) "%.3f mm), button latch SET", zero_at, drift)
def _port_jog_sampled(ctx, fc, params, samples, timeout=30.0):
"""One jog through the controller port, sampled at 8 Hz until the port
says it is over. Returns the HTTP status and body of the jog request."""
st, body = fc.post("/motion/jog", params=params)
t0 = time.time()
while time.time() - t0 < timeout:
ctx.checkpoint()
smp = sample(ctx)
if smp:
samples.append(smp)
try:
ps = fc.get("/motion/state")[1]
except hw.HwError:
ps = None
if st != 200 or (isinstance(ps, dict) and ps.get("state") == "Idle" and not ps.get("port_jog")):
break
time.sleep(0.125)
return st, body
@test("laser.port-dark", title="A panel jog under an open armed window ships dark",
subsystem="laser", kind="live", mode="grbl", est_min=3,
covers=_LASER_COVERS + [("forgectrl", "src/grblport.*")],
requires=["laser.emission-witness", "motion.port-jog"], 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 M3 S400/F600 with no M5 and no program end after it, so the "
"armed window stays open with M3 modal and S above zero: the state in which an "
"injected G1 would fire. The line itself must be witnessed lit, or the case "
"proves nothing. Then POST /motion/jog takes the head back over the line and out "
"again, through the controller port. The window must read armed before and "
"after the jogs, and through both the kernel's LASER_ON sample count stays 0, "
"the HV current stays at its idle reading, and the head's beam detector does "
"not rise over its level before the jogs. The client then ends the job (M5, "
"M2) and the window closes.")
def port_dark(ctx):
ev = ctx.evidence
fc = ctx.forgectrl
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")
ctx.arm_press()
jog_samples = []
try:
# No M5 and no M2: the window stays open, M3 S400 stays modal.
lit = run_and_sample(ctx, g, ["G91", "G21", "M3", "S400", "G1 X20 F600"])
peak = max((s["emission"] or 0 for s in lit), default=0)
ctx.check(peak > 0, "no emission witnessed on the G1: the dark jogs would prove nothing")
# The cut is over and dark again; the window is still open.
ok = ctx.wait_for(lambda: (sample(ctx) or {}).get("emission") == 0, 10, poll=0.125)
ctx.check(ok is not None, "emission_samples did not return to 0 after the G1")
before = sample(ctx)
ctx.check(before and before["armed"], "the armed window closed before the jogs: %s",
before and before["phase"])
ctx.sleep(0.6) # past the port's sender-quiet interval
for params in ({"x": "-20", "feed": "600"}, {"x": "20", "feed": "2400"},
{"x": "-20", "feed": "2400"}):
st, body = _port_jog_sampled(ctx, fc, params, jog_samples)
ctx.check(st == 200, "POST /motion/jog %s under the open window -> %s %s", params, st, body)
ctx.sleep(0.6)
after = sample(ctx)
ctx.check(after and after["armed"],
"the armed window closed during the jogs: the dark samples were not under it")
finally:
ctx.clear_notice()
r = g.command("M5")
r += g.command("M2")
ctx.check(not any(x.startswith("error") for x in r), "the client could not end the job: %s", r)
ev["disarm_s"] = wait_disarm(ctx, 30)
ctx.check(ev["disarm_s"] is not None, "the armed window did not close after the program end")
lit_in_jog = [s for s in jog_samples if s["emission"]]
hv_max = max((s["hv"] for s in jog_samples if s["hv"] is not None), default=0)
beams = [s["beam"] for s in jog_samples if s.get("beam") is not None]
beam_rise = (max(beams) - before["beam"]) if beams and before.get("beam") is not None else None
ev.update({"emission_peak_g1": peak, "jog_samples": len(jog_samples),
"jog_emission_samples": len(lit_in_jog), "jog_hv_max": hv_max,
"jog_beam_rise": beam_rise, "trail": trail(jog_samples)})
ctx.log("G1 emission peak %s; %d samples across three port jogs: %d with emission, HV max %s, "
"beam detector rise %s", peak, len(jog_samples), len(lit_in_jog), hv_max, beam_rise)
ctx.check(len(jog_samples) >= 16, "only %d samples across the jogs", len(jog_samples))
ctx.check(not lit_in_jog, "a port jog fired under the open window: %d samples with emission, "
"first emission_samples %s", len(lit_in_jog), lit_in_jog[0]["emission"] if lit_in_jog else None)
ctx.check(hv_max <= HV_DARK_MAX, "HV current %s during a port jog (idle reads ~0)", hv_max)
ctx.check(beam_rise is not None and beam_rise < BEAM_DELTA_MIN,
"the head's beam detector rose %s during a port jog", beam_rise)
ctx.log("PASS: lit on the G1 (peak %s), and three port jogs under the same open window with M3 S400 "
"modal shipped dark by all three witnesses", peak)
# ---------------------------------------------------------------- the recorder, as a job of the runner's
def _laser_keys(fc):
s = fc.settings()
return {k: s.get(k) for k in ("laser_floor_density", "laser_dose_curve")}
def _arming(fc):
rep = ((fc.status().get("grbl") or {}).get("report") or {})
return bool((rep.get("laser") or {}).get("arming"))
@test("laser.recorder-dark", title="The dose-curve recorder takes the machine as a job, arms, and is stopped dark",
subsystem="laser", kind="auto", mode="grbl", est_min=2,
covers=_LASER_COVERS + [("forgectrl", "src/curverec.*"), ("forgectrl", "src/jobrun.*"),
("forgectrl", "src/jobstream.*"), ("forgectrl", "src/lease.*"),
("forgectrl", "src/settings.*")],
requires=["motion.job"],
steps=["Lid closed, bed clear. Nobody presses the button: the recorder is stopped before its press.",
"The ladder begins at the controller's X0 Y0: the head goes there, and is brought back."],
description="POST /curve/record starts the dose ladder as a job of the job runner's. While it "
"waits for a press that never comes: /status names the recorder as the lease "
"holder of kind sender, GET /job reports the run (owner recorder, not a posted "
"program), the floor and the curve are overridden (0 and off), the controller "
"reaches its arm wait, POST /job/abort refuses to stop a run it did not start, a "
"posted job is refused in the recorder's name, and the kernel's LASER_ON sample "
"count and the HV current stay dark. POST /curve/stop then ends it: the recorder "
"says it was stopped before the ladder fired, the lease is free, and both laser "
"keys read as they did before. The head is returned by a posted job.")
def recorder_dark(ctx):
ev = ctx.evidence
fc = hw.Forgectrl()
machine_idle(ctx)
x0, y0 = kernel_start(ctx)
keys0 = _laser_keys(fc)
base = sample(ctx)
ctx.check(base and not base["emission"], "emission_samples nonzero before the recording (%s)", base)
ctx.check(not _lease_holder(fc), "the machine is not free at the start: %s", _lease_holder(fc))
started = False
samples = []
try:
st, body = fc.post("/curve/record")
ctx.check(st == 200 and isinstance(body, dict) and body.get("state") == "waiting",
"POST /curve/record -> %s %s", st, _words(body)[:200])
started = True
ok = ctx.wait_for(lambda: _lease_holder(fc).get("owner") == "recorder", 5, poll=0.1)
held = _lease_holder(fc)
ev["holder"] = held
ctx.check(ok is not None and held.get("kind") == "sender", "the lease does not name the recorder: %s", held)
rec = _job(fc)
ev["job_record"] = rec
ctx.check(rec["state"] == "running" and rec["owner"] == "recorder" and rec["program"] is False,
"GET /job while the recorder runs: %s", rec)
over = _laser_keys(fc)
ev["override"] = over
ctx.check(str(over["laser_dose_curve"]) == "off" and float(over["laser_floor_density"] or 0) == 0.0,
"the floor and the curve are not overridden for the ladder: %s", over)
ok = ctx.wait_for(lambda: _arming(fc), 40, poll=0.25)
ctx.check(ok is not None, "the controller never reached its arm wait: %s",
(fc.status().get("grbl") or {}).get("report"))
ctx.log("the recorder holds the machine as %s, and the controller waits for the press", held)
st, body = fc.post("/job/abort")
ev["abort_route"] = [st, _words(body)[:160]]
ctx.check(st == 409 and "stop it where it was started" in _words(body),
"POST /job/abort on the recorder's run -> %s %s", st, _words(body)[:160])
st, body = _job_post(fc, "G21\n")
ev["job_beside"] = [st, _words(body)[:160]]
ctx.check(st == 409 and "the dose-curve recorder holds the machine" in _words(body),
"a posted job beside the recorder -> %s %s", st, _words(body)[:160])
for _ in range(16): # two seconds in the arm wait, by the witnesses
samples.append(sample(ctx))
ctx.sleep(0.125)
ctx.check(_lease_holder(fc).get("owner") == "recorder", "a refused request took the lease away")
finally:
if started:
st, body = fc.post("/curve/stop")
ev["stopped"] = body if isinstance(body, dict) else _words(body)[:160]
ctx.check(st == 200 and isinstance(body, dict) and body.get("state") == "failed"
and "before the ladder fired" in body.get("reason", ""), "POST /curve/stop -> %s %s",
st, _words(body)[:200])
ok = ctx.wait_for(lambda: not _lease_holder(fc), 10, poll=0.2)
ctx.check(ok is not None, "the lease was not given back: %s", _lease_holder(fc))
keys1 = _laser_keys(fc)
ev["keys"] = {"before": keys0, "after": keys1}
ctx.check(keys1 == keys0, "the laser keys were not restored: %s, were %s", keys1, keys0)
lit = [s for s in samples if s and s["emission"]]
hv_max = max((s["hv"] for s in samples if s and s["hv"] is not None), default=0)
ev["witnesses"] = {"samples": len(samples), "with_emission": len(lit), "hv_max": hv_max,
"job": _job(fc)["emission"]}
ctx.check(not lit and hv_max <= HV_DARK_MAX and _job(fc)["emission"]["laser_on_samples"] == 0,
"the recorder's arm wait was not dark: %s", ev["witnesses"])
# The ladder's first move took the head to the controller's X0 Y0.
ctx.sleep(1.0)
dx, dy = (a - b for a, b in zip(kernel_xy_mm(ctx), (x0, y0)))
ev["moved_mm"] = [round(dx, 3), round(dy, 3)]
ev["controller_after_stop"] = _port_state(fc).get("state")
ctx.log("after the stop the controller is in %s; the head is %.3f, %.3f mm from where it was found",
ev["controller_after_stop"], dx, dy)
if abs(dx) > 0.05 or abs(dy) > 0.05:
st, body = _job_post(fc, "G21\nG91\nG1 X%.3f Y%.3f F2400\nG90\n" % (-dx, -dy), unlock="1")
ctx.check(st == 200, "the returning job -> %s %s", st, _words(body)[:200])
rec, _far = _job_wait(ctx, fc, 60)
ctx.check(rec["state"] == "done", "the returning job: %s", rec)
elif str(ev["controller_after_stop"]).startswith("Alarm"):
st, body = _job_post(fc, "G21\n", unlock="1")
rec, _far = _job_wait(ctx, fc, 20)
ctx.check(st == 200 and rec["state"] == "done", "the unlocking job: %s %s", st, rec)
check_kernel_returned(ctx, ev, (x0, y0), tag="recorder")
machine_idle(ctx)
ctx.log("PASS: the recorder ran as a job of the runner's, held the machine as a sender through its arm "
"wait with every witness dark, and its stop freed the machine and restored the laser keys")
+78 -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
"""logs.* - unified logging: the log tree, tail, and the sanitized export; """logs.* - unified logging: the log tree, tail, and the sanitized export;
the routing path (emitters, relays, rendered rules); the level settings.""" the routing path (emitters, relays, rendered rules); the level settings."""
import gzip import gzip
@@ -20,7 +25,14 @@ def _read(path, default=None):
_LOG_COVERS = [("forgectrl", "src/logs.*"), ("forgectrl", "src/fflog.*"), ("forgectrl", "src/sanitize.*"), _LOG_COVERS = [("forgectrl", "src/logs.*"), ("forgectrl", "src/fflog.*"), ("forgectrl", "src/sanitize.*"),
("forgectrl", "src/main.c")] ("forgectrl", "src/extpkg.*"), ("forgectrl", "src/main.c")]
LOGS_ROOT = "/data/log/forgefirm"
# What the export test plants in the installer's directory: a line to find
# again, and two addresses (documentation ranges) the sanitizer must take.
_PROBE_MARK = "forgetest export probe"
_PROBE_MAC = "02:00:5e:10:20:30"
_PROBE_IP = "192.0.2.77"
@test("logs.tree-tail-export", title="Log tree, tail, and sanitized export", subsystem="logs", @test("logs.tree-tail-export", title="Log tree, tail, and sanitized export", subsystem="logs",
@@ -28,8 +40,36 @@ _LOG_COVERS = [("forgectrl", "src/logs.*"), ("forgectrl", "src/fflog.*"), ("forg
covers=_LOG_COVERS, requires=["forgectrl.auth"], covers=_LOG_COVERS, requires=["forgectrl.auth"],
description="/logs lists the loggers with their levels and files, /logs/tail returns the " description="/logs lists the loggers with their levels and files, /logs/tail returns the "
"forgectrl logger's tail, and POST /logs/export streams a sanitized tar.gz " "forgectrl logger's tail, and POST /logs/export streams a sanitized tar.gz "
"bundle that contains neither the panel token nor the camera key.") "bundle that contains neither the panel token nor the camera key. The bundle "
"also carries the installer's directory of the tree (logs/install/), which no "
"logger feeds: a probe file planted there for the export comes back in the "
"bundle with its addresses redacted, and an install log that the installer "
"left is in the bundle too. system/extensions.json is in it as well: what is "
"installed that is not the firmware, with each package's tier, whether it is "
"enabled, and what the operator granted it, which is what a report about a "
"machine running extensions has to be read against.")
def tree_tail_export(ctx): def tree_tail_export(ctx):
install_dir = os.path.join(LOGS_ROOT, "install")
probe = os.path.join(install_dir, "forgetest-probe.txt")
made_dir = not os.path.isdir(install_dir)
try:
os.makedirs(install_dir, exist_ok=True)
with open(probe, "w") as f:
f.write("%s from %s at %s\n" % (_PROBE_MARK, _PROBE_MAC, _PROBE_IP))
_tree_tail_export(ctx, probe)
finally:
try:
os.unlink(probe)
except OSError:
pass
if made_dir:
try:
os.rmdir(install_dir)
except OSError:
pass
def _tree_tail_export(ctx, probe):
fc = ctx.forgectrl fc = ctx.forgectrl
ev = ctx.evidence ev = ctx.evidence
st, body = fc.get("/logs") st, body = fc.get("/logs")
@@ -82,6 +122,40 @@ def tree_tail_export(ctx):
ev[what.replace(" ", "_") + "_leaks"] = leaked ev[what.replace(" ", "_") + "_leaks"] = leaked
ctx.check(not leaked, "the sanitized bundle contains the %s: %s", what, leaked) ctx.check(not leaked, "the sanitized bundle contains the %s: %s", what, leaked)
# the installer's directory: no logger feeds it, the export carries it
by_name = dict(contents)
want = "logs/install/" + os.path.basename(probe)
got = [name for name in by_name if name.endswith(want)]
ev["install_members"] = sorted(name for name in by_name if "/logs/install/" in name)
ctx.check(got, "the bundle lacks %s: %s", want, ev["install_members"])
if got:
text = by_name[got[0]].decode("utf-8", "replace")
ctx.check(_PROBE_MARK in text, "the probe file came back without its line: %r", text)
for what, value in (("MAC address", _PROBE_MAC), ("IPv4 address", _PROBE_IP)):
ctx.check(value not in text, "the install directory left the sanitizer with its %s", what)
if os.path.isfile(os.path.join(os.path.dirname(probe), "install.log")):
ctx.check(any(name.endswith("logs/install/install.log") for name in by_name),
"the machine has an install log and the bundle does not")
# What is installed that is not the firmware. A report that starts "it
# stopped working" reads differently with packages on the machine, and
# the bundle is where that is read.
import json
ext = [name for name in by_name if name.endswith("system/extensions.json")]
ctx.check(ext, "the bundle carries no system/extensions.json")
if ext:
doc = json.loads(by_name[ext[0]].decode("utf-8", "replace"))
ev["extensions"] = {"enabled": doc.get("enabled"), "safe_mode": doc.get("safe_mode"),
"packages": [{k: p.get(k) for k in ("id", "version", "tier", "enabled", "grants")}
for p in doc.get("packages", [])]}
ctx.log("the bundle's extension listing: %s", ev["extensions"])
ctx.check(set(doc) >= {"enabled", "safe_mode", "host", "packages"},
"the listing is not the status document: %s", sorted(doc))
for p in doc.get("packages", []):
ctx.check(set(p) >= {"id", "version", "tier", "enabled", "grants"},
"a listed package does not say what it is: %s", sorted(p))
# The routing test proves the whole path every logger takes: emitter (or # The routing test proves the whole path every logger takes: emitter (or
# relay) -> /dev/log -> rsyslog rules rendered from the settings -> the # relay) -> /dev/log -> rsyslog rules rendered from the settings -> the
@@ -95,8 +169,7 @@ _ROUTING_COVERS = [("forgectrl", "src/logs.*"), ("forgectrl", "src/fflog.*"), ("
("forgefirm-app", "forgefirm-app/ffmachine.py"), ("forgefirm-app", "forgefirm-app/gfcloud.py"), ("forgefirm-app", "forgefirm-app/ffmachine.py"), ("forgefirm-app", "forgefirm-app/gfcloud.py"),
("forgefirm-app", "forgefirm-app/gfhome.py")] ("forgefirm-app", "forgefirm-app/gfhome.py")]
LOGS_ROOT = "/data/log/forgefirm" LOGGERS =("forgectrl", "grblhal", "gfcloud", "gfhome", "forgeext", "kernel", "system")
LOGGERS = ("forgectrl", "grblhal", "gfcloud", "gfhome", "kernel", "system")
_LINE_RE = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?[+-]\d\d:\d\d (?P<prog>[A-Za-z0-9_.-]+)\[(?P<pid>[-\d]+)\] " _LINE_RE = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?[+-]\d\d:\d\d (?P<prog>[A-Za-z0-9_.-]+)\[(?P<pid>[-\d]+)\] "
r"(?P<sev>EMERG|ALERT|CRIT|ERR|WARNING|NOTICE|INFO|DEBUG) (?P<msg>.*)$") r"(?P<sev>EMERG|ALERT|CRIT|ERR|WARNING|NOTICE|INFO|DEBUG) (?P<msg>.*)$")
_SEV_RANK = {"off": -1, "error": 3, "warning": 4, "notice": 5, "info": 6, "debug": 7} _SEV_RANK = {"off": -1, "error": 3, "warning": 4, "notice": 5, "info": 6, "debug": 7}
@@ -211,7 +284,7 @@ def routing(ctx):
# a probe under another name must NOT leak into these files # 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]) rc, out = hw.run(["logger", "-t", "forgetest-stray", "-p", "daemon.err", "%s stray" % nonce])
time.sleep(1.5) time.sleep(1.5)
for name in ("grblhal", "gfhome", "forgectrl"): for name in ("grblhal", "gfhome", "forgectrl", "forgeext"):
path = os.path.join(LOGS_ROOT, name, name + ".log") path = os.path.join(LOGS_ROOT, name, name + ".log")
ctx.check(not any((nonce + " stray") in l for l in _tail_lines(path, 100)), ctx.check(not any((nonce + " stray") in l for l in _tail_lines(path, 100)),
"a stray program's line landed in %s", path) "a stray program's line landed in %s", path)
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,14 @@
"""commission.* - the first-run commissioning: the record and the # Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""setup.* - the first-run setup: the record and the
controller gate, the advisories, the account and its login, the HTTPS controller gate, the advisories, the account and its login, the HTTPS
boundary, SSH, the cloud switch, the machine's name, and the factory boundary, SSH, the cloud switch, the machine's name, and the factory
return. return.
The daemon reads the commissioning record (commissioning.json in the The daemon reads the setup record (setup.json in the
data directory) and the account record (users) once, at its start, and data directory) and the account record (users) once, at its start, and
keeps them in memory; only its own routes write them. A test that needs keeps them in memory; only its own routes write them. A test that needs
a different record installs the file under a forgectrl restart (a a different record installs the file under a forgectrl restart (a
@@ -34,13 +39,13 @@ from ..catalog import test
from .. import hw from .. import hw
from .forgectrl import lan_ip from .forgectrl import lan_ip
RECORD = "commissioning.json" RECORD = "setup.json"
USERS = "users" USERS = "users"
OVERRIDE = "commissioning-override" OVERRIDE = "setup-override"
SSH_FLAG = "ssh-enabled" SSH_FLAG = "ssh-enabled"
TLS_BASE = "https://127.0.0.1" # FORGECTRL_TLS_URL overrides (host tests) TLS_BASE = "https://127.0.0.1" # FORGECTRL_TLS_URL overrides (host tests)
# The image's required wizards, all at version 1 (commission.c). # The image's required wizards, all at version 1 (setup.c).
REQUIRED_WIZARDS = ("advisories", "account", "preferences", "machine", "cloud") REQUIRED_WIZARDS = ("advisories", "account", "preferences", "machine", "cloud")
ADVISORY_DOCS = ("safety-and-risk", "licenses", "privacy", "cloud-service") ADVISORY_DOCS = ("safety-and-risk", "licenses", "privacy", "cloud-service")
SAFETY_PHRASE = "I UNDERSTAND" SAFETY_PHRASE = "I UNDERSTAND"
@@ -176,8 +181,8 @@ def record_without_wizards(record):
def complete_record(status, base=None, account_name="bench"): def complete_record(status, base=None, account_name="bench"):
"""A complete commissioning record for a machine that must count as """A complete setup record for a machine that must count as
commissioned: every advisory at the hash GET /wiz reports, the set up: every advisory at the hash GET /wiz reports, the
press recorded, an account (the one of `base` when it has one), press recorded, an account (the one of `base` when it has one),
every wizard of the catalog at its version, and the run complete. every wizard of the catalog at its version, and the run complete.
`status` is a GET /wiz body; `base` is an existing record to keep `status` is a GET /wiz body; `base` is an existing record to keep
@@ -352,12 +357,12 @@ def gate_is_open():
# -------------------------------------------------------------- the gate # -------------------------------------------------------------- the gate
_GATE_COVERS = [("forgectrl", "src/commission.*"), ("forgectrl", "src/super.c"), _GATE_COVERS = [("forgectrl", "src/setup.*"), ("forgectrl", "src/super.c"),
("forgectrl", "src/main.c"), ("forgectrl", "src/paths.h")] ("forgectrl", "src/main.c"), ("forgectrl", "src/paths.h")]
@test("commission.gate-blocks-controllers", title="The commissioning gate spawns no controller", @test("setup.gate-blocks-controllers", title="The setup gate spawns no controller",
subsystem="commission", kind="auto", hardware="takeover", mode="grbl", est_min=5, subsystem="setup", kind="auto", hardware="takeover", mode="grbl", est_min=5,
covers=_GATE_COVERS, requires=["forgectrl.auth"], precheck=gate_is_open, covers=_GATE_COVERS, requires=["forgectrl.auth"], precheck=gate_is_open,
description="With the override removed and a record that lacks every required wizard " description="With the override removed and a record that lacks every required wizard "
"(installed under a forgectrl restart, the consent and the account kept), " "(installed under a forgectrl restart, the consent and the account kept), "
@@ -374,11 +379,11 @@ def gate_blocks_controllers(ctx):
ctx.log("before: gate %s, override %s, required %s", before.get("gate"), before.get("override"), ctx.log("before: gate %s, override %s, required %s", before.get("gate"), before.get("override"),
before.get("required")) before.get("required"))
raw = read_file(record_path()) raw = read_file(record_path())
ctx.check(raw, "no commissioning record at %s", record_path()) ctx.check(raw, "no setup record at %s", record_path())
try: try:
record = json.loads(raw.decode("utf-8")) record = json.loads(raw.decode("utf-8"))
except ValueError as e: except ValueError as e:
ctx.fail("the commissioning record is not JSON: %s", e) ctx.fail("the setup record is not JSON: %s", e)
lacking = json.dumps(record_without_wizards(record), indent=1, sort_keys=True).encode() + b"\n" lacking = json.dumps(record_without_wizards(record), indent=1, sort_keys=True).encode() + b"\n"
had_override = override_present() had_override = override_present()
ev["override_before"] = had_override ev["override_before"] = had_override
@@ -391,7 +396,7 @@ def gate_blocks_controllers(ctx):
ctx.log("/mode after the restart: %s (after %s s)", m, took) ctx.log("/mode after the restart: %s (after %s s)", m, took)
ctx.check(m.get("controller") == "gated" and m.get("gated") is True, ctx.check(m.get("controller") == "gated" and m.get("gated") is True,
"the supervisor did not report the gate: %s", m) "the supervisor did not report the gate: %s", m)
ctx.check("commissioning required" in (m.get("why") or ""), ctx.check("setup required" in (m.get("why") or ""),
"the reason does not name the missing wizards: %r", m.get("why")) "the reason does not name the missing wizards: %r", m.get("why"))
w = wiz(fc) w = wiz(fc)
ev["gated_wiz"] = wiz_summary(w) ev["gated_wiz"] = wiz_summary(w)
@@ -422,8 +427,12 @@ def gate_blocks_controllers(ctx):
ev["override_mode"] = m ev["override_mode"] = m
ctx.check(took is not None and isinstance(m, dict) and m.get("controller") == "running", ctx.check(took is not None and isinstance(m, dict) and m.get("controller") == "running",
"the controller did not come back under the override: %s", m) "the controller did not come back under the override: %s", m)
ctx.check(hw.grbl_port_open(timeout=5), "the Grbl port is closed with the controller running") # /mode reads running from the fork; the controller binds its port
ctx.log("override: the controller is back (%s)", m) # a few tens of milliseconds later
opened = ctx.wait_for(lambda: hw.grbl_port_open(timeout=1), 10, poll=0.05)
ev["port_open_after_s"] = opened
ctx.check(opened is not None, "the Grbl port is closed with the controller running")
ctx.log("override: the controller is back (%s), the Grbl port accepts after %.3f s", m, opened)
# the real record is back under a restart; the override reads as found # the real record is back under a restart; the override reads as found
after = wiz(fc) after = wiz(fc)
@@ -439,11 +448,11 @@ def gate_blocks_controllers(ctx):
ctx.log("restored: gate %s, controller %s", after.get("gate"), m.get("controller")) ctx.log("restored: gate %s, controller %s", after.get("gate"), m.get("controller"))
@test("commission.override-until-reboot", title="The override stands until the next reboot", @test("setup.override-until-reboot", title="The override stands until the next reboot",
subsystem="commission", kind="auto", est_min=1, subsystem="setup", kind="auto", est_min=1,
covers=_GATE_COVERS, requires=["forgectrl.auth"], covers=_GATE_COVERS, requires=["forgectrl.auth"],
description="The bench seed: the dev image's forgetest init script writes " description="The bench seed: the dev image's forgetest init script writes "
"/run/forgefirm/commissioning-override at boot. With it present, GET /wiz " "/run/forgefirm/setup-override at boot. With it present, GET /wiz "
"reports the override and an open gate, the reason names the wizards it lifts " "reports the override and an open gate, the reason names the wizards it lifts "
"when any is required, and GET /mode reports the controller running and not " "when any is required, and GET /mode reports the controller running and not "
"gated. The file lives on tmpfs (/proc/mounts), so a reboot removes it by " "gated. The file lives on tmpfs (/proc/mounts), so a reboot removes it by "
@@ -486,11 +495,13 @@ def override_until_reboot(ctx):
# ------------------------------------------------------------ advisories # ------------------------------------------------------------ advisories
@test("commission.advisories-rehash", title="An advisory is accepted at its current hash only", @test("setup.advisories-rehash", title="An advisory is accepted at its current hash only",
subsystem="commission", kind="auto", hardware="takeover", est_min=3, subsystem="setup", kind="auto", hardware="takeover", est_min=3,
covers=[("forgectrl", "src/advisories.*"), ("forgectrl", "src/commission.*"), covers=[("forgectrl", "src/advisories.*"), ("forgectrl", "src/setup.*"),
("forgectrl", "src/wiz.*"), ("forgectrl", "src/sha256.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/wiz.*"), ("forgectrl", "src/sha256.*"), ("forgectrl", "src/main.c"),
("forgectrl", "src/ui/md.js"), ("forgectrl", "src/ui/embed_docs.cmake")], ("forgectrl", "src/ui/md.js"), ("forgectrl", "src/ui/embed_docs.cmake"),
("forgectrl", "docs/advisories/safety-and-risk.md"), ("forgectrl", "docs/advisories/licenses.md"),
("forgectrl", "docs/advisories/privacy.md"), ("forgectrl", "docs/advisories/cloud-service.md")],
requires=["forgectrl.auth"], requires=["forgectrl.auth"],
description="GET /advisories/safety-and-risk serves the document as markdown with an ETag " description="GET /advisories/safety-and-risk serves the document as markdown with an ETag "
"equal to the SHA-256 of the body and to the hash GET /wiz lists; an unknown " "equal to the SHA-256 of the body and to the hash GET /wiz lists; an unknown "
@@ -504,7 +515,7 @@ def advisories_rehash(ctx):
fc = ctx.forgectrl fc = ctx.forgectrl
ev = ctx.evidence ev = ctx.evidence
raw = read_file(record_path()) raw = read_file(record_path())
ctx.check(raw, "no commissioning record at %s", record_path()) ctx.check(raw, "no setup record at %s", record_path())
before = wiz(fc) before = wiz(fc)
ev["before"] = wiz_summary(before) ev["before"] = wiz_summary(before)
docs = {d.get("id"): d for d in before.get("documents") or []} docs = {d.get("id"): d for d in before.get("documents") or []}
@@ -579,7 +590,7 @@ def advisories_rehash(ctx):
# -------------------------------------------------------------- the login # -------------------------------------------------------------- the login
@test("commission.account-login", title="The panel login over HTTPS", subsystem="commission", @test("setup.account-login", title="The panel login over HTTPS", subsystem="setup",
kind="auto", est_min=2, kind="auto", est_min=2,
covers=[("forgectrl", "src/auth.*"), ("forgectrl", "src/session.*"), ("forgectrl", "src/users.*"), covers=[("forgectrl", "src/auth.*"), ("forgectrl", "src/session.*"), ("forgectrl", "src/users.*"),
("forgectrl", "src/tls.*"), ("forgectrl", "src/peer.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/tls.*"), ("forgectrl", "src/peer.*"), ("forgectrl", "src/main.c"),
@@ -588,7 +599,11 @@ def advisories_rehash(ctx):
description="The test makes its own account: the account record is moved aside under a " 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 " "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 " "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. Over " "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 " "HTTPS (self-signed, so unverified): GET /wiz reports no session and the "
"certificate fingerprint; POST /login with a wrong password is refused (401); " "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 " "five failures lock the address (429, wait at most 30 s) and the right password "
@@ -603,15 +618,33 @@ def account_login(ctx):
before = wiz(fc) before = wiz(fc)
ev["account_before"] = (before.get("users") or {}).get("name") ev["account_before"] = (before.get("users") or {}).get("name")
homes = "/data/forgefirm/home" 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}): with installed(ctx, {users_path(): None}):
w = wiz(fc) w = wiz(fc)
ctx.check(not (w.get("users") or {}).get("exists"), "an account still exists: %s", w.get("users")) 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}) 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.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) ctx.check(st == 200, "the temporary account was not created: %s %s", st, body)
w = wiz(fc) w = wiz(fc)
ctx.check((w.get("users") or {}).get("name") == name, "the account reads %s", w.get("users")) 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) 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") rc, out = hw.initd("forgefirm-users", "reload")
ev["users_reload_rc"] = rc ev["users_reload_rc"] = rc
ctx.log("forgefirm-users reload -> rc %s %s", rc, out.strip()[:200]) ctx.log("forgefirm-users reload -> rc %s %s", rc, out.strip()[:200])
@@ -624,6 +657,49 @@ def account_login(ctx):
ev["account_before"]) 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): def login_checks(ctx, name, pw):
"""The login rules against an account whose password the test knows.""" """The login rules against an account whose password the test knows."""
ev = ctx.evidence ev = ctx.evidence
@@ -702,7 +778,7 @@ def login_checks(ctx, name, pw):
# ------------------------------------------------------- HTTPS boundary # ------------------------------------------------------- HTTPS boundary
@test("commission.https-only-writes", title="Writes from the LAN go to HTTPS", subsystem="commission", @test("setup.https-only-writes", title="Writes from the LAN go to HTTPS", subsystem="setup",
kind="auto", est_min=1, kind="auto", est_min=1,
covers=[("forgectrl", "src/auth.*"), ("forgectrl", "src/tls.*"), ("forgectrl", "src/peer.*"), covers=[("forgectrl", "src/auth.*"), ("forgectrl", "src/tls.*"), ("forgectrl", "src/peer.*"),
("forgectrl", "src/main.c")], ("forgectrl", "src/main.c")],
@@ -802,8 +878,8 @@ def restore_ssh(fc, before):
write_file(flag, b"" if before.get("enabled") else None) write_file(flag, b"" if before.get("enabled") else None)
@test("commission.ssh-until-reboot", title="SSH is switched from the panel until the next reboot", @test("setup.ssh-until-reboot", title="SSH is switched from the panel until the next reboot",
subsystem="commission", kind="auto", est_min=1, subsystem="setup", kind="auto", est_min=1,
covers=[("forgectrl", "src/main.c"), ("forgectrl", "src/paths.h")], covers=[("forgectrl", "src/main.c"), ("forgectrl", "src/paths.h")],
requires=["forgectrl.auth"], requires=["forgectrl.auth"],
description="GET /system/ssh reports enabled, running, and dev_image, and agrees with the " description="GET /system/ssh reports enabled, running, and dev_image, and agrees with the "
@@ -888,15 +964,20 @@ def ssh_until_reboot(ctx):
# ---------------------------------------------------------------- cloud # ---------------------------------------------------------------- cloud
@test("commission.cloud-disabled-surface", title="Nothing points at the cloud while it is off", @test("setup.cloud-disabled-surface", title="Nothing points at the cloud while it is off",
subsystem="commission", kind="auto", mode="grbl", est_min=1, subsystem="setup", kind="auto", mode="grbl", est_min=1,
covers=[("forgectrl", "src/main.c"), ("forgectrl", "src/super.c"), ("forgectrl", "src/settings.*"), covers=[("forgectrl", "src/main.c"), ("forgectrl", "src/super.c"), ("forgectrl", "src/settings.*"),
("forgectrl", "src/wiz.c"), ("forgectrl", "src/hooks.h")], ("forgectrl", "src/wiz.c"), ("forgectrl", "src/hooks.h"), ("forgectrl", "src/builtin.*")],
requires=["forgectrl.settings-bounds"], requires=["forgectrl.settings-bounds"],
description="The test turns cloud mode off itself with one write and puts every setting " description="The test turns cloud mode off itself with one write and puts every setting "
"back as found. cloud_enabled=0 takes the gfcloud homing and the cloud boot mode " "back as found. GET /extensions lists cloud mode as a built-in with its two "
"down with it, as the cloud step does. With it at 0, POST /settings " "roles (homing gfcloud, controller cloud), enabled as cloud_enabled says and each "
"controller_mode=cloud and homing_mode=gfcloud are refused (409) and leave the " "role active exactly when its setting selects it. cloud_enabled=0 takes the "
"gfcloud homing and the cloud boot mode "
"down with it, as the cloud step does, and the list then reads off with no role "
"active. With it at 0, POST /settings "
"controller_mode=cloud and homing_mode=gfcloud are refused (409, in the words the "
"table gives each role) and leave the "
"settings unchanged, cloud_enabled=1 without the typed phrase is refused (400) " "settings unchanged, cloud_enabled=1 without the typed phrase is refused (400) "
"and leaves it at 0, and POST /mode controller=cloud is refused (409) with a " "and leaves it at 0, and POST /mode controller=cloud is refused (409) with a "
"message that names cloud mode, the machine staying in GRBL mode.") "message that names cloud mode, the machine staying in GRBL mode.")
@@ -907,6 +988,29 @@ def cloud_disabled_surface(ctx):
found = fc.settings() found = fc.settings()
prior = found.get("cloud_enabled") or "" prior = found.get("cloud_enabled") or ""
ev["found"] = {k: found.get(k) for k in ("cloud_enabled", "homing_mode", "controller_mode")} ev["found"] = {k: found.get(k) for k in ("cloud_enabled", "homing_mode", "controller_mode")}
def cloud_entry():
st, doc = fc.get("/extensions")
ctx.check(st == 200 and isinstance(doc, dict), "GET /extensions -> %s", st)
ext = [e for e in doc.get("extensions", []) if e.get("id") == "cloud"]
ctx.check(len(ext) == 1, "GET /extensions lists cloud mode %d times", len(ext))
return ext[0]
# The list agrees with the settings as found.
e = cloud_entry()
ev["extension_found"] = {"enabled": e.get("enabled"), "roles": e.get("roles")}
ctx.check(e.get("builtin") is True and e.get("enable_key") == "cloud_enabled" and e.get("tab") == "gfcloud"
and e.get("setup_step") == "cloud", "the cloud entry: %s", e)
ctx.check(e.get("enabled") is (prior == "1"), "the list says enabled=%s with cloud_enabled=%r",
e.get("enabled"), prior)
roles = {r["role"]: r for r in e.get("roles", [])}
ctx.check(set(roles) == {"homing", "controller"} and roles["homing"]["provider"] == "gfcloud"
and roles["homing"]["fallback"] == "none" and roles["controller"]["provider"] == "cloud"
and roles["controller"]["fallback"] == "grbl", "the cloud entry's roles: %s", e.get("roles"))
for r in roles.values():
want = prior == "1" and found.get(r["select_key"]) == r["provider"]
ctx.check(r["active"] is want, "role %s reads active=%s with %s=%r", r["role"], r["active"],
r["select_key"], found.get(r["select_key"]))
try: try:
if prior != "0": if prior != "0":
# the one write sweeps what pointed at the cloud # the one write sweeps what pointed at the cloud
@@ -922,12 +1026,18 @@ def cloud_disabled_surface(ctx):
"cloud_enabled=0 left controller_mode at %r, expected grbl", "cloud_enabled=0 left controller_mode at %r, expected grbl",
swept.get("controller_mode")) swept.get("controller_mode"))
ctx.check(fc.settings().get("cloud_enabled") == "0", "cloud_enabled does not read 0") ctx.check(fc.settings().get("cloud_enabled") == "0", "cloud_enabled does not read 0")
off = cloud_entry()
ev["extension_off"] = {"enabled": off.get("enabled"), "active": [r["active"] for r in off["roles"]]}
ctx.check(off.get("enabled") is False and not any(r["active"] for r in off["roles"]),
"the list with cloud mode off: %s", ev["extension_off"])
before = fc.settings() before = fc.settings()
for key, val in (("controller_mode", "cloud"), ("homing_mode", "gfcloud")): for key, val, words in (("controller_mode", "cloud", "cloud mode is not enabled on this machine"),
("homing_mode", "gfcloud", "cloud homing needs cloud mode enabled")):
st, body = fc.post("/settings", data={key: val}) st, body = fc.post("/settings", data={key: val})
ev["settings %s=%s" % (key, val)] = st ev["settings %s=%s" % (key, val)] = st
ctx.log("POST /settings %s=%s -> %s %s", key, val, st, body if isinstance(body, str) else "") ctx.log("POST /settings %s=%s -> %s %s", key, val, st, body if isinstance(body, str) else "")
ctx.check(st == 409, "%s=%s with cloud off -> %s, expected 409", key, val, st) ctx.check(st == 409, "%s=%s with cloud off -> %s, expected 409", key, val, st)
ctx.check(isinstance(body, str) and words in body, "%s=%s was refused in other words: %r", key, val, body)
# the pair in one request is refused too: the request's own switch counts # the pair in one request is refused too: the request's own switch counts
st, body = fc.post("/settings", data={"cloud_enabled": "0", "controller_mode": "cloud"}) st, body = fc.post("/settings", data={"cloud_enabled": "0", "controller_mode": "cloud"})
ctx.check(st == 409, "cloud_enabled=0 with controller_mode=cloud -> %s, expected 409", st) ctx.check(st == 409, "cloud_enabled=0 with controller_mode=cloud -> %s, expected 409", st)
@@ -967,12 +1077,155 @@ def cloud_disabled_surface(ctx):
for key in ("cloud_enabled", "homing_mode", "controller_mode"): for key in ("cloud_enabled", "homing_mode", "controller_mode"):
ctx.check((after.get(key) or "") == (found.get(key) or ""), "%s not restored: %r, was %r", ctx.check((after.get(key) or "") == (found.get(key) or ""), "%s not restored: %r, was %r",
key, after.get(key), found.get(key)) key, after.get(key), found.get(key))
back = cloud_entry()
ctx.check(back.get("enabled") == e.get("enabled") and back.get("roles") == e.get("roles"),
"the list did not come back as found: %s, was %s", back, e)
# ----------------------------------------------------------- extensions
@test("setup.extensions-consent", title="Extensions are turned on over their own advisory",
subsystem="setup", kind="auto", hardware="takeover", est_min=3,
covers=[("forgectrl", "src/main.c"), ("forgectrl", "src/advisories.*"), ("forgectrl", "src/setup.*"),
("forgectrl", "src/wiz.c"), ("forgectrl", "src/settings.*"),
("forgectrl", "docs/advisories/extensions.md"), ("forgectrl", "src/ui/embed_docs.cmake")],
requires=["forgectrl.settings-bounds", "setup.advisories-rehash"],
description="The Extensions advisory is on demand: GET /wiz does not list it among the "
"first-run documents, GET /advisories/extensions serves it as markdown with an "
"ETag equal to the SHA-256 of the body, and the first-run accept refuses it (400) "
"and leaves the press. The test turns ext_enabled off itself and puts it back as "
"found. From off, POST /settings ext_enabled=1 is refused without the advisory's "
"hash and with a stale one (409), and without the typed phrase and with the phrase "
"in another case (400); each refusal leaves the setting at 0 and the record on "
"disk untouched. A request that carries the hash and the phrase beside a write the "
"daemon refuses (controller_mode=cloud with cloud_enabled=0, 409) is refused whole "
"and records nothing. With the "
"hash and the phrase it is accepted (200): the setting reads 1, the record on disk "
"holds on_demand.extensions with that hash and the typed method, the first-run "
"documents and their press are as they were, and the data directory has gained "
"the search bit for group and others and nothing else (a package's account walks "
"through it to its own files). Re-sending 1 while it stands and "
"sending 0 ask for nothing. The previous record is put back under a forgectrl "
"restart.")
def extensions_consent(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle: settings are locked")
raw = read_file(record_path())
ctx.check(raw, "no setup record at %s", record_path())
before = wiz(fc)
ev["before"] = wiz_summary(before)
prior = fc.settings().get("ext_enabled") or ""
dir_mode = os.stat(data_dir()).st_mode & 0o7777
ev["found"] = {"ext_enabled": prior, "data_dir_mode": "%04o" % dir_mode}
def on_disk():
try:
return json.loads((read_file(record_path()) or b"{}").decode("utf-8"))
except ValueError:
return {}
ctx.check("extensions" not in [d.get("id") for d in before.get("documents") or []],
"GET /wiz lists the Extensions advisory among the first-run documents")
st, body, hdrs = request(fc.base, "GET", "/advisories/extensions", headers={"Host": fc.host_header()})
etag = hdrs.get("etag")
ev["etag"] = etag
ctx.log("GET /advisories/extensions -> %s, %d bytes, ETag %s", st, len(body), etag)
ctx.check(st == 200 and body, "GET /advisories/extensions -> %s", st)
ctx.check((hdrs.get("content-type") or "").startswith("text/markdown"),
"the document is not served as markdown: %r", hdrs.get("content-type"))
ctx.check(etag == hashlib.sha256(body).hexdigest(), "the ETag is not the SHA-256 of the body")
try:
st, reply = fc.post("/wiz/advisories/accept",
data={"doc": "extensions", "hash": etag, "phrase": SAFETY_PHRASE})
ev["first_run_accept"] = st
ctx.log("the first-run accept of the on-demand document -> %s %s", st, reply)
ctx.check(st == 400, "the first-run accept of the on-demand document -> %s, expected 400", st)
ctx.check(wiz(fc).get("acceptance_done") == before.get("acceptance_done"),
"the refused accept changed the press record")
if prior == "1":
st, reply = fc.post("/settings", data={"ext_enabled": "0"})
ctx.check(st == 200, "ext_enabled=0 -> %s %s", st, reply)
ctx.log("extensions turned off for the test (found %r)", prior)
ctx.check((fc.settings().get("ext_enabled") or "0") == "0", "ext_enabled does not read off")
start = on_disk()
for name, form, want, words in (
("no hash", {"ext_enabled": "1"}, 409, "read the Extensions advisory first"),
("stale hash", {"ext_enabled": "1", "advisory": "0" * 64, "phrase": SAFETY_PHRASE}, 409,
"read the Extensions advisory first"),
("no phrase", {"ext_enabled": "1", "advisory": etag}, 400, "type I UNDERSTAND"),
("phrase in another case", {"ext_enabled": "1", "advisory": etag,
"phrase": SAFETY_PHRASE.lower()}, 400, "type I UNDERSTAND"),
("good consent beside a refused write", {"ext_enabled": "1", "advisory": etag,
"phrase": SAFETY_PHRASE, "cloud_enabled": "0",
"controller_mode": "cloud"},
409, "cloud mode is not enabled on this machine")):
st, reply = fc.post("/settings", data=form)
ev[name] = st
ctx.log("POST /settings ext_enabled=1, %s -> %s %s", name, st, reply if isinstance(reply, str) else "")
ctx.check(st == want, "%s -> %s, expected %s", name, st, want)
ctx.check(isinstance(reply, str) and words in reply, "%s was refused in other words: %r", name, reply)
ctx.check((fc.settings().get("ext_enabled") or "0") == "0", "%s: a refused write turned extensions on", name)
ctx.check(on_disk() == start, "%s: a refused write changed the record on disk", name)
st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE})
ev["accept"] = st
ctx.log("POST /settings ext_enabled=1 with the hash and the phrase -> %s", st)
ctx.check(st == 200, "the consent -> %s %r", st, reply)
ctx.check(fc.settings().get("ext_enabled") == "1", "ext_enabled does not read 1")
rec = on_disk()
entry = (rec.get("on_demand") or {}).get("extensions") or {}
ev["recorded"] = entry
ctx.check(entry.get("hash") == etag and entry.get("method") == "typed" and entry.get("accepted"),
"the record on disk does not carry the acceptance: %s", entry)
ctx.check("extensions" not in (rec.get("advisories") or {}),
"the acceptance landed among the first-run documents")
# a package's account walks through the data directory to its own files: the
# consent adds the search bit, and nothing else
now_mode = os.stat(data_dir()).st_mode & 0o7777
ev["data_dir_mode_after"] = "%04o" % now_mode
ctx.check(now_mode == dir_mode | 0o011, "the data directory went from %04o to %04o, expected %04o",
dir_mode, now_mode, dir_mode | 0o011)
ctx.check(rec.get("advisories") == start.get("advisories") and rec.get("acceptance") == start.get("acceptance"),
"the first-run documents or their press moved")
after = wiz(fc)
ctx.check(after.get("acceptance_done") == before.get("acceptance_done") and after.get("gate") == before.get("gate"),
"the machine's gate moved with the consent: %s", wiz_summary(after))
for form in ({"ext_enabled": "1"}, {"ext_enabled": "0"}):
st, reply = fc.post("/settings", data=form)
ctx.check(st == 200, "%s while on -> %s, expected 200", form, st)
ctx.check(fc.settings().get("ext_enabled") == "0", "ext_enabled=0 did not turn extensions off")
finally:
if prior == "":
st, reply = fc.post("/settings", params={"ext_enabled": ""})
elif prior == "1":
st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE})
else:
st, reply = fc.post("/settings", data={"ext_enabled": prior})
ctx.log("restore ext_enabled=%r -> %s", prior, st)
if prior != "1":
os.chmod(data_dir(), dir_mode)
with ctx.takeover():
write_file(record_path(), raw)
ctx.log("the previous record is back under a restart")
ctx.check((fc.settings().get("ext_enabled") or "") == prior, "ext_enabled not restored: %r, was %r",
fc.settings().get("ext_enabled"), prior)
ctx.check(read_file(record_path()) == raw, "the record on disk is not the one found")
ctx.check(prior == "1" or os.stat(data_dir()).st_mode & 0o7777 == dir_mode, "the data directory's mode is not the one found")
final = wiz(fc)
ev["after_restore"] = wiz_summary(final)
ctx.check(final.get("acceptance_done") == before.get("acceptance_done") and final.get("gate") == before.get("gate"),
"the machine does not read as before: %s", wiz_summary(final))
# ---------------------------------------------------------- the operator # ---------------------------------------------------------- the operator
@test("commission.factory-return", title="The return to the factory firmware is guarded", @test("setup.factory-return", title="The return to the factory firmware is guarded",
subsystem="commission", kind="auto", est_min=1, subsystem="setup", kind="auto", est_min=1,
covers=[("forgectrl", "src/update.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.js")], covers=[("forgectrl", "src/update.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.js")],
requires=["update.slots-and-signature"], requires=["update.slots-and-signature"],
description="The return itself reboots the machine as a Glowforge, so it never runs from the " description="The return itself reboots the machine as a Glowforge, so it never runs from the "
@@ -1034,8 +1287,8 @@ def cmdlines():
return out return out
@test("commission.machine-name", title="The machine names itself from its MAC address", @test("setup.machine-name", title="The machine names itself from its MAC address",
subsystem="commission", kind="auto", est_min=1, subsystem="setup", kind="auto", est_min=1,
requires=["forgectrl.auth"], requires=["forgectrl.auth"],
description="The machine calls itself forgefirm-<xxxx>, where xxxx is the last four hex " description="The machine calls itself forgefirm-<xxxx>, where xxxx is the last four hex "
"digits of its WiFi MAC address, so two machines on one network answer to " "digits of its WiFi MAC address, so two machines on one network answer to "
@@ -1170,6 +1423,7 @@ def first_run(ctx):
except OSError: except OSError:
homes_before = set() homes_before = set()
state = {"temp_account": None} state = {"temp_account": None}
try:
with Restore(ctx, FIRST_RUN_SETTINGS): with Restore(ctx, FIRST_RUN_SETTINGS):
with installed(ctx, {record_path(): seed, users_path(): None, override_path(): b""}): with installed(ctx, {record_path(): seed, users_path(): None, override_path(): b""}):
w = wiz(fc) w = wiz(fc)
@@ -1178,9 +1432,13 @@ def first_run(ctx):
and w.get("advisories_complete") is False and w.get("acceptance_done") is False, and w.get("advisories_complete") is False and w.get("acceptance_done") is False,
"the machine does not read as a first run: %s", wiz_summary(w)) "the machine does not read as a first run: %s", wiz_summary(w))
m = mode(fc) m = mode(fc)
ctx.check(m.get("controller") == "gated", "the controller is %s on a first run", m.get("controller")) ctx.check(m.get("controller") == "gated", "the controller is %s on a first run",
m.get("controller"))
yield state yield state
# the real records are back; the system accounts follow the record finally:
# the real records are back, whether or not the block passed; the
# system accounts follow the record, and a temporary account the
# block created goes with its home
rc, out = hw.initd("forgefirm-users", "reload") rc, out = hw.initd("forgefirm-users", "reload")
ev["users_reload_rc"] = rc ev["users_reload_rc"] = rc
ctx.log("forgefirm-users reload -> rc %s %s", rc, out.strip()[:200]) ctx.log("forgefirm-users reload -> rc %s %s", rc, out.strip()[:200])
@@ -1213,12 +1471,12 @@ def finished_first_run(ctx):
"the controller did not come back after the setup: %s", m) "the controller did not come back after the setup: %s", m)
_FIRST_RUN_REQUIRES = ["commission.gate-blocks-controllers", "commission.advisories-rehash"] _FIRST_RUN_REQUIRES = ["setup.gate-blocks-controllers", "setup.advisories-rehash"]
@test("commission.first-run-flow", title="The first run, driven as the page drives it", subsystem="commission", @test("setup.first-run-flow", title="The first run, driven as the page drives it", subsystem="setup",
kind="operator", hardware="takeover", est_min=3, kind="operator", hardware="takeover", est_min=3,
covers=[("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"), ("forgectrl", "src/users.*"), covers=[("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"), ("forgectrl", "src/users.*"),
("forgectrl", "src/session.*"), ("forgectrl", "src/button.*"), ("forgectrl", "src/led.*"), ("forgectrl", "src/session.*"), ("forgectrl", "src/button.*"), ("forgectrl", "src/led.*"),
("forgectrl", "src/sheetid.*"), ("forgectrl", "src/advisories.*"), ("forgectrl", "src/hooks.h"), ("forgectrl", "src/sheetid.*"), ("forgectrl", "src/advisories.*"), ("forgectrl", "src/hooks.h"),
("forgectrl", "src/main.c")], ("forgectrl", "src/main.c")],
@@ -1309,11 +1567,11 @@ def first_run_flow(ctx):
ctx.log("the run completed: gate open, the controller back") ctx.log("the run completed: gate open, the controller back")
@test("commission.first-run-page", title="The first-run page, walked once", subsystem="commission", @test("setup.first-run-page", title="The first-run page, walked once", subsystem="setup",
kind="operator", hardware="takeover", est_min=6, kind="operator", hardware="takeover", est_min=6,
covers=[("forgectrl", "src/ui/wizard.*"), ("forgectrl", "src/ui/forms.js"), ("forgectrl", "src/ui/md.js"), covers=[("forgectrl", "src/ui/wizard.*"), ("forgectrl", "src/ui/forms.js"), ("forgectrl", "src/ui/md.js"),
("forgectrl", "src/ui/login.*"), ("forgectrl", "src/ui/embed_docs.cmake"), ("forgectrl", "src/tls.*")], ("forgectrl", "src/ui/login.*"), ("forgectrl", "src/ui/embed_docs.cmake"), ("forgectrl", "src/tls.*")],
requires=_FIRST_RUN_REQUIRES + ["commission.first-run-flow"], requires=_FIRST_RUN_REQUIRES + ["setup.first-run-flow"],
actions=["button"], hands=["workstation"], actions=["button"], hands=["workstation"],
steps=["From the workstation open https://<address>/ and accept the self-signed certificate.", steps=["From the workstation open https://<address>/ and accept the self-signed certificate.",
"Click through the setup: scroll each document to its end and accept it (type I " "Click through the setup: scroll each document to its end and accept it (type I "
@@ -1328,7 +1586,7 @@ def first_run_flow(ctx):
"clicks through (the documents accepted, the press step with the LEDs " "clicks through (the documents accepted, the press step with the LEDs "
"breathing teal, the account created, the run complete, the gate open, the " "breathing teal, the account created, the run complete, the gate open, the "
"controller back). The operator confirms the panel opened. Everything the run " "controller back). The operator confirms the panel opened. Everything the run "
"wrote is restored as commission.first-run-flow restores it.") "wrote is restored as setup.first-run-flow restores it.")
def first_run_page(ctx): def first_run_page(ctx):
fc = ctx.forgectrl fc = ctx.forgectrl
ev = ctx.evidence ev = ctx.evidence
@@ -1364,8 +1622,8 @@ def first_run_page(ctx):
ctx.confirm("Did the control panel open after Finish?") ctx.confirm("Did the control panel open after Finish?")
@test("commission.cert-page", title="The certificate is checkable before the warning", @test("setup.cert-page", title="The certificate is checkable before the warning",
subsystem="commission", kind="auto", est_min=1, subsystem="setup", kind="auto", est_min=1,
covers=[("forgectrl", "src/tls.*"), ("forgectrl", "src/main.c"), covers=[("forgectrl", "src/tls.*"), ("forgectrl", "src/main.c"),
("forgectrl", "src/ui/wizard.html"), ("forgectrl", "src/ui/wizard.js")], ("forgectrl", "src/ui/wizard.html"), ("forgectrl", "src/ui/wizard.js")],
requires=["forgectrl.auth"], requires=["forgectrl.auth"],
@@ -1423,9 +1681,9 @@ def flag_of(w, level, wid):
return None return None
@test("commission.what-changed", title="A replaced part asks for its checks again", @test("setup.what-changed", title="A replaced part asks for its checks again",
subsystem="commission", kind="auto", est_min=2, hardware="takeover", subsystem="setup", kind="auto", est_min=2, hardware="takeover",
covers=[("forgectrl", "src/commission.*"), ("forgectrl", "src/wiz.*"), ("forgectrl", "src/main.c"), covers=[("forgectrl", "src/setup.*"), ("forgectrl", "src/wiz.*"), ("forgectrl", "src/main.c"),
("forgectrl", "src/ui/panel.js"), ("forgectrl", "src/ui/index.html"), ("forgectrl", "src/ui/panel.js"), ("forgectrl", "src/ui/index.html"),
("forgectrl", "src/ui/help.js")], ("forgectrl", "src/ui/help.js")],
requires=["forgectrl.auth"], requires=["forgectrl.auth"],
@@ -1493,9 +1751,9 @@ def what_changed(ctx):
ctx.check(bool(mode(fc).get("gated")) == gated_before, "the gate does not read as before") ctx.check(bool(mode(fc).get("gated")) == gated_before, "the gate does not read as before")
@test("commission.record-export", title="The record: JSON, the printable page, and the log bundle", @test("setup.record-export", title="The record: JSON, the printable page, and the log bundle",
subsystem="commission", kind="auto", est_min=2, subsystem="setup", kind="auto", est_min=2,
covers=[("forgectrl", "src/commission.*"), ("forgectrl", "src/recordhtml.*"), ("forgectrl", "src/wiz.*"), covers=[("forgectrl", "src/setup.*"), ("forgectrl", "src/recordhtml.*"), ("forgectrl", "src/wiz.*"),
("forgectrl", "src/logs.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/ui/index.html"), ("forgectrl", "src/logs.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/ui/index.html"),
("forgectrl", "src/ui/panel.js"), ("forgectrl", "src/ui/wizard.html")], ("forgectrl", "src/ui/panel.js"), ("forgectrl", "src/ui/wizard.html")],
requires=["forgectrl.auth", "logs.tree-tail-export"], requires=["forgectrl.auth", "logs.tree-tail-export"],
@@ -1504,7 +1762,7 @@ def what_changed(ctx):
"named after the sheet id; GET /wiz/record.html is a page with no script that " "named after the sheet id; GET /wiz/record.html is a page with no script that "
"carries the sheet id and every completed wizard; without the token and without " "carries the sheet id and every completed wizard; without the token and without "
"a login both are refused (403); and the sanitized log export carries " "a login both are refused (403); and the sanitized log export carries "
"system/commissioning.json, parseable, with the same sheet id and no panel token.") "system/setup.json, parseable, with the same sheet id and no panel token.")
def record_export(ctx): def record_export(ctx):
import gzip import gzip
import io import io
@@ -1544,7 +1802,7 @@ def record_export(ctx):
ctx.check(st == 200 and "text/html" in (hdrs.get("content-type") or ""), "GET /wiz/record.html -> %s %s", ctx.check(st == 200 and "text/html" in (hdrs.get("content-type") or ""), "GET /wiz/record.html -> %s %s",
st, hdrs.get("content-type")) st, hdrs.get("content-type"))
ctx.check("<script" not in page.lower(), "the printable page carries a script") ctx.check("<script" not in page.lower(), "the printable page carries a script")
ctx.check(sid in page and "commissioning record" in page, "the page lacks the sheet id or its title") ctx.check(sid in page and "setup record" in page, "the page lacks the sheet id or its title")
titles = {z["id"]: z["title"] for z in w.get("wizards") or []} titles = {z["id"]: z["title"] for z in w.get("wizards") or []}
missing = [d for d in done if titles.get(d) and ("<h3>" + titles[d]) not in page] missing = [d for d in done if titles.get(d) and ("<h3>" + titles[d]) not in page]
ev["missing_on_page"] = missing ev["missing_on_page"] = missing
@@ -1565,11 +1823,11 @@ def record_export(ctx):
try: try:
tf = tarfile.open(fileobj=io.BytesIO(gzip.decompress(data))) tf = tarfile.open(fileobj=io.BytesIO(gzip.decompress(data)))
for m in tf.getmembers(): for m in tf.getmembers():
if m.isfile() and m.name.endswith("system/commissioning.json"): if m.isfile() and m.name.endswith("system/setup.json"):
member = tf.extractfile(m).read() member = tf.extractfile(m).read()
except (OSError, tarfile.TarError, EOFError) as e: except (OSError, tarfile.TarError, EOFError) as e:
ctx.fail("export is not a readable tar.gz: %s", e) ctx.fail("export is not a readable tar.gz: %s", e)
ctx.check(member is not None, "the bundle lacks system/commissioning.json") ctx.check(member is not None, "the bundle lacks system/setup.json")
if member is not None: if member is not None:
try: try:
inside = json.loads(member.decode("utf-8")) inside = json.loads(member.decode("utf-8"))
@@ -1587,12 +1845,12 @@ def record_export(ctx):
ctx.log("record %d wizards; page %d bytes; bundle carries the record", len(done), ev["page_bytes"]) ctx.log("record %d wizards; page %d bytes; bundle carries the record", len(done), ev["page_bytes"])
@test("commission.mirror", title="One browser drives a check; a second one follows", @test("setup.mirror", title="One browser drives a check; a second one follows",
subsystem="commission", kind="auto", est_min=2, hardware="takeover", subsystem="setup", kind="auto", est_min=2, hardware="takeover",
covers=[("forgectrl", "src/wizdark.*"), ("forgectrl", "src/wizcalc.*"), ("forgectrl", "src/wiz.*"), covers=[("forgectrl", "src/wizdark.*"), ("forgectrl", "src/wizcalc.*"), ("forgectrl", "src/wiz.*"),
("forgectrl", "src/session.*"), ("forgectrl", "src/ui/wizard.js"), ("forgectrl", "src/session.*"), ("forgectrl", "src/ui/wizard.js"),
("forgectrl", "src/ui/wizard.html"), ("forgectrl", "src/ui/wizard.css")], ("forgectrl", "src/ui/wizard.html"), ("forgectrl", "src/ui/wizard.css")],
requires=["forgectrl.auth", "commission.account-login", "commission.check-sensors"], requires=["forgectrl.auth", "setup.account-login", "setup.check-sensors"],
description="With a temporary account (made and removed as the login test does) and two " description="With a temporary account (made and removed as the login test does) and two "
"login sessions: the first session starts the sensors check; GET /wiz/dark " "login sessions: the first session starts the sensors check; GET /wiz/dark "
"reports the run as owned and its own to the first session, not to the second, " "reports the run as owned and its own to the first session, not to the second, "
@@ -1,4 +1,9 @@
"""commission.check-* - the setup's checks (the dark wizards), driven the # Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""setup.check-* - the setup's checks (the dark wizards), driven the
way the page drives them: POST /wiz/<id>/start, GET /wiz/dark polled, way the page drives them: POST /wiz/<id>/start, GET /wiz/dark polled,
the prompts answered from here (the bench fixture works the lid and the the prompts answered from here (the bench fixture works the lid and the
button; a confirmation is answered yes once the snapshot exists), the button; a confirmation is answered yes once the snapshot exists), the
@@ -12,7 +17,7 @@ import time
from ..catalog import test from ..catalog import test
from .. import hw from .. import hw
from ..baseline import read_position from ..baseline import read_position
from .commission import wiz, Restore # noqa: F401 - Restore is re-exported for the sheet from .setup import wiz, Restore # noqa: F401 - Restore is re-exported for the sheet
POLL_S = 1.0 POLL_S = 1.0
# What the machine's own press prompt says, used when it sends no text. # What the machine's own press prompt says, used when it sends no text.
@@ -22,7 +27,7 @@ PRESS_TEXT = "The button is lit white. Press it now: the laser fires after your
# (74) for the first seconds and settles near 630. # (74) for the first seconds and settles near 630.
PURGE_SPINUP_S = 30 PURGE_SPINUP_S = 30
DARK_COVERS = [("forgectrl", "src/wizdark.*"), ("forgectrl", "src/wizcalc.*"), DARK_COVERS = [("forgectrl", "src/wizdark.*"), ("forgectrl", "src/wizcalc.*"),
("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"), ("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"),
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*")] ("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*")]
@@ -113,10 +118,10 @@ def run_check(ctx, wid, on_prompt, timeout_s):
return last return last
@test("commission.check-switches", title="The switches check follows the lid and the button", @test("setup.check-switches", title="The switches check follows the lid and the button",
subsystem="commission", kind="operator", est_min=3, subsystem="setup", kind="operator", est_min=3,
covers=DARK_COVERS + [("forgectrl", "src/status.c")], covers=DARK_COVERS + [("forgectrl", "src/status.c")],
requires=["forgectrl.auth"], actions=["lid", "button"], requires=["forgectrl.auth"], actions=["lid", "button", "interlock"],
description="POST /wiz/switches/start; the check asks for the lid to open and close and for " description="POST /wiz/switches/start; the check asks for the lid to open and close and for "
"a press, each as a 'wait' prompt that the switch edge itself answers; the bench " "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 " "fixture works the lid and the button. On a Basic or Plus the interlock loop reads "
@@ -148,48 +153,66 @@ def check_switches(ctx):
ctx.log("switches: %s", json.dumps(r)) ctx.log("switches: %s", json.dumps(r))
@test("commission.check-sensors", title="The sensors check reads a plausible machine at rest", @test("setup.check-sensors", title="The sensors check reads a plausible machine at rest",
subsystem="commission", kind="auto", est_min=2, subsystem="setup", kind="auto", est_min=2,
covers=DARK_COVERS + [("forgectrl", "src/status.c"), ("forgectrl", "src/accel.c"), covers=DARK_COVERS + [("forgectrl", "src/status.c"), ("forgectrl", "src/accel.c"),
("forgectrl", "src/cool.c")], ("forgectrl", "src/cool.c")],
requires=["forgectrl.auth"], requires=["forgectrl.auth"],
description="POST /wiz/sensors/start; ten seconds of readings, then the room-temperature " description="POST /wiz/sensors/start; ten seconds of readings and no question asked. The "
"prompt, answered Skip so no offset is written. The result carries both coolant " "result carries both coolant temperatures, the chassis and SoC, the lid IR "
"temperatures, the chassis and SoC, the lid IR maxima, the accelerometer event " "maxima, the accelerometer event count, the supply power-good, the HV current, "
"count, the supply power-good, the HV current, and the idle fan speeds; the " "and the idle fan speeds; the record carries sensors at version 1 and the "
"record carries sensors at version 1 and cool_temp_offset_c reads as before.") "settings read as before (the check writes none).")
def check_sensors(ctx): def check_sensors(ctx):
with Restore(ctx, ["cool_temp_offset_c"]): fc = ctx.forgectrl
last = run_check(ctx, "sensors", lambda p: "Skip" if p.get("id") == "room-temp" else None, 120) ev = ctx.evidence
before = fc.settings() or {}
ev["settings_before"] = dict(before)
def on_prompt(p):
ctx.fail("the sensors check asked a question: %s", p)
last = run_check(ctx, "sensors", on_prompt, 120)
r = last.get("result") or {} r = last.get("result") or {}
for k in ("coolant_down_c", "coolant_up_c", "lid_ir_max", "laser_pgood", "hv_current_max", for k in ("coolant_down_c", "coolant_up_c", "lid_ir_max", "laser_pgood", "hv_current_max",
"exhaust_rpm_idle", "intake_rpm_idle"): "exhaust_rpm_idle", "intake_rpm_idle"):
ctx.check(k in r, "the result lacks %s: %s", k, r) ctx.check(k in r, "the result lacks %s: %s", k, r)
ctx.check(r.get("laser_pgood") == 1, "power-good read %s", r.get("laser_pgood")) ctx.check(r.get("laser_pgood") == 1, "power-good read %s", r.get("laser_pgood"))
after = fc.settings() or {}
changed = {k: (before.get(k), after.get(k)) for k in set(before) | set(after)
if before.get(k) != after.get(k)}
ctx.check(not changed, "the sensors check wrote a setting: %s", changed)
ctx.log("sensors: coolant %s/%s C, IR %s, HV %s", r.get("coolant_down_c"), r.get("coolant_up_c"), ctx.log("sensors: coolant %s/%s C, IR %s, HV %s", r.get("coolant_down_c"), r.get("coolant_up_c"),
r.get("lid_ir_max"), r.get("hv_current_max")) r.get("lid_ir_max"), r.get("hv_current_max"))
@test("commission.check-airflow", title="The airflow check measures the fans and sets the floors", @test("setup.check-airflow", title="The airflow check measures the fans and sets the floors",
subsystem="commission", kind="auto", hardware="takeover", est_min=3, subsystem="setup", kind="auto", hardware="takeover", est_min=3,
covers=DARK_COVERS + [("forgectrl", "src/cool.c"), ("forgectrl", "src/airflow.*"), covers=DARK_COVERS + [("forgectrl", "src/cool.c"), ("forgectrl", "src/airflow.*"),
("forgectrl", "src/gates.c"), ("forgectrl", "src/super.c")], ("forgectrl", "src/gates.c"), ("forgectrl", "src/super.c")],
requires=["forgectrl.auth", "cooling.fan-gate-trips"], requires=["forgectrl.auth", "cooling.fan-gate-trips"],
description="POST /wiz/airflow/start: the controller stops, the fans run at the cut profile " 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 " "for 35 s, the purge is measured on and off, and six settings are written: the "
"four floors at 55 percent of steady and the grace from the slowest spin-up. Each " "four floors at 55 percent of steady, the grace from the slowest spin-up, and the "
"fan must read at least 1000 rpm. The five settings are put back as found at the " "intake fans fitted (2 when both intakes turn; 1 when intake 2 never reads a pulse "
"end, the purge fan is on again and drawing over the floor the check just wrote, " "and intake 1 passes, and then intake 2 is reported not fitted and the intake floor "
"and the controller must be running again.") "comes from intake 1). Each fan judged must read at least 1000 rpm. The six "
"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): def check_airflow(ctx):
keys = ["cool_tach_exhaust_min_rpm", "cool_tach_intake_min_rpm", "cool_tach_air_assist_min_rpm", 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"] "cool_purge_min_current", "cool_fan_grace_s"]
with Restore(ctx, keys): with Restore(ctx, keys + ["cool_intake_fans"]):
last = run_check(ctx, "airflow", lambda p: None, 240) last = run_check(ctx, "airflow", lambda p: None, 240)
r = last.get("result") or {} r = last.get("result") or {}
fans = r.get("fans") or {} fans = r.get("fans") or {}
intakes = r.get("intake_fans")
ctx.check(intakes in (1, 2), "the result carries no intake fan count: %r", intakes)
ctx.log("intake fans fitted: %s", intakes)
for name in ("exhaust", "intake 1", "intake 2", "air assist"): for name in ("exhaust", "intake 1", "intake 2", "air assist"):
f = fans.get(name) or {} f = fans.get(name) or {}
if name == "intake 2" and intakes == 1:
ctx.check(f == {"fitted": False}, "intake 2 with one intake fan: %s", f)
continue
ctx.check(f.get("ok") is True, "%s: %s", name, f) 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")) 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 {} floors = r.get("floors") or {}
@@ -198,6 +221,8 @@ def check_airflow(ctx):
s = ctx.forgectrl.settings() or {} s = ctx.forgectrl.settings() or {}
for k in keys: 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)) ctx.check(s.get(k) == floors.get(k), "%s reads %r, the check wrote %r", k, s.get(k), floors.get(k))
ctx.check(s.get("cool_intake_fans") == str(intakes),
"cool_intake_fans reads %r, the check found %r", s.get("cool_intake_fans"), intakes)
ctx.log("floors: %s", json.dumps(floors)) ctx.log("floors: %s", json.dumps(floors))
# The check switches purge air off to read its off current. Leaving # 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 # it off costs the next job an airflow hold mid-cut, judged against
@@ -229,8 +254,8 @@ def check_airflow(ctx):
ctx.check(ok is not None, "the controller did not come back after the check") ctx.check(ok is not None, "the controller did not come back after the check")
@test("commission.check-cameras", title="The cameras check captures both cameras", @test("setup.check-cameras", title="The cameras check captures both cameras",
subsystem="commission", kind="operator", est_min=2, subsystem="setup", kind="operator", est_min=2,
covers=DARK_COVERS + [("forgectrl", "src/cam.c")], covers=DARK_COVERS + [("forgectrl", "src/cam.c")],
requires=["forgectrl.auth", "camera.snapshot"], actions=["lid"], requires=["forgectrl.auth", "camera.snapshot"], actions=["lid"],
description="POST /wiz/cameras/start with the lid closed: a lid snapshot, the question, a " description="POST /wiz/cameras/start with the lid closed: a lid snapshot, the question, a "
@@ -264,8 +289,8 @@ def check_cameras(ctx):
shots.get("lid", {}).get("bytes", 0), shots.get("head", {}).get("bytes", 0)) shots.get("lid", {}).get("bytes", 0), shots.get("head", {}).get("bytes", 0))
@test("commission.check-motion", title="The motion check proves the rail, the lens reference, and the jogs", @test("setup.check-motion", title="The motion check proves the rail, the lens reference, and the jogs",
subsystem="commission", kind="auto", hardware="takeover", est_min=5, subsystem="setup", kind="auto", hardware="takeover", est_min=5,
covers=DARK_COVERS + [("forgectrl", "src/super.c"), ("forgectrl", "src/liveness.c"), covers=DARK_COVERS + [("forgectrl", "src/super.c"), ("forgectrl", "src/liveness.c"),
("forgectrl", "src/lenshome.*"), ("forgectrl", "src/lenshome.*"),
("forgectrl", "src/accel.c"), ("forgectrl", "src/cool.c")], ("forgectrl", "src/accel.c"), ("forgectrl", "src/cool.c")],
@@ -305,8 +330,8 @@ def check_motion(ctx):
r.get("rest"), {k: (v.get("p2p_lp_x"), v.get("p2p_lp_y")) for k, v in moves.items()}) r.get("rest"), {k: (v.get("p2p_lp_x"), v.get("p2p_lp_y")) for k, v in moves.items()})
@test("commission.check-flow-verify", title="The flow check runs as a setup check", @test("setup.check-flow-verify", title="The flow check runs as a setup check",
subsystem="commission", kind="auto", hardware="takeover", est_min=5, subsystem="setup", kind="auto", hardware="takeover", est_min=5,
covers=DARK_COVERS + [("forgectrl", "src/diag.c")], covers=DARK_COVERS + [("forgectrl", "src/diag.c")],
requires=["forgectrl.auth", "cooling.aa-offset-calibrate", "cooling.flow-verify"], requires=["forgectrl.auth", "cooling.aa-offset-calibrate", "cooling.flow-verify"],
description="POST /wiz/cooling.flow-verify/start drives the flow-verify diagnostic through the " description="POST /wiz/cooling.flow-verify/start drives the flow-verify diagnostic through the "
@@ -323,8 +348,8 @@ def check_flow_verify(ctx):
r.get("flow_rise"), r.get("noflow_rise"), r.get("thin_margin")) r.get("flow_rise"), r.get("noflow_rise"), r.get("thin_margin"))
@test("commission.cloud-header-capture", title="The cloud header check takes one print's envelope", @test("setup.cloud-header-capture", title="The cloud header check takes one print's envelope",
subsystem="commission", kind="operator", hardware="takeover", mode="grbl", est_min=8, subsystem="setup", kind="operator", hardware="takeover", mode="grbl", est_min=8,
covers=DARK_COVERS + [("forgectrl", "src/super.c"), covers=DARK_COVERS + [("forgectrl", "src/super.c"),
("python3-gfhardware", "gfhardware/machine.py"), ("python3-gfhardware", "gfhardware/machine.py"),
("python3-gfhardware", "forgefirm-app/gfcloud.py"), ("python3-gfhardware", "forgefirm-app/gfcloud.py"),
@@ -1,4 +1,9 @@
"""commission.sheet - the setup's sheet (the live wizards) in one run, # Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""setup.sheet - the setup's sheet (the live wizards) in one run,
driven the way the page drives them: POST /wiz/<id>/start, GET /wiz/dark driven the way the page drives them: POST /wiz/<id>/start, GET /wiz/dark
polled, the prompts answered from here, each burn armed by a press, the polled, the prompts answered from here, each burn armed by a press, the
result judged, and every setting a wizard wrote put back as found. One result judged, and every setting a wizard wrote put back as found. One
@@ -18,13 +23,14 @@ restored. The page is where the real numbers go in.
import re import re
from ..catalog import test from ..catalog import test
from .commission_dark import run_check, Restore from .setup_dark import run_check, Restore
SHEET_COVERS = [("forgectrl", "src/wizlive.*"), ("forgectrl", "src/wizrun.h"), SHEET_COVERS = [("forgectrl", "src/wizlive.*"), ("forgectrl", "src/wizrun.h"),
("forgectrl", "src/lens.*"), ("forgectrl", "src/sheet.*"), ("forgectrl", "src/lens.*"), ("forgectrl", "src/sheet.*"),
("forgectrl", "src/font_hershey.*"), ("forgectrl", "src/jobstream.*"), ("forgectrl", "src/font_hershey.*"), ("forgectrl", "src/jobstream.*"),
("forgectrl", "src/jobrun.*"), ("forgectrl", "src/lease.*"),
("forgectrl", "src/curverec.*"), ("forgectrl", "src/wizdark.*"), ("forgectrl", "src/curverec.*"), ("forgectrl", "src/wizdark.*"),
("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"), ("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"),
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*"),
("forgectrl", "src/super.c"), ("forgectrl", "src/super.c"),
("forgectrl", "src/status.c"), ("forgectrl", "src/cool.c"), ("forgectrl", "src/status.c"), ("forgectrl", "src/cool.c"),
@@ -222,10 +228,10 @@ def burn(ctx, wid, answers, want):
file_card(ctx, wid) file_card(ctx, wid)
@test("commission.sheet", title="The commissioning sheet: the placement, the frame, and the five cards", @test("setup.sheet", title="The setup sheet: the placement, the frame, and the five cards",
subsystem="commission", kind="live", hardware="takeover", mode="grbl", est_min=35, subsystem="setup", kind="live", hardware="takeover", mode="grbl", est_min=35,
covers=SHEET_COVERS, covers=SHEET_COVERS,
requires=["forgectrl.auth", "commission.check-motion", "laser.emission-witness", "cooling.flow-verify"], requires=["forgectrl.auth", "setup.check-motion", "laser.emission-witness", "cooling.flow-verify"],
actions=["button"], hands=["scrap"], actions=["button"], hands=["scrap"],
steps=["A piece of wood at least 200 x 150 mm (8 x 6 in) on the bed, pushed as far left as " steps=["A piece of wood at least 200 x 150 mm (8 x 6 in) on the bed, pushed as far left as "
"it goes with its top edge at the top of the cut area (the head's home corner); " "it goes with its top edge at the top of the cut area (the head's home corner); "
+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)
+203
View File
@@ -0,0 +1,203 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The crumb tray's mode, on the machine.
Its own module, so that no other test's fingerprint moves. With the tray
out, the GRBL controller's Z is the focal height above the floor of the cut
area: its Z position and its Z range move up by tray_offset_mm on the lens
step grid, and nothing moves. The mode persists as the controller's marker
in the data directory. That it survives a controller restart is the
driver's harness case (tray_test.py): a restart here would drop the X and
Y reference, and the machine would not be handed back as it was found.
"""
import math
import os
import sys
import time
from ..catalog import test
from .. import hw
from .motion import (_kernel_position, _port_state, _words, clean_slate, drain_text, machine_idle)
TRAY_MARKER = "/data/forgefirm/tray.out"
LENS_SPM = 2.922 # $102: the lens screw's half-steps per mm
SETUP_CARD = "sheet.place" # the first setup card: it asks before it moves anything
def _grid_mm(offset_mm):
"""The offset on the lens step grid, the controller's way (lroundf)."""
return math.floor(offset_mm * LENS_SPM + 0.5) / LENS_SPM
def _lens(fc):
return fc.status().get("lens") or {}
def _judge(ctx, fc, ev, key, mode, found, shift):
"""The machine reads the tray `mode`, with Z and the reach moved by
`shift` mm from what the test found."""
s = fc.status()
lens, pos = s.get("lens") or {}, s.get("pos") or {}
port = _port_state(fc)
rec = ev[key] = {"lens": lens, "pos_z": pos.get("z"), "port_tray": port.get("tray"),
"port_z": port.get("mpos", [None] * 3)[2], "marker": os.path.exists(TRAY_MARKER),
"home": [s.get("homed_axes"), s.get("home_source")]}
ctx.log("%s: %s", key, rec)
ctx.check(lens.get("tray") == mode and port.get("tray") == mode,
"%s: the status says tray %s and the port %s, not %s", key, lens.get("tray"), port.get("tray"), mode)
ctx.check(rec["marker"] == (mode == "out"), "%s: the marker %s is %s with the tray %s", key, TRAY_MARKER,
"present" if rec["marker"] else "absent", mode)
for k in ("reach_min", "reach_max"):
ctx.check(lens.get(k) is not None and abs(lens[k] - (found["lens"][k] + shift)) < 0.011,
"%s: %s reads %s, not %.2f", key, k, lens.get(k), found["lens"][k] + shift)
ctx.check(pos.get("z") is not None and abs(pos["z"] - (found["pos_z"] + shift)) < 0.006,
"%s: the status Z reads %s, not %.2f", key, pos.get("z"), found["pos_z"] + shift)
ctx.check(rec["port_z"] is not None and abs(rec["port_z"] - (found["port_z"] + shift)) < 0.002,
"%s: the controller's Z reads %s, not %.3f", key, rec["port_z"], found["port_z"] + shift)
ctx.check(rec["home"] == found["home"], "%s: the reference changed across the switch: %s, not %s",
key, rec["home"], found["home"])
def _tray_route(fc, mode):
st, body = fc.post("/motion/tray", params={"tray": mode})
return st, _words(body)
def _card_refused(ctx, fc, ev):
"""A setup card is refused while the tray is out. The machine is idle a
moment after the suite's Grbl client closes; until then the refusal is
the idle rule's. A card that started anyway is ended at once: the first
one asks before it moves anything."""
path = "/wiz/%s/start" % SETUP_CARD
deadline = time.time() + 20
while True:
st, body = fc.post(path)
words = _words(body)
if st == 200:
fc.post("/wiz/%s/abort" % SETUP_CARD)
ctx.wait_for(lambda: not (fc.get("/wiz/dark")[1] or {}).get("running"), 20, poll=0.5)
if st != 409 or "not idle" not in words or time.time() > deadline:
break
ctx.sleep(0.5)
ev["setup_card"] = [st, words]
ctx.log("a setup card started with the tray out -> %s %s", st, words)
ctx.check(st == 409 and "crumb tray" in words, "a setup card started with the tray out -> %s %s", st, words)
@test("motion.tray", title="The crumb tray: tray out moves Z and its range up by the offset, and nothing moves",
subsystem="motion", kind="auto", mode="grbl", est_min=2,
covers=[("grblhal-glowforge", "src/glowforge_tray.*"), ("grblhal-glowforge", "src/glowforge_homing.*"),
("grblhal-glowforge", "src/ctlport.*"), ("grblhal-glowforge", "src/glowforge_io.*"),
("forgectrl", "src/tray.*"), ("forgectrl", "src/grblport.*"), ("forgectrl", "src/status.*"),
("forgectrl", "src/main.c"), ("forgectrl", "src/wizdark.*"), ("forgectrl", "src/ui/panel.js"),
("forgectrl", "src/ui/index.html")],
requires=["motion.port-jog"],
steps=["Nothing moves; the crumb tray can stay where it is."],
description="The tray mode is switched to the other one and back, through the panel's route and "
"through M103 from a Grbl client, both ways. Each switch changes the numbers and "
"moves nothing: /status says the tray, its Z and the lens reach move by the offset on "
"the lens step grid (1.35 in is 100 half-steps, 34.22 mm), the reference and what set it "
"stand, the controller port's state "
"agrees, the marker in the data directory follows, the client is told [MSG:Tray in] or "
"[MSG:Tray out] and its next line draws ok, and the kernel's position and byte counters "
"do not move. In the other mode, a jog to the Z the lens read before the switch is "
"refused (error:15): the two Z ranges never overlap; back in the mode found, the same "
"jog is taken. A setup card started with the tray out is refused in the operator's "
"words, with nothing sent. The page "
"the panel serves carries the switch. The tray mode is put back as found in a finally. "
"The controller is not restarted: that would drop the X and Y reference. That the mode "
"survives a restart, the errors of M103, and its wait for the move before it are the "
"driver's harness cases.")
def tray(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
s0 = fc.status()
found_mode = (s0.get("lens") or {}).get("tray")
ctx.check(found_mode in ("in", "out"), "the status carries no tray mode: %s", s0.get("lens"))
other = "out" if found_mode == "in" else "in"
p_found = "P1" if found_mode == "out" else "P0"
p_other = "P1" if other == "out" else "P0"
shift = _grid_mm((s0.get("lens") or {}).get("tray_offset_mm") or 34.29) * (1 if other == "out" else -1)
machine_idle(ctx)
found = {"lens": s0.get("lens"), "pos_z": (s0.get("pos") or {}).get("z"),
"port_z": _port_state(fc)["mpos"][2], "kernel": _kernel_position(),
"home": [s0.get("homed_axes"), s0.get("home_source")]}
ev["found"] = {"tray": found_mode, "shift_mm": round(shift, 4), **found}
ctx.log("found the tray %s; switching to %s moves Z %+.3f mm", found_mode, other, shift)
ctx.check(found["pos_z"] is not None, "the status carries no Z")
st, page = fc.get("/", raw=True)
text = page.decode("utf-8", "replace") if isinstance(page, bytes) else str(page)
for want in ('id="traybtn"', "function trayToggle", "/motion/tray?tray="):
ctx.check(st == 200 and want in text, "the panel page carries no %s", want)
switched = False
try:
if found_mode == "out":
_card_refused(ctx, fc, ev)
with ctx.grbl() as g:
clean_slate(ctx, g)
ctx.sleep(0.6) # past the port's sender-quiet interval
# Through the panel's route.
st, words = _tray_route(fc, other)
ctx.check(st == 200, "POST /motion/tray tray=%s -> %s %s", other, st, words)
switched = True
said = drain_text(g, 0.5)
ctx.check("[MSG:Tray %s]" % other in said, "the client was not told the tray is %s: %r", other, said)
_judge(ctx, fc, ev, "route_" + other, other, found, shift)
r = g.command("G4 P0")
ctx.check(r[-1:] == ["ok"], "the client's line after the switch drew %s", r)
# The Z the lens read before the switch is outside this mode's range.
jog = "$J=G90 G21 Z%.3f F200" % found["port_z"]
r = g.command(jog)
ev["jog_" + other] = [jog, r]
ctx.check(r[-1:] == ["error:15"], "with the tray %s, %s drew %s, not error:15", other, jog, r)
g.command("") # the empty line that acknowledges the error
# Back through M103, from the client.
r = g.command("M103 " + p_found)
ev["m103_" + found_mode] = r
ctx.check(r[-1:] == ["ok"] and "[MSG:Tray %s]" % found_mode in r,
"M103 %s drew %s", p_found, r)
switched = False
_judge(ctx, fc, ev, "m103_" + found_mode, found_mode, found, 0.0)
r = g.command(jog)
ev["jog_" + found_mode] = [jog, r]
ctx.check(r[-1:] == ["ok"], "with the tray %s again, %s drew %s", found_mode, jog, r)
machine_idle(ctx)
# And the other way: M103 out, the route back.
r = g.command("M103 " + p_other)
ev["m103_" + other] = r
ctx.check(r[-1:] == ["ok"] and "[MSG:Tray %s]" % other in r, "M103 %s drew %s", p_other, r)
switched = True
_judge(ctx, fc, ev, "m103_" + other, other, found, shift)
if other == "out":
_card_refused(ctx, fc, ev)
st, words = _tray_route(fc, found_mode)
ctx.check(st == 200, "POST /motion/tray tray=%s -> %s %s", found_mode, st, words)
switched = False
_judge(ctx, fc, ev, "route_" + found_mode, found_mode, found, 0.0)
finally:
if switched:
# The route is refused for a moment after the suite's Grbl client closes.
first = sys.exc_info()[1]
took = ctx.wait_for(lambda: _tray_route(fc, found_mode)[0] == 200 or None, 20, poll=0.5)
ctx.log("put the tray back %s -> %s", found_mode, "taken" if took is not None else "not taken")
ev["put_back"] = took is not None
if first is None:
ctx.check(took is not None, "the tray mode is left %s, not put back %s", other, found_mode)
machine_idle(ctx)
k1 = _kernel_position()
ev["kernel"] = [found["kernel"], k1]
ctx.check(k1 == found["kernel"], "the kernel moved across the switches: %s -> %s", found["kernel"], k1)
ctx.log("PASS: the tray mode switched both ways through the route and through M103, Z and the reach "
"moved by %.3f mm and back, nothing moved, and it is %s as found", abs(shift), found_mode)
+162
View File
@@ -1,5 +1,11 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""update.* - the A/B slot inventory and the firmware verification path.""" """update.* - the A/B slot inventory and the firmware verification path."""
import os import os
import re
import tempfile import tempfile
import time import time
import shutil import shutil
@@ -8,6 +14,7 @@ from ..catalog import test
from .. import hw from .. import hw
_UPDATE_COVERS = [("forgectrl", "src/update.c"), ("forgectrl", "src/update.h"), _UPDATE_COVERS = [("forgectrl", "src/update.c"), ("forgectrl", "src/update.h"),
("forgectrl", "src/relcheck.c"), ("forgectrl", "src/relcheck.h"),
("ffboot", "**")] ("ffboot", "**")]
@@ -117,3 +124,158 @@ def slots_and_signature(ctx):
except OSError: except OSError:
pass pass
shutil.rmtree(work, ignore_errors=True) shutil.rmtree(work, ignore_errors=True)
@test("update.product-gate", title="The firmware upload takes firmware only: an extension package is refused by name",
subsystem="update", kind="auto", est_min=1,
covers=_UPDATE_COVERS + [("forgectrl", "src/fwproduct.c"), ("forgectrl", "src/fwproduct.h")],
requires=["forgectrl.auth"],
description="Firmware and extension packages are the same container, and a signature says who "
"made an archive, never what it is. Archives made on the spot with the machine's own "
"fwup go to POST /update/upload: an extension package unsigned, one signed with a "
"throwaway key, and one that carries a task are each refused with 400 in words that "
"say what they are, and nothing is staged; an archive of some other product is "
"refused as not firmware; and one whose product is ForgeFIRM firmware is taken as an "
"unsigned upload, which is what it is. 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.")
def product_gate(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
staged = "/data/forgefirm/upload.fw"
ctx.check(not os.path.exists(staged), "an upload is already staged: %s", staged)
work = tempfile.mkdtemp(prefix="forgetest-gate-")
def archive(name, product, task=False, sign=False):
conf = os.path.join(work, name + ".conf")
with open(conf, "w") as f:
f.write('meta-product = "%s"\nmeta-version = "0.0.0-test"\n'
'file-resource payload.tar.gz { host-path = "payload.tar.gz" }\n' % product)
if task:
f.write('task upgrade.a { on-resource payload.tar.gz { raw_write(0) } }\n')
out = name + ".fw"
cmd = "cd %s && fwup -c -f %s.conf -o %s" % (work, name, out)
if sign:
cmd += " && fwup -S -s fwup-key.priv -i %s -o %s.signed && mv %s.signed %s" % (out, out, out, out)
rc, text = hw.run(["sh", "-c", cmd], timeout=60)
ctx.check(rc == 0, "could not make %s (rc %s): %s", name, rc, text.strip()[:200])
return os.path.join(work, out)
def upload(path):
mark = "forgetestGateBoundary9c2"
body = ('--%s\r\nContent-Disposition: form-data; name="file"; filename="%s"\r\n'
'Content-Type: application/octet-stream\r\n\r\n' % (mark, os.path.basename(path))).encode()
with open(path, "rb") as f:
body += f.read() + ("\r\n--%s--\r\n" % mark).encode()
return fc.post("/update/upload", data=body,
headers={"Content-Type": "multipart/form-data; boundary=%s" % mark})
try:
with open(os.path.join(work, "payload.tar.gz"), "wb") as f:
f.write(b"forgetest product-gate drill\n" * 8)
rc, text = hw.run(["sh", "-c", "cd %s && fwup -g" % work], timeout=60)
ctx.check(rc == 0, "fwup -g did not make a key pair (rc %s): %s", rc, text.strip()[:200])
seen = {}
for name, product, task, sign in (("extension-unsigned", "ForgeFIRM extension", False, False),
("extension-signed", "ForgeFIRM extension", False, True),
("extension-with-task", "ForgeFIRM extension", True, False)):
st, body = upload(archive(name, product, task, sign))
words = body.get("error", "") if isinstance(body, dict) else str(body)
seen[name] = [st, words]
ctx.log("upload %s -> %s %s", name, st, words)
ctx.check(st == 400 and "an extension package, not firmware" in words,
"%s at the firmware upload -> %s %s", name, st, words)
ctx.check(not os.path.exists(staged), "%s was refused and is staged all the same", name)
st, body = upload(archive("another-product", "forgetest"))
words = body.get("error", "") if isinstance(body, dict) else str(body)
seen["another-product"] = [st, words]
ctx.log("upload another-product -> %s %s", st, words)
ctx.check(st == 400 and "not firmware" in words and not os.path.exists(staged),
"an archive of another product -> %s %s", st, words)
st, body = upload(archive("firmware-unsigned", "ForgeFIRM firmware"))
seen["firmware-unsigned"] = [st, body]
ctx.log("upload firmware-unsigned -> %s %s", st, body)
ctx.check(st == 200 and isinstance(body, dict) and body.get("signature") == "unsigned"
and os.path.exists(staged), "an unsigned archive that is firmware -> %s %s", st, body)
ev["uploads"] = seen
finally:
try:
os.unlink(staged)
except OSError:
pass
shutil.rmtree(work, ignore_errors=True)
ctx.log("PASS: three extension packages and one foreign product refused at the firmware upload with "
"nothing staged; firmware taken as the unsigned upload it is")
_VERSION_RX = re.compile(r"^v?\d+\.\d+\.\d+")
@test("update.release-check", title="Release check answers from the releases API; the alert dismissal is per release",
subsystem="update", kind="auto", est_min=1,
covers=_UPDATE_COVERS + [("forgectrl", "src/ui/panel.js"), ("forgectrl", "src/ui/index.html")],
requires=["forgectrl.auth"],
description="GET /update/release answers the kept answer of the daily check (available, version, "
"current, new, checked, dismissed). POST /update/check asks the releases API now and "
"answers the same shape; a machine with no route to the API answers 502, which the drill "
"records and steps over. A published release is a v<semver> tag with the firmware "
"file's size and its notes, and `new` follows the version order: a development build "
"sees every release as newer. POST /update/dismiss marks that version dismissed, an "
"empty version undoes it, and a version that is not a tag is refused. The dismissal "
"the machine had before the drill is put back.")
def release_check(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
keys = ("available", "version", "current", "new", "checked", "dismissed", "detail", "bytes")
st, before = fc.get("/update/release")
ctx.log("GET /update/release -> %s %s", st, {k: before.get(k) for k in keys} if isinstance(before, dict) else before)
ctx.check(st == 200 and isinstance(before, dict) and "available" in before and "checked" in before,
"GET /update/release -> %s", st)
ev["before"] = {k: before.get(k) for k in keys}
prior = before.get("version", "") if before.get("dismissed") else ""
st, now = fc.post("/update/check")
ctx.log("POST /update/check -> %s %s", st, {k: now.get(k) for k in keys} if isinstance(now, dict) else now)
if st == 502:
ctx.log("the machine has no route to the releases API; the kept answer stands")
now = before
else:
ctx.check(st == 200 and isinstance(now, dict) and "available" in now, "POST /update/check -> %s", st)
ctx.check(isinstance(now.get("checked"), int) and now["checked"] >= before.get("checked", 0),
"the check did not move `checked`: %s", now.get("checked"))
ev["after_check"] = {k: now.get(k) for k in keys}
if now.get("available"):
ctx.check(_VERSION_RX.match(str(now.get("version", ""))) is not None,
"the release version is not a v<semver> tag: %r", now.get("version"))
ctx.check(isinstance(now.get("bytes"), int) and now["bytes"] > 0,
"the firmware file's size is missing: %r", now.get("bytes"))
ctx.check(isinstance(now.get("notes"), str), "the notes are not a string")
ctx.check(isinstance(now.get("new"), bool), "`new` is not a bool")
if not _VERSION_RX.match(str(now.get("current", ""))):
ctx.check(now.get("new") is True,
"a development build (%r) must see release %s as newer", now.get("current"), now["version"])
else:
ctx.log("no release available: %s", now.get("detail"))
ctx.check(isinstance(now.get("detail"), str) and now["detail"], "an unavailable release names no reason")
try:
st, body = fc.post("/update/dismiss", params={"version": "v0.0.1; rm -rf /"})
ctx.log("POST /update/dismiss version=<not a tag> -> %s %s", st, body)
ctx.check(st == 400, "a version that is not a tag was not refused: %s", st)
tag = now["version"] if now.get("available") else "v0.0.0"
st, body = fc.post("/update/dismiss", params={"version": tag})
ctx.log("POST /update/dismiss version=%s -> %s %s", tag, st,
{k: body.get(k) for k in keys} if isinstance(body, dict) else body)
ctx.check(st == 200 and isinstance(body, dict), "POST /update/dismiss -> %s", st)
if now.get("available"):
ctx.check(body.get("dismissed") is True, "the dismissal of %s was not recorded: %r", tag, body.get("dismissed"))
st, body = fc.post("/update/dismiss", params={"version": ""})
ctx.log("POST /update/dismiss version= -> %s", st)
ctx.check(st == 200 and isinstance(body, dict) and body.get("dismissed") is False,
"the empty version did not undo the dismissal: %s %r", st, body.get("dismissed") if isinstance(body, dict) else body)
finally:
st, body = fc.post("/update/dismiss", params={"version": prior})
ctx.log("dismissal put back to %r -> %s", prior, st)
+121
View File
@@ -0,0 +1,121 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""An update job holds the machine, on the machine.
Its own module, so that no other test's fingerprint moves. The job is a
probe download of the published release: the download job itself, run on
the release's acceptance record instead of its firmware file, because
GitHub counts every request of the firmware file as an install. The
verification refuses the record and the probe keeps nothing; a download
the machine had staged is left as it was.
"""
import os
from ..catalog import test
from .motion import _job_post, _words
DL_FW = "/data/forgefirm/download.fw"
DL_PROBE = "/data/forgefirm/download-probe"
HELD = "an update job (download) holds the machine"
def _as_found(path):
try:
st = os.stat(path)
return [st.st_size, st.st_mtime_ns]
except OSError:
return None
@test("update.job-locks", title="An update job holds the machine: the settings are locked while it runs",
subsystem="update", kind="auto", est_min=2,
covers=[("forgectrl", "src/update.*"), ("forgectrl", "src/relcheck.*"), ("forgectrl", "src/lease.*")],
requires=["update.release-check"],
description="With a release published on the releases API, POST /update/download?probe=1 starts the "
"download job (202) on the release's acceptance record in place of its firmware file, so "
"the test adds nothing to the firmware file's download count. The machine lease is taken "
"before the 202: a settings write and a posted job right after it are each refused in the "
"job's name (409, an update job (download) holds the machine, the settings locked), and "
"/status names the job (update:download, of kind system) as the holder while it runs. The "
"job fetches the record through the download's own path and ends refused by the signature "
"check with the record discarded. When it ends, the lease is free and the same settings "
"write is taken. A download the machine had staged is untouched and the probe leaves no "
"file. That a log export does not lock the settings while every other holder does is "
"lease_test's; that a probe never names the firmware file is relcheck_test's.")
def job_locks(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle")
st, rel = fc.get("/update/release")
if st == 200 and isinstance(rel, dict) and not rel.get("checked"):
# the kept answer does not outlive a restart of the daemon: ask now
st, rel = fc.post("/update/check")
ctx.log("the release was not checked since the daemon started: POST /update/check -> %s", st)
ev["release"] = {k: (rel or {}).get(k) for k in ("available", "version")} if isinstance(rel, dict) else rel
ctx.check(st == 200 and isinstance(rel, dict) and rel.get("available"), "no release is published to probe: %s", rel)
staged = _as_found(DL_FW)
ev["staged_download"] = staged
ctx.check(_as_found(DL_PROBE) is None, "a probe's file is left over from before: %s", DL_PROBE)
units = fc.settings().get("ui_units") or "metric"
def holder():
return ((fc.status().get("lease") or {}).get("holder") or {})
def job():
st_, j = fc.get("/update/status")
return j if isinstance(j, dict) else {}
try:
# The record is small, so the job can end within a second. The
# lease is taken before the 202, so the settings write goes first
# and at once: it is the same value, so a probe that ended before
# it only costs another probe. The posted job goes only while the
# settings are refused in the job's name.
refused = None
for attempt in (1, 2, 3):
st, body = fc.post("/update/download", params={"probe": "1"})
ctx.check(st == 202, "POST /update/download probe=1 -> %s %s", st, _words(body)[:200])
st, body = fc.post("/settings", params={"ui_units": units})
if st == 200:
ctx.wait_for(lambda: job().get("running") is False or None, 120, poll=0.5)
result = job().get("result") or {}
ctx.log("probe %d ended before the settings write was read (%s); again", attempt, result)
ctx.check("signature verification failed" in str(result.get("error")),
"the probe ended, and not refused by the signature check: %s", result)
continue
st2, body2 = _job_post(fc, "G21\n", name="beside-an-update")
held = holder()
refused = {"a settings write": [st, _words(body)[:200]], "a posted job": [st2, _words(body2)[:200]]}
break
ctx.check(refused is not None, "three probes each ended before a settings write could be refused")
ev["refused"] = refused
ev["holder"] = held
ctx.log("beside the probe: %s; the lease: %s", refused, held)
ctx.check(st == 409 and HELD in _words(body) and "settings are locked" in _words(body),
"a settings write beside the probe -> %s %s", st, _words(body)[:200])
ctx.check(st2 == 409 and HELD in _words(body2), "a job beside the probe -> %s %s", st2, _words(body2)[:200])
if held:
ctx.check(held.get("owner") == "update:download" and held.get("kind") == "system",
"the probe does not hold the machine as the download job: %s", held)
else:
ctx.log("the probe had ended before /status was read; its refusals name it")
ctx.wait_for(lambda: job().get("running") is False or None, 600, poll=1.0)
done = job()
ev["job"] = done
ctx.log("the probe ended: %s", done)
result = done.get("result") or {}
ctx.check(result.get("ok") is False and "signature verification failed" in str(result.get("error")),
"the probe did not end refused by the signature check (the record fetched and discarded): %s",
result)
ctx.check(not holder(), "the lease was not given back: %s", holder())
st, body = fc.post("/settings", params={"ui_units": units})
ctx.check(st == 200, "the same settings write after the job -> %s %s", st, _words(body)[:200])
finally:
# The job runs on after a check that failed: its end is waited for.
ctx.wait_for(lambda: job().get("running") is False or None, 600, poll=2.0)
ctx.check(_as_found(DL_FW) == staged, "the staged download moved: %s, and %s before", _as_found(DL_FW), staged)
ctx.check(_as_found(DL_PROBE) is None, "the probe left its file: %s", DL_PROBE)

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