126 Commits
Author SHA1 Message Date
ScottW514 f9190ffddc Release v0.0.9
FORGEFIRM_RELEASE is 0.0.9, and releases/v0.0.9 carries the release notes
and the acceptance artifact that forgetest exported on the bench reference.

The artifact is from campaign c-20261018224638-4ca0 on the pins-only dev
image 20261018222431: authorized, 43 of 43 tests satisfied, all 43 run on
that image and none inherited. scripts/acceptance-gate.py against that
build's release manifest gives AUTHORIZED, with inputs identical to the
bench image.
2026-10-18 19:45:26 -04:00
ScottW514 5036517e22 Pin grblHAL 0.1.22, forgeext 0.1.5, forgectrl 0.1.34 and forgefirm-app 0.1.33 on the pushed heads, and meta-openglow in the kas lock
- grblhal-glowforge badc832 -> e159720 (0.1.21 -> 0.1.22): a soft reset
  during motion gets the kernel's ramp and the position is read back
  from the kernel's step counters, a reset that lands after the core's
  own idle still gets the stop, the owed step is taken only between
  callbacks, a position that stays lost pins the lens and tells the
  sender, the homing cycle masks are pinned so a write cannot drop the
  lens hold, the test hooks (GF_SWITCH_DEV included) are honored only in
  a null-sink run through one gate, the underrun fail-safe log line
  names its entry, and the latch_test and serial_test cases that hold
  each.
- forgeext bb75b23 -> ebd9592 (0.1.4 -> 0.1.5): the storage quota counts
  entries (one per 4 KiB) and the unlinked files a service holds open or
  mapped, bounded, every 10 s; a quarantine the host could not write is
  kept under the run directory, taken up at the next start or the first
  turn the state reads, and dropped by the write or the lift; the root's
  prepare turns current back to the state's version, drops only a
  versioned tree, and leaves a package it cannot turn refused while the
  host runs the others; a manifest that cannot be read holds its package
  out by name; the required-holds marker directory is one literal.
- forgectrl 6c2bb38 -> b235be3 (0.1.33 -> 0.1.34): a diagnostic start
  the cooling engine refuses goes once more a second later, canceled by
  a later press or the Abort, which clears its text; the sysfs root, the
  switch device and the run directory are honored only in a host-test
  run through one gate (src/testhook.h, with its HOST_TESTED row here);
  test_routes holds the panel's retry text to the daemon's and the
  environment scan reads secure_getenv; the operator's texts say Grbl
  sender; holds_wire_test holds the required-holds directory to
  forgeext's and a marker alone holds.
- forgefirm-app 0f59c54 -> 6fb146a (0.1.32 -> 0.1.33): the shared
  AGENTS.md block names a Grbl sender; nothing it installs changes.
- kas lock: meta-openglow b7626be -> c542ce6, the head that pins the
  kernel module dab378d, python3-gfhardware 6fb146a and gfutilities
  b9e74b8.

Proof: the four repositories' CI runs on the pushed heads are green
(grblHAL-glowforge 39170662884, forgeext 39170677776, forgectrl
39168946865 on its second attempt, python3-gfhardware 39168949407), and forgefirm's own
forgetest-ci on 0de9051 (39168937632). bitbake -c fetch of each pinned
recipe from these pins succeeds. The tree manifest from the pins holds
forgectrl at b235be3 (232 files), forgeext at ebd9592 (90), grblhal at
e159720 (178) and the app at 6fb146a (46); the coverage lint over it
finds no uncovered path and no row problem (43 tests), with the row for
forgectrl src/testhook.h selecting host_start.sh in build.yml;
check-ui-vendor finds the shared UI files identical; test_tree_manifest
passes (7); the gate self-check refuses the empty artifact (rc 1).

Acceptance: the pins move the fingerprints of every test whose covers
name the four components, and the whole catalog runs on the next image.
2026-10-18 18:20:47 -04:00
ScottW514 0de9051820 forgetest: a pull_request narrowed by types: is a filter too
The coverage lint counts a HOST_TESTED row's workflow on a push or a
pull_request with no filter under it, and it read a `types:` under
the pull_request as nothing. A pull_request under `types:` runs on
the activity types it names and no other, so a workflow on
`pull_request: types: [closed]` never runs on a pull request's open
or its new commits, and a row on it counted as running on every
change, with the host test behind it keeping its paths covered. No
workflow a row names writes a `types:`.

workflow_filters reads a `types:` key at the pull_request's own level
as a filter, by name, as it reads a `paths:`: the lint reads no type,
so any `types:` is one, fail closed. A `types:` is read under the
pull_request alone: a push has none, and the lint counts no other
event. host_row_problems lists a workflow whose pull_request carries
one and whose push carries a filter, or is absent, as "every push or
pull_request in its own on: carries a filter (pull_request: types)";
a push with no filter beside it keeps the workflow counting. The
HOST_TESTED comment and the docstrings name the types filter.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 41 tests, OK, on
Ubuntu 24.04 with python 3.12, in a private PID namespace.
test_a_push_or_pull_request_under_a_filter_runs_on_not_every_change
gains: workflow_filters reads a `types:` under the pull_request with
a plain push beside it as the pull_request's filter alone; a
`types:` block list with a `paths-ignore:` after it as both; and a
`types:` under a push as none. Through host_row_problems, a plain
push with a pull_request under `types: [closed]` passes, and the
pull_request under `types: [closed]` alone is listed with
"pull_request: types". Control: the parent, 9e7b065, with the new
test file fails the case (failures=1) at the first `types:`, and
against the parent's manifest.py, workflow_filters reads both
`types:` fixtures as no filter and host_row_problems passes both
rows. The coverage lint on the tree manifest from the recipe pins,
with meta-openglow at 1e163bd on scarthgap, reports 0 uncovered
paths, 0 empty entries, 0 host-tested row problems, 43 tests.
pyflakes reports nothing.

Acceptance: none. forgetest is dev-only and in no fingerprint; the
lint runs in CI, not in a campaign, and tests/ are in no fingerprint.
2026-10-18 14:34:10 -04:00
ScottW514 9e7b065343 forgetest: the lint reads a needs: list at the key's indentation and across lines, and an empty one fails closed
The coverage lint counts a step of a HOST_TESTED row's workflow only
in a job whose `needs:` are met on every run, and _needs read two
valid forms of the key as an empty list, which _needs_run takes as
needs met. A block list whose items sit at the key's own indentation
(`needs:` over `- build`) ended at its first item, which the loop took
for the next key. A flow list continued on the lines under the key
(`needs: [` over `build` over `]`) ended at the key's line, with no
item, and one split after a comma (`needs: [lint,` over `build]`)
kept the first line's names and dropped the rest. So a job that
needed a conditional job, written either way, counted as running on
every run, and a host test behind a conditional build kept its row
green and its paths covered. No workflow a row names writes its
`needs:` in these forms.

_needs now takes a `- ` item at the key's own indentation as part of
the block list, and ends the list at a key at the key's indentation
or less; items at two indentations are a form it does not read. A
flow list whose `]` is not on the key's line is joined with the lines
under it up to the `]`: a key, or a line at the key's indentation
that is not the `]`, before it ends the join as a form not read.
Every form not read gives None, a name no job has, so the need is
never met: fail closed. An empty `needs:` (`[]`, or nothing under the
key) gives None too: GitHub refuses an empty needs, and a job that
names one does not run. The docstring names every form that fails
closed, so its claim is true of each.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 41 tests, OK, on
Ubuntu 24.04 with python 3.12, in a private PID namespace.
test_a_job_runs_only_when_every_job_it_needs_runs gains the block
list at the key's indentation and the flow list across three lines
among the forms that run the test with the needed job unconditional
and do not with it under `if: false` or `if: github.event_name ==
'push'`; a flow list split after its comma in the chain of three,
which runs the test, and does not with the second-line job under
`if: false`; and, each failing closed with the needed job
unconditional, `needs: []`, a bare `needs:`, a flow list a key
interrupts before its `]`, and items at two indentations. Through
host_row_problems, a workflow on `[push]` whose test job needs, in a
block list at the key's indentation, a build job under `if: false`
is listed as "does not run it". Control: the parent, 8fc411f, with
the new test file fails the case (failures=1) at the block list at
the key's indentation, and run one by one against the parent's
manifest.py, each of the seven new forms reads as run and the row
passes. The coverage lint on the tree manifest from the recipe pins,
with meta-openglow at 1e163bd on scarthgap, reports 0 uncovered
paths, 0 empty entries, 0 host-tested row problems, 43 tests.
pyflakes reports nothing.

Acceptance: none. forgetest is dev-only and in no fingerprint; the
lint runs in CI, not in a campaign, and tests/ are in no fingerprint.
2026-10-18 14:31:59 -04:00
ScottW514 8fc411f50f AGENTS.md: the shared rules name the Grbl sender, not one product
The "Project-wide rules" block is the same text in every repository
of the project, and this copy's "What ForgeFIRM is" said "controller
for LightBurn and other senders on TCP port 23", naming one sender as
the controller's counterpart. The project's term is "a Grbl sender",
the generic client of the controller on port 23, and no sender is
named as a synonym for it anywhere. The sentence now reads
"controller for a Grbl sender on TCP port 23", at the same wrap,
and nothing else in the block changes.

Proof: the block from "## Project-wide rules" to the end of the file
diffs empty against forgectrl's AGENTS.md; before the change the two
differed in that one line. pyflakes, the unit tests and the coverage
lint do not read AGENTS.md.

Acceptance: none. AGENTS.md is in no fingerprint and no catalog
test's covers; a text that guides contributors changes no behavior.
2026-10-18 13:45:31 -04:00
ScottW514 4fa03f7e2b forgetest: the lint counts a push or pull request only when no filter is under it
A HOST_TESTED row's workflow has to run on push or pull_request, and
the lint read the events of its `on:` and nothing under them. A
`paths:` or `paths-ignore:` under the event keeps the workflow from
the pushes that change the other files, a `branches:` or
`branches-ignore:` from the pushes to the other branches, and a push
with only a `tags:` or `tags-ignore:` runs on no branch push at all.
So a workflow whose push and pull_request each carried such a filter
counted as running on every change, and a host test behind one kept
its row green and its paths covered.

workflow_filters reads what is under each event of the `on:` block
mapping: each `branches:`, `branches-ignore:`, `tags:`, `tags-ignore:`,
`paths:` or `paths-ignore:` key at the event's own level, by name; a
value on the event's line (`push: {branches: [main]}`); and a key
with one stray quote under the event. Each is a filter the lint does
not read: the simple rule, fail closed. The manifest carries no
branch (a component's record is its srcrev, source, files and
recipes), so a `branches:` naming the default branch is read as any
other filter, and a `paths:` is not matched against the row's covered
paths. host_row_problems counts a push or a pull_request only with no
filter under it, and lists a workflow whose every push and
pull_request carries one as "every push or pull_request in its own
on: carries a filter (push: paths-ignore)", with each event's filters
named. One push or pull_request with no filter is enough: the
workflow then runs on every change, through that event. "Runs" for
a row's workflow now means: on a push or a pull_request with no
filter under it, in a step on every run of the workflow, in a job
whose needs are met on every run, on a line of run text whose
failure ends the step. The HOST_TESTED comment and the docstrings
say so. A key deeper than the event's own level (a dispatch's
inputs) is no filter, and an `on:` list or scalar carries none.

The workflows the rows name, forgectrl's and forgeext's build.yml,
run on `push: branches: [main]` with a plain `pull_request:`, as
grblhal-glowforge's does: the pull request keeps each row counting,
and the push to main is the push that moves a pin.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 41 tests, OK, on
Ubuntu 24.04 with python 3.12, in a private PID namespace. The new
case test_a_push_or_pull_request_under_a_filter_runs_on_not_every_change:
workflow_filters reads a `branches:` on the push with a plain
pull_request beside it; a `branches-ignore:` and a `paths-ignore:` on
the push with a `paths:` on the pull_request; `tags:`; `tags-ignore:`;
a value on the event's line and a stray-quoted key under the event as
filters the lint does not read; a dispatch's `inputs: paths:` as
none; and an `on:` flow list, scalar and block list as none. Through
host_row_problems, a push to main with every pull request passes, a
push with `paths-ignore:` alone is listed, and a push under
`branches:` with a pull_request under `paths:` is listed, each with
its filters named. The CI_YML fixture of the two older cases moves
its `paths:` from the push to the pull_request, so the workflow
still runs on every push and still names the second harness in a
filter alone. Controls: the parent, 9c97f05, with the new test file
errors the new case at workflow_filters (errors=1); host_row_problems
on the parent, and on e1d1c1b, passes a fixture whose push carries
`paths-ignore:` alone, which the change lists with "push:
paths-ignore". The coverage lint on the tree manifest from the recipe
pins, with meta-openglow at 1e163bd on scarthgap, reports 0 uncovered
paths, 0 empty entries, 0 host-tested row problems, 43 tests.
pyflakes reports no undefined name.

Acceptance: none. forgetest is dev-only and in no fingerprint; the
lint runs in CI, not in a campaign, and tests/ are in no fingerprint.
2026-10-18 13:44:59 -04:00
ScottW514 9c97f05db9 forgetest: the lint counts a step as run only when every job its job needs runs
The coverage lint judges a HOST_TESTED row by a step of its workflow
that runs on every run of the workflow. It read the `if:` and the
`continue-on-error:` of a step and of its job, and not the job's
`needs:`. GitHub skips a job whose needed job did not run (an `if:`
that held it back, or a failure), unless the job says `always()`, so
a test step in a job that needs a conditional job runs on not every
push, and counted all the same: a host test behind a conditional
build keeps its row green and its paths covered. No workflow a row
names uses `needs:`.

workflow_steps now reads each job's `needs:` (_needs: a flow list,
one scalar, or a block list), keeps the jobs by name, and resolves
them when the workflow is read (_needs_run): a job's needs are met
on every run when every job it names runs on every run, that is,
carries no `if:` other than the literal true and has its own needs
met, transitively. A job the workflow does not have, a cycle, and a
`needs:` in a form the lint does not read each fail closed. A needed
job's `continue-on-error:` does not bear: its failure fails neither
the run nor the jobs after it. The job's dict carries the result as
"needs_run", and _on_every_run counts no step of a job whose needs
are not met, so workflow_runs and checkout_path refuse it as they
refuse a conditional step. The reason stays "does not run it". The
lint evaluates no condition, so a dependent job under `always()`
counts as no job, as a step under `always()` counts as no step. The
HOST_TESTED comment and the docstrings say what the lint reads.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 40 tests, OK, on
Ubuntu 24.04 with python 3.12, in a private PID namespace. The new
case test_a_job_runs_only_when_every_job_it_needs_runs: a job that
needs an unconditional job runs its test, with the `needs:` as a
scalar, a flow list, a quoted flow list and a block list; the same
four with the needed job under `if: false` or `if: github.event_name
== 'push'` do not; a needed job under `continue-on-error: true`
runs it; a chain of three runs it, and does not with the first job
under `if: false`; a needed job the workflow does not have, a cycle
of two, a self-need and a `needs:` holding a mapping each do not;
`if: always()` on the dependent job, and on the middle job of the
chain, does not. test_the_workflow_has_to_run_the_host_test gains a
workflow whose test job needs a job under `if: false`, listed as
"does not run it". Control: the parent, e1d1c1b, with the new test
file fails both cases (failures=2). The coverage lint on the tree
manifest from the recipe pins, with meta-openglow at 1e163bd on
scarthgap, reports 0 uncovered paths, 0 empty entries, 0 host-tested
row problems, 43 tests. pyflakes reports no undefined name.

Acceptance: none. forgetest is dev-only and in no fingerprint; the
lint runs in CI, not in a campaign, and tests/ are in no fingerprint.
2026-10-18 13:40:54 -04:00
ScottW514 e1d1c1b84e forgetest: motion.jog-roundtrip's ramp comment follows the kernel's stepped ramp
The soft-reset check's comment described the kernel's ramp with the
continuous formula: (f0^2 - 900^2) / (2 a) ticks, 12687 over 223 ms
at the x32 tick, and a part-way run-out at f0 * sqrt(1 - 2 (a / f0)
Q). The kernel's ramp is stepped (cnc.c, ramp_update_tasklet_fn): the
tick holds for the first 10 ms after the stop, then every 10 ms drops
by ramp_rate / 100 until it reaches the 900 Hz floor, which plays
10 ms before the stop. At 112640 Hz and 500000 Hz/s that is 5000 Hz
off every 10 ms: 24 intervals, 240 ms, about 13270 ticks, 118 ms of
the tick. The comment now says so, with the figures that follow: the
rate at a run-out is 24% of the tick with 110 ms of queue left, 38%
with 100 ms, 51% with 90 ms; the tail's mean over the 25 to 30 ms the
poll samples is 32%, 45% and 56% (61% over the 50 ms window bound),
not 21% to 56%; the 70% bound is 14 points above the slowest ramped
tail and 30 below a run-out; the time to leave running is the 100 ms
hold plus the run's end on the ramp, about 240 ms with 100 ms left,
270 ms with 110 ms, 340 ms at the floor. The evidence's ramp_ticks and
ramp_s stay the continuous figures, and the comment says they are,
about 4% and 7% under the stepped ramp's. No judge changes: the 70%
bound, the 25 ms window and the 50 ms gap stay.

The Alarm wait's failure text names a sampling stall: when the 5 ms
sampling loop runs past 2 s after the ^X, the wait has no time to
read a state, and the failure read "not in Alarm within 2 s (state
None)". It now says the state is unread because the sampling loop ran
past the 2 s. The condition is the same; the text is the change.

Proof: a compile and pyflakes 4.0.3 give no message on
forgetest/suite/motion.py, with no undefined name in forgetest or
tests. forgetest unit tests, python3 -B -m unittest discover -s tests
-p test_<name>.py, in a private PID namespace: test_manifest.py 39
OK, test_queue.py 15 OK, test_campaign.py 13 OK. Coverage lint on the
tree manifest from scripts/manifest-from-tree.py (python3 -B -m
forgetest.coverage --manifest ../tree-manifest.json --enforce):
0 uncovered, 0 empty, 0 host-tested row problems, 43 tests. Host dry
runs of the whole test against the null-sink build of the
grblHAL-glowforge default branch head ("serial_test: the scan's exempt
arguments each match once in the sources"), with the machine side
stood in for as before: with a model kernel that ramps from the
controller's cnc/stop write, the test passes, MPos X 3502.07 steps
against 3502, tail 22% of the tick; with a model kernel that ramps as
cnc.c does, 5000 Hz off every 10 ms from 110 ms of queue, the test
passes, MPos X 3496.95 steps against 3497, tail 33% of the tick, the
figure the comment gives for a run-out from 110 ms of queue.

Acceptance: the comment and the message text are inside the test's
own function, so the shared text of suite/motion.py does not change.
The fingerprint of motion.jog-roundtrip moves, and no other test's:
of the implementation hashes of the 43 catalog tests, before and
after, only that one differs.
2026-10-17 19:10:40 -04:00
ScottW514 b4493f5052 forgetest: motion.jog-roundtrip's covers name the forgectrl route its homed-mask judge reads
The soft-reset check's reference verdict is the homed mask, read before
and after the reset through forgectrl's GET /motion/state
(cb_motion_state in src/main.c, through motion_port to the controller
port's state record), because $# prints the mask only with $22 on,
which the driver leaves off. A test that depends on a forgectrl route
names ("forgectrl", "src/main.c") in its covers, as microstep_modes
and deadman do, so that a change to the route invalidates the test's
inherited PASS. motion.jog-roundtrip's covers are now _MOTION_COVERS
plus that entry.

Proof: a compile and pyflakes 4.0.3 give no message on
forgetest/suite/motion.py, with no undefined name in forgetest or
tests. forgetest unit tests, python3 -B -m unittest discover -s tests
-p test_<name>.py, in a private PID namespace: test_manifest.py 39
OK, test_queue.py 15 OK, test_campaign.py 13 OK. Coverage lint on the
tree manifest from scripts/manifest-from-tree.py (python3 -B -m
forgetest.coverage --manifest ../tree-manifest.json --enforce):
0 uncovered, 0 empty, 0 host-tested row problems, 43 tests; the
catalog loads motion.jog-roundtrip with the five covers entries. A
host dry run of the whole test against the null-sink build of the
grblHAL-glowforge default branch head ("serial_test: the scan's exempt
arguments each match once in the sources"), with the machine side
stood in for as before and a model kernel that ramps from the
controller's cnc/stop write, passes: MPos X 3534.93 steps against
3535, tail 23% of the tick, mask 4 kept, drift 0.000 mm.

Acceptance: the covers are part of the test's own text, so the
fingerprint of motion.jog-roundtrip moves, and no other test's: of
the implementation hashes of the 43 catalog tests, before and after,
only that one differs. From here a change under forgectrl's
src/main.c invalidates motion.jog-roundtrip's inherited PASS as well.
2026-10-17 19:08:02 -04:00
ScottW514 a3211ba1ea forgetest: motion.jog-roundtrip's read-back judge allows half a step, so a one-step miss always fails
The read-back judge of the soft-reset check compared MPos X in steps
with the kernel's X count to one step, and the Y and Z judges allowed
one step of MPos motion. One step is the smallest miss the read-back
can make: the core's kill step for the pulse start that never came,
which a position kept as the core counted it carries. MPos is
reported to 0.001 mm, so the difference of two readings carries up to
0.21 step at x32 (0.001 mm at $100 = 213.333; 0.05 step at x8), and
a one-step miss lands between 0.79 and 1.21 steps: on either side of
a one-step bound, at random. The bound is now half a step for X, Y
and Z: a correct read-back lands within 0.21 step, a one-step miss at
0.79 or more. The comment and the description say so.

With the committed judges the parent of the read-back work fails the
check on four counts, not two: the kernel is still running 1.0 s after
the ^X (the parent writes no cnc/stop), the tail rate is 100% of the
tick (the run ends at speed), the position is not read back (MPos X
one step past the kernel), and the homed mask goes 4 -> 0.

Proof: a compile and pyflakes 4.0.3 give no message on
forgetest/suite/motion.py, with no undefined name in forgetest or
tests. forgetest unit tests, python3 -B -m unittest discover -s tests
-p test_<name>.py, in a private PID namespace: test_manifest.py 39
OK, test_queue.py 15 OK, test_campaign.py 13 OK. Coverage lint on the
tree manifest from scripts/manifest-from-tree.py (python3 -B -m
forgetest.coverage --manifest ../tree-manifest.json --enforce):
0 uncovered, 0 empty, 0 host-tested row problems, 43 tests. Host dry
runs of the whole test against the null-sink build of the
grblHAL-glowforge default branch head ("serial_test: the scan's exempt
arguments each match once in the sources"), configured as its CI
configures it (-DGFMCODE_WAIT_S=5.0), with the head accelerometer,
the kernel's state and bytes-played counter, and forgectrl stood in
for and the step counters taken from the shipped stream: with a model
kernel that ramps from the controller's cnc/stop write, the test
passes, MPos X 3489.06 steps against 3489, tail 23% of the tick, mask
4 kept, drift 0.000 mm; with a model kernel that ramps as cnc.c does
(the tick held 10 ms, then 5000 Hz off every 10 ms to the 900 Hz
floor), the test passes, MPos X 3509.97 steps against 3510, tail 32%.
Against the null-sink build of the parent of "Stream engine: a soft
reset during motion gets the kernel's ramp, and the position is read
back from the kernel's counters" (the commit "Test hooks: honored only
in a null-sink run, through one gate"), under the ramp model, the
read-back judge fails 5 runs in 5, MPos X one step past the kernel
each time (1.06, 1.06, 1.09, 0.99 and 1.10 steps); with the one-step
judge the same parent passed the read-back 3 runs in 5 (misses of
1.09, 0.95, 0.90, 0.98 and 1.09 steps). Every parent run also fails
on the kernel still running 1.0 s after the ^X, the tail at 100%, and
the mask 4 -> 0. The real counters and the machine's timing are read
on the machine only.

Acceptance: the change is inside the test's own function and its
decorator, so the shared text of suite/motion.py does not change. The
fingerprint of motion.jog-roundtrip moves, and no other test's: of
the implementation hashes of the 43 catalog tests, before and after,
only that one differs. The check runs on the bench in the next
campaign as part of motion.jog-roundtrip.
2026-10-17 19:06:48 -04:00
ScottW514 84a231163a laser_lifecycle_test: a ^X during the web-service homing keeps the lens hold
Rule 18 (home-reset) sends a ^X during the web-service homing session
and reads what the reset leaves. The session runs with homing_mode =
gfcloud and a session command that sleeps (sleep 47.25, a command no
other process on the host runs), so the device is the session's for
as long as the rule wants it; Session drops GFSINK from the inherited
environment, which is what lets gfcloud_home_cmd run.

A reset during the session marks the position lost: the device is
the session's, and nothing is read back. Left set, the core's re-init
clears the homed mask, and the driver's Z-only hold with it, so a jog
after $X would move an unreferenced lens. The rule reads:

- before the session, a 1 mm Z jog is refused with error 15: the lens
  has no reference in this session, so it is held where it stands;
- $H reports Home and the session's command is running;
- the ^X 0.5 s later lands in Alarm with ALARM:6, the session's
  command is gone within 3 s, and the attribute log holds no new
  "cnc/stop 1": the device was the session's, so the stream owed the
  kernel no stop;
- $X recovers to Idle; the Z jog is refused with error 15 again and
  MPos Z has not moved;
- a program move G91 G1 Z1 F100 raises ALARM:2, the soft limit of the
  pinned lens; a ^X and $X then leave the controller Idle.

The verdicts are the error code, the alarm codes, MPos Z, the
attribute count and the process count; no log wording is judged.
Session gets mpos_z(), the Z of a status report, beside mpos_x().

The soft-limit alarm asks for a reset before $X unlocks it, and the
rule sends that ^X only once a status report has come from the
alarm's wait: a ^X on the heels of the ALARM line lands while the
soft limit's own reset is still pending and is folded into it.
Measured on the null sink, a ^X 0 ms or 20 ms after the ALARM line is
lost (the alarm's wait goes on, $X answers error 79) and one 100 ms
after it is taken. The '?' polls that wait for the reset banner keep
the controller's output flowing: in Alarm its protocol thread waits
up to 10 s for traffic before it drains its output.

Proof: python3 -m py_compile and pyflakes 4.0.3 are clean on
scripts/bench/laser_lifecycle_test.py. Against the null-sink build of
the grblHAL-glowforge default branch head, which carries "Stream
engine: a reset whose position stays lost pins the lens before the
core's re-init", configured as its CI configures it
(-DGFMCODE_WAIT_S=5.0), rule 18 passes 10 runs in 10 and the harness
passes whole, with no session command left running. Against the
build of that change's parent ("Stream engine: the step a reset owes
is taken only when the idle lands between interrupt callbacks") rule
18 fails 3 runs in 3, each at the Z jog after $X: the parent answers
ok and the lens moves to Z 1.027. The harness runs in
grblHAL-glowforge CI, step "Armed-window lifecycle" (python3
forgefirm/scripts/bench/laser_lifecycle_test.py
build/grblHAL_glowforge), from the head of this repository's default
branch: this commit is pushed before that driver change.

Acceptance: none. scripts/bench/ is outside every covers map.
2026-10-17 18:53:44 -04:00
ScottW514 c978e76b4c laser_lifecycle_test: rule 17's abort lands in a move above the step-smoothing cutoff
Rule 17's ^X mid-move goes out during G1 X120 F3000 instead of
G1 X30 F600, and the plausibility bound on the distance the abort
kept widens to the new move's length. Every other line of the rule
stays.

At $100 = 213.333 the F600 move cruises at about 2133 steps/s. The
core's step smoothing puts a rate between 2000 and 4000 steps/s at
level 2: four interrupt callbacks per step period, and only one in
four carries an X step. The core counts a step one interrupt callback
ahead of the pulse start that outputs it, so a reset leaves a pending
step only when its idle lands on a callback that carries one, one run
in four at F600. On the null sink the shipped backlog is empty by the
time the reset runs and the residue is zero, so that pending step is
all the position check can see, and the check is the harness's only
proof of the driver's hand-over of the step at the idle and of the
read-back correction. At F3000 the move cruises at 10667 steps/s,
above the 8000 steps/s cutoff of level 0, where every callback
carries a step and every reset owes one. The ^X goes out 0.5 s after
the move reports Run and lands near 56 mm of the 120, inside the
move.

Proof: python3 -m py_compile and pyflakes 4.0.3 are clean on
scripts/bench/laser_lifecycle_test.py. Against the null-sink build of
the grblHAL-glowforge default branch head, configured as its CI
configures it (-DGFMCODE_WAIT_S=5.0), rule 17 passes 10 runs in 10
and the harness passes whole. Two scratch mutants of that tree's
driver.c, each built the same way: (a) readback_position keeps
sys.position as the core counted it, with the position_lost handling
kept; (b) stepperGoIdle hands the engine no pending step, the step
and direction bits passed to gf_stream_go_idle forced to 0. With this
change each fails rule 17 in 5 runs of 5, every time on the position
check with MPos one step past the stream (17104.0 steps against
17103 for (a), 17197.0 against 17196 for (b), in their first runs).
With the parent's F600 the same mutants pass rule 17 15 runs in 20
(a) and 14 runs in 20 (b). The harness runs in grblHAL-glowforge CI,
step "Armed-window lifecycle" (python3
forgefirm/scripts/bench/laser_lifecycle_test.py
build/grblHAL_glowforge), from the head of this repository's default
branch.

Acceptance: none. scripts/bench/ is outside every covers map.
2026-10-17 18:51:58 -04:00
ScottW514 18cba1daa2 forgetest: motion.jog-roundtrip sends a soft reset during a move and judges the kernel's stop and the position read back
motion.jog-roundtrip gets a check after its hold/resume segment, with
the head at the start and the machine at rest: a 60 mm +X G1 at F3000,
and a ^X 0.3 s after the kernel starts to play it. At a soft reset
during motion the grblHAL driver stops the kernel with its ramp, reads
the position back from the kernel's step counters, and keeps the core's
reference. No other catalog test sends a ^X while the head moves, and
the null-sink harness cannot read real counters: its residue is zero by
construction.

The check fails when:
- the controller is not in Alarm within 2 s of the ^X, or is Idle with
  no alarm (the move had ended before the ^X);
- cnc/state does not leave running within 1.0 s, or leaves it for a
  state other than idle, or cnc/underruns or cnc/faults change across
  the check;
- the kernel did not decelerate: over the run's last 25 to 50 ms, the
  bytes played (the position record, polled at 5 ms) are more than 70%
  of cnc/step_freq;
- the ring holds bytes after the reset;
- after $X, MPos X in steps ($100) and the kernel's X count differ by
  more than one step, or Y or Z moved;
- the homed mask changes, or the sender gets a [MSG:] line that the
  position cannot be read back;
- the jog back by the MPos distance leaves the kernel's X counter more
  than one step from its start.
The head is jogged back before these judgments, so a failure of one of
them leaves the head at the start.

The ramp witness is the tail rate because of the arithmetic. At the
stop the ring holds about 110 ms of ticks: the 200 ms queue and the
10 ms lead, less the 100 ms that the core's alarm hold plays. The ramp
to the kernel's 900 Hz floor uses 112.6 ms of ticks, in every mode,
because the ramp scales with the tick. So the run ends at the floor or
runs out of data part of the way down the ramp, and the steps that
never played can be zero. A minimum of unplayed steps in the driver's
read-back log line, the bytes played after the ^X, and the time to
leave running cannot tell the ramp from a run-out with a 20% margin.
The log line is also an INFO line, which the log level setting can
remove. The tail rate is 21% to 56% of the tick for 90 to 110 ms of
queue at the stop, and 100% for a run-out. The other readings and the
log line go to the evidence.

The homed mask comes from the controller port's state record (forgectrl
/motion/state): $# prints [HOME:...:mask] only with $22 on, and the
driver's default is off. Before the reset the mask must hold the Z bit
(the lens hold), because a mask without it cannot show a dropped
reference.

The test also requires kernel.k1-k2, the kernel's controlled stop on
the SoC. The title and the description say what the test proves now.
est_min stays 3.

Proof: compile and pyflakes 4.0.3 give no message on suite/motion.py
and no undefined name in forgetest or tests. forgetest unit tests,
python3 -B -m unittest discover -s tests -p test_<name>.py, in a
private PID namespace: test_manifest.py 39 OK, test_queue.py 15 OK,
test_campaign.py 13 OK. Coverage lint on the tree manifest from
scripts/manifest-from-tree.py (python3 -B -m forgetest.coverage
--manifest ../tree-manifest.json --enforce): 0 uncovered, 0 empty,
0 host-tested row problems, 43 tests. A host dry run of the whole test
against the null-sink build of grblHAL-glowforge f3158d3: the head
accelerometer, the kernel's state and bytes-played counter, and
forgectrl are stand-ins; the step counters are the steps of the shipped
stream; /motion/state is the controller's own control port. With a
model kernel that ramps from the controller's cnc/stop write, the test
passes: tail 23% of the tick, MPos X 3496.1 steps against 3496, mask 4
kept, the read-back log line found, the X counter back at its start,
drift 0.000 mm. With a model kernel that plays its queue to the end at
the tick, the ramp check fails at 100%. Against the null-sink build of
the parent (eb8eb61), the check fails on the mask (4 -> 0) and on the
read-back (MPos X 3483.1 steps against 3482). The real counters, the
ramp, and the machine's timing are read on the machine only.

Acceptance: the change is inside the test's own function, so the
shared text of suite/motion.py does not change. The fingerprint of
motion.jog-roundtrip moves, and no other test's: of the implementation
hashes of the 43 catalog tests, before and after, only that one
differs. The new check runs on the bench in the next campaign as part
of motion.jog-roundtrip.
2026-10-17 17:21:30 -04:00
ScottW514 9c768619f8 laser_lifecycle_test: a ^X during a move asks for the kernel's stop, and keeps the position and the lens hold
Rule 17 (cancel-abort) reads three more witnesses of a soft reset
during motion. Its session runs with GFSINK_ATTR_LOG, the sysfs writes
that the hardware would get, beside GFSINK_DUMP.

- After the ^X, the attribute log holds one more "cnc/stop 1" than
  before it: the controller asked the kernel to stop with its ramp.
- MPos X, in steps at $100, is the net X of the shipped stream within
  half a step: the position kept is where the stream left the head, so
  a step that the controller counted and never shipped fails the rule.
- After $X, a 1 mm Z jog is refused with error 15: the lens has no
  reference in this session, so it is held where it stands, and the
  abort keeps that hold.

The three new checks read no log wording: the attribute write, the
stream's bytes, MPos, and the jog's error code. The header's rule 17 says the same.
Session gets an attr_log option (GFSINK_ATTR_LOG, taken out of the
inherited environment otherwise) and three readers: setting(n) from a
$$ dump, attr_count(write) from the attribute log, and
shipped_x_steps() from the dump.

Proof: python3 -m py_compile and pyflakes 3.4.0 are clean on
scripts/bench/laser_lifecycle_test.py. The harness passes whole, on its
first attempt, against the null-sink build of grblHAL-glowforge with
the change that stops the kernel at a soft reset during motion and
reads the position back ("Stream engine: a soft reset during motion
gets the kernel's ramp, and the position is read back from the
kernel's counters", on top of eb8eb61), configured as its CI configures
it (-DGFMCODE_WAIT_S=5.0), and rule 17's session passes 10 runs in 10
there. Against the build of eb8eb61 the harness fails at rule 17, on
each of three attempts: "^X mid-move did not ask the kernel to stop
with its ramp (no cnc/stop write)". That build also accepts the Z jog
after the abort: a probe of it answered ok and then reported Z 1.027.
The position check, on scratch copies of the new driver: with the
core's pending step left out of the read-back, rule 17 fails 2 runs in
10, and with the read-back unapplied 3 runs in 10, each time with MPos
one step past the stream. The backlog is empty by the time the reset
runs, so a pending step is all that is left to miss, and a reset
leaves one only now and then. The harness runs in grblHAL-glowforge CI
(python3 forgefirm/scripts/bench/laser_lifecycle_test.py
build/grblHAL_glowforge) from the head of this repository's default
branch: push this commit first, then the driver change, as that
repository's AGENTS.md asks.

Acceptance: none. scripts/bench/ is outside every covers map.
2026-10-17 16:44:26 -04:00
ScottW514 79796b305b forgetest: the workflow_steps docstring says which list items the stray-quote rule reads
The docstring said a key with one stray quote as the first key of any
list item makes every step count as no step. The parser reads a step's
first key on its dash line and a list item outside a step, such as a
matrix entry, and does not read a list inside a step's body, so a
stray quote there leaves the step counted. The docstring now says so,
as the Testing page does.

Proof: forgetest-ci "Unit tests" step, test_manifest.py: 39 tests OK
on the committed tree (python -B -m unittest discover -s tests -p
test_manifest.py). pyflakes: clean. Coverage lint on the tree manifest:
0 uncovered, 0 empty, 0 host-tested row problems, 43 tests. The change
is a docstring; no case changes.

Acceptance: none; forgetest is dev-only and manifest.py is in no
catalog fingerprint.
2026-10-17 09:27:43 -04:00
ScottW514 7a71dd80b5 forgetest: the implementation hash walks a sibling imported as from . import x too
_imported walked a relative import only when it names a module (`from
.x import y`): a level-1 ImportFrom with no module, `from . import x`,
was passed over, so the suite module x joined no hash through it. The
guard on the suite's package imports reads levels 2 and 0 alone, so it
passed that spelling, and its own message sends an absolute import
there: "import it relatively (from .. or from .)". So the guard and the
walk did not read the same spellings: on a scratch copy of the catalog,
forgectrl.auth's import of setup.py written `from . import setup`
passed the guard, and an edit to setup.py's decode(), which the test
calls, left forgectrl.auth's implementation hash where it was.
homeoff.py imports cloud.py so (`from . import cloud as logs`); its
`from .cloud import log_lines_since, log_size` on the next line keeps
cloud.py in the walk, so no hash misses a module.

_imported now walks every level-1 relative import: one with no module
walks each name it imports as <suite>/<name>.py, the name and not its
alias (a name that is no module of the suite is passed over, as a
module was). The docstrings of _imported and sibling_imports, and the
comment of the guard's case, name both spellings.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 39 tests, OK, on Windows
with python 3.14 and on Ubuntu 24.04 with python 3.12.
test_a_sibling_imported_inside_a_function_moves_the_tests_that_run_it
gains a leg in which m.two imports judge.py as `from . import judge as
j`: an edit to judge.py moves m.two and not m.one. Negative controls on
scratch copies: catalog.py as it was fails that leg (the two hashes are
equal), and so does the walk that reads the alias (j) in place of the
name. On a scratch copy of the catalog with forgectrl.auth's import
written `from . import setup`, an edit to setup.py's decode() now moves
forgectrl.auth's hash. The coverage lint on the tree manifest from the
recipe pins reports 0 uncovered paths, 0 host-tested row problems, 43
tests. pyflakes reports no undefined name.

Acceptance: none. The source_sha of 43 of 43 tests is unchanged: the
one module-less sibling import outside the registry (suite/__init__.py,
which has no tests) is homeoff.py's, and cloud.py is in its walk
already. catalog.py is the registry and no shared judge, forgetest is
in no fingerprint, and tests/ are in no fingerprint.
2026-10-17 07:17:14 -04:00
ScottW514 614efb23e4 forgetest: the lint counts a host test only on a line of run text whose failure ends the step
workflow_runs counted any simple command in a step's run text as
running the test: _SPLIT_RX split the text on &&, ||, ;, |, newlines,
then, do and else, and every part was matched. So a step that hid the
test's failure, or never ran it, kept a HOST_TESTED row green, against
the HOST_TESTED comment's "a step ... whose failure fails it":
`./build/tray_test || true`, `| tee log`, `&`, `true ||
./build/tray_test`, an `exit 0` or a `set +e` line before the test,
`if false; then ./build/tray_test; fi`, a here-document's lines, a
folded block (`run: >`) whose next line is `|| true`, and a step
under `shell: bash {0}` or a `defaults: run: shell:` of pwsh each
counted. The workflow_runs docstring said "Only what a step's run
commands execute counts", which a command the shell skips, or whose
status it drops, does not meet. No workflow of the tree uses these
forms at a row's test.

workflow_runs now reads the run text line by line as bash -e or sh -e
runs it (_run_commands), a backslash at the end of a line joining the
next. _shell_words reads a line's words and operators, with quotes and
expansions ($(...), ${...}, `...`) inside the words. A command counts
on a line that holds it alone, with its redirections, or in a pipeline
under pipefail (shell: bash, or `set -o pipefail` on an earlier line),
and only while errexit holds: a `set` of errexit or pipefail is
followed. A pipeline with no pipefail does not count. The lint stops at
a line with any other operator (`||`, `&&`, `;`, `&`, a subshell, a
here-document), at a command that opens a compound command or negates a
status (if, case, while, until, a group, a function, `!`), at a command
after which it cannot tell what a later line runs (exit, exec, trap,
eval, source, alias, shopt, hash and the like), and at a `set` of an
option it does not follow (noexec): nothing on that line or after it
counts. A for loop counts in one form: `for <name> in <words>; do` on
its own line, body lines that count (a pipeline under pipefail only),
and `done` on its own line. A step counts only when its own shell:,
and the `defaults: run: shell:` of its job and of the workflow, are
each bash, sh or absent: workflow_steps now reads those defaults, and
reads a folded block, or a scalar continued on the lines under its key,
as one line, as YAML folds it. A prefix is taken off only with the
flags with which it runs its command (`sudo -E`, `env -i`), so `command
-v ./build/tray_test`, which prints the path, does not count; exec is a
stop, no longer a prefix. The HOST_TESTED comment and the docstrings of
workflow_steps and workflow_runs say what the lint reads.

The lint does not read, and counts as no step: `./build/x; true`
(bash -e stops at the test, and the lint refuses the `;` all the
same), `exec ./build/x`, a loop on one line or with `do` on a line of
its own, a pipeline with the test last and no pipefail, a test under
`set +e` whose status a later line checks, a test after a guard line
with `||` or `&&`, and a `set` of any other option (`set -E`). It
counts without reading: what a loop's body does with the loop's name
(its list counts), a `cd` before the test, a `defaults: run:
working-directory:`, what PATH or BASH_ENV change, and the runner's
operating system (it does not read runs-on; a Windows runner's default
shell is pwsh).

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 39 tests, OK, on Windows
with python 3.14 and on Ubuntu 24.04 with python 3.12.
test_a_step_counts_only_when_every_run_of_the_workflow_runs_it_and_its_failure_fails_the_run
gains these legs, none of which runs the test: `|| true`, `true ||`,
`; true`, `&` and a pipe with no pipefail on the test's line; an `exit
0`, `set +e`, `set -n` or here-document line before it; a `{` group
that `} || true` ends; a loop whose body is `python $t || true`, or a
pipe with no pipefail; a folded block and a continued scalar with `||
true` under the test; a step under `shell: bash {0}`; a job's and a
workflow's `defaults: run: shell: pwsh`. A pipe under `set -o
pipefail` and under shell: bash, and a step under shell: sh, run it.
test_what_running_a_host_test_looks_like gains `command -v
./build/tray_test`, which does not run it; its loop case is the loop as
python3-gfhardware's CI writes it (an assignment from `$(basename "$t"
.py)` in the body), and its glob case is a loop on lines of its own.
Negative controls on scratch copies: manifest.py as it was fails the
first new leg (True is not false), and each new leg reads as running
the test there; 19 mutations of the new reading each fail a case: exit
no stop, errexit not followed, an unknown `set` option followed, a
here-document read as a redirection, no reserved words, pipefail always
on, shell: bash with no pipefail, `set -o pipefail` not followed, the
loop's body not read, a body pipeline with no pipefail read, a folded
block read line by line, a continued scalar's lines dropped, the job's
defaults not read, the workflow's defaults not read, any shell read,
sh not read, `$(` read as an operator, every flag of a prefix taken
off, and `sudo -E` kept. Under bash -e with a failing stand-in test,
the step exits 0 for `|| true`, `true ||`, `&`, a pipe with no
pipefail, an earlier `exit 0`, `set +e` or `set -n`, the `{ } || true`
group, both loop bodies and the folded block; `; true` exits 1. A
here-document's lines are its command's input (`cat <<'EOF'` exits 0),
and `bash {0}` runs with no errexit, so a line after the test decides
the step (exit 0). The 17 workflows of the tree's repositories (211
steps) read to the same events and steps; of their 161 run steps, 20
count fewer commands and 4 the same commands with a redirection taken
off or an expansion kept as written, none of them a step that runs a
row's test. Each
of the 46 HOST_TESTED rows runs its test under the new reading, in the
workflows at the recipe pins and at the heads of forgectrl and
forgeext. The coverage lint on the tree manifest from the recipe pins
reports 0 uncovered paths, 0 host-tested row problems, 43 tests.
pyflakes reports no undefined name.

Acceptance: none. forgetest is dev-only and in no fingerprint (the
source_sha of 43 of 43 tests is unchanged); the lint runs in CI, not in
a campaign, and tests/ are in no fingerprint.
2026-10-17 07:14:07 -04:00
ScottW514 3aecf800a5 forgetest: the lint's stray-quote rule reads a step's first key, on its dash line, too
The rule that a key with one stray quote makes every step of the
workflow count as no step never saw a step's first key, the one on its
dash line. _STEP_RX reads no key with a stray quote (`- run": make`,
`- name": Build`), and _stray_key cannot match a line that starts with
`- `. So such a step dropped out with no "unread" set, and the
workflow's other steps still counted. YAML reads the key with its quote
(PyYAML gives {'name"': 'Build', 'run': 'make'}), or refuses the file
(`- "run: make`), and GitHub refuses a workflow with a step key outside
the step schema: a HOST_TESTED row passed the lint for a workflow that
runs nothing. The HOST_TESTED comment, the workflow_steps docstring and
the Test page state the rule for a step's key, and every step's first
key is on its dash line. No workflow of the tree has such a key.

workflow_steps now reads the first key of every list item as it reads a
step's other keys: the dash and the spaces after it become spaces, and
a key with one stray quote there makes every step of the workflow carry
"unread", which _on_every_run refuses. The rule also takes in a list
item outside the steps (a matrix include entry): that only lists more.
The docstring says so.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 39 tests, OK, on Windows
with python 3.14 and on Ubuntu 24.04 with python 3.12.
test_a_step_counts_only_when_every_run_of_the_workflow_runs_it_and_its_failure_fails_the_run
gains a step `- run": make` before a step that runs the test: no step
counts. Negative control on a scratch copy: manifest.py as it was fails
that case (True is not false). A probe of workflow_runs: `- name":
Build` with `run: make` under it, and `- "run: make`, each before a
step that runs the test, count as no step, as a stray quote on a
step's second key already did. The 17 workflows of the tree's
repositories (211 steps) read to the same events and steps before and
after the change. The coverage lint on the tree manifest from the
recipe pins reports 0 uncovered paths, 0 host-tested row problems, 43
tests. pyflakes reports no undefined name.

Acceptance: none. forgetest is dev-only and in no fingerprint (the
source_sha of 43 of 43 tests is unchanged); the lint runs in CI, not in
a campaign, and tests/ are in no fingerprint.
2026-10-17 06:59:13 -04:00
ScottW514 609ae561bf forgetest: forgectrl's test scratch trees leave the repository, and the two still written are ignored
forgetest/cool-flow-test/, forgetest/super-test/ and
forgetest/cool-fanwrite-test/ (46 files) are the scratch trees that
forgectrl's cool_flow_test and super_test, and the fan-write test that
cool_flow_test took in, write in the directory they run from: fake
sysfs attributes, a cooling.state verdict, an empty dev/glowforge.
447f902 committed them beside a change to suite/exthost.py that needs
none of them. Nothing reads them: forgetest.bb fetches forgetest/LICENSE,
forgetest/forgetest/, forgetest/forgetest.init and scripts/bench/, no
manifest component holds them, and no other file of the repository
names them.

The three trees go. forgetest/.gitignore ignores the two that the
forgectrl tests still write, so a run from this directory cannot stage
them again.

Proof: git grep finds the three directory names nowhere else in the
repository. forgetest-ci "Unit tests" (`python -B -m unittest discover
-s tests`) on this tree with meta-openglow beside it, on Ubuntu 24.04
with python 3.12 in a private PID namespace: 357 tests OK, with
test_image.py's account-render case skipped there (it makes a namespace
through sudo -n); test_image.py on its own outside the namespace: 29
tests OK. pyflakes reports no undefined name. The coverage lint on the
tree manifest from the recipe pins reports 0 uncovered paths, 0
host-tested row problems, 43 tests.

Acceptance: none. forgetest is dev-only, and the trees are outside
forgetest.bb's sources, every manifest component and every layer
content hash.
2026-10-16 23:56:28 -04:00
ScottW514 c849603942 bench README: the lifecycle harness row links the Test page plainly, as the bench page does
The row said that what laser_lifecycle_test.py walks is on the site,
Developers, "Testing". The Test page lists four of the fifteen sessions
main() runs, by choice: the harness header is the full list. And the
page is "Test" in the site's navigation, in its title and in the bench
page's own link; the site has no page called "Testing". The row now
says what the harness is and links the Test page by its title, as the
bench page of the site does.

Proof: README text only, no behavior change. The row names no session
and makes no claim about the page's list; its link text is the page's
title (mkdocs.yml: "Test: developers/testing.md"), and its URL does not
change.

Acceptance: none. **/*.md is outside every fingerprint
(NON_BEHAVIORAL in manifest.py).
2026-10-16 23:49:30 -04:00
ScottW514 6473e7d9ae forgetest-ci: the undefined-name step prints why it failed, and fails a run SIGINT stopped
The comment over the step said that a signal fails it. pyflakes ends a
SIGINT and a SIGPIPE itself, with status 1 ("... stopped" for SIGINT),
and status 1 goes on to the grep, so a run SIGINT stopped mid-tree
passed the step with nothing judged. And every failure the step gained
for a status other than 0 or 1 left no cause in the step log: the
group's 2>&1 also captures bash's own notice of a signal ("Killed"),
and nothing printed pyflakes.txt, so a usage error or a killed run
showed only the pyflakes version line before the failure.

The capture is printed when the pyflakes group fails, and the grep
also takes the "... stopped" line. The comment says what fails the
step: the grep's lines, a SIGINT stop, any status other than 0 or 1,
and a capture that cannot be written; a SIGPIPE ends with status 1 and
no line, and a write to the capture file raises none.

Proof: the step body, read with PyYAML from this workflow and, as the
control, from the workflow before this change, run under bash -e over
the forgetest tree on Ubuntu 24.04 with python 3.12 and pyflakes 4.0.3
(the install line a no-op: the interpreter on PATH is a shim to a
venv). The tree as it is exits 0 with both bodies, and an undefined
name planted exits 1 with its line in the log. A run that exits 2 and
a run killed by SIGKILL while it blocks on a FIFO exit 1 with both
bodies; the step log of the former body holds only the version line,
and this one prints "unrecognized arguments" and the "Killed" notice.
A run stopped by SIGINT exits 0 with the former body and 1 with this
one, its "... stopped" line in the log. A working directory that
cannot be written exits 1 with both, its "Permission denied" in the
log.

Acceptance: none; CI only. .github/ is outside meta-forgefirm and
every covers map.
2026-10-16 23:49:02 -04:00
ScottW514 fc6b7797af forgetest: the shared judges' comments say why puls.py is one
The comment over SHARED_JUDGES in catalog.py said that every test runs
through all four judges without importing them, puls.py among them, and
the comment of test_a_shared_judge_edit_moves_every_test said the same.
No test runs puls.py but the three that print the dark job
(cloud.lid-interlock-abort, cloud.oversize-stream and
exthost.armed-freeze, through offline_job), and suite/cloud.py imports
it with `from .. import puls`: it is a judge because the sibling walk
does not follow that import, as the Acceptance page of the site says.

Both comments now say that every test runs through runner.py, hw.py and
baseline.py with no import to follow, and that puls.py comes in by a
package import the walk does not follow. Comments only: SHARED_JUDGES
does not change.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 39 tests, OK, on Windows
with python 3.14 and on Ubuntu 24.04 with python 3.12. The only import
of puls in the package is suite/cloud.py's (grep). pyflakes reports no
undefined name.

Acceptance: none. catalog.py is the registry and in no fingerprint, and
tests/ are in none (source_sha of 43 of 43 unchanged).
2026-10-16 23:46:48 -04:00
ScottW514 a10fd7782d forgetest: the hash cases hold what a test's own import imports in turn, and the guard refuses an absolute package import
No case held the step of implementation_sha that adds what a test's own
import imports in turn (`sibling_imports(sib, sibs)`), nor the
recursion in sibling_imports itself: the judge.py of
test_a_sibling_imported_inside_a_function_moves_the_tests_that_run_it
imports nothing. Either line deleted kept test_manifest.py green, while
on the real catalog the first drops motion.py's shared text from
cloud.mode-switch's hash and setup.py's from motion.release's. The guard
on the suite's package imports read the relative spelling alone: a
`from forgetest import server`, an `import forgetest.server`, or a
`from forgetest.suite.setup import ...` in a suite module passed it,
and the sibling walk does not follow any of them, so the module they
name joined no hash.

The case's judge.py now imports limit.py, which imports base.py: an
edit to either moves m.two and not m.one. The guard refuses any import
of the package by its absolute name in a suite module, at any depth.
No suite module imports the package that way. catalog.py does not change.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 39 tests, OK, on Windows
with python 3.14 and on Ubuntu 24.04 with python 3.12. Negative
controls on scratch copies: catalog.py without `sibling_imports(sib,
sibs)` in implementation_sha fails the case at the limit.py edit; the
walk without its recursion fails it at the base.py edit; in
suite/forgectrl.py, a `from forgetest import server` and an `import
forgetest.server` inside a function, and forgectrl.auth's import of
setup.py spelled `from forgetest.suite.setup import request, decode`,
each fail the guard. pyflakes reports no undefined name.

Acceptance: none. tests/ are in no fingerprint (source_sha of 43 of 43
unchanged).
2026-10-16 23:45:25 -04:00
ScottW514 2af9b0277f forgetest: the lint's docstrings and its row reason say what it reads, not what a workflow runs on
host_row_problems' docstring gave the reason a row is listed as "a
dispatch, a call or a schedule runs on no change". A workflow on
workflow_call alone runs on every run of the workflow that calls it
(forgeext's package.yml is one); the lint lists it because it does not
follow a call to its caller. The reason it printed, "<ci> runs on no
push or pull request", was false the same way for such a workflow, and
for a flow mapping (`on: {push: ...}`) that names push. The docstrings
of _on_every_run and workflow_runs said that a step under an `if:`
other than true does not run on every run, where the lint evaluates no
condition: a step under `always()` or `success()` runs on every run and
still counts as no step. The Test page of the site says each of these
as the lint applies it.

The docstrings now say what the lint reads: a row is listed when the
workflow's own `on:` names neither push nor pull_request as the lint
reads it (a dispatch, a call or a schedule alone, a call not followed
to its caller, a flow mapping), and a step with a condition counts as
no step because the lint evaluates none. The row's reason reads "<ci>:
the lint reads no push or pull_request in its own on:". The comments
of the two cases that hold these rules say the same. No rule changes:
the same rows are listed, for the same workflows.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 39 tests, OK, on Windows
with python 3.14 and on Ubuntu 24.04 with python 3.12, with
test_the_workflow_has_to_run_the_host_test asserting the new reason. A
probe of host_row_problems: a workflow on workflow_call alone and one
whose flow-mapping `on:` names push are each listed with the new
reason, and a plain `on: [push]` one is not. The coverage lint on the
tree manifest from the recipe pins reports 0 uncovered paths, 0
host-tested row problems, 43 tests. pyflakes reports no undefined name.

Acceptance: none. forgetest is dev-only and in no fingerprint (the
source_sha of 43 of 43 tests is unchanged); the lint runs in CI, not in
a campaign, and tests/ are in no fingerprint.
2026-10-16 23:43:23 -04:00
ScottW514 c717ecbcce forgetest: the lint reads a quoted key only in a matched pair, and a key with one stray quote runs nothing
_KEY_RX took an opening quote and a closing quote each on its own, so a
key with one stray quote read as the bare key: `push":` and
`pull_request':` counted as push and pull_request, where YAML reads the
keys `push"` and `pull_request'`, which are no events; a step's
`run": ...` counted as its run command, and `uses": actions/checkout@v4`
as a checkout. A HOST_TESTED row whose workflow was spelled so passed
the lint, while that workflow runs on no push and runs no such step.

_KEY_RX now reads a quote only as a matched pair. A key with one stray
quote is no key the lint reads, and where the lint reads keys it counts
as running nothing: an event key spelled so makes the `on:` name no
event, and one at the top level, at a job's name or key, or at a step's
key below its dash line makes every step of the workflow count as no
step (each carries "unread", which _on_every_run refuses). So a step's `if": false` still
keeps the step from counting, as it did under the loose reading, and
under a `jobs":` top level, where a job's `if: false` goes unread, no
step counts either. The HOST_TESTED comment says so. The lint only
lists more: a workflow with no such key reads as before.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 39 tests, OK, on Windows
with python 3.14 and on Ubuntu 24.04 with python 3.12.
test_the_events_a_workflow_runs_on gains an `on:` with `push":`, and
one with `push:` and `'pull_request:`, each naming no event, and
test_a_step_counts_only_when_every_run_of_the_workflow_runs_it_and_its_failure_fails_the_run
gains a step with `if": false`, a step with `run": ./build/tray_test`,
a job with `if": false` and a `jobs":` top level, none of them running
the test. Negative controls on scratch copies: _KEY_RX as it was fails
the `run":` step and the `push":` event; the matched pairs with no
stray-quote rule fail the `if": false` step and the second `on:`; the
rule at a step's keys alone fails the job's `if": false`. The 17
workflows of the tree's repositories (211 steps) read to the same
events and steps before and after the change. The coverage lint on the
tree manifest from the recipe pins reports 0 uncovered paths, 0
host-tested row problems, 43 tests. pyflakes reports no undefined name.

Acceptance: none. forgetest is dev-only and in no fingerprint (the
source_sha of 43 of 43 tests is unchanged); the lint runs in CI, not in
a campaign, and tests/ are in no fingerprint.
2026-10-16 23:41:24 -04:00
ScottW514 49f087f376 forgetest: a failed press is recorded as the fixture's only when its request went out
_press_taken recorded the press as the fixture's for every FixtureError
that arrived with the button out. That included failures in which the
box made no press for the request: its refusal (a 401, or the 409 of a
jumper pulled after the probe), a failed mDNS lookup, a refused
connect. When the person at the machine pressed meanwhile, or the arm
wait ended for another reason, the evidence and the exported artifact
gave the box a press it never made, and press_now returned True.

FixtureError now carries `sent`: True when the failure came after the
request went out (a timeout or a reset once the request was sent,
which urllib raises bare; a failed connect or send is a URLError), so
the box may have pressed. The one retry after a fresh lookup keeps it,
also when the lookup itself fails. A refusal and a failed lookup leave
it False. With the button out, _press_taken still puts up no notice,
and it records the press as the fixture's only for a sent error;
otherwise the record stays the operator's, and press_now returns
whether the fixture pressed. No judge reads `by`, and no energy path
changes.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_fixture.py runs 22 tests, OK on Ubuntu
24.04 with python 3.12, and on Windows with python 3.14 with
test_ready_reads_a_short_press_as_an_edge skipped (no os.mkfifo).
test_a_failure_after_the_request_went_out_says_so drives the real
client against the fake box: a press whose reply comes after the
client's timeout is sent, with one press, also when the refresh lookup
after it finds nothing; the jumper-out refusal, a failed lookup and a
refused connect are not sent, with no press.
test_the_arm_notice_comes_down_when_the_press_is_taken gains the leg
where the box refuses while the button goes out: press_now returns
False with no notice and the record the operator's, and arm_press
leaves the same. test_server.py (10), test_operator.py (17),
test_queue.py (15), test_mode.py (9), test_setup_dark.py (21) and
test_setup_burns.py (24) pass on both. Negative controls on scratch
copies: _press_taken giving every failure to the box fails the refusal
leg (press_now True); `sent` never set fails the first client leg;
`sent` set for a URLError too fails the refused-connect leg; the retry
that drops the flag fails the retry leg. pyflakes reports no undefined
name.

Acceptance: every catalog test's fingerprint moves: runner.py is a
shared judge, in every implementation hash (43 of 43 move); fixture.py
is in none. Against the campaign this series already needs, that adds
no run: 850498f moved all 43 once. tests/ are in none.
2026-10-16 23:36:59 -04:00
ScottW514 e046c534c2 setup.focus: the tube-current case plants a reading at HV_ON, so it holds the threshold
test_a_burn_whose_tube_current_the_test_never_saw_rise_fails planted
pic/hv_current at 0. That proved the check on the test's own reads
exists, but not its threshold: the check written as `> 0`, or as
`>= HV_ON`, passed every case. The case now plants HV_ON itself (30,
which is not over 30), so with the 431 the passing cases plant, the
threshold is held between 30 and 430. setup_burns.py does not change.

Proof: tests/test_setup_burns.py, the Unit tests step of forgetest-ci.yml
(python -B -m unittest discover -s tests), 24 tests OK on Windows with
python 3.14 and on Ubuntu 24.04 with python 3.12. Negative controls on
scratch copies: the check as `> 0`, as `>= HV_ON`, and as no check at
all each fail the case (Failed not raised); the case as it was (a
plant of 0) passes against the `> 0` check. pyflakes reports no
undefined name.

Acceptance: none. tests/ are in no fingerprint (source_sha of 43 of 43
unchanged).
2026-10-16 23:30:06 -04:00
ScottW514 36c1b75bb8 setup.focus: the head's place judged against the stops its manual home declared, read from the home anchor
head_at_home placed the gantry stops at minus manual_home_x and
manual_home_y as the settings read at the burn. A manual home reads
them once: it declares the head at the stops at minus the offsets, and
with an offset it moves the head to X0 Y0, that far in front of them. A
write of the offsets after the home restarts no controller and keeps
the X/Y reference. So a home made with 5 and 3, the offsets then
cleared, had the head at X0 Y0 judged home 5 and 3 mm in front of the
stops, and the burn started: the false PASS 2172ae4 set out to close.
The other way round, offsets set after a home made with none failed a
head that was at the stops. The home anchor holds the stops as the home
declared them: /status reports the anchor's X and Y plus the step
counters.

head_at_home now takes the stops from the X and Y of the anchor
(baseline.ANCHOR_PATH) when /status reports X and Y referenced by a
manual home, and judges the head against them within HOME_TOL_MM. An
anchor that is gone, or that is not a manual home of X and Y, fails the
test before any start. The settings are read for the remedy alone. With
an offset, the remedy names how the push ends: release the motors, push
the head against the stops, and end the release with Energize motors
($ME), not with Set home here, which moves the head off the stops to X0
Y0 again. The remedy before said only to release the motors and push
the head, and a release left standing makes the burn ask home-push,
which the test does not answer. Without an offset it stays "home it
there". The judge only refuses more: no energy or motion path changes.

Proof: tests/test_setup_burns.py, the Unit tests step of forgetest-ci.yml
(python -B -m unittest discover -s tests), 24 tests OK on Windows with
python 3.14 and on Ubuntu 24.04 with python 3.12. BurnRunTests plants
the anchor of a home with no offset beside the fake's /status, and
test_a_manual_home_offset_puts_the_stops_off_x0_y0 plants the anchor of
each home: a home made with 5 and 3 fails the head at X0 Y0 and passes
it at X-5 Y-3; the offsets cleared after that home still fail the head
at X0 Y0 before any start (no wizard start, no lever); the offsets set
after a home made with none pass the head at X0 Y0; an anchor that is
gone, or that reads as the startup's Z-only reference, fails before any
start. That last pair replaces the leg where an offset that did not
read as a number failed: the stops no longer come from the settings,
and the rule that leg held (no stops placed, nothing starts) holds on
the anchor. test_setup_dark.py (21) and test_manifest.py (39, with the
guard on the suite's package imports, which setup_burns.py's import of
baseline.py passes) pass. Negative controls on scratch copies:
head_at_home as it was fails the case at the offsets cleared after the
home (Failed not raised), and so do the stops read from the settings;
an anchor that cannot be read taken as X0 Y0 fails it at the anchor
that is gone; an anchor of any axes and any source fails it at the
startup anchor. A probe of the failure text: with the offsets 5 and 3
it names Energize motors ($ME), not Set home here; with none it says
"home it there". pyflakes reports no undefined name.

Acceptance: setup.focus's fingerprint moves (setup_burns.py's shared
text and the test's own description); the other 42 do not move
(source_sha compared before and after). baseline.py is a shared judge
already, in every hash. Against the campaign this series already
needs, that adds no run: 850498f moved all 43 once.
2026-10-16 23:28:00 -04:00
ScottW514 ce73dbb052 forgetest: a check run_check stops following at its timeout is aborted before the test fails
run_check judged its timeout with ctx.check, which raises, ahead of the
abort meant for that case, so the abort never ran: a check still
running at the end of its follow was left running behind the FAIL. For
setup.focus that is a live burn wizard. Its put-back waits 120 s for
the burn to end and then skips the controller restart, and forgectrl's
own bound on a live run (1500 s) outlasts the test's 900 s follow. The
same driver serves the setup.check-* tests,
setup.cloud-header-capture and motion.release.

The abort now goes out before the timeout is judged, and the
unreachable return after it is gone. The change only ends a check the
test started itself: it adds no energy and no motion.

Proof: tests/test_setup_dark.py, the Unit tests step of forgetest-ci.yml
(python -B -m unittest discover -s tests), 21 tests OK on Windows with
python 3.14 and on Ubuntu 24.04 with python 3.12:
test_a_check_that_does_not_end_in_time_is_aborted_before_the_test_fails
(a lens.travel check that never ends, followed for 0.2 s: Failed, and
/wiz/lens.travel/abort posted). test_setup_burns.py (24) and
test_setup_suite.py (15) pass. Negative control on a scratch copy: the
timeout judged before the abort, as it was, fails the case (only
/wiz/lens.travel/start posted). setup.focus run whole against
test_setup_burns.py's scripted daemon with a burn that never ends (the
follow cut to 0.5 s, the press prompt held open): before, Failed with no
abort, the wizard still running and the restart skipped ("the burn is
still ending"); after, Failed with laser.focus aborted, the wizard
stopped, and the controller stopped and started (200, 200). pyflakes
reports no undefined name.

Acceptance: the shared text of setup_dark.py moves the fingerprints of
its five tests (setup.check-switches, setup.check-airflow,
setup.check-motion, setup.check-lens, setup.cloud-header-capture), of
setup.focus (setup_burns.py imports it at its top level) and of
motion.release (its own function imports run_check); the other 36 do
not move (source_sha compared before and after). Against the campaign
this series already needs, that adds no run: 850498f moved all 43
once.
2026-10-16 23:00:54 -04:00
ScottW514 7b0f065a3f laser.emission-witness: the head is placed back only by a sample that read the window open
rapid_place searched the samples up to the first disarmed one, that one
included, and sample_to_disarm read each sample's X after its armed
flag. So the first sample to read the window closed could place the
head back with an X read after the close: a window that closed on the
return leg, up to about one sampling loop before the head was back,
placed the rapid inside the window with a disarm time of 0, and the
`0 <=` on that time could not fail.

rapid_place now searches the samples before the first disarmed one
alone, and sample_to_disarm reads the controller's report (its state
and X) before the sample, so a sample that reads the window open read
its X with the window open. The head stands back for 1 s before the M2,
not 0.5 s: the placement needs a sample whose X and armed flag both
come between the head's return and the close, and a slow sampling loop
needs that time. The judge only gets stricter. No energy or motion path
changes: the longer dwell runs dark after the M5 with the window open,
as the shorter one did.

Proof: tests/test_laser_dwell.py, the Unit tests step of forgetest-ci.yml
(python -B -m unittest discover -s tests), 13 tests OK on Windows with
python 3.14 and on Ubuntu 24.04 with python 3.12:
test_a_window_closed_before_the_head_is_back_does_not_place_the_rapid
gains the close at the first sample with the head back (2, None, 6),
and test_the_sampling_runs_on_past_the_close_and_ends_where_it_stopped
holds the order (each sample's X read before its armed flag).
test_cooling_suite.py (3) passes. Negative controls on scratch copies:
the search with the first disarmed sample included fails the first
case with (2, 6, 6); the report read after the sample fails the second.
emission_witness run whole on a fake clock with only its I/O stubbed
(the review's sweep: the armed flag served mid-GET, X read mid status
report, 40 phases of the rapid against sampling loops of 0.20 to
0.64 s): a window closing 0 to 2 s before the head is back fails the
placement at every phase and every loop, and the normal close passes
at 40 of 40 phases at every loop. The code before this change passed
early closes at some phases up to about one loop early (0.20 s at a
0.20 s loop, 0.5 s at a 0.64 s loop). With the new order and the 0.5 s
dwell, the normal close passed 39 of 40 at a 0.38 s loop and 12 of 40
at a 0.64 s loop. pyflakes reports no undefined name.

Acceptance: laser.emission-witness's fingerprint moves (its own text and
laser.py's shared text), laser.armed-kill and
laser.pause-resume-lid-cancel move through the shared text, and
cooling.flow-under-load and cooling.fail-tier-stop move because they
import from .laser in their own functions; the other 38 do not move
(source_sha compared before and after). Against the campaign this
series already needs, that adds no run: 850498f moved all 43 once.
laser.emission-witness is always run, and the next campaign judges the
placement on the bench.
2026-10-16 22:56:33 -04:00
ScottW514 d57421c71d bench README: the lifecycle harness row points at the Testing page
The row for laser_lifecycle_test.py in scripts/bench/README.md credited
the harness with M5/M3 persistence inside a job, the M2 close, and the
arm refusal under a blocking cooling verdict. The harness runs no
session for any of the three: its header gives them to laser_arm_test.c
case I, laser_stream_test session K, and laser_arm_test.c cases C, A2
and A3. The row also restated a list that the site keeps (Developers,
"Test"), and a README is an index card. The row now says what the
harness is and points at that page, as the bench page of the site does.

Proof: README text only, no behavior change. The three claims are gone
from the file, and each of the four sessions the Test page lists
is one main() runs.

Acceptance: none. **/*.md is outside every fingerprint
(NON_BEHAVIORAL in manifest.py).
2026-10-16 14:54:18 -04:00
ScottW514 50c56a863d laser_stream_test: rule 19 names every laser session that runs with the dose curve in force
Rule 19 of the header said that every session but the rolloff reference
run has laser_dose_curve = off. Beside that run, four laser sessions run
with the curve in force: rolloff-g1 and rolloff-g2 (rule 20) and
rolloff-two (rule 23) under DENSITY_CONF_CURVED, which carries no curve
key, and jog-dark (rule 22), which runs with no configuration at all,
so the controller's curve_default applies. Rule 20 itself says "under
M4 with the curve in force". Rule 19 now says that every laser session
but the rolloff ones (rules 20 and 23) and the jog run (rule 22) runs
with laser_dose_curve = off. The z session carries no laser key and
fires nothing, so "laser session" leaves it out.

Proof: header text only, no behavior change. python3 -B -m py_compile
and pyflakes are clean on scripts/bench/laser_stream_test.py, and the
harness passes whole, every session, against the native null-sink
build of the driver at badc832 (-DGFMCODE_WAIT_S=5.0). The harness runs
in grblHAL-glowforge CI (the step "Laser stream emission rules",
python3 forgefirm/scripts/bench/laser_stream_test.py
build/grblHAL_glowforge).

Acceptance: none; the header docstring of a harness outside every
covers map.
2026-10-16 14:53:14 -04:00
ScottW514 2172ae4101 setup.focus: the head's place judged against the gantry stops, at minus the manual home offsets when they are set
head_at_home judged a head referenced by a manual home at X0 Y0, on
the premise that a manual home declares X0 Y0 at the gantry stops. That
holds only while manual_home_x and manual_home_y are blank or 0. With an
offset, the manual provider declares the head at the stops at minus the
offsets and then jogs it to X0 Y0, that far in front of them
(glowforge_homing.c manual_home; the Homing pages of the docs), and
/status reads pos X0 Y0 there. The wizard draws every card from the
head's home against the stops (wizlive.c head_home; Setup, "The head at
home"), and the test answers HOME to that prompt. So with an offset the
judge recorded judged True and "the head is home on record" for a head
the offsets in front of the stops, the burn ran from there, and its box
landed that much farther from the stops. The failure message (home it
there) rebuilt the same state.

The sources put the home of the burn at the stops, and the stops at
minus the offsets in the coordinates /status reports, so the judge now
judges the head there. head_at_home reads the two offsets from
/settings (blank is 0; a value that is not a number of 0 or more fails
the test before any start), records the stops it judges against in
head_at_start, and judges pos against them within HOME_TOL_MM. With no
offset that is X0 Y0, the rule as it was. With an offset, a head that a
manual home left at X0 Y0 fails before any wizard start, with no
controller lever touched, and the message names the offsets, where
they put the stops, and the remedy: release the motors and push the
head against the stops (the head's place then rests on the operator's
word, as for any unreferenced head, once the release ends with
Energize motors). Judging at X0 Y0 under an offset
would affirm a place the wizard does not ask for, and recording the
read without judging it would let the test answer HOME with a read in
hand that puts the head away from the stops; neither is taken. The
offsets are read as they stand at the burn: /status does not report
the offsets the home used, and the home anchor, which holds the stops
that home declared, is not read. The description and the comments say
what is judged.

Proof: tests/test_setup_burns.py, the Unit tests step of
forgetest-ci.yml (python -B -m unittest discover -s tests), 24 tests OK
on Windows with python 3.14 and on Ubuntu 24.04 with python 3.12:
test_a_manual_home_offset_puts_the_stops_off_x0_y0 (manual_home_x 5 and
manual_home_y 3, homed_axes 7, home_source manual: at X0 Y0 the test
fails before any start, the stops on record at X-5 Y-3, no /controller/
lever posted, the settings as found; at X-5 Y-3 the burn runs, judged
True; manual_home_x "five" fails before any start), and the lit burn's
record carries the stops at X0 Y0. Negative controls on scratch copies:
setup_burns.py as it was, and the offsets taken as 0, each fail the new
case at its first run (the burn starts). pyflakes reports no undefined
name.

Acceptance: setup.focus's fingerprint moves (its own text and
setup_burns.py's shared text); no other test moves (source_sha compared
before and after). Against the campaign this series already needs, that
adds no run: 850498f moved all 43 once.
2026-10-16 14:48:32 -04:00
ScottW514 9451587728 setup.focus: the test's own sysfs reads go on record however the burn ends, and their tube-current check has a case
emission_seen, the two peaks the test reads from sysfs at every poll
and the count of polls that read both, was written inside emission_ok
after the three checks of the burn's result. A burn the daemon reports
dark fails at the first of them (Context.check raises), and a burn that
ends in a wizard error, a timeout or an abort never reaches emission_ok.
On each of those paths the record kept the daemon's word alone, where
the test's own reading is the one worth comparing with it. burn() now
records emission_seen, and logs it, in the finally of its run_check, so
it is on record however the burn ends. emission_ok keeps its checks, in
their order.

The check that the tube current read over HV_ON from here had no case
that could fail it: the one negative case reads both attributes at 0
and stops at the LASER_ON check, which comes first. A new case reads
the LASER_ON count nonzero and the tube current at 0, and fails on the
tube current.

Proof: tests/test_setup_burns.py, the Unit tests step of
forgetest-ci.yml (python -B -m unittest discover -s tests), 23 tests OK
on Windows with python 3.14 and on Ubuntu 24.04 with python 3.12, with
tests/test_setup_dark.py (20 OK):
test_a_burn_whose_tube_current_the_test_never_saw_rise_fails
(cnc/laser_on_sampled 118, pic/hv_current 0: Failed "the tube current
never read over 30 from here", the settings as found);
test_a_dark_burn_fails_and_still_restores finds emission_seen at 118
and 431 beside the dark result; the aborted burn's record carries
emission_seen too. run_test keeps the Run of a test that fails.
Negative controls on scratch copies: the tube-current check made
vacuous (True or ...) fails the new case (Failed not raised);
setup_burns.py as it was fails the dark case (no emission_seen) and the
aborted case. pyflakes reports no undefined name.

Acceptance: setup.focus's fingerprint moves (setup_burns.py's shared
text); no other test moves (source_sha compared before and after).
Against the campaign this series already needs, that adds no run:
850498f moved all 43 once.
2026-10-16 14:44:40 -04:00
ScottW514 bed46e961e laser.emission-witness: the read rule ends where the sampling stopped, and the rapid is placed inside the window
The dark stretch after the M5 runs on past the close, and three of its
judgments did not hold with it.

The read rule took the last sample served as the trail's last edge, and
sample() returns None for a request the daemon does not answer. So an
outage after the served close (one request that hangs to the client's
10 s timeout is enough) left a trail that ended at the close with no
gap, and the window that latches after the close was never read: the
test passed whole with a 30 ms fire at the end of the return rapid.
The sampling could also stop as early as about 1.8 s past the close,
and with a 0.9 s unread tail allowed, the last reading could come
before the latch of the window in flight: the review's tail model
(the kernel's ~1 s window latch, the sampler's GET timing, a fire just
before the close) finds 4 runs in 20000, 21 with a wider GET spread,
where that window goes unread and the judge passes.

The placement and the disarm timing searched the whole stretch. A
window that closed before the head was back (in the dwell at +20 mm
or on the return leg, up to about 2 s early) found the head back after
the close and passed "the rapid ran inside the dark stretch", with a
negative disarm time.

The rapid's sample count in the record ran past the close while its
span stopped at the close, so the record read about 8 Hz for a 5 Hz
loop.

sample_to_disarm moves from emission_witness to module level, ends two
windows and EMISSION_READ_GAP_S past the close, and returns the time
the sampling stopped beside the samples. read_gaps takes that time as
the trail's last edge (t_end), so a tail the daemon served none of is a
gap. rapid_place judges the placement over the armed part alone, up to
the first disarmed sample, and the disarm time must be 0 or more. The
rapid block counts its samples to the close and records how long the
sampling ran past it (sampled_past_close_s), and the log line says
both. The relit, HV and read judgments still cover the whole stretch.
The description says what is judged. tests/test_laser_dwell.py drops
relit(), a copy of the judge that no change to laser.py could fail.
Every change makes the judge stricter: no energy or motion path
changes.

Proof: tests/test_laser_dwell.py, the Unit tests step of
forgetest-ci.yml (python -B -m unittest discover -s tests), 13 tests OK
on Windows with python 3.14 and on Ubuntu 24.04 with python 3.12:
test_a_tail_the_daemon_never_served_fails (every sample served read,
then nothing for 2.9 s before the sampling stopped: gap 2.9 s, the rule
fails; served through, it holds),
test_the_sampling_runs_on_past_the_close_and_ends_where_it_stopped
(sample_to_disarm on a fake clock, the daemon serving the close and
nothing after it: the sampling ends at least 2.9 s past the close and
the rule fails), and
test_a_window_closed_before_the_head_is_back_does_not_place_the_rapid
(a close after the return places out, back and the close; a close in
the dwell at +20 mm leaves back None). test_cooling_suite.py (3) and
test_setup_burns.py (22) pass. Negative controls on scratch copies:
read_gaps without the end edge fails the first two cases; the end at
two windows fails the second; sample_to_disarm that returns the samples
alone errors in the second; rapid_place over the whole stretch fails
the third. emission_witness run whole on a fake clock with only its I/O
stubbed: a window closing 0.2, 1.0, 1.9 or 2.5 s before the head is
back fails the placement (the code before this change passed the first
three), and a normal close passes with 22 samples over 4.07 s to the
close in the record (32 before). Against a simulated daemon and kernel
latch with that 30 ms fire: an outage after the served close and one
request hung to the 10 s timeout fail the read rule (gaps 3.05 s and
10.32 s; both passed before); a tail served through with no fire
passes. In the review's tail model, the end edge alone and the longer
sampling alone each leave 0 of 20000 runs with the window in flight
unread and the judge passing, in both GET spreads. pyflakes reports no
undefined name.

Acceptance: laser.emission-witness's fingerprint moves (its own text
and laser.py's shared text), laser.armed-kill and
laser.pause-resume-lid-cancel move through the shared text, and
cooling.flow-under-load and cooling.fail-tier-stop move because they
import from .laser in their own functions; the other 38 do not move
(source_sha compared before and after). Against the campaign this
series already needs, that adds no run: 850498f moved all 43 once.
laser.emission-witness is always run, and the next campaign judges the
rapid with the end edge on the bench.
2026-10-16 14:40:53 -04:00
ScottW514 41ca7fab82 forgetest-ci: the coverage lint step fails when the lint fails, not only when tee does
The "Coverage lint (enforced)" step pipes the lint into tee for the
step summary. A step with no shell: runs under bash -e with no
pipefail, so the status of the step was tee's. An uncovered path, an
empty covers entry, a host-tested row problem (the lint exits 1) and a
manifest the lint cannot load (exit 2) each passed the job: the first
three after they printed the report, the last after one error line on
stderr and an empty step summary. The header of the workflow and the
Testing page say that an uncovered path fails the job, and this step
is the only place that runs the lint with --enforce.

The run block now starts with `set -o pipefail`, so the status of the
step is the lint's. The tee to the step summary stays. A comment above
the block says why.

Proof: the step body, read with PyYAML from this workflow and, as the
control, from the workflow before this change, run under bash -e from
forgefirm/forgetest of a copy of the tree on Ubuntu 24.04 with python
3.12, GITHUB_STEP_SUMMARY a file. The tree manifest from the recipe
pins: both bodies exit 0 and write the summary (0 uncovered paths, 0
host-tested row problems, 43 tests). The same manifest with one
uncovered forgectrl path planted: the lint reports 1 uncovered path,
the former body exits 0, this one exits 1, and both write the summary.
The manifest missing: the former body exits 0, this one exits 2.

Acceptance: none; CI only. .github/ is outside meta-forgefirm and
every covers map.
2026-10-16 14:27:06 -04:00
ScottW514 eb684d366e forgetest-ci: the undefined-name step fails on a pyflakes status other than 0 or 1
The step captured the pyflakes run with `|| true`, because pyflakes
exits 1 for any message, and judged the capture alone: "undefined
name" or "Traceback". A run that ended abnormally with no traceback
passed the step with nothing checked: a run killed by a signal, a usage
error (exit 2), or a working directory where the capture cannot be
written (grep then reads no file, and the `if` is false).

The capture is now `{ python -B -m pyflakes forgetest tests || test $?
-eq 1; } > pyflakes.txt 2>&1`. The two normal statuses of pyflakes, 0
and 1, go on to the grep; any other status fails the step under
bash -e, and so does a redirect that fails. The grep stays the gate
for an undefined name and for a Traceback. A comment above the run
block says so.

Proof: the step body, read from this workflow and, as the control,
from the workflow before this change, run under bash -e over the
forgetest tree on Ubuntu 24.04 with python 3.12 and pyflakes 4.0.3
(the install line left out: the interpreter on PATH is a shim or a
venv). Both bodies: the tree as it is exits 0; an undefined name
planted, pyflakes missing, and a pyflakes that raises each exit 1. A
pyflakes killed by SIGKILL, one ended by SIGTERM, one that exits 2,
and a working directory that cannot be written: the former body exits
0 each time, this one exits 1. PyYAML reads the run text of the step
as written.

Acceptance: none; CI only. .github/ is outside meta-forgefirm and
every covers map.
2026-10-16 14:24:54 -04:00
ScottW514 9ea9c5d10f forgefirm-image: the pack-licenses test says what tar does with a member it cannot read
The docstring of the case that holds the refusal said that the
manifest check alone passes a bundle truncated after the unreadable
member, and a comment on the member's directory said that it sorts
after license.manifest. Neither is the mechanism. tar reports an error
that it cannot open the member, skips it, writes every other member
and the end of the archive, and exits nonzero only at its end, a
status the former pipeline lost. Where the member sorts does not
matter. The docstring now says that tar skips the member and the
manifest check alone passes the bundle without it, and the comment is
gone.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest), on Ubuntu 24.04 with python 3.12 as an
unprivileged user: test_image.py's PackLicensesTests, 5 tests OK. With
the pack script as it was before 03146a1, the case fails with the
member under zlib/ and with it under aaa/ (exit status 0 both times);
with the script as it is, both placements pass. At the shell, on a
fixture tree with the unreadable member under aaa/ and then under
zlib/, the script as it was exits 0 both times after "Cannot open:
Permission denied", writes a bundle that holds every other member
(zzz/generic_Last, which sorts last, included), and removes the tree.
Text only, so no negative control applies to the change itself.
pyflakes reports no undefined name.

Acceptance: none. tests/ are in no fingerprint.
2026-10-16 14:23:02 -04:00
ScottW514 5d26818946 forgetest: a press the fixture made and reported failed leaves no notice standing
press_now and arm_press hand the arm press to the operator when the
bench actuator reports that the press failed. When the box makes its
200 ms press and its reply is lost, the client raises that failure at
its 3 s timeout or later (with ip set in the config it does not
retry), and by then the controller has taken the press and put the
button out. The fallback put the notice up all the same and started
the watch, which waits for the button to light before it waits for it
to go out. The button does not light again, so "Press it now" stood
through the job and asked for a second press, and a press during the
job pauses it.

Both fallbacks now record the fixture's error and then read the
button. Out: the press was taken, it is recorded as the fixture's, and
no notice goes up. Lit, or unreadable: the notice goes up with its
watch, as before. Nothing presses for the operator, and no judge or
energy path changes.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest), on Ubuntu 24.04 with python 3.12 and on
Windows with python 3.14: test_fixture.py's
test_the_arm_notice_comes_down_when_the_press_is_taken gains the leg
where the box puts the button out and then reports a failure: press_now
returns True with no notice and the press recorded as the fixture's,
and arm_press leaves no notice and the same record. The file's 21
tests OK on Ubuntu, and on Windows with
test_ready_reads_a_short_press_as_an_edge skipped (no os.mkfifo).
test_setup_burns.py (22), test_setup_dark.py (20), test_server.py
(10), test_operator.py (17), test_queue.py (15) and test_mode.py (9)
OK on Ubuntu. Negative controls on scratch copies: the leg
against the runner as it was fails at press_now (False, the notice
standing); with arm_press's fallback alone as it was, the leg fails at
its record ('operator', the notice up). pyflakes reports no undefined
name.

Acceptance: every catalog test's fingerprint moves: runner.py is a
shared judge, in every implementation hash (43 of 43 move). Against
the campaign this series already needs, that adds no run: 850498f moved
all 43 once. tests/ are in none.
2026-10-16 14:21:24 -04:00
ScottW514 8d16b93e86 forgetest: the shared judges and a test's own imports reach the hash the gate reads
The sibling walk read the top-level statements of a module alone. Five
tests import their judging helpers inside their own functions, so that
no other test of the module moves with them: cloud.mode-switch
(homeoff.py: judge_whole_motions, camera_home_return),
cooling.flow-under-load and cooling.fail-tier-stop (laser.py's sample
and run_and_sample, motion.py's log readers), forgectrl.auth (setup.py's
request and decode) and motion.release (setup_dark.py's run_check).
None of those helpers was in the hash of its test, and homeoff.py was
in no hash at all: an edit to judge_whole_motions moved 0 of 43 tests,
and an edit to laser.sample moved the three laser.* tests and neither
cooling test, so an inherited PASS survived a change to its own judge.
The guard that every package module a suite module imports is a listed
judge read the top level alone too, and no unit test held the
production path: Test.source_sha, which the fingerprint, the campaign
and the gate read, could drop the shared judges with CI green.

implementation_sha now adds to the hash of a test the suite modules its
own @test function imports, at any depth, and what they import in turn;
no other test of the module takes them. The shared text of a module
gives the modules it imports at any depth too: a helper that imports
inside its body brings that module into every test of the module, as
the helper itself does. _test_defs finds the @test functions of a
module for module_parts and for both walks. The guard walks the whole
tree of each suite module, so a package import inside a function or a
block is held to the list as well. test_a_shared_judge_edit_moves_every_test
holds the production path on a suite test: its source_sha equals
implementation_sha with SHARED_JUDGES and differs from it with none.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 39 tests, OK, on Windows
with python 3.14 and on Ubuntu 24.04 with python 3.12, with
test_a_sibling_imported_inside_a_function_moves_the_tests_that_run_it
(a judge imported in the body of one test moves that test and not the
other; imported in the body of a helper, it moves both), the
source_sha assertions, and the guard on the whole tree; test_artifact.py,
test_campaign.py, test_redact.py and test_responsiveness.py pass.
Negative controls on scratch copies: implementation_sha's default
taking no judges fails the source_sha assertion, and so does
Test.source_sha passing judges=(); the new case fails against
catalog.py as it was, and against a shared-text walk of top-level
statements alone; a `from .. import server` inside a function of
suite/forgectrl.py, or in a try block at its top level, fails the
guard, and the function-local one passes the guard that reads the top
level alone. On the real catalog: an edit to judge_whole_motions moves
cloud.mode-switch alone (0 tests before); an edit to laser.sample moves
the three laser.* tests and both cooling tests (the three alone
before); an edit to run_check moves the six setup.* tests and
motion.release (the six alone before); an edit to setup.request moves
forgectrl.auth and motion.release (motion.release through setup_dark.py,
which imports setup.py) beside the 8 it moved before; an edit to
hw.sysfs_int
moves 43 of 43, as before. The coverage lint on the tree manifest
from the recipe pins reports 0 uncovered paths, 0 host-tested row
problems, 43 tests. pyflakes reports no undefined name.

Acceptance: cloud.mode-switch, cooling.flow-under-load,
cooling.fail-tier-stop, forgectrl.auth and motion.release: the
implementation hash of each moves once, its own imports joining it; the
other 38 do not move (source_sha compared before and after). Against
the campaign this series already needs, that adds no run: 850498f moved
all 43 once. tests/ are in no fingerprint.
2026-10-16 14:17:00 -04:00
ScottW514 5bcc536853 forgetest: the lint's step parser keeps a step's real key column and reads a quoted key
workflow_steps rebuilt the first line of a step with its first key two
columns past the dash, whatever followed the dash. A step written with
more than one space after its dash (`-   run: ./build/tray_test`, its
`if: false` at the column of the run key) then had its first key at one
column and the others at another: every key after the first was read as
a child of the first and dropped, `if:`, `continue-on-error:`,
`working-directory:` and `with:` among them. So such a step under
`if: false` counted as running its host test, and such a checkout under
a condition counted as a checkout. _KEY_RX did not read a quoted key,
so a step's `"if": false` was dropped the same way. Both spellings are
valid YAML that GitHub skips or tolerates.

The parser now replaces the dash with a space and realigns nothing, so
the key column of a step is the column its keys use. _KEY_RX reads a
key in single or double quotes as the same key. The HOST_TESTED comment
states the rule the lint applies: no `if:` other than `true` and no
`continue-on-error:` other than `false` on the step or its job.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 38 tests, OK, on Windows
with python 3.14 and on Ubuntu 24.04 with python 3.12, with
test_a_step_counts_only_when_every_run_of_the_workflow_runs_it_and_its_failure_fails_the_run
extended by a step with three spaces after its dash under `if: false`
and a step under a quoted `"if": false`, neither running the test.
Negative controls on scratch copies: the first line rebuilt as before
fails the three-space case; _KEY_RX as before fails the quoted case.
The 17 workflows of the tree's repositories (205 steps) parse to the
same steps and events before and after the change. The coverage lint
on the tree manifest from the recipe pins reports 0 uncovered paths,
0 host-tested row problems, 43 tests. pyflakes reports no undefined
name.

Acceptance: none. forgetest is dev-only and in no fingerprint (the
source_sha of 43 of 43 tests is unchanged); the lint runs in CI, not in
a campaign, and tests/ are in no fingerprint.
2026-10-16 14:11:07 -04:00
ScottW514 c0c74d1658 laser_lifecycle_test: rule 3 names the holders of the sender-change reset
Rule 3 of the header says a sender change closes the window and a
reconnected client must re-arm. The harness does not walk the sequence
on from there, a new sender's soft reset of the held job, as a session
of its own (commit 09cf924 removed test_sender_change_then_reset), and
the header did not say where each assertion of that sequence lives.
The disarm and the latch relock at the change are laser_arm_test.c
case H (glowforge_laser.c: the client generation changing mid-cycle
enqueues the feed hold and disarms). The soft reset of the held job is
the core's reset from a completed hold, which session
hold-reset-restart walks under rule 10 with more asserted than the
removed case had: the reset banner, Idle with no alarm, and the
position kept. The hold's origin does not reach the reset path
(driver.c driverReset disarms and resets the stream whatever held the
job, and gflaser_disarm is idempotent), so the sender-change variant
is a permutation of that one branch, and this repository keeps one
proof per rule. Rule 3 now names both holders.

Proof: header text only, no behavior change. python3 -B -m py_compile
and pyflakes are clean on scripts/bench/laser_lifecycle_test.py, and
the harness passes whole against the native null-sink build of the
driver at badc832 (-DGFMCODE_WAIT_S=5.0), hold-reset-restart among
its sessions. The harness runs in grblHAL-glowforge CI (python3
forgefirm/scripts/bench/laser_lifecycle_test.py build/grblHAL_glowforge).

Acceptance: none; the header docstring of a harness outside every
covers map.
2026-10-15 22:42:49 -04:00
ScottW514 b2a011ac08 ctlport_test: the polling sender is a Grbl sender, not a product
The NewlinePoller docstring and poll_case's named one sender product
as the sender that polls '?' with an end of line behind it, and quoted
that product's poll rate beside the harness's own. The behavior under
test is the controller port's: an empty line is not the sender
speaking, a port jog is never refused for a poll, and every poll draws
its one ok. Which sender polls, and how often, is not the contract;
the harness's own rate (four times a second, period 0.25) is the fact
that matters. Both docstrings now say a Grbl sender that polls, and
the comparative rate is gone.

Proof: docstrings only, no behavior change. python3 -B -m py_compile
and pyflakes are clean on scripts/bench/ctlport_test.py, and the file
names no product. The harness runs in grblHAL-glowforge CI (python3
forgefirm/scripts/bench/ctlport_test.py build/grblHAL_glowforge).

Acceptance: none; docstrings of a harness outside every covers map.
2026-10-15 22:42:49 -04:00
ScottW514 68e9d3fb8e laser_stream_test: the rolloff reference pins the bench-default curve's rendering of S300 end to end
Rule 19 names laser_arm_test.c case P as the proof of the dose curve's
mapping. Case P calls curveComputeValue directly and reads its counts;
it does not reach the wiring from the S word to the stream:
spindleConfig installing the curve as the PWM compute hook, and the
level the hook returns riding the dither into FIRE density. A driver
that left the hook uninstalled passes case P and renders every cut
through the identity mapping, a different delivered dose at every S.

The rolloff reference session (rule 23) already runs one M4 line at
S300 under the shipped curve and reads its cruise density, to compare
against the two-cut run. It now also holds that density to the curve's
own point: 30 percent light through the bench-default curve lands
between its (45 percent density, 21 percent light) and (60, 37)
points, 53.44 percent density, level 68 of 127, density 0.535, held
to 0.02. The expectation is mirrored next to the curved configuration
the way the floor is, so a change to the curve changes it. The
header's rule 19 names the point. No session is added, and no session's
configuration changes.

Proof: the host test grblHAL-glowforge CI runs (the step "Laser stream
emission rules", python3 forgefirm/scripts/bench/laser_stream_test.py
build/grblHAL_glowforge) against the native null-sink build of the
driver at badc832 (-DGFMCODE_WAIT_S=5.0): the rolloff reference cruise
renders 0.5355 against the expected 0.5354, and the whole harness
passes, every session. Negative control, through a scratch copy of the
harness: the same cut with laser_dose_curve = off renders 0.3625 (the
identity mapping, level 46 through the 10 percent floor), 0.17 from
the point, and fails the assertion.

Acceptance: none; the harness is grblHAL's CI's, and src/** of
grblhal-glowforge keeps its covers.
2026-10-15 22:42:49 -04:00
ScottW514 f47373a20d laser_stream_test: check_density holds each lit span to the move, so a level applied late shows
check_density judges a density run by what its lit spans carry: every
power byte at full duty, each span's FIRE density at its level, no
burst past the base period. It did not read a span's length against
the move it rode. A level that reaches the stream late (the move opens
dark, FIRE starts where the level is applied) renders the right
density over a shorter span, and the density alone cannot see it:
with FIRE blanked in the first 40 percent of the S300 move of the
idle-s stream, the check passed. Rule 14 (a level set while idle
survives to the next run) and rule 12 (density rung by rung) proved
the density of whatever was lit, not that the whole move was lit;
rules 16 and 17 keep their span length through check_cut_spans.

check_density takes an optional move length in machine ticks and holds
each span to 0.9 to 1.1 of it. The three density call sites pass
theirs: the ladder sessions (density, floor-derived) LADDER_TICKS,
from the ladder's now explicit LADDER_FEED, and idle-s IDLE_S_TICKS.
Span and move are both in the mode's own ticks, so the bound holds at
every microstep mode. The bound is 0.9 to 1.1, not check_cut_spans'
0.8 to 1.25, because the real spread is about 1 percent (the faintest
rung, S20, opens on carried debt at 0.991 of the move; idle-s runs at
1.007) and the looser bound lets a move 15 percent dark pass. The
header's rules 12 and 14 say what they prove.

Proof: the host test grblHAL-glowforge CI runs (the step "Laser stream
emission rules", python3 forgefirm/scripts/bench/laser_stream_test.py
build/grblHAL_glowforge) against the native null-sink build of the
driver at badc832 (-DGFMCODE_WAIT_S=5.0): the density, idle-s and
floor-derived sessions pass the bound with span ratios 0.991 to 1.007,
and the whole harness passes, every session. Negative control on the
captured idle-s stream, through a scratch copy of the harness: FIRE
blanked in the first 40 percent of the S300 move (span ratio 0.604)
and in the first 15 percent (0.855) fail the bound, and the same
streams pass with the move length omitted; a 5 percent trim (0.956)
passes, so the bound is neither vacuous nor hair-trigger.

Acceptance: none; the harness is grblHAL's CI's, and src/** of
grblhal-glowforge keeps its covers.
2026-10-15 22:42:49 -04:00
ScottW514 199282c029 setup.focus: the head's place read from /status and judged at X0 Y0 before the burn starts
The focus burn answers the wizard's home prompt with HOME from a dict,
and the wizard takes the operator's word the same way: nothing reads
where the head stands. A read at that prompt places nothing either: the
wizard restarts the controller before it, and a fresh controller drops
the X/Y reference. The one read with meaning comes before POST
/wiz/laser.focus/start, under the campaign's own controller, when a
manual home references X and Y: with manual_home_x and manual_home_y
blank or 0, it is the one provider that declares X0 Y0 at the gantry
stops.

head_at_home(ctx), called after the ready gate and before anything
starts, waits up to 10 s for a /status that serves a position (a
transient miss is not a stop; ten seconds of none fails the test before
any energy), records the read as head_at_start (pos, homed_axes,
home_source, judged), and judges the head at X0 Y0 within HOME_TOL_MM
(0.05 mm) when homed_axes carries X and Y and home_source is manual:
off its home, the test fails before any wizard start, so no controller
lever is touched. Unreferenced, the head's place rests on the
operator's word at the ready gate, the log says so, and judged is
False. The HOME answer and the steps stay; the description names the
read. The strict form, a manual home before every focus burn, is a
procedure the operator must add to every campaign; this change records
and judges what it can read.

Proof: tests/test_setup_burns.py, the Unit tests step of
forgetest-ci.yml (python -B -m unittest discover -s tests), 22 tests OK
on Windows and on Linux, with tests/test_setup_dark.py (20 OK) and
tests/test_laser_dwell.py (10 OK). The scripted daemon's /status
carries homed_axes 7 and home_source manual: at X0 Y0 the burn runs and
the evidence carries the read with judged True; at X 12.5 the test
fails before any start, with no burn started, no /controller/ lever
posted, and the settings as found; unreferenced (homed_axes 4,
home_source startup) the burn runs with judged False. pyflakes reports
no undefined name. Negative control on a scratch copy: with
setup_burns.py as it was before this change (the HOME answer alone),
the X 12.5 case burns (Failed not raised) and the two evidence cases
find no head_at_start; the three tests catch it.

Acceptance: setup.focus's fingerprint moves (its own text and
setup_burns.py's shared text); no other suite module imports from
.setup_burns, so nothing else moves. The next campaign runs setup.focus
with the head's place on record.
2026-10-15 22:42:49 -04:00
ScottW514 6a4ece4b0d setup.focus: the test reads the LASER_ON count and the tube current from sysfs itself at every poll of the burn
The focus burn's emission was judged from the burn's result alone: the
daemon's own 25 Hz reads of cnc/laser_on_sampled and pic/hv_current,
reported as laser_on_samples and hv_max. The one live catalog test of
the wizard's burn took the daemon under test at its word for the
emission it reported, where the laser.* tests judge from the test's own
/status samples.

run_check takes on_poll(doc), called once per poll of /wiz/dark with
the status document, a caller's own witness read beside the check's;
the default keeps every positional caller as it is. Witnesses, in
setup_burns, reads the two attributes from sysfs at every poll and
keeps each at its peak and the count of polls that read both. burn()
passes it to run_check, and emission_ok(ctx, last, seen) keeps the
result's three checks and adds the test's own: at least one poll read
both witnesses, the LASER_ON count read nonzero from here, and the tube
current read over HV_ON (30, the daemon's own rule for a lit burn,
jobstream.h JOBSTREAM_HV_ON) from here. Once the result's three checks
pass, the evidence carries the two peaks and the read count as
emission_seen; the two readings are not compared with each other. The beam sensor's handling is unchanged, and
the description names both readings.

Proof: tests/test_setup_burns.py, the Unit tests step of
forgetest-ci.yml (python -B -m unittest discover -s tests), 20 tests
OK on Windows and on Linux, with tests/test_setup_dark.py (20 OK) for
the positional callers of run_check. BurnRunTests plants
cnc/laser_on_sampled and pic/hv_current under GF_SYSFS_ROOT (the shape
of a bench record), the lit burn's evidence carries the two peaks with
reads over zero, and a burn whose result reports a lit burn while the
two attributes read 0 from here fails on the LASER_ON count, with the
setting put back and the controller restarted on it. pyflakes reports
no undefined name. Negative control on a scratch copy: with
setup_burns.py and setup_dark.py as they were before this change, that
same case passes (Failed not raised) and the evidence has no
emission_seen; the two new assertions catch both.

Acceptance: setup.focus's fingerprint moves (its own text, and the
shared text of setup_burns.py and setup_dark.py), and every test of
setup_dark.py moves through run_check: setup.check-switches,
setup.check-airflow, setup.check-motion, setup.check-lens and
setup.cloud-header-capture. motion.release does not move (its run_check
import is inside the function body). The next campaign runs setup.focus
with both readings on the bench.
2026-10-15 22:42:49 -04:00
ScottW514 802cd3af8d laser.emission-witness: the LASER_ON count read through the rapid, and two windows past the close
The dark stretch after the M5 judged emission from the samples whose
/status carried a laser block. forgectrl leaves that block out of a
served /status when cnc/laser_on_sampled or cnc/laser_pgood_sampled
does not read, and sample() records the count as None, which the relit
judge (any sample with emission) and the HV judge (the maximum over the
readings) both pass: a stretch whose count went unread after the dark
point passed as dark. The kernel latches the count once per ~1 s window
and holds it through the next, so a window with no read in it is a
window in which a fire is not witnessed. The stretch also ended at the
first disarmed sample, before the count for the window in flight at the
close had latched, so the rapid's last second was never read.

read_gaps(samples, key) reports, over a trail, the samples that carry a
reading, the ones that do not, and the longest stretch of seconds
between two readings, with the trail's ends as edges: an unread lead or
tail is a gap, and a trail with no reading is one gap, its whole span.
The judge requires RAPID_MIN_READS (4) readings of the count with no
gap over EMISSION_READ_GAP_S (0.9 s), inside the EMISSION_WINDOW_S
(1.0 s) latch period, and the same floor of readings for the HV
current. Both checks come before the relit and HV-dark checks, so those
never judge an unread stretch. sample_to_disarm keeps sampling for two
windows after the first disarmed sample: the count for the window in
flight at the close latches up to one window later and is held through
the next, and the gap rule asks for a read of it in that tail,
measured to the last sample served. The timeout bounds the wait for
the close alone. The close is the first disarmed
sample, which the disarm timing and the span are measured to; the
evidence's rapid block carries emission_reads and hv_reads, the log
line says how many samples carried the count and the longest unread
stretch, and the description names the rule.

Proof: tests/test_laser_dwell.py RapidReadTests, the Unit tests step of
forgetest-ci.yml (python -B -m unittest discover -s tests), 10 tests OK
on Windows and on Linux: every sample read (gap 0.2 s), one miss
(0.4 s) and a 0.8 s hole are inside the rule; a 1.2 s hole, the block
gone after the dark point (1 of 25 read, gap 4.8 s), the block gone for
the last 1.6 s, and a one-sample stretch fail it. pyflakes reports no
undefined name. Negative control on a scratch copy: with the trail's tail dropped
from the edges, the last-window case and the block-gone case report no
gap and their two tests catch it; with the gap bound raised to 1.3 s,
the 1.2 s hole passes the rule and its test catches it.

Acceptance: laser.emission-witness's fingerprint moves (its own text
and laser.py's shared text), and laser.armed-kill and
laser.pause-resume-lid-cancel move through the shared text; no other
suite module imports .laser at module level. laser.emission-witness is
always run, and the next campaign judges the rapid stretch's read
cadence on the bench (about 5 Hz in the last campaign's record).
2026-10-15 22:42:49 -04:00
ScottW514 099399b58f forgetest-ci: the undefined-name step fails when pyflakes is missing or raises
The "No undefined names" step runs under bash -e with no pipefail, and
-e does not reach the condition of an `if`. The former body pipes the
pyflakes run into grep, so the step's status is grep's alone: with
pyflakes absent ("No module named pyflakes" on stderr, exit 1) or
crashing, grep sees no match, the `if` is false, and the step passes
with nothing checked. Its `pip install pyflakes` also names no
interpreter.

The step now installs with `python -m pip`, into the interpreter it
runs; runs `pyflakes --version`, which fails the step under -e when
the module is missing; captures the pyflakes run, stderr included, in
a file (its own exit status is 1 for any message, unused imports
included, so it cannot be the gate); and fails on "undefined name" or
on "Traceback", a crash. The grep is pinned to pyflakes' message text;
no pyflakes version is pinned.

Proof: the step body, read from this workflow, run under bash -e over
the forgetest tree on Ubuntu 24.04 with python 3.12, against the
former body as the control. pyflakes missing: the former body exits 0,
this one exits 1. pyflakes 4.0.3 present, the tree as committed: both
exit 0 (22 unused imports and 2 unused locals, no undefined name). An
undefined name planted in a scratch copy: both exit 1. A pyflakes that
raises (a shim, scratch only): the former body exits 0, this one exits
1. In CI the step's log shows the pyflakes version before the check.

Acceptance: none; CI only. .github/ is outside meta-forgefirm and
every covers map.
2026-10-15 21:13:05 -04:00
ScottW514 03146a1529 forgefirm-image: pack-licenses.sh refuses a license text tar cannot read
pack-licenses.sh packs the image's license tree into one tar.gz and
removes the tree. It piped tar into gzip under /bin/sh (dash, no
pipefail): the pipeline's status is gzip's, and tar's is lost. The
check that follows proves only that the bundle holds license.manifest.
So a member tar cannot read leaves a bundle without that text, the
build green, and the tree removed, the only copy of the text with it.

tar now runs the compressor itself (-I 'gzip -9 -n'), so its exit
status covers the read and the write. A failed pack removes the
partial bundle and the script refuses, with the tree left for the
build log; pack_licenses in forgefirm-image.bb fails do_rootfs on the
refusal. The bundle's bytes do not change: tar feeds the same stream
to the same gzip, and a tree packed by both forms gives one sha256.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest), on Ubuntu 24.04 with python 3.12 as an
unprivileged user: test_image.py's PackLicensesTests gain
test_a_license_text_tar_cannot_read_is_refused_and_the_tree_left (a
text under zlib/ with mode 0: a nonzero exit, a message on stderr, no
bundle, the manifest left); the class's 5 tests OK. Negative control
on a scratch copy: the new case against the script without this change
fails at assertRefused with an exit status of 0. At the shell, on a
fixture tree with the unreadable member, the script without this
change exits 0, leaves the bundle without the text and removes the
tree; with it, the script exits 1, leaves no bundle and leaves the
tree. On a readable tree both forms write byte-identical bundles (one
sha256 for both). pyflakes reports no undefined name.

Acceptance: every catalog test. pack-licenses.sh is under
meta-forgefirm, whose content hash is part of the platform in every
fingerprint, so the image that carries this change needs the whole
catalog on the bench.
2026-10-15 21:12:04 -04:00
ScottW514 852943e20c forgetest: the fixture's press docstring stops inviting a long press
Fixture._press said that a check that must reach the machine while the
button is down asks for the longest press the box can make, and the
unit test's comment repeated the invitation. That contradicts the
design: no press is ever held, every reader of the button catches the
default press (an edge, or a poll of 100 ms or less), and the box
clamps any request into 20 to 500 ms. The docstring now says what `ms`
is (the box's own 200 ms when it is not said, clamped by the box) and
that no check needs a longer press. The `ms` plumbing stays: it
mirrors the box's API field, and the box clamps regardless. The test's
pass-through assertion stays as the client's contract that a request
reaches the box as asked; the clamp is the box's, held by the fixture
firmware's policy test.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest), on Ubuntu 24.04 with python 3.12: the
ClientTests of test_fixture.py, 6 tests, OK. A grep for "longest
press" and "while the button is down" over fixture.py and
test_fixture.py prints nothing. Text only, so no negative control
applies. pyflakes reports no undefined name.

Acceptance: none. fixture.py is a non-suite module outside the shared
judges, and tests/ are in no fingerprint; no press length or path
changes.
2026-10-15 21:09:29 -04:00
ScottW514 a596767a9a forgetest: a press the fixture could not make leaves a notice that comes down at the press
press_now and arm_press hand the arm press to the operator when the
bench actuator refuses it (the box is off) or when the button never
lights for it within the arm wait, and put the notice up for a person.
Those three fallbacks raised the notice without the watch that takes
it down at the press, so the notice stood until the test's next step:
the second-press hazard the watch exists for, because a press during
the job pauses it. Each fallback now calls _clear_when_taken() after
its notice, so the notice comes down the moment the controller takes
the press, as the operator's own path does. No press, judge, or energy
path changes.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest), on Ubuntu 24.04 with python 3.12:
test_fixture.py's test_the_arm_notice_comes_down_when_the_press_is_taken
gains the leg where the fixture cannot press: press_now's notice for a
refused press, arm_press's notice for a refused press, and arm_press's
notice for a button that never lit each come down within 2 s of the
press. Three runs OK, the file's 21 tests OK. Negative control on a
scratch copy: the leg against the runner without the three calls fails
at the first press, the notice still standing. pyflakes reports no
undefined name.

Acceptance: every catalog test's fingerprint moves: runner.py is a
shared judge, in every implementation hash (43 of 43 move). Against
the campaign this series already needs, that adds no run: 850498f
moved all 43 once. tests/ are in none.
2026-10-15 18:06:36 -04:00
ScottW514 3d2f2d9ab4 forgetest: the arm-notice watch ends with its notice and the run
_clear_when_taken takes the operator's arm notice down the moment the
controller takes the press (the button lights for the arm wait and
goes out at the press). Its watch thread polled the button LEDs every
50 ms with the arm's timeouts as its only exits: 30 minutes for the
button to light, 90 minutes in all. Nothing in it read the run's end,
the abort event, or whether the notice it guarded still stood, so a
test that ended before the press (a FAIL on the way to the arm) left a
thread reading up to six sysfs files twenty times a second behind it.

The watch now lives only as long as the notice it guards and the run:
a notice cleared or replaced, a run that finished, or an abort ends
it. `standing()` reads both under the run's lock on every poll, and
the poll waits on the run's abort event instead of sleeping, so an
abort ends the wait at once. The cadence stays 50 ms: the notice still
drops within 50 ms of the press. The watch commands nothing; no press,
judge, or energy path changes.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest), on Ubuntu 24.04 with python 3.12:
test_fixture.py's test_the_arm_notice_comes_down_when_the_press_is_taken
gains the leg that a run finished before the press ends the watch
within 2 s and leaves the finished run's notice as it was; the file's
21 tests pass; test_setup_burns.py, whose burns reach the watch
through press_now, passes its 19 tests and leaves no
forgetest-arm-notice thread behind (10 before this change); and
test_server.py, test_operator.py and test_queue.py pass. Negative control on a scratch
copy: the new leg against the unchanged runner fails with the
forgetest-arm-notice thread still alive after the run finished.
pyflakes reports no undefined name.

Acceptance: every catalog test's fingerprint moves: runner.py is a
shared judge, in every implementation hash (43 of 43 move). Against
the campaign this series already needs, that adds no run: 850498f
moved all 43 once. tests/ are in none.
2026-10-15 18:01:31 -04:00
ScottW514 850498fb00 forgetest: the shared judges join every test's implementation hash
Every catalog test judges the machine through package modules it never
imports: the Context a test is handed and the takeover (runner.py), the
sysfs, LED, switch and HTTP readers behind it (hw.py), and the
fresh-boot idle state judged around every run (baseline.py). The cloud
and extension tests print the dark job of puls.py, which the suite
imports at level 2, and the sibling walk follows level-1 imports only.
So none of the four was in any fingerprint: a weakening of sysfs_int,
button_lit, Context.check, wait_for or position_quantized moved no
hash, and an inherited PASS of kernel.latch-locked-idle,
laser.armed-kill or any takeover test survived it unseen by the gate.

catalog.py names them in SHARED_JUDGES, with the criterion in the
comment above it: a module every test runs through without importing
it, or the dark job the cloud tests print. The rest of the package (the
server, the page, the campaign rules, the artifact, the manifest, the
registry, the bench actuator) judges nothing about the machine and
stays out. shared_judge_paths() resolves the four next to the module,
and implementation_sha folds their text after the module's shared text
and its imported siblings. A listed file that is missing raises, never
a hash computed without it. _direct_siblings stays level-1 only, and
the tool version does not move: the gate judges by source_sha and the
fingerprint.

Every catalog test's implementation hash moves once with this change,
so every recorded PASS is stale and the next campaign is a full one, 43
tests. The catalog hash does not move, so an open campaign stays open
with every test required. From here on, an edit to runner.py, hw.py,
baseline.py or puls.py moves every test.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 38 tests, OK, with
test_a_shared_judge_edit_moves_every_test (an edit to a judge moves
both tests of a module, a package file outside the list moves neither)
and test_the_shared_judges_exist_and_take_every_package_import_of_the_suite
(the four files exist next to catalog.py, and every level-2 import of
a suite module is a listed judge or the registry). Negative controls
on scratch copies: `judged = []` in implementation_sha fails the first
case alone; "runner.py" dropped from SHARED_JUDGES fails the guard
alone, naming homeoff.py's import of runner. On the real catalog the
source_sha of 43 of 43 tests differs from HEAD's; with hw.sysfs_int
weakened to return the latch bit on an unparsable read, 0 of 43 move at
HEAD and 43 of 43 move with this change. The coverage lint on the tree
manifest from the recipe pins reports 0 uncovered paths, 0 host-tested
row problems, 43 tests. pyflakes reports no undefined name.

Acceptance: every catalog test. The implementation hash of all 43 moves
once, so the next campaign runs the whole catalog.
2026-10-15 17:54:06 -04:00
ScottW514 4d8f0d2e67 forgetest: the lint counts a host test as run only by a step every run of the workflow runs, on push or pull request
A HOST_TESTED row names a workflow and a host test, and the coverage
lint judges the row by what the workflow's steps run. The parser stored
a step's `if:` and `continue-on-error:` and read neither, read no job
key, and read no `on:`. So a step under `if: false`, a step whose
failure the run tolerates, a job under an `if:`, and a workflow on
`workflow_dispatch` alone all counted as running the test, and a
checkout under a condition counted as a checkout: a disabled or
tolerated host test keeps its row green and its paths covered.

The lint now reads the workflow's events and the conditions of a step
and of its job. `workflow_triggers` returns the events of the `on:`
mapping, list or scalar. What is under an event (its branches, its
paths, a dispatch's inputs) is not an event, and a flow mapping names
none. A row's workflow must run on push or pull_request, or the lint
reports that it runs on no push or pull request: a dispatch or a
schedule alone runs on no change, and a call alone is listed as well,
since the lint does not follow it to its caller. `workflow_steps`
tracks the job
each step is in and carries the job's `if:` and `continue-on-error:`
on the step as `job`. `_on_every_run` counts a step only when neither
it nor its job carries an `if:` other than the literal `true` or a
`continue-on-error:` other than the literal `false`. `workflow_runs`
and `checkout_path` use it, so a conditional step runs nothing and a
conditional checkout is no checkout. The reason for such a step stays
"does not run it". `needs:` is not read, nor are the `paths:` and
`branches:` filters of an event: the lint stays a floor there.

Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_manifest.py runs 36 tests, OK, with the
two new cases, test_a_step_counts_only_when_every_run_of_the_workflow_runs_it_and_its_failure_fails_the_run
and test_the_events_a_workflow_runs_on, and with
test_the_workflow_has_to_run_the_host_test extended by a dispatch-only
workflow and a tolerated step. Negative controls on scratch copies:
`_on_every_run` forced to return True fails the step case and the
host-test case (failures=2); the trigger check deleted from
host_row_problems fails the host-test case (failures=1). The coverage
lint on the tree manifest from the recipe pins reports 0 uncovered
paths, 0 host-tested row problems, 43 tests. pyflakes reports no
undefined name.

Acceptance: none. forgetest is dev-only and in no fingerprint; the
lint runs in CI, not in a campaign.
2026-10-15 17:52:16 -04:00
ScottW514 f165713d05 forgetest: button.c and led.c leave the host-tested rows for the covers of the tests that drive them
HOST_TESTED carried forgectrl's src/button.* and src/led.* against
host_setup.sh, which presents the button as a held word in a fake
switches file and no LED at all. Both are driven on the machine by
catalog tests that still run: the fuse view forgectrl.auth reads is
two-factor, the token and the physical button (main.c cb_fuse_identity
through operator_present, button.c); the setup checks' press prompts
read the button and light it through led.c, and setup.check-switches
reads both back. manifest.py's own rule puts a path a catalog test
exercises on the hardware in that test's covers, not in a row: in a row
it is in no fingerprint, so a change to the button's poll, its debounce
or the LED release moved nothing and every PASS was inherited past it.

- manifest.py: the two rows go (src/hooks.h stays: main.c's hook
  declarations, proven through the setup routes on a host).
- suite/forgectrl.py: src/button.* joins _COVERS_AUTH.
- suite/setup_dark.py: src/button.* and src/led.* join DARK_COVERS, the
  covers of every setup check.

The laser tests judge the LED by the target the controller writes
(glowforge_laser.c button_led), so led.c is not theirs.

Proof: the coverage lint, enforced, on the tree manifest from the
current pins: 0 uncovered paths, 0 host-tested row problems, 43 tests.
tests/test_manifest.py (34) and the unit suite pass.

Acceptance: the fingerprints of forgectrl.auth and of every setup check
move; a release campaign runs them again.
2026-10-15 16:18:51 -04:00
ScottW514 80ddd53bcd laser_stream_test: rule 8 reads a FIRE tick's distance from a step, so a dwell burn shows under the dither
check_fire_gaps counted consecutive stepless ticks carrying FIRE and
reset on any dark tick: the analog rendering of a fire state leaked
into a cycle's idle pad (stepper_stream.c gf_stream_go_idle names it:
a stationary dwell burn at every planner starve). Under the density
model every tick goes through the dither mask, so the same leaked
state renders as bursts of at most one period (80 ticks at S800)
split by dark ticks, and the run never reaches the limit. With the
analog churn session gone, density-churn was the only session on rule
9, and it could not see the leak it was there for.

The check now reads rule 8 both ways: the run of lit stepless ticks as
before, and for every FIRE tick its distance from the nearest step on
either side, the one before it and the one after it. A cut's first
FIRE tick leads its first step by the accel-from-rest interval (412
ticks at x32) and its last trails its last step by less, both inside
the limit (1000); a FIRE tick with no step that near on either side is
in a gap, and lit, however the dither spaced it. The distance is read
from both sides because a stream opens dark on its lead-in pad and a
cycle opens lit before its first step: depth into a stepless run alone
flagged the m4 session's first lit tick, 22529 ticks of dark pad after
the stream start and a few hundred before the first step.

Proof: on synthetic streams, a legitimate 2253-tick dark gap between
two cuts passes both readings; a continuous analog leak across it
fails both; the same gap carrying a dithered leak at S800 (64 of 80
ticks lit) and at S200 (16 of 80) passed the old reading and fails the
new. The whole harness passes against the native null-sink build
(-DGFMCODE_WAIT_S=5.0), every session.

Acceptance: none; the harness is grblHAL's CI's, and src/** of
grblhal-glowforge keeps its covers.
2026-10-15 16:18:06 -04:00
ScottW514 c3da9fa1bd Catalog: setup.account-login, motion.cancel-abort, forgectrl.settings-bounds and exthost.service leave; the rootfs lint takes image.health's file checks
The catalog is 43. Each test leaves for a host proof that its
component's CI runs at the pins:

- setup.account-login: the login rules are host_routes.sh's (forgectrl
  6c2bb38); the render is test_image.py's new AccountRenderTests, which
  runs the layer's forgefirm-users.init with reload in a private mount
  namespace as root (a fresh /run, the scratch account files over /etc,
  the scratch data directory, root's, over /data): the data directory
  found 0700 is 0711 after the render, and as the account's own uid the
  home is entered and written while listing the data directory and
  reading the users record are EACCES. A control without the script's
  chmod go+x fails it (700 != 711). forgectrl.auth's cover widens to
  src/ui/login.*, since login.html had no other cover.
- motion.cancel-abort: laser_lifecycle_test.py's new rule 17 on the
  null-sink controller: 0x85 stops a jog short of its target, Idle; ^X
  mid-move lands in Alarm with the position kept and no return to the
  job start; $X recovers to Idle and the next jog ships steps (counted
  in GFSINK_DUMP). It passes on the driver at its pin, badc832.
  motion.deadman requires motion.pacing in its place.
- forgectrl.settings-bounds: host_routes.sh refuses the out-of-range and
  over-length values with the settings file byte for byte the same, and
  the lid lamp under GF_SYSFS_ROOT follows lid_lamp_idle (forgectrl
  6c2bb38).
- exthost.service: forgeext's host tests at the pinned bb75b23 -
  sandbox_test (the confinement from inside, the pulse device, the
  settings file and the setup record refused), run_test.py (the API,
  safe mode, the master switch, and the killed host under the init
  script's own wrapper), install_test.py and sdk_test.py.
  exthost.armed-freeze requires exthost.platform in its place.

HOST_TESTED rows for each path the lint named once exthost.service
left: forgectrl src/logs.* to logs_test.c and
docs/advisories/extensions.md to host_setup.sh; forgeext src/api.*,
caps.*, evfeed.*, holdkeep.*, httpreq.*, manifest.*, quota.*,
sandbox.*, settings.*, state.* to their unit tests, netrules.* to
netrules_test.py, call.* to sdk_test.py, install.*, pkg.* and index.* to
install_test.py, and main.c, fflog.* and CMakeLists.txt to run_test.py.
test_manifest's behavioral-path case is unaffected.

The rootfs lint takes image.health's file checks (rules init-order,
logging, watchdog, read-only, dhcpv6, and on a release image factory),
each proved in RootfsLintTests by a drift that fails it. image.health
keeps the live half: the rendered issue and motd against the running
manifest, one host and no pool process, watchdog0, no userspace
watchdog running, the mounts, the dev image's factory slots read-only,
no DHCPv6 client running, the DHCP client's -b and parent, ifup, the
getty, and IPv6. release.sh drops its PermitRootLogin and
PermitEmptyPasswords greps, its ROOTFS_READ_ONLY check and its /factory
fstab check, which the lint holds; it keeps the empty root shadow
field, / mounted ro, and the three HostKey lines under /data.

setup.py registers no test now: its helpers the deleted tests alone
called go (tls_base, tcp_open, mode, press_status, wiz_summary,
wait_settled, the login constants), and test_setup_suite.py keeps the
cases of what remains.

Fingerprints: image.health's moves (its text), every test in
exthost.py, forgectrl.py and motion.py moves with its module text, and
the layer content hash moves with rootfs-lint.sh: the next image
inherits no campaign.

Proof: the coverage lint over the tree manifest from the pins finds 0
uncovered paths and 0 host-tested row problems (43 tests); pyflakes on
the touched files is clean; forgetest's unit suite under Linux passes
(334), test_image.py's 28 among them. The lint passes image
20261015142442's release tree as release and its dev tree as dev, and
refuses the dev tree as release (sshd, factory).
laser_lifecycle_test.py passes whole on the null-sink build of badc832.
2026-10-15 14:02:20 -04:00
ScottW514 56aa164708 Pin forgeext 0.1.4 on the pushed head
- forgeext e09f7b8 -> bb75b23 (0.1.3 -> 0.1.4): sandbox_test opens the
  machine's own files from inside the sandbox - /dev/glowforge,
  /data/forgefirm/forgefirm.conf, /data/forgefirm/setup.json - and
  requires EACCES of each (ENOENT is no pass; a file the host lacks is
  made for the run, readable by every account, and removed after).
  Test-only; nothing the image installs changes.

Proof: forgeext's CI run on bb75b23 (38780750106) is green on a kernel
with Landlock ABI 7; its log shows the three files made for the run and
each open refused with EACCES (sandbox_test, 0 failures). bitbake -c
fetch forgeext from this pin succeeds (2 tasks).
2026-10-15 13:57:52 -04:00
ScottW514 df5ae151ab Pin forgeext 0.1.3 on the pushed head
- forgeext 31cdb40 -> e09f7b8 (0.1.2 -> 0.1.3): run_test's last phase
  runs the host under init/forgeext.init's own wrapper; a SIGKILL of the
  host takes its service with it at once, the wrapper starts the host
  again, and the service runs again under a new pid. Test-only; nothing
  the image installs changes.

Proof: forgeext's CI run on e09f7b8 (38777839870) is green on a kernel
with Landlock ABI 7, and the phase's six checks passed in it (run_test,
0 failures). bitbake -c fetch forgeext from this pin succeeds (2 tasks).
2026-10-15 13:37:42 -04:00
ScottW514 10ae7bf39c Pin forgectrl 0.1.33 on the pushed head
- forgectrl 22a2cfc -> 6c2bb38 (0.1.32 -> 0.1.33): cam.c resolves the
  lid and head lamps under GF_SYSFS_ROOT (unset: /sys/glowforge/, the
  paths as they were), and host_routes.sh proves the lid lamp follows
  lid_lamp_idle: a level is written, a refused value writes nothing, a
  cleared key writes the default 236.

Proof: forgectrl's CI run on 6c2bb38 (38769524512) is green, and
host_routes.sh ran in it. bitbake -c fetch forgectrl from this pin
succeeds (2 tasks). The tree manifest from the pins holds forgectrl at
6c2bb38 (229 files); the coverage lint finds no uncovered path and no
row problem (47 tests); check-ui-vendor finds the shared UI files
identical; test_tree_manifest passes; the gate self-check refuses the
empty artifact (rc 1).
2026-10-15 12:50:22 -04:00
ScottW514 c40dc6c24c Catalog: setup.machine-name leaves for the rootfs lint and test_image.py
Its checks, and what holds each now:
- the name is forgefirm-<the last four hex digits of the MAC>, wlan0
  first and eth0 on a machine with no WiFi, and the console banner
  offers the addresses and nothing else: test_image.py's
  MachineNameTests run forgefirm-hostname and forgefirm-banner as the
  image runs them, on a scratch /sys/class/net;
- /etc/hostname is the bind-mounted copy: image.health;
- the DHCP request sends the name, and no mDNS responder is on the
  image: the rootfs lint's dhcp-name and mdns rules (08c7df9), which
  image 20261015142442 ran in do_rootfs and which passed campaign
  c-20261015143634-68f0, 48 of 48.
It covered no component path, so no HOST_TESTED row. Its helpers
mac_suffix and cmdlines had no other caller and go with it, with
test_setup_suite's cases of them and of its registration; setup.py's re
and lan_ip imports went with them.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Push this before grblHAL-glowforge's commit.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Proof: the touched host tests pass (test_cooling_intake_suite 4,
test_setup_dark 10, test_cooling_suite 25) and pyflakes is clean.
test_cooling_intake_suite replays the test against a scripted engine,
and two broken engines fail it with the settings put back: one that
ignores the setting, and one that takes intake 1 out with intake 2.
test_setup_dark's new AirflowCheckTests take a two-fan and a one-fan
result, and fail a count the setting does not match and a result with
no count. On the bench reference, image 20260928194539 (dev) with these
files and forgectrl c274f19 bind-mounted, cooling.fan-gate-trips,
cooling.intake-one-fan and setup.check-airflow pass with a clean
baseline before and after each (forgectrl's commit has the readings).
2026-10-10 17:33:43 -04:00
ScottW514 72907f311a Update README 2026-10-06 15:59:31 -04:00
ScottW514 3873013383 v0.0.7 release notes: the text as revised after publishing
The notes were rewritten after v0.0.7 was published: the extensions
paragraph and every item under "Also new". The release page on GitHub
now carries the same text, followed by its "Installing your own build"
section. The v0.0.7 tag keeps the notes as first published.
2026-09-29 06:58:40 -04:00
141 changed files with 53984 additions and 15861 deletions
+96 -6
View File
@@ -11,10 +11,18 @@
# release with source missing # release with source missing
# - shared UI files: theme.css and the vendored Bootstrap the page # - shared UI files: theme.css and the vendored Bootstrap the page
# carries are byte-identical to forgectrl's at its pinned revision # carries are byte-identical to forgectrl's at its pinned revision
# - the extension key: the public key the image installs for the official
# tier is byte-identical to the one the extension catalog publishes
# (keys/forgefirm-ext.pub on its main branch)
# - the catalog's address: EXTPKG_INDEX_URL in the pinned forgectrl's
# src/extpkg.h is the catalog's latest-release download of the file its
# tools/publish.py passes to gh release create; read, never requested
# (every request of it is counted as a download)
# - coverage lint: every source path of every component pinned by the # - coverage lint: every source path of every component pinned by the
# recipes must be selected by some catalog test's coverage globs (the # recipes must be selected by some catalog test's coverage globs or by a
# tree manifest is generated from the pins with git - no Yocto build); # host-tested row naming the host test that proves it (the tree manifest
# enforced: an uncovered path fails the job (https://docs.forgefirm.org/developers/acceptance/). # is generated from the pins with git - no Yocto build); enforced: an
# uncovered path fails the job (https://docs.forgefirm.org/developers/testing/).
name: forgetest-ci name: forgetest-ci
@@ -49,6 +57,13 @@ jobs:
ref: scarthgap ref: scarthgap
path: meta-openglow path: meta-openglow
- name: Checkout forgefirm-extensions-catalog (sibling)
uses: actions/checkout@v4
with:
repository: openglow-org/forgefirm-extensions-catalog
ref: main
path: forgefirm-extensions-catalog
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
@@ -58,6 +73,68 @@ jobs:
working-directory: forgefirm working-directory: forgefirm
run: python scripts/manifest-from-tree.py --out tree-manifest.json run: python scripts/manifest-from-tree.py --out tree-manifest.json
- name: The image's extension key is the catalog's published key
run: |
image=forgefirm/meta-forgefirm/recipes-forgefirm/forgefirm-keys/files/ext/forgefirm-ext.pub
published=forgefirm-extensions-catalog/keys/forgefirm-ext.pub
for f in "$image" "$published"; do
test -f "$f" || { echo "missing: $f"; exit 1; }
done
cmp -s "$image" "$published" || { echo "$image is not $published"; exit 1; }
- name: The catalog's address in the pinned forgectrl is the file the catalog publishes (read, never requested)
run: |
python3 - <<'EOF'
import ast, glob, os, re, subprocess, sys
PREFIX = "https://github.com/openglow-org/forgefirm-extensions-catalog/releases/latest/download/"
def fail(why):
print(why)
sys.exit(1)
# forgectrl's pin, and its src/extpkg.h from the tree manifest's cache
pin = open("forgefirm/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl-pin.inc").read()
m = re.search(r'^SRCREV = "([0-9a-f]{40})"$', pin, re.M)
if not m:
fail("forgectrl-pin.inc names no SRCREV")
rev = m.group(1)
header = None
for repo in sorted(glob.glob("forgefirm/.manifest-cache/*")):
p = subprocess.run(["git", "-C", repo, "show", rev + ":src/extpkg.h"], capture_output=True, text=True)
if p.returncode == 0:
header = p.stdout
break
if header is None:
fail("forgectrl %s is not in forgefirm/.manifest-cache" % rev)
m = re.search(r'#define\s+EXTPKG_INDEX_URL\s*(?:\\\n\s*)?"([^"]*)"', header)
if not m:
fail("forgectrl %s's src/extpkg.h defines no EXTPKG_INDEX_URL" % rev[:7])
url = m.group(1)
# the file the catalog's publish tool passes to gh release create
tree = ast.parse(open("forgefirm-extensions-catalog/tools/publish.py").read())
assigned = {n.targets[0].id: n.value for n in ast.walk(tree)
if isinstance(n, ast.Assign) and len(n.targets) == 1 and isinstance(n.targets[0], ast.Name)}
def file_name(v):
if isinstance(v, ast.Name):
v = assigned.get(v.id)
if isinstance(v, ast.Call) and v.args:
v = v.args[-1]
return os.path.basename(v.value) if isinstance(v, ast.Constant) and isinstance(v.value, str) else None
names = [file_name(n.args[3]) if len(n.args) > 3 else None for n in ast.walk(tree)
if isinstance(n, ast.Call) and isinstance(n.func, ast.Name) and n.func.id == "gh"
and [a.value for a in n.args[:2] if isinstance(a, ast.Constant)] == ["release", "create"]]
if len(names) != 1 or not names[0]:
fail("tools/publish.py passes no one file to gh release create: %s" % names)
print("forgectrl %s: %s; the catalog publishes %s" % (rev[:7], url, names[0]))
if url != PREFIX + names[0]:
fail("the address is not %s" % (PREFIX + names[0]))
EOF
- name: Unit tests - name: Unit tests
working-directory: forgefirm/forgetest working-directory: forgefirm/forgetest
# -B: no bytecode caches under the package directory (the recipe's fetch # -B: no bytecode caches under the package directory (the recipe's fetch
@@ -66,9 +143,18 @@ jobs:
- name: No undefined names (the live drills never execute on the host) - name: No undefined names (the live drills never execute on the host)
working-directory: forgefirm/forgetest working-directory: forgefirm/forgetest
# pyflakes exits 1 for any message, unused imports included, so the
# grep is the gate. A run stopped by SIGINT exits 1 too, and its
# "... stopped" line fails the grep. Any other status (a usage
# error, a signal pyflakes does not handle) fails the step and
# prints the capture, and a capture that cannot be written fails it
# too. pyflakes ends a SIGPIPE with status 1 and no line, and a
# write to the capture file raises none.
run: | run: |
pip install pyflakes python -m pip install pyflakes
if python -B -m pyflakes forgetest tests | grep -E "undefined name"; then exit 1; fi python -B -m pyflakes --version
{ python -B -m pyflakes forgetest tests || test $? -eq 1; } > pyflakes.txt 2>&1 || { cat pyflakes.txt; exit 1; }
if grep -E "undefined name|Traceback|^\.\.\. stopped$" pyflakes.txt; then exit 1; fi
- name: Shared UI files match forgectrl at its pinned revision - name: Shared UI files match forgectrl at its pinned revision
working-directory: forgefirm working-directory: forgefirm
@@ -76,7 +162,11 @@ jobs:
- name: Coverage lint (enforced) - name: Coverage lint (enforced)
working-directory: forgefirm/forgetest working-directory: forgefirm/forgetest
run: python -B -m forgetest.coverage --manifest ../tree-manifest.json --enforce | tee "$GITHUB_STEP_SUMMARY" # The step runs under bash -e with no pipefail of its own: set here,
# the status of the step is the lint's, not tee's.
run: |
set -o pipefail
python -B -m forgetest.coverage --manifest ../tree-manifest.json --enforce | tee "$GITHUB_STEP_SUMMARY"
- name: Gate self-check on the tree manifest - name: Gate self-check on the tree manifest
working-directory: forgefirm working-directory: forgefirm
+1 -1
View File
@@ -133,7 +133,7 @@ ForgeFIRM is open firmware for stock Glowforge lasers (Basic, Plus, Pro). It
runs on the factory NXP i.MX6 control board with no hardware modification. It runs on the factory NXP i.MX6 control board with no hardware modification. It
replaces the cloud-dependent factory userspace with an open Linux image that replaces the cloud-dependent factory userspace with an open Linux image that
controls the machine locally. In GRBL mode the machine is a standard grblHAL controls the machine locally. In GRBL mode the machine is a standard grblHAL
controller for LightBurn and other senders on TCP port 23. Cloud mode runs the controller for a Grbl sender on TCP port 23. Cloud mode runs the
factory experience through the Glowforge web service, and it is a deliberate, factory experience through the Glowforge web service, and it is a deliberate,
maintained feature. The stack is hardware validated: GRBL mode cuts real jobs, maintained feature. The stack is hardware validated: GRBL mode cuts real jobs,
and cloud mode runs end to end. and cloud mode runs end to end.
+1 -1
View File
@@ -3,7 +3,7 @@
> ### BETA > ### BETA
> >
> **ForgeFIRM is in beta.** Every release below 0.1.0 is a beta release. > **ForgeFIRM is in beta.** Every release below 0.1.0 is a beta release.
> Expect problems, and expect frequent updates. Upgrade whenever a newer > It's stable, but you may still hit the occasional bug, and expect frequent updates. Upgrade whenever a newer
> release is available, and report what you find on the > release is available, and report what you find on the
> [community forum](https://community.openglow.org). > [community forum](https://community.openglow.org).
+4
View File
@@ -0,0 +1,4 @@
# The scratch trees forgectrl's cool_flow_test and super_test write in the
# directory they run from
/cool-flow-test/
/super-test/
@@ -1 +0,0 @@
{"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}
@@ -1 +0,0 @@
0
@@ -1 +0,0 @@
0
@@ -1 +0,0 @@
255
@@ -1 +0,0 @@
idle
@@ -1 +0,0 @@
1023
@@ -1 +0,0 @@
300
@@ -1 +0,0 @@
629
@@ -1 +0,0 @@
0
@@ -1 +0,0 @@
700
@@ -1 +0,0 @@
700
@@ -1 +0,0 @@
65535
@@ -1 +0,0 @@
26214
@@ -1 +0,0 @@
43278
@@ -1 +0,0 @@
300
@@ -1 +0,0 @@
300
@@ -1 +0,0 @@
300
@@ -1 +0,0 @@
{"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
@@ -1 +0,0 @@
0
@@ -1 +0,0 @@
1
@@ -1 +0,0 @@
0
@@ -1 +0,0 @@
0
-1
View File
@@ -1 +0,0 @@
idle
-1
View File
@@ -1 +0,0 @@
1
@@ -1 +0,0 @@
204
@@ -1 +0,0 @@
300
@@ -1 +0,0 @@
629
@@ -1 +0,0 @@
0
@@ -1 +0,0 @@
100
@@ -1 +0,0 @@
100
@@ -1 +0,0 @@
100
@@ -1 +0,0 @@
100
@@ -1 +0,0 @@
700
@@ -1 +0,0 @@
700
@@ -1 +0,0 @@
0
@@ -1 +0,0 @@
0
@@ -1 +0,0 @@
0
@@ -1 +0,0 @@
300
@@ -1 +0,0 @@
300
@@ -1 +0,0 @@
300
-7
View File
@@ -26,13 +26,6 @@ case "$1" in
start) start)
echo "Starting forgetest" echo "Starting forgetest"
mkdir -p "$DATA" mkdir -p "$DATA"
# The bench seed: the setup override lifts the wizard part of
# the controller gate until the next reboot (/run is tmpfs), so a
# campaign runs on a machine whose required wizards are not complete.
# Nothing lifts the agreements or the account: the bench accepts them
# once, and that stays in /data.
mkdir -p /run/forgefirm
: > /run/forgefirm/setup-override
start-stop-daemon -S -q -p $PIDFILE -m -b -x /bin/sh -- -c \ start-stop-daemon -S -q -p $PIDFILE -m -b -x /bin/sh -- -c \
"exec /usr/bin/python3 -m forgetest >> $LOG 2>&1" "exec /usr/bin/python3 -m forgetest >> $LOG 2>&1"
;; ;;
+36 -4
View File
@@ -19,7 +19,9 @@ restores it after (on every exit path), so a test cannot hand the next one
restored where the interface allows. restored where the interface allows.
The lid lamp is fixed too, at forgectrl's `lid_lamp_idle` setting (unset = The lid lamp is fixed too, at forgectrl's `lid_lamp_idle` setting (unset =
236): the daemon asserts it at start and at every controller spawn. 236): the daemon asserts it at start and at every controller spawn. The
lamp fades toward a level written to it, so a level written a moment
before the check is read again until it holds before it is judged.
The controller mode in force decides what the baseline owns. In GRBL mode The controller mode in force decides what the baseline owns. In GRBL mode
everything above applies. In cloud mode the cloud client owns what it everything above applies. In cloud mode the cloud client owns what it
@@ -140,6 +142,15 @@ PRESERVED_SYSFS = [] # sysfs attrs captured before, restored afte
LID_LAMP_ATTR = "pic/lid_led" LID_LAMP_ATTR = "pic/lid_led"
LID_LAMP_DEFAULT = "236" # forgectrl's lid_lamp_idle default LID_LAMP_DEFAULT = "236" # forgectrl's lid_lamp_idle default
# The lid LED runs on the kernel's "smooth" trigger: a level written to it
# is reached by a fade over a fraction of a second, so a read right after
# a write (the supervisor re-applies the idle level at every controller
# spawn) finds the lamp part-way there: on the bench reference, 232 read
# 0.05 s after the supervisor re-applies 236. The lamp is read again every
# LID_LAMP_POLL_S until it holds the level, for at most LID_LAMP_SETTLE_S,
# before it is called a leftover.
LID_LAMP_SETTLE_S = 3.0
LID_LAMP_POLL_S = 0.1
BOOT_MAX_AGE_S = 600 # a boot reference is taken only this soon after boot BOOT_MAX_AGE_S = 600 # a boot reference is taken only this soon after boot
SETTLE_S = 150 # the supervisor's probe + rail-off ladder SETTLE_S = 150 # the supervisor's probe + rail-off ladder
@@ -743,13 +754,34 @@ class Baseline:
got = hw.sysfs_read(LID_LAMP_ATTR) got = hw.sysfs_read(LID_LAMP_ATTR)
if got is None or got == want: if got is None or got == want:
return return
held, now = self._lamp_holds(want)
if held:
self.log("lid lamp: read %s, then held %s; the fade toward a level just written, "
"not a leftover" % (got, want))
return
found = now if now is not None else got
try: try:
hw.sysfs_write(LID_LAMP_ATTR, want) hw.sysfs_write(LID_LAMP_ATTR, want)
back = hw.sysfs_read(LID_LAMP_ATTR) held, back = self._lamp_holds(want)
act = "restored" if back == want else "failed: reads %s" % back act = "restored" if held else "failed: reads %s" % back
except OSError as e: except OSError as e:
act = "failed: %s" % e act = "failed: %s" % e
left.append(Leftover(LID_LAMP_ATTR, got, want, act)) left.append(Leftover(LID_LAMP_ATTR, found, want, act))
def _lamp_holds(self, want):
"""Read the lid lamp every LID_LAMP_POLL_S, for at most
LID_LAMP_SETTLE_S, until it holds `want`: two reads in a row at
it, so a fade that only passes through the level does not count.
Returns (held, the last read)."""
last = prev = None
for _ in range(max(2, int(round(LID_LAMP_SETTLE_S / LID_LAMP_POLL_S)))):
if self.abort():
break
time.sleep(LID_LAMP_POLL_S)
prev, last = last, hw.sysfs_read(LID_LAMP_ATTR)
if last == want and prev == want:
return True, last
return False, last
def _kernel_side(self, left): def _kernel_side(self, left):
if hw.sysfs_read("cnc/state") is None: if hw.sysfs_read("cnc/state") is None:
+98 -31
View File
@@ -69,11 +69,14 @@ class Test:
def source_sha(self): def source_sha(self):
"""The hash of the test's own implementation: its function (the """The hash of the test's own implementation: its function (the
decorator included) together with the code its module shares decorator included) together with the code its module shares
among its tests - everything outside the @test functions. Part among its tests (everything outside the @test functions), the
of the fingerprint: a change inside one test's body invalidates shared code of every sibling module that either of the two
that test's earlier passes and no other; a change to a helper imports, and the shared judges (SHARED_JUDGES). Part of the
invalidates the tests of that module. A test the module does not fingerprint: a change inside one test's body invalidates that
define in the @test form hashes its whole file.""" test's earlier passes and no other; a change to a helper
invalidates the tests of that module and the tests that import
it; a change to a shared judge invalidates every test. A test the
module does not define in the @test form hashes its whole file."""
if self._source_sha is None: if self._source_sha is None:
# One filler at a time: a second poll that arrives while the # One filler at a time: a second poll that arrives while the
# first computes waits for it rather than repeating the work. # first computes waits for it rather than repeating the work.
@@ -144,6 +147,28 @@ def source_file_sha(path):
return hashlib.sha256(data).hexdigest() return hashlib.sha256(data).hexdigest()
# The package modules that judge the machine. Every test runs through
# the first three without importing them: the Context a test is handed
# and the takeover (runner.py), the sysfs, LED, switch and HTTP readers
# behind it (hw.py), and the fresh-boot idle state judged around every
# run (baseline.py). puls.py is the dark job the cloud and extension
# tests print: suite/cloud.py imports it from the package (`from .. import
# puls`), an import the sibling walk does not follow. Their text joins
# every implementation hash: a change to one of them moves every test.
# The rest of the package (the server, the page, the campaign rules, the
# artifact, the manifest, this registry, the bench actuator) judges
# nothing about the machine and stays out.
SHARED_JUDGES = ("baseline.py", "hw.py", "puls.py", "runner.py")
def shared_judge_paths():
"""The SHARED_JUDGES files, next to this module. Every one has to be
there: a judge missing from the package is an error, never a hash
computed without it."""
here = os.path.dirname(os.path.abspath(__file__))
return [os.path.join(here, name) for name in SHARED_JUDGES]
# The files never change under a run, so each is read and parsed once: # 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 -> (text, tree or None), path -> (shared sha, {test id: own sha}),
# path -> the sibling modules it imports itself. # path -> the sibling modules it imports itself.
@@ -178,6 +203,20 @@ def _parsed(path):
return hit return hit
def _test_defs(tree):
"""{test id: its function} for the @test("...") functions at the top
level of a module's tree."""
defs = {}
for node in (tree.body if tree is not None else []):
if not isinstance(node, ast.FunctionDef):
continue
for d in node.decorator_list:
if (isinstance(d, ast.Call) and isinstance(d.func, ast.Name) and d.func.id == "test"
and d.args and isinstance(d.args[0], ast.Constant) and isinstance(d.args[0].value, str)):
defs[d.args[0].value] = node
return defs
def module_parts(path): def module_parts(path):
"""(shared sha, {test id: own sha}) for a suite module: the test ids """(shared sha, {test id: own sha}) for a suite module: the test ids
are read from the @test("...") decorators on the module's top-level are read from the @test("...") decorators on the module's top-level
@@ -191,14 +230,9 @@ def module_parts(path):
text, tree = _parsed(path) text, tree = _parsed(path)
lines = text.split("\n") lines = text.split("\n")
spans = {} spans = {}
for node in (tree.body if tree is not None else []): for tid, node in _test_defs(tree).items():
if not isinstance(node, ast.FunctionDef): start = min([dd.lineno for dd in node.decorator_list] + [node.lineno])
continue spans[tid] = (start, node.end_lineno)
for d in node.decorator_list:
if (isinstance(d, ast.Call) and isinstance(d.func, ast.Name) and d.func.id == "test"
and d.args and isinstance(d.args[0], ast.Constant) and isinstance(d.args[0].value, str)):
start = min([dd.lineno for dd in node.decorator_list] + [node.lineno])
spans[d.args[0].value] = (start, node.end_lineno)
owned = set() owned = set()
for a, b in spans.values(): for a, b in spans.values():
owned.update(range(a, b + 1)) owned.update(range(a, b + 1))
@@ -211,10 +245,10 @@ def module_parts(path):
def sibling_imports(path, seen=None): def sibling_imports(path, seen=None):
"""The suite modules a module imports with `from .x import ...`, as """The suite modules a module's shared text imports with `from .x
paths, transitively, in a stable order. A helper a test calls from import ...` or `from . import x`, as paths, transitively, in a stable
a sibling module is part of the judge, so its text belongs in the order. A helper a test calls from a sibling module is part of the
test's fingerprint.""" judge, so its text belongs in the test's fingerprint."""
if seen is None: if seen is None:
seen = [] seen = []
for sib in _direct_siblings(path): for sib in _direct_siblings(path):
@@ -224,32 +258,65 @@ def sibling_imports(path, seen=None):
return seen return seen
def _imported(path, nodes):
"""The suite modules the statements `nodes` import with `from .x
import` or `from . import x` (each name a module of the suite), at any
depth (inside a function or a block too), in the order they are
written."""
here = os.path.dirname(path)
found = sorted((n for top in nodes for n in ast.walk(top)
if isinstance(n, ast.ImportFrom) and n.level == 1),
key=lambda n: (n.lineno, n.col_offset))
out = []
for node in found:
for name in ([node.module] if node.module else [a.name for a in node.names]):
sib = os.path.join(here, name.replace(".", os.sep) + ".py")
if os.path.isfile(sib) and sib not in out:
out.append(sib)
return out
def _direct_siblings(path): def _direct_siblings(path):
"""The suite modules one module imports itself, in import order.""" """The suite modules one module's shared text imports itself: at its
top level, and inside its helpers and blocks. What a @test function
imports is that test's own (_own_siblings)."""
with _PARTS_LOCK: with _PARTS_LOCK:
hit = _SIBLINGS.get(path) hit = _SIBLINGS.get(path)
if hit is None: if hit is None:
_text, tree = _parsed(path) _text, tree = _parsed(path)
here = os.path.dirname(path) tests = list(_test_defs(tree).values())
hit = [] shared = [n for n in (tree.body if tree is not None else []) if not any(n is t for t in tests)]
for node in (tree.body if tree is not None else []): hit = _SIBLINGS[path] = _imported(path, shared)
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 return hit
def implementation_sha(path, test_id): def _own_siblings(path, test_id):
"""The suite modules one test's own function imports: part of that
test's judge, and of no other test's."""
_text, tree = _parsed(path)
node = _test_defs(tree).get(test_id)
return _imported(path, [node]) if node is not None else []
def implementation_sha(path, test_id, judges=None):
"""The implementation hash of one test (see Test.source_sha): the """The implementation hash of one test (see Test.source_sha): the
test's own text, the shared text of its module, and the shared text test's own text, the shared text of its module, the shared text of
of every sibling suite module the module imports.""" every sibling suite module the module's shared text or the test's
own function imports (transitively), and the text of the shared
judges. `judges` names files to take as the judges in place of
SHARED_JUDGES (the unit tests edit their own)."""
shared, own = module_parts(path) shared, own = module_parts(path)
if test_id not in own: if test_id not in own:
return source_file_sha(path) return source_file_sha(path)
helpers = [module_parts(p)[0] for p in sorted(sibling_imports(path))] sibs = sibling_imports(path)
return _manifest.sha256_text("%s:%s" % (":".join([shared] + helpers), own[test_id])) for sib in _own_siblings(path, test_id):
if sib not in sibs and sib != path:
sibs.append(sib)
sibling_imports(sib, sibs)
helpers = [module_parts(p)[0] for p in sorted(sibs) if p != path]
paths = shared_judge_paths() if judges is None else list(judges)
judged = [module_parts(p)[0] for p in paths]
return _manifest.sha256_text("%s:%s" % (":".join([shared] + helpers + judged), own[test_id]))
def test(id, *, title, subsystem, kind="auto", hardware="api", mode=None, covers=(), def test(id, *, title, subsystem, kind="auto", hardware="api", mode=None, covers=(),
+38 -12
View File
@@ -4,24 +4,33 @@
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
"""Coverage lint: every source path in the manifest must be selected by """Coverage lint: every source path in the manifest must be selected by
some test's coverage globs, except the allowlisted non-behavioral paths, some test's coverage globs or by a host-tested row (manifest.HOST_TESTED),
and every coverage glob must select at least one path of its component. except the allowlisted non-behavioral paths; every coverage glob and every
host-tested glob must select at least one path of its component; and every
host-tested row must name a host test that exists and a CI workflow that
runs it.
Why a hard rule: under the domain model an uncovered file is worse than an Why a hard rule: under the domain model an uncovered file is worse than an
untested one - a change there leaves every inherited PASS valid when it untested one - a change there leaves every inherited PASS valid when it
should have invalidated them. The lint is the floor (the file is should have invalidated them. The lint is the floor (the file is
fingerprinted by at least one test); whether that test exercises the fingerprinted by at least one test, or a named host test that CI runs
change stays with the change author. An entry that selects nothing is answers for it); whether that test exercises the change stays with the
the same defect from the other side: the test's fingerprint ignores the change author. An entry that selects nothing is the same defect from the
file it named. other side: the test's fingerprint ignores the file it named.
python3 -m forgetest.coverage [--manifest PATH] [--enforce] [--json] Whether a workflow runs its host test is read from the workflow's text at
the pinned revision, in the sources scripts/manifest-from-tree.py fetched
(--sources, default the checkout's .manifest-cache). Without them (on an
image) that part is not checked, and the report says so.
python3 -m forgetest.coverage [--manifest PATH] [--sources DIR] [--enforce] [--json]
Exit 0 when nothing is uncovered (or when only reporting), 1 under Exit 0 when nothing is uncovered (or when only reporting), 1 under
--enforce with uncovered paths, 2 on a usage or load error. --enforce with uncovered paths, 2 on a usage or load error.
""" """
import argparse import argparse
import json import json
import os
import sys import sys
from . import catalog as _catalog from . import catalog as _catalog
@@ -33,16 +42,22 @@ ALLOW = _manifest.NON_BEHAVIORAL
def run(manifest, tests, allow=ALLOW): def run(manifest, tests, allow=ALLOW):
return _manifest.coverage_report(manifest, tests, allow) return _manifest.coverage_report(manifest, tests, allow, host=_manifest.HOST_TESTED)
def empty(manifest, tests): def empty(manifest, tests):
return _manifest.empty_covers(manifest, tests) return _manifest.empty_covers(manifest, tests)
def host_problems(manifest, sources=_manifest.SOURCES):
return _manifest.host_row_problems(manifest, host=_manifest.HOST_TESTED, sources=sources)
def main(argv=None): def main(argv=None):
ap = argparse.ArgumentParser(description="forgetest coverage lint") ap = argparse.ArgumentParser(description="forgetest coverage lint")
ap.add_argument("--manifest", default=None, help="manifest JSON (default: the running image's)") ap.add_argument("--manifest", default=None, help="manifest JSON (default: the running image's)")
ap.add_argument("--sources", default=_manifest.SOURCES,
help="the pinned sources manifest-from-tree.py fetched (default: the checkout's .manifest-cache)")
ap.add_argument("--enforce", action="store_true", help="exit 1 when any path is uncovered") ap.add_argument("--enforce", action="store_true", help="exit 1 when any path is uncovered")
ap.add_argument("--json", action="store_true", help="machine-readable report") ap.add_argument("--json", action="store_true", help="machine-readable report")
args = ap.parse_args(argv) args = ap.parse_args(argv)
@@ -56,19 +71,30 @@ def main(argv=None):
report = run(manifest, tests) report = run(manifest, tests)
total = sum(len(v) for v in report.values()) total = sum(len(v) for v in report.values())
hollow = empty(manifest, tests) hollow = empty(manifest, tests)
sources = args.sources if args.sources and os.path.isdir(args.sources) else None
rows = host_problems(manifest, sources)
if args.json: if args.json:
print(json.dumps({"uncovered": report, "total": total, "tests": len(tests), print(json.dumps({"uncovered": report, "total": total, "tests": len(tests),
"empty": [list(e) for e in hollow]}, indent=1, sort_keys=True)) "empty": [list(e) for e in hollow],
"host_tested": [list(r) for r in rows],
"workflows_read": sources is not None and not any("cannot be read" in r[3] for r in rows)},
indent=1, sort_keys=True))
else: else:
if _manifest.HOST_TESTED and sources is None:
print("host-tested: no sources, so whether a workflow runs each host test is not checked")
for comp in sorted(report): for comp in sorted(report):
print("%s: %d uncovered path(s)" % (comp, len(report[comp]))) print("%s: %d uncovered path(s)" % (comp, len(report[comp])))
for p in report[comp]: for p in report[comp]:
print(" %s" % p) print(" %s" % p)
for tid, comp, pat in hollow: for tid, comp, pat in hollow:
print("%s: covers (%s, %s) selects nothing" % (tid, comp, pat)) print("%s: covers (%s, %s) selects nothing" % (tid, comp, pat))
print("coverage: %d uncovered path(s) across %d component(s), %d empty entr%s, %d tests" for comp, pat, ref, why in rows:
% (total, len(report), len(hollow), "y" if len(hollow) == 1 else "ies", len(tests))) print("host-tested (%s, %s) by %s: %s" % (comp, pat, ref, why))
if args.enforce and (total or hollow): print("coverage: %d uncovered path(s) across %d component(s), %d empty entr%s, "
"%d host-tested row problem(s), %d tests"
% (total, len(report), len(hollow), "y" if len(hollow) == 1 else "ies", len(rows),
len(tests)))
if args.enforce and (total or hollow or rows):
return 1 return 1
return 0 return 0
+27 -10
View File
@@ -43,7 +43,14 @@ PULSE_WAIT_S = 1.5 # a pulse in progress ends within the firmware's
class FixtureError(Exception): class FixtureError(Exception):
pass """A request the fixture did not carry out as asked. `sent` is True
when the failure came after the request went out (no reply, or a
reply cut off), so the box may have acted on it; a refusal, a failed
lookup and a failed connect leave it False."""
def __init__(self, *args, sent=False):
super().__init__(*args)
self.sent = sent
# ---------------------------------------------------------------- mDNS # ---------------------------------------------------------------- mDNS
@@ -271,11 +278,20 @@ class Fixture:
body = {} body = {}
return e.code, body return e.code, body
except (urllib.error.URLError, socket.timeout, OSError) as e: except (urllib.error.URLError, socket.timeout, OSError) as e:
# urllib raises a failed connect or send as a URLError; a
# timeout or a reset after the request went out comes bare, and
# the box may have acted on the request
sent = not isinstance(e, urllib.error.URLError)
if retry and not self.ip_override: if retry and not self.ip_override:
# the address may have moved: one fresh lookup, one retry # the address may have moved: one fresh lookup, one retry,
self.address(refresh=True) # and a failure of either still says the first one went out
return self._request(method, path, body, retry=False) try:
raise FixtureError("fixture %s: %s %s: %s" % (self.where(), method, path, e)) self.address(refresh=True)
return self._request(method, path, body, retry=False)
except FixtureError as again:
again.sent = again.sent or sent
raise
raise FixtureError("fixture %s: %s %s: %s" % (self.where(), method, path, e), sent=sent)
def status(self): def status(self):
st, body = self._request("GET", "/") st, body = self._request("GET", "/")
@@ -314,11 +330,12 @@ class Fixture:
def _press(self, ms=None): def _press(self, ms=None):
"""One press, spaced from the last one. `ms` is how long the box """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 holds it: the box's own 200 ms when it is not said, and the box
it is not said): a check that must reach the machine *while* the clamps a request into 20 to 500, so no press is ever held. Every
button is down asks for the longest press the box can make. The controller detects reader of the button catches the default press (an edge, or a poll
the button's rising edge, so a press must follow a release it has of 100 ms or less), so no check needs a longer one. The controller
seen: the next press waits for the last pulse to end (the 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 fixture's own pulse_ms) plus BUTTON_GAP_S. A 409 for a pulse in
progress (a press this client did not time) is waited out once progress (a press this client did not time) is waited out once
against the fixture's state, then retried.""" against the fixture's state, then retried."""
+86
View File
@@ -14,9 +14,12 @@ exercised against a mock on a host:
GRBL_HOST / GRBL_PORT default 127.0.0.1 / 23 GRBL_HOST / GRBL_PORT default 127.0.0.1 / 23
FORGETEST_INITD default /etc/init.d FORGETEST_INITD default /etc/init.d
""" """
import glob
import json import json
import os import os
import select
import socket import socket
import struct
import subprocess import subprocess
import threading import threading
import time import time
@@ -310,6 +313,89 @@ def button_lit():
return any(v > 0 for v in vals) return any(v > 0 for v in vals)
# ----------------------------------------------------------- the button
EV_SW = 0x05
SW_BUTTON = 2 # the button's EV_SW code (bit 2 of the switch word)
def switches_device():
"""The machine's switches as forgectrl and the controller find them:
GF_SWITCH_DEV, else the input device the device tree names
"switches". None when there is none."""
dev = os.environ.get("GF_SWITCH_DEV")
if dev:
return dev
for d in sorted(glob.glob("/sys/class/input/event*")):
try:
with open(os.path.join(d, "device", "name")) as f:
if f.read().strip() == "switches":
return "/dev/input/" + os.path.basename(d)
except OSError:
continue
return None
class ButtonEdges:
"""The button as edges, from the switches' input events. A press is an
event however short it is, so nothing has to be held long enough for a
poll to land on it: an operator's press is never longer than a quarter
second. Open it before the press is asked for. It reads without a
grab, so forgectrl and the controller see the switches as always."""
EVENT = struct.Struct("llHHi") # struct input_event: time, type, code, value
def __init__(self):
self.fd = None
self.pending = b"" # events read and not yet looked at
dev = switches_device()
if dev:
try:
self.fd = os.open(dev, os.O_RDONLY | os.O_NONBLOCK | getattr(os, "O_CLOEXEC", 0))
except OSError:
self.fd = None
@property
def available(self):
return self.fd is not None
def wait(self, value, timeout, checkpoint=None):
"""The seconds until the button's next edge to `value` (1 pressed,
0 released), or None at the timeout. checkpoint() is called about
every 0.1 s (an abort raises out of it)."""
size = self.EVENT.size
t0 = time.time()
while True:
# The events already read come first: a short press arrives
# with its release in one read, and the release is the next
# wait's.
while len(self.pending) >= size:
_sec, _usec, typ, code, val = self.EVENT.unpack_from(self.pending, 0)
self.pending = self.pending[size:]
if typ == EV_SW and code == SW_BUTTON and val == value:
return time.time() - t0
if time.time() - t0 >= timeout:
return None
if checkpoint:
checkpoint()
ready, _, _ = select.select([self.fd], [], [], 0.1)
if not ready:
continue
try:
data = os.read(self.fd, size * 64)
except BlockingIOError:
continue
if not data:
time.sleep(0.02) # a FIFO with no writer (the host tests) reads empty
continue
self.pending += data
def close(self):
if self.fd is not None:
os.close(self.fd)
self.fd = None
# --------------------------------------------------------------- init.d # --------------------------------------------------------------- init.d
def initd(service, action, timeout=60): def initd(service, action, timeout=60):
File diff suppressed because it is too large Load Diff
+100 -10
View File
@@ -125,6 +125,8 @@ ACT_TIMEOUT_S = 180
# machine by pressing its button. Generous: they may be setting up the # machine by pressing its button. Generous: they may be setting up the
# scrap, and nothing fires until it lands. # scrap, and nothing fires until it lands.
PRESENCE_TIMEOUT_S = 600 PRESENCE_TIMEOUT_S = 600
ARM_LIT_WAIT_S = 1800 # an arm can wait minutes (a coolant settle) before it lights
ARM_PRESS_WAIT_S = 3600 # the controller's own button timeout, at most
class Run: class Run:
@@ -316,17 +318,31 @@ class Context:
fixture = getattr(self.runner, "fixture", None) if self.runner is not None else None fixture = getattr(self.runner, "fixture", None) if self.runner is not None else None
if fixture is not None and fixture.covers("button"): if fixture is not None and fixture.covers("button"):
# The press is read as an edge from the switches' events, opened
# before the notice goes up: a press of any length counts, and
# nobody holds the button for a poll to land on it.
edges = hw.ButtonEdges()
self.notice(text + " Then press the button on the machine to start.") self.notice(text + " Then press the button on the machine to start.")
self.log("READY: waiting for the operator's press on the machine " self.log("READY: waiting for the operator's press on the machine "
"(the bench then does every press in this test)") "(the bench then does every press in this test)")
pressed = self.wait_for(lambda: self.switch("button") is True, PRESENCE_TIMEOUT_S) try:
self.clear_notice() if edges.available:
pressed = edges.wait(1, PRESENCE_TIMEOUT_S, self.checkpoint)
# The release, so this press is never read as the arm
# press or a pause toggle; a short press has queued it.
if pressed is not None:
edges.wait(0, 10, self.checkpoint)
else:
pressed = self.wait_for(lambda: self.switch("button") is True, PRESENCE_TIMEOUT_S,
poll=0.05)
if pressed is not None:
self.wait_for(lambda: self.switch("button") is False, 10, poll=0.05)
finally:
edges.close()
self.clear_notice()
if pressed is None: if pressed is None:
raise Failed("no press on the machine within %d s: %s" raise Failed("no press on the machine within %d s: %s"
% (PRESENCE_TIMEOUT_S, text)) % (PRESENCE_TIMEOUT_S, text))
# Let the button come back up, so this press is never read as
# the arm press or a pause toggle.
self.wait_for(lambda: self.switch("button") is False, 10)
self.run.fixture_takeover = True self.run.fixture_takeover = True
self.evidence.setdefault("actions", []).append( self.evidence.setdefault("actions", []).append(
{"channel": "button", "state": "presence", "by": "operator", {"channel": "button", "state": "presence", "by": "operator",
@@ -448,8 +464,9 @@ class Context:
The fixture presses where the bench took this test's presses (or The fixture presses where the bench took this test's presses (or
opted in standing); otherwise the notice goes up for a person, opted in standing); otherwise the notice goes up for a person,
who is looking at the same prompt. Returns True when the fixture who is looking at the same prompt. A press the fixture reports
pressed.""" failed while the button went out was taken all the same, and no
notice goes up for it. Returns True when the fixture pressed."""
fixture = getattr(self.runner, "fixture", None) if self.runner is not None else None fixture = getattr(self.runner, "fixture", None) if self.runner is not None else None
rec = {"channel": "button", "state": "arm", "by": "operator", "ts": now_ts()} rec = {"channel": "button", "state": "arm", "by": "operator", "ts": now_ts()}
self.evidence.setdefault("actions", []).append(rec) self.evidence.setdefault("actions", []).append(rec)
@@ -461,18 +478,84 @@ class Context:
self.log("ARM: WARNING the bench actuator took this test's presses and is now " self.log("ARM: WARNING the bench actuator took this test's presses and is now "
"gone - asking the operator for the arm press") "gone - asking the operator for the arm press")
self.notice(text) self.notice(text)
self._clear_when_taken()
return False return False
try: try:
fixture.act("button", "press") fixture.act("button", "press")
except _fixture.FixtureError as e: except _fixture.FixtureError as e:
rec["fixture_error"] = str(e) rec["fixture_error"] = str(e)
if self._press_taken(rec, e):
return rec["by"] == "fixture"
self.log("ARM press: fixture failed (%s) - asking the operator", e) self.log("ARM press: fixture failed (%s) - asking the operator", e)
self.notice(text) self.notice(text)
self._clear_when_taken()
return False return False
rec["by"] = "fixture" rec["by"] = "fixture"
self.log("ARM press by the fixture, at the machine's own press prompt") self.log("ARM press by the fixture, at the machine's own press prompt")
return True return True
def _press_taken(self, rec, err):
"""After the box reported a failed press of a lit button: True when
the button is out, so the controller took a press. No notice goes
up then, because it would ask for a second press, and a press during
the job pauses it. The press is recorded as the box's only when its
request went out and the failure came after it (err.sent: the box
pressed and its reply was lost). After a refusal, a failed lookup
or a failed connect the box made no press, and the record stays as
it was: the press was a person's. A button still lit, or
unreadable, is False: the operator is asked."""
if hw.button_lit() is not False:
return False
if getattr(err, "sent", False):
rec["by"] = "fixture"
self.log("ARM press: the fixture reported a failure (%s), and the button is out: the press "
"was taken; no notice", err)
else:
self.log("ARM press: the fixture made no press (%s), and the button is out: a press was "
"taken all the same; no notice", err)
return True
def _clear_when_taken(self, lit_timeout=ARM_LIT_WAIT_S):
"""Take the arm notice down the moment the press is taken: the
controller lights the button white for its arm wait and puts it out
at the press. Without this the notice stood until the test's next
step, and an operator who still saw it pressed again, which pauses
the job. A notice replaced meanwhile is left alone. The watch lives
only as long as the notice it guards and the run: a notice cleared
or replaced, a run that finished (passed, failed, or errored before
the press) or an abort ends it, so no thread is left polling the
LEDs behind a test."""
run = self.run
with run._lock:
nid = (run.notice or {}).get("id")
if nid is None:
return
def standing():
with run._lock:
return run.finished is None and (run.notice or {}).get("id") == nid
def watch():
t0 = time.time()
while hw.button_lit() is not True:
if not standing() or time.time() - t0 > lit_timeout:
return
if run.aborted.wait(0.05):
return
while hw.button_lit() is not False:
if not standing() or time.time() - t0 > lit_timeout + ARM_PRESS_WAIT_S:
return
if run.aborted.wait(0.05):
return
with run._lock:
same = (run.notice or {}).get("id") == nid
if same:
run.notice = None
if same:
self.log("ARM: the button went out at the press; the notice is down")
threading.Thread(target=watch, daemon=True, name="forgetest-arm-notice").start()
def arm_press(self, text="The button lights white: press it to arm. The machine fires after your press.", def arm_press(self, text="The button lights white: press it to arm. The machine fires after your press.",
lit_timeout=60): lit_timeout=60):
"""The arm cue of a live test. A person's press by default: a """The arm cue of a live test. A person's press by default: a
@@ -482,8 +565,9 @@ class Context:
seconds for the button to light (the job may still be on its way seconds for the button to light (the job may still be on its way
to the arm wait; a card that settles the coolant first takes to the arm wait; a card that settles the coolant first takes
minutes) and presses once, recorded as the fixture's; if the minutes) and presses once, recorded as the fixture's; if the
button never lights or the press fails, the notice goes up for a button never lights, or the press fails with the button still lit,
person. Returns True when the fixture has been asked.""" the notice goes up for a person. Returns True when the fixture has
been asked."""
fixture = getattr(self.runner, "fixture", None) if self.runner is not None else None fixture = getattr(self.runner, "fixture", None) if self.runner is not None else None
rec = {"channel": "button", "state": "arm", "by": "operator", "ts": now_ts()} rec = {"channel": "button", "state": "arm", "by": "operator", "ts": now_ts()}
self.evidence.setdefault("actions", []).append(rec) self.evidence.setdefault("actions", []).append(rec)
@@ -499,6 +583,7 @@ class Context:
self.log("ARM: WARNING the bench actuator took this test's presses and is now " self.log("ARM: WARNING the bench actuator took this test's presses and is now "
"gone - asking the operator for the arm press") "gone - asking the operator for the arm press")
self.notice(text) self.notice(text)
self._clear_when_taken()
return False return False
self.log("ARM: the fixture presses when the button lights (the bench's arm_press opt-in)") self.log("ARM: the fixture presses when the button lights (the bench's arm_press opt-in)")
@@ -507,13 +592,18 @@ class Context:
if lit is None: if lit is None:
self.log("ARM: the button never lit within %d s - asking the operator", lit_timeout) self.log("ARM: the button never lit within %d s - asking the operator", lit_timeout)
self.notice(text) self.notice(text)
self._clear_when_taken()
return return
try: try:
fixture.act("button", "press") fixture.act("button", "press")
except _fixture.FixtureError as e: except _fixture.FixtureError as e:
self.log("ARM press: fixture failed (%s) - asking the operator", e)
rec["fixture_error"] = str(e) rec["fixture_error"] = str(e)
if self._press_taken(rec, e):
rec["took_s"] = round(lit, 2)
return
self.log("ARM press: fixture failed (%s) - asking the operator", e)
self.notice(text) self.notice(text)
self._clear_when_taken()
return return
rec["by"] = "fixture" rec["by"] = "fixture"
rec["took_s"] = round(lit, 2) rec["took_s"] = round(lit, 2)
+1 -14
View File
@@ -11,8 +11,7 @@ from . import kernel # noqa: F401,E402
from . import forgectrl # noqa: F401,E402 from . import forgectrl # noqa: F401,E402
from . import setup # noqa: F401,E402 from . import setup # noqa: F401,E402
from . import setup_dark # noqa: F401,E402 from . import setup_dark # noqa: F401,E402
from . import setup_sheet # noqa: F401,E402 from . import setup_burns # noqa: F401,E402
from . import logs # noqa: F401,E402
from . import motion # noqa: F401,E402 from . import motion # noqa: F401,E402
from . import cooling # noqa: F401,E402 from . import cooling # noqa: F401,E402
from . import laser # noqa: F401,E402 from . import laser # noqa: F401,E402
@@ -21,17 +20,5 @@ from . import update # noqa: F401,E402
from . import cloud # noqa: F401,E402 from . import cloud # noqa: F401,E402
# Last: it stands on the setup record's consent and on the cloud suite's dark print. # 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 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 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 from . import tlsrec # noqa: F401,E402
-287
View File
@@ -1,287 +0,0 @@
# 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)
+5 -70
View File
@@ -4,8 +4,6 @@
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
"""camera.* - the lid camera pipeline through forgectrl.""" """camera.* - the lid camera pipeline through forgectrl."""
import json
from ..catalog import test from ..catalog import test
from .. import hw from .. import hw
from ..baseline import LID_LAMP_ATTR from ..baseline import LID_LAMP_ATTR
@@ -57,7 +55,7 @@ def _jpeg_size(data):
@test("camera.snapshot", title="Lid camera snapshot and stream", subsystem="camera", @test("camera.snapshot", title="Lid camera snapshot and stream", subsystem="camera",
kind="auto", est_min=2, kind="auto", est_min=2,
covers=_CAM_COVERS, requires=["forgectrl.panel-serves"], covers=_CAM_COVERS,
steps=["Setup: lid closed."], steps=["Setup: lid closed."],
description="/cam/snapshot returns a JPEG of a plausible size (a black frame compresses " description="/cam/snapshot returns a JPEG of a plausible size (a black frame compresses "
"far smaller), a second snapshot with the lid lamp switched off differs from it " "far smaller), a second snapshot with the lid lamp switched off differs from it "
@@ -171,7 +169,7 @@ def snapshot(ctx):
@test("camera.sensor-profile", title="Camera geometry follows the fitted sensor", subsystem="camera", @test("camera.sensor-profile", title="Camera geometry follows the fitted sensor", subsystem="camera",
kind="auto", est_min=1, kind="auto", est_min=1,
covers=_CAM_COVERS, requires=["forgectrl.panel-serves"], covers=_CAM_COVERS,
description="/cam/status names the sensor that bound (OV5648 on a 5 MP machine, OV8856 on an " description="/cam/status names the sensor that bound (OV5648 on a 5 MP machine, OV8856 on an "
"8 MP 'HD' one) and reports the geometry that sensor implies, and the JPEGs it " "8 MP 'HD' one) and reports the geometry that sensor implies, and the JPEGs it "
"actually returns are that size. A machine that reports 'unknown', or whose frames " "actually returns are that size. A machine that reports 'unknown', or whose frames "
@@ -214,7 +212,7 @@ def sensor_profile(ctx):
@test("camera.h264-stream", title="H.264 live stream", subsystem="camera", @test("camera.h264-stream", title="H.264 live stream", subsystem="camera",
kind="auto", est_min=2, kind="auto", est_min=2,
covers=_CAM_COVERS, requires=["forgectrl.panel-serves"], covers=_CAM_COVERS,
steps=["Setup: lid closed."], steps=["Setup: lid closed."],
description="/cam/h264 answers a fragmented MP4: a codec header naming the SPS profile, an " description="/cam/h264 answers a fragmented MP4: a codec header naming the SPS profile, an "
"init segment (ftyp+moov) followed by media fragments (moof+mdat) within a few " "init segment (ftyp+moov) followed by media fragments (moof+mdat) within a few "
@@ -276,7 +274,7 @@ def h264_stream(ctx):
@test("camera.frame-health", title="Capture delivers whole frames", subsystem="camera", @test("camera.frame-health", title="Capture delivers whole frames", subsystem="camera",
kind="auto", est_min=1, kind="auto", est_min=1,
covers=_CAM_COVERS, requires=["forgectrl.panel-serves"], covers=_CAM_COVERS,
description="The capture queue flags a frame errored when it is short, torn, or arrived " description="The capture queue flags a frame errored when it is short, torn, or arrived "
"after the CSI-2 receiver lost sync; forgectrl drops those rather than " "after the CSI-2 receiver lost sync; forgectrl drops those rather than "
"demosaicing them, restarts the stream if they persist, and withholds the " "demosaicing them, restarts the stream if they persist, and withholds the "
@@ -364,7 +362,7 @@ def frame_health(ctx):
@test("camera.lid-privacy", title="Cameras see the room only with the lid closed", subsystem="camera", @test("camera.lid-privacy", title="Cameras see the room only with the lid closed", subsystem="camera",
kind="operator", est_min=3, kind="operator", est_min=3,
covers=_PRIVACY_COVERS + [("forgectrl", "src/auth.*")], requires=["forgectrl.panel-serves"], covers=_PRIVACY_COVERS + [("forgectrl", "src/auth.*")],
actions=["lid"], actions=["lid"],
steps=["Start with the lid closed; you will be told to open it, then close it again.", steps=["Start with the lid closed; you will be told to open it, then close it again.",
"Nothing moves and the laser is not involved."], "Nothing moves and the laser is not involved."],
@@ -496,66 +494,3 @@ def lid_privacy(ctx):
ctx.check(st == 200 and data[:2] == b"\xff\xd8", ctx.check(st == 200 and data[:2] == b"\xff\xd8",
"snapshot after closing the lid -> %s", st) "snapshot after closing the lid -> %s", st)
ctx.log("lid closed again: snapshot %d bytes", len(data)) ctx.log("lid closed again: snapshot %d bytes", len(data))
@test("camera.key-read", title="The camera key reads without a login", subsystem="camera",
kind="auto", est_min=1,
covers=[("forgectrl", "src/camkey.*"), ("forgectrl", "src/auth.c"),
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/panel.js"),
("forgectrl", "src/ui/index.html"), ("forgectrl", "src/ui/help.js")],
requires=["forgectrl.auth"],
description="With the read routes closed to the network (panel_open_reads=0), a camera "
"status read from the board's LAN address is refused without the key and "
"served with it, on HTTP and on HTTPS; a wrong key is refused; the key never "
"authorizes a write; a rotation from the panel route stops the old key and "
"the new one reads. The setting is put back after.")
def key_read(ctx):
from .setup import request
from .forgectrl import lan_ip
fc = ctx.forgectrl
ev = ctx.evidence
ip = lan_ip()
ctx.check(ip, "cannot determine the board's LAN address")
http_base = "http://%s" % ip
tls_base = "https://%s" % ip
token = {"X-ForgeFIRM-Token": fc.token}
st, body, _ = request(fc.base, "GET", "/system/camera-key", headers=token)
ctx.check(st == 200, "GET /system/camera-key from loopback -> %s", st)
key = json.loads(body)["key"]
ctx.check(len(key) == 32, "the key is not 32 characters: %r", key)
ev["key_len"] = len(key)
before = fc.settings().get("panel_open_reads", "")
st, _ = fc.post("/settings", params={"panel_open_reads": "0"})
ctx.check(st == 200, "closing the reads -> %s", st)
try:
for base in (http_base, tls_base):
st, _, _ = request(base, "GET", "/cam/status")
ctx.log("GET %s/cam/status (no key) -> %s", base, st)
ctx.check(st == 403, "a closed read without the key -> %s, expected 403", st)
st, _, _ = request(base, "GET", "/cam/status?key=" + key)
ctx.log("GET %s/cam/status?key=... -> %s", base, st)
ctx.check(st == 200, "a read with the key -> %s, expected 200", st)
st, _, _ = request(base, "GET", "/cam/status", headers={"X-ForgeFIRM-Camera-Key": key})
ctx.check(st == 200, "a read with the key header -> %s, expected 200", st)
wrong = ("0" if key[0] != "0" else "1") + key[1:]
st, _, _ = request(base, "GET", "/cam/status?key=" + wrong)
ctx.check(st == 403, "a wrong key -> %s, expected 403", st)
# the key is a read credential only
st, _, _ = request(tls_base, "POST", "/settings", data={"panel_open_reads": "0"},
headers={"X-ForgeFIRM-Camera-Key": key})
ctx.log("POST /settings with the camera key -> %s", st)
ctx.check(st == 403, "the camera key authorized a write (%s)", st)
# rotation
st, body, _ = request(fc.base, "POST", "/system/camera-key?rotate=1", headers=token)
ctx.check(st == 200, "rotate -> %s", st)
new = json.loads(body)["key"]
ctx.check(new != key, "rotation kept the same key")
st, _, _ = request(http_base, "GET", "/cam/status?key=" + key)
ctx.check(st == 403, "the old key still reads after rotation (%s)", st)
st, _, _ = request(http_base, "GET", "/cam/status?key=" + new)
ctx.check(st == 200, "the new key does not read (%s)", st)
ev["rotated"] = True
finally:
fc.post("/settings", params={"panel_open_reads": before})
+1 -267
View File
@@ -1262,123 +1262,7 @@ def lid_interlock_abort_body(ctx, ev, off, job, offset):
ev.get("edge_to_stop_ms")) ev.get("edge_to_stop_ms"))
@test("cloud.lid-during-button-wait", title="Lid open at the cloud button prompt cancels the print", # ---------------------------------------------------------------- the dark print (exthost.armed-freeze)
subsystem="cloud", kind="operator", est_min=8,
covers=_MACHINE_RUN, requires=[], actions=["lid"],
steps=[OFFLINE_STEP, LID_STEP,
"The job is longer than the ring holds, so the ring is filled before the button "
"lights; that takes a minute. When the button lights white, do NOT press it - open "
"the lid, and close it when told. Nothing moves and nothing fires."],
description="A cloud print waiting for the button is canceled by the lid: the wait ends "
"with the lid named as the reason, the laser latch relocks, the armed window "
"closes, no run starts, and the job ends ':cancelled'. The job is longer than "
"the ring, so its feeder is alive through the wait with the rest of the print "
"in hand: the cancel has to stop that feeder before the park clears the ring, "
"and the ring has to stay empty afterward. A feeder left alive would refill "
"it and the park, which ignores the lid, would play the print's path.")
def lid_during_button_wait(ctx):
ev = ctx.evidence
offset = enter_offline(ctx)
job = offline_job(ctx, "wait.puls", seconds=3500)
off = Offline().__enter__()
try:
lid_during_button_wait_body(ctx, ev, off, job, offset)
finally:
off.__exit__(None, None, None)
offline_cleanup(ctx)
def lid_during_button_wait_body(ctx, ev, off, job, offset):
off.print_ready(9003, job)
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.act("lid", "open", text="The button is lit: do NOT press it.", timeout=120)
relock = "button wait lid opened - relocking the laser"
got = wait_log(ctx, offset, [relock], 60)
fin = wait_action_finished(ctx, offset, "print", 60)
ev["log"] = {relock: bool(got[relock]), "print finished": message(fin)}
ctx.check(got[relock], "the lid did not end the button wait")
ctx.check(fin and CANCELLED in fin, "the print did not end ':cancelled': %s",
message(fin) or "no finish line")
# No run may start between the button wait and the cancel: the print
# itself, or a park (the head never moved, there is nothing to park).
# The service's moves BEFORE the print are legitimate runs and are
# outside this window.
lines = log_lines_since(GFCLOUD_LOG, offset)
wait_i = next((i for i, ln in enumerate(lines) if "waiting for button" in ln), None)
end_i = action_finish_index(lines, "print")
if end_i is None or wait_i is None or end_i < wait_i:
end_i = len(lines)
ran = ([ln for ln in lines[wait_i:end_i] if "starting run" in ln]
if wait_i is not None else [])
ev["runs_started_after_wait"] = len(ran)
ctx.check(not ran, "a run started after the lid-open cancel (%d)", len(ran))
st, cs = ctx.forgectrl.get("/cool/status")
ev["armed_after"] = cs.get("armed") if isinstance(cs, dict) else None
ev["latch_locked"] = latch_locked()
ctx.check(not ev["armed_after"], "armed window still open after the cancel")
ctx.check(ev["latch_locked"], "kernel latch not locked after the cancel")
ev["button_dark"] = hw.button_lit()
ctx.check(ev["button_dark"] is False, "the button is still lit after the cancel (%s)", ev["button_dark"])
# The park cleared the ring and nothing refilled it: the feeder is gone,
# the device is out of live-feed mode, and the program total stands at
# zero and stays there. A feeder left alive shows here as a total that
# comes back within its retry period.
total = read_program_total()
ctx.sleep(2)
later = read_program_total()
streaming = hw.sysfs_int("cnc/streaming", 0)
ev["program_total_after"] = {"first": total, "later": later}
ev["streaming_after"] = streaming
ctx.check(total == 0 and later == 0,
"the ring is not empty after the cancel (program total %s, then %s): the feeder "
"outlived the job", total, later)
ctx.check(streaming == 0, "cnc/streaming is %s after the cancel", streaming)
ctx.act("lid", "close")
settle_cloud(ctx, offset)
ev["events"] = offline_events(off)
ctx.log("PASS: lid open at the button prompt canceled the print; latch locked, armed=false, "
"button dark, ring empty with the feed stopped")
# ---------------------------------------------------------------- the cooling verdict
HOLD_KEYS = ("cool_temp_min", "cool_temp_start")
REFUSE_KEYS = HOLD_KEYS + ("cloud_hold_max_s",)
HOLD_REFUSE_S = 60 # the shortest bound the client takes
HOLD_REFUSE_LINE = "cooling hold held 60 s - relocking the laser"
WARMUP_WAIT_LINE = "waiting on the cooling engine: WARMUP"
@test("cloud.dark-print", title="A dark cloud print: the latch locked until the run, unlocked "
"for it, locked after it",
subsystem="cloud", kind="operator", est_min=3,
covers=_MACHINE_RUN + [("python3-gfhardware", "gfhardware/coolsvc.py"),
("forgectrl", "src/cool.*"), ("forgectrl", "src/main.c")],
requires=["cloud.lid-during-button-wait", "cooling.floor-and-warm-up"], actions=["button"],
steps=[OFFLINE_STEP,
"Bed clear (the job is dark: a 30 s square at S0, nothing fires). Press the button "
"when it lights: the print waits for the engine, runs, and finishes."],
description="The cooling-engine contract in cloud mode at its ordinary end, on the engine "
"itself: the print arms on the press, waits for the engine's acknowledgment (the "
"fans at their floors), and runs. The laser latch is locked at the button and "
"through the wait, unlocks only for the run (immediately before it starts), and "
"is locked again when the job ends; the print completes. A hold the engine never "
"clears is cloud.verdict-refuse; the engine's own warm-up release is "
"cooling.floor-and-warm-up.")
def dark_print(ctx):
ev = ctx.evidence
fc = ctx.forgectrl
offset = enter_offline(ctx)
job = offline_job(ctx, "dark.puls", seconds=30)
off = Offline().__enter__()
try:
dark_print_body(ctx, ev, off, job, offset, fc)
finally:
off.__exit__(None, None, None)
offline_cleanup(ctx)
def dark_print_body(ctx, ev, off, job, offset, fc): def dark_print_body(ctx, ev, off, job, offset, fc):
off.print_ready(9005, job) off.print_ready(9005, job)
@@ -1404,91 +1288,6 @@ def dark_print_body(ctx, ev, off, job, offset, fc):
ctx.log("PASS: locked at the button, unlocked for the run, locked after it; the print completed") ctx.log("PASS: locked at the button, unlocked for the run, locked after it; the print completed")
@test("cloud.verdict-refuse", title="A cloud print held by the engine past the bound is canceled with "
"the latch never unlocked",
subsystem="cloud", kind="operator", est_min=4,
covers=_MACHINE_RUN + [("python3-gfhardware", "gfhardware/coolsvc.py"),
("forgectrl", "src/cool.*")],
requires=["cloud.dark-print"], actions=["button"],
steps=[OFFLINE_STEP,
"Coolant at room temperature (10 to 30 C). The test sets the warm-up gate well above "
"the coolant and the hold bound to its minimum, and restores both. Press the button "
"when it lights: nothing runs; a minute later the print cancels by itself."],
description="The other end of the cooling-engine contract before a run: a verdict that "
"refuses fire and never clears. The start gate far above the coolant keeps the "
"armed session under the warm-up for longer than the hold bound (cloud_hold_max_s "
"at its minimum, 60 s). The client waits with the laser latch locked the whole "
"time - it is never unlocked before the verdict passes, so there is nothing to "
"relock - then cancels the print at the bound: no run, the armed window closed, "
"the print ':cancelled'. Dark by construction.")
def verdict_refuse(ctx):
ev = ctx.evidence
fc = ctx.forgectrl
before = fc.settings()
orig = {k: before.get(k, "") for k in REFUSE_KEYS}
ev["orig"] = orig
up = (fc.status().get("coolant") or {}).get("up_c")
ctx.check(up is not None and 10.0 <= up <= 30.0, "coolant outside the test's range (up_c %s)", up)
offset = enter_offline(ctx)
job = offline_job(ctx, "refuse.puls", seconds=30)
off = Offline().__enter__()
try:
verdict_refuse_body(ctx, ev, off, job, offset, fc, up, orig)
finally:
st, body = fc.post("/settings", params=orig)
ctx.log("restore: POST /settings %s -> %s", orig, st)
off.__exit__(None, None, None)
offline_cleanup(ctx)
def verdict_refuse_body(ctx, ev, off, job, offset, fc, up, orig):
gate = round(up + 8.0, 1) # eight degrees: the heater cannot get there in a minute
st, body = fc.post("/settings", params={"cool_temp_start": str(gate),
"cool_temp_min": orig["cool_temp_min"],
"cloud_hold_max_s": str(HOLD_REFUSE_S)})
ctx.check(st == 200, "POST /settings cool_temp_start=%s cloud_hold_max_s=%s -> %s %s",
gate, HOLD_REFUSE_S, st, body)
ev["gate"] = gate
off.print_ready(9006, job)
wait_button_wait(ctx, offset, 120)
ev["latch_locked_at_button"] = latch_locked()
ctx.check(ev["latch_locked_at_button"], "the latch is unlocked at the button wait")
ctx.act("button", "press", text="The button is lit: press it. Nothing runs; the print cancels "
"itself a minute later.",
until=lambda: log_has(offset, WARMUP_WAIT_LINE), timeout=PRESS_TIMEOUT_S)
got = wait_log(ctx, offset, [WARMUP_WAIT_LINE], 30)
ctx.check(got[WARMUP_WAIT_LINE], "the armed print did not wait on the warm-up")
t0 = time.time()
samples = []
ctx.notice("The engine holds the print; the client gives up at the bound. Do nothing.")
try:
while time.time() - t0 < HOLD_REFUSE_S + 30:
ctx.checkpoint()
samples.append(latch_locked())
if log_has(offset, HOLD_REFUSE_LINE):
break
ctx.sleep(2)
finally:
ctx.clear_notice()
ev["hold_s"] = round(time.time() - t0)
ev["latch_samples"] = {"n": len(samples), "unlocked": samples.count(False)}
ctx.check(log_has(offset, HOLD_REFUSE_LINE), "the client did not cancel at the hold bound (%d s)",
ev["hold_s"])
ctx.check(all(samples), "the latch unlocked during the hold (%d of %d samples)",
samples.count(False), len(samples))
ctx.check(wait_print_running(ctx, offset, 1) is None, "the run started under the refused verdict")
fin = wait_action_finished(ctx, offset, "print", 120)
ev["print finished"] = message(fin)
ctx.check(fin and CANCELLED in fin, "the held print did not end ':cancelled': %s",
message(fin) or "no finish line")
st, cs = fc.get("/cool/status")
ev["armed_after"] = cs.get("armed") if isinstance(cs, dict) else None
ctx.check(not ev["armed_after"], "armed window still open after the cancel")
ctx.check(latch_locked(), "the latch is unlocked after the cancel")
ev["events"] = offline_events(off)
ctx.log("PASS: held %s s with the latch locked throughout, then canceled at the bound", ev["hold_s"])
@test("cloud.pause-resume", title="Button pauses and resumes a cloud print (factory backtrack + lead)", @test("cloud.pause-resume", title="Button pauses and resumes a cloud print (factory backtrack + lead)",
subsystem="cloud", kind="live", est_min=8, subsystem="cloud", kind="live", est_min=8,
covers=_CLOUD_ALL, covers=_CLOUD_ALL,
@@ -1722,68 +1521,3 @@ def oversize_stream_body(ctx, ev, off, job, offset, before):
"stopped it, parked, relocked and reported ':cancelled'") "stopped it, parked, relocked and reported ':cancelled'")
@test("cloud.paused-lid-cancel", title="A paused cloud print is canceled by the lid",
subsystem="cloud", kind="live", est_min=6,
covers=_MACHINE_RUN, requires=["cloud.pause-resume", "cloud.lid-interlock-abort"],
actions=["button", "lid"],
steps=[OFFLINE_STEP, ARM_STEP, LID_STEP,
"The head needs 40 mm of free +X and +Y travel.",
"Press the button to start; when asked, press it again (pause), then open the lid and "
"leave it open until the head is back; close it when told."],
description="A job paused on the button is canceled by the lid, from the state the factory "
"ends it in - there is no resume past a lid open. The same tail as every abort: "
"the motion stops, the head parks back at the job start, the latch relocks and "
"the armed window closes, the button goes dark, and the print ends ':cancelled'. "
"(The app's own cancel of a running print is exercised where a print has to be "
"ended that way: cloud.oversize-stream.)")
def paused_lid_cancel(ctx):
ev = ctx.evidence
offset = enter_offline(ctx)
job = offline_job(ctx, "paused.puls", seconds=60)
off = Offline().__enter__()
try:
paused_lid_cancel_body(ctx, ev, off, job, offset)
finally:
off.__exit__(None, None, None)
offline_cleanup(ctx)
def paused_lid_cancel_body(ctx, ev, off, job, offset):
# -- paused on the button, then the lid ---------------------------------
offline_start_print(ctx, off, 9005, job, offset)
ctx.act("button", "press", text="The head is moving: the press pauses the print.",
until=lambda: log_has(offset, PAUSE_LINES[0]), timeout=PRESS_TIMEOUT_S, fail=False)
got = wait_log(ctx, offset, list(PAUSE_LINES), 10)
ev["paused"] = {k: bool(v) for k, v in got.items()}
ctx.check(got["button pressed mid-run; pausing"], "the press did not pause the print")
ctx.check(got["paused at"], "the pause did not settle (no 'paused at')")
ctx.act("lid", "open", text="The print is paused: leave the lid open until the head has returned "
"to the corner.", timeout=60)
lid_stop = "lid opened mid-run; stopping motion"
got = wait_log(ctx, offset, [lid_stop, "start return home", "return home complete"], 120)
fin1 = wait_action_finished(ctx, offset, "print", 60)
ev["lid_from_pause"] = {k: message(v) for k, v in got.items()}
ev["lid_from_pause"]["print finished"] = message(fin1)
for k, v in ev["lid_from_pause"].items():
ctx.log(" [print] %s: %s", k, "seen" if v else "MISSING")
ctx.check(got[lid_stop], "the lid did not end the paused print")
ctx.check(got["return home complete"], "the paused print did not park to completion")
ctx.check(fin1 and CANCELLED in fin1, "the print did not end ':cancelled': %s",
message(fin1) or "no finish line")
ctx.check(ctx.forgectrl.wait_idle(15, abort=ctx.aborted), "machine not idle after the park")
kpos = read_position()
ev["counters_after"] = kpos
ctx.check(kpos is not None and abs(kpos[0]) <= 3 and abs(kpos[1]) <= 3,
"the head did not come back to the job start (kernel counters %s)", kpos)
st, cs = ctx.forgectrl.get("/cool/status")
ev["armed_after"] = cs.get("armed") if isinstance(cs, dict) else None
ev["latch_locked_after"] = latch_locked()
ctx.check(not ev["armed_after"], "armed window still open after the paused print was canceled")
ctx.check(ev["latch_locked_after"],
"kernel latch not locked after the paused print was canceled")
ev["button_dark"] = hw.button_lit()
ctx.check(ev["button_dark"] is False, "the button is still lit after the cancel (%s)", ev["button_dark"])
ctx.act("lid", "close")
settle_cloud(ctx, offset)
ev["events"] = offline_events(off)
ctx.log("PASS: a paused print canceled by the lid stopped, parked, relocked and reported ':cancelled'")
File diff suppressed because it is too large Load Diff
-100
View File
@@ -1,100 +0,0 @@
# 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="auto", mode="grbl", est_min=2,
covers=[("forgectrl", "src/events.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/status.*")],
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
@@ -1,265 +0,0 @@
# 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
@@ -1,188 +0,0 @@
# 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
@@ -1,117 +0,0 @@
# 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
@@ -1,237 +0,0 @@
# 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
@@ -1,154 +0,0 @@
# 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
@@ -1,238 +0,0 @@
# 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
@@ -1,286 +0,0 @@
# 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
@@ -1,224 +0,0 @@
# 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)
+4 -700
View File
@@ -4,21 +4,17 @@
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
"""forgectrl.* - the machine-services daemon's API, access control, and panel.""" """forgectrl.* - the machine-services daemon's API, access control, and panel."""
import json
import os
import socket import socket
import time
import urllib.parse
from ..catalog import test from ..catalog import test
from .. import hw
# The write guard reads the account and the session store (a machine with # The write guard reads the account and the session store (a machine with
# an account needs a session from the LAN; this host and the dev image # an account needs a session from the LAN; this host and the dev image
# write with the token), so both are the guard's domain. # write with the token), so both are the guard's domain. The fuse view
# is two-factor: the token and the physical button, read by button.c.
_COVERS_AUTH = [("forgectrl", "src/auth.*"), ("forgectrl", "src/peer.*"), ("forgectrl", "src/main.c"), _COVERS_AUTH = [("forgectrl", "src/auth.*"), ("forgectrl", "src/peer.*"), ("forgectrl", "src/main.c"),
("forgectrl", "src/session.*"), ("forgectrl", "src/users.*"), ("forgectrl", "src/session.*"), ("forgectrl", "src/users.*"), ("forgectrl", "src/button.*"),
("forgectrl", "src/ui/login.js"), ("forgectrl", "src/ui/wizard.js")] ("forgectrl", "src/ui/login.*"), ("forgectrl", "src/ui/wizard.js")]
def lan_ip(): def lan_ip():
@@ -213,695 +209,3 @@ def auth(ctx):
if st == 302: if st == 302:
ctx.check(loc == "/login?next=/setup%3Fstep%3Dlaser.focus", ctx.check(loc == "/login?next=/setup%3Fstep%3Dlaser.focus",
"the login redirect does not carry the path: %r", loc) "the login redirect does not carry the path: %r", loc)
@test("forgectrl.settings-bounds", title="Settings validation and restore", subsystem="forgectrl",
kind="auto", est_min=1,
covers=[("forgectrl", "src/settings.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/cam.c")],
description="An over-length value and an out-of-range value are refused (400) and leave the "
"settings byte-identical; an in-range value is accepted (200). The lid lamp "
"idles at lid_lamp_idle (unset = 236), an out-of-range level is refused, a new "
"level applies to the lamp at once, and clearing it returns the default.")
def settings_bounds(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
before = fc.settings()
ev["keys"] = len(before)
st, body = fc.post("/settings", data={"gf_serial": "X" * 300})
ev["overlong"] = st
ctx.log("POST /settings gf_serial=<300 chars> -> %s %s", st, body if isinstance(body, dict) else "")
ctx.check(st == 400, "over-length value -> %s, expected 400", st)
st, body = fc.post("/settings", data={"laser_disarm_s": "99999"})
ev["out_of_range"] = st
ctx.log("POST /settings laser_disarm_s=99999 -> %s", st)
ctx.check(st == 400, "out-of-range value -> %s, expected 400", st)
# The cloud download guard is bytes, so its range is far wider than the
# other numeric keys: check the far end is still a wall.
st, body = fc.post("/settings", data={"pulse_reject_threshold_bytes": "2000000000"})
ev["pulse_bytes_out_of_range"] = st
ctx.log("POST /settings pulse_reject_threshold_bytes=2000000000 -> %s", st)
ctx.check(st == 400, "out-of-range byte limit -> %s, expected 400", st)
st, body = fc.post("/settings", data={"no_such_key_forgetest": "1"})
ev["unknown_key"] = st
ctx.log("POST /settings no_such_key_forgetest=1 -> %s", st)
ctx.check(st in (400, 404), "unknown key -> %s, expected 400", st)
after = fc.settings()
ctx.check(json.dumps(after, sort_keys=True) == json.dumps(before, sort_keys=True),
"settings changed after refused writes")
ctx.log("settings unchanged after the refused writes")
# an accepted in-range write: rewrite a present key with its own value
key = None
for k in ("ui_units", "laser_disarm_s", "cool_flow_rise", "rail_settle_s"):
v = before.get(k)
if isinstance(v, str) and v != "":
key = k
break
if key is None:
key, val = "ui_units", "metric"
ctx.log("no settable key is present; writing %s=%s (recorded in evidence)", key, val)
else:
val = before[key]
st, body = fc.post("/settings", data={key: val})
ev["accepted"] = {"key": key, "value": val, "status": st}
ctx.log("POST /settings %s=%s -> %s", key, val, st)
ctx.check(st == 200, "in-range write -> %s, expected 200", st)
final = fc.settings()
others_before = {k: v for k, v in before.items() if k != key}
others_after = {k: v for k, v in final.items() if k != key}
ctx.check(others_before == others_after, "other settings changed by the write")
ctx.check(final.get(key) == val, "%s reads back %r, wrote %r", key, final.get(key), val)
# the lid lamp's idle level: resting at the setting, bounded, applied live
lamp_was = (before.get("lid_lamp_idle") or "").strip()
want = lamp_was or "236"
got = ctx.sysfs("pic/lid_led")
ev["lid_lamp"] = {"setting": lamp_was, "resting": got}
ctx.log("lid lamp: setting %r, pic/lid_led=%s (expected %s)", lamp_was, got, want)
ctx.check(got == want, "lid lamp rests at %s, lid_lamp_idle is %s", got, want)
for bad in ("256", "-1", "bright"):
st, body = fc.post("/settings", data={"lid_lamp_idle": bad})
ctx.check(st == 400, "lid_lamp_idle=%s -> %s, expected 400", bad, st)
ctx.log("lid_lamp_idle 256 / -1 / bright refused")
try_level = "100" if want != "100" else "120"
st, body = fc.post("/settings", data={"lid_lamp_idle": try_level})
ctx.check(st == 200, "lid_lamp_idle=%s -> %s, expected 200", try_level, st)
applied = None
t0 = time.time()
while time.time() - t0 < 5:
applied = ctx.sysfs("pic/lid_led")
if applied == try_level:
break
ctx.sleep(0.2)
ctx.log("lid_lamp_idle=%s -> pic/lid_led=%s after %.1f s", try_level, applied, time.time() - t0)
# an empty value clears the key: the query-string form carries it
st, body = (fc.post("/settings", params={"lid_lamp_idle": ""}) if not lamp_was
else fc.post("/settings", data={"lid_lamp_idle": lamp_was}))
ctx.check(st == 200, "restoring lid_lamp_idle=%r -> %s", lamp_was, st)
t0 = time.time()
back = None
while time.time() - t0 < 5:
back = ctx.sysfs("pic/lid_led")
if back == want:
break
ctx.sleep(0.2)
ev["lid_lamp"].update({"applied": applied, "restored": back})
ctx.check(applied == try_level, "lamp did not follow lid_lamp_idle=%s (reads %s)", try_level, applied)
ctx.check(back == want, "lamp did not return to %s after the restore (reads %s)", want, back)
ctx.log("lid lamp follows the setting live and returns to %s", want)
@test("forgectrl.panel-serves", title="Control panel and status endpoints", subsystem="forgectrl",
kind="auto", est_min=1,
covers=[("forgectrl", "src/ui.*"), ("forgectrl", "src/ui/**"), ("forgectrl", "src/status.*"),
("forgectrl", "src/cam.c"), ("forgectrl", "src/main.c"), ("forgectrl", "src/super.c"),
("grblhal-glowforge", "src/glowforge_status.c"), ("grblhal-glowforge", "src/serial.c"),
("forgectrl", "src/curverec.*")],
description="The panel page is served, 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 "
"own at the controller's start), and /cam/status "
"answers. In GRBL mode with a live controller, /status also echoes the "
"controller's published state file as the grbl block (fresh age, machine "
"state, sender session, laser window and dose model, modal report), "
"GET /grbl/settings serves the published $$ view, and the controller's "
"settings store is /data/forgefirm/EEPROM-glowforge.DAT with nothing of "
"it at the top of /data.")
def panel_serves(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
st, body = fc.get("/", raw=True)
ev["panel_status"] = st
ctx.log("GET / -> %s (%d bytes)", st, len(body) if body else 0)
ctx.check(st == 200, "GET / -> %s", st)
text = body.decode("utf-8", "replace")
ctx.check("<html" in text.lower() and "ForgeFIRM" in text, "the panel does not look like the panel")
ctx.check(fc.token and fc.token in text, "the panel does not embed the bearer token")
ctx.check("<link " not in text and "<script src=" not in text,
"the panel references an external asset (the build did not bundle src/ui/)")
# The daemon stores the page gzipped and inflates it once at first
# request; what it serves is the plain page with the theme attribute
# the head script sets and the one save bar every settings tab shares.
ctx.check("data-bs-theme" in text, "the panel lacks the theme attribute (inflate failed?)")
ctx.check('id="savebar"' in text, "the panel lacks the save bar")
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"):
ctx.check(key in s, "/status lacks %r", key)
ev["state"] = s.get("state")
ev["switches"] = s.get("switches")
ctx.log("/status state=%s switches=%s", s.get("state"), s.get("switches"))
for key in ("lid", "button", "interlock_ok", "head", "hv_enable"):
ctx.check(key in (s.get("switches") or {}), "/status switches lacks %r", key)
# SoC utilization rides /status next to the temperatures. The CPU
# number is a delta over the interval since the previous read, so
# the read above primes it; after a beat both percents must be
# numbers in range.
ctx.sleep(1)
sys_ = ctx.forgectrl.status().get("sys") or {}
ev["sys"] = sys_
ctx.log("/status sys=%s", sys_)
ctx.check(isinstance(sys_.get("cpu_pct"), (int, float)) and 0.0 <= sys_["cpu_pct"] <= 100.0,
"/status sys.cpu_pct is not a percent: %s", sys_)
ctx.check(isinstance(sys_.get("mem_pct"), (int, float)) and 0.0 < sys_["mem_pct"] < 100.0,
"/status sys.mem_pct is not a percent: %s", sys_)
# The lens takes its own reference on the hall edge at every
# controller start, so with a controller running Z is referenced on
# its own while X and Y wait for a home: homed_axes names the axes
# that carry one, and Z reads inside the lens reach rather than the
# zero an unreferenced axis would show.
axes = s.get("homed_axes")
ev["homed_axes"] = axes
ctx.check(isinstance(axes, int) and 0 <= axes <= 7,
"/status homed_axes is not an axis mask: %s", axes)
ctx.check(bool(s.get("homed")) == (axes == 7),
"/status homed (%s) disagrees with homed_axes (%s)",
s.get("homed"), axes)
st, mode0 = fc.get("/mode")
if isinstance(mode0, dict) and mode0.get("controller") == "running":
lens = s.get("lens") or {}
pos = s.get("pos") or {}
ctx.log("/status homed_axes=%s pos.z=%s reach=%s..%s", axes,
pos.get("z"), lens.get("reach_min"), lens.get("reach_max"))
ctx.check(axes is not None and axes & 4,
"Z is not referenced with a controller running: %s", axes)
ctx.check(isinstance(pos.get("z"), (int, float))
and lens.get("reach_min") is not None
and lens["reach_min"] <= pos["z"] <= lens["reach_max"],
"Z %s is outside the lens reach %s..%s", pos.get("z"),
lens.get("reach_min"), lens.get("reach_max"))
st, cam = fc.get("/cam/status")
ev["cam_status"] = st
ctx.log("GET /cam/status -> %s %s", st, cam)
ctx.check(st == 200 and isinstance(cam, dict) and "running" in cam, "GET /cam/status -> %s", st)
# The controller's published state, echoed only while a live GRBL
# controller runs (glowforge_status.c -> /run/forgefirm -> /status).
st, mode = fc.get("/mode")
if isinstance(mode, dict) and mode.get("mode") == "grbl" and mode.get("controller") == "running":
g = ctx.forgectrl.status().get("grbl") or {}
ev["grbl"] = g
ctx.log("/status grbl=%s", g)
ctx.check(isinstance(g.get("age_s"), (int, float)) and g["age_s"] < 30,
"/status grbl block missing or stale: %s", g)
rep = g.get("report") or {}
for key in ("state", "sender", "laser", "modals"):
ctx.check(key in rep, "/status grbl.report lacks %r", key)
ctx.check((rep.get("laser") or {}).get("model") in ("density", "analog"),
"grbl.report.laser carries no model: %s", rep.get("laser"))
ctx.check((rep.get("laser") or {}).get("curve"),
"grbl.report.laser carries no dose curve: %s", rep.get("laser"))
st, text = fc.get("/grbl/settings", raw=True)
ev["grbl_settings_status"] = st
ctx.check(st == 200 and b"$35=" in (text or b""),
"GET /grbl/settings -> %s without the $$ view", st)
# The $-settings persist in the data directory, never loose in /data.
nvs = "/data/forgefirm/EEPROM-glowforge.DAT"
ev["grbl_nvs"] = os.path.isfile(nvs)
ctx.check(os.path.isfile(nvs), "the controller's settings store is not at %s", nvs)
ctx.check(not os.path.exists("/data/EEPROM-glowforge.DAT"),
"a settings store remains at the top of /data")
else:
ctx.log("no live GRBL controller (%s); grbl block checks skipped", mode)
# The dose-curve recorder's read surface answers in any mode.
st, cs = fc.get("/curve/status")
ev["curve_status"] = cs
ctx.check(st == 200 and isinstance(cs, dict) and cs.get("state") in
("idle", "waiting", "recording", "done", "failed"),
"GET /curve/status -> %s %s", st, cs)
st, text = fc.get("/curve/ladder.gcode", raw=True)
ctx.check(st == 200 and b"S1000" in (text or b"") and b"M5" in (text or b""),
"GET /curve/ladder.gcode -> %s without the ladder", st)
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")
+7 -80
View File
@@ -3,13 +3,12 @@
# https://community.openglow.org # https://community.openglow.org
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
"""A camera home's offset pair, on the machine. """The helpers for a test that lets the web service move the head.
Its own module, so that no other test's fingerprint moves. The homing is The homing is the web-service session cloud.mode-switch runs. These judge
the web-service session cloud.mode-switch runs. The helpers for a test that its motions ran whole, and take the head back to where the test found
that lets the service move the head live here too: the motions ran whole, it by the travel the service's motions logged. No counter reading can say
and the head goes back to where the test found it, by the travel the where that was: every
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 service motion zeroes the step counters at its start, and a home and every
controller start zero them again. controller start zero them again.
""" """
@@ -18,13 +17,11 @@ import re
import sys import sys
from ..baseline import XY_STEPS_PER_MM, counter_frame, counter_steps_per_mm, read_position from ..baseline import XY_STEPS_PER_MM, counter_frame, counter_steps_per_mm, read_position
from ..catalog import test
from ..runner import Failed from ..runner import Failed
from . import cloud as logs # the log paths, read at each call 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 .cloud import log_lines_since, log_size
from .motion import _drop_reference, clean_slate, machine_idle, wait_idle, wait_state from .motion import _drop_reference, clean_slate, machine_idle, wait_idle
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 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 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 # the motion zeroed at its start (the "(actual/expected)" line of the same
@@ -169,73 +166,3 @@ def _jog_back(ctx, rec, travel, since):
_drop_reference(ctx, ctx.forgectrl) _drop_reference(ctx, ctx.forgectrl)
ctx.counters_rezeroed() ctx.counters_rezeroed()
ctx.log("head returned to where the test found it (%.3f/%.3f mm)", -dx, -dy) 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)
+76 -152
View File
@@ -4,11 +4,9 @@
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
"""image.* - the post-flash health of the running image (always-required).""" """image.* - the post-flash health of the running image (always-required)."""
import glob
import gzip import gzip
import mmap import mmap
import os import os
import shutil
import struct import struct
import re import re
import stat import stat
@@ -112,6 +110,7 @@ def fds_of(pid):
@test("image.health", title="Post-flash image health", subsystem="image", kind="auto", @test("image.health", title="Post-flash image health", subsystem="image", kind="auto",
always=True, est_min=1, always=True, est_min=1,
covers=[("forgectrl", "init/**"), ("forgeext", "init/**"), ("forgectrl", "src/main.c"), ("forgectrl", "src/auth.c"), covers=[("forgectrl", "init/**"), ("forgeext", "init/**"), ("forgectrl", "src/main.c"), ("forgectrl", "src/auth.c"),
("forgectrl", "src/status.*"),
("forgectrl", "CMakeLists.txt"), ("grblhal-glowforge", "src/boards/**"), ("forgectrl", "CMakeLists.txt"), ("grblhal-glowforge", "src/boards/**"),
("grblhal-glowforge", "CMakeLists.txt"), ("kernel-module-glowforge", "**"), ("grblhal-glowforge", "CMakeLists.txt"), ("kernel-module-glowforge", "**"),
("linux-fslc", "**")], ("linux-fslc", "**")],
@@ -119,12 +118,20 @@ def fds_of(pid):
"version stamped in /etc/forgefirm-version and under the machine mark in the " "version stamped in /etc/forgefirm-version and under the machine mark in the "
"console banner and the motd, " "console banner and the motd, "
"the kernel options, the module, the pulse ring it maps and the SDMA clocks it holds, " "the kernel options, the module, the pulse ring it maps and the SDMA clocks it holds, "
"the daemon ownership, " "the daemon ownership, the board temperatures /status reports (the chassis and "
"the init ordering, the extension host as one process that starts after forgectrl and " "the SoC die in a plausible range, the supply as a 10-bit count, the CPU "
"stops before it on a machine with no package installed and nothing running under a " "unthrottled), "
"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 " "the extension host as one process on a machine with no package installed and "
"nothing running under a pool account, no userspace watchdog running, "
"the data directory open for search on a machine with an account, the file modes the release depends on, and the mounts: the "
"rootfs read-only, /data writable, the account files and the banner rendered " "rootfs read-only, /data writable, the account files and the banner rendered "
"into tmpfs, the sshd host keys on /data, the factory slots on the dev image only.") "into tmpfs, the sshd host keys on /data, the dev image's factory slots read-only. "
"And the boot does not wait on the network: ifup has returned for wlan0 (it is "
"in the state file and no ifup is running) and the console login is up; each "
"DHCP client runs with -b and has left ifup (its parent is pid 1); no DHCPv6 "
"client is running; and IPv6 is the "
"kernel's own: enabled on wlan0, router advertisements accepted, a link-local "
"address up (a global address is logged, not checked).")
def image_health(ctx): def image_health(ctx):
ev = ctx.evidence ev = ctx.evidence
manifest = ctx.runner.manifest manifest = ctx.runner.manifest
@@ -260,17 +267,23 @@ def image_health(ctx):
"controller is %r (expected running or standby)", mode.get("controller")) "controller is %r (expected running or standby)", mode.get("controller"))
ctx.check(mode.get("motion") != "fault", "supervisor reports a motion fault") ctx.check(mode.get("motion") != "fault", "supervisor reports a motion fault")
# 5. init ordering: controllers stop before the daemon # 4b. the board temperatures /status carries, watched and not gated:
k = sorted(os.path.basename(p) for p in glob.glob("/etc/rc6.d/K*")) # the chassis and the SoC die in degrees, the supply as its raw count,
ev["rc6_kill"] = k # and the CPU unthrottled at idle.
kg = [x for x in k if "grblhal" in x] temps = (ctx.forgectrl.status() or {}).get("temps") or {}
kf = [x for x in k if x.endswith("forgectrl")] ev["temps"] = temps
ctx.log("rc6.d: %s", " ".join(k)) ctx.log("temps: %s", temps)
ctx.check(kg and kf, "rc6.d lacks the grblhal/forgectrl kill links") ctx.check(isinstance(temps.get("chassis_c"), (int, float)) and 5.0 <= temps["chassis_c"] <= 80.0,
ctx.check(kg[0] < kf[0], "controller kill link %s must sort before forgectrl's %s", kg[0], kf[0]) "/status temps.chassis_c is not a plausible chassis temperature: %s", temps)
ctx.check(isinstance(temps.get("soc_c"), (int, float)) and 10.0 <= temps["soc_c"] <= 100.0,
"/status temps.soc_c is not a plausible die temperature: %s", temps)
ctx.check(isinstance(temps.get("supply_raw"), int) and 0 <= temps["supply_raw"] <= 1023,
"/status temps.supply_raw is not a 10-bit count: %s", temps)
ctx.check(temps.get("soc_throttle") == 0,
"the CPU is throttled (or its cooling device is missing) at idle: %s", temps)
# 5b. the extension host: one process, up after forgectrl and down # 5. the extension host: one process, and a campaign's machine is
# before it, and a campaign's machine is extension-free # extension-free (its init order is the rootfs lint's)
host = [] host = []
for pid in hw.pidof("forgeext"): for pid in hw.pidof("forgeext"):
argv = _read("/proc/%d/cmdline" % pid, "").split("\0") argv = _read("/proc/%d/cmdline" % pid, "").split("\0")
@@ -278,13 +291,6 @@ def image_health(ctx):
host.append(pid) host.append(pid)
ev["extension_host_pids"] = host ev["extension_host_pids"] = host
ctx.check(len(host) == 1, "the extension host is not one running process: %s", 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["extension_packages"] = hw.ext_packages()
ev["pool_processes"] = len(hw.pool_pids()) 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.log("extension host pid %s, packages %s, pool processes %d", host, ev["extension_packages"], ev["pool_processes"])
@@ -292,12 +298,9 @@ def image_health(ctx):
ev["extension_packages"]) ev["extension_packages"])
ctx.check(not ev["pool_processes"], "%d process(es) run under extension pool accounts", ev["pool_processes"]) ctx.check(not ev["pool_processes"], "%d process(es) run under extension pool accounts", ev["pool_processes"])
# 6. logging lever present, no userspace watchdog daemon # 6. no userspace watchdog daemon running (the logging init and the
logging = [n for n in ("forgefirm-logging", "forgefirm-logrotate") if os.path.exists("/etc/init.d/" + n)] # absent watchdog init script are the rootfs lint's); kernel threads
ev["logging_init"] = logging # (the imx2_wdt kthread is expected) have an empty cmdline
ctx.check(logging, "no ForgeFIRM logging/logrotate init script")
ctx.check(not os.path.exists("/etc/init.d/watchdog"), "a userspace watchdog init script is present")
# kernel threads (the imx2_wdt kthread is expected) have an empty cmdline
user_wd = [p for p in hw.pidof("watchdog") if _read("/proc/%d/cmdline" % p, "")] user_wd = [p for p in hw.pidof("watchdog") if _read("/proc/%d/cmdline" % p, "")]
ev["userspace_watchdog_pids"] = user_wd ev["userspace_watchdog_pids"] = user_wd
ctx.check(not user_wd, "a userspace watchdog daemon is running: %s", user_wd) ctx.check(not user_wd, "a userspace watchdog daemon is running: %s", user_wd)
@@ -327,8 +330,9 @@ def image_health(ctx):
# 8. the mounts: the rootfs read-only, /data the writable partition, # 8. the mounts: the rootfs read-only, /data the writable partition,
# the state a read-only rootfs hands off (the read-only-rootfs image # the state a read-only rootfs hands off (the read-only-rootfs image
# feature, forgefirm-users, forgefirm-hostname, forgefirm-banner, the # feature, forgefirm-users, forgefirm-hostname, forgefirm-banner, the
# sshd host keys). The # sshd host keys). The dev image's factory slots under /factory are
# dev image alone mounts the factory slots under /factory. # read-only; rcS's ROOTFS_READ_ONLY and the release fstab with no
# factory slot are the rootfs lint's.
mounts = {} mounts = {}
for line in (_read("/proc/mounts", "") or "").splitlines(): for line in (_read("/proc/mounts", "") or "").splitlines():
parts = line.split() parts = line.split()
@@ -346,8 +350,6 @@ def image_health(ctx):
ctx.check("rw" in mount_opts("/data"), "/data is not mounted read-write: %s", mounts.get("/data")) ctx.check("rw" in mount_opts("/data"), "/data is not mounted read-write: %s", mounts.get("/data"))
ctx.check("rw" in mount_opts("/var/lib"), ctx.check("rw" in mount_opts("/var/lib"),
"/var/lib is not a writable copy (read-only-rootfs-hook.sh): %s", mounts.get("/var/lib")) "/var/lib is not a writable copy (read-only-rootfs-hook.sh): %s", mounts.get("/var/lib"))
rcs = _read("/etc/default/rcS", "") or ""
ctx.check("ROOTFS_READ_ONLY=yes" in rcs.splitlines(), "/etc/default/rcS lacks ROOTFS_READ_ONLY=yes")
factory = sorted(p for p in mounts if p.startswith("/factory/")) factory = sorted(p for p in mounts if p.startswith("/factory/"))
ev["factory_mounts"] = factory ev["factory_mounts"] = factory
dev_image = os.path.exists("/etc/forgefirm-dev") dev_image = os.path.exists("/etc/forgefirm-dev")
@@ -358,8 +360,6 @@ def image_health(ctx):
if os.path.exists("/dev/mmcblk2p%d" % n): if os.path.exists("/dev/mmcblk2p%d" % n):
p = "/factory/img%d" % n p = "/factory/img%d" % n
ctx.check("ro" in mount_opts(p), "%s is not mounted read-only on the dev image: %s", p, mounts.get(p)) ctx.check("ro" in mount_opts(p), "%s is not mounted read-only on the dev image: %s", p, mounts.get(p))
else:
ctx.check(not factory, "a release image mounts the factory slots: %s", factory)
if os.path.isfile("/data/forgefirm/users"): if os.path.isfile("/data/forgefirm/users"):
names = [l.split(":")[0] for l in (_read("/data/forgefirm/users", "") or "").splitlines() names = [l.split(":")[0] for l in (_read("/data/forgefirm/users", "") or "").splitlines()
if l.strip() and not l.startswith("#")] if l.strip() and not l.startswith("#")]
@@ -381,59 +381,48 @@ def image_health(ctx):
ctx.check("linux-fslc" in manifest.components, "manifest lacks the kernel entry") ctx.check("linux-fslc" in manifest.components, "manifest lacks the kernel entry")
ctx.log("manifest %s: %d components", manifest.content_sha[:12], len(manifest.components)) ctx.log("manifest %s: %d components", manifest.content_sha[:12], len(manifest.components))
# 10. the network boot. init starts the rest of the boot, the console
# login last, only after the networking script returns, so a client
# that waits in ifup's foreground on a server's answer holds the
# whole machine. The interfaces file starts no DHCPv6 client
# (test_image.py reads the layer's file); the bounded DHCP client
# (-b, which leaves ifup after three unanswered discovers) is checked
# here, on the client that runs.
procs = _procs()
up = ifstate_names(_read(IFSTATE, ""))
ev["ifstate"] = sorted(up)
ctx.check("wlan0" in up, "ifup has not finished wlan0: %s holds %s", IFSTATE, sorted(up))
stuck = [p for p in procs if p[2] in ("ifup", "ifdown")]
ev["ifup_running"] = [p[3] for p in stuck]
ctx.check(not stuck, "ifup is still running: %s", [p[3] for p in stuck])
logins = [p[3] for p in procs if p[2] == "getty"]
ev["getty"] = logins
ctx.check(any("ttymxc0" in c for c in logins), "no console login is running: %s", logins)
v4 = [p for p in procs if p[2] == "udhcpc"]
ev["udhcpc"] = [p[3] for p in v4]
ctx.check(v4, "no DHCP client is running")
for pid, ppid, _comm, cmd in v4:
ctx.check("-b" in cmd.split(), "the DHCP client runs without -b: %s", cmd)
ctx.check(ppid == 1, "the DHCP client (pid %d) is still a child of pid %d", pid, ppid)
# no DHCPv6 client running (none on the image is the rootfs lint's)
alive = [p[3] for p in procs if p[2] in DHCP6_CLIENTS]
ev["dhcp6_running"] = alive
ctx.check(not alive, "a DHCPv6 client is running: %s", alive)
# IPv6 is the kernel's own. A global address is evidence only: a
# network whose router advertisement offers no SLAAC prefix gives none.
conf = "/proc/sys/net/ipv6/conf/wlan0/"
ev["disable_ipv6"] = (_read(conf + "disable_ipv6", "") or "").strip()
ev["accept_ra"] = (_read(conf + "accept_ra", "") or "").strip()
ctx.check(ev["disable_ipv6"] == "0", "IPv6 is off on wlan0 (disable_ipv6=%r)", ev["disable_ipv6"])
ctx.check(ev["accept_ra"] not in ("", "0"),
"wlan0 does not accept router advertisements (accept_ra=%r)", ev["accept_ra"])
local, world = link_addrs(_read("/proc/net/if_inet6", ""), "wlan0")
ev["link_local"] = local
ev["global"] = world
ctx.check(local, "wlan0 has no link-local IPv6 address")
ctx.log("wlan0: %d link-local, %d global IPv6 address(es)", len(local), len(world))
@test("image.license-bundle", title="The license texts travel with the image",
subsystem="image", kind="auto", est_min=1,
covers=[("forgectrl", "src/main.c"), ("forgectrl", "src/ui/index.html"),
("forgectrl", "src/ui/wizard.html"), ("forgectrl", "src/ui/login.html")],
requires=["forgectrl.panel-serves"],
description="The image carries /usr/share/forgefirm/licenses.tar.gz, a gzip tar with "
"the license manifest (every installed package with its license) and the "
"license texts, and no loose common-licenses tree; the panel serves the "
"bundle, the manifest as text, and the Licenses page every footer links, "
"each without a login.")
def license_bundle(ctx):
import io
import tarfile
fc = ctx.forgectrl
ev = ctx.evidence
path = "/usr/share/forgefirm/licenses.tar.gz"
ctx.check(os.path.isfile(path), "%s is missing", path)
ctx.check(not os.path.isdir("/usr/share/common-licenses"),
"the loose common-licenses tree is still on the rootfs")
size = os.path.getsize(path)
ev["bundle_bytes"] = size
ctx.log("%s: %d bytes", path, size)
with tarfile.open(path, "r:gz") as t:
names = t.getnames()
ctx.check("common-licenses/license.manifest" in names, "no license.manifest in the bundle")
manifest = t.extractfile("common-licenses/license.manifest").read().decode("utf-8", "replace")
packages = manifest.count("PACKAGE NAME: ")
texts = sum(1 for n in names if "/generic_" in n and not n.endswith("/"))
ev["packages"] = packages
ev["generic_texts"] = texts
ctx.log("manifest lists %d packages; %d generic license entries", packages, texts)
ctx.check(packages >= 40, "only %d packages in the manifest", packages)
ctx.check(texts >= 5, "only %d generic license entries", texts)
for name in ("forgectrl", "grblhal-glowforge", "kernel-module-glowforge"):
ctx.check("RECIPE NAME: %s\n" % name in manifest, "%s is not in the manifest", name)
st, body = fc.get("/system/licenses/manifest", raw=True)
ctx.check(st == 200, "GET /system/licenses/manifest -> %s", st)
ctx.check(isinstance(body, (bytes, bytearray)) and b"PACKAGE NAME: " in body,
"the served manifest is not the manifest")
st, body = fc.get("/system/licenses", raw=True)
ctx.check(st == 200, "GET /system/licenses -> %s", st)
ctx.check(isinstance(body, (bytes, bytearray)) and len(body) == size,
"the served bundle is %s bytes, the file %d", len(body) if body else None, size)
with tarfile.open(fileobj=io.BytesIO(bytes(body)), mode="r:gz") as t:
ctx.check("common-licenses/license.manifest" in t.getnames(),
"the served bundle holds no manifest")
st, body = fc.get("/licenses", raw=True)
ctx.check(st == 200, "GET /licenses -> %s", st)
text = bytes(body).decode("utf-8", "replace") if body else ""
ctx.check("/system/licenses" in text and "PACKAGE NAME: " in text,
"the Licenses page lacks the download link or the manifest")
# ------------------------------------------------------- the network boot # ------------------------------------------------------- the network boot
@@ -506,68 +495,3 @@ def link_addrs(text, iface):
return local, world return local, world
@test("image.network-boot", title="The boot does not wait on the network",
subsystem="image", kind="auto", est_min=1,
description="The network comes up without the boot waiting on a server. init starts the "
"rest of the boot, the console login last, only after the networking script "
"returns, so a client that waits in ifup's foreground on a server's answer "
"holds the whole machine: no login, no SSH, no control panel. ifup has "
"returned for wlan0 (it is in the state file and no ifup is running) and the "
"console login is up; the DHCP client is the bounded one (-b, which leaves "
"ifup after three unanswered discovers) and has left ifup; no DHCPv6 client "
"is named in the interfaces file, running, or on the image; and IPv6 is the "
"kernel's own: enabled on wlan0, router advertisements accepted, a link-local "
"address up. A global address is evidence only, because a network whose "
"router advertisement offers no SLAAC prefix gives none. What a hostile "
"server does to a client is a bench drill, not a test. No component covers "
"this: the interfaces file is layer content, in the platform identity of "
"every fingerprint.")
def network_boot(ctx):
ev = ctx.evidence
procs = _procs()
# 1. ifup came back, and the boot went on to the console login
state = ifstate_names(_read(IFSTATE, ""))
ev["ifstate"] = sorted(state)
ctx.check("wlan0" in state, "ifup has not finished wlan0: %s holds %s", IFSTATE, sorted(state))
stuck = [p for p in procs if p[2] in ("ifup", "ifdown")]
ev["ifup_running"] = [p[3] for p in stuck]
ctx.check(not stuck, "ifup is still running: %s", [p[3] for p in stuck])
logins = [p[3] for p in procs if p[2] == "getty"]
ev["getty"] = logins
ctx.check(any("ttymxc0" in c for c in logins), "no console login is running: %s", logins)
# 2. the DHCP client is the bounded one, and it has left ifup
v4 = [p for p in procs if p[2] == "udhcpc"]
ev["udhcpc"] = [p[3] for p in v4]
ctx.check(v4, "no DHCP client is running")
for pid, ppid, _comm, cmd in v4:
ctx.check("-b" in cmd.split(), "the DHCP client runs without -b: %s", cmd)
ctx.check(ppid == 1, "the DHCP client (pid %d) is still a child of pid %d", pid, ppid)
# 3. no DHCPv6 client: not started, not running, not on the image
text = _read(INTERFACES)
ctx.check(text is not None, "%s is missing", INTERFACES)
named = dhcp6_in_interfaces(text)
ev["interfaces_dhcp6"] = named
ctx.check(not named, "%s starts a DHCPv6 client: %s", INTERFACES, named)
alive = [p[3] for p in procs if p[2] in DHCP6_CLIENTS]
ev["dhcp6_running"] = alive
ctx.check(not alive, "a DHCPv6 client is running: %s", alive)
for name in DHCP6_CLIENTS:
ctx.check(shutil.which(name) is None, "%s is on the image", name)
ctx.check(not os.path.exists("/usr/share/udhcpc/default6.script"),
"the DHCPv6 hook script is on the image")
# 4. IPv6 is the kernel's own
conf = "/proc/sys/net/ipv6/conf/wlan0/"
ev["disable_ipv6"] = (_read(conf + "disable_ipv6", "") or "").strip()
ev["accept_ra"] = (_read(conf + "accept_ra", "") or "").strip()
ctx.check(ev["disable_ipv6"] == "0", "IPv6 is off on wlan0 (disable_ipv6=%r)", ev["disable_ipv6"])
ctx.check(ev["accept_ra"] not in ("", "0"),
"wlan0 does not accept router advertisements (accept_ra=%r)", ev["accept_ra"])
local, world = link_addrs(_read("/proc/net/if_inet6", ""), "wlan0")
ev["link_local"] = local
ev["global"] = world
ctx.check(local, "wlan0 has no link-local IPv6 address")
ctx.log("wlan0: %d link-local, %d global IPv6 address(es)", len(local), len(world))
File diff suppressed because it is too large Load Diff
-379
View File
@@ -1,379 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""logs.* - unified logging: the log tree, tail, and the sanitized export;
the routing path (emitters, relays, rendered rules); the level settings."""
import gzip
import io
import os
import re
import tarfile
import time
from ..catalog import test
from .. import hw
def _read(path, default=None):
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
return f.read()
except OSError:
return default
_LOG_COVERS = [("forgectrl", "src/logs.*"), ("forgectrl", "src/fflog.*"), ("forgectrl", "src/sanitize.*"),
("forgectrl", "src/extpkg.*"), ("forgectrl", "src/main.c")]
LOGS_ROOT = "/data/log/forgefirm"
# What the export test plants in the installer's directory: a line to find
# again, and two addresses (documentation ranges) the sanitizer must take.
_PROBE_MARK = "forgetest export probe"
_PROBE_MAC = "02:00:5e:10:20:30"
_PROBE_IP = "192.0.2.77"
@test("logs.tree-tail-export", title="Log tree, tail, and sanitized export", subsystem="logs",
kind="auto", est_min=1,
covers=_LOG_COVERS, requires=["forgectrl.auth"],
description="/logs lists the loggers with their levels and files, /logs/tail returns the "
"forgectrl logger's tail, and POST /logs/export streams a sanitized tar.gz "
"bundle that contains neither the panel token nor the camera key. The bundle "
"also carries the installer's directory of the tree (logs/install/), which no "
"logger feeds: a probe file planted there for the export comes back in the "
"bundle with its addresses redacted, and an install log that the installer "
"left is in the bundle too. system/extensions.json is in it as well: what is "
"installed that is not the firmware, with each package's tier, whether it is "
"enabled, and what the operator granted it, which is what a report about a "
"machine running extensions has to be read against.")
def tree_tail_export(ctx):
install_dir = os.path.join(LOGS_ROOT, "install")
probe = os.path.join(install_dir, "forgetest-probe.txt")
made_dir = not os.path.isdir(install_dir)
try:
os.makedirs(install_dir, exist_ok=True)
with open(probe, "w") as f:
f.write("%s from %s at %s\n" % (_PROBE_MARK, _PROBE_MAC, _PROBE_IP))
_tree_tail_export(ctx, probe)
finally:
try:
os.unlink(probe)
except OSError:
pass
if made_dir:
try:
os.rmdir(install_dir)
except OSError:
pass
def _tree_tail_export(ctx, probe):
fc = ctx.forgectrl
ev = ctx.evidence
st, body = fc.get("/logs")
ctx.log("GET /logs -> %s", st)
ctx.check(st == 200 and isinstance(body, dict), "GET /logs -> %s", st)
loggers = body.get("loggers") or []
names = [l.get("name") for l in loggers] if isinstance(loggers, list) else list(loggers)
ev["loggers"] = names
ctx.log("loggers: %s", names)
ctx.check("forgectrl" in names, "/logs lacks the forgectrl logger: %s", names)
st, tail = fc.get("/logs/tail", params={"name": "forgectrl", "lines": "20"})
ctx.log("GET /logs/tail?name=forgectrl -> %s", st)
ctx.check(st == 200, "GET /logs/tail -> %s %s", st, tail if isinstance(tail, dict) else "")
st, tail = fc.get("/logs/tail", params={"name": "no-such-logger"})
ctx.check(st == 404, "unknown logger -> %s, expected 404", st)
# the sanitizer walks every log file; a full tree takes tens of
# seconds on the target, so this call gets its own client timeout
slow = hw.Forgectrl(token=fc.token, timeout=300.0)
t0 = time.time()
st, data = slow.post("/logs/export", raw=True)
ev["export_s"] = round(time.time() - t0, 1)
ctx.log("POST /logs/export -> %s (%d bytes, %.1f s)", st, len(data) if data else 0, ev["export_s"])
ctx.check(st == 200, "POST /logs/export -> %s", st)
ev["export_bytes"] = len(data)
try:
raw = gzip.decompress(data)
tf = tarfile.open(fileobj=io.BytesIO(raw))
members = tf.getnames()
except (OSError, tarfile.TarError, EOFError) as e:
ctx.fail("export is not a readable tar.gz: %s", e)
ev["members"] = len(members)
ctx.log("bundle: %d members, e.g. %s", len(members), members[:5])
ctx.check(members, "empty bundle")
ctx.check(any(m.endswith("README.txt") for m in members), "sanitized bundle lacks README.txt")
# the two secrets the sanitizer knows by value and a bundle could
# carry: the panel token and the camera key (a URL parameter that
# lands in sender logs)
secrets = {}
if fc.token:
secrets["panel token"] = fc.token
st, body = fc.get("/system/camera-key")
if st == 200 and isinstance(body, dict) and body.get("key"):
secrets["camera key"] = body["key"]
ctx.check("camera key" in secrets, "GET /system/camera-key -> %s: no key to check for", st)
contents = [(m.name, tf.extractfile(m).read()) for m in tf.getmembers() if m.isfile()]
for what, value in secrets.items():
leaked = [name for name, content in contents if value.encode() in content]
ev[what.replace(" ", "_") + "_leaks"] = leaked
ctx.check(not leaked, "the sanitized bundle contains the %s: %s", what, leaked)
# the installer's directory: no logger feeds it, the export carries it
by_name = dict(contents)
want = "logs/install/" + os.path.basename(probe)
got = [name for name in by_name if name.endswith(want)]
ev["install_members"] = sorted(name for name in by_name if "/logs/install/" in name)
ctx.check(got, "the bundle lacks %s: %s", want, ev["install_members"])
if got:
text = by_name[got[0]].decode("utf-8", "replace")
ctx.check(_PROBE_MARK in text, "the probe file came back without its line: %r", text)
for what, value in (("MAC address", _PROBE_MAC), ("IPv4 address", _PROBE_IP)):
ctx.check(value not in text, "the install directory left the sanitizer with its %s", what)
if os.path.isfile(os.path.join(os.path.dirname(probe), "install.log")):
ctx.check(any(name.endswith("logs/install/install.log") for name in by_name),
"the machine has an install log and the bundle does not")
# What is installed that is not the firmware. A report that starts "it
# stopped working" reads differently with packages on the machine, and
# the bundle is where that is read.
import json
ext = [name for name in by_name if name.endswith("system/extensions.json")]
ctx.check(ext, "the bundle carries no system/extensions.json")
if ext:
doc = json.loads(by_name[ext[0]].decode("utf-8", "replace"))
ev["extensions"] = {"enabled": doc.get("enabled"), "safe_mode": doc.get("safe_mode"),
"packages": [{k: p.get(k) for k in ("id", "version", "tier", "enabled", "grants")}
for p in doc.get("packages", [])]}
ctx.log("the bundle's extension listing: %s", ev["extensions"])
ctx.check(set(doc) >= {"enabled", "safe_mode", "host", "packages"},
"the listing is not the status document: %s", sorted(doc))
for p in doc.get("packages", []):
ctx.check(set(p) >= {"id", "version", "tier", "enabled", "grants"},
"a listed package does not say what it is: %s", sorted(p))
# The routing test proves the whole path every logger takes: emitter (or
# relay) -> /dev/log -> rsyslog rules rendered from the settings -> the
# logger's own file in the ff_line format. It stands for the emitters in
# every component (fflog in forgectrl and grblHAL, the SysLogHandler in the
# Python apps), the supervisor's per-controller relay, the daemon's fifo
# relay in its init script, and the render step.
_ROUTING_COVERS = [("forgectrl", "src/logs.*"), ("forgectrl", "src/fflog.*"), ("forgectrl", "src/super.c"),
("forgectrl", "init/**"),
("grblhal-glowforge", "src/fflog.*"), ("grblhal-glowforge", "src/main.c"),
("forgefirm-app", "forgefirm-app/ffmachine.py"), ("forgefirm-app", "forgefirm-app/gfcloud.py"),
("forgefirm-app", "forgefirm-app/gfhome.py")]
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}
def _tail_lines(path, n=400):
try:
with open(path, "rb") as f:
f.seek(0, 2)
size = f.tell()
f.seek(max(0, size - 256 * 1024))
data = f.read()
except OSError:
return []
return data.decode("utf-8", "replace").splitlines()[-n:]
def _wait_line(path, needle, timeout=6.0):
"""Poll a log file for a line containing needle; the matched line or None."""
deadline = time.time() + timeout
while time.time() < deadline:
for line in _tail_lines(path, 200):
if needle in line:
return line
time.sleep(0.25)
return None
@test("logs.routing", title="Log routing, relays, and the rendered rules", subsystem="logs",
kind="auto", est_min=1,
covers=_ROUTING_COVERS, requires=["logs.tree-tail-export"],
description="rsyslog is the only logger (no busybox syslogd/klogd), the rules rendered from "
"the settings and the effective-levels record exist and agree with /logs, every "
"logger has its directory, the daemon's own emitter and the per-controller "
"`logger` relay both land in the right file in the ff_line format, a stray "
"program's line goes to system/ and nowhere else, kernel lines reach kernel/, "
"and nothing of ours is written outside the tree.")
def routing(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
# 1. one logger daemon
rs = hw.pidof("rsyslogd")
ev["rsyslogd_pids"] = rs
ctx.check(rs, "rsyslogd is not running")
for legacy in ("syslogd", "klogd"):
pids = hw.pidof(legacy)
ctx.check(not pids, "busybox %s is running (%s) next to rsyslog", legacy, pids)
ctx.log("rsyslogd %s; no syslogd/klogd", rs)
# 2. the render step: rules + effective levels, consistent with what
# the API reports as effective
ctx.check(os.path.isfile("/data/forgefirm/rsyslog-forgefirm.conf"),
"rendered rules missing: /data/forgefirm/rsyslog-forgefirm.conf")
rules = _read("/data/forgefirm/rsyslog-forgefirm.conf") or ""
for name in LOGGERS:
ctx.check(("# %s:" % name) in rules, "rendered rules carry no block for %s", name)
ctx.check(os.path.isfile("/var/run/forgefirm-loglevels"), "effective-levels record missing")
st, body = fc.get("/logs")
ctx.check(st == 200 and isinstance(body, dict), "GET /logs -> %s", st)
ctx.check(body.get("effective_known") is True, "/logs does not know the effective levels")
eff = {}
for l in body.get("loggers") or []:
eff[l["name"]] = (l.get("effective_disk"), l.get("effective_remote"))
ev["effective"] = eff
ctx.log("effective levels: %s", eff)
ctx.check(set(eff) == set(LOGGERS), "loggers reported: %s", sorted(eff))
levels = _read("/var/run/forgefirm-loglevels") or ""
for name, (disk, _remote) in eff.items():
ctx.check(("log_%s_disk=%s" % (name, disk)) in levels,
"effective record and /logs disagree on %s (%s)", name, disk)
# 3. the tree
for name in LOGGERS:
ctx.check(os.path.isdir(os.path.join(LOGS_ROOT, name)), "no directory for %s", name)
# 4. the daemon's own emitter (fflog): a line from the running pid, if
# its level admits info
pids = hw.pidof("forgectrl")
ctx.check(pids, "forgectrl is not running")
disk_rank = _SEV_RANK.get(eff.get("forgectrl", ("info", "off"))[0], 6)
fpath = os.path.join(LOGS_ROOT, "forgectrl", "forgectrl.log")
if disk_rank >= 6:
own = [l for l in _tail_lines(fpath, 2000)
if any((" forgectrl[%d] " % p) in l for p in pids)]
ev["own_lines"] = len(own)
ctx.log("forgectrl[%s] lines in its file: %d", pids, len(own))
ctx.check(own, "no line from the running daemon in %s (emitter path broken?)", fpath)
m = _LINE_RE.match(own[-1])
ctx.check(m and m.group("prog") == "forgectrl", "not in the ff_line format: %r", own[-1])
else:
ctx.log("forgectrl disk level %s: own-emitter check skipped", eff["forgectrl"][0])
# 5. the relay path: a `logger` probe under a controller's name lands
# in that controller's file with the ff_line format
nonce = "forgetest-routing-%d" % int(time.time())
for name in ("grblhal", "gfhome"):
rank = _SEV_RANK.get(eff.get(name, ("info", "off"))[0], 6)
if rank < 3:
ctx.log("%s disk level off: relay probe skipped", name)
continue
rc, out = hw.run(["logger", "-t", name, "-p", "daemon.err", "%s %s" % (nonce, name)])
ctx.check(rc == 0, "logger -t %s failed: %s", name, out)
path = os.path.join(LOGS_ROOT, name, name + ".log")
line = _wait_line(path, nonce)
ev["probe_" + name] = line
ctx.check(line, "%s probe did not reach %s", name, path)
m = _LINE_RE.match(line)
ctx.check(m and m.group("prog") == name and m.group("sev") == "ERR",
"%s probe line not in the ff_line format: %r", name, line)
ctx.log("%s: %s", name, line)
# a probe under another name must NOT leak into these files
rc, out = hw.run(["logger", "-t", "forgetest-stray", "-p", "daemon.err", "%s stray" % nonce])
time.sleep(1.5)
for name in ("grblhal", "gfhome", "forgectrl", "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)
sys_rank = _SEV_RANK.get(eff.get("system", ("info", "off"))[0], 6)
if sys_rank >= 3:
line = _wait_line(os.path.join(LOGS_ROOT, "system", "system.log"), nonce + " stray")
ctx.check(line, "the stray program's line did not reach system/")
# 6. relay processes: the supervisor spawns one `logger` per controller
# (its stdin is the controller's output pipe); the daemon's init
# script keeps one on the fifo
st, mode = fc.get("/mode")
relays = hw.pidof("logger")
ev["logger_relays"] = relays
ev["controller"] = mode.get("controller") if isinstance(mode, dict) else None
ctx.log("logger relays: %s (controller %s)", relays, ev["controller"])
if isinstance(mode, dict) and mode.get("controller") == "running":
ctx.check(relays, "controller running but no `logger` relay process")
if os.path.exists("/var/run/forgectrl.stderr"):
ctx.check(len(relays) >= 2 or ev["controller"] != "running",
"fifo relay present but only %s logger process(es)", relays)
# 7. the kernel reaches kernel/ (unless filtered off)
if _SEV_RANK.get(eff.get("kernel", ("info", "off"))[0], 6) >= 6:
klines = _tail_lines(os.path.join(LOGS_ROOT, "kernel", "kernel.log"), 50)
ev["kernel_lines"] = len(klines)
ctx.check(klines and any(" kernel[" in l for l in klines), "no kernel lines in kernel/")
# 8. nothing outside the tree
for stray in ("/data/forgectrl.log", "/data/gfcloud.log", "/data/gfhome.log",
"/data/log/gfcloud", "/data/log/gfhome", "/var/log/messages"):
ctx.check(not os.path.exists(stray), "pre-syslog log path still present: %s", stray)
ctx.log("no ForgeFIRM log outside %s", LOGS_ROOT)
@test("logs.level-settings", title="Log level and remote-target settings", subsystem="logs",
kind="auto", est_min=1,
covers=[("forgectrl", "src/logs.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/settings.*")],
requires=["logs.tree-tail-export", "forgectrl.settings-bounds"],
description="A log level outside off..debug, a bad remote port, a bad protocol and a bad "
"server name are refused (400); a valid remote level is accepted and /logs shows "
"it as configured but not effective (pending_reboot); the setting is restored to "
"its prior value.")
def level_settings(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle: settings are locked")
for key, val in (("log_grblhal_disk", "verbose"), ("log_kernel_remote", "7"),
("syslog_port", "70000"), ("syslog_proto", "tls"),
("syslog_server", "bad host name")):
st, body = fc.post("/settings", data={key: val})
ev["refused %s=%s" % (key, val)] = st
ctx.log("POST /settings %s=%s -> %s", key, val, st)
ctx.check(st == 400, "%s=%s -> %s, expected 400", key, val, st)
st, before = fc.get("/logs")
ctx.check(st == 200 and isinstance(before, dict), "GET /logs -> %s", st)
prior = {l["name"]: l for l in before.get("loggers") or []}
ctx.check("system" in prior, "/logs lacks the system logger")
was = prior["system"].get("remote") or "off"
was_pending = bool(before.get("pending_reboot"))
new = "warning" if was != "warning" else "error"
ev["system_remote_before"] = was
ev["pending_before"] = was_pending
try:
st, body = fc.post("/settings", data={"log_system_remote": new})
ctx.log("POST /settings log_system_remote=%s -> %s", new, st)
ctx.check(st == 200, "valid level refused: %s", st)
st, after = fc.get("/logs")
now = {l["name"]: l for l in after.get("loggers") or []}
ctx.check(now["system"].get("remote") == new, "configured level reads %r", now["system"].get("remote"))
ctx.check(now["system"].get("effective_remote") == prior["system"].get("effective_remote"),
"effective level changed without a reboot")
ctx.check(after.get("pending_reboot") is True, "pending_reboot not raised by a level change")
ctx.log("configured %s, effective %s, pending_reboot %s",
now["system"].get("remote"), now["system"].get("effective_remote"), after.get("pending_reboot"))
finally:
# restore: a value that was stored goes back as a value; a default
# is restored by clearing the key (a query parameter - empty
# values never reach the form body)
stored = fc.settings().get("log_system_remote", "")
if was == "off" and stored in ("", None, new):
st, body = fc.post("/settings", params={"log_system_remote": ""})
else:
st, body = fc.post("/settings", data={"log_system_remote": was})
ctx.log("restore log_system_remote -> %s", st)
st, final = fc.get("/logs")
fin = {l["name"]: l for l in final.get("loggers") or []}
ctx.check(fin["system"].get("remote") == was, "system remote level not restored (%r)", fin["system"].get("remote"))
ctx.check(bool(final.get("pending_reboot")) == was_pending,
"pending_reboot did not return to %s after the restore", was_pending)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+466
View File
@@ -0,0 +1,466 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""setup.focus - the setup's focus burn (a live wizard), driven the way
the page drives it: POST /wiz/<id>/start, GET /wiz/dark polled, the
prompts answered from here, the burn armed by a press, the result
judged, and the setting the burn wrote put back as found. The burn
stands alone on its own scrap of wood in the back-left corner, drawn
from the head's home against the gantry stops, and re-runs on the real
record, so a completed burn completes again at the same version.
The operator's part is the scrap and one press on the machine at the
start (the presence check of the ready gate; with the bench actuator up
it then makes the arm press). The judgment a person makes by eye on the
scrap (the narrowest line) is answered here with a fixed pick: the test
proves the wizard's mechanics and the emission witnesses, not the
operator's eye, and the setting it writes is restored. The page is where
the real numbers go in.
"""
import re
import time
from .. import baseline
from .. import hw
from ..catalog import test
from .setup_dark import run_check, Restore, FALLBACK_WINDOW, LENS_WINDOW_KEYS, dark
BURN_COVERS = [("forgectrl", "src/wizlive.*"), ("forgectrl", "src/wizrun.h"),
("forgectrl", "src/lens.*"), ("forgectrl", "src/sheet.*"),
("forgectrl", "src/lenshome.*"),
("forgectrl", "src/font_hershey.*"), ("forgectrl", "src/jobstream.*"),
("forgectrl", "src/jobrun.*"), ("forgectrl", "src/lease.*"),
("forgectrl", "src/curverec.*"), ("forgectrl", "src/wizdark.*"),
("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"),
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*"),
("forgectrl", "src/super.c"),
("forgectrl", "src/status.c"), ("forgectrl", "src/cool.c"),
("grblhal-glowforge", "src/glowforge_laser.c"),
("grblhal-glowforge", "src/glowforge_status.c"),
("grblhal-glowforge", "src/glowforge_homing.*"),
("grblhal-glowforge", "src/glowforge_io.*")]
# Every burn asks first that the gantry stops are installed and the head
# is at its home against them; the bench's head stands there, and every
# burn's tail brings it back. head_at_home reads the head's place from
# /status before the wizard starts and judges it, within HOME_TOL_MM,
# against the stops as the manual home that references X and Y declared
# them (its home anchor): X0 Y0 for a home with no manual home offset,
# minus manual_home_x and manual_home_y for a home made with them.
HOME = "The head is home"
HOME_TOL_MM = 0.05
HOME_OFFSET_KEYS = ("manual_home_x", "manual_home_y")
EDGE_KEY = "lens_hall_edge_z_mm"
FOCUS_KEYS = ["thickness_mm", "z_passes", "ladder_z", "window", "emission", "pick", "picked_lines",
"pick_half_steps", "edge_z_mm", "steps_per_mm", "max_height_mm", "focus_range_mm", "summary"]
# pic/hv_current above this: the discharge is present (the daemon's own
# rule for a lit burn, jobstream.h JOBSTREAM_HV_ON).
HV_ON = 30
class Witnesses:
"""The two emission witnesses read from sysfs by the test itself at
every poll of the burn: the kernel's LASER_ON sample count
(cnc/laser_on_sampled) and the tube current (pic/hv_current), each
at its peak, and how many polls read both. The burn's result reports
the daemon's own reading of the same two; this one is taken past the
daemon."""
def __init__(self):
self.laser_on_max = None
self.hv_max = None
self.reads = 0
def __call__(self, _doc):
lon = hw.sysfs_int("cnc/laser_on_sampled")
hv = hw.sysfs_int("pic/hv_current")
if lon is not None:
self.laser_on_max = lon if self.laser_on_max is None else max(self.laser_on_max, lon)
if hv is not None:
self.hv_max = hv if self.hv_max is None else max(self.hv_max, hv)
if lon is not None and hv is not None:
self.reads += 1
def record(self):
return {"laser_on_max": self.laser_on_max, "hv_max": self.hv_max, "reads": self.reads}
def anchor_stops():
"""Where the manual home that references X and Y declared the gantry
stops, in /status coordinates: the X and Y of the controller's home
anchor (baseline.ANCHOR_PATH), to which /status adds the step
counters. None when the anchor does not read as a manual home of X
and Y. The settings do not place the stops: a manual home reads
manual_home_x and manual_home_y once, and a change to them after the
home leaves its reference as it is."""
try:
with open(baseline.ANCHOR_PATH, encoding="ascii", errors="replace") as f:
fields = f.read().split()
x, y, axes, source = float(fields[0]), float(fields[1]), int(fields[3]), fields[4]
except (OSError, ValueError, IndexError):
return None
if (axes & 3) != 3 or source != "manual":
return None
return x, y
def home_offset_remedy(ctx):
"""What brings the head to its home against the stops, from what the
next manual home reads: with no manual home offset, a home there;
with one, Set home here moves the head off the stops to X0 Y0, so the
head is pushed there with the motors released, and the release ends
with Energize motors."""
s = ctx.forgectrl.settings() or {}
raw = [str(s.get(key) or "").strip() for key in HOME_OFFSET_KEYS]
def no_offset(v):
try:
return v == "" or float(v) == 0
except ValueError:
return False
if no_offset(raw[0]) and no_offset(raw[1]):
return "home it there before the burn"
return ("with manual_home_x %s and manual_home_y %s, Set home here moves the head off the stops to X0 Y0; "
"release the motors, push the head against the stops, and end the release with Energize motors "
"($ME), not with Set home here, before the burn" % (raw[0] or "blank", raw[1] or "blank"))
def head_at_home(ctx):
"""The head's place on record before the wizard starts, read from
/status under the campaign's own controller: the wizard restarts the
controller before its home prompt, and a fresh controller drops the
X/Y reference, so a read at that prompt places nothing. When X and Y
are referenced by a manual home, the head is judged within
HOME_TOL_MM against the gantry stops, where the wizard draws from,
as that home declared them (anchor_stops): X0 Y0 for a home with no
offset, and minus manual_home_x and manual_home_y for a home made
with them, which moves the head to X0 Y0, that far in front of the
stops. Unreferenced, the head's place rests on the operator's word
at the ready gate, and the record says so."""
fc = ctx.forgectrl
read = {}
def served():
st = fc.status()
if not isinstance(st.get("pos"), dict):
return False
read.update(st)
return True
took = ctx.wait_for(served, 10)
ctx.check(took is not None, "/status served no head position within 10 s: the head's place cannot "
"go on record before the burn")
pos = read.get("pos") or {}
axes = read.get("homed_axes")
source = read.get("home_source")
referenced = isinstance(axes, int) and (axes & 3) == 3 and source == "manual"
rec = ctx.evidence["head_at_start"] = {"pos": pos, "homed_axes": axes, "home_source": source,
"judged": referenced}
if not referenced:
ctx.log("the head's place rests on the operator's word at the ready gate (X and Y unreferenced: "
"homed_axes %s, home_source %s, pos %s)", axes, source, pos)
return
found = anchor_stops()
ctx.check(found is not None, "the home anchor (%s) does not read as the manual home /status reports: the "
"gantry stops cannot be placed before the burn", baseline.ANCHOR_PATH)
stops = rec["stops"] = {"x": found[0], "y": found[1]}
x, y = pos.get("x"), pos.get("y")
at_home = (isinstance(x, (int, float)) and isinstance(y, (int, float))
and abs(x - stops["x"]) <= HOME_TOL_MM and abs(y - stops["y"]) <= HOME_TOL_MM)
ctx.check(at_home, "the head is not at its home against the gantry stops: /status reads pos %s with X "
"and Y referenced by a manual home that declared the stops at X%.2f Y%.2f; %s", pos, stops["x"],
stops["y"], "" if at_home else home_offset_remedy(ctx))
ctx.log("the head is home on record: pos %s, X and Y referenced by a manual home, the stops at X%.2f Y%.2f",
pos, stops["x"], stops["y"])
def preview_ok(ctx, wid):
"""The page's preview and the program for the burn are served."""
fc = ctx.forgectrl
st, body = fc.get("/wiz/sheet.svg?card=%s" % wid, raw=True)
ctx.check(st == 200 and body.lstrip().startswith(b"<svg"), "no preview for %s (%s)", wid, st)
ctx.evidence["preview_bytes"] = len(body)
st, body = fc.get("/wiz/sheet.gcode?card=%s" % wid, raw=True)
# The text is M4 at the mark dose; the ladders bring their own M3 or
# M4 lines. A program is one with a laser-on command.
ctx.check(st == 200 and re.search(rb"^M[34] S\d", body or b"", re.M) is not None,
"no program for %s (%s)", wid, st)
ctx.evidence["program_lines"] = body.count(b"\n")
# Every Z the program commands is a height the head reaches in the
# window the machine holds now: the Lens travel check's, or the
# fallback on a head whose travel is not known yet. The controller
# refuses a Z outside it with ALARM:2, and it refuses it while the
# body is still burning, so the burn dies where the parser is and not
# where the bad line is.
lens = (fc.status() or {}).get("lens") or {}
lo, hi = lens.get("reach_min"), lens.get("reach_max")
zs = [float(m) for m in re.findall(rb"^G0 Z(-?[0-9.]+)", body or b"", re.M)]
ctx.evidence.setdefault("program_z", {})[wid] = {
"z": zs, "reach": [lo, hi], "stops_found": lens.get("stops_found")}
ctx.check(lo is not None and hi is not None, "/status carries no lens reach: %s", lens)
if lo is not None and hi is not None:
out = [z for z in zs if z < lo - 0.01 or z > hi + 0.01]
ctx.check(not out, "%s commands Z outside the lens window %.2f..%.2f: %s",
wid, lo, hi, out)
# The tail the wizard streams after the body: the lens back on the
# reference the program's head declared, the head back to X0 Y0, the
# G92 origin cleared (the controller keeps a G92 offset and restores it
# at its next start, where it would shift a sender's own work offsets),
# and the program end.
lines = [ln.strip() for ln in (body or b"").decode("utf-8", "replace").splitlines()]
tail = [ln for ln in lines if ln and not ln.startswith(";")][-4:]
edge = lens.get("edge_z")
want = ["G0 Z%.2f" % edge, "G0 X0 Y0", "G92.1", "M2"] if isinstance(edge, (int, float)) else None
ctx.evidence.setdefault("program_tail", {})[wid] = tail
ctx.check(tail == want, "the %s program ends %s, not the tail %s", wid, tail, want)
def emission_ok(ctx, last, seen):
"""The two witnesses of the burn, from the wizard's result and from
the test's own reads of them at every poll (`seen`, a Witnesses).
The head's beam sensor is recorded, never required: the result
reports its rise and whether it read as the beam seen."""
e = (last.get("result") or {}).get("emission") or {}
ctx.evidence["emission"] = e
ctx.check(e.get("hv_max", 0) > HV_ON, "the tube current never rose (%s)", e.get("hv_max"))
ctx.check(e.get("laser_on_samples", 0) > 0, "the kernel never sampled LASER_ON")
ctx.check(isinstance(e.get("thermopile_delta"), int) and isinstance(e.get("beam_sensor_seen"), bool),
"the result does not record the beam sensor: %s", e)
ctx.log("the beam sensor rose %s (seen: %s)", e.get("thermopile_delta"), e.get("beam_sensor_seen"))
w = seen.record()
ctx.check(w["reads"] > 0, "the test could not read the witnesses from sysfs during the burn")
ctx.check((w["laser_on_max"] or 0) > 0,
"the kernel's LASER_ON count never read nonzero from here during the burn (%d reads); the "
"burn reports %s", w["reads"], e.get("laser_on_samples"))
ctx.check((w["hv_max"] or 0) > HV_ON,
"the tube current never read over %d from here during the burn (peak %s over %d reads); the "
"burn reports %s", HV_ON, w["hv_max"], w["reads"], e.get("hv_max"))
def thickness_answer(ctx):
"""The thickness the burn is answered with, in the machine's units
(the number prompts read ui_units), and the millimeters the record
must carry for it."""
imperial = (ctx.forgectrl.settings() or {}).get("ui_units") == "imperial"
return ("0.125", 3.175) if imperial else ("3.2", 3.2)
def burn(ctx, wid, arm, pick, want):
"""One burn: the preview, then the burn after the press with every
prompt answered (the gantry stops installed, the head at home, the
scrap in the corner, its thickness in the machine's units, the arm,
and the pick in `pick`), the witnesses (the burn's own, and the two
read from sysfs here at every poll), and the keys of the result in
`want`. run_check makes the arm press when the burn asks for it. A
prompt the test does not answer, or one asked again because its
answer was refused, ends the burn. Returns the result."""
answer, want_mm = thickness_answer(ctx)
ctx.evidence["thickness_answer"] = answer
answers = {"gantry-stops": "Yes", "home": HOME, "scrap": "Continue", "thickness": answer,
arm: "Continue"}
answers.update(pick)
preview_ok(ctx, wid)
asked = set()
seen = Witnesses()
def on_prompt(p):
pid = p.get("id")
if pid == "press":
return None
if pid not in answers:
# the motors found released ask for the head pushed home by
# hand, which the bench's head never needs
ctx.fail("the %s burn asked %s, which the test does not answer: %s", wid, pid, p.get("text"))
if pid in asked:
ctx.fail("the %s burn asked %s again: it refused %r", wid, pid, answers[pid])
asked.add(pid)
return answers[pid]
try:
last = run_check(ctx, wid, on_prompt, 900, on_poll=seen)
finally:
ctx.clear_notice()
# What the test read itself goes on record however the burn ends:
# reported dark, failed, timed out, or aborted.
w = seen.record()
ctx.evidence["emission_seen"] = w
ctx.log("seen from here: LASER_ON count peak %s, tube current peak %s over %d polls",
w["laser_on_max"], w["hv_max"], w["reads"])
emission_ok(ctx, last, seen)
r = last.get("result") or {}
missing = [k for k in want if k not in r]
ctx.check(not missing, "the result lacks %s: %s", missing, sorted(r))
ctx.check(abs((r.get("thickness_mm") or 0) - want_mm) < 0.01,
"thickness %s mm, expected %s (answered %s)", r.get("thickness_mm"), want_mm, answer)
ctx.check(len(r.get("z_passes") or []) >= 1, "the lens was not referenced: %s", r.get("z_passes"))
return r
def window_held(ctx):
"""The lens window the machine holds: the two settings, and the lens
block of /status, which reads them (the fallback where they are
unset)."""
fc = ctx.forgectrl
s = fc.settings() or {}
lens = (fc.status() or {}).get("lens") or {}
return {"settings": {k: s.get(k) for k in LENS_WINDOW_KEYS},
"window": (lens.get("below"), lens.get("above")), "stops_found": lens.get("stops_found")}
def focus_window_ok(ctx, r, before):
"""The ladder burned in the window the machine held before the burn
(`before`, from window_held): the Lens travel check's, or the fallback
while the settings are unset. The burn left that window as it found
it and wrote the edge alone, the one its result reports; every ladder
height lies in the reach, and the program served now agrees with
/status."""
fc = ctx.forgectrl
ev = ctx.evidence
w = r.get("window") or {}
window = (w.get("below"), w.get("above"))
after = window_held(ctx)
ctx.check(after["settings"] == before["settings"], "the focus burn wrote the lens window: %s, was %s",
after["settings"], before["settings"])
ctx.check(window == before["window"], "the ladder ran in %s/%s, the machine holds %s",
window[0], window[1], before["window"])
held = before["settings"]
if before["stops_found"]:
ctx.check(tuple(str(held[k]) for k in LENS_WINDOW_KEYS) == tuple(str(v) for v in window),
"the ladder ran in %s/%s, the settings hold %s", window[0], window[1], held)
else:
ctx.check(window == FALLBACK_WINDOW, "the settings hold no window (%s), but the ladder ran in %s/%s",
held, window[0], window[1])
edge = (fc.settings() or {}).get(EDGE_KEY)
try:
edge_ok = abs(float(edge) - float(r.get("edge_z_mm"))) < 0.006
except (TypeError, ValueError):
edge_ok = False
ctx.check(edge_ok, "%s reads %r, the burn reports %s", EDGE_KEY, edge, r.get("edge_z_mm"))
zs = r.get("ladder_z") or []
lo, hi = w.get("reach_min"), w.get("reach_max")
out = [z for z in zs if lo is None or hi is None or z < lo - 0.01 or z > hi + 0.01]
ctx.check(len(zs) == 12 and not out, "ladder heights outside the reach %s..%s: %s", lo, hi, out or zs)
first = dict(ev.get("program_z") or {}).get("laser.focus")
preview_ok(ctx, "laser.focus")
ev["focus_window"] = {"window": w, "ladder_z": zs, "held": before, "edge": edge,
"program_z_before": first,
"program_z_after": (ev.get("program_z") or {}).get("laser.focus"),
"preview_bytes": ev.pop("preview_bytes", None),
"program_lines": ev.pop("program_lines", None)}
@test("setup.focus", title="The focus burn: the ladder in the lens window, and the edge it writes",
subsystem="setup", kind="live", hardware="takeover", mode="grbl", est_min=10,
covers=BURN_COVERS,
requires=["forgectrl.auth", "setup.check-motion", "laser.emission-witness", "setup.check-lens"],
actions=["button"], hands=["scrap"],
steps=["The gantry stops installed and the head at its home against them. A fresh flat scrap "
"of wood about 3 mm (1/8 in) thick and at least 78 x 68 mm (3.1 x 2.7 in), pushed into "
"the back-left corner of the cut area: as far left as it goes, its top edge at the top "
"of the cut area; lid closed. Eye protection on, exhaust on, extinguisher in reach.",
"Start the test and press the machine's button once. That press says you are at the "
"machine, and the bench actuator makes the arm press after it, when the burn itself "
"asks for it. With no actuator the start is the Ready answer instead, and you press "
"the button again at the arm, as the page asks.",
"Nothing else: the test answers every prompt itself and puts back the setting the "
"burn writes."],
description="The focus burn as a fresh head runs it. Before anything starts, the head's "
"place is read from /status and recorded: judged against the gantry stops when "
"a manual home references X and Y (where that home declared them: X0 Y0, or "
"minus the manual_home_x and manual_home_y it read), else left to the "
"operator's word at the ready gate. The "
"edge setting is cleared, so the burn starts from the bench reference's edge, "
"while the lens window stays the one the Lens travel check wrote (or the "
"fallback). POST "
"/wiz/laser.focus/start is answered that the gantry stops are installed, "
"references the lens, takes the controller in loopback posture, is answered that "
"the head is home and that the scrap is in the corner, and takes the thickness "
"in the machine's units (3.2 mm or 0.125 in); its program declares X0 Y0 at the "
"head, and its tail puts the lens back on the reference, returns the head there, "
"clears the G92 origin (G92.1), and ends (M2). With its preview and program "
"served by GET /wiz/sheet.svg and /wiz/sheet.gcode, every Z the program commands "
"inside the reach /status reports, and the program ending in that tail at the "
"edge /status reports, it burns twelve lines after the press, from the top of "
"the window to the bottom, seen by the two witnesses (the tube current and the "
"kernel's LASER_ON samples) as the burn reports them and as the test reads them "
"from sysfs itself at every poll, with the beam sensor's rise recorded and not "
"required. The pick is line 11. The "
"result carries its keys; the ladder ran in the window the machine held, which "
"the burn leaves as found (it writes the edge alone, the one its result "
"reports); every ladder height lies in the reach; the program served afterward "
"agrees with /status; the record carries laser.focus at version 1. The edge and "
"the window are restored at the end. grblHAL reads them only at its start, and "
"the burn started it before the restore, so the controller is stopped and "
"started again on the settings as found (on every exit path), and it must be "
"running with motion verified under a new process.")
def focus(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
restart = {}
def restart_on_found():
"""A fresh controller start on the settings as found, once the
burn has ended. Plain waits, not the run's, so an abort gets it
too; a burn still ending starts its own controller after the
restore, which reads them already."""
try:
t_end = time.time() + 120
while time.time() < t_end and dark(fc).get("running"):
time.sleep(1)
if dark(fc).get("running"):
restart["skipped"] = "the burn is still ending"
ctx.log("the burn is still ending: its own controller start reads the settings as found")
return
st, m = fc.get("/mode")
m = m if isinstance(m, dict) else {}
restart.update(mode=m.get("mode"), pid=m.get("pid"))
if m.get("mode") != "grbl":
return # the cloud client reads neither the edge nor the window
restart["stop"], body = fc.post("/controller/stop")
ctx.log("restarting the controller on the edge and the window as found: POST "
"/controller/stop -> %s %s", restart["stop"], body)
for _ in range(10):
restart["start"], body = fc.post("/controller/start")
if restart["start"] != 409:
break
time.sleep(1) # the burn's lease goes just after its end
ctx.log("POST /controller/start -> %s %s", restart["start"], body)
except hw.HwError as e:
restart["error"] = str(e)
ctx.log("the controller could not be restarted on the settings as found: %s", e)
ctx.ready("The focus burn: twelve lines on the scrap after a press. The bench presses when the "
"button lights.")
head_at_home(ctx)
try:
with Restore(ctx, [EDGE_KEY] + LENS_WINDOW_KEYS):
# A fresh head's focus: the edge unknown, so the burn runs from the
# bench reference's number and writes the head's own. The window
# stays: it is the Lens travel check's.
st, _ = fc.post("/settings", params={EDGE_KEY: ""})
ctx.check(st == 200, "clearing %s -> %s", EDGE_KEY, st)
before = window_held(ctx)
ev["lens_fresh"] = (fc.status() or {}).get("lens") or {}
r = burn(ctx, "laser.focus", "focus-arm", {"focus-pick": "11"}, FOCUS_KEYS)
ctx.check(r.get("pick") == 11 and r.get("picked_lines") == [11, 11],
"the pick reads %s (lines %s), answered 11", r.get("pick"), r.get("picked_lines"))
ctx.check("stops" not in r, "the focus burn searched for the stops: %s", r.get("stops"))
focus_window_ok(ctx, r, before)
ctx.log("focus: line %s, the edge at %s mm, the window %s/%s", r.get("pick"), r.get("edge_z_mm"),
(r.get("window") or {}).get("below"), (r.get("window") or {}).get("above"))
finally:
restart_on_found()
ev["restart"] = dict(restart)
if restart.get("mode") == "grbl":
ctx.check(restart.get("stop") == 200 and restart.get("start") == 200,
"the controller was not restarted on the settings as found: %s", restart)
def back():
st, m = fc.get("/mode")
m = m if isinstance(m, dict) else {}
return (m.get("controller") == "running" and m.get("motion") == "verified"
and (restart.get("start") != 200 or m.get("pid") != restart.get("pid")))
ok = ctx.wait_for(back, 150)
ctx.check(ok is not None, "the controller did not come back on the settings as found")
ctx.log("PASS: the focus burn ran in the window the machine holds and wrote the edge alone; "
"settings restored and the controller restarted on them")
+168 -97
View File
@@ -17,7 +17,7 @@ import time
from ..catalog import test from ..catalog import test
from .. import hw from .. import hw
from ..baseline import read_position from ..baseline import read_position
from .setup import wiz, Restore # noqa: F401 - Restore is re-exported for the sheet from .setup import wiz, Restore, INTAKE_KEY
POLL_S = 1.0 POLL_S = 1.0
# What the machine's own press prompt says, used when it sends no text. # What the machine's own press prompt says, used when it sends no text.
@@ -26,9 +26,33 @@ PRESS_TEXT = "The button is lit white. Press it now: the laser fires after your
# check switches it back on. The bench reference draws its off current # check switches it back on. The bench reference draws its off current
# (74) for the first seconds and settles near 630. # (74) for the first seconds and settles near 630.
PURGE_SPINUP_S = 30 PURGE_SPINUP_S = 30
# The checks' press prompts read the button (button.c) and light it
# (led.c) on the machine; the switches check reads both back.
DARK_COVERS = [("forgectrl", "src/wizdark.*"), ("forgectrl", "src/wizcalc.*"), DARK_COVERS = [("forgectrl", "src/wizdark.*"), ("forgectrl", "src/wizcalc.*"),
("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"), ("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"),
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*")] ("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*"),
("forgectrl", "src/button.*"), ("forgectrl", "src/led.*")]
# The lens window: the free half-steps below and above the hall edge the
# Lens travel check writes, and the fallback the lens frame holds while
# they are unset (lens.h).
LENS_WINDOW_KEYS = ["lens_stop_below_steps", "lens_stop_above_steps"]
FALLBACK_WINDOW = (10, 12)
def intakes_counted():
"""Precheck: the intake fan count is set. The setup gate holds every
controller without it, and a test that puts the count back as found
would put an unset count back and close the gate on the tests after
it."""
try:
s = hw.Forgectrl().settings() or {}
except hw.HwError as e:
return "forgectrl unreachable: %s" % e
if not s.get(INTAKE_KEY):
return ("the intake fans are not counted, so the setup gate holds every controller; this "
"test puts the count back as it finds it, and an uncounted machine handed back "
"would hold the controllers of every test after it")
return None
def dark(fc): def dark(fc):
@@ -58,11 +82,13 @@ def answer(fc, wid, prompt, value):
return fc.post("/wiz/%s/answer" % wid, data={"seq": str(prompt["seq"]), "value": value}) return fc.post("/wiz/%s/answer" % wid, data={"seq": str(prompt["seq"]), "value": value})
def run_check(ctx, wid, on_prompt, timeout_s): def run_check(ctx, wid, on_prompt, timeout_s, on_poll=None):
"""Start `wid` and follow it to its end. on_prompt(prompt) is called """Start `wid` and follow it to its end. on_prompt(prompt) is called
once per prompt (by sequence number) and returns the value to answer once per prompt (by sequence number) and returns the value to answer
with, or None when it handled the prompt another way (a fixture with, or None when it handled the prompt another way (a fixture
action). Returns the final status document.""" action). on_poll(doc), when given, is called once per poll of
/wiz/dark with the status document: a caller's own witness read
beside the check's. Returns the final status document."""
fc = ctx.forgectrl fc = ctx.forgectrl
ev = ctx.evidence ev = ctx.evidence
start(ctx, fc, wid) start(ctx, fc, wid)
@@ -76,6 +102,8 @@ def run_check(ctx, wid, on_prompt, timeout_s):
d = dark(fc) d = dark(fc)
if d.get("id") != wid: if d.get("id") != wid:
raise hw.HwError("another check took the slot: %s" % d.get("id")) raise hw.HwError("another check took the slot: %s" % d.get("id"))
if on_poll is not None:
on_poll(d)
if d.get("phase") and (not phases or phases[-1] != d["phase"]): if d.get("phase") and (not phases or phases[-1] != d["phase"]):
phases.append(d["phase"]) phases.append(d["phase"])
ctx.log("%s: %s", wid, d["phase"]) ctx.log("%s: %s", wid, d["phase"])
@@ -104,10 +132,11 @@ def run_check(ctx, wid, on_prompt, timeout_s):
raise raise
ev["elapsed_s"] = round(time.time() - t0, 1) ev["elapsed_s"] = round(time.time() - t0, 1)
ev["phases"] = phases[-12:] ev["phases"] = phases[-12:]
ctx.check(last is not None, "the %s check did not end within %d s", wid, timeout_s)
if last is None: if last is None:
# A check this test stops following is aborted before the test
# fails: no check runs on behind the FAIL.
fc.post("/wiz/%s/abort" % wid) fc.post("/wiz/%s/abort" % wid)
return {} ctx.check(last is not None, "the %s check did not end within %d s", wid, timeout_s)
ev["log"] = last.get("log", [])[-12:] ev["log"] = last.get("log", [])[-12:]
ev["error"] = last.get("error") ev["error"] = last.get("error")
ev["result"] = last.get("result") ev["result"] = last.get("result")
@@ -121,7 +150,7 @@ def run_check(ctx, wid, on_prompt, timeout_s):
@test("setup.check-switches", title="The switches check follows the lid and the button", @test("setup.check-switches", title="The switches check follows the lid and the button",
subsystem="setup", kind="operator", est_min=3, subsystem="setup", kind="operator", est_min=3,
covers=DARK_COVERS + [("forgectrl", "src/status.c")], covers=DARK_COVERS + [("forgectrl", "src/status.c")],
requires=["forgectrl.auth"], actions=["lid", "button"], requires=["forgectrl.auth"], actions=["lid", "button", "interlock"],
description="POST /wiz/switches/start; the check asks for the lid to open and close and for " description="POST /wiz/switches/start; the check asks for the lid to open and close and for "
"a press, each as a 'wait' prompt that the switch edge itself answers; the bench " "a press, each as a 'wait' prompt that the switch edge itself answers; the bench "
"fixture works the lid and the button. On a Basic or Plus the interlock loop reads " "fixture works the lid and the button. On a Basic or Plus the interlock loop reads "
@@ -153,58 +182,48 @@ def check_switches(ctx):
ctx.log("switches: %s", json.dumps(r)) ctx.log("switches: %s", json.dumps(r))
@test("setup.check-sensors", title="The sensors check reads a plausible machine at rest",
subsystem="setup", kind="auto", est_min=2,
covers=DARK_COVERS + [("forgectrl", "src/status.c"), ("forgectrl", "src/accel.c"),
("forgectrl", "src/cool.c")],
requires=["forgectrl.auth"],
description="POST /wiz/sensors/start; ten seconds of readings and no question asked. The "
"result carries both coolant temperatures, the chassis and SoC, the lid IR "
"maxima, the accelerometer event count, the supply power-good, the HV current, "
"and the idle fan speeds; the record carries sensors at version 1 and the "
"settings read as before (the check writes none).")
def check_sensors(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
before = fc.settings() or {}
ev["settings_before"] = dict(before)
def on_prompt(p):
ctx.fail("the sensors check asked a question: %s", p)
last = run_check(ctx, "sensors", on_prompt, 120)
r = last.get("result") or {}
for k in ("coolant_down_c", "coolant_up_c", "lid_ir_max", "laser_pgood", "hv_current_max",
"exhaust_rpm_idle", "intake_rpm_idle"):
ctx.check(k in r, "the result lacks %s: %s", k, r)
ctx.check(r.get("laser_pgood") == 1, "power-good read %s", r.get("laser_pgood"))
after = fc.settings() or {}
changed = {k: (before.get(k), after.get(k)) for k in set(before) | set(after)
if before.get(k) != after.get(k)}
ctx.check(not changed, "the sensors check wrote a setting: %s", changed)
ctx.log("sensors: coolant %s/%s C, IR %s, HV %s", r.get("coolant_down_c"), r.get("coolant_up_c"),
r.get("lid_ir_max"), r.get("hv_current_max"))
@test("setup.check-airflow", title="The airflow check measures the fans and sets the floors", @test("setup.check-airflow", title="The airflow check measures the fans and sets the floors",
subsystem="setup", kind="auto", hardware="takeover", est_min=3, subsystem="setup", kind="auto", hardware="takeover", est_min=3,
covers=DARK_COVERS + [("forgectrl", "src/cool.c"), ("forgectrl", "src/airflow.*"), covers=DARK_COVERS + [("forgectrl", "src/cool.c"), ("forgectrl", "src/airflow.*"),
("forgectrl", "src/gates.c"), ("forgectrl", "src/super.c")], ("forgectrl", "src/gates.c"), ("forgectrl", "src/super.c")],
requires=["forgectrl.auth", "cooling.fan-gate-trips"], requires=["forgectrl.auth"], precheck=intakes_counted,
description="POST /wiz/airflow/start: the controller stops, the fans run at the cut profile " description="POST /wiz/airflow/start: the controller stops, the fans run at the cut profile "
"for 35 s, the purge is measured on and off, and five settings are written: the " "for 35 s, the purge is measured on and off, and the five floors are written: the "
"four floors at 55 percent of steady and the grace from the slowest spin-up. Each " "four at 55 percent of steady and the grace from the slowest spin-up. The intake "
"fan must read at least 1000 rpm. The five settings are put back as found at the " "fan count is the machine's: intake 2 turning proves two fans, both intakes are "
"end, the purge fan is on again and drawing over the floor the check just wrote, " "judged, and the count is written as 2; intake 2 silent on a machine counted with "
"and the controller must be running again.") "one is reported not fitted and not turning, intake 1 alone sets the intake "
"floor, and the count is not written; intake 2 silent on a machine counted with "
"two fails the check. Both intakes turn on the bench reference, so the bench "
"path writes 2. Each fan judged must read at least 1000 rpm. The settings are put "
"back as found at the end, the purge fan is on again and drawing over the floor "
"the check just wrote, and the controller must be running again.")
def check_airflow(ctx): def check_airflow(ctx):
keys = ["cool_tach_exhaust_min_rpm", "cool_tach_intake_min_rpm", "cool_tach_air_assist_min_rpm", keys = ["cool_tach_exhaust_min_rpm", "cool_tach_intake_min_rpm", "cool_tach_air_assist_min_rpm",
"cool_purge_min_current", "cool_fan_grace_s"] "cool_purge_min_current", "cool_fan_grace_s"]
with Restore(ctx, keys): with Restore(ctx, keys + ["cool_intake_fans"]) as held:
counted = held.before.get("cool_intake_fans")
last = run_check(ctx, "airflow", lambda p: None, 240) last = run_check(ctx, "airflow", lambda p: None, 240)
r = last.get("result") or {} r = last.get("result") or {}
fans = r.get("fans") or {} fans = r.get("fans") or {}
intakes = r.get("intake_fans")
silent2 = (fans.get("intake 2") or {}).get("turning") is False
ctx.evidence["intakes"] = {"counted": counted, "found": intakes, "intake_2_silent": silent2}
ctx.check(intakes in (1, 2), "the result carries no intake fan count: %r", intakes)
if silent2:
# one intake fan, as the machine is counted (on a machine
# counted with two the check itself fails, and run_check says so)
ctx.check(counted == "1" and intakes == 1,
"intake 2 gave no reading on a machine counted with %r intake fans, and the check "
"found %r", counted, intakes)
else:
ctx.check(intakes == 2, "intake 2 turned, and the check found %r intake fans", intakes)
ctx.log("intake fans: %s (the machine counted %s)", intakes, counted)
for name in ("exhaust", "intake 1", "intake 2", "air assist"): for name in ("exhaust", "intake 1", "intake 2", "air assist"):
f = fans.get(name) or {} f = fans.get(name) or {}
if name == "intake 2" and silent2:
ctx.check(f == {"fitted": False, "turning": False}, "intake 2 with one intake fan: %s", f)
continue
ctx.check(f.get("ok") is True, "%s: %s", name, f) ctx.check(f.get("ok") is True, "%s: %s", name, f)
ctx.log("%s: %s rpm steady, 90 percent at %s s", name, f.get("steady_rpm"), f.get("spinup_s")) ctx.log("%s: %s rpm steady, 90 percent at %s s", name, f.get("steady_rpm"), f.get("spinup_s"))
floors = r.get("floors") or {} floors = r.get("floors") or {}
@@ -213,6 +232,12 @@ def check_airflow(ctx):
s = ctx.forgectrl.settings() or {} s = ctx.forgectrl.settings() or {}
for k in keys: for k in keys:
ctx.check(s.get(k) == floors.get(k), "%s reads %r, the check wrote %r", k, s.get(k), floors.get(k)) ctx.check(s.get(k) == floors.get(k), "%s reads %r, the check wrote %r", k, s.get(k), floors.get(k))
# The count: 2 written when intake 2 turned; left as counted when it
# was silent.
want = counted if silent2 else "2"
ctx.check(s.get("cool_intake_fans") == want,
"cool_intake_fans reads %r, expected %r (intake 2 %s, the machine counted %r)",
s.get("cool_intake_fans"), want, "silent" if silent2 else "turning", counted)
ctx.log("floors: %s", json.dumps(floors)) ctx.log("floors: %s", json.dumps(floors))
# The check switches purge air off to read its off current. Leaving # The check switches purge air off to read its off current. Leaving
# it off costs the next job an airflow hold mid-cut, judged against # it off costs the next job an airflow hold mid-cut, judged against
@@ -244,41 +269,6 @@ def check_airflow(ctx):
ctx.check(ok is not None, "the controller did not come back after the check") ctx.check(ok is not None, "the controller did not come back after the check")
@test("setup.check-cameras", title="The cameras check captures both cameras",
subsystem="setup", kind="operator", est_min=2,
covers=DARK_COVERS + [("forgectrl", "src/cam.c")],
requires=["forgectrl.auth", "camera.snapshot"], actions=["lid"],
description="POST /wiz/cameras/start with the lid closed: a lid snapshot, the question, a "
"head snapshot, the question. Each question is answered yes once GET /wiz/shot "
"serves the snapshot as image/jpeg. The result names the sensor and says both "
"views were accepted.")
def check_cameras(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
ctx.act("lid", "close")
shots = {}
def on_prompt(p):
pid = p.get("id", "")
if pid in ("lid-view", "head-view"):
cam = "lid" if pid == "lid-view" else "head"
st, body = fc.get("/wiz/shot", params={"cam": cam}, raw=True)
shots[cam] = {"status": st, "bytes": len(body) if body else 0,
"jpeg": bool(body) and body[:2] == b"\xff\xd8"}
ctx.check(st == 200 and shots[cam]["jpeg"], "no %s snapshot to judge: %s", cam, shots[cam])
return "Yes"
if pid == "lid-close":
ctx.act("lid", "close", until=lambda: (dark(fc).get("prompt") or {}).get("seq") != p["seq"])
return None
last = run_check(ctx, "cameras", on_prompt, 180)
ev["shots"] = shots
r = last.get("result") or {}
ctx.check(r.get("lid_ok") is True and r.get("head_ok") is True, "a view was not accepted: %s", r)
ctx.check(bool(r.get("sensor")) and r.get("sensor") != "unknown", "no sensor named: %s", r)
ctx.log("cameras: %s, lid %d bytes, head %d bytes", r.get("sensor"),
shots.get("lid", {}).get("bytes", 0), shots.get("head", {}).get("bytes", 0))
@test("setup.check-motion", title="The motion check proves the rail, the lens reference, and the jogs", @test("setup.check-motion", title="The motion check proves the rail, the lens reference, and the jogs",
subsystem="setup", kind="auto", hardware="takeover", est_min=5, subsystem="setup", kind="auto", hardware="takeover", est_min=5,
covers=DARK_COVERS + [("forgectrl", "src/super.c"), ("forgectrl", "src/liveness.c"), covers=DARK_COVERS + [("forgectrl", "src/super.c"), ("forgectrl", "src/liveness.c"),
@@ -320,22 +310,103 @@ def check_motion(ctx):
r.get("rest"), {k: (v.get("p2p_lp_x"), v.get("p2p_lp_y")) for k, v in moves.items()}) r.get("rest"), {k: (v.get("p2p_lp_x"), v.get("p2p_lp_y")) for k, v in moves.items()})
@test("setup.check-flow-verify", title="The flow check runs as a setup check", @test("setup.check-lens", title="The lens travel check finds the stops and writes the window",
subsystem="setup", kind="auto", hardware="takeover", est_min=5, subsystem="setup", kind="auto", hardware="takeover", est_min=4,
covers=DARK_COVERS + [("forgectrl", "src/diag.c")], covers=DARK_COVERS + [("forgectrl", "src/lensfind.*"), ("forgectrl", "src/lens.*"),
requires=["forgectrl.auth", "cooling.aa-offset-calibrate", "cooling.flow-verify"], ("forgectrl", "src/lenshome.*"), ("forgectrl", "src/accel.c"),
description="POST /wiz/cooling.flow-verify/start drives the flow-verify diagnostic through the " ("forgectrl", "src/cool.c"), ("forgectrl", "src/super.c")],
"check runner: the diagnostic's phases show as the check's, its result becomes the " requires=["forgectrl.auth", "setup.check-motion"],
"check's result with pass true, and the record carries cooling.flow-verify at " description="POST /wiz/lens.travel/start: the controller stops, the lens takes its hall-edge "
"version 1. A thin margin recommends the calibration in the record's flags. The " "reference, the fans stop and the head accelerometer listens while the lens "
"air-assist offset calibration is named first among the prerequisites so a queue " "steps to each stop and back (nothing fires), the two window settings are "
"keeps it ahead of every heater tool this check pulls forward.") "written, and the controller comes back. No question is asked. The result "
def check_flow_verify(ctx): "carries the reference passes, the stops (found, the free half-steps each way, "
last = run_check(ctx, "cooling.flow-verify", lambda p: None, 600) "the contact counts, or why not), and the window: the stops found, or the "
r = last.get("result") or {} "fallback (10 half-steps below, 12 above) when they were not. The settings and "
ctx.check(r.get("pass") is True, "the flow check did not pass: %s", r) "the lens block of /status hold that window, the record carries lens.travel at "
ctx.log("flow check: threshold %s, flow %s, no-flow %s, thin %s", r.get("threshold"), "version 1, and the two settings are put back as found. grblHAL reads the "
r.get("flow_rise"), r.get("noflow_rise"), r.get("thin_margin")) "window only at its start, and the check started it before the put-back, so in "
"GRBL mode the controller is stopped and started again on the window as found "
"(on every exit path), and it must be running with motion verified under a new "
"process.")
def check_lens(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
restart = {}
def on_prompt(p):
ctx.fail("the lens travel check asked a question: %s", p)
def restart_on_found():
"""A fresh controller start on the settings as found, once the
check has ended. Plain waits, not the run's, so an abort gets it
too; a check still ending starts its own controller after the
put-back, which reads them already."""
try:
t_end = time.time() + 120
while time.time() < t_end and dark(fc).get("running"):
time.sleep(1)
if dark(fc).get("running"):
restart["skipped"] = "the check is still ending"
ctx.log("the check is still ending: its own controller start reads the settings as found")
return
st, m = fc.get("/mode")
m = m if isinstance(m, dict) else {}
restart.update(mode=m.get("mode"), pid=m.get("pid"))
if m.get("mode") != "grbl":
return # the cloud client reads no lens window
restart["stop"], body = fc.post("/controller/stop")
ctx.log("restarting the controller on the window as found: POST /controller/stop -> %s %s",
restart["stop"], body)
for _ in range(10):
restart["start"], body = fc.post("/controller/start")
if restart["start"] != 409:
break
time.sleep(1) # the check's lease goes just after its end
ctx.log("POST /controller/start -> %s %s", restart["start"], body)
except hw.HwError as e:
restart["error"] = str(e)
ctx.log("the controller could not be restarted on the window as found: %s", e)
try:
with Restore(ctx, LENS_WINDOW_KEYS):
last = run_check(ctx, "lens.travel", on_prompt, 300)
r = last.get("result") or {}
ctx.check(len(r.get("z_passes") or []) >= 1, "the lens was not referenced: %s", r.get("z_passes"))
stops, w = r.get("stops") or {}, r.get("window") or {}
ctx.check(isinstance(stops.get("found"), bool), "the result files no stops: %s", r)
window = (w.get("below"), w.get("above"))
if stops.get("found"):
ctx.check(window == (stops.get("below"), stops.get("above")) and w.get("found") is True,
"the window %s is not the stops found %s", w, stops)
else:
ctx.check(window == FALLBACK_WINDOW and w.get("found") is False,
"the stops were not found (%s), but the window is %s", stops.get("why"), w)
s = fc.settings() or {}
held = tuple(str(s.get(k)) for k in LENS_WINDOW_KEYS)
lens = (fc.status() or {}).get("lens") or {}
ev["window"] = {"stops": stops, "window": w, "settings": held, "status": lens}
ctx.check(held == tuple(str(v) for v in window), "the settings hold %s, the check wrote %s",
held, window)
ctx.check((lens.get("below"), lens.get("above")) == window,
"/status reads the window %s/%s, the check wrote %s", lens.get("below"), lens.get("above"),
window)
ctx.log("lens travel: %s half-steps below the edge, %s above (%s); contact %s/%s", window[0],
window[1], "the stops found" if stops.get("found") else "the fallback: %s" % stops.get("why"),
stops.get("contact_below"), stops.get("contact_above"))
finally:
restart_on_found()
ev["restart"] = dict(restart)
if restart.get("mode") == "grbl":
ctx.check(restart.get("stop") == 200 and restart.get("start") == 200,
"the controller was not restarted on the window as found: %s", restart)
def back():
st, m = fc.get("/mode")
m = m if isinstance(m, dict) else {}
return (m.get("controller") == "running" and m.get("motion") == "verified"
and (restart.get("start") != 200 or m.get("pid") != restart.get("pid")))
ok = ctx.wait_for(back, 150)
ctx.check(ok is not None, "the controller did not come back after the check")
@test("setup.cloud-header-capture", title="The cloud header check takes one print's envelope", @test("setup.cloud-header-capture", title="The cloud header check takes one print's envelope",
-283
View File
@@ -1,283 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""setup.sheet - the setup's sheet (the live wizards) in one run,
driven the way the page drives them: POST /wiz/<id>/start, GET /wiz/dark
polled, the prompts answered from here, each burn armed by a press, the
result judged, and every setting a wizard wrote put back as found. One
piece of wood at least 200 x 150 mm (8 x 6 in) carries the placement,
the frame, and the five cards. Each wizard re-runs on the real record,
so a completed one completes again at the same version.
The operator's part is one press on the machine at the start (the
presence check of the ready gate; with the bench actuator up it then
makes every arm press) and nothing else. The judgments a person makes
by eye on the sheet (the thinnest line, the faintest rung, the best
corner, the frame's fit) are answered here with a middle option or the
witnesses: the test proves each wizard's mechanics and the emission
witnesses, not the operator's eye, and the settings it writes are
restored. The page is where the real numbers go in.
"""
import re
from ..catalog import test
from .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.*"),
("forgectrl", "src/super.c"),
("forgectrl", "src/status.c"), ("forgectrl", "src/cool.c"),
("forgectrl", "src/accel.*"), ("forgectrl", "src/wizcalc.*"),
("grblhal-glowforge", "src/glowforge_laser.c"),
("grblhal-glowforge", "src/glowforge_status.c"),
("grblhal-glowforge", "src/glowforge_homing.*"),
("grblhal-glowforge", "src/glowforge_io.*")]
# The live wizards in the order the sheet burns them: the wizard id, the
# answers to its prompts (a middle pick where a person would look at the
# sheet), and the keys its result must carry. The arm press is not timed
# from here: each card opens a press prompt of its own when the button is
# lit and the tube still dark, and run_check presses on that.
CARDS = [
("sheet.frame", {"frame-arm": "Continue", "frame-ok": "Yes"}, ["mark_s", "mark_feed"]),
("laser.focus", {"focus-arm": "Continue", "focus-pick": "11", "thickness": "Keep"},
["pick", "thickness_mm", "pick_half_steps", "edge_z_mm", "steps_per_mm", "max_height_mm",
"focus_range_mm", "stops"]),
("laser.floor", {"floor-arm": "Continue", "floor-pick": "8"}, ["faintest_density", "floor_density"]),
("laser.dose-curve", {"dose-arm": "Continue"}, ["points", "curve"]),
("laser.corner", {"corner-arm": "Continue", "corner-pick": "1.50"}, ["gamma"]),
("cooling.flow-load", {"load-arm": "Continue"}, ["lit_s", "dose_raw_s", "peak_c", "k_density", "k_cw"]),
]
# Every setting a card writes, restored as found when the run ends.
CARD_SETTINGS = ["lens_hall_edge_z_mm", "lens_stop_below_steps", "lens_stop_above_steps",
"laser_floor_density", "laser_dose_curve", "laser_corner_gamma",
"cool_laser_heat_density", "cool_laser_heat_cw"]
# The evidence run_check and the witnesses write at the top level, moved
# under the card's own key once it is done.
CARD_EVIDENCE = ("started", "elapsed_s", "phases", "log", "error", "result",
"preview_bytes", "program_lines", "emission")
def preview_ok(ctx, wid):
"""The page's preview and the program for the wizard are served."""
fc = ctx.forgectrl
st, body = fc.get("/wiz/sheet.svg?card=%s" % wid, raw=True)
ctx.check(st == 200 and body.lstrip().startswith(b"<svg"), "no preview for %s (%s)", wid, st)
ctx.evidence["preview_bytes"] = len(body)
st, body = fc.get("/wiz/sheet.gcode?card=%s" % wid, raw=True)
# The text and the frame are M4 at the mark dose; the patterns bring
# their own M3 or M4 lines. A program is one with a laser-on command.
ctx.check(st == 200 and re.search(rb"^M[34] S\d", body or b"", re.M) is not None,
"no program for %s (%s)", wid, st)
ctx.evidence["program_lines"] = body.count(b"\n")
# Every Z the program commands is a height the head reaches in the
# window the machine holds now: the focus card's own numbers once it
# has them, and the conservative fallback on a head whose travel is
# not known yet. The controller refuses a Z outside it with ALARM:2,
# and it refuses it while the body is still burning, so the card dies
# where the parser is and not where the bad line is.
lens = (fc.status() or {}).get("lens") or {}
lo, hi = lens.get("reach_min"), lens.get("reach_max")
zs = [float(m) for m in re.findall(rb"^G0 Z(-?[0-9.]+)", body or b"", re.M)]
ctx.evidence.setdefault("program_z", {})[wid] = {
"z": zs, "reach": [lo, hi], "stops_found": lens.get("stops_found")}
ctx.check(lo is not None and hi is not None, "/status carries no lens reach: %s", lens)
if lo is not None and hi is not None:
out = [z for z in zs if z < lo - 0.01 or z > hi + 0.01]
ctx.check(not out, "%s commands Z outside the lens window %.2f..%.2f: %s",
wid, lo, hi, out)
def emission_ok(ctx, last):
"""The three witnesses of the burn, from the wizard's result."""
e = (last.get("result") or {}).get("emission") or {}
ctx.evidence["emission"] = e
ctx.check(e.get("hv_max", 0) > 30, "the tube current never rose (%s)", e.get("hv_max"))
ctx.check(e.get("laser_on_samples", 0) > 0, "the kernel never sampled LASER_ON")
ctx.check(e.get("thermopile_delta", 0) >= 100, "the thermopile did not see the beam (%s)",
e.get("thermopile_delta"))
def file_card(ctx, wid):
"""Move the card's evidence under its own key."""
ev = ctx.evidence
ev.setdefault("cards", {})[wid] = {k: ev.pop(k) for k in CARD_EVIDENCE if k in ev}
# The lens settings: the head's numbers, which a fresh machine does not
# have. Cleared before the cards so the run is a fresh machine's.
LENS_SETTINGS = CARD_SETTINGS[:3]
FALLBACK_WINDOW = (10, 12)
def fresh_lens(ctx):
"""The lens as a fresh machine has it: the head's numbers unknown, so
the frame runs in the fallback window and the focus card has to find
the stops and write them before its ladder burns. The machine's own
numbers come back with the restore at the end of the run."""
fc = ctx.forgectrl
for k in LENS_SETTINGS:
st, _ = fc.post("/settings", params={k: ""})
ctx.check(st == 200, "clearing %s -> %s", k, st)
lens = (fc.status() or {}).get("lens") or {}
ctx.evidence["lens_fresh"] = lens
ctx.check(lens.get("stops_found") is False and (lens.get("below"), lens.get("above")) == FALLBACK_WINDOW,
"the lens window is not the fallback after the clear: %s", lens)
def focus_window_ok(ctx):
"""The window the focus card burned its ladder in is the one it wrote
before its controller started, and the one the machine holds now: the
result's window is the stops it found (or the fallback), the settings
hold that window, every ladder height lies in its reach, and the
program served now agrees with /status."""
fc = ctx.forgectrl
r = ((ctx.evidence.get("cards") or {}).get("laser.focus") or {}).get("result") or {}
w, stops = r.get("window") or {}, r.get("stops") or {}
s = fc.settings() or {}
held = (str(s.get("lens_stop_below_steps")), str(s.get("lens_stop_above_steps")))
ctx.check(held == (str(w.get("below")), str(w.get("above"))),
"the settings hold the window %s, the ladder ran in %s/%s", held, w.get("below"), w.get("above"))
if stops.get("found"):
ctx.check((w.get("below"), w.get("above")) == (stops.get("below"), stops.get("above")),
"the ladder's window %s is not the stops found %s", w, stops)
else:
ctx.check((w.get("below"), w.get("above")) == FALLBACK_WINDOW,
"stops not found (%s), but the ladder's window is %s", stops.get("why"), w)
zs = r.get("ladder_z") or []
lo, hi = w.get("reach_min"), w.get("reach_max")
out = [z for z in zs if lo is None or hi is None or z < lo - 0.01 or z > hi + 0.01]
ctx.check(len(zs) == 12 and not out, "ladder heights outside the reach %s..%s: %s", lo, hi, out or zs)
ev = ctx.evidence
before = dict(ev.get("program_z") or {}).get("laser.focus")
preview_ok(ctx, "laser.focus")
ev["focus_window"] = {"window": w, "stops": stops, "ladder_z": zs,
"settings": {k: s.get(k) for k in LENS_SETTINGS},
"program_z_before": before,
"program_z_after": (ev.get("program_z") or {}).get("laser.focus"),
"preview_bytes": ev.pop("preview_bytes", None),
"program_lines": ev.pop("program_lines", None)}
def thickness_answer(ctx):
"""The thickness the placement is answered with, in the machine's
units (the number prompts read ui_units), and the millimeters the
record must carry for it."""
imperial = (ctx.forgectrl.settings() or {}).get("ui_units") == "imperial"
return ("0.125", 3.175) if imperial else ("3.2", 3.2)
def place(ctx):
"""The placement: the lens reference, two jogs, the origin at the
head's position, the full sheet, a thickness. Nothing fires."""
jogs = ["X+10", "X-10"]
answer, want_mm = thickness_answer(ctx)
ctx.evidence["thickness_answer"] = answer
def on_prompt(p):
# Each jog answer closes the prompt and the wizard asks again with
# the next sequence number, so the pad is answered one move at a
# time until the origin is set.
if p.get("id") == "place":
return jogs.pop(0) if jogs else "Set origin"
if p.get("id") == "sheet-kind":
return "Full sheet"
if p.get("id") == "thickness":
return answer
return None
last = run_check(ctx, "sheet.place", on_prompt, 300)
r = last.get("result") or {}
ctx.check("origin_x" in r and "origin_y" in r, "no origin in the result: %s", r)
ctx.check((r.get("steps_per_mm") or {}).get("x", 0) > 0, "no steps per mm: %s", r)
ctx.check(r.get("z_referenced") is True, "the lens was not referenced")
ctx.check(abs((r.get("thickness_mm") or 0) - want_mm) < 0.01,
"thickness %s mm, expected %s (answered %s)", r.get("thickness_mm"), want_mm, answer)
ctx.check(r.get("alone") is False, "alone %s", r.get("alone"))
ctx.log("placed: datum at %s, %s; steps per mm %s", r.get("origin_x"), r.get("origin_y"),
r.get("steps_per_mm"))
file_card(ctx, "sheet.place")
def burn(ctx, wid, answers, want):
"""One live card: the preview, the burn after the press with the
prompts answered from `answers`, the witnesses, the keys of the
result in `want`. run_check makes the arm press when the card asks
for it."""
preview_ok(ctx, wid)
def on_prompt(p):
return answers.get(p.get("id"))
try:
last = run_check(ctx, wid, on_prompt, 900)
finally:
ctx.clear_notice()
emission_ok(ctx, last)
r = last.get("result") or {}
missing = [k for k in want if k not in r]
ctx.check(not missing, "the result lacks %s: %s", missing, sorted(r))
ctx.log("%s: %s, emission %s", wid, {k: r.get(k) for k in want}, ctx.evidence.get("emission"))
file_card(ctx, wid)
@test("setup.sheet", title="The setup sheet: the placement, the frame, and the five cards",
subsystem="setup", kind="live", hardware="takeover", mode="grbl", est_min=35,
covers=SHEET_COVERS,
requires=["forgectrl.auth", "setup.check-motion", "laser.emission-witness", "cooling.flow-verify"],
actions=["button"], hands=["scrap"],
steps=["A piece of wood at least 200 x 150 mm (8 x 6 in) on the bed, pushed as far left as "
"it goes with its top edge at the top of the cut area (the head's home corner); "
"lid closed. Eye protection on, exhaust on, extinguisher in reach.",
"Start the test and press the machine's button once. That press says you are at the "
"machine, and the bench actuator makes every arm press after it, each one when the "
"card itself asks for it. With no actuator the start is the Ready answer instead, "
"and you press the button each time the page asks: six times, the frame and the "
"five cards.",
"Nothing else: the test answers every prompt itself and puts back every setting the "
"cards write. It runs about half an hour, mostly the coolant settle and the dark "
"tails."],
description="One run over the sheet's seven wizards on one piece of wood, as a fresh "
"machine runs them: the three lens settings are cleared first, so the frame "
"runs in the fallback window and the focus card has to find the stops and write "
"them before its ladder burns. "
"POST /wiz/sheet.place/start references the lens, takes the controller in "
"loopback posture, jogs +X 10 and -X 10, and sets the origin at the head "
"(Full sheet; the thickness answered in the machine's units, 3.2 mm or "
"0.125 in). Then, each after the press and each with its preview "
"and program served by GET /wiz/sheet.svg and /wiz/sheet.gcode, every Z the "
"program commands inside the reach /status reports: the frame "
"(180 x 130 mm) and the header band at the mark dose; the focus card (the "
"lens on its hall edge, the stops found by the head accelerometer and written "
"as the window before the controller starts, twelve lines from the top of the "
"window to the bottom; the pick is line 11, the thickness kept; after it the "
"settings, the result's window, and the served program must hold the window "
"the ladder ran in); the floor card (twelve "
"rungs from 2 to 24 percent with the floor and the curve off for the job; the "
"pick is 8, the floor written is 10); the dose card (seven rungs sampled at "
"25 Hz, the curve fit on 20 s of dark); the corner card (five gammas reloaded "
"through M102 inside one armed job; the pick is 1.50); and the flow-load card "
"(the coolant loop settled, the box and the patch at 60 percent, the "
"downstream peak over 100 s of dark as the coefficient). Every burn must be "
"seen by the three witnesses (the tube current, the kernel's LASER_ON samples, "
"the thermopile rise); every result must carry its keys; the record must "
"carry each wizard at version 1; the seven settings the cards write are "
"restored at the end.")
def sheet(ctx):
ctx.ready("The sheet: the placement, then the frame and the five cards burn after a press "
"each. The bench presses when the button lights.")
place(ctx)
with Restore(ctx, CARD_SETTINGS):
fresh_lens(ctx)
for wid, answers, want in CARDS:
burn(ctx, wid, answers, want)
if wid == "laser.focus":
focus_window_ok(ctx)
ctx.log("PASS: the sheet's seven wizards ran on one piece as a fresh machine's; settings restored")
-203
View File
@@ -1,203 +0,0 @@
# 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)
+25 -157
View File
@@ -5,7 +5,6 @@
"""update.* - the A/B slot inventory and the firmware verification path.""" """update.* - the A/B slot inventory and the firmware verification path."""
import os import os
import re
import tempfile import tempfile
import time import time
import shutil import shutil
@@ -25,7 +24,8 @@ _UPDATE_COVERS = [("forgectrl", "src/update.c"), ("forgectrl", "src/update.h"),
"answers; `fwup` refuses a garbage archive. A tiny archive signed with a " "answers; `fwup` refuses a garbage archive. A tiny archive signed with a "
"throwaway key made on the spot verifies with its own key and fails against the " "throwaway key made on the spot verifies with its own key and fails against the "
"shipped release key, and an apply of it without confirm_unsigned is refused by " "shipped release key, and an apply of it without confirm_unsigned is refused by "
"the update job before anything is written. Nothing is written to any slot.") "the update job before anything is written, and the forgectrl log records the "
"job's start and its refusal. Nothing is written to any slot.")
def slots_and_signature(ctx): def slots_and_signature(ctx):
fc = ctx.forgectrl fc = ctx.forgectrl
ev = ctx.evidence ev = ctx.evidence
@@ -96,6 +96,11 @@ def slots_and_signature(ctx):
target = name target = name
ctx.check(target is not None, "no inactive firmware slot in /slots: %s", slots) ctx.check(target is not None, "no inactive firmware slot in /slots: %s", slots)
shutil.copyfile(signed, staged) shutil.copyfile(signed, staged)
log_path = "/data/log/forgefirm/forgectrl/forgectrl.log"
try:
log_from = os.path.getsize(log_path)
except OSError:
log_from = 0
st, body = fc.post("/update/apply", params={"slot": target, "file": "upload"}) st, body = fc.post("/update/apply", params={"slot": target, "file": "upload"})
ev["apply"] = {"status": st, "body": body} ev["apply"] = {"status": st, "body": body}
ctx.log("POST /update/apply slot=%s file=upload (no confirm_unsigned) -> %s %s", target, st, body) ctx.log("POST /update/apply slot=%s file=upload (no confirm_unsigned) -> %s %s", target, st, body)
@@ -118,164 +123,27 @@ def slots_and_signature(ctx):
ctx.check(isinstance(result, dict) and result.get("ok") is False ctx.check(isinstance(result, dict) and result.get("ok") is False
and "not signed" in str(result.get("error", "")), and "not signed" in str(result.get("error", "")),
"the apply of a foreign-signed archive was not refused for its signature: %s", result) "the apply of a foreign-signed archive was not refused for its signature: %s", result)
# The forgectrl log records the job: what it set out to write
# and why it stopped.
want = ["update: writing the uploaded archive into slot %s" % target,
"update: the apply failed: archive is not signed"]
def log_text():
try:
with open(log_path, "r", encoding="utf-8", errors="replace") as f:
f.seek(log_from)
return f.read()
except OSError:
return ""
ctx.wait_for(lambda: all(w in log_text() for w in want), 10, poll=0.5)
text = log_text()
ev["apply_log"] = [ln for ln in text.splitlines() if "update: " in ln][-4:]
for w in want:
ctx.check(w in text, "the forgectrl log lacks %r: %s", w, ev["apply_log"])
finally: finally:
try: try:
os.unlink(staged) os.unlink(staged)
except OSError: except OSError:
pass pass
shutil.rmtree(work, ignore_errors=True) shutil.rmtree(work, ignore_errors=True)
@test("update.product-gate", title="The firmware upload takes firmware only: an extension package is refused by name",
subsystem="update", kind="auto", est_min=1,
covers=_UPDATE_COVERS + [("forgectrl", "src/fwproduct.c"), ("forgectrl", "src/fwproduct.h")],
requires=["forgectrl.auth"],
description="Firmware and extension packages are the same container, and a signature says who "
"made an archive, never what it is. Archives made on the spot with the machine's own "
"fwup go to POST /update/upload: an extension package unsigned, one signed with a "
"throwaway key, and one that carries a task are each refused with 400 in words that "
"say what they are, and nothing is staged; an archive of some other product is "
"refused as not firmware; and one whose product is ForgeFIRM firmware is taken as an "
"unsigned upload, which is what it is. Nothing is applied: the apply calls the same "
"gate before fwup -a, and its proof is the daemon's host test, because a catalog test "
"that could fail there would write a slot.")
def product_gate(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
staged = "/data/forgefirm/upload.fw"
ctx.check(not os.path.exists(staged), "an upload is already staged: %s", staged)
work = tempfile.mkdtemp(prefix="forgetest-gate-")
def archive(name, product, task=False, sign=False):
conf = os.path.join(work, name + ".conf")
with open(conf, "w") as f:
f.write('meta-product = "%s"\nmeta-version = "0.0.0-test"\n'
'file-resource payload.tar.gz { host-path = "payload.tar.gz" }\n' % product)
if task:
f.write('task upgrade.a { on-resource payload.tar.gz { raw_write(0) } }\n')
out = name + ".fw"
cmd = "cd %s && fwup -c -f %s.conf -o %s" % (work, name, out)
if sign:
cmd += " && fwup -S -s fwup-key.priv -i %s -o %s.signed && mv %s.signed %s" % (out, out, out, out)
rc, text = hw.run(["sh", "-c", cmd], timeout=60)
ctx.check(rc == 0, "could not make %s (rc %s): %s", name, rc, text.strip()[:200])
return os.path.join(work, out)
def upload(path):
mark = "forgetestGateBoundary9c2"
body = ('--%s\r\nContent-Disposition: form-data; name="file"; filename="%s"\r\n'
'Content-Type: application/octet-stream\r\n\r\n' % (mark, os.path.basename(path))).encode()
with open(path, "rb") as f:
body += f.read() + ("\r\n--%s--\r\n" % mark).encode()
return fc.post("/update/upload", data=body,
headers={"Content-Type": "multipart/form-data; boundary=%s" % mark})
try:
with open(os.path.join(work, "payload.tar.gz"), "wb") as f:
f.write(b"forgetest product-gate drill\n" * 8)
rc, text = hw.run(["sh", "-c", "cd %s && fwup -g" % work], timeout=60)
ctx.check(rc == 0, "fwup -g did not make a key pair (rc %s): %s", rc, text.strip()[:200])
seen = {}
for name, product, task, sign in (("extension-unsigned", "ForgeFIRM extension", False, False),
("extension-signed", "ForgeFIRM extension", False, True),
("extension-with-task", "ForgeFIRM extension", True, False)):
st, body = upload(archive(name, product, task, sign))
words = body.get("error", "") if isinstance(body, dict) else str(body)
seen[name] = [st, words]
ctx.log("upload %s -> %s %s", name, st, words)
ctx.check(st == 400 and "an extension package, not firmware" in words,
"%s at the firmware upload -> %s %s", name, st, words)
ctx.check(not os.path.exists(staged), "%s was refused and is staged all the same", name)
st, body = upload(archive("another-product", "forgetest"))
words = body.get("error", "") if isinstance(body, dict) else str(body)
seen["another-product"] = [st, words]
ctx.log("upload another-product -> %s %s", st, words)
ctx.check(st == 400 and "not firmware" in words and not os.path.exists(staged),
"an archive of another product -> %s %s", st, words)
st, body = upload(archive("firmware-unsigned", "ForgeFIRM firmware"))
seen["firmware-unsigned"] = [st, body]
ctx.log("upload firmware-unsigned -> %s %s", st, body)
ctx.check(st == 200 and isinstance(body, dict) and body.get("signature") == "unsigned"
and os.path.exists(staged), "an unsigned archive that is firmware -> %s %s", st, body)
ev["uploads"] = seen
finally:
try:
os.unlink(staged)
except OSError:
pass
shutil.rmtree(work, ignore_errors=True)
ctx.log("PASS: three extension packages and one foreign product refused at the firmware upload with "
"nothing staged; firmware taken as the unsigned upload it is")
_VERSION_RX = re.compile(r"^v?\d+\.\d+\.\d+")
@test("update.release-check", title="Release check answers from the releases API; the alert dismissal is per release",
subsystem="update", kind="auto", est_min=1,
covers=_UPDATE_COVERS + [("forgectrl", "src/ui/panel.js"), ("forgectrl", "src/ui/index.html")],
requires=["forgectrl.auth"],
description="GET /update/release answers the kept answer of the daily check (available, version, "
"current, new, checked, dismissed). POST /update/check asks the releases API now and "
"answers the same shape; a machine with no route to the API answers 502, which the drill "
"records and steps over. A published release is a v<semver> tag with the firmware "
"file's size and its notes, and `new` follows the version order: a development build "
"sees every release as newer. POST /update/dismiss marks that version dismissed, an "
"empty version undoes it, and a version that is not a tag is refused. The dismissal "
"the machine had before the drill is put back.")
def release_check(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
keys = ("available", "version", "current", "new", "checked", "dismissed", "detail", "bytes")
st, before = fc.get("/update/release")
ctx.log("GET /update/release -> %s %s", st, {k: before.get(k) for k in keys} if isinstance(before, dict) else before)
ctx.check(st == 200 and isinstance(before, dict) and "available" in before and "checked" in before,
"GET /update/release -> %s", st)
ev["before"] = {k: before.get(k) for k in keys}
prior = before.get("version", "") if before.get("dismissed") else ""
st, now = fc.post("/update/check")
ctx.log("POST /update/check -> %s %s", st, {k: now.get(k) for k in keys} if isinstance(now, dict) else now)
if st == 502:
ctx.log("the machine has no route to the releases API; the kept answer stands")
now = before
else:
ctx.check(st == 200 and isinstance(now, dict) and "available" in now, "POST /update/check -> %s", st)
ctx.check(isinstance(now.get("checked"), int) and now["checked"] >= before.get("checked", 0),
"the check did not move `checked`: %s", now.get("checked"))
ev["after_check"] = {k: now.get(k) for k in keys}
if now.get("available"):
ctx.check(_VERSION_RX.match(str(now.get("version", ""))) is not None,
"the release version is not a v<semver> tag: %r", now.get("version"))
ctx.check(isinstance(now.get("bytes"), int) and now["bytes"] > 0,
"the firmware file's size is missing: %r", now.get("bytes"))
ctx.check(isinstance(now.get("notes"), str), "the notes are not a string")
ctx.check(isinstance(now.get("new"), bool), "`new` is not a bool")
if not _VERSION_RX.match(str(now.get("current", ""))):
ctx.check(now.get("new") is True,
"a development build (%r) must see release %s as newer", now.get("current"), now["version"])
else:
ctx.log("no release available: %s", now.get("detail"))
ctx.check(isinstance(now.get("detail"), str) and now["detail"], "an unavailable release names no reason")
try:
st, body = fc.post("/update/dismiss", params={"version": "v0.0.1; rm -rf /"})
ctx.log("POST /update/dismiss version=<not a tag> -> %s %s", st, body)
ctx.check(st == 400, "a version that is not a tag was not refused: %s", st)
tag = now["version"] if now.get("available") else "v0.0.0"
st, body = fc.post("/update/dismiss", params={"version": tag})
ctx.log("POST /update/dismiss version=%s -> %s %s", tag, st,
{k: body.get(k) for k in keys} if isinstance(body, dict) else body)
ctx.check(st == 200 and isinstance(body, dict), "POST /update/dismiss -> %s", st)
if now.get("available"):
ctx.check(body.get("dismissed") is True, "the dismissal of %s was not recorded: %r", tag, body.get("dismissed"))
st, body = fc.post("/update/dismiss", params={"version": ""})
ctx.log("POST /update/dismiss version= -> %s", st)
ctx.check(st == 200 and isinstance(body, dict) and body.get("dismissed") is False,
"the empty version did not undo the dismissal: %s %r", st, body.get("dismissed") if isinstance(body, dict) else body)
finally:
st, body = fc.post("/update/dismiss", params={"version": prior})
ctx.log("dismissal put back to %r -> %s", prior, st)
-121
View File
@@ -1,121 +0,0 @@
# 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)
View File
-1
View File
@@ -1 +0,0 @@
0
-1
View File
@@ -1 +0,0 @@
1
-1
View File
@@ -1 +0,0 @@
0
-1
View File
@@ -1 +0,0 @@
1
-1
View File
@@ -1 +0,0 @@
1
-41
View File
@@ -1,41 +0,0 @@
2026-08-17T09:44:00.417337+00:00 gfcloud[1927] INFO cnc:set_step_freq 28160
2026-08-17T09:44:00.418627+00:00 gfcloud[1927] INFO cnc:set_x_decay 1
2026-08-17T09:44:00.419858+00:00 gfcloud[1927] INFO cnc:set_x_mode 8
2026-08-17T09:44:00.421083+00:00 gfcloud[1927] INFO cnc:set_x_current 135
2026-08-17T09:44:00.422535+00:00 gfcloud[1927] INFO cnc:set_y_decay 1
2026-08-17T09:44:00.423794+00:00 gfcloud[1927] INFO cnc:set_y_mode 8
2026-08-17T09:44:00.425014+00:00 gfcloud[1927] INFO cnc:set_y_current 22
2026-08-17T09:44:00.432591+00:00 gfcloud[1927] INFO machine:_run_loop starting run
2026-08-17T09:44:00.434005+00:00 gfcloud[1927] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:44:00.438277+00:00 gfcloud[1927] INFO machine:_run_loop current state: MachineState.RUNNING
2026-08-17T09:44:01.785230+00:00 gfcloud[1927] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:44:01.797728+00:00 gfcloud[1927] INFO machine:_run_loop finished run
2026-08-17T09:44:01.800661+00:00 gfcloud[1927] INFO cnc:laser_latch 1
2026-08-17T09:44:01.808504+00:00 gfcloud[1927] INFO machine:_motion_locked end positions (actual/expected): X (-10313/-10313), Y (-649/-649), Z (0/0)
2026-08-17T09:44:01.809326+00:00 gfcloud[1927] INFO machine:_motion_locked motion bytes actual:34952, expected: 34952
2026-08-17T09:44:01.810066+00:00 gfcloud[1927] INFO machine:_motion_locked start idle
2026-08-17T09:44:01.811352+00:00 gfcloud[1927] INFO cnc:set_x_current 33
2026-08-17T09:44:01.812874+00:00 gfcloud[1927] INFO cnc:set_y_current 5
2026-08-17T09:44:01.820631+00:00 gfcloud[1927] INFO machine:_motion_locked end positions (-10313, -649, 0)
2026-08-17T09:44:01.822062+00:00 gfcloud[1927] INFO machine:_motion end motion
2026-08-17T09:44:01.822829+00:00 gfcloud[1927] INFO basemachine:_finish_action motion [1576550614]: finished with event ":completed"
2026-08-17T09:44:01.824200+00:00 gfcloud[1927] INFO cnc:laser_latch 1
2026-08-17T09:44:07.993647+00:00 gfcloud[1927] INFO gfuiservice:run service action request: print (ready)
2026-08-17T09:44:07.998655+00:00 gfcloud[1927] INFO machine:_motion start motion
2026-08-17T09:44:07.999643+00:00 gfcloud[1927] INFO gfuiservice:run print (ready) service action print
2026-08-17T09:44:14.199666+00:00 gfcloud[1927] INFO cnc:laser_latch 0
2026-08-17T09:44:14.208291+00:00 gfcloud[1927] INFO machine:_button_wait waiting for button
2026-08-17T09:44:21.270846+00:00 gfcloud[1927] INFO machine:_switch_event lid opened
2026-08-17T09:44:21.275703+00:00 gfcloud[1927] WARNING machine:_button_wait button wait lid opened - relocking the laser
2026-08-17T09:44:21.278615+00:00 gfcloud[1927] INFO cnc:laser_latch 1
2026-08-17T09:44:21.288305+00:00 gfcloud[1927] INFO machine:_return_home start return home
2026-08-17T09:44:21.291373+00:00 gfcloud[1927] INFO machine:_return_home return home: already at the job start
2026-08-17T09:44:21.292501+00:00 gfcloud[1927] INFO machine:_motion_locked start cool down
2026-08-17T09:44:21.305578+00:00 gfcloud[1927] INFO machine:_motion_locked start idle
2026-08-17T09:44:21.307322+00:00 gfcloud[1927] INFO cnc:set_x_current 33
2026-08-17T09:44:21.309185+00:00 gfcloud[1927] INFO cnc:set_y_current 5
2026-08-17T09:44:21.316340+00:00 gfcloud[1927] INFO machine:_motion_locked end positions (0, 0, 0)
2026-08-17T09:44:21.318139+00:00 gfcloud[1927] INFO machine:_motion end motion
2026-08-17T09:44:21.318860+00:00 gfcloud[1927] INFO basemachine:_finish_action print [1576550621]: finished with event ":cancelled"
2026-08-17T09:44:21.320214+00:00 gfcloud[1927] INFO cnc:laser_latch 1
2026-08-17T09:44:34.058320+00:00 gfcloud[1927] INFO machine:_switch_event lid closed
-207
View File
@@ -1,207 +0,0 @@
2026-08-17T09:44:35.789556+00:00 gfcloud[1927] INFO websocket:img_upload START
2026-08-17T09:44:36.105516+00:00 gfcloud[1927] INFO websocket:img_upload COMPLETE
2026-08-17T09:44:36.109421+00:00 gfcloud[1927] INFO cnc:laser_latch 1
2026-08-17T09:44:38.461442+00:00 gfcloud[1927] INFO gfcloud:_shutdown shutdown requested
2026-08-17T09:44:38.910494+00:00 gfcloud[1927] INFO machine:_shutdown shutting down
2026-08-17T09:44:38.913049+00:00 gfcloud[1927] INFO cnc:laser_latch 1
2026-08-17T09:44:38.950354+00:00 gfcloud[1927] INFO machine:_shutdown joining switch thread
2026-08-17T09:44:38.956909+00:00 gfcloud[1927] INFO machine:_shutdown shut down complete
2026-08-17T09:44:38.986287+00:00 gfcloud[1927] INFO websocket:_on_close RX-EVENT: closed (None, None)
2026-08-17T09:44:38.989003+00:00 gfcloud[1927] INFO websocket:run CLOSING
2026-08-17T09:44:38.992724+00:00 gfcloud[1927] INFO gfcloud:main gfcloud exit
2026-08-17T09:45:08.449553+00:00 gfcloud[2278] INFO gfuiservice:__init__ INITIALIZED
2026-08-17T09:45:08.451929+00:00 gfcloud[2278] INFO authentication:authenticate_machine START
2026-08-17T09:45:08.965893+00:00 gfcloud[2278] INFO authentication:authenticate_machine SUCCESS
2026-08-17T09:45:08.967947+00:00 gfcloud[2278] INFO websocket:ws_connect CONNECTING
2026-08-17T09:45:09.421918+00:00 gfcloud[2278] INFO _logging:info Websocket connected
2026-08-17T09:45:09.424224+00:00 gfcloud[2278] INFO websocket:_on_open RX-EVENT: ready
2026-08-17T09:45:09.973615+00:00 gfcloud[2278] INFO websocket:ws_connect ESTABLISHED
2026-08-17T09:45:09.994287+00:00 gfcloud[2278] INFO cnc:set_ignored_faults 0
2026-08-17T09:45:09.996281+00:00 gfcloud[2278] INFO cnc:set_step_freq 10000
2026-08-17T09:45:09.998803+00:00 gfcloud[2278] INFO cnc:set_x_mode Microstep.M_8
2026-08-17T09:45:09.999688+00:00 gfcloud[2278] INFO cnc:set_y_mode Microstep.M_8
2026-08-17T09:45:10.000483+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
2026-08-17T09:45:10.001229+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
2026-08-17T09:45:10.002000+00:00 gfcloud[2278] INFO cnc:set_x_current 33
2026-08-17T09:45:10.007267+00:00 gfcloud[2278] INFO cnc:set_y_current 33
2026-08-17T09:45:10.008695+00:00 gfcloud[2278] INFO z_axis:reset resetting z
2026-08-17T09:45:10.009124+00:00 gfcloud[2278] INFO z_axis:configure configuring z_enable: False, current: ZCur.LOW, mode: Microstep.HALF
2026-08-17T09:45:10.021592+00:00 gfcloud[2278] INFO gfuiservice:run service action request: settings (ready)
2026-08-17T09:45:10.024928+00:00 gfcloud[2278] INFO settings:send_report START
2026-08-17T09:45:10.039856+00:00 gfcloud[2278] INFO settings:send_report COMPLETE
2026-08-17T09:45:10.040650+00:00 gfcloud[2278] INFO gfuiservice:run settings (ready) service action dispatched
2026-08-17T09:45:10.591813+00:00 gfcloud[2278] INFO gfuiservice:run service action request: hunt (ready)
2026-08-17T09:45:10.596213+00:00 gfcloud[2278] INFO z_axis:home starting z homing cycle
2026-08-17T09:45:10.599109+00:00 gfcloud[2278] INFO gfuiservice:run hunt (ready) service action hunt
2026-08-17T09:45:10.600433+00:00 gfcloud[2278] INFO z_axis:configure configuring z_enable: True, current: ZCur.HIGH, mode: Microstep.FULL
2026-08-17T09:45:10.607559+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
2026-08-17T09:45:11.160422+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
2026-08-17T09:45:11.161195+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
2026-08-17T09:45:11.714663+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
2026-08-17T09:45:11.717907+00:00 gfcloud[2278] INFO z_axis:home pass: 1
2026-08-17T09:45:11.719134+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
2026-08-17T09:45:12.272747+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
2026-08-17T09:45:12.274301+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
2026-08-17T09:45:12.829585+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
2026-08-17T09:45:12.831305+00:00 gfcloud[2278] INFO z_axis:home pass: 2
2026-08-17T09:45:12.832786+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
2026-08-17T09:45:13.385567+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
2026-08-17T09:45:13.397106+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
2026-08-17T09:45:13.947958+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
2026-08-17T09:45:13.949754+00:00 gfcloud[2278] INFO z_axis:home pass: 3
2026-08-17T09:45:13.951244+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
2026-08-17T09:45:14.504840+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
2026-08-17T09:45:14.506743+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
2026-08-17T09:45:15.060854+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
2026-08-17T09:45:15.062536+00:00 gfcloud[2278] INFO z_axis:home pass: 4
2026-08-17T09:45:15.063941+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
2026-08-17T09:45:15.617907+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
2026-08-17T09:45:15.619505+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
2026-08-17T09:45:16.175270+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
2026-08-17T09:45:16.177421+00:00 gfcloud[2278] INFO z_axis:home pass: 5
2026-08-17T09:45:16.179808+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: False
2026-08-17T09:45:16.733698+00:00 gfcloud[2278] INFO z_axis:_step_until took -3 z_steps
2026-08-17T09:45:16.735355+00:00 gfcloud[2278] INFO z_axis:_step_until starting z_step until at_home: True
2026-08-17T09:45:17.289377+00:00 gfcloud[2278] INFO z_axis:_step_until took 3 z_steps
2026-08-17T09:45:17.291109+00:00 gfcloud[2278] INFO z_axis:configure configuring z_enable: None, current: ZCur.LOW, mode: Microstep.HALF
2026-08-17T09:45:17.296301+00:00 gfcloud[2278] INFO z_axis:home homing cycle complete: [0, 0, 0, 0, 0]
2026-08-17T09:45:17.298209+00:00 gfcloud[2278] INFO machine:_motion start motion
2026-08-17T09:45:17.968494+00:00 gfcloud[2278] INFO cnc:set_step_freq 10000
2026-08-17T09:45:17.969759+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
2026-08-17T09:45:17.970993+00:00 gfcloud[2278] INFO cnc:set_x_mode 8
2026-08-17T09:45:17.972200+00:00 gfcloud[2278] INFO cnc:set_x_current 135
2026-08-17T09:45:17.973616+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
2026-08-17T09:45:17.974870+00:00 gfcloud[2278] INFO cnc:set_y_mode 8
2026-08-17T09:45:17.976077+00:00 gfcloud[2278] INFO cnc:set_y_current 22
2026-08-17T09:45:17.983820+00:00 gfcloud[2278] INFO machine:_run_loop starting run
2026-08-17T09:45:17.985159+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:45:17.989360+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.RUNNING
2026-08-17T09:45:19.630807+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:45:19.636283+00:00 gfcloud[2278] INFO machine:_run_loop finished run
2026-08-17T09:45:19.637979+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:45:19.651159+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (actual/expected): X (0/0), Y (0/0), Z (-4/-4)
2026-08-17T09:45:19.652103+00:00 gfcloud[2278] INFO machine:_motion_locked motion bytes actual:15874, expected: 15874
2026-08-17T09:45:19.652852+00:00 gfcloud[2278] INFO machine:_motion_locked start idle
2026-08-17T09:45:19.653964+00:00 gfcloud[2278] INFO cnc:set_x_current 33
2026-08-17T09:45:19.655305+00:00 gfcloud[2278] INFO cnc:set_y_current 5
2026-08-17T09:45:19.664032+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (0, 0, -4)
2026-08-17T09:45:19.664853+00:00 gfcloud[2278] INFO machine:_motion end motion
2026-08-17T09:45:19.665575+00:00 gfcloud[2278] INFO basemachine:_finish_action hunt [1576550660]: finished with event ":completed"
2026-08-17T09:45:19.667114+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:45:20.027338+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
2026-08-17T09:45:20.030024+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
2026-08-17T09:45:20.040337+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
2026-08-17T09:45:21.195560+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
2026-08-17T09:45:21.196529+00:00 gfcloud[2278] INFO websocket:img_upload START
2026-08-17T09:45:21.721382+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
2026-08-17T09:45:21.725394+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:45:23.732857+00:00 gfcloud[2278] INFO gfuiservice:run service action request: motion (ready)
2026-08-17T09:45:23.736044+00:00 gfcloud[2278] INFO machine:_motion start motion
2026-08-17T09:45:23.737995+00:00 gfcloud[2278] INFO gfuiservice:run motion (ready) service action motion
2026-08-17T09:45:23.738941+00:00 gfcloud[2278] INFO machine:_safe_to_move lid opened, unsafe to move
2026-08-17T09:45:23.739814+00:00 gfcloud[2278] INFO machine:_motion end motion
2026-08-17T09:45:23.740605+00:00 gfcloud[2278] INFO basemachine:_finish_action motion [1576550667]: finished with event ":cancelled"
2026-08-17T09:45:23.742169+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:45:42.629674+00:00 gfcloud[2278] INFO machine:_switch_event lid closed
2026-08-17T09:45:42.920194+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
2026-08-17T09:45:42.924152+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
2026-08-17T09:45:42.927934+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
2026-08-17T09:45:43.989152+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
2026-08-17T09:45:43.990022+00:00 gfcloud[2278] INFO websocket:img_upload START
2026-08-17T09:45:44.312060+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
2026-08-17T09:45:44.315952+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:45:46.652633+00:00 gfcloud[2278] INFO gfuiservice:run service action request: motion (ready)
2026-08-17T09:45:46.654950+00:00 gfcloud[2278] INFO machine:_motion start motion
2026-08-17T09:45:46.657522+00:00 gfcloud[2278] INFO gfuiservice:run motion (ready) service action motion
2026-08-17T09:45:48.089456+00:00 gfcloud[2278] INFO cnc:set_step_freq 10000
2026-08-17T09:45:48.090706+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
2026-08-17T09:45:48.091970+00:00 gfcloud[2278] INFO cnc:set_x_mode 8
2026-08-17T09:45:48.093155+00:00 gfcloud[2278] INFO cnc:set_x_current 135
2026-08-17T09:45:48.094587+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
2026-08-17T09:45:48.095866+00:00 gfcloud[2278] INFO cnc:set_y_mode 8
2026-08-17T09:45:48.097182+00:00 gfcloud[2278] INFO cnc:set_y_current 22
2026-08-17T09:45:48.104758+00:00 gfcloud[2278] INFO machine:_run_loop starting run
2026-08-17T09:45:48.106128+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:45:48.110477+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.RUNNING
2026-08-17T09:45:50.393091+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:45:50.398748+00:00 gfcloud[2278] INFO machine:_run_loop finished run
2026-08-17T09:45:50.399629+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:45:50.423949+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (actual/expected): X (12939/12939), Y (7402/7402), Z (0/0)
2026-08-17T09:45:50.425534+00:00 gfcloud[2278] INFO machine:_motion_locked motion bytes actual:22740, expected: 22740
2026-08-17T09:45:50.426258+00:00 gfcloud[2278] INFO machine:_motion_locked start idle
2026-08-17T09:45:50.427835+00:00 gfcloud[2278] INFO cnc:set_x_current 33
2026-08-17T09:45:50.429387+00:00 gfcloud[2278] INFO cnc:set_y_current 5
2026-08-17T09:45:50.437659+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (12939, 7402, 0)
2026-08-17T09:45:50.438474+00:00 gfcloud[2278] INFO machine:_motion end motion
2026-08-17T09:45:50.439167+00:00 gfcloud[2278] INFO basemachine:_finish_action motion [1576550679]: finished with event ":completed"
2026-08-17T09:45:50.440481+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:45:50.770056+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
2026-08-17T09:45:50.772800+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
2026-08-17T09:45:50.782017+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
2026-08-17T09:45:51.427848+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
2026-08-17T09:45:51.428456+00:00 gfcloud[2278] INFO websocket:img_upload START
2026-08-17T09:45:51.757479+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
2026-08-17T09:45:51.760692+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:45:54.222123+00:00 gfcloud[2278] INFO gfuiservice:run service action request: motion (ready)
2026-08-17T09:45:54.224786+00:00 gfcloud[2278] INFO machine:_motion start motion
2026-08-17T09:45:54.226855+00:00 gfcloud[2278] INFO gfuiservice:run motion (ready) service action motion
2026-08-17T09:45:54.557705+00:00 gfcloud[2278] INFO cnc:set_step_freq 10000
2026-08-17T09:45:54.562210+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
2026-08-17T09:45:54.571212+00:00 gfcloud[2278] INFO cnc:set_x_mode 8
2026-08-17T09:45:54.573373+00:00 gfcloud[2278] INFO cnc:set_x_current 135
2026-08-17T09:45:54.596668+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
2026-08-17T09:45:54.597612+00:00 gfcloud[2278] INFO cnc:set_y_mode 8
2026-08-17T09:45:54.598479+00:00 gfcloud[2278] INFO cnc:set_y_current 22
2026-08-17T09:45:54.622488+00:00 gfcloud[2278] INFO machine:_run_loop starting run
2026-08-17T09:45:54.624223+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:45:54.629307+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.RUNNING
2026-08-17T09:45:54.835600+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:45:54.838848+00:00 gfcloud[2278] INFO machine:_run_loop finished run
2026-08-17T09:45:54.839726+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:45:54.849011+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (actual/expected): X (159/159), Y (5/5), Z (0/0)
2026-08-17T09:45:54.849946+00:00 gfcloud[2278] INFO machine:_motion_locked motion bytes actual:1222, expected: 1222
2026-08-17T09:45:54.850790+00:00 gfcloud[2278] INFO machine:_motion_locked start idle
2026-08-17T09:45:54.852317+00:00 gfcloud[2278] INFO cnc:set_x_current 33
2026-08-17T09:45:54.854073+00:00 gfcloud[2278] INFO cnc:set_y_current 5
2026-08-17T09:45:54.880450+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (159, 5, 0)
2026-08-17T09:45:54.881925+00:00 gfcloud[2278] INFO machine:_motion end motion
2026-08-17T09:45:54.882641+00:00 gfcloud[2278] INFO basemachine:_finish_action motion [1576550683]: finished with event ":completed"
2026-08-17T09:45:54.884500+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:45:55.137070+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
2026-08-17T09:45:55.139449+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
2026-08-17T09:45:55.142346+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
2026-08-17T09:45:55.809276+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
2026-08-17T09:45:55.810155+00:00 gfcloud[2278] INFO websocket:img_upload START
2026-08-17T09:45:56.158888+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
2026-08-17T09:45:56.162644+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:45:58.467590+00:00 gfcloud[2278] INFO gfuiservice:run service action request: motion (ready)
2026-08-17T09:45:58.469426+00:00 gfcloud[2278] INFO machine:_motion start motion
2026-08-17T09:45:58.471091+00:00 gfcloud[2278] INFO gfuiservice:run motion (ready) service action motion
2026-08-17T09:46:00.721960+00:00 gfcloud[2278] INFO cnc:set_step_freq 28160
2026-08-17T09:46:00.725688+00:00 gfcloud[2278] INFO cnc:set_x_decay 1
2026-08-17T09:46:00.727477+00:00 gfcloud[2278] INFO cnc:set_x_mode 8
2026-08-17T09:46:00.728738+00:00 gfcloud[2278] INFO cnc:set_x_current 135
2026-08-17T09:46:00.730198+00:00 gfcloud[2278] INFO cnc:set_y_decay 1
2026-08-17T09:46:00.731506+00:00 gfcloud[2278] INFO cnc:set_y_mode 8
2026-08-17T09:46:00.732735+00:00 gfcloud[2278] INFO cnc:set_y_current 22
2026-08-17T09:46:00.740459+00:00 gfcloud[2278] INFO machine:_run_loop starting run
2026-08-17T09:46:00.741865+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:46:00.745983+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.RUNNING
2026-08-17T09:46:02.491897+00:00 gfcloud[2278] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:46:02.494137+00:00 gfcloud[2278] INFO machine:_run_loop finished run
2026-08-17T09:46:02.495155+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:46:02.530565+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (actual/expected): X (-13096/-13096), Y (-7400/-7400), Z (0/0)
2026-08-17T09:46:02.531413+00:00 gfcloud[2278] INFO machine:_motion_locked motion bytes actual:47243, expected: 47243
2026-08-17T09:46:02.538168+00:00 gfcloud[2278] INFO machine:_motion_locked start idle
2026-08-17T09:46:02.539603+00:00 gfcloud[2278] INFO cnc:set_x_current 33
2026-08-17T09:46:02.541147+00:00 gfcloud[2278] INFO cnc:set_y_current 5
2026-08-17T09:46:02.549578+00:00 gfcloud[2278] INFO machine:_motion_locked end positions (-13096, -7400, 0)
2026-08-17T09:46:02.550385+00:00 gfcloud[2278] INFO machine:_motion end motion
2026-08-17T09:46:02.551153+00:00 gfcloud[2278] INFO basemachine:_finish_action motion [1576550685]: finished with event ":completed"
2026-08-17T09:46:02.552496+00:00 gfcloud[2278] INFO cnc:laser_latch 1
2026-08-17T09:46:03.007623+00:00 gfcloud[2278] INFO gfuiservice:run service action request: lid_image (ready)
2026-08-17T09:46:03.009488+00:00 gfcloud[2278] INFO ffmachine:_lid_image capturing Lid Image via forgectrl
2026-08-17T09:46:03.037155+00:00 gfcloud[2278] INFO gfuiservice:run lid_image (ready) service action dispatched
2026-08-17T09:46:03.758520+00:00 gfcloud[2278] INFO ffmachine:_lid_image uploading Lid Image
2026-08-17T09:46:03.760049+00:00 gfcloud[2278] INFO websocket:img_upload START
2026-08-17T09:46:04.128126+00:00 gfcloud[2278] INFO websocket:img_upload COMPLETE
+29 -146
View File
@@ -21,7 +21,10 @@ from forgetest import baseline
DEFAULT_TICK = dict(baseline.fixed_sysfs())["cnc/step_freq"] DEFAULT_TICK = dict(baseline.fixed_sysfs())["cnc/step_freq"]
class BaselineTests(unittest.TestCase): class BaselineFixture:
"""The fake sysfs tree and LED class a baseline runs against. It has
no tests of its own, so a class that mixes it in runs only its own."""
def setUp(self): def setUp(self):
self.tmp = tempfile.mkdtemp(prefix="forgetest-bl-") self.tmp = tempfile.mkdtemp(prefix="forgetest-bl-")
self.sysfs = os.path.join(self.tmp, "sysfs") + os.sep self.sysfs = os.path.join(self.tmp, "sysfs") + os.sep
@@ -65,13 +68,6 @@ class BaselineTests(unittest.TestCase):
# the class interface writes 'target'; the fake mirrors it into brightness # the class interface writes 'target'; the fake mirrors it into brightness
# only when the test asks (see _sync_leds) # only when the test asks (see _sync_leds)
def _led2(self, name, target, brightness):
"""Both attributes of one button LED: the trigger's commanded
level and the fade that follows it."""
for a, v in (("target", target), ("brightness", brightness)):
with open(self.leds + name + "/" + a, "w") as f:
f.write(v)
def _sync_leds(self): def _sync_leds(self):
for name in baseline.BUTTON_LEDS: for name in baseline.BUTTON_LEDS:
p = self.leds + name + "/target" p = self.leds + name + "/target"
@@ -88,6 +84,19 @@ class BaselineTests(unittest.TestCase):
def bl(self): def bl(self):
return baseline.Baseline(self.lines.append) return baseline.Baseline(self.lines.append)
def _pos_bytes(self, x, y, z, processed, total):
with open(self.sysfs + "cnc/position", "wb") as f:
f.write(struct.pack("<3i2I", x, y, z, processed, total))
def _probe_state(self):
# what the kernel shows between the supervisor's motion probe and
# the GRBL controller's init writes
self._attr("cnc/motor_lock", "0")
self._attr("cnc/step_freq", "10000")
self._attr("cnc/y_mode", "1")
class BaselineTests(BaselineFixture, unittest.TestCase):
def test_clean_machine_has_no_leftovers(self): def test_clean_machine_has_no_leftovers(self):
left = self.bl().enforce("pre", captured=None) left = self.bl().enforce("pre", captured=None)
self.assertEqual(left, []) self.assertEqual(left, [])
@@ -226,10 +235,6 @@ class BaselineTests(unittest.TestCase):
for k in ("GRBL_HOST", "GRBL_PORT"): for k in ("GRBL_HOST", "GRBL_PORT"):
os.environ.pop(k, None) os.environ.pop(k, None)
def _pos_bytes(self, x, y, z, processed, total):
with open(self.sysfs + "cnc/position", "wb") as f:
f.write(struct.pack("<3i2I", x, y, z, processed, total))
def test_ring_residue_is_a_leftover_and_blocks_the_return_jog(self): def test_ring_residue_is_a_leftover_and_blocks_the_return_jog(self):
b = self.bl() b = self.bl()
cap = b.capture() cap = b.capture()
@@ -308,13 +313,6 @@ class BaselineTests(unittest.TestCase):
# -- the reference is taken after the controller applied its config ----- # -- the reference is taken after the controller applied its config -----
def _probe_state(self):
# what the kernel shows between the supervisor's motion probe and
# the GRBL controller's init writes
self._attr("cnc/motor_lock", "0")
self._attr("cnc/step_freq", "10000")
self._attr("cnc/y_mode", "1")
def test_wait_configured_returns_once_the_controller_wrote_its_config(self): def test_wait_configured_returns_once_the_controller_wrote_its_config(self):
self._probe_state() self._probe_state()
calls = {"n": 0} calls = {"n": 0}
@@ -460,11 +458,7 @@ class BaselineTests(unittest.TestCase):
os.environ.pop("FORGETEST_BOOT_ID", None) os.environ.pop("FORGETEST_BOOT_ID", None)
if __name__ == "__main__": class ExtensionFreeTests(BaselineFixture, unittest.TestCase):
unittest.main()
class ExtensionFreeTests(BaselineTests):
"""A campaign's machine is extension-free: what a test made is removed, """A campaign's machine is extension-free: what a test made is removed,
what is the operator's is reported and left alone.""" what is the operator's is reported and left alone."""
@@ -529,104 +523,6 @@ class ExtensionFreeTests(BaselineTests):
self.assertTrue(os.path.isdir(os.path.join(self.root, "pkg", "org.example.notify"))) 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
laser.arm-wait-lid passed every check it makes and failed its
hand-back on the post-job smoke clear."""
def test_a_fading_button_led_is_not_a_leftover(self):
# the trigger fades brightness toward target: target 0 with
# brightness still high is the fade from a level the machine ended
for name in baseline.BUTTON_LEDS:
self._led2(name, target="0", brightness="900")
left = self.bl().enforce("post", captured=None)
self.assertEqual([x for x in left if x.item.startswith("leds/")], [])
def test_a_lit_button_led_is_a_leftover(self):
# a target the run left standing is dirt, whatever the fade reads
self._led2(baseline.BUTTON_LEDS[0], target="1014", brightness="0")
left = self.bl().enforce("post", captured=None)
items = [x for x in left if x.item.startswith("leds/")]
self.assertEqual(len(items), 1)
self.assertEqual(items[0].found, "1014")
class CoolPublishTests(unittest.TestCase):
"""The cooling engine publishes once a tick. A status read inside the
tick after a run ended is the engine's last word on the run, not the
machine as the run left it. Found on the bench reference: a diagnostic
that finished clean (cooling.aa-offset-calibrate) and a fail tier that
did its work (cooling.fail-tier-stop) both failed their hand-back on a
hold the engine's next tick cleared."""
IDLE = {"phase": "idle", "armed": False, "hold": False}
def setUp(self):
self.lines = []
def bl(self, statuses):
"""A baseline whose /cool/status answers play in order (the last
one repeats) and whose waits count reads, not seconds: three for
the engine's next tick, as the real wait makes, and enough for the
cooldown to let a slow script run out."""
b = baseline.Baseline(self.lines.append)
seq = list(statuses)
b.fc_get = lambda path: (200, dict(seq.pop(0) if len(seq) > 1 else seq[0]))
def wait(what, pred, timeout):
for i in range(3 if what == "cool publish" else 50):
if pred():
return float(i)
return None
b._wait = wait
b.stood_down = []
b.stand_down = b.stood_down.append
return b
def test_a_diagnostic_hold_the_next_tick_clears_is_not_a_leftover(self):
left = []
b = self.bl([{"phase": "diag", "armed": False, "hold": True}, self.IDLE])
b._cool_side(left)
self.assertEqual(left, [])
self.assertTrue(any("last word on the run" in ln for ln in self.lines), self.lines)
def test_a_fail_tier_hold_clears_into_the_engines_own_post_job_phase(self):
left = []
b = self.bl([{"phase": "run", "armed": False, "hold": True},
{"phase": "smoke", "armed": False, "hold": False},
{"phase": "smoke", "armed": False, "hold": False}, self.IDLE])
b._cool_side(left)
self.assertEqual(left, [])
self.assertTrue(any("not a leftover" in ln for ln in self.lines), self.lines)
def test_a_hold_that_outlives_the_tick_is_the_runs_doing(self):
left = []
b = self.bl([{"phase": "run", "armed": True, "hold": True}])
b._cool_side(left)
self.assertEqual([x.item for x in left], ["cool"])
self.assertEqual(left[0].found, "run/armed=True/hold=True")
self.assertTrue(left[0].action.startswith("failed: still"), left[0].action)
self.assertEqual(len(b.stood_down), 1)
def test_a_hold_that_clears_only_later_is_still_recorded(self):
left = []
held = {"phase": "run", "armed": False, "hold": True}
b = self.bl([held] * 10 + [self.IDLE])
b._cool_side(left)
self.assertEqual([x.item for x in left], ["cool"])
self.assertEqual(left[0].action, "waited")
self.assertEqual(b.stood_down, [])
def test_an_idle_engine_and_a_silent_daemon_leave_nothing(self):
left = []
self.bl([self.IDLE])._cool_side(left)
b = baseline.Baseline(self.lines.append)
b.fc_get = lambda path: (None, None)
b._cool_side(left)
self.assertEqual(left, [])
class CloudOwnedSysfsTests(unittest.TestCase): class CloudOwnedSysfsTests(unittest.TestCase):
"""In cloud mode the cloud client configures the machine from the """In cloud mode the cloud client configures the machine from the
pulse header it is playing, the lens included: z_mode comes from pulse header it is playing, the lens included: z_mode comes from
@@ -651,52 +547,35 @@ class PositionDeadbandTests(unittest.TestCase):
where motion.lid-cancel-home returned the head twice, each within where motion.lid-cancel-home returned the head twice, each within
0.04 mm, and failed the hand-back on the four steps left over.""" 0.04 mm, and failed the hand-back on the four steps left over."""
def test_a_few_steps_of_xy_are_the_quantization(self):
self.assertTrue(baseline.position_quantized([0, 0, 0], [4, 0, 0]))
self.assertTrue(baseline.position_quantized([0, 0, 0], [-4, 4, 0]))
self.assertTrue(baseline.position_quantized([10, 20, 30], [10, 20, 30]))
def test_past_the_dead_band_is_a_leftover(self): def test_past_the_dead_band_is_a_leftover(self):
over = int(baseline.POSITION_DEADBAND_MM * baseline.XY_STEPS_PER_MM) + 2 over = int(baseline.POSITION_DEADBAND_MM * baseline.XY_STEPS_PER_MM) + 2
self.assertFalse(baseline.position_quantized([0, 0, 0], [over, 0, 0])) self.assertFalse(baseline.position_quantized([0, 0, 0], [over, 0, 0]))
self.assertFalse(baseline.position_quantized([0, 0, 0], [0, over, 0])) self.assertFalse(baseline.position_quantized([0, 0, 0], [0, over, 0]))
def test_the_dead_band_scales_with_the_counters(self):
# the same step count is a quarter of the distance at x32
over = int(baseline.POSITION_DEADBAND_MM * baseline.XY_STEPS_PER_MM) + 2
x32 = baseline.XY_STEPS_PER_MM_OF[32]
self.assertTrue(baseline.position_quantized([0, 0, 0], [over, 0, 0], x32))
self.assertFalse(baseline.position_quantized([0, 0, 0], [4 * over, 0, 0], x32))
def test_z_is_exact_because_the_return_never_moves_it(self):
self.assertFalse(baseline.position_quantized([0, 0, 0], [0, 0, 1]))
def test_an_unreadable_reading_is_never_quantization(self): def test_an_unreadable_reading_is_never_quantization(self):
self.assertFalse(baseline.position_quantized(None, [0, 0, 0])) self.assertFalse(baseline.position_quantized(None, [0, 0, 0]))
self.assertFalse(baseline.position_quantized([0, 0, 0], None)) self.assertFalse(baseline.position_quantized([0, 0, 0], None))
class BaselineModeTests(BaselineTests): class BaselineModeTests(BaselineFixture, unittest.TestCase):
"""The baseline against a fake forgectrl: what the mode in force """The baseline against a fake forgectrl: what the mode in force
owns. Reuses the fake sysfs tree of BaselineTests; only the new owns, over the same fake sysfs tree."""
tests run here (the inherited ones are skipped)."""
def setUp(self): def setUp(self):
super().setUp() super().setUp()
import helpers import helpers
self.fc = helpers.FakeForgectrl().start() self.fc = helpers.FakeForgectrl().start()
baseline.Baseline._unreachable_until = 0.0 baseline.Baseline._unreachable_until = 0.0
# the lamp's settle, short: five polls, so a lamp that never holds
# costs the suite no real seconds
self._lamp_timing = (baseline.LID_LAMP_SETTLE_S, baseline.LID_LAMP_POLL_S)
baseline.LID_LAMP_SETTLE_S, baseline.LID_LAMP_POLL_S = 0.05, 0.01
def tearDown(self): def tearDown(self):
baseline.LID_LAMP_SETTLE_S, baseline.LID_LAMP_POLL_S = self._lamp_timing
self.fc.stop() self.fc.stop()
super().tearDown() super().tearDown()
def run(self, result=None):
# only this class's own tests, not the base class's
if self._testMethodName not in BaselineModeTests.__dict__:
return
return super().run(result)
def cloud(self): def cloud(self):
self.fc.state["mode"] = {"mode": "cloud", "controller": "running", "pid": 7, "motion": "verified"} self.fc.state["mode"] = {"mode": "cloud", "controller": "running", "pid": 7, "motion": "verified"}
self.fc.state["settings"]["controller_mode"] = "cloud" self.fc.state["settings"]["controller_mode"] = "cloud"
@@ -843,3 +722,7 @@ class BaselineModeTests(BaselineTests):
cap["mode"] = "cloud" cap["mode"] = "cloud"
b.enforce("post", captured=cap) b.enforce("post", captured=cap)
self.assertEqual([p for p, _ in self.fc.posts if p == "/settings"], []) self.assertEqual([p for p, _ in self.fc.posts if p == "/settings"], [])
if __name__ == "__main__":
unittest.main()
+2 -72
View File
@@ -5,13 +5,11 @@
"""The bench registry against scripts/bench: every python tool is """The bench registry against scripts/bench: every python tool is
registered (or named as not-a-tool), every registered script exists, registered (or named as not-a-tool), every registered script exists,
every argument spec builds a command line, every script compiles, and a ported tool builds a command line with its defaults, and every
the shared helper resolves host and local mode as documented.""" script compiles."""
import glob import glob
import os import os
import py_compile import py_compile
import subprocess
import sys
import unittest import unittest
import helpers # noqa: F401 (sys.path) import helpers # noqa: F401 (sys.path)
@@ -56,78 +54,10 @@ class RegistryTests(unittest.TestCase):
self.assertTrue(ok, "%s: %s" % (t["id"], err)) self.assertTrue(ok, "%s: %s" % (t["id"], err))
self.assertEqual(argv[1], os.path.join(BENCH, t["script"])) self.assertEqual(argv[1], os.path.join(BENCH, t["script"]))
def test_flagged_args_build_option_pairs(self):
tool = next(t for t in bench_mod.TOOLS if t["id"] == "resume-dark-lead")
ok, argv, err = self.bench.command(tool, {"run": "live", "feed": 900})
self.assertTrue(ok, err)
self.assertIn("--run", argv)
self.assertEqual(argv[argv.index("--run") + 1], "live")
self.assertEqual(argv[argv.index("--feed") + 1], "900.0")
# an empty optional with an empty default is left off entirely
self.assertNotIn("--auto", argv)
# a choice outside the list is refused before anything runs
ok, _argv, err = self.bench.command(tool, {"run": "sideways"})
self.assertFalse(ok)
self.assertIn("run must be one of", err)
def test_unported_tools_are_host_only(self):
# what stays unported is what cannot run on the machine at all
for t in bench_mod.TOOLS:
if not t["ported"]:
self.assertEqual(t["where"], "host", t["id"])
self.assertIn("not a bench-page tool", t["desc"], t["id"])
def test_scripts_compile(self): def test_scripts_compile(self):
for s in self.scripts: for s in self.scripts:
py_compile.compile(os.path.join(BENCH, s), doraise=True) py_compile.compile(os.path.join(BENCH, s), doraise=True)
class GfbenchTests(unittest.TestCase):
"""gfbench.py in a subprocess (module-level host resolution)."""
def run_snippet(self, code, env_extra):
env = dict(os.environ)
env.pop("GF_HOST", None)
env.pop("FORGETEST_BENCH_DATA", None)
env.update(env_extra)
r = subprocess.run([sys.executable, "-c", code], cwd=BENCH, env=env,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=30)
return r.returncode, r.stdout.strip()
def test_host_mode(self):
rc, out = self.run_snippet("import gfbench; print(gfbench.HOST, gfbench.LOCAL, gfbench.SSH[0])",
{"GF_HOST": "192.0.2.7", "GF_SSH": "fakessh -x"})
self.assertEqual(rc, 0, out)
self.assertEqual(out, "192.0.2.7 False fakessh")
def test_local_when_named_local(self):
rc, out = self.run_snippet("import gfbench; print(gfbench.HOST, gfbench.LOCAL); "
"print(gfbench.board('echo hi').strip())",
{"GF_HOST": "127.0.0.1"})
self.assertEqual(rc, 0, out)
self.assertEqual(out.splitlines(), ["127.0.0.1 True", "hi"])
def test_refuses_without_a_machine(self):
if os.path.isdir("/sys/glowforge"):
self.skipTest("running on the machine")
rc, out = self.run_snippet("import gfbench", {})
self.assertNotEqual(rc, 0)
self.assertIn("GF_HOST", out)
def test_degc_and_data_dir(self):
import tempfile
d = tempfile.mkdtemp(prefix="forgetest-bench-")
rc, out = self.run_snippet(
"import gfbench, os; print(round(gfbench.degc(700), 2), gfbench.degc('x') != gfbench.degc('x'), "
"os.path.dirname(gfbench.data_path('f.json')) == os.environ['FORGETEST_BENCH_DATA'])",
{"GF_HOST": "127.0.0.1", "FORGETEST_BENCH_DATA": d})
self.assertEqual(rc, 0, out)
val, nan, indir = out.split()
# a plausible coolant reading (700 counts is room temperature territory)
self.assertTrue(15.0 < float(val) < 35.0, val)
self.assertEqual(nan, "True")
self.assertEqual(indir, "True")
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -14,7 +14,6 @@ session whose one motion the cooling engine stopped short, and a whole
three-motion one. Runs against scratch logs and fakes for the controller.""" three-motion one. Runs against scratch logs and fakes for the controller."""
import os import os
import shutil import shutil
import sys
import tempfile import tempfile
import unittest import unittest
@@ -130,30 +129,6 @@ class HandBackTests(unittest.TestCase):
def expect_jog(self, dx, dy): def expect_jog(self, dx, dy):
self.assertEqual(self.ctx.jogs, ["$J=G91 G21 X%.3f Y%.3f F2400" % (-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 -------------------------------------------- # -- the stopped-short judge --------------------------------------------
def test_a_motion_stopped_short_fails_the_judge(self): def test_a_motion_stopped_short_fails_the_judge(self):
self.write(STOPPED) self.write(STOPPED)
@@ -162,12 +137,6 @@ class HandBackTests(unittest.TestCase):
homeoff.judge_whole_motions(self.ctx, ev, 0) homeoff.judge_whole_motions(self.ctx, ev, 0)
self.assertEqual(len(ev["short_motions"]), 2) 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 ------------------------------------------------- # -- after a camera home -------------------------------------------------
def test_homed_the_head_goes_back_by_the_travel_and_the_counters_since(self): 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 # the home zeroed the counters; the test's own jogs left a step or so
@@ -228,21 +197,6 @@ class HandBackTests(unittest.TestCase):
homeoff.cloud_mode_return(self.ctx, {}, 0) homeoff.cloud_mode_return(self.ctx, {}, 0)
self.assertEqual(self.ctx.jogs, []) 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__": if __name__ == "__main__":
unittest.main() unittest.main()
File diff suppressed because it is too large Load Diff
+8 -765
View File
@@ -3,774 +3,16 @@
# https://community.openglow.org # https://community.openglow.org
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
"""cooling.fans-quiet-after-motion and cooling.gate-off replayed host-side """cooling.flow-under-load reads the engine's flow verdict from what the
under the real runner Context against a scripted machine: a fake forgectrl log gained since the job began: only the text after the offset,
forgectrl (/status fans, /cool/status phase and verdict, /settings with never an older verdict before it, and the whole of a log rotated under
the gates table, /logs/tail), a fake kernel sysfs (the fan duties), and the test."""
a fake Grbl port that answers '?' and every command.
The bench case that motivated this: the test started one second after
the engine of a previous test went idle, took the tachs still coasting
at the cooldown level as its idle reference, and then waited for the
fans to come back UP to it. The idle reference now needs the engine
idle, the idle duty applied, and tachs that have stopped changing; the
pass condition is at-or-below that level, and every sample is logged.
"""
import os import os
import shutil import shutil
import socket
import tempfile import tempfile
import threading
import time
import unittest import unittest
import helpers from forgetest.suite import cooling
from forgetest.runner import Context, Failed, Run
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:
"""Answers '?' with an Idle report and every line with ok."""
def __init__(self):
self.sock = socket.socket()
self.sock.bind(("127.0.0.1", 0))
self.sock.listen(2)
os.environ["GRBL_HOST"] = "127.0.0.1"
os.environ["GRBL_PORT"] = str(self.sock.getsockname()[1])
self.commands = []
self.on_command = None
self._stop = False
threading.Thread(target=self._serve, daemon=True).start()
def _serve(self):
self.sock.settimeout(0.2)
while not self._stop:
try:
c, _ = self.sock.accept()
except OSError:
continue
threading.Thread(target=self._client, args=(c,), daemon=True).start()
def _client(self, c):
c.settimeout(0.1)
buf = b""
c.sendall(b"\r\nGrbl 1.1f ['$' for help]\r\n")
while not self._stop:
try:
d = c.recv(4096)
except socket.timeout:
continue
except OSError:
break
if not d:
break
for ch in d:
if ch == 0x3F: # '?'
c.sendall(b"<Idle|MPos:0.000,0.000,0.000|FS:0,0>\r\n")
elif ch in (0x18, 0x85):
pass
else:
buf += bytes([ch])
while b"\n" in buf:
line, buf = buf.split(b"\n", 1)
line = line.decode().strip()
if line:
self.commands.append(line)
if self.on_command:
self.on_command(line)
c.sendall(b"ok\r\n")
c.close()
def close(self):
self._stop = True
self.sock.close()
for k in ("GRBL_HOST", "GRBL_PORT"):
os.environ.pop(k, None)
class FansQuietTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp(prefix="forgetest-cool-")
self.sysfs = os.path.join(self.tmp, "sysfs") + os.sep
os.makedirs(self.sysfs + "thermal")
os.makedirs(self.sysfs + "cnc")
with open(self.sysfs + "cnc/state", "w") as f:
f.write("idle")
os.environ["GF_SYSFS_ROOT"] = self.sysfs
self.fc = helpers.FakeForgectrl().start()
self.grbl = FakeGrbl()
self.saved = (cooling.SAMPLE_S, cooling.IDLE_REF_TIMEOUT_S, cooling.COOLDOWN_TIMEOUT_S,
cooling.RESTART_IDLE_TIMEOUT_S, cooling.hw.initd)
cooling.SAMPLE_S = 0.1
cooling.IDLE_REF_TIMEOUT_S = 3
cooling.COOLDOWN_TIMEOUT_S = 4
cooling.RESTART_IDLE_TIMEOUT_S = 1
# The daemon restart of the last phase, scripted: the init script
# is a stub, and the engine that comes up applies `after_start`
# (the idle duties by default; a daemon that keeps the busy-start
# cooldown airflow applies those instead).
self.initd_calls = []
self.after_start = (0, 0)
def initd(service, action, timeout=60):
self.initd_calls.append((service, action))
if action == "start":
self.duty(*self.after_start)
return 0, ""
cooling.hw.initd = initd
self.fc.state["cool"] = {"phase": "idle", "armed": False, "hold": False}
self.duty(0, 0)
self.fans(0, 736, 733)
def tearDown(self):
(cooling.SAMPLE_S, cooling.IDLE_REF_TIMEOUT_S, cooling.COOLDOWN_TIMEOUT_S,
cooling.RESTART_IDLE_TIMEOUT_S, cooling.hw.initd) = self.saved
self.grbl.close()
self.fc.stop()
os.environ.pop("GF_SYSFS_ROOT", None)
shutil.rmtree(self.tmp, ignore_errors=True)
# -- the machine -------------------------------------------------------
def duty(self, exhaust, intake):
for attr, v in (("thermal/exhaust_pwm", exhaust), ("thermal/intake_pwm", intake)):
with open(self.sysfs + attr, "w") as f:
f.write(str(v))
def fans(self, exhaust, i1, i2, air=2080):
self.fc.state["status"] = dict(self.fc.state["status"],
fans={"air_assist": air, "exhaust": exhaust, "intake_1": i1, "intake_2": i2})
def run_engine(self):
"""M8 -> run profile; M9 -> smoke phase, then idle duty, then the
tachs coast down (a few samples of spin-down), like forgectrl's
engine on the bench."""
def on_command(line):
if line == "M8":
self.duty(65535, 43278)
self.fc.state["cool"]["phase"] = "run"
self.fans(6753, 3212, 3328, air=10997)
elif line == "M9":
def cooldown():
self.fc.state["cool"]["phase"] = "smoke"
time.sleep(0.3)
self.fc.state["cool"]["phase"] = "idle"
self.duty(0, 0)
for ex, i1 in ((5030, 3319), (3100, 2200), (1200, 1300), (0, 740)):
self.fans(ex, i1, i1 + 5)
time.sleep(0.25)
threading.Thread(target=cooldown, daemon=True).start()
self.grbl.on_command = on_command
def run_test(self):
run = Run("test", "cooling.fans-quiet-after-motion", "t")
ctx = Context(run, None, helpers.make_test("cooling.fans-quiet-after-motion", []))
cooling.fans_quiet(ctx)
return run
# -- cases ------------------------------------------------------------------
def test_idle_machine_passes_and_logs_every_sample(self):
self.run_engine()
run = self.run_test()
self.assertEqual(run.evidence["before"]["exhaust"], 0)
self.assertIsNotNone(run.evidence["settle_s"])
self.assertEqual(run.evidence["duty_after"], cooling.IDLE_DUTY)
self.assertIn("M8", self.grbl.commands)
self.assertIn("M9", self.grbl.commands)
cooldown_lines = [ln for ln in run.lines if "cooldown +" in ln]
self.assertGreaterEqual(len(cooldown_lines), 2, run.lines)
def test_a_spin_down_in_progress_is_not_taken_as_the_idle_reference(self):
"""The bench case: the previous test's fans are still coasting when
this one starts. The reference waits for them to settle, and the
run passes instead of waiting for the fans to come back up."""
self.run_engine()
self.fans(5030, 3319, 3377) # coasting, engine already idle, duty 0
def coast():
for ex, i1 in ((3900, 2600), (2500, 1700), (1100, 1000), (0, 736), (0, 736)):
time.sleep(0.12)
self.fans(ex, i1, i1 + 5)
threading.Thread(target=coast, daemon=True).start()
run = self.run_test()
self.assertEqual(run.evidence["before"]["exhaust"], 0, run.evidence["before"])
self.assertGreater(run.evidence["idle_ref_s"], 0.3)
self.assertIsNotNone(run.evidence["settle_s"])
self.assertTrue(any("idle ref:" in ln for ln in run.lines))
def test_the_restart_phase_stops_disables_starts_and_sees_idle_duty(self):
self.run_engine()
run = self.run_test()
self.assertEqual(run.finished, None)
self.assertEqual(self.initd_calls, [("forgectrl", "stop"), ("forgectrl", "start")])
with open(self.sysfs + "cnc/disable") as f:
self.assertEqual(f.read(), "1")
self.assertEqual(run.evidence["duty_after_restart"], cooling.IDLE_DUTY)
self.assertIsNotNone(run.evidence["restart_idle_s"])
def test_a_daemon_that_keeps_the_busy_start_airflow_fails(self):
# The bench case: after a takeover's restart the exhaust ran at
# cooldown duty on an idle machine for good.
self.run_engine()
self.after_start = (32768, 21639)
with self.assertRaises(Failed) as cm:
self.run_test()
self.assertIn("kept the busy-start airflow", str(cm.exception))
self.assertIn("32768", str(cm.exception))
def test_fans_left_running_fail_with_the_reference_in_the_message(self):
def on_command(line):
if line == "M8":
self.duty(65535, 43278)
self.fc.state["cool"]["phase"] = "run"
self.fans(6753, 3212, 3328)
# M9 ignored: the run profile stays on
self.grbl.on_command = on_command
run = Run("test", "cooling.fans-quiet-after-motion", "t")
ctx = Context(run, None, helpers.make_test("cooling.fans-quiet-after-motion", []))
with self.assertRaises(Failed) as cm:
cooling.fans_quiet(ctx)
self.assertIn("did not return to the idle profile", str(cm.exception))
self.assertIn("idle reference", str(cm.exception))
self.assertIn("65535", str(cm.exception))
def test_a_reference_that_never_settles_fails_early_and_says_so(self):
def churn():
seq = (1000, 4000, 2500, 700, 3300, 1600, 4200) # aperiodic against the sampler
i = 0
while not self.grbl._stop:
v = seq[i % len(seq)]
i += 1
self.fans(v, v, v)
time.sleep(0.03)
threading.Thread(target=churn, daemon=True).start()
run = Run("test", "cooling.fans-quiet-after-motion", "t")
ctx = Context(run, None, helpers.make_test("cooling.fans-quiet-after-motion", []))
with self.assertRaises(Failed) as cm:
cooling.fans_quiet(ctx)
self.assertIn("never settled to an idle reference", str(cm.exception))
self.assertEqual(self.grbl.commands, []) # nothing was jogged
if __name__ == "__main__":
unittest.main()
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
gate and reports gates_off when the ceiling sits at its top, and
writes the run-start line the test looks for."""
TOP, BOTTOM = 60.0, 5.0
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
cooling.SESSION_END_WAIT_S = 3
self.fc.state["status"] = dict(self.fc.state["status"],
coolant={"down_c": 22.4, "up_c": 22.3, "pump": True, "tec": False},
temps={"chassis_c": 29.0, "supply_raw": 589, "soc_c": 42.8,
"soc_throttle": 0},
gates_off=[])
self.fc.state["cool"] = {"phase": "idle", "verdict": "OK", "fire_ok": False, "hold": False,
"gates_off": []}
self.fc.state["settings"].update({"cool_temp_max": "", "cool_temp_resume": ""})
self.log_line = True # the engine writes its run-start line
self.temps_line = True # the engine writes its run-end temperature line
self.report_off = True # the engine reports the off gate
self.trips = True # the engine trips a low ceiling
self.sessions = 0 # run sessions the engine saw (M8 with the phase not run)
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 = self.saved
self.grbl.close()
self.fc.stop()
self.log_teardown()
# -- the scripted machine --------------------------------------------------
def ceiling(self):
v = self.fc.state["settings"].get("cool_temp_max") or ""
return float(v) if v else 33.0
def _describe(self):
"""/settings carries the gates table the way forgectrl's gates.c
publishes it, classified from the stored value."""
v = self.ceiling()
state = "off" if v >= self.TOP else ("ok" if 25 <= v <= 38 else "warn")
self.fc.state["settings"]["gates"] = {
"cool_temp_max": {"gate": "coolant_max", "def": 33, "lo": self.BOTTOM, "hi": self.TOP,
"band": [25, 38], "off": "high", "value": v, "state": state}}
def _on_post(self, path, form):
"""The real settings reply re-classifies the gates from the new values."""
if path != "/settings":
return None
self.fc.state["settings"].update(form)
self._describe()
return (200, self.fc.state["settings"])
def _engine(self, line):
"""M8 opens a run session: the engine re-reads the ceiling and
ticks the gate. M9 ends it a report period later (the phase
leaves run); a hold taken against the old ceiling stands until
the next session re-reads."""
self._describe()
cool = self.fc.state["cool"]
if line == "M9":
def end():
time.sleep(0.3)
cool["phase"] = "smoke"
if self.temps_line:
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)
cool["phase"] = "idle"
threading.Thread(target=end, daemon=True).start()
return
if line != "M8":
return
self.sessions += 1
cool["phase"] = "run"
v = self.ceiling()
off = v >= self.TOP
if off and self.log_line:
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 []
if not off and self.trips and 22.3 > v:
cool.update(verdict="OVERTEMP", fire_ok=False, hold=True, gates_off=gates_off)
else:
cool.update(verdict="OK", fire_ok=True, hold=False, gates_off=gates_off)
self.fc.state["status"]["gates_off"] = gates_off
def run_test(self):
run = Run("test", "cooling.gate-off", "t")
ctx = Context(run, None, helpers.make_test("cooling.gate-off", []))
cooling.gate_off(ctx)
return run
def settings_posts(self):
return [f for p, f in self.fc.posts if p == "/settings"]
# -- cases ------------------------------------------------------------------
def test_trip_then_off_then_restored_passes(self):
run = self.run_test()
self.assertEqual(run.evidence["trip"]["verdict"], "OVERTEMP")
self.assertEqual(run.evidence["off"]["gates_off"], ["coolant_max"])
self.assertEqual(run.evidence["restored"]["verdict"], "OK")
posts = self.settings_posts()
self.assertEqual(posts[0], {"cool_temp_max": "6.0", "cool_temp_resume": "5.0"})
self.assertEqual(posts[1], {"cool_temp_max": "60.0", "cool_temp_resume": ""})
self.assertEqual(posts[-1], {"cool_temp_max": "", "cool_temp_resume": ""})
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "")
self.assertEqual(self.grbl.commands.count("M8"), 3)
self.assertEqual(self.grbl.commands.count("M9"), 3)
def test_an_engine_that_does_not_trip_fails_and_restores(self):
self.trips = False
with self.assertRaises(Failed) as cm:
self.run_test()
self.assertIn("did not trip", str(cm.exception))
self.assertEqual(self.settings_posts()[-1], {"cool_temp_max": "", "cool_temp_resume": ""})
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "")
# The restore cycles a run session so the engine re-reads the
# restored values; the bench is not left holding on the test's.
self.assertEqual(self.grbl.commands.count("M8"), 2)
self.assertEqual(self.grbl.commands.count("M9"), 2)
self.assertEqual(self.fc.state["cool"]["verdict"], "OK")
def test_every_session_waits_for_the_previous_one_to_end(self):
"""Each M8 must find the engine out of phase run, or the engine
never re-reads: the bench failure behind this case sent M9 and
the next M8 300 ms apart and the 1 Hz report pipeline swallowed
the session end."""
seen = []
inner = self._engine
def engine(line):
if line == "M8":
seen.append(self.fc.state["cool"]["phase"])
inner(line)
self.grbl.on_command = engine
self.run_test()
self.assertEqual(seen, ["idle", "idle", "idle"])
self.assertEqual(self.sessions, 3)
def test_an_engine_that_hides_the_off_gate_fails(self):
self.report_off = False
with self.assertRaises(Failed) as cm:
self.run_test()
self.assertIn("gates_off", str(cm.exception))
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "")
def test_a_missing_run_start_log_line_fails(self):
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_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:
self.run_test()
self.assertIn("board-temperature line", str(cm.exception))
def test_a_custom_ceiling_is_restored_verbatim(self):
self.fc.state["settings"].update({"cool_temp_max": "30", "cool_temp_resume": "28"})
self._describe()
run = self.run_test()
self.assertEqual(run.evidence["orig"], {"cool_temp_max": "30", "cool_temp_resume": "28"})
self.assertEqual(self.settings_posts()[-1], {"cool_temp_max": "30", "cool_temp_resume": "28"})
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "30")
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
cross-check refusing a critical line at or below the ceiling."""
UP = 22.3
ROWS = { # key: (gate, default, lo, hi, band_lo, band_hi, off)
"cool_temp_max": ("coolant_max", 33.0, 5.0, 60.0, 25.0, 38.0, "high"),
"cool_temp_resume": (None, 31.0, 5.0, 59.0, 20.0, 36.0, "none"),
"cool_temp_critical_c": ("coolant_critical", 38.0, 6.0, 70.0, 36.0, 45.0, "high"),
}
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
cooling.SESSION_END_WAIT_S = 3
self.fc.state["status"] = dict(self.fc.state["status"],
coolant={"down_c": 22.4, "up_c": self.UP, "pump": True, "tec": False},
gates_off=[])
self.fc.state["cool"] = {"phase": "idle", "verdict": "OK", "fire_ok": False, "hold": False,
"gates_off": []}
for k in self.ROWS:
self.fc.state["settings"].setdefault(k, "")
self.fc.state["logs_tail"] = {"text": ""}
self.faults = True # the engine has the critical tier
self.ends_with_session = True
self.cross_checks = True # the settings API refuses crit <= max
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 = self.saved
self.grbl.close()
self.fc.stop()
self.log_teardown()
def setting(self, key):
v = self.fc.state["settings"].get(key) or ""
return float(v) if v else self.ROWS[key][1]
def _describe(self):
gates = {}
for k, (gate, d, lo, hi, blo, bhi, off) in self.ROWS.items():
v = self.setting(k)
state = "off" if (off == "high" and v >= hi) else ("ok" if blo <= v <= bhi else "warn")
gates[k] = {"gate": gate, "def": d, "lo": lo, "hi": hi, "band": [blo, bhi], "off": off,
"value": v, "state": state}
self.fc.state["settings"]["gates"] = gates
def _on_post(self, path, form):
if path != "/settings":
return None
if self.cross_checks:
merged = dict(self.fc.state["settings"])
merged.update(form)
tmax = float(merged.get("cool_temp_max") or 33.0)
tcrit = float(merged.get("cool_temp_critical_c") or 38.0)
ceiling_off = tmax >= self.ROWS["cool_temp_max"][3]
if not ceiling_off and tcrit <= tmax:
return (400, {"error": "cool_temp_critical_c must be above cool_temp_max"})
self.fc.state["settings"].update(form)
self._describe()
return (200, self.fc.state["settings"])
def _verdict_idle(self):
"""Outside a run only the ceiling's pause tier stands."""
return "OVERTEMP" if self.UP > self.setting("cool_temp_max") else "OK"
def _engine(self, line):
self._describe()
cool = self.fc.state["cool"]
if line == "M9":
def end():
time.sleep(0.3)
cool["phase"] = "idle"
if self.ends_with_session or cool["verdict"] != "CRITICAL":
v = self._verdict_idle()
cool.update(verdict=v, fire_ok=v == "OK", hold=v != "OK", reason="")
threading.Thread(target=end, daemon=True).start()
return
if line != "M8":
return
cool["phase"] = "run"
tmax, tcrit = self.setting("cool_temp_max"), self.setting("cool_temp_critical_c")
crit_off = tcrit >= self.ROWS["cool_temp_critical_c"][3]
off = ["coolant_critical"] if crit_off else []
if crit_off:
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:
cool.update(verdict="CRITICAL", fire_ok=False, hold=True, resume_ok=False,
reason="CRITICAL: coolant %.1f C at or over the %.0f C critical line - hold, "
"no resume this job" % (self.UP, tcrit), gates_off=off)
elif self.UP > tmax:
cool.update(verdict="OVERTEMP", fire_ok=False, hold=True, resume_ok=False,
reason="coolant over the ceiling", gates_off=off)
else:
cool.update(verdict="OK", fire_ok=True, hold=False, resume_ok=True, reason="", gates_off=off)
self.fc.state["status"]["gates_off"] = off
def run_test(self):
run = Run("test", "cooling.critical-tier", "t")
ctx = Context(run, None, helpers.make_test("cooling.critical-tier", []))
cooling.critical_tier(ctx)
return run
def settings_posts(self):
return [f for p, f in self.fc.posts if p == "/settings"]
# -- cases ------------------------------------------------------------------
def test_fault_then_off_then_restored_passes(self):
run = self.run_test()
self.assertEqual(run.evidence["cross_check"]["status"], 400)
self.assertEqual(run.evidence["critical"]["verdict"], "CRITICAL")
self.assertEqual(run.evidence["critical_after"]["verdict"], "OVERTEMP")
self.assertEqual(run.evidence["critical_off"]["gates_off"], ["coolant_critical"])
self.assertEqual(run.evidence["restored"]["verdict"], "OK")
self.assertEqual(run.evidence["ceiling_off_accepted"], 200)
posts = self.settings_posts()
# refused cross-check, the ceiling at its off end (accepted), its undo, then the low leg
self.assertEqual(posts[1], {"cool_temp_max": "60.0"})
self.assertEqual(posts[3], {"cool_temp_max": "6.0", "cool_temp_resume": "5.0",
"cool_temp_critical_c": "7.0"})
self.assertEqual(posts[-1], {"cool_temp_max": "", "cool_temp_resume": "", "cool_temp_critical_c": ""})
self.assertEqual(self.grbl.commands.count("M8"), 3)
self.assertEqual(self.grbl.commands.count("M9"), 3)
def test_an_engine_without_the_tier_fails_and_restores(self):
self.faults = False
with self.assertRaises(Failed) as cm:
self.run_test()
self.assertIn("did not fault", str(cm.exception))
self.assertEqual(self.settings_posts()[-1],
{"cool_temp_max": "", "cool_temp_resume": "", "cool_temp_critical_c": ""})
self.assertEqual(self.fc.state["cool"]["verdict"], "OK")
def test_a_fault_that_outlives_its_session_fails(self):
self.ends_with_session = False
with self.assertRaises(Failed) as cm:
self.run_test()
self.assertIn("after the faulted session", str(cm.exception))
def test_a_missing_cross_check_fails_before_any_session(self):
self.cross_checks = False
with self.assertRaises(Failed) as cm:
self.run_test()
self.assertIn("was accepted", str(cm.exception))
self.assertEqual(self.grbl.commands.count("M8"), 0)
class FanGateTests(unittest.TestCase):
"""cooling.fan-gate-trips against a scripted engine: the fake reads the
floors at every M8 (the engine reloads at run start), holds every fan
in grace for the configured seconds, then judges the bench readings
(exhaust 6753, intakes 3212/3328, air assist 10997 rpm, purge 628)
against the floors: a floor a reading cannot meet trips AIRFLOW three
ticks after the grace, a floor of zero reads off."""
READINGS = {"exhaust": 6753, "intake_1": 3212, "intake_2": 3328, "air_assist": 10997, "purge": 628}
FLOORS = {"exhaust": ("cool_tach_exhaust_min_rpm", 3700.0, 20000.0),
"intake_1": ("cool_tach_intake_min_rpm", 1800.0, 20000.0),
"intake_2": ("cool_tach_intake_min_rpm", 1800.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.FAN_TRIP_WAIT_S)
cooling.VERDICT_WAIT_S = 4
cooling.SESSION_END_WAIT_S = 3
cooling.FAN_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.trips = True # the engine trips an unmeetable floor
self.reports_off = True # the engine reports a zero floor as off
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.FAN_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.7, default * 1.4], "off": "low", "value": v,
"state": "off" if v <= 0 else "ok"}
g = self.setting("cool_fan_grace_s", 15.0)
gates["cool_fan_grace_s"] = {"gate": None, "def": 15.0, "lo": 0.0, "hi": 120.0, "band": [5, 30],
"off": "none", "value": g, "state": "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"
# the fan fault is the session's: it ends with it
cool.update(verdict="OK", fire_ok=True, hold=False, reason="")
for g in cool.get("fan_gates", {}).values():
g["state"] = "idle"
threading.Thread(target=end, daemon=True).start()
return
if line != "M8":
return
cool["phase"] = "run"
floors = {fan: self.setting(key, default) for fan, (key, default, _h) in self.FLOORS.items()}
off = sorted({self.GATE_OF[f] for f, v in floors.items() if v <= 0}) if self.reports_off else []
cool.update(verdict="OK", fire_ok=True, hold=False, resume_ok=True, reason="", gates_off=off,
fan_gates={f: {"reading": self.READINGS[f], "floor": floors[f],
"state": "off" if floors[f] <= 0 else "grace"} for f in floors})
self.fc.state["status"]["gates_off"] = off
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 floors[f] > 0:
cool["fan_gates"][f]["state"] = "ok" if self.READINGS[f] >= floors[f] else "under"
time.sleep(0.3)
if cool["phase"] != "run" or not self.trips:
return
for f in floors:
if 0 < floors[f] > self.READINGS[f]:
cool["fan_gates"][f]["state"] = "TRIPPED"
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]))
break
threading.Thread(target=judge, daemon=True).start()
def run_test(self):
run = Run("test", "cooling.fan-gate-trips", "t")
ctx = Context(run, None, helpers.make_test("cooling.fan-gate-trips", []))
cooling.fan_gate_trips(ctx)
return run
def settings_posts(self):
return [f for p, f in self.fc.posts if p == "/settings"]
def test_trip_purge_off_and_restore_pass(self):
run = self.run_test()
ev = run.evidence
self.assertEqual(ev["exhaust_trip"]["cool"]["verdict"], "AIRFLOW")
self.assertEqual(ev["exhaust_trip"]["gate"]["state"], "TRIPPED")
self.assertEqual(ev["purge_trip"]["gate"]["state"], "TRIPPED")
self.assertEqual(ev["exhaust_off"]["cool"]["gates_off"], ["exhaust"])
self.assertTrue(all(g["state"] == "ok" for g in ev["restored"].values()))
posts = self.settings_posts()
self.assertEqual(posts[0], {"cool_tach_exhaust_min_rpm": "20000.0", "cool_fan_grace_s": "8"})
self.assertEqual(posts[-1], {"cool_tach_exhaust_min_rpm": "", "cool_purge_min_current": "",
"cool_fan_grace_s": ""})
self.assertEqual(self.grbl.commands.count("M8"), 4)
self.assertEqual(self.grbl.commands.count("M9"), 4)
def test_an_engine_that_does_not_trip_fails_and_restores(self):
self.trips = False
with self.assertRaises(Failed) as cm:
self.run_test()
self.assertIn("did not trip", str(cm.exception))
self.assertEqual(self.settings_posts()[-1], {"cool_tach_exhaust_min_rpm": "", "cool_purge_min_current": "",
"cool_fan_grace_s": ""})
self.assertEqual(self.fc.state["cool"]["verdict"], "OK")
def test_an_engine_that_hides_an_off_floor_fails(self):
self.reports_off = False
with self.assertRaises(Failed) as cm:
self.run_test()
self.assertIn("lacks exhaust", str(cm.exception))
self.assertEqual(self.fc.state["settings"]["cool_tach_exhaust_min_rpm"], "")
class FlowVerdictLogTests(unittest.TestCase): class FlowVerdictLogTests(unittest.TestCase):
@@ -817,5 +59,6 @@ class FlowVerdictLogTests(unittest.TestCase):
self.write(self.VERDICT) # the rotation: a new, shorter file self.write(self.VERDICT) # the rotation: a new, shorter file
self.assertEqual(cooling._log_since(self.path, off), self.VERDICT) self.assertEqual(cooling._log_since(self.path, off), self.VERDICT)
def test_a_missing_log_reads_as_nothing(self):
self.assertEqual(cooling._log_since(os.path.join(self.dir, "none.log"), 0), "") if __name__ == "__main__":
unittest.main()
+225 -230
View File
@@ -7,19 +7,16 @@
The fixture is a box on the network that opens the lid loop, pulls the The fixture is a box on the network that opens the lid loop, pulls the
interlock loop and presses the button on request. What has to hold: the interlock loop and presses the button on request. What has to hold: the
tool finds it by name without a resolver on the image, speaks its API tool speaks its API under the key, falls back to the operator when it
under the key, falls back to the operator when it cannot act, moves an cannot act, moves an operator test it can perform alone into the
operator test it can perform alone into the unattended queue (never a unattended queue (never a live one, never one that needs a person for
live one, never one that needs a person for anything else), refuses a anything else), refuses a prompt during such a run rather than hanging on
prompt during such a run rather than hanging on it, releases what the it, releases what the box still holds after every run, and presses the
box still holds after every run, and presses the arm button only where arm button only where the bench opted in.
the bench opted in.
""" """
import json import json
import os import os
import shutil import shutil
import socket
import struct
import tempfile import tempfile
import threading import threading
import time import time
@@ -30,7 +27,7 @@ import helpers
from forgetest import fixture as fx from forgetest import fixture as fx
from forgetest import runner as runner_mod from forgetest import runner as runner_mod
from forgetest.log import Log from forgetest.log import Log
from forgetest.runner import Context, Failed, Run, Runner from forgetest.runner import Context, Run, Runner
KEY = "0123456789abcdef0123456789abcdef" KEY = "0123456789abcdef0123456789abcdef"
@@ -43,6 +40,8 @@ class FakeFixture:
self.button_enabled = button_enabled self.button_enabled = button_enabled
self.calls = [] self.calls = []
self.presses = 0 # presses the device performed self.presses = 0 # presses the device performed
self.reply_delay = 0.0 # seconds between a press and its reply
self.replied = 0 # delayed replies the device is done with (sent or lost)
srv = self srv = self
class H(BaseHTTPRequestHandler): class H(BaseHTTPRequestHandler):
@@ -98,6 +97,14 @@ class FakeFixture:
threading.Timer(0.05, lambda: srv.state.__setitem__("button", "idle")).start() threading.Timer(0.05, lambda: srv.state.__setitem__("button", "idle")).start()
s = self._state() s = self._state()
s["pulse_ms"] = 200 s["pulse_ms"] = 200
if srv.reply_delay:
time.sleep(srv.reply_delay) # the reply comes after the client gave up
try:
self._json(200, s)
except OSError:
pass
srv.replied += 1
return None
return self._json(200, s) return self._json(200, s)
if self.path == "/release": if self.path == "/release":
srv.state = {"lid": "closed", "interlock": "closed", "button": "idle"} srv.state = {"lid": "closed", "interlock": "closed", "button": "idle"}
@@ -112,37 +119,10 @@ class FakeFixture:
self.httpd.shutdown() self.httpd.shutdown()
self.httpd.server_close() self.httpd.server_close()
def wait_replied(self, n, timeout=5.0):
class MdnsTests(unittest.TestCase): deadline = time.time() + timeout
def test_query_packet(self): while self.replied < n and time.time() < deadline:
q = fx.mdns_query("forgefixture.local") time.sleep(0.02)
self.assertEqual(q[:12], struct.pack("!HHHHHH", 0, 0, 1, 0, 0, 0))
self.assertEqual(q[12:], b"\x0cforgefixture\x05local\x00" + struct.pack("!HH", 1, 0x8001))
def _response(self, name, ip, extra_name=None):
labels = b"".join(struct.pack("B", len(p)) + p.encode() for p in name.split(".")) + b"\x00"
hdr = struct.pack("!HHHHHH", 0, 0x8400, 0, 2 if extra_name else 1, 0, 0)
rr = labels + struct.pack("!HHIH", 1, 0x8001, 120, 4) + socket.inet_aton(ip)
if extra_name:
# a second answer naming the first through a compression pointer
# to offset 12 (the first name), plus its own first label
other = struct.pack("B", len(extra_name)) + extra_name.encode() + struct.pack("!H", 0xC000 | 12)
rr += other + struct.pack("!HHIH", 1, 0x8001, 120, 4) + socket.inet_aton("10.0.0.9")
return hdr + rr
def test_answers_are_the_named_a_records(self):
data = self._response("forgefixture.local", "192.0.2.50")
self.assertEqual(fx.mdns_answers(data, "forgefixture.local"), ["192.0.2.50"])
self.assertEqual(fx.mdns_answers(data, "FORGEFIXTURE.local."), ["192.0.2.50"])
self.assertEqual(fx.mdns_answers(data, "other.local"), [])
# a query (QR clear) is never an answer
self.assertEqual(fx.mdns_answers(fx.mdns_query("forgefixture.local"), "forgefixture.local"), [])
# compression pointers are followed; the other record is not ours
data2 = self._response("forgefixture.local", "192.0.2.50", extra_name="printer")
self.assertEqual(fx.mdns_answers(data2, "forgefixture.local"), ["192.0.2.50"])
self.assertEqual(fx.mdns_answers(data2, "printer.forgefixture.local"), ["10.0.0.9"])
# garbage does not raise
self.assertEqual(fx.mdns_answers(b"\x00\x01", "forgefixture.local"), [])
class ConfigTests(unittest.TestCase): class ConfigTests(unittest.TestCase):
@@ -190,11 +170,11 @@ class ClientTests(unittest.TestCase):
self.resolved.append(hostname) self.resolved.append(hostname)
return "127.0.0.1" return "127.0.0.1"
def client(self, **over): def client(self, timeout=3.0, resolver=None, **over):
cfg = {"hostname": "forgefixture", "key": KEY, "channels": ["lid", "interlock", "button"], cfg = {"hostname": "forgefixture", "key": KEY, "channels": ["lid", "interlock", "button"],
"port": self.dev.port} "port": self.dev.port}
cfg.update(over) cfg.update(over)
return fx.Fixture(cfg, resolver=self.resolver) return fx.Fixture(cfg, timeout=timeout, resolver=resolver or self.resolver)
def test_status_covers_act_release(self): def test_status_covers_act_release(self):
f = self.client() f = self.client()
@@ -208,7 +188,7 @@ class ClientTests(unittest.TestCase):
f.act("button", "press") f.act("button", "press")
self.assertEqual(self.dev.calls[-1], ("POST", "/button", {})) self.assertEqual(self.dev.calls[-1], ("POST", "/button", {}))
time.sleep(0.15) time.sleep(0.15)
# a check that must reach the machine while the button is down asks for the longest press # a pulse length asked for goes to the box as asked; the clamp is the box's
f.act("button", "press", ms=500) f.act("button", "press", ms=500)
self.assertEqual(self.dev.calls[-1], ("POST", "/button", {"ms": 500})) self.assertEqual(self.dev.calls[-1], ("POST", "/button", {"ms": 500}))
time.sleep(0.15) # the fake's pulse ends time.sleep(0.15) # the fake's pulse ends
@@ -231,36 +211,34 @@ class ClientTests(unittest.TestCase):
f.act("button", "press") f.act("button", "press")
self.assertIn("jumper", str(cm.exception)) self.assertIn("jumper", str(cm.exception))
def test_two_presses_are_spaced_so_the_controller_sees_the_release(self): def test_a_failure_after_the_request_went_out_says_so(self):
f = self.client() # The press made and its reply lost (it comes after the client's
f.status() # timeout): sent, one press. A refusal, a failed lookup and a
t0 = time.time() # refused connect: not sent, no press. The one retry keeps it.
f.act("button", "press") self.dev.reply_delay = 0.6
f.act("button", "press")
took = time.time() - t0
posts = [c for c in self.dev.calls if c[:2] == ("POST", "/button")]
self.assertEqual(len(posts), 2) # no 409 round trip was needed
self.assertEqual(self.dev.presses, 2)
# the second waited for the first pulse (200 ms as reported) and the gap
self.assertGreaterEqual(took, 0.2 + fx.BUTTON_GAP_S - 0.05)
def test_a_pulse_in_progress_is_waited_out_then_retried(self):
f = self.client()
f.status()
self.dev.state["button"] = "pressed" # a press this client did not time
threading.Timer(0.3, lambda: self.dev.state.__setitem__("button", "idle")).start()
f.act("button", "press")
posts = [c for c in self.dev.calls if c[:2] == ("POST", "/button")]
self.assertEqual(len(posts), 2) # the 409, then the press
self.assertEqual(self.dev.presses, 1)
def test_a_pulse_that_never_ends_is_the_fixtures_error(self):
f = self.client()
f.status()
self.dev.state["button"] = "pressed"
with self.assertRaises(fx.FixtureError) as cm: with self.assertRaises(fx.FixtureError) as cm:
f.act("button", "press") self.client(timeout=0.2, ip="127.0.0.1").act("button", "press")
self.assertIn("in progress", str(cm.exception)) self.assertTrue(cm.exception.sent)
self.dev.wait_replied(1)
self.assertEqual(self.dev.presses, 1)
answers = ["127.0.0.1"] # the refresh lookup after the timeout finds nothing
with self.assertRaises(fx.FixtureError) as cm:
self.client(timeout=0.2, resolver=lambda h: answers.pop(0) if answers else None).act("button", "press")
self.assertTrue(cm.exception.sent)
self.dev.wait_replied(2)
self.assertEqual(self.dev.presses, 2)
self.dev.reply_delay = 0.0
self.dev.button_enabled = False
with self.assertRaises(fx.FixtureError) as cm:
self.client(ip="127.0.0.1").act("button", "press")
self.assertFalse(cm.exception.sent)
with self.assertRaises(fx.FixtureError) as cm:
self.client(resolver=lambda h: None).act("button", "press")
self.assertFalse(cm.exception.sent)
with self.assertRaises(fx.FixtureError) as cm:
self.client(timeout=0.5, ip="127.0.0.1", port=1).act("button", "press")
self.assertFalse(cm.exception.sent)
self.assertEqual(self.dev.presses, 2)
def test_a_wrong_key_is_refused(self): def test_a_wrong_key_is_refused(self):
f = self.client(key="wrong") f = self.client(key="wrong")
@@ -279,10 +257,9 @@ class ClientTests(unittest.TestCase):
self.assertTrue(f.covers("lid")) self.assertTrue(f.covers("lid"))
self.assertFalse(f.covers("interlock")) self.assertFalse(f.covers("interlock"))
def test_probe_without_a_config_is_none_and_a_silent_box_is_logged(self): def test_probe_without_a_config_or_with_a_silent_box_is_none(self):
lines = [] lines = []
self.assertIsNone(fx.probe(lines.append, path=os.path.join(tempfile.gettempdir(), "no-such-fixture.json"))) self.assertIsNone(fx.probe(lines.append, path=os.path.join(tempfile.gettempdir(), "no-such-fixture.json")))
self.assertEqual(lines, [])
tmp = tempfile.mkdtemp(prefix="forgetest-fixture-") tmp = tempfile.mkdtemp(prefix="forgetest-fixture-")
try: try:
p = os.path.join(tmp, "fixture.json") p = os.path.join(tmp, "fixture.json")
@@ -291,7 +268,6 @@ class ClientTests(unittest.TestCase):
# port 1 on localhost: nothing listens there # port 1 on localhost: nothing listens there
f2 = fx.probe(lines.append, path=p, resolver=lambda h: None) f2 = fx.probe(lines.append, path=p, resolver=lambda h: None)
self.assertIsNone(f2) self.assertIsNone(f2)
self.assertTrue(lines and "running without it" in lines[-1], lines)
finally: finally:
shutil.rmtree(tmp, ignore_errors=True) shutil.rmtree(tmp, ignore_errors=True)
@@ -314,6 +290,24 @@ class CatalogTests(unittest.TestCase):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
catalog.test("z.auto", title="t", subsystem="z", hands=("app",))(lambda ctx: None) 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: class StubFixture:
"""What the runner needs of a fixture, scripted.""" """What the runner needs of a fixture, scripted."""
@@ -412,39 +406,6 @@ class RoutingTests(unittest.TestCase):
self.assertEqual(av["attended"], ["r.app", "r.live"]) # needs the app; live never moves self.assertEqual(av["attended"], ["r.app", "r.live"]) # needs the app; live never moves
self.assertEqual(summary["hostname"], "forgefixture") self.assertEqual(summary["hostname"], "forgefixture")
def test_without_the_jumper_the_button_tests_stay_attended(self):
self.stub.button_enabled = False
av, _ = self.selection()
self.assertIn("r.btn", av["attended"])
self.assertIn("r.lid", av["unattended"])
def test_no_fixture_is_the_old_routing(self):
fx.probe = lambda log, path=None, resolver=None: None
self.runner._fixture_probed = 0.0
av, summary = self.selection()
self.assertEqual(av["unattended"], ["r.auto"])
self.assertIsNone(summary)
def test_a_queue_started_during_a_probe_waits_for_the_fixture(self):
# The daemon just came up: nothing probed yet, a page poll starts
# the first probe (slow: an mDNS answer), and the queue start
# lands while it is in flight. The start must see the fixture.
stub = self.stub
def slow_probe(log, path=None, resolver=None):
time.sleep(0.5)
return stub
fx.probe = slow_probe
self.runner._fixture_probed = 0.0
self.runner.fixture = None
poll = threading.Thread(target=self.runner.probe_fixture)
poll.start()
time.sleep(0.1)
order = self.runner.batch_selection("unattended")
poll.join()
self.assertIn("r.lid", order)
self.assertIn("r.btn", order)
def run_queue(self, group, ack_live=False): def run_queue(self, group, ack_live=False):
ok, msg, order = self.runner.start_batch(group, ack_live=ack_live) ok, msg, order = self.runner.start_batch(group, ack_live=ack_live)
self.assertTrue(ok, msg) self.assertTrue(ok, msg)
@@ -477,20 +438,6 @@ class RoutingTests(unittest.TestCase):
asks = self.last_result("r.asks") asks = self.last_result("r.asks")
self.assertIn("declare the step in hands=", asks["message"]) self.assertIn("declare the step in hands=", asks["message"])
def test_a_failing_box_hands_the_action_to_the_operator(self):
self.stub.fail = True
run = Run("test", "r.lid", "r.lid")
ctx = Context(run, self.runner, self.reg["r.lid"])
self.runner.probe_fixture(force=True)
seen = []
ctx.wait_for = lambda cond, timeout, poll=0.25: 1.0 # the machine shows the state
run.set_notice = lambda text: seen.append(text)
ctx.act("lid", "open")
rec = run.evidence["actions"][0]
self.assertEqual(rec["by"], "operator")
self.assertIn("the box is off", rec["fixture_error"])
self.assertTrue(seen and "lid" in seen[0].lower())
def test_a_failing_box_in_an_unattended_run_ends_the_test_as_an_error(self): def test_a_failing_box_in_an_unattended_run_ends_the_test_as_an_error(self):
# nobody is in the room: the run must not wait ACT_TIMEOUT_S for a # nobody is in the room: the run must not wait ACT_TIMEOUT_S for a
# hand that is not there; it ends at once, as the harness's error # hand that is not there; it ends at once, as the harness's error
@@ -545,36 +492,6 @@ class RoutingTests(unittest.TestCase):
self.assertEqual(rec[0]["by"], "operator") self.assertEqual(rec[0]["by"], "operator")
self.assertTrue(any("press the button on the machine" in ln.lower() for ln in run.lines)) self.assertTrue(any("press the button on the machine" in ln.lower() for ln in run.lines))
def test_presence_is_proved_once_per_test_not_once_per_gate(self):
"""A test with two armed halves reaches the ready gate twice. The
second one must not ask for another press: the operator proved
presence a moment earlier and the actuator has the presses. The
setup line still goes up, because the second half may want the
scrap moved."""
run = Run("test", "r.live", "r.live")
ctx = Context(run, self.runner, self.reg["r.live"])
self.runner.probe_fixture(force=True)
asked = []
run.ask = lambda q, o: asked.append(q)
self.press_button(False)
threading.Timer(0.3, self.press_button, args=(True,)).start()
threading.Timer(0.8, self.press_button, args=(False,)).start()
ctx.ready("First half.")
self.assertTrue(run.fixture_takeover)
presses = [r for r in run.evidence["actions"] if r["state"] == "presence"]
self.assertEqual(len(presses), 1)
# the second gate: returns at once, no new press, notice still shown
seen = []
run.set_notice = lambda text: seen.append(text)
t0 = time.time()
ctx.ready("Second half. Move the scrap.")
self.assertLess(time.time() - t0, 1.0)
self.assertEqual(asked, [])
self.assertEqual(len([r for r in run.evidence["actions"] if r["state"] == "presence"]), 1)
self.assertIn("Second half. Move the scrap.", seen)
self.assertTrue(any("presence already proved" in ln for ln in run.lines))
def test_the_presence_press_hands_the_arm_press_to_the_actuator(self): def test_the_presence_press_hands_the_arm_press_to_the_actuator(self):
"""The bench's standing opt-in is not needed once the operator has """The bench's standing opt-in is not needed once the operator has
proved presence: that press is what the opt-in existed to proved presence: that press is what the opt-in existed to
@@ -595,68 +512,6 @@ class RoutingTests(unittest.TestCase):
finally: finally:
runner_mod.hw.button_lit = saved runner_mod.hw.button_lit = saved
def test_the_press_prompt_is_what_the_actuator_presses_on(self):
"""The machine's own press prompt, not a button LED read from
here: the button is lit through parts of a live check that are
not the arm, and a press then lands before the job waits for it
and is lost. press_now presses at the moment the check asks, with
no LED reading and no waiting thread."""
run = Run("test", "r.live", "r.live")
ctx = Context(run, self.runner, self.reg["r.live"])
self.runner.probe_fixture(force=True)
self.stub.arm_press = False
run.fixture_takeover = True
saved = runner_mod.hw.button_lit
runner_mod.hw.button_lit = lambda: False # never consulted
try:
self.assertTrue(ctx.press_now())
self.assertIn(("button", "press"), self.stub.acts)
finally:
runner_mod.hw.button_lit = saved
rec = [r for r in run.evidence["actions"] if r["state"] == "arm"][-1]
self.assertEqual(rec["by"], "fixture")
def test_the_press_prompt_goes_to_the_operator_without_an_actuator(self):
"""No actuator, or one that never took this test's presses: the
notice goes up for a person, who is looking at the same prompt."""
run = Run("test", "r.live", "r.live")
ctx = Context(run, self.runner, self.reg["r.live"])
self.runner.probe_fixture(force=True)
self.stub.arm_press = False
run.fixture_takeover = False
seen = []
run.set_notice = lambda text: seen.append(text)
self.assertFalse(ctx.press_now("Press it now."))
self.assertNotIn(("button", "press"), self.stub.acts)
self.assertIn("Press it now.", seen)
def test_the_arm_press_waits_as_long_as_the_card_says_for_the_light(self):
"""A card that settles the coolant before it lights the button
takes minutes; the caller names the wait, and the actuator presses
when the light comes instead of handing the press to a person at
the default 60 s (found on the bench by the flow-load card)."""
run = Run("test", "r.live", "r.live")
ctx = Context(run, self.runner, self.reg["r.live"])
self.runner.probe_fixture(force=True)
run.fixture_takeover = True
lit_at = time.time() + 0.6
saved = runner_mod.hw.button_lit
runner_mod.hw.button_lit = lambda: time.time() >= lit_at
seen = []
run.set_notice = lambda text: seen.append(text)
try:
self.assertTrue(ctx.arm_press(lit_timeout=5))
deadline = time.time() + 5
while ("button", "press") not in self.stub.acts and time.time() < deadline:
time.sleep(0.05)
self.assertIn(("button", "press"), self.stub.acts)
self.assertEqual([n for n in seen if n], []) # nobody was asked
rec = run.evidence["actions"][-1]
self.assertEqual(rec["by"], "fixture")
self.assertGreaterEqual(rec["took_s"], 0.5)
finally:
runner_mod.hw.button_lit = saved
def test_an_actuator_lost_after_the_takeover_is_said_out_loud(self): def test_an_actuator_lost_after_the_takeover_is_said_out_loud(self):
"""Falling back to the operator without a word is how one dropped """Falling back to the operator without a word is how one dropped
actuator becomes a press nobody can account for afterwards.""" actuator becomes a press nobody can account for afterwards."""
@@ -671,21 +526,6 @@ class RoutingTests(unittest.TestCase):
rec = run.evidence["actions"][-1] rec = run.evidence["actions"][-1]
self.assertTrue(rec.get("fixture_lost")) self.assertTrue(rec.get("fixture_lost"))
self.assertEqual(rec["by"], "operator") self.assertEqual(rec["by"], "operator")
self.assertTrue(any("WARNING" in ln and "now gone" in ln for ln in run.lines))
def test_ready_still_asks_the_page_with_no_actuator(self):
run = Run("test", "r.live", "r.live")
ctx = Context(run, self.runner, self.reg["r.live"])
self.runner.fixture = None
asked = []
def ask(q, o):
asked.append((q, list(o)))
return "Ready"
run.ask = ask
ctx.ready("LIVE FIRE. Scrap under the head.")
self.assertEqual(asked, [("LIVE FIRE. Scrap under the head.", ["Ready", "Cannot"])])
self.assertFalse(run.fixture_takeover)
def test_the_arm_press_is_the_operators_unless_the_bench_opted_in(self): def test_the_arm_press_is_the_operators_unless_the_bench_opted_in(self):
run = Run("test", "r.live", "r.live") run = Run("test", "r.live", "r.live")
@@ -716,6 +556,161 @@ class RoutingTests(unittest.TestCase):
finally: finally:
runner_mod.hw.button_lit = saved runner_mod.hw.button_lit = saved
@unittest.skipUnless(hasattr(os, "mkfifo"), "needs a FIFO for the switches' event device")
def test_ready_reads_a_short_press_as_an_edge(self):
"""The presence press is the switches' input events, so a press
far shorter than a quarter second counts: the press and release
land back to back, and the status the old poll read never shows
the button down at all."""
dev = os.path.join(self.tmp, "switches")
os.mkfifo(dev)
os.environ["GF_SWITCH_DEV"] = dev
ev = runner_mod.hw.ButtonEdges.EVENT
def press():
with open(dev, "wb") as w:
w.write(ev.pack(0, 0, runner_mod.hw.EV_SW, runner_mod.hw.SW_BUTTON, 1) +
ev.pack(0, 0, 0, 0, 0) +
ev.pack(0, 0, runner_mod.hw.EV_SW, runner_mod.hw.SW_BUTTON, 0))
try:
run = Run("test", "r.live", "r.live")
ctx = Context(run, self.runner, self.reg["r.live"])
self.runner.probe_fixture(force=True)
self.press_button(False) # the status never shows it down
threading.Timer(0.3, press).start()
t0 = time.time()
ctx.ready("LIVE FIRE. Scrap under the head.")
self.assertLess(time.time() - t0, 5)
self.assertTrue(run.fixture_takeover)
rec = [r for r in run.evidence["actions"] if r["state"] == "presence"]
self.assertEqual(len(rec), 1)
self.assertIsNone(run.notice) # the notice is down with the press
finally:
os.environ.pop("GF_SWITCH_DEV", None)
def test_the_arm_notice_comes_down_when_the_press_is_taken(self):
"""The controller puts the button out at the press: the operator's
arm notice goes down then, not at the test's next step, so nobody
presses again (a press during the job pauses it)."""
run = Run("test", "r.live", "r.live")
ctx = Context(run, self.runner, self.reg["r.live"])
self.runner.probe_fixture(force=True)
lit = {"v": False}
saved = runner_mod.hw.button_lit
runner_mod.hw.button_lit = lambda: lit["v"]
try:
self.assertFalse(ctx.arm_press()) # the operator's press
self.assertIsNotNone(run.notice)
time.sleep(0.2)
self.assertIsNotNone(run.notice) # dark before the arm: still asked for
lit["v"] = True
time.sleep(0.2)
self.assertIsNotNone(run.notice) # lit: the arm waits for the press
lit["v"] = False # the press taken
deadline = time.time() + 2
while run.notice is not None and time.time() < deadline:
time.sleep(0.02)
self.assertIsNone(run.notice)
# A notice put up meanwhile is not the arm's: it stays.
lit["v"] = False
self.assertFalse(ctx.arm_press())
ctx.notice("Something else.")
lit["v"] = True
time.sleep(0.2)
lit["v"] = False
time.sleep(0.3)
self.assertEqual((run.notice or {}).get("text"), "Something else.")
# The watch ends with the run: a test that ends before the press
# (a FAIL on the way to the arm, say) leaves no thread polling the
# LEDs for up to the arm's whole timeout.
def watching():
return [t for t in threading.enumerate() if t.name == "forgetest-arm-notice"]
deadline = time.time() + 2
while watching() and time.time() < deadline:
time.sleep(0.02) # the earlier watches are gone
self.assertFalse(watching())
lit["v"] = False
self.assertFalse(ctx.arm_press())
self.assertTrue(watching()) # up while the arm is still dark
run.finished = {"result": "FAIL", "message": "", "duration_s": 0}
deadline = time.time() + 2
while watching() and time.time() < deadline:
time.sleep(0.02)
self.assertFalse(watching())
self.assertIsNotNone(run.notice) # the finished run's notice is left as it was
# A press the fixture could not make is asked of the operator, and
# that notice comes down at the press like any other: press_now and
# arm_press with the box refusing, and arm_press with the button
# never lit for the box.
self.stub.arm_press = True
self.stub.fail = True
run = Run("test", "r.live", "r.live")
ctx = Context(run, self.runner, self.reg["r.live"])
def settles(up):
deadline = time.time() + 2
while (run.notice is not None) != up and time.time() < deadline:
time.sleep(0.02)
return (run.notice is not None) == up
lit["v"] = True # the machine waits for the press
self.assertFalse(ctx.press_now()) # the box is off: the operator is asked
self.assertIsNotNone(run.notice)
time.sleep(0.2) # lit: the arm waits for the press
lit["v"] = False # the operator's press is taken
self.assertTrue(settles(False))
lit["v"] = True
self.assertTrue(ctx.arm_press()) # the fixture is asked, and refuses
self.assertTrue(settles(True))
time.sleep(0.2)
lit["v"] = False
self.assertTrue(settles(False))
self.assertTrue(ctx.arm_press(lit_timeout=0)) # the button never lit for the fixture
self.assertTrue(settles(True))
lit["v"] = True
time.sleep(0.2)
lit["v"] = False
self.assertTrue(settles(False))
# A press the box made and reported failed (its reply lost after
# the press): the button is out when the error arrives, so the
# press was taken, and no notice asks for a second one.
def pressed_then_failed(channel, state, ms=None):
lit["v"] = False # the controller takes the press
raise fx.FixtureError("timed out", sent=True)
self.stub.act = pressed_then_failed
lit["v"] = True
self.assertTrue(ctx.press_now())
self.assertIsNone(run.notice)
self.assertEqual(ctx.evidence["actions"][-1]["by"], "fixture")
lit["v"] = True
self.assertTrue(ctx.arm_press())
deadline = time.time() + 2
while ctx.evidence["actions"][-1]["by"] != "fixture" and time.time() < deadline:
time.sleep(0.02)
time.sleep(0.1)
self.assertEqual(ctx.evidence["actions"][-1]["by"], "fixture")
self.assertIsNone(run.notice)
# A refusal (or a request that never reached the box) while the
# button went out: a press at the machine was taken, so no notice
# goes up either, and the record does not give it to the box.
def refused_while_pressed(channel, state, ms=None):
lit["v"] = False # a person's press is taken
raise fx.FixtureError("button press -> 409 button disabled: the enable jumper is out")
self.stub.act = refused_while_pressed
lit["v"] = True
self.assertFalse(ctx.press_now())
self.assertIsNone(run.notice)
self.assertEqual(ctx.evidence["actions"][-1]["by"], "operator")
lit["v"] = True
self.assertTrue(ctx.arm_press())
deadline = time.time() + 2
while "fixture_error" not in ctx.evidence["actions"][-1] and time.time() < deadline:
time.sleep(0.02)
time.sleep(0.1)
self.assertEqual(ctx.evidence["actions"][-1]["by"], "operator")
self.assertIsNone(run.notice)
finally:
runner_mod.hw.button_lit = saved
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+386 -19
View File
@@ -3,27 +3,205 @@
# https://community.openglow.org # https://community.openglow.org
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
import json
import os
import shutil
import subprocess
import tarfile
import tempfile
import unittest import unittest
from forgetest import catalog
from forgetest.suite import image from forgetest.suite import image
# The layer's scripts, when the tests run from a checkout of forgefirm.
LAYER = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"meta-forgefirm", "recipes-forgefirm")
# The BSP layer's interfaces file, from the meta-openglow checkout beside
# forgefirm's (forgetest-ci checks out its scarthgap branch there).
BSP_INTERFACES = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))),
"meta-openglow", "meta-openglow-core", "recipes-core", "init-ifupdown", "init-ifupdown", "interfaces")
class KernelIdentTests(unittest.TestCase): # A layer script run as the image runs it, in private mount, UTS and
def test_localversion_hash_is_stripped(self): # network namespaces: /run a fresh tmpfs, the scratch tree's net directory
self.assertEqual(image.kernel_ident("6.12.20-fslc-fslc-g72a0b1431a9d"), "6.12.20-fslc-fslc") # over /sys/class/net, and /etc read-only with the scratch tree's files
self.assertEqual(image.kernel_ident("6.12.20-fslc-fslc+g72a0b1431a9d"), "6.12.20-fslc-fslc") # over the host's (an overlay with no upper layer, as the image's rootfs is
# read-only), so the script makes its own copy under /run/forgefirm and
# writes through its own bind mount. With "net", wlan0 is a veth carrying a
# global IPv4 and IPv6 address and a link-local one. Exit 97: the
# namespace could not be set up.
NAMESPACE = """
T=$1; SCRIPT=$2
mount --make-rprivate / && mount -t tmpfs tmpfs /run &&
mount --bind "$T/net" /sys/class/net &&
mount -t overlay overlay -o "lowerdir=$T/etc:/etc" /etc || exit 97
if [ "$3" = net ]; then
ip link add wlan0 type veth peer name vpeer0 &&
ip addr add 192.0.2.5/24 dev wlan0 &&
ip -6 addr add 2001:db8::5/64 dev wlan0 nodad &&
ip -6 addr add fe80::5/64 dev wlan0 nodad &&
ip link set wlan0 up || exit 97
fi
sh "$SCRIPT"
echo "rc=$?"
echo "name=$(hostname)"
echo "file=$(cat /etc/hostname)"
echo "--- issue"
cat /etc/issue
"""
def test_plain_name_is_unchanged(self):
self.assertEqual(image.kernel_ident("6.12.20-fslc-fslc"), "6.12.20-fslc-fslc")
self.assertEqual(image.kernel_ident("6.12.20-fslc-g12"), "6.12.20-fslc-g12")
def test_release_matches_manifest_with_or_without_hash(self): def run_layer_script(script, macs, etc, net=False):
rel = "6.12.20-fslc-fslc-g72a0b1431a9d" """Run a layer script over a scratch tree: macs {interface: address or
self.assertTrue(image.kernel_matches(rel, ["6.12.20-fslc-fslc"])) None for a directory with no address file}, etc {file: text}. Returns
self.assertTrue(image.kernel_matches(rel, ["6.12.20-fslc-fslc-gf52af5b522f4"])) the namespace's output, or None when no namespace can be made here
self.assertTrue(image.kernel_matches(rel, [])) (unprivileged where user namespaces are open, else root via sudo -n)."""
self.assertFalse(image.kernel_matches(rel, ["6.12.19-fslc-fslc"])) tree = tempfile.mkdtemp(prefix="forgetest-net.")
try:
for dev, mac in macs.items():
os.makedirs(os.path.join(tree, "net", dev))
if mac is not None:
with open(os.path.join(tree, "net", dev, "address"), "w") as f:
f.write(mac + "\n")
os.makedirs(os.path.join(tree, "net"), exist_ok=True)
os.makedirs(os.path.join(tree, "etc"))
for name, text in etc.items():
with open(os.path.join(tree, "etc", name), "w") as f:
f.write(text)
args = ["sh", "-c", NAMESPACE, "sh", tree, os.path.join(LAYER, script), "net" if net else ""]
setup = []
for cmd in (["unshare", "-Urmun"], ["sudo", "-n", "unshare", "-mun"]):
try:
p = subprocess.run(cmd + args, capture_output=True, text=True, timeout=60)
except (OSError, subprocess.TimeoutExpired):
continue
if "rc=" in p.stdout:
return p.stdout
if p.returncode == 97:
setup.append(p.stderr.strip())
if setup:
raise AssertionError("the namespace was made but not set up: %s" % setup)
return None
finally:
shutil.rmtree(tree, ignore_errors=True)
def field(out, key):
return [ln[len(key) + 1:] for ln in out.splitlines() if ln.startswith(key + "=")][0]
@unittest.skipUnless(os.path.isdir(LAYER) and shutil.which("unshare"), "needs Linux and a forgefirm checkout")
class MachineNameTests(unittest.TestCase):
"""forgefirm-hostname and forgefirm-banner, the layer's own files."""
def named(self, macs):
out = run_layer_script("forgefirm-hostname/files/forgefirm-hostname", macs, {"hostname": "forgefirm\n"})
if out is None:
self.skipTest("no namespace can be made here")
self.assertEqual(field(out, "rc"), "0", out)
self.assertEqual(field(out, "file"), field(out, "name"), out)
return field(out, "name")
def test_the_name_is_built_from_the_first_interface_with_an_address(self):
self.assertEqual(self.named({"wlan0": "02:00:00:00:12:AB", "eth0": "02:00:00:00:34:cd"}), "forgefirm-12ab")
self.assertEqual(self.named({"eth0": "02:00:00:00:34:cd"}), "forgefirm-34cd")
self.assertEqual(self.named({"wlan0": "00:00:00:00:00:00", "eth0": "02:00:00:00:34:cd"}), "forgefirm-34cd")
def test_the_banner_adds_the_global_addresses_and_nothing_else(self):
base = "ForgeFIRM\n"
out = run_layer_script("forgefirm-banner/files/forgefirm-banner", {"wlan0": "02:00:00:00:12:ab"},
{"issue": base}, net=True)
if out is None:
self.skipTest("no namespace can be made here")
self.assertEqual(field(out, "rc"), "0", out)
issue = out.split("--- issue\n", 1)[1]
self.assertTrue(issue.startswith(base + "\n"), issue)
heading, *urls = issue[len(base) + 1:].strip("\n").split("\n")
self.assertEqual(urls, [" https://192.0.2.5/", " https://[2001:db8::5]/"], issue)
self.assertFalse(heading.startswith("#") or ".local" in issue, issue)
# forgefirm-users rendering a record, as the image runs it, in a private
# mount namespace as root: /run a fresh tmpfs, /etc read-only with the
# scratch tree's account files over the host's, and the scratch data
# directory over /data (made for the run where the host has none, as
# fflog_e2e.sh does, and removed after). The data directory is root's, as
# on the machine, whoever made the scratch tree. Then, as the account's
# own uid, what it can do with its home and with the data directory
# around it. Exit 97: the namespace could not be set up.
ACCOUNTS = r"""
T=$1; SCRIPT=$2
made=
[ -d /data ] || { mkdir /data && made=1; } || exit 97
chown -R 0:0 "$T/data" &&
mount --make-rprivate / && mount -t tmpfs tmpfs /run &&
mount -t overlay overlay -o "lowerdir=$T/etc:/etc" /etc &&
mount --bind "$T/data" /data || { [ -z "$made" ] || rmdir /data; exit 97; }
sh "$SCRIPT" reload
echo "rc=$?"
echo "mode=$(stat -c %a /data/forgefirm)"
python3 - <<'PY'
import errno, json, os, pwd
p = pwd.getpwnam("bench")
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, ".reach")
with open(path, "w") as f:
f.write("x")
os.remove(path)
print("reach=" + 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("/data/forgefirm")),
"read_record": tried(lambda: open("/data/forgefirm/users").read())}))
PY
cd /
umount /data
[ -z "$made" ] || rmdir /data
rm -rf "$T/data"
"""
@unittest.skipUnless(os.path.isdir(LAYER) and shutil.which("unshare"), "needs Linux and a forgefirm checkout")
class AccountRenderTests(unittest.TestCase):
"""forgefirm-users, the layer's own init script, rendering the record."""
def test_the_render_opens_the_data_directory_to_its_account_and_no_further(self):
tree = tempfile.mkdtemp(prefix="forgetest-users.")
self.addCleanup(shutil.rmtree, tree, ignore_errors=True)
os.makedirs(os.path.join(tree, "etc"))
for name, text in (("passwd", "root:x:0:0:root:/root:/bin/sh\n"), ("shadow", "root::19000:0:99999:7:::\n"),
("group", "root:x:0:\n"), ("gshadow", "root:*::\n")):
with open(os.path.join(tree, "etc", name), "w") as f:
f.write(text)
data = os.path.join(tree, "data", "forgefirm")
os.makedirs(data)
os.chmod(data, 0o700) # as a strict umask leaves it
with open(os.path.join(data, "users"), "w") as f:
f.write("bench:$6$salt$hash:1000\n")
os.chmod(os.path.join(data, "users"), 0o600)
try:
p = subprocess.run(["sudo", "-n", "unshare", "-m", "sh", "-c", ACCOUNTS, "sh", tree,
os.path.join(LAYER, "forgefirm-users", "files", "forgefirm-users.init")],
capture_output=True, text=True, timeout=60)
except (OSError, subprocess.TimeoutExpired):
p = None
if p is None or "rc=" not in p.stdout:
if p is not None and p.returncode == 97:
raise AssertionError("the namespace was made but not set up: %s" % p.stderr.strip())
self.skipTest("no namespace can be made here (root through sudo -n)")
self.assertEqual(field(p.stdout, "rc"), "0", p.stdout + p.stderr)
self.assertEqual(field(p.stdout, "mode"), "711", p.stdout)
reach = json.loads(field(p.stdout, "reach"))
self.assertEqual((reach["uid"], reach["home"]), (1000, "/data/forgefirm/home/bench"), reach)
self.assertEqual((reach["cd"], reach["write"]), ("ok", "ok"), reach)
self.assertEqual((reach["list_data_dir"], reach["read_record"]), ("EACCES", "EACCES"), reach)
class NetworkBootTests(unittest.TestCase): class NetworkBootTests(unittest.TestCase):
@@ -32,11 +210,6 @@ class NetworkBootTests(unittest.TestCase):
"\twpa-driver nl80211\n" "\twpa-driver nl80211\n"
"\tudhcpc_opts -x hostname:$(hostname)\n") "\tudhcpc_opts -x hostname:$(hostname)\n")
def test_the_test_is_registered_and_covers_nothing_by_design(self):
t = catalog.load_suite()["image.network-boot"]
self.assertEqual(t.kind, "auto")
self.assertEqual(t.covers, ()) # the interfaces file is layer content
def test_ifstate_names_the_interfaces_ifup_finished(self): def test_ifstate_names_the_interfaces_ifup_finished(self):
self.assertEqual(image.ifstate_names("lo=lo\nwlan0=wlan0\n"), {"lo", "wlan0"}) self.assertEqual(image.ifstate_names("lo=lo\nwlan0=wlan0\n"), {"lo", "wlan0"})
self.assertEqual(image.ifstate_names("lo=lo\n"), {"lo"}) self.assertEqual(image.ifstate_names("lo=lo\n"), {"lo"})
@@ -65,6 +238,12 @@ class NetworkBootTests(unittest.TestCase):
text = self.STANZA + "# No DHCPv6 client runs; udhcpc6 is not on the image.\n" text = self.STANZA + "# No DHCPv6 client runs; udhcpc6 is not on the image.\n"
self.assertEqual(image.dhcp6_in_interfaces(text), []) self.assertEqual(image.dhcp6_in_interfaces(text), [])
def test_the_layer_interfaces_file_starts_no_dhcpv6_client(self):
self.assertTrue(os.path.isfile(BSP_INTERFACES),
"%s is missing: meta-openglow is checked out beside forgefirm" % BSP_INTERFACES)
with open(BSP_INTERFACES, encoding="utf-8") as f:
self.assertEqual(image.dhcp6_in_interfaces(f.read()), [])
def test_parse_stat_reads_the_parent_past_an_awkward_comm(self): def test_parse_stat_reads_the_parent_past_an_awkward_comm(self):
self.assertEqual(image.parse_stat("371 (udhcpc) S 1 371 371 0 -1 4194624"), self.assertEqual(image.parse_stat("371 (udhcpc) S 1 371 371 0 -1 4194624"),
(371, "udhcpc", 1)) (371, "udhcpc", 1))
@@ -82,5 +261,193 @@ class NetworkBootTests(unittest.TestCase):
self.assertEqual(image.link_addrs(text, "eth0"), ([], [])) self.assertEqual(image.link_addrs(text, "eth0"), ([], []))
class PackLicensesTests(unittest.TestCase):
"""pack-licenses.sh, the image's license step, on a fixture tree."""
RECIPES = ("forgectrl", "grblhal-glowforge", "kernel-module-glowforge")
def setUp(self):
self.root = tempfile.mkdtemp(prefix="forgetest-lic.")
self.addCleanup(shutil.rmtree, self.root, ignore_errors=True)
self.tree = os.path.join(self.root, "common-licenses")
self.manifest = os.path.join(self.tree, "license.manifest")
self.out = os.path.join(self.root, "forgefirm", "licenses.tar.gz")
def make_tree(self, recipes=None):
"""The tree, with a manifest naming `recipes` (None: no manifest)."""
os.makedirs(os.path.join(self.tree, "generic_MIT"))
with open(os.path.join(self.tree, "generic_MIT", "generic_MIT"), "w") as f:
f.write("MIT License\n")
if recipes is not None:
with open(self.manifest, "w") as f:
for r in recipes:
f.write("PACKAGE NAME: %s\nPACKAGE VERSION: 1.0\nRECIPE NAME: %s\nLICENSE: MIT\n\n" % (r, r))
def pack(self):
return subprocess.run(["sh", os.path.join(LAYER, "images", "pack-licenses.sh"), self.tree, self.out],
capture_output=True, text=True, timeout=60)
def assertRefused(self, p):
self.assertNotEqual(p.returncode, 0, p.stdout)
self.assertTrue(p.stderr.strip(), "a refusal with no message on stderr")
self.assertFalse(os.path.exists(self.out), "a refusal wrote the tar")
def test_a_missing_tree_is_refused(self):
self.assertRefused(self.pack())
def test_a_tree_with_no_manifest_is_refused_and_left(self):
self.make_tree()
self.assertRefused(self.pack())
self.assertTrue(os.path.isdir(self.tree))
def test_a_manifest_that_leaves_out_a_recipe_is_refused_and_left(self):
for left_out in self.RECIPES:
with self.subTest(left_out=left_out):
shutil.rmtree(self.tree, ignore_errors=True)
self.make_tree([r for r in self.RECIPES if r != left_out])
self.assertRefused(self.pack())
self.assertTrue(os.path.isfile(self.manifest))
def test_a_manifest_that_names_all_three_is_packed_and_the_tree_removed(self):
self.make_tree(self.RECIPES)
p = self.pack()
self.assertEqual(p.returncode, 0, p.stderr)
with tarfile.open(self.out, "r:gz") as t:
self.assertIn("common-licenses/license.manifest", t.getnames())
self.assertFalse(os.path.exists(self.tree))
def test_a_license_text_tar_cannot_read_is_refused_and_the_tree_left(self):
"""A member tar cannot read fails the pack, bundle removed and tree
left: tar skips that member, and the manifest check alone passes
the bundle without it."""
if os.geteuid() == 0:
self.skipTest("root reads every file")
self.make_tree(self.RECIPES)
d = os.path.join(self.tree, "zlib")
os.makedirs(d)
locked = os.path.join(d, "generic_Zlib")
with open(locked, "w") as f:
f.write("zlib license\n")
os.chmod(locked, 0) # rmtree still unlinks it
self.assertRefused(self.pack())
self.assertTrue(os.path.isfile(self.manifest))
class RootfsLintTests(unittest.TestCase):
"""rootfs-lint.sh, the image's last rootfs step, on a fixture tree: a
tree that holds every rule passes, and a drift fails the lint naming
its rule."""
SSHD = ("Include /etc/ssh/sshd_config.d/*.conf\n"
"PermitRootLogin no\n"
"PasswordAuthentication yes\n"
"PermitEmptyPasswords no\n"
"Subsystem\tsftp\t/usr/libexec/sftp-server\n")
def setUp(self):
self.root = tempfile.mkdtemp(prefix="forgetest-lint.")
self.addCleanup(shutil.rmtree, self.root, ignore_errors=True)
self.put("usr/sbin/rsyslogd", "", mode=0o755)
self.put("etc/init.d/syslog", "#!/bin/sh\nRSYSLOGD=rsyslogd\nRSYSLOGD_BIN=/usr/sbin/rsyslogd\n", mode=0o755)
os.makedirs(self.path("etc/rc5.d"))
os.symlink("../init.d/syslog", self.path("etc/rc5.d/S20syslog"))
self.put("etc/ssh/sshd_config", self.SSHD)
self.put("etc/ssh/sshd_config_readonly", self.SSHD)
self.put("etc/network/interfaces", "auto lo\niface lo inet loopback\n" + NetworkBootTests.STANZA)
self.put("etc/nsswitch.conf", "hosts: files dns\n")
for name in ("forgectrl", "forgeext", "grblhal", "forgefirm-logging"):
self.put("etc/init.d/" + name, "#!/bin/sh\n", mode=0o755)
os.makedirs(self.path("etc/rc6.d"))
for link in ("rc5.d/S90forgectrl", "rc5.d/S91forgeext", "rc6.d/K09forgeext", "rc6.d/K80grblhal",
"rc6.d/K90forgectrl"):
os.symlink("../init.d/" + link.split("/")[1][3:], self.path("etc/" + link))
self.put("etc/default/rcS", "VERBOSE=no\nROOTFS_READ_ONLY=yes\n")
self.put("etc/fstab", "/dev/root / auto ro 1 0\n/dev/mmcblk2p3 /data auto defaults 0 0\n")
def path(self, rel):
return os.path.join(self.root, rel)
def put(self, rel, text, mode=0o644):
os.makedirs(os.path.dirname(self.path(rel)), exist_ok=True)
with open(self.path(rel), "w") as f:
f.write(text)
os.chmod(self.path(rel), mode)
def lint(self, kind="release"):
return subprocess.run(["sh", os.path.join(LAYER, "images", "rootfs-lint.sh"), self.root, kind],
capture_output=True, text=True, timeout=60)
def assertFails(self, rule, kind="release"):
p = self.lint(kind)
self.assertEqual(p.returncode, 1, p.stderr)
self.assertIn("rootfs-lint: %s:" % rule, p.stderr)
def test_a_tree_that_holds_every_rule_passes(self):
for kind in ("release", "dev"):
p = self.lint(kind)
self.assertEqual(p.returncode, 0, "%s: %s" % (kind, p.stderr))
def test_the_layer_interfaces_file_sends_the_name(self):
with open(BSP_INTERFACES, encoding="utf-8") as f:
self.put("etc/network/interfaces", f.read())
p = self.lint()
self.assertEqual(p.returncode, 0, p.stderr)
def test_busybox_syslogd_beside_rsyslog_fails(self):
os.makedirs(self.path("sbin"))
os.symlink("/bin/busybox.nosuid", self.path("sbin/syslogd"))
self.assertFails("syslog")
def test_a_pre_authentication_banner_fails_either_image(self):
self.put("etc/ssh/sshd_config_readonly", self.SSHD + "Banner /etc/issue.net\n")
for kind in ("release", "dev"):
self.assertFails("sshd", kind)
def test_root_and_an_empty_password_over_ssh_fail_a_release_image_only(self):
dev = self.SSHD.replace("PermitRootLogin no", "PermitRootLogin yes").replace(
"PermitEmptyPasswords no", "PermitEmptyPasswords yes")
self.put("etc/ssh/sshd_config", dev)
self.put("etc/ssh/sshd_config_readonly", dev)
self.assertFails("sshd", "release")
p = self.lint("dev")
self.assertEqual(p.returncode, 0, p.stderr)
def test_a_dhcp_stanza_that_does_not_send_the_name_fails(self):
self.put("etc/network/interfaces", NetworkBootTests.STANZA.replace(
"\tudhcpc_opts -x hostname:$(hostname)\n", ""))
self.assertFails("dhcp-name")
def test_an_mdns_responder_fails(self):
os.makedirs(self.path("etc/avahi"))
self.assertFails("mdns")
def test_the_extension_host_starting_before_forgectrl_fails(self):
os.rename(self.path("etc/rc5.d/S91forgeext"), self.path("etc/rc5.d/S89forgeext"))
self.assertFails("init-order")
def test_no_logging_init_script_fails(self):
os.remove(self.path("etc/init.d/forgefirm-logging"))
self.assertFails("logging")
def test_a_userspace_watchdog_init_script_fails(self):
self.put("etc/init.d/watchdog", "#!/bin/sh\n", mode=0o755)
self.assertFails("watchdog")
def test_rcs_without_the_read_only_rootfs_fails(self):
self.put("etc/default/rcS", "VERBOSE=no\n")
self.assertFails("read-only")
def test_a_dhcpv6_client_on_the_image_fails(self):
os.makedirs(self.path("sbin"))
os.symlink("/bin/busybox.nosuid", self.path("sbin/udhcpc6"))
self.assertFails("dhcpv6")
def test_a_factory_slot_in_fstab_fails_a_release_image_only(self):
self.put("etc/fstab", "/dev/root / auto ro 1 0\n/dev/mmcblk2p1 /factory/img1 auto ro,noatime 0 0\n")
self.assertFails("factory", "release")
p = self.lint("dev")
self.assertEqual(p.returncode, 0, p.stderr)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+3 -37
View File
@@ -9,10 +9,9 @@ The installer runs once per machine, on factory firmware, over whatever
network the owner has. These tests run its own functions under sh with a network the owner has. These tests run its own functions under sh with a
scripted curl in front of them: a transfer that fails resumes and is scripted curl in front of them: a transfer that fails resumes and is
tried again, the failures that waiting cannot fix end the tries at once, tried again, the failures that waiting cannot fix end the tries at once,
and every step leaves a line in the install log in the log tree's own the reason an install stopped is in the install log, and a log that cannot
format.""" be written never fails the install."""
import os import os
import re
import shutil import shutil
import stat import stat
import subprocess import subprocess
@@ -25,10 +24,6 @@ REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)
SCRIPT = os.path.join(REPO, "scripts", "install-forgefirm.sh") SCRIPT = os.path.join(REPO, "scripts", "install-forgefirm.sh")
MARK = "# The install log, before anything can fail." MARK = "# The install log, before anything can fail."
# The log tree's line format (forgetest/suite/logs.py keeps the same one).
LINE_RE = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?[+-]\d\d:\d\d install\[\d+\] "
r"(EMERG|ALERT|CRIT|ERR|WARNING|NOTICE|INFO|DEBUG) .+$")
# A curl that plays a script: each call takes the next "rc http bytes" # A curl that plays a script: each call takes the next "rc http bytes"
# line of $CURL_PLAN, appends that many bytes to the --output file the way # line of $CURL_PLAN, appends that many bytes to the --output file the way
# a resumed transfer does, prints the HTTP code as -w '%{http_code}' # a resumed transfer does, prints the HTTP code as -w '%{http_code}'
@@ -149,24 +144,11 @@ class InstallerTests(unittest.TestCase):
self.assertEqual(len(calls), 3) self.assertEqual(len(calls), 3)
self.assertEqual(os.path.getsize(self.fw), 5000) # not 9000: the stale part went self.assertEqual(os.path.getsize(self.fw), 5000) # not 9000: the stale part went
def test_a_tls_failure_names_the_clock(self):
out, _, _ = self.download(["60 000 0"] * 5)
self.assertRegex(out, r"TLS handshake failed \(a wrong clock does this: \d{4}-\d\d-\d\d")
def test_every_log_line_is_in_the_tree_format(self):
self.download(["7 000 0", "0 200 10"])
self.run_sh('log NOTICE "operator declined to continue; no changes made"')
lines = self.log_lines()
self.assertGreater(len(lines), 5)
for line in lines:
self.assertRegex(line, LINE_RE)
def test_die_leaves_the_reason_in_the_log(self): def test_die_leaves_the_reason_in_the_log(self):
r = self.run_sh('die "archiving slot 1 failed"; echo not-reached') r = self.run_sh('die "archiving slot 1 failed"; echo not-reached')
self.assertEqual(r.returncode, 1) self.assertEqual(r.returncode, 1)
self.assertNotIn("not-reached", r.stdout) self.assertNotIn("not-reached", r.stdout)
self.assertTrue(any(" ERR install failed: archiving slot 1 failed" in line self.assertTrue(any("archiving slot 1 failed" in line for line in self.log_lines()))
for line in self.log_lines()))
def test_no_writable_log_never_fails_the_install(self): def test_no_writable_log_never_fails_the_install(self):
r = self.run_sh('LOG_OK=""; log INFO "x"; LOG_OK=yes; LOG_FILE=/nonexistent/dir/x.log; ' r = self.run_sh('LOG_OK=""; log INFO "x"; LOG_OK=yes; LOG_FILE=/nonexistent/dir/x.log; '
@@ -174,21 +156,5 @@ class InstallerTests(unittest.TestCase):
self.assertIn("still-running rc=0", r.stdout) self.assertIn("still-running rc=0", r.stdout)
class InstallerShapeTests(unittest.TestCase):
"""What holds on any host, with no shell to run."""
def test_the_log_lives_in_the_tree_the_export_carries(self):
head = functions()
self.assertIn('LOG_DIR="/data/log/forgefirm/install"', head)
self.assertIn('LOG_FILE="$LOG_DIR/install.log"', head)
def test_the_download_is_the_retrying_one(self):
with open(SCRIPT, encoding="utf-8") as f:
text = f.read()
body = text.partition(MARK)[2]
self.assertIn("download_fw", body)
self.assertNotRegex(body, r"(?m)^\s*curl ") # no bare curl past the functions
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+159 -1
View File
@@ -7,7 +7,13 @@
the button latch judged from the first emission through every sample the button latch judged from the first emission through every sample
whose readback word shows the laser latch unlocked, both bits from that whose readback word shows the laser latch unlocked, both bits from that
word, so the relocked tail (where the relock itself sets the latch and word, so the relocked tail (where the relock itself sets the latch and
the emission counter still reads nonzero) is never judged.""" the emission counter still reads nonzero) is never judged. And its
read-gap verdict over the dark stretch after the M5: a sample whose
/status carried no laser block is a gap, never a dark sample, a tail the
daemon did not serve before the sampling stopped is a gap too, and a
stretch with a kernel window unread fails the rule; the sampling runs on
past the close; and the head's reports place the rapid inside the armed
window only."""
import unittest import unittest
from forgetest.suite import laser from forgetest.suite import laser
@@ -47,5 +53,157 @@ class DwellGapTests(unittest.TestCase):
self.assertFalse(g["hv_enable_dipped"]) self.assertFalse(g["hv_enable_dipped"])
def rapid_trail(n, dt=0.2, holes=()):
"""A dark stretch of n samples dt seconds apart, the emission count
and the HV current read on every sample but the ones in `holes`: a
/status served without its laser block, which sample() records as
None."""
out = []
for i in range(n):
s = {"t": 100.0 + i * dt, "emission": 0, "hv": 0, "armed": True}
if i in holes:
s["emission"] = None
s["hv"] = None
out.append(s)
return out
def covered(r):
"""The rule emission_witness applies to read_gaps' report."""
return (r["read"] >= laser.RAPID_MIN_READS and r["gap_max_s"] is not None
and r["gap_max_s"] <= laser.EMISSION_READ_GAP_S)
class RapidReadTests(unittest.TestCase):
def test_every_sample_read_is_inside_the_rule(self):
r = laser.read_gaps(rapid_trail(25), "emission")
self.assertEqual((r["read"], r["unread"]), (25, 0))
self.assertAlmostEqual(r["gap_max_s"], 0.2)
self.assertTrue(covered(r))
def test_one_transient_miss_is_inside_the_rule(self):
r = laser.read_gaps(rapid_trail(25, holes=(12,)), "emission")
self.assertEqual((r["read"], r["unread"]), (24, 1))
self.assertAlmostEqual(r["gap_max_s"], 0.4)
self.assertTrue(covered(r))
def test_a_hole_shorter_than_a_window_is_inside_the_rule(self):
r = laser.read_gaps(rapid_trail(25, holes=(10, 11, 12)), "emission")
self.assertAlmostEqual(r["gap_max_s"], 0.8)
self.assertTrue(covered(r))
def test_a_hole_longer_than_a_window_fails(self):
r = laser.read_gaps(rapid_trail(25, holes=(10, 11, 12, 13, 14)), "emission")
self.assertAlmostEqual(r["gap_max_s"], 1.2)
self.assertFalse(covered(r))
def test_the_block_gone_after_the_dark_point_fails(self):
r = laser.read_gaps(rapid_trail(25, holes=tuple(range(1, 25))), "emission")
self.assertEqual((r["read"], r["unread"]), (1, 24))
self.assertAlmostEqual(r["gap_max_s"], 4.8)
self.assertFalse(covered(r))
def test_the_block_gone_for_the_last_window_and_a_half_fails(self):
# the tail is an edge: the window that latches after the close
# goes unread
r = laser.read_gaps(rapid_trail(25, holes=tuple(range(17, 25))), "emission")
self.assertAlmostEqual(r["gap_max_s"], 1.6)
self.assertFalse(covered(r))
def test_a_tail_the_daemon_never_served_fails(self):
# every sample served was read, and the served samples end at the
# close: the sampling ran on past it with nothing served, so the
# stretch from the last reading to where the sampling stopped is a gap
trail = rapid_trail(25)
tail = 2 * laser.EMISSION_WINDOW_S + laser.EMISSION_READ_GAP_S
r = laser.read_gaps(trail, "emission", trail[-1]["t"] + tail)
self.assertAlmostEqual(r["gap_max_s"], tail)
self.assertFalse(covered(r))
# served through: the sampling stops one loop after the last sample
self.assertTrue(covered(laser.read_gaps(trail, "emission", trail[-1]["t"] + 0.125)))
def test_a_stretch_of_one_sample_fails(self):
r = laser.read_gaps(rapid_trail(1), "emission")
self.assertEqual(r, {"read": 1, "unread": 0, "gap_max_s": 0.0})
self.assertFalse(covered(r))
class Clock:
"""time.time and time.sleep on a clock the test moves."""
def __init__(self, now=1000.0):
self.now = now
def time(self):
return self.now
def sleep(self, s):
self.now += s
class RapidTailTests(unittest.TestCase):
def test_the_sampling_runs_on_past_the_close_and_ends_where_it_stopped(self):
# The window closes 3 s in, and the daemon serves the closed
# sample and nothing after it: the sampling still runs two kernel
# windows and the read gap allowed past the close, and the time it
# stopped is the trail's end, so the tail it never served fails the
# read rule.
clock = Clock()
close = clock.now + 3.0
served = []
def sample(_ctx):
clock.now += 0.06 # the two GETs
if served and not served[-1]["armed"]:
return None # nothing served after the close
s = {"t": clock.now, "armed": clock.now < close, "emission": 0, "hv": 0}
served.append(s)
return s
class Ctx:
def checkpoint(self):
pass
class Grbl:
def status_report(self):
# the time of the read, as the head's X
return {"state": "Idle", "MPos": (clock.now, 0.0, 0.0)}
saved = laser.time, laser.sample
self.addCleanup(setattr, laser, "time", saved[0])
self.addCleanup(setattr, laser, "sample", saved[1])
laser.time, laser.sample = clock, sample
tail, t_end = laser.sample_to_disarm(Ctx(), Grbl())
closed = tail[-1]
self.assertFalse(closed["armed"])
self.assertTrue(all(s["armed"] for s in tail[:-1]))
self.assertGreaterEqual(t_end - closed["t"], 2 * laser.EMISSION_WINDOW_S + laser.EMISSION_READ_GAP_S)
self.assertFalse(covered(laser.read_gaps(tail, "emission", t_end)))
# each sample's X is read before its armed flag: an X that places
# the head was read with the window open
self.assertTrue(all(s["x"] < s["t"] for s in tail))
def place_trail(xs, closes_at):
"""A dark stretch with the head at machine X `xs` (one sample each,
0.2 s apart), the window closing at sample `closes_at`."""
return [{"t": 100.0 + i * 0.2, "x": float(x), "armed": i < closes_at} for i, x in enumerate(xs)]
class RapidPlaceTests(unittest.TestCase):
# out to +20 mm, the dwell there, back at the start, the dwell before the M2
XS = [0, 5, 20, 20, 20, 10, 0, 0, 0, 0]
def test_a_window_closed_before_the_head_is_back_does_not_place_the_rapid(self):
# the window closes after the head is back: out at 2, back at 6, closed at 8
self.assertEqual(laser.rapid_place(place_trail(self.XS, 8), 0.0), (2, 6, 8))
# the window closes in the dwell at +20 mm and the head comes back
# after the close: the samples past the close do not place it
self.assertEqual(laser.rapid_place(place_trail(self.XS, 4), 0.0), (2, None, 4))
# the window reads closed at the first sample with the head back:
# that sample does not tell whether the head was back before the
# close, so it does not place it
self.assertEqual(laser.rapid_place(place_trail(self.XS, 6), 0.0), (2, None, 6))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
-105
View File
@@ -1,105 +0,0 @@
# 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()
+562 -6
View File
@@ -4,6 +4,7 @@
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
import os import os
import sys
import unittest import unittest
import helpers import helpers
@@ -122,13 +123,14 @@ class CoverageReportTests(unittest.TestCase):
def test_behavioral_paths_are_never_allowed_away(self): 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 # 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. # **/*.md do not take it out of the report, as they do not take it out of a fingerprint. No
# host-tested rows here: the real table's rows name the first-run documents.
man = helpers.with_file(helpers.make_manifest(), "forgectrl", "docs/advisories/privacy.md", "text") man = helpers.with_file(helpers.make_manifest(), "forgectrl", "docs/advisories/privacy.md", "text")
rest = helpers.make_test("a.one", [("forgectrl", "src/**"), ("grblhal-glowforge", "**"), rest = helpers.make_test("a.one", [("forgectrl", "src/**"), ("grblhal-glowforge", "**"),
("kernel-module-glowforge", "**"), ("linux-fslc", "**")]) ("kernel-module-glowforge", "**"), ("linux-fslc", "**")])
self.assertEqual(m.coverage_report(man, [rest]), {"forgectrl": ["docs/advisories/privacy.md"]}) self.assertEqual(m.coverage_report(man, [rest], host=()), {"forgectrl": ["docs/advisories/privacy.md"]})
doc = helpers.make_test("a.two", [("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.assertEqual(m.coverage_report(man, [rest, doc], host=()), {})
self.assertNotEqual(m.fingerprint(man, doc.covers), self.assertNotEqual(m.fingerprint(man, doc.covers),
m.fingerprint(helpers.with_file(man, "forgectrl", "docs/advisories/privacy.md", "new"), m.fingerprint(helpers.with_file(man, "forgectrl", "docs/advisories/privacy.md", "new"),
doc.covers)) doc.covers))
@@ -169,6 +171,441 @@ class CoverageReportTests(unittest.TestCase):
docs = helpers.make_test("a.three", [("forgectrl", "**/*.md")]) docs = helpers.make_test("a.three", [("forgectrl", "**/*.md")])
self.assertEqual(m.empty_covers(man, [docs]), [("a.three", "forgectrl", "**/*.md")]) self.assertEqual(m.empty_covers(man, [docs]), [("a.three", "forgectrl", "**/*.md")])
def test_a_host_tested_path_is_covered_and_in_no_fingerprint(self):
man = helpers.with_file(helpers.make_manifest(), "forgectrl", "tests/cool_test.c", "test")
t = helpers.make_test("a.one", [("forgectrl", "src/ui.c"), ("forgectrl", "src/auth.c"),
("grblhal-glowforge", "**"), ("kernel-module-glowforge", "**"),
("linux-fslc", "**")])
self.assertEqual(m.coverage_report(man, [t], host=[]), {"forgectrl": ["src/cool.c", "src/main.c"]})
ci = "forgectrl:.github/workflows/build.yml"
host = [("forgectrl", "src/cool.c", "forgectrl:tests/cool_test.c", ci),
("forgectrl", "src/main.c", "forgectrl:tests/cool_test.c", ci)]
self.assertEqual(m.coverage_report(man, [t], host=host), {})
# a change to a host-tested path makes no acceptance run necessary
self.assertEqual(m.fingerprint(man, t.covers),
m.fingerprint(helpers.with_file(man, "forgectrl", "src/cool.c", "changed"), t.covers))
def _root(self, files):
"""A scratch checkout of this repository: {path: text}."""
import shutil
import tempfile
root = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, root)
for path, text in files.items():
os.makedirs(os.path.dirname(os.path.join(root, path)), exist_ok=True)
with open(os.path.join(root, path), "w", encoding="utf-8") as f:
f.write(text)
return root
# A workflow of this repository as the scratch checkouts carry it: on
# every push, and on the pull requests that touch one bench tool, it
# checks itself out at the workspace root and runs the one harness.
CI_YML = ("on:\n push:\n pull_request:\n paths:\n - 'scripts/bench/drill.py'\n"
"jobs:\n test:\n steps:\n"
" - uses: actions/checkout@v4\n"
" # drill.py is a bench tool\n"
" - name: The stream\n"
" run: python3 scripts/bench/stream_test.py # not drill.py\n")
def test_a_host_tested_row_selects_a_path_and_names_a_real_test_and_workflow(self):
man = helpers.make_manifest()
for path in ("tests/cool_test.c", ".github/workflows/build.yml"):
man = helpers.with_file(man, "forgectrl", path, path)
root = self._root({"scripts/bench/stream_test.py": "", ".github/workflows/ci.yml": self.CI_YML})
ci = "forgectrl:.github/workflows/build.yml"
good = [("forgectrl", "src/cool.c", "forgectrl:tests/cool_test.c", ci),
("grblhal-glowforge", "src/driver.c", "forgefirm:scripts/bench/stream_test.py",
"forgefirm:.github/workflows/ci.yml")]
self.assertEqual(m.host_row_problems(man, good, root=root, sources=None), [])
bad = [("forgectrl", "cool.c", "forgectrl:tests/cool_test.c", ci), # globs anchor at the root
("forgectrl", "**/*.md", "forgectrl:tests/cool_test.c", ci), # docs only
("forgectrl", "src/main.c", "forgectrl:tests/gone_test.c", ci),
("forgectrl", "src/main.c", "tests/cool_test.c", ci),
("forgectrl", "src/main.c", "forgetest:forgetest/x.py", ci), # the acceptance tool itself
("forgectrl", "src/main.c", "forgefirm:scripts/bench/gone_test.py", ci),
("forgectrl", "src/main.c", "forgectrl:tests/cool_test.c", "forgectrl:.github/workflows/gone.yml"),
("forgectrl", "src/main.c", "forgectrl:tests/cool_test.c", "build.yml")]
self.assertEqual([r[3] for r in m.host_row_problems(man, bad, root=root, sources=None)],
["selects nothing", "selects nothing", "no such host test",
"names no repository:path for its host test", "no repository 'forgetest' for its host test",
"no such host test", "no such workflow", "names no repository:path for its workflow"])
# installed on an image the package is in no checkout: a "forgefirm:" file is not looked for
self.assertEqual(m.host_row_problems(man, bad[5:6], root=None, sources=None), [])
def test_the_workflow_has_to_run_the_host_test(self):
man = helpers.make_manifest()
root = self._root({"scripts/bench/stream_test.py": "", "scripts/bench/drill.py": "",
".github/workflows/ci.yml": self.CI_YML})
ci = "forgefirm:.github/workflows/ci.yml"
runs = ("grblhal-glowforge", "src/driver.c", "forgefirm:scripts/bench/stream_test.py", ci)
# drill.py is in the trigger's paths, a comment line and an inline comment, and run by no step
idle = ("grblhal-glowforge", "src/driver.c", "forgefirm:scripts/bench/drill.py", ci)
self.assertEqual(m.host_row_problems(man, [runs], root=root, sources=None), [])
self.assertEqual(m.host_row_problems(man, [idle], root=root, sources=None),
[(idle[0], idle[1], idle[2], ci + " does not run it")])
# a workflow whose own on: names neither push nor pull_request is listed, and a step whose
# failure the run tolerates does not run it
job = "jobs:\n test:\n steps:\n - uses: actions/checkout@v4\n - run: python3 scripts/bench/stream_test.py\n"
root = self._root({"scripts/bench/stream_test.py": "",
".github/workflows/manual.yml": "on:\n workflow_dispatch:\n" + job,
".github/workflows/soft.yml": "on: [push]\n" + job.replace(
" - run:", " - continue-on-error: true\n run:")})
manual = runs[:3] + ("forgefirm:.github/workflows/manual.yml",)
soft = runs[:3] + ("forgefirm:.github/workflows/soft.yml",)
self.assertEqual(m.host_row_problems(man, [manual, soft], root=root, sources=None),
[manual[:3] + (manual[3] + ": the lint reads no push or pull_request in its own on:",),
soft[:3] + (soft[3] + " does not run it",)])
# a step in a job that needs a conditional job does not run it (the checkout in a job of
# its own, as the lint reads a checkout for the whole workflow)
root = self._root({"scripts/bench/stream_test.py": "",
".github/workflows/needs.yml": "on: [push]\njobs:\n lint:\n steps:\n"
" - uses: actions/checkout@v4\n build:\n if: false\n steps:\n"
" - run: make\n test:\n needs: build\n steps:\n"
" - run: python3 scripts/bench/stream_test.py\n"})
needs = runs[:3] + ("forgefirm:.github/workflows/needs.yml",)
self.assertEqual(m.host_row_problems(man, [needs], root=root, sources=None),
[needs[:3] + (needs[3] + " does not run it",)])
def _runs(self, yml, path):
return m.workflow_runs(m.workflow_steps(yml), path)
def _block(self, *lines, keys=""):
"""A one-step workflow whose run text is these lines, the step's other keys before it."""
return "steps:\n - name: x\n%s run: |\n%s" % (keys, "".join(" %s\n" % ln for ln in lines))
def test_what_running_a_host_test_looks_like(self):
step = "steps:\n - name: x\n run: %s\n"
self.assertTrue(self._runs(step % "./build/tray_test", "tests/tray_test.c"))
self.assertTrue(self._runs(step % "sudo ./build/sandbox_test", "tests/sandbox_test.c"))
self.assertTrue(self._runs(step % "python3 -B tests/run_test.py", "tests/run_test.py"))
self.assertTrue(self._runs(step % "python3 forgefirm/scripts/bench/ctlport_test.py build/x",
"forgefirm/scripts/bench/ctlport_test.py"))
self.assertTrue(self._runs(step % "python -m pytest -q tests", "tests/test_frame.py"))
# a block, with the step's working-directory: the words are its paths (the loop as
# python3-gfhardware's CI writes it, an expansion in an assignment)
block = ("steps:\n - name: every module\n working-directory: %s\n run: |\n"
" set -e\n for t in tests/test_*.py; do\n m=$(basename \"$t\" .py)\n"
" python -m unittest -v \"tests.$m\"\n"
" done\n - working-directory: forgefirm/forgetest\n"
" run: python -B -m unittest discover -s tests -v\n")
self.assertTrue(self._runs(block % "gfh", "gfh/tests/test_feeder.py"))
self.assertTrue(self._runs(block % "gfh", "forgefirm/forgetest/tests/test_x.py"))
self.assertFalse(self._runs(block % "other", "gfh/tests/test_feeder.py"))
# what the review found accepted: a trigger's paths, an inline comment, a glob or a discover
# that the path only ends like
self.assertFalse(self._runs("on:\n push:\n paths:\n - 'kas/source-bundle.yml'\n"
+ step % "python3 x.py", "kas/source-bundle.yml"))
self.assertFalse(self._runs(step % "./build/lens_test # tray_test is manual", "tests/tray_test.c"))
self.assertFalse(self._runs("steps:\n - run: |\n for t in tests/test_*.py; do\n python $t\n"
" done\n", "tools/tests/test_feeder.py"))
self.assertFalse(self._runs(step % "python -m unittest discover -s tests",
"scripts/bench/tests/foo_test.py"))
# a longer name, another file, a step's name, a comment line
self.assertFalse(self._runs(step % "./build/tray_test_more", "tests/tray_test.c"))
self.assertFalse(self._runs(step % "./build/lens_test", "tests/fflog_e2e.sh"))
self.assertFalse(self._runs("steps:\n - name: tests/run_test.py\n run: make\n", "tests/run_test.py"))
self.assertFalse(self._runs("steps:\n - run: |\n # python3 tests/run_test.py\n make\n",
"tests/run_test.py"))
# the second review: an argument to a command that is not the test does not run it, and a
# discover or pytest runs its own directory's matching files only
self.assertFalse(self._runs(step % "echo tray_test", "tests/tray_test.c"))
self.assertFalse(self._runs(step % "echo ./build/tray_test", "tests/tray_test.c"))
self.assertFalse(self._runs(step % "command -v ./build/tray_test", "tests/tray_test.c")) # prints it
self.assertFalse(self._runs(step % "cmake --build build --target tray_test", "tests/tray_test.c"))
self.assertFalse(self._runs(step % "cat tests/run_test.py", "tests/run_test.py"))
self.assertFalse(self._runs(step % "python -m unittest discover -s forgefirm",
"forgefirm/scripts/bench/fire_test.py"))
self.assertFalse(self._runs(step % "python -m unittest discover -s tests", "tests/foo_test.py"))
self.assertFalse(self._runs(step % "python -m pytest forgefirm", "forgefirm/tests/test_a.py"))
self.assertTrue(self._runs(step % "python -m unittest discover -s tests -p '*_test.py'", "tests/foo_test.py"))
self.assertTrue(self._runs(step % "pytest -q tests/test_a.py", "tests/test_a.py"))
# a script run directly or by its shell, and a command continued across lines
self.assertTrue(self._runs(step % "./tests/fflog_e2e.sh", "tests/fflog_e2e.sh"))
self.assertTrue(self._runs(step % "bash tests/fflog_e2e.sh", "tests/fflog_e2e.sh"))
self.assertTrue(self._runs("steps:\n - run: |\n sudo -E FORGEEXT=build/forgeext \\\n"
" python3 -B tests/run_test.py\n", "tests/run_test.py"))
# python -m unittest given the test's file or its module (the third review: forgectrl's CI
# runs its mock test as a file), and not a module beside it or a -k pattern
self.assertTrue(self._runs(step % "python3 -B -m unittest -v tests/test_devserver_mock.py",
"tests/test_devserver_mock.py"))
self.assertTrue(self._runs(step % "python -m unittest tests.test_feeder", "tests/test_feeder.py"))
self.assertTrue(self._runs(step % "python -m unittest tests.test_feeder.FeederTests.test_one",
"tests/test_feeder.py"))
self.assertFalse(self._runs(step % "python -m unittest tests.test_lens", "tests/test_feeder.py"))
self.assertFalse(self._runs(step % "python -m unittest -k test_feeder tests.test_lens",
"tests/test_feeder.py"))
def test_a_step_counts_only_when_every_run_of_the_workflow_runs_it_and_its_failure_fails_the_run(self):
step = "steps:\n - name: x\n%s run: ./build/tray_test\n"
test = "tests/tray_test.c"
# the lint evaluates no condition: an if: other than the literal true counts as no step,
# always() included; literally true is no condition
self.assertFalse(self._runs(step % " if: false\n", test))
self.assertFalse(self._runs(step % " if: github.event_name == 'push'\n", test))
self.assertFalse(self._runs(step % " if: ${{ always() }}\n", test))
self.assertTrue(self._runs(step % " if: true\n", test))
self.assertTrue(self._runs(step % " if: 'true'\n", test))
# however the step is spelled: more than one space after its dash, or a quoted key
self.assertFalse(self._runs("steps:\n - run: ./build/tray_test\n if: false\n", test))
self.assertFalse(self._runs(step % " \"if\": false\n", test))
# a key with one stray quote is no key the lint reads: at a step's key (its first one on
# its dash line too), a job's key or the top level, no step of the workflow counts
self.assertFalse(self._runs(step % " if\": false\n", test))
self.assertFalse(self._runs("steps:\n - name: x\n run\": ./build/tray_test\n", test))
self.assertFalse(self._runs("steps:\n - run\": make\n - run: ./build/tray_test\n", test))
self.assertFalse(self._runs("jobs:\n t:\n if\": false\n steps:\n - run: ./build/tray_test\n",
test))
self.assertFalse(self._runs("jobs\":\n t:\n if: false\n steps:\n - run: ./build/tray_test\n",
test))
# a step whose failure the run tolerates proves nothing; literally false is no tolerance
self.assertFalse(self._runs(step % " continue-on-error: true\n", test))
self.assertFalse(self._runs(step % " continue-on-error: ${{ inputs.soft }}\n", test))
self.assertTrue(self._runs(step % " continue-on-error: false\n", test))
# the job's own if: or continue-on-error: holds for each of its steps, wherever it is
# written, over a step's own, and not for another job's steps
job = "jobs:\n t:\n%s steps:\n - run: ./build/tray_test\n%s"
self.assertFalse(self._runs(job % (" if: github.ref == 'refs/heads/main'\n", ""), test))
self.assertFalse(self._runs(job % ("", " if: false\n"), test))
self.assertFalse(self._runs(job % (" continue-on-error: true\n", ""), test))
self.assertFalse(self._runs("jobs:\n t:\n continue-on-error: true\n steps:\n"
" - continue-on-error: false\n run: ./build/tray_test\n", test))
self.assertTrue(self._runs(job % (" if: true\n runs-on: ubuntu-latest\n", ""), test))
self.assertTrue(self._runs(job % (" permissions:\n contents: read\n", ""), test))
two = "jobs:\n a:\n if: false\n steps:\n - run: %s\n b:\n steps:\n - run: %s\n"
self.assertTrue(self._runs(two % ("make", "./build/tray_test"), test))
self.assertFalse(self._runs(two % ("./build/tray_test", "make"), test))
# the run text is read as bash -e or sh -e runs it: the test counts on a line that holds it
# alone, or in a pipeline under pipefail; any other operator on the line, or a pipe with no
# pipefail, keeps it from counting
for line in ("./build/tray_test || true", "true || ./build/tray_test", "./build/tray_test; true",
"./build/tray_test &", "./build/tray_test | tee log"):
self.assertFalse(self._runs(self._block(line), test), line)
self.assertTrue(self._runs(self._block("set -o pipefail", "./build/tray_test | tee log"), test))
self.assertTrue(self._runs(self._block("./build/tray_test | tee log", keys=" shell: bash\n"), test))
# no line counts after one that ends the step, turns errexit off, sets an option the lint
# does not follow (noexec), or opens a here-document or a compound command
for before in ("exit 0", "set +e", "set -n", "python3 - <<'EOF'"):
self.assertFalse(self._runs(self._block(before, "./build/tray_test"), test), before)
self.assertFalse(self._runs(self._block("{", "./build/tray_test", "} || true"), test))
# a loop counts only with a body whose lines count
for body in ("python $t || true", "python $t | tee log"):
self.assertFalse(self._runs(self._block("for t in tests/test_*.py; do", body, "done"), "tests/test_x.py"),
body)
# a folded block, and a scalar continued under its key, are one line to the shell
self.assertFalse(self._runs("steps:\n - run: >\n ./build/tray_test\n || true\n", test))
self.assertFalse(self._runs("steps:\n - run: ./build/tray_test\n || true\n", test))
# the lint reads the text of bash and sh alone: the step's shell, or its job's or its
# workflow's default
self.assertFalse(self._runs(self._block("./build/tray_test", keys=" shell: bash {0}\n"), test))
self.assertTrue(self._runs(self._block("./build/tray_test", keys=" shell: sh\n"), test))
self.assertFalse(self._runs("jobs:\n t:\n defaults:\n run:\n shell: pwsh\n steps:\n"
" - run: ./build/tray_test\n", test))
self.assertFalse(self._runs("defaults:\n run:\n shell: pwsh\njobs:\n t:\n steps:\n"
" - run: ./build/tray_test\n", test))
# a checkout under a condition is no checkout
yml = "steps:\n - uses: actions/checkout@v4\n%s with:\n path: own\n"
self.assertEqual(m.checkout_path(m.workflow_steps(yml % ""), "x", True), "own")
self.assertIsNone(m.checkout_path(m.workflow_steps(yml % " if: false\n"), "x", True))
def test_a_job_runs_only_when_every_job_it_needs_runs(self):
test = "tests/tray_test.c"
# GitHub skips a job whose needed job did not run: a job that needs a conditional job runs
# on not every run, however the needs: is written
yml = "jobs:\n build:\n%s steps:\n - run: make\n test:\n%s steps:\n - run: ./build/tray_test\n"
for needs in (" needs: build\n", " needs: [build]\n", " needs: ['build']\n",
" needs:\n - build\n", " needs:\n - build\n",
" needs: [\n build\n ]\n"):
self.assertTrue(self._runs(yml % ("", needs), test), needs)
self.assertFalse(self._runs(yml % (" if: false\n", needs), test), needs)
self.assertFalse(self._runs(yml % (" if: github.event_name == 'push'\n", needs), test), needs)
# the needed job's continue-on-error: does not bear: its failure fails neither the run nor
# the jobs after it
self.assertTrue(self._runs(yml % (" continue-on-error: true\n", " needs: build\n"), test))
# transitively; a job the workflow does not have, a cycle, and a needs: the lint does not
# read each fail closed
chain = "jobs:\n a:\n%s steps:\n - run: make\n b:\n needs: a\n steps:\n - run: make\n" \
" c:\n needs: [b]\n steps:\n - run: ./build/tray_test\n"
self.assertTrue(self._runs(chain % "", test))
self.assertFalse(self._runs(chain % " if: false\n", test))
self.assertFalse(self._runs(yml % ("", " needs: lint\n"), test))
self.assertFalse(self._runs(yml % (" needs: test\n", " needs: build\n"), test))
self.assertFalse(self._runs(yml % ("", " needs: test\n"), test))
self.assertFalse(self._runs(yml % ("", " needs:\n build: true\n"), test))
# a flow list split after a comma keeps the name on its second line: a conditional job
# there holds the dependent job back
split = (chain % "").replace(" needs: [b]\n", " needs: [a,\n b]\n")
self.assertTrue(self._runs(split, test))
self.assertFalse(self._runs(split.replace(" b:\n needs: a\n", " b:\n needs: a\n if: false\n"), test))
# an empty needs: (which GitHub refuses), a flow list a key interrupts before its ], and
# list items at two indentations each fail closed
for needs in (" needs: []\n", " needs:\n", " needs: [\n build\n",
" needs:\n - build\n - build\n"):
self.assertFalse(self._runs(yml % ("", needs), test), needs)
# through a row: the test job needs, in a block list at the key's own indentation, a
# build job under if: false (the checkout in a job of its own, as the lint reads a
# checkout for the whole workflow)
man = helpers.make_manifest()
root = self._root({"scripts/bench/stream_test.py": "",
".github/workflows/needs.yml": "on: [push]\njobs:\n lint:\n steps:\n"
" - uses: actions/checkout@v4\n build:\n if: false\n steps:\n"
" - run: make\n test:\n needs:\n - build\n steps:\n"
" - run: python3 scripts/bench/stream_test.py\n"})
row = ("grblhal-glowforge", "src/driver.c", "forgefirm:scripts/bench/stream_test.py",
"forgefirm:.github/workflows/needs.yml")
self.assertEqual(m.host_row_problems(man, [row], root=root, sources=None),
[row[:3] + (row[3] + " does not run it",)])
# a job under always() runs on every run, and the lint evaluates no condition: it counts as
# no job for its own steps and for the jobs after it
self.assertFalse(self._runs(yml % (" if: false\n", " needs: build\n if: always()\n"), test))
self.assertFalse(self._runs((chain % " if: false\n").replace(
" b:\n needs: a\n", " b:\n needs: a\n if: ${{ always() }}\n"), test))
def test_the_events_a_workflow_runs_on(self):
self.assertEqual(m.workflow_triggers("on:\n push:\n branches: [main]\n pull_request:\njobs:\n t:\n"),
{"push", "pull_request"})
self.assertEqual(m.workflow_triggers("on: [push, pull_request]\n"), {"push", "pull_request"})
self.assertEqual(m.workflow_triggers("on: push\n"), {"push"})
self.assertEqual(m.workflow_triggers("on:\n - push\n - workflow_dispatch\n"), {"push", "workflow_dispatch"})
self.assertEqual(m.workflow_triggers("\"on\":\n workflow_dispatch:\n inputs:\n push:\n"
" type: string\n"), {"workflow_dispatch"})
self.assertEqual(m.workflow_triggers("on:\n workflow_call:\n"), {"workflow_call"})
self.assertEqual(m.workflow_triggers("name: x\njobs:\n t:\n steps: []\n"), set())
# an event key with one stray quote is no event, and the workflow then names none
self.assertEqual(m.workflow_triggers("on:\n push\":\n"), set())
self.assertEqual(m.workflow_triggers("on:\n push:\n 'pull_request:\n"), set())
def test_a_push_or_pull_request_under_a_filter_runs_on_not_every_change(self):
f = m.workflow_filters
self.assertEqual(f("on:\n push:\n branches: [main]\n pull_request:\njobs:\n t:\n"), {"push": ["branches"]})
self.assertEqual(f("on:\n push:\n branches-ignore:\n - wip/*\n paths-ignore:\n - 'docs/**'\n"
" pull_request:\n paths:\n - 'src/**'\n"),
{"push": ["branches-ignore", "paths-ignore"], "pull_request": ["paths"]})
self.assertEqual(f("on:\n push:\n tags: ['v*']\n"), {"push": ["tags"]})
self.assertEqual(f("on:\n push:\n tags-ignore: ['v*']\n"), {"push": ["tags-ignore"]})
# a pull_request under types: runs on the activity types it names and no other; the lint
# reads no type, so any types: is a filter, on the pull_request alone
self.assertEqual(f("on:\n push:\n pull_request:\n types: [closed]\n"), {"pull_request": ["types"]})
self.assertEqual(f("on:\n pull_request:\n types:\n - opened\n - synchronize\n paths-ignore:\n"
" - '**.md'\n"), {"pull_request": ["types", "paths-ignore"]})
self.assertEqual(f("on:\n push:\n types: [x]\n"), {})
# a value on the event's line, and a key with one stray quote under it, are filters the
# lint does not read
self.assertEqual(f("on:\n push: {branches: [main]}\n"), {"push": ["a value the lint does not read"]})
self.assertEqual(f("on:\n push:\n paths\":\n - x\n"), {"push": ["a key the lint does not read"]})
# a filter is read at the event's own level: a dispatch's inputs are none, and an on: list
# or scalar carries none
self.assertEqual(f("on:\n workflow_dispatch:\n inputs:\n paths:\n type: string\n push:\n"),
{})
self.assertEqual(f("on: [push, pull_request]\n"), {})
self.assertEqual(f("on: push\n"), {})
self.assertEqual(f("on:\n - push\n"), {})
# a row's workflow counts when its push or its pull_request carries no filter: a push to
# the default branch alone with every pull request does, a push with paths-ignore alone
# does not, nor do a push and a pull request each under a filter; every push with a pull
# request under types: does, a pull request under types: alone does not
man = helpers.make_manifest()
job = "jobs:\n test:\n steps:\n - uses: actions/checkout@v4\n - run: python3 scripts/bench/stream_test.py\n"
root = self._root({"scripts/bench/stream_test.py": "",
".github/workflows/main.yml": "on:\n push:\n branches: [main]\n pull_request:\n" + job,
".github/workflows/docs.yml": "on:\n push:\n paths-ignore:\n - 'docs/**'\n" + job,
".github/workflows/both.yml": "on:\n push:\n branches: [main]\n pull_request:\n"
" paths:\n - 'src/**'\n" + job,
".github/workflows/push.yml": "on:\n push:\n pull_request:\n types: [closed]\n" + job,
".github/workflows/closed.yml": "on:\n pull_request:\n types: [closed]\n" + job})
rows = [("grblhal-glowforge", "src/driver.c", "forgefirm:scripts/bench/stream_test.py",
"forgefirm:.github/workflows/%s.yml" % w) for w in ("main", "docs", "both", "push", "closed")]
filtered = ": every push or pull_request in its own on: carries a filter (%s)"
self.assertEqual(m.host_row_problems(man, rows, root=root, sources=None),
[rows[1][:3] + (rows[1][3] + filtered % "push: paths-ignore",),
rows[2][:3] + (rows[2][3] + filtered % "pull_request: paths; push: branches",),
rows[4][:3] + (rows[4][3] + filtered % "pull_request: types",)])
def test_the_lint_says_when_it_could_not_read_a_workflow(self):
import contextlib
import io
import json
import tempfile
from unittest import mock
from forgetest import coverage
work = tempfile.mkdtemp()
self.addCleanup(__import__("shutil").rmtree, work)
comps = {"forgectrl": {"srcrev": "aaa", "source": "https://example.invalid/forgectrl.git", "files": [
["src/tray.c", "1" * 40], ["tests/tray_test.c", "2" * 40], [".github/workflows/build.yml", "3" * 40]]}}
path = os.path.join(work, "manifest.json")
with open(path, "w", encoding="utf-8") as f:
f.write(json.dumps(helpers.make_manifest(components=comps).data))
row = ("forgectrl", "src/tray.c", "forgectrl:tests/tray_test.c", "forgectrl:.github/workflows/build.yml")
out = io.StringIO()
with mock.patch.object(m, "HOST_TESTED", [row]), contextlib.redirect_stdout(out):
rc = coverage.main(["--manifest", path, "--sources", work, "--json"])
self.assertEqual(rc, 0) # reporting only
rep = json.loads(out.getvalue())
self.assertFalse(rep["workflows_read"])
self.assertTrue(any("cannot be read" in r[3] for r in rep["host_tested"]))
out = io.StringIO()
with mock.patch.object(m, "HOST_TESTED", [row]), contextlib.redirect_stdout(out):
self.assertEqual(coverage.main(["--manifest", path, "--sources", work, "--enforce"]), 1)
def test_where_a_workflow_checks_a_repository_out(self):
steps = m.workflow_steps("jobs:\n t:\n steps:\n"
" - uses: actions/checkout@v4\n with:\n path: own\n"
" - name: Fetch the harnesses\n uses: actions/checkout@v4\n"
" with:\n repository: openglow-org/forgefirm\n path: forgefirm\n")
self.assertEqual(m.checkout_path(steps, "grblHAL-glowforge", True), "own")
self.assertEqual(m.checkout_path(steps, "forgefirm", False), "forgefirm")
self.assertIsNone(m.checkout_path(steps, "forgectrl", False))
self.assertEqual(m.checkout_path(m.workflow_steps("steps:\n - uses: actions/checkout@v4\n"), "x", True), ".")
self.assertEqual(m.repo_name(helpers.make_manifest(components={"python3-gfutilities": {
"source": "https://github.com/openglow-org/Glowforge-Utilities.git", "files": []}}),
"python3-gfutilities"), "Glowforge-Utilities")
def test_a_components_workflow_is_read_at_its_pinned_blob(self):
import hashlib
import shutil
import subprocess
import tempfile
if not shutil.which("git"):
self.skipTest("no git")
sources = tempfile.mkdtemp()
def writable(func, path, _exc): # git writes its objects read-only
os.chmod(path, 0o700)
func(path)
self.addCleanup(shutil.rmtree, sources, **({"onexc": writable} if sys.version_info >= (3, 12)
else {"onerror": writable}))
url = "https://example.invalid/forgectrl.git"
cache = os.path.join(sources, hashlib.sha1(url.encode()).hexdigest()[:16])
os.makedirs(cache)
subprocess.run(["git", "init", "-q", "--bare"], cwd=cache, check=True)
def blob(text):
return subprocess.run(["git", "hash-object", "-w", "--stdin"], cwd=cache, input=text.encode(),
capture_output=True, check=True).stdout.decode().strip()
yml = "on: [push, pull_request]\nsteps:\n - uses: actions/checkout@v4\n - run: ./build/tray_test\n"
comps = {"forgectrl": {"srcrev": "aaa", "source": url, "files": [
["src/tray.c", "1" * 40], ["tests/tray_test.c", "2" * 40], ["tests/lens_test.c", "3" * 40],
[".github/workflows/build.yml", blob(yml)], [".github/workflows/lost.yml", "4" * 40]]}}
man = helpers.make_manifest(components=comps)
ci = "forgectrl:.github/workflows/build.yml"
self.assertEqual(m.read_source(man, ci, root=None, sources=sources), (yml, None))
tray = ("forgectrl", "src/tray.c", "forgectrl:tests/tray_test.c", ci)
lens = ("forgectrl", "src/tray.c", "forgectrl:tests/lens_test.c", ci)
self.assertEqual(m.host_row_problems(man, [tray, lens], root=None, sources=sources),
[(lens[0], lens[1], lens[2], ci + " does not run it")])
# without the sources the workflow cannot be read, and is not judged
self.assertEqual(m.host_row_problems(man, [lens], root=None, sources=None), [])
# with them, a workflow that should be read and is not is a problem, never a pass
lost = ("forgectrl", "src/tray.c", "forgectrl:tests/tray_test.c", "forgectrl:.github/workflows/lost.yml")
probs = m.host_row_problems(man, [lost], root=None, sources=sources)
self.assertEqual(len(probs), 1)
self.assertIn("cannot be read", probs[0][3])
def test_the_real_host_tested_rows_are_well_formed(self):
for row in m.HOST_TESTED:
self.assertEqual(len(row), 4, row)
self.assertIn(":", row[2], row)
self.assertIn(":", row[3], row)
class CatalogTests(unittest.TestCase): class CatalogTests(unittest.TestCase):
def test_catalog_hash_is_definition_only(self): def test_catalog_hash_is_definition_only(self):
@@ -203,9 +640,36 @@ class CatalogTests(unittest.TestCase):
for t in catalog.all_tests(reg): for t in catalog.all_tests(reg):
self.assertEqual(len(t.source_sha), 64) self.assertEqual(len(t.source_sha), 64)
def test_the_shared_judges_exist_and_take_every_package_import_of_the_suite(self):
if __name__ == "__main__": # The list is kept by hand: the Context reaches runner.py, hw.py
unittest.main() # and baseline.py with no import to find. So every package module
# a suite module does import, at its top level or inside a function
# or a block, has to be on it (or be the registry, which judges
# nothing), or that judge would be in no fingerprint. The sibling
# walk follows the relative spellings alone (`from .x import` and
# `from . import x`), so a suite module imports the package by no
# absolute name.
import ast
import glob
paths = catalog.shared_judge_paths()
self.assertEqual([os.path.basename(p) for p in paths], list(catalog.SHARED_JUDGES))
for p in paths:
self.assertTrue(os.path.isfile(p), p)
allowed = set(catalog.SHARED_JUDGES) | {"catalog.py"}
for mod in sorted(glob.glob(os.path.join(catalog.suite_dir(), "*.py"))):
with open(mod, encoding="utf-8") as f:
tree = ast.parse(f.read())
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.level == 2:
for name in ([node.module] if node.module else [a.name for a in node.names]):
self.assertIn(name + ".py", allowed,
"%s imports %s, which no fingerprint carries" % (os.path.basename(mod), name))
absolute = ([node.module or ""] if isinstance(node, ast.ImportFrom) and node.level == 0
else [a.name for a in node.names] if isinstance(node, ast.Import) else [])
for name in absolute:
self.assertFalse(name == "forgetest" or name.startswith("forgetest."),
"%s imports %s by its absolute name, which no hash follows: import it "
"relatively (from .. or from .)" % (os.path.basename(mod), name))
class ImplementationHashTests(unittest.TestCase): class ImplementationHashTests(unittest.TestCase):
@@ -293,6 +757,94 @@ def two(ctx):
f.write("def o():\n return 1\n") f.write("def o():\n return 1\n")
self.assertEqual((a2, b2), self.shas()) self.assertEqual((a2, b2), self.shas())
def test_a_shared_judge_edit_moves_every_test(self):
# runner.py, hw.py and baseline.py are in every test's path with no
# import to follow (the Context, the baseline around each run), and
# puls.py comes in by a package import the walk does not follow, so
# their text joins every implementation hash.
judge = os.path.join(self.tmp, "hwlike.py")
def write_judge(text):
with open(judge, "w", newline="\n") as f:
f.write(text)
catalog.forget(judge)
def shas():
return tuple(catalog.implementation_sha(self.path, t, judges=[judge]) for t in ("m.one", "m.two"))
write_judge("def sysfs_int(attr, default=None):\n return default\n")
a1, b1 = shas()
write_judge("def sysfs_int(attr, default=None):\n return 8\n")
a2, b2 = shas()
self.assertNotEqual(a1, a2)
self.assertNotEqual(b1, b2)
# a package file outside the list changes nothing
other = os.path.join(self.tmp, "server.py")
with open(other, "w", newline="\n") as f:
f.write("def serve():\n return 1\n")
self.assertEqual((a2, b2), shas())
# Test.source_sha, which the fingerprint, the campaign and the gate
# read, takes the shared judges itself: a suite test's hash is the
# one with SHARED_JUDGES folded in, not the one without them.
import inspect
t = catalog.load_suite()["image.health"]
src = inspect.getsourcefile(t.fn)
self.assertEqual(t.source_sha, catalog.implementation_sha(src, t.id, judges=catalog.shared_judge_paths()))
self.assertNotEqual(t.source_sha, catalog.implementation_sha(src, t.id, judges=[]))
def test_a_sibling_imported_inside_a_function_moves_the_tests_that_run_it(self):
# A test that imports a judge inside its own function carries the
# judge in its hash, and what the judge imports in turn, at any
# depth, and no other test of its module does; a helper of the
# module that imports one inside its body carries it into every
# test of the module.
def put(name, text):
path = os.path.join(self.tmp, name)
with open(path, "w", newline="\n") as f:
f.write(text)
catalog.forget(path)
def write_judge(text):
put("judge.py", text)
write_judge("def judge(x):\n return x > 1\n")
self.write(self.MODULE.replace("def two(ctx):\n",
"def two(ctx):\n from .judge import judge\n judge(2)\n"))
a1, b1 = self.shas()
write_judge("def judge(x):\n return x > 2\n")
a2, b2 = self.shas()
self.assertEqual(a1, a2)
self.assertNotEqual(b1, b2)
# judge.py imports limit.py, which imports base.py: an edit to
# either moves m.two, and not m.one
put("base.py", "BASE = 1\n")
put("limit.py", "from .base import BASE\n\n\nLIMIT = BASE\n")
write_judge("from .limit import LIMIT\n\n\ndef judge(x):\n return x > LIMIT\n")
a5, b5 = self.shas()
put("limit.py", "from .base import BASE\n\n\nLIMIT = BASE + 1\n")
a6, b6 = self.shas()
self.assertEqual(a5, a6)
self.assertNotEqual(b5, b6)
put("base.py", "BASE = 2\n")
a7, b7 = self.shas()
self.assertEqual(a6, a7)
self.assertNotEqual(b6, b7)
self.write(self.MODULE.replace(" return x + 1\n",
" from .judge import judge\n return judge(x)\n"))
a3, b3 = self.shas()
write_judge("def judge(x):\n return x > 3\n")
a4, b4 = self.shas()
self.assertNotEqual(a3, a4)
self.assertNotEqual(b3, b4)
# the spelling with no module (`from . import x`, as homeoff.py imports cloud.py) is
# walked the same
self.write(self.MODULE.replace("def two(ctx):\n",
"def two(ctx):\n from . import judge as j\n j.judge(2)\n"))
a8, b8 = self.shas()
write_judge("def judge(x):\n return x > 4\n")
a9, b9 = self.shas()
self.assertEqual(a8, a9)
self.assertNotEqual(b8, b9)
def test_line_endings_do_not_count(self): def test_line_endings_do_not_count(self):
a1, b1 = self.shas() a1, b1 = self.shas()
catalog.forget(self.path) catalog.forget(self.path)
@@ -309,3 +861,7 @@ def two(ctx):
for t in catalog.all_tests(reg): for t in catalog.all_tests(reg):
path = inspect.getsourcefile(t.fn) path = inspect.getsourcefile(t.fn)
self.assertIn(t.id, catalog.module_parts(path)[1], t.id) self.assertIn(t.id, catalog.module_parts(path)[1], t.id)
if __name__ == "__main__":
unittest.main()
-21
View File
@@ -83,27 +83,6 @@ class SwitchModeTests(unittest.TestCase):
self.assertTrue(ok, detail) self.assertTrue(ok, detail)
self.assertEqual(self.fc.posts, []) self.assertEqual(self.fc.posts, [])
def test_the_supervisors_levers_get_the_slow_timeout(self):
from forgetest import hw
fc = hw.Forgectrl(timeout=1.0, slow_timeout=2.0)
self.assertEqual(fc.timeout_for("POST", "/mode"), 2.0)
self.assertEqual(fc.timeout_for("POST", "/controller/start"), 2.0)
self.assertEqual(fc.timeout_for("POST", "/controller/stop"), 2.0)
self.assertEqual(fc.timeout_for("GET", "/mode"), 1.0)
self.assertEqual(fc.timeout_for("POST", "/settings"), 1.0)
self.assertGreater(hw.SLOW_TIMEOUT_S, 15.0) # forgectrl's first-report wait
# a switch that answers after the ordinary timeout still lands
import time
def on_post(path, form):
time.sleep(1.5)
self.fc.state["mode"] = dict(self.fc.state["mode"], mode="cloud")
return None
self.fc.on_post = on_post
st, body = fc.post("/mode", data={"controller": "cloud"})
self.assertEqual(st, 200) # 1.5 s > timeout, < slow_timeout
self.assertEqual(self.fc.state["mode"]["mode"], "cloud")
def test_cloud_to_grbl_switches_and_waits_for_the_port(self): def test_cloud_to_grbl_switches_and_waits_for_the_port(self):
cloud(self.fc) cloud(self.fc)
b = self.bl() b = self.bl()

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