93 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
133 changed files with 77083 additions and 334 deletions
+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
+1 -1
View File
@@ -3,7 +3,7 @@
> ### BETA
>
> **ForgeFIRM is in beta.** Every release below 0.1.0 is a beta release.
> Expect problems, and expect frequent updates. Upgrade whenever a newer
> It's stable, but you may still hit the occasional bug, and expect frequent updates. Upgrade whenever a newer
> release is available, and report what you find on the
> [community forum](https://community.openglow.org).
@@ -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
+91 -2
View File
@@ -41,6 +41,7 @@ that made it, not a reason to hand the dirt on.
import json
import os
import struct
import subprocess
import time
from . import hw
@@ -116,6 +117,12 @@ UNPRESERVED_SETTINGS = ("controller_mode",)
# service starts its own client with the hunt.
NOHUNT_MARKER = os.environ.get("FORGETEST_NOHUNT_MARKER", "/run/gfcloud-nohunt")
# The offline service: gfcloud's one-start offline marker and the socket
# the service listens on while it runs. A takeover that finds it running
# makes forgectrl's start at its end under the marker again (runner.Takeover).
OFFLINE_MARKER = os.environ.get("FORGETEST_OFFLINE_MARKER", "/run/gfcloud-offline")
OFFLINE_SOCKET = os.environ.get("FORGETEST_OFFLINE_SOCKET", "/run/gfcloud-offline.sock")
# Read-only readbacks with their idle values (no direct restore: the state
# comes right through forgectrl - see restore_forgectrl - or is fatal).
IDLE_READBACKS = [
@@ -214,6 +221,23 @@ def write_led(name, value):
f.write(str(value))
# The GRBL controller zeroes the kernel's step counters at every start (the
# lens reference) and at every home, and rewrites its home anchor each time.
# The anchor's identity is therefore the counters' frame: if it changed
# during a run, the counters at the end are not in the frame of the counters
# at the start, and their difference is not a distance the head traveled.
ANCHOR_PATH = os.environ.get("FORGETEST_ANCHOR", "/run/grblhal.homed")
def counter_frame():
"""What identifies the frame the step counters are in, or None."""
try:
st = os.stat(ANCHOR_PATH)
return [st.st_ino, st.st_mtime_ns]
except OSError:
return None
def read_position():
"""(x, y, z) step counters, or None when unreadable."""
try:
@@ -272,6 +296,11 @@ def read_program_total():
return None
# The extension host stops a removed package's service on its next
# one-second turn; its group is killed and waited for inside that turn.
EXT_STOP_S = 8
class Leftover:
def __init__(self, item, found, expected, action):
self.item = item
@@ -445,7 +474,8 @@ class Baseline:
# -- capture -------------------------------------------------------
def capture(self):
"""Record the preserved state before a run."""
cap = {"sysfs": {}, "position": read_position(), "settings": None, "mode": None}
cap = {"sysfs": {}, "position": read_position(), "frame": counter_frame(),
"settings": None, "mode": None}
for attr in PRESERVED_SYSFS:
cap["sysfs"][attr] = hw.sysfs_read(attr)
st, body = self.fc_get("/settings")
@@ -471,6 +501,7 @@ class Baseline:
self._forgectrl_side(left, captured)
self._kernel_side(left)
self._lamp_side(left)
self._ext_side(left)
self._preserved(left, captured)
if left:
self.log("%s: %d leftover(s): %s" % (phase, len(left), "; ".join(str(x) for x in left)))
@@ -669,6 +700,37 @@ class Baseline:
left.append(Leftover("cool", found, "idle/unarmed/no hold",
act if w is not None else "failed: still %s" % found))
def _ext_side(self, left):
"""A campaign's machine is extension-free. What a test made and
left behind (a package under the tests' own prefix, the owner key
it was signed with) is removed, and the host stops its service.
The operator's own packages are theirs: running ones are reported
and not touched."""
for id_ in [p for p in hw.ext_packages() if p.startswith(hw.EXT_TEST_PREFIX)]:
try:
p = subprocess.run([hw.FORGEEXT, "remove", id_], capture_output=True, text=True, timeout=120)
ok = p.returncode == 0 and id_ not in hw.ext_packages()
act = "restored" if ok else "failed: %s" % (p.stdout.strip()[-120:] or p.returncode)
except (OSError, subprocess.SubprocessError) as e:
act = "failed: %s" % e
left.append(Leftover("ext.package", id_, "not installed", act))
keys = hw.EXT_ROOT + "/keys"
for name in sorted(os.listdir(keys)) if os.path.isdir(keys) else []:
if not name.startswith("forgetest-"):
continue
try:
os.remove(os.path.join(keys, name))
act = "restored"
except OSError as e:
act = "failed: %s" % e
left.append(Leftover("ext.owner_key", name, "absent", act))
# the host stops a removed package's service on its next turn
running = self._wait("no test package running", lambda: not hw.pool_pids() or None, EXT_STOP_S) is None \
and hw.pool_pids()
if running:
left.append(Leftover("ext.running", "%d process(es) under pool accounts, packages %s"
% (len(running), hw.ext_packages()), "an extension-free machine", "unrestorable"))
def _lamp_side(self, left):
"""The lid lamp at forgectrl's idle level (the lid_lamp_idle setting).
In cloud mode the cloud client owns the lamp (its lid-image level)."""
@@ -829,7 +891,19 @@ class Baseline:
# action re-zeroes them at its start, so they preserve nothing.
was = captured.get("position")
now = read_position()
if was is not None and now is not None and now != was and not self.cloud_mode():
# A controller start or a home during the run re-zeroed the counters
# wherever the head then stood. Unless the test said where that was
# (ctx.counters_rezeroed), the two readings share no frame: on the
# bench reference a head that had not moved was "returned" 30 mm into
# the gantry stops this way, twice. Nothing is moved on a guess.
frame_lost = ("frame" in captured and captured["frame"] != counter_frame()
and not captured.get("rezero_declared"))
if was is not None and now is not None and now != was and frame_lost and not self.cloud_mode():
self.log("position: %s, and %s when the run began, but the controller re-zeroed its "
"counters during the run (a controller start or a home): the two are not in "
"one frame, so the head's position cannot be judged and the head is not moved"
% (now, was))
elif was is not None and now is not None and now != was and not self.cloud_mode():
spm = self._counter_spm
act = self._return_head(was, now, spm)
if position_quantized(was, now, spm):
@@ -837,6 +911,21 @@ class Baseline:
"not a leftover" % (now, was, act, POSITION_DEADBAND_MM))
else:
left.append(Leftover("position", now, was, act))
# A controller restart during the run (a takeover's) re-zeroed the
# counters wherever the head then stood, so a head left out before
# it reads as home from here. The takeover judged it on the spot
# (runner.Takeover); what it recorded is a leftover, never moved.
for r in captured.get("restart_positions") or []:
left.append(Leftover("position at the %s" % r["where"], r["found"], r["expected"],
"unrestorable: a controller restart re-zeroed the counters there, "
"so the head is not moved"))
# The cloud client forgectrl's start brought back at the end of a
# takeover, when it is not the one the takeover found (the same
# judge, runner.Takeover). The client that came up is left as it
# is: the cloud test that needs a client starts its own.
for r in captured.get("restart_clients") or []:
left.append(Leftover("cloud client at the %s" % r["where"], r["found"], r["expected"],
"not restored: a cloud test starts the client it needs"))
was = captured.get("settings")
if was:
st, body = self.fc_get("/settings")
+58
View File
@@ -257,6 +257,64 @@ TOOLS = [
"card's stops: the ends of the reach run, two half-steps past either end alarms. A CI harness "
"(the grblHAL repo): needs the host-built null-sink controller, not the machine, so it is not a "
"bench-page tool."},
{"id": "ctlport-test", "title": "Controller port and line multiplexer harness", "script": "ctlport_test.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "A scripted sender that counts every response, beside the controller port's one client, on the "
"null-sink controller: a port jog's status goes to the port and never to the sender, a port "
"error does not reach the sender's parser, a sender line cancels a port jog and gets its own ok "
"(a line queued right behind the port's included), the refusals, the single client, a CR LF "
"sender, a soft reset, and the client as the dead-man. A CI harness (the grblHAL repo): needs "
"the host-built null-sink controller, not the machine, so it is not a bench-page tool."},
{"id": "cool-report-test", "title": "Cooling report secret harness", "script": "cool_report_test.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "The controller's cooling reports on the null-sink controller, read by a stand-in for "
"forgectrl's listener: every report carries the secret the supervisor hands the controller at "
"its spawn (GF_REPORT_SECRET), none does when there is none, a value that is not 32 hex digits "
"(a CR LF with a header behind it included) never reaches the wire, and the homing runner the "
"controller starts is handed it (it reports in the controller's place during a gfcloud $H), "
"and none when the controller has none. A CI harness (the grblHAL repo): needs the host-built "
"null-sink controller, not the machine, so it is not a bench-page tool."},
{"id": "mcode-test", "title": "Package M-code barrier harness", "script": "mcode_test.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "The M-codes extension packages answer (M160 to M179), on the null-sink controller with a "
"scripted sender and a stand-in for the machine daemon on the port: the table and its refusals, "
"a number nothing answers is error:20 where it is parsed, an answered one waits with the head "
"still and a port jog refused while the port's state names it, the answer's words, an answer "
"that the work was not done and no answer in time each hold the job, a soft reset ends the "
"wait, and a wait under an open armed window with M3 S500 ships no FIRE tick. Imports its "
"sender and port client from ctlport_test.py. A CI harness (the grblHAL repo): needs the "
"host-built null-sink controller, not the machine, so it is not a bench-page tool."},
{"id": "envelope-test", "title": "Measured envelope harness", "script": "envelope_test.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "The measured work envelope and the bed check's port op, on the null-sink controller with a "
"scripted sender and a port client: unset keys are the axis travel, envelope_x_mm and _y are the "
"far edges at a home and are held to 50 mm up to the travel plus 30 mm, the port's envelope open "
"is refused before a home and under an open armed window and lets the port's jogs reach the "
"travel plus 30 mm, envelope apply puts the keys' edges back, and the sender's first line (never "
"a status poll or an empty line), a soft reset, and the port client going away each close an "
"open envelope, the sender's line only after it closed. Imports its sender and port client from "
"ctlport_test.py. A CI harness (the grblHAL repo): needs the host-built null-sink controller, "
"not the machine, so it is not a bench-page tool."},
{"id": "manual-home-test", "title": "Manual home and motor release harness", "script": "manual_home_test.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "The manual homing provider and the motor release on the null-sink controller: $H under manual "
"ships nothing and declares X0 Y0 with the soft limits on and Z kept; while the motors are "
"released every motion source is refused and ships nothing, $X and a soft reset do not unlock "
"it, and only $ME and a manual $H write the energize, once each; the port's panel operations "
"do the same with the sender's count exact. Imports its sender and port client from "
"ctlport_test.py. A CI harness (the grblHAL repo): needs the host-built null-sink controller, "
"not the machine, so it is not a bench-page tool."},
{"id": "tray-test", "title": "Crumb tray mode harness", "script": "tray_test.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "The crumb tray's mode on the null-sink controller: M103 P1 and P0 move Z and its envelope by "
"tray_offset_mm on the lens step grid, tell the sender, write and remove the marker beside the "
"shared config, and ship no step; a missing P, a P other than 0 or 1 and an axis word are "
"errors; the switch waits for the move before it; the port's tray op, and its refusals during "
"a program and at a package's M-code; the mode survives a soft reset and a restart, and a start "
"over the marker references Z in the tray-out frame; the offset's range; an unreferenced lens; "
"and a camera home in both modes hands the runner the same park. Imports its sender and port "
"client from ctlport_test.py. A CI harness (the grblHAL repo): needs the host-built null-sink "
"controller, not the machine, so it is not a bench-page tool."},
{"id": "raster-dry", "title": "Dry top-speed raster per XY microstep mode", "script": "raster_dry.py",
"safety": "dry", "where": "board", "ported": True,
"args": [_arg("modes", "choice", "8 16 32", "the modes to run, in order", ["8", "16", "32", "8 16 32"])],
+60 -23
View File
@@ -75,8 +75,12 @@ class Test:
invalidates the tests of that module. A test the module does not
define in the @test form hashes its whole file."""
if self._source_sha is None:
path = inspect.getsourcefile(self.fn) or inspect.getfile(self.fn)
self._source_sha = implementation_sha(path, self.id)
# One filler at a time: a second poll that arrives while the
# first computes waits for it rather than repeating the work.
with _IMPL_LOCK:
if self._source_sha is None:
path = inspect.getsourcefile(self.fn) or inspect.getfile(self.fn)
self._source_sha = implementation_sha(path, self.id)
return self._source_sha
def fingerprint(self, manifest):
@@ -140,8 +144,38 @@ def source_file_sha(path):
return hashlib.sha256(data).hexdigest()
_PARTS = {} # path -> (shared sha, {test id: own sha}); the files never change under a run
_PARTS_LOCK = threading.Lock()
# The files never change under a run, so each is read and parsed once:
# path -> (text, tree or None), path -> (shared sha, {test id: own sha}),
# path -> the sibling modules it imports itself.
_PARSED = {}
_PARTS = {}
_SIBLINGS = {}
_PARTS_LOCK = threading.RLock()
_IMPL_LOCK = threading.Lock()
def forget(path):
"""Drop what is known about a module: its file changed (the unit tests
edit their modules between readings)."""
with _PARTS_LOCK:
for cache in (_PARSED, _PARTS, _SIBLINGS):
cache.pop(path, None)
def _parsed(path):
"""(text, tree) of a suite module, line endings normalized; the tree
is None when the file does not parse."""
with _PARTS_LOCK:
hit = _PARSED.get(path)
if hit is None:
with open(path, "rb") as f:
text = f.read().replace(b"\r\n", b"\n").decode("utf-8")
try:
tree = ast.parse(text)
except SyntaxError:
tree = None
hit = _PARSED[path] = (text, tree)
return hit
def module_parts(path):
@@ -154,14 +188,9 @@ def module_parts(path):
hit = _PARTS.get(path)
if hit is not None:
return hit
with open(path, "rb") as f:
text = f.read().replace(b"\r\n", b"\n").decode("utf-8")
text, tree = _parsed(path)
lines = text.split("\n")
spans = {}
try:
tree = ast.parse(text)
except SyntaxError:
tree = None
for node in (tree.body if tree is not None else []):
if not isinstance(node, ast.FunctionDef):
continue
@@ -188,22 +217,30 @@ def sibling_imports(path, seen=None):
test's fingerprint."""
if seen is None:
seen = []
with open(path, "rb") as f:
text = f.read().replace(b"\r\n", b"\n").decode("utf-8")
try:
tree = ast.parse(text)
except SyntaxError:
return seen
here = os.path.dirname(path)
for node in tree.body:
if isinstance(node, ast.ImportFrom) and node.level == 1 and node.module:
sib = os.path.join(here, node.module.replace(".", os.sep) + ".py")
if os.path.isfile(sib) and sib not in seen and sib != path:
seen.append(sib)
sibling_imports(sib, seen)
for sib in _direct_siblings(path):
if sib not in seen and sib != path:
seen.append(sib)
sibling_imports(sib, seen)
return seen
def _direct_siblings(path):
"""The suite modules one module imports itself, in import order."""
with _PARTS_LOCK:
hit = _SIBLINGS.get(path)
if hit is None:
_text, tree = _parsed(path)
here = os.path.dirname(path)
hit = []
for node in (tree.body if tree is not None else []):
if isinstance(node, ast.ImportFrom) and node.level == 1 and node.module:
sib = os.path.join(here, node.module.replace(".", os.sep) + ".py")
if os.path.isfile(sib):
hit.append(sib)
_SIBLINGS[path] = hit
return hit
def implementation_sha(path, test_id):
"""The implementation hash of one test (see Test.source_sha): the
test's own text, the shared text of its module, and the shared text
+9 -6
View File
@@ -297,11 +297,11 @@ class Fixture:
return bool(st and st.get("button_enabled"))
return True
def act(self, channel, state):
def act(self, channel, state, ms=None):
if channel == "button":
if state != "press":
raise FixtureError("the button is only ever pressed")
st, body = self._press()
st, body = self._press(ms)
else:
if state not in ("open", "close"):
raise FixtureError("%s: unknown state %r" % (channel, state))
@@ -312,8 +312,11 @@ class Fixture:
self.last_state = body
return body
def _press(self):
"""One press, spaced from the last one. The controller detects
def _press(self, ms=None):
"""One press, spaced from the last one. `ms` is how long the box
holds it (the firmware clamps it into 20 to 500, and takes 200 when
it is not said): a check that must reach the machine *while* the
button is down asks for the longest press the box can make. The controller detects
the button's rising edge, so a press must follow a release it has
seen: the next press waits for the last pulse to end (the
fixture's own pulse_ms) plus BUTTON_GAP_S. A 409 for a pulse in
@@ -322,7 +325,7 @@ class Fixture:
wait = self._pulse_end + BUTTON_GAP_S - time.time()
if wait > 0:
time.sleep(wait)
st, body = self._request("POST", "/button", {})
st, body = self._request("POST", "/button", {"ms": int(ms)} if ms else {})
if st == 409 and "in progress" in str(body.get("error", "")):
deadline = time.time() + PULSE_WAIT_S
while time.time() < deadline:
@@ -331,7 +334,7 @@ class Fixture:
if s == 200 and not b.get("button_pulsing"):
break
time.sleep(BUTTON_GAP_S)
st, body = self._request("POST", "/button", {})
st, body = self._request("POST", "/button", {"ms": int(ms)} if ms else {})
if st == 200:
self._pulse_end = time.time() + float(body.get("pulse_ms") or 500) / 1000.0
return st, body
+72
View File
@@ -324,6 +324,40 @@ def initd(service, action, timeout=60):
return p.returncode, p.stdout.decode("utf-8", "replace")
# The extension host's side of the machine (forgefirm-sandbox, forgeext).
EXT_POOL_FIRST, EXT_POOL_LAST = 800, 831
EXT_ROOT = "/data/forgefirm/ext"
EXT_TEST_PREFIX = "org.forgetest." # a package a test made; every other one is the operator's
FORGEEXT = "/usr/bin/forgeext"
def pool_pids():
"""{pid: uid} of every process that runs under an extension pool account."""
out = {}
try:
pids = [p for p in os.listdir("/proc") if p.isdigit()]
except OSError:
return out
for pid in pids:
try:
with open("/proc/%s/status" % pid) as f:
line = next((x for x in f if x.startswith("Uid:")), "")
uid = int(line.split()[1]) # the real uid; a service has all four alike
except (OSError, IndexError, ValueError):
continue
if EXT_POOL_FIRST <= uid <= EXT_POOL_LAST:
out[int(pid)] = uid
return out
def ext_packages():
"""The ids of the installed extension packages."""
try:
return sorted(d for d in os.listdir(EXT_ROOT + "/pkg") if os.path.isdir(os.path.join(EXT_ROOT, "pkg", d)))
except OSError:
return []
def pidof(comm):
"""PIDs whose /proc/<pid>/comm equals comm (15-char kernel limit applies)."""
out = []
@@ -361,6 +395,44 @@ def pgrep_f(needle):
return out
def listens_on(pid, path):
"""Process pid holds a listening UNIX socket bound at path: one of its
own descriptors is that socket, so a socket file another process left
behind does not count. Reads /proc (FORGETEST_PROC_ROOT for tests)."""
if not pid:
return False
proc = os.environ.get("FORGETEST_PROC_ROOT") or "/proc"
inodes = set()
try:
with open(os.path.join(proc, "net", "unix")) as f:
next(f, None) # the header line
for ln in f:
# Num RefCount Protocol Flags Type St Inode Path
parts = ln.split()
try:
if len(parts) >= 8 and parts[7] == path and int(parts[3], 16) & 0x10000:
inodes.add(parts[6]) # __SO_ACCEPTCON: a listener
except ValueError:
continue
except OSError:
return False
if not inodes:
return False
fd_dir = os.path.join(proc, str(pid), "fd")
try:
fds = os.listdir(fd_dir)
except OSError:
return False
for fd in fds:
try:
link = os.readlink(os.path.join(fd_dir, fd))
except OSError:
continue
if link.startswith("socket:[") and link[8:-1] in inodes:
return True
return False
def run(cmd, timeout=60):
"""Run a command list; returns (rc, combined output)."""
try:
+25 -4
View File
@@ -93,10 +93,27 @@ NON_BEHAVIORAL = [
("*", ".vscode/**"),
("*", ".env.example"),
("forgectrl", "tools/**"), # host-side dev tools (panel dev server)
("forgectrl", "examples/**"), # clients of the remote API, run on another computer
# forgeext's recipe installs the binary and its init script, and nothing of these: the author's kit
# and the host tools run off the image (OpenGlow's own packages have repositories of their own).
("forgeext", "sdk/**"),
("forgeext", "template/**"),
("forgeext", "tools/**"),
]
# Paths the list above would take out that are behavior all the same: forgectrl embeds its advisory
# documents in the binary, serves them, and records the operator's consent to a document by its hash, so
# an edited advisory is a changed consent and must move every fingerprint that covers it.
BEHAVIORAL = [
("forgectrl", "docs/advisories/**"),
]
def non_behavioral(comp, path, allow=NON_BEHAVIORAL):
for c, pat in BEHAVIORAL:
if c == comp and glob_to_regex(pat).match(path):
return False
for c, pat in allow:
if c in ("*", comp) and glob_to_regex(pat).match(path):
return True
@@ -203,7 +220,8 @@ def coverage_report(manifest, tests, allow=NON_BEHAVIORAL):
"""Which manifest paths no test covers.
tests: iterable with .covers. allow: iterable of (component, glob)
that need no coverage (docs, CI, licenses...). Returns
that need no coverage (docs, CI, licenses...); a BEHAVIORAL path is
never allowed away, as it is never taken out of a fingerprint. Returns
{component: [uncovered paths]} for the non-dev-only components.
"""
covered = {}
@@ -217,11 +235,14 @@ def coverage_report(manifest, tests, allow=NON_BEHAVIORAL):
for comp in manifest.component_names():
if comp in DEV_ONLY_COMPONENTS:
continue
rxs = covered.get(comp, []) + allowed.get(comp, [])
star = allowed.get("*", [])
rxs = covered.get(comp, [])
free = allowed.get(comp, []) + allowed.get("*", [])
behavior = [glob_to_regex(pat) for c, pat in BEHAVIORAL if c == comp]
missing = []
for p, _b in manifest.files(comp):
if any(rx.match(p) for rx in rxs) or any(rx.match(p) for rx in star):
if any(rx.match(p) for rx in rxs):
continue
if any(rx.match(p) for rx in free) and not any(rx.match(p) for rx in behavior):
continue
missing.append(p)
if missing:
+167 -5
View File
@@ -366,7 +366,7 @@ class Context:
"""One of forgectrl's switch readings (lid, interlock_ok, ...)."""
return (self.forgectrl.status().get("switches") or {}).get(name)
def act(self, channel, state, until=None, timeout=ACT_TIMEOUT_S, text="", fail=True):
def act(self, channel, state, until=None, timeout=ACT_TIMEOUT_S, text="", fail=True, ms=None):
"""A machine action by name: ("lid", "open"|"close"),
("interlock", "open"|"close"), ("button", "press"). The bench's
actuator performs it when one covers the channel (the runner's
@@ -405,7 +405,7 @@ class Context:
self.log("ACT %s %s (fixture)", channel, state)
rec["by"] = "fixture"
try:
fixture.act(channel, state)
fixture.act(channel, state, ms)
except _fixture.FixtureError as e:
rec["fixture_error"] = str(e)
if self.run.unattended:
@@ -539,7 +539,7 @@ class Context:
return hw.Grbl()
def takeover(self):
return Takeover(self.run.log, self.test.id)
return Takeover(self.run.log, self.test.id, run=self.run)
def counters_rezeroed(self):
"""Tell the baseline the kernel position counters were re-zeroed
@@ -549,6 +549,10 @@ class Context:
cap = self.run.baseline_captured
if cap and cap.get("position") is not None:
cap["position"] = [0, 0, 0]
cap["rezero_declared"] = True # the test vouches for the new frame
# ... and it is the frame now in force, so a takeover later in
# the run can still judge where the head stands against it
cap["frame"] = _baseline.counter_frame()
self.log("position counters re-zeroed at the starting position; the baseline "
"expects (0,0,0) at the end")
@@ -578,11 +582,16 @@ class Takeover:
"cnc/x_mode", "cnc/y_mode", "cnc/x_decay", "cnc/y_decay",
"pic/x_step_current", "pic/y_step_current")
def __init__(self, log, who):
def __init__(self, log, who, run=None):
self.log = log # callable(str)
self.who = who
self.marker = marker_path()
self.saved = {}
self.run = run # the test's run, whose baseline the head is judged against
self.cloud = False
self.offline = False # the cloud client found is the offline service
self.frame = None # the counters' frame, read before the controller is stopped
self.found = None # (counters, microstep mode) once the controller is gone
def wait_settled(self):
return _baseline.Baseline(self.log).wait_settled()
@@ -609,6 +618,18 @@ class Takeover:
self.saved[attr] = v
if self.saved:
log("takeover: preserving %s" % ", ".join("%s=%s" % kv for kv in self.saved.items()))
try:
st, mode = hw.Forgectrl().get("/mode")
self.cloud = st == 200 and isinstance(mode, dict) and mode.get("mode") == "cloud"
self.offline = self.cloud and hw.listens_on(mode.get("pid"), _baseline.OFFLINE_SOCKET)
except hw.HwError:
pass
if self.offline:
log("takeover: the cloud client is the offline service (pid %s); forgectrl's start "
"brings it back offline" % mode.get("pid"))
# The frame is read while the controller runs: its exit takes the
# anchor with it (forgectrl unlinks it), though not the counters.
self.frame = _baseline.counter_frame()
log("takeover: stopping the controller through forgectrl")
try:
st, body = hw.Forgectrl().post("/controller/stop")
@@ -631,10 +652,146 @@ class Takeover:
self.__exit__(None, None, None)
raise Failed("takeover: processes still alive after stop: %s" % left)
log("takeover: pulse device free")
try:
self.position_at_start()
except Exception as e: # noqa: BLE001 - a reading never keeps forgectrl down
log("takeover: WARNING the head's position could not be judged: %s: %s" % (type(e).__name__, e))
return self
# A controller start re-zeroes the step counters where the head then
# stands, and forgectrl's start after a takeover is one, so the
# baseline's check after the run cannot see a head left out before it.
# The takeover judges it at the two moments it can: as the controller
# goes away (against where the run expects the head) and before it
# comes back (against where the takeover found it). A miss is recorded
# for the baseline's post pass, which fails the run; nothing is moved,
# because a jog sized from these counters is only as good as the frame
# they are in. Cloud mode is not judged: its counters are the cloud
# client's, as in the baseline.
def _record(self, where, found, expected):
cap = self.run.baseline_captured if self.run is not None else None
if cap is not None:
cap.setdefault("restart_positions", []).append(
{"where": where, "found": found, "expected": expected})
self.log("takeover: the head is not where it belongs at the %s: counters %s, expected %s; "
"recorded as a leftover, nothing moved" % (where, found, expected))
def position_at_start(self):
cap = self.run.baseline_captured if self.run is not None else None
if not cap or self.cloud:
return
now = _baseline.read_position()
self.found = (now, hw.sysfs_read("cnc/x_mode"))
was = cap.get("position")
if now is None or was is None:
return
if self.frame is None or cap.get("frame") != self.frame:
self.log("takeover: the counters are not in the frame the run began in (a controller "
"start or a home the test did not declare), so the head is not judged here")
return
if now != was and not _baseline.position_quantized(was, now, _baseline.counter_steps_per_mm()):
self._record("takeover start", now, was)
def position_at_end(self):
if self.found is None:
return
was, mode = self.found
now = _baseline.read_position()
if was is None or now is None:
return
if hw.sysfs_read("cnc/x_mode") != mode:
self.log("takeover: the microstep mode changed under the takeover, so the counters "
"before and after it are not one scale; the head is not judged here")
return
if now != was and not _baseline.position_quantized(was, now, _baseline.counter_steps_per_mm()):
self._record("takeover end", now, was)
# In cloud mode forgectrl's start at the end starts a cloud client, and
# what that client is comes only from the one-start markers gfcloud
# reads and takes down as it starts. Started bare, it is the online
# client with the service's connect-time hunt, and the moves the
# service sends after the hunt are still running as the run ends and
# the next one begins, whatever the takeover found. The start is made
# under the no-hunt marker, as every client start the runner makes is,
# and under the offline marker as well when the takeover found the
# offline service; that client is then proven to be the offline
# service, and a miss is recorded for the baseline's post pass, which
# fails the run.
OFFLINE_BACK_S = 60 # the start to the offline service's listener
def client_markers(self):
"""(path, name) of the markers forgectrl's start is made under."""
if not self.cloud:
return []
marks = [(_baseline.NOHUNT_MARKER, "no-hunt")]
if self.offline:
marks.append((_baseline.OFFLINE_MARKER, "offline"))
return marks
def markers_on(self):
for path, name in self.client_markers():
try:
with open(path, "w") as f:
f.write("forgetest takeover %s\n" % self.who)
except OSError as e:
self.log("takeover: WARNING the %s marker could not be written: %s" % (name, e))
def wait_offline(self):
"""The cloud client's pid once it listens on the offline socket, or
None after OFFLINE_BACK_S."""
deadline = time.time() + self.OFFLINE_BACK_S
while True:
try:
st, m = hw.Forgectrl().get("/mode")
except hw.HwError:
st, m = None, None
pid = m.get("pid") if st == 200 and isinstance(m, dict) else None
if hw.listens_on(pid, _baseline.OFFLINE_SOCKET):
return pid
if time.time() >= deadline:
return None
time.sleep(0.5)
def client_back(self, mode):
"""After forgectrl's start: a marker no client read is taken down
(no client started - the gate, standby, a fault - and a later
start must not come up under it), and the offline service found is
judged against the client that came up."""
if not self.cloud:
return
mode = mode if isinstance(mode, dict) else {}
running = mode.get("mode") == "cloud" and mode.get("controller") == "running"
pid = self.wait_offline() if self.offline and running else None
for path, name in self.client_markers():
if (not running or name == "offline") and os.path.exists(path):
try:
os.remove(path)
self.log("takeover: the %s marker was never read - taken down" % name)
except OSError:
pass
if not self.offline:
return
if pid:
self.log("takeover: the cloud client is back as the offline service (pid %s)" % pid)
return
found = ("a client that is not the offline service (pid %s)" % mode.get("pid") if running
else "no cloud client running (mode %s, controller %s)"
% (mode.get("mode"), mode.get("controller")))
cap = self.run.baseline_captured if self.run is not None else None
if cap is not None:
cap.setdefault("restart_clients", []).append(
{"where": "takeover end", "found": found, "expected": "the offline service"})
self.log("takeover: the offline service did not come back: %s; recorded as a leftover" % found)
def __exit__(self, exc_type, exc, tb):
try:
self.position_at_end()
except Exception as e: # noqa: BLE001 - a reading never keeps forgectrl down
self.log("takeover: WARNING the head's position could not be judged: %s: %s"
% (type(e).__name__, e))
self.restore_attrs()
self.markers_on()
rc, out = hw.initd("forgectrl", "start")
self.log("takeover: forgectrl start -> rc %s" % rc)
try:
@@ -643,7 +800,12 @@ class Takeover:
pass
# leave the machine settled for whatever runs next: the probe
# move done, the controller back (or the ladder's verdict logged)
self.wait_settled()
mode = self.wait_settled()
try:
self.client_back(mode)
except Exception as e: # noqa: BLE001 - the judge never breaks the exit path
self.log("takeover: WARNING the cloud client could not be judged: %s: %s"
% (type(e).__name__, e))
return False
+17
View File
@@ -15,7 +15,24 @@ from . import setup_sheet # noqa: F401,E402
from . import logs # noqa: F401,E402
from . import motion # noqa: F401,E402
from . import cooling # noqa: F401,E402
from . import cooling_intake # noqa: F401,E402
from . import laser # noqa: F401,E402
from . import camera # noqa: F401,E402
from . import update # noqa: F401,E402
from . import cloud # noqa: F401,E402
# Last: it stands on the setup record's consent and on the cloud suite's dark print.
from . import exthost # noqa: F401,E402
from . import 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)
+124 -15
View File
@@ -63,7 +63,10 @@ def _jpeg_size(data):
"far smaller), a second snapshot with the lid lamp switched off differs from it "
"and is smaller (the capture is live, not a frame the pipeline is re-serving, "
"and the lamp lights what the camera sees), and the MJPEG stream starts and "
"stops.")
"stops. A capture marked background=1 yields to a viewer: while the stream is "
"open it is refused in words, the operator's own unmarked capture is still "
"served, and once the viewer stops the background capture is served again. A "
"background value that is neither 0 nor 1 is refused.")
def snapshot(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
@@ -116,6 +119,48 @@ def snapshot(ctx):
ev["stream_ctype"] = ctype
ctx.log("stream: %s, first %d bytes", ctype, len(chunk))
ctx.check("multipart" in ctype and b"\xff\xd8" in chunk, "stream is not an MJPEG multipart")
# A capture nobody is waiting for yields to one somebody is: a
# snapshot borrows the mux and stutters a running stream, which is a
# price worth paying for an operator and not for a program. The
# stream above is closed by now, so one is opened and held here.
bad = fc.get("/cam/snapshot", params={"cam": "lid", "background": "maybe"})
ev["background_bad_value"] = bad
ctx.check(bad[0] == 400, "background=maybe -> %s", bad)
held = urllib.request.urlopen(
urllib.request.Request(fc.base + "/cam/stream", headers={"Host": fc.host_header()}), timeout=10)
try:
held.read(8192)
watching = None
for _ in range(40):
st_, body_ = fc.get("/cam/status")
if isinstance(body_, dict) and body_.get("clients"):
watching = body_
break
ctx.sleep(0.25)
ev["clients_while_watching"] = (watching or {}).get("clients")
ctx.check(watching, "no viewer was counted while the stream was held: %s", body_)
st_, why = fc.get("/cam/snapshot", params={"cam": "lid", "res": "half", "background": "1"})
ev["background_while_watched"] = [st_, why]
ctx.log("background capture while a viewer watches -> %s %s", st_, why)
ctx.check(st_ == 409, "a background capture did not yield to a viewer: %s %s", st_, why)
st_, data_ = fc.get("/cam/snapshot", params={"cam": "lid", "res": "half"}, raw=True)
ev["operator_while_watched"] = [st_, len(data_ or b"")]
ctx.check(st_ == 200 and data_ and data_[:2] == b"\xff\xd8",
"the operator's own capture was refused while watching: %s", st_)
finally:
held.close()
for _ in range(40):
st_, body_ = fc.get("/cam/status")
if isinstance(body_, dict) and not body_.get("clients"):
break
ctx.sleep(0.25)
st_, data_ = fc.get("/cam/snapshot", params={"cam": "lid", "res": "half", "background": "1"}, raw=True)
ev["background_after_watching"] = [st_, len(data_ or b"")]
ctx.check(st_ == 200 and data_ and data_[:2] == b"\xff\xd8",
"a background capture was still refused after the viewer stopped: %s", st_)
ctx.sleep(2)
st, body = fc.get("/cam/status")
ev["cam_status_after"] = body
@@ -234,10 +279,12 @@ def h264_stream(ctx):
covers=_CAM_COVERS, requires=["forgectrl.panel-serves"],
description="The capture queue flags a frame errored when it is short, torn, or arrived "
"after the CSI-2 receiver lost sync; forgectrl drops those rather than "
"demosaicing them, and restarts the stream if they persist. A healthy machine "
"captures a burst with none of them. A nonzero corrupt count here is a real "
"signal - a marginal camera ribbon, a mistimed D-PHY - even though the frames "
"themselves never reach a client.")
"demosaicing them, restarts the stream if they persist, and withholds the "
"stream frames on either side of one. A healthy machine captures a burst of "
"snapshots and a few seconds of stream with none of them flagged and none "
"withheld, and reports the stream's per-stage timing while a viewer watches. "
"A nonzero corrupt count here is a real signal - a marginal camera ribbon, a "
"mistimed D-PHY - even though the frames themselves never reach a client.")
def frame_health(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
@@ -274,18 +321,63 @@ def frame_health(ctx):
ctx.check(corrupt == 0, "%d of %d captured frames came back errored", corrupt, captured)
ctx.check(restarts == 0, "the capture stream was restarted %d time(s)", restarts)
# The stream path: five seconds of MJPEG, so the frame gate judges a
# few dozen frames and the timing covers two whole windows. A frame is
# withheld only next to one the receiver flagged, so a healthy machine
# withholds none, and the per-stage timing is reported while the
# viewer watches.
import time
import urllib.request
s0 = health()
stream = urllib.request.urlopen(
urllib.request.Request(fc.base + "/cam/stream", headers={"Host": fc.host_header()}),
timeout=10)
try:
got = 0
t_end = time.monotonic() + 5.0
while time.monotonic() < t_end:
got += len(stream.read(65536))
st, body = fc.get("/cam/status")
timing = body.get("timing") if isinstance(body, dict) else None
finally:
stream.close()
s1 = health()
ev["stream_bytes"] = got
ev["stream_timing"] = timing
ev["stream_health"] = [s0, s1]
ctx.log("stream: %d bytes in 5 s, timing %s", got, timing)
s_captured = (s1.get("captured") or 0) - (s0.get("captured") or 0)
s_corrupt = (s1.get("corrupt") or 0) - (s0.get("corrupt") or 0)
s_withheld = (s1.get("withheld") or 0) - (s0.get("withheld") or 0)
ev["stream_captured"] = s_captured
ev["stream_withheld"] = s_withheld
ctx.check(s_captured >= 30, "only %d frames were dequeued in 5 s of stream", s_captured)
ctx.check(s_corrupt == 0, "%d stream frames came back errored", s_corrupt)
ctx.check("withheld" in s1, "/cam/status health carries no withheld count: %s", s1)
ctx.check(s_withheld == 0, "%d stream frames were withheld with none flagged", s_withheld)
ctx.check(isinstance(timing, dict) and
all(k in timing for k in ("latency_ms", "convert_ms", "copy_ms", "encode_ms")),
"/cam/status carries no timing block while a viewer watches: %s", body)
latency = (timing or {}).get("latency_ms") or 0
ctx.check(0 < latency < 250, "capture to publication took %.1f ms while streaming", latency)
@test("camera.lid-privacy", title="Cameras capture only with the lid closed", subsystem="camera",
@test("camera.lid-privacy", title="Cameras see the room only with the lid closed", subsystem="camera",
kind="operator", est_min=3,
covers=_PRIVACY_COVERS, requires=["forgectrl.panel-serves"], actions=["lid"],
covers=_PRIVACY_COVERS + [("forgectrl", "src/auth.*")], requires=["forgectrl.panel-serves"],
actions=["lid"],
steps=["Start with the lid closed; you will be told to open it, then close it again.",
"Nothing moves and the laser is not involved."],
description="The privacy gate: with the lid open neither camera captures. A running stream "
"stops within a frame or so of the lid opening, /cam/status reports capture as "
"not allowed, and both the snapshot and the stream are refused with 409 and a "
"reason naming the lid. Closing the lid restores all of it. This is what stops "
"the machine - and in cloud mode the Glowforge service - from imaging the room "
"through an open lid.")
description="The privacy gate: with the lid open the lid camera does not capture, and the head "
"camera captures only for a local viewer. A running lid-camera stream stops within a "
"frame or so of the lid opening, /cam/status reports capture as not allowed, and the "
"lid camera's snapshot and stream are refused with 409 and a reason naming the lid, "
"to the panel too. The head camera, which looks down at the bed, still answers the "
"panel (its token) and the extension host (its client header from this host), while "
"the same snapshot and stream asked without either - the cloud client's form - are "
"refused for the lid. Closing the lid restores all of it. This is what stops the "
"machine - and in cloud mode the Glowforge service - from imaging the room through "
"an open lid.")
def lid_privacy(ctx):
import urllib.error
import urllib.request
@@ -366,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(data[:2] != b"\xff\xd8", "a JPEG was returned with the lid open")
# The head camera looks at the bed: a local viewer still has it, the cloud client's form does not.
st, data = fc.get("/cam/snapshot", params={"cam": "head", "res": "half"}, raw=True)
ctx.log("lid open: head snapshot -> %s", st)
ctx.check(st == 409, "the head camera should be refused too, got %s", st)
ctx.log("lid open: the panel's head snapshot -> %s", st)
ctx.check(st == 200 and data[:2] == b"\xff\xd8", "the panel's head snapshot with the lid open -> %s", st)
st, data = fc.get("/cam/snapshot", params={"cam": "head", "res": "half"}, raw=True, auth=False,
headers={"X-ForgeFIRM-Client": "extension-host"})
ctx.log("lid open: the extension host's head snapshot -> %s", st)
ctx.check(st == 200 and data[:2] == b"\xff\xd8", "the extension host's head snapshot with the lid open -> %s", st)
st, data = fc.get("/cam/snapshot", params={"cam": "head", "res": "half"}, raw=True, auth=False)
ev["head_cloud_form_lid_open"] = [st, (data or b"")[:120].decode("utf-8", "replace")]
ctx.log("lid open: head snapshot without a token or the header -> %s %s", st, (data or b"")[:120])
ctx.check(st == 409 and b"lid" in (data or b"").lower(),
"the head camera without a token or the header should be refused for the lid, got %s", st)
try:
hreq = urllib.request.Request(fc.base + "/cam/stream?cam=head", headers={"Host": fc.host_header()})
with urllib.request.urlopen(hreq, timeout=10) as r:
ctx.fail("the head stream opened with the lid open, for no local viewer (%s)", r.status)
except urllib.error.HTTPError as e:
ctx.log("lid open: head stream for no local viewer -> %s", e.code)
ctx.check(e.code == 409, "the head stream for no local viewer should be refused with 409, got %s", e.code)
try:
with urllib.request.urlopen(req, timeout=10) as r:
+71 -56
View File
@@ -95,7 +95,6 @@ SESSION_MARKS = ("authenticate_machine SUCCESS", "ws_connect ESTABLISHED")
# The service's connect-time hunt, as the client logs its request: a few
# seconds after the controller starts, right behind the session.
HUNT_REQUEST = "service action request: hunt"
RETURN_MAX_MM = 600.0 # the head comes back from the home corner across the bed
def log_size(path):
@@ -120,39 +119,6 @@ def session_established(lines):
return all(any(m in ln for ln in lines) for m in SESSION_MARKS)
def return_head(ctx, feed=2400):
"""Jog the head back to where the run found it: cloud mode re-zeroed the
kernel counters at the starting position, so the counters now read the
displacement (the home corner). Ends on the machine idle."""
fc = ctx.forgectrl
pos = fc.status().get("pos") or {}
x, y = float(pos.get("x", 0.0)), float(pos.get("y", 0.0))
ctx.log("head displacement since the switch: X %.3f Y %.3f mm", x, y)
ctx.check(abs(x) <= RETURN_MAX_MM and abs(y) <= RETURN_MAX_MM,
"displacement %.1f/%.1f mm exceeds %.0f mm - not jogging back", x, y, RETURN_MAX_MM)
if abs(x) < 0.05 and abs(y) < 0.05:
return
with ctx.grbl() as g:
st = g.status_report()["state"]
if st.startswith("Alarm"):
g.command("$X")
r = g.command("$J=G91X%.3fY%.3fF%d" % (-x, -y, feed))
ctx.check(not any(k.startswith("error") for k in r), "return jog refused: %s", r)
t0 = time.time()
while time.time() - t0 < 120:
ctx.checkpoint()
st = g.status_report()["state"]
if st.startswith("Idle") and time.time() - t0 > 0.5:
break
time.sleep(0.2)
g.command("G90")
ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle after the return jog")
pos = fc.status().get("pos") or {}
ctx.log("head returned: counters X %.3f Y %.3f mm", float(pos.get("x", 0)), float(pos.get("y", 0)))
ctx.check(abs(float(pos.get("x", 0))) < 0.1 and abs(float(pos.get("y", 0))) < 0.1,
"head not back at the start after the return jog: %s", pos)
def wait_mode(ctx, fc, want_mode, want_controller="running", timeout=90, poll=1.0):
t0 = time.time()
last = None
@@ -254,8 +220,8 @@ def gfhome_homing(ctx, ev, g):
"Open the lid the moment you are told, with a hand ready on it: the cloud client's "
"first hunt begins a few seconds after its controller starts and must find the lid "
"open. Leave it open through the connect and the hunt; close it when told. Nothing "
"else: the switch back and the $H homing run on their own, and the head ends parked "
"at the home corner."],
"else: the switch back and the $H homing run on their own, and the head ends where the "
"test found it."],
description="One round trip with the two service-driven motions on it. POST /mode switches "
"to the cloud controller with the lid closed (no controller starts with the "
"enclosure open): gfcloud comes up under supervision, authenticates and "
@@ -268,11 +234,18 @@ def gfhome_homing(ctx, ev, g):
"is measured by the airflow gates but not judged (no AIRFLOW, the exhaust row "
"reads unjudged); the camera service survives the switch. The lid closed, the "
"service's re-hunt is waited out; switching back brings grblHAL up with the Grbl "
"port open and Idle, and the head returns to its start. Then $H with "
"port open and Idle, and the head returns to its start by the travel the "
"client logged for the service's motions. Then $H with "
"homing_mode = gfcloud runs gfhome, the web-service homing session with the "
"head-accelerometer motion witness: the controller returns to Idle with "
"homed:true and gfhome reports the homing complete with the motion it saw.")
"homed:true, gfhome reports the homing complete with the motion it saw, and "
"every service motion of the session ran whole. The camera home is then "
"dropped and the head goes back to where the test found it, by the travel the "
"session's motions logged.")
def mode_switch(ctx):
# The hand-back helpers, imported here so that no other cloud test's
# fingerprint moves.
from .homeoff import camera_home_return, cloud_mode_return, judge_whole_motions, session_mark
fc = ctx.forgectrl
ev = ctx.evidence
st, m0 = fc.get("/mode")
@@ -383,10 +356,9 @@ def mode_switch(ctx):
st, cam2 = fc.get("/cam/status")
ev["cam_after"] = cam2
ctx.check(st == 200, "camera status lost after the switch back")
# cloud mode's connect cleared the kernel counters at the starting position
# and its hunt homed the head: bring it back
ctx.counters_rezeroed()
return_head(ctx)
# The service moved the head in cloud mode, and the controller's start
# zeroed the counters where it left it: the client's record brings it back.
cloud_mode_return(ctx, ev, log_offset)
# cloud mode sets its own lid-lamp level (LLvl) and leaves it: hand back the level found
lamp1 = hw.sysfs_read("pic/lid_led")
ev["lid_lamp"] = {"before": lamp0, "after_cloud": lamp1}
@@ -400,9 +372,11 @@ def mode_switch(ctx):
st, body = fc.post("/settings", data={"homing_mode": "gfcloud"})
ctx.log("homing_mode=gfcloud for the homing -> %s %s", st, body if isinstance(body, str) else "")
ctx.check(st == 200, "homing_mode=gfcloud -> %s %s", st, body)
session_at = session_mark()
try:
with ctx.grbl() as g:
gfhome_homing(ctx, ev, g)
judge_whole_motions(ctx, ev, session_at)
finally:
if ev["homing_mode"] != "gfcloud":
# Back to exactly what the machine had: unset is the empty
@@ -413,6 +387,7 @@ def mode_switch(ctx):
if not ev["homing_mode"]
else fc.post("/settings", data={"homing_mode": ev["homing_mode"]}))
ctx.log("restore homing_mode=%r -> %s", ev["homing_mode"], st)
camera_home_return(ctx, ev, session_at)
ctx.log("PASS: grbl -> cloud (session, hunt with the lid open, lens homed, airflow unjudged) -> "
"grbl (port open, %s), then $H homed in %.1f s", ev["grbl_state"], ev["homing_s"])
@@ -527,9 +502,18 @@ NOHUNT_MARK = "NO-HUNT:"
NOHUNT_MARKER = "/run/gfcloud-nohunt"
HUNT_DONE = "hunt ["
WS_MARKS = ("RX-EVENT: ready", "RX-EVENT: closed", "RECONNECTING", "CLOSING", OFFLINE_MARK)
ACTIVITY_MARKS = ("start motion", "start return home", "starting run", "starting z homing cycle")
# The service at work: every line of a service action, the requests, the image
# uploads and the ends included. Between a lid image and its next move the
# service is thinking and the log is silent, so the ends count: the quiet is
# measured from the last line of any action.
ACTIVITY_MARKS = ("service action request:", "img_upload COMPLETE", "finished with event",
"start motion", "end motion", "start return home", "return home complete",
"starting run", "finished run", "starting z homing cycle")
LOG_TAIL_BYTES = 4 << 20
QUIET_S = 8 # the re-hunt's motions are ~4 s apart (a lid image between them)
# The service's think time after an image, on the bench reference: over 1901
# motion, lid-image and hunt requests, half came within 1.6 s of the line before,
# 99 percent within 12.8 s, and two after more than 30 s (32.8 and 62.2 s).
QUIET_S = 30
QUIET_TIMEOUT_S = 180
HUNT_TIMEOUT_S = 180
@@ -586,6 +570,42 @@ def wait_log(ctx, offset, needles, timeout, poll=0.5):
return found
# What the client logs when it turns a print away before the button wait
# (machine._safe_to_move): the lid or the interlock, a machine that is not
# idle, the coolant above the start ceiling of its own configuration
# (THERMAL.max_start_temp), a coolant sensor that reads nothing.
REFUSED_BEFORE_THE_BUTTON = ("unsafe to move", "machine is not idle", "machine temp is too high",
"coolant sensor reads invalid")
def wait_button_wait(ctx, offset, timeout, also=()):
"""Wait for the client's button wait (and the `also` needles). A print
the client turns away never gets there: it logs why and finishes at
once, so the wait ends on that finish line too and the failure carries
the client's own reason, not two minutes of silence. Returns wait_log's
result."""
needles = list(also) + ["waiting for button"]
found = {n: None for n in needles}
t0 = time.time()
while time.time() - t0 < timeout:
ctx.checkpoint()
lines = log_lines_since(GFCLOUD_LOG, offset)
for ln in lines:
for n in needles:
if found[n] is None and n in ln:
found[n] = ln
if all(found.values()):
break
if found["waiting for button"] is None and action_finish_index(lines, "print") is not None:
why = [message(ln) for ln in lines if any(r in ln for r in REFUSED_BEFORE_THE_BUTTON)]
fin = lines[action_finish_index(lines, "print")]
ctx.fail("the client turned the print away before the button wait: %s (%s)",
"; ".join(why[-3:]) or "it logged no reason", message(fin))
time.sleep(0.5)
ctx.check(found["waiting for button"], "the print never reached the button wait")
return found
def log_has(offset, needle):
"""True when the gfcloud log carries needle since offset (one read;
the condition an `act` waits on)."""
@@ -718,9 +738,9 @@ def client_offline(pid):
def wait_quiet(ctx, offset, quiet_s=None, timeout=None):
"""The service's moves are over: the machine idle and no new service
activity in the log (a motion, a park, a run, a lens homing) for
quiet_s (default QUIET_S; timeout QUIET_TIMEOUT_S). False on timeout."""
"""The service's moves are over: the machine idle and no new line of any
service action in the log (ACTIVITY_MARKS) for quiet_s (default QUIET_S;
timeout QUIET_TIMEOUT_S). False on timeout."""
quiet_s = QUIET_S if quiet_s is None else quiet_s
timeout = QUIET_TIMEOUT_S if timeout is None else timeout
fc = ctx.forgectrl
@@ -1043,9 +1063,7 @@ def offline_start_print(ctx, off, action_id, path, offset, settings=None):
client loads the job, lights the button and waits; the operator's
press arms it and the run starts. Returns the 'starting run' line."""
off.print_ready(action_id, path, settings)
got = wait_log(ctx, offset, ["waiting for button"], 120)
ctx.check(got["waiting for button"], "the offline print never reached the button wait (refused, "
"or the job did not load)")
wait_button_wait(ctx, offset, 120)
ctx.act("button", "press", text="The button is lit white: the press arms the print and the "
"head starts to move. Nothing fires: the job commands no laser.",
until=lambda: wait_print_running(ctx, offset, 0.1) is not None, timeout=180)
@@ -1272,10 +1290,9 @@ def lid_during_button_wait(ctx):
def lid_during_button_wait_body(ctx, ev, off, job, offset):
off.print_ready(9003, job)
got = wait_log(ctx, offset, ["job is longer than the ring", "waiting for button"], 180)
got = wait_button_wait(ctx, offset, 180, also=["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")
ctx.check(got["waiting for button"], "the print never reached the button wait")
ctx.act("lid", "open", text="The button is lit: do NOT press it.", timeout=120)
relock = "button wait lid opened - relocking the laser"
got = wait_log(ctx, offset, [relock], 60)
@@ -1365,8 +1382,7 @@ def dark_print(ctx):
def dark_print_body(ctx, ev, off, job, offset, fc):
off.print_ready(9005, job)
got = wait_log(ctx, offset, ["waiting for button"], 120)
ctx.check(got["waiting for button"], "the print never reached the button wait")
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.",
@@ -1434,8 +1450,7 @@ def verdict_refuse_body(ctx, ev, off, job, offset, fc, up, orig):
gate, HOLD_REFUSE_S, st, body)
ev["gate"] = gate
off.print_ready(9006, job)
got = wait_log(ctx, offset, ["waiting for button"], 120)
ctx.check(got["waiting for button"], "the print never reached the button wait")
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. Nothing runs; the print cancels "
+100 -8
View File
@@ -375,6 +375,7 @@ def _tail_has(fc, needle):
"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.")
def gate_off(ctx):
from .motion import FORGECTRL_LOG, _log_lines, _log_offset
fc = ctx.forgectrl
ev = ctx.evidence
before = fc.settings()
@@ -428,6 +429,7 @@ def gate_off(ctx):
# 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.
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"]})
state, val = _gate_state(fc, "cool_temp_max")
ev["off_state"] = state
@@ -439,13 +441,17 @@ def gate_off(ctx):
c.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(_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")
# Restore, and prove the restore: the next run start reloads.
_set_gates(ctx, fc, orig)
restored = True
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"),
"restored")
ev["restored"] = c
@@ -454,7 +460,10 @@ def gate_off(ctx):
ctx.log("restored ceiling %s reports state %s", val, state)
# Every run session ends with the board temperatures ranged
# 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 "
"forgectrl log")
finally:
@@ -519,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 "
"before.")
def floor_and_warm_up(ctx):
from .motion import FORGECTRL_LOG, _log_lines, _log_offset
fc = ctx.forgectrl
ev = ctx.evidence
before = fc.settings()
@@ -544,6 +554,7 @@ def floor_and_warm_up(ctx):
# 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.
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"]})
c = _hold_session(ctx, grbl, fc, lambda c: c.get("verdict") == "WARMUP", "warm-up hold", VERDICT_WAIT_S)
ev["warmup_hold"] = c
@@ -582,7 +593,11 @@ def floor_and_warm_up(ctx):
ev["run_duty"] = duty
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(_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")
_session_ended(ctx, fc, "warm-up")
@@ -600,6 +615,7 @@ def floor_and_warm_up(ctx):
c.get("gates_off"))
# 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"})
c = _run_session(ctx, grbl, fc, lambda c: c.get("verdict") == "OK" and len(c.get("gates_off") or []) == 2,
"both off")
@@ -607,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(sorted(c.get("gates_off") or []) == ["coolant_min", "warm_up"],
"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")
# Restore, and prove the restore at the next run start.
@@ -656,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 "
"what the test reads; the drive rule is the engine's, never the cloud's.")
def tec_drive(ctx):
from .motion import FORGECTRL_LOG, _log_lines, _log_offset
fc = ctx.forgectrl
ev = ctx.evidence
before = fc.settings()
@@ -681,6 +700,7 @@ def tec_drive(ctx):
_set_gates(ctx, fc, {"cool_tec_present": "1", "cool_tec_on_c": str(on_c),
"cool_tec_off_c": str(off_c)})
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")
t0 = time.time()
tec = 0
@@ -692,7 +712,11 @@ def tec_drive(ctx):
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.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")
_session_ended(ctx, fc, "tec leg 1")
t0 = time.time()
@@ -1002,6 +1026,7 @@ def crash_watch_plumbing(ctx):
"value (gates_off names it) and the ceiling alone pauses; restored, the next "
"session runs OK with nothing off.")
def critical_tier(ctx):
from .motion import FORGECTRL_LOG, _log_lines, _log_offset
fc = ctx.forgectrl
ev = ctx.evidence
before = fc.settings()
@@ -1078,6 +1103,7 @@ def critical_tier(ctx):
# Leg 2: the critical line at its top is the gate off; the
# 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)})
c = _run_session(ctx, grbl, fc,
lambda c: c.get("verdict") == "OVERTEMP" and "coolant_critical" in (c.get("gates_off") or []),
@@ -1087,7 +1113,10 @@ def critical_tier(ctx):
"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 []),
"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")
# Restore, and prove it: OK, nothing off.
@@ -1386,6 +1415,7 @@ def flow_under_load(ctx):
"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}
@@ -1399,6 +1429,7 @@ def fan_duty_readback(ctx):
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")}
@@ -1416,9 +1447,13 @@ def fan_duty_readback(ctx):
ctx.check(back.get("head/air_assist_pwm") == 1023, "the air-assist duty was not put back: %s", back)
ctx.check(back.get("thermal/exhaust_pwm") == 65535, "the exhaust duty was not put back: %s", back)
ctx.sleep(2)
ctx.check(_tail_has(fc, "head/air_assist_pwm read 204 with 1023 commanded: put back"),
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")
ctx.check(_tail_has(fc, "thermal/exhaust_pwm read 0 with 65535 commanded: put back"),
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)
@@ -1532,3 +1567,60 @@ def fail_tier_stop(ctx):
"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)
+498 -10
View File
@@ -8,6 +8,7 @@ import json
import os
import socket
import time
import urllib.parse
from ..catalog import test
from .. import hw
@@ -142,14 +143,39 @@ def auth(ctx):
"GET /fuse-identity with the token but no button -> %s %r (expected the two-factor refusal)",
st, body)
# the cooling report channel: the loopback peer is accepted. An idle
# report is what the controller sends every period; the engine is idle
# here, so it changes nothing. A dual-stack listener reports this peer
# as ::ffff:127.0.0.1, which the check must recognize in full.
st, body = fc.post("/cool/state", params={"mode": "idle", "armed": "0"})
# the cooling report channel is the running controller's alone. A
# loopback peer is not enough: anything on this host is one. The
# supervisor hands the controller a secret at its spawn, and the route
# asks for it. An idle report is what the controller sends every
# period; the engine is idle here, so the accepted one changes nothing.
# A dual-stack listener reports this peer as ::ffff:127.0.0.1, which
# the loopback check must recognize in full.
report = {"mode": "idle", "armed": "0"}
st, body = fc.post("/cool/state", params=report)
ev["cool_state_from_loopback_no_secret"] = st
ctx.log("POST /cool/state from loopback, no secret -> %s %s", st, body if isinstance(body, str) else "")
ctx.check(st == 403 and "controller's alone" in str(body),
"/cool/state from loopback without the secret -> %s %r", st, body)
st, body = fc.post("/cool/state", params=report, headers={"X-ForgeFIRM-Report": "0" * 32})
ctx.check(st == 403, "/cool/state with a secret that is not the controller's -> %s %r", st, body)
# The secret itself, as only root on this host can read it: out of
# the running controller's environment. It is never logged.
secret = None
pid = (fc.get("/mode")[1] or {}).get("pid")
if pid:
try:
with open("/proc/%d/environ" % int(pid), "rb") as f:
for item in f.read().split(b"\0"):
if item.startswith(b"GF_REPORT_SECRET="):
secret = item.split(b"=", 1)[1].decode("ascii", "replace")
except OSError:
pass
ev["controller_has_a_secret"] = bool(secret)
ctx.check(secret and len(secret) == 32, "the running controller (pid %s) was handed no report secret", pid)
st, body = fc.post("/cool/state", params=report, headers={"X-ForgeFIRM-Report": secret})
ev["cool_state_from_loopback"] = st
ctx.log("POST /cool/state from loopback -> %s %s", st, body if isinstance(body, dict) else "")
ctx.check(st == 200, "/cool/state refused the loopback peer (%s %r): the controller's "
ctx.log("POST /cool/state from loopback with the controller's secret -> %s", st)
ctx.check(st == 200, "/cool/state refused the controller's own secret (%s %r): the controller's "
"reports never reach the engine", st, body)
# ...and a non-loopback peer is refused, even with a token. Over HTTP
@@ -161,8 +187,7 @@ def auth(ctx):
ip = lan_ip()
ev["lan_ip"] = ip
ctx.check(ip, "cannot determine the board's LAN address")
token = {"X-ForgeFIRM-Token": fc.token}
report = {"mode": "idle", "armed": "0"}
token = {"X-ForgeFIRM-Token": fc.token, "X-ForgeFIRM-Report": secret}
st, body, hdrs = request("http://%s" % ip, "POST", "/cool/state", data=report, headers=token)
loc = hdrs.get("location", "")
ev["cool_state_from_lan_http"] = {"status": st, "location": loc}
@@ -297,7 +322,12 @@ def settings_bounds(ctx):
("forgectrl", "src/cam.c"), ("forgectrl", "src/main.c"), ("forgectrl", "src/super.c"),
("grblhal-glowforge", "src/glowforge_status.c"), ("grblhal-glowforge", "src/serial.c"),
("forgectrl", "src/curverec.*")],
description="The panel page is served, /status carries the machine telemetry the panel and "
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 "
"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 "
@@ -325,6 +355,12 @@ def panel_serves(ctx):
# the head script sets and the one save bar every settings tab shares.
ctx.check("data-bs-theme" in text, "the panel lacks the theme attribute (inflate failed?)")
ctx.check('id="savebar"' in text, "the panel lacks the save bar")
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()
for key in ("state", "switches", "coolant", "fans"):
@@ -417,3 +453,455 @@ def panel_serves(ctx):
st, text = fc.get("/curve/ladder.gcode", raw=True)
ctx.check(st == 200 and b"S1000" in (text or b"") and b"M5" in (text or b""),
"GET /curve/ladder.gcode -> %s without the ladder", st)
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)
+35 -2
View File
@@ -111,7 +111,7 @@ def fds_of(pid):
@test("image.health", title="Post-flash image health", subsystem="image", kind="auto",
always=True, est_min=1,
covers=[("forgectrl", "init/**"), ("forgectrl", "src/main.c"), ("forgectrl", "src/auth.c"),
covers=[("forgectrl", "init/**"), ("forgeext", "init/**"), ("forgectrl", "src/main.c"), ("forgectrl", "src/auth.c"),
("forgectrl", "CMakeLists.txt"), ("grblhal-glowforge", "src/boards/**"),
("grblhal-glowforge", "CMakeLists.txt"), ("kernel-module-glowforge", "**"),
("linux-fslc", "**")],
@@ -120,7 +120,9 @@ def fds_of(pid):
"console banner and the motd, "
"the kernel options, the module, the pulse ring it maps and the SDMA clocks it holds, "
"the daemon ownership, "
"the 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 "
"into tmpfs, the sshd host keys on /data, the factory slots on the dev image only.")
def image_health(ctx):
@@ -267,6 +269,29 @@ def image_health(ctx):
ctx.check(kg and kf, "rc6.d lacks the grblhal/forgectrl kill links")
ctx.check(kg[0] < kf[0], "controller kill link %s must sort before forgectrl's %s", kg[0], kf[0])
# 5b. the extension host: one process, up after forgectrl and down
# before it, and a campaign's machine is extension-free
host = []
for pid in hw.pidof("forgeext"):
argv = _read("/proc/%d/cmdline" % pid, "").split("\0")
if argv[:2] == [hw.FORGEEXT, "run"]:
host.append(pid)
ev["extension_host_pids"] = host
ctx.check(len(host) == 1, "the extension host is not one running process: %s", host)
s5 = sorted(os.path.basename(x) for x in glob.glob("/etc/rc5.d/S*"))
sx = [x for x in s5 if x.endswith("forgeext")]
sf = [x for x in s5 if x.endswith("forgectrl")]
kx = [x for x in k if x.endswith("forgeext")]
ev["forgeext_links"] = {"start": sx, "kill": kx}
ctx.check(sx and sf and sf[0] < sx[0], "the extension host must start after forgectrl: %s, %s", sf, sx)
ctx.check(kx and kx[0] < kf[0], "the extension host must stop before forgectrl: %s, %s", kx, kf)
ev["extension_packages"] = hw.ext_packages()
ev["pool_processes"] = len(hw.pool_pids())
ctx.log("extension host pid %s, packages %s, pool processes %d", host, ev["extension_packages"], ev["pool_processes"])
ctx.check(not ev["extension_packages"], "a campaign wants an extension-free machine: installed %s",
ev["extension_packages"])
ctx.check(not ev["pool_processes"], "%d process(es) run under extension pool accounts", ev["pool_processes"])
# 6. logging lever present, no userspace watchdog daemon
logging = [n for n in ("forgefirm-logging", "forgefirm-logrotate") if os.path.exists("/etc/init.d/" + n)]
ev["logging_init"] = logging
@@ -284,6 +309,14 @@ def image_health(ctx):
ev[path] = "%o" % m
ctx.log("%s mode %o", path, m)
ctx.check(m == 0o600, "%s mode %o, expected 600", path, m)
# an operator account walks through the data directory to its home, and a
# package's account to its files: forgefirm-users opens it for search at
# every render, whoever made it and under whatever umask
if os.path.exists("/data/forgefirm/users"):
m = stat.S_IMODE(os.stat("/data/forgefirm").st_mode)
ev["/data/forgefirm"] = "%o" % m
ctx.log("/data/forgefirm mode %o", m)
ctx.check(m & 0o011 == 0o011, "/data/forgefirm mode %o: no account can walk through it to its home", m)
if os.path.isdir("/data"):
s = os.statvfs("/data")
free_mb = s.f_bavail * s.f_frsize // (1024 * 1024)
+311 -19
View File
@@ -30,7 +30,8 @@ from ..catalog import test
from .. import hw
from ..runner import Failed
from .motion import (kernel_xy_mm, kernel_start, check_kernel_returned, wait_state,
wait_state_text, wait_left_state, wait_idle, drain_text, Watch)
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", "**"),
("forgectrl", "src/super.c"), ("forgectrl", "src/cool.c"),
@@ -670,8 +671,11 @@ def disarm_in_hold(ctx):
stream(g, ["G91", "G21", "M4", "S400", "G1 X40 F300"])
ctx.log("armed; waiting for motion to start (arm + your button press)...")
w = Watch(g)
ctx.act("button", "press", text="The button is lit white: the press arms the job and the "
"move starts.", until=w.in_state("Run"), timeout=180, fail=False)
# The press waits for the button to light: pressed before the job
# 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
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)
@@ -849,11 +853,15 @@ def armed_kill(ctx):
"Press the physical button when it lights white (the arm). Nothing else: the test "
"takes the cooling verdict away itself and gives it back."],
description="A 40 mm line at constant power (M3 S400/F300, 8 s). About 1.5 s in, the test "
"pauses the machine-services daemon for 3.5 s, so the cooling verdict the "
"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, so the kernel's LASER_ON sample count reads 0 before the cut "
"resumes, and it must not write the latch: a lock sets the hardware button "
"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 "
@@ -876,6 +884,61 @@ def verdict_cut(ctx):
# 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
@@ -908,7 +971,10 @@ def verdict_cut(ctx):
smp = arm_and_fire(ctx, g, room="40 mm +X", job=job)
beams = [(smp.get("beam"), smp.get("beam_d"))]
ctx.log("emission live (%s); pausing the daemon in 1 s", smp["emission"])
ctx.sleep(1.0)
# 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:
@@ -924,7 +990,10 @@ def verdict_cut(ctx):
trail.append({"t": round(time.time() - t0, 2), "gstate": st, "il": il,
"emission": hw.sysfs_int("cnc/laser_on_sampled"),
"armed": '"armed":true' in grbl_state_file()})
time.sleep(0.12)
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)
@@ -950,7 +1019,10 @@ def verdict_cut(ctx):
resumed_at = time.time() - t0
if st.startswith("Idle") and resumed_at is not None and time.time() - t0 > resumed_at + 3.0:
break
time.sleep(0.12)
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
@@ -985,13 +1057,22 @@ def verdict_cut(ctx):
"the armed window closed during the pause")
ctx.check("press the button" not in text, "the resume asked for a button press")
ctx.check(DISARMED_MSG not in text.split("resuming")[0], "the pause tier disarmed the job")
# Dark from the hold until the resume: the gate masked the stream.
span = [r for r in trail if held and r["t"] >= held[0]["t"]
and (resumed_at is None or r["t"] < resumed_at)]
zero = next((r for r in span if r["emission"] == 0), None)
ev["emission_zero_after_hold_s"] = round(zero["t"] - held[0]["t"], 2) if zero else None
ctx.check(zero is not None and zero["t"] - held[0]["t"] <= 2.5,
"emission did not read 0 within 2.5 s of the hold (before the resume)")
# 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]
@@ -1001,9 +1082,9 @@ def verdict_cut(ctx):
beam_witness(ctx, ev, [{"beam": b, "beam_d": d} for b, d in beams], base)
judge_beam(ctx, ev["beam"], "the cut")
check_button_dark(ctx, ev)
ctx.log("PASS: held at +%s s with the latch untouched, emission 0 %s s after the hold, resumed "
"lit at +%s s with no press, disarmed %.1f s after Idle", ev["held_at_s"],
ev["emission_zero_after_hold_s"], ev["resumed_at_s"], dt)
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"
@@ -1244,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; "
"the lid then canceled it - emission 0 at +%s s, reset without alarm, returned (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")
+26 -4
View File
@@ -25,7 +25,7 @@ def _read(path, default=None):
_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
@@ -44,7 +44,10 @@ _PROBE_IP = "192.0.2.77"
"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.")
"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):
install_dir = os.path.join(LOGS_ROOT, "install")
probe = os.path.join(install_dir, "forgetest-probe.txt")
@@ -134,6 +137,25 @@ def _tree_tail_export(ctx, probe):
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
# relay) -> /dev/log -> rsyslog rules rendered from the settings -> the
@@ -147,7 +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/gfhome.py")]
LOGGERS =("forgectrl", "grblhal", "gfcloud", "gfhome", "kernel", "system")
LOGGERS =("forgectrl", "grblhal", "gfcloud", "gfhome", "forgeext", "kernel", "system")
_LINE_RE = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?[+-]\d\d:\d\d (?P<prog>[A-Za-z0-9_.-]+)\[(?P<pid>[-\d]+)\] "
r"(?P<sev>EMERG|ALERT|CRIT|ERR|WARNING|NOTICE|INFO|DEBUG) (?P<msg>.*)$")
_SEV_RANK = {"off": -1, "error": 3, "warning": 4, "notice": 5, "info": 6, "debug": 7}
@@ -262,7 +284,7 @@ def routing(ctx):
# a probe under another name must NOT leak into these files
rc, out = hw.run(["logger", "-t", "forgetest-stray", "-p", "daemon.err", "%s stray" % nonce])
time.sleep(1.5)
for name in ("grblhal", "gfhome", "forgectrl"):
for name in ("grblhal", "gfhome", "forgectrl", "forgeext"):
path = os.path.join(LOGS_ROOT, name, name + ".log")
ctx.check(not any((nonce + " stray") in l for l in _tail_lines(path, 100)),
"a stray program's line landed in %s", path)
+805 -56
View File
@@ -10,7 +10,9 @@ Ported from `scripts/bench/pacing_test.py` (protocol-loop pacing) and
and round-trip; the laser stays latched (the tests never touch it); the
suite is the only Grbl client while a test runs.
"""
import json
import os
import struct
import time
from ..catalog import test
@@ -1200,72 +1202,132 @@ def respawn_gate(ctx):
@test("motion.soft-limits", title="After a home the bed is the X/Y envelope",
subsystem="motion", kind="auto", mode="grbl", est_min=3,
subsystem="motion", kind="auto", mode="grbl", est_min=5,
covers=_MOTION_COVERS, requires=["motion.jog-roundtrip", "cloud.mode-switch"],
steps=["Bed clear, lid closed. The test homes the machine through the web service if it is "
"not homed (about a minute), then sends moves the controller must refuse."],
steps=["Bed clear, lid closed; cloud credentials configured; the machine on the network. The test "
"homes the machine through the web service (about a minute), sends moves the controller "
"must refuse, and hands the head back where it found it."],
description="The machine has no limit switches, so the core's $20 cannot be turned on and "
"the X/Y soft limits are the driver's: off while the position is not trusted, on "
"after a home, when the envelope is the bed ($130 by $131 from the home corner). "
"Homed, a program move 5 mm past X max or Y max, or past the near edge, raises "
"ALARM:2 before any motion (the kernel counters do not move), a jog past the bed "
"is refused with error 15, and a move inside the bed runs. The Z envelope is the "
"lens window, as before.")
"after a home, when the envelope is the bed from the home corner: the far edges the "
"bed check measured (envelope_x_mm, envelope_y_mm, held to 50 mm up to the travel "
"plus 30 mm), or $130 by $131 on a machine that was never measured. "
"With homing_mode = gfcloud and the camera-home offsets unset for the run, $H runs "
"the web-service homing session, every motion of it run whole. Homed, a program "
"move 5 mm past the far edge in X or in Y, or past the near edge, raises ALARM:2 "
"before any motion (the kernel counters do not move), a jog past the far edge is "
"refused with error 15, and a move inside the bed runs. A move or jog that is not "
"refused is stopped before the test fails. The Z envelope is the lens window, as "
"before. The settings 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.")
def soft_limits(ctx):
# The camera-home helpers, imported here: homeoff imports this module.
from .cloud import gfhome_homing
from .homeoff import camera_home_return, judge_whole_motions, session_mark
fc = ctx.forgectrl
ev = ctx.evidence
with ctx.grbl() as g:
clean_slate(ctx, g)
if not fc.status().get("homed"):
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
def put(k, v):
# The controller refuses a setting for a moment after a Grbl client
# closes: each is written when it is taken. An empty value unsets it.
def write():
st_, body_ = fc.post("/settings", params={k: ""}) if v == "" else fc.post("/settings", data={k: v})
if st_ != 200:
ctx.log("%s=%r -> %s %s", k, v, st_, body_)
return st_ == 200 or None
return ctx.wait_for(write, 20, poll=0.5) is not None
try:
# A camera home at the origin: the envelope is the bed from the home corner.
for k, v in (("homing_mode", "gfcloud"), ("gfcloud_home_x", ""), ("gfcloud_home_y", "")):
ctx.check(put(k, v), "%s=%r was not taken", k, v)
with ctx.grbl() as g:
clean_slate(ctx, g)
session_at = session_mark()
gfhome_homing(ctx, ev, g)
ctx.check(fc.status().get("homed"), "the machine is not homed")
x_travel = float(grbl_setting(g, "$130"))
y_travel = float(grbl_setting(g, "$131"))
ev["travel"] = {"x": x_travel, "y": y_travel}
k0 = kernel_xy_mm(ctx)
judge_whole_motions(ctx, ev, session_at)
ctx.check(fc.status().get("homed"), "the machine is not homed")
x_travel = float(grbl_setting(g, "$130"))
y_travel = float(grbl_setting(g, "$131"))
ev["travel"] = {"x": x_travel, "y": y_travel}
def refused(cmd):
lines = g.command(cmd, timeout=2)
ctx.sleep(0.5)
text = "\n".join(lines) + g.drain()
k1 = kernel_xy_mm(ctx)
moved = max(abs(k1[0] - k0[0]), abs(k1[1] - k0[1]))
st = g.status_report()["state"]
rec = {"cmd": cmd, "reply": lines, "alarm": "ALARM:2" in text, "moved_mm": round(moved, 3), "state": st}
ctx.log("%s", rec)
ctx.check(rec["alarm"], "%s was not refused with ALARM:2: %s", cmd, lines)
ctx.check(moved < 0.05, "%s moved the kernel %.3f mm before the alarm", cmd, moved)
g.realtime(0x18)
ctx.sleep(1.5)
g.drain()
unlock = g.command("$X")
ctx.check(unlock and unlock[-1] == "ok", "$X after the soft-limit alarm: %s", unlock)
st = g.status_report()["state"]
ctx.check(st.startswith("Idle"), "controller is %s after the recovery", st)
return rec
# The far edges the home armed, as the driver takes them
# (gfhome_clamp_envelope_mm): the measured key held to 50 mm up
# to the travel plus 30 mm, or the travel when it is unset.
def far_edge(key, travel):
try:
mm = float(s0.get(key) or 0)
except ValueError:
mm = 0.0
if not mm > 0:
return travel
return min(max(mm, 50.0), travel + 30.0)
ev["refused"] = [refused("G90 G1 X%.1f F600" % (x_travel + 5)),
refused("G90 G1 Y%.1f F600" % (y_travel + 5)),
refused("G90 G1 X-1 F600")]
# Homed still: the soft-limit alarm and its reset keep the reference.
ctx.check(fc.status().get("homed"), "the soft-limit alarm and the reset un-homed the machine")
jog = g.command("$J=G91X%.1fF1200" % (x_travel + 5))
ev["jog"] = jog
ctx.check(any(l.startswith("error:15") for l in jog), "a jog past the bed was not refused with error 15: %s", jog)
# The core answers the line after an error with that error again
# until an empty line clears it (the sender's acknowledgment).
g.command("")
g.command("G90 G1 X10 Y10 F600", timeout=0.5)
peak, states, st = wait_idle(ctx, g, 20)
ev["inside"] = states
ctx.check("TIMEOUT" not in states, "a move inside the bed did not run")
g.command("G90 G1 X0 Y0 F600", timeout=0.5)
wait_idle(ctx, g, 20)
k1 = kernel_start(ctx) # at rest: grbl's Idle leads the kernel's tail
ev["kernel_back_mm"] = [round(k1[0] - k0[0], 3), round(k1[1] - k0[1], 3)]
ctx.check(max(abs(k1[0] - k0[0]), abs(k1[1] - k0[1])) < 0.1, "the head did not come back to the corner: %s",
ev["kernel_back_mm"])
x_edge = far_edge("envelope_x_mm", x_travel)
y_edge = far_edge("envelope_y_mm", y_travel)
ev["envelope"] = {"x": x_edge, "y": y_edge}
k0 = kernel_xy_mm(ctx)
def refused(cmd):
lines = g.command(cmd, timeout=2)
ctx.sleep(0.5)
text = "\n".join(lines) + g.drain()
k1 = kernel_xy_mm(ctx)
moved = max(abs(k1[0] - k0[0]), abs(k1[1] - k0[1]))
st = g.status_report()["state"]
rec = {"cmd": cmd, "reply": lines, "alarm": "ALARM:2" in text, "moved_mm": round(moved, 3),
"state": st}
ctx.log("%s", rec)
# Stopped first: a move that was not refused must not run on
# to its target while the test fails.
g.realtime(0x18)
ctx.sleep(1.5)
g.drain()
ctx.check(rec["alarm"], "%s was not refused with ALARM:2: %s", cmd, lines)
ctx.check(moved < 0.05, "%s moved the kernel %.3f mm before the alarm", cmd, moved)
unlock = g.command("$X")
ctx.check(unlock and unlock[-1] == "ok", "$X after the soft-limit alarm: %s", unlock)
st = g.status_report()["state"]
ctx.check(st.startswith("Idle"), "controller is %s after the recovery", st)
return rec
ev["refused"] = [refused("G90 G1 X%.1f F600" % (x_edge + 5)),
refused("G90 G1 Y%.1f F600" % (y_edge + 5)),
refused("G90 G1 X-1 F600")]
# Homed still: the soft-limit alarm and its reset keep the reference.
ctx.check(fc.status().get("homed"), "the soft-limit alarm and the reset un-homed the machine")
jog = g.command("$J=G91X%.1fF1200" % (x_edge + 5))
ev["jog"] = jog
if not any(l.startswith("error:15") for l in jog):
g.realtime(0x85) # the jog cancel, before the test fails
ctx.sleep(1.5)
g.drain()
ctx.check(any(l.startswith("error:15") for l in jog), "a jog past the bed was not refused with error 15: %s",
jog)
# The core answers the line after an error with that error again
# until an empty line clears it (the sender's acknowledgment).
g.command("")
g.command("G90 G1 X10 Y10 F600", timeout=0.5)
peak, states, st = wait_idle(ctx, g, 20)
ev["inside"] = states
ctx.check("TIMEOUT" not in states, "a move inside the bed did not run")
g.command("G90 G1 X0 Y0 F600", timeout=0.5)
wait_idle(ctx, g, 20)
k1 = kernel_start(ctx) # at rest: grbl's Idle leads the kernel's tail
ev["kernel_back_mm"] = [round(k1[0] - k0[0], 3), round(k1[1] - k0[1], 3)]
ctx.check(max(abs(k1[0] - k0[0]), abs(k1[1] - k0[1])) < 0.1, "the head did not come back to the corner: %s",
ev["kernel_back_mm"])
finally:
for k, v in found.items():
ctx.log("restore %s=%r -> %s", k, v, "taken" if put(k, v) else "not taken")
if session_at is not None:
camera_home_return(ctx, ev, session_at)
ctx.log("PASS: X max, Y max and X min refused with ALARM:2 and no motion, the jog refused with "
"error 15, a move inside the bed ran")
@@ -1778,3 +1840,690 @@ def step_timing_under_load(ctx):
"; ".join(clamped))
ctx.log("PASS: %d legs at 2000 mm/min against a nice-5 CPU hog, no clamped events",
ev["legs"])
# ------------------------------------------------- manual home, motor release
HOLD_CURRENTS = ("33", "5") # pic/x_step_current, pic/y_step_current at idle
MANUAL_NOTICE = "Manual home: position set where the head was placed"
def _step_currents():
return (hw.sysfs_read("pic/x_step_current"), hw.sysfs_read("pic/y_step_current"))
def _rail_lines():
"""How many times the kernel has logged the 40 V supply going on or off."""
rc, out = hw.run(["dmesg"])
if rc != 0:
return -1
return sum(1 for line in out.splitlines() if "40V on" in line or "40V off" in line)
def _kernel_position():
"""The kernel's position record: (x, y, z) in steps, then the bytes it
has played and the bytes it was handed. Nothing moves without the last
two moving, which makes them the witness for "nothing was shipped": the
head accelerometer cannot say it, since the rotors relax off their held
microstep when the drivers let go, when the currents drop to hold, and at
a reset, and the head feels each of those."""
with open(hw.sysfs_root() + "cnc/position", "rb") as f:
raw = f.read(32)
return struct.unpack_from("<3i", raw, 0) + struct.unpack_from("<2I", raw, 12)
def _refused(ctx, g, line):
"""Send a line that must be refused; acknowledge the parser error it
leaves (clear_error has the why) and return the error."""
r = g.command(line)
err = next((x for x in r if x.startswith("error")), None)
if err is not None:
g.command("")
return err
def _drop_reference(ctx, fc):
"""A false home is not left behind: a fresh controller starts unhomed."""
fc.post("/controller/stop")
_wait_controller(ctx, fc, ("stopped", "standby"), 30)
fc.post("/controller/start")
m = _wait_controller(ctx, fc, ("running",), 120, motion="verified")
ctx.check(m.get("controller") == "running", "the controller did not come back: %s", m)
@test("motion.release", title="Motor release: nothing moves a released gantry, only the operator energizes it",
subsystem="motion", kind="auto", hardware="takeover", mode="grbl", est_min=8,
covers=_MOTION_COVERS + [("forgectrl", "src/status.*"), ("forgectrl", "src/wizdark.*")],
requires=["motion.jog-roundtrip"],
steps=["Bed clear; the head needs 50 mm of free travel each way on X and Y. Nobody touches the gantry."],
description="$MD releases X and Y by taking their step currents to 0, with the 40 V rail "
"untouched (no supply line in the kernel log, cnc/state idle, no fault), drops the "
"X and Y reference and locks the machine in alarm. While released a jog, a G0 and "
"$X are refused, a soft reset does not unlock it, and forgectrl refuses a switch "
"to cloud mode; the kernel's position record "
"is the witness that nothing was shipped (no step and no byte played), and the head "
"accelerometer stays under the moving threshold in the window after the release. "
"$ME energizes with no fault. The drivers are then proven alive the way the machine "
"proves it to itself: the Setup motion check runs the liveness probe on the held "
"device and the witnessed 50 mm jogs.")
def motor_release(ctx):
from .setup_dark import run_check
ev = ctx.evidence
fc = hw.Forgectrl()
accel = hw.AccelSampler()
ctx.check(accel.available, "no head accelerometer found (i2c %s): the motion witness is missing",
hw.HEAD_ACCEL_I2C)
rail0 = _rail_lines()
ctx.check(rail0 >= 0, "the kernel log is unreadable: the rail witness is missing")
with ctx.grbl() as g, accel:
clean_slate(ctx, g)
r = g.command("$MD")
ctx.check(not any(x.startswith("error") for x in r), "$MD refused at idle: %s", r)
text = "\n".join(r) + drain_text(g, 1.0)
ev["released_currents"] = list(_step_currents())
ev["state_released"] = g.status_report()["state"]
ev["kernel_state_released"] = hw.sysfs_read("cnc/state")
ev["faults_released"] = hw.sysfs_int("cnc/faults")
ev["homed_axes_released"] = fc.status().get("homed_axes")
ctx.log("$MD: step currents %s, %s, cnc/state %s, faults %s, homed_axes %s",
ev["released_currents"], ev["state_released"], ev["kernel_state_released"],
ev["faults_released"], ev["homed_axes_released"])
ctx.check(ev["released_currents"] == ["0", "0"],
"the step currents read %s after $MD, not 0 and 0", ev["released_currents"])
ctx.check(ev["state_released"].startswith("Alarm"), "not locked in alarm: %s", ev["state_released"])
ctx.check(ev["kernel_state_released"] == "idle", "the kernel left idle: %s", ev["kernel_state_released"])
ctx.check(ev["faults_released"] == 0, "a driver fault on release: %s", ev["faults_released"])
ctx.check(not (ev["homed_axes_released"] or 0) & 3, "X or Y still referenced while released")
ctx.check("Motors released" in text, "the sender was not told the motors are released")
# The rotors have relaxed by now: from here on the head is still.
ctx.sleep(1.0)
k0 = _kernel_position()
t0 = time.time()
refusals = {}
for line in ("$J=G91X10F2400", "G0X10", "$X"):
refusals[line] = _refused(ctx, g, line)
ctx.check(refusals[line], "%s was accepted while the motors are released", line)
ctx.sleep(1.0)
p2px, p2py, n = accel.p2p(t0)
ev["released_accel_p2p"] = [p2px, p2py, n]
ctx.check(n > 0, "no accelerometer samples in the released window")
ctx.check(max(p2px, p2py) < hw.ACCEL_P2P_MOVING,
"the head moved while released (accel p2p x=%d y=%d)", p2px, p2py)
g.realtime(0x18)
ctx.sleep(2)
g.drain()
refusals["^X then $X"] = _refused(ctx, g, "$X")
ctx.check(refusals["^X then $X"], "a soft reset and $X unlocked a released machine")
ev["state_after_reset"] = g.status_report()["state"]
ctx.check(ev["state_after_reset"].startswith("Alarm"),
"a soft reset unlocked a released machine: %s", ev["state_after_reset"])
k1 = _kernel_position()
ev["refusals"] = refusals
ev["kernel_position"] = [list(k0), list(k1)]
ctx.log("refused while released: %s; accel p2p x=%d y=%d over %d samples; kernel record %s -> %s",
refusals, p2px, p2py, n, k0, k1)
ctx.check(k1 == k0, "the kernel played something while released: %s -> %s", k0, k1)
# forgectrl reads the release too: cloud homing moves the head, so the
# switch is refused (for the release where cloud mode is enabled, for
# cloud mode being off where it is not; the mode stays either way).
st, body = fc.post("/mode", data={"controller": "cloud"})
text = body if isinstance(body, str) else json.dumps(body)
cloud_on = str(fc.settings().get("cloud_enabled", "")).lower() in ("1", "true", "yes", "on")
ev["mode_cloud_released"] = {"status": st, "body": text, "cloud_enabled": cloud_on}
ctx.log("POST /mode controller=cloud while released -> %s %s", st, text)
ctx.check(st == 409, "a switch to cloud mode while released -> %s, expected 409", st)
if cloud_on:
ctx.check("released" in text, "the refusal does not name the release: %r", text)
ctx.check(fc.get("/mode")[1].get("mode") == "grbl", "the mode changed under a released gantry")
ctx.check(_step_currents() == ("0", "0"),
"something energized the motors: step currents %s", _step_currents())
r = g.command("$ME")
ctx.check(not any(x.startswith("error") for x in r), "$ME refused: %s", r)
ctx.sleep(0.5)
ev["energized_currents"] = list(_step_currents())
ev["faults_energized"] = hw.sysfs_int("cnc/faults")
ev["state_energized"] = g.status_report()["state"]
ctx.check(tuple(ev["energized_currents"]) == HOLD_CURRENTS,
"the step currents read %s after $ME, not the hold currents", ev["energized_currents"])
ctx.check(ev["faults_energized"] == 0, "a driver fault on energize: %s", ev["faults_energized"])
ctx.check(ev["state_energized"].startswith("Idle"), "not idle after $ME: %s", ev["state_energized"])
# The drivers are alive: the machine's own witness says so. The liveness
# probe is built for the accelerometer (a slow, rough move sampled for
# seconds); a quick smooth jog is not, and reads as nothing half the time.
ctx.notice("The head moves 50 mm each way on X and on Y. Keep the bed clear.")
last = run_check(ctx, "motion", lambda p: "Continue" if p.get("id") == "jogs" else None, 420)
ctx.clear_notice()
res = last.get("result") or {}
ev["probe"] = res.get("probe")
ev["moves"] = res.get("moves")
ctx.log("after energize: probe '%s', moves %s", res.get("probe"),
{k: v.get("witnessed") for k, v in (res.get("moves") or {}).items()})
ctx.check(res.get("probe") and last.get("result") is not None and not last.get("error"),
"the motion check did not complete after the energize: %s", last.get("error") or last)
for name in ("+X", "-X", "+Y", "-Y"):
m = (res.get("moves") or {}).get(name) or {}
ctx.check(m.get("witnessed") is True,
"the %s jog was not witnessed after the energize: the drivers did not come back (%s)", name, m)
ok = ctx.wait_for(lambda: (fc.get("/mode")[1] or {}).get("local") is False
and (fc.get("/mode")[1] or {}).get("controller") == "running", 90)
ctx.check(ok is not None, "the controller is not back in its normal posture")
ev["rail_lines"] = [rail0, _rail_lines()]
ctx.check(ev["rail_lines"][0] == ev["rail_lines"][1],
"the kernel logged the 40 V supply changing across a release: %s", ev["rail_lines"])
machine_idle(ctx)
ctx.log("PASS: released with the rail untouched, every motion refused and nothing shipped, "
"energized with no fault, and the liveness probe and the witnessed jogs say the drivers are alive")
@test("homing.manual", title="Manual home: $H declares the gantry-stop position and jogs to an offset origin",
subsystem="motion", kind="auto", mode="grbl", est_min=5,
covers=_MOTION_COVERS + [("forgectrl", "src/status.*"), ("forgectrl", "src/main.c")],
requires=["motion.jog-roundtrip"],
steps=["Bed clear; the head needs 35 mm of free +X travel and 3 mm of free +Y travel."],
description="With homing_mode = manual and no manual home offset, $H ships nothing (the kernel's "
"position record shows no byte played across it), sets X and Y to X0 Y0 where the head "
"stands, marks X and Y homed with their soft limits on (a jog past the envelope is "
"refused with error 15), leaves Z where it was, tells the sender the home was set by "
"hand, and the anchor's source reads back as manual through forgectrl. With "
"manual_home_x = 5 and manual_home_y = 3, $H declares the head at X-5 Y-3, says so, and "
"jogs it to X0 Y0 before it answers: the kernel plays the move, Grbl and forgectrl both "
"read X0 Y0, and the envelope starts at the origin. Whatever a check decides, the "
"settings are restored, the false reference is dropped and the head returned before the "
"machine is handed back.")
def manual_home(ctx):
ev = ctx.evidence
fc = hw.Forgectrl()
settings = fc.settings() or {}
keys = ("homing_mode", "manual_home_x", "manual_home_y")
ev["found"] = {k: settings.get(k) for k in keys}
offset = (5.0, 3.0)
moved = None # how far the head is from where the run found it
for k, v in (("homing_mode", "manual"), ("manual_home_x", ""), ("manual_home_y", "")):
st, body = (fc.post("/settings", data={k: v}) if v else fc.post("/settings", params={k: ""}))
ctx.check(st == 200, "%s=%r -> %s %s", k, v, st, body)
try:
with ctx.grbl() as g:
clean_slate(ctx, g)
r = g.command("$J=G91X30F2400") # away from wherever the origin was
ctx.check(not any(x.startswith("error") for x in r), "the outbound jog was refused: %s", r)
moved = [30.0, 0.0]
wait_idle(ctx, g)
z0 = g.status_report()["MPos"][2]
ctx.sleep(1.5) # the kernel has played the jog's tail
k0 = _kernel_position()
r = g.command("$H", timeout=15)
ctx.check(not any(x.startswith("error") for x in r), "$H under manual refused: %s", r)
text = "\n".join(r) + drain_text(g, 1.5)
k1 = _kernel_position()
rep = g.status_report()
st = fc.status()
ev.update(kernel_position=[list(k0), list(k1)], mpos=rep["MPos"], state=rep["state"],
homed_axes=st.get("homed_axes"), home_source=st.get("home_source"),
pos=st.get("pos"))
ctx.log("$H: %s MPos %s; kernel record %s -> %s; forgectrl homed_axes %s source %s pos %s",
rep["state"], rep["MPos"], k0, k1, ev["homed_axes"], ev["home_source"], ev["pos"])
ctx.check(k1[3:] == k0[3:], "$H played pulse bytes: %s -> %s", k0[3:], k1[3:])
ctx.check(k1[0] == 0 and k1[1] == 0, "the kernel counters were not cleared: %s", k1)
ctx.check(rep["MPos"][0] == 0 and rep["MPos"][1] == 0, "X and Y are %s, not X0 Y0", rep["MPos"][:2])
ctx.check(rep["MPos"][2] == z0, "Z changed across the home: %s -> %s", z0, rep["MPos"][2])
ctx.check(rep["state"].startswith("Idle"), "not idle after the home: %s", rep["state"])
ctx.check(MANUAL_NOTICE in text, "the sender was not told the home was set by hand")
ctx.check((ev["homed_axes"] or 0) & 3 == 3, "forgectrl does not show X and Y homed")
ctx.check(ev["home_source"] == "manual", "the anchor's source reads %r", ev["home_source"])
ctx.check(ev["pos"] and abs(ev["pos"]["x"]) < 0.02 and abs(ev["pos"]["y"]) < 0.02,
"forgectrl's position is not X0 Y0: %s", ev["pos"])
# The envelope starts at the origin.
ev["past_envelope"] = _refused(ctx, g, "$J=G91X-5F600")
ctx.check(ev["past_envelope"] == "error:15",
"a jog past the envelope after the home got %s, not error:15", ev["past_envelope"])
# With an offset: the head at the gantry stops is minus it, and $H jogs
# it to the origin before it answers.
st, body = fc.post("/settings", data={"manual_home_x": "%g" % offset[0],
"manual_home_y": "%g" % offset[1]})
ctx.check(st == 200, "manual_home_x/_y = %s -> %s %s", offset, st, body)
k2 = _kernel_position()
r = g.command("$H", timeout=15)
refused = any(x.startswith("error") for x in r)
text = "\n".join(r) + drain_text(g, 1.5)
rep = g.status_report()
if not refused: # declared at minus the offset, then moved
moved = [30.0 + rep["MPos"][0] + offset[0], rep["MPos"][1] + offset[1]]
ctx.check(not refused, "$H with an offset refused: %s", r)
k3 = _kernel_position()
st = fc.status()
ev.update(offset_kernel_position=[list(k2), list(k3)], offset_mpos=rep["MPos"],
offset_state=rep["state"], offset_pos=st.get("pos"))
ctx.log("$H with offset %s: %s MPos %s; kernel record %s -> %s; forgectrl pos %s",
offset, rep["state"], rep["MPos"], k2, k3, ev["offset_pos"])
said = [ln for ln in text.splitlines() if "Manual home: the head is at X" in ln]
ev["offset_said"] = said
try:
words = said[-1].split("the head is at ")[1].split(";")[0].split()
declared = [float(words[0][1:]), float(words[1][1:])]
except (IndexError, ValueError):
declared = None
ctx.check(declared is not None and abs(declared[0] + offset[0]) < 0.01
and abs(declared[1] + offset[1]) < 0.01,
"the sender was told the head stood at %s, not minus %s", declared, offset)
ctx.check(k3[3:] != k2[3:], "the move to the origin played no pulse byte: %s -> %s", k2[3:], k3[3:])
ctx.check(k3[0] != 0 and k3[1] != 0, "the kernel counters did not move on X and Y: %s", k3)
ctx.check(rep["MPos"][0] == 0 and rep["MPos"][1] == 0,
"the head is at %s when $H answered, not X0 Y0", rep["MPos"][:2])
ctx.check(rep["state"].startswith("Idle"), "not idle when $H answered: %s", rep["state"])
ctx.check(ev["offset_pos"] and abs(ev["offset_pos"]["x"]) < 0.02 and abs(ev["offset_pos"]["y"]) < 0.02,
"forgectrl's position is not X0 Y0: %s", ev["offset_pos"])
ev["offset_past_envelope"] = _refused(ctx, g, "$J=G91Y-1F600")
ctx.check(ev["offset_past_envelope"] == "error:15",
"a jog behind the origin got %s, not error:15", ev["offset_past_envelope"])
finally:
# Whatever a check above decided, the machine is handed back as it was
# found: the settings restored, the false home dropped, the head returned.
for k in keys:
v = ev["found"][k]
st, body = (fc.post("/settings", data={k: v}) if v else fc.post("/settings", params={k: ""}))
ctx.log("restore %s=%r -> %s", k, v, st)
if moved:
_drop_reference(ctx, fc)
with ctx.grbl() as g:
clean_slate(ctx, g)
r = g.command("$J=G91X%.3fY%.3fF2400" % (-moved[0], -moved[1]))
ctx.check(not any(x.startswith("error") for x in r), "the return jog was refused: %s", r)
wait_idle(ctx, g)
machine_idle(ctx)
# The home cleared the kernel's counters away from where the run
# began, so the return leaves them off zero with the head where
# the run found it. The hand-back compares counters, and a
# controller start zeroes them: left like this, the next test
# that restarts the controller reads as out of place and the
# hand-back "returns" a head that never moved (on the bench
# reference it drove the head into the gantry stops). One more
# start, with the head back, leaves the counters at zero where the
# run began, which is what the rest of the catalog counts on.
_drop_reference(ctx, fc)
ctx.counters_rezeroed()
machine_idle(ctx)
ctx.log("PASS: a manual home with no offset shipped nothing and declared X0 Y0; with %s it declared "
"minus that and jogged to X0 Y0 before answering; the soft limits start at the origin, Z was "
"kept, and the home reads back as manual", offset)
def _port_state(fc):
st, body = fc.get("/motion/state")
if st != 200 or not isinstance(body, dict):
raise hw.HwError("forgectrl /motion/state -> %s %s" % (st, body))
return body
def _port_idle(ctx, fc, timeout=30.0):
"""The port's own word that its jog is over. The suite's Grbl client
sends no line while a port jog runs: a line from it is what cancels one."""
ok = ctx.wait_for(lambda: (lambda s: s["state"] == "Idle" and not s["port_jog"])(_port_state(fc)),
timeout, poll=0.1)
ctx.check(ok is not None, "the port jog did not end: %s", _port_state(fc))
machine_idle(ctx)
def _words(body):
return body if isinstance(body, str) else json.dumps(body)
@test("motion.port-jog", title="The controller port: the panel jogs beside a connected Grbl client",
subsystem="motion", kind="auto", mode="grbl", est_min=4,
covers=_MOTION_COVERS + [("forgectrl", "src/grblport.*"), ("forgectrl", "src/main.c"),
("forgectrl", "src/status.*")],
requires=["motion.jog-roundtrip"],
steps=["Bed clear; the head needs 180 mm of free travel toward +X. Nobody touches the gantry."],
description="With the suite connected as the Grbl client, POST /motion/jog moves the head by "
"what was asked (the kernel's counters are the witness), the client is told "
"([MSG:Panel jog]) and is not displaced: its next line still draws ok. "
"GET /motion/state agrees with the kernel. With the client polling the way "
"LightBurn does ('?' and an end of line), no port jog is refused for it, a 30 mm "
"jog runs whole, and every poll draws its ok. POST /motion/cancel stops a long jog "
"short. The client goes first: its line during a port jog cancels the jog and "
"draws ok, never an error. A jog past the per-request bound is refused with "
"nothing moved. POST /motion/release takes the step currents to 0 and /status "
"says so, a jog is then refused in words, and POST /motion/energize restores "
"the hold currents. The head is returned to where it was found.")
def port_jog(ctx):
ev = ctx.evidence
fc = hw.Forgectrl()
with ctx.grbl() as g:
clean_slate(ctx, g)
x0, y0 = kernel_start(ctx)
ctx.sleep(0.6) # past the port's sender-quiet interval
# A bounded jog, beside the client.
st, body = fc.post("/motion/jog", params={"x": "10", "feed": "2400"})
ctx.check(st == 200, "POST /motion/jog x=10 -> %s %s", st, _words(body))
_port_idle(ctx, fc)
x1, y1 = kernel_xy_mm(ctx)
ps = _port_state(fc)
ev["jog"] = {"kernel": [x0, x1], "port_mpos": ps.get("mpos"), "sender": ps.get("sender")}
ctx.log("port jog +10: kernel X %.3f -> %.3f, port state %s", x0, x1, ps)
ctx.check(abs((x1 - x0) - 10.0) < 0.1, "the kernel moved %.3f mm, not 10", x1 - x0)
ctx.check(abs(y1 - y0) < 0.05, "Y moved on an X jog: %.3f -> %.3f", y0, y1)
ctx.check(ps.get("sender") is True, "the port does not see the connected client: %s", ps)
text = drain_text(g, 0.5)
ctx.check("Panel jog" in text, "the client was not told of the panel's jog: %r", text)
r = g.command("G4 P0")
ctx.check("ok" in r and not any(x.startswith("error") for x in r),
"the client was displaced or refused after a port jog: %s", r)
# The client polls the way LightBurn does: '?' with an end of line
# behind it. The '?' is a realtime character; the end of line is an
# empty line, and an empty line is not the client speaking. No jog
# is refused for it, a jog runs whole under it, each poll draws its ok.
ctx.sleep(0.6)
g.drain()
polls, refused = 0, []
for _ in range(8):
g.send_raw(b"?\n")
polls += 1
st, body = fc.post("/motion/jog", params={"x": "0.5", "feed": "3000"})
if st != 200:
refused.append(_words(body))
ctx.sleep(0.12)
ctx.check(not refused, "%d of 8 port jogs were refused under a '?'+EOL poll: %s", len(refused), refused[:2])
_port_idle(ctx, fc)
xa = kernel_xy_mm(ctx)[0]
st, body = fc.post("/motion/jog", params={"x": "30", "feed": "1200"}) # 1.5 s: several polls long
ctx.check(st == 200, "the jog under the poll -> %s %s", st, _words(body))
in_jog = 0
deadline = time.time() + 6
while time.time() < deadline:
g.send_raw(b"?\n")
polls += 1
ctx.sleep(0.25)
ps = _port_state(fc)
if ps["state"] == "Idle" and not ps["port_jog"]:
break
in_jog += 1
_port_idle(ctx, fc)
xb = kernel_xy_mm(ctx)[0]
oks = sum(1 for line in drain_text(g, 1.0).splitlines() if line.strip() == "ok")
ev["poll"] = {"polls": polls, "oks": oks, "polls_in_jog": in_jog, "kernel": [xa, xb]}
ctx.log("'?'+EOL poll: 8 of 8 jogs accepted; a 30 mm jog moved %.3f with %d polls into it; "
"%d polls drew %d oks", xb - xa, in_jog, polls, oks)
ctx.check(in_jog >= 3, "only %d polls landed inside the jog: the case did not test it", in_jog)
ctx.check(abs((xb - xa) - 30.0) < 0.1, "a status poll cut the port jog short: moved %.3f of 30", xb - xa)
ctx.check(oks == polls, "%d polls drew %d oks", polls, oks)
x1 = xb
# Past the bound: refused at the door, nothing moved.
st, body = fc.post("/motion/jog", params={"x": "101"})
ev["past_bound"] = [st, _words(body)]
ctx.check(st == 400, "a 101 mm jog -> %s %s, expected 400", st, _words(body))
ctx.check(abs(kernel_xy_mm(ctx)[0] - x1) < 0.01, "a refused jog moved the head")
# The cancel stops a long jog short.
ctx.sleep(0.6)
st, body = fc.post("/motion/jog", params={"x": "40", "feed": "600"})
ctx.check(st == 200, "the long jog -> %s %s", st, _words(body))
ctx.sleep(1.0)
st, body = fc.post("/motion/cancel")
ctx.check(st == 200, "POST /motion/cancel -> %s %s", st, _words(body))
_port_idle(ctx, fc)
x2 = kernel_xy_mm(ctx)[0]
ev["cancel"] = [x1, x2]
ctx.log("canceled a 40 mm jog at %.3f mm", x2 - x1)
ctx.check(1.0 < x2 - x1 < 35.0, "the cancel did not stop the jog short: moved %.3f of 40", x2 - x1)
# The client goes first: its line cancels the port's jog and draws ok.
# A fast jog, so the cancel is a real deceleration: the core stays in
# the jog state for the length of it, and a client line read in that
# window is the error:9 the hold exists to prevent. A slow jog stops
# at once and would pass with no hold at all.
g.drain()
ctx.sleep(0.6)
st, body = fc.post("/motion/jog", params={"x": "100", "feed": "6000"})
ctx.check(st == 200, "the fast jog -> %s %s", st, _words(body))
ctx.sleep(0.3)
r = g.command("G4 P0", timeout=15)
_port_idle(ctx, fc)
x3 = kernel_xy_mm(ctx)[0]
ev["sender_first"] = {"reply": r, "kernel": [x2, x3]}
ctx.log("the client's line during a port jog drew %s; the jog stopped at %.3f of 100", r, x3 - x2)
ctx.check("ok" in r and not any(x.startswith("error") for x in r),
"the client's line during a port jog drew %s, not ok", r)
ctx.check(2.0 < x3 - x2 < 95.0, "the client's line did not cancel the port jog: moved %.3f", x3 - x2)
# The panel's own: the release and its end, by their routes.
ctx.sleep(0.6)
st, body = fc.post("/motion/release")
ctx.check(st == 200, "POST /motion/release -> %s %s", st, _words(body))
ctx.sleep(0.5)
ev["released"] = {"currents": list(_step_currents()),
"status": fc.status().get("motors_released"),
"port": _port_state(fc).get("released")}
ctx.check(ev["released"]["currents"] == ["0", "0"],
"the step currents read %s after the release", ev["released"]["currents"])
ctx.check(ev["released"]["status"] is True and ev["released"]["port"] is True,
"the release is not reported: %s", ev["released"])
k0 = _kernel_position()
st, body = fc.post("/motion/jog", params={"x": "1"})
ev["jog_released"] = [st, _words(body)]
ctx.check(st == 409 and "released" in _words(body),
"a jog while released -> %s %s", st, _words(body))
ctx.check(_kernel_position() == k0, "the kernel played something while released")
st, body = fc.post("/motion/energize")
ctx.check(st == 200, "POST /motion/energize -> %s %s", st, _words(body))
ctx.sleep(0.5)
ev["energized"] = {"currents": list(_step_currents()),
"status": fc.status().get("motors_released"),
"faults": hw.sysfs_int("cnc/faults")}
ctx.check(tuple(ev["energized"]["currents"]) == HOLD_CURRENTS and ev["energized"]["status"] is False,
"not energized: %s", ev["energized"])
ctx.check(ev["energized"]["faults"] == 0, "a driver fault on energize: %s", ev["energized"]["faults"])
# Back to where the head was found, by the kernel's own measure.
ctx.sleep(0.6)
for _ in range(3): # one request moves 100 mm at most
back = x0 - kernel_xy_mm(ctx)[0]
if abs(back) < 0.05:
break
st, body = fc.post("/motion/jog", params={"x": "%.3f" % max(-100.0, min(100.0, back)),
"feed": "2400"})
ctx.check(st == 200, "the return jog -> %s %s", st, _words(body))
_port_idle(ctx, fc)
ctx.sleep(0.6)
check_kernel_returned(ctx, ev, (x0, y0), tag="port")
machine_idle(ctx)
ctx.log("PASS: the port jogged beside the client, the cancel and the client's own line each stopped "
"a jog short, the bound held, and the release and the energize went through their routes")
# ---------------------------------------------------------------- the job runner
def _job(fc):
st, body = fc.get("/job")
if st != 200 or not isinstance(body, dict):
raise hw.HwError("forgectrl /job -> %s %s" % (st, body))
return body
def _job_post(fc, program, name="forgetest", **fields):
"""POST /job the way a client does: a multipart form, the program as
the file part."""
mark = "forgetestJobBoundary7d1"
form = ([("name", name)] if name is not None else []) + sorted(fields.items())
body = "".join('--%s\r\nContent-Disposition: form-data; name="%s"\r\n\r\n%s\r\n' % (mark, k, v)
for k, v in form)
body += ('--%s\r\nContent-Disposition: form-data; name="program"; filename="job.gcode"\r\n'
'Content-Type: text/plain\r\n\r\n%s\r\n--%s--\r\n' % (mark, program, mark))
return fc.post("/job", data=body.encode("ascii"),
headers={"Content-Type": "multipart/form-data; boundary=%s" % mark})
def _job_wait(ctx, fc, timeout, x0=None):
"""Until the job is over; the record, and the farthest the kernel saw X go
from x0. A caller that reads the distance passes the X it took before it
posted the job: by the time this is called the head is already moving."""
if x0 is None:
x0 = kernel_xy_mm(ctx)[0]
far = 0.0
deadline = time.time() + timeout
while time.time() < deadline:
far = max(far, abs(kernel_xy_mm(ctx)[0] - x0))
if _job(fc)["state"] != "running":
break
ctx.sleep(0.1)
return _job(fc), far
def _lease_holder(fc):
return ((fc.status().get("lease") or {}).get("holder") or {})
@test("motion.job", title="The job runner: a posted program runs with the daemon as its sender",
subsystem="motion", kind="auto", mode="grbl", est_min=3,
covers=_MOTION_COVERS + [("forgectrl", "src/jobrun.*"), ("forgectrl", "src/jobpost.*"),
("forgectrl", "src/jobstream.*"), ("forgectrl", "src/lease.*"),
("forgectrl", "src/main.c"), ("forgectrl", "src/status.*")],
requires=["motion.jog-roundtrip"],
steps=["Bed clear; the head needs 60 mm of free travel toward +X. Nobody touches the gantry."],
description="POST /job takes a G-code program as a multipart form and runs it with the daemon "
"as the machine's one sender. A program with a $ line, and one with no name, are "
"refused with 400 and nothing moved. With the suite connected as a Grbl client the "
"job is refused with 409 (a sender is connected) and nothing moved. A dark program "
"(out 20 mm, a dwell, back) then plays: while it does, /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 holder'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 then stops a long move "
"short: the record says failed (aborted), the controller is in its alarm state, and "
"the lease is free. A job sent into the alarm without unlock fails at its first "
"line with nothing moved; the same job with unlock=1 clears the alarm with the "
"runner's own $X and returns the head to where it was found.")
def job_runner(ctx):
ev = ctx.evidence
fc = hw.Forgectrl()
machine_idle(ctx)
x0, y0 = kernel_start(ctx)
rest = _job(fc)
ev["record_keys"] = sorted(rest)
ctx.check({"state", "owner", "program", "lines", "sent", "acked", "elapsed_s", "lit", "emission",
"reason"} <= set(rest), "GET /job lacks keys: %s", sorted(rest))
ctx.check(rest["state"] != "running", "a job is already running: %s", rest)
ctx.check(not _lease_holder(fc), "the machine is not free at the start: %s", _lease_holder(fc))
# Refused at the door, nothing moved.
refused = {}
st, body = _job_post(fc, "G21\n$X\nG91\nG1 X5 F1200\n")
refused["a $ line"] = [st, _words(body)[:160]]
ctx.check(st == 400 and "line 2" in _words(body) and "$ command" in _words(body),
"a program with a $ line -> %s %s", st, _words(body)[:160])
st, body = _job_post(fc, "G21\nG91\nG1 X5 F1200\n", name=None)
refused["no name"] = [st, _words(body)[:160]]
ctx.check(st == 400 and "name" in _words(body), "a job with no name -> %s %s", st, _words(body)[:160])
with ctx.grbl() as g:
clean_slate(ctx, g)
# The lease asks the controller's published state, which follows the connection.
ok = ctx.wait_for(lambda: ((fc.status().get("lease") or {}).get("observed") or {}).get("sender"),
10, poll=0.2)
ctx.check(ok is not None, "the controller never reported the suite's client as connected")
st, body = _job_post(fc, "G21\nG91\nG1 X5 F1200\n")
refused["a connected client"] = [st, _words(body)[:160]]
ctx.check(st == 409 and "sender is connected" in _words(body),
"a job beside a connected Grbl client -> %s %s", st, _words(body)[:160])
ev["refused_at_the_door"] = refused
ctx.check(abs(kernel_xy_mm(ctx)[0] - x0) < 0.01, "a refused job moved the head")
ok = ctx.wait_for(lambda: not ((fc.status().get("lease") or {}).get("observed") or {}).get("sender"),
10, poll=0.2)
ctx.check(ok is not None, "the controller still reports the suite's client as connected")
moved = 0.0
try:
# A dark program plays, and holds the machine while it does.
st, body = _job_post(fc, "(forgetest motion.job)\nG21\nG91\nG1 X20 F1200 ; out\nG4 P2\n"
"G1 X-20 F1200\nG90\n")
ctx.check(st == 200 and isinstance(body, dict) and body.get("state") == "running",
"POST /job -> %s %s", st, _words(body)[:200])
ok = ctx.wait_for(lambda: _lease_holder(fc).get("owner") == "job:forgetest", 5, poll=0.1)
held = _lease_holder(fc)
ev["holder"] = held
ctx.log("the lease while the job plays: %s", held)
ctx.check(ok is not None and held.get("kind") == "sender" and "forgetest" in (held.get("words") or ""),
"the lease does not name the job: %s", held)
rec = _job(fc)
ctx.check(rec["state"] == "running" and rec["owner"] == "job:forgetest" and rec["program"] is True
and rec["lines"] == 6, "the record while it plays: %s", rec)
beside = {}
for name, call in (("a second job", lambda: _job_post(fc, "G21\n", name="second")),
("a port jog", lambda: fc.post("/motion/jog", params={"x": "1"})),
("a settings write", lambda: fc.post("/settings", params={
"ui_units": fc.settings().get("ui_units") or "metric"}))):
st, body = call()
beside[name] = [st, _words(body)[:160]]
ctx.check(st == 409 and "a job (forgetest) holds the machine" in _words(body),
"%s beside the job -> %s %s", name, st, _words(body)[:160])
ev["refused_beside_the_job"] = beside
rec, far = _job_wait(ctx, fc, 40, x0)
x1 = kernel_xy_mm(ctx)[0]
ev["dark_job"] = {"record": rec, "farthest_mm": round(far, 3), "kernel": [x0, x1]}
ctx.log("the dark job: %s; the kernel saw %.3f mm out and ended %.3f from the start",
rec, far, x1 - x0)
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 (the runner's M2 is the one more)",
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(19.0 < far < 21.0, "the kernel saw %.3f mm of the 20", far)
ctx.check(abs(x1 - x0) < 0.1, "the job did not end where it began: %.3f mm off", x1 - x0)
ctx.check(not _lease_holder(fc), "the lease was not given back: %s", _lease_holder(fc))
# The abort stops a long move short.
st, body = _job_post(fc, "G21\nG91\nG1 X40 F300\nG90\n")
ctx.check(st == 200, "the long job -> %s %s", st, _words(body)[:200])
ctx.sleep(2.0)
st, body = fc.post("/job/abort")
ctx.check(st == 200 and isinstance(body, dict) and body.get("state") == "failed"
and "aborted" in body.get("reason", ""), "POST /job/abort -> %s %s", st, _words(body)[:200])
ctx.sleep(1.0)
moved = kernel_xy_mm(ctx)[0] - x0
after = _port_state(fc)
ev["abort"] = {"record": body, "moved_mm": round(moved, 3), "controller": after.get("state")}
ctx.log("aborted a 40 mm move at %.3f mm; the controller is in %s", moved, after.get("state"))
ctx.check(1.0 < moved < 35.0, "the abort did not stop the move short: moved %.3f of 40", moved)
ctx.check(str(after.get("state", "")).startswith("Alarm"),
"the controller is in %s after the abort, not its alarm state", after.get("state"))
ctx.check(not _lease_holder(fc), "the lease was not given back after the abort: %s", _lease_holder(fc))
# Into the alarm: refused by the controller without the unlock, and
# with it the head goes back where it was found.
back = "G21\nG91\nG1 X%.3f F1200\nG90\n" % -moved
st, body = _job_post(fc, back)
ctx.check(st == 200, "the job into the alarm -> %s %s", st, _words(body)[:200])
rec, far = _job_wait(ctx, fc, 20)
ev["into_the_alarm"] = rec
ctx.check(rec["state"] == "failed" and "line 1" in rec["reason"],
"a job into the alarm without unlock: %s", rec)
ctx.check(abs((kernel_xy_mm(ctx)[0] - x0) - moved) < 0.05, "a refused job moved the head")
st, body = _job_post(fc, back, unlock="1")
ctx.check(st == 200, "the unlocking job -> %s %s", st, _words(body)[:200])
rec, far = _job_wait(ctx, fc, 40)
ev["unlocked"] = rec
ctx.log("the unlocking job: %s", rec)
ctx.check(rec["state"] == "done" and rec["sent"] == rec["acked"] == rec["lines"] + 2,
"the unlocking job (its $X and its M2 are the two more): %s", rec)
moved = kernel_xy_mm(ctx)[0] - x0
finally:
if _job(fc)["state"] == "running":
fc.post("/job/abort")
ctx.sleep(1.0)
left = kernel_xy_mm(ctx)[0] - x0
if abs(left) > 0.05:
ctx.log("returning the head %.3f mm through a Grbl client", -left)
_return_x(ctx, left)
check_kernel_returned(ctx, ev, (x0, y0), tag="job")
machine_idle(ctx)
ctx.log("PASS: a posted program ran as job:forgetest with the lease held as a sender, everything "
"beside it was refused in its name, the abort stopped a move short and freed the machine, "
"and the unlocking job cleared the alarm and returned the head")
+296 -43
View File
@@ -427,8 +427,12 @@ def gate_blocks_controllers(ctx):
ev["override_mode"] = m
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)
ctx.check(hw.grbl_port_open(timeout=5), "the Grbl port is closed with the controller running")
ctx.log("override: the controller is back (%s)", m)
# /mode reads running from the fork; the controller binds its port
# 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
after = wiz(fc)
@@ -495,7 +499,9 @@ def override_until_reboot(ctx):
subsystem="setup", kind="auto", hardware="takeover", est_min=3,
covers=[("forgectrl", "src/advisories.*"), ("forgectrl", "src/setup.*"),
("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"],
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 "
@@ -593,7 +599,11 @@ def advisories_rehash(ctx):
description="The test makes its own account: the account record is moved aside under a "
"forgectrl restart, the account route creates a temporary one with a password "
"only the test knows, and the real record comes back under another restart at "
"the end, the system accounts replayed and the temporary home removed. 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 "
"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 "
@@ -608,27 +618,88 @@ def account_login(ctx):
before = wiz(fc)
ev["account_before"] = (before.get("users") or {}).get("name")
homes = "/data/forgefirm/home"
with installed(ctx, {users_path(): None}):
w = wiz(fc)
ctx.check(not (w.get("users") or {}).get("exists"), "an account still exists: %s", w.get("users"))
st, body = fc.post("/wiz/account", data={"name": name, "password": pw})
ctx.log("POST /wiz/account (temporary %r) -> %s %s", name, st, body if isinstance(body, dict) else "")
ctx.check(st == 200, "the temporary account was not created: %s %s", st, body)
w = wiz(fc)
ctx.check((w.get("users") or {}).get("name") == name, "the account reads %s", w.get("users"))
login_checks(ctx, name, pw)
rc, out = hw.initd("forgefirm-users", "reload")
ev["users_reload_rc"] = rc
ctx.log("forgefirm-users reload -> rc %s %s", rc, out.strip()[:200])
if name != ev["account_before"]:
import shutil
shutil.rmtree(os.path.join(homes, name), ignore_errors=True)
dir_mode = os.stat(data_dir()).st_mode & 0o7777
ev["data_dir_mode_found"] = "%04o" % dir_mode
try:
with installed(ctx, {users_path(): None}):
w = wiz(fc)
ctx.check(not (w.get("users") or {}).get("exists"), "an account still exists: %s", w.get("users"))
# the data directory as a strict umask leaves it: the account's render has to open it
os.chmod(data_dir(), 0o700)
st, body = fc.post("/wiz/account", data={"name": name, "password": pw})
ctx.log("POST /wiz/account (temporary %r) -> %s %s", name, st, body if isinstance(body, dict) else "")
ctx.check(st == 200, "the temporary account was not created: %s %s", st, body)
w = wiz(fc)
ctx.check((w.get("users") or {}).get("name") == name, "the account reads %s", w.get("users"))
reach = home_reach(name)
ev["home_reach"] = reach
ev["data_dir_mode_after_render"] = "%04o" % (os.stat(data_dir()).st_mode & 0o7777)
ctx.log("as %s, from a data directory found 0700 (now %s): %s", name, ev["data_dir_mode_after_render"], reach)
ctx.check(reach.get("cd") == "ok" and reach.get("write") == "ok",
"the account cannot use its own home %s: %s", reach.get("home"), reach)
ctx.check(ev["data_dir_mode_after_render"] == "0711", "the render left the data directory at %s, expected "
"0711 (the search bit and nothing else)", ev["data_dir_mode_after_render"])
ctx.check(reach.get("list_data_dir") == "EACCES" and reach.get("read_record") == "EACCES",
"the account reads what is not its own: %s", reach)
login_checks(ctx, name, pw)
finally:
# as found; the replay below is the product's own say on the mode
os.chmod(data_dir(), dir_mode)
rc, out = hw.initd("forgefirm-users", "reload")
ev["users_reload_rc"] = rc
ctx.log("forgefirm-users reload -> rc %s %s", rc, out.strip()[:200])
if name != ev["account_before"]:
import shutil
shutil.rmtree(os.path.join(homes, name), ignore_errors=True)
after = wiz(fc)
ctx.check((after.get("users") or {}).get("name") == ev["account_before"],
"the account reads %r after the restore, was %r", (after.get("users") or {}).get("name"),
ev["account_before"])
# Run as root: becomes the account and tries its own home, and what is
# not its own. One JSON line.
HOME_REACH = r'''
import errno, json, os, pwd, sys
p = pwd.getpwnam(sys.argv[1])
os.setgroups([])
os.setgid(p.pw_gid)
os.setuid(p.pw_uid)
def tried(fn):
try:
fn()
return "ok"
except OSError as e:
return errno.errorcode.get(e.errno, str(e.errno))
def write():
path = os.path.join(p.pw_dir, ".forgetest-reach")
with open(path, "w") as f:
f.write("x")
os.remove(path)
print(json.dumps({"uid": os.getuid(), "home": p.pw_dir, "cd": tried(lambda: os.chdir(p.pw_dir)), "write": tried(write),
"list_data_dir": tried(lambda: os.listdir(sys.argv[2])),
"read_record": tried(lambda: open(os.path.join(sys.argv[2], "users")).read())}))
'''
def home_reach(name):
"""What the account can do with its own home and with the data
directory around it, tried as the account."""
import subprocess
import sys
p = subprocess.run([sys.executable, "-c", HOME_REACH, name, data_dir()], capture_output=True, text=True, timeout=30)
try:
return json.loads(p.stdout)
except ValueError:
return {"error": (p.stderr or p.stdout).strip()[-200:]}
def login_checks(ctx, name, pw):
"""The login rules against an account whose password the test knows."""
ev = ctx.evidence
@@ -896,12 +967,17 @@ def ssh_until_reboot(ctx):
@test("setup.cloud-disabled-surface", title="Nothing points at the cloud while it is off",
subsystem="setup", kind="auto", mode="grbl", est_min=1,
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"],
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 "
"down with it, as the cloud step does. With it at 0, POST /settings "
"controller_mode=cloud and homing_mode=gfcloud are refused (409) and leave the "
"back as found. GET /extensions lists cloud mode as a built-in with its two "
"roles (homing gfcloud, controller cloud), enabled as cloud_enabled says and each "
"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) "
"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.")
@@ -912,6 +988,29 @@ def cloud_disabled_surface(ctx):
found = fc.settings()
prior = found.get("cloud_enabled") or ""
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:
if prior != "0":
# the one write sweeps what pointed at the cloud
@@ -927,12 +1026,18 @@ def cloud_disabled_surface(ctx):
"cloud_enabled=0 left controller_mode at %r, expected grbl",
swept.get("controller_mode"))
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()
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})
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.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
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)
@@ -972,6 +1077,149 @@ def cloud_disabled_surface(ctx):
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",
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
@@ -1175,25 +1423,30 @@ def first_run(ctx):
except OSError:
homes_before = set()
state = {"temp_account": None}
with Restore(ctx, FIRST_RUN_SETTINGS):
with installed(ctx, {record_path(): seed, users_path(): None, override_path(): b""}):
w = wiz(fc)
ev["fresh"] = wiz_summary(w)
ctx.check(w.get("first_run") is True and not (w.get("users") or {}).get("exists")
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))
m = mode(fc)
ctx.check(m.get("controller") == "gated", "the controller is %s on a first run", m.get("controller"))
yield state
# the real records are back; the system accounts follow the record
rc, out = hw.initd("forgefirm-users", "reload")
ev["users_reload_rc"] = rc
ctx.log("forgefirm-users reload -> rc %s %s", rc, out.strip()[:200])
temp = state.get("temp_account")
if temp and temp not in homes_before and temp != ev["account_before"]:
import shutil
shutil.rmtree(os.path.join(homes, temp), ignore_errors=True)
ctx.log("removed the temporary home directory of %r", temp)
try:
with Restore(ctx, FIRST_RUN_SETTINGS):
with installed(ctx, {record_path(): seed, users_path(): None, override_path(): b""}):
w = wiz(fc)
ev["fresh"] = wiz_summary(w)
ctx.check(w.get("first_run") is True and not (w.get("users") or {}).get("exists")
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))
m = mode(fc)
ctx.check(m.get("controller") == "gated", "the controller is %s on a first run",
m.get("controller"))
yield state
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")
ev["users_reload_rc"] = rc
ctx.log("forgefirm-users reload -> rc %s %s", rc, out.strip()[:200])
temp = state.get("temp_account")
if temp and temp not in homes_before and temp != ev["account_before"]:
import shutil
shutil.rmtree(os.path.join(homes, temp), ignore_errors=True)
ctx.log("removed the temporary home directory of %r", temp)
after = wiz(fc)
ev["after"] = wiz_summary(after)
ctx.check((after.get("users") or {}).get("name") == ev["account_before"],
+17 -7
View File
@@ -121,7 +121,7 @@ def run_check(ctx, wid, on_prompt, timeout_s):
@test("setup.check-switches", title="The switches check follows the lid and the button",
subsystem="setup", kind="operator", est_min=3,
covers=DARK_COVERS + [("forgectrl", "src/status.c")],
requires=["forgectrl.auth"], actions=["lid", "button"],
requires=["forgectrl.auth"], actions=["lid", "button", "interlock"],
description="POST /wiz/switches/start; the check asks for the lid to open and close and for "
"a press, each as a 'wait' prompt that the switch edge itself answers; the bench "
"fixture works the lid and the button. On a Basic or Plus the interlock loop reads "
@@ -191,20 +191,28 @@ def check_sensors(ctx):
("forgectrl", "src/gates.c"), ("forgectrl", "src/super.c")],
requires=["forgectrl.auth", "cooling.fan-gate-trips"],
description="POST /wiz/airflow/start: the controller stops, the fans run at the cut profile "
"for 35 s, the purge is measured on and off, and five settings are written: the "
"four floors at 55 percent of steady and the grace from the slowest spin-up. Each "
"fan must read at least 1000 rpm. The five settings are put back as found at the "
"end, the purge fan is on again and drawing over the floor the check just wrote, "
"and the controller must be running again.")
"for 35 s, the purge is measured on and off, and six settings are written: the "
"four floors at 55 percent of steady, the grace from the slowest spin-up, and the "
"intake fans fitted (2 when both intakes turn; 1 when intake 2 never reads a pulse "
"and intake 1 passes, and then intake 2 is reported not fitted and the intake floor "
"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):
keys = ["cool_tach_exhaust_min_rpm", "cool_tach_intake_min_rpm", "cool_tach_air_assist_min_rpm",
"cool_purge_min_current", "cool_fan_grace_s"]
with Restore(ctx, keys):
with Restore(ctx, keys + ["cool_intake_fans"]):
last = run_check(ctx, "airflow", lambda p: None, 240)
r = last.get("result") 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"):
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.log("%s: %s rpm steady, 90 percent at %s s", name, f.get("steady_rpm"), f.get("spinup_s"))
floors = r.get("floors") or {}
@@ -213,6 +221,8 @@ def check_airflow(ctx):
s = ctx.forgectrl.settings() or {}
for k in keys:
ctx.check(s.get(k) == floors.get(k), "%s reads %r, the check wrote %r", k, s.get(k), floors.get(k))
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))
# 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
+1
View File
@@ -28,6 +28,7 @@ from .setup_dark import run_check, Restore
SHEET_COVERS = [("forgectrl", "src/wizlive.*"), ("forgectrl", "src/wizrun.h"),
("forgectrl", "src/lens.*"), ("forgectrl", "src/sheet.*"),
("forgectrl", "src/font_hershey.*"), ("forgectrl", "src/jobstream.*"),
("forgectrl", "src/jobrun.*"), ("forgectrl", "src/lease.*"),
("forgectrl", "src/curverec.*"), ("forgectrl", "src/wizdark.*"),
("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"),
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*"),
+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)
+82
View File
@@ -126,6 +126,88 @@ def slots_and_signature(ctx):
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+")
+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)
+40
View File
@@ -451,6 +451,23 @@ footer a {
font-size: 11.5px;
margin-left: 2px;
}
.extadvdoc {
max-height: 22rem;
overflow-y: auto;
margin-bottom: 0.5rem;
}
.extadvdoc h1,
.extadvdoc h2 {
font-size: 1rem;
margin: 0.6rem 0 0.3rem;
}
.extcaps {
margin: 0.2rem 0 0.2rem 1.1rem;
padding: 0;
}
.extcaps li {
margin: 0;
}
.hint {
color: var(--og-dim);
font-size: 12.5px;
@@ -798,3 +815,26 @@ footer a {
min-height: 16px;
text-align: right;
}
/* A package's card on the Extensions tab: the card and its heading (the
label) are the panel's, the frame is the package's, and the frame is
given a size rather than taking one. The frame takes the panel's color
scheme, which the page inside reads as its prefers-color-scheme; it is
transparent, so a page that paints no background sits on the card. */
.extcard h2 .mono {
text-transform: none;
letter-spacing: 0;
font-weight: 400;
color: var(--og-dim);
}
.extframe {
display: block;
width: 100%;
height: 360px;
border: 0;
background: transparent;
color-scheme: light;
}
[data-bs-theme='dark'] .extframe {
color-scheme: dark;
}
View File
+1
View File
@@ -0,0 +1 @@
0
+1
View File
@@ -0,0 +1 @@
1
+1
View File
@@ -0,0 +1 @@
0
+1
View File
@@ -0,0 +1 @@
1
+1
View File
@@ -0,0 +1 @@
1
+128 -13
View File
@@ -139,6 +139,49 @@ class BaselineTests(unittest.TestCase):
self.assertTrue(items["position"].action.startswith("unrestorable"), items["position"].action)
self.assertEqual(items["position"].found, [1000, 0, 0])
def test_a_lost_counter_frame_never_moves_the_head(self):
# The controller zeroes the step counters at every start and at
# every home, and rewrites its anchor then. A run that began at
# -6400 steps and restarted the controller ends at 0 with the head
# where it was: the difference is not a distance, and the hand-back
# must neither jog the head nor call it a leftover.
anchor = os.path.join(self.tmp, "grblhal.homed")
old_path, baseline.ANCHOR_PATH = baseline.ANCHOR_PATH, anchor
try:
with open(anchor, "w") as f:
f.write("0 0 0 4 startup")
self._pos(-6400, 0, 0)
b = self.bl()
cap = b.capture()
self.assertIsNotNone(cap["frame"])
os.unlink(anchor) # a controller start: a new anchor file
with open(anchor + ".new", "w") as f:
f.write("0 0 0 4 startup")
os.replace(anchor + ".new", anchor)
os.utime(anchor, ns=(5, 5)) # a freed inode can come straight back, inside one clock tick
self._pos(0, 0, 0)
left = b.enforce("post", captured=cap)
self.assertEqual([x.item for x in left], [])
self.assertTrue(any("re-zeroed its counters during the run" in l for l in self.lines), self.lines)
# the same counters with the frame intact are a displaced head, as before
self.lines.clear()
self._pos(-6400, 0, 0)
cap = b.capture()
self._pos(0, 0, 0)
left = b.enforce("post", captured=cap)
self.assertEqual([x.item for x in left], ["position"])
# and a test that vouches for the new frame is held to it
self._pos(-6400, 0, 0)
cap = b.capture()
os.utime(anchor, ns=(1, 1)) # re-zeroed, and declared (ctx.counters_rezeroed)
cap["position"], cap["rezero_declared"] = [0, 0, 0], True
self._pos(0, 0, 0)
self.assertEqual(b.enforce("post", captured=cap), [])
self._pos(1000, 0, 0)
self.assertEqual([x.item for x in b.enforce("post", captured=cap)], ["position"])
finally:
baseline.ANCHOR_PATH = old_path
def test_the_counters_are_read_at_the_modes_scale(self):
# 6400 steps is 30 mm at x32 (a return within the bound; on the
# host it stops at the missing controller) and 120 mm at x8
@@ -391,22 +434,29 @@ class BaselineTests(unittest.TestCase):
"forgectrl": {"/settings": {"xy_microsteps": "16"}}}))
def test_stale_preconfig_reference_is_retaken_on_a_fresh_boot(self):
# Both sides of BOOT_MAX_AGE_S, with the uptime held: read from the host, it can cross the limit
# between boot_reference()'s reading and the test's (it did, on a CI runner 600 s after its boot).
os.environ["FORGETEST_BOOT_ID"] = "test-boot-2"
real = baseline.uptime_s
try:
path = os.path.join(self.tmp, "boot-test-boot-2.json")
with open(path, "w") as f:
json.dump({"ts": "old", "sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000",
"cnc/y_mode": "1"}}, f)
ref = baseline.boot_reference(self.lines.append, self.tmp)
up = baseline.uptime_s()
if up is None or up > baseline.BOOT_MAX_AGE_S:
# too old to retake: the stale reference stands, marked
self.assertTrue(any("predates the controller's config" in l for l in self.lines))
self.assertEqual(ref["ts"], "old")
else:
self.assertTrue(any("retaking" in l for l in self.lines))
self.assertEqual(ref["sysfs"]["cnc/motor_lock"], "0")
for up, fresh in ((baseline.BOOT_MAX_AGE_S - 60, True), (baseline.BOOT_MAX_AGE_S + 60, False)):
self.lines.clear()
path = os.path.join(self.tmp, "boot-test-boot-2.json")
with open(path, "w") as f:
json.dump({"ts": "old", "sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000",
"cnc/y_mode": "1"}}, f)
baseline.uptime_s = lambda up=up: up
ref = baseline.boot_reference(self.lines.append, self.tmp)
if fresh:
self.assertTrue(any("retaking" in l for l in self.lines), self.lines)
self.assertNotEqual(ref["ts"], "old")
self.assertEqual(ref["sysfs"]["cnc/motor_lock"], "0")
else:
# too old to retake: the stale reference stands, marked
self.assertTrue(any("predates the controller's config" in l for l in self.lines), self.lines)
self.assertEqual(ref["ts"], "old")
finally:
baseline.uptime_s = real
os.environ.pop("FORGETEST_BOOT_ID", None)
@@ -414,6 +464,71 @@ if __name__ == "__main__":
unittest.main()
class ExtensionFreeTests(BaselineTests):
"""A campaign's machine is extension-free: what a test made is removed,
what is the operator's is reported and left alone."""
def setUp(self):
super().setUp()
self.root = os.path.join(self.tmp, "ext")
for d in ("pkg", "keys", "data"):
os.makedirs(os.path.join(self.root, d))
# a stand-in for the host's command line: remove <id> takes the package away
self.forgeext = os.path.join(self.tmp, "forgeext")
with open(self.forgeext, "w") as f:
f.write('#!/bin/sh\n[ "$1" = remove ] && rm -rf "%s/pkg/$2" && echo \'{"ok": true}\'\n' % self.root)
os.chmod(self.forgeext, 0o755)
self.pool = {}
self._saved = (baseline.hw.EXT_ROOT, baseline.hw.FORGEEXT, baseline.hw.pool_pids, baseline.EXT_STOP_S)
baseline.hw.EXT_ROOT, baseline.hw.FORGEEXT = self.root, self.forgeext
baseline.hw.pool_pids = lambda: dict(self.pool)
baseline.EXT_STOP_S = 0.2
def tearDown(self):
baseline.hw.EXT_ROOT, baseline.hw.FORGEEXT, baseline.hw.pool_pids, baseline.EXT_STOP_S = self._saved
super().tearDown()
def _package(self, id_):
os.makedirs(os.path.join(self.root, "pkg", id_, "1.0.0"))
def _key(self, name):
with open(os.path.join(self.root, "keys", name), "w") as f:
f.write("x")
def test_an_installed_package_that_does_not_run_is_nobodys_leftover(self):
self._package("org.example.notify")
self._key("owner.pub")
self.assertEqual(self.bl().enforce("pre", captured=None), [])
def test_what_a_test_left_behind_is_removed_and_the_operators_is_not(self):
self._package("org.forgetest.reference")
self._package("org.example.notify")
self._key("forgetest-reference.pub")
self._key("owner.pub")
left = {x.item: x for x in self.bl().enforce("post", captured=None)}
self.assertEqual(sorted(left), ["ext.owner_key", "ext.package"])
self.assertEqual((left["ext.package"].found, left["ext.package"].action), ("org.forgetest.reference", "restored"))
self.assertEqual((left["ext.owner_key"].found, left["ext.owner_key"].action), ("forgetest-reference.pub", "restored"))
self.assertEqual(os.listdir(os.path.join(self.root, "pkg")), ["org.example.notify"])
self.assertEqual(os.listdir(os.path.join(self.root, "keys")), ["owner.pub"])
def test_a_package_that_cannot_be_removed_says_so(self):
self._package("org.forgetest.reference")
with open(self.forgeext, "w") as f:
f.write('#!/bin/sh\necho \'{"ok": false, "error": "the lock is held"}\'\nexit 1\n')
left = self.bl().enforce("post", captured=None)
self.assertEqual([x.item for x in left], ["ext.package"])
self.assertTrue(left[0].action.startswith("failed:") and "the lock is held" in left[0].action, left[0].action)
def test_running_extensions_are_reported_and_left_alone(self):
self._package("org.example.notify")
self.pool = {4242: 800}
left = self.bl().enforce("pre", captured=None)
self.assertEqual([(x.item, x.action) for x in left], [("ext.running", "unrestorable")])
self.assertIn("org.example.notify", left[0].found)
self.assertTrue(os.path.isdir(os.path.join(self.root, "pkg", "org.example.notify")))
class TransientNotLeftoverTests(BaselineTests):
"""A leftover is what a run left behind, not the machine part-way
through its own work. Found on the bench reference, where
+248
View File
@@ -0,0 +1,248 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""A test that lets the service move the head hands it back where it found
it. Every service motion zeroes the step counters at its start, and a home
and every controller start zero them again, so no counter reading says
where the test found the head: the client's own record does. Every motion
logs the counters it ended on, and the travel is their sum (x8 steps, the
one mode a service motion runs at), plus what the counters read since. The
log lines are the machine's own, trimmed to the motion records: a homing
session whose one motion the cooling engine stopped short, and a whole
three-motion one. Runs against scratch logs and fakes for the controller."""
import os
import shutil
import sys
import tempfile
import unittest
from forgetest.runner import Failed
from forgetest.suite import cloud, homeoff
STOPPED = """\
2026-09-25T15:56:19.641824+00:00 gfhome[7387] INFO gfhome:home service action: motion (ready)
2026-09-25T15:56:19.644041+00:00 gfhome[7387] INFO machine:_motion start motion
2026-09-25T15:56:19.970342+00:00 gfhome[7387] INFO machine:_run_loop starting run
2026-09-25T15:56:20.905040+00:00 gfhome[7387] ERR machine:_run_loop run stopped short: 8130 of 21756 bytes played; the job did not finish
2026-09-25T15:56:20.918652+00:00 gfhome[7387] INFO machine:_feed_and_run end positions (actual/expected): X (4390/12115), Y (2682/7402), Z (0/0)
2026-09-25T15:56:20.937235+00:00 gfhome[7387] INFO machine:_motion_locked end positions (4390, 2682, 0)
2026-09-25T15:56:20.938026+00:00 gfhome[7387] INFO machine:_motion end motion
2026-09-25T15:56:20.938677+00:00 gfhome[7387] INFO basemachine:_finish_action motion [1588842275]: finished with event ":cancelled"
2026-09-25T15:56:21.145580+00:00 gfhome[7387] INFO gfhome:home motion completed
"""
WHOLE = "".join(
"2026-09-07T20:%s gfhome[28592] INFO machine:_motion start motion\n"
"2026-09-07T20:%s gfhome[28592] INFO machine:_feed_and_run end positions (actual/expected): "
"X (%d/%d), Y (%d/%d), Z (0/0)\n"
"2026-09-07T20:%s gfhome[28592] INFO machine:_motion_locked end positions (%d, %d, 0)\n"
"2026-09-07T20:%s gfhome[28592] INFO machine:_motion end motion\n"
"2026-09-07T20:%s gfhome[28592] INFO basemachine:_finish_action motion [%d]: finished with event "
"\":completed\"\n" % (t, t, x, x, y, y, t, x, y, t, t, i)
for i, (t, x, y) in enumerate((("25:50.1", 4774, 4600), ("25:56.2", -71, -144), ("26:02.9", -13096, -7399))))
class FakeGrbl:
def __init__(self, ctx):
self.ctx = ctx
def __enter__(self):
return self
def __exit__(self, *a):
return False
def command(self, line, timeout=None):
self.ctx.jogs.append(line)
return ["ok"]
class FakeCtx:
def __init__(self):
self.forgectrl = object()
self.jogs = []
self.lines = []
self.rezeroed = 0
def check(self, cond, msg, *args):
if not cond:
raise Failed(msg % args if args else msg)
def log(self, msg, *args):
self.lines.append(msg % args if args else msg)
def grbl(self):
return FakeGrbl(self)
def counters_rezeroed(self):
self.rezeroed += 1
class HandBackTests(unittest.TestCase):
def setUp(self):
self.dir = tempfile.mkdtemp()
self.log = os.path.join(self.dir, "gfhome.log")
self.cloudlog = os.path.join(self.dir, "gfcloud.log")
self.ctx = FakeCtx()
self.anchor = True
self.counters = [0, 0, 0]
self.restarts = 0
self.spm = 213.333 # the controller's x32 scale
saved_logs = (cloud.GFHOME_LOG, cloud.GFCLOUD_LOG)
self.addCleanup(lambda: setattr(cloud, "GFHOME_LOG", saved_logs[0]))
self.addCleanup(lambda: setattr(cloud, "GFCLOUD_LOG", saved_logs[1]))
cloud.GFHOME_LOG, cloud.GFCLOUD_LOG = self.log, self.cloudlog
patches = {
"counter_frame": lambda: [1, 2] if self.anchor else None,
"read_position": lambda: list(self.counters),
"counter_steps_per_mm": lambda: self.spm,
"_drop_reference": self._restart,
"clean_slate": lambda ctx, g: None,
"wait_idle": self._jog_ends,
"machine_idle": lambda ctx: None,
}
saved = {k: getattr(homeoff, k) for k in patches}
self.addCleanup(lambda: [setattr(homeoff, k, v) for k, v in saved.items()])
for k, v in patches.items():
setattr(homeoff, k, v)
self.addCleanup(shutil.rmtree, self.dir, True)
def _restart(self, ctx, fc):
# a controller start zeroes the counters where the head stands and
# removes the anchor
self.restarts += 1
self.counters = [0, 0, 0]
self.anchor = False
def _jog_ends(self, ctx, g, timeout=30.0):
# the jog played: the counters moved by what it asked, at the scale
words = dict((w[0], float(w[1:])) for w in self.ctx.jogs[-1].split()[2:4])
self.counters = [self.counters[0] + round(words["X"] * self.spm),
self.counters[1] + round(words["Y"] * self.spm), 0]
return 0.0, ["Jog", "Idle"], {}
def write(self, text, path=None):
with open(path or self.log, "a", encoding="utf-8", newline="\n") as f:
f.write(text)
def expect_jog(self, dx, dy):
self.assertEqual(self.ctx.jogs, ["$J=G91 G21 X%.3f Y%.3f F2400" % (-dx, -dy)])
# -- the travel ------------------------------------------------------
def test_the_travel_is_the_sum_of_the_motions_ends(self):
self.write(WHOLE)
self.assertEqual(homeoff.session_travel(0), (4774 - 71 - 13096, 4600 - 144 - 7399))
def test_the_travel_starts_at_the_mark(self):
self.write(WHOLE)
mark = homeoff.session_mark()
self.write(STOPPED)
self.assertEqual(homeoff.session_travel(mark), (4390, 2682))
def test_the_cloud_clients_log_is_read_the_same_way(self):
self.write(WHOLE.replace("gfhome[", "gfcloud["), self.cloudlog)
self.assertEqual(homeoff.session_travel(0, cloud.GFCLOUD_LOG), (4774 - 71 - 13096, 4600 - 144 - 7399))
self.assertEqual(homeoff.session_travel(0), (0, 0)) # gfhome's log is its own
def test_a_motion_with_no_end_leaves_the_travel_unknown(self):
self.write(STOPPED.split("machine:_motion_locked")[0])
self.assertIsNone(homeoff.session_travel(0))
def test_a_log_rotated_under_the_run_leaves_the_travel_unknown(self):
self.write(STOPPED)
self.assertIsNone(homeoff.session_travel(os.path.getsize(self.log) + 1))
# -- the stopped-short judge --------------------------------------------
def test_a_motion_stopped_short_fails_the_judge(self):
self.write(STOPPED)
ev = {}
with self.assertRaises(Failed):
homeoff.judge_whole_motions(self.ctx, ev, 0)
self.assertEqual(len(ev["short_motions"]), 2)
def test_whole_motions_pass_the_judge(self):
self.write(WHOLE)
ev = {}
homeoff.judge_whole_motions(self.ctx, ev, 0)
self.assertEqual(ev["short_motions"], [])
# -- after a camera home -------------------------------------------------
def test_homed_the_head_goes_back_by_the_travel_and_the_counters_since(self):
# the home zeroed the counters; the test's own jogs left a step or so
self.write(STOPPED)
self.counters = [round(1.0 * self.spm), 0, 0]
since = self.counters[0] / self.spm
homeoff.camera_home_return(self.ctx, {"homed": True}, 0)
self.expect_jog(4390 / homeoff.XY_STEPS_PER_MM + since, 2682 / homeoff.XY_STEPS_PER_MM)
# dropped before the jog, and once more with the head back
self.assertEqual(self.restarts, 2)
self.assertEqual(self.counters, [0, 0, 0])
self.assertEqual(self.ctx.rezeroed, 1)
def test_unhomed_the_counters_are_not_counted_twice(self):
# a failed session leaves the last motion's end in the counters,
# which the travel already counts
self.write(STOPPED)
self.counters = [4390, 2682, 0]
self.anchor = False
homeoff.camera_home_return(self.ctx, {"homed": False}, 0)
self.expect_jog(4390 / homeoff.XY_STEPS_PER_MM, 2682 / homeoff.XY_STEPS_PER_MM)
def test_an_unknown_travel_moves_nothing(self):
self.write(STOPPED.split("machine:_motion_locked")[0])
with self.assertRaises(Failed):
homeoff.camera_home_return(self.ctx, {"homed": True}, 0)
self.assertEqual(self.ctx.jogs, [])
self.assertEqual(self.ctx.rezeroed, 0)
def test_a_restart_since_the_home_moves_nothing(self):
# the anchor gone: the counters no longer read from the home
self.write(STOPPED)
self.anchor = False
with self.assertRaises(Failed):
homeoff.camera_home_return(self.ctx, {"homed": True}, 0)
self.assertEqual(self.ctx.jogs, [])
def test_a_head_that_never_moved_is_not_jogged(self):
homeoff.camera_home_return(self.ctx, {}, 0)
self.assertEqual(self.ctx.jogs, [])
self.assertEqual((self.restarts, self.ctx.rezeroed), (1, 1))
# -- after cloud mode ----------------------------------------------------
def test_after_cloud_mode_the_head_goes_back_by_the_clients_record(self):
# the service's re-hunt, cut short by the switch back: to the middle
# of the bed and one correction (the bench reference, 2026-09-25)
self.write(WHOLE[:WHOLE.index("2026-09-07T20:26:02.9")].replace("gfhome[", "gfcloud["), self.cloudlog)
ev = {}
homeoff.cloud_mode_return(self.ctx, ev, 0)
self.expect_jog((4774 - 71) / homeoff.XY_STEPS_PER_MM, (4600 - 144) / homeoff.XY_STEPS_PER_MM)
self.assertEqual(tuple(ev["cloud_return"]["session_travel_steps"]), (4774 - 71, 4600 - 144))
# no drop before the jog (the switch back started the controller), one after
self.assertEqual((self.restarts, self.ctx.rezeroed), (1, 1))
def test_after_cloud_mode_an_unknown_travel_moves_nothing(self):
self.write(STOPPED.replace("gfhome[", "gfcloud[").split("machine:_motion_locked")[0], self.cloudlog)
with self.assertRaises(Failed):
homeoff.cloud_mode_return(self.ctx, {}, 0)
self.assertEqual(self.ctx.jogs, [])
# -- the first failure wins --------------------------------------------
def test_a_hand_back_that_fails_after_the_test_failed_is_logged_not_raised(self):
self.write(STOPPED.split("machine:_motion_locked")[0])
ev = {"homed": True}
with self.assertRaises(Failed) as cm:
try:
raise Failed("the test's own failure")
finally:
homeoff.camera_home_return(self.ctx, ev, 0)
self.assertEqual(str(cm.exception), "the test's own failure")
self.assertIn("cannot be known", ev["return"]["failed"])
self.assertTrue(any("the head is not back" in l for l in self.ctx.lines), self.ctx.lines)
self.assertEqual(self.ctx.jogs, [])
self.assertIsNone(sys.exc_info()[1])
if __name__ == "__main__":
unittest.main()
+124 -3
View File
@@ -28,6 +28,7 @@ import time
import unittest
import helpers
from forgetest import baseline
from forgetest.runner import Context, Failed, Run
from forgetest.suite import cloud
@@ -185,7 +186,10 @@ class CloudSuiteTests(unittest.TestCase):
self.engine_line = EFFECTIVE_LINE # what the engine logs at the print; None = nothing
self.client_limits = True # the client names its header limits
cloud.QUIET_S = 0.4
cloud.QUIET_TIMEOUT_S = 3
# A deadline, not a wait: a quiet machine is seen at once. The
# replays land their lines from threads, so under a loaded host a
# tighter deadline expires before the last of them is written.
cloud.QUIET_TIMEOUT_S = 15
cloud.HUNT_TIMEOUT_S = 8
self.script = None
@@ -556,14 +560,51 @@ class CloudSuiteTests(unittest.TestCase):
self.append(fixture("huntlid"))
# a motion in flight (never idle within the timeout) fails, and says so
self.fc.state["status"]["state"] = "running"
cloud.QUIET_TIMEOUT_S = 3 # this one waits the deadline out; tearDown puts it back
self.assertFails(cloud.enter_cloud, "still running service moves")
def test_the_quiet_waits_out_the_service_thinking_after_a_lid_image(self):
# Between a lid image and the service's next move the log is silent
# while the service works on the image (the re-hunt on the bench
# reference, 2026-09-25). The quiet counts from the image's own
# lines, so it is not declared in that gap: it comes after the move.
cloud.QUIET_S = 1.0
offset = cloud.log_size(self.log)
p = "2026-09-25T16:43:%s+00:00 gfcloud[12821] INFO "
self.append([p % "20.103479" + "gfuiservice:run service action request: motion (ready)",
p % "20.105376" + "machine:_motion start motion",
p % "22.617720" + "machine:_motion_locked end positions (12120, 7402, 0)",
p % "22.618542" + "machine:_motion end motion",
p % "22.619218" + "basemachine:_finish_action motion [1588864337]: finished with event "
"\":completed\""])
self.append([p % "23.249589" + "gfuiservice:run service action request: lid_image (ready)",
p % "24.686536" + "websocket:img_upload COMPLETE"], delay=0.8)
self.append([p % "29.667978" + "gfuiservice:run service action request: motion (ready)",
p % "29.671663" + "machine:_motion start motion"], delay=1.6)
run = Run("test", "cloud.x", "cloud.x")
ctx = Context(run, None, helpers.make_test("cloud.x", []))
self.assertTrue(cloud.wait_quiet(ctx, offset))
self.assertIn("service action request: motion", cloud.log_lines_since(self.log, offset)[-2])
# -- the hunt with the lid open ------------------------------------------
# -- the mode switch: hunt with the lid open, then $H -----------------------
HUNT_RUN_SAMPLE = {"phase": "run", "verdict": "ok", "armed": False,
"fan_gates": {"exhaust": {"state": "unjudged", "reading": 0, "floor": 500}}}
# A homing session's motions as gfhome logs them (the bench reference,
# 2026-09-25): a small correction, then into the camera home.
HOME_SESSION = [
"2026-09-25T16:43:45.374951+00:00 gfhome[12982] INFO machine:_motion start motion",
"2026-09-25T16:43:45.853976+00:00 gfhome[12982] INFO machine:_motion_locked end positions (-13, -13, 0)",
"2026-09-25T16:43:45.854819+00:00 gfhome[12982] INFO machine:_motion end motion",
"2026-09-25T16:43:45.856076+00:00 gfhome[12982] INFO basemachine:_finish_action motion [1588864529]: "
"finished with event \":completed\"",
"2026-09-25T16:43:51.600734+00:00 gfhome[12982] INFO machine:_motion start motion",
"2026-09-25T16:43:53.605402+00:00 gfhome[12982] INFO machine:_motion_locked end positions (-13095, -7399, 0)",
"2026-09-25T16:43:53.606171+00:00 gfhome[12982] INFO machine:_motion end motion",
"2026-09-25T16:43:53.606916+00:00 gfhome[12982] INFO basemachine:_finish_action motion [1588864567]: "
"finished with event \":completed\""]
def mode_switch_setup(self, hunt_lines=None, home_complete=True, lid_late=False):
def mode_switch_setup(self, hunt_lines=None, home_complete=True, lid_late=False, home_lines=None):
"""The fakes a mode-switch run needs: grbl to answer $H, the lid
lamp attr, homing_mode = gfcloud, the service lines landing on
the switch to cloud - the client's start at once, its session and
@@ -572,11 +613,16 @@ class CloudSuiteTests(unittest.TestCase):
reads as a run to the cooling engine while it lasts) - the re-hunt
on the lid close, and gfhome finishing the homing after $H. With
lid_late the hunt is requested before the lid opens: the race the
test must call."""
test must call. The step counters are the machine's: a controller
start zeroes them and removes the homing anchor, the home zeroes
them and writes it, and a jog moves them by what it asked."""
self.grbl = helpers.FakeGrbl().start()
os.makedirs(self.sysfs + "pic", exist_ok=True)
self._attr("pic/lid_led", "236")
self.fc.state["settings"]["homing_mode"] = "gfcloud"
anchor = os.path.join(self.tmp, "grblhal.homed")
self.addCleanup(setattr, baseline, "ANCHOR_PATH", baseline.ANCHOR_PATH)
baseline.ANCHOR_PATH = anchor
lines = fixture("huntlid")
pre, post = cut(lines, "gfuiservice:__init__ INITIALIZED")
hunt_part, close_part = cut(post, "_switch_event lid closed")
@@ -584,6 +630,20 @@ class CloudSuiteTests(unittest.TestCase):
hunt_part = hunt_lines(hunt_part)
fc = self.fc
def controller_start():
self._pos(0, 0, 3)
if os.path.exists(anchor):
os.remove(anchor)
def jog(line):
if line.startswith("$J=G91"):
words = dict((w[0], float(w[1:])) for w in line[3:].split() if w[0] in "XY")
with open(self.sysfs + "cnc/position", "rb") as f:
x, y, z = struct.unpack("<3i", f.read(12))
self._pos(x + round(words.get("X", 0.0) * baseline.XY_STEPS_PER_MM),
y + round(words.get("Y", 0.0) * baseline.XY_STEPS_PER_MM), z)
self.grbl.on_command = jog
def hunt():
self.append(hunt_part, delay=0.0)
time.sleep(4.0) # the hunt outlasts the session wait on the bench
@@ -604,7 +664,14 @@ class CloudSuiteTests(unittest.TestCase):
hunt()
threading.Thread(target=land, daemon=True).start()
elif path == "/mode" and form.get("controller") == "grbl":
controller_start()
self.grbl.state = "Idle"
elif path == "/controller/stop":
fc.state["mode"] = dict(fc.state["mode"], controller="standby", pid=0)
elif path == "/controller/start":
controller_start()
fc.state["mode"] = dict(fc.state["mode"], controller="running", pid=4300, motion="verified")
fc.state["status"]["homed"] = False
return None
self.fc.on_post = on_post
@@ -619,7 +686,11 @@ class CloudSuiteTests(unittest.TestCase):
def homing():
self.grbl.state = "Home"
with open(self.homelog, "ab") as f:
f.write(("\n".join(home_lines or self.HOME_SESSION) + "\n").encode())
time.sleep(0.4)
self._pos(0, 0, 3)
open(anchor, "w").close()
self.grbl.state = "Idle"
self.fc.state["status"]["homed"] = True
if home_complete:
@@ -654,6 +725,39 @@ class CloudSuiteTests(unittest.TestCase):
self.assertEqual([r["state"] for r in ev["actions"]], ["open", "close"])
self.assertEqual(self.script.asked, [])
self.assertTrue(any("PASS:" in l for l in run.lines), run.lines[-5:])
# The head goes back where the test found it, each stretch by its
# client's record: the re-hunt of the excerpt ends 2/7 steps from
# where the cloud mode began, and the homing went into the corner.
self.assertEqual(tuple(ev["cloud_return"]["session_travel_steps"]), (2, 7))
self.assertEqual(tuple(ev["return"]["session_travel_steps"]), (-13108, -7412))
spm = baseline.XY_STEPS_PER_MM
self.assertEqual([l for l in self.grbl.sent if l.startswith("$J=")],
["$J=G91 G21 X%.3f Y%.3f F2400" % (-2 / spm, -7 / spm),
"$J=G91 G21 X%.3f Y%.3f F2400" % (13108 / spm, 7412 / spm)])
# a start after each jog, and the camera home dropped before the second
self.assertEqual([p for p, f in self.fc.posts if p.startswith("/controller/")],
["/controller/stop", "/controller/start"] * 3)
with open(self.sysfs + "cnc/position", "rb") as f:
self.assertEqual(struct.unpack("<3i", f.read(12))[:2], (0, 0))
def test_mode_switch_a_hand_back_that_fails_too_does_not_hide_the_failure(self):
# gfhome never said it homed, and its last motion has no end on
# record: the travel is unknown, the head is not moved, and the
# failure reported is the test's own
hooks = self.mode_switch_setup(home_complete=False, home_lines=self.HOME_SESSION[:5])
threading.Thread(target=self.wait_home_command, daemon=True).start()
self.assertFails(cloud.mode_switch, "no 'homing complete' line", hooks=hooks)
run = self.script.run
self.assertTrue(any("the head is not back" in l for l in run.lines), run.lines[-6:])
self.assertIn("cannot be known", run.evidence["return"]["failed"])
self.assertEqual(len([l for l in self.grbl.sent if l.startswith("$J=")]), 1) # the cloud stretch's
def test_mode_switch_fails_when_the_travel_in_cloud_mode_cannot_be_known(self):
# the hunt's motion has no end on record (a client killed inside it)
hooks = self.mode_switch_setup(
hunt_lines=lambda part: [l for l in part if not l.endswith("machine:_motion end motion")])
self.assertFails(cloud.mode_switch, "the travel in cloud mode cannot be known", hooks=hooks)
self.assertEqual([l for l in self.grbl.sent if l.startswith("$J=")], [])
def test_mode_switch_opens_the_lid_behind_the_controller_and_ahead_of_the_hunt(self):
# No controller starts with the enclosure open: the switch is made
@@ -1003,6 +1107,23 @@ class CloudSuiteTests(unittest.TestCase):
hooks = {"press it. The print runs dark": lambda: self.append(run, delay=0.05)}
self.assertFails(cloud.dark_print, "the latch is unlocked at the button wait", hooks=hooks)
def test_a_print_turned_away_before_the_button_fails_at_once_with_the_reason(self):
"""The bench reference's own lines, 2026-09-20: the coolant read 27.4
against the client's start ceiling of 27, and the test then sat out
its two minutes and said only that the button wait never came."""
self.in_offline()
stamp = "2026-09-20T21:28:16.83+00:00 gfcloud[782] INFO "
refused = [stamp + "gfuiservice:run service action request: print (ready)",
stamp + "machine:_motion start motion",
stamp + "machine:_safe_to_move machine temp is too high, temp: 27.4",
stamp + "machine:_motion end motion",
stamp + 'basemachine:_finish_action print [9005]: finished with event ":cancelled"']
self.offline_print_hooks(refused)
t0 = time.time()
self.assertFails(cloud.dark_print, "turned the print away before the button wait: "
"INFO machine:_safe_to_move machine temp is too high, temp: 27.4")
self.assertLess(time.time() - t0, 30, "the refusal was not seen until the wait timed out")
def test_verdict_refuse_on_the_bench_excerpt(self):
self.in_offline()
self.fc.state["status"]["coolant"] = {"up_c": 21.5, "down_c": 21.4}
@@ -0,0 +1,176 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""cooling.intake-one-fan replayed host-side under the real runner
Context against a scripted engine: the fake reads cool_intake_fans and
the floors at every M8 (the engine reloads at run start), publishes
intake_2 not fitted with no floor when one intake fan is set, holds every
fan in grace for the configured seconds, then judges the bench readings
against the floors. The negative engines are the two ways the change
could go wrong: the setting ignored (intake 2 still judged) and the
setting taking intake 1 out with it."""
import threading
import time
import unittest
import helpers
from forgetest.runner import Context, Failed, Run
from forgetest.suite import cooling, cooling_intake
from test_cooling_suite import FakeGrbl
class IntakeOneFanTests(unittest.TestCase):
READINGS = {"exhaust": 11640, "intake_1": 4157, "intake_2": 4158, "air_assist": 11050, "purge": 625}
FLOORS = {"exhaust": ("cool_tach_exhaust_min_rpm", 6400.0, 20000.0),
"intake_1": ("cool_tach_intake_min_rpm", 2290.0, 20000.0),
"intake_2": ("cool_tach_intake_min_rpm", 2290.0, 20000.0),
"air_assist": ("cool_tach_air_assist_min_rpm", 6000.0, 30000.0),
"purge": ("cool_purge_min_current", 300.0, 1023.0)}
GATE_OF = {"exhaust": "exhaust", "intake_1": "intake", "intake_2": "intake",
"air_assist": "air_assist", "purge": "purge"}
def setUp(self):
self.fc = helpers.FakeForgectrl().start()
self.grbl = FakeGrbl()
self.saved = (cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S, cooling_intake.INTAKE_TRIP_WAIT_S)
cooling.VERDICT_WAIT_S = 4
cooling.SESSION_END_WAIT_S = 3
cooling_intake.INTAKE_TRIP_WAIT_S = 4
self.fc.state["status"] = dict(self.fc.state["status"], gates_off=[])
self.fc.state["cool"] = {"phase": "idle", "verdict": "OK", "fire_ok": False, "hold": False,
"gates_off": [], "fan_gates": {}}
for key, _d, _h in self.FLOORS.values():
self.fc.state["settings"].setdefault(key, "")
self.fc.state["settings"].setdefault("cool_fan_grace_s", "")
self.fc.state["settings"].setdefault("cool_intake_fans", "")
self.honors_setting = True # the engine takes intake 2 out with one intake fan
self.drops_both = False # the engine takes intake 1 out with it
self.grace_scale = 0.1 # seconds of fake grace per configured second
self._describe()
self.grbl.on_command = self._engine
self.fc.on_post = self._on_post
def tearDown(self):
cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S, cooling_intake.INTAKE_TRIP_WAIT_S = self.saved
self.grbl.close()
self.fc.stop()
def setting(self, key, default):
v = self.fc.state["settings"].get(key) or ""
return float(v) if v else default
def _describe(self):
gates = {}
for fan, (key, default, hi) in self.FLOORS.items():
v = self.setting(key, default)
gates[key] = {"gate": self.GATE_OF[fan], "def": default, "lo": 0.0, "hi": hi,
"band": [default * 0.9, default * 1.1], "off": "low", "value": v,
"state": "off" if v <= 0 else "ok"}
self.fc.state["settings"]["gates"] = gates
def _on_post(self, path, form):
if path != "/settings":
return None
self.fc.state["settings"].update(form)
self._describe()
return (200, self.fc.state["settings"])
def _engine(self, line):
self._describe()
cool = self.fc.state["cool"]
if line == "M9":
def end():
time.sleep(0.3)
cool["phase"] = "idle"
cool.update(verdict="OK", fire_ok=True, hold=False, reason="")
for g in cool.get("fan_gates", {}).values():
if g["state"] != "not fitted":
g["state"] = "idle"
threading.Thread(target=end, daemon=True).start()
return
if line != "M8":
return
cool["phase"] = "run"
one = self.fc.state["settings"].get("cool_intake_fans") == "1" and self.honors_setting
out = {"intake_2"} | ({"intake_1"} if self.drops_both else set()) if one else set()
floors = {fan: 0.0 if fan in out else self.setting(key, default)
for fan, (key, default, _h) in self.FLOORS.items()}
cool.update(verdict="OK", fire_ok=True, hold=False, resume_ok=True, reason="", gates_off=[],
fan_gates={f: {"reading": self.READINGS[f], "floor": floors[f],
"state": "not fitted" if f in out else "grace"} for f in floors})
grace = self.setting("cool_fan_grace_s", 15.0) * self.grace_scale
def judge():
time.sleep(grace)
if cool["phase"] != "run":
return
for f in floors:
if f not in out:
cool["fan_gates"][f]["state"] = "ok" if self.READINGS[f] >= floors[f] else "under"
time.sleep(0.3)
if cool["phase"] != "run":
return
for f in floors:
if f not in out and floors[f] > self.READINGS[f]:
cool["fan_gates"][f]["state"] = "TRIPPED"
if cool["verdict"] != "AIRFLOW":
cool.update(verdict="AIRFLOW", fire_ok=False, hold=True, resume_ok=False,
reason="AIRFLOW: %s %d under the %d floor for 3 s - hold, no resume this job"
% (f, self.READINGS[f], floors[f]))
threading.Thread(target=judge, daemon=True).start()
def run_test(self):
run = Run("test", "cooling.intake-one-fan", "t")
ctx = Context(run, None, helpers.make_test("cooling.intake-one-fan", []))
cooling_intake.intake_one_fan(ctx)
return run
def settings_posts(self):
return [f for p, f in self.fc.posts if p == "/settings"]
def restored(self):
return {"cool_intake_fans": "", "cool_tach_intake_min_rpm": "", "cool_fan_grace_s": ""}
def test_one_fan_then_intake_1_trip_then_restore_pass(self):
run = self.run_test()
ev = run.evidence
self.assertEqual(ev["one_fan"]["intake_2"]["state"], "not fitted")
self.assertEqual(ev["one_fan"]["intake_1"]["state"], "ok")
self.assertEqual(ev["intake_1_trip"]["intake_1"]["state"], "TRIPPED")
self.assertEqual(ev["intake_1_trip"]["intake_2"]["state"], "not fitted")
self.assertTrue(all(g["state"] == "ok" for g in ev["restored"].values()))
posts = self.settings_posts()
self.assertEqual(posts[0], {"cool_intake_fans": "1", "cool_fan_grace_s": "8"})
self.assertEqual(posts[1], {"cool_tach_intake_min_rpm": "20000.0"})
self.assertEqual(posts[-1], self.restored())
self.assertEqual(self.grbl.commands.count("M8"), 3)
self.assertEqual(self.grbl.commands.count("M9"), 3)
def test_an_engine_that_ignores_the_setting_fails_and_restores(self):
self.honors_setting = False
with self.assertRaises(Failed) as cm:
self.run_test()
self.assertIn("intake_2 is not published not fitted", str(cm.exception))
self.assertEqual(self.settings_posts()[-1], self.restored())
self.assertEqual(self.fc.state["cool"]["verdict"], "OK")
def test_an_engine_that_drops_intake_1_too_fails_and_restores(self):
self.drops_both = True
with self.assertRaises(Failed) as cm:
self.run_test()
self.assertIn("intake_1 is not judged ok at the intake floor", str(cm.exception))
self.assertEqual(self.settings_posts()[-1], self.restored())
def test_a_machine_already_at_one_intake_fan_is_refused_untouched(self):
self.fc.state["settings"]["cool_intake_fans"] = "1"
with self.assertRaises(Failed) as cm:
self.run_test()
self.assertIn("already 1", str(cm.exception))
self.assertEqual(self.settings_posts(), [])
self.assertEqual(self.grbl.commands.count("M8"), 0)
if __name__ == "__main__":
unittest.main()
+43 -6
View File
@@ -26,7 +26,28 @@ import unittest
import helpers
from forgetest.runner import Context, Failed, Run
from forgetest.suite import cooling
from forgetest.suite import cooling, motion
class EngineLog:
"""The engine's log lines, as the tests read them: appended to a scratch
forgectrl log (a check reads the lines written after its mark) and to the
fake /logs/tail."""
def log_setup(self):
self.logdir = tempfile.mkdtemp(prefix="forgetest-cool-")
self.saved_log = motion.FORGECTRL_LOG
motion.FORGECTRL_LOG = os.path.join(self.logdir, "forgectrl.log")
open(motion.FORGECTRL_LOG, "w").close()
def log_teardown(self):
motion.FORGECTRL_LOG = self.saved_log
shutil.rmtree(self.logdir, ignore_errors=True)
def emit(self, text):
self.fc.state.setdefault("logs_tail", {"text": ""})["text"] += text
with open(motion.FORGECTRL_LOG, "a", encoding="utf-8") as f:
f.write(text)
class FakeGrbl:
@@ -256,7 +277,7 @@ if __name__ == "__main__":
unittest.main()
class GateOffTests(unittest.TestCase):
class GateOffTests(EngineLog, unittest.TestCase):
"""cooling.gate-off against a scripted engine: the fake forgectrl
re-reads the ceiling at every M8 (as the engine reloads its tunables
at run start), trips OVERTEMP when the coolant is over it, skips the
@@ -267,6 +288,7 @@ class GateOffTests(unittest.TestCase):
def setUp(self):
self.fc = helpers.FakeForgectrl().start()
self.log_setup()
self.grbl = FakeGrbl()
self.saved = (cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S)
cooling.VERDICT_WAIT_S = 3
@@ -292,6 +314,7 @@ class GateOffTests(unittest.TestCase):
cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S = self.saved
self.grbl.close()
self.fc.stop()
self.log_teardown()
# -- the scripted machine --------------------------------------------------
def ceiling(self):
@@ -327,7 +350,7 @@ class GateOffTests(unittest.TestCase):
time.sleep(0.3)
cool["phase"] = "smoke"
if self.temps_line:
self.fc.state["logs_tail"]["text"] += (
self.emit(
"Aug 22 12:00:30 forgectrl: cool: temps this job: chassis 29.0..29.4 C, "
"soc 42.8..47.1 C, supply raw 587..592\n")
time.sleep(0.2)
@@ -341,7 +364,7 @@ class GateOffTests(unittest.TestCase):
v = self.ceiling()
off = v >= self.TOP
if off and self.log_line:
self.fc.state["logs_tail"]["text"] += (
self.emit(
"Aug 21 12:00:00 forgectrl: cool: gate coolant_max OFF: cool_temp_max = 60 "
"(the high end of 5 to 60; recommended 25 to 38, default 33)\n")
gates_off = ["coolant_max"] if off and self.report_off else []
@@ -418,6 +441,18 @@ class GateOffTests(unittest.TestCase):
self.assertIn("run-start log line", str(cm.exception))
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "")
def test_an_earlier_runs_line_is_not_this_runs(self):
# The off gate's line from a run before this one is in the log, and
# the engine writes none this time: the line judged is one written
# after the test's own mark, so the old one does not pass for it.
self.emit("Aug 21 11:00:00 forgectrl: cool: gate coolant_max OFF: cool_temp_max = 60 "
"(the high end of 5 to 60; recommended 25 to 38, default 33)\n")
self.log_line = False
with self.assertRaises(Failed) as cm:
self.run_test()
self.assertIn("run-start log line", str(cm.exception))
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "")
def test_a_missing_run_end_temperature_line_fails(self):
self.temps_line = False
with self.assertRaises(Failed) as cm:
@@ -433,7 +468,7 @@ class GateOffTests(unittest.TestCase):
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "30")
class CriticalTierTests(unittest.TestCase):
class CriticalTierTests(EngineLog, unittest.TestCase):
"""cooling.critical-tier against a scripted engine: two tiers on the
coolant, the fail tier winning over the pause tier in a run session
and ending with it, the critical line off at its top, and the settings
@@ -448,6 +483,7 @@ class CriticalTierTests(unittest.TestCase):
def setUp(self):
self.fc = helpers.FakeForgectrl().start()
self.log_setup()
self.grbl = FakeGrbl()
self.saved = (cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S)
cooling.VERDICT_WAIT_S = 3
@@ -471,6 +507,7 @@ class CriticalTierTests(unittest.TestCase):
cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S = self.saved
self.grbl.close()
self.fc.stop()
self.log_teardown()
def setting(self, key):
v = self.fc.state["settings"].get(key) or ""
@@ -523,7 +560,7 @@ class CriticalTierTests(unittest.TestCase):
crit_off = tcrit >= self.ROWS["cool_temp_critical_c"][3]
off = ["coolant_critical"] if crit_off else []
if crit_off:
self.fc.state["logs_tail"]["text"] += (
self.emit(
"Aug 22 12:00:00 forgectrl: cool: gate coolant_critical OFF: cool_temp_critical_c = 70 "
"(the high end of 6 to 70; recommended 36 to 45, default 38)\n")
if self.faults and not crit_off and self.UP >= tcrit:
+24 -2
View File
@@ -207,6 +207,10 @@ class ClientTests(unittest.TestCase):
f.act("interlock", "open")
f.act("button", "press")
self.assertEqual(self.dev.calls[-1], ("POST", "/button", {}))
time.sleep(0.15)
# a check that must reach the machine while the button is down asks for the longest press
f.act("button", "press", ms=500)
self.assertEqual(self.dev.calls[-1], ("POST", "/button", {"ms": 500}))
time.sleep(0.15) # the fake's pulse ends
self.assertEqual(sorted(fx.Fixture.energized(f.status())), ["interlock", "lid"])
f.release()
@@ -310,6 +314,24 @@ class CatalogTests(unittest.TestCase):
with self.assertRaises(ValueError):
catalog.test("z.auto", title="t", subsystem="z", hands=("app",))(lambda ctx: None)
def test_every_action_a_test_takes_is_declared(self):
# The queues route a test by its kind and its actions: an auto test
# runs unattended with or without a fixture, and an operator test
# whose actions the fixture covers runs unattended only while it
# does. A test that acts on the lid, the interlock or the button
# without saying so runs unattended with no fixture and waits for a
# person who is not there.
import inspect
import re
from forgetest import catalog
bad = []
for t in catalog.all_tests(catalog.load_suite()):
src = inspect.getsource(t.fn)
taken = set(re.findall(r'ctx\.act\(\s*"(\w+)"', src))
if taken and (t.kind == "auto" or not taken <= set(t.actions)):
bad.append("%s (%s) acts on %s, declares %s" % (t.id, t.kind, sorted(taken), list(t.actions)))
self.assertEqual(bad, [])
class StubFixture:
"""What the runner needs of a fixture, scripted."""
@@ -330,10 +352,10 @@ class StubFixture:
def covers(self, channel):
return channel in self.channels and (channel != "button" or self.button_enabled)
def act(self, channel, state):
def act(self, channel, state, ms=None):
if self.fail:
raise fx.FixtureError("the box is off")
self.acts.append((channel, state))
self.acts.append((channel, state) if ms is None else (channel, state, ms))
if state == "open":
self.held.append(channel)
if self.fc is not None and channel in ("lid", "interlock"):
+105
View File
@@ -0,0 +1,105 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The dark judge of laser.verdict-cut over synthetic trails. The judge is
a function inside the test's own body, so a change to it moves that test's
fingerprint and no other laser test's; it is reached here through the test
function's code object. A trail row is a status poll; its `fast` entry is
the burst of cnc/laser_on reads taken after that poll: [start, end, reads,
reads that saw the laser on, reads that failed]."""
import types
import unittest
from forgetest.suite import laser
DRAIN_S = 0.3
def dark_span():
code = next(c for c in laser.verdict_cut.__code__.co_consts
if isinstance(c, types.CodeType) and c.co_name == "dark_span")
assert code.co_freevars == (), "the judge must not close over the test's variables"
return types.FunctionType(code, vars(laser))
def row(t, gstate, on=None, n=350, bad=0):
r = {"t": t, "gstate": gstate}
if on is not None:
r["fast"] = [round(t + 0.01, 3), round(t + 0.11, 3), n, on, bad]
return r
class DarkSpanTests(unittest.TestCase):
def setUp(self):
self.judge = dark_span()
def hold(self, lit_at=(), tail_lit=0):
"""Run, then a hold from 2.0 s (Hold:0) polled every 0.14 s with a
burst after each poll, then the resume at 3.4 s. lit_at: the
poll times whose burst saw the laser on; tail_lit: the burst
after the last Hold poll, which the resume ends."""
rows = [row(1.72, "Run"), row(1.86, "Run")]
t = 2.0
while t < 3.3:
rows.append(row(round(t, 2), "Hold:0", on=5 if round(t, 2) in lit_at else 0))
t += 0.14
rows[-1]["fast"][3] = tail_lit
rows.append(row(3.4, "Run"))
return rows
def test_a_dark_hold_is_judged_dark(self):
d = self.judge(self.hold(), DRAIN_S)
reads, on, bad, bursts, first, last = d
self.assertEqual((on, bad), (0, 0))
self.assertGreater(bursts, 0)
self.assertEqual(reads, bursts * 350)
self.assertGreaterEqual(first, 2.0 + DRAIN_S)
self.assertLessEqual(last, 3.4)
def test_the_lit_deceleration_inside_the_drain_is_not_judged(self):
# the pause tier's first deceleration plays out of the kernel's
# queue after Hold:0: lit bursts inside the drain are expected
d = self.judge(self.hold(lit_at=(2.0, 2.14)), DRAIN_S)
self.assertEqual(d[1], 0)
def test_emission_after_the_drain_is_caught(self):
# the negative control: a lit read inside the judged span is seen
d = self.judge(self.hold(lit_at=(2.56,)), DRAIN_S)
self.assertEqual(d[1], 5)
def test_a_burst_the_resume_ends_is_not_judged(self):
# the poll after it reads Run: the cut may have relit inside it
d = self.judge(self.hold(tail_lit=40), DRAIN_S)
self.assertEqual(d[1], 0)
def test_failed_reads_are_counted(self):
rows = self.hold()
rows[5]["fast"][4] = 3
self.assertEqual(self.judge(rows, DRAIN_S)[2], 3)
def test_the_drain_counts_from_the_first_hold_complete(self):
# Hold:1 (decelerating) rows do not start the span: a lit burst
# after a Hold:1 poll but inside the drain of the first Hold:0 is
# not judged
rows = [row(1.8, "Run"), row(1.94, "Hold:1", on=30), row(2.08, "Hold:0", on=12),
row(2.22, "Hold:0", on=0), row(2.36, "Hold:0", on=0), row(2.5, "Hold:0", on=0),
row(2.64, "Hold:0", on=0), row(2.78, "Run")]
reads, on, bad, bursts, first, last = self.judge(rows, DRAIN_S)
self.assertEqual(on, 0)
self.assertGreaterEqual(first, 2.08 + DRAIN_S)
# 2.51 only: 2.37 is inside the drain, 2.65 is ended by the resume
self.assertEqual(bursts, 1)
def test_no_hold_complete_cannot_be_judged(self):
self.assertIsNone(self.judge([row(1.0, "Run"), row(1.2, "Hold:1", on=0), row(1.4, "Run")], DRAIN_S))
def test_a_hold_too_short_for_the_drain_leaves_nothing_to_judge(self):
# the test then fails on too few reads, never passes on none
rows = [row(1.8, "Run"), row(2.0, "Hold:0", on=0), row(2.14, "Hold:0", on=0), row(2.28, "Run")]
self.assertEqual(self.judge(rows, DRAIN_S)[:4], [0, 0, 0, 0])
if __name__ == "__main__":
unittest.main()
+28 -4
View File
@@ -120,6 +120,19 @@ class CoverageReportTests(unittest.TestCase):
self.assertNotIn("forgetest", m.coverage_report(man, [], allow=[]),
"dev-only components are outside the report")
def test_behavioral_paths_are_never_allowed_away(self):
# An advisory document is behavior (the consent is to its hash): the allowlist's docs/** and
# **/*.md do not take it out of the report, as they do not take it out of a fingerprint.
man = helpers.with_file(helpers.make_manifest(), "forgectrl", "docs/advisories/privacy.md", "text")
rest = helpers.make_test("a.one", [("forgectrl", "src/**"), ("grblhal-glowforge", "**"),
("kernel-module-glowforge", "**"), ("linux-fslc", "**")])
self.assertEqual(m.coverage_report(man, [rest]), {"forgectrl": ["docs/advisories/privacy.md"]})
doc = helpers.make_test("a.two", [("forgectrl", "docs/advisories/privacy.md")])
self.assertEqual(m.coverage_report(man, [rest, doc]), {})
self.assertNotEqual(m.fingerprint(man, doc.covers),
m.fingerprint(helpers.with_file(man, "forgectrl", "docs/advisories/privacy.md", "new"),
doc.covers))
def test_non_behavioral_paths_are_outside_every_fingerprint(self):
man = helpers.make_manifest()
t = helpers.make_test("a.one", [("forgectrl", "**")])
@@ -132,6 +145,17 @@ class CoverageReportTests(unittest.TestCase):
t.covers))
self.assertTrue(m.non_behavioral("forgectrl", "tools/devserver.py"))
self.assertFalse(m.non_behavioral("grblhal-glowforge", "tools/devserver.py"))
# forgeext's image content is its binary and init script: the kit, the template, the host tools
# and the shared package workflow are not, and the host's sources are
for path in ("sdk/js/ffx-bridge.js", "template/manifest.json", "tools/ffx", ".github/actions/kit/action.yml"):
self.assertTrue(m.non_behavioral("forgeext", path), path)
for path in ("src/api.c", "init/forgeext.init", "CMakeLists.txt"):
self.assertFalse(m.non_behavioral("forgeext", path), path)
# an advisory is a document forgectrl serves and records consent to by its hash: behavior, though
# it is Markdown under docs/
self.assertFalse(m.non_behavioral("forgectrl", "docs/advisories/extensions.md"))
self.assertTrue(m.non_behavioral("forgectrl", "docs/SERVICES.md"))
self.assertTrue(m.non_behavioral("grblhal-glowforge", "docs/advisories/x.md"))
def test_an_entry_that_selects_nothing_is_reported(self):
man = helpers.make_manifest()
@@ -219,7 +243,7 @@ def two(ctx):
shutil.rmtree(self.tmp, ignore_errors=True)
def write(self, text):
catalog._PARTS.pop(self.path, None)
catalog.forget(self.path)
with open(self.path, "w", newline="\n") as f:
f.write(text)
@@ -254,12 +278,12 @@ def two(ctx):
sib = os.path.join(self.tmp, "judge.py")
with open(sib, "w", newline="\n") as f:
f.write("def judge(x):\n return x > 1\n")
catalog._PARTS.pop(sib, None)
catalog.forget(sib)
self.write("from .judge import judge\n" + self.MODULE)
a1, b1 = self.shas()
with open(sib, "w", newline="\n") as f:
f.write("def judge(x):\n return x > 2\n")
catalog._PARTS.pop(sib, None)
catalog.forget(sib)
a2, b2 = self.shas()
self.assertNotEqual(a1, a2)
self.assertNotEqual(b1, b2)
@@ -271,7 +295,7 @@ def two(ctx):
def test_line_endings_do_not_count(self):
a1, b1 = self.shas()
catalog._PARTS.pop(self.path, None)
catalog.forget(self.path)
with open(self.path, "wb") as f:
f.write(self.MODULE.replace("\n", "\r\n").encode())
self.assertEqual((a1, b1), self.shas())
+2 -2
View File
@@ -141,8 +141,8 @@ class ActTests(unittest.TestCase):
def covers(self, channel):
return channel == "lid"
def act(self, channel, state):
self.done.append((channel, state))
def act(self, channel, state, ms=None):
self.done.append((channel, state) if ms is None else (channel, state, ms))
self_outer.lid(state != "open")
self_outer = self
+62 -1
View File
@@ -5,12 +5,16 @@
"""What keeps the page answering the operator instead of the timer.
Two things went wrong on the bench and are pinned here:
Three things went wrong on the bench and are pinned here:
- the state cost. Every poll re-read and re-parsed the whole result log,
and recomputed every test's domain fingerprint. A result record carries
its run log, so the file reaches megabytes over a campaign and the poll
grew with it. Both are now parsed and computed once.
- the first state's cost. Hashing the implementations parsed a test's
module and every module it imports once per test, and each poll the
page timed out on started the same work again beside the first. Each
module is now parsed once, and a poll waits for the work in progress.
- the wasted payload. An idle page polls an unchanged state; it now gets
a 304 instead of the whole thing.
@@ -171,6 +175,63 @@ class FingerprintCacheTests(unittest.TestCase):
self.assertIsNone(self.man.files("no-such-component"))
class ImplementationCostTests(unittest.TestCase):
"""The first state after a start hashes every test's implementation.
It parses each suite module once, and a poll that arrives while it
runs waits for it: on the board a parse per test took minutes, and
the page's timed-out polls each started the work again beside it."""
def test_each_suite_module_is_parsed_once(self):
import ast
import inspect
reg = catalog.load_suite()
paths = sorted({inspect.getsourcefile(t.fn) for t in catalog.all_tests(reg)})
for p in os.listdir(catalog.suite_dir()):
catalog.forget(os.path.join(catalog.suite_dir(), p))
for p in paths:
catalog.forget(p)
real_parse, parsed = ast.parse, []
def counting_parse(source, *a, **kw):
parsed.append(source[:40])
return real_parse(source, *a, **kw)
ast.parse = counting_parse
try:
for t in catalog.all_tests(reg):
catalog.implementation_sha(inspect.getsourcefile(t.fn), t.id)
finally:
ast.parse = real_parse
modules = [p for p in os.listdir(catalog.suite_dir()) if p.endswith(".py")]
self.assertLessEqual(len(parsed), len(modules),
"%d parses for %d suite modules" % (len(parsed), len(modules)))
def test_a_concurrent_fill_waits_instead_of_repeating(self):
import threading
import time
t = helpers.make_test("fake.impl", [("forgectrl", "src/ui.c")], fn=t_noop)
real, calls = catalog.implementation_sha, []
def slow(path, test_id):
calls.append(test_id)
time.sleep(0.3)
return real(path, test_id)
catalog.implementation_sha = slow
try:
got = []
threads = [threading.Thread(target=lambda: got.append(t.source_sha)) for _ in range(3)]
for th in threads:
th.start()
for th in threads:
th.join(10)
finally:
catalog.implementation_sha = real
self.assertEqual(calls, ["fake.impl"], "the implementation hash was computed %d times" % len(calls))
self.assertEqual(len(set(got)), 1)
self.assertEqual(len(got), 3)
class PageTests(unittest.TestCase):
def test_page_never_rebuilds_what_the_operator_may_be_pressing(self):
"""A poll must update rows, prompt buttons and tool entries in
+119
View File
@@ -137,5 +137,124 @@ class SensorsCheckTests(unittest.TestCase):
self.assertIn(("/wiz/sensors/abort", {}), [(p, f) for p, f in self.fake.posts])
class AirflowCheckTests(unittest.TestCase):
"""setup.check-airflow's judge against a scripted daemon: the check
writes its floors and the intake fans fitted, and the test reads the
fans the count says are there. A machine built with one intake fan
reports intake 2 not fitted; the bench reference has two."""
FLOORS = {"cool_tach_exhaust_min_rpm": "6400", "cool_tach_intake_min_rpm": "2290",
"cool_tach_air_assist_min_rpm": "6050", "cool_purge_min_current": "345",
"cool_fan_grace_s": "12"}
FAN = {"steady_rpm": 4150, "spinup_s": 6.8, "ok": True}
def result(self, intakes):
fans = {"exhaust": dict(self.FAN), "intake 1": dict(self.FAN), "intake 2": dict(self.FAN),
"air assist": dict(self.FAN)}
if intakes == 1:
fans["intake 2"] = {"fitted": False}
return {"fans": fans, "intake_fans": intakes, "purge_current_on": 628, "purge_current_off": 74,
"floors": dict(self.FLOORS)}
def setUp(self):
import tempfile
self.sysfs = tempfile.mkdtemp(prefix="forgetest-airflow-")
os.makedirs(os.path.join(self.sysfs, "head"))
with open(os.path.join(self.sysfs, "head", "purge_air"), "w") as f:
f.write("1\n")
self.saved_root = os.environ.get("GF_SYSFS_ROOT")
os.environ["GF_SYSFS_ROOT"] = self.sysfs + "/"
self.fake = helpers.FakeForgectrl().start()
self.fake.state["settings"].update({"cool_tach_intake_min_rpm": "2300"})
self.fake.state["cool"] = {"phase": "idle", "fan_gates": {"purge": {"reading": 628, "floor": 345}}}
self.dark = {"id": "", "running": False, "phase": "", "progress": 0, "elapsed_s": 0,
"log": [], "prompt": None, "result": None, "error": "",
"shots": {"lid": False, "head": False}}
self.versions = {}
self.polls = 0
self.wizard_result = self.result(2)
self.wizard_writes = None # what the check writes; None = what its result says
def on_get(path, q):
if path == "/wiz/dark":
if self.dark["running"]:
self.polls += 1
if self.polls >= 2:
r = self.wizard_result
s = self.fake.state["settings"]
s.update(r["floors"])
s["cool_intake_fans"] = self.wizard_writes or str(r["intake_fans"])
self.dark.update({"running": False, "progress": 100, "result": r})
self.versions["airflow"] = 1
return 200, dict(self.dark)
if path == "/wiz":
return 200, {"versions": dict(self.versions), "required": [], "completed": True}
return None
def on_post(path, form):
s = self.fake.state["settings"]
if path == "/wiz/airflow/start":
self.dark.update({"id": "airflow", "running": True, "phase": "fans to the run profile"})
return 200, {"started": True, "id": "airflow"}
if path == "/settings":
for k, v in form.items():
if v == "":
s.pop(k, None)
else:
s[k] = v
return 200, dict(s)
return None
self.fake.on_get = on_get
self.fake.on_post = on_post
def tearDown(self):
import shutil
self.fake.stop()
if self.saved_root is None:
os.environ.pop("GF_SYSFS_ROOT", None)
else:
os.environ["GF_SYSFS_ROOT"] = self.saved_root
shutil.rmtree(self.sysfs, ignore_errors=True)
def run_check(self):
t = catalog.load_suite()["setup.check-airflow"]
run = Run("test", t.id, t.title)
t.fn(Context(run, None, t))
return run
def assert_put_back(self):
s = self.fake.state["settings"]
self.assertEqual(s.get("cool_tach_intake_min_rpm"), "2300")
self.assertNotIn("cool_intake_fans", s)
self.assertNotIn("cool_tach_exhaust_min_rpm", s)
def test_two_intake_fans_pass_and_are_put_back(self):
self.run_check()
self.assert_put_back()
def test_one_intake_fan_passes_with_intake_2_not_fitted(self):
self.wizard_result = self.result(1)
self.run_check()
self.assert_put_back()
def test_one_intake_fan_with_the_setting_left_at_two_fails(self):
self.wizard_result = self.result(1)
self.wizard_writes = "2"
with self.assertRaises(Exception) as cm:
self.run_check()
self.assertIn("cool_intake_fans reads '2', the check found 1", str(cm.exception))
self.assert_put_back()
def test_a_result_without_the_count_fails(self):
r = self.result(2)
del r["intake_fans"]
self.wizard_result = r
self.wizard_writes = "2"
with self.assertRaises(Exception) as cm:
self.run_check()
self.assertIn("no intake fan count", str(cm.exception))
self.assert_put_back()
if __name__ == "__main__":
unittest.main()
+28 -1
View File
@@ -16,7 +16,7 @@ import unittest
import helpers
from forgetest import baseline, catalog
from forgetest.runner import Context, Run
from forgetest.runner import Context, Failed, Run
from forgetest.suite import setup
IDS = ("setup.gate-blocks-controllers", "setup.override-until-reboot",
@@ -375,6 +375,26 @@ class CloudDisabledSurfaceTests(unittest.TestCase):
return 409, "cloud mode is not enabled on this machine"
return None
self.fake.on_post = on_post
self.list_lies = None # a test sets this to break the list one way
def on_get(path, query):
if path != "/extensions":
return None
s = self.fake.state["settings"]
on = s.get("cloud_enabled") == "1"
roles = [{"role": role, "provider": prov, "kind": kind, "select_key": key, "fallback": back,
"active": on and s.get(key) == prov}
for role, prov, kind, key, back in (("homing", "gfcloud", "runner-fd", "homing_mode", "none"),
("controller", "cloud", "supervised", "controller_mode",
"grbl"))]
if self.list_lies == "stale-active":
roles[0]["active"] = True
if self.list_lies == "always-on":
on = True
return 200, {"extensions": [{"id": "cloud", "name": "Glowforge cloud mode", "builtin": True,
"enabled": on, "enable_key": "cloud_enabled", "setup_step": "cloud",
"tab": "gfcloud", "roles": roles}]}
self.fake.on_get = on_get
def tearDown(self):
self.fake.stop()
@@ -385,6 +405,13 @@ class CloudDisabledSurfaceTests(unittest.TestCase):
t.fn(Context(run, None, t))
return run
def test_a_list_that_disagrees_with_the_settings_fails(self):
for lie in ("always-on", "stale-active"):
self.list_lies = lie
self.fake.state["settings"].update({"cloud_enabled": "1", "homing_mode": "gfcloud"})
with self.assertRaises(Failed, msg=lie):
self.run_test()
def test_refusals_and_restore(self):
run = self.run_test()
s = self.fake.state["settings"]
+200
View File
@@ -0,0 +1,200 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""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. Started bare it
is the online client with the service's connect-time hunt, and the moves
the service sends after the hunt outlast the run. The start is made under
the no-hunt marker, and under the offline marker when the takeover found
the offline service, which it then proves came back; a miss is a leftover
that fails the run. Runs against the fake forgectrl, a fake /proc, and a
fake init script that plays the client's start."""
import os
import shutil
import tempfile
import unittest
from forgetest import baseline, hw, runner
from helpers import FakeForgectrl
class FakeRun:
def __init__(self, cap):
self.baseline_captured = cap
self.lines = []
def log(self, s):
self.lines.append(s)
class TakeoverClientTests(unittest.TestCase):
OLD_PID, NEW_PID = 200, 201
def setUp(self):
self.tmp = tempfile.mkdtemp(prefix="forgetest-tkc-")
self.proc = os.path.join(self.tmp, "proc")
os.makedirs(os.path.join(self.proc, "net"))
self.sysfs = os.path.join(self.tmp, "sysfs") + os.sep
for group in ("cnc", "pic", "head"):
os.makedirs(self.sysfs + group)
os.environ["GF_SYSFS_ROOT"] = self.sysfs
os.environ["FORGETEST_PROC_ROOT"] = self.proc
os.environ["FORGETEST_MARKER"] = os.path.join(self.tmp, "forgetest.active")
self.saved = (baseline.NOHUNT_MARKER, baseline.OFFLINE_MARKER, baseline.OFFLINE_SOCKET,
hw.initd, runner.Takeover.OFFLINE_BACK_S)
baseline.NOHUNT_MARKER = os.path.join(self.tmp, "gfcloud-nohunt")
baseline.OFFLINE_MARKER = os.path.join(self.tmp, "gfcloud-offline")
baseline.OFFLINE_SOCKET = "/run/gfcloud-offline.sock"
runner.Takeover.OFFLINE_BACK_S = 0
baseline.Baseline._unreachable_until = 0.0
self.unix = [] # (pid, inode, path, listening)
self.fc = FakeForgectrl().start()
self.fc.state["mode"] = {"mode": "cloud", "controller": "running", "pid": self.OLD_PID,
"motion": "verified"}
self.calls = []
self.at_start = None # the markers present when forgectrl started
self.comes_up = "running" # the controller forgectrl's start settles on
hw.initd = self._initd
def tearDown(self):
(baseline.NOHUNT_MARKER, baseline.OFFLINE_MARKER, baseline.OFFLINE_SOCKET,
hw.initd, runner.Takeover.OFFLINE_BACK_S) = self.saved
self.fc.stop()
for k in ("GF_SYSFS_ROOT", "FORGETEST_PROC_ROOT", "FORGETEST_MARKER"):
os.environ.pop(k, None)
shutil.rmtree(self.tmp, ignore_errors=True)
# -- the fakes ------------------------------------------------------
def _socket(self, pid, inode, path, listening=True):
self.unix.append((pid, inode, path, listening))
fd_dir = os.path.join(self.proc, str(pid), "fd")
os.makedirs(fd_dir, exist_ok=True)
os.symlink("socket:[%d]" % inode, os.path.join(fd_dir, str(len(os.listdir(fd_dir)) + 3)))
with open(os.path.join(self.proc, "net", "unix"), "w") as f:
f.write("Num RefCount Protocol Flags Type St Inode Path\n")
for _pid, ino, p, lis in self.unix:
f.write("00000000: 00000002 00000000 %08X 0001 %s %d %s\n"
% (0x10000 if lis else 0, "01" if lis else "03", ino, p))
def _offline_client(self, pid):
self._socket(pid, 9000 + pid, baseline.OFFLINE_SOCKET)
def _initd(self, service, action, timeout=60):
"""forgectrl stop/start; the start plays gfcloud's: it reads and
takes down the markers, and it is the offline service when the
offline marker was there."""
self.calls.append((service, action))
if action != "start":
return 0, ""
self.at_start = sorted(os.path.basename(p) for p in (baseline.NOHUNT_MARKER, baseline.OFFLINE_MARKER)
if os.path.exists(p))
mode = self.fc.state["mode"]
if self.comes_up != "running":
self.fc.state["mode"] = dict(mode, controller=self.comes_up, pid=None, motion="unverified")
return 0, ""
offline = os.path.exists(baseline.OFFLINE_MARKER)
for p in (baseline.NOHUNT_MARKER, baseline.OFFLINE_MARKER):
if os.path.exists(p):
os.remove(p)
self.fc.state["mode"] = dict(mode, controller="running", pid=self.NEW_PID, motion="verified")
if offline and mode.get("mode") == "cloud":
self._offline_client(self.NEW_PID)
return 0, ""
def takeover(self):
cap = {"position": None, "mode": self.fc.state["mode"]["mode"]}
run = FakeRun(cap)
with runner.Takeover(run.log, "test.id", run=run):
pass
return cap, run
def markers_left(self):
return [p for p in (baseline.NOHUNT_MARKER, baseline.OFFLINE_MARKER) if os.path.exists(p)]
# -- the socket judge -------------------------------------------------
def test_the_listener_is_the_process_own_descriptor(self):
self._offline_client(self.OLD_PID)
self.assertTrue(hw.listens_on(self.OLD_PID, baseline.OFFLINE_SOCKET))
# the path alone is not enough: another process's socket, a
# connected (not listening) socket, another path, no pid
self.assertFalse(hw.listens_on(self.NEW_PID, baseline.OFFLINE_SOCKET))
self._socket(self.NEW_PID, 7001, baseline.OFFLINE_SOCKET, listening=False)
self.assertFalse(hw.listens_on(self.NEW_PID, baseline.OFFLINE_SOCKET))
self.assertFalse(hw.listens_on(self.OLD_PID, "/run/forgefirm/grbl.ctl"))
self.assertFalse(hw.listens_on(None, baseline.OFFLINE_SOCKET))
# -- the takeover ------------------------------------------------------
def test_the_offline_service_comes_back_offline(self):
# exthost.armed-freeze's case: the setup record put back under a
# takeover while the offline service ran
self._offline_client(self.OLD_PID)
cap, run = self.takeover()
self.assertEqual(self.calls, [("forgectrl", "stop"), ("forgectrl", "start")])
self.assertEqual(self.at_start, ["gfcloud-nohunt", "gfcloud-offline"])
self.assertTrue(hw.listens_on(self.NEW_PID, baseline.OFFLINE_SOCKET))
self.assertNotIn("restart_clients", cap)
self.assertEqual(self.markers_left(), [])
self.assertTrue(any("back as the offline service (pid %d)" % self.NEW_PID in l for l in run.lines),
run.lines)
def test_an_online_client_after_the_offline_service_fails_the_run(self):
# the client that came up is not listening: an online client, or
# a marker the start never saw
self._offline_client(self.OLD_PID)
real = self._initd
def lost_marker(service, action, timeout=60):
if action == "start" and os.path.exists(baseline.OFFLINE_MARKER):
os.remove(baseline.OFFLINE_MARKER)
return real(service, action, timeout)
hw.initd = lost_marker
cap, run = self.takeover()
self.assertEqual(cap["restart_clients"],
[{"where": "takeover end", "expected": "the offline service",
"found": "a client that is not the offline service (pid %d)" % self.NEW_PID}])
left = baseline.Baseline(run.log)
items = []
left._preserved(items, cap)
self.assertEqual([x.item for x in items], ["cloud client at the takeover end"])
self.assertTrue(items[0].action.startswith("not restored"), items[0].action)
def test_no_client_started_takes_the_markers_down(self):
# the gate closed, or standby: nothing read the markers, and a
# later start must not come up offline or without its hunt
self._offline_client(self.OLD_PID)
self.comes_up = "gated"
cap, run = self.takeover()
self.assertEqual(self.at_start, ["gfcloud-nohunt", "gfcloud-offline"])
self.assertEqual(self.markers_left(), [])
self.assertEqual(len(cap["restart_clients"]), 1)
self.assertIn("no cloud client running", cap["restart_clients"][0]["found"])
def test_an_online_client_comes_back_without_the_hunt(self):
cap, run = self.takeover()
self.assertEqual(self.at_start, ["gfcloud-nohunt"])
self.assertFalse(hw.listens_on(self.NEW_PID, baseline.OFFLINE_SOCKET))
self.assertNotIn("restart_clients", cap)
self.assertEqual(self.markers_left(), [])
def test_a_stale_offline_socket_file_is_not_the_offline_service(self):
# a socket another process left at the path: the online client
# found stays online
self._socket(99, 7002, baseline.OFFLINE_SOCKET)
cap, run = self.takeover()
self.assertEqual(self.at_start, ["gfcloud-nohunt"])
self.assertNotIn("restart_clients", cap)
def test_grbl_mode_starts_under_no_marker(self):
self.fc.state["mode"] = {"mode": "grbl", "controller": "running", "pid": self.OLD_PID,
"motion": "verified"}
cap, run = self.takeover()
self.assertEqual(self.at_start, [])
self.assertNotIn("restart_clients", cap)
if __name__ == "__main__":
unittest.main()
+244
View File
@@ -0,0 +1,244 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""A takeover judges the head where the baseline cannot. forgectrl's start
at the end of a takeover is a controller start, which re-zeroes the step
counters wherever the head stands, so after it a head left out reads as
home. The takeover reads the counters 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 a leftover that fails the run, and the head is
never moved on it. Runs against a fake sysfs tree and a fake anchor file."""
import os
import shutil
import struct
import tempfile
import unittest
from forgetest import baseline, hw, runner
class FakeRun:
def __init__(self, cap):
self.baseline_captured = cap
self.lines = []
def log(self, s):
self.lines.append(s)
class TakeoverPositionTests(unittest.TestCase):
def setUp(self):
# the clean machine test_baseline.py builds, so the post pass
# finds nothing but what a test here leaves
self.tmp = tempfile.mkdtemp(prefix="forgetest-tko-")
self.sysfs = os.path.join(self.tmp, "sysfs") + os.sep
self.leds = os.path.join(self.tmp, "leds") + os.sep
for group in ("cnc", "pic", "head", "thermal"):
os.makedirs(self.sysfs + group)
for name in baseline.BUTTON_LEDS + ("lid_led",):
os.makedirs(self.leds + name)
with open(self.leds + name + "/brightness", "w") as f:
f.write("0")
for attr, val in baseline.fixed_sysfs() + baseline.IDLE_READBACKS:
self._attr(attr, val)
self._attr("cnc/interlock_circuit", "45")
self._attr("pic/lid_led", "0")
self._pos(0, 0, 0)
os.environ["GF_SYSFS_ROOT"] = self.sysfs
os.environ["GF_LEDS_ROOT"] = self.leds
os.environ["FORGECTRL_URL"] = "http://127.0.0.1:1" # nothing listens
baseline.Baseline._unreachable_until = 0.0
self.anchor = os.path.join(self.tmp, "grblhal.homed")
self._new_anchor(1)
self.old_anchor, baseline.ANCHOR_PATH = baseline.ANCHOR_PATH, self.anchor
self.spm = baseline.counter_steps_per_mm()
def tearDown(self):
baseline.ANCHOR_PATH = self.old_anchor
shutil.rmtree(self.tmp, ignore_errors=True)
for k in ("GF_SYSFS_ROOT", "GF_LEDS_ROOT", "FORGECTRL_URL"):
os.environ.pop(k, None)
def _attr(self, attr, val):
with open(self.sysfs + attr, "w") as f:
f.write(str(val))
def _pos(self, x, y, z):
with open(self.sysfs + "cnc/position", "wb") as f:
f.write(struct.pack("<5i", x, y, z, 0, 0))
def _new_anchor(self, ns):
"""A controller start or a home: a new anchor file."""
with open(self.anchor + ".new", "w") as f:
f.write("0 0 0 4 startup")
os.replace(self.anchor + ".new", self.anchor)
os.utime(self.anchor, ns=(ns, ns))
def cap(self):
return {"position": baseline.read_position(), "frame": baseline.counter_frame()}
def takeover(self, cap):
run = FakeRun(cap)
return runner.Takeover(run.log, "test.id", run=run), run
def mm(self, mm):
return round(mm * self.spm)
def start(self, t):
"""Takeover.__enter__'s order: the frame is read while the
controller runs, then its exit takes the anchor with it (forgectrl
unlinks it) and leaves the counters, which are then read."""
t.frame = baseline.counter_frame()
if os.path.exists(self.anchor):
os.unlink(self.anchor)
t.position_at_start()
def test_a_head_where_the_run_expects_it_is_not_recorded(self):
cap = self.cap()
t, run = self.takeover(cap)
self.start(t)
t.position_at_end()
self.assertNotIn("restart_positions", cap)
def test_a_head_left_out_at_the_start_is_recorded(self):
cap = self.cap()
self._pos(self.mm(10), self.mm(10), 0)
t, run = self.takeover(cap)
self.start(t)
self.assertEqual(cap["restart_positions"],
[{"where": "takeover start", "found": [self.mm(10), self.mm(10), 0],
"expected": [0, 0, 0]}])
def test_the_frame_is_read_before_the_controller_goes(self):
# found on the bench: forgectrl unlinks the anchor when the
# controller exits, so a frame read after the stop never matched
# and the check judged nothing. Read before the stop, it does.
cap = self.cap()
self._pos(self.mm(10), self.mm(10), 0)
late, run = self.takeover(cap)
os.unlink(self.anchor)
late.frame = baseline.counter_frame()
late.position_at_start()
self.assertNotIn("restart_positions", cap)
self._new_anchor(1)
cap = self.cap() # a run that began at the origin in this frame
cap["position"] = [0, 0, 0]
on_time, run = self.takeover(cap)
self.start(on_time)
self.assertEqual([r["where"] for r in cap["restart_positions"]], ["takeover start"])
def test_the_dead_band_is_not_a_leftover(self):
cap = self.cap()
self._pos(self.mm(0.05), 0, 0)
t, run = self.takeover(cap)
self.start(t)
self.assertNotIn("restart_positions", cap)
def test_an_undeclared_frame_change_is_not_judged(self):
cap = self.cap()
self._new_anchor(2) # a controller start the test did not declare
self._pos(self.mm(10), 0, 0)
t, run = self.takeover(cap)
self.start(t)
self.assertNotIn("restart_positions", cap)
self.assertTrue(any("not in the frame the run began in" in l for l in run.lines), run.lines)
def test_cloud_mode_is_not_judged(self):
cap = self.cap()
self._pos(self.mm(10), 0, 0)
t, run = self.takeover(cap)
t.cloud = True
t.position_at_start()
self._pos(self.mm(20), 0, 0)
t.position_at_end()
self.assertNotIn("restart_positions", cap)
def test_a_bench_tool_is_not_judged(self):
t = runner.Takeover(lambda s: None, "bench:tool")
self._pos(self.mm(10), 0, 0)
t.position_at_start()
t.position_at_end() # no run: nothing to judge against, nothing raised
def test_a_drill_that_leaves_the_head_out_is_recorded_at_the_end(self):
cap = self.cap()
t, run = self.takeover(cap)
self.start(t)
self._pos(self.mm(5), 0, 0) # the drill moved the head and did not bring it back
t.position_at_end()
self.assertEqual([r["where"] for r in cap["restart_positions"]], ["takeover end"])
self.assertEqual(cap["restart_positions"][0]["expected"], [0, 0, 0])
def test_a_changed_microstep_mode_is_not_judged_at_the_end(self):
cap = self.cap()
t, run = self.takeover(cap)
self.start(t)
self._attr("cnc/x_mode", "8" if hw.sysfs_read("cnc/x_mode") != "8" else "16")
self._pos(self.mm(5), 0, 0)
t.position_at_end()
self.assertNotIn("restart_positions", cap)
def test_the_post_pass_fails_the_run_and_moves_nothing(self):
# setup.check-envelope's case: a declared camera home, the head
# left out, the record put back under a takeover, whose restart
# zeroes the counters where the head stands
cap = self.cap()
self._new_anchor(3) # the camera home
cap["position"], cap["rezero_declared"], cap["frame"] = [0, 0, 0], True, baseline.counter_frame()
self._pos(self.mm(10), self.mm(10), 0)
t, run = self.takeover(cap)
self.start(t)
t.position_at_end()
self._new_anchor(4) # forgectrl's start: a controller start
self._pos(0, 0, 0)
lines = []
left = baseline.Baseline(lines.append).enforce("post", captured=cap)
self.assertEqual([x.item for x in left], ["position at the takeover start"])
self.assertTrue(left[0].action.startswith("unrestorable"), left[0].action)
self.assertIn("not moved", left[0].action)
self.assertEqual(baseline.read_position(), [0, 0, 0])
# without the takeover's record the same run reads clean: the gap
del cap["restart_positions"]
self.assertEqual(baseline.Baseline(lines.append).enforce("post", captured=cap), [])
def test_a_failed_reading_never_keeps_forgectrl_down(self):
# the exit path restores the attributes, relocks the latch and
# starts forgectrl whatever the position reading does
os.environ["FORGETEST_MARKER"] = os.path.join(self.tmp, "marker")
calls = []
real = (hw.initd, baseline.read_position, runner.Takeover.wait_settled)
hw.initd = lambda service, action, timeout=60: (calls.append((service, action)) or (0, ""))
def broken():
raise OSError("unreadable")
baseline.read_position = broken
runner.Takeover.wait_settled = lambda self: None
try:
cap = {"position": [0, 0, 0], "frame": baseline.counter_frame()}
t, run = self.takeover(cap)
t.found = ([0, 0, 0], hw.sysfs_read("cnc/x_mode"))
t.saved = {"cnc/x_mode": hw.sysfs_read("cnc/x_mode")}
t.__exit__(None, None, None)
finally:
hw.initd, baseline.read_position, runner.Takeover.wait_settled = real
os.environ.pop("FORGETEST_MARKER", None)
self.assertEqual(calls, [("forgectrl", "start")])
with open(self.sysfs + "cnc/laser_latch") as f:
self.assertEqual(f.read().strip(), "1")
self.assertTrue(any("could not be judged" in l for l in run.lines), run.lines)
def test_a_declared_rezero_records_the_frame_in_force(self):
cap = self.cap()
self._new_anchor(5) # the camera home
stub = type("Ctx", (), {})()
stub.run = FakeRun(cap)
stub.log = stub.run.log
runner.Context.counters_rezeroed(stub)
self.assertEqual(cap["position"], [0, 0, 0])
self.assertTrue(cap["rezero_declared"])
self.assertEqual(cap["frame"], baseline.counter_frame())
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -11,4 +11,4 @@ overrides:
meta-freescale-distro:
commit: b9d6a5d9931922558046d230c1f5f4ef6ee72345
meta-openglow:
commit: 71fad52e8ff5695bce3b9c6bc8530a1468426819
commit: f012d8e82aa0dbfa511214e46a1e13559b1a0d74

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