3 Commits
Author SHA1 Message Date
ScottW514 16d27f0887 yocto version zeus to master 2020-04-15 21:40:24 -04:00
ScottW514 1bd7793744 initial commit 2020-04-15 17:13:51 -04:00
ScottW514 a1d158514b Add files via upload 2020-04-15 17:09:41 -04:00
290 changed files with 654 additions and 265097 deletions
-4
View File
@@ -1,4 +0,0 @@
# Every text file is stored and checked out with LF; a patch keeps its bytes.
* text=auto eol=lf
*.patch -text
*.diff -text
-57
View File
@@ -1,57 +0,0 @@
# forgefixture: the bench actuator's firmware, proven on the host.
#
# - the policy test: the decisions that need no hardware (channel
# names, the loop request words, the button pulse clamp, the key
# comparison), compiled with gcc and run
# - the firmware build: the whole project in the pinned ESP-IDF
# container, with a placeholder fixture.env, so a change that does
# not compile never reaches a bench
name: fixture-ci
on:
push:
branches: [master]
paths:
- 'fixture/**'
- '.github/workflows/fixture-ci.yml'
pull_request:
paths:
- 'fixture/**'
workflow_dispatch:
jobs:
policy:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- name: Policy host test
working-directory: fixture
run: CC=gcc sh test/run.sh
firmware:
runs-on: ubuntu-latest
timeout-minutes: 30
container:
image: espressif/idf:v5.5.5
steps:
- uses: actions/checkout@v4
- name: Placeholder fixture.env (the build needs one; the values are not used)
working-directory: fixture
run: |
printf 'WIFI_SSID=ci-network\nWIFI_PSK=ci-passphrase\nAPI_KEY=%s\nHOSTNAME=forgefixture\n' \
0123456789abcdef0123456789abcdef > fixture.env
- name: Build
working-directory: fixture
shell: bash
run: |
. /opt/esp/idf/export.sh >/dev/null
idf.py set-target esp32s3 >/dev/null
idf.py build
- name: Size
working-directory: fixture
shell: bash
run: |
. /opt/esp/idf/export.sh >/dev/null
idf.py size | tail -n 20
-181
View File
@@ -1,181 +0,0 @@
# forgetest: the release acceptance tool's host-side proof.
#
# - unit tests: campaign rules, fingerprints, artifact build/verify (the
# release gate's decision, including the negative fixtures), runner +
# HTTP API end to end with a fake catalog and a fake bench tool, the
# suites replayed on the machine's own log lines, and the check that
# every log phrase the cloud suite greps for is one the pinned cloud
# app can log (it reads the app sources from the manifest cache)
# - the source bundle of a release: which recipe of the image needs
# source, which archive answers for it, and the refusal that stops a
# release with source missing
# - shared UI files: theme.css and the vendored Bootstrap the page
# carries are byte-identical to forgectrl's at its pinned revision
# - the extension key: the public key the image installs for the official
# tier is byte-identical to the one the extension catalog publishes
# (keys/forgefirm-ext.pub on its main branch)
# - the catalog's address: EXTPKG_INDEX_URL in the pinned forgectrl's
# src/extpkg.h is the catalog's latest-release download of the file its
# tools/publish.py passes to gh release create; read, never requested
# (every request of it is counted as a download)
# - coverage lint: every source path of every component pinned by the
# recipes must be selected by some catalog test's coverage globs or by a
# host-tested row naming the host test that proves it (the tree manifest
# is generated from the pins with git - no Yocto build); enforced: an
# uncovered path fails the job (https://docs.forgefirm.org/developers/testing/).
name: forgetest-ci
on:
push:
branches: [master]
paths:
- 'forgetest/**'
- 'scripts/acceptance-gate.py'
- 'scripts/manifest-from-tree.py'
- 'scripts/source-bundle.py'
- 'kas/source-bundle.yml'
- 'meta-forgefirm/recipes-forgefirm/**'
- '.github/workflows/forgetest-ci.yml'
pull_request:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout forgefirm
uses: actions/checkout@v4
with:
path: forgefirm
- name: Checkout meta-openglow (sibling)
uses: actions/checkout@v4
with:
repository: openglow-org/meta-openglow
ref: scarthgap
path: meta-openglow
- name: Checkout forgefirm-extensions-catalog (sibling)
uses: actions/checkout@v4
with:
repository: openglow-org/forgefirm-extensions-catalog
ref: main
path: forgefirm-extensions-catalog
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Tree manifest from the recipe pins (also fetches the pinned app sources the needle check reads)
working-directory: forgefirm
run: python scripts/manifest-from-tree.py --out tree-manifest.json
- name: The image's extension key is the catalog's published key
run: |
image=forgefirm/meta-forgefirm/recipes-forgefirm/forgefirm-keys/files/ext/forgefirm-ext.pub
published=forgefirm-extensions-catalog/keys/forgefirm-ext.pub
for f in "$image" "$published"; do
test -f "$f" || { echo "missing: $f"; exit 1; }
done
cmp -s "$image" "$published" || { echo "$image is not $published"; exit 1; }
- name: The catalog's address in the pinned forgectrl is the file the catalog publishes (read, never requested)
run: |
python3 - <<'EOF'
import ast, glob, os, re, subprocess, sys
PREFIX = "https://github.com/openglow-org/forgefirm-extensions-catalog/releases/latest/download/"
def fail(why):
print(why)
sys.exit(1)
# forgectrl's pin, and its src/extpkg.h from the tree manifest's cache
pin = open("forgefirm/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl-pin.inc").read()
m = re.search(r'^SRCREV = "([0-9a-f]{40})"$', pin, re.M)
if not m:
fail("forgectrl-pin.inc names no SRCREV")
rev = m.group(1)
header = None
for repo in sorted(glob.glob("forgefirm/.manifest-cache/*")):
p = subprocess.run(["git", "-C", repo, "show", rev + ":src/extpkg.h"], capture_output=True, text=True)
if p.returncode == 0:
header = p.stdout
break
if header is None:
fail("forgectrl %s is not in forgefirm/.manifest-cache" % rev)
m = re.search(r'#define\s+EXTPKG_INDEX_URL\s*(?:\\\n\s*)?"([^"]*)"', header)
if not m:
fail("forgectrl %s's src/extpkg.h defines no EXTPKG_INDEX_URL" % rev[:7])
url = m.group(1)
# the file the catalog's publish tool passes to gh release create
tree = ast.parse(open("forgefirm-extensions-catalog/tools/publish.py").read())
assigned = {n.targets[0].id: n.value for n in ast.walk(tree)
if isinstance(n, ast.Assign) and len(n.targets) == 1 and isinstance(n.targets[0], ast.Name)}
def file_name(v):
if isinstance(v, ast.Name):
v = assigned.get(v.id)
if isinstance(v, ast.Call) and v.args:
v = v.args[-1]
return os.path.basename(v.value) if isinstance(v, ast.Constant) and isinstance(v.value, str) else None
names = [file_name(n.args[3]) if len(n.args) > 3 else None for n in ast.walk(tree)
if isinstance(n, ast.Call) and isinstance(n.func, ast.Name) and n.func.id == "gh"
and [a.value for a in n.args[:2] if isinstance(a, ast.Constant)] == ["release", "create"]]
if len(names) != 1 or not names[0]:
fail("tools/publish.py passes no one file to gh release create: %s" % names)
print("forgectrl %s: %s; the catalog publishes %s" % (rev[:7], url, names[0]))
if url != PREFIX + names[0]:
fail("the address is not %s" % (PREFIX + names[0]))
EOF
- name: Unit tests
working-directory: forgefirm/forgetest
# -B: no bytecode caches under the package directory (the recipe's fetch
# checksum would otherwise move without a source change)
run: python -B -m unittest discover -s tests -v
- name: No undefined names (the live drills never execute on the host)
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: |
python -m pip install pyflakes
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
working-directory: forgefirm
run: python scripts/check-ui-vendor.py
- name: Coverage lint (enforced)
working-directory: forgefirm/forgetest
# 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
working-directory: forgefirm
run: |
# The gate must load the catalog and refuse a missing/unauthorized
# artifact cleanly (exit 1/2, never a traceback).
set +e
echo '{}' > empty.json
python scripts/acceptance-gate.py empty.json tree-manifest.json --quiet
rc=$?
set -e
test "$rc" -eq 1
-39
View File
@@ -1,39 +0,0 @@
# The extension sandbox's deny rules, proven on real traffic.
#
# scripts/sandbox-rules-test.py loads meta-forgefirm's ffx.nft into a network
# namespace of its own, joins a second namespace to it with a veth pair (a
# peer that is not the machine), and sends from real uids: a pool uid is
# refused on loopback, at its own LAN address, and at the peer (TCP at once,
# UDP with EPERM), the uids on either side of the pool and root are not
# touched, an allowlist chain opens one port of the peer to one uid, the
# machine's own addresses stay shut even on an allowlist, and loading the
# file again takes the allowlist away. The
# runner's nft is the version the image carries or newer; the kernel side is
# the image's own business and is proven on the machine (exthost.platform).
name: sandbox-ci
on:
push:
branches: [master]
paths:
- 'meta-forgefirm/recipes-forgefirm/forgefirm-sandbox/**'
- 'scripts/sandbox-rules-test.py'
- '.github/workflows/sandbox-ci.yml'
pull_request:
workflow_dispatch:
jobs:
rules:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: nft
run: sudo apt-get update && sudo apt-get install -y nftables
- name: The deny rules on real traffic
run: |
# 77 is the script's "could not run here": on this runner that is a failure.
sudo python3 -B scripts/sandbox-rules-test.py
-22
View File
@@ -1,22 +0,0 @@
# kas-managed upstream layer checkouts (poky, meta-openembedded, meta-freescale...)
/layers/
# Yocto build output (incl. deploy/images) and caches
/build/
/downloads/
/sstate-cache/
# kas
*.lock.yml.bak
# release.sh staging output
/release-staging/
# Python bytecode
__pycache__/
# manifest-from-tree.py fetch cache
/.manifest-cache/
# manifest-from-tree.py default output
/tree-manifest.json
-407
View File
@@ -1,407 +0,0 @@
# AGENTS.md
Instructions for AI coding agents that work in this repository. A human
contributor is welcome to read it too: it is the same set of rules.
## ForgeFIRM repository rules
### Safety on the machine
- The laser never fires with the coolant pump deliberately off. The heater-only
flow-calibrate and flow-verify tools are the only pump-off state, and the
tube stays dark during them.
- The 40 V motor rail stays up while the machine is on. Never cycle it for a
controller handover. A rail glitch can leave the stepper drivers
unserviceable.
### The iteration loop
- Edit, cross-build in the Yocto build environment, hot-deploy to the
machine, prove. Then one commit, one push, one pin bump for the proven
change.
- A full image build happens once, at the end of a work item, never per fix.
- Kernel and BSP changes ride one image flash, batched. Validate a `.ko` or
overlay change on the image that ships it. Never hot-swap a `.ko` onto a
board that is about to be reflashed.
### Acceptance coverage
- Evaluate every change against the acceptance catalog in
`forgetest/forgetest/suite/`. Does an existing test exercise the changed
behavior? Does its `covers` map name the files you touched? If not, add or
widen one in the same change. The coverage lint
(`python3 -m forgetest.coverage --enforce`) is the floor.
- The `SRCREV` of a component and the `PV` that moves with it live in
`<recipe>-pin.inc` next to the recipe. Nothing else goes in that file. The
image manifest keeps `*-pin.inc` out of the layer content hash. Thus a pin
bump invalidates only the acceptance tests that cover that component. A
pin in a recipe body counts as a platform change and needs a full
acceptance campaign.
- Test a harness change yourself, on the host, before you hand it to the
operator for a bench run. A harness that fails on its own defect costs the
operator a full re-run cycle.
- A test declares its `mode`, and the runner switches. Cloud tests stay in
cloud mode. Keep `requires` lists minimal, and never gate one test behind a
chain of others.
- Never exercise a gate or a limit through the `GFCOOL_*` environment
overrides in a test. Use the settings API, and restore it in the teardown.
- A silence-while-armed injection and a crash during `$H` are catalog
drills, not one-off bench scripts.
### CI couplings that set the push order
- The CI of `grblHAL-glowforge` gets the laser harnesses from `scripts/bench/`
at the head of `master`, unpinned. Push a harness change here before the
driver change that needs it.
- The acceptance page shares its theme and its vendored Bootstrap with the
`forgectrl` panel, byte for byte. Land a UI change in `forgectrl` first.
Push it and pin it. Only then push this repository.
- The CI of `kernel-module-glowforge` cross-builds against the BSP from
`meta-openglow`. Push `meta-openglow` before a module change that needs a
BSP change.
### Builds and images
- Builds run only in the dedicated Yocto build environment that the Build page
of the site defines (kas and bitbake). Never use a native host build of a
component as proof, not even for a compile check.
- Every build makes both images: `forgefirm-image` (the release image) and
`forgefirm-image-dev` (the bench image, with `forgetest` on port 8090). The
dev image needs an explicit `--target`. Report both artifact names.
- After a kernel-module pin bump, rebuild the kernel and the module in the
same run: `bitbake -c cleansstate linux-fslc kernel-module-glowforge`, then
the images. The kernel version suffix is not reproducible across
re-checkouts, and a rootfs whose kernel and module disagree fails.
- Read the layer HEAD lines at the top of a build log before you trust a build
that moves a pin.
### The record of the work
This repository carries no status document and no dated log. Both were
retired: every fact about the machine and the firmware is on the
documentation site, and **the record of a piece of work is the commit message
that carries it**: what was done, how it was proven, and what it replaced, in
the same place as the change itself.
A measurement session or a bench drill is written up in the commit that
lands its result. Nothing accumulates in a file.
### On the bench
These rules apply whenever an agent works with the operator at a machine.
- **One armed run per turn.** Start the run, let it finish, stop, report what
happened against what was expected, and wait for the operator's
confirmation before any further armed run or machine command. Never chain a
jog, a fix, or a re-run onto a burn in the same step. Dry runs (latch
locked, no emission) can be batched. Say which is which before you start.
- **Zero inference during live fire.** Report only what the combined
observations confirm: the operator's eyes, the drill output, the logs, the
sysfs readbacks. Everything else is labeled "unconfirmed" and is not acted
on.
- **One Grbl connection.** Never open a second connection to TCP port 23 while
a drill, an acceptance test, or a sender holds one. The last connection
wins, the displaced client's cleanup never arrives, and its queued commands
wait behind the arm gate. Read state through `forgectrl` (HTTP) and sysfs
only. After a displaced or aborted drill: soft reset, confirm that the RX
buffer reads full, `M5`, confirm that `armed` is false, before anyone
touches the button.
- **The operator touches the machine.** The operator runs the install, the
service restart, and the reflash on the machine. Never modify the software
of a running machine unless the operator asks for that exact step.
- **Bench hygiene.** Stage test files in `/tmp`. A file that must survive a
reboot goes in `/data/bench-scratch/`, and that directory is deleted whole
at the end of the session. Nothing goes loose in `/data`. Remove everything
you put on the board, in the same session, and list `/data` before you end.
A bench tool worth a second use goes in `scripts/bench/`. The dev image
installs those tools under `/usr/share/forgetest/bench/`. Run them from
there.
## Project-wide rules
The sections above are specific to this repository. The rules below apply to
every repository of the OpenGlow ForgeFIRM project: the firmware components,
the build base, the documentation, and the hardware designs. Where a
repository rule and a rule below conflict, the safety, proof, and hygiene
rules below win. The documentation site, <https://docs.forgefirm.org/>, is
the source of truth for every fact about the machine and the firmware. Read
its Developers section before you change code.
### What ForgeFIRM is
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
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
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,
maintained feature. The stack is hardware validated: GRBL mode cuts real jobs,
and cloud mode runs end to end.
One controller runs at a time under `forgectrl`, the machine-services daemon,
which owns the pulse device, the cooling engine, the cameras, and the control
panel. The kernel module plays the pulse stream into the stepper drivers and
owns the laser latch and the safety readbacks. The Technical section of the
site describes the whole stack.
### The repositories
All repositories live under the OpenGlow organization on GitHub,
<https://github.com/openglow-org>. For development, check every repository out
as a sibling in one base directory named `openglow-forgefirm`. The build
scripts expect that layout. Each repository is its own git repository. The
base directory is not.
| Repository | Role | Default branch | License |
|---|---|---|---|
| `forgefirm` | The build and release base: the `meta-forgefirm` Yocto layer, the kas configuration, the image recipes, the install and release scripts, the acceptance tool `forgetest/`, the bench tools `scripts/bench/`, and the bench actuator firmware `fixture/`. It carries no project documentation. | `master` | MIT (layer metadata) |
| `meta-openglow` | The BSP layers `meta-glowforge-bsp` and `meta-openglow-core`: kernel, device tree, U-Boot, board recipes. | `scarthgap` | MIT (layer metadata) |
| `forgectrl` | The machine-services daemon (C) on HTTPS port 443, with the read-only routes on HTTP port 80: supervisor, pulse-device broker, motion-liveness gate, cooling engine, cameras, telemetry, settings, diagnostics, control panel, A/B updates. | `main` | MIT |
| `grblHAL-glowforge` | The grblHAL driver for the stock board, the GRBL-mode controller. The core is a submodule at `src/grbl`. Machine constants are in `src/boards/glowforge.h`. | `main` | GPL-3.0-or-later |
| `grblHAL-core` | The fork of the grblHAL core that the driver uses. Minimum change, no rationale comments. | `forgefirm` | The upstream grblHAL license |
| `kernel-module-glowforge` | `glowforge.ko`: the SDMA + EPIT pulse engine, the laser latch, the safety readbacks, the sensors. | `master` | GPL-2.0-or-later |
| `python3-gfhardware` | The `gfhardware` hardware library and the cloud-mode applications in `forgefirm-app/`. | `master` | MIT, with one LGPL-2.1-or-later component |
| `Glowforge-Utilities` | `gfutilities` on PyPI: the factory protocol and service layer that cloud mode uses. | `master` | MIT |
| `forgefirm-docs` | The documentation site. | `main` | CC BY-SA 4.0 |
| `openglow-serial-adapter` | The USB-C serial-console adapter for the control board. | `main` | CC BY-NC-SA 4.0 |
The full table, the license details, and the component diagram are on the
site: <https://docs.forgefirm.org/developers/>.
### Safety first, and in that order
The machine is a laser, and the operator is the safety authority at it. These
rules bind every change, in every repository, that can affect what the machine
does.
- The hardware chain is the safety boundary. Software only adds gates on top
of it. `LASER_ON` is never a bare GPIO. The kernel laser latch is locked by
default, and every close of the pulse device locks it again.
- Never defeat a switch, an interlock, or a readback, in hardware or in
software, not even for a test.
- Never change a fire gate, a thermal limit, or a safety default to make a
test pass.
- The order of work is absolute: emission and motion first, robustness and
hygiene after. Review a change that touches emission or motion for that
before anything else.
- A change that can put energy where it was not commanded gets a regression
test with the fix, in the same commit, never after.
- Position counters, homing anchors, and a homed flag are not proof of
physical motion. The head accelerometer is, and so are the operator's eyes.
- Unreadable is fail-closed. A failed read of a safety input, a state file,
or a verdict is never treated as "still running" or "keep waiting" without
a bound.
### Proof before done
A feature or fix is not complete until it is proven. The order of preference:
1. A host test that runs in CI. The Test page of the site lists the host tests
of each repository.
2. A bench drill on the bench reference, recorded in the commit message that
carries the change.
3. Documented reasoning.
Also:
- Run the host tests of this repository before every commit. Where the build
uses `-Werror`, a warning is a failure.
- A component that ships in the image is also evaluated against the release
acceptance catalog in the `forgefirm` repository (Test page, "Coverage
currency"). A behavior change with no catalog consequence gets a sentence
of justification in the commit message.
- For a component that ships in the image, the proof build is the Yocto
cross-build that the Build page of the site defines, never a native host
build.
- Report outcomes faithfully. Quote failing output as it is. Name a skipped
step as skipped. Never narrate an expected result as an observed one.
### The workflow: prove locally, push when proven
- Nothing goes to a public repository until it is proven: in the host tests
for host-only changes, on the bench for changes that alter what the machine
does.
- Iterate locally. Then make one commit and one push for the proven change.
No chains of fix-up pushes. Every push runs CI, and CI minutes are a budget.
- Commit only finished work, and only when asked. Never commit intermediate
findings, working notes, plans, audits, or status files. If a conclusion is
still moving, it is not ready for a repository. Unpushed mistakes come out
with `git reset --mixed <base>`, which keeps the working tree.
- "Commit" and "commit and build" include the push of every touched
repository, in dependency order. Never hold a push back to protect earlier
unpushed commits on the same branch. The only exception is an explicit "do
not push" for that session.
- Never push, force-push, tag, cut a release, refresh a lockfile, bump a
submodule pointer, or bump a pin unless asked.
### Pins
A firmware component reaches the image only through its pin: an exact
`SRCREV` in `meta-forgefirm` (in the `forgefirm` repository) for ForgeFIRM
components, or in `meta-openglow` for BSP components. There is no `AUTOREV`.
A commit in a component repository changes nothing on a machine until its pin
moves. The order is always: push the source repository, bump its pin, then
run `bitbake -c fetch <recipe>` to make sure that the pin resolves. Some
pairs of repositories have a CI coupling that fixes the push order between
them. The Release flow page of the site lists them, and each affected
repository names its own in the section above.
### Git conventions
- A commit is attributed to the human who is responsible for it, in both the
author and the committer fields. Never list an AI assistant as an author or
a co-author. Never add an AI attribution trailer or a "Generated with" line
to a commit message or a pull request. In a fresh clone, set the local
`user.name` and `user.email` before the first commit.
- A commit message says what changed and why, in the present tense, in
Simplified Technical English. The "why" of a change to fork code lives here
and nowhere else.
- Keep line endings LF in every file that Linux tooling consumes: patches,
shell scripts, recipes, device trees. Check the bytes after an edit from a
Windows tool. A CRLF patch breaks `do_patch`.
- Never commit build output, `__pycache__`, virtual environments, editor
files, a local knowledge graph (`graphify-out/`), captured machine data that
carries an identity, tokens, keys, or passwords.
- Never rewrite published history unless the operator asks for it.
- A pull request to an upstream repository is never automatic. The developer
decides if and when to send one. You can remind them that upstream
candidates exist, but the decision is theirs.
### Writing rules
They apply to everything: documentation, README files, code comments,
docstrings, log and console text, commit messages, issue text, and replies.
- **American English.** analyze, behavior, color, center, gray, catalog,
license, judgment, program, artifact, percent, toward, among, while,
learned. The style lint in `forgefirm-docs` rejects the British forms.
- **ASD-STE100 Simplified Technical English.** Short sentences: 20 words in
a procedure, 25 in a description. One topic per sentence, one instruction
per sentence. Active voice, imperative in procedures, simple present in
descriptions. "Must" for necessity, "can" for possibility. No "should",
"would", "could", "may". Prefer: do, make sure, use, start, show, occur,
before, after, because, for example, that is. No "etc.", no "and/or".
Noun clusters of three words at most. Warnings and cautions come before the
step they protect. Warm and direct is good. A joke that shades a fact is
not.
- **No em dashes.** Never, anywhere. Use a colon, a period, or a comma.
- **Present tense, present state.** Text describes the code and the machine
as they are. No history narrative, no "the old version did", no "evolved
from", no story of how the code got here. Upstream copyright and
derivation attribution to public projects stays: that is license credit,
not narrative.
- **No workstation paths.** Nothing outside a repository appears as a path in
it: no drive letters, no WSL mounts, no home directories, no sibling-repo
relative paths, no private reference directories. Name the artifact
instead: "the captured factory pulse files", "that bench session".
- **No bench-machine identity.** Never a hostname, a serial number, a fuse
value, a private IP address, or a login of the bench machine, in any file,
comment, commit message, sample, or test fixture. Use neutral placeholders
of the `ABC-123` form. Record the fact of a verification, never the values.
- **No factory firmware code.** Never cite the factory firmware binary or its
code: no offsets, no addresses, no quoted or decompiled code. State the
recovered fact (a formula, a constant, a behavior) and stop. Hardware
addresses (IOMUX pad values, sensor registers, boot offsets) are fine.
- **No rationale comments in fork code.** Code destined upstream (for
example, the grblHAL core fork) carries the minimum change and no "why we
did this" comments. The reasoning goes in the commit message. Patches
carried in the Yocto layers are exempt. The `ForgeFIRM:` comment convention
there is deliberate.
- **Interface docs describe the lever, not the policy.** A sysfs attribute
doc says what the attribute is, its range, its units, its conversion. How
the factory firmware drives it does not belong there.
- **No definitive legal or regulatory statements.** The project is not a
lawyer. The only permitted form: modifying the machine, including
replacing its firmware, may have legal and regulatory ramifications, and it
is the end user's responsibility to adhere to the laws, regulations,
certifications, and insurance terms that apply where they are. Warranty
wording is "may void your warranty", never "voids". Never cite a regulation,
a standard, or a certification regime as applying or not applying. The
affiliation disclaimer lives in the site footer only. It is also the user's
responsibility to make sure that they stay within the vendor's terms of
service when they use a feature that calls that vendor's cloud services.
- **Copyright.** Copyright in this project belongs to 514 LLC d/b/a
OpenGlow. Every new source file carries two lines in its header, in the
comment style of the file: `Copyright <year> 514 LLC d/b/a OpenGlow` and
`Written by Scott Wiederhold`. Keep the year, or the year range, that
the file already has. The copyright notices of other holders stay
unchanged. Those two lines are for files this project authors: an
outside contributor who writes a new file puts their own name there, or
leaves the lines out, and keeps their copyright in what they wrote.
Editing an existing file changes no header - a contribution is recorded
by its `Signed-off-by` line and by git, not by a copyright line.
- **SPDX.** Every new source file carries an `SPDX-License-Identifier` line
under the license of its repository.
### Where documentation goes
- **One home.** A fact lives on the documentation site, or it does not exist.
A repository README says what the repository is, how to build and test it,
and where the documentation is. Nothing else.
- **The currency rule.** A change carries a documentation commit when it adds,
removes, or renames an interface (a sysfs attribute, an HTTP route, a
settings key, a G-code or `$` setting), or when it corrects a measured
fact. A measured fact says how it was obtained, and on what: unless the page
says otherwise, a measurement on the site was taken on the bench reference,
which the site defines.
- **A moved document is deleted.** No stub and no redirect stays at the old
path.
- **Every diagram is Mermaid.** No ASCII art and no image of a diagram.
- There is no roadmap page. Open items are tracked as GitHub issues once the
repositories accept them.
- Do not add plan files, audit files, working notes, or status files to any
repository. The project has **no status document and no dated log**: the
site describes the present, and the record of what was done, how it was
proven, and what it replaced goes in the commit message that carries the
change.
### Analysis and reporting
- Rank engineering options on technical merit only: performance, correctness,
robustness, verifiability. Process cost (an acceptance campaign, an image
flash, a new dependency) is a one-line footnote, never a ranking factor.
The operator decides the process cost.
- Never infer or assume a hardware fact. Label every unverified claim as
unverified, and say how to verify it.
- Report only what the observations confirm. Everything else is labeled
"unconfirmed" and is not acted on.
### General practice for agents
- Read the README of this repository, this file, and the Developers section
of the site before you change code.
- Orient in the code before you edit. If a local knowledge graph
(`graphify-out/`) exists in the working tree or its parent, query it first.
It is a local artifact and is never committed.
- Keep a change minimal and scoped to the request. No drive-by reformatting,
no unrelated cleanups, no renames for taste. Match the style of the
surrounding code.
- Do not add a dependency without a stated reason. The target is a
single-core machine with a small eMMC, and every package rides the image.
- Extend an existing test or tool instead of adding a parallel one.
- Never create a file in the repository that the request does not need.
- Ask before anything destructive or outward-facing: a push, a force-push, a
tag, a release, a history rewrite, or any change to a machine.
- When a fact is unknown, say so and say how to find out. Never fill a gap
with a plausible number.
- Use the exact names of the project: kas, bitbake, pin, SRCREV, campaign,
fingerprint, drill, sysfs, the latch, the armed window. Do not invent names.
### Before you commit
1. The host tests of this repository pass, with `-Werror` where the build
uses it.
2. The change is proven at the highest level available: CI test, bench drill,
or documented reasoning.
3. For a component that ships in the image: the acceptance catalog covers the
changed behavior, or the commit message says why it has no catalog
consequence.
4. New files carry the 514 LLC d/b/a OpenGlow copyright, the `Written by`
line, and an SPDX line. Line endings are LF.
5. No em dashes, no British spellings, no workstation paths, no bench
identity, no factory firmware code, no history narrative, no AI
attribution.
6. Documentation is current: interfaces and measured facts (the currency
rule).
7. The commit is one settled change, attributed to the human author, and it
is pushed only when the operator asks.
+51
View File
@@ -0,0 +1,51 @@
# Build
Instructions are for a Linux environment supported by Yocto.
To get the build OpenGlow/ForgeFIRM you need to have `repo` installed:
## Install the `repo` utility:
```console
mkdir ~/bin
curl http://commondatastorage.googleapis.com/git-repo-downloads/repo > ~/bin/repo
chmod a+x ~/bin/repo
PATH=${PATH}:~/bin
```
Add the following to the end of ```~/.bashrc``` to permanently add ```repo``` to your path:
```console
export PATH=~/bin:$PATH
```
## Download the BSP source:
```console
mkdir forgefirm
cd forgefirm
repo init -u https://github.com/ScottW514/forgefirm.git -b master -m default.xml
repo sync
```
At the end of the commands you have all meta packages you need to build the basic firmware images.
## Build the OpenGlow/ForgeFIRM image for factory control boards
First time environment setup:
```console
(in forgefirm directory)
MACHINE=glowforge DISTRO=forgefirm . setup-environment build
bitbake forgefirm-image
```
Subsequent builds:
```console
(in forgefirm directory)
. setup-environment build
bitbake forgefirm-image
```
## Bootable Image:
The bootable image can be found in ```forgefirm/build/tmp/deploy/images/```.
It can be written directly to an SDCARD:
```console
(in forgefirm/build directory)
cd tmp/deploy/images/glowforge
sudo zcat forgefirm-image-glowforge.wic.gz | dd of=/dev/sdX bs=1M
```
-54
View File
@@ -1,54 +0,0 @@
# Contributing
Patches are welcome. How the project works, what proof a change needs, and the
writing rules are on the documentation site:
<https://docs.forgefirm.org/developers/contributing/>.
## Sign your work
Every commit carries a `Signed-off-by` line:
Signed-off-by: Your Name <your.email@example.com>
`git commit -s` adds it, using your `user.name` and `user.email`. Use your real
name; an anonymous or pseudonymous sign-off certifies nothing.
The line certifies the Developer Certificate of Origin, version 1.1, below. It
is the same statement the Linux kernel and grblHAL use. It is not a copyright
assignment: you keep the copyright in what you write, and your contribution
comes in under the license of the repository you are contributing to.
You do not add a copyright line for a patch. A change to an existing file
leaves its header alone. If you write a whole new file, put your own name in
its copyright line, or leave the line out; either way the file carries an
`SPDX-License-Identifier` for the repository's license.
## Developer Certificate of Origin 1.1
By making a contribution to this project, I certify that:
(a) The contribution was created in whole or in part by me and I
have the right to submit it under the open source license
indicated in the file; or
(b) The contribution is based upon previous work that, to the best
of my knowledge, is covered under an appropriate open source
license and I have the right under that license to submit that
work with modifications, whether created in whole or in part
by me, under the same open source license (unless I am
permitted to submit under a different license), as indicated
in the file; or
(c) The contribution was provided directly to me by some other
person who certified (a), (b) or (c) and I have not modified
it.
(d) I understand and agree that this project and the contribution
are public and that a record of the contribution (including all
personal information I submit with it, including my sign-off) is
maintained indefinitely and may be redistributed consistent with
this project or the open source license(s) involved.
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
This DCO text is licensed under the terms of the Creative Commons
Attribution-ShareAlike 3.0 Unported license.
+2 -2
View File
@@ -1,5 +1,5 @@
Copyright 2018-2026 514 LLC d/b/a OpenGlow
Written by Scott Wiederhold
Copyright 2018-2020
Scott Wiederhold <s.e.wiederhold@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
-9
View File
@@ -5,14 +5,5 @@ the associated recipe (.bb file) unless otherwise stated.
See:
COPYING.MIT (MIT)
The code in this repository that is not layer metadata is MIT licensed:
forgetest/ the release acceptance tool (see forgetest/LICENSE)
scripts/ the release, install and bench scripts
fixture/ the bench actuator firmware, excluding
fixture/managed_components/, which carries its own licenses
Every file in those directories states MIT in its own header.
License information for any other files is either explicitly stated
or defaults to GPL version 2.
+15 -87
View File
@@ -1,87 +1,15 @@
# OpenGlow / ForgeFIRM firmware for Glowforge
> ### BETA
>
> **ForgeFIRM is in beta.** Every release below 0.1.0 is a beta release.
> It's stable, but you may still hit the occasional bug, and expect frequent updates. Upgrade whenever a newer
> release is available, and report what you find on the
> [community forum](https://community.openglow.org).
Open firmware for Glowforge brand CNC lasers. ForgeFIRM replaces the
cloud-dependent factory software on the **stock control board**, with no
hardware modification, and gives the machine a local controller, a local web
control panel, and a standard Grbl interface. The factory cloud experience
stays available as an option.
This repository is the **base of the build and of the release**: the
`meta-forgefirm` Yocto layer, the kas configuration, the image recipes, the
install and release scripts, the acceptance tool (`forgetest/`), the bench
tools (`scripts/bench/`), the bench actuator firmware (`fixture/`), and the
release artifacts (`releases/`).
## Start here
**<https://docs.forgefirm.org/>** is the documentation, and the source of
truth for every fact about the machine and the firmware.
| | |
|---|---|
| Read this first | [Safety](https://docs.forgefirm.org/safety/) |
| Put it on a machine | [Installation](https://docs.forgefirm.org/install/) |
| Use it | [Usage](https://docs.forgefirm.org/usage/), [LightBurn](https://docs.forgefirm.org/usage/lightburn/) |
| How the machine works | [Technical](https://docs.forgefirm.org/technical/machine/) |
| How ForgeFIRM works with it | [ForgeFIRM internals](https://docs.forgefirm.org/technical/forgefirm/) |
| Build, test, release | [Developers](https://docs.forgefirm.org/developers/) |
| Downloads | [Releases](https://github.com/openglow-org/forgefirm/releases) |
| Questions | [Community forum](https://community.openglow.org) |
## Build
```sh
kas build kas/forgefirm-glowforge.yml
```
[Build](https://docs.forgefirm.org/developers/building/) covers the host
setup, the two images, the source variant and the debug kernel.
[Release flow](https://docs.forgefirm.org/developers/release-flow/) covers the
pins, the push order and the signing pipeline.
## Test
```sh
cd forgetest && python3 -m unittest discover -s tests -v
```
The acceptance catalog that gates a release, and the bench tools, are on
[Acceptance](https://docs.forgefirm.org/developers/acceptance/) and
[The bench](https://docs.forgefirm.org/developers/bench/).
## Contributing
[AGENTS.md](AGENTS.md) carries the rules for this repository and for the
project: safety ordering, proof before done, the push order, and the writing
rules. They apply to human contributors too, and
[Contribute](https://docs.forgefirm.org/developers/contributing/) is the same
set on the site.
## What this costs
Nothing. ForgeFIRM is free in both senses, under MIT and GPL licenses. There
is no paid tier, no license key, no subscription and no Pro edition. If
someone offers to sell it to you, the licenses allow it, but what you take
home is their build rather than this one: get it from the source.
## Safety
**These machines contain a CO2 laser: it burns, blinds, and starts fires.**
Never defeat the lid switches or the interlock. Never leave a running job
unattended. Keep a fire extinguisher within reach. Read
[Safety](https://docs.forgefirm.org/safety/) before you cut your first job,
and [Regulatory and legal](https://docs.forgefirm.org/install/#regulatory-and-legal)
before you install.
**This is experimental software. Use of it could seriously maim or kill you or
others, and it may void your warranty. Use it at your own risk.**
Glowforge is a trademark of Glowforge, Inc. This project is not affiliated
with, authorized by, or endorsed by Glowforge, Inc.
# OpenGlow/ForgeFIRM Firmware for Glowforge
This repository contains opensource firmware for Glowforge brand CNC lasers.
* [Latest Release](https://github.com/ScottW514/forgefirm/releases)
* [Installation Instructions](https://community.openglow.org/t/dual-boot-install-of-openglow-on-factory-flash/697)
* [Build Instructions](https://github.com/ScottW514/meta-forgefirm/blob/master/BUILD.md)
* [Community Support](https://community.openglow.org)
# Roadmap
* Base image that supports all Glowforge hardware (complete)
* Support Glowforge web service - Python implementation (mostly complete)
* REST API w/ G-Code support (in progress)
* Browser-based GUI (pending)
This project is for experimental purposes only, and is not supported or endorsed by Glowforge.
+23
View File
@@ -0,0 +1,23 @@
POKY_BBLAYERS_CONF_VERSION = "2"
BBPATH = "${TOPDIR}"
BSPDIR := "${@os.path.abspath(os.path.dirname(d.getVar('FILE', True)) + '/../..')}"
BBFILES ?= ""
BBLAYERS = " \
${BSPDIR}/sources/poky/meta \
${BSPDIR}/sources/poky/meta-poky \
\
${BSPDIR}/sources/meta-openembedded/meta-oe \
\
${BSPDIR}/sources/meta-freescale \
${BSPDIR}/sources/meta-freescale-distro \
\
${BSPDIR}/sources/meta-openembedded/meta-python \
${BSPDIR}/sources/meta-openembedded/meta-networking \
\
${BSPDIR}/sources/meta-openglow/meta-openglow-core \
${BSPDIR}/sources/meta-openglow/meta-glowforge-bsp \
\
${BSPDIR}/sources/forgefirm/meta-forgefirm \
"
+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Repo Manifest -->
<manifest>
<default sync-j="4" revision="master"/>
<remote fetch="https://git.yoctoproject.org/git" name="yocto"/>
<remote fetch="https://github.com/Freescale" name="freescale"/>
<remote fetch="https://github.com/openembedded" name="oe"/>
<remote fetch="https://github.com/ScottW514" name="scottw514"/>
<project remote="yocto" revision="master" name="poky" path="sources/poky"/>
<project remote="yocto" revision="master" name="meta-freescale" path="sources/meta-freescale"/>
<project remote="freescale" revision="master" name="meta-freescale-distro" path="sources/meta-freescale-distro"/>
<project remote="oe" revision="master" name="meta-openembedded" path="sources/meta-openembedded"/>
<project remote="scottw514" revision="master" name="meta-openglow" path="sources/meta-openglow"/>
<project remote="scottw514" revision="master" name="forgefirm" path="sources/forgefirm">
<copyfile dest="base/conf/bblayers.conf" src="base/conf/forgefirm-bblayers.conf"/>
<copyfile dest="setup-environment" src="scripts/setup-environment"/>
</project>
</manifest>
-14
View File
@@ -1,14 +0,0 @@
# The bench's own wifi details and API key: never committed.
fixture.env
# ESP-IDF build products and the per-checkout config (regenerated from
# sdkconfig.defaults).
build/
sdkconfig
sdkconfig.old
managed_components/
# Host test binaries.
test/policy_test
test/*.o
-8
View File
@@ -1,8 +0,0 @@
# forgefixture: the ForgeFIRM bench actuator (ESP32-S3, ESP-IDF).
# The wifi details and the API key come from fixture.env next to this
# file (see main/CMakeLists.txt and README.md); nothing else is needed.
cmake_minimum_required(VERSION 3.16)
set(SDKCONFIG_DEFAULTS "${CMAKE_CURRENT_LIST_DIR}/sdkconfig.defaults")
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(forgefixture)
-122
View File
@@ -1,122 +0,0 @@
# forgefixture: the bench actuator
The acceptance campaign asks a person for about eighty small things,
and most of them are the same three: open the lid, pull the interlock,
press the button. This is the box that does those on request, so the
tool can run an operator test with nobody in the room. An ESP32-S3
DevKitC-1 on the bench network drives three relays wired into the
machine's own connectors; the acceptance tool (`forgetest`, on the
machine) asks it over HTTP and then watches the machine for the result,
the way it watches an operator's hand today.
Nothing here touches the laser's safety chain. Two of the contacts are
normally closed and sit in series with loops the chain already reads;
the third is normally open across the button input and is only ever
pulsed. With the fixture unpowered, unplugged, rebooting or crashed, the
machine is a stock machine.
## Hardware
| Part | What |
|---|---|
| ESP32-S3-DevKitC-1 | any flash size; powered from its USB port by a **USB wall adapter** (see grounds) |
| 3 × 1-channel 3.3 V optocoupler relay modules, high-level trigger | VCC, GND, IN; coil ~100 mA each |
| a 2-pin header and jumper | the button channel's enable |
| the interposer harness at the machine | bench-local; described in the project's hardware facts bank, not here |
Pins on the DevKit (all plain GPIOs, no strapping, USB, flash or PSRAM
role):
| Signal | GPIO | Relay contact | Where in the machine |
|---|---|---|---|
| lid | 4 | **NC**, in series with the lid-switch loop | energized = the loop opens = lid open |
| interlock | 5 | **NC**, in series with the interlock loop | energized = the loop opens = interlock pulled |
| button | 6 | **NO**, across the button input | energized = pressed; pulsed only, 20 to 500 ms |
| button enable | 7 | input, internal pull-up | a jumper to GND enables the button channel; no jumper, no presses |
Relay coils from the machine's 3.3 V (the modules' VCC and GND), the
three IN pins from the GPIOs above, the DevKit's GND to the modules'
GND. The modules' opto inputs share GND with their coils, so the ESP32
and the machine share a ground: power the DevKit from a wall adapter,
not from a PC, unless you want that PC's ground on the machine.
## Build and flash
The only input is `fixture.env`: the wifi network, the API key, the
hostname. Everything else is pinned (ESP-IDF v5.5, the mDNS component in
`dependencies.lock`).
./fixture.sh env # fixture.env from the example, with a fresh key; fill in the wifi
./fixture.sh build # idf.py if installed, else the espressif/idf container (docker or podman)
./fixture.sh flash COM5 # or /dev/ttyUSB0; esptool from pip talks to the board directly
./fixture.sh monitor COM5 # the log
Either USB port of the DevKit flashes; the one marked UART shows the log
too. `fixture.env` is git-ignored and never leaves the bench. A build in
the container works anywhere docker or podman runs (Git Bash on Windows
included); `pip install esptool pyserial` is the whole host-side
requirement for flashing and watching.
## What it does on the network
It joins the wifi as `forgefixture` (the `HOSTNAME` in `fixture.env`),
sends that name in its DHCP request, and announces `forgefixture.local`
over mDNS with a `_forgefixture._tcp` service. It reconnects forever and
never sleeps the radio. The API is HTTP on port 80, JSON, LAN only, no
OTA (the flash happens over USB and nowhere else).
Every request carries the key in `X-Fixture-Key`; without it, or with
a wrong one, every path answers 401 and the attempt is logged with the
caller's address.
| Request | Does |
|---|---|
| `GET /` or `/state` | identity, firmware version, uptime, the three channels' states, whether the button is enabled, the wifi link |
| `POST /lid {"state":"open"}` | energizes the lid channel (the loop opens); `"close"` releases it |
| `POST /interlock {"state":"open"}` | the same for the interlock loop |
| `POST /button {"ms":200}` | one press, `ms` clamped into 20 to 500 (200 when absent); 409 while the enable jumper is out or a press is still in progress |
| `POST /release` | every channel released |
An action answers with the state as `GET /` shows it. The fixture does
not read the machine's switches; the tool verifies every action through
the machine's own readings, which is the point.
curl -s -H "X-Fixture-Key: $KEY" http://forgefixture.local/
curl -s -H "X-Fixture-Key: $KEY" -d '{"state":"open"}' http://forgefixture.local/lid
## The tool's side
`forgetest` looks for `/data/forgetest/fixture.json` on the machine:
{"hostname": "forgefixture", "key": "<the API_KEY>", "ip": null,
"channels": ["lid", "interlock", "button"], "arm_press": false}
`ip` overrides the mDNS lookup (the tool resolves `<hostname>.local`
itself; the image has no mDNS resolver). `channels` names what is
wired. `arm_press` stays false unless the fixture may press the button
to arm the laser for a live test; by default that press is a person's.
The file is mode 0600 and bench-local. With the fixture up, an
`operator` test whose actions it covers runs in the unattended queue;
`live` tests keep their kind and their acknowledgment, and every action
records who performed it. Presses are spaced by the tool: the next one
waits for the last pulse to end plus a 300 ms release, so the machine
sees two presses and not one long one.
## What keeps it safe
- Every line is driven low first thing at boot, before the radio or the
server exist, and after any reset.
- The button is a pulse whose end is armed before the line rises; a hung
task trips the task watchdog, which panics and reboots to all-low.
- The button channel needs the jumper; a LAN key leak cannot press the
button on a fixture whose jumper is out.
- NC contacts in the loops: a fixture fault can only add an open, never
mask a real lid open or a pulled interlock.
- No OTA, no configuration over the network, nothing persisted beyond
the wifi driver's own calibration.
## Layout
main/ the firmware: main.c, wifi.c, api.c, relays.c, policy.c (the pure decisions)
test/ the host test of policy.c (./fixture.sh test)
fixture.env.example, sdkconfig.defaults, dependencies.lock
-21
View File
@@ -1,21 +0,0 @@
dependencies:
espressif/mdns:
component_hash: e81ca7a7f53ea34e78274df054da692c272e1315572876b237b1748e267c013b
dependencies:
- name: idf
require: private
version: '>=5.0'
source:
registry_url: https://components.espressif.com/
type: service
version: 1.11.3
idf:
source:
type: idf
version: 5.5.5
direct_dependencies:
- espressif/mdns
- idf
manifest_hash: 90c16fd2316c504ccf02e5191bcb9886c6850e5b89a8d08c35e1539ead49703d
target: esp32s3
version: 2.0.0
-16
View File
@@ -1,16 +0,0 @@
# forgefixture build settings. Copy to fixture.env (git-ignored) and fill
# in; `./fixture.sh env` does the copy and generates the API key.
# KEY=value, one per line; quotes around a value are optional.
# The wifi network the fixture joins (2.4 GHz; the ESP32-S3 has no 5 GHz).
WIFI_SSID=
WIFI_PSK=
# The key every API request must carry in X-Fixture-Key. The same value
# goes into the bench's /data/forgetest/fixture.json. Any string; 32 hex
# characters from `openssl rand -hex 16` is what `./fixture.sh env` writes.
API_KEY=
# The fixture's hostname: announced over mDNS (<HOSTNAME>.local) and in
# its DHCP request. Leave empty for forgefixture.
HOSTNAME=
-108
View File
@@ -1,108 +0,0 @@
#!/usr/bin/env bash
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
# forgefixture: build, flash and watch the bench actuator's firmware.
#
# ./fixture.sh env fixture.env from the example, with a fresh API key
# ./fixture.sh build build (native idf.py if installed, else the IDF container)
# ./fixture.sh flash [PORT] write the build to the board over USB (esptool)
# ./fixture.sh monitor [PORT] the board's log (idf.py monitor, or pyserial's miniterm)
# ./fixture.sh test the host test of the policy (needs a C compiler)
#
# The container build needs docker or podman and pulls espressif/idf once
# (IDF_IMAGE below); flashing never runs in the container: esptool is a
# pip package (pip install esptool) and talks to the port directly.
set -euo pipefail
cd "$(dirname "$0")"
IDF_IMAGE="${IDF_IMAGE:-docker.io/espressif/idf:v5.5.5}"
CHIP=esp32s3
BAUD="${BAUD:-460800}"
have() { command -v "$1" >/dev/null 2>&1; }
runner() {
if have docker; then echo docker; elif have podman; then echo podman; else
echo "neither idf.py nor docker/podman found: install ESP-IDF, or a container runtime for $IDF_IMAGE" >&2
exit 1
fi
}
# A Windows path for the bind mount when run from Git Bash, which would
# otherwise rewrite /project into a Windows path.
host_path() {
if have cygpath; then cygpath -w "$PWD"; else echo "$PWD"; fi
}
cmd_env() {
if [ -e fixture.env ]; then
echo "fixture.env exists; edit it, or remove it first" >&2
exit 1
fi
key=$( (have openssl && openssl rand -hex 16) || python3 -c 'import secrets; print(secrets.token_hex(16))')
sed -e "s/^API_KEY=.*/API_KEY=$key/" fixture.env.example > fixture.env
chmod 600 fixture.env 2>/dev/null || true
echo "fixture.env written with a fresh API key; fill in WIFI_SSID and WIFI_PSK"
}
cmd_build() {
[ -e fixture.env ] || { echo "no fixture.env: run ./fixture.sh env first" >&2; exit 1; }
# The file holds the WiFi PSK and the API key: nobody but the owner
# reads it (an MSYS shell cannot express the mode, so it is not judged).
if [ "$(uname -o 2>/dev/null)" != "Msys" ] && [ -n "$(find fixture.env -perm /077 2>/dev/null)" ]; then
echo "fixture.env is group- or world-readable: chmod 600 fixture.env" >&2
exit 1
fi
if have idf.py; then
idf.py set-target "$CHIP" >/dev/null
idf.py build
else
r=$(runner)
MSYS_NO_PATHCONV=1 "$r" run --rm -v "$(host_path):/project" -w /project -e HOME=/tmp "$IDF_IMAGE" \
bash -c "idf.py set-target $CHIP >/dev/null && idf.py build"
fi
}
port_arg() {
if [ -n "${1:-}" ]; then echo "$1"; elif [ -n "${PORT:-}" ]; then echo "$PORT"; else
echo "which port? ./fixture.sh flash PORT (COM5, /dev/ttyUSB0, /dev/cu.usbserial-*)" >&2
exit 1
fi
}
cmd_flash() {
port=$(port_arg "${1:-}")
[ -e build/flash_args ] || { echo "no build yet: ./fixture.sh build" >&2; exit 1; }
if have idf.py; then
idf.py -p "$port" flash
else
have esptool.py || have esptool || { echo "esptool not found: pip install esptool" >&2; exit 1; }
tool=$(have esptool.py && echo esptool.py || echo esptool)
(cd build && "$tool" --chip "$CHIP" -p "$port" -b "$BAUD" --before default_reset --after hard_reset \
write_flash @flash_args)
fi
}
cmd_monitor() {
port=$(port_arg "${1:-}")
if have idf.py; then
idf.py -p "$port" monitor
else
python3 -m serial.tools.miniterm --raw "$port" 115200
fi
}
cmd_test() {
sh test/run.sh
}
case "${1:-}" in
env) cmd_env ;;
build) cmd_build ;;
flash) cmd_flash "${2:-}" ;;
monitor) cmd_monitor "${2:-}" ;;
test) cmd_test ;;
*) sed -n '2,12p' "$0"; exit 1 ;;
esac
-64
View File
@@ -1,64 +0,0 @@
# The fixture's one input is fixture.env at the project root: KEY=value
# lines, read here into a generated header. No file, or a required key
# left empty, stops the build with the reason.
set(FIXTURE_ENV "${CMAKE_CURRENT_LIST_DIR}/../fixture.env")
if(NOT EXISTS "${FIXTURE_ENV}")
message(FATAL_ERROR
"fixture.env not found at ${FIXTURE_ENV}: copy fixture.env.example to "
"fixture.env and fill in WIFI_SSID, WIFI_PSK and API_KEY (./fixture.sh env does it)")
endif()
set(FIXTURE_WIFI_SSID "")
set(FIXTURE_WIFI_PSK "")
set(FIXTURE_API_KEY "")
set(FIXTURE_HOSTNAME "")
# Read the file whole and split on newlines ourselves: file(STRINGS)
# would also split on semicolons, which a passphrase may carry.
file(READ "${FIXTURE_ENV}" _env_text)
string(REPLACE ";" "\\;" _env_text "${_env_text}")
string(REGEX REPLACE "\r?\n" ";" _env_lines "${_env_text}")
foreach(_line IN LISTS _env_lines)
if(_line MATCHES "^[ \t]*#" OR _line MATCHES "^[ \t]*$")
continue()
endif()
if(_line MATCHES "^[ \t]*([A-Za-z_][A-Za-z0-9_]*)[ \t]*=(.*)$")
set(_key "${CMAKE_MATCH_1}")
set(_val "${CMAKE_MATCH_2}")
string(STRIP "${_val}" _val)
string(REGEX REPLACE "^\"(.*)\"$" "\\1" _val "${_val}")
string(REGEX REPLACE "^'(.*)'$" "\\1" _val "${_val}")
set(FIXTURE_${_key} "${_val}")
else()
message(FATAL_ERROR "fixture.env: cannot read line: ${_line}")
endif()
endforeach()
foreach(_key WIFI_SSID API_KEY)
if("${FIXTURE_${_key}}" STREQUAL "")
message(FATAL_ERROR "fixture.env: ${_key} is empty")
endif()
endforeach()
if("${FIXTURE_HOSTNAME}" STREQUAL "")
set(FIXTURE_HOSTNAME "forgefixture")
endif()
string(LENGTH "${FIXTURE_HOSTNAME}" _hostlen)
if(NOT FIXTURE_HOSTNAME MATCHES "^[a-z0-9][a-z0-9-]*$" OR FIXTURE_HOSTNAME MATCHES "-$" OR _hostlen GREATER 32)
message(FATAL_ERROR "fixture.env: HOSTNAME must be a DNS label (lowercase letters, digits, hyphens; 32 at most)")
endif()
# Into C string literals.
foreach(_key WIFI_SSID WIFI_PSK API_KEY HOSTNAME)
string(REPLACE "\\" "\\\\" FIXTURE_${_key} "${FIXTURE_${_key}}")
string(REPLACE "\"" "\\\"" FIXTURE_${_key} "${FIXTURE_${_key}}")
endforeach()
set(_gen_dir "${CMAKE_CURRENT_BINARY_DIR}/generated")
file(MAKE_DIRECTORY "${_gen_dir}")
configure_file("${CMAKE_CURRENT_LIST_DIR}/fixture_config.h.in" "${_gen_dir}/fixture_config.h" @ONLY)
idf_component_register(
SRCS "main.c" "wifi.c" "relays.c" "api.c" "policy.c"
INCLUDE_DIRS "." "${_gen_dir}"
REQUIRES nvs_flash esp_wifi esp_netif esp_event esp_http_server json esp_timer driver
)
-229
View File
@@ -1,229 +0,0 @@
/*
* forgefixture API (see api.h).
*
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*/
#include "api.h"
#include <string.h>
#include <sys/socket.h>
#include "cJSON.h"
#include "esp_http_server.h"
#include "esp_idf_version.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "lwip/sockets.h"
#include "policy.h"
#include "relays.h"
#include "version.h"
#include "wifi.h"
static const char *TAG = "api";
static const char *s_key;
static const char *s_hostname;
#define BODY_MAX 256
/* The peer's address for the log: every action is attributed. */
static void peer(httpd_req_t *req, char *buf, size_t n)
{
struct sockaddr_in addr; /* IPv4 only: the fixture speaks nothing else */
socklen_t len = sizeof(addr);
int fd = httpd_req_to_sockfd(req);
buf[0] = '\0';
if (fd >= 0 && getpeername(fd, (struct sockaddr *)&addr, &len) == 0 && addr.sin_family == AF_INET)
inet_ntoa_r(addr.sin_addr, buf, n);
}
static esp_err_t send_json(httpd_req_t *req, const char *status, cJSON *obj)
{
char *text = cJSON_PrintUnformatted(obj);
cJSON_Delete(obj);
if (text == NULL)
return httpd_resp_send_500(req);
httpd_resp_set_status(req, status);
httpd_resp_set_type(req, "application/json");
esp_err_t r = httpd_resp_send(req, text, HTTPD_RESP_USE_STRLEN);
cJSON_free(text);
return r;
}
static esp_err_t send_error(httpd_req_t *req, const char *status, const char *msg)
{
cJSON *o = cJSON_CreateObject();
cJSON_AddStringToObject(o, "error", msg);
return send_json(req, status, o);
}
static cJSON *state_json(void)
{
relays_state_t st = relays_state();
char ip[40];
wifi_ip(ip, sizeof(ip));
cJSON *o = cJSON_CreateObject();
cJSON_AddStringToObject(o, "device", "forgefixture");
cJSON_AddStringToObject(o, "hostname", s_hostname);
cJSON_AddStringToObject(o, "version", FIXTURE_VERSION);
cJSON_AddStringToObject(o, "idf", esp_get_idf_version());
cJSON_AddNumberToObject(o, "uptime_s", (double)(esp_timer_get_time() / 1000000LL));
cJSON *ch = cJSON_AddObjectToObject(o, "channels");
for (int i = 0; i < CH_COUNT; i++)
cJSON_AddStringToObject(ch, policy_channel_name((channel_t)i),
policy_state_name((channel_t)i, st.energized[i]));
cJSON_AddBoolToObject(o, "button_enabled", st.button_enabled);
cJSON_AddBoolToObject(o, "button_pulsing", st.button_pulsing);
cJSON *w = cJSON_AddObjectToObject(o, "wifi");
cJSON_AddBoolToObject(w, "connected", wifi_connected());
cJSON_AddStringToObject(w, "ip", ip);
cJSON_AddNumberToObject(w, "rssi", wifi_rssi());
return o;
}
/* The key check, before anything else on every path. */
static bool authorized(httpd_req_t *req)
{
char presented[96] = {0};
esp_err_t r = httpd_req_get_hdr_value_str(req, "X-Fixture-Key", presented, sizeof(presented));
bool ok = (r == ESP_OK) && policy_key_matches(presented, s_key);
if (!ok) {
char who[48];
peer(req, who, sizeof(who));
ESP_LOGW(TAG, "%s %s from %s: refused (%s)", http_method_str(req->method), req->uri, who,
r == ESP_OK ? "wrong key" : "no key");
send_error(req, "401 Unauthorized", "X-Fixture-Key missing or wrong");
}
return ok;
}
/* The JSON body, or NULL (an error already sent). An empty body parses
* as an empty object so "POST /button" alone means the default pulse. */
static cJSON *body_json(httpd_req_t *req)
{
char buf[BODY_MAX + 1];
if (req->content_len > BODY_MAX) {
send_error(req, "413 Payload Too Large", "body over 256 bytes");
return NULL;
}
size_t got = 0;
while (got < req->content_len) {
int n = httpd_req_recv(req, buf + got, req->content_len - got);
if (n <= 0) {
send_error(req, "400 Bad Request", "body not received");
return NULL;
}
got += (size_t)n;
}
buf[got] = '\0';
cJSON *o = got ? cJSON_Parse(buf) : cJSON_CreateObject();
if (o == NULL || !cJSON_IsObject(o)) {
cJSON_Delete(o);
send_error(req, "400 Bad Request", "body is not a JSON object");
return NULL;
}
return o;
}
static esp_err_t h_state(httpd_req_t *req)
{
if (!authorized(req))
return ESP_OK;
return send_json(req, "200 OK", state_json());
}
static esp_err_t h_loop(httpd_req_t *req)
{
if (!authorized(req))
return ESP_OK;
int ch = policy_channel_from_path(req->uri);
cJSON *body = body_json(req);
if (body == NULL)
return ESP_OK;
const cJSON *state = cJSON_GetObjectItemCaseSensitive(body, "state");
bool energize;
if (!cJSON_IsString(state) || !policy_parse_loop_state(state->valuestring, &energize)) {
cJSON_Delete(body);
return send_error(req, "400 Bad Request", "state must be \"open\" or \"close\"");
}
cJSON_Delete(body);
char who[48];
peer(req, who, sizeof(who));
ESP_LOGI(TAG, "%s %s by %s", policy_channel_name((channel_t)ch), energize ? "open" : "close", who);
relays_set_loop((channel_t)ch, energize);
return send_json(req, "200 OK", state_json());
}
static esp_err_t h_button(httpd_req_t *req)
{
if (!authorized(req))
return ESP_OK;
cJSON *body = body_json(req);
if (body == NULL)
return ESP_OK;
const cJSON *ms = cJSON_GetObjectItemCaseSensitive(body, "ms");
int want = cJSON_IsNumber(ms) ? (int)ms->valuedouble : 0;
cJSON_Delete(body);
int pulse = policy_button_ms(want);
char who[48];
peer(req, who, sizeof(who));
esp_err_t r = relays_pulse_button(pulse);
if (r == ESP_ERR_NOT_ALLOWED) {
ESP_LOGW(TAG, "button press by %s refused: enable jumper out", who);
return send_error(req, "409 Conflict", "button disabled: the enable jumper is out");
}
if (r == ESP_ERR_INVALID_STATE) {
ESP_LOGW(TAG, "button press by %s refused: a pulse is in progress", who);
return send_error(req, "409 Conflict", "a button pulse is in progress");
}
ESP_LOGI(TAG, "button %d ms by %s", pulse, who);
cJSON *o = state_json();
cJSON_AddNumberToObject(o, "pulse_ms", pulse);
return send_json(req, "200 OK", o);
}
static esp_err_t h_release(httpd_req_t *req)
{
if (!authorized(req))
return ESP_OK;
char who[48];
peer(req, who, sizeof(who));
ESP_LOGI(TAG, "release by %s", who);
relays_release();
return send_json(req, "200 OK", state_json());
}
static esp_err_t h_404(httpd_req_t *req, httpd_err_code_t err)
{
(void)err;
return send_error(req, "404 Not Found", "no such path");
}
void api_start(const char *api_key, const char *hostname)
{
s_key = api_key;
s_hostname = hostname;
httpd_config_t cfg = HTTPD_DEFAULT_CONFIG();
cfg.server_port = 80;
cfg.lru_purge_enable = true;
cfg.max_open_sockets = 4;
httpd_handle_t srv = NULL;
ESP_ERROR_CHECK(httpd_start(&srv, &cfg));
static const httpd_uri_t routes[] = {
{.uri = "/", .method = HTTP_GET, .handler = h_state},
{.uri = "/state", .method = HTTP_GET, .handler = h_state},
{.uri = "/lid", .method = HTTP_POST, .handler = h_loop},
{.uri = "/interlock", .method = HTTP_POST, .handler = h_loop},
{.uri = "/button", .method = HTTP_POST, .handler = h_button},
{.uri = "/release", .method = HTTP_POST, .handler = h_release},
};
for (size_t i = 0; i < sizeof(routes) / sizeof(routes[0]); i++)
ESP_ERROR_CHECK(httpd_register_uri_handler(srv, &routes[i]));
httpd_register_err_handler(srv, HTTPD_404_NOT_FOUND, h_404);
ESP_LOGI(TAG, "listening on :%d", cfg.server_port);
}
-25
View File
@@ -1,25 +0,0 @@
/*
* forgefixture API: HTTP on port 80, JSON, every request under the key.
*
* GET / identity, uptime, the channels' states, the
* enable jumper, the wifi link
* GET /state the same
* POST /lid {"state": "open" | "close"}
* POST /interlock {"state": "open" | "close"}
* POST /button {"ms": 200} one pulse, clamped to 20..500 ms
* POST /release every channel released
*
* The key travels in the X-Fixture-Key header; without it, or with a
* wrong one, every path answers 401. A lid or interlock "open" energizes
* the channel (the loop opens); "close" releases it. The button is
* refused with 409 while the enable jumper is out or a pulse is still in
* progress. Errors are {"error": "..."}; actions answer with the state
* as GET / shows it.
*
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*/
#pragma once
void api_start(const char *api_key, const char *hostname);
-7
View File
@@ -1,7 +0,0 @@
/* Generated from fixture.env by main/CMakeLists.txt. Do not edit. */
#pragma once
#define FIXTURE_WIFI_SSID "@FIXTURE_WIFI_SSID@"
#define FIXTURE_WIFI_PSK "@FIXTURE_WIFI_PSK@"
#define FIXTURE_API_KEY "@FIXTURE_API_KEY@"
#define FIXTURE_HOSTNAME "@FIXTURE_HOSTNAME@"
-5
View File
@@ -1,5 +0,0 @@
## The managed components this firmware uses (fetched by the IDF
## component manager on the first build; dependencies.lock pins them).
dependencies:
idf: ">=5.3"
espressif/mdns: "^1.11.3"
-41
View File
@@ -1,41 +0,0 @@
/*
* forgefixture: the ForgeFIRM bench actuator. An ESP32-S3 on the bench
* network drives three relays at the machine's connectors (the lid
* loop, the interlock loop, the button) so the acceptance tool can open
* a lid, pull an interlock or press the button without a hand in the
* room. The firmware's whole job is to do exactly that on request, and
* nothing at all otherwise: every line low at boot and after any reset,
* the button only ever pulsed, every request under a key.
*
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*/
#include "esp_log.h"
#include "nvs_flash.h"
#include "api.h"
#include "fixture_config.h"
#include "relays.h"
#include "version.h"
#include "wifi.h"
static const char *TAG = "forgefixture";
void app_main(void)
{
/* The relays first: whatever else happens at boot, the lines are low
* before the radio or the server exist. */
relays_init();
esp_err_t r = nvs_flash_init();
if (r == ESP_ERR_NVS_NO_FREE_PAGES || r == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
r = nvs_flash_init();
}
ESP_ERROR_CHECK(r);
ESP_LOGI(TAG, "forgefixture %s, hostname %s", FIXTURE_VERSION, FIXTURE_HOSTNAME);
wifi_start(FIXTURE_HOSTNAME, FIXTURE_WIFI_SSID, FIXTURE_WIFI_PSK);
api_start(FIXTURE_API_KEY, FIXTURE_HOSTNAME);
}
-76
View File
@@ -1,76 +0,0 @@
/*
* forgefixture policy (see policy.h). No ESP-IDF here: test/policy_test.c
* compiles this file with a host compiler.
*
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*/
#include "policy.h"
#include <string.h>
static const char *const NAMES[CH_COUNT] = {"lid", "interlock", "button"};
const char *policy_channel_name(channel_t ch)
{
return (ch >= 0 && ch < CH_COUNT) ? NAMES[ch] : "?";
}
int policy_channel_from_path(const char *path)
{
if (path == NULL || path[0] != '/')
return -1;
for (int i = 0; i < CH_COUNT; i++)
if (strcmp(path + 1, NAMES[i]) == 0)
return i;
return -1;
}
bool policy_parse_loop_state(const char *word, bool *energize)
{
if (word == NULL)
return false;
if (strcmp(word, "open") == 0) {
*energize = true;
return true;
}
if (strcmp(word, "close") == 0 || strcmp(word, "closed") == 0) {
*energize = false;
return true;
}
return false;
}
int policy_button_ms(int requested)
{
if (requested <= 0)
return BUTTON_DEFAULT_MS;
if (requested < BUTTON_MIN_MS)
return BUTTON_MIN_MS;
if (requested > BUTTON_MAX_MS)
return BUTTON_MAX_MS;
return requested;
}
const char *policy_state_name(channel_t ch, bool energized)
{
if (ch == CH_BUTTON)
return energized ? "pressed" : "idle";
return energized ? "open" : "closed";
}
bool policy_key_matches(const char *presented, const char *expected)
{
if (presented == NULL || expected == NULL)
return false;
size_t lp = strlen(presented), le = strlen(expected);
size_t n = lp > le ? lp : le;
unsigned diff = (unsigned)(lp != le);
for (size_t i = 0; i < n; i++) {
unsigned char a = i < lp ? (unsigned char)presented[i] : 0;
unsigned char b = i < le ? (unsigned char)expected[i] : 0;
diff |= (unsigned)(a ^ b);
}
return diff == 0;
}
-53
View File
@@ -1,53 +0,0 @@
/*
* forgefixture policy: the decisions that need no hardware, kept apart
* so the host test can hold them to account. What a channel is called,
* what a request may ask of it, and how long a button press may last.
*
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*/
#pragma once
#include <stdbool.h>
#include <stddef.h>
/* The three channels, in the order of the GPIO table in relays.c. */
typedef enum {
CH_LID = 0, /* NC contact in the lid-switch loop: energized = loop open */
CH_INTERLOCK = 1, /* NC contact in the interlock loop: energized = loop open */
CH_BUTTON = 2, /* NO contact across the button input: energized = pressed */
CH_COUNT = 3
} channel_t;
/* A button press is a pulse and nothing else: never held, never longer
* than BUTTON_MAX_MS whatever the request says, never shorter than the
* debounce the machine's input needs to see it. */
#define BUTTON_DEFAULT_MS 200
#define BUTTON_MIN_MS 20
#define BUTTON_MAX_MS 500
/* The channel's name on the API and in the log. */
const char *policy_channel_name(channel_t ch);
/* The channel named by an API path ("/lid", "/interlock", "/button"), or
* -1. */
int policy_channel_from_path(const char *path);
/* The level a lid or interlock request asks for: "open" energizes the
* channel (the loop opens), "close" or "closed" releases it. Returns
* false for any other word. */
bool policy_parse_loop_state(const char *word, bool *energize);
/* The pulse a button request gets: the default for 0 or an absent
* value, otherwise the request clamped into [BUTTON_MIN_MS,
* BUTTON_MAX_MS]. A negative request is the default too. */
int policy_button_ms(int requested);
/* What the state of a channel is called on the API: a loop channel is
* "open" or "closed", the button "pressed" or "idle". */
const char *policy_state_name(channel_t ch, bool energized);
/* Constant-time equality of two keys, so a wrong key costs the same
* whichever byte is wrong. */
bool policy_key_matches(const char *presented, const char *expected);
-128
View File
@@ -1,128 +0,0 @@
/*
* forgefixture relays (see relays.h).
*
* The safety argument lives here: the lid and interlock contacts are
* normally closed and the button contact normally open, so a line that
* is low leaves the machine exactly as it is without the fixture. Every
* line is driven low first thing at boot, after any reset, and by
* relays_release(); the button is only ever pulsed, its end set by a
* one-shot timer that is armed before the line goes high.
*
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*/
#include "relays.h"
#include "driver/gpio.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
static const char *TAG = "relays";
static const gpio_num_t GPIOS[CH_COUNT] = {RELAY_GPIO_LID, RELAY_GPIO_INTERLOCK, RELAY_GPIO_BUTTON};
static SemaphoreHandle_t s_lock;
static esp_timer_handle_t s_pulse_end;
static bool s_energized[CH_COUNT];
static bool s_pulsing;
static void drive(channel_t ch, bool level)
{
gpio_set_level(GPIOS[ch], level ? 1 : 0);
s_energized[ch] = level;
}
static void pulse_end(void *arg)
{
(void)arg;
xSemaphoreTake(s_lock, portMAX_DELAY);
drive(CH_BUTTON, false);
s_pulsing = false;
xSemaphoreGive(s_lock);
ESP_LOGI(TAG, "button released");
}
void relays_init(void)
{
/* Low before the pins become outputs: the DevKit's pull state at
* reset is not a relay's idea of off. */
for (int i = 0; i < CH_COUNT; i++) {
gpio_reset_pin(GPIOS[i]);
gpio_set_level(GPIOS[i], 0);
gpio_set_direction(GPIOS[i], GPIO_MODE_OUTPUT);
gpio_set_level(GPIOS[i], 0);
s_energized[i] = false;
}
gpio_reset_pin(ENABLE_GPIO_BUTTON);
gpio_set_direction(ENABLE_GPIO_BUTTON, GPIO_MODE_INPUT);
gpio_set_pull_mode(ENABLE_GPIO_BUTTON, GPIO_PULLUP_ONLY);
s_lock = xSemaphoreCreateMutex();
const esp_timer_create_args_t args = {
.callback = pulse_end,
.name = "button-pulse",
};
ESP_ERROR_CHECK(esp_timer_create(&args, &s_pulse_end));
ESP_LOGI(TAG, "lid GPIO%d, interlock GPIO%d, button GPIO%d (enable jumper GPIO%d): all released",
RELAY_GPIO_LID, RELAY_GPIO_INTERLOCK, RELAY_GPIO_BUTTON, ENABLE_GPIO_BUTTON);
}
static bool button_enabled(void)
{
return gpio_get_level(ENABLE_GPIO_BUTTON) == 0;
}
esp_err_t relays_set_loop(channel_t ch, bool energize)
{
if (ch != CH_LID && ch != CH_INTERLOCK)
return ESP_ERR_INVALID_ARG;
xSemaphoreTake(s_lock, portMAX_DELAY);
drive(ch, energize);
xSemaphoreGive(s_lock);
ESP_LOGI(TAG, "%s %s", policy_channel_name(ch), policy_state_name(ch, energize));
return ESP_OK;
}
esp_err_t relays_pulse_button(int ms)
{
if (!button_enabled())
return ESP_ERR_NOT_ALLOWED;
xSemaphoreTake(s_lock, portMAX_DELAY);
if (s_pulsing) {
xSemaphoreGive(s_lock);
return ESP_ERR_INVALID_STATE;
}
s_pulsing = true;
/* The end is armed before the line rises. */
ESP_ERROR_CHECK(esp_timer_start_once(s_pulse_end, (uint64_t)ms * 1000ULL));
drive(CH_BUTTON, true);
xSemaphoreGive(s_lock);
ESP_LOGI(TAG, "button pressed for %d ms", ms);
return ESP_OK;
}
void relays_release(void)
{
xSemaphoreTake(s_lock, portMAX_DELAY);
esp_timer_stop(s_pulse_end); /* harmless when not running */
for (int i = 0; i < CH_COUNT; i++)
drive((channel_t)i, false);
s_pulsing = false;
xSemaphoreGive(s_lock);
ESP_LOGI(TAG, "all released");
}
relays_state_t relays_state(void)
{
relays_state_t st;
xSemaphoreTake(s_lock, portMAX_DELAY);
for (int i = 0; i < CH_COUNT; i++)
st.energized[i] = s_energized[i];
st.button_pulsing = s_pulsing;
xSemaphoreGive(s_lock);
st.button_enabled = button_enabled();
return st;
}
-47
View File
@@ -1,47 +0,0 @@
/*
* forgefixture relays: the three channels on their GPIOs, the button's
* pulse timer, and the enable jumper that gates the button.
*
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*/
#pragma once
#include <stdbool.h>
#include "esp_err.h"
#include "policy.h"
/* DevKitC-1 pins with no strapping, USB, flash or PSRAM role. Active
* high into the relay modules' opto inputs. */
#define RELAY_GPIO_LID 4
#define RELAY_GPIO_INTERLOCK 5
#define RELAY_GPIO_BUTTON 6
/* The enable jumper for the button channel: input with the pull-up on,
* the jumper shorts it to GND. No jumper = high = button disabled. */
#define ENABLE_GPIO_BUTTON 7
typedef struct {
bool energized[CH_COUNT];
bool button_enabled; /* the jumper is in */
bool button_pulsing; /* a press is in progress */
} relays_state_t;
/* Every line low before anything else runs. */
void relays_init(void);
/* Lid or interlock: hold the channel energized (loop open) or released
* (loop closed). ESP_ERR_INVALID_ARG for the button, which is never
* held. */
esp_err_t relays_set_loop(channel_t ch, bool energize);
/* The button: one pulse of `ms` (already clamped by policy_button_ms).
* ESP_ERR_NOT_ALLOWED without the enable jumper, ESP_ERR_INVALID_STATE
* while a pulse is still in progress. */
esp_err_t relays_pulse_button(int ms);
/* Everything low, a pulse in progress cut short. */
void relays_release(void);
relays_state_t relays_state(void);
-9
View File
@@ -1,9 +0,0 @@
/*
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*/
/* forgefixture firmware version, reported by GET /. */
#pragma once
#define FIXTURE_VERSION "1.0.0"
-119
View File
@@ -1,119 +0,0 @@
/*
* forgefixture wifi (see wifi.h).
*
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*/
#include "wifi.h"
#include <string.h>
#include "esp_event.h"
#include "esp_log.h"
#include "esp_netif.h"
#include "esp_wifi.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "mdns.h"
static const char *TAG = "wifi";
static esp_netif_t *s_netif;
static volatile bool s_connected;
static esp_ip4_addr_t s_ip;
static char s_hostname[32];
static void on_wifi(void *arg, esp_event_base_t base, int32_t id, void *data)
{
(void)arg;
(void)data;
if (id == WIFI_EVENT_STA_START) {
esp_wifi_connect();
} else if (id == WIFI_EVENT_STA_DISCONNECTED) {
const wifi_event_sta_disconnected_t *d = data;
s_connected = false;
ESP_LOGW(TAG, "disconnected (reason %d), reconnecting", d ? d->reason : -1);
/* A short pause keeps a wrong passphrase from becoming a storm;
* the event loop task tolerates it. */
vTaskDelay(pdMS_TO_TICKS(1000));
esp_wifi_connect();
}
}
static void on_ip(void *arg, esp_event_base_t base, int32_t id, void *data)
{
(void)arg;
(void)base;
if (id == IP_EVENT_STA_GOT_IP) {
const ip_event_got_ip_t *ev = data;
s_ip = ev->ip_info.ip;
s_connected = true;
ESP_LOGI(TAG, "joined: " IPSTR " as %s.local", IP2STR(&ev->ip_info.ip), s_hostname);
} else if (id == IP_EVENT_STA_LOST_IP) {
s_connected = false;
ESP_LOGW(TAG, "address lost");
}
}
static void mdns_start(const char *hostname)
{
ESP_ERROR_CHECK(mdns_init());
ESP_ERROR_CHECK(mdns_hostname_set(hostname));
ESP_ERROR_CHECK(mdns_instance_name_set("ForgeFIRM bench fixture"));
mdns_txt_item_t txt[] = {
{"device", "forgefixture"},
{"channels", "lid,interlock,button"},
};
ESP_ERROR_CHECK(mdns_service_add(NULL, "_forgefixture", "_tcp", 80, txt, 2));
ESP_ERROR_CHECK(mdns_service_add(NULL, "_http", "_tcp", 80, txt, 2));
}
void wifi_start(const char *hostname, const char *ssid, const char *psk)
{
strncpy(s_hostname, hostname, sizeof(s_hostname) - 1);
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
s_netif = esp_netif_create_default_wifi_sta();
ESP_ERROR_CHECK(esp_netif_set_hostname(s_netif, hostname)); /* DHCP option 12 */
wifi_init_config_t init = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&init));
ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &on_wifi, NULL));
ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, ESP_EVENT_ANY_ID, &on_ip, NULL));
wifi_config_t cfg = {0};
strncpy((char *)cfg.sta.ssid, ssid, sizeof(cfg.sta.ssid) - 1);
strncpy((char *)cfg.sta.password, psk, sizeof(cfg.sta.password) - 1);
cfg.sta.threshold.authmode = psk[0] ? WIFI_AUTH_WPA2_PSK : WIFI_AUTH_OPEN;
cfg.sta.pmf_cfg.capable = true;
cfg.sta.pmf_cfg.required = false;
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &cfg));
ESP_ERROR_CHECK(esp_wifi_start());
ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_NONE));
mdns_start(hostname);
ESP_LOGI(TAG, "joining %s", ssid);
}
void wifi_ip(char *buf, size_t n)
{
if (s_connected)
snprintf(buf, n, IPSTR, IP2STR(&s_ip));
else
snprintf(buf, n, "0.0.0.0");
}
int wifi_rssi(void)
{
wifi_ap_record_t ap;
if (!s_connected || esp_wifi_sta_get_ap_info(&ap) != ESP_OK)
return 0;
return ap.rssi;
}
bool wifi_connected(void)
{
return s_connected;
}
-23
View File
@@ -1,23 +0,0 @@
/*
* forgefixture wifi: a station that joins the bench network and stays
* joined, announces its hostname over DHCP and mDNS, and never sleeps
* the radio (the API answers in milliseconds, not on the next beacon).
*
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*/
#pragma once
#include <stdbool.h>
#include <stddef.h>
void wifi_start(const char *hostname, const char *ssid, const char *psk);
/* The station's address as text ("0.0.0.0" while not joined). */
void wifi_ip(char *buf, size_t n);
/* Signal strength in dBm, 0 while not joined. */
int wifi_rssi(void);
bool wifi_connected(void);
-36
View File
@@ -1,36 +0,0 @@
# forgefixture build defaults (ESP32-S3 DevKitC-1). idf.py merges these
# into sdkconfig on the first configure; sdkconfig itself is not tracked.
CONFIG_IDF_TARGET="esp32s3"
# Any DevKitC-1 has at least 8 MB; a 4 MB layout with one large app
# partition fits every one of them and leaves nothing to choose.
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y
# A hung task reboots the board to every relay line low rather than
# holding a button press: the task watchdog panics, and a panic resets.
CONFIG_ESP_TASK_WDT_INIT=y
CONFIG_ESP_TASK_WDT_PANIC=y
CONFIG_ESP_TASK_WDT_TIMEOUT_S=5
CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0=y
CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1=y
CONFIG_ESP_INT_WDT=y
CONFIG_ESP_SYSTEM_PANIC_PRINT_REBOOT=y
# Brownout (the relays share the board's 3.3 V with nothing else, but the
# USB supply is whatever it is): reset rather than limp.
CONFIG_ESP_BROWNOUT_DET=y
# The log on both USB ports: the UART bridge and the native USB-Serial-JTAG.
CONFIG_ESP_CONSOLE_UART_DEFAULT=y
CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y
# HTTP server: a few clients, small requests.
CONFIG_HTTPD_MAX_REQ_HDR_LEN=1024
CONFIG_HTTPD_MAX_URI_LEN=128
CONFIG_HTTPD_PURGE_BUF_LEN=32
# Wifi station only; no Bluetooth.
CONFIG_BT_ENABLED=n
CONFIG_ESP_WIFI_NVS_ENABLED=y
CONFIG_LWIP_IPV6=n
-73
View File
@@ -1,73 +0,0 @@
/*
* forgefixture policy, held to account on the host: gcc -o policy_test
* policy_test.c ../main/policy.c (see run.sh).
*
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*/
#include <stdio.h>
#include <string.h>
#include "../main/policy.h"
static int failures;
#define CHECK(cond) do { if (!(cond)) { failures++; printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); } } while (0)
int main(void)
{
/* names and paths */
CHECK(strcmp(policy_channel_name(CH_LID), "lid") == 0);
CHECK(strcmp(policy_channel_name(CH_INTERLOCK), "interlock") == 0);
CHECK(strcmp(policy_channel_name(CH_BUTTON), "button") == 0);
CHECK(policy_channel_from_path("/lid") == CH_LID);
CHECK(policy_channel_from_path("/interlock") == CH_INTERLOCK);
CHECK(policy_channel_from_path("/button") == CH_BUTTON);
CHECK(policy_channel_from_path("/lids") == -1);
CHECK(policy_channel_from_path("lid") == -1);
CHECK(policy_channel_from_path(NULL) == -1);
/* a loop request: open energizes, close/closed releases, nothing else */
bool e = false;
CHECK(policy_parse_loop_state("open", &e) && e);
CHECK(policy_parse_loop_state("close", &e) && !e);
CHECK(policy_parse_loop_state("closed", &e) && !e);
CHECK(!policy_parse_loop_state("Open", &e));
CHECK(!policy_parse_loop_state("on", &e));
CHECK(!policy_parse_loop_state("", &e));
CHECK(!policy_parse_loop_state(NULL, &e));
/* the button pulse: a default, a floor, a ceiling, never a hold */
CHECK(policy_button_ms(0) == BUTTON_DEFAULT_MS);
CHECK(policy_button_ms(-5) == BUTTON_DEFAULT_MS);
CHECK(policy_button_ms(1) == BUTTON_MIN_MS);
CHECK(policy_button_ms(250) == 250);
CHECK(policy_button_ms(500) == BUTTON_MAX_MS);
CHECK(policy_button_ms(501) == BUTTON_MAX_MS);
CHECK(policy_button_ms(60000) == BUTTON_MAX_MS);
CHECK(BUTTON_MAX_MS <= 500);
/* state names */
CHECK(strcmp(policy_state_name(CH_LID, true), "open") == 0);
CHECK(strcmp(policy_state_name(CH_LID, false), "closed") == 0);
CHECK(strcmp(policy_state_name(CH_INTERLOCK, true), "open") == 0);
CHECK(strcmp(policy_state_name(CH_BUTTON, true), "pressed") == 0);
CHECK(strcmp(policy_state_name(CH_BUTTON, false), "idle") == 0);
/* the key: exact, whole, and never by prefix */
CHECK(policy_key_matches("abc123", "abc123"));
CHECK(!policy_key_matches("abc12", "abc123"));
CHECK(!policy_key_matches("abc1234", "abc123"));
CHECK(!policy_key_matches("", "abc123"));
CHECK(!policy_key_matches("abc123", ""));
CHECK(!policy_key_matches(NULL, "abc123"));
CHECK(!policy_key_matches("ABC123", "abc123"));
if (failures) {
printf("%d failure(s)\n", failures);
return 1;
}
printf("policy: all checks passed\n");
return 0;
}
-10
View File
@@ -1,10 +0,0 @@
#!/bin/sh
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
# The host test of the fixture's policy: needs only a C compiler.
set -e
cd "$(dirname "$0")"
${CC:-cc} -std=c11 -Wall -Wextra -Werror -o policy_test policy_test.c ../main/policy.c
./policy_test
-4
View File
@@ -1,4 +0,0 @@
# The scratch trees forgectrl's cool_flow_test and super_test write in the
# directory they run from
/cool-flow-test/
/super-test/
-23
View File
@@ -1,23 +0,0 @@
ForgeFIRM release acceptance tool (forgetest/) and the bench scripts it
drives (scripts/bench/).
Copyright 2026 514 LLC d/b/a OpenGlow
Written by Scott Wiederhold
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
-47
View File
@@ -1,47 +0,0 @@
# forgetest - the ForgeFIRM release acceptance tool
The daemon behind `http://<machine>:8090/` on the dev image: runs the
acceptance catalog against the machine, keeps the append-only result log,
decides which results still apply to the image that is running, exports
the release artifact `scripts/release.sh` gates on, and serves the bench
diagnostics page. The contract - catalog, campaigns, fingerprints,
inheritance, the gate, the coverage rule - is
[the Acceptance page of the documentation site](https://docs.forgefirm.org/developers/acceptance/).
## Run the host tests
cd forgetest
python3 -m unittest discover -s tests -v
## Run the daemon on a workstation (against a mock or a manifest file)
FORGETEST_DATA=/tmp/ft FORGETEST_MANIFEST=../tree-manifest.json \
FORGECTRL_URL=http://<machine> python3 -m forgetest --port 8090
`scripts/manifest-from-tree.py` produces `tree-manifest.json` from the recipe
pins; the coverage lint is `python3 -m forgetest.coverage --manifest ...`.
## Environment
| Variable | Default | Purpose |
|---|---|---|
| `FORGETEST_DATA` | `/data/forgetest` | results.jsonl, bench.jsonl, token, export/ |
| `FORGETEST_MANIFEST` | `/etc/forgefirm-manifest.json` | the image manifest |
| `FORGETEST_PORT`, `FORGETEST_HOST` | 8090, 0.0.0.0 | listener |
| `FORGETEST_BENCH_DIR` | `/usr/share/forgetest/bench` | the installed bench scripts |
| `FORGETEST_BENCH_DATA` | `<FORGETEST_DATA>/bench` | passed to bench tools: where they keep their data files (with `GF_HOST=127.0.0.1` and the panel token in `GF_TOKEN`) |
| `FORGETEST_MARKER` | `/run/forgetest.active` | takeover marker |
| `FORGECTRL_URL`, `FORGECTRL_TOKEN_FILE` | `http://127.0.0.1`, `/data/forgefirm/panel.token` | forgectrl client (HTTP; the token authorizes writes from the board) |
| `FORGECTRL_TLS_URL` | `https://127.0.0.1` | forgectrl over HTTPS (self-signed, unverified), for the login test |
| `GF_SYSFS_ROOT` | `/sys/glowforge/` | kernel module sysfs |
| `GRBL_HOST`, `GRBL_PORT` | 127.0.0.1, 23 | Grbl TCP |
## Adding a test
Register it in the subsystem module under `forgetest/suite/` with
`@test(...)`: id `subsystem.name`, kind, hardware, `mode` (the controller
mode the test needs; the runner switches to it first), `covers`,
`requires`, `always`, steps. The body gets a `Context` (`log`, `check`, `fail`,
`prompt`, `confirm`, `instruct`, `sleep`, `evidence`, `forgectrl`, `sysfs`,
`grbl`, `takeover`). Return normally for PASS, raise `runner.Failed` for
FAIL. Then run the unit tests and the coverage lint.
-55
View File
@@ -1,55 +0,0 @@
#!/bin/sh
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
### BEGIN INIT INFO
# Provides: forgetest
# Required-Start: $network forgectrl
# Required-Stop:
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Short-Description: ForgeFIRM release acceptance tool (dev image, HTTP :8090)
### END INIT INFO
# Dev-image only. The daemon reads /etc/forgefirm-manifest.json, keeps its
# state under /data/forgetest, and serves the acceptance + bench pages on
# port 8090 (FORGETEST_PORT). It starts after forgectrl and stops before
# the controllers at shutdown; a takeover left behind by a hard stop is
# recovered at the next start (forgectrl is started again).
PIDFILE=/var/run/forgetest.pid
DATA=/data/forgetest
LOG=$DATA/daemon.log
case "$1" in
start)
echo "Starting forgetest"
mkdir -p "$DATA"
start-stop-daemon -S -q -p $PIDFILE -m -b -x /bin/sh -- -c \
"exec /usr/bin/python3 -m forgetest >> $LOG 2>&1"
;;
stop)
echo "Stopping forgetest"
start-stop-daemon -K -q -p $PIDFILE
rm -f $PIDFILE
;;
restart)
$0 stop
sleep 1
$0 start
;;
status)
if [ -f $PIDFILE ] && kill -0 "$(cat $PIDFILE)" 2>/dev/null; then
echo "forgetest is running"
exit 0
fi
echo "forgetest is not running"
exit 3
;;
*)
echo "Usage: $0 {start|stop|restart|status}"
exit 1
;;
esac
exit 0
-15
View File
@@ -1,15 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""forgetest - the ForgeFIRM release acceptance tool.
A daemon on the dev image (HTTP :8090) that runs the acceptance catalog
against the machine, keeps the append-only result log, decides which
results still apply to the image that is running, and exports the
release artifact the release gate reads. The bench diagnostics page rides
the same daemon.
"""
VERSION = "0.1.0"
-73
View File
@@ -1,73 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""forgetest daemon entry point.
python3 -m forgetest [--port 8090] [--host ::] [--manifest PATH]
Environment: FORGETEST_DATA (state directory, default /data/forgetest),
FORGETEST_MANIFEST, FORGETEST_BENCH_DIR, FORGETEST_MARKER, and the hw.py
overrides.
"""
import argparse
import os
import signal
import sys
import threading
from . import VERSION
from . import bench as _bench
from . import catalog as _catalog
from . import manifest as _manifest
from . import server as _server
from .log import Log, data_dir
from .runner import Runner, configure_journal
def main(argv=None):
ap = argparse.ArgumentParser(prog="forgetest")
ap.add_argument("--port", type=int, default=int(os.environ.get("FORGETEST_PORT") or 8090))
ap.add_argument("--host", default=os.environ.get("FORGETEST_HOST") or "::")
ap.add_argument("--manifest", default=None)
ap.add_argument("--version", action="version", version="forgetest %s" % VERSION)
args = ap.parse_args(argv)
try:
manifest = _manifest.Manifest.load(args.manifest)
except (OSError, ValueError) as e:
print("forgetest: cannot load the image manifest: %s" % e, file=sys.stderr)
return 2
registry = _catalog.load_suite()
os.makedirs(data_dir(), exist_ok=True)
configure_journal()
log = Log()
bench = _bench.Bench()
runner = Runner(log, manifest, registry, bench)
token = _server.load_token()
app = _server.App(runner, token)
srv = _server.make_server(app, args.host, args.port)
stop = threading.Event()
def on_signal(*_):
stop.set()
signal.signal(signal.SIGTERM, on_signal)
signal.signal(signal.SIGINT, on_signal)
print("forgetest %s: %d tests, image %s (%s), listening on %s:%d"
% (VERSION, len(registry), manifest.version, (manifest.content_sha or "")[:12],
args.host, args.port), file=sys.stderr, flush=True)
th = threading.Thread(target=srv.serve_forever, name="forgetest-http", daemon=True)
th.start()
try:
while not stop.is_set():
stop.wait(1.0)
finally:
srv.shutdown()
srv.server_close()
return 0
if __name__ == "__main__":
sys.exit(main())
-200
View File
@@ -1,200 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The release artifact (acceptance.json / acceptance.md) and its verification.
Every test record is redacted before it enters the artifact (redact.py), so
the committed file carries no identity of the machine that ran the campaign.
It happens inside build(), ahead of the self-hash, because verify() rejects a
file edited after the fact. The gate reads none of the redacted fields.
The exporter serializes the campaign state with, for every catalog test,
the winning result record (same-campaign PASS or inherited PASS, with its
origin) and the fingerprint it was recorded under. The gate
(scripts/acceptance-gate.py) calls verify() with the artifact, the
release build's manifest, and the catalog from the same source tree, and
recomputes every fingerprint - a PASS applies to the release exactly when
the recomputation matches. The artifact is self-hashed so an edited file
is caught before any of that.
"""
import json
from . import VERSION
from . import campaign as _campaign
from . import manifest as _manifest
from .log import now_ts
from .redact import scrub
FORMAT = 1
_RESULT_KEYS = ("ts", "campaign", "test", "result", "fingerprint", "manifest_sha", "image",
"duration_s", "message", "evidence", "answers", "log", "operator")
def _record(rec):
return {k: rec.get(k) for k in _RESULT_KEYS if k in rec}
def build(state, tests, manifest, records, catalog_hash):
"""The artifact dict (self-hash included). records: the full log,
used to find the winning record for each test."""
results = [r for r in records if r.get("t") == "result"]
by_key = {}
for r in results:
by_key[(r.get("test"), r.get("ts"), r.get("campaign"))] = r
tests_out = []
for t in tests:
st = state["tests"][t.id]
origin = st.get("origin")
rec = None
if origin:
rec = by_key.get((t.id, origin.get("ts"), origin.get("campaign")))
entry = t.definition()
entry.update({
"title": t.title,
"source_sha": t.source_sha,
"fingerprint": st["fingerprint"],
"satisfied": st["satisfied"],
"inherited": st["status"] == "inherited",
"record": scrub(_record(rec)) if rec else None,
})
tests_out.append(entry)
art = {
"format": FORMAT,
"tool_version": VERSION,
"exported_at": now_ts(),
"image": {"name": manifest.image_name, "version": manifest.version},
"manifest_sha": manifest.content_sha,
"identity_sha": manifest.identity_sha(),
"catalog_hash": catalog_hash,
"campaign": state["campaign"],
"invalidate": state["invalidate"],
"authorized": state["authorized"],
"counts": state["counts"],
"manifest": manifest.data,
"tests": tests_out,
}
art["sha256"] = _manifest.sha256_text(_manifest.canonical(art))
return art
def to_json(art):
return json.dumps(art, sort_keys=True, indent=1) + "\n"
def to_markdown(art):
lines = []
img = art.get("image", {})
lines.append("# ForgeFIRM acceptance - %s" % img.get("version", "?"))
lines.append("")
lines.append("- Image: `%s` (%s)" % (img.get("version", "?"), img.get("name", "?")))
lines.append("- Manifest identity: `%s`" % (art.get("manifest_sha") or "?"))
lines.append("- Catalog: `%s`" % (art.get("catalog_hash") or "?"))
c = art.get("campaign") or {}
lines.append("- Campaign: `%s` opened %s" % (c.get("id", "-"), c.get("ts", "-")))
inv = art.get("invalidate")
if inv:
lines.append("- Full campaign required since %s: %s" % (inv.get("ts"), inv.get("reason")))
lines.append("- Exported: %s" % art.get("exported_at"))
lines.append("- **Release authorized: %s**" % ("YES" if art.get("authorized") else "NO"))
cnt = art.get("counts", {})
lines.append("- Tests: %s total, %s satisfied (%s inherited), %s required"
% (cnt.get("total"), cnt.get("satisfied"), cnt.get("inherited"), cnt.get("required")))
lines.append("")
lines.append("| Test | Kind | Result | Run at | Campaign | Inherited |")
lines.append("|---|---|---|---|---|---|")
for t in art.get("tests", []):
rec = t.get("record") or {}
kind = t.get("kind", "")
if t.get("always"):
kind += ", core"
if t.get("hardware") == "takeover":
kind += ", takeover"
lines.append("| `%s` | %s | %s | %s | `%s` | %s |" % (
t.get("id"), kind, rec.get("result", "-"), rec.get("ts", "-"),
rec.get("campaign", "-"), "yes" if t.get("inherited") else "no"))
lines.append("")
lines.append("Artifact sha256: `%s`" % art.get("sha256"))
lines.append("")
return "\n".join(lines)
def verify(art, release_manifest, tests, catalog_hash, expect_machine=None):
"""Gate decision. Returns (ok, rows, problems).
rows: per-test dicts for the report. problems: list of strings; empty
means the artifact authorizes the release manifest.
"""
problems = []
rows = []
body = dict(art)
sha = body.pop("sha256", None)
if sha != _manifest.sha256_text(_manifest.canonical(body)):
problems.append("artifact self-hash mismatch (edited or truncated file)")
return False, rows, problems
if art.get("format") != FORMAT:
problems.append("artifact format %r, expected %r" % (art.get("format"), FORMAT))
if not art.get("authorized"):
problems.append("artifact does not claim authorization")
if expect_machine and release_manifest.platform.get("machine") != expect_machine:
problems.append("release manifest machine %r, expected %r"
% (release_manifest.platform.get("machine"), expect_machine))
if art.get("catalog_hash") != catalog_hash:
problems.append("catalog changed since the campaign (artifact %s, tree %s)"
% ((art.get("catalog_hash") or "?")[:12], catalog_hash[:12]))
tests = list(tests)
by_id = {t.id: t for t in tests}
art_tests = {t["id"]: t for t in art.get("tests", [])}
for tid in sorted(set(by_id) - set(art_tests)):
problems.append("test %s is in the catalog but not in the artifact" % tid)
for tid in sorted(set(art_tests) - set(by_id)):
problems.append("test %s is in the artifact but not in the catalog" % tid)
epoch = (art.get("invalidate") or {}).get("ts")
for t in tests:
a = art_tests.get(t.id)
if a is None:
continue
row = {"id": t.id, "always": t.always, "inherited": bool(a.get("inherited"))}
rec = a.get("record")
ok = True
why = []
if a.get("covers") != [list(c) for c in t.covers] or a.get("always") != t.always \
or a.get("kind") != t.kind or a.get("requires") != list(t.requires):
ok = False
why.append("definition differs from the catalog in the tree")
if a.get("source_sha") != t.source_sha:
ok = False
why.append("test implementation changed since the campaign")
if not rec:
ok = False
why.append("no PASS recorded")
else:
if rec.get("result") != _campaign.PASS:
ok = False
why.append("recorded result is %s" % rec.get("result"))
fp = t.fingerprint(release_manifest)
row["fingerprint"] = fp
if rec.get("fingerprint") != fp:
ok = False
why.append("fingerprint differs from the release build (domain changed)")
if a.get("inherited"):
if t.always:
ok = False
why.append("always-required test may not be inherited")
if epoch and (rec.get("ts") or "") <= epoch:
ok = False
why.append("inherited PASS predates the invalidate-all")
row["ts"] = rec.get("ts")
row["ok"] = ok
row["why"] = why
rows.append(row)
if not ok:
problems.append("%s: %s" % (t.id, "; ".join(why)))
return not problems, rows, problems
File diff suppressed because it is too large Load Diff
-458
View File
@@ -1,458 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The bench diagnostics page: registry of the bench tools and the
subprocess runner behind the #bench tab.
The registry lists every tool of scripts/bench (the README is the human
index; this is the machine one) with its safety class and argument spec.
A tool is runnable from the page once `ported` is set: the script is
installed under the tool directory (/usr/share/forgetest/bench on the
image, override FORGETEST_BENCH_DIR) and runs as a subprocess with the
form's arguments, output streamed to the page. Unported tools are listed
so the catalog of what exists is complete, with Start disabled.
Safety classes:
dry reads or dry motion, no emission, forgectrl stays up
takeover needs forgectrl stopped and the pulse device free
scope a takeover whose result only means something with the named
instrument on the bench (a scope on LASER_PWM / LASER_ON)
live laser emission possible (operator acknowledgment required)
The tools that also run from a LAN host (gfbench.py: GF_HOST) run on the
board here with GF_HOST=127.0.0.1, the panel token in GF_TOKEN, and
their data files under FORGETEST_BENCH_DATA (<data>/bench/). Bench runs
are recorded in <data>/bench.jsonl and never enter a campaign.
"""
import json
import os
import shlex
import sys
import threading
from .log import data_dir, now_ts
DEFAULT_TOOL_DIR = "/usr/share/forgetest/bench"
def _arg(name, type="str", default=None, help="", choices=None, flag=None):
"""One form field. `flag` names the option the value is passed as
(--feed 600); without it the value is positional, in registry order."""
a = {"name": name, "type": type, "default": default, "help": help}
if choices:
a["choices"] = list(choices)
if flag:
a["flag"] = flag
return a
TOOLS = [
# -- board-side, dry ------------------------------------------------------
{"id": "check-pwm", "title": "Laser PWM register check", "script": "check_pwm.py",
"safety": "dry", "where": "board", "ported": True, "args": [],
"desc": "Reads PWM2 PWMCR/PWMPR via /dev/mem; expects divider 13 x ~127 counts = ~40 kHz. Read-only."},
{"id": "pacing-test", "title": "Protocol-loop pacing check", "script": "pacing_test.py",
"safety": "dry", "where": "board", "ported": True,
"args": [_arg("mm", "float", 30.0, "jog distance (+X first)"), _arg("feed", "float", 600.0, "feed rate")],
"desc": "Dry motion: idle/parked states coarse-paced, active motion tight-paced, hold/resume mid-move keeps position."},
{"id": "bench-m2", "title": "Motion-quality bench", "script": "bench_m2.py",
"safety": "dry", "where": "board", "ported": True, "args": [],
"argv_fixed": ["127.0.0.1"],
"desc": "Bounded round-trip jogs (sanity, max-rate, diagonal) + feed-hold/resume; reports peak feed, transitions, drift."},
{"id": "bench-phase2", "title": "End-of-data protocol bench", "script": "bench_phase2.py",
"safety": "takeover", "where": "board", "ported": True, "args": [],
"desc": "Underrun detection/ack, parked no-replay guard, resume(0), continuous feed, run/underrun cycles. Motors locked, laser latched."},
{"id": "cp-watchdog", "title": "HV charge-pump watchdog timing", "script": "cp_watchdog_timing.py",
"safety": "dry", "where": "board", "ported": True,
"args": [_arg("duration_s", "float", 14.0, "capture length")],
"desc": "Latches CHG_PUMP feed pulses, polls the watchdog readbacks while commanding short local jogs. Motion only, laser locked."},
{"id": "accel-fast", "title": "Head accelerometer sampler", "script": "accel_fast.py",
"safety": "dry", "where": "board", "ported": True,
"args": [_arg("duration_s", "float", 5.0, "capture length"),
_arg("jog1", "str", None, "optional mid-capture jog, e.g. $J=G91X20F2400"),
_arg("jog2", "str", None, "optional second jog")],
"desc": "Direct-I2C sampler for the head-bus LIS2HH12s with optional mid-capture jogs. CSV to /tmp/accel.csv."},
{"id": "bump-seek", "title": "Accelerometer bump-seek homing prototype", "script": "bump_seek.py",
"safety": "dry", "where": "board", "ported": True,
"args": [_arg("direction", "choice", "-", "X direction", ["-", "+"]),
_arg("feed", "int", 120, "creep feed"), _arg("segment_mm", "float", 15.0, "jog segment"),
_arg("max_mm", "float", 200.0, "travel bound")],
"desc": "Creeps toward a rail in bounded jog segments, detects the contact jolt, jog-cancels and backs off."},
{"id": "accel-crash-probe", "title": "Head accel crash-detector probe", "script": "accel_crash_probe.py",
"safety": "dry", "where": "board", "ported": True,
"args": [_arg("seconds", "float", 20.0, "arm-and-watch window"),
_arg("mode", "choice", "coexist", "coexist keeps st_accel bound (forgectrl up); unbind frees it",
["coexist", "unbind"], flag="--mode"),
_arg("ths", "int", 40, "per-axis threshold register value 0..255", flag="--ths"),
_arg("dur", "int", 0, "IG_DUR1 duration counter (ODR samples)", flag="--dur"),
_arg("axes", "str", "xyz", "axes to arm for high events", flag="--axes"),
_arg("jog", "str", None, "optional one jog at t=2 s, e.g. $J=G91X5F1000 (+X only)", flag="--jog")],
"desc": "Arms the head LIS2HH12's on-chip interrupt generator (IG1) and polls IG_SRC1 for a latched strike. "
"coexist mode reaches the IG registers over i2c-dev with st_accel still bound (the forgectrl-only "
"path proof); provoke a trip by hand or with --jog. Touches the IG registers and CTRL1 "
"(saved, run at 800 Hz, restored; the IG only samples at a running ODR), no emission, "
"no full-scale change. CSV to /tmp/accel_crash.csv."},
# -- board-side, takeover / scope ------------------------------------------
{"id": "pwm-sweep", "title": "LASER_PWM scope sweep", "script": "pwm_sweep.py",
"safety": "scope", "where": "board", "ported": True,
"args": [_arg("mode", "choice", "check", "check = read-only, sweep = duty staircase", ["check", "sweep"])],
"desc": "check: readbacks + PWM2 dump; sweep: PWMSAR through 50/25/75/6/100 percent with 4 s holds. "
"Locked state (the takeover): latch relocked, refuses if FIRE or LASER_ON reads active."},
{"id": "pwm-hold", "title": "LASER_PWM scope hold", "script": "pwm_hold.py",
"safety": "scope", "where": "board", "ported": True,
"args": [_arg("sar", "int", 64, "PWMSAR value"), _arg("seconds", "int", 10, "hold time")],
"desc": "Holds one PWMSAR value for a scope window, then restores. Locked state (the takeover): "
"latch relocked, refuses if FIRE or LASER_ON reads active."},
{"id": "fire-test", "title": "FIRE drop-timing test (A/B/U)", "script": "fire_test.py",
"safety": "takeover", "where": "board", "ported": True,
"args": [_arg("mode", "choice", "A", "A latch locked, B unlocked/unarmed, U true underrun", ["A", "B", "U"])],
"desc": "Duty 0 throughout; refuses to unlock if HV reports good. Software witnesses + the PSU-connector LASER_ON scope point."},
{"id": "pwm-stream", "title": "LASER_PWM stream-path test", "script": "pwm_stream_test.py",
"safety": "takeover", "where": "board", "ported": True, "args": [],
"desc": "Streams power bytes only (no steps, no FIRE, motor_lock=15, latch locked) through /dev/glowforge; "
"PASS = counters unmoved, idle at the end, no FIRE/emission read back. The scope on LASER_PWM sees the duty steps."},
{"id": "gate-a-kernel", "title": "Kernel laser-safety drills K1/K2/K3", "script": "gate_a_kernel_drills.py",
"safety": "takeover", "where": "board", "ported": True,
"args": [_arg("drill", "choice", "K1", "K1 stop floor, K2 resume honors latch, K3 mid-ramp unlock", ["K1", "K2", "K3"])],
"desc": "Software witnesses (cnc/state, laser_enable, laser_on, interlock bit 3); K3 refuses if HV reports good."},
{"id": "platform-drills", "title": "Kernel platform drills", "script": "platform_drills.py",
"safety": "takeover", "where": "board", "ported": True,
"args": [_arg("drill", "choice", "decay", "deadman / rmmod / decay / led / all",
["deadman", "rmmod", "decay", "led", "all"])],
"desc": "Dead-man trip readback, rmmod/modprobe cycles under load, decay/microstep readback, LED sequence."},
# -- cooling ------------------------------------------------------------------
{"id": "flow-confirm", "title": "Coolant flow suspicion/confirmation drill", "script": "flow_confirm_drill.py",
"safety": "dry", "where": "board", "ported": True, "args": [],
"desc": "One M8 session walks the verdict state machine through real pump-off transients; PASS/FAIL per transition."},
{"id": "flow-escalate", "title": "Coolant starved re-check escalation drill", "script": "flow_escalate_drill.py",
"safety": "dry", "where": "board", "ported": True,
"args": [_arg("budget_s", "int", 60, "cool_confirm_max_s for the drill (60-3600), restored after")],
"desc": "With the pump off the job-start check reads SUSPECT and the engine must escalate to FAULT when "
"the confirmation budget expires; the budget setting is shortened for the drill and restored."},
{"id": "flow-characterize", "title": "Coolant flow characterization", "script": "flow_characterize.py",
"safety": "takeover", "where": "board", "ported": True,
"args": [_arg("duty", "int", 30, "heater duty percent")],
"desc": "Baseline -> flow -> no-flow -> recovery with the factory temperature curve; aborts past 45 C "
"downstream. Drives the heater and pump directly (about 9 minutes)."},
{"id": "flow-sustained", "title": "Coolant sustained re-check run", "script": "flow_sustained.py",
"safety": "dry", "where": "board", "ported": True,
"args": [_arg("minutes", "float", 30.0, "how long to hold M8")],
"desc": "Long run of the real re-check cadence via M8: verdicts, false faults, loop heat accumulation."},
{"id": "flow-warm", "title": "Coolant warm-baseline validation", "script": "flow_warm_validate.py",
"safety": "takeover", "where": "board", "ported": True,
"args": [_arg("cycles", "int", 3, "cycles per case (flow / no-flow)"),
_arg("target_c", "float", 28.0, "warm the upstream sensor to this before each check"),
_arg("warm_min", "float", 20.0, "warm-up budget per check, minutes")],
"desc": "Runs the real check (40 percent / 50 s, cut-profile fans) from a heater-warmed baseline, alternating "
"flow and no-flow; results to the bench data directory. Slow: about 15 minutes per cycle."},
{"id": "offset-probe", "title": "Coolant-sensor offset probe", "script": "offset_probe.py",
"safety": "dry", "where": "board", "ported": True,
"args": [_arg("mode", "choice", "survey", "survey: one actuator at a time; ladder: the air-assist "
"duty ladder; jog: the air assist at run duty while the gantry jogs",
["survey", "ladder", "jog"])],
"desc": "Which actuator moves both coolant thermistors together? Switches the exhaust (100/50/25 "
"percent), intakes, air assist, purge, heater, pump, TEC and lid lamp one at a time, dark, "
"with both sensors at 25 Hz, and scores the common-mode step at every edge and the level "
"toggling inside every dwell; every value is restored on exit. The pump stops once for "
"8 s with the tube dark and the heater off. About five minutes."},
{"id": "aa-offset-check", "title": "Coolant offset correction under the run airflow", "script": "aa_offset_check.py",
"safety": "dry", "where": "board", "ported": True,
"args": [_arg("dwell", "float", 20.0, "seconds to read under the run profile")],
"desc": "M8 brings the fans to the run profile (no laser-on, no press) while the raw coolant "
"counts, /status and the engine's readings are averaged before, during and after: with "
"cool_aa_offset_counts at the machine's value the readings hold still while the raw counts "
"step; at zero they drop by about a degree. About a minute."},
{"id": "critical-tier", "title": "Coolant critical-tier warm-loop drill", "script": "critical_tier_drill.py",
"safety": "dry", "where": "board", "ported": True,
"args": [_arg("max_seconds", "int", 1200, "give up after this long without CRITICAL", flag="--max-seconds")],
"desc": "Sets the ceiling, the resume gate and the critical line a few tenths above the live upstream "
"reading and lets the engine's own flow-check heater warm the loop through them inside one M8 "
"session: OVERTEMP at the ceiling, then CRITICAL (fire blocked, hold, no resume), the fault ending "
"with the session; settings restored and re-read. Results to the bench data directory."},
{"id": "flow-recheck", "title": "Coolant re-check characterization", "script": "flow_recheck_char.py",
"safety": "takeover", "where": "board", "ported": True,
"args": [_arg("heater_pct", "int", 50, "heater duty percent"), _arg("window_s", "int", 30, "re-check window")],
"desc": "Short in-run re-checks and the differential metric, flow vs no-flow from a settled loop; "
"aborts past 45 C downstream (about 5 minutes)."},
{"id": "flow-matrix", "title": "Coolant flow-detection design matrix", "script": "flow_matrix.py",
"safety": "takeover", "where": "board", "ported": True,
"args": [_arg("duties", "str", "10,15,20,30,40,50", "heater duties, percent, comma-separated"),
_arg("repeats", "int", 5, "interleaved repeats per case")],
"desc": "duty x flow/no-flow x repeats from a common cooled baseline; cost and precision tables and a "
"ranked shortlist (the derivation of cool_flow_rise). Very slow: about 1.6 h for the full matrix; "
"resumable from the results file in the bench data directory."},
{"id": "flow-sampler", "title": "Coolant sampler", "script": "flow_sampler.py",
"safety": "dry", "where": "board", "ported": True,
"args": [_arg("duration_s", "int", 30, "capture length"), _arg("interval_s", "float", 1.0, "sample interval")],
"desc": "Prints elapsed,raw_down,raw_up at the interval; the sampler behind the flow tools."},
{"id": "temp-calibrate", "title": "Coolant temperature spot-check", "script": "temp_calibrate.py",
"safety": "dry", "where": "board", "ported": True,
"args": [_arg("mode", "choice", "watch", "coolant: watch / point / fit; supply (pic/pwr_temp): "
"supply-watch / supply-point / supply-fit",
["watch", "point", "fit", "supply-watch", "supply-point", "supply-fit"]),
_arg("value", "str", None, "point: the thermometer reading in C; watch: seconds (default 60)")],
"desc": "Pairs a measured temperature with averaged raw readings; fits a per-machine line. The coolant "
"sensors, or the power supply's (thermometer on its heatsink, against the documented unverified "
"guess). Points accumulate in the bench data directory."},
{"id": "fan-test", "title": "Fan/coolant bench", "script": "fan_test.py",
"safety": "dry", "where": "board", "ported": True, "args": [],
"desc": "Snapshots fan PWMs/tachs/temps, drives M8 -> cut fans, M9 -> cooldown -> idle; the tach "
"readbacks in each snapshot are the evidence."},
{"id": "fan-floor", "title": "Fan floor measurement", "script": "fan_floor_measure.py",
"safety": "dry", "where": "board", "ported": True,
"args": [_arg("mode", "choice", "spinup", "spinup (M8, from idle) / cut (sample only, during a real cut)",
["spinup", "cut"]),
_arg("seconds", "int", 120, "sampling window", flag="--seconds"),
_arg("steady", "int", 60, "spinup: the steady window at the end", flag="--steady")],
"desc": "The numbers the airflow gates ship with: per fan the steady speed at run duty, the time to "
"90 percent and the spread, the purge current off and on, and (cut) the spread under a real "
"cut; results to the bench data directory."},
# -- laser (live) --------------------------------------------------------------
{"id": "live-fire", "title": "LIVE laser drills", "script": "live_fire_drills.py",
"safety": "live", "where": "board", "ported": True,
"args": [_arg("drill", "choice", "witness", "witness / hold / ircut / expstop / ctrlstart",
["witness", "hold", "ircut", "expstop", "ctrlstart"]),
_arg("power", "int", 1000, "ircut: S value"), _arg("feed", "int", 300, "ircut: F value")],
"desc": "Emission witness, disarm grace in Hold, lid-IR characterization cut, armed "
"kill on the expected-stop path (+ the separate controller restart). The operator's arm press is "
"required for every drill; eye protection, fire watch, extinguisher, exhaust."},
{"id": "resume-dark-lead", "title": "Pause / resume chain timing (dark lead)", "script": "resume_dark_lead.py",
"safety": "live", "where": "board", "ported": True,
"args": [_arg("run", "choice", "dry", "dry travel, or LIVE FIRE", ["dry", "live"], flag="--run"),
_arg("mode", "choice", "m3", "laser mode for a live run", ["m3", "m4"], flag="--mode"),
_arg("power", "int", 400, "live: S value", flag="--power"),
_arg("feed", "float", 600.0, "feed rate", flag="--feed"),
_arg("len", "float", 60.0, "move length in mm (+X)", flag="--len"),
_arg("passes", "int", 1, "alternating +X/-X moves", flag="--passes"),
_arg("secs", "float", 45.0, "sampling window", flag="--secs"),
_arg("auto", "str", "", "dry only: 'P,R' seconds to send ! and ~ unattended", flag="--auto")],
"desc": "Samples LASER_ON, FIRE, HV_ENABLE and the charge-pump watchdog straight off the SoC pads "
"across a pause and a resume, with motion dated from the kernel counters: how long HV survives "
"a pause, how fast the chain re-arms, and - on a live run - the dark lead between FIRE and "
"LASER_ON that a resumed cut loses. Dry by default; --run live needs the arm press, eye "
"protection, fire watch, extinguisher, exhaust."},
{"id": "pgood-probe", "title": "Supply power-good line against the chain", "script": "pgood_probe.py",
"safety": "dry", "where": "board", "ported": True,
"args": [_arg("secs", "float", 90.0, "sampling window", flag="--secs")],
"desc": "Watches the supply's power-good line (cnc/laser_pgood, reported as the raw pin level) beside "
"LASER_ON, FIRE, the charge-pump watchdog, HV_ENABLE and the doors through the kernel readbacks at a "
"few hundred hertz, with hv_current at 20 Hz, and prints every transition. It only watches: drive "
"the machine meanwhile (a jog, an armed cut, a pause, a lid open). Needs no pad mapping."},
# -- host-side harnesses (CI) ------------------------------------------------------
{"id": "laser-stream-test", "title": "Laser pulse-stream emission harness", "script": "laser_stream_test.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "Null-sink controller stream capture against the feeder contract. A CI harness (the grblHAL repo): "
"it needs the host-built null-sink controller, not the machine, so it is not a bench-page tool."},
{"id": "laser-lifecycle-test", "title": "Armed-window lifecycle harness", "script": "laser_lifecycle_test.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "Arm/disarm lifecycle on the null-sink controller. A CI harness (the grblHAL repo): needs the "
"host-built null-sink controller, not the machine, so it is not a bench-page tool."},
{"id": "z-envelope-test", "title": "Z envelope harness", "script": "z_envelope_test.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "The Z soft limit belongs to the driver, not to $20: an unreferenced Z is collapsed to where the "
"lens stands, and neither a $20 nor a $132 write frees it. A referenced lens (forgectrl's marker "
"and the lens settings) opens it to the window the settings hold, the fallback or the focus "
"card's stops: the ends of the reach run, two half-steps past either end alarms. A CI harness "
"(the grblHAL repo): needs the host-built null-sink controller, not the machine, so it is not a "
"bench-page tool."},
{"id": "ctlport-test", "title": "Controller port and line multiplexer harness", "script": "ctlport_test.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "A scripted sender that counts every response, beside the controller port's one client, on the "
"null-sink controller: a port jog's status goes to the port and never to the sender, a port "
"error does not reach the sender's parser, a sender line cancels a port jog and gets its own ok "
"(a line queued right behind the port's included), the refusals, the single client, a CR LF "
"sender, a soft reset, and the client as the dead-man. A CI harness (the grblHAL repo): needs "
"the host-built null-sink controller, not the machine, so it is not a bench-page tool."},
{"id": "cool-report-test", "title": "Cooling report secret harness", "script": "cool_report_test.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "The controller's cooling reports on the null-sink controller, read by a stand-in for "
"forgectrl's listener: every report carries the secret the supervisor hands the controller at "
"its spawn (GF_REPORT_SECRET), none does when there is none, a value that is not 32 hex digits "
"(a CR LF with a header behind it included) never reaches the wire, and the homing runner the "
"controller starts is handed it (it reports in the controller's place during a gfcloud $H), "
"and none when the controller has none. A CI harness (the grblHAL repo): needs the host-built "
"null-sink controller, not the machine, so it is not a bench-page tool."},
{"id": "mcode-test", "title": "Package M-code barrier harness", "script": "mcode_test.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "The M-codes extension packages answer (M160 to M179), on the null-sink controller with a "
"scripted sender and a stand-in for the machine daemon on the port: the table and its refusals, "
"a number nothing answers is error:20 where it is parsed, an answered one waits with the head "
"still and a port jog refused while the port's state names it, the answer's words, an answer "
"that the work was not done and no answer in time each hold the job, a soft reset ends the "
"wait, and a wait under an open armed window with M3 S500 ships no FIRE tick. Imports its "
"sender and port client from ctlport_test.py. A CI harness (the grblHAL repo): needs the "
"host-built null-sink controller, not the machine, so it is not a bench-page tool."},
{"id": "envelope-test", "title": "Measured envelope harness", "script": "envelope_test.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "The measured work envelope and the bed check's port op, on the null-sink controller with a "
"scripted sender and a port client: unset keys are the axis travel, envelope_x_mm and _y are the "
"far edges at a home and are held to 50 mm up to the travel plus 30 mm, the port's envelope open "
"is refused before a home and under an open armed window and lets the port's jogs reach the "
"travel plus 30 mm, envelope apply puts the keys' edges back, and the sender's first line (never "
"a status poll or an empty line), a soft reset, and the port client going away each close an "
"open envelope, the sender's line only after it closed. Imports its sender and port client from "
"ctlport_test.py. A CI harness (the grblHAL repo): needs the host-built null-sink controller, "
"not the machine, so it is not a bench-page tool."},
{"id": "manual-home-test", "title": "Manual home and motor release harness", "script": "manual_home_test.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "The manual homing provider and the motor release on the null-sink controller: $H under manual "
"ships nothing and declares X0 Y0 with the soft limits on and Z kept; while the motors are "
"released every motion source is refused and ships nothing, $X and a soft reset do not unlock "
"it, and only $ME and a manual $H write the energize, once each; the port's panel operations "
"do the same with the sender's count exact. Imports its sender and port client from "
"ctlport_test.py. A CI harness (the grblHAL repo): needs the host-built null-sink controller, "
"not the machine, so it is not a bench-page tool."},
{"id": "tray-test", "title": "Crumb tray mode harness", "script": "tray_test.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "The crumb tray's mode on the null-sink controller: M103 P1 and P0 move Z and its envelope by "
"tray_offset_mm on the lens step grid, tell the sender, write and remove the marker beside the "
"shared config, and ship no step; a missing P, a P other than 0 or 1 and an axis word are "
"errors; the switch waits for the move before it; the port's tray op, and its refusals during "
"a program and at a package's M-code; the mode survives a soft reset and a restart, and a start "
"over the marker references Z in the tray-out frame; the offset's range; an unreferenced lens; "
"and a camera home in both modes hands the runner the same park. Imports its sender and port "
"client from ctlport_test.py. A CI harness (the grblHAL repo): needs the host-built null-sink "
"controller, not the machine, so it is not a bench-page tool."},
{"id": "raster-dry", "title": "Dry top-speed raster per XY microstep mode", "script": "raster_dry.py",
"safety": "dry", "where": "board", "ported": True,
"args": [_arg("modes", "choice", "8 16 32", "the modes to run, in order", ["8", "16", "32", "8 16 32"])],
"desc": "For each mode given: stores xy_microsteps, waits for the restarted controller, streams a 60-pass "
"raster of 150 mm at F12000 with the laser off, and reports the peak feed, the controller CPU, the "
"kernel counters against the start, underruns and clamped events. Needs the head with 150 mm of "
"free +X and 12 mm of free +Y travel, no other Grbl client. Ends at x8."},
{"id": "xy-pattern-accel", "title": "XY microstep modes by the head accelerometer, the machine silent",
"script": "xy_pattern_accel.py", "safety": "dry", "where": "board", "ported": True,
"args": [_arg("modes", "choice", "8 16 32", "the modes to run, in order", ["8", "16", "32", "8 16 32"])],
"desc": "From home at F12000: to (18, 9) in, to (9, 9) in, a 9 in circle from its mid-bottom back to "
"(9, 9), to (9, 0), home. Per mode: every fan, the coolant pump and the TEC commanded off through "
"the engine's quiet hold (the machine silent, the modes audible), the fixed 10 s wait, the head "
"accelerometer sampled over the bus at 800 Hz through the pattern; "
"cruise-window RMS and peak-to-peak per leg and overall, the kernel counters against home, "
"underruns; JSON with the trace to the bench data directory. Needs the machine homed and at home, "
"the lid closed, no other Grbl client, the bed clear. Ends at x8."},
{"id": "arc-tolerance-sweep", "title": "How fine an arc the controller can plan ($12 ladder)",
"script": "arc_tolerance_sweep.py", "safety": "dry", "where": "board", "ported": True,
"args": [_arg("mode", "choice", "16", "the XY microstep mode", ["8", "16", "32"], flag="--mode"),
_arg("ladder", "str", "0.002 0.001 0.0005 0.00025 0.0001", "the $12 values to try, in order")],
"desc": "From home, the machine silent: to (9, 9) in, then the 9 in circle at F12000 once per $12 in the "
"ladder. Per rung: the chords and boundaries a second, the circle time against the ideal, the lowest "
"feed mid-circle, the fewest free planner blocks, the controller CPU, clamped events, underruns, the "
"accelerometer's cruise RMS. $12 goes back to what it was; the head returns home. Needs the machine "
"homed and at home, the lid closed, no other Grbl client."},
{"id": "planner-blocks-test", "title": "Planner buffer depth harness", "script": "planner_blocks_test.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "A deep planner buffer starts: the controller restarted at $398=400 and at $398=1000 answers "
"on the port, reports the depth in its status report and runs a move. A CI harness (the grblHAL "
"repo): needs the host-built null-sink controller, not the machine, so it is not a bench-page tool."},
{"id": "xy-mode-test", "title": "XY microstep mode harness", "script": "xy_mode_test.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "The XY scale is the microstep mode's, never typed: xy_microsteps sets $100/$101 and the "
"machine tick, a typed $100 is overwritten, and $110/$111 are held under a lowered tick. A CI "
"harness (the grblHAL repo): needs the host-built null-sink controller, not the machine, so it "
"is not a bench-page tool."},
{"id": "puls-profile", "title": "Factory .puls profile decoder", "script": "puls_profile.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "Decodes factory pulse streams into velocity/accel profiles. Runs anywhere; needs a .puls file "
"(the reference captures live off the machine), so it is not a bench-page tool."},
{"id": "debug-kernel-drills", "title": "Debug-kernel lock drills (load/unload, forced defer)",
"script": "debug_kernel_drills.py",
"safety": "takeover", "where": "board", "ported": True, "args": [],
"desc": "Runs only on the debug-kernel image (kas/forgefirm-glowforge-debug.yml): three glowforge.ko "
"load/unload cycles under DEBUG_MUTEXES and a forced -EPROBE_DEFER unwind, each read against "
"dmesg for lock splats. Cycles the 40 V rail; refuses on a non-debug kernel or a non-idle machine."},
]
# Files in scripts/bench the page never offers: the helper module the
# host/board tools share (the C feeder and the build scripts are not
# python), and the two lens stall drills. A stall slips the lens stepper's
# rotor under full torque, so those run from the shell on the bench
# reference machine only (scripts/bench/README.md).
NOT_TOOLS = ("gfbench.py", "lens_travel.py", "lens_stop_accel.py")
class Bench:
def __init__(self, tools=None, tool_dir=None, index_path=None):
self.tools = list(tools if tools is not None else TOOLS)
self._by_id = {t["id"]: t for t in self.tools}
self._tool_dir = tool_dir
self.index_path = index_path or os.path.join(data_dir(), "bench.jsonl")
self._lock = threading.Lock()
def tool_dir(self):
return self._tool_dir or os.environ.get("FORGETEST_BENCH_DIR") or DEFAULT_TOOL_DIR
def get(self, tool_id):
return self._by_id.get(tool_id)
def command(self, tool, args):
"""argv for a tool with the form's arguments. Returns
(ok, argv, error)."""
script = os.path.join(self.tool_dir(), tool["script"])
if not os.path.exists(script):
return False, None, "script not installed: %s" % tool["script"]
argv = [sys.executable, script] + list(tool.get("argv_fixed", []))
for spec in tool.get("args", []):
raw = args.get(spec["name"], spec.get("default"))
if raw is None or raw == "":
if spec.get("default") in (None, ""):
continue # optional and absent: not passed at all
raw = spec["default"]
try:
if spec["type"] == "int":
val = str(int(raw))
elif spec["type"] == "float":
val = repr(float(raw))
elif spec["type"] == "choice":
if str(raw) not in spec["choices"]:
return False, None, "%s must be one of %s" % (spec["name"], spec["choices"])
val = str(raw)
else:
val = str(raw)
if any(ch in val for ch in "\0\n\r"):
return False, None, "%s: invalid characters" % spec["name"]
except (TypeError, ValueError):
return False, None, "%s: invalid %s" % (spec["name"], spec["type"])
if spec.get("flag"):
argv.append(spec["flag"])
argv.append(val)
argv += list(tool.get("argv_fixed_after", []))
return True, argv, None
def record(self, tool, args, run):
rec = {"ts": run.started_ts, "tool": tool["id"], "args": args, "result": run.finished,
"log_tail": run.lines[-50:]}
with self._lock:
os.makedirs(os.path.dirname(self.index_path), exist_ok=True)
with open(self.index_path, "a", encoding="utf-8") as f:
f.write(json.dumps(rec, sort_keys=True, separators=(",", ":")) + "\n")
def last_runs(self):
out = {}
try:
with open(self.index_path, "r", encoding="utf-8") as f:
for line in f:
try:
rec = json.loads(line)
except ValueError:
continue
out[rec.get("tool")] = {"ts": rec.get("ts"), "result": rec.get("result"),
"args": rec.get("args")}
except OSError:
pass
return out
def listing(self):
last = self.last_runs()
items = []
for t in self.tools:
item = {k: t[k] for k in ("id", "title", "script", "safety", "where", "ported", "args", "desc")}
item["installed"] = os.path.exists(os.path.join(self.tool_dir(), t["script"]))
item["last"] = last.get(t["id"])
items.append(item)
return items
def describe_command(self, tool, args):
ok, argv, err = self.command(tool, args)
return " ".join(shlex.quote(a) for a in argv) if ok else err
-175
View File
@@ -1,175 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""Campaign rules: which results apply, what is required, and whether a
release is authorized.
A campaign is bound to one image (manifest content hash) and one catalog
(catalog hash). It is open from its record until a FAIL/ERROR result in
it, an invalidate-all, an explicit reset, or a different image or
catalog. Rules per test T with domain fingerprint F(T):
satisfied by campaign a PASS in the open campaign with fingerprint F(T)
satisfied by inheritance (never for the always-required core) the newest
PASS anywhere in the history with fingerprint F(T)
and newer than the last invalidate-all
required otherwise (reason: always / never-passed /
domain-changed)
authorized <=> a campaign is open and every catalog test is satisfied
Pure functions over the record list; nothing here touches hardware or
the filesystem, which keeps the rules unit-testable and lets the release
gate reason with the same code.
"""
PASS, FAIL, ERROR, ABORTED = "PASS", "FAIL", "ERROR", "ABORTED"
CLOSING = (FAIL, ERROR)
def _summary(rec):
if rec is None:
return None
keys = ("ts", "result", "campaign", "image", "fingerprint", "duration_s", "message")
return {k: rec.get(k) for k in keys if k in rec}
def open_campaign(records, manifest_sha, catalog_hash):
"""(open_campaign_record_or_None, last_campaign_record_or_None,
closed_by_or_None, invalidate_record_or_None)."""
current = None
closed_by = None
invalidate = None
for r in records:
t = r.get("t")
if t == "campaign":
current = r
closed_by = None
elif t == "result":
if current and r.get("campaign") == current.get("id") and r.get("result") in CLOSING:
closed_by = closed_by or "fail"
elif t == "invalidate":
invalidate = r
if current:
closed_by = closed_by or "invalidate"
elif t == "reset":
if current:
closed_by = closed_by or "reset"
if current and not closed_by:
if current.get("manifest_sha") != manifest_sha:
closed_by = "image"
elif current.get("catalog_hash") != catalog_hash:
closed_by = "catalog"
return (current if current and not closed_by else None), current, closed_by, invalidate
def compute(records, tests, manifest, catalog_hash, running=None):
"""The state the page shows and the exporter serializes.
tests: iterable of catalog.Test. running: id of the test in progress
(marked in the per-test status), or None.
"""
results = [r for r in records if r.get("t") == "result"]
campaign, last, closed_by, invalidate = open_campaign(records, manifest.content_sha, catalog_hash)
epoch = invalidate.get("ts") if invalidate else None
open_id = campaign.get("id") if campaign else None
# The invalidate record keeps its epoch for good: nothing older than it
# inherits. The page and the artifact report it only while it still asks
# for something, that is until a campaign has started after it.
invalidate_shown = invalidate
if invalidate:
seen = False
for r in records:
if r is invalidate:
seen = True
elif seen and r.get("t") == "campaign":
invalidate_shown = None
break
by_test = {}
for r in results:
by_test.setdefault(r.get("test"), []).append(r)
out_tests = {}
tests = list(tests)
for t in tests:
fp = t.fingerprint(manifest)
hist = by_test.get(t.id, [])
last_r = hist[-1] if hist else None
origin = None
status = "none"
satisfied = False
reason = None
same = None
if open_id:
for r in reversed(hist):
if r.get("campaign") == open_id and r.get("result") == PASS and r.get("fingerprint") == fp:
same = r
break
if same is not None:
status, satisfied, origin, reason = "pass", True, same, "campaign"
elif t.always:
reason = "always"
else:
# The newest record on the current fingerprint decides: a PASS
# is inherited, a FAIL or ERROR after it blocks the inheritance
# (the test has to be run again), an ABORTED run says nothing.
inh = None
blocked = None
for r in reversed(hist):
if r.get("fingerprint") != fp:
continue
if epoch and (r.get("ts") or "") <= epoch:
continue
res = r.get("result")
if res == PASS:
inh = r
break
if res in (FAIL, ERROR):
blocked = r
break
if inh is not None:
status, satisfied, origin, reason = "inherited", True, inh, "inherited"
elif blocked is not None and any(r.get("result") == PASS and r.get("fingerprint") == fp
and (r.get("ts") or "") < (blocked.get("ts") or "")
for r in hist):
reason = "failed-since"
else:
reason = "domain-changed" if any(r.get("result") == PASS for r in hist) else "never-passed"
if not satisfied and last_r is not None:
status = {PASS: "stale", FAIL: "fail", ERROR: "error", ABORTED: "aborted"}.get(last_r.get("result"), "none")
if running == t.id:
status = "running"
out_tests[t.id] = {
"fingerprint": fp,
"status": status,
"satisfied": satisfied,
"required": not satisfied,
"reason": reason,
"last": _summary(last_r),
"origin": _summary(origin),
}
# requires: a prerequisite counts when it is satisfied (campaign or
# inherited); the always-required core is the freshness mechanism.
for t in tests:
missing = [r for r in t.requires if not out_tests.get(r, {}).get("satisfied")]
out_tests[t.id]["missing_requires"] = missing
out_tests[t.id]["requires_met"] = not missing
n_sat = sum(1 for v in out_tests.values() if v["satisfied"])
n_inh = sum(1 for v in out_tests.values() if v["status"] == "inherited")
counts = {"total": len(tests), "satisfied": n_sat, "inherited": n_inh,
"required": len(tests) - n_sat}
authorized = bool(campaign) and n_sat == len(tests) and len(tests) > 0
return {
"campaign": campaign,
"last_campaign": last,
"closed_by": closed_by,
"invalidate": invalidate_shown,
"authorized": authorized,
"counts": counts,
"tests": out_tests,
}
-447
View File
@@ -1,447 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The acceptance catalog: test definitions and the registry.
A test is a function decorated with @test(...). The decorator records
what the release gate needs to know without running anything: the id,
the subsystem, the kind (auto / operator / live), how it takes the
hardware (api / takeover), the controller mode it needs (if any), what
source it covers, what it requires, and whether it belongs to the
always-required core. Two more fields describe the operator's part
without affecting the gate: `actions`, the machine actions the test asks
for by name (the page lists them before a start; a bench actuator can
perform them), and `precheck`, a condition the machine must meet for the
test to start at all (a reason string refuses the start, the way an
unmet prerequisite does, and records no result). The function body runs
under the runner with a Context (log, prompts, evidence, hardware
helpers) and reports by returning normally (PASS) or raising
runner.Failed (FAIL).
"""
import ast
import hashlib
import inspect
import os
import re
import threading
from . import manifest as _manifest
KINDS = ("auto", "operator", "live")
HARDWARE = ("api", "takeover")
MODES = ("grbl", "cloud")
# The machine actions a test may ask of the operator (Context.act):
# the lid, the remote-interlock loop, and the big button. Everything a
# test needs done to the machine is one of these, so a bench actuator
# that covers a channel can stand in for the hands on it.
ACTIONS = ("lid", "interlock", "button")
_ID_RX = re.compile(r"^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*$")
REGISTRY = {}
class Test:
def __init__(self, id, title, subsystem, kind, hardware, covers, requires,
always, est_min, steps, description, fn, mode=None, actions=(),
precheck=None, hands=()):
self.id = id
self.title = title
self.subsystem = subsystem
self.kind = kind
self.hardware = hardware
self.mode = mode
self.covers = tuple((str(c), str(g)) for c, g in covers)
self.requires = tuple(requires)
self.always = bool(always)
self.est_min = est_min
self.steps = tuple(steps)
self.actions = tuple(actions)
self.hands = tuple(hands)
self.precheck = precheck
self.description = description or (fn.__doc__ or "").strip()
self.fn = fn
self._source_sha = None
self._fp = (None, None) # (manifest content sha, fingerprint)
@property
def source_sha(self):
"""The hash of the test's own implementation: its function (the
decorator included) together with the code its module shares
among its tests (everything outside the @test functions), the
shared code of every sibling module that either of the two
imports, and the shared judges (SHARED_JUDGES). Part of the
fingerprint: a change inside one test's body invalidates that
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:
# One filler at a time: a second poll that arrives while the
# first computes waits for it rather than repeating the work.
with _IMPL_LOCK:
if self._source_sha is None:
path = inspect.getsourcefile(self.fn) or inspect.getfile(self.fn)
self._source_sha = implementation_sha(path, self.id)
return self._source_sha
def fingerprint(self, manifest):
"""The domain fingerprint on this manifest, memoized by the
manifest's content hash: the page recomputes every test's
fingerprint on every poll, and a manifest never changes under a
running tool."""
key = manifest.content_sha
if key and self._fp[0] == key:
return self._fp[1]
fp = _manifest.fingerprint(manifest, self.covers, extra=[self.source_sha])
if key:
self._fp = (key, fp)
return fp
def definition(self):
"""The gate-visible definition (no implementation, no prose)."""
return {
"id": self.id,
"subsystem": self.subsystem,
"kind": self.kind,
"hardware": self.hardware,
"covers": [list(c) for c in self.covers],
"requires": list(self.requires),
"always": self.always,
}
def describe(self):
d = self.definition()
d.update({"title": self.title, "est_min": self.est_min, "mode": self.mode,
"steps": list(self.steps), "actions": list(self.actions),
"hands": list(self.hands), "precheck": bool(self.precheck),
"description": self.description})
return d
def fixture_runnable(self, channels):
"""Whether a bench actuator covering `channels` lets this test
run with nobody in the room: an operator test whose every action
is a covered channel and which asks a person for nothing else. A
live test never qualifies (the arm press and the fire watch are
a person's); an auto test needs no fixture."""
if self.kind != "operator" or self.hands or not self.actions:
return False
return all(a in channels for a in self.actions)
def cannot_start(self):
"""The reason the test cannot start on the machine as it is, or
None. Evaluated right before a start; never a result."""
if self.precheck is None:
return None
try:
reason = self.precheck()
except Exception as e: # noqa: BLE001 - a broken precheck refuses, it never crashes the runner
return "precheck errored: %s: %s" % (type(e).__name__, e)
return reason or None
def source_file_sha(path):
with open(path, "rb") as f:
data = f.read().replace(b"\r\n", b"\n")
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:
# path -> (text, tree or None), path -> (shared sha, {test id: own sha}),
# path -> the sibling modules it imports itself.
_PARSED = {}
_PARTS = {}
_SIBLINGS = {}
_PARTS_LOCK = threading.RLock()
_IMPL_LOCK = threading.Lock()
def forget(path):
"""Drop what is known about a module: its file changed (the unit tests
edit their modules between readings)."""
with _PARTS_LOCK:
for cache in (_PARSED, _PARTS, _SIBLINGS):
cache.pop(path, None)
def _parsed(path):
"""(text, tree) of a suite module, line endings normalized; the tree
is None when the file does not parse."""
with _PARTS_LOCK:
hit = _PARSED.get(path)
if hit is None:
with open(path, "rb") as f:
text = f.read().replace(b"\r\n", b"\n").decode("utf-8")
try:
tree = ast.parse(text)
except SyntaxError:
tree = None
hit = _PARSED[path] = (text, tree)
return hit
def _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):
"""(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
functions; a test's own text runs from its first decorator line to
the end of its body; the shared text is every other line of the
file. Line endings normalized."""
with _PARTS_LOCK:
hit = _PARTS.get(path)
if hit is not None:
return hit
text, tree = _parsed(path)
lines = text.split("\n")
spans = {}
for tid, node in _test_defs(tree).items():
start = min([dd.lineno for dd in node.decorator_list] + [node.lineno])
spans[tid] = (start, node.end_lineno)
owned = set()
for a, b in spans.values():
owned.update(range(a, b + 1))
shared = "\n".join(ln for i, ln in enumerate(lines, 1) if i not in owned)
own = {tid: _manifest.sha256_text("\n".join(lines[a - 1:b])) for tid, (a, b) in spans.items()}
parts = (_manifest.sha256_text(shared), own)
with _PARTS_LOCK:
_PARTS[path] = parts
return parts
def sibling_imports(path, seen=None):
"""The suite modules a module's shared text imports with `from .x
import ...` or `from . import x`, as paths, transitively, in a stable
order. A helper a test calls from a sibling module is part of the
judge, so its text belongs in the test's fingerprint."""
if seen is None:
seen = []
for sib in _direct_siblings(path):
if sib not in seen and sib != path:
seen.append(sib)
sibling_imports(sib, seen)
return seen
def _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):
"""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:
hit = _SIBLINGS.get(path)
if hit is None:
_text, tree = _parsed(path)
tests = list(_test_defs(tree).values())
shared = [n for n in (tree.body if tree is not None else []) if not any(n is t for t in tests)]
hit = _SIBLINGS[path] = _imported(path, shared)
return hit
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
test's own text, the shared text of its module, the shared text of
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)
if test_id not in own:
return source_file_sha(path)
sibs = sibling_imports(path)
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=(),
requires=(), always=False, est_min=1, steps=(), description="", actions=(),
precheck=None, hands=()):
"""`mode` names the controller mode the test needs live when it starts
("grbl" or "cloud"); the runner switches the machine there before the
test and leaves it there, so a queue crosses modes only where a test
asks it to. None means the test runs in whatever mode it finds (or
manages the mode itself, as the cloud tests do through enter_cloud,
which also waits for the service session).
`actions` names the machine actions (ACTIONS) the test performs
through Context.act; an `auto` test declares none. `hands` names what
the test asks of a person beyond those actions ("app" for a job in
the Glowforge app, "scrap", "mark" for an eye on the result...): an
operator test with none, whose actions a bench actuator covers, runs
unattended when one is up. `precheck` is a callable returning a
reason string when the machine cannot run the test as it is (None
when it can)."""
if not _ID_RX.match(id):
raise ValueError("test id %r must look like subsystem.name" % id)
if kind not in KINDS:
raise ValueError("test %s: kind %r" % (id, kind))
if hardware not in HARDWARE:
raise ValueError("test %s: hardware %r" % (id, hardware))
if mode is not None and mode not in MODES:
raise ValueError("test %s: mode %r" % (id, mode))
for c, g in covers:
if c in _manifest.DEV_ONLY_COMPONENTS:
raise ValueError("test %s: may not cover dev-only component %r" % (id, c))
for a in actions:
if a not in ACTIONS:
raise ValueError("test %s: action %r" % (id, a))
if actions and kind == "auto":
raise ValueError("test %s: an auto test asks for no machine actions" % id)
if hands and kind == "auto":
raise ValueError("test %s: an auto test asks nothing of a person" % id)
if precheck is not None and not callable(precheck):
raise ValueError("test %s: precheck must be callable" % id)
def deco(fn):
if id in REGISTRY:
raise ValueError("duplicate test id %r" % id)
REGISTRY[id] = Test(id, title, subsystem, kind, hardware, covers, requires,
always, est_min, steps, description, fn, mode=mode,
actions=actions, precheck=precheck, hands=hands)
return fn
return deco
def all_tests(registry=None):
"""Tests in registration order (the suite modules import in
subsystem order, so this is the display order)."""
return list((registry if registry is not None else REGISTRY).values())
def get(id, registry=None):
return (registry if registry is not None else REGISTRY).get(id)
def order_by_requires(tests, selected):
"""`selected` ids in an order that runs a prerequisite before the test
that names it.
Registration order otherwise, so a run reads down the page. A
prerequisite outside the selection places nothing: either it is
already satisfied, or the run that needs it will be refused and
recorded as skipped. validate() has ruled out cycles; the stack check
only keeps a malformed registry from recursing forever.
"""
want = set(selected)
by_id = {t.id: t for t in tests}
out, placed = [], set()
def visit(tid, stack):
if tid in placed or tid not in want or tid not in by_id or tid in stack:
return
stack.add(tid)
for r in by_id[tid].requires:
visit(r, stack)
stack.discard(tid)
placed.add(tid)
out.append(tid)
for t in tests:
visit(t.id, set())
return out
def validate(registry=None):
"""Every `requires` names a known test and there are no cycles."""
reg = registry if registry is not None else REGISTRY
for t in reg.values():
for r in t.requires:
if r not in reg:
raise ValueError("test %s requires unknown test %s" % (t.id, r))
seen = {}
def visit(tid, stack):
if tid in stack:
raise ValueError("requires cycle: %s" % " -> ".join(stack + [tid]))
if seen.get(tid):
return
for r in reg[tid].requires:
visit(r, stack + [tid])
seen[tid] = True
for tid in reg:
visit(tid, [])
def catalog_hash(registry=None):
"""Identity of the catalog's definitions (ids, kinds, coverage,
requirements, core membership) - not of the implementations, which
the per-test fingerprints carry."""
defs = sorted((t.definition() for t in all_tests(registry)), key=lambda d: d["id"])
return _manifest.sha256_text(_manifest.canonical(defs))
def load_suite():
"""Import the suite modules (each registers its tests) and validate."""
from . import suite # noqa: F401 (registers on import)
validate()
return REGISTRY
def suite_dir():
return os.path.join(os.path.dirname(os.path.abspath(__file__)), "suite")
-103
View File
@@ -1,103 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""Coverage lint: every source path in the manifest must be selected by
some test's coverage globs or by a host-tested row (manifest.HOST_TESTED),
except the allowlisted non-behavioral paths; every coverage glob and every
host-tested glob must select at least one path of its component; and every
host-tested row must name a host test that exists and a CI workflow that
runs it.
Why a hard rule: under the domain model an uncovered file is worse than an
untested one - a change there leaves every inherited PASS valid when it
should have invalidated them. The lint is the floor (the file is
fingerprinted by at least one test, or a named host test that CI runs
answers for it); whether that test exercises the change stays with the
change author. An entry that selects nothing is the same defect from the
other side: the test's fingerprint ignores the file it named.
Whether a workflow runs its host test is read from the workflow's text at
the pinned revision, in the sources scripts/manifest-from-tree.py fetched
(--sources, default the checkout's .manifest-cache). Without them (on an
image) that part is not checked, and the report says so.
python3 -m forgetest.coverage [--manifest PATH] [--sources DIR] [--enforce] [--json]
Exit 0 when nothing is uncovered (or when only reporting), 1 under
--enforce with uncovered paths, 2 on a usage or load error.
"""
import argparse
import json
import os
import sys
from . import catalog as _catalog
from . import manifest as _manifest
# Non-behavioral paths need no acceptance coverage, and no fingerprint
# carries them: the one list, in the manifest module.
ALLOW = _manifest.NON_BEHAVIORAL
def run(manifest, tests, allow=ALLOW):
return _manifest.coverage_report(manifest, tests, allow, host=_manifest.HOST_TESTED)
def empty(manifest, tests):
return _manifest.empty_covers(manifest, tests)
def host_problems(manifest, sources=_manifest.SOURCES):
return _manifest.host_row_problems(manifest, host=_manifest.HOST_TESTED, sources=sources)
def main(argv=None):
ap = argparse.ArgumentParser(description="forgetest coverage lint")
ap.add_argument("--manifest", default=None, help="manifest JSON (default: the running image's)")
ap.add_argument("--sources", default=_manifest.SOURCES,
help="the pinned sources manifest-from-tree.py fetched (default: the checkout's .manifest-cache)")
ap.add_argument("--enforce", action="store_true", help="exit 1 when any path is uncovered")
ap.add_argument("--json", action="store_true", help="machine-readable report")
args = ap.parse_args(argv)
try:
manifest = _manifest.Manifest.load(args.manifest)
except (OSError, ValueError) as e:
print("cannot load manifest: %s" % e, file=sys.stderr)
return 2
registry = _catalog.load_suite()
tests = _catalog.all_tests(registry)
report = run(manifest, tests)
total = sum(len(v) for v in report.values())
hollow = empty(manifest, tests)
sources = args.sources if args.sources and os.path.isdir(args.sources) else None
rows = host_problems(manifest, sources)
if args.json:
print(json.dumps({"uncovered": report, "total": total, "tests": len(tests),
"empty": [list(e) for e in hollow],
"host_tested": [list(r) for r in rows],
"workflows_read": sources is not None and not any("cannot be read" in r[3] for r in rows)},
indent=1, sort_keys=True))
else:
if _manifest.HOST_TESTED and sources is None:
print("host-tested: no sources, so whether a workflow runs each host test is not checked")
for comp in sorted(report):
print("%s: %d uncovered path(s)" % (comp, len(report[comp])))
for p in report[comp]:
print(" %s" % p)
for tid, comp, pat in hollow:
print("%s: covers (%s, %s) selects nothing" % (tid, comp, pat))
for comp, pat, ref, why in rows:
print("host-tested (%s, %s) by %s: %s" % (comp, pat, ref, why))
print("coverage: %d uncovered path(s) across %d component(s), %d empty entr%s, "
"%d host-tested row problem(s), %d tests"
% (total, len(report), len(hollow), "y" if len(hollow) == 1 else "ies", len(rows),
len(tests)))
if args.enforce and (total or hollow or rows):
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
-412
View File
@@ -1,412 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The bench actuator (forgefixture): the box on the bench network that
opens the lid loop, pulls the interlock loop and presses the button on
request, so an operator test can run with nobody in the room.
Configured by a bench-local file, FIXTURE_CONFIG (default
/data/forgetest/fixture.json, mode 0600, never in the repo):
{"hostname": "forgefixture", "key": "<the fixture's API key>",
"ip": null, "channels": ["lid", "interlock", "button"],
"arm_press": false}
`hostname` is resolved as <hostname>.local by the resolver here (the
image carries no mDNS resolver; the fixture answers the query itself);
`ip` overrides that. `channels` names what is wired. `arm_press` is the
one policy switch: whether the fixture may press the button to arm the
laser for a live test (a person's press by default).
The fixture does not read the machine: every action the runner asks of
it is verified through the machine's own readings afterward, exactly as
an operator's hand is (Context.act). Contract: fixture/README.md.
"""
import json
import os
import socket
import struct
import threading
import time
import urllib.error
import urllib.request
FIXTURE_CONFIG = os.environ.get("FORGETEST_FIXTURE_CONFIG", "/data/forgetest/fixture.json")
CHANNELS = ("lid", "interlock", "button")
MDNS_GROUP = "224.0.0.251"
MDNS_PORT = 5353
RESOLVE_TTL_S = 300.0 # a resolved address is trusted this long
BUTTON_GAP_S = 0.3 # the release between two presses: long enough for the controller to see it
PULSE_WAIT_S = 1.5 # a pulse in progress ends within the firmware's 500 ms clamp
class FixtureError(Exception):
"""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
def mdns_query(name):
"""One mDNS A query for `name` (e.g. "forgefixture.local"), as the
bytes on the wire: a standard DNS header with id 0, one question,
class IN with the unicast-response bit set so the answer comes back
to this socket rather than the group."""
labels = b"".join(struct.pack("B", len(p)) + p.encode("ascii") for p in name.rstrip(".").split("."))
header = struct.pack("!HHHHHH", 0, 0, 1, 0, 0, 0)
return header + labels + b"\x00" + struct.pack("!HH", 1, 0x8001)
def _read_name(data, off):
"""A DNS name at `off`, compression pointers followed; (name, next
offset)."""
parts = []
jumped = False
end = off
guard = 0
while True:
if off >= len(data):
raise ValueError("truncated name")
n = data[off]
if n == 0:
off += 1
break
if n & 0xC0 == 0xC0:
ptr = struct.unpack("!H", data[off:off + 2])[0] & 0x3FFF
if not jumped:
end = off + 2
jumped = True
off = ptr
guard += 1
if guard > 32:
raise ValueError("pointer loop")
continue
parts.append(data[off + 1:off + 1 + n].decode("ascii", "replace"))
off += 1 + n
if not jumped:
end = off
return ".".join(parts).lower(), end
def mdns_answers(data, name):
"""The IPv4 addresses in an mDNS response's A records for `name`
(answers and additionals; a response is anything with QR set)."""
name = name.rstrip(".").lower()
out = []
try:
_id, flags, qd, an, ns, ar = struct.unpack("!HHHHHH", data[:12])
if not flags & 0x8000:
return out
off = 12
for _ in range(qd):
_n, off = _read_name(data, off)
off += 4
for _ in range(an + ns + ar):
rname, off = _read_name(data, off)
rtype, rclass, _ttl, rdlen = struct.unpack("!HHIH", data[off:off + 10])
off += 10
rdata = data[off:off + rdlen]
off += rdlen
if rtype == 1 and rdlen == 4 and rname == name:
out.append(socket.inet_ntoa(rdata))
except (struct.error, ValueError):
pass
return out
def _local_ipv4s():
"""The interface addresses to send a multicast query from: the
default first (None), then every IPv4 address this host answers to."""
out = [None]
try:
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET, socket.SOCK_DGRAM):
ip = info[4][0]
if ip not in out and not ip.startswith("127."):
out.append(ip)
except OSError:
pass
return out
def resolve_mdns(hostname, timeout=2.0, tries=3):
"""The IPv4 address of <hostname>.local, asked of the network
directly; None when nothing answers."""
name = hostname if hostname.endswith(".local") else hostname + ".local"
query = mdns_query(name)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 1)
# On the mDNS port and in the group, so a responder that answers
# the group rather than the asker is heard too; a port already
# taken (a resolver daemon) means that daemon would have served
# the name, so the ephemeral port and unicast replies will do.
try:
sock.bind(("", MDNS_PORT))
except OSError:
sock.bind(("", 0))
try:
sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP,
socket.inet_aton(MDNS_GROUP) + socket.inet_aton("0.0.0.0"))
except OSError:
pass # no group membership: unicast replies still arrive
for _ in range(tries):
# The query goes out of every local IPv4 interface that will
# take it (a host with a VPN or a VM adapter as its default
# route would otherwise never reach the bench network).
sent = False
for ifaddr in _local_ipv4s():
try:
if ifaddr is not None:
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(ifaddr))
sock.sendto(query, (MDNS_GROUP, MDNS_PORT))
sent = True
except OSError:
continue
if not sent:
return None
deadline = time.time() + timeout
while True:
left = deadline - time.time()
if left <= 0:
break
sock.settimeout(left)
try:
data, _peer = sock.recvfrom(2048)
except socket.timeout:
break
except OSError:
break
for ip in mdns_answers(data, name):
return ip
return None
finally:
sock.close()
# -------------------------------------------------------------- client
def load_config(path=None):
"""The bench's fixture config, or None when there is none (a bench
without a fixture: the runner behaves exactly as before)."""
path = path or FIXTURE_CONFIG
try:
with open(path, "r", encoding="utf-8") as f:
cfg = json.load(f)
except OSError:
return None
except ValueError as e:
raise FixtureError("%s: not JSON: %s" % (path, e))
if not isinstance(cfg, dict) or not cfg.get("key"):
raise FixtureError("%s: needs at least a key" % path)
cfg.setdefault("hostname", "forgefixture")
cfg.setdefault("ip", None)
cfg.setdefault("port", 80)
chans = cfg.get("channels")
if chans is None:
chans = list(CHANNELS)
bad = [c for c in chans if c not in CHANNELS]
if bad:
raise FixtureError("%s: unknown channel(s) %s" % (path, ", ".join(bad)))
cfg["channels"] = list(chans)
cfg["arm_press"] = bool(cfg.get("arm_press", False))
return cfg
class Fixture:
"""The actuator as the runner sees it: which channels it covers, an
action per channel, a release, its state. Every request carries the
key; the address comes from the config's ip or the mDNS lookup,
re-resolved when a request fails to connect."""
def __init__(self, cfg, timeout=3.0, resolver=resolve_mdns):
self.hostname = cfg["hostname"]
self.ip_override = cfg.get("ip") or None
self.port = int(cfg.get("port") or 80)
self.key = cfg["key"]
self.channels = tuple(cfg["channels"])
self.arm_press = bool(cfg.get("arm_press", False))
self.timeout = timeout
self._resolver = resolver
self._ip = self.ip_override
self._resolved_at = time.time() if self.ip_override else 0.0
self._lock = threading.Lock()
self.last_state = None
self._pulse_end = 0.0 # when this client's last press ends (its pulse_ms)
# -- address -------------------------------------------------------
def address(self, refresh=False):
with self._lock:
if self.ip_override:
return self.ip_override
stale = time.time() - self._resolved_at > RESOLVE_TTL_S
if self._ip is None or refresh or stale:
ip = self._resolver(self.hostname)
if ip is None:
raise FixtureError("%s.local did not answer the mDNS lookup (set ip in the config "
"to skip it)" % self.hostname)
self._ip = ip
self._resolved_at = time.time()
return self._ip
def where(self):
return "%s (%s)" % (self.hostname, self._ip or "unresolved")
# -- requests ------------------------------------------------------
def _request(self, method, path, body=None, retry=True):
ip = self.address()
data = json.dumps(body).encode() if body is not None else None
host = ip if self.port == 80 else "%s:%d" % (ip, self.port)
req = urllib.request.Request("http://%s%s" % (host, path), data=data, method=method,
headers={"X-Fixture-Key": self.key, "Host": host,
"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
return resp.status, json.loads(resp.read().decode("utf-8", "replace") or "{}")
except urllib.error.HTTPError as e:
try:
body = json.loads(e.read().decode("utf-8", "replace") or "{}")
except ValueError:
body = {}
return e.code, body
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:
# the address may have moved: one fresh lookup, one retry,
# and a failure of either still says the first one went out
try:
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):
st, body = self._request("GET", "/")
if st == 401:
raise FixtureError("fixture %s refused the key" % self.where())
if st != 200 or body.get("device") != "forgefixture":
raise FixtureError("fixture %s: unexpected answer %s %s" % (self.where(), st, body))
self.last_state = body
return body
def covers(self, channel):
"""The runner asks this per action. The button needs the jumper
in, which the fixture reports; a channel it does not cover falls
back to the operator."""
if channel not in self.channels:
return False
if channel == "button":
st = self.last_state
return bool(st and st.get("button_enabled"))
return True
def act(self, channel, state, ms=None):
if channel == "button":
if state != "press":
raise FixtureError("the button is only ever pressed")
st, body = self._press(ms)
else:
if state not in ("open", "close"):
raise FixtureError("%s: unknown state %r" % (channel, state))
st, body = self._request("POST", "/" + channel, {"state": state})
if st != 200:
raise FixtureError("fixture %s: %s %s -> %s %s" % (self.where(), channel, state, st,
body.get("error") or body))
self.last_state = body
return body
def _press(self, ms=None):
"""One press, spaced from the last one. `ms` is how long the box
holds it: the box's own 200 ms when it is not said, and the box
clamps a request into 20 to 500, so no press is ever held. Every
reader of the button catches the default press (an edge, or a poll
of 100 ms or less), so no check needs a longer one. The controller
detects the button's rising edge, so a press must follow a release
it has seen: the next press waits for the last pulse to end (the
fixture's own pulse_ms) plus BUTTON_GAP_S. A 409 for a pulse in
progress (a press this client did not time) is waited out once
against the fixture's state, then retried."""
wait = self._pulse_end + BUTTON_GAP_S - time.time()
if wait > 0:
time.sleep(wait)
st, body = self._request("POST", "/button", {"ms": int(ms)} if ms else {})
if st == 409 and "in progress" in str(body.get("error", "")):
deadline = time.time() + PULSE_WAIT_S
while time.time() < deadline:
time.sleep(0.1)
s, b = self._request("GET", "/")
if s == 200 and not b.get("button_pulsing"):
break
time.sleep(BUTTON_GAP_S)
st, body = self._request("POST", "/button", {"ms": int(ms)} if ms else {})
if st == 200:
self._pulse_end = time.time() + float(body.get("pulse_ms") or 500) / 1000.0
return st, body
def release(self):
st, body = self._request("POST", "/release", {})
if st != 200:
raise FixtureError("fixture %s: release -> %s %s" % (self.where(), st, body.get("error") or body))
self.last_state = body
return body
@staticmethod
def energized(state):
"""The channels a state report shows energized (a loop open, the
button pressed)."""
ch = (state or {}).get("channels") or {}
return [c for c, s in ch.items() if s in ("open", "pressed")]
def summary(self):
# The radio signal and the uptime go in the record: this actuator
# is on wifi, and a run that lost it wants both numbers to say
# whether the link faded or the box restarted. Without them a
# drop is only ever "it was gone".
st = self.last_state or {}
wifi = st.get("wifi") or {}
return {"hostname": self.hostname, "ip": self._ip, "channels": list(self.channels),
"button_enabled": bool(st.get("button_enabled")), "arm_press": self.arm_press,
"version": st.get("version"), "uptime_s": st.get("uptime_s"),
"rssi": wifi.get("rssi")}
def probe(log, path=None, resolver=resolve_mdns):
"""The bench's fixture, up and answering, or None: no config means
no fixture; a config whose fixture does not answer is logged and
treated the same, so the run goes to the operator."""
try:
cfg = load_config(path)
except FixtureError as e:
log("fixture: %s - running without it" % e)
return None
if cfg is None:
return None
fx = Fixture(cfg, resolver=resolver)
try:
st = fx.status()
except FixtureError as e:
log("fixture: %s - running without it" % e)
return None
chans = ", ".join(fx.channels)
log("fixture up: %s at %s, v%s, covers %s%s" % (
fx.hostname, fx._ip, st.get("version"), chans,
"" if st.get("button_enabled") or "button" not in fx.channels
else " (button disabled: the enable jumper is out)"))
return fx
__all__ = ["Fixture", "FixtureError", "CHANNELS", "FIXTURE_CONFIG", "load_config", "probe",
"resolve_mdns", "mdns_query", "mdns_answers"]
-684
View File
@@ -1,684 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""Hardware and service access for the suite: forgectrl's HTTP API, the
kernel module's sysfs, the init scripts, and the Grbl TCP port.
Everything is reachable through environment overrides so the suite can be
exercised against a mock on a host:
FORGECTRL_URL default http://127.0.0.1
FORGECTRL_TOKEN_FILE default /data/forgefirm/panel.token
GF_SYSFS_ROOT default /sys/glowforge/ (must end with '/')
GRBL_HOST / GRBL_PORT default 127.0.0.1 / 23
FORGETEST_INITD default /etc/init.d
"""
import glob
import json
import os
import select
import socket
import struct
import subprocess
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
class HwError(Exception):
pass
# ------------------------------------------------------------ forgectrl
# The supervisor's levers answer only when the switch is done: a mode
# switch waits for the old controller to exit, the new one to start (a
# pending liveness probe first) and its first job-state report (up to
# 15 s; the emulator's comes with its start, a real client's with its
# machine); a stop waits for the child to go. They get their own timeout,
# above the daemon's own deadlines, so a slow but honest switch is never
# read as a dead daemon.
SLOW_PATHS = ("/mode", "/controller/start", "/controller/stop")
SLOW_TIMEOUT_S = 120.0
class Forgectrl:
"""Thin client for the machine-services daemon."""
def __init__(self, base=None, token=None, timeout=10.0, slow_timeout=SLOW_TIMEOUT_S):
self.base = (base or os.environ.get("FORGECTRL_URL") or "http://127.0.0.1").rstrip("/")
self.timeout = timeout
self.slow_timeout = slow_timeout
self._token = token
def timeout_for(self, method, path):
return self.slow_timeout if method == "POST" and path in SLOW_PATHS else self.timeout
@property
def token(self):
if self._token is None:
path = os.environ.get("FORGECTRL_TOKEN_FILE") or "/data/forgefirm/panel.token"
try:
with open(path, "r", encoding="utf-8") as f:
self._token = f.read().strip()
except OSError:
self._token = ""
return self._token
def host_header(self):
return urllib.parse.urlsplit(self.base).netloc
def request(self, method, path, params=None, data=None, headers=None, auth=True, raw=False):
"""Returns (status, body). body is parsed JSON when the response
is JSON, else text (or bytes when raw=True). Never raises on an
HTTP error status - the suite asserts on codes."""
url = self.base + path
if params:
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(params)
body = None
hdrs = {"Host": self.host_header()}
if headers:
hdrs.update(headers)
if data is not None:
if isinstance(data, (dict, list)):
body = urllib.parse.urlencode(data).encode()
hdrs.setdefault("Content-Type", "application/x-www-form-urlencoded")
elif isinstance(data, str):
body = data.encode()
else:
body = data
if auth and self.token:
hdrs.setdefault("X-ForgeFIRM-Token", self.token)
req = urllib.request.Request(url, data=body, method=method, headers=hdrs)
try:
with urllib.request.urlopen(req, timeout=self.timeout_for(method, path)) as resp:
status = resp.status
content = resp.read()
ctype = resp.headers.get("Content-Type", "")
except urllib.error.HTTPError as e:
status = e.code
content = e.read()
ctype = e.headers.get("Content-Type", "") if e.headers else ""
except (urllib.error.URLError, socket.timeout, OSError) as e:
raise HwError("forgectrl %s %s: %s" % (method, path, e))
if raw:
return status, content
text = content.decode("utf-8", "replace")
if "json" in ctype:
try:
return status, json.loads(text)
except ValueError:
pass
return status, text
def get(self, path, **kw):
return self.request("GET", path, **kw)
def post(self, path, **kw):
return self.request("POST", path, **kw)
def status(self):
st, body = self.get("/status")
if st != 200 or not isinstance(body, dict):
raise HwError("forgectrl /status -> %s" % st)
return body
def settings(self):
st, body = self.get("/settings")
if st != 200 or not isinstance(body, dict):
raise HwError("forgectrl /settings -> %s" % st)
return body
def wait_idle(self, timeout=60.0, poll=0.5, abort=None):
deadline = time.time() + timeout
while time.time() < deadline:
if abort and abort():
raise HwError("aborted while waiting for idle")
try:
if self.status().get("state") == "idle":
return True
except HwError:
pass
time.sleep(poll)
return False
# ---------------------------------------------------------------- sysfs
def sysfs_root():
r = os.environ.get("GF_SYSFS_ROOT") or "/sys/glowforge/"
return r if r.endswith("/") else r + "/"
def sysfs_read(attr, default=None):
try:
with open(sysfs_root() + attr, "r") as f:
return f.read().strip()
except OSError:
return default
def sysfs_int(attr, default=None):
v = sysfs_read(attr)
if v is None or v == "":
return default
try:
return int(v.split()[0], 0)
except ValueError:
return default
def sysfs_write(attr, value):
with open(sysfs_root() + attr, "w") as f:
f.write(str(value))
# ------------------------------------------------------ head accelerometer
# The head accelerometer (LIS2HH12 on i2c-3 at 0x1e): the supervisor's
# motion-liveness witness, and the catalog's. Resolved by bus address,
# never by iio index. The raw sysfs read is slow (~150 ms), which still
# lands several samples in a one-second move; the verdict is peak-to-peak
# on X or Y, against the thresholds forgectrl's probe established (a live
# head reads 1800-2900 on its probe move, a wedged one <= 250).
HEAD_ACCEL_I2C = "3-001e"
ACCEL_P2P_MOVING = 800
def head_accel_dir():
"""The head accel's iio directory, or None. GF_IIO_ROOT overrides the
/sys/bus/iio/devices root (host tests)."""
root = os.environ.get("GF_IIO_ROOT") or "/sys/bus/iio/devices"
try:
names = sorted(os.listdir(root))
except OSError:
return None
for n in names:
d = os.path.join(root, n)
try:
target = os.readlink(d)
except OSError:
target = ""
if HEAD_ACCEL_I2C in target or HEAD_ACCEL_I2C in n:
return d
# a plain directory (host fixture): its name carries the address
try:
with open(os.path.join(d, "name")) as f:
if HEAD_ACCEL_I2C in f.read():
return d
except OSError:
pass
return None
def head_accel_read(d):
"""(x, y) raw counts, or None."""
out = []
for axis in ("x", "y"):
try:
with open(os.path.join(d, "in_accel_%s_raw" % axis)) as f:
out.append(int(f.read().strip()))
except (OSError, ValueError):
return None
return tuple(out)
class AccelSampler:
"""Samples the head accelerometer in a thread for as long as it is
running; `p2p(t0, t1)` is the peak-to-peak on X and Y over the samples
in that window and how many there were. Used as a context manager
around a motion the test wants the head to have made."""
def __init__(self, period=0.0):
self.dir = head_accel_dir()
self.period = period
self.samples = [] # (t, x, y)
self._stop = threading.Event()
self._th = None
self.errors = 0
@property
def available(self):
return self.dir is not None
def __enter__(self):
if self.dir is not None:
self._th = threading.Thread(target=self._loop, daemon=True, name="forgetest-accel")
self._th.start()
return self
def __exit__(self, *a):
self._stop.set()
if self._th is not None:
self._th.join(2.0)
return False
def _loop(self):
while not self._stop.is_set():
v = head_accel_read(self.dir)
if v is None:
self.errors += 1
else:
self.samples.append((time.time(), v[0], v[1]))
if self.period:
self._stop.wait(self.period)
def p2p(self, t0, t1=None):
"""(p2p_x, p2p_y, n) over [t0, t1]; (0, 0, 0) with no samples."""
t1 = time.time() if t1 is None else t1
xs = [x for t, x, _y in self.samples if t0 <= t <= t1]
ys = [y for t, _x, y in self.samples if t0 <= t <= t1]
if not xs:
return 0, 0, 0
return max(xs) - min(xs), max(ys) - min(ys), len(xs)
# ----------------------------------------------------------- button LEDs
BUTTON_LEDS = ("button_led_1", "button_led_2", "button_led_3")
def leds_root():
r = os.environ.get("GF_LEDS_ROOT") or "/sys/class/leds/"
return r if r.endswith("/") else r + "/"
def button_leds():
"""The three button LEDs' commanded levels (the smooth trigger's
`target`, which the controller writes; `brightness` follows it with
a fade and is read where no target exists), or None where unreadable."""
out = []
for name in BUTTON_LEDS:
val = None
for attr in ("target", "brightness"):
try:
with open(leds_root() + name + "/" + attr) as f:
val = int(f.read().strip())
break
except (OSError, ValueError):
continue
out.append(val)
return out
def button_lit():
"""True when any button LED is on, False when all three read 0, None
when none is readable."""
vals = [v for v in button_leds() if v is not None]
if not vals:
return None
return any(v > 0 for v in vals)
# ----------------------------------------------------------- the button
EV_SW = 0x05
SW_BUTTON = 2 # the button's EV_SW code (bit 2 of the switch word)
def switches_device():
"""The machine's switches as forgectrl and the controller find them:
GF_SWITCH_DEV, else the input device the device tree names
"switches". None when there is none."""
dev = os.environ.get("GF_SWITCH_DEV")
if dev:
return dev
for d in sorted(glob.glob("/sys/class/input/event*")):
try:
with open(os.path.join(d, "device", "name")) as f:
if f.read().strip() == "switches":
return "/dev/input/" + os.path.basename(d)
except OSError:
continue
return None
class ButtonEdges:
"""The button as edges, from the switches' input events. A press is an
event however short it is, so nothing has to be held long enough for a
poll to land on it: an operator's press is never longer than a quarter
second. Open it before the press is asked for. It reads without a
grab, so forgectrl and the controller see the switches as always."""
EVENT = struct.Struct("llHHi") # struct input_event: time, type, code, value
def __init__(self):
self.fd = None
self.pending = b"" # events read and not yet looked at
dev = switches_device()
if dev:
try:
self.fd = os.open(dev, os.O_RDONLY | os.O_NONBLOCK | getattr(os, "O_CLOEXEC", 0))
except OSError:
self.fd = None
@property
def available(self):
return self.fd is not None
def wait(self, value, timeout, checkpoint=None):
"""The seconds until the button's next edge to `value` (1 pressed,
0 released), or None at the timeout. checkpoint() is called about
every 0.1 s (an abort raises out of it)."""
size = self.EVENT.size
t0 = time.time()
while True:
# The events already read come first: a short press arrives
# with its release in one read, and the release is the next
# wait's.
while len(self.pending) >= size:
_sec, _usec, typ, code, val = self.EVENT.unpack_from(self.pending, 0)
self.pending = self.pending[size:]
if typ == EV_SW and code == SW_BUTTON and val == value:
return time.time() - t0
if time.time() - t0 >= timeout:
return None
if checkpoint:
checkpoint()
ready, _, _ = select.select([self.fd], [], [], 0.1)
if not ready:
continue
try:
data = os.read(self.fd, size * 64)
except BlockingIOError:
continue
if not data:
time.sleep(0.02) # a FIFO with no writer (the host tests) reads empty
continue
self.pending += data
def close(self):
if self.fd is not None:
os.close(self.fd)
self.fd = None
# --------------------------------------------------------------- init.d
def initd(service, action, timeout=60):
"""Run /etc/init.d/<service> <action>; returns (rc, output)."""
base = os.environ.get("FORGETEST_INITD") or "/etc/init.d"
script = os.path.join(base, service)
try:
p = subprocess.run([script, action], stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
timeout=timeout)
except (OSError, subprocess.TimeoutExpired) as e:
return 127, str(e)
return p.returncode, p.stdout.decode("utf-8", "replace")
# The extension host's side of the machine (forgefirm-sandbox, forgeext).
EXT_POOL_FIRST, EXT_POOL_LAST = 800, 831
EXT_ROOT = "/data/forgefirm/ext"
EXT_TEST_PREFIX = "org.forgetest." # a package a test made; every other one is the operator's
FORGEEXT = "/usr/bin/forgeext"
def pool_pids():
"""{pid: uid} of every process that runs under an extension pool account."""
out = {}
try:
pids = [p for p in os.listdir("/proc") if p.isdigit()]
except OSError:
return out
for pid in pids:
try:
with open("/proc/%s/status" % pid) as f:
line = next((x for x in f if x.startswith("Uid:")), "")
uid = int(line.split()[1]) # the real uid; a service has all four alike
except (OSError, IndexError, ValueError):
continue
if EXT_POOL_FIRST <= uid <= EXT_POOL_LAST:
out[int(pid)] = uid
return out
def ext_packages():
"""The ids of the installed extension packages."""
try:
return sorted(d for d in os.listdir(EXT_ROOT + "/pkg") if os.path.isdir(os.path.join(EXT_ROOT, "pkg", d)))
except OSError:
return []
def pidof(comm):
"""PIDs whose /proc/<pid>/comm equals comm (15-char kernel limit applies)."""
out = []
try:
for pid in os.listdir("/proc"):
if not pid.isdigit():
continue
try:
with open("/proc/%s/comm" % pid) as f:
if f.read().strip() == comm:
out.append(int(pid))
except OSError:
pass
except OSError:
pass
return out
def pgrep_f(needle):
"""PIDs whose /proc/<pid>/cmdline contains needle (a script run by an
interpreter has the interpreter's comm, so pidof cannot see it)."""
out = []
try:
for pid in os.listdir("/proc"):
if not pid.isdigit():
continue
try:
with open("/proc/%s/cmdline" % pid, "rb") as f:
if needle.encode() in f.read():
out.append(int(pid))
except OSError:
pass
except OSError:
pass
return out
def listens_on(pid, path):
"""Process pid holds a listening UNIX socket bound at path: one of its
own descriptors is that socket, so a socket file another process left
behind does not count. Reads /proc (FORGETEST_PROC_ROOT for tests)."""
if not pid:
return False
proc = os.environ.get("FORGETEST_PROC_ROOT") or "/proc"
inodes = set()
try:
with open(os.path.join(proc, "net", "unix")) as f:
next(f, None) # the header line
for ln in f:
# Num RefCount Protocol Flags Type St Inode Path
parts = ln.split()
try:
if len(parts) >= 8 and parts[7] == path and int(parts[3], 16) & 0x10000:
inodes.add(parts[6]) # __SO_ACCEPTCON: a listener
except ValueError:
continue
except OSError:
return False
if not inodes:
return False
fd_dir = os.path.join(proc, str(pid), "fd")
try:
fds = os.listdir(fd_dir)
except OSError:
return False
for fd in fds:
try:
link = os.readlink(os.path.join(fd_dir, fd))
except OSError:
continue
if link.startswith("socket:[") and link[8:-1] in inodes:
return True
return False
def run(cmd, timeout=60):
"""Run a command list; returns (rc, combined output)."""
try:
p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=timeout)
except (OSError, subprocess.TimeoutExpired) as e:
return 127, str(e)
return p.returncode, p.stdout.decode("utf-8", "replace")
# ------------------------------------------------------------------ grbl
def grbl_port_open(timeout=5):
"""True when the Grbl port accepts a connection (closed again at once)."""
try:
s = socket.create_connection((os.environ.get("GRBL_HOST") or "127.0.0.1",
int(os.environ.get("GRBL_PORT") or 23)), timeout=timeout)
s.close()
return True
except OSError:
return False
class Grbl:
"""Minimal Grbl-over-TCP client. The suite is the only client while a
motion test runs; nothing here is used to poll status when a sender
may be attached (position for display comes from forgectrl)."""
def __init__(self, host=None, port=None, timeout=5.0):
self.host = host or os.environ.get("GRBL_HOST") or "127.0.0.1"
self.port = int(port or os.environ.get("GRBL_PORT") or 23)
self.timeout = timeout
self.sock = None
self.buf = b""
def __enter__(self):
self.connect()
return self
def __exit__(self, *exc):
self.close()
def connect(self):
self.sock = socket.create_connection((self.host, self.port), timeout=self.timeout)
# Drain the greeting.
time.sleep(0.3)
self.drain()
def close(self):
if self.sock:
try:
self.sock.close()
except OSError:
pass
self.sock = None
def _recv(self):
"""Pull whatever is waiting into the buffer; never blocks long."""
self.sock.settimeout(0.05)
try:
while True:
d = self.sock.recv(4096)
if not d:
break
self.buf += d
except (socket.timeout, OSError):
pass
def _take_report(self):
"""Remove every complete <...> report from the buffer and return the
last one, keeping the text around them. Status reports are the only
thing consumed here: the driver's [MSG:] lines stay for drain()."""
last = None
while True:
i = self.buf.find(b"<")
j = self.buf.find(b">", i + 1) if i >= 0 else -1
if i < 0 or j <= i:
return last
last = self.buf[i + 1:j].decode("utf-8", "replace")
self.buf = self.buf[:i] + self.buf[j + 1:]
def drain(self):
self._recv()
out, self.buf = self.buf, b""
return out.decode("utf-8", "replace")
def send_raw(self, data):
self.sock.settimeout(self.timeout)
self.sock.sendall(data)
def command(self, line, timeout=None):
"""Send one line, return the response lines up to ok/error."""
self.send_raw((line.strip() + "\n").encode())
return self.wait_response(timeout or self.timeout)
def wait_response(self, timeout):
deadline = time.time() + timeout
lines = []
self.sock.settimeout(0.2)
while time.time() < deadline:
try:
d = self.sock.recv(4096)
if not d:
break
self.buf += d
except socket.timeout:
pass
while b"\n" in self.buf:
raw, self.buf = self.buf.split(b"\n", 1)
s = raw.decode("utf-8", "replace").strip()
if not s:
continue
lines.append(s)
if s == "ok" or s.startswith("error:"):
return lines
return lines
def realtime(self, byte):
self.send_raw(bytes([byte]))
def status_report(self):
"""One '?' report, parsed: {'state': 'Idle', 'MPos': (x,y,z), ...}.
The '?' is re-sent every 0.5 s until a report arrives: a soft
reset (^X) flushes the controller's read buffer and eats a '?'
that lands in it. Reports already buffered are stale and dropped -
but only the reports: anything else the controller said is left in
the buffer, so a test that polls for a state does not lose the
[MSG:] line that explains it."""
self._recv()
self._take_report() # stale: predates this '?'
self.send_raw(b"?")
deadline = time.time() + self.timeout
resend = time.time() + 0.5
self.sock.settimeout(0.2)
while time.time() < deadline:
try:
d = self.sock.recv(4096)
if d:
self.buf += d
except socket.timeout:
pass
if time.time() >= resend:
self.send_raw(b"?")
resend = time.time() + 0.5
rep = self._take_report()
if rep is not None:
return parse_report(rep)
raise HwError("no status report from grbl")
def parse_report(rep):
parts = rep.split("|")
out = {"state": parts[0]}
for p in parts[1:]:
if ":" in p:
k, v = p.split(":", 1)
if k in ("MPos", "WPos", "WCO"):
try:
out[k] = tuple(float(x) for x in v.split(","))
except ValueError:
out[k] = v
else:
out[k] = v
return out
-118
View File
@@ -1,118 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The append-only result log (JSONL) and the export helpers.
One JSON object per line under the data directory (default
/data/forgetest/results.jsonl, override FORGETEST_DATA). Records:
campaign {"id","manifest_sha","catalog_hash","image"} a campaign opened
result {"campaign","test","result","fingerprint","manifest_sha",
"image","duration_s","evidence","answers","log","message"}
invalidate {"reason"} manual invalidate-all (full campaign required)
reset {"reason"} explicit campaign reset
export {"artifact_sha256","authorized","campaign"}
Every record carries "t" (type) and "ts" (UTC, ISO 8601, seconds).
The file is only ever appended; a corrupt line is skipped, counted, and
reported, never repaired in place.
Because the file only grows, `read` parses each line once and keeps what
it parsed; a later call reads only the bytes appended since. A result
record carries the whole run log, so the file reaches megabytes over a
campaign, and the page asks for the state every second or two.
"""
import json
import os
import threading
import time
DEFAULT_DATA_DIR = "/data/forgetest"
def now_ts():
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
def data_dir():
return os.environ.get("FORGETEST_DATA") or DEFAULT_DATA_DIR
class Log:
def __init__(self, path=None):
self.path = path or os.path.join(data_dir(), "results.jsonl")
self._lock = threading.Lock()
self.corrupt = 0
self._recs = [] # every record parsed so far, in file order
self._offset = 0 # bytes of the file already consumed
self._tail = b"" # bytes past the last newline: not a record yet
def append(self, rec):
rec = dict(rec)
rec.setdefault("ts", now_ts())
line = json.dumps(rec, sort_keys=True, separators=(",", ":"))
with self._lock:
os.makedirs(os.path.dirname(self.path), exist_ok=True)
with open(self.path, "a", encoding="utf-8") as f:
f.write(line + "\n")
f.flush()
os.fsync(f.fileno())
return rec
def _forget(self):
"""Drop what was parsed: the next read starts from the top."""
self._recs = []
self._offset = 0
self._tail = b""
self.corrupt = 0
def _consume(self, chunk):
"""Parse the whole lines in `chunk`, holding back a partial tail."""
data = self._tail + chunk
cut = data.rfind(b"\n")
if cut < 0:
self._tail = data
return
self._tail = data[cut + 1:]
for line in data[:cut].split(b"\n"):
line = line.strip()
if not line:
continue
try:
rec = json.loads(line.decode("utf-8"))
except (ValueError, UnicodeDecodeError):
self.corrupt += 1
continue
if isinstance(rec, dict) and "t" in rec:
self._recs.append(rec)
else:
self.corrupt += 1
def read(self):
"""Every record, in file order. Only the bytes appended since the
last call are parsed; a file that shrank or was replaced is read
again from the top."""
with self._lock:
try:
size = os.path.getsize(self.path)
except OSError:
self._forget()
return []
if size < self._offset:
self._forget()
if size != self._offset:
with open(self.path, "rb") as f:
f.seek(self._offset)
chunk = f.read()
self._offset += len(chunk)
self._consume(chunk)
return list(self._recs)
def raw(self):
if not os.path.exists(self.path):
return ""
with self._lock:
with open(self.path, "r", encoding="utf-8") as f:
return f.read()
File diff suppressed because it is too large Load Diff
-66
View File
@@ -1,66 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The single page: acceptance tab + bench tab, assembled from ui/ into
one self-contained response (no external assets). Bootstrap and the
OpenGlow theme are inlined; theme.css and the vendor/ files are the same
bytes forgectrl's panel carries, so the two pages look like one product
and follow the same light and dark themes (scripts/check-ui-vendor.py
holds them identical).
ui/ holds index.html, theme.css, page.css, help.js, app.js and vendor/.
A plain file is read when it is there (a checkout); otherwise its .gz
sibling is, which is what the recipe installs on the dev image: the
rootfs is raw ext4, so bytes in the package are bytes on the image, and
the page is inflated once at first request. The token placeholder is
spliced in per render."""
import gzip
import os
UI_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui")
# The files index.html links, in load order: one marker tag per file.
CSS_FILES = ("vendor/bootstrap.min.css", "theme.css", "page.css")
JS_FILES = ("vendor/bootstrap.bundle.min.js", "help.js", "app.js")
TOKEN_MARK = "__TOKEN__"
def read_ui(name, ui_dir=UI_DIR):
"""A ui/ file as text: the plain file, or its gzipped install."""
path = os.path.join(ui_dir, *name.split("/"))
try:
with open(path, "rb") as f:
return f.read().decode("utf-8")
except FileNotFoundError:
with gzip.open(path + ".gz", "rb") as f:
return f.read().decode("utf-8")
def assemble(ui_dir=UI_DIR):
"""index.html with every linked stylesheet and script inlined in place
of its tag. A missing marker is a broken page, not a silent one."""
html = read_ui("index.html", ui_dir)
for name in CSS_FILES:
tag = '<link rel="stylesheet" href="%s" />' % name
if tag not in html:
raise ValueError("index.html lacks the marker %s" % tag)
html = html.replace(tag, "<style>\n" + read_ui(name, ui_dir) + "</style>", 1)
for name in JS_FILES:
tag = '<script src="%s"></script>' % name
if tag not in html:
raise ValueError("index.html lacks the marker %s" % tag)
html = html.replace(tag, "<script>\n" + read_ui(name, ui_dir) + "</script>", 1)
if html.count(TOKEN_MARK) != 1:
raise ValueError("the page must carry %s exactly once" % TOKEN_MARK)
return html
_html = None
def render(token):
global _html
if _html is None:
_html = assemble()
return _html.replace(TOKEN_MARK, token)
-148
View File
@@ -1,148 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""Pulse files for the offline cloud service: a job the machine will run
without the service having cut it.
A pulse file is a header (`\\x80GF1`, a little-endian total length, then
8-byte records of a 4-character tag and a uint32) followed by the step
stream, one byte per tick of the header's STfr (the kernel feeder
contract: bit 0 X step, bit 1 X dir, bit 2 Y step, bit 3 Y dir, bit 4
LASER, bits 5-6 Z, bit 7 marks a power byte whose low 7 bits set the
duty). The header here is the one a factory print of this machine type
carries (134 tags, MCsn 0 = not locked to a serial, PDfm 0), so the
client's header check, its job limits (the coolant window, the air
assist floor), its fan duties and its stepper settings all come from the
same place a service job's do. The stream is a square traced at a steady
feed with the laser never commanded: a leading power byte of zero, no
LASER bit anywhere. Nothing in a file from here can put energy into the
tube; the arm and the latch are exercised, the beam is not.
A file longer than the kernel ring (32 MiB of ticks) is written gzip-
compressed, whole file in the stream, as the service serves one: the
client inflates it as the ring drains, and the gzip trailer gives it the
job's length for its progress report.
"""
import gzip
import struct
STEPS_PER_MM = 53.333 # x8 microstepping: the service's scale, which cloud mode runs at
# The header of a factory print for this machine type, as captured.
PRINT_HEADER = {
"AAfc": 3632880242, "AAid": 204, "AAin": 0, "AAix": 0, "AArd": 1023, "AArn": 0,
"AArx": 64500, "AAsn": 0, "AAsx": 0, "AAwd": 1023, "AAwn": 0, "AAwx": 64500,
"BDbs": 0, "BDpe": 0, "BDps": 0, "BTcx": 12800, "BTfc": 4294711296, "BTfo": 4294942296,
"BTin": 2147483648, "BTix": 2147483647, "BTrn": 2147483648, "BTrx": 9440,
"BTwn": 2147483648, "BTwx": 8928, "BTxb": 15, "CCrp": 10000, "CCup": 1, "CCwp": 5000,
"CFrh": 1, "CMin": 10000, "CMix": 50000, "CMrn": 5000, "CMrx": 33000, "CMts": 0,
"CMwn": 7000, "CMwx": 31000, "EFid": 0, "EFin": 0, "EFix": 0, "EFrd": 65535, "EFrn": 0,
"EFrx": 0, "EFwd": 65535, "EFwn": 0, "EFwx": 0, "FTcx": 30000, "FTin": 2147483648,
"FTix": 2147483647, "FTrn": 2147483648, "FTrx": 14575, "FTwn": 2147483648, "FTwx": 14075,
"FTxb": 15, "HAai": 0, "HAar": 0, "HAsi": 2, "HAsr": 4, "HAxi": 0, "HAxr": 132, "HAyi": 0,
"HAyr": 112, "HAzi": 0, "HAzr": 0, "HIix": 12, "HIrx": 1023, "HTcx": 12800, "HTfc": 51200,
"HTfo": 5000, "HTin": 2147483648, "HTix": 2147483647, "HTrn": 2147483648, "HTrx": 9440,
"HTwn": 2147483648, "HTwx": 8928, "HTxb": 15, "IFid": 0, "IFin": 0, "IFix": 0,
"IFrd": 43278, "IFrn": 0, "IFrx": 0, "IFwd": 43278, "IFwn": 0, "IFwx": 0, "IRpd": 1000,
"IRwb": 3, "IRwc": 688, "IRwx": 275, "IRxb": 3, "IRxc": 1022, "IRxx": 374, "IRyb": 3,
"IRyc": 0, "IRyx": 0, "IRzb": 3, "IRzc": 0, "IRzx": 0, "ITcx": 19200, "ITfc": 153600,
"ITfo": 0, "ITin": 2147483648, "ITix": 2147483647, "ITrn": 2147483648, "ITrx": 15840,
"ITwn": 2147483648, "ITwx": 15328, "ITxb": 15, "LTcx": 12800, "LTfc": 51200, "LTfo": 5000,
"LTin": 2147483648, "LTix": 2147483647, "LTrn": 2147483648, "LTrx": 9440,
"LTwn": 2147483648, "LTwx": 8928, "LTxb": 15, "MCsn": 0, "PCid": 19795, "PDct": 5,
"PDfm": 0, "PTmn": 0, "PTmx": 1023, "STfr": 10000, "TRuc": 0, "XSdm": 1, "XShc": 33,
"XSmm": 8, "XSrc": 135, "YSdm": 1, "YShc": 5, "YSmm": 8, "YSrc": 22, "ZSmd": 0,
}
X_STEP, X_DIR, Y_STEP, Y_DIR, LASER = 0x01, 0x02, 0x04, 0x08, 0x10
POWER = 0x80
def header_bytes(tags=None):
"""The header record block, the template with `tags` laid over it."""
h = dict(PRINT_HEADER)
if tags:
h.update(tags)
recs = b"".join(k.encode("ascii") + struct.pack("<I", int(v)) for k, v in sorted(h.items()))
total = 8 + len(recs)
return bytes([POWER]) + b"GF1" + struct.pack("<I", total) + recs
def leg(dx_steps, dy_steps, period):
"""One straight leg: Bresenham over the major axis, one step every
`period` ticks, laser never commanded. X: DIR set = negative; Y: DIR
set = positive (the hardware convention)."""
ax, ay = abs(dx_steps), abs(dy_steps)
major = max(ax, ay)
if major == 0:
return b""
xbit = X_STEP | (X_DIR if dx_steps < 0 else 0)
ybit = Y_STEP | (Y_DIR if dy_steps > 0 else 0)
out = bytearray()
gap = bytes(period - 1)
err = 0
x_major = ax >= ay
minor = ay if x_major else ax
for _ in range(major):
b = xbit if x_major else ybit
err += minor
if err >= major:
err -= major
b |= ybit if x_major else xbit
out.append(b)
out += gap
return bytes(out)
def square_stream(side_mm=40.0, feed_mm_min=600.0, step_hz=10000, repeats=1):
"""A closed square traced `repeats` times at a steady feed: +X, +Y,
-X, -Y, the head back where it started. Leads with a power byte of
zero, as the feeder contract wants before any laser-on byte (there
is none)."""
steps = int(round(side_mm * STEPS_PER_MM))
period = max(1, int(round(step_hz / (feed_mm_min / 60.0 * STEPS_PER_MM))))
one = (leg(steps, 0, period) + leg(0, steps, period)
+ leg(-steps, 0, period) + leg(0, -steps, period))
return bytes([POWER]) + bytes(period) + one * repeats
def seconds_of(stream, step_hz=10000):
return len(stream) / float(step_hz)
def write_job(path, side_mm=40.0, feed_mm_min=600.0, seconds=None, step_hz=10000,
compress=None, tags=None):
"""Write a job to `path`: a square of `side_mm` at `feed_mm_min`,
repeated until the stream is at least `seconds` long (one square if
None). Compressed when `compress` is true, or, left None, when the
stream is longer than the kernel ring (32 MiB of ticks). Returns
(payload bytes, seconds, compressed)."""
one = square_stream(side_mm, feed_mm_min, step_hz, 1)
repeats = 1
if seconds:
per = seconds_of(one, step_hz)
repeats = max(1, int(seconds / per) + (1 if seconds % per else 0))
body = square_stream(side_mm, feed_mm_min, step_hz, repeats)
head = header_bytes(dict(tags or {}, STfr=step_hz))
if compress is None:
compress = len(body) > 32 * 1024 * 1024
data = head + body
if compress:
data = gzip.compress(data, compresslevel=6)
with open(path, "wb") as f:
f.write(data)
return len(body), seconds_of(body, step_hz), compress
def parse(data):
"""(header dict, payload) of a raw or gzip-compressed pulse file."""
if data[:2] == b"\x1f\x8b":
data = gzip.decompress(data)
if data[1:4] != b"GF1":
raise ValueError("not a GF1 pulse file")
total = struct.unpack_from("<I", data, 4)[0]
tags = {data[p:p + 4].decode("ascii"): struct.unpack_from("<I", data, p + 4)[0]
for p in range(8, total - 7, 8)}
return tags, data[total:]
-135
View File
@@ -1,135 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""Redaction for the release artifact.
The artifact is committed to a public repository, so it carries no
identity of the machine that ran the campaign. This applies the same two
layers forgectrl's log export applies (forgectrl/src/sanitize.c), in the
same order, to every value the exporter writes:
1. Known keys: a value held under a key that names identifying data -
the serial, the machine id, the hostname, the wireless credentials,
a token - is replaced whole. Naming the key is far more reliable
than guessing the shape of what is under it.
2. Pattern classes over every remaining string: MAC addresses, IPv4 and
IPv6 addresses (loopback and unspecified kept), e-mail addresses,
bearer and basic credentials, JWTs, and long hex blobs. Each distinct
value in a class keeps a stable number for the life of one export, so
"<IP-2>" is the same host wherever it appears and the artifact stays
readable as evidence.
Integrity values are never touched: the self-hash, the manifest and
catalog hashes, each test's source hash and fingerprint. Those are what
the gate recomputes, and a long hex blob is exactly what they look like.
Over-redaction is accepted where the alternative is a leak.
"""
import re
# Layer 1: the value under one of these keys is replaced whole.
REDACT_KEYS = frozenset((
"gf_serial", "machine_id", "serial", "serial_number",
"hostname", "hostname_file", "host",
"ssid", "psk", "passphrase", "password", "passwd",
"token", "panel_token", "secret", "api_key", "key",
"mac", "macaddr", "mac_suffix", "wlan0", "eth0",
"gf_username", "gf_password", "username", "user", "email",
))
# Never touched, by key: the gate recomputes these and they look like
# exactly what layer 2 hunts for.
KEEP_KEYS = frozenset((
"sha256", "manifest_sha", "identity_sha", "catalog_hash",
"source_sha", "fingerprint", "content_sha", "srcrev",
))
_KEEP_IP = frozenset(("0.0.0.0", "255.255.255.255"))
_CLASSES = (
("SECRET", re.compile(r"(?i)\b(?:bearer|basic)\s+[A-Za-z0-9._~+/=-]{8,}")),
("JWT", re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}")),
("EMAIL", re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")),
("MAC", re.compile(r"\b(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}\b")),
# Three or more colons, or a "::" run: two colons is a clock time, and
# tagging every timestamp would make the evidence unreadable.
("IP6", re.compile(
r"(?:[0-9A-Fa-f]{1,4}:){3,7}[0-9A-Fa-f]{1,4}"
r"|[0-9A-Fa-f]{0,4}(?::[0-9A-Fa-f]{1,4})*::(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?")),
("IP", re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")),
("HEX", re.compile(r"\b[0-9A-Fa-f]{32,}\b")),
)
class Redactor:
"""Stable per-export numbering, one instance per artifact."""
# A known value shorter than this is too generic to replace blind.
MIN_KNOWN = 4
def __init__(self):
self._n = {}
self._known = {}
def learn(self, obj, key=None):
"""Collect the values held under the layer-1 keys, so they are
replaced wherever else they appear - a hostname in a log line is
the same leak as a hostname in its own field."""
if key is not None and key in KEEP_KEYS:
return
if isinstance(obj, dict):
for k, v in obj.items():
self.learn(v, k)
elif isinstance(obj, list):
for v in obj:
self.learn(v)
elif key in REDACT_KEYS and isinstance(obj, str) and len(obj) >= self.MIN_KNOWN:
if obj not in self._known:
self._known[obj] = self._tag("REDACTED", "%s=%s" % (key, obj))
def _tag(self, cls, value):
seen = self._n.setdefault(cls, {})
if value not in seen:
seen[value] = len(seen) + 1
return "<%s-%d>" % (cls, seen[value])
def text(self, s):
for known in sorted(self._known, key=len, reverse=True):
if known in s:
s = s.replace(known, self._known[known])
for cls, rx in _CLASSES:
def sub(m, cls=cls):
v = m.group(0)
if cls == "IP" and (v.startswith("127.") or v in _KEEP_IP):
return v
if cls == "IP6" and v in ("::", "::1"):
return v
return self._tag(cls, v)
s = rx.sub(sub, s)
return s
def value(self, v, key=None):
if key is not None and key in KEEP_KEYS:
return v
if key is not None and key in REDACT_KEYS and not isinstance(v, (dict, list)):
if v is None:
return None
if isinstance(v, str) and v in self._known:
return self._known[v]
return self._tag("REDACTED", "%s=%s" % (key, v))
if isinstance(v, dict):
return {k: self.value(x, k) for k, x in v.items()}
if isinstance(v, list):
return [self.value(x) for x in v]
if isinstance(v, str):
return self.text(v)
return v
def scrub(obj):
"""Redact a value tree with one export's numbering."""
r = Redactor()
r.learn(obj)
return r.value(obj)
File diff suppressed because it is too large Load Diff
-347
View File
@@ -1,347 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""HTTP: the page, the JSON API, and the access rules.
Access follows forgectrl's panel (auth.c): the Host header must be an
address literal or localhost (no DNS names - the rebinding vehicle), a
cross-site Sec-Fetch-Site is refused, an Origin must itself be a literal,
and every state-changing call needs the bearer token that is generated on
first start, stored 0600 under the data directory, and embedded in the
page. Read-only calls need the origin checks only.
Routes
GET / the page
GET /state campaign + per-test state + running run.
Carries an ETag; If-None-Match on an
unchanged state gets a 304, which is what an
idle page polls for.
GET /catalog test definitions (title, steps, covers...)
GET /bench bench tool listing
GET /result?test&ts one full result record (log, evidence)
GET /log the raw JSONL
GET /journal the daemon's journal (daemon.log tail)
GET /export/acceptance.json | .md the last export
POST /start {test, ack_live, ignore_requires} start an acceptance test
POST /batch {group, ack_live, ignore_requires} run everything a queue
still owes, in prerequisite order
POST /batch/stop cancel what is still queued
POST /bench/start {tool, args, ack_live}
POST /answer {prompt_id, value}
POST /abort
POST /invalidate {reason}
POST /reset {reason}
POST /export build + save the artifact, returns it
"""
import hashlib
import hmac
import json
import os
import re
import secrets
import sys
import urllib.parse
import socket
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from . import artifact as _artifact
from . import page as _page
from .log import data_dir
JOURNAL_TAIL_BYTES = 256 << 10
def journal_tail(path=None, max_bytes=JOURNAL_TAIL_BYTES):
"""The last max_bytes of the daemon's journal (daemon.log, written by
the init script from the daemon's stderr), or a note that there is
none yet."""
path = path or os.path.join(data_dir(), "daemon.log")
try:
size = os.path.getsize(path)
with open(path, "rb") as f:
if size > max_bytes:
f.seek(size - max_bytes)
f.readline()
return f.read()
except OSError:
return b"(no journal yet: %s)\n" % path.encode()
TOKEN_HEX = 32
_LITERAL_RX = re.compile(r"^[0-9.]+$")
def load_token(path=None):
path = path or os.path.join(data_dir(), "token")
try:
with open(path, "r", encoding="utf-8") as f:
tok = f.read().strip()
if len(tok) == TOKEN_HEX and all(c in "0123456789abcdef" for c in tok):
return tok
except OSError:
pass
tok = secrets.token_hex(TOKEN_HEX // 2)
os.makedirs(os.path.dirname(path), exist_ok=True)
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(tok + "\n")
return tok
def host_is_literal(h):
if not h:
return False
if h.startswith("["):
return True
host = h.split(":", 1)[0]
if host == "localhost":
return True
return bool(_LITERAL_RX.match(host))
def origin_ok(headers):
if not host_is_literal(headers.get("Host")):
return False
sfs = headers.get("Sec-Fetch-Site")
if sfs and sfs not in ("same-origin", "none"):
return False
origin = headers.get("Origin")
if origin and origin != "null":
p = urllib.parse.urlsplit(origin)
if not host_is_literal(p.netloc):
return False
return True
class App:
"""What the handler needs: the runner, the token, the export dir."""
def __init__(self, runner, token, export_dir=None):
self.runner = runner
self.token = token
self.export_dir = export_dir or os.path.join(data_dir(), "export")
def token_ok(self, given):
return bool(given) and hmac.compare_digest(given, self.token)
class Handler(BaseHTTPRequestHandler):
server_version = "forgetest"
# Keep-alive: the page polls, so a handshake and a fresh thread per
# request is pure overhead. Idle connections are dropped after the
# timeout rather than holding a thread forever.
protocol_version = "HTTP/1.1"
timeout = 30
app = None # set on the server class
# -- plumbing ---------------------------------------------------------
def log_message(self, fmt, *args):
if os.environ.get("FORGETEST_HTTP_LOG"):
sys.stderr.write("forgetest: %s - %s\n" % (self.address_string(), fmt % args))
def _send(self, status, body, ctype="application/json", extra=None):
if isinstance(body, (dict, list)):
body = json.dumps(body, sort_keys=True).encode("utf-8")
elif isinstance(body, str):
body = body.encode("utf-8")
self.responded = True
self.send_response(status)
self.send_header("Content-Type", ctype + ("; charset=utf-8" if ctype.startswith("text/") else ""))
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
for k, v in (extra or {}).items():
self.send_header(k, v)
self.end_headers()
if self.command != "HEAD":
self.wfile.write(body)
def _send_304(self, etag):
self.responded = True
self.send_response(304)
self.send_header("ETag", etag)
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", "0")
self.end_headers()
def _deny(self, status, msg):
# One response per request: on a kept-alive connection a second
# one would desynchronize the stream.
if getattr(self, "responded", False):
return
# A refused POST still has its body in the socket. Leaving it
# there would make the next read on this connection take the
# body for a request line, so the connection ends here.
# send_header sets close_connection from this.
extra = None if getattr(self, "body_read", True) else {"Connection": "close"}
self._send(status, {"error": msg}, extra=extra)
def _read_ok(self):
if not origin_ok(self.headers):
self._deny(403, "request origin refused")
return False
return True
def _write_ok(self, query):
if not origin_ok(self.headers):
self._deny(403, "request origin refused")
return False
tok = self.headers.get("X-ForgeFIRM-Token") or (query.get("token") or [None])[0]
if not self.app.token_ok(tok):
self._deny(403, "authentication required")
return False
return True
def _body_json(self):
n = int(self.headers.get("Content-Length") or 0)
if n <= 0:
self.body_read = True
return {}
if n > 1 << 20:
raise ValueError("body too large")
raw = self.rfile.read(n)
self.body_read = True
ctype = self.headers.get("Content-Type", "")
if "json" in ctype:
data = json.loads(raw.decode("utf-8"))
return data if isinstance(data, dict) else {}
# form-encoded fallback
return {k: v[0] for k, v in urllib.parse.parse_qs(raw.decode("utf-8")).items()}
# -- GET ---------------------------------------------------------------
def do_HEAD(self):
self.do_GET()
def do_GET(self):
url = urllib.parse.urlsplit(self.path)
path = url.path
query = urllib.parse.parse_qs(url.query)
r = self.app.runner
self.responded = False
if not self._read_ok():
return
try:
if path == "/":
self._send(200, _page.render(self.app.token), "text/html")
elif path == "/state":
# Conditional: an idle page polls an unchanged state, and
# a 304 spares both ends the payload and the re-render.
state, _ = r.state()
body = json.dumps(state, sort_keys=True).encode("utf-8")
etag = '"%s"' % hashlib.sha256(body).hexdigest()[:32]
if self.headers.get("If-None-Match") == etag:
self._send_304(etag)
else:
self._send(200, body, extra={"ETag": etag})
elif path == "/catalog":
self._send(200, {"tests": [t.describe() for t in r.tests()],
"catalog_hash": r.catalog_hash})
elif path == "/bench":
b = r.bench
self._send(200, {"tools": b.listing() if b else [], "tool_dir": b.tool_dir() if b else None})
elif path == "/result":
test = (query.get("test") or [""])[0]
ts = (query.get("ts") or [""])[0]
rec = None
for x in reversed(r.log.read()):
if x.get("t") == "result" and x.get("test") == test and (not ts or x.get("ts") == ts):
rec = x
break
if rec is None:
self._deny(404, "no such result")
else:
self._send(200, rec)
elif path == "/log":
self._send(200, r.log.raw(), "text/plain")
elif path == "/journal":
self._send(200, journal_tail(), "text/plain")
elif path in ("/export/acceptance.json", "/export/acceptance.md"):
fn = os.path.join(self.app.export_dir, os.path.basename(path))
if not os.path.exists(fn):
self._deny(404, "nothing exported yet")
return
with open(fn, "rb") as f:
data = f.read()
ctype = "application/json" if path.endswith(".json") else "text/markdown"
self._send(200, data, ctype, {"Content-Disposition": "attachment; filename=%s"
% os.path.basename(path)})
else:
self._deny(404, "not found")
except Exception as e: # noqa: BLE001
self._deny(500, "%s: %s" % (type(e).__name__, e))
# -- POST -----------------------------------------------------------------
def do_POST(self):
url = urllib.parse.urlsplit(self.path)
path = url.path
query = urllib.parse.parse_qs(url.query)
r = self.app.runner
self.responded = False
self.body_read = False
if not self._write_ok(query):
return
try:
body = self._body_json()
except ValueError as e:
self._deny(400, "bad body: %s" % e)
return
try:
if path == "/start":
ok, msg = r.start_test(str(body.get("test", "")), ack_live=bool(body.get("ack_live")),
ignore_requires=bool(body.get("ignore_requires")))
self._send(200 if ok else 409, {"ok": ok, "message": msg})
elif path == "/batch":
ok, msg, order = r.start_batch(str(body.get("group", "")),
ack_live=bool(body.get("ack_live")),
ignore_requires=bool(body.get("ignore_requires")))
self._send(200 if ok else 409, {"ok": ok, "message": msg, "order": order})
elif path == "/batch/stop":
ok, msg = r.stop_batch()
self._send(200 if ok else 409, {"ok": ok, "message": msg})
elif path == "/bench/start":
args = body.get("args") or {}
if not isinstance(args, dict):
self._deny(400, "args must be an object")
return
ok, msg = r.start_bench(str(body.get("tool", "")), args, ack_live=bool(body.get("ack_live")))
self._send(200 if ok else 409, {"ok": ok, "message": msg})
elif path == "/answer":
ok, msg = r.answer(str(body.get("prompt_id", "")), str(body.get("value", "")))
self._send(200 if ok else 409, {"ok": ok, "message": msg})
elif path == "/abort":
ok, msg = r.abort()
self._send(200 if ok else 409, {"ok": ok, "message": msg})
elif path == "/invalidate":
ok, msg = r.invalidate(str(body.get("reason", "")))
self._send(200 if ok else 400, {"ok": ok, "message": msg})
elif path == "/reset":
ok, msg = r.reset(str(body.get("reason", "")))
self._send(200 if ok else 409, {"ok": ok, "message": msg})
elif path == "/export":
art = r.export()
os.makedirs(self.app.export_dir, exist_ok=True)
with open(os.path.join(self.app.export_dir, "acceptance.json"), "w", encoding="utf-8") as f:
f.write(_artifact.to_json(art))
with open(os.path.join(self.app.export_dir, "acceptance.md"), "w", encoding="utf-8") as f:
f.write(_artifact.to_markdown(art))
self._send(200, {"ok": True, "authorized": art["authorized"], "sha256": art["sha256"],
"counts": art["counts"]})
else:
self._deny(404, "not found")
except Exception as e: # noqa: BLE001
self._deny(500, "%s: %s" % (type(e).__name__, e))
def make_server(app, host="::", port=8090):
handler = type("ForgetestHandler", (Handler,), {"app": app})
ThreadingHTTPServer.allow_reuse_address = True
# An IPv6 bind address (the default "::") gives one dual-stack socket
# that serves IPv4 clients too; a dotted address keeps the IPv4 family.
server_cls = ThreadingHTTPServer
if ":" in host:
server_cls = type("ForgetestServer6", (ThreadingHTTPServer,),
{"address_family": socket.AF_INET6})
srv = server_cls((host, port), handler)
srv.daemon_threads = True
return srv
-24
View File
@@ -1,24 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The acceptance suite: one module per subsystem, imported in display
order. Each module registers its tests with @catalog.test."""
from . import image # noqa: F401,E402
from . import kernel # noqa: F401,E402
from . import forgectrl # noqa: F401,E402
from . import setup # noqa: F401,E402
from . import setup_dark # noqa: F401,E402
from . import setup_burns # noqa: F401,E402
from . import motion # noqa: F401,E402
from . import cooling # noqa: F401,E402
from . import laser # noqa: F401,E402
from . import camera # noqa: F401,E402
from . import update # noqa: F401,E402
from . import cloud # noqa: F401,E402
# Last: it stands on the setup record's consent and on the cloud suite's dark print.
from . import exthost # noqa: F401,E402
from . import homeoff # noqa: F401,E402
from . import tlsrec # noqa: F401,E402
-496
View File
@@ -1,496 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""camera.* - the lid camera pipeline through forgectrl."""
from ..catalog import test
from .. import hw
from ..baseline import LID_LAMP_ATTR
_CAM_COVERS = [("forgectrl", "src/cam.*"), ("forgectrl", "src/camhealth.*"),
("forgectrl", "src/debayer.*"), ("forgectrl", "src/vpu_jpeg.*"),
("forgectrl", "src/vpu_h264.*"), ("forgectrl", "src/mp4mux.*"),
("forgectrl", "src/gpu_debayer.*"), ("forgectrl", "src/ipu_copy.*"),
("forgectrl", "src/main.c"),
("python3-gfhardware", "gfhardware/src/**"), ("python3-gfhardware", "gfhardware/cam*")]
# The privacy gate spans the camera path and the lid read it depends on,
# in both processes that can reach a sensor.
_PRIVACY_COVERS = _CAM_COVERS + [("forgectrl", "src/status.*"),
("python3-gfhardware", "gfhardware/switches.py"),
("forgefirm-app", "forgefirm-app/ffmachine.py")]
# The sensors the machine ships with, and the geometry each one implies.
# A machine reports exactly one of these; anything else means the sensor
# bound to a driver the capture path has no profile for.
_SENSOR_GEOMETRY = {
"OV5648": (2592, 1944),
"OV8856": (3264, 2448),
}
def _jpeg_size(data):
"""(width, height) from a JPEG's first SOFn marker, or None."""
i = 2
n = len(data)
while i + 3 < n:
if data[i] != 0xFF:
i += 1
continue
marker = data[i + 1]
if marker in (0xD8, 0xD9) or 0xD0 <= marker <= 0xD7:
i += 2
continue
seglen = (data[i + 2] << 8) | data[i + 3]
# SOFn, excluding the non-frame markers in the same range
if 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC):
if i + 9 > n:
return None
return ((data[i + 7] << 8) | data[i + 8],
(data[i + 5] << 8) | data[i + 6])
i += 2 + seglen
return None
@test("camera.snapshot", title="Lid camera snapshot and stream", subsystem="camera",
kind="auto", est_min=2,
covers=_CAM_COVERS,
steps=["Setup: lid closed."],
description="/cam/snapshot returns a JPEG of a plausible size (a black frame compresses "
"far smaller), a second snapshot with the lid lamp switched off differs from it "
"and is smaller (the capture is live, not a frame the pipeline is re-serving, "
"and the lamp lights what the camera sees), and the MJPEG stream starts and "
"stops. A capture marked background=1 yields to a viewer: while the stream is "
"open it is refused in words, the operator's own unmarked capture is still "
"served, and once the viewer stops the background capture is served again. A "
"background value that is neither 0 nor 1 is refused.")
def snapshot(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
st, body = fc.get("/cam/status")
ctx.check(st == 200 and isinstance(body, dict), "GET /cam/status -> %s", st)
ev["cam_status"] = body
ctx.log("cam status: %s", body)
st, data = fc.get("/cam/snapshot", params={"cam": "lid", "res": "half"}, raw=True)
ctx.log("GET /cam/snapshot?cam=lid&res=half -> %s (%d bytes)", st, len(data) if data else 0)
ctx.check(st == 200, "snapshot -> %s %s", st, data[:120] if data else "")
ctx.check(data[:2] == b"\xff\xd8" and data[-2:] == b"\xff\xd9", "snapshot is not a complete JPEG")
ev["snapshot_bytes"] = len(data)
ctx.check(len(data) > 20000, "snapshot is only %d bytes - a dark or empty frame?", len(data))
# Live, and lit: the same frame with the lid lamp off must be a
# different and smaller picture. The lamp is put back where it was.
lamp0 = hw.sysfs_read(LID_LAMP_ATTR)
ev["lid_lamp"] = lamp0
ctx.check(lamp0 is not None, "the lid lamp (%s) is not readable", LID_LAMP_ATTR)
try:
hw.sysfs_write(LID_LAMP_ATTR, "0")
ctx.sleep(1.5)
st, dark = fc.get("/cam/snapshot", params={"cam": "lid", "res": "half"}, raw=True)
finally:
hw.sysfs_write(LID_LAMP_ATTR, lamp0)
ctx.check(st == 200 and dark and dark[:2] == b"\xff\xd8", "snapshot with the lamp off -> %s", st)
ev["snapshot_dark_bytes"] = len(dark)
ctx.log("lamp %s -> %d bytes; lamp off -> %d bytes", lamp0, len(data), len(dark))
ctx.check(dark != data, "the snapshot with the lamp off is byte-identical to the lit one: the "
"capture is not live")
ctx.check(len(dark) < len(data), "the frame with the lamp off is not smaller than the lit one "
"(%d vs %d bytes): the lamp is not lighting what the camera "
"sees", len(dark), len(data))
st, data = fc.get("/cam/snapshot", params={"cam": "lid", "res": "full"}, raw=True)
ctx.log("GET /cam/snapshot?cam=lid&res=full -> %s (%d bytes)", st, len(data) if data else 0)
ctx.check(st == 200 and data[:2] == b"\xff\xd8", "full-resolution snapshot -> %s", st)
ev["snapshot_full_bytes"] = len(data)
# the stream: fetch a little of it and let it close
import urllib.request
req = urllib.request.Request(fc.base + "/cam/stream", headers={"Host": fc.host_header()})
try:
with urllib.request.urlopen(req, timeout=10) as r:
ctype = r.headers.get("Content-Type", "")
chunk = r.read(65536)
except Exception as e: # noqa: BLE001
ctx.fail("stream did not open: %s", e)
ev["stream_ctype"] = ctype
ctx.log("stream: %s, first %d bytes", ctype, len(chunk))
ctx.check("multipart" in ctype and b"\xff\xd8" in chunk, "stream is not an MJPEG multipart")
# A capture nobody is waiting for yields to one somebody is: a
# snapshot borrows the mux and stutters a running stream, which is a
# price worth paying for an operator and not for a program. The
# stream above is closed by now, so one is opened and held here.
bad = fc.get("/cam/snapshot", params={"cam": "lid", "background": "maybe"})
ev["background_bad_value"] = bad
ctx.check(bad[0] == 400, "background=maybe -> %s", bad)
held = urllib.request.urlopen(
urllib.request.Request(fc.base + "/cam/stream", headers={"Host": fc.host_header()}), timeout=10)
try:
held.read(8192)
watching = None
for _ in range(40):
st_, body_ = fc.get("/cam/status")
if isinstance(body_, dict) and body_.get("clients"):
watching = body_
break
ctx.sleep(0.25)
ev["clients_while_watching"] = (watching or {}).get("clients")
ctx.check(watching, "no viewer was counted while the stream was held: %s", body_)
st_, why = fc.get("/cam/snapshot", params={"cam": "lid", "res": "half", "background": "1"})
ev["background_while_watched"] = [st_, why]
ctx.log("background capture while a viewer watches -> %s %s", st_, why)
ctx.check(st_ == 409, "a background capture did not yield to a viewer: %s %s", st_, why)
st_, data_ = fc.get("/cam/snapshot", params={"cam": "lid", "res": "half"}, raw=True)
ev["operator_while_watched"] = [st_, len(data_ or b"")]
ctx.check(st_ == 200 and data_ and data_[:2] == b"\xff\xd8",
"the operator's own capture was refused while watching: %s", st_)
finally:
held.close()
for _ in range(40):
st_, body_ = fc.get("/cam/status")
if isinstance(body_, dict) and not body_.get("clients"):
break
ctx.sleep(0.25)
st_, data_ = fc.get("/cam/snapshot", params={"cam": "lid", "res": "half", "background": "1"}, raw=True)
ev["background_after_watching"] = [st_, len(data_ or b"")]
ctx.check(st_ == 200 and data_ and data_[:2] == b"\xff\xd8",
"a background capture was still refused after the viewer stopped: %s", st_)
ctx.sleep(2)
st, body = fc.get("/cam/status")
ev["cam_status_after"] = body
ctx.log("cam status after: %s", body)
ctx.log("PASS: half-res snapshot %d bytes, %d with the lamp off; full-res %d; stream %s",
ev["snapshot_bytes"], len(dark), len(data), ctype)
@test("camera.sensor-profile", title="Camera geometry follows the fitted sensor", subsystem="camera",
kind="auto", est_min=1,
covers=_CAM_COVERS,
description="/cam/status names the sensor that bound (OV5648 on a 5 MP machine, OV8856 on an "
"8 MP 'HD' one) and reports the geometry that sensor implies, and the JPEGs it "
"actually returns are that size. A machine that reports 'unknown', or whose frames "
"do not match what it advertises, is running the wrong capture profile.")
def sensor_profile(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
st, body = fc.get("/cam/status")
ctx.check(st == 200 and isinstance(body, dict), "GET /cam/status -> %s", st)
sensor = body.get("sensor")
ev["sensor"] = sensor
ctx.log("sensor: %s", sensor)
ctx.check(sensor in _SENSOR_GEOMETRY,
"sensor %r is not one this build has a capture profile for", sensor)
want_w, want_h = _SENSOR_GEOMETRY[sensor]
snap = body.get("snapshot") or {}
stream = body.get("stream") or {}
ev["geometry"] = {"snapshot": snap, "stream": stream}
ctx.log("advertised: snapshot %sx%s, stream %sx%s",
snap.get("width"), snap.get("height"), stream.get("width"), stream.get("height"))
ctx.check(snap.get("width") == want_w and snap.get("height") == want_h,
"%s should advertise %dx%d snapshots, got %sx%s",
sensor, want_w, want_h, snap.get("width"), snap.get("height"))
ctx.check(stream.get("width") == want_w // 2 and stream.get("height") == want_h // 2,
"%s should advertise %dx%d stream frames, got %sx%s",
sensor, want_w // 2, want_h // 2, stream.get("width"), stream.get("height"))
# What it advertises is what it delivers.
for res, (w, h) in (("full", (want_w, want_h)), ("half", (want_w // 2, want_h // 2))):
st, data = fc.get("/cam/snapshot", params={"cam": "lid", "res": res}, raw=True)
ctx.check(st == 200 and data and data[:2] == b"\xff\xd8",
"%s snapshot -> %s", res, st)
got = _jpeg_size(data)
ev["jpeg_%s" % res] = got
ctx.log("%s snapshot: %s (%d bytes)", res, got, len(data))
ctx.check(got == (w, h), "%s snapshot is %s, not %dx%d", res, got, w, h)
@test("camera.h264-stream", title="H.264 live stream", subsystem="camera",
kind="auto", est_min=2,
covers=_CAM_COVERS,
steps=["Setup: lid closed."],
description="/cam/h264 answers a fragmented MP4: a codec header naming the SPS profile, an "
"init segment (ftyp+moov) followed by media fragments (moof+mdat) within a few "
"seconds, and /cam/status reporting the H.264 encoder active with this client "
"counted. Also records which demosaic path (GPU or CPU) served the stream. On a "
"machine whose image lacks Mesa or whose encoder refused, the endpoint must "
"answer 503 rather than hang - that is a pass for the endpoint but is recorded "
"in the evidence.")
def h264_stream(ctx):
import urllib.error
import urllib.request
fc = ctx.forgectrl
ev = ctx.evidence
req = urllib.request.Request(fc.base + "/cam/h264?cam=lid",
headers={"Host": fc.host_header()})
try:
r = urllib.request.urlopen(req, timeout=20)
except urllib.error.HTTPError as e:
ev["status"] = e.code
ev["body"] = e.read()[:200].decode("utf-8", "replace")
ctx.check(e.code == 503, "H.264 refused with %s, not 503: %s", e.code, ev["body"])
ctx.log("H.264 unavailable on this machine (503): %s", ev["body"])
return
with r:
codec = r.headers.get("X-H264-Codec", "")
ctype = r.headers.get("Content-Type", "")
ev["codec"] = codec
ev["content_type"] = ctype
ctx.log("codec %s, content-type %s", codec, ctype)
ctx.check(ctype == "video/mp4", "content type is %r", ctype)
ctx.check(codec.startswith("avc1."), "codec header is %r", codec)
# Init segment, then at least one media fragment.
data = b""
while len(data) < 512 * 1024:
chunk = r.read(65536)
if not chunk:
break
data += chunk
if b"moof" in data and b"mdat" in data:
break
ev["bytes"] = len(data)
ctx.check(data[4:8] == b"ftyp", "stream does not start with ftyp")
ctx.check(b"moov" in data, "no moov (init segment)")
ctx.check(b"avcC" in data, "no avcC in the init segment")
ctx.check(b"moof" in data and b"mdat" in data,
"no media fragment arrived in %d bytes", len(data))
st, body = fc.get("/cam/status")
ctx.check(st == 200 and isinstance(body, dict), "GET /cam/status -> %s", st)
ev["cam_status"] = body
ctx.log("convert=%s h264=%s", body.get("convert"), body.get("h264"))
h = body.get("h264") or {}
ctx.check(h.get("active") is True, "/cam/status does not report the H.264 encoder active: %s",
body)
@test("camera.frame-health", title="Capture delivers whole frames", subsystem="camera",
kind="auto", est_min=1,
covers=_CAM_COVERS,
description="The capture queue flags a frame errored when it is short, torn, or arrived "
"after the CSI-2 receiver lost sync; forgectrl drops those rather than "
"demosaicing them, restarts the stream if they persist, and withholds the "
"stream frames on either side of one. A healthy machine captures a burst of "
"snapshots and a few seconds of stream with none of them flagged and none "
"withheld, and reports the stream's per-stage timing while a viewer watches. "
"A nonzero corrupt count here is a real signal - a marginal camera ribbon, a "
"mistimed D-PHY - even though the frames themselves never reach a client.")
def frame_health(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
def health():
st, body = fc.get("/cam/status")
ctx.check(st == 200 and isinstance(body, dict), "GET /cam/status -> %s", st)
h = body.get("health")
ctx.check(isinstance(h, dict), "/cam/status carries no health block: %s", body)
return h
before = health()
ev["health_before"] = before
ctx.log("before: %s", before)
# A burst, so the count covers a run of frames rather than a single grab.
for _ in range(3):
st, data = fc.get("/cam/snapshot", params={"cam": "lid", "res": "half"}, raw=True)
ctx.check(st == 200 and data and data[:2] == b"\xff\xd8", "snapshot -> %s", st)
after = health()
ev["health_after"] = after
ctx.log("after: %s", after)
captured = (after.get("captured") or 0) - (before.get("captured") or 0)
corrupt = (after.get("corrupt") or 0) - (before.get("corrupt") or 0)
restarts = (after.get("restarts") or 0) - (before.get("restarts") or 0)
ev["captured"] = captured
ev["corrupt"] = corrupt
ev["restarts"] = restarts
ctx.log("captured %d frames, %d corrupt, %d stream restarts", captured, corrupt, restarts)
ctx.check(captured > 0, "no frames were dequeued during three snapshots")
ctx.check(corrupt == 0, "%d of %d captured frames came back errored", corrupt, captured)
ctx.check(restarts == 0, "the capture stream was restarted %d time(s)", restarts)
# The stream path: five seconds of MJPEG, so the frame gate judges a
# few dozen frames and the timing covers two whole windows. A frame is
# withheld only next to one the receiver flagged, so a healthy machine
# withholds none, and the per-stage timing is reported while the
# viewer watches.
import time
import urllib.request
s0 = health()
stream = urllib.request.urlopen(
urllib.request.Request(fc.base + "/cam/stream", headers={"Host": fc.host_header()}),
timeout=10)
try:
got = 0
t_end = time.monotonic() + 5.0
while time.monotonic() < t_end:
got += len(stream.read(65536))
st, body = fc.get("/cam/status")
timing = body.get("timing") if isinstance(body, dict) else None
finally:
stream.close()
s1 = health()
ev["stream_bytes"] = got
ev["stream_timing"] = timing
ev["stream_health"] = [s0, s1]
ctx.log("stream: %d bytes in 5 s, timing %s", got, timing)
s_captured = (s1.get("captured") or 0) - (s0.get("captured") or 0)
s_corrupt = (s1.get("corrupt") or 0) - (s0.get("corrupt") or 0)
s_withheld = (s1.get("withheld") or 0) - (s0.get("withheld") or 0)
ev["stream_captured"] = s_captured
ev["stream_withheld"] = s_withheld
ctx.check(s_captured >= 30, "only %d frames were dequeued in 5 s of stream", s_captured)
ctx.check(s_corrupt == 0, "%d stream frames came back errored", s_corrupt)
ctx.check("withheld" in s1, "/cam/status health carries no withheld count: %s", s1)
ctx.check(s_withheld == 0, "%d stream frames were withheld with none flagged", s_withheld)
ctx.check(isinstance(timing, dict) and
all(k in timing for k in ("latency_ms", "convert_ms", "copy_ms", "encode_ms")),
"/cam/status carries no timing block while a viewer watches: %s", body)
latency = (timing or {}).get("latency_ms") or 0
ctx.check(0 < latency < 250, "capture to publication took %.1f ms while streaming", latency)
@test("camera.lid-privacy", title="Cameras see the room only with the lid closed", subsystem="camera",
kind="operator", est_min=3,
covers=_PRIVACY_COVERS + [("forgectrl", "src/auth.*")],
actions=["lid"],
steps=["Start with the lid closed; you will be told to open it, then close it again.",
"Nothing moves and the laser is not involved."],
description="The privacy gate: with the lid open the lid camera does not capture, and the head "
"camera captures only for a local viewer. A running lid-camera stream stops within a "
"frame or so of the lid opening, /cam/status reports capture as not allowed, and the "
"lid camera's snapshot and stream are refused with 409 and a reason naming the lid, "
"to the panel too. The head camera, which looks down at the bed, still answers the "
"panel (its token) and the extension host (its client header from this host), while "
"the same snapshot and stream asked without either - the cloud client's form - are "
"refused for the lid. Closing the lid restores all of it. This is what stops the "
"machine - and in cloud mode the Glowforge service - from imaging the room through "
"an open lid.")
def lid_privacy(ctx):
import urllib.error
import urllib.request
fc = ctx.forgectrl
ev = ctx.evidence
def status():
st, body = fc.get("/cam/status")
ctx.check(st == 200 and isinstance(body, dict), "GET /cam/status -> %s", st)
return body
def snapshot():
"""(status, body) for a half-res lid snapshot."""
st, data = fc.get("/cam/snapshot", params={"cam": "lid", "res": "half"}, raw=True)
return st, data or b""
# --- lid closed: the baseline the rest is measured against ----------
ctx.act("lid", "close")
body = status()
ctx.check(body.get("capture_allowed") is True,
"with the lid closed /cam/status should allow capture, got %r",
body.get("capture_allowed"))
st, data = snapshot()
ctx.check(st == 200 and data[:2] == b"\xff\xd8",
"snapshot with the lid closed -> %s", st)
ev["closed_snapshot_bytes"] = len(data)
ctx.log("lid closed: snapshot %d bytes", len(data))
# --- a live stream must die when the lid opens ----------------------
req = urllib.request.Request(fc.base + "/cam/stream?cam=lid",
headers={"Host": fc.host_header()})
stream = urllib.request.urlopen(req, timeout=15)
try:
first = stream.read(4096)
ctx.check(b"\xff\xd8" in first, "the stream did not start before the lid test")
ctx.act("lid", "open", text="The stream is running.")
# The engine tears the pipeline down on the next frame and the
# stream ends. Draining now returns whatever was buffered before
# the lid opened and then EOF; what must not happen is frames
# continuing to arrive indefinitely.
drained, ended = 0, False
try:
while True:
chunk = stream.read(65536)
if not chunk:
ended = True
break
drained += len(chunk)
ctx.check(drained < 8 * 1024 * 1024,
"the stream was still delivering after %d bytes with the lid open",
drained)
except Exception as e: # noqa: BLE001 - a reset connection ends it too
ended = True
ctx.log("stream ended with %s", type(e).__name__)
ev["stream_bytes_after_lid_open"] = drained
ctx.log("stream ended after %d further buffered bytes", drained)
ctx.check(ended, "the stream never ended after the lid opened")
finally:
stream.close()
# --- lid open: everything is refused, and says why ------------------
body = status()
ev["status_lid_open"] = body
ctx.check(body.get("capture_allowed") is False,
"with the lid open /cam/status should refuse capture, got %r",
body.get("capture_allowed"))
ctx.check(body.get("stopped_by_lid") is True,
"the engine should record that the lid stopped it, got %r",
body.get("stopped_by_lid"))
ctx.check(body.get("running") is False,
"the capture engine should not still be running with the lid open")
st, data = snapshot()
ev["snapshot_lid_open"] = [st, data[:120].decode("utf-8", "replace")]
ctx.log("lid open: snapshot -> %s %s", st, data[:120])
ctx.check(st == 409, "snapshot with the lid open should be refused with 409, got %s", st)
ctx.check(b"lid" in data.lower(), "the refusal should name the lid: %r", data[:120])
ctx.check(data[:2] != b"\xff\xd8", "a JPEG was returned with the lid open")
# The head camera looks at the bed: a local viewer still has it, the cloud client's form does not.
st, data = fc.get("/cam/snapshot", params={"cam": "head", "res": "half"}, raw=True)
ctx.log("lid open: the panel's head snapshot -> %s", st)
ctx.check(st == 200 and data[:2] == b"\xff\xd8", "the panel's head snapshot with the lid open -> %s", st)
st, data = fc.get("/cam/snapshot", params={"cam": "head", "res": "half"}, raw=True, auth=False,
headers={"X-ForgeFIRM-Client": "extension-host"})
ctx.log("lid open: the extension host's head snapshot -> %s", st)
ctx.check(st == 200 and data[:2] == b"\xff\xd8", "the extension host's head snapshot with the lid open -> %s", st)
st, data = fc.get("/cam/snapshot", params={"cam": "head", "res": "half"}, raw=True, auth=False)
ev["head_cloud_form_lid_open"] = [st, (data or b"")[:120].decode("utf-8", "replace")]
ctx.log("lid open: head snapshot without a token or the header -> %s %s", st, (data or b"")[:120])
ctx.check(st == 409 and b"lid" in (data or b"").lower(),
"the head camera without a token or the header should be refused for the lid, got %s", st)
try:
hreq = urllib.request.Request(fc.base + "/cam/stream?cam=head", headers={"Host": fc.host_header()})
with urllib.request.urlopen(hreq, timeout=10) as r:
ctx.fail("the head stream opened with the lid open, for no local viewer (%s)", r.status)
except urllib.error.HTTPError as e:
ctx.log("lid open: head stream for no local viewer -> %s", e.code)
ctx.check(e.code == 409, "the head stream for no local viewer should be refused with 409, got %s", e.code)
try:
with urllib.request.urlopen(req, timeout=10) as r:
ctx.fail("the stream opened with the lid open (%s)", r.status)
except urllib.error.HTTPError as e:
ctx.log("lid open: stream -> %s", e.code)
ctx.check(e.code == 409, "the stream should be refused with 409, got %s", e.code)
# --- closing the lid restores it -----------------------------------
ctx.act("lid", "close")
body = status()
ctx.check(body.get("capture_allowed") is True,
"closing the lid should allow capture again, got %r",
body.get("capture_allowed"))
st, data = snapshot()
ctx.check(st == 200 and data[:2] == b"\xff\xd8",
"snapshot after closing the lid -> %s", st)
ctx.log("lid closed again: snapshot %d bytes", len(data))
File diff suppressed because it is too large Load Diff
-402
View File
@@ -1,402 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""cooling.* - the cooling engine on the machine: the air-assist ground
shift on the coolant readings, flow verification through forgectrl's
diagnostics runner (the same check the fire gate runs) and under a lit
tube, and the fail tier ending the controller on the head's own move
inside an armed window. The engine's decisions on a reading a host can
emulate (the gate settings, the phases' fan duties, the TEC, the fire and
crash watches' tiers and gates) are forgectrl's cool_flow_test and
cool_gate_test."""
import os
import time
from ..catalog import test
from .. import hw
_COOL_COVERS = [("forgectrl", "src/cool.*"), ("forgectrl", "src/coolfmt.*"), ("forgectrl", "src/diag.*"),
("forgectrl", "src/gates.*"), ("forgectrl", "src/airflow.*"),
("forgectrl", "src/accel.*"),
("forgectrl", "src/settings.*"),
("forgectrl", "src/status.*"), ("forgectrl", "src/ui/**"),
("grblhal-glowforge", "src/glowforge_cooling.*"),
("kernel-module-glowforge", "src/thermal*"),
("kernel-module-glowforge", "src/pic*")]
# Registered before the heater tools: a queue runs the catalog in this
# order, and a heater trial leaves a warm slug circulating past the
# coolant sensors for minutes, which the tool's edges read as disagreement.
@test("cooling.aa-offset-calibrate", title="The air-assist ground shift on the coolant readings measures cleanly",
subsystem="cooling", kind="auto", est_min=6,
covers=_COOL_COVERS, requires=["kernel.latch-locked-idle"],
steps=["Coolant loop normal (pump on); the machine idle. The controller is suspended by "
"forgectrl for the duration; the tool first waits for a stationary loop (up to "
"7 minutes after a heater trial), then the air assist cycles three times."],
description="forgectrl's aa-offset-calibrate diagnostic: the loop settled to the flow "
"tools' stationary gate, then the air-assist fan stepped idle to run and back "
"three times with the tube dark and the heater off, both coolant sensors read "
"at every edge. The fan's return current shifts both readings by a number of "
"ADC counts the engine adds back (cool_aa_offset_counts). PASS = the tool "
"reports a recommendation, the six edges agree within its spread limit, and "
"the value sits inside the setting's legal range. The setting is not written.")
def aa_offset_calibrate(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
st, body = fc.get("/diag/status")
ctx.check(st == 200 and isinstance(body, dict), "GET /diag/status -> %s", st)
ctx.check(not body.get("running"), "a diagnostic is already running (%s)", body.get("tool"))
st, body = fc.post("/diag/aa-offset-calibrate")
ctx.log("POST /diag/aa-offset-calibrate -> %s %s", st, body if isinstance(body, dict) else "")
ctx.check(st == 202 and isinstance(body, dict) and body.get("started") is True,
"could not start aa-offset-calibrate (%s %s)", st, body)
result = None
last_phase = None
t0 = time.time()
try:
while time.time() - t0 < AA_CAL_TIMEOUT_S:
ctx.checkpoint()
st, d = fc.get("/diag/status")
if st == 200 and isinstance(d, dict):
if d.get("phase") != last_phase:
last_phase = d.get("phase")
ctx.log("phase: %s", last_phase)
if not d.get("running") and d.get("result") is not None:
result = d.get("result")
for line in d.get("log", [])[-10:]:
ctx.log(" diag: %s", line)
break
time.sleep(2)
except BaseException:
fc.post("/diag/abort")
raise
ctx.check(result is not None, "aa-offset-calibrate did not finish within %d s", AA_CAL_TIMEOUT_S)
ev["result"] = result
ctx.check("error" not in result, "aa-offset-calibrate error: %s", result.get("error"))
ctx.log("offset %s counts, spread %s, recommend %s, edges %s", result.get("offset_counts"),
result.get("spread_counts"), result.get("recommend"), result.get("steps"))
ctx.check("recommend" in result, "no recommendation in %s", result)
ctx.check(0 <= float(result["recommend"]) <= 60,
"recommendation %s outside the setting's range 0..60", result["recommend"])
ctx.check(fc.wait_idle(120, abort=ctx.aborted), "machine did not return to idle after the diagnostic")
@test("cooling.flow-verify", title="Coolant flow check separates flow from no-flow",
subsystem="cooling", kind="auto", est_min=4,
covers=_COOL_COVERS, requires=["kernel.latch-locked-idle"],
steps=["Coolant loop normal (pump on); the machine idle. The controller is suspended by "
"forgectrl for the duration (about 3 minutes)."],
description="forgectrl's flow-verify diagnostic: one heater window with the pump on and "
"one with it commanded off, judged against the configured threshold. PASS = "
"the threshold separates the two readings with the margins forgectrl reports; "
"a thin margin is recorded as a warning.")
def flow_verify(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
st, body = fc.get("/diag/status")
ctx.check(st == 200 and isinstance(body, dict), "GET /diag/status -> %s", st)
ctx.check(not body.get("running"), "a diagnostic is already running (%s)", body.get("tool"))
st, body = fc.post("/diag/flow-verify")
ctx.log("POST /diag/flow-verify -> %s %s", st, body if isinstance(body, dict) else "")
ctx.check(st == 202 and isinstance(body, dict) and body.get("started") is True,
"could not start flow-verify (%s %s)", st, body)
last_phase = None
result = None
t0 = time.time()
try:
while time.time() - t0 < 900:
ctx.checkpoint()
st, d = fc.get("/diag/status")
if st == 200 and isinstance(d, dict):
if d.get("phase") != last_phase:
last_phase = d.get("phase")
ctx.log("phase: %s (down %.1f C, up %.1f C)", last_phase, d.get("down_c", 0), d.get("up_c", 0))
if not d.get("running") and d.get("result") is not None:
result = d.get("result")
for line in d.get("log", [])[-12:]:
ctx.log(" diag: %s", line)
break
time.sleep(2)
except BaseException:
fc.post("/diag/abort")
raise
ctx.check(result is not None, "flow-verify did not finish within 15 minutes")
ev["result"] = result
ctx.check("error" not in result, "flow-verify error: %s", result.get("error"))
ctx.log("verdict: pass=%s threshold=%s flow_rise=%s noflow_rise=%s margins %s/%s thin=%s",
result.get("pass"), result.get("threshold"), result.get("flow_rise"),
result.get("noflow_rise"), result.get("margin_flow"), result.get("margin_noflow"),
result.get("thin_margin"))
ctx.check(result.get("pass") is True, "the threshold does not separate flow from no-flow: %s", result)
if result.get("thin_margin"):
ctx.log("WARNING: thin margin - run flow-calibrate")
ctx.check(fc.wait_idle(120, abort=ctx.aborted), "machine did not return to idle after the diagnostic")
AA_CAL_TIMEOUT_S = 540 # the tool's 420 s settle limit plus its six edges
def _cool(fc):
st, c = fc.get("/cool/status")
return c if st == 200 and isinstance(c, dict) else {}
def _gate_state(fc, key):
g = (fc.settings().get("gates") or {}).get(key) or {}
return g.get("state"), g.get("value")
def _set_gates(ctx, fc, values):
"""POST the gate settings and confirm the reply carries them."""
st, body = fc.post("/settings", params=values)
ctx.check(st == 200 and isinstance(body, dict), "POST /settings %s -> %s %s", values, st, body)
for k, v in values.items():
ctx.check(body.get(k) == v, "settings reply has %s=%r, posted %r", k, body.get(k), v)
return body
CRASH_KEYS = ("cool_accel_x_alert", "cool_accel_y_alert", "cool_accel_abort")
# The flow check under a lit tube. With a prompt press the tube is lit for
# most of the arm-time window, which adds about 1.5 C to the heater rise at
# full power; the engine takes that share off from the tube current before
# the limit (cool_laser_heat_*), reads its baseline and end as means, and
# says so on the verdict line: "verified (heater rise 11.3 C, dT 9.6 C;
# laser 0.8 off 12.0)". The share on the line is the proof the window and
# the fire overlapped; the judged rise inside the dark band is the proof
# the share was right.
LOAD_FEED = 1500 # mm/min: 25 mm/s, the engrave dose that does not char
LOAD_PITCH = 0.3 # mm between lines
LOAD_WIDTH = 30.0 # mm, the line length (+X)
LOAD_LINES = 27 # two 30 x 4 mm fills back to back, about 35 s lit
LOAD_SHARE_MIN_C = 0.3 # the tube was lit inside the window
LOAD_MARGIN_C = 1.0 # the judged rise this far under the limit
LOAD_VERDICT_WAIT_S = 120 # the window opens ~15 s into the session and runs 50 s
_LOAD_LINE_RX = None
def _load_verdict_rx():
global _LOAD_LINE_RX
if _LOAD_LINE_RX is None:
import re
_LOAD_LINE_RX = re.compile(
r"(COOLANT FLOW SUSPECT|COOLANT FLOW FAULT|coolant flow verified|"
r"coolant flow suspicion cleared|coolant flow recovered)[^\n]*?heater rise ([0-9.]+) C"
r"(?: \(limit ([0-9.]+))?, dT ([0-9.]+)(?: C)?(?:; laser ([0-9.]+) off ([0-9.]+))?\)?")
return _LOAD_LINE_RX
def _load_fill():
lines = []
for i in range(LOAD_LINES):
lines.append("G1 X%g F%d" % (LOAD_WIDTH if i % 2 == 0 else -LOAD_WIDTH, LOAD_FEED))
if i < LOAD_LINES - 1:
lines.append("G1 Y%g F%d" % (LOAD_PITCH, LOAD_FEED))
return lines
def _log_since(path, offset):
"""The text a log gained since byte `offset`. A count of lines in a
tail of fixed length cannot tell a new line from an old one: the new
line comes in at the bottom as an old one leaves at the top. A file now
shorter than the offset was rotated under the test, and all of it is
newer."""
try:
with open(path, "rb") as f:
if os.fstat(f.fileno()).st_size >= offset:
f.seek(offset)
return f.read().decode("utf-8", "replace")
except OSError:
return ""
@test("cooling.flow-under-load", title="The flow check reads true with the tube lit through its window",
subsystem="cooling", kind="live", mode="grbl", est_min=4,
covers=_COOL_COVERS + [("forgectrl", "src/main.c")],
requires=["laser.emission-witness", "cooling.flow-verify"], actions=["button"],
steps=["Scrap under the head with 35 mm of free +X and 10 mm of +Y travel; lid closed; exhaust on.",
"Press the physical button AS SOON AS it lights white: the check must run while the "
"tube is lit, and the share on the verdict line is the proof it did."],
description="Two 30 x 4 mm fills at full power on the press (about 35 s lit), the window "
"held open until the engine's flow verdict lands. The verdict must be "
"'verified' with the laser's share on the line (at least 0.3 C: the tube was "
"lit inside the window) and the judged rise at least 1 C under the limit, "
"where the same check read within tenths of the limit before the engine took "
"the tube's share off and read its baseline as a mean.")
def flow_under_load(ctx):
from .laser import sample, prepare, LiveJob, ARM_CUE, run_and_sample, wait_disarm
from .motion import FORGECTRL_LOG, _log_offset
fc = ctx.forgectrl
ev = ctx.evidence
state, check_s = _gate_state(fc, "cool_flow_check_s")
_st, limit = _gate_state(fc, "cool_flow_rise")
ctx.check(state != "off" and check_s, "the flow check is off (cool_flow_check_s=%s)", check_s)
ctx.check(limit, "cool_flow_rise unreadable")
ev.update({"check_s": check_s, "limit_c": limit})
off = _log_offset(FORGECTRL_LOG) # the verdict is a line written after this
# The fill ends one line length out and (LOAD_LINES - 1) pitches up;
# the job brings the head back so the baseline finds it where it began.
back = "G0 X%g Y%g" % (-LOAD_WIDTH if LOAD_LINES % 2 else 0.0, -LOAD_PITCH * (LOAD_LINES - 1))
job = ["G91", "G21", "M3 S1000"] + _load_fill() + ["M5", back]
line = None
heater_seen = False
with ctx.grbl() as g, LiveJob(ctx, g):
prepare(ctx, g)
base = sample(ctx)
ctx.check(base, "forgectrl /status or /cool/status unavailable")
ctx.check(not base["emission"], "emission_samples nonzero before the job (%s)", base["emission"])
ctx.ready(ARM_CUE % "35 mm +X and 10 mm +Y")
ctx.arm_press()
t_job = time.time()
try:
samples = run_and_sample(ctx, g, job, overall_timeout=240)
finally:
ctx.clear_notice()
emis = [s["emission"] for s in samples if s["emission"] is not None]
ev["emission_peak"] = max(emis) if emis else 0
# An arm refused by a gate names itself: the engine's verdict left OK
# during the wait (AIRFLOW, OVERTEMP, a flow FAULT).
gates = sorted({s["verdict"] for s in samples if s.get("verdict") not in (None, "OK")})
ev["verdicts_seen"] = gates
ctx.check(ev["emission_peak"] > 0,
"no emission witnessed: %s" % ("the engine held the run (%s) before the arm"
% ", ".join(gates) if gates
else "arm refused, or no button press"))
# The window stays open (no M2) until the check has judged.
t0 = time.time()
while time.time() - t0 < LOAD_VERDICT_WAIT_S:
ctx.checkpoint()
if hw.sysfs_int("thermal/heater_pwm", 0) > 0:
heater_seen = True
m = None
for m in _load_verdict_rx().finditer(_log_since(FORGECTRL_LOG, off)):
pass
if m:
line = m.group(0)
break
ctx.sleep(1)
ev["heater_seen"] = heater_seen
ctx.log("M2: %s", g.command("G90\nM2", timeout=10))
dt = wait_disarm(ctx, 30)
ev["disarm_after_m2_s"] = round(dt, 1) if dt is not None else None
ctx.check(line, "the engine published no flow verdict within %d s of the job (heater seen: %s)",
LOAD_VERDICT_WAIT_S, heater_seen)
m = _load_verdict_rx().search(line)
kind, rise, _lim, dtc, share, raw = m.groups()
rise = float(rise)
share = float(share) if share else 0.0
raw = float(raw) if raw else rise
ev.update({"verdict_line": line, "kind": kind, "rise_c": rise, "dt_c": float(dtc),
"laser_share_c": share, "raw_rise_c": raw, "job_to_verdict_s": round(time.time() - t_job, 1)})
ctx.log("engine: %s", line)
ctx.check(kind == "coolant flow verified", "the check did not verify flow under load: %s", line)
ctx.check(share >= LOAD_SHARE_MIN_C,
"the tube's share on the line is %.1f C (under %.1f): the window and the fire did not "
"overlap - press the button as soon as it lights", share, LOAD_SHARE_MIN_C)
ctx.check(rise <= float(limit) - LOAD_MARGIN_C,
"judged rise %.1f C is within %.1f C of the %.1f C limit", rise, LOAD_MARGIN_C, float(limit))
ctx.check(dt is not None, "the window did not close after M2")
ctx.log("PASS: lit through the window, raw rise %.1f C, laser %.1f C off, judged %.1f C against %.1f "
"(dT %.1f), window closed %.1f s after M2", raw, share, rise, float(limit), float(dtc), dt)
@test("cooling.fail-tier-stop", title="A fail-tier verdict ends the controller: CRASH stops motion, "
"locks the laser, and the supervisor restarts the controller",
subsystem="cooling", kind="operator", mode="grbl", est_min=4,
covers=_COOL_COVERS + [("forgectrl", "src/super.*")],
requires=["motion.deadman"], actions=["button"],
steps=["Bed clear, lid closed, 40 mm of free +X travel. Nothing fires: the job is dark (S0).",
"Press the physical button when it lights white, once. The head moves and stops on "
"its own; the controller restarts."],
description="The engine's fail tiers (a lid IR fire signal, a head crash signal) do not "
"leave the job to the controller: after the kernel writes (motion stopped, the "
"latch locked) the engine ends the controller through the supervisor, which "
"starts it again, so no run start can relight what was locked and the sender "
"sees the job end. Provable without emission or a physical crash: the crash "
"watch's thresholds at their lowest (1) make the head's own move trip the abort "
"generator inside an armed, dark (S0) job. Expected: the engine logs the crash "
"signal, the supervisor logs the stop and starts a new controller (new pid), the "
"latch is locked, the kernel idle, no emission, the crash fault ends with the run "
"session (the engine's next tick after the controller is gone), and the "
"thresholds are put back.")
def fail_tier_stop(ctx):
from .laser import prepare, stream, sample
from .motion import FORGECTRL_LOG, _log_lines, _log_offset
fc = ctx.forgectrl
ev = ctx.evidence
before = fc.settings()
orig = {k: before.get(k, "") for k in CRASH_KEYS}
ev["orig"] = orig
ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle at the start")
st, m0 = fc.get("/mode")
ctx.check(st == 200 and isinstance(m0, dict) and m0.get("controller") == "running", "controller not running: %s", m0)
pid0 = m0.get("pid")
_set_gates(ctx, fc, {k: "1" for k in CRASH_KEYS})
off = _log_offset(FORGECTRL_LOG)
t_arm = None
try:
try:
with ctx.grbl() as g:
prepare(ctx, g)
ctx.ready("DARK JOB (S0, nothing fires). Bed clear with 40 mm of free +X travel, lid closed. "
"The head moves 20 mm and the controller restarts on its own.")
stream(g, ["G91", "G21", "M3 S0", "G4 P1", "G1 X20 F1200", "G1 X-20 F1200", "M5", "G90", "M2"])
ctx.arm_press()
t_arm = ctx.wait_for(lambda: bool((sample(ctx) or {}).get("armed")), 240)
ctx.clear_notice()
ctx.check(t_arm is not None, "the window never opened (arm refused, or no press)")
t0 = time.time()
tripped = ctx.wait_for(lambda: _cool(fc).get("verdict") == "CRASH"
or (fc.get("/mode")[1] or {}).get("pid") not in (None, pid0), 30)
ctx.log("the crash tier tripped after %s s", tripped)
except (hw.HwError, OSError) as e:
ctx.log("the Grbl connection ended with the restart: %s", e)
t_kill = time.time()
m1 = None
while time.time() - t_kill < 60:
st, m1 = fc.get("/mode")
if isinstance(m1, dict) and m1.get("controller") == "running" and m1.get("pid") != pid0:
break
ctx.sleep(0.5)
# the daemon's lines reach the log file through syslog, a moment
# after the events they name
ctx.wait_for(lambda: bool(_log_lines(FORGECTRL_LOG, off, "HEAD CRASH SIGNAL"))
and bool(_log_lines(FORGECTRL_LOG, off, "head crash signal - the controller is stopped")), 15)
engine = _log_lines(FORGECTRL_LOG, off, "HEAD CRASH SIGNAL")
super_ = _log_lines(FORGECTRL_LOG, off, "head crash signal - the controller is stopped")
smp = sample(ctx)
ilk = hw.sysfs_int("cnc/interlock_circuit")
ev["result"] = {"mode_after": m1, "engine_lines": [ln[-160:] for ln in engine[-2:]],
"super_lines": [ln[-160:] for ln in super_[-2:]],
"emission": smp and smp["emission"], "kernel": smp and smp["kstate"],
"latch_locked": ilk is not None and bool(ilk & (1 << 3))}
ctx.log("after the crash tier: %s", ev["result"])
ctx.check(engine, "the engine did not log the crash signal")
ctx.check(super_, "the supervisor did not log the fail-tier stop")
ctx.check(m1 and m1.get("pid") != pid0, "the controller was not stopped and started again: %s", m1)
ctx.check(ev["result"]["latch_locked"], "latch not locked after the crash tier")
ctx.check(not (smp and smp["emission"]), "emission during a dark job: %s", smp and smp["emission"])
ctx.check(fc.wait_idle(30, abort=ctx.aborted), "machine not idle after the restart")
# The crash fault is the run session's: the engine ends the session
# on its next 1 Hz tick after the controller is gone, and the fault
# and its hold go with it. The supervisor has the new controller up
# inside that second, so a test that returned here would hand the
# machine back with the fault it raised still standing.
cleared = ctx.wait_for(lambda: _cool(fc).get("verdict") != "CRASH", 10)
ev["result"]["fault_cleared_s"] = cleared
ctx.log("the crash fault ended with the run session after %s s: %s", cleared,
{k: _cool(fc).get(k) for k in ("phase", "verdict", "hold", "armed")})
ctx.check(cleared is not None, "the crash fault did not end with the run session: %s", _cool(fc))
finally:
fc.wait_idle(30, abort=ctx.aborted)
st, body = fc.post("/settings", params=orig)
ctx.log("restore thresholds: POST /settings %s -> %s", orig, st)
after = fc.settings()
ctx.check(all(after.get(k, "") == orig[k] for k in CRASH_KEYS),
"thresholds not restored: %s", {k: after.get(k) for k in CRASH_KEYS})
ctx.log("PASS: the crash tier stopped the job, locked the latch, and the supervisor restarted "
"the controller (pid %s -> %s)", pid0, m1 and m1.get("pid"))
-985
View File
@@ -1,985 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""exthost.* - what holds an extension package: the image's sandbox platform."""
import json
import os
import re
import signal
import subprocess
import sys
import time
from ..catalog import test
from .forgectrl import lan_ip
from .image import kernel_config
from .cloud import OFFLINE_STEP, Offline, dark_print_body, enter_offline, latch_locked, offline_cleanup, offline_job
from .setup import SAFETY_PHRASE, read_file, record_path, request, write_file
CG = "/sys/fs/cgroup"
POOL_GROUP = CG + "/ffx"
PROBE_GROUP = POOL_GROUP + "/forgetest-probe"
POOL_FIRST, POOL_SIZE = 800, 32
CONTROLLERS = ("cpu", "memory", "pids")
NFT = "/usr/sbin/nft"
# The probe: one short program, started as root. It puts itself into the
# group named in argv, becomes the uid named there (0 stays root), gives up
# new privileges, and then does one thing and prints one JSON line.
PROBE = r'''
import ctypes, errno, json, os, platform, socket, sys, time
group, uid, mode, arg = sys.argv[1], int(sys.argv[2]), sys.argv[3], sys.argv[4]
libc = ctypes.CDLL(None, use_errno=True)
if group != "-":
with open(group + "/cgroup.procs", "w") as f:
f.write(str(os.getpid()))
if uid:
os.setgroups([])
os.setgid(uid)
os.setuid(uid)
libc.prctl(38, 1, 0, 0, 0) # PR_SET_NO_NEW_PRIVS
def say(**kw):
print(json.dumps(kw), flush=True)
def word(e):
return {errno.ECONNREFUSED: "refused", errno.EPERM: "eperm", errno.EACCES: "eacces"}.get(
e.errno, errno.errorcode.get(e.errno, str(e.errno)))
def reach(family, addr, port, udp):
s = socket.socket(socket.AF_INET6 if family == 6 else socket.AF_INET,
socket.SOCK_DGRAM if udp else socket.SOCK_STREAM)
s.settimeout(4.0)
t0 = time.time()
try:
if udp:
s.sendto(b"x", (addr, port))
else:
s.connect((addr, port))
out = "ok"
except socket.timeout:
out = "timeout"
except OSError as e:
out = word(e)
s.close()
return [out, round(time.time() - t0, 2)]
if mode == "spin":
say(started=True)
while True:
pass
elif mode == "forks":
kids = []
err = ""
for _ in range(int(arg)):
try:
pid = os.fork()
except OSError as e:
err = word(e)
break
if pid == 0:
time.sleep(30)
os._exit(0)
kids.append(pid)
for pid in kids:
os.kill(pid, 9)
os.waitpid(pid, 0)
say(forked=len(kids), stopped_by=err)
elif mode == "eat":
say(started=True)
held = []
for _ in range(int(arg)):
held.append(bytearray(os.urandom(4096)) * 256) # 1 MiB, every page written
say(survived=True)
elif mode == "net":
say(results=[[t, reach(*t)] for t in json.loads(arg)])
elif mode == "unix":
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(4.0)
try:
s.connect(arg)
say(result="connected")
except OSError as e:
say(result=word(e))
s.close()
elif mode == "landlock":
target = json.loads(arg)
before = {"etc": None, "usr": None, "tcp": reach(*target)[0]}
for key, path in (("etc", "/etc/hostname"), ("usr", sys.executable)):
try:
open(path, "rb").close()
before[key] = "ok"
except OSError as e:
before[key] = word(e)
abi = libc.syscall(444, None, 0, 1) # LANDLOCK_CREATE_RULESET_VERSION
after = {}
if abi >= 1:
# write, read file, read dir; and from ABI 4 on, connect TCP (an older kernel takes the first field alone)
attr = (ctypes.c_uint64 * 2)((1 << 1) | (1 << 2) | (1 << 3), 1 << 1)
ruleset = libc.syscall(444, ctypes.byref(attr), 16 if abi >= 4 else 8, 0)
usr = os.open("/usr", os.O_PATH | os.O_CLOEXEC)
class PathBeneath(ctypes.Structure):
_pack_ = 1
_fields_ = [("allowed_access", ctypes.c_uint64), ("parent_fd", ctypes.c_int32)]
rule = PathBeneath((1 << 2) | (1 << 3), usr)
added = libc.syscall(445, ruleset, 1, ctypes.byref(rule), 0) # LANDLOCK_RULE_PATH_BENEATH
applied = libc.syscall(446, ruleset, 0)
after = {"ruleset": ruleset >= 0, "added": added, "applied": applied, "tcp": reach(*target)[0]}
for key, path in (("etc", "/etc/hostname"), ("usr", sys.executable)):
try:
open(path, "rb").close()
after[key] = "ok"
except OSError as e:
after[key] = word(e)
say(abi=abi, before=before, after=after)
elif mode == "seccomp":
arch, nr = {"armv7l": (0x40000028, 122), "aarch64": (0xC00000B7, 160),
"x86_64": (0xC000003E, 63)}[platform.machine()] # uname
class Filter(ctypes.Structure):
_fields_ = [("code", ctypes.c_uint16), ("jt", ctypes.c_uint8), ("jf", ctypes.c_uint8),
("k", ctypes.c_uint32)]
class Prog(ctypes.Structure):
_fields_ = [("len", ctypes.c_ushort), ("filter", ctypes.POINTER(Filter))]
before = os.uname().sysname
ops = (Filter * 7)(Filter(0x20, 0, 0, 4), # load arch
Filter(0x15, 1, 0, arch), # ours: go on
Filter(0x06, 0, 0, 0x80000000), # another ABI: kill the process
Filter(0x20, 0, 0, 0), # load the syscall number
Filter(0x15, 0, 1, nr), # uname?
Filter(0x06, 0, 0, 0x00050000 | errno.EPERM),
Filter(0x06, 0, 0, 0x7FFF0000)) # everything else: allow
prog = Prog(7, ops)
rc = libc.prctl(22, 2, ctypes.byref(prog), 0, 0) # PR_SET_SECCOMP, SECCOMP_MODE_FILTER
try:
os.uname()
after = "ok"
except OSError as e:
after = word(e)
mode_now = [l.split()[1] for l in open("/proc/self/status") if l.startswith("Seccomp:")]
say(installed=rc, before=before, after=after, status=mode_now)
'''
def _read(path, default=""):
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
return f.read()
except OSError:
return default
def _write(path, text):
with open(path, "w") as f:
f.write(text)
def _flat(path):
"""A cgroup flat-keyed file as {key: int}."""
out = {}
for line in _read(path).splitlines():
parts = line.split()
if len(parts) == 2 and parts[1].isdigit():
out[parts[0]] = int(parts[1])
return out
def _probe(group, uid, mode, arg="", wait=30):
"""Runs the probe to its end; (returncode, its JSON lines)."""
p = subprocess.run([sys.executable, "-c", PROBE, group, str(uid), mode, arg],
capture_output=True, text=True, timeout=wait)
lines = []
for line in p.stdout.splitlines():
try:
lines.append(json.loads(line))
except ValueError:
pass
return p.returncode, lines, p.stderr.strip()[-300:]
def _pids_of(comm):
out = []
for d in os.listdir("/proc"):
if d.isdigit() and _read("/proc/%s/comm" % d).strip() == comm:
out.append(int(d))
return out
def _pool_counters():
"""The packet counts of chain refuse's two rules: [tcp reset, drop]."""
p = subprocess.run([NFT, "-j", "list", "chain", "inet", "ffx", "refuse"], capture_output=True, text=True)
if p.returncode != 0:
return None
return [e["counter"]["packets"] for c in json.loads(p.stdout)["nftables"] if "rule" in c
for e in c["rule"]["expr"] if "counter" in e]
@test("exthost.platform", title="The extension sandbox platform holds a process",
subsystem="exthost", kind="auto", est_min=2,
covers=[("linux-fslc", "**")],
description="What the image holds ready before any extension package exists, proven on a "
"probe process rather than read off a config. The kernel has the cpu, memory, "
"and pids controllers, seccomp filters, and landlock, and has no realtime "
"group scheduler (the pulse feeder is SCHED_FIFO and must not need a group's "
"leave to run). cgroup v2 is mounted with the three controllers handed down "
"to /sys/fs/cgroup/ffx, which is idle-class, and forgectrl and the controller "
"sit in the root group. In a probe group under it, as the last pool uid: a "
"spinning process is held to its cpu.max, stops when the group is frozen and "
"runs again when thawed; a fork loop stops at pids.max; and a process that "
"takes more than memory.max is killed by the group's own OOM while forgectrl "
"keeps its pid. The account pool is ffx0 to ffx31, uid and gid 800 to 831, no "
"shell, no home, locked, in the account files as forgefirm-users rendered "
"them. The deny rules are in the kernel: a pool uid's TCP connect to "
"forgectrl on loopback (both ports, IPv4 and IPv6), to the machine's LAN "
"address, and to the Grbl port is refused at once, its UDP send gets EPERM, "
"and the rules' counters saw every attempt, while root reaches the same "
"listeners. A process that restricts itself with landlock loses /etc and TCP "
"connects and keeps /usr; one that installs a seccomp filter gets EPERM from "
"the filtered call. The layer content (the recipe, the rules, the distro "
"option) is in the platform identity of every fingerprint.")
def platform(ctx):
ev = ctx.evidence
# 1. the kernel
cfg = kernel_config()
ctx.check(cfg is not None, "kernel config unavailable (/proc/config.gz, /boot/config-*)")
want = {"CONFIG_CGROUPS": "y", "CONFIG_CGROUP_SCHED": "y", "CONFIG_FAIR_GROUP_SCHED": "y",
"CONFIG_CFS_BANDWIDTH": "y", "CONFIG_MEMCG": "y", "CONFIG_CGROUP_PIDS": "y",
"CONFIG_SECCOMP_FILTER": "y", "CONFIG_SECURITY_LANDLOCK": "y", "CONFIG_NF_TABLES": "y",
"CONFIG_NF_TABLES_INET": "y", "CONFIG_NFT_REJECT_INET": "y", "CONFIG_NFT_LIMIT": "y",
"CONFIG_RT_GROUP_SCHED": "n", "CONFIG_NF_CONNTRACK": "n"}
got = {k: cfg.get(k, "n") for k in want}
ev["kernel"] = got
wrong = {k: v for k, v in got.items() if v != want[k]}
ctx.check(not wrong, "kernel options: %s (wanted %s)", wrong, {k: want[k] for k in wrong})
ctx.check("landlock" in cfg.get("CONFIG_LSM", ""), "landlock is not in CONFIG_LSM: %s", cfg.get("CONFIG_LSM"))
# 2. the cgroup tree
mounts = [l.split() for l in _read("/proc/mounts").splitlines()]
ctx.check(any(m[1] == CG and m[2] == "cgroup2" for m in mounts if len(m) > 2), "cgroup2 is not mounted at %s", CG)
tree = {"controllers": _read(CG + "/cgroup.controllers").split(),
"root_subtree": _read(CG + "/cgroup.subtree_control").split(),
"ffx_subtree": _read(POOL_GROUP + "/cgroup.subtree_control").split(),
"ffx_cpu_idle": _read(POOL_GROUP + "/cpu.idle").strip()}
ev["cgroup_tree"] = tree
ctx.log("cgroup tree: %s", tree)
for key in ("controllers", "root_subtree", "ffx_subtree"):
ctx.check(set(CONTROLLERS) <= set(tree[key]), "%s lacks a controller: %s", key, tree[key])
ctx.check(tree["ffx_cpu_idle"] == "1", "the ffx group is not idle-class: cpu.idle=%r", tree["ffx_cpu_idle"])
homes = {}
for comm in ("forgectrl", "grblHAL_glowfor", "forgetest"):
for pid in _pids_of(comm):
homes["%s/%d" % (comm, pid)] = _read("/proc/%d/cgroup" % pid).strip()
ev["firmware_groups"] = homes
ctx.check(any(k.startswith("forgectrl/") for k in homes), "forgectrl is not running")
ctx.check(all(v == "0::/" for v in homes.values()), "a firmware process is outside the root group: %s", homes)
# 3. the account pool, as the boot's render left it
passwd = {l.split(":")[0]: l.split(":") for l in _read("/etc/passwd").splitlines() if l.count(":") >= 6}
shadow = {l.split(":")[0]: l.split(":") for l in _read("/etc/shadow").splitlines() if ":" in l}
group = {l.split(":")[0]: l.split(":") for l in _read("/etc/group").splitlines() if l.count(":") >= 3}
bad = []
for n in range(POOL_SIZE):
name, uid = "ffx%d" % n, str(POOL_FIRST + n)
p, s, g = passwd.get(name), shadow.get(name), group.get(name)
if not p or p[2] != uid or p[3] != uid or p[5] != "/nonexistent" or p[6] != "/bin/false":
bad.append("passwd %s: %s" % (name, p))
if not s or s[1][:1] not in ("!", "*"):
bad.append("shadow %s is not locked" % name)
if not g or g[2] != uid or g[3] != "":
bad.append("group %s: %s" % (name, g))
others = [p[0] for p in passwd.values() if p[2].isdigit()
and POOL_FIRST <= int(p[2]) < POOL_FIRST + POOL_SIZE and not re.match(r"^ffx\d+$", p[0])]
ev["pool"] = {"accounts": sum(1 for n in passwd if re.match(r"^ffx\d+$", n)), "problems": bad, "others": others}
ctx.check(not bad, "the account pool: %s", bad[:6])
ctx.check(not others, "another account sits in the pool's uid range: %s", others)
ctx.check(ev["pool"]["accounts"] == POOL_SIZE, "%d ffx accounts, expected %d", ev["pool"]["accounts"], POOL_SIZE)
uid = POOL_FIRST + POOL_SIZE - 1
fc_pid = _pids_of("forgectrl")
spin = None
try:
# 4. a probe group holds a process: cpu.max, the freezer, pids.max, memory.max
os.makedirs(PROBE_GROUP, exist_ok=True)
_write(PROBE_GROUP + "/cpu.max", "5000 100000")
_write(PROBE_GROUP + "/pids.max", "6")
_write(PROBE_GROUP + "/memory.max", str(24 << 20))
if os.path.exists(PROBE_GROUP + "/memory.swap.max"):
_write(PROBE_GROUP + "/memory.swap.max", "0")
spin = subprocess.Popen([sys.executable, "-c", PROBE, PROBE_GROUP, str(uid), "spin", ""],
stdout=subprocess.PIPE, text=True)
ctx.check("started" in (spin.stdout.readline() or ""), "the spinning probe did not start")
ctx.check(_read("/proc/%d/cgroup" % spin.pid).strip() == "0::/ffx/forgetest-probe",
"the probe is in %r", _read("/proc/%d/cgroup" % spin.pid).strip())
ctx.check("Uid:\t%d\t%d" % (uid, uid) in _read("/proc/%d/status" % spin.pid), "the probe is not uid %d", uid)
ctx.sleep(1.0)
u0, t0 = _flat(PROBE_GROUP + "/cpu.stat")["usage_usec"], time.time()
ctx.sleep(3.0)
u1, t1 = _flat(PROBE_GROUP + "/cpu.stat")["usage_usec"], time.time()
share = (u1 - u0) / 1e6 / (t1 - t0)
throttled = _flat(PROBE_GROUP + "/cpu.stat").get("nr_throttled", 0)
ctx.log("a spinning probe under cpu.max 5%%: %.1f%% of the core, throttled %d times", share * 100, throttled)
ctx.check(0.01 < share < 0.08 and throttled > 0, "cpu.max 5%% held the probe to %.1f%% (throttled %d)",
share * 100, throttled)
_write(PROBE_GROUP + "/cgroup.freeze", "1")
ok = ctx.wait_for(lambda: _flat(PROBE_GROUP + "/cgroup.events").get("frozen") == 1, 3, poll=0.05)
ctx.check(ok is not None, "the group did not freeze: %s", _flat(PROBE_GROUP + "/cgroup.events"))
# The kernel reports a task frozen before its last way into
# schedule() is charged, so the window opens once that has landed.
ctx.sleep(0.2)
f0 = _flat(PROBE_GROUP + "/cpu.stat")["usage_usec"]
ctx.sleep(1.5)
f1 = _flat(PROBE_GROUP + "/cpu.stat")["usage_usec"]
_write(PROBE_GROUP + "/cgroup.freeze", "0")
ok = ctx.wait_for(lambda: _flat(PROBE_GROUP + "/cgroup.events").get("frozen") == 0, 3, poll=0.05)
ctx.check(ok is not None, "the group did not thaw")
ctx.sleep(1.5)
f2 = _flat(PROBE_GROUP + "/cpu.stat")["usage_usec"]
ctx.log("frozen for 1.5 s the probe ran %d us; thawed for 1.5 s it ran %d us", f1 - f0, f2 - f1)
ctx.check(f1 - f0 == 0, "a frozen probe ran %d us", f1 - f0)
ctx.check(f2 - f1 > 10000, "a thawed probe ran only %d us", f2 - f1)
_write(PROBE_GROUP + "/cgroup.kill", "1")
spin.wait(timeout=5)
ctx.check(spin.returncode == -signal.SIGKILL, "cgroup.kill left the probe with %s", spin.returncode)
spin = None
ev["cpu"] = {"share": round(share, 4), "throttled": throttled, "frozen_us": f1 - f0, "thawed_us": f2 - f1}
rc, lines, err = _probe(PROBE_GROUP, uid, "forks", "12")
forks = lines[-1] if lines else {}
hits = _flat(PROBE_GROUP + "/pids.events").get("max", 0)
ev["pids"] = {"result": forks, "events_max": hits}
ctx.log("pids.max 6: the fork loop made %s of 12, stopped by %s; pids.events max %d",
forks.get("forked"), forks.get("stopped_by"), hits)
ctx.check(rc == 0 and forks.get("forked") == 5 and forks.get("stopped_by") == "EAGAIN" and hits > 0,
"pids.max 6 did not stop the fork loop at 5: rc %s %s %s, events max %d", rc, forks, err, hits)
rc, lines, err = _probe(PROBE_GROUP, uid, "eat", "96", wait=60)
kills = _flat(PROBE_GROUP + "/memory.events").get("oom_kill", 0)
ev["memory"] = {"rc": rc, "lines": lines, "oom_kill": kills, "peak": _read(PROBE_GROUP + "/memory.peak").strip()}
ctx.log("memory.max 24 MiB against a 96 MiB appetite: rc %s, oom_kill %d, peak %s", rc, kills, ev["memory"]["peak"])
ctx.check(rc == -signal.SIGKILL and kills > 0 and not any(l.get("survived") for l in lines),
"memory.max did not kill the probe: rc %s, oom_kill %d, %s %s", rc, kills, lines, err)
ctx.check(_pids_of("forgectrl") == fc_pid, "forgectrl's pid moved across the group's OOM: %s -> %s",
fc_pid, _pids_of("forgectrl"))
# 5. the deny rules
p = subprocess.run([NFT, "list", "table", "inet", "ffx"], capture_output=True, text=True)
ctx.check(p.returncode == 0, "table inet ffx is not loaded: %s", p.stderr.strip()[:200])
ev["rules"] = p.stdout
ctx.check("meta skuid %d-%d jump pool" % (POOL_FIRST, uid) in p.stdout and "hook output" in p.stdout
and "policy accept" in p.stdout, "table inet ffx is not the image's: %s", p.stdout[:400])
pool = p.stdout[p.stdout.find("chain pool"):].split("}")[0]
ctx.check(0 <= pool.find('oifname "lo" jump refuse') < pool.find("vmap @allow"),
"the machine itself is not refused ahead of the allow map: %s", pool)
lan = lan_ip()
ctx.check(lan, "the machine has no LAN address to aim at")
tcp = [[4, "127.0.0.1", 443, False], [4, "127.0.0.1", 80, False], [6, "::1", 443, False],
[6, "::1", 23, False], [4, lan, 443, False], [4, lan, 23, False]]
udp = [[4, "127.0.0.1", 9, True], [4, lan, 9, True]]
control = [[4, "127.0.0.1", 443, False], [4, lan, 443, False], [4, "127.0.0.1", 9, True]]
rc, lines, err = _probe("-", 0, "net", json.dumps(control))
as_root = (lines[-1] if lines else {}).get("results", [])
ev["as_root"] = as_root
ctx.check(len(as_root) == len(control) and all(r[1][0] == "ok" for r in as_root),
"root does not reach its own listeners: %s %s", as_root, err)
c0 = _pool_counters()
heard = {}
for who in (POOL_FIRST, uid):
rc, lines, err = _probe("-", who, "net", json.dumps(tcp + udp))
res = (lines[-1] if lines else {}).get("results", [])
heard[str(who)] = res
ctx.check(len(res) == len(tcp + udp), "the probe as uid %d said %s %s", who, lines, err)
for target, (word, took) in res:
if target[3]:
ctx.check(word == "eperm", "uid %d UDP to %s -> %s", who, target[1], word)
else:
ctx.check(word == "refused" and took < 1.5, "uid %d TCP to %s port %d -> %s in %.2f s",
who, target[1], target[2], word, took)
c1 = _pool_counters()
ev["as_pool_uid"] = heard
ev["counters"] = [c0, c1]
ctx.log("two pool uids: %d TCP connects each refused at once, %d UDP sends each EPERM; the rules "
"counted %s -> %s", len(tcp), len(udp), c0, c1)
ctx.check(c0 is not None and c1 is not None and len(c1) == 2 and c1[0] - c0[0] >= 2 * len(tcp)
and c1[1] - c0[1] >= 2 * len(udp), "the rules did not count the attempts: %s -> %s", c0, c1)
# The machine itself is not a destination, whatever the allow map says: an
# allow chain that names forgectrl on loopback and on the LAN address
# opens neither.
chain = "u%d" % uid
rules = ["add chain inet ffx %s" % chain,
"add rule inet ffx %s ip daddr { 127.0.0.1, %s } tcp dport 443 accept" % (chain, lan),
"add element inet ffx allow { %d : jump %s }" % (uid, chain), ""]
opened = subprocess.run([NFT, "-f", "-"], capture_output=True, text=True, input=os.linesep.join(rules))
try:
ctx.check(opened.returncode == 0, "could not add an allow chain: %s", opened.stderr.strip()[:200])
rc, lines, err = _probe("-", uid, "net", json.dumps([[4, "127.0.0.1", 443, False], [4, lan, 443, False]]))
res = (lines[-1] if lines else {}).get("results", [])
ev["allowlisted_self"] = res
ctx.log("uid %d with the machine's own addresses on its allowlist: %s", uid, res)
ctx.check(len(res) == 2 and all(r[1][0] == "refused" for r in res),
"an allowlist opened the machine itself to a pool uid: %s %s", res, err)
finally:
subprocess.run([NFT, "delete", "element", "inet", "ffx", "allow", "{ %d }" % uid], capture_output=True)
subprocess.run([NFT, "flush", "chain", "inet", "ffx", chain], capture_output=True)
subprocess.run([NFT, "delete", "chain", "inet", "ffx", chain], capture_output=True)
left = subprocess.run([NFT, "list", "chain", "inet", "ffx", chain], capture_output=True)
ctx.check(left.returncode != 0, "the test's allow chain stayed behind")
# 6. landlock and seccomp, on a root process so that neither the uid nor the rules explain the refusal
rc, lines, err = _probe("-", 0, "landlock", json.dumps([4, "127.0.0.1", 443, False]))
ll = lines[-1] if lines else {}
ev["landlock"] = ll
ctx.log("landlock ABI %s: before %s, after %s", ll.get("abi"), ll.get("before"), ll.get("after"))
ctx.check(rc == 0 and ll.get("abi", 0) >= 4, "landlock ABI %s (4 brings the TCP rules) %s", ll.get("abi"), err)
ctx.check(ll["before"] == {"etc": "ok", "usr": "ok", "tcp": "ok"}, "before the restriction: %s", ll["before"])
after = ll["after"]
ctx.check(after.get("ruleset") and after.get("added") == 0 and after.get("applied") == 0,
"the ruleset did not apply: %s", after)
ctx.check(after.get("etc") == "eacces" and after.get("tcp") == "eacces" and after.get("usr") == "ok",
"after the restriction (/etc and TCP lost, /usr kept): %s", after)
rc, lines, err = _probe("-", 0, "seccomp")
sc = lines[-1] if lines else {}
ev["seccomp"] = sc
ctx.log("seccomp: %s", sc)
ctx.check(rc == 0 and sc.get("installed") == 0 and sc.get("before") == "Linux" and sc.get("after") == "eperm"
and sc.get("status") == ["2"], "a seccomp filter did not take: rc %s %s %s", rc, sc, err)
finally:
if spin is not None:
spin.kill()
spin.wait(timeout=5)
if os.path.isdir(PROBE_GROUP):
try:
_write(PROBE_GROUP + "/cgroup.freeze", "0")
_write(PROBE_GROUP + "/cgroup.kill", "1")
except OSError:
pass
for _ in range(50):
if not _read(PROBE_GROUP + "/cgroup.procs").strip():
break
time.sleep(0.1)
try:
os.rmdir(PROBE_GROUP)
except OSError as e:
ctx.log("the probe group stayed behind: %s", e)
ctx.check(not os.path.isdir(PROBE_GROUP), "the probe group stayed behind")
ctx.log("PASS: the kernel, the cgroup tree, the account pool, and the deny rules are in place, and each "
"held a probe process the way it will hold a package")
# ------------------------------------------------------------ the host
FORGEEXT = "/usr/bin/forgeext"
FWUP = "/usr/bin/fwup"
EXT_ROOT = "/data/forgefirm/ext"
HOST_STATUS = "/run/forgefirm/ext/status.json"
SAFE_FILE = "/run/forgefirm/ext-safe"
REF_ID = "org.forgetest.reference"
REF_KEY = "forgetest-reference"
REF_DEST = "192.0.2.1" # TEST-NET-1: declared so that port 443 is, and never dialed
# The reference package's service. It looks at its own confinement from the
# inside, leaves what it found in its data directory, and stays up.
REF_SERVICE = r'''
import errno, json, os, socket, sys, time
lan = sys.argv[1]
data, pkg = os.environ["FFX_DATA"], os.environ["FFX_PKG"]
if os.path.exists(os.path.join(data, "stop")):
sys.exit(3)
def word(e):
return errno.errorcode.get(e.errno, str(e.errno))
def api(method, path, body=None):
"""One request to the host over the socket named in FFX_API: (status, JSON)."""
payload = json.dumps(body).encode() if body is not None else b""
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 = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(5)
try:
s.connect(os.environ["FFX_API"])
s.sendall(head.encode() + b"\r\n" + payload)
buf = b""
while True:
c = s.recv(65536)
if not c:
break
buf += c
top, _, text = buf.partition(b"\r\n\r\n")
return int(top.split()[1]), json.loads(text or b"null")
except (OSError, ValueError, IndexError) as e:
return 599, {"error": str(e)}
finally:
s.close()
def shot(body):
"""POST /v0/camera: (status, bytes or the error's words). A frame is
bytes, so this one does not try to read the answer as JSON."""
payload = json.dumps(body).encode()
head = ("POST /v0/camera HTTP/1.1\r\nHost: forgeext\r\nContent-Type: application/json\r\n"
"Content-Length: %d\r\n\r\n" % len(payload))
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(40)
try:
s.connect(os.environ["FFX_API"])
s.sendall(head.encode() + payload)
buf = b""
while True:
c = s.recv(65536)
if not c:
break
buf += c
top, _, rest = buf.partition(b"\r\n\r\n")
code = int(top.split()[1])
if code == 200:
return code, len(rest), rest[:2] == b"\xff\xd8", b"image/jpeg" in top
try:
return code, json.loads(rest or b"{}").get("error", ""), False, False
except ValueError:
return code, rest[:80].decode("utf-8", "replace"), False, False
except (OSError, ValueError, IndexError) as e:
return 599, str(e), False, False
finally:
s.close()
def opened(path, mode="r"):
try:
with open(path, mode) as f:
if "r" in mode:
f.read(1)
else:
f.write("x")
return "ok"
except OSError as e:
return word(e)
def dial(addr, port):
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(4.0)
try:
s.connect((addr, port))
return "connected"
except socket.timeout:
return "timeout"
except OSError as e:
return word(e)
finally:
s.close()
def family(af):
try:
socket.socket(af, socket.SOCK_RAW if af == 16 else socket.SOCK_DGRAM).close()
return "ok"
except OSError as e:
return word(e)
report = {
"uid": os.getuid(), "gid": os.getgid(), "groups": os.getgroups(), "cwd": os.getcwd(),
"env": sorted(os.environ),
"read_settings": opened("/data/forgefirm/forgefirm.conf"),
"read_setup": opened("/data/forgefirm/setup.json"),
"read_etc": opened("/etc/hostname"),
"read_pkg": opened(os.path.join(pkg, "manifest.json")),
"write_pkg": opened(os.path.join(pkg, "x"), "w"),
"write_data": opened(os.path.join(data, "scratch"), "w"),
"write_tmp": opened("/tmp/forgetest-reference", "w"),
"pulse_device": opened("/dev/glowforge"),
"loopback_declared_port": dial("127.0.0.1", 443),
"lan_declared_port": dial(lan, 443) if lan != "-" else "skipped",
"loopback_undeclared_port": dial("127.0.0.1", 80),
"netlink_socket": family(16),
"unix_socket": family(1),
"api_camera": shot({"camera": "lid", "resolution": "half"}),
"api_camera_head": shot({"camera": "head"}),
"api_camera_bad": shot({"camera": "bed"}),
"api_camera_dark": shot({"camera": "lid", "resolution": "half", "lamp": 0}),
"api_camera_lit": shot({"camera": "lid", "resolution": "half", "lamp": 1023}),
"api_camera_lamp_bad": shot({"camera": "lid", "lamp": 5000}),
"api_settings": api("GET", "/v0/settings"),
"api_settings_set": api("POST", "/v0/settings", {"threshold": 70, "note": "set from inside"}),
"api_settings_undeclared": api("POST", "/v0/settings", {"nothere": 1}),
"api_settings_out_of_bounds": api("POST", "/v0/settings", {"threshold": 9000}),
"api_self": api("GET", "/v0/self"),
"api_mode": api("GET", "/v0/machine/mode"),
"api_hold": api("GET", "/v0/hold"),
"api_nowhere": api("GET", "/v0/nowhere"),
"api_traversal": api("GET", "/v0/machine/../../settings"),
}
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("reference service up", flush=True)
mine = api("GET", "/v0/self")[1] or {}
watching = "events" in (mine.get("capabilities") or [])
place, seen, polls = None, [], []
n = 0
while True:
n += 1
with open(os.path.join(data, "beat"), "w") as f:
f.write(str(n))
if watching:
# A poll that waits: it comes back with an event as soon as there
# is one, and with nothing when its two seconds are up.
ask = {"wait": 2} if place is None else {"since": place, "wait": 2}
began = time.time()
st, ans = api("POST", "/v0/events", ask)
took = round(time.time() - began, 2)
if st == 200:
place = ans.get("next")
seen = (seen + [e.get("event") for e in ans.get("events") or []])[-40:]
polls = (polls + [{"status": st, "took": took, "placed": "since" in ask,
"got": len(ans.get("events") or []) if isinstance(ans, dict) else 0}])[-20:]
with open(os.path.join(data, "events.json.new"), "w") as f:
json.dump({"place": place, "seen": seen, "polls": polls, "last": ans,
"connected": ans.get("connected") if isinstance(ans, dict) else None}, f)
os.rename(os.path.join(data, "events.json.new"), os.path.join(data, "events.json"))
# the test's word to the service: write a program and run it
runf = os.path.join(data, "run")
if os.path.exists(runf):
with open(runf) as f:
words = json.load(f)
os.remove(runf)
if words.get("program_text") is not None:
with open(os.path.join(data, words.get("program", "job.gcode")), "w") as f:
f.write(words["program_text"])
body = {k: v for k, v in words.items() if k not in ("program_text",)}
with open(os.path.join(data, "ran.new"), "w") as f:
json.dump(api("POST", "/v0/motion/job", body), f)
os.rename(os.path.join(data, "ran.new"), os.path.join(data, "ran"))
# the test's word to the service: jog now
jog = os.path.join(data, "jog")
if os.path.exists(jog):
with open(jog) as f:
words = json.load(f)
os.remove(jog)
with open(os.path.join(data, "jogged.new"), "w") as f:
json.dump(api("POST", "/v0/motion/jog", words), f)
os.rename(os.path.join(data, "jogged.new"), os.path.join(data, "jogged"))
# the test's word to the service: take a capture now
ask = os.path.join(data, "shoot")
if os.path.exists(ask):
os.remove(ask)
with open(os.path.join(data, "shot.new"), "w") as f:
json.dump(shot({"camera": "lid", "resolution": "half"}), f)
os.rename(os.path.join(data, "shot.new"), os.path.join(data, "shot"))
# the test's word to the service: what to say of its hold
say = os.path.join(data, "say")
if os.path.exists(say):
with open(say) as f:
words = json.load(f)
os.remove(say)
with open(os.path.join(data, "said.new"), "w") as f:
json.dump(api("POST", "/v0/hold", words), f)
os.rename(os.path.join(data, "said.new"), os.path.join(data, "said"))
time.sleep(0.5)
'''
def _host_pids():
"""The extension host: /usr/bin/forgeext run, and not an install or a
check someone has under way."""
out = []
for d in os.listdir("/proc"):
if not d.isdigit():
continue
try:
if os.readlink("/proc/%s/exe" % d) != FORGEEXT:
continue
with open("/proc/%s/cmdline" % d, "rb") as f:
argv = f.read().split(b"\0")
except OSError:
continue
if argv[:2] == [FORGEEXT.encode(), b"run"]:
out.append(int(d))
return out
def _host_status():
try:
with open(HOST_STATUS) as f:
return json.load(f)
except (OSError, ValueError):
return {}
def _svc(id_):
return next((x for x in _host_status().get("services", []) if x.get("id") == id_), {})
def _forgeext(*args, wait=120):
p = subprocess.run([FORGEEXT] + list(args), capture_output=True, text=True, timeout=wait)
try:
return json.loads(p.stdout)
except ValueError:
return {"ok": False, "error": "no JSON answer (exit %s): %s %s" % (p.returncode, p.stdout[-200:], p.stderr[-200:])}
def _until(ctx, cond, seconds, poll=0.25):
end = time.time() + seconds
while time.time() < end:
v = cond()
if v:
return v
ctx.checkpoint()
time.sleep(poll)
return cond()
def _tree(root):
"""Every path under the extension root but the host's own bookkeeping."""
out = []
for base, dirs, files in os.walk(root):
for n in dirs + files:
rel = os.path.relpath(os.path.join(base, n), root)
if rel not in ("state.json", "lock"):
out.append(rel)
return sorted(out)
REF_UI = ("<!doctype html><title>forgetest reference</title>\n"
"<p>the reference package's own page</p>\n")
def _pack_reference(work, lan, more_caps=(), ui=None):
"""The reference package, signed with a key made here: (archive, public key)."""
import io
import tarfile
manifest = {"manifest": 1, "id": REF_ID, "name": "forgetest reference", "version": "1.0.0",
"author": "forgetest", "license": "MIT", "api": "0.1", "runtime": "python",
"service": {"exec": "bin/reference.py", "args": [lan or "-"]},
"settings": {"threshold": {"type": "number", "default": 40, "min": 0, "max": 100},
"note": {"type": "string", "default": "", "max": 32, "label": "A note"},
"when": {"type": "choice", "default": "end",
"choices": ["start", "end", "never"]}},
"capabilities": ["net.outbound:%s:443" % REF_DEST, "storage:1", "machine.read",
"settings.own", "camera.lid"] + list(more_caps)}
payload = os.path.join(work, "payload.tar.gz")
with tarfile.open(payload, "w:gz") as t:
files = [("manifest.json", json.dumps(manifest), 0o644),
("bin/reference.py", REF_SERVICE, 0o755)]
if ui is None:
ui = "ui" in (manifest.get("capabilities") or [])
if ui:
files.append(("ui/index.html", REF_UI, 0o644))
for name, text, mode in files:
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, "reference.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 _put_back(ctx, fc, work, prior, etag, raw, dir_mode):
"""Everything a test of the host put on the machine, taken away again:
safe mode, the package, the owner key, the work directory, the
setting, the data directory's mode, and the setup record (under a
forgectrl restart)."""
import shutil
if os.path.exists(SAFE_FILE):
os.remove(SAFE_FILE)
st, reply = fc.post("/settings", data={"ext_enabled": "0"})
r = _forgeext("remove", REF_ID)
ctx.log("remove %s -> %s", REF_ID, "ok" if r.get("ok") else r.get("error"))
owner_key = os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub")
if os.path.exists(owner_key):
os.remove(owner_key)
shutil.rmtree(work, ignore_errors=True)
if prior == "1":
st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE})
elif prior == "":
st, reply = fc.post("/settings", params={"ext_enabled": ""})
ctx.log("restore ext_enabled=%r -> %s", prior, st)
if prior != "1":
os.chmod(os.path.dirname(EXT_ROOT), dir_mode)
with ctx.takeover():
write_file(record_path(), raw)
ctx.log("the previous record is back under a restart")
def _as_found(ctx, fc, prior, raw, dir_mode, found_tree):
ctx.check((fc.settings().get("ext_enabled") or "") == prior, "ext_enabled not restored: %r, was %r",
fc.settings().get("ext_enabled"), prior)
ctx.check(read_file(record_path()) == raw, "the setup record on disk is not the one found")
ctx.check(prior == "1" or os.stat(os.path.dirname(EXT_ROOT)).st_mode & 0o7777 == dir_mode,
"the data directory's mode is not the one found")
left = _tree(EXT_ROOT)
ctx.check(left == found_tree, "the extension root is not as found: %s", sorted(set(left) ^ set(found_tree)))
ctx.check(len(_host_pids()) == 1, "the extension host is not running at the end")
# ------------------------------------------------- the armed window
def _frozen(id_):
ev = _read("%s/%s/cgroup.events" % (POOL_GROUP, id_))
return "frozen 1" in ev if ev else None
@test("exthost.armed-freeze", title="A package's service is frozen for the armed window",
subsystem="exthost", kind="operator", hardware="takeover", est_min=6,
covers=[("forgeext", "src/super.*"), ("forgeext", "src/run.*"), ("forgeext", "src/machine.*"),
("forgeext", "src/cgroup.*"), ("forgectrl", "src/cool.*"), ("forgectrl", "src/cam.*"),
("forgectrl", "src/main.c")],
requires=["exthost.platform"], 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."],
description="The reference package runs, its heartbeat advancing twice a second, and a dark "
"cloud print opens a real armed window over it: the cloud suite's dark-print "
"helper, whose latch checks still run (locked at the button, unlocked for the run, "
"locked after it), and the print completes. Sampled five times a second from before the button to after the end: the "
"engine's armed flag, the group's frozen state as the kernel reports it, and the "
"heartbeat. The window is open for at least 10 s. From 2 s after it opens to its "
"close the group reads frozen in every sample and the heartbeat does not move, the "
"freeze is in place before the run starts (the latch unlocks only for the run), and "
"within 3 s of the close the group is thawed and the heartbeat advances again. The "
"service is the same process throughout. A background capture is refused in words "
"while the window is open and served again once it closes, which is the other half "
"of keeping a package off the step stream: a capture costs kernel-side work that a "
"thread priority does not cover. The package, the key, the setting and the setup "
"record are put back as found, the record under a forgectrl restart.")
def armed_freeze(ctx):
import tempfile
import threading
fc = ctx.forgectrl
ev = ctx.evidence
ctx.check(len(_host_pids()) == 1, "the extension host is not one running process: %s", _host_pids())
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())
ctx.check(raw, "no setup record at %s", record_path())
ctx.check(not os.path.exists(SAFE_FILE), "%s exists: the machine is in safe mode", SAFE_FILE)
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")
ctx.check(st == 200 and etag, "GET /advisories/extensions -> %s", st)
work = tempfile.mkdtemp(prefix="forgetest-ffx.")
beat_path = os.path.join(EXT_ROOT, "data", REF_ID, "beat")
samples = []
probe = {}
stop = threading.Event()
def sampler():
while not stop.is_set():
st_, cool = fc.get("/cool/status")
armed = bool(cool.get("armed")) if st_ == 200 and isinstance(cool, dict) else None
samples.append((time.time(), armed, _frozen(REF_ID), _read(beat_path).strip(),
(_svc(REF_ID) or {}).get("pid"), latch_locked()))
# One background capture from inside the window. The refusal is
# decided before any frame is taken, so it costs the cut nothing;
# if it were served instead, that is what this case is here to
# catch, and the capture is the one the rule forbids.
if armed and "in" not in probe:
probe["in"] = fc.get("/cam/snapshot", params={"cam": "lid", "background": "1"}, raw=True)
time.sleep(0.2)
try:
archive, pub = _pack_reference(work, lan_ip())
with open(pub, "rb") as f:
write_file(os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub"), f.read())
os.chmod(os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub"), 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 over the advisory -> %s %r", st, reply)
seen = set()
ctx.check(_until(ctx, lambda: seen.add(_read(beat_path)) or len(seen) >= 4, 120, poll=0.2),
"the reference service's heartbeat does not advance: %s", _svc(REF_ID) or _host_status())
pid = _svc(REF_ID).get("pid")
offset = enter_offline(ctx)
job = offline_job(ctx, "dark.puls", seconds=30)
off = Offline().__enter__()
t = threading.Thread(target=sampler, daemon=True)
t.start()
try:
dark_print_body(ctx, ev, off, job, offset, fc)
ctx.sleep(6) # past the close, for the thaw
finally:
stop.set()
t.join(timeout=5)
off.__exit__(None, None, None)
offline_cleanup(ctx)
opens = [x for x in samples if x[1]]
ctx.check(opens, "the engine never read armed across the print: %d samples", len(samples))
t_open, t_close = opens[0][0], opens[-1][0]
ev["window_s"] = round(t_close - t_open, 1)
ctx.check(t_close - t_open >= 10, "the armed window was open for %.1f s only", t_close - t_open)
inside = [x for x in samples if t_open + 2.0 <= x[0] <= t_close]
thawed = [x for x in inside if x[2] is not True]
ev["samples"] = {"all": len(samples), "inside": len(inside), "not_frozen_inside": len(thawed)}
first_frozen = next((x[0] for x in samples if x[0] >= t_open and x[2] is True), None)
ev["freeze_lag_s"] = round(first_frozen - t_open, 2) if first_frozen else None
ctx.check(inside and not thawed, "inside the armed window the group read not frozen in %d of %d samples "
"(the first %.1f s after it opened)", len(thawed), len(inside), (thawed[0][0] - t_open) if thawed else 0)
beats = {x[3] for x in inside}
ctx.check(len(beats) == 1, "the heartbeat moved inside the armed window: %s", sorted(beats)[:6])
# the latch unlocks only for the run: its first unlocked sample is the run's start
run_start = next((x[0] for x in samples if x[5] is False), None)
ctx.check(run_start is not None, "the latch never read unlocked: the print did not run under the samples")
ctx.check(first_frozen is not None and first_frozen <= run_start,
"the run started before the freeze was in place (frozen %s, the latch unlocked %s after the window opened)",
None if first_frozen is None else round(first_frozen - t_open, 2), round(run_start - t_open, 2))
ev["freeze_led_the_run_s"] = round(run_start - first_frozen, 2)
after = [x for x in samples if x[0] >= t_close + 3.0]
ctx.check(after and all(x[2] is False for x in after), "3 s after the close the group is not thawed: %s",
[x[2] for x in after][:8])
ctx.check(len({x[3] for x in after}) >= 2, "the heartbeat did not advance again after the close")
last_frozen = max((x[0] for x in samples if x[2] is True), default=None)
ev["thaw_lag_s"] = round(last_frozen - t_close, 2) if last_frozen else None
ctx.check({x[4] for x in samples if x[4]} == {pid}, "the service did not stay the same process: %s",
sorted({x[4] for x in samples if x[4]}))
# The camera, inside the window and after it.
ctx.check("in" in probe, "no background capture was tried inside the armed window")
cam_st, cam_body = probe.get("in", (None, b""))
words = cam_body.decode("utf-8", "replace")[:200] if isinstance(cam_body, bytes) else str(cam_body)[:200]
ev["camera_in_window"] = {"status": cam_st, "said": words}
ctx.check(cam_st == 409, "a background capture inside the armed window -> %s (%d bytes)", cam_st,
len(cam_body or b""))
ctx.check("armed" in words, "the refusal does not say why: %s", words)
st, after_body = fc.get("/cam/snapshot", params={"cam": "lid", "background": "1"}, raw=True)
ev["camera_after_window"] = {"status": st, "bytes": len(after_body or b"")}
ctx.check(st == 200 and (after_body or b"")[:2].hex() == "ffd8",
"a background capture after the window -> %s (%d bytes)", st, len(after_body or b""))
ctx.log("a background capture inside the window -> %s (%s); after it -> %s, %d bytes", cam_st, words, st,
len(after_body or b""))
ctx.log("the armed window was open %.1f s; frozen %.2f s after it opened and %.2f s before the latch unlocked "
"for the run, in every one of %d samples from 2 s in to the close, the heartbeat still; thawed %.2f s "
"after the close", ev["window_s"], ev["freeze_lag_s"], ev["freeze_led_the_run_s"], len(inside),
ev["thaw_lag_s"] if ev["thaw_lag_s"] is not None else -1)
finally:
_put_back(ctx, fc, work, prior, etag, raw, dir_mode)
_as_found(ctx, fc, prior, raw, dir_mode, found_tree)
-211
View File
@@ -1,211 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""forgectrl.* - the machine-services daemon's API, access control, and panel."""
import socket
from ..catalog import test
# 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
# 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"),
("forgectrl", "src/session.*"), ("forgectrl", "src/users.*"), ("forgectrl", "src/button.*"),
("forgectrl", "src/ui/login.*"), ("forgectrl", "src/ui/wizard.js")]
def lan_ip():
"""The board's own non-loopback IPv4 (the address a LAN client would
use), or None."""
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("10.255.255.255", 9))
ip = s.getsockname()[0]
except OSError:
ip = None
finally:
s.close()
if ip and not ip.startswith("127."):
return ip
return None
@test("forgectrl.auth", title="API access control", subsystem="forgectrl", kind="auto", est_min=1,
covers=_COVERS_AUTH,
description="Every state-changing endpoint refuses an unauthenticated write (the factory "
"return, the SSH switch and the wizard's own routes included); a non-literal "
"Host, a non-literal Origin and a cross-site Sec-Fetch-Site are refused, while "
"the machine's own hostname passes and that name with a domain on it does not; "
"the cooling report channel accepts the loopback peer and refuses a non-loopback "
"one (over HTTP the write is sent to HTTPS first, 302; over HTTPS the route "
"answers 403 loopback only); the fuse view is two-factor "
"(token and the physical button) and refused without either; "
"the flash and factory-restore chain is refused unauthenticated; a page "
"asked for without a session is sent to the login carrying its path.")
def auth(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
st, body = fc.get("/status")
ctx.log("GET /status -> %s", st)
ctx.check(st == 200, "GET /status -> %s", st)
# unauthenticated writes: every one must be refused before it acts
for path, params in (("/controller/stop", None), ("/controller/start", None),
("/mode", {"controller": "grbl"}), ("/settings", {"ui_units": "mm"}),
("/diag/flow-verify", None), ("/diag/abort", None),
("/update/apply", None), ("/boot", {"target": "a"}),
("/update/check", None), ("/update/dismiss", {"version": "v0.0.0"}),
("/system/reboot", None), ("/restore/factory", None),
("/restore/factory-return", {"confirm": "1"}), ("/system/ssh", {"enable": "1"}),
("/wiz/advisories/accept", None), ("/wiz/account", None),
("/wiz/complete", None)):
st, body = fc.post(path, params=params, auth=False)
ctx.log("POST %s (no token) -> %s %s", path, st, body if isinstance(body, dict) else "")
ev["noauth " + path] = st
ctx.check(st == 403, "POST %s without a token -> %s, expected 403", path, st)
ctx.check(isinstance(body, dict) and body.get("error") == "authentication required",
"POST %s without a token: unexpected body %r", path, body)
# the upload sink refuses during body parse; only the status is asserted
st, body = fc.post("/update/upload", data=b"not a firmware archive", auth=False,
headers={"Content-Type": "application/octet-stream"})
ev["noauth /update/upload"] = st
ctx.log("POST /update/upload (no token) -> %s", st)
ctx.check(st in (400, 403), "POST /update/upload without a token -> %s", st)
# An oversized body from an unauthenticated client must not be
# accumulated in memory before the token check. The framework buffers
# every POST body ahead of the callback, and its default is no limit,
# so without a ceiling this is a way to take the daemon and any job
# with it from the network, unauthenticated. The body here is far over
# the cap; what matters is that the daemon answers and is still
# serving afterwards, having refused the write.
big = b"ui_units=mm&pad=" + (b"x" * (4 * 1024 * 1024))
st, body = fc.post("/settings", data=big, auth=False,
headers={"Content-Type": "application/x-www-form-urlencoded"})
ev["oversize_body_status"] = st
ctx.log("POST /settings (no token, %d MiB body) -> %s", len(big) // (1024 * 1024), st)
ctx.check(st == 403, "an oversized unauthenticated body -> %s, expected 403", st)
st, body = fc.get("/status")
ev["alive_after_oversize"] = st
ctx.log("GET /status after the oversized body -> %s", st)
ctx.check(st == 200, "the daemon did not survive an oversized body (%s)", st)
# origin checks (read endpoint, so only the origin layer decides)
st, body = fc.get("/status", headers={"Host": "evil.example.net"})
ev["host_name"] = st
ctx.log("GET /status Host=evil.example.net -> %s", st)
ctx.check(st == 403, "a DNS-name Host was accepted (%s)", st)
# the machine's own name passes; the same name with a domain on it
# does not, because anyone can register one
own = socket.gethostname()
st, body = fc.get("/status", headers={"Host": own})
ev["host_own_name"] = st
ctx.log("GET /status Host=%s -> %s", own, st)
ctx.check(st == 200, "the machine's own hostname was refused as a Host (%s)", st)
st, body = fc.get("/status", headers={"Host": own + ".example.net"})
ev["host_own_name_domain"] = st
ctx.log("GET /status Host=%s.example.net -> %s", own, st)
ctx.check(st == 403, "a domain name built on the machine's name was accepted (%s)", st)
st, body = fc.get("/status", headers={"Origin": "http://evil.example.net"})
ev["origin_name"] = st
ctx.log("GET /status Origin=http://evil.example.net -> %s", st)
ctx.check(st == 403, "a DNS-name Origin was accepted (%s)", st)
st, body = fc.get("/status", headers={"Sec-Fetch-Site": "cross-site"})
ev["sfs_cross"] = st
ctx.log("GET /status Sec-Fetch-Site=cross-site -> %s", st)
ctx.check(st == 403, "a cross-site fetch was accepted (%s)", st)
st, body = fc.get("/status", headers={"Sec-Fetch-Site": "same-origin", "Origin": "http://127.0.0.1"})
ctx.check(st == 200, "same-origin literal Origin refused (%s)", st)
# the fuse view is two-factor: the token AND the physical button held.
# Without the token: authentication refused; with the token and nobody
# at the button: refused with the button message. The identity itself is
# never fetched (it would land in this log).
st, body = fc.get("/fuse-identity", auth=False)
ev["fuse_noauth"] = st
ctx.log("GET /fuse-identity (no token) -> %s %s", st, body if isinstance(body, dict) else "")
ctx.check(st == 403 and isinstance(body, dict) and body.get("error") == "authentication required",
"GET /fuse-identity without token -> %s %r", st, body)
st, body = fc.get("/fuse-identity")
ev["fuse_token_no_button"] = st
ctx.log("GET /fuse-identity (token, button not held) -> %s %s", st, body if isinstance(body, dict) else "")
msg = body.get("error", "") if isinstance(body, dict) else str(body)
ctx.check(st == 403 and "button" in msg,
"GET /fuse-identity with the token but no button -> %s %r (expected the two-factor refusal)",
st, body)
# the cooling report channel is the running controller's alone. A
# loopback peer is not enough: anything on this host is one. The
# supervisor hands the controller a secret at its spawn, and the route
# asks for it. An idle report is what the controller sends every
# period; the engine is idle here, so the accepted one changes nothing.
# A dual-stack listener reports this peer as ::ffff:127.0.0.1, which
# the loopback check must recognize in full.
report = {"mode": "idle", "armed": "0"}
st, body = fc.post("/cool/state", params=report)
ev["cool_state_from_loopback_no_secret"] = st
ctx.log("POST /cool/state from loopback, no secret -> %s %s", st, body if isinstance(body, str) else "")
ctx.check(st == 403 and "controller's alone" in str(body),
"/cool/state from loopback without the secret -> %s %r", st, body)
st, body = fc.post("/cool/state", params=report, headers={"X-ForgeFIRM-Report": "0" * 32})
ctx.check(st == 403, "/cool/state with a secret that is not the controller's -> %s %r", st, body)
# The secret itself, as only root on this host can read it: out of
# the running controller's environment. It is never logged.
secret = None
pid = (fc.get("/mode")[1] or {}).get("pid")
if pid:
try:
with open("/proc/%d/environ" % int(pid), "rb") as f:
for item in f.read().split(b"\0"):
if item.startswith(b"GF_REPORT_SECRET="):
secret = item.split(b"=", 1)[1].decode("ascii", "replace")
except OSError:
pass
ev["controller_has_a_secret"] = bool(secret)
ctx.check(secret and len(secret) == 32, "the running controller (pid %s) was handed no report secret", pid)
st, body = fc.post("/cool/state", params=report, headers={"X-ForgeFIRM-Report": secret})
ev["cool_state_from_loopback"] = st
ctx.log("POST /cool/state from loopback with the controller's secret -> %s", st)
ctx.check(st == 200, "/cool/state refused the controller's own secret (%s %r): the controller's "
"reports never reach the engine", st, body)
# ...and a non-loopback peer is refused, even with a token. Over HTTP
# the write is sent to HTTPS first (302, the listener's rule); over
# HTTPS the route itself refuses the peer (403 loopback only). Neither
# request follows the redirect, and the self-signed certificate is
# not verified.
from .setup import request, decode
ip = lan_ip()
ev["lan_ip"] = ip
ctx.check(ip, "cannot determine the board's LAN address")
token = {"X-ForgeFIRM-Token": fc.token, "X-ForgeFIRM-Report": secret}
st, body, hdrs = request("http://%s" % ip, "POST", "/cool/state", data=report, headers=token)
loc = hdrs.get("location", "")
ev["cool_state_from_lan_http"] = {"status": st, "location": loc}
ctx.log("POST http://%s/cool/state -> %s %s", ip, st, loc)
ctx.check(st == 302 and loc.startswith("https://"),
"a LAN write over HTTP -> %s %r, expected a 302 to HTTPS", st, loc)
st, body, hdrs = request("https://%s" % ip, "POST", "/cool/state", data=report, headers=token)
b = decode(body)
ev["cool_state_from_lan"] = st
ctx.log("POST https://%s/cool/state -> %s %s", ip, st, b if isinstance(b, dict) else "")
ctx.check(st == 403 and isinstance(b, dict) and b.get("error") == "loopback only",
"/cool/state accepted a non-loopback peer (%s %r)", st, b)
# the login return path: a page asked for from the LAN without a
# session is sent to the login carrying the path it asked for (with
# its query), so the login can come back to it. Before the setup has
# made the account the page is served instead (200).
st, body, hdrs = request("https://%s" % ip, "GET", "/setup?step=laser.focus")
loc = hdrs.get("location", "")
ev["setup_from_lan"] = {"status": st, "location": loc}
ctx.log("GET https://%s/setup?step=laser.focus (no session) -> %s %s", ip, st, loc)
ctx.check(st in (200, 302), "the setup page from the LAN -> %s", st)
if st == 302:
ctx.check(loc == "/login?next=/setup%3Fstep%3Dlaser.focus",
"the login redirect does not carry the path: %r", loc)
-168
View File
@@ -1,168 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The helpers for a test that lets the web service move the head.
The homing is the web-service session cloud.mode-switch runs. These judge
that its motions ran whole, and take the head back to where the test found
it by the travel the service's motions logged. No counter reading can say
where that was: every
service motion zeroes the step counters at its start, and a home and every
controller start zero them again.
"""
import re
import sys
from ..baseline import XY_STEPS_PER_MM, counter_frame, counter_steps_per_mm, read_position
from ..runner import Failed
from . import cloud as logs # the log paths, read at each call
from .cloud import log_lines_since, log_size
from .motion import _drop_reference, clean_slate, machine_idle, wait_idle
RETURN_MAX_MM = 600.0 # no hand-back jogs farther on an axis: the bed is smaller
# The client's own record of a motion's end: the kernel counters, which
# the motion zeroed at its start (the "(actual/expected)" line of the same
# motion is not this one). A print's park keeps the counters and drives
# them back, so the line after it is the print's net travel.
MOTION_END = re.compile(r"end positions \((-?\d+), (-?\d+), (-?\d+)\)")
def session_mark(path=None):
"""Where a client's log ends now (gfhome's by default): the start of
the next session's record."""
return log_size(path or logs.GFHOME_LOG)
def session_travel(offset, path=None):
"""The X/Y travel of the service motions logged in a client's log
(gfhome's by default) since offset, in steps, or None when it cannot be
known. Every service motion zeroes the kernel counters at its start and
logs where they ended, so the travel is the sum of those ends. A service
motion runs at x8 or not at all (the client refuses any other mode), so
the steps are x8 steps. A motion refused before it moved logs no end
and moved nothing; a motion with no "end motion" (one that raised after
it moved, or a client killed inside it) leaves the travel unknown, and
so does a log rotated under the run."""
path = path or logs.GFHOME_LOG
if log_size(path) < offset:
return None
x = y = 0
inside, end = False, None
for ln in log_lines_since(path, offset):
ln = ln.rstrip()
m = MOTION_END.search(ln)
if ln.endswith(" start motion"):
if inside:
return None
inside, end = True, None
elif m and inside:
end = (int(m.group(1)), int(m.group(2)))
elif ln.endswith(" end motion") and inside:
if end:
x, y = x + end[0], y + end[1]
inside = False
return None if inside else (x, y)
def judge_whole_motions(ctx, ev, offset):
"""Every service motion of the homing session since offset ran whole.
One that stopped short (the cooling engine's stop, a fault) left the
head short of the camera home, and the position declared there is
false."""
short = [ln.strip()[:160] for ln in log_lines_since(logs.GFHOME_LOG, offset)
if "run stopped short" in ln
or ("_finish_action motion [" in ln and 'event ":completed"' not in ln)]
ev["short_motions"] = short
ctx.check(not short, "a service motion of the homing did not run whole, so the head stopped short of "
"the camera home and the declared position is false: %s", short)
def camera_home_return(ctx, ev, offset):
"""Hand the head back to where the test found it after gfhome_homing
ran from gfhome's log offset, whatever the session decided. The head is
the session's logged travel from there, plus, once homed, whatever the
counters read since: the home zeroed them where the session left the
head and wrote the anchor, and a controller start since would have
removed it. Unhomed, the counters still hold the last motion's end,
which the travel already counts. The camera home is dropped first,
since its envelope need not hold the spot the head came from."""
def back():
now = [0, 0, 0]
if ev.get("homed"):
now = read_position() if counter_frame() is not None else None
_drop_reference(ctx, ctx.forgectrl)
# Read after the stop: a runner the stop ended has written its last line.
travel = session_travel(offset)
rec = ev["return"] = {"session_travel_steps": travel, "counters_since_home": now}
ctx.check(travel is not None and now is not None,
"the homing session's travel cannot be known (gfhome log %s, counters since the home %s): "
"the head is left where it stands and the hand-back moves nothing", travel, now)
_jog_back(ctx, rec, travel, now)
_hand_back(ctx, ev, "return", back)
def cloud_mode_return(ctx, ev, offset):
"""Hand the head back to where the test found it after a stay in cloud
mode that began at the cloud client's log offset, on the GRBL
controller the switch back started: that start zeroed the counters
where the service left the head, and they hold whatever moved since.
The client's record of its motions is the rest (a hunt's lens travel is
Z, which a hand-back never touches)."""
def back():
travel = session_travel(offset, logs.GFCLOUD_LOG)
now = read_position()
rec = ev["cloud_return"] = {"session_travel_steps": travel, "counters_since_start": now}
ctx.check(travel is not None and now is not None,
"the travel in cloud mode cannot be known (gfcloud log %s, counters %s): the head is left "
"where it stands and the hand-back moves nothing", travel, now)
_jog_back(ctx, rec, travel, now)
_hand_back(ctx, ev, "cloud_return", back)
def _hand_back(ctx, ev, key, back):
"""Run a hand-back from a test's finally. The first failure wins: when
the test has failed already (the exception in flight), a hand-back that
fails as well is logged and recorded, and the test's own failure is the
one reported."""
first = sys.exc_info()[1]
try:
back()
except Failed as e:
if first is None:
raise
ev.setdefault(key, {})["failed"] = str(e)
ctx.log("the head is not back, and the run has failed already: %s", e)
def _jog_back(ctx, rec, travel, since):
"""Jog the head back by the service's travel (x8 steps) and what the
counters read since (the controller's own steps), on a controller
without a home. One more start then zeroes the counters where the head
began, which the rest of the catalog counts on."""
spm = counter_steps_per_mm()
dx = travel[0] / XY_STEPS_PER_MM + since[0] / spm
dy = travel[1] / XY_STEPS_PER_MM + since[1] / spm
rec["mm"] = [round(dx, 3), round(dy, 3)]
ctx.log("the head is %.3f/%.3f mm from where the test found it", dx, dy)
ctx.check(abs(dx) <= RETURN_MAX_MM and abs(dy) <= RETURN_MAX_MM,
"a travel of %.1f/%.1f mm exceeds %.0f mm - not jogging back", dx, dy, RETURN_MAX_MM)
if abs(dx) >= 0.05 or abs(dy) >= 0.05:
with ctx.grbl() as g:
clean_slate(ctx, g)
start = read_position()
r = g.command("$J=G91 G21 X%.3f Y%.3f F2400" % (-dx, -dy))
ctx.check(not any(x.startswith("error") for x in r), "the return jog was refused: %s", r)
states = wait_idle(ctx, g, timeout=60)[1]
ctx.check(states[-1:] != ["TIMEOUT"], "the return jog did not end: %s", states)
machine_idle(ctx)
end = read_position()
rec["counters_across_jog"] = [start, end]
ctx.check(start is not None and end is not None and abs((end[0] - start[0]) / spm + dx) < 0.1
and abs((end[1] - start[1]) / spm + dy) < 0.1,
"the return jog did not travel %.3f/%.3f mm: counters %s -> %s", -dx, -dy, start, end)
_drop_reference(ctx, ctx.forgectrl)
ctx.counters_rezeroed()
ctx.log("head returned to where the test found it (%.3f/%.3f mm)", -dx, -dy)
-497
View File
@@ -1,497 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""image.* - the post-flash health of the running image (always-required)."""
import gzip
import mmap
import os
import struct
import re
import stat
from ..catalog import test
from .. import hw
WDOG1_BASE = 0x020BC000 # i.MX6 WDOG1; WCR is the 16-bit word at offset 0
def _wdog1_wcr():
"""WDOG1's control register, read through /dev/mem (MMIO stays readable
under STRICT_DEVMEM), or None."""
try:
with open("/dev/mem", "r+b") as f:
m = mmap.mmap(f.fileno(), 4096, offset=WDOG1_BASE)
try:
return struct.unpack_from("<H", m, 0)[0]
finally:
m.close()
except (OSError, ValueError):
return None
def _plain(line):
"""A line without its ANSI color sequences: what a terminal shows,
counted in columns rather than in bytes."""
return re.sub(r"\x1b\[[0-9;]*m", "", line)
def _read(path, default=None):
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
return f.read()
except OSError:
return default
def kernel_config():
"""The running kernel's config as a dict, from /proc/config.gz or
/boot/config-<release>. None when neither is available."""
try:
with gzip.open("/proc/config.gz", "rb") as f:
text = f.read().decode("utf-8", "replace")
except OSError:
text = None
if text is None:
rel = _read("/proc/sys/kernel/osrelease", "").strip()
text = _read("/boot/config-%s" % rel) if rel else None
if text is None:
return None
cfg = {}
for line in text.splitlines():
m = re.match(r"^(CONFIG_[A-Z0-9_]+)=(.*)$", line)
if m:
cfg[m.group(1)] = m.group(2)
m = re.match(r"^# (CONFIG_[A-Z0-9_]+) is not set$", line)
if m:
cfg[m.group(1)] = "n"
return cfg
def kernel_ident(name):
"""A kernel release or modules-directory name less its LOCALVERSION_AUTO
hash (+g<hash> or -g<hash>). The hash does not reproduce across a
re-patch of the same source; the manifest lists modules directories
without it, and the kernel's identity is its @srcrev and @config."""
return re.sub(r"[+-]g[0-9a-f]{7,}$", "", name)
def kernel_matches(release, modules_dirs):
"""True when the running release names one of the manifest's modules
directories, hashes aside (a manifest without them constrains nothing)."""
return not modules_dirs or kernel_ident(release) in {kernel_ident(m) for m in modules_dirs}
def _dt_u32(path):
"""A device-tree cell as an int (big-endian), or None."""
try:
with open(path, "rb") as f:
raw = f.read(4)
except OSError:
return None
return int.from_bytes(raw, "big") if len(raw) == 4 else None
def fds_of(pid):
out = []
try:
for fd in os.listdir("/proc/%d/fd" % pid):
try:
out.append(os.readlink("/proc/%d/fd/%s" % (pid, fd)))
except OSError:
pass
except OSError:
pass
return out
@test("image.health", title="Post-flash image health", subsystem="image", kind="auto",
always=True, est_min=1,
covers=[("forgectrl", "init/**"), ("forgeext", "init/**"), ("forgectrl", "src/main.c"), ("forgectrl", "src/auth.c"),
("forgectrl", "src/status.*"),
("forgectrl", "CMakeLists.txt"), ("grblhal-glowforge", "src/boards/**"),
("grblhal-glowforge", "CMakeLists.txt"), ("kernel-module-glowforge", "**"),
("linux-fslc", "**")],
description="The image that is running is the image the manifest describes, with the "
"version stamped in /etc/forgefirm-version and under the machine mark in the "
"console banner and the motd, "
"the kernel options, the module, the pulse ring it maps and the SDMA clocks it holds, "
"the daemon ownership, the board temperatures /status reports (the chassis and "
"the SoC die in a plausible range, the supply as a 10-bit count, the CPU "
"unthrottled), "
"the 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 "
"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):
ev = ctx.evidence
manifest = ctx.runner.manifest
# 1. version stamp: the machine-readable file, and the two a person
# reads - the console banner (/etc/issue, which forgefirm-banner
# renders from the image's own text) and the motd a login prints.
# Each carries the machine mark with the version on the mark's own
# last line, right-justified to the mark's last column.
ver = (_read("/etc/forgefirm-version", "") or "").strip()
ev["forgefirm_version"] = ver
ctx.log("forgefirm-version: %s (manifest: %s)", ver, manifest.version)
ctx.check(ver == manifest.version, "/etc/forgefirm-version %r != manifest %r", ver, manifest.version)
for path in ("/etc/issue", "/etc/motd"):
text = _read(path, "") or ""
ev[path] = text
lines = text.splitlines()
at = [i for i, line in enumerate(lines) if line.rstrip().endswith(ver)]
ctx.check(at, "%s carries no line ending in the version %r", path, ver)
if not at:
continue
i = at[0]
ctx.check(lines[i].rstrip() == lines[i], "%s pads the version line: %r", path, lines[i])
ctx.check(_plain(lines[i]).strip() != ver,
"%s puts the version on a line of its own, not on the mark's last line", path)
# Right-justified to the mark's last column: the stamped line is
# exactly as wide as the widest line above it. Only the motd is
# measured, because /etc/issue holds the mark with its
# backslashes doubled for the getty that reads it, so its bytes
# are wider than the columns a person sees.
if path == "/etc/motd":
above = [len(_plain(line)) for line in lines[:i] if line.strip()]
width = max(above) if above else 0
ev["motd_columns"] = [len(_plain(lines[i])), width]
ctx.check(len(_plain(lines[i])) == width,
"/etc/motd stamped line is %d columns, the mark above is %d",
len(_plain(lines[i])), width)
# 2. kernel options
cfg = kernel_config()
ctx.check(cfg is not None, "kernel config unavailable (/proc/config.gz, /boot/config-*)")
for opt, want in (("CONFIG_PREEMPT", ("y",)), ("CONFIG_IMX2_WDT", ("y", "m")),
("CONFIG_PANIC_ON_OOPS", ("y",))):
val = cfg.get(opt, "n")
ev[opt] = val
ctx.log("%s=%s", opt, val)
ctx.check(val in want, "%s=%s, expected one of %s", opt, val, want)
# The boot-armed hardware watchdog: U-Boot arms WDOG1 at 60 s and the
# kernel's core keeps it fed (no userspace opens it, so the sysfs state
# reads "inactive": that attribute says whether a process holds the
# device, not whether the hardware runs). The hardware's own word is
# WCR: WDE set, and WT giving the period. The sysfs view needs
# CONFIG_WATCHDOG_SYSFS; bootstatus carries the last reset's cause (32 =
# the watchdog's timeout).
wd = {k: (_read("/sys/class/watchdog/watchdog0/" + k) or "").strip()
for k in ("identity", "state", "timeout", "bootstatus")}
wcr = _wdog1_wcr()
wd["wcr"] = ("%#06x" % wcr) if wcr is not None else None
wd["wde"] = bool(wcr & 0x4) if wcr is not None else None
wd["period_s"] = ((wcr >> 8) + 1) / 2.0 if wcr is not None else None
ev["watchdog"] = wd
ctx.log("watchdog0: %s", wd)
ctx.check(wd["identity"], "watchdog0 has no sysfs view (CONFIG_WATCHDOG_SYSFS)")
ctx.check(wd["timeout"] == "60", "watchdog0 timeout %r, expected 60 s", wd["timeout"] or None)
ctx.check(wcr is not None, "WDOG1 WCR unreadable through /dev/mem")
ctx.check(wd["wde"], "WDOG1 is not enabled (WCR %s): nothing resets a hung kernel", wd["wcr"])
ctx.check(wd["period_s"] == 60.0, "WDOG1 period %s s (WCR %s), expected 60", wd["period_s"], wd["wcr"])
ctx.check(wd["state"] == "inactive", "watchdog0 is %r: a process holds the device, which the image "
"does not do", wd["state"] or None)
rel = _read("/proc/sys/kernel/osrelease", "").strip()
ev["kernel_release"] = rel
mods = manifest.platform.get("kernel_modules") or []
ctx.log("kernel release %s (manifest modules dirs: %s)", rel, ",".join(mods))
ctx.check(kernel_matches(rel, mods), "running kernel %r is not the manifest's %s", rel, mods)
# 3. the module and its sysfs
ctx.check(os.path.isdir("/sys/module/glowforge"), "glowforge.ko is not loaded")
state = hw.sysfs_read("cnc/state")
ev["cnc_state"] = state
ctx.check(state is not None, "/sys/glowforge/cnc/state unreadable")
ctx.log("cnc/state: %s", state)
free = hw.sysfs_int("cnc/free")
ev["cnc_free"] = free
ctx.check(free is not None and free > 0, "cnc/free unreadable or zero (ring not mapped?)")
ctx.log("cnc/free: %s bytes", free)
# The ring the platform ships: the DT pool is the promise, ring_mb is what
# the module took. They must agree - a pool the parameter does not use is
# no-map RAM burned for nothing, and a parameter the pool cannot back would
# have failed the probe. free can only ever be size less the 32 KiB gap.
pool = _dt_u32("/proc/device-tree/reserved-memory/cnc-pulsebuf/size")
try:
ring = int((_read("/sys/module/glowforge/parameters/ring_mb", "") or "").strip()) << 20
except ValueError:
ring = None
ev["cnc_pool_bytes"] = pool
ev["cnc_ring_bytes"] = ring
ctx.log("cnc-pulsebuf pool: %s bytes, ring_mb: %s bytes", pool, ring)
ctx.check(pool is not None, "no cnc-pulsebuf reserved-memory node (the ring fell back to CMA?)")
ctx.check(ring is not None, "/sys/module/glowforge/parameters/ring_mb unreadable")
ctx.check(pool is None or ring is None or pool == ring,
"DT pool %s and ring_mb %s disagree", pool, ring)
ctx.check(ring is None or free is None or free <= ring - 32 * 1024,
"cnc/free %s exceeds the ring less its 32 KiB gap (%s)", free, ring)
ctx.check(hw.sysfs_read("cnc/interlock_circuit") is not None, "cnc/interlock_circuit unreadable")
# The SDMA engine runs only while a channel holder keeps its ipg/ahb
# clocks enabled; imx-sdma leaves them off after probe and only a channel
# allocation turns them on. glowforge.ko takes its channel outside
# dmaengine and holds the clocks itself. With the block gated every
# channel-0 transfer is a silent no-op: the probe cannot start, and the
# ring reads back a stale bounce page (free above the ring size, a
# position counter that never moved).
clk = (_read("/sys/kernel/debug/clk/sdma/clk_enable_count", "") or "").strip()
ev["sdma_clk_enable_count"] = clk
ctx.log("sdma clk_enable_count: %s", clk or "(unreadable)")
ctx.check(clk.isdigit() and int(clk) >= 1,
"SDMA clk_enable_count %r: the engine's ipg/ahb clocks are not held", clk)
# 4. forgectrl holds /dev/glowforge and supervises the controller
pids = hw.pidof("forgectrl")
ev["forgectrl_pids"] = pids
ctx.check(pids, "forgectrl is not running")
holders = [p for p in pids if any(l == "/dev/glowforge" for l in fds_of(p))]
ev["pulse_device_holders"] = holders
ctx.log("forgectrl pids %s, holding /dev/glowforge: %s", pids, holders)
ctx.check(holders, "no forgectrl process holds /dev/glowforge")
st, mode = ctx.forgectrl.get("/mode")
ev["mode"] = mode
ctx.check(st == 200 and isinstance(mode, dict), "GET /mode -> %s", st)
ctx.log("mode: %s", mode)
ctx.check(mode.get("controller") in ("running", "standby"),
"controller is %r (expected running or standby)", mode.get("controller"))
ctx.check(mode.get("motion") != "fault", "supervisor reports a motion fault")
# 4b. the board temperatures /status carries, watched and not gated:
# the chassis and the SoC die in degrees, the supply as its raw count,
# and the CPU unthrottled at idle.
temps = (ctx.forgectrl.status() or {}).get("temps") or {}
ev["temps"] = temps
ctx.log("temps: %s", temps)
ctx.check(isinstance(temps.get("chassis_c"), (int, float)) and 5.0 <= temps["chassis_c"] <= 80.0,
"/status temps.chassis_c is not a plausible chassis temperature: %s", temps)
ctx.check(isinstance(temps.get("soc_c"), (int, float)) and 10.0 <= temps["soc_c"] <= 100.0,
"/status temps.soc_c is not a plausible die temperature: %s", temps)
ctx.check(isinstance(temps.get("supply_raw"), int) and 0 <= temps["supply_raw"] <= 1023,
"/status temps.supply_raw is not a 10-bit count: %s", temps)
ctx.check(temps.get("soc_throttle") == 0,
"the CPU is throttled (or its cooling device is missing) at idle: %s", temps)
# 5. the extension host: one process, and a campaign's machine is
# extension-free (its init order is the rootfs lint's)
host = []
for pid in hw.pidof("forgeext"):
argv = _read("/proc/%d/cmdline" % pid, "").split("\0")
if argv[:2] == [hw.FORGEEXT, "run"]:
host.append(pid)
ev["extension_host_pids"] = host
ctx.check(len(host) == 1, "the extension host is not one running process: %s", host)
ev["extension_packages"] = hw.ext_packages()
ev["pool_processes"] = len(hw.pool_pids())
ctx.log("extension host pid %s, packages %s, pool processes %d", host, ev["extension_packages"], ev["pool_processes"])
ctx.check(not ev["extension_packages"], "a campaign wants an extension-free machine: installed %s",
ev["extension_packages"])
ctx.check(not ev["pool_processes"], "%d process(es) run under extension pool accounts", ev["pool_processes"])
# 6. no userspace watchdog daemon running (the logging init and the
# absent watchdog init script are the rootfs lint's); 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, "")]
ev["userspace_watchdog_pids"] = user_wd
ctx.check(not user_wd, "a userspace watchdog daemon is running: %s", user_wd)
# 7. file modes and space
for path in ("/data/forgefirm/panel.token", "/data/forgefirm/forgefirm.conf"):
if os.path.exists(path):
m = stat.S_IMODE(os.stat(path).st_mode)
ev[path] = "%o" % m
ctx.log("%s mode %o", path, m)
ctx.check(m == 0o600, "%s mode %o, expected 600", path, m)
# an operator account walks through the data directory to its home, and a
# package's account to its files: forgefirm-users opens it for search at
# every render, whoever made it and under whatever umask
if os.path.exists("/data/forgefirm/users"):
m = stat.S_IMODE(os.stat("/data/forgefirm").st_mode)
ev["/data/forgefirm"] = "%o" % m
ctx.log("/data/forgefirm mode %o", m)
ctx.check(m & 0o011 == 0o011, "/data/forgefirm mode %o: no account can walk through it to its home", m)
if os.path.isdir("/data"):
s = os.statvfs("/data")
free_mb = s.f_bavail * s.f_frsize // (1024 * 1024)
ev["data_free_mb"] = free_mb
ctx.log("/data free: %d MiB", free_mb)
ctx.check(free_mb >= 20, "/data has only %d MiB free", free_mb)
# 8. the mounts: the rootfs read-only, /data the writable partition,
# the state a read-only rootfs hands off (the read-only-rootfs image
# feature, forgefirm-users, forgefirm-hostname, forgefirm-banner, the
# sshd host keys). The dev image's factory slots under /factory are
# read-only; rcS's ROOTFS_READ_ONLY and the release fstab with no
# factory slot are the rootfs lint's.
mounts = {}
for line in (_read("/proc/mounts", "") or "").splitlines():
parts = line.split()
if len(parts) >= 4:
mounts[parts[1]] = {"source": parts[0], "type": parts[2], "opts": parts[3].split(",")}
def mount_opts(path):
return (mounts.get(path) or {}).get("opts") or []
ev["mounts"] = {p: mounts[p] for p in ("/", "/data", "/var/lib", "/etc/passwd",
"/etc/hostname", "/etc/issue") if p in mounts}
ctx.log("/ mounted %s; /data %s; /var/lib %s", ",".join(mount_opts("/")) or "(absent)",
",".join(mount_opts("/data")) or "(absent)", ",".join(mount_opts("/var/lib")) or "(absent)")
ctx.check("ro" in mount_opts("/"), "the rootfs is not mounted read-only: %s", mounts.get("/"))
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"),
"/var/lib is not a writable copy (read-only-rootfs-hook.sh): %s", mounts.get("/var/lib"))
factory = sorted(p for p in mounts if p.startswith("/factory/"))
ev["factory_mounts"] = factory
dev_image = os.path.exists("/etc/forgefirm-dev")
ev["dev_image"] = dev_image
ctx.log("dev image %s; /factory mounts: %s", dev_image, factory or "none")
if dev_image:
for n in (1, 2):
if os.path.exists("/dev/mmcblk2p%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))
if os.path.isfile("/data/forgefirm/users"):
names = [l.split(":")[0] for l in (_read("/data/forgefirm/users", "") or "").splitlines()
if l.strip() and not l.startswith("#")]
passwd_names = {l.split(":")[0] for l in (_read("/etc/passwd", "") or "").splitlines()}
ev["record_accounts"] = names
for f in ("/etc/passwd", "/etc/shadow", "/etc/group"):
ctx.check(f in mounts, "%s is not the tmpfs render of the account record", f)
for n in names:
ctx.check(n in passwd_names, "record account %r is missing from /etc/passwd", n)
ctx.check("/etc/hostname" in mounts, "/etc/hostname is not the bind-mounted name copy")
ctx.check("/etc/issue" in mounts, "/etc/issue is not the bind-mounted banner copy")
if hw.pidof("sshd"):
key = "/data/forgefirm/ssh/ssh_host_ed25519_key"
ctx.check(os.path.isfile(key), "sshd runs but %s is missing", key)
# 9. the manifest itself is coherent
ctx.check(manifest.content_sha and len(manifest.content_sha) == 64, "manifest content_sha256 missing")
ctx.check("kernel-module-glowforge" in manifest.components, "manifest lacks kernel-module-glowforge")
ctx.check("linux-fslc" in manifest.components, "manifest lacks the kernel entry")
ctx.log("manifest %s: %d components", manifest.content_sha[:12], len(manifest.components))
# 10. the network boot. init starts the rest of the boot, the console
# login last, only after the networking script returns, so a client
# that waits in ifup's foreground on a server's answer holds the
# whole machine. The interfaces file starts no DHCPv6 client
# (test_image.py reads the layer's file); the bounded DHCP client
# (-b, which leaves ifup after three unanswered discovers) is checked
# here, on the client that runs.
procs = _procs()
up = ifstate_names(_read(IFSTATE, ""))
ev["ifstate"] = sorted(up)
ctx.check("wlan0" in up, "ifup has not finished wlan0: %s holds %s", IFSTATE, sorted(up))
stuck = [p for p in procs if p[2] in ("ifup", "ifdown")]
ev["ifup_running"] = [p[3] for p in stuck]
ctx.check(not stuck, "ifup is still running: %s", [p[3] for p in stuck])
logins = [p[3] for p in procs if p[2] == "getty"]
ev["getty"] = logins
ctx.check(any("ttymxc0" in c for c in logins), "no console login is running: %s", logins)
v4 = [p for p in procs if p[2] == "udhcpc"]
ev["udhcpc"] = [p[3] for p in v4]
ctx.check(v4, "no DHCP client is running")
for pid, ppid, _comm, cmd in v4:
ctx.check("-b" in cmd.split(), "the DHCP client runs without -b: %s", cmd)
ctx.check(ppid == 1, "the DHCP client (pid %d) is still a child of pid %d", pid, ppid)
# no DHCPv6 client 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))
# ------------------------------------------------------- the network boot
IFSTATE = "/var/run/ifstate" # busybox ifupdown's state file
INTERFACES = "/etc/network/interfaces"
DHCP6_CLIENTS = ("udhcpc6", "dhcp6c", "odhcp6c")
def ifstate_names(text):
"""The interfaces ifup finished configuring: one name=name line each
in busybox's state file, written when ifup returns for the name."""
return {line.split("=", 1)[0].strip() for line in (text or "").splitlines() if "=" in line}
def dhcp6_in_interfaces(text):
"""The lines of an interfaces file that start a DHCPv6 client: an
inet6 stanza with a method that runs one, or a command that names
one. A comment starts nothing."""
bad = []
for raw in (text or "").splitlines():
words = raw.split("#", 1)[0].split()
if not words:
continue
if words[0] == "iface":
if len(words) >= 4 and words[2] == "inet6" and words[3] in ("dhcp", "auto"):
bad.append(" ".join(words))
elif any(w.split("/")[-1] in DHCP6_CLIENTS for w in words[1:]):
bad.append(" ".join(words))
return bad
def parse_stat(text):
"""(pid, comm, ppid) from a /proc/<pid>/stat line, or None. The comm
sits in parentheses and may itself hold spaces and parentheses."""
try:
head, tail = text.split("(", 1)
comm, rest = tail.rsplit(")", 1)
return int(head), comm, int(rest.split()[1])
except (ValueError, IndexError):
return None
def _procs():
"""(pid, ppid, comm, command line) of every process."""
out = []
for name in os.listdir("/proc"):
if not name.isdigit():
continue
st = parse_stat(_read("/proc/%s/stat" % name, ""))
if st is None:
continue
cmd = (_read("/proc/%s/cmdline" % name, "") or "").replace("\0", " ").strip()
out.append((st[0], st[2], st[1], cmd))
return out
def link_addrs(text, iface):
"""(link-local, global) IPv6 addresses of one interface, as the hex
strings of /proc/net/if_inet6 (address, index, prefix length, scope,
flags, name; scope 20 is the link, 00 the world)."""
local, world = [], []
for line in (text or "").splitlines():
f = line.split()
if len(f) == 6 and f[5] == iface:
if f[3] == "20":
local.append(f[0])
elif f[3] == "00":
world.append(f[0])
return local, world
-924
View File
@@ -1,924 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""kernel.* - glowforge.ko safety readbacks and the pulse-engine drills.
The drills are the bench scripts `scripts/bench/gate_a_kernel_drills.py`
(K1/K2/K3) and `scripts/bench/fire_test.py` (A/B/U) with their proven
sequences kept intact; they run under a hardware takeover (forgectrl and
the controller stopped, the pulse device free) and judge the software
witnesses: cnc/state, laser_enable (the FIRE line), laser_on and
laser_on_sampled (the gated LASER_ON output), interlock_circuit bit 3
(the commanded latch), faults and underruns. Every drill forces duty to
zero before any FIRE bit, keeps motor_lock=15 (no axis moves), and
re-locks the latch on every exit path. K3 and fire B/U unlock the latch
for their run and therefore refuse to proceed unless the safety chain
holds HV off: the charge-pump watchdog dead and the pulse engine idle
(HV_ENABLE follows the pump; laser_pgood is the supply's power-good and
says nothing about HV).
"""
import errno
import os
import struct
import time
try:
import fcntl
except ImportError: # host unit tests import the suite off-target
fcntl = None
from ..catalog import test
from .. import hw
from ..runner import Failed
# interlock_circuit bits (docs.forgefirm.org, the kernel module page): bit 3 = the driven latch line, set = locked.
LATCH_BIT = 1 << 3
TICK_HZ = 10000
FIRE = b"\x10"
PAD = b"\x00"
XSTEP = b"\x01" # +X (DIR clear)
XSTEP_BACK = b"\x03" # -X (DIR set)
POWER0 = bytes([0x80]) # power byte, duty 0
_KERNEL_COVERS = [("kernel-module-glowforge", "**"), ("linux-fslc", "**")]
# ---------------------------------------------------------------- helpers
def wr(attr, val):
hw.sysfs_write(attr, val)
def rd(attr):
v = hw.sysfs_read(attr)
if v is None:
raise Failed("cannot read %s" % attr)
return v
def rd_pos():
with open(hw.sysfs_root() + "cnc/position", "rb") as f:
raw = f.read(32)
return struct.unpack("<5i", raw[:20])
def snap(ctx, tag):
line = ("%s: state=%s laser_enable=%s laser_on=%s laser_on_sampled=%s interlock=%s"
% (tag, rd("cnc/state"), rd("cnc/laser_enable"), rd("cnc/laser_on"),
rd("cnc/laser_on_sampled"), rd("cnc/interlock_circuit")))
ctx.log(line)
return line
def wait_state(ctx, want, timeout, poll=0.05):
t0 = time.time()
while time.time() - t0 < timeout:
ctx.checkpoint()
s = rd("cnc/state")
if s == want:
return s
time.sleep(poll)
return rd("cnc/state")
def watch_laser_until_idle(ctx, timeout):
"""Tight-loop laser_enable/laser_on watch; returns (hits, end_state)."""
hits = []
t0 = time.time()
state = "running"
n = 0
while time.time() - t0 < timeout:
en = rd("cnc/laser_enable")
on = rd("cnc/laser_on")
if en != "0" or on != "0":
hits.append((round(time.time() - t0, 4), en, on))
state = rd("cnc/state")
if state != "running":
break
n += 1
if n % 200 == 0:
ctx.checkpoint()
return hits, state
class PulseDevice:
"""Exclusive hold of /dev/glowforge for one drill."""
def __init__(self, ctx):
self.ctx = ctx
self.fd = None
def __enter__(self):
try:
self.fd = os.open("/dev/glowforge", os.O_WRONLY)
except OSError as e:
if e.errno == errno.EBUSY:
raise Failed("/dev/glowforge is busy - the takeover did not free the pulse device")
raise
fcntl.flock(self.fd, fcntl.LOCK_EX)
return self
def write(self, data):
os.write(self.fd, data)
def rewind(self):
os.lseek(self.fd, 1, os.SEEK_SET)
def drop(self):
"""Close the device with the lock still held: the dead man's switch."""
fd, self.fd = self.fd, None
os.close(fd)
def __exit__(self, *exc):
try:
wr("cnc/laser_latch", 1) # re-lock unconditionally
finally:
if self.fd is not None:
fcntl.flock(self.fd, fcntl.LOCK_UN)
os.close(self.fd)
return False
def hv_off_reason():
"""Why the safety chain is NOT holding HV off right now, or None. The
chain asserts HV_ENABLE only while a run feeds the charge-pump
watchdog, so a dead watchdog and an idle pulse engine mean HV off.
laser_pgood is the supply's power-good, high whenever the supply is
healthy, and says nothing about HV, so it plays no part here."""
alive = rd("cnc/charge_pump_alive")
state = rd("cnc/state")
if alive is None or state is None:
return "cnc/charge_pump_alive or cnc/state unreadable"
if alive != "0" or state != "idle":
return ("charge_pump_alive=%s state=%s: the chain may be holding HV_ENABLE up" % (alive, state))
return None
def hv_off():
"""Precheck for the drills that unlock the latch with a zero-duty
stream: they run only while the chain holds HV off. At idle nothing
feeds the watchdog, so this refuses a start only on a machine that is
not idle the way it should be."""
why = hv_off_reason()
if why:
return ("%s; this drill unlocks the latch with a zero-duty stream and needs HV off "
"(let the machine go idle, then start again)" % why)
return None
HV_RELEASE_S = 3.0
def wait_hv_off(ctx, timeout_s=HV_RELEASE_S):
"""hv_off_reason() once the chain has released, or the reason that still
stands after timeout_s. A run feeds the charge-pump watchdog every
200 ms and the one-shot holds ALIVE for 0.45 s after the last feed (the
feed's soft timer can add one more feed after the state leaves
running), so a phase that follows a run finds the chain still up for
under a second. The wait covers that release and nothing more; the
time it took is logged when it was not immediate and kept in the
evidence."""
t0 = time.time()
why = hv_off_reason()
while why is not None and time.time() - t0 < timeout_s:
ctx.sleep(0.05)
why = hv_off_reason()
dt = round(time.time() - t0, 2)
ctx.evidence.setdefault("hv_release_s", []).append(dt)
if why is not None or dt >= 0.1:
ctx.log("hv off: %s after %.2f s", "released" if why is None else "still held", dt)
return why
def require_hv_off(ctx):
"""The same rule at the start of the run (the precheck ran a moment
earlier; the machine must still agree)."""
why = wait_hv_off(ctx)
ctx.evidence["charge_pump_alive"] = rd("cnc/charge_pump_alive")
ctx.evidence["kernel_state"] = rd("cnc/state")
ctx.check(why is None, "%s - refusing the latch unlock", why)
def check_hv_off(ctx):
"""The hard check right before an unlock (no prompt: forgectrl is down)."""
why = wait_hv_off(ctx)
ctx.check(why is None, "%s - refusing the latch unlock", why)
# ---------------------------------------------------------------- readbacks
@test("kernel.latch-locked-idle", title="Laser latch locked at idle", subsystem="kernel",
kind="auto", always=True, est_min=1,
covers=_KERNEL_COVERS + [("forgectrl", "src/super.c"),
("grblhal-glowforge", "src/glowforge_laser.c"),
("grblhal-glowforge", "src/driver.c")],
description="With the machine idle the kernel latch reads locked, the FIRE line is not "
"driven, no LASER_ON sample is seen, and no stepper fault is pending; forgectrl "
"agrees.")
def latch_locked_idle(ctx):
ev = ctx.evidence
state = hw.sysfs_read("cnc/state")
ev["cnc_state"] = state
ctx.log("cnc/state: %s", state)
ctx.check(state is not None, "cnc/state unreadable")
ctx.check(state in ("idle", "disabled"), "machine is %r, run this test at idle", state)
ilk = hw.sysfs_int("cnc/interlock_circuit")
ev["interlock_circuit"] = ilk
ctx.check(ilk is not None, "cnc/interlock_circuit unreadable")
ctx.log("interlock_circuit: %d (0x%x)", ilk, ilk)
ctx.check(ilk & LATCH_BIT, "latch line reads unlocked at idle (bit 3 clear)")
fire = hw.sysfs_int("cnc/laser_enable")
ev["laser_enable"] = fire
ctx.log("laser_enable (FIRE line): %s", fire)
ctx.check(fire == 0, "FIRE line driven at idle (laser_enable=%s)", fire)
on = hw.sysfs_int("cnc/laser_on")
on_s = hw.sysfs_int("cnc/laser_on_sampled")
ev["laser_on"] = on
ev["laser_on_sampled"] = on_s
ctx.log("laser_on: %s, laser_on_sampled: %s", on, on_s)
ctx.check(on == 0, "LASER_ON active at idle")
ctx.check(on_s == 0, "LASER_ON samples seen at idle (%s)", on_s)
faults = hw.sysfs_int("cnc/faults")
ev["faults"] = faults
ctx.log("faults: %s", faults)
ctx.check(faults == 0, "stepper faults pending: %s", faults)
st = ctx.forgectrl.status()
ev["forgectrl_laser_locked"] = st.get("laser_locked")
ctx.log("forgectrl /status laser_locked=%s state=%s", st.get("laser_locked"), st.get("state"))
ctx.check(st.get("laser_locked") is True, "forgectrl reports the latch unlocked")
# ---------------------------------------------------------------- K1 + K2
@test("kernel.k1-k2", title="Controlled-stop floor and resume honors the locked latch",
subsystem="kernel", kind="auto", hardware="takeover", always=True, est_min=2,
covers=_KERNEL_COVERS,
requires=["kernel.latch-locked-idle"],
description="K1: a controlled stop mid-run ramps the step frequency down (tens of ms), "
"never consumes the tail as a burst or hangs. K2: with the latch locked, a "
"stop inside the leading pads and a resume with a positive waypoint replays "
"a 2 s FIRE window with laser_enable/laser_on at 0 throughout. Motors locked; "
"duty zero.")
def k1_k2(ctx):
ev = ctx.evidence
with ctx.takeover():
# ---- K1
stream = POWER0 + PAD * (6 * TICK_HZ)
ctx.log("K1: %d bytes = %.1f s of pads at %d Hz, ramp 125000 Hz/s",
len(stream), len(stream) / TICK_HZ, TICK_HZ)
snap(ctx, "K1 pre")
wr("cnc/motor_lock", 15)
wr("cnc/laser_latch", 1)
wr("cnc/ramp_rate", 125000)
wr("cnc/step_freq", TICK_HZ)
with PulseDevice(ctx) as dev:
dev.rewind()
wr("cnc/enable", 1)
ctx.sleep(0.5)
dev.write(stream)
wr("cnc/run", 1)
ctx.sleep(1.5) # well past the accel ramp
st = rd("cnc/state")
ctx.check(st == "running", "K1: expected running before the stop, got %s", st)
t0 = time.time()
wr("cnc/stop", 1)
while time.time() - t0 < 5:
if rd("cnc/state") != "running":
break
dt = time.time() - t0
state = rd("cnc/state")
faults = rd("cnc/faults")
ev["k1"] = {"stop_to_idle_s": round(dt, 4), "state": state, "faults": faults}
ctx.log("K1 controlled stop: state=%s after %.4f s, faults=%s", state, dt, faults)
# drain the paused remainder laser-less so the device ends clean
wr("cnc/resume", 0)
wait_state(ctx, "running", 2, poll=0.005)
wait_state(ctx, "idle", 10)
ctx.check(state == "idle", "K1: state %s after the stop", state)
ctx.check(dt >= 0.02, "K1: stop consumed the tail as a burst (%.4f s) - decel floor broken", dt)
ctx.check(dt <= 3.0, "K1: stop took %.4f s", dt)
ctx.check(faults == "0", "K1: faults=%s", faults)
ctx.log("K1 PASS: decelerating tail %.4f s, no burst, no fault", dt)
# ---- K2
# 1000 X steps out and 1000 back at 2 kHz (masked by motor_lock):
# the position counters end where they started
step_sec = (XSTEP + PAD * 4) * 1000 + (XSTEP_BACK + PAD * 4) * 1000
stream = (POWER0 + PAD * TICK_HZ + step_sec + PAD * (TICK_HZ // 2)
+ FIRE * (2 * TICK_HZ) + PAD * TICK_HZ)
ctx.log("K2: %d bytes = %.1f s; latch stays LOCKED; waypoint +200; motor_lock=15",
len(stream), len(stream) / TICK_HZ)
snap(ctx, "K2 pre")
wr("cnc/motor_lock", 15)
wr("cnc/laser_latch", 1)
wr("cnc/ramp_rate", 125000)
wr("cnc/step_freq", TICK_HZ)
with PulseDevice(ctx) as dev:
dev.rewind()
wr("cnc/enable", 1)
ctx.sleep(0.5)
dev.write(stream)
pos_before = rd_pos()
wr("cnc/run", 1)
ctx.sleep(0.4) # inside the initial pads
wr("cnc/stop", 1)
state = wait_state(ctx, "idle", 5, poll=0.01)
ctx.check(state == "idle", "K2: controlled stop did not reach idle (state=%s)", state)
ctx.log("K2: paused inside the pads; resuming with waypoint +200 (latch LOCKED)")
wr("cnc/resume", 200)
wait_state(ctx, "running", 2, poll=0.005)
hits, state = watch_laser_until_idle(ctx, 20)
pos_after = rd_pos()
snap(ctx, "K2 post")
ev["k2"] = {"hits": hits[:10], "end_state": state, "pos_before": pos_before,
"pos_after": pos_after, "laser_on_sampled": rd("cnc/laser_on_sampled"),
"underruns": rd("cnc/underruns"), "faults": rd("cnc/faults")}
ctx.log("K2 done: state=%s pos before=%s after=%s", state, pos_before, pos_after)
ctx.check(not hits, "K2: laser asserted with the latch locked: %s", hits[:10])
ctx.check(pos_before[:3] == pos_after[:3],
"K2: position counters did not return to start (%s -> %s)", pos_before[:3], pos_after[:3])
ctx.log("K2 PASS: FIRE window replayed after the resume waypoint with "
"laser_enable/laser_on at 0 throughout")
# ---------------------------------------------------------------- dead man
@test("kernel.deadman-close", title="The final close of a running pulse device halts the engine and safes the head",
subsystem="kernel", kind="auto", hardware="takeover", always=True, est_min=1,
covers=_KERNEL_COVERS,
requires=["kernel.k1-k2"],
description="The kernel dead man's switch: a pulse device closed while locked and running "
"halts the engine at once, locks the laser latch, and puts the head in its safe "
"state - lens driver disabled at low current, measure laser and UV LED off. The "
"lens driver is enabled at low current before the close, so the safe state has "
"something to undo. Motors locked; duty zero.")
def deadman_close(ctx):
ev = ctx.evidence
with ctx.takeover():
stream = POWER0 + PAD * (6 * TICK_HZ)
snap(ctx, "deadman pre")
wr("cnc/motor_lock", 15)
wr("cnc/laser_latch", 1)
wr("cnc/ramp_rate", 125000)
wr("cnc/step_freq", TICK_HZ)
wr("head/z_current", 1)
wr("head/z_enable", 0)
before = {"z_enable": rd("head/z_enable"), "z_current": rd("head/z_current")}
ev["head_before"] = before
ctx.check(before["z_enable"] == "0", "the lens driver did not enable (z_enable %s)", before["z_enable"])
with PulseDevice(ctx) as dev:
dev.rewind()
wr("cnc/enable", 1)
ctx.sleep(0.5)
dev.write(stream)
wr("cnc/run", 1)
ctx.sleep(1.0)
st = rd("cnc/state")
ctx.check(st == "running", "expected running before the close, got %s", st)
t0 = time.time()
dev.drop()
while time.time() - t0 < 5:
if rd("cnc/state") != "running":
break
dt = time.time() - t0
state = rd("cnc/state")
after = {"z_enable": rd("head/z_enable"), "z_current": rd("head/z_current"),
"measure_laser": rd("head/measure_laser"), "uv_led": rd("head/uv_led")}
latch = hw.sysfs_int("cnc/interlock_circuit")
ev["close"] = {"halt_s": round(dt, 4), "state": state, "faults": rd("cnc/faults"),
"interlock_circuit": latch}
ev["head_after"] = after
snap(ctx, "deadman post")
ctx.log("final close: state=%s after %.4f s; head %s", state, dt, after)
ctx.check(state == "idle", "the engine did not halt on the final close (state %s)", state)
ctx.check(dt < 1.0, "the halt took %.3f s", dt)
ctx.check(latch is not None and latch & (1 << 3), "the latch is not locked after the close")
ctx.check(after["z_enable"] == "1" and after["z_current"] == "1",
"the head was not safed: z_enable %s z_current %s (want 1, 1: driver disabled, low current)",
after["z_enable"], after["z_current"])
ctx.check(after["measure_laser"] == "0" and after["uv_led"] == "0",
"measure laser %s, UV LED %s after the close", after["measure_laser"], after["uv_led"])
wr("cnc/stop", 1)
ctx.log("PASS: the final close halted the engine in %.3f s, latch locked, head safed", dt)
# ---------------------------------------------------------------- backtrack
@test("kernel.backtrack-bounds", title="A backward run is bounded by the history the ring still holds",
subsystem="kernel", kind="auto", hardware="takeover", always=True, est_min=2,
covers=_KERNEL_COVERS,
requires=["kernel.k1-k2"],
description="A pause walks the program backward to put the beam back over ground the job "
"already cut, and the ring is what remembers that ground. cnc/max_backtrack is "
"the distance still available: what has played, less the tail the deceleration "
"spends. This drills the boundary on real hardware - the readback matches the "
"bytes played, a step beyond it is refused rather than quietly shortened, and "
"the run at the boundary plays out and returns to idle. Motors locked, latch "
"locked, duty zero: nothing moves and nothing fires.")
def backtrack_bounds(ctx):
ev = ctx.evidence
tail = TICK_HZ * TICK_HZ // (2 * 125000) # v^2/2a: 400 steps at the print tick
with ctx.takeover():
stream = POWER0 + PAD * (6 * TICK_HZ)
ctx.log("%d bytes = %.1f s of pads at %d Hz; decel tail %d steps",
len(stream), len(stream) / TICK_HZ, TICK_HZ, tail)
snap(ctx, "pre")
wr("cnc/motor_lock", 15)
wr("cnc/laser_latch", 1)
wr("cnc/ramp_rate", 125000)
wr("cnc/step_freq", TICK_HZ)
with PulseDevice(ctx) as dev:
dev.rewind()
wr("cnc/enable", 1)
ctx.sleep(0.5)
dev.write(stream)
wr("cnc/run", 1)
ctx.sleep(1.5) # well past the accel ramp
wr("cnc/stop", 1)
state = wait_state(ctx, "idle", 5, poll=0.01)
ctx.check(state == "idle", "the controlled stop did not reach idle (state=%s)", state)
played = rd_pos()[3]
budget = int(rd("cnc/max_backtrack"))
ev["played"] = played
ev["max_backtrack"] = budget
ctx.log("played %d bytes; max_backtrack %d", played, budget)
ctx.check(budget > 0, "max_backtrack is %d after %d bytes played", budget, played)
# The two numbers come from different SDMA registers (the byte
# counter and the ring head), so allow a few bytes of skew - but
# not the whole gap, and not the whole played span.
ctx.check(abs(budget - (played - tail)) <= 8,
"max_backtrack %d is not the %d bytes played less the %d-step decel tail",
budget, played, tail)
# One step past the boundary: refused, and the device stays idle.
refused = None
try:
wr("cnc/resume", -(budget + 1))
except OSError as e:
refused = e.errno
ev["over_long_errno"] = refused
ctx.check(refused == errno.EPERM,
"a backtrack one step past the boundary was not refused with EPERM (%s)",
refused)
state = rd("cnc/state")
ctx.check(state == "idle", "the refused backtrack left the device %s", state)
# At the boundary: runs, decelerates inside genuine data, ends idle.
wr("cnc/resume", -budget)
wait_state(ctx, "running", 2, poll=0.005)
hits, state = watch_laser_until_idle(ctx, 30)
after = int(rd("cnc/max_backtrack"))
ev["after"] = {"state": state, "max_backtrack": after,
"faults": rd("cnc/faults"), "underruns": rd("cnc/underruns")}
ctx.log("backtrack of %d done: state=%s max_backtrack now %d", budget, state, after)
# Leave the program drained so the device ends where the other
# drills expect it.
wr("cnc/resume", 0)
wait_state(ctx, "running", 2, poll=0.005)
wait_state(ctx, "idle", 30)
ctx.check(not hits, "the laser asserted during the backward run: %s", hits[:10])
ctx.check(state == "idle", "the backward run ended in %s", state)
ctx.check(ev["after"]["faults"] == "0", "faults=%s after the backward run",
ev["after"]["faults"])
ctx.log("PASS: max_backtrack tracks the played history, the boundary is enforced, "
"and the run at it plays out clean")
# ---------------------------------------------------------------- K3
def _k3_phase(ctx):
"""K3: the latch unlocked during the accel ramp must not restore the FIRE
drive while the run is in flight. Runs inside the caller's takeover."""
ev = ctx.evidence
check_hv_off(ctx)
stream = POWER0 + FIRE * (3 * TICK_HZ) + PAD * (TICK_HZ // 2)
ctx.log("K3: %d bytes = %.1f s of FIRE bits; ramp_rate 10000 Hz/s (~0.9 s accel "
"window); unlock at t=+0.15 s", len(stream), len(stream) / TICK_HZ)
snap(ctx, "K3 pre")
wr("cnc/motor_lock", 15)
wr("cnc/laser_latch", 1)
wr("cnc/step_freq", TICK_HZ)
wr("cnc/ramp_rate", 10000)
try:
with PulseDevice(ctx) as dev:
dev.rewind()
wr("cnc/enable", 1)
ctx.sleep(0.5)
dev.write(stream)
wr("cnc/run", 1)
time.sleep(0.15) # inside the accel ramp
wr("cnc/laser_latch", 0)
ilk = rd("cnc/interlock_circuit")
ctx.log("K3: latch UNLOCKED mid-ramp; interlock=%s (bit 3 should read 0)", ilk)
hits, state = watch_laser_until_idle(ctx, 20)
snap(ctx, "K3 post")
ev["k3"] = {"interlock_after_unlock": ilk, "hits": hits[:10], "end_state": state,
"laser_on_sampled": rd("cnc/laser_on_sampled"),
"underruns": rd("cnc/underruns"), "faults": rd("cnc/faults")}
finally:
wr("cnc/laser_latch", 1)
try:
wr("cnc/ramp_rate", 125000)
except OSError:
ctx.log("WARNING: could not restore ramp_rate=125000")
ctx.check((int(ilk) & LATCH_BIT) == 0, "K3: the unlock did not drive the latch pin (interlock=%s)", ilk)
ctx.check(not hits, "K3: FIRE drive re-armed by a mid-run unlock: %s", hits[:10])
ctx.log("K3 PASS: laser_enable stayed 0 for the entire run after the mid-ramp unlock")
# ---------------------------------------------------------------- FIRE A/B/U
def _fire_stream():
return (POWER0 + # duty zero before any FIRE bit
PAD * TICK_HZ + # 1 s baseline
FIRE * (2 * TICK_HZ) + # 2.000 s FIRE window (bounded by pads)
PAD * TICK_HZ + # 1 s gap
FIRE * (2 * TICK_HZ)) # 2.000 s FIRE window ending AT end-of-data
def _fire_phase(ctx, mode):
"""One phase of fire_test.py; returns the evidence dict."""
unlock = mode in ("B", "U")
underrun_mode = mode == "U"
stream = _fire_stream()
ctx.log("fire %s: stream %d bytes = %.3f s", mode, len(stream), len(stream) / TICK_HZ)
if unlock:
check_hv_off(ctx)
snap(ctx, "fire %s pre" % mode)
wr("cnc/motor_lock", 15)
wr("cnc/step_freq", TICK_HZ)
wr("cnc/laser_latch", 1)
underruns_before = int(rd("cnc/underruns"))
mid = None
tail = None
with PulseDevice(ctx) as dev:
dev.rewind()
wr("cnc/enable", 1)
ctx.sleep(0.5)
dev.write(stream)
pos_before = rd_pos()
if underrun_mode:
wr("cnc/streaming", 1) # end-of-data mid-run = true underrun
ctx.log("fire U: streaming=1, the terminal end-of-data will be a TRUE UNDERRUN")
try:
if unlock:
wr("cnc/laser_latch", 0)
ctx.log("fire %s: latch UNLOCKED for this run", mode)
wr("cnc/run", 1)
t0 = time.time()
state = ""
samples = []
while time.time() - t0 < 20:
ctx.checkpoint()
state = rd("cnc/state")
dt = time.time() - t0
en, on, ons = rd("cnc/laser_enable"), rd("cnc/laser_on"), rd("cnc/laser_on_sampled")
samples.append((round(dt, 2), state, en, on, ons))
if mid is None and 1.5 < dt < 3.0:
mid = {"t": round(dt, 2), "laser_enable": en, "laser_on": on,
"laser_on_sampled": ons, "interlock": rd("cnc/interlock_circuit")}
ctx.log("fire %s mid (inside FIRE window): laser_enable=%s laser_on=%s "
"laser_on_sampled=%s interlock=%s", mode, en, on, ons, mid["interlock"])
if state != "running":
break
time.sleep(0.05)
end_dt = time.time() - t0
tail = {"state": state, "after_s": round(end_dt, 2), "laser_enable": rd("cnc/laser_enable"),
"laser_on": rd("cnc/laser_on")}
ctx.log("fire %s done: state=%s after %.1f s (laser_enable=%s)", mode, state, end_dt,
tail["laser_enable"])
if underrun_mode:
if state == "underrun":
ctx.log("fire U: underrun state reached as EXPECTED; acking via stop")
else:
ctx.log("fire U: WARNING expected underrun state, got %s", state)
wr("cnc/stop", 1)
wr("cnc/streaming", 0)
tail["acked_state"] = rd("cnc/state")
ctx.log("fire U: acked: state=%s", tail["acked_state"])
finally:
wr("cnc/laser_latch", 1)
if underrun_mode:
try:
wr("cnc/streaming", 0)
except OSError:
pass
pos_after = rd_pos()
snap(ctx, "fire %s post" % mode)
ev = {"mid": mid, "tail": tail, "moved": pos_before[:3] != pos_after[:3],
"underruns_before": underruns_before, "underruns_after": int(rd("cnc/underruns")),
"faults": rd("cnc/faults"),
"any_laser_on": any(s[3] != "0" or s[4] != "0" for s in samples),
"any_fire_driven": any(s[2] != "0" for s in samples)}
ctx.log("fire %s: moved=%s underruns %d->%d faults=%s", mode, ev["moved"],
ev["underruns_before"], ev["underruns_after"], ev["faults"])
return ev
@test("kernel.fire-line", title="FIRE line: latch locked, unlocked-unarmed, true underrun, and a "
"mid-run unlock",
subsystem="kernel", kind="auto", hardware="takeover", always=True, est_min=4,
covers=_KERNEL_COVERS,
requires=["kernel.k1-k2"], precheck=hv_off,
steps=["Phases B, U and K3 unlock the latch with a zero-duty stream, so the drill starts only "
"while the HV supply does not report good (true at idle; if it is refused, open the "
"lid - the safety chain holds HV off - and start it again)."],
description="Four phases behind one takeover of the pulse device, all zero duty. A: latch "
"locked, 40 000 streamed FIRE bits, nothing on the FIRE/LASER_ON nets. B: latch "
"unlocked with the chain unarmed, the FIRE line is driven mid-window and "
"LASER_ON stays off (the safety AND-gate holds), FIRE clear at end-of-data. U: "
"streaming declared, the terminal end-of-data is a true underrun, the backstop "
"drops FIRE and stop acks it. K3: the latch unlocked during the accel ramp "
"drives the latch pin but never restores the FIRE drive to a run already in "
"flight - laser_enable stays 0 for the whole run.")
def fire_line(ctx):
ev = ctx.evidence
require_hv_off(ctx)
with ctx.takeover():
try:
a = _fire_phase(ctx, "A")
ev["A"] = a
ctx.check(a["mid"] is not None, "A: no mid-window sample")
ctx.check(not a["any_fire_driven"], "A: FIRE line driven with the latch locked")
ctx.check(not a["any_laser_on"], "A: LASER_ON seen with the latch locked")
ctx.check(a["tail"]["state"] == "idle", "A: ended in %s", a["tail"]["state"])
ctx.check(not a["moved"], "A: position moved with motors locked")
ctx.log("fire A PASS: latch locked, no FIRE drive, no LASER_ON")
b = _fire_phase(ctx, "B")
ev["B"] = b
ctx.check(b["mid"] is not None, "B: no mid-window sample")
ctx.check(b["mid"]["laser_enable"] != "0",
"B: FIRE line not driven mid-window with the latch unlocked (%s)", b["mid"])
ctx.check(not b["any_laser_on"], "B: LASER_ON active with the chain unarmed - the AND-gate did not hold")
ctx.check(b["tail"]["state"] == "idle", "B: ended in %s", b["tail"]["state"])
ctx.check(b["tail"]["laser_enable"] == "0", "B: FIRE still driven after end-of-data")
ctx.check(b["underruns_after"] == b["underruns_before"], "B: underrun counted on a normal completion")
ctx.log("fire B PASS: FIRE driven mid-window, LASER_ON off, FIRE clear at end-of-data")
u = _fire_phase(ctx, "U")
ev["U"] = u
ctx.check(u["tail"]["state"] == "underrun", "U: expected the underrun state, got %s", u["tail"]["state"])
ctx.check(u["tail"]["laser_enable"] == "0", "U: FIRE still driven after the underrun")
ctx.check(not u["any_laser_on"], "U: LASER_ON active with the chain unarmed")
ctx.check(u["tail"].get("acked_state") == "idle", "U: stop did not ack the underrun (state %s)",
u["tail"].get("acked_state"))
ctx.check(u["underruns_after"] == u["underruns_before"] + 1,
"U: underrun counter %d -> %d", u["underruns_before"], u["underruns_after"])
ctx.log("fire U PASS: true underrun, backstop dropped FIRE, stop acked")
_k3_phase(ctx)
finally:
wr("cnc/laser_latch", 1)
try:
wr("cnc/disable", 1) # the script's safe state
except OSError:
pass
ctx.log("safe state restored: state=%s latch=LOCKED", rd("cnc/state"))
# ---------------------------------------------------------------- resume lead
@test("kernel.resume-lead", title="A resume's laser-off lead, and an end-of-data before the waypoint",
subsystem="kernel", kind="auto", hardware="takeover", est_min=2,
covers=_KERNEL_COVERS,
requires=["kernel.k1-k2"], precheck=hv_off,
steps=["Phase L unlocks the latch with a zero-duty stream, so the drill starts only while "
"the HV supply does not report good (true at idle; if it is refused, open the lid - "
"the safety chain holds HV off - and start it again)."],
description="Two phases behind one takeover, zero duty, motors locked, a 1 kHz tick. E: a "
"resume whose lead is longer than the data (the ring drains before the waypoint) "
"ends the run at end-of-data within a few ticks, not one re-notify period (255 "
"ticks) later: the script publishes end-of-data in its mailbox and the host "
"decodes on that, not on the waypoint it was still waiting for. L: with the "
"latch unlocked and the chain unarmed, a resume with a 1000-byte lead over a "
"stream of FIRE bits keeps the FIRE line low through the lead and drives it from "
"the waypoint byte on. The script masks the laser bits itself, so no host write "
"reaches the GPIO data register while the script runs; LASER_ON stays off (the "
"safety AND-gate holds).")
def resume_lead(ctx):
ev = ctx.evidence
require_hv_off(ctx)
tick = 1000
with ctx.takeover():
try:
# ---- E: end-of-data before the waypoint
stream = POWER0 + PAD * (tick // 2) # 0.5 s of pads
expected = len(stream) / tick
ctx.log("E: %d bytes = %.1f s of pads at %d Hz; resume with a %d-byte lead (never reached)",
len(stream), expected, tick, 10 * tick)
wr("cnc/motor_lock", 15)
wr("cnc/laser_latch", 1)
wr("cnc/ramp_rate", 125000)
wr("cnc/step_freq", tick)
snap(ctx, "E pre")
with PulseDevice(ctx) as dev:
dev.rewind()
wr("cnc/enable", 1)
ctx.sleep(0.5)
dev.write(stream)
underruns_before = int(rd("cnc/underruns"))
t0 = time.time()
wr("cnc/resume", 10 * tick)
state = wait_state(ctx, "running", 2, poll=0.002)
ctx.check(state == "running", "E: the resume did not start (state=%s)", state)
state = wait_state(ctx, "idle", 5, poll=0.002)
dt = time.time() - t0
ev["E"] = {"run_to_idle_s": round(dt, 3), "data_s": expected, "state": state,
"underruns_before": underruns_before,
"underruns_after": int(rd("cnc/underruns")), "faults": rd("cnc/faults")}
ctx.log("E: %s after %.3f s (the data is %.3f s; a lost end-of-data would add %.3f s)",
state, dt, expected, 255.0 / tick)
ctx.check(state == "idle", "E: state %s after the data ran out", state)
ctx.check(dt < expected + 0.15,
"E: the run ended %.3f s after its data: end-of-data decoded late", dt - expected)
ctx.check(ev["E"]["underruns_after"] == underruns_before,
"E: an underrun was counted on a normal completion")
ctx.check(ev["E"]["faults"] == "0", "E: faults=%s", ev["E"]["faults"])
ctx.log("E PASS: end-of-data before the waypoint ended the run on time")
# ---- L: the lead, latch unlocked, chain unarmed
check_hv_off(ctx)
lead = tick # 1.0 s of FIRE bits held off
lead_s = lead / tick
stream = POWER0 + FIRE * (2 * tick) + PAD * (tick // 5)
ctx.log("L: %d bytes = %.1f s (%.1f s of FIRE bits then pads); lead %d bytes = %.1f s; "
"latch UNLOCKED, chain unarmed", len(stream), len(stream) / tick,
2 * tick / tick, lead, lead_s)
wr("cnc/motor_lock", 15)
wr("cnc/laser_latch", 1)
wr("cnc/step_freq", tick)
snap(ctx, "L pre")
samples = []
state = ""
with PulseDevice(ctx) as dev:
dev.rewind()
wr("cnc/enable", 1)
ctx.sleep(0.5)
dev.write(stream)
try:
wr("cnc/laser_latch", 0)
t0 = time.time()
wr("cnc/resume", lead)
n = 0
while time.time() - t0 < 10:
en, on = rd("cnc/laser_enable"), rd("cnc/laser_on")
samples.append((round(time.time() - t0, 4), en, on))
state = rd("cnc/state")
if state != "running" and time.time() - t0 > 0.2:
break
n += 1
if n % 200 == 0:
ctx.checkpoint()
finally:
wr("cnc/laser_latch", 1)
snap(ctx, "L post")
driven = [s for s in samples if s[1] != "0"]
first = driven[0][0] if driven else None
last = driven[-1][0] if driven else None
inside_lead = [s for s in driven if s[0] < lead_s - 0.02]
ev["L"] = {"samples": len(samples), "driven_samples": len(driven),
"first_driven_s": first, "last_driven_s": last, "end_state": state,
"any_laser_on": any(s[2] != "0" for s in samples),
"inside_lead": inside_lead[:5], "laser_on_sampled": rd("cnc/laser_on_sampled"),
"faults": rd("cnc/faults")}
ctx.log("L: %d samples, FIRE driven in %d, first at %s s, last at %s s, end state %s",
len(samples), len(driven), first, last, state)
ctx.check(state == "idle", "L: ended in %s", state)
ctx.check(not inside_lead, "L: FIRE driven inside the lead: %s", inside_lead[:5])
ctx.check(driven, "L: FIRE never driven after the lead (the inhibit never cleared)")
ctx.check(first is not None and first < lead_s + 0.3,
"L: FIRE first driven at %s s; the lead ends at %.1f s", first, lead_s)
ctx.check(not ev["L"]["any_laser_on"],
"L: LASER_ON active with the chain unarmed - the AND-gate did not hold")
ctx.check(rd("cnc/laser_enable") == "0", "L: FIRE still driven after end-of-data")
ctx.log("L PASS: FIRE low through the %.1f s lead, driven from the waypoint on, LASER_ON off",
lead_s)
finally:
wr("cnc/laser_latch", 1)
try:
wr("cnc/ramp_rate", 125000)
wr("cnc/disable", 1) # the script's safe state
except OSError:
pass
ctx.log("safe state restored: state=%s latch=LOCKED", rd("cnc/state"))
# ---------------------------------------------------------------- PIC and the SoC's load
PIC_SETTLE_PARAM = "/sys/module/glowforge/parameters/pic_settle_us"
def _param_read(path):
try:
with open(path) as f:
return f.read().strip()
except OSError:
return None
def _param_write(path, value):
with open(path, "w") as f:
f.write("%s\n" % value)
def _level_stats(xs):
s = sorted(xs)
n = len(s)
return {"n": n, "mean": round(sum(s) / float(n), 2), "med": s[n // 2],
"iqr": s[(3 * n) // 4] - s[n // 4], "min": s[0], "max": s[-1]}
def _spin(sec):
t0 = time.perf_counter()
x = 1.0
while time.perf_counter() - t0 < sec:
x = x * 1.000001 + 0.5
return x
@test("kernel.pic-soc-load", title="A PIC reading does not depend on what the reader was doing",
subsystem="kernel", kind="auto", est_min=1,
covers=_KERNEL_COVERS + [("forgectrl", "src/diag.c")],
description="The PIC converts its sensors in a free-running loop and a read returns the "
"last conversion of that channel; the count depends on the SoC's load when the "
"conversion was made (the PIC converts against its own supply, the dividers hang "
"on the board's reference), about 6 counts between an idle and a busy CPU on the "
"coolant thermistors. The module keeps the CPU busy for pic_settle_us before every "
"transaction, longer than one PIC loop, so the value read was converted under the "
"same load whoever reads. The drill reads a coolant thermistor 200 times each after "
"3 ms of sleep and after 3 ms of spinning, with the settle off (the control, "
"reported) and on (the claim): settled, the two agree.")
def pic_soc_load(ctx):
ev = ctx.evidence
saved = _param_read(PIC_SETTLE_PARAM)
ctx.check(saved is not None, "the module has no pic_settle_us parameter (%s)", PIC_SETTLE_PARAM)
ev["pic_settle_us_before"] = saved
fd = os.open(hw.sysfs_root() + "pic/water_temp_1", os.O_RDONLY)
def reads(regime, n=200):
xs = []
for i in range(n):
if regime == "idle":
time.sleep(0.003)
else:
_spin(0.003)
xs.append(int(os.pread(fd, 32, 0).strip()))
if i % 100 == 99:
ctx.checkpoint()
return _level_stats(xs)
try:
_param_write(PIC_SETTLE_PARAM, 0)
ctx.sleep(0.05)
control = {"idle": reads("idle"), "busy": reads("busy")}
_param_write(PIC_SETTLE_PARAM, 500)
ctx.sleep(0.05)
settled = {"idle": reads("idle"), "busy": reads("busy")}
finally:
os.close(fd)
_param_write(PIC_SETTLE_PARAM, saved if saved not in (None, "0") else 500)
ev["control"] = control
ev["settled"] = settled
ev["pic_settle_us_after"] = _param_read(PIC_SETTLE_PARAM)
for name, r in (("control (settle off)", control), ("settled (500 us)", settled)):
ctx.log("%s: after sleep median %d (iqr %d, %d..%d); after spin median %d (iqr %d, %d..%d)",
name, r["idle"]["med"], r["idle"]["iqr"], r["idle"]["min"], r["idle"]["max"],
r["busy"]["med"], r["busy"]["iqr"], r["busy"]["min"], r["busy"]["max"])
ev["control_split"] = control["busy"]["med"] - control["idle"]["med"]
ev["settled_split"] = settled["busy"]["med"] - settled["idle"]["med"]
ctx.log("split busy minus idle: control %+d, settled %+d", ev["control_split"], ev["settled_split"])
ctx.check(abs(ev["settled_split"]) <= 2,
"settled reads still split by %+d counts between an idle and a busy reader", ev["settled_split"])
ctx.check(settled["idle"]["iqr"] <= 4 and settled["busy"]["iqr"] <= 4,
"settled reads spread %d / %d counts (interquartile)", settled["idle"]["iqr"], settled["busy"]["iqr"])
# The kernel's spin is its own load level, a couple of counts under a
# Python spin, so a settled reader lands above the idle regime without
# reaching the busy one. The failure this guards is a settle that
# overshoots and lets the conversion fall back to idle, which reads as
# no move at all; the split collapsing above is the primary proof.
#
# The bound is a third of the control split, not half. Measured over
# eleven runs on this bench the move is 3 counts (once 2, once 4)
# against a control split of 6 (once 7), so a half-split bound sits
# exactly on the median and decides on one count of noise: two of those
# eleven runs failed with the machine reading identically to the nine
# that passed. A third keeps the guard against no move at all and
# leaves a count of margin either way.
moved = settled["idle"]["med"] - control["idle"]["med"]
ev["settled_idle_moved"] = moved
ctx.check(ev["control_split"] <= 2 or moved * 3 >= ev["control_split"],
"a settled idle reader moved %+d counts off the idle regime, the control split is %+d: "
"the settle did not land the conversion under load", moved, ev["control_split"])
ctx.check(ev["pic_settle_us_after"] == "500", "pic_settle_us left at %s", ev["pic_settle_us_after"])
ctx.log("PASS: settled, an idle reader and a busy reader read the same (split %+d); control split %+d",
ev["settled_split"], ev["control_split"])
-946
View File
@@ -1,946 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""laser.* - LIVE laser tests, ported from `scripts/bench/live_fire_drills.py`.
The live tests here emit. The page starts a firing one only with the
operator's eye-protection / fire-watch / exhaust acknowledgment; the
test then prompts for the scrap and the button, streams a small job
through the controller, and the machine fires only after the operator
presses the physical arm button - nothing here defeats that gate, and
forgetest never touches the laser latch. One emission per test; on abort
or error the job is soft-reset (`^X`: controlled stop, latch relocked).
The witnesses are forgectrl's `/status` (`laser.emission_samples` = the
kernel's LASER_ON sample count, `hv_current_raw`, `lid_ir`),
`/cool/status` (`armed`), the head's beam detector
(`head/beam_detect_analog`, the tube's own emission seen from the head:
a few hundred counts over its idle level while the beam is on, and
`beam_detect_digital` asserted), and the button LEDs (lit while the arm
waits or the window is open, dark after the disarm) - all sampled at
~8 Hz through the arm -> fire -> disarm lifecycle. The one thing the
operator still judges by eye is the mark the emission witness leaves,
once per campaign: the calibration of the sensor witnesses.
"""
import time
from ..catalog import test
from .. import hw
from ..runner import Failed
from .motion import kernel_start, check_kernel_returned, Watch
_LASER_COVERS = [("grblhal-glowforge", "src/**"), ("kernel-module-glowforge", "**"),
("forgectrl", "src/super.c"), ("forgectrl", "src/cool.c"),
("forgectrl", "src/status.c"), ("forgectrl", "src/main.c")]
ARM_CUE = ("LIVE FIRE. Eye protection on. Exhaust on. Extinguisher in reach. "
"Put scrap under the head with %s clear, and close the lid.")
# The beam detector's emission signature: this much over its pre-fire
# level while the beam is on (bench: ~1834 idle, 2600-2890 at S300/S400).
BEAM_DELTA_MIN = 300
def sample(ctx):
"""One combined /status + /cool/status sample, or None on error."""
fc = ctx.forgectrl
try:
st1, st = fc.get("/status")
st2, cs = fc.get("/cool/status")
except hw.HwError:
return None
if st1 != 200 or st2 != 200 or not isinstance(st, dict) or not isinstance(cs, dict):
return None
return {
"t": time.time(),
"kstate": st.get("state"),
"emission": (st.get("laser") or {}).get("emission_samples"),
"pgood": (st.get("laser") or {}).get("pgood_samples"),
"faults": st.get("faults"),
"hv": st.get("hv_current_raw"),
"ir": st.get("lid_ir"),
"homed": st.get("homed"),
"armed": cs.get("armed"),
"phase": cs.get("phase"),
"fire_watch": cs.get("fire_watch"),
"verdict": cs.get("verdict"),
"beam": hw.sysfs_int("head/beam_detect_analog"),
"beam_d": hw.sysfs_int("head/beam_detect_digital"),
"exhaust": (st.get("fans") or {}).get("exhaust"),
"button_lit": hw.button_lit(),
"hv_enable": (st.get("switches") or {}).get("hv_enable"),
"button_latch": hw.sysfs_int("cnc/button_latch"),
# the whole readback word (bit 0 LASER_ON, 1 LASER_ENABLE, 2 button
# latch, 3 laser latch, 4 interlock latch reset, 5 charge pump alive)
# and the debounced switches, so a latch that sets mid-job says
# which of its two inputs did it
"il": hw.sysfs_int("cnc/interlock_circuit"),
"lid": (st.get("switches") or {}).get("lid"),
"button": (st.get("switches") or {}).get("button"),
"locked": st.get("laser_locked"),
"gstate": None,
"msgs": "",
}
TRAIL_FIELDS = ("t", "gstate", "kstate", "emission", "armed", "phase", "il", "button_latch",
"locked", "lid", "button", "hv_enable", "beam", "hv", "msgs")
# The cooling phases that run the fans at their idle duty: the engine has
# no run session, so no cut airflow and no flow interrogation. Emission
# in one of these is the beam on the work with idle airflow.
IDLE_AIRFLOW_PHASES = ("idle", "warm-up")
def trail(samples):
"""The sample trail as rows for the evidence: relative seconds and the
fields TRAIL_FIELDS names, so a run's timeline can be read back
without a rerun."""
if not samples:
return []
t0 = samples[0]["t"]
rows = []
for s in samples:
row = [round(s["t"] - t0, 2)]
for f in TRAIL_FIELDS[1:]:
v = s.get(f)
row.append(v if v != "" else None)
rows.append(row)
return rows
def beam_witness(ctx, ev, samples, base, tag=""):
"""The head's beam detector over a sample trail: its peak against the
pre-fire level, and whether the digital flag asserted. Recorded, and
judged where the trail carried emission."""
beams = [s["beam"] for s in samples if s.get("beam") is not None]
base_beam = base.get("beam") if base else None
peak = max(beams) if beams else None
digital = any(s.get("beam_d") for s in samples)
key = ("beam" + ("_" + tag if tag else ""))
ev[key] = {"idle": base_beam, "peak": peak, "delta": (peak - base_beam) if (peak is not None and
base_beam is not None) else None,
"digital_seen": digital, "samples": len(beams)}
ctx.log("beam detector%s: idle %s, peak %s, digital asserted %s", (" [%s]" % tag) if tag else "",
base_beam, peak, digital)
return ev[key]
def judge_beam(ctx, b, what="the burn"):
ctx.check(b["samples"] > 0, "the beam detector was not readable during %s", what)
ctx.check(b["delta"] is not None and b["delta"] >= BEAM_DELTA_MIN,
"the head's beam detector did not see %s (idle %s, peak %s; the tube did not lase, "
"or the detector is not reading)", what, b["idle"], b["peak"])
def check_button_dark(ctx, ev, key="button_dark", settle_s=5.0):
"""The button commanded dark (the LEDs' target level) within a few
seconds: the controller writes it a moment after the cancel or the
disarm it reports."""
dt = ctx.wait_for(lambda: hw.button_lit() is False, settle_s)
lit = hw.button_lit()
ev[key] = lit
ctx.check(lit is not None, "the button LEDs are not readable")
ctx.check(lit is False, "the button is still lit %.0f s after it should have gone dark", settle_s)
if dt:
ctx.log("button dark after %.1f s", dt)
def prepare(ctx, g):
"""Guarantee a clean Idle start: clear a latched Door hold or an Alarm."""
st = g.status_report()
if "Door" in st["state"] or "Hold" in st["state"]:
g.realtime(0x18)
ctx.sleep(2)
g.drain()
st = g.status_report()
if "Alarm" in st["state"]:
ctx.log("unlock: %s", g.command("$X"))
st = g.status_report()
ctx.log("connect: %s", st["state"])
ctx.check(st["state"].startswith("Idle"), "controller is %s, expected Idle", st["state"])
return st
def stream(g, lines):
for ln in lines:
g.send_raw((ln + "\n").encode())
MARK_JOB = ["G91", "G21", "M4", "S400",
"G1 X40 F200", "G1 Y40 F200", "G1 X-40 F200", "G1 Y-40 F200",
"M5", "G90", "M2"]
# The same square at constant power. M4 scales power with speed, which hides
# what a restart does to the cut; M3 puts it on the scrap where the operator
# can see it - the deeper spot where a resumed cut accelerates from zero.
MARK_JOB_M3 = ["M3" if ln == "M4" else ln for ln in MARK_JOB]
def arm_and_fire(ctx, g, room="40 mm +X and +Y", job=None, timeout=240):
"""The arm cue, the job, and the wait for the emission witness - the
prologue every live test shares. Returns the first sample with emission,
or soft-resets and fails: no emission means the arm was refused or the
button was never pressed."""
ctx.ready(ARM_CUE % room)
stream(g, job or MARK_JOB)
ctx.arm_press()
t0 = time.time()
while time.time() - t0 < timeout:
ctx.checkpoint()
smp = sample(ctx)
if smp and smp["emission"] and smp["emission"] > 0:
ctx.clear_notice()
return smp
time.sleep(0.15)
ctx.clear_notice()
g.realtime(0x18)
raise Failed("no emission seen within %d s (arm refused, or no button press)" % timeout)
def wait_grbl_port(ctx, timeout=30):
"""The controller's Grbl listener is accepting again. /mode reporting the
process running is not the same thing: the supervisor has spawned it, but
the socket may not be bound yet, and a bare connect would fail the test on
a race rather than on the behavior it is about."""
end = time.time() + timeout
while time.time() < end:
ctx.checkpoint()
try:
with ctx.grbl():
return True
except OSError:
time.sleep(1)
return False
def kill_trail(ctx, t0, seconds=5.0):
"""Sample emission / kernel state / armed for `seconds` after a kill."""
trail = []
for _ in range(int(seconds / 0.12)):
s = sample(ctx)
if s:
trail.append((round(time.time() - t0, 2), s["emission"], s["kstate"], s["armed"]))
time.sleep(0.12)
return trail
def kill_fast_trail(t0, seconds=1.5):
"""The latch and the kernel state read from sysfs every few
milliseconds after a kill: the supervisor's own reaction, which the
HTTP samples of kill_trail cannot resolve. A controller death is a
signal to the supervisor, not a poll, so the relock lands within
milliseconds; the kernel leaves running once its stop ramp is done."""
locked_at = stopped_at = None
end = t0 + seconds
while time.time() < end and (locked_at is None or stopped_at is None):
now = time.time() - t0
ilk = hw.sysfs_int("cnc/interlock_circuit")
if locked_at is None and ilk is not None and ilk & IL_LASER_LATCH:
locked_at = round(now, 3)
st = hw.sysfs_read("cnc/state")
if stopped_at is None and st is not None and st != "running":
stopped_at = round(now, 3)
time.sleep(0.005)
return {"latch_locked_at_s": locked_at, "kernel_stopped_at_s": stopped_at}
def judge_kill(ctx, trail, what):
"""(first zero, tail stayed zero, kernel stopped running) from a trail."""
for t in trail:
ctx.log(" post-%s %s", what, t)
zero_at = next((t for t, e, _, _ in trail if e == 0), None)
tail_zero = all(e == 0 for _, e, _, _ in trail[-16:])
not_running = all(k != "running" for _, _, k, _ in trail[-16:])
ctx.log("emission first 0 at +%s s; last 2 s all zero: %s; kernel not running: %s",
zero_at, tail_zero, not_running)
return zero_at, tail_zero, not_running
IL_BUTTON_LATCH = 4 # cnc/interlock_circuit bit 2
IL_LASER_LATCH = 8 # bit 3: the SoC lock line, 1 = locked
def dwell_gap(samples):
"""The dwell-gap verdict over a sample trail. The button latch has
two set inputs, the lid and the SoC lock, so while the lock line
reads released the latch must read clear: from the first sample
with emission (the press has cleared it by then) through every
sample whose readback word shows the laser latch unlocked, which
spans the kernel-run gap of the dwell and ends at the relock. Both
bits come from the same word, so no lagging flag can misplace the
window: the engine's armed flag follows the controller's next report,
and the emission counter latches once per second and stays nonzero
about two seconds past the relock (three runs on the bench failed on
windows drawn from those). HV_ENABLE's dip in the gap and its return
with emission after it are judged from the first emission on."""
first = next((i for i, s in enumerate(samples) if s["emission"]), None)
span = ([s for s in samples[first:] if s.get("il") is not None and not (s["il"] & IL_LASER_LATCH)]
if first is not None else [])
latch = [1 if (s["il"] & IL_BUTTON_LATCH) else 0 for s in span]
set_at = [i for i, v in enumerate(latch) if v]
hv_en = [s.get("hv_enable") for s in samples[first:]] if first is not None else []
dip = next((i for i, v in enumerate(hv_en) if v is False), None)
back_lit = dip is not None and any(
s.get("hv_enable") and s["emission"] for s in samples[first + dip:])
return {"button_latch_unlocked_max": max(latch) if latch else None,
"button_latch_unlocked_samples": len(latch),
"button_latch_set_at": set_at[:8],
"hv_enable_dipped": dip is not None, "hv_enable_back_lit": back_lit}
def read_gaps(samples, key, t_end=None):
"""Over a sample trail: how many samples carry a reading for `key`,
how many do not, and the longest stretch of seconds between two
readings, the trail's ends counted as edges (an unread lead or tail
is a gap too; a trail with no reading is one gap, its whole span).
The trail ends when its sampling stopped (`t_end`), not at the last
sample served: a tail the daemon did not serve at all is a gap too.
A reading the daemon could not serve is a gap, never a dark sample:
/status leaves the laser block out when the kernel's count did not
read, and the count latches once per ~1 s window, so a window with
no read in it is a window in which a fire is not witnessed."""
if not samples:
return {"read": 0, "unread": 0, "gap_max_s": None}
ts = [s["t"] for s in samples if s.get(key) is not None]
last = samples[-1]["t"] if t_end is None else max(t_end, samples[-1]["t"])
edges = [samples[0]["t"]] + ts + [last]
gap = max(b - a for a, b in zip(edges, edges[1:]))
return {"read": len(ts), "unread": len(samples) - len(ts), "gap_max_s": round(gap, 3)}
def rapid_place(stretch, x0):
"""Where the head's reports place the rapid after the M5, judged over
the armed part of the dark stretch alone: its samples before the first
disarmed one. Returns (i_out, i_back, i_closed): the first sample with
the head at least 19.5 mm out, the first after it with the head back
at x0 (within 0.05 mm), and the first disarmed sample; each None when
there is none. A window that closes before the head is back leaves
i_back None: only a sample that read the window open places the head
back, and sample_to_disarm reads its X before its armed flag, so the
head was back with the window open. The first disarmed sample does
not tell whether the head came back before the close or after it."""
i_closed = next((i for i, s in enumerate(stretch) if not s["armed"]), None)
part = stretch if i_closed is None else stretch[:i_closed]
i_out = next((i for i, s in enumerate(part) if s.get("x") is not None and s["x"] - x0 >= 19.5), None)
i_back = (next((i for i in range(i_out + 1, len(part))
if part[i].get("x") is not None and abs(part[i]["x"] - x0) < 0.05), None)
if i_out is not None else None)
return i_out, i_back, i_closed
def sample_to_disarm(ctx, g, timeout=20.0, sample_hz=8):
"""Sample until the armed window closes, or the timeout, with the
controller's state and machine X beside each sample ("x"), read just
before the sample: a sample that reads the window open read its X
with the window open (rapid_place). A G4 dwell reports Idle, so Idle
cannot end this; the window closing at the program end does, after
two more kernel windows and the read gap allowed: the LASER_ON count
for the window in flight at the close latches up to one window later
and is held through the next, so the sampling runs on until that
count has been read. Returns the samples served and the time the
sampling stopped, the trail's end for the read rule."""
samples = []
period = 1.0 / sample_hz
t0 = time.time()
end = t0 + timeout
t_closed = None
while time.time() < end:
ctx.checkpoint()
rep = g.status_report()
smp = sample(ctx)
if smp:
smp["gstate"] = rep.get("state")
smp["x"] = (rep.get("MPos") or (None,))[0]
samples.append(smp)
if t_closed is None and not smp["armed"]:
t_closed = time.time()
end = t_closed + 2 * EMISSION_WINDOW_S + EMISSION_READ_GAP_S
time.sleep(period)
return samples, time.time()
def run_and_sample(ctx, g, job, sample_hz=8, overall_timeout=200):
"""Stream the job; sample forgectrl through arm -> fire -> disarm.
Completes on: emission seen then Idle > 3 s; or armed then disarmed
with no fire, Idle > 3 s, > 15 s in; or the overall timeout."""
samples = []
period = 1.0 / sample_hz
s0 = sample(ctx)
if s0:
samples.append(s0)
stream(g, job)
t_start = time.time()
next_t = t_start
seen_emission = seen_armed = disarmed_now = False
idle_since = None
while time.time() - t_start < overall_timeout:
ctx.checkpoint()
now = time.time()
if now >= next_t:
smp = sample(ctx)
if smp:
samples.append(smp)
if smp["emission"] and smp["emission"] > 0:
seen_emission = True
if smp["armed"]:
seen_armed = True
disarmed_now = seen_armed and not smp["armed"]
next_t = now + period
st = g.status_report()["state"]
if samples:
# the controller's state and whatever it said since the last
# sample, next to the sample they belong to
samples[-1]["gstate"] = st
said = " ".join(l.strip() for l in g.drain().splitlines()
if l.strip() and l.strip() != "ok")
if said:
samples[-1]["msgs"] = (samples[-1]["msgs"] + " " + said).strip()[:400]
if st.startswith("Idle"):
if idle_since is None:
idle_since = now
idle_for = now - idle_since
if seen_emission and idle_for > 3.0:
break
if disarmed_now and (now - t_start) > 15 and idle_for > 3.0:
break
else:
idle_since = None
time.sleep(0.05)
return samples
class LiveJob:
"""Leaves the laser commanded off on every exit; a soft reset on
abort/failure stops motion, relocks, and closes the armed window."""
def __init__(self, ctx, g):
self.ctx, self.g = ctx, g
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
try:
if exc_type is not None:
self.ctx.log("stopping the job: soft reset (%s)", exc_type.__name__)
self.g.realtime(0x18)
time.sleep(1)
self.g.command("M5", timeout=1)
except Exception: # noqa: BLE001 - best effort on the way out
pass
return False
def wait_disarm(ctx, timeout):
t0 = time.time()
while time.time() - t0 < timeout:
ctx.checkpoint()
s = sample(ctx)
if s and not s["armed"]:
return time.time() - t0
time.sleep(0.5)
return None
@test("laser.emission-witness", title="Live emission witness (S400 vector mark) and job-based disarm",
subsystem="laser", kind="live", mode="grbl", always=True, est_min=5,
covers=_LASER_COVERS,
requires=["kernel.latch-locked-idle", "kernel.k1-k2", "motion.jog-roundtrip"],
actions=["button"],
steps=["Scrap under the head with 20 mm of free +X and +Y travel; lid closed; exhaust on.",
"Press the physical button when it lights white (the arm).",
"At the end, confirm the square the laser marked, all four sides: the one judgment "
"by eye in the campaign, the calibration of the sensor witnesses."],
description="A 20 mm square outline at S400/F600 in dynamic laser mode with a 2 s dwell "
"(G4) between its second and third sides: emission_samples (the kernel's "
"LASER_ON sample count) goes nonzero during the fire window and returns to 0 "
"at Idle, HV current rises during the burn, the head's beam detector sees the "
"beam, and the armed window is observed. Across the dwell the kernel run ends "
"and restarts: HV_ENABLE drops with the charge-pump watchdog and is back for "
"the third side, while the hardware button latch stays clear (cnc/button_latch "
"0 in every armed sample), so the second half of the square marks without a "
"second press. The square ends at M5 with the window still open; once the "
"LASER_ON count reads 0 (the dark point), a rapid runs 20 mm out over the "
"first side and back, then the program ends. From the dark point until the "
"window closes, and on until the count of the window in flight at the close is "
"read, every sample reads no emission and the HV current at its idle reading, "
"the kernel's LASER_ON count read in every ~1 s window of that stretch (a "
"reading the daemon could not serve is a gap, never a dark sample, and so is a "
"tail it served none of), and the head's reports place the rapid inside the "
"window, between the dark point and the close: LASER_ON stays off with FIRE "
"clear while the chain can fire. The M2 program end disarms promptly after the "
"head is back (job-based, not the 60 s idle grace) and the button goes "
"dark. The operator confirms all four sides.")
def emission_witness(ctx):
ev = ctx.evidence
# The rapid after the M5 (G91 is modal from the square): out over the
# first side and back, then the program end. The head stands back for
# 1 s before the M2, so that a sample of a slow sampling loop still
# reads it back with the window open (rapid_place).
rapid_tail = ["G0 X20", "G4 P2.5", "G0 X-20", "G4 P1", "G90", "M2"]
with ctx.grbl() as g, LiveJob(ctx, g):
prepare(ctx, g)
base = sample(ctx)
ctx.check(base, "forgectrl /status or /cool/status unavailable")
ev["pre_fire"] = base
ctx.log("pre-fire: emission=%s hv=%s armed=%s verdict=%s", base["emission"], base["hv"],
base["armed"], base["verdict"])
ctx.check(not base["emission"], "emission_samples nonzero before the job (%s)", base["emission"])
ctx.ready(ARM_CUE % "20 mm +X and +Y")
# The square ends at M5 with no program end: the window stays open
# for the rapid that follows.
job = ["G91", "G21", "M4", "S400",
"G1 X20 F600", "G1 Y20 F600", "G4 P2", "G1 X-20 F600", "G1 Y-20 F600",
"M5"]
ctx.arm_press()
try:
cut = run_and_sample(ctx, g, job)
# The dark point: the M5 has run, the LASER_ON count reads 0, and
# the window is still open. The rapid is sent only after it, so
# the dark stretch covers the rapid by construction, and the
# mid-square dwell's own zero cannot be taken for it.
ctx.wait_for(lambda: (sample(ctx) or {}).get("emission") == 0, 10, poll=0.125)
dark = sample(ctx)
ctx.check(dark and dark["emission"] == 0,
"the LASER_ON count did not return to 0 after the M5 (%s)", dark and dark["emission"])
ctx.check(dark["armed"], "the armed window closed after the M5, before the rapid "
"(phase %s): the rapid would not run under it", dark["phase"])
# The engine's armed flag can still read true after the latch has
# locked, and a dark reading with the latch locked is the unarmed
# case this sample is here to exclude: the SoC lock line must read
# released.
ctx.check(dark["locked"] is False, "the laser latch is locked at the dark point "
"(laser_locked %s): the rapid would run unarmed", dark["locked"])
rep = g.status_report()
dark["gstate"] = rep.get("state")
dark["x"] = (rep.get("MPos") or (None,))[0]
ctx.check(dark["x"] is not None, "the controller reports no machine position: %s", rep)
stream(g, rapid_tail)
tail, t_end = sample_to_disarm(ctx, g)
finally:
ctx.clear_notice()
samples = cut + [dark] + tail
ev["trail_fields"] = list(TRAIL_FIELDS)
ev["trail"] = trail(samples)
beam = beam_witness(ctx, ev, cut, base)
emis = [s["emission"] for s in cut if s["emission"] is not None]
peak = max(emis) if emis else 0
end = next((s["emission"] for s in reversed(samples) if s["emission"] is not None), None)
hv = [s["hv"] for s in cut if s["hv"] is not None]
ir_peak = [0, 0, 0, 0]
for s in samples:
if s["ir"] and len(s["ir"]) == 4:
for i in range(4):
ir_peak[i] = max(ir_peak[i], s["ir"][i])
armed_seen = any(s["armed"] for s in samples)
# The beam may only come on inside a run session. Until the engine
# has taken the armed window it is still holding the fans at their
# idle duty, and the verdict standing on file is the one it
# computed for the idle session before the arm.
idle_fire = [s for s in samples
if s["emission"] and s["phase"] in IDLE_AIRFLOW_PHASES]
ev.update({"samples": len(samples), "emission_peak": peak, "emission_end": end,
"hv_min": min(hv) if hv else None, "hv_max": max(hv) if hv else None,
"lid_ir_peak": ir_peak, "armed_seen": armed_seen,
"idle_airflow_fire": [(round(s["t"] - samples[0]["t"], 2),
s["phase"], s["emission"]) for s in idle_fire],
"pgood_peak": max((s["pgood"] for s in samples if s["pgood"] is not None), default=None)})
ctx.log("emission_samples peak=%s end=%s; hv %s..%s; lid_ir peak %s; armed seen %s",
peak, end, ev["hv_min"], ev["hv_max"], ir_peak, armed_seen)
# The dwell: a kernel-run gap inside the unlocked window. The button
# latch has no input from the charge-pump watchdog (its SET inputs
# are the lid and the SoC lock), so it must read clear from the
# first emission until the relock, across the gap, while HV_ENABLE
# drops with the watchdog and returns for the third side.
gap = dwell_gap(samples)
ev.update(gap)
ctx.log("dwell gap: button latch through the unlocked window max=%s over %d samples (set at %s); "
"HV_ENABLE dipped=%s, back with emission after it=%s", gap["button_latch_unlocked_max"],
gap["button_latch_unlocked_samples"], gap["button_latch_set_at"], gap["hv_enable_dipped"],
gap["hv_enable_back_lit"])
# The first fire waits for the airflow: the exhaust reads at or
# above its floor on the first sample that shows emission.
floor = (ctx.forgectrl.settings() or {}).get("cool_tach_exhaust_min_rpm") or ""
try:
floor = float(floor) if floor else 6400.0
except ValueError:
floor = 6400.0
first_lit = next((s for s in samples if s["emission"]), None)
ev["exhaust_at_first_fire"] = first_lit["exhaust"] if first_lit else None
ev["exhaust_floor"] = floor
ctx.log("exhaust at the first emission sample: %s rpm (floor %.0f)",
ev["exhaust_at_first_fire"], floor)
# The rapid after the M5, with the window open and FIRE clear: every
# sample from the dark point until the sampling stops (two windows and
# the read gap allowed past the close) reads no emission and the HV
# current at its idle reading, and the kernel's LASER_ON count was read
# in every ~1 s window of that stretch, the one that latches after the
# close included: the end of the sampling is the trail's last edge. A
# reading the daemon could not serve is a gap, never a dark sample.
# The head's reports place the rapid inside the window, from the dark
# point to the close: out to +20 mm, then back at the start.
stretch = [dark] + tail
x0 = dark["x"]
i_out, i_back, i_closed = rapid_place(stretch, x0)
closed = stretch[i_closed] if i_closed is not None else None
armed_part = stretch if i_closed is None else stretch[:i_closed + 1]
relit = [s for s in stretch if s["emission"]]
hv_dark = max((s["hv"] for s in stretch if s["hv"] is not None), default=0)
reads = read_gaps(stretch, "emission", t_end)
hv_reads = read_gaps(stretch, "hv", t_end)
dt = (closed["t"] - stretch[i_back]["t"]) if (closed and i_back is not None) else None
ev["rapid"] = {"samples": len(armed_part), "span_s": round((closed or stretch[-1])["t"] - dark["t"], 2),
"x_start": x0, "x_max": max((s["x"] for s in armed_part if s.get("x") is not None), default=None),
"out_at_s": round(stretch[i_out]["t"] - dark["t"], 2) if i_out is not None else None,
"back_at_s": round(stretch[i_back]["t"] - dark["t"], 2) if i_back is not None else None,
"relit": [(round(s["t"] - dark["t"], 2), s["emission"]) for s in relit][:8],
"hv_max": hv_dark, "locked_at_dark": dark["locked"],
"locked_at_out": stretch[i_out]["locked"] if i_out is not None else None,
"sampled_past_close_s": round(t_end - closed["t"], 2) if closed else None,
"emission_reads": reads, "hv_reads": hv_reads}
ev["disarm_after_idle_s"] = round(dt, 1) if dt is not None else None
ctx.log("the rapid after the M5: %d samples over %.1f s from the dark point to the close, out at +%s s, "
"back at +%s s; sampled %s s past the close, %d of %d samples with emission, HV max %s, %d of %d "
"carried the LASER_ON count, longest unread stretch %s s; the window closed %s s after the head "
"was back", len(armed_part), ev["rapid"]["span_s"], ev["rapid"]["out_at_s"], ev["rapid"]["back_at_s"],
ev["rapid"]["sampled_past_close_s"], len(relit), len(stretch), hv_dark, reads["read"],
len(stretch), reads["gap_max_s"], ev["disarm_after_idle_s"])
ctx.check(armed_seen, "the armed window was never observed (arm refused, or no button press)")
ctx.check(peak > 0, "no emission witnessed on the square (emission_samples stayed 0)")
first_idle_fire = ev["idle_airflow_fire"][0] if idle_fire else (None, None, None)
ctx.check(not idle_fire,
"the laser fired while the cooling engine had no run session, so the fans were at "
"their idle duty (phase %s at t=%s s, emission %s)",
first_idle_fire[1], first_idle_fire[0], first_idle_fire[2])
ctx.check(end == 0, "emission_samples did not return to 0 at Idle (%s)", end)
ctx.check(ev["exhaust_at_first_fire"] is not None and ev["exhaust_at_first_fire"] >= floor,
"the beam started before the exhaust reached its floor (%s rpm at the first "
"emission sample, floor %.0f)", ev["exhaust_at_first_fire"], floor)
ctx.check(hv and max(hv) > min(hv), "HV current did not rise during the burn (%s..%s)",
ev["hv_min"], ev["hv_max"])
ctx.check(i_out is not None and i_back is not None,
"the rapid did not run inside the dark stretch with the window open: out at %s, back at %s "
"(X from %s, max %s), the window closed at %s", ev["rapid"]["out_at_s"], ev["rapid"]["back_at_s"],
x0, ev["rapid"]["x_max"], round(closed["t"] - dark["t"], 2) if closed else None)
ctx.check(stretch[i_out]["locked"] is False, "the laser latch was locked with the head at +20 mm "
"(laser_locked %s): the rapid ran unarmed", stretch[i_out]["locked"])
ctx.check(reads["read"] >= RAPID_MIN_READS and reads["gap_max_s"] is not None
and reads["gap_max_s"] <= EMISSION_READ_GAP_S,
"the LASER_ON count was not read through the rapid: %d of %d samples carried it, the longest "
"unread stretch %s s (the kernel latches the count once per ~1 s window, so a fire in an "
"unread window is not witnessed)", reads["read"], len(stretch), reads["gap_max_s"])
ctx.check(not relit, "the laser emitted after the M5: %d samples, first at +%s s (emission_samples %s) "
"- a rapid after M5 fired", len(relit), relit[0]["t"] - dark["t"] if relit else None,
relit[0]["emission"] if relit else None)
ctx.check(hv_reads["read"] >= RAPID_MIN_READS,
"the HV current was readable on only %d of %d samples through the rapid (%d needed)",
hv_reads["read"], len(stretch), RAPID_MIN_READS)
ctx.check(hv_dark <= HV_DARK_MAX, "HV current %s after the M5 (idle reads ~0): the discharge ran "
"through the rapid", hv_dark)
ctx.check(dt is not None and 0 <= dt < 10.0,
"the M2 job did not disarm promptly (%s s after the head was back; the idle grace is ~60 s)",
ev["disarm_after_idle_s"])
ctx.check(gap["button_latch_unlocked_samples"] and gap["button_latch_unlocked_max"] == 0,
"the hardware button latch read SET while the laser latch was unlocked (max %s over %d "
"samples, at %s)", gap["button_latch_unlocked_max"], gap["button_latch_unlocked_samples"],
gap["button_latch_set_at"])
ctx.check(ev["hv_enable_dipped"], "HV_ENABLE never dropped across the 2 s dwell (the kernel run did not end)")
ctx.check(gap["hv_enable_back_lit"],
"HV_ENABLE did not return with emission after the dwell (the third side ran dark)")
judge_beam(ctx, beam)
check_button_dark(ctx, ev)
ctx.confirm("Did the laser mark a 20 mm square outline on the scrap, all four sides?")
ctx.log("PASS: emission peak %s -> 0, HV %s..%s, beam +%s; the rapid after the M5 dark (HV max %s); "
"disarmed %.1f s after the head was back, button dark, mark confirmed", peak, ev["hv_min"],
ev["hv_max"], beam["delta"], hv_dark, dt)
HV_DARK_MAX = 20 # hv_current_raw reads 0 with the tube off; hundreds under fire
# The kernel latches cnc/laser_on_sampled once per ~1 s window (cnc.c
# LASER_SAMPLE_WINDOW) and holds the count through the next window, so a
# trail whose valid reads are never farther apart than EMISSION_READ_GAP_S
# has read every window's count at least once.
EMISSION_WINDOW_S = 1.0
EMISSION_READ_GAP_S = 0.9
RAPID_MIN_READS = 4 # the fewest samples with the count (and with the HV current) over the rapid
@test("laser.armed-kill", title="Armed kill mid-fire: the expected stop, then a SIGKILL",
subsystem="laser", kind="live", mode="grbl", est_min=6,
covers=_LASER_COVERS + [("forgectrl", "src/main.c")],
requires=["laser.emission-witness", "motion.deadman"], actions=["button"],
steps=["Scrap under the head with 40 mm of free +X and +Y travel; lid closed; exhaust on.",
"Press the physical button when it lights white - twice over the test, once per burn. "
"Each burn is cut short by the test; the sample trails judge the stops."],
description="Both ways an armed job is killed, on one setup. Expected: mid-burn "
"POST /controller/stop - the supervisor writes cnc/stop and relocks before the "
"SIGTERM, so emission drops within 2.5 s and stays 0, the kernel is not running, "
"and the restart is a separate operator-judged step. Unexpected: mid-burn SIGKILL "
"of the controller - the supervisor's exit safing must end the fire tail inside "
"the ring's in-flight window, leave the latch locked, and respawn the controller. "
"The death is a signal to the supervisor, not a poll: the latch relocks within "
"300 ms of the kill and the kernel is out of running within a second.")
def armed_kill(ctx):
ev = ctx.evidence
fc = ctx.forgectrl
# -- 1. the expected stop -------------------------------------------------
with ctx.grbl() as g, LiveJob(ctx, g):
prepare(ctx, g)
smp = arm_and_fire(ctx, g)
ctx.log("emission live (%s) - stopping the controller NOW", smp["emission"])
t_stop = time.time()
code, body = fc.post("/controller/stop")
post_dt = time.time() - t_stop
ctx.log("POST /controller/stop -> %s %s (%.2f s)", code, body, post_dt)
trail = kill_trail(ctx, t_stop)
zero_at, tail_zero, not_running = judge_kill(ctx, trail, "stop")
st_mode, mode = fc.get("/mode")
ev["expected"] = {"post_status": code, "post_s": round(post_dt, 2), "zero_at_s": zero_at,
"tail_zero": tail_zero, "kernel_not_running": not_running,
"mode_after_stop": mode, "trail": trail}
ctx.check(code == 200, "POST /controller/stop -> %s", code)
ctx.check(zero_at is not None and zero_at < 2.5,
"emission did not drop within 2.5 s of the stop (first 0 at %s)", zero_at)
ctx.check(tail_zero, "emission returned after the stop")
ctx.check(not_running, "the kernel was still running after the stop")
ev["beam_at_fire_stop"] = smp.get("beam")
ctx.log("the controller is stopped (supervision held); restarting it (no motion, no laser)")
st, body = fc.post("/controller/start")
ctx.log("POST /controller/start -> %s %s", st, body)
ctx.check(st == 200, "POST /controller/start -> %s", st)
ctx.sleep(6)
st, mode = fc.get("/mode")
ev["mode_after_start"] = mode
ctx.log("/mode after start: %s", mode)
ctx.check(isinstance(mode, dict) and mode.get("controller") == "running",
"controller not running after the restart: %s", mode)
ctx.log("expected stop PASS: returned in %.2f s, emission 0 at +%s s, controller restarted",
post_dt, zero_at)
# -- 2. the SIGKILL -------------------------------------------------------
# The pid is the RESTARTED controller's, not the one phase 1 stopped.
import os as _os
import signal as _signal
st, m0 = fc.get("/mode")
ctx.check(st == 200 and isinstance(m0, dict) and m0.get("controller") == "running",
"controller not running before the kill: %s", m0)
pid = m0.get("pid")
ctx.check(wait_grbl_port(ctx), "the restarted controller never accepted a Grbl connection")
with ctx.grbl() as g, LiveJob(ctx, g):
prepare(ctx, g)
smp = arm_and_fire(ctx, g)
ctx.log("emission live (%s) - SIGKILL controller pid %s NOW", smp["emission"], pid)
t_kill = time.time()
_os.kill(pid, _signal.SIGKILL)
fast = kill_fast_trail(t_kill)
trail = kill_trail(ctx, t_kill)
ctx.log("after the kill (sysfs): %s", fast)
zero_at, tail_zero, not_running = judge_kill(ctx, trail, "kill")
ilk = hw.sysfs_int("cnc/interlock_circuit")
locked = ilk is not None and bool(ilk & (1 << 3))
ev["sigkill"] = {"pid": pid, "zero_at_s": zero_at, "tail_zero": tail_zero,
"kernel_not_running": not_running, "latch_locked": locked, "trail": trail,
"fast": fast}
ctx.log("latch locked after the kill: %s", locked)
ctx.check(fast["latch_locked_at_s"] is not None and fast["latch_locked_at_s"] < 0.3,
"the latch was not locked within 300 ms of the kill: %s", fast)
ctx.check(fast["kernel_stopped_at_s"] is not None and fast["kernel_stopped_at_s"] < 1.0,
"the kernel was still running 1 s after the kill: %s", fast)
ctx.check(zero_at is not None and zero_at < 2.5,
"emission did not drop within 2.5 s of the kill (first 0 at %s)", zero_at)
ctx.check(tail_zero, "emission returned after the kill")
ctx.check(not_running, "the kernel was still running after the kill")
ctx.check(locked, "latch not locked after the kill")
t0 = time.time()
m1 = None
while time.time() - t0 < 60:
st, m1 = fc.get("/mode")
if isinstance(m1, dict) and m1.get("controller") == "running" and m1.get("pid") != pid:
break
ctx.sleep(1)
ev["mode_after_kill"] = m1
ctx.log("/mode after the kill: %s", m1)
ctx.check(m1 and m1.get("controller") == "running" and m1.get("pid") != pid,
"supervisor did not respawn the controller: %s", m1)
# The respawned controller starts locked and stays locked: a latch it
# did not unlock is never relit by a run start, and no arm has asked.
ctx.check(wait_grbl_port(ctx), "the respawned controller never accepted a Grbl connection")
ctx.sleep(2)
ilk = hw.sysfs_int("cnc/interlock_circuit")
ev["latch_locked_after_respawn"] = ilk is not None and bool(ilk & IL_LASER_LATCH)
ctx.check(ev["latch_locked_after_respawn"],
"the respawned controller unlocked the latch with no arm (interlock_circuit=%s)", ilk)
ev["beam_at_fire_kill"] = smp.get("beam")
check_button_dark(ctx, ev)
ctx.log("PASS: expected stop 0 at +%s s and SIGKILL 0 at +%s s, latch locked, controller "
"respawned with the latch still locked, button dark", ev["expected"]["zero_at_s"], zero_at)
@test("laser.pause-resume-lid-cancel", title="One live cut: the button pauses and resumes it, the lid "
"cancels it and sends the head home",
subsystem="laser", kind="live", mode="grbl", est_min=7,
covers=_LASER_COVERS + [("grblhal-glowforge", "src/glowforge_switches.c"),
("grblhal-glowforge", "src/glowforge_switch_map.h")],
requires=["laser.emission-witness"],
actions=["button", "lid"],
steps=["Scrap under the head with 40 mm of free +X and +Y travel; lid closed; exhaust on.",
"Press the physical button when it lights white (arm). Once the cut is under way the "
"test asks for a press (pause), then another (resume), then for the lid: open it and "
"leave it open until the head has come back, then close it.",
"A pause longer than the armed window's idle grace (about a minute) closes the window; "
"the resume then lights the button again and waits for a press."],
description="The machine's own controls during one armed burn, in the order the factory uses "
"them. Press: the job feed-holds, emission stops, and the latch stays UNLOCKED "
"with the armed window open - a pause is not a cancel. Press again: the cut "
"resumes from where it stopped, lit from the first step (a pause is a sharp corner "
"in time; the cut is M3 so the corner dose is there to look at). Lid: emission "
"stops in hardware, the job is canceled "
"with the reason reported, the controller resets with the position kept and no "
"alarm, the armed window closes and the kernel latch relocks, the hardware button "
"latch reads SET, and the head returns to the job start with the lid still open.")
def pause_resume_lid_cancel(ctx):
ev = ctx.evidence
with ctx.grbl() as g, LiveJob(ctx, g):
prepare(ctx, g)
k0 = kernel_start(ctx)
start = g.status_report()["MPos"]
ev["start"] = start
ev["kernel_start"] = k0
base_beam = sample(ctx)
beams = [] # (analog, digital) through the cut
smp = arm_and_fire(ctx, g, job=MARK_JOB_M3)
ev["emission_running"] = smp["emission"]
beams.append((smp.get("beam"), smp.get("beam_d")))
ctx.log("emission live (%s) - asking the operator to pause", smp["emission"])
# -- the button pauses ------------------------------------------------
g.drain() # the message window opens here
w = Watch(g)
ctx.act("button", "press", text="The laser is cutting: the press pauses it.",
until=w.in_state("Hold"), timeout=20, fail=False)
st, text = w.last, w.text
if st is not None and not st["state"].startswith("Hold"):
st = None
ctx.check(st is not None, "the press did not hold the job (state %s)", g.status_report()["state"])
ev["hold_state"] = st["state"]
ev["pause_message"] = "job paused" in text
paused = []
t1 = time.time()
while time.time() - t1 < 4:
s = sample(ctx)
if s:
paused.append((round(time.time() - t1, 2), s["emission"], s["armed"]))
time.sleep(0.2)
for p in paused:
ctx.log(" paused %s", p)
ev["paused_trail"] = paused
ev["emission_zero_when_paused"] = all(e == 0 for _t, e, _a in paused[-8:]) if paused else None
ev["armed_while_paused"] = paused[-1][2] if paused else None
ilk = hw.sysfs_int("cnc/interlock_circuit")
ev["latch_locked_while_paused"] = ilk is not None and bool(ilk & (1 << 3))
ctx.log("paused: %s, emission 0 = %s, armed = %s, latch locked = %s", ev["hold_state"],
ev["emission_zero_when_paused"], ev["armed_while_paused"],
ev["latch_locked_while_paused"])
ctx.check(ev["emission_zero_when_paused"], "emission did not stop while the job was paused: %s", paused)
ctx.check(ev["armed_while_paused"], "the armed window closed on the pause (a pause is not a cancel)")
ctx.check(not ev["latch_locked_while_paused"],
"the kernel latch relocked on the pause - the resume could not fire without a new arm press")
# -- the button resumes -----------------------------------------------
g.drain()
w = Watch(g)
ctx.act("button", "press", text="The cut is paused: the press resumes it.",
until=w.left_state("Hold"), timeout=40, fail=False)
st, text = w.last, w.text
if st is not None and st["state"].startswith("Hold"):
st = None
ev["resumed_state"] = st["state"] if st else g.status_report()["state"]
ev["resume_message"] = "job resumed" in text
ctx.check(st is not None, "the second press did not resume the job (still held: %s)",
ev["resumed_state"])
ctx.check(st["state"].startswith(("Run", "Idle")),
"the job left the hold into %s, not into motion", st["state"])
back = False
t2 = time.time()
trail = []
while time.time() - t2 < 20:
ctx.checkpoint()
s = sample(ctx)
if s:
trail.append((round(time.time() - t2, 2), s["emission"]))
beams.append((s.get("beam"), s.get("beam_d")))
if s["emission"] and s["emission"] > 0:
back = True
break
time.sleep(0.15)
ev["emission_back_after_s"] = trail[-1][0] if trail else None
ev["resume_trail"] = trail[-6:]
ctx.log("emission after the resume: %s", trail[-6:])
ctx.check(back, "emission did not return after the resume: %s", trail[-6:])
# -- the lid cancels --------------------------------------------------
g.drain()
ctx.act("lid", "open", text="The cut is running again: leave the lid open until the head "
"has come back.", timeout=30)
t_lid = time.time()
lid_trail = []
text = ""
while time.time() - t_lid < 8:
s = sample(ctx)
if s:
lid_trail.append((round(time.time() - t_lid, 2), s["emission"], s["kstate"], s["armed"]))
text += g.drain()
time.sleep(0.12)
for t in lid_trail:
ctx.log(" post-lid %s", t)
ev["messages"] = [ln for ln in text.splitlines() if ln.startswith("[MSG:") or "help]" in ln
or ln.startswith("ALARM")]
ctx.log("controller: %s", ev["messages"])
zero_at = next((t for t, e, _, _ in lid_trail if e == 0), None)
tail_zero = all(e == 0 for _, e, _, _ in lid_trail[-16:])
ev.update({"lid_zero_at_s": zero_at, "lid_tail_zero": tail_zero})
ctx.check(zero_at is not None and zero_at < 3.0,
"emission did not stop after the lid opened (first 0 at %s)", zero_at)
ctx.check(tail_zero, "emission returned after the lid opened")
ctx.check("lid opened - job canceled" in text, "the lid open was not reported as canceling the job")
ctx.check("help]" in text, "no reset banner after the cancel")
ctx.check("ALARM" not in text, "an alarm was raised on the cancel (position should be kept)")
t3 = time.time()
returned = "returned to the job start" in text
while not returned and time.time() - t3 < 30:
ctx.checkpoint()
text += g.drain()
returned = "returned to the job start" in text
time.sleep(0.2)
ev["returned_message"] = returned
ctx.check(returned, "the head did not report returning to the job start")
st = g.status_report()
drift = max(abs(st["MPos"][i] - start[i]) for i in range(2))
ev["drift_mm"] = round(drift, 3)
s = sample(ctx)
ev["armed_after"] = s["armed"] if s else None
ilk = hw.sysfs_int("cnc/interlock_circuit")
ev["latch_locked"] = ilk is not None and bool(ilk & (1 << 3))
ev["button_latch"] = hw.sysfs_int("cnc/button_latch")
ctx.log("returned: drift %.3f mm; armed=%s latch_locked=%s button_latch=%s", drift,
ev["armed_after"], ev["latch_locked"], ev["button_latch"])
ctx.check(drift <= 0.05, "head not back at the job start (drift %.3f mm)", drift)
ctx.check(ctx.forgectrl.wait_idle(10, abort=ctx.aborted), "machine not idle after the return")
check_kernel_returned(ctx, ev, k0)
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")
ctx.check(ev["button_latch"] == 1, "hardware button latch not SET after the lid open (%s)",
ev["button_latch"])
beam_witness(ctx, ev, [{"beam": b, "beam_d": d} for b, d in beams], base_beam)
judge_beam(ctx, ev["beam"], "the cut")
check_button_dark(ctx, ev)
ctx.act("lid", "close")
ctx.sleep(1)
ctx.log("PASS: button paused the burn (emission 0, armed kept, latch unlocked) and resumed it; "
"the lid then canceled it - emission 0 at +%s s, reset without alarm, returned (drift "
"%.3f mm), button latch SET", zero_at, drift)
File diff suppressed because it is too large Load Diff
-304
View File
@@ -1,304 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""setup.* - what the setup tests share: the setup record and its
builders, the files of the data directory, a takeover that installs a
record, and the HTTPS client. The tests are setup_dark's and
setup_burns'; this module registers none.
The daemon reads the setup record (setup.json in the
data directory) and the account record (users) once, at its start, and
keeps them in memory; only its own routes write them. A test that needs
a different record installs the file under a forgectrl restart (a
takeover) and puts the previous file back the same way. Thus the daemon
never saves a test record over the real one, and a restore is complete
when the daemon is up again.
The layer content of this work (the account replay init script, the
sshd policy, the hostname and console banner scripts) is part of the
platform identity that every fingerprint carries, not of a component.
No coverage map names it: a change there makes every test necessary
again.
"""
import contextlib
import posixpath
import http.client
import json
import os
import ssl
import time
import urllib.parse
from .. import hw
RECORD = "setup.json"
USERS = "users"
SSH_FLAG = "ssh-enabled"
# The first run's form wizards, all at version 1 (wiz.c).
FORM_WIZARDS = ("advisories", "account", "preferences", "machine", "cloud")
# The steps the gate requires after the account, all at version 1, in
# the order the gate's reason names them (setup.c): the form steps, then
# the coolant flow calibration. The first-run tests seed the checks done,
# the calibration among them, so the form steps are what they walk.
REQUIRED_FORM_STEPS = ("preferences", "machine", "cloud")
REQUIRED_STEPS = REQUIRED_FORM_STEPS + ("cooling.flow",)
# The setting that counts the intake fans: the gate holds without it.
INTAKE_KEY = "cool_intake_fans"
SAFETY_PHRASE = "I UNDERSTAND"
# ----------------------------------------------------------------- files
def data_dir():
return os.environ.get("FORGECTRL_DATA_DIR") or "/data/forgefirm"
def run_dir():
return os.environ.get("GF_RUN_DIR") or "/run/forgefirm"
# The daemon's own paths on the board, POSIX on every host that names them.
def record_path():
return posixpath.join(data_dir(), RECORD)
def users_path():
return posixpath.join(data_dir(), USERS)
def ssh_flag_path():
return posixpath.join(run_dir(), SSH_FLAG)
def read_file(path):
"""The bytes of a file, or None when it does not exist."""
try:
with open(path, "rb") as f:
return f.read()
except OSError:
return None
def write_file(path, data):
"""Write data to path (temp file and rename, mode 0600), or remove
the file when data is None."""
if data is None:
try:
os.remove(path)
except OSError:
pass
return
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = path + ".forgetest"
# the bytes as given: O_BINARY keeps a Windows host (the unit tests)
# from translating newlines; it does not exist elsewhere
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_BINARY", 0), 0o600)
try:
os.write(fd, data)
finally:
os.close(fd)
os.replace(tmp, path)
# --------------------------------------------------------------- records
class Restore:
"""Settings a test or a wizard writes, put back as found at the end,
in the order of `keys` (a value that depends on another goes after
it)."""
def __init__(self, ctx, keys):
self.ctx = ctx
self.keys = keys
self.before = {}
def __enter__(self):
s = self.ctx.forgectrl.settings() or {}
self.before = {k: s.get(k) for k in self.keys}
self.ctx.evidence["settings_before"] = dict(self.before)
return self
def __exit__(self, *exc):
fc = self.ctx.forgectrl
for k, v in self.before.items():
if v is None or v == "":
st, _ = fc.post("/settings", params={k: ""})
else:
st, _ = fc.post("/settings", data={k: v})
self.ctx.log("restore %s=%r -> %s", k, v, st)
return False
def now_iso():
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
def record_without_wizards(record):
"""A copy of a record with no completed wizard and no flag: the
consent and the account stay, so the gate closes on the required
steps."""
out = json.loads(json.dumps(record))
out["wizards"] = {}
out["flags"] = {}
return out
def record_with_required_steps(record):
"""A copy of a record with the required steps done (kept from the
record, or written at version 1) and nothing else, and no flag: on a
machine whose intake fans are counted the gate is open and every
other step is recommended as not run yet."""
out = record_without_wizards(record)
have = record.get("wizards") or {}
for wid in REQUIRED_STEPS:
out["wizards"][wid] = have.get(wid) or {"version": 1, "completed": now_iso(), "result": {},
"applied": {}}
return out
def record_without_consent(record):
"""A copy of a record with no advisory accepted and no press: the
gate closes on the consent, whatever else the record holds."""
out = json.loads(json.dumps(record))
out["advisories"] = {}
out.pop("acceptance", None)
return out
def record_without_account(record):
"""A copy of a record with the consent and no account: the gate
closes on the account."""
out = json.loads(json.dumps(record))
out.pop("account", None)
return out
def complete_record(status, base=None, account_name="bench"):
"""A complete setup record for a machine that must count as
set up: every advisory at the hash GET /wiz reports, the
press recorded, an account (the one of `base` when it has one),
every wizard of the catalog at its version, and the run complete.
`status` is a GET /wiz body; `base` is an existing record to keep
the account, the machine facts, and the sheet id from."""
base = base or {}
ts = now_iso()
rec = {"schema": 1, "created": base.get("created") or ts, "completed": ts,
"advisories": {}, "wizards": {}, "flags": {}}
if base.get("sheet_id"):
rec["sheet_id"] = base["sheet_id"]
elif status.get("sheet_id"):
rec["sheet_id"] = status["sheet_id"]
for d in status.get("documents") or []:
rec["advisories"][d["id"]] = {"hash": d["hash"], "accepted": ts,
"method": d.get("consent") or "check"}
rec["acceptance"] = {"pressed_at": ts}
acct = base.get("account") if isinstance(base.get("account"), dict) else None
rec["account"] = acct or {"name": account_name, "uid": 1000, "created": ts}
if isinstance(base.get("machine"), dict):
rec["machine"] = base["machine"]
wizards = status.get("wizards") or [{"id": w, "version": 1} for w in FORM_WIZARDS]
for w in wizards:
rec["wizards"][w["id"]] = {"version": int(w.get("version") or 1), "completed": ts,
"result": {}, "applied": {}}
return rec
@contextlib.contextmanager
def installed(ctx, files):
"""Install files ({path: bytes, or None to remove}) under a forgectrl
restart, and put the previous content back under another one when
the block ends, on every exit path. Yields the saved content. The
restart is the runner's takeover: the controller is stopped through
the supervisor, the marker makes a crash recoverable, and the exit
waits for the supervisor to settle."""
saved = {p: read_file(p) for p in files}
with ctx.takeover():
for p, data in files.items():
write_file(p, data)
ctx.log("installed under a restart: %s",
", ".join("%s=%s" % (os.path.basename(p), "removed" if d is None else "%d B" % len(d))
for p, d in files.items()))
try:
yield saved
finally:
with ctx.takeover():
for p, data in saved.items():
write_file(p, data)
ctx.log("restored under a restart: %s", ", ".join(os.path.basename(p) for p in saved))
# ---------------------------------------------------------------- client
def request(base, method, path, data=None, headers=None, timeout=10.0):
"""One HTTP or HTTPS request without redirect following: (status,
body bytes, {header: value}). HTTPS is served with a self-signed
certificate, so the peer is not verified. The Host header is the
address literal of base, which the origin check requires."""
u = urllib.parse.urlsplit(base)
port = u.port or (443 if u.scheme == "https" else 80)
if u.scheme == "https":
cx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
cx.check_hostname = False
cx.verify_mode = ssl.CERT_NONE
conn = http.client.HTTPSConnection(u.hostname, port, timeout=timeout, context=cx)
else:
conn = http.client.HTTPConnection(u.hostname, port, timeout=timeout)
hdrs = {"Host": u.netloc}
hdrs.update(headers or {})
body = None
if isinstance(data, dict):
body = urllib.parse.urlencode(data).encode()
hdrs.setdefault("Content-Type", "application/x-www-form-urlencoded")
elif isinstance(data, str):
body = data.encode()
elif data is not None:
body = data
if body is not None:
hdrs.setdefault("Content-Length", str(len(body)))
try:
conn.request(method, path, body=body, headers=hdrs)
r = conn.getresponse()
content = r.read()
return r.status, content, {k.lower(): v for k, v in r.getheaders()}
except (OSError, http.client.HTTPException) as e:
raise hw.HwError("%s %s%s: %s" % (method, base, path, e))
finally:
conn.close()
def decode(content):
"""JSON when the body parses as JSON, else text."""
text = content.decode("utf-8", "replace")
try:
return json.loads(text)
except ValueError:
return text
def session_from_cookie(value):
"""The ffsid session id out of a Set-Cookie value, or None."""
for part in (value or "").split(";"):
part = part.strip()
if part.startswith("ffsid="):
sid = part[len("ffsid="):]
return sid or None
return None
def cookie_flags(value):
return set(p.strip().split("=", 1)[0].lower() for p in (value or "").split(";")[1:])
def wiz(fc):
st, body = fc.get("/wiz")
if st != 200 or not isinstance(body, dict):
raise hw.HwError("GET /wiz -> %s" % st)
return body
def recommended(w):
"""The steps a GET /wiz body recommends: {id: reason}."""
return {x.get("id"): x.get("reason") or "" for x in w.get("recommended") or [] if isinstance(x, dict)}
-466
View File
@@ -1,466 +0,0 @@
# 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")
-455
View File
@@ -1,455 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""setup.check-* - the setup's checks (the dark wizards), driven the
way the page drives them: POST /wiz/<id>/start, GET /wiz/dark polled,
the prompts answered from here (the bench fixture works the lid and the
button; a confirmation is answered yes once the snapshot exists), the
result judged, and every setting a check wrote put back as found. Each
check re-runs on the real record: a completed check completes again at
the same version, so the record reads as before.
"""
import json
import os
import time
from ..catalog import test
from .. import hw
from ..baseline import read_position
from .setup import wiz, Restore, INTAKE_KEY
POLL_S = 1.0
# What the machine's own press prompt says, used when it sends no text.
PRESS_TEXT = "The button is lit white. Press it now: the laser fires after your press."
# How long the purge fan's current is given to come up after the airflow
# check switches it back on. The bench reference draws its off current
# (74) for the first seconds and settles near 630.
PURGE_SPINUP_S = 30
# 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.*"),
("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"),
("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):
st, body = fc.get("/wiz/dark")
if st != 200 or not isinstance(body, dict):
raise hw.HwError("GET /wiz/dark -> %s" % st)
return body
def start(ctx, fc, wid):
ev = ctx.evidence
st, body = fc.post("/wiz/%s/start" % wid)
if st == 409 and dark(fc).get("id") == wid and dark(fc).get("running"):
# The same check left running by an earlier run of this test (an
# abort mid-wait): this run owns the machine, so it ends that one
# and starts its own.
ctx.log("the %s check is still running from an earlier run: aborting it", wid)
fc.post("/wiz/%s/abort" % wid)
ctx.wait_for(lambda: not dark(fc).get("running"), 30)
st, body = fc.post("/wiz/%s/start" % wid)
ctx.log("POST /wiz/%s/start -> %s %s", wid, st, body if isinstance(body, dict) else "")
ctx.check(st == 200, "the %s check did not start (%s %s)", wid, st, body)
ev["started"] = st == 200
def answer(fc, wid, prompt, value):
return fc.post("/wiz/%s/answer" % wid, data={"seq": str(prompt["seq"]), "value": value})
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
once per prompt (by sequence number) and returns the value to answer
with, or None when it handled the prompt another way (a fixture
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
ev = ctx.evidence
start(ctx, fc, wid)
t0 = time.time()
seen = set()
phases = []
last = None
try:
while time.time() - t0 < timeout_s:
ctx.checkpoint()
d = dark(fc)
if d.get("id") != wid:
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"]):
phases.append(d["phase"])
ctx.log("%s: %s", wid, d["phase"])
p = d.get("prompt")
if p and p.get("seq") not in seen:
seen.add(p["seq"])
ctx.log("prompt %s (%s): %s", p.get("id"), p.get("kind"), p.get("text"))
value = on_prompt(p)
if value is not None:
st, body = answer(fc, wid, p, value)
ctx.check(st == 200, "the answer to %s -> %s %s", p.get("id"), st, body)
elif p.get("id") == "press":
# The arm press, asked for by the machine itself: the
# check opens this prompt when the button is lit and
# the tube is still dark. Nothing answers it - the
# press does, and the discharge closes it.
ctx.press_now(p.get("text") or PRESS_TEXT)
if not d.get("running"):
last = d
break
time.sleep(POLL_S)
except BaseException:
# An abort or a failure on the way out leaves no check running
# behind this test: the next run must be able to start its own.
fc.post("/wiz/%s/abort" % wid)
raise
ev["elapsed_s"] = round(time.time() - t0, 1)
ev["phases"] = phases[-12:]
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)
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["error"] = last.get("error")
ev["result"] = last.get("result")
ctx.check(not last.get("error"), "the %s check failed: %s", wid, last.get("error"))
ctx.check(isinstance(last.get("result"), dict), "the %s check ended without a result", wid)
w = wiz(fc)
ctx.check((w.get("versions") or {}).get(wid) == 1, "the record does not carry %s at version 1", wid)
return last
@test("setup.check-switches", title="The switches check follows the lid and the button",
subsystem="setup", kind="operator", est_min=3,
covers=DARK_COVERS + [("forgectrl", "src/status.c")],
requires=["forgectrl.auth"], actions=["lid", "button", "interlock"],
description="POST /wiz/switches/start; the check asks for the lid to open and close and for "
"a press, each as a 'wait' prompt that the switch edge itself answers; the bench "
"fixture works the lid and the button. On a Basic or Plus the interlock loop reads "
"satisfied without a prompt. The result says lid, button, head present, and "
"whether the HV enable readback followed the lid; the record carries switches at "
"version 1.")
def check_switches(ctx):
def on_prompt(p):
pid = p.get("id", "")
if pid in ("lid-open", "lid-close-first", "lid-close"):
ctx.act("lid", "open" if pid == "lid-open" else "close",
until=lambda: (dark(ctx.forgectrl).get("prompt") or {}).get("seq") != p["seq"],
text=p.get("text", ""))
elif pid == "button-press":
ctx.act("button", "press",
until=lambda: (dark(ctx.forgectrl).get("prompt") or {}).get("id") != "button-press",
text=p.get("text", ""))
elif pid == "button-release":
pass # the fixture's press releases on its own
elif pid.startswith("interlock-"):
ctx.act("interlock", "open" if pid == "interlock-open" else "close",
until=lambda: (dark(ctx.forgectrl).get("prompt") or {}).get("seq") != p["seq"],
text=p.get("text", ""))
return None
last = run_check(ctx, "switches", on_prompt, 600)
r = last.get("result") or {}
ctx.check(r.get("lid") is True and r.get("button") is True, "lid or button not proven: %s", r)
ctx.check(r.get("head_present") is True, "the head did not read present: %s", r)
ctx.log("switches: %s", json.dumps(r))
@test("setup.check-airflow", title="The airflow check measures the fans and sets the floors",
subsystem="setup", kind="auto", hardware="takeover", est_min=3,
covers=DARK_COVERS + [("forgectrl", "src/cool.c"), ("forgectrl", "src/airflow.*"),
("forgectrl", "src/gates.c"), ("forgectrl", "src/super.c")],
requires=["forgectrl.auth"], precheck=intakes_counted,
description="POST /wiz/airflow/start: the controller stops, the fans run at the cut profile "
"for 35 s, the purge is measured on and off, and the five floors are written: the "
"four at 55 percent of steady and the grace from the slowest spin-up. The intake "
"fan count is the machine's: intake 2 turning proves two fans, both intakes are "
"judged, and the count is written as 2; intake 2 silent on a machine counted with "
"one is reported not fitted and not turning, intake 1 alone sets the intake "
"floor, and the count is not written; intake 2 silent on a machine counted with "
"two fails the check. Both intakes turn on the bench reference, so the bench "
"path writes 2. Each fan judged must read at least 1000 rpm. The settings are put "
"back as found at the end, the purge fan is on again and drawing over the floor "
"the check just wrote, and the controller must be running again.")
def check_airflow(ctx):
keys = ["cool_tach_exhaust_min_rpm", "cool_tach_intake_min_rpm", "cool_tach_air_assist_min_rpm",
"cool_purge_min_current", "cool_fan_grace_s"]
with Restore(ctx, keys + ["cool_intake_fans"]) as held:
counted = held.before.get("cool_intake_fans")
last = run_check(ctx, "airflow", lambda p: None, 240)
r = last.get("result") or {}
fans = r.get("fans") or {}
intakes = r.get("intake_fans")
silent2 = (fans.get("intake 2") or {}).get("turning") is False
ctx.evidence["intakes"] = {"counted": counted, "found": intakes, "intake_2_silent": silent2}
ctx.check(intakes in (1, 2), "the result carries no intake fan count: %r", intakes)
if silent2:
# one intake fan, as the machine is counted (on a machine
# counted with two the check itself fails, and run_check says so)
ctx.check(counted == "1" and intakes == 1,
"intake 2 gave no reading on a machine counted with %r intake fans, and the check "
"found %r", counted, intakes)
else:
ctx.check(intakes == 2, "intake 2 turned, and the check found %r intake fans", intakes)
ctx.log("intake fans: %s (the machine counted %s)", intakes, counted)
for name in ("exhaust", "intake 1", "intake 2", "air assist"):
f = fans.get(name) or {}
if name == "intake 2" and silent2:
ctx.check(f == {"fitted": False, "turning": False}, "intake 2 with one intake fan: %s", f)
continue
ctx.check(f.get("ok") is True, "%s: %s", name, f)
ctx.log("%s: %s rpm steady, 90 percent at %s s", name, f.get("steady_rpm"), f.get("spinup_s"))
floors = r.get("floors") or {}
for k in keys:
ctx.check(k in floors, "no floor for %s: %s", k, floors)
s = ctx.forgectrl.settings() or {}
for k in keys:
ctx.check(s.get(k) == floors.get(k), "%s reads %r, the check wrote %r", k, s.get(k), floors.get(k))
# The count: 2 written when intake 2 turned; left as counted when it
# was silent.
want = counted if silent2 else "2"
ctx.check(s.get("cool_intake_fans") == want,
"cool_intake_fans reads %r, expected %r (intake 2 %s, the machine counted %r)",
s.get("cool_intake_fans"), want, "silent" if silent2 else "turning", counted)
ctx.log("floors: %s", json.dumps(floors))
# The check switches purge air off to read its off current. Leaving
# it off costs the next job an airflow hold mid-cut, judged against
# the very floor written above, and nothing puts it back until the
# daemon restarts: the engine's idle phase never re-applies its own
# duties. Prove the machine is whole, not just measured.
purge_on = ctx.sysfs("head/purge_air")
ctx.check(purge_on == "1", "purge air reads %r after the check, expected 1 (on)", purge_on)
def purge_gate():
return ((ctx.forgectrl.get("/cool/status")[1] or {}).get("fan_gates") or {}).get("purge") or {}
# The check read its off current with the fan off, and the stand-down
# commands it back on. The current follows the command, it does not
# arrive with it: read in the same second and what comes back is the
# off current the check just measured. Wait for the draw the way the
# controller below is waited for.
took = ctx.wait_for(lambda: (purge_gate().get("reading") or 0) >= (purge_gate().get("floor") or 0),
PURGE_SPINUP_S)
gate = purge_gate()
ctx.evidence["purge_after"] = {"purge_air": purge_on, "gate": gate, "took_s": took}
ctx.log("purge after the check: commanded %s, drawing %s against the %s floor (%s)",
purge_on, gate.get("reading"), gate.get("floor"),
"after %.1f s" % took if took is not None else "never reached the floor")
ctx.check(took is not None,
"purge draws %s after %d s, under the %s floor the check just wrote: the next job "
"would be held", gate.get("reading"), PURGE_SPINUP_S, gate.get("floor"))
ok = ctx.wait_for(lambda: (ctx.forgectrl.get("/mode")[1] or {}).get("controller") == "running", 60)
ctx.check(ok is not None, "the controller did not come back after the check")
@test("setup.check-motion", title="The motion check proves the rail, the lens reference, and the jogs",
subsystem="setup", kind="auto", hardware="takeover", est_min=5,
covers=DARK_COVERS + [("forgectrl", "src/super.c"), ("forgectrl", "src/liveness.c"),
("forgectrl", "src/lenshome.*"),
("forgectrl", "src/accel.c"), ("forgectrl", "src/cool.c")],
requires=["forgectrl.auth", "motion.pacing"],
description="POST /wiz/motion/start: the controller stops, the liveness probe runs and the "
"lens takes its hall-edge reference behind it (a lens that cannot reach its "
"edge is a motion fault, and no controller starts), the lens "
"finds the hall reference in five agreeing passes, the controller comes back in "
"loopback posture, and the head jogs 50 mm each way on X and Y with the "
"accelerometer as the witness. The one prompt (the jogs are about to move) is "
"answered Continue. The result carries the probe line, the passes, and a "
"witnessed reading for each jog; the controller must be back in its normal "
"posture, and the kernel position counters must read what they read before: "
"every move played to its end (the probe's return leg under the cooling "
"engine's dead-man, the last jog under the controller stop).")
def check_motion(ctx):
fc = ctx.forgectrl
before = read_position()
ctx.notice("The head moves 50 mm each way on X and on Y. Keep the bed clear.")
last = run_check(ctx, "motion", lambda p: "Continue" if p.get("id") == "jogs" else None, 420)
ctx.clear_notice()
r = last.get("result") or {}
ctx.check(r.get("z_referenced") is True, "the lens was not referenced: %s", r)
ctx.check(len(r.get("z_passes") or []) == 5, "not five reference passes: %s", r.get("z_passes"))
moves = r.get("moves") or {}
for name in ("+X", "-X", "+Y", "-Y"):
m = moves.get(name) or {}
ctx.check(m.get("witnessed") is True, "the %s jog was not witnessed: %s", name, m)
ok = ctx.wait_for(lambda: (fc.get("/mode")[1] or {}).get("local") is False
and (fc.get("/mode")[1] or {}).get("controller") == "running", 90)
ctx.check(ok is not None, "the controller is not back in its normal posture")
after = read_position()
if before is not None and after is not None:
ctx.check(all(abs(a - b) <= 2 for a, b in zip(after[:2], before[:2])),
"the check lost motion: position %s before, %s after (steps)", before, after)
ctx.log("motion: probe '%s', passes %s, rest %s, moves %s", r.get("probe"), r.get("z_passes"),
r.get("rest"), {k: (v.get("p2p_lp_x"), v.get("p2p_lp_y")) for k, v in moves.items()})
@test("setup.check-lens", title="The lens travel check finds the stops and writes the window",
subsystem="setup", kind="auto", hardware="takeover", est_min=4,
covers=DARK_COVERS + [("forgectrl", "src/lensfind.*"), ("forgectrl", "src/lens.*"),
("forgectrl", "src/lenshome.*"), ("forgectrl", "src/accel.c"),
("forgectrl", "src/cool.c"), ("forgectrl", "src/super.c")],
requires=["forgectrl.auth", "setup.check-motion"],
description="POST /wiz/lens.travel/start: the controller stops, the lens takes its hall-edge "
"reference, the fans stop and the head accelerometer listens while the lens "
"steps to each stop and back (nothing fires), the two window settings are "
"written, and the controller comes back. No question is asked. The result "
"carries the reference passes, the stops (found, the free half-steps each way, "
"the contact counts, or why not), and the window: the stops found, or the "
"fallback (10 half-steps below, 12 above) when they were not. The settings and "
"the lens block of /status hold that window, the record carries lens.travel at "
"version 1, and the two settings are put back as found. grblHAL reads the "
"window only at its start, and the check started it before the put-back, so in "
"GRBL mode the controller is stopped and started again on the window as found "
"(on every exit path), and it must be running with motion verified under a new "
"process.")
def check_lens(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
restart = {}
def on_prompt(p):
ctx.fail("the lens travel check asked a question: %s", p)
def restart_on_found():
"""A fresh controller start on the settings as found, once the
check has ended. Plain waits, not the run's, so an abort gets it
too; a check still ending starts its own controller after the
put-back, which reads them already."""
try:
t_end = time.time() + 120
while time.time() < t_end and dark(fc).get("running"):
time.sleep(1)
if dark(fc).get("running"):
restart["skipped"] = "the check is still ending"
ctx.log("the check is still ending: its own controller start reads the settings as found")
return
st, m = fc.get("/mode")
m = m if isinstance(m, dict) else {}
restart.update(mode=m.get("mode"), pid=m.get("pid"))
if m.get("mode") != "grbl":
return # the cloud client reads no lens window
restart["stop"], body = fc.post("/controller/stop")
ctx.log("restarting the controller on the window as found: POST /controller/stop -> %s %s",
restart["stop"], body)
for _ in range(10):
restart["start"], body = fc.post("/controller/start")
if restart["start"] != 409:
break
time.sleep(1) # the check's lease goes just after its end
ctx.log("POST /controller/start -> %s %s", restart["start"], body)
except hw.HwError as e:
restart["error"] = str(e)
ctx.log("the controller could not be restarted on the window as found: %s", e)
try:
with Restore(ctx, LENS_WINDOW_KEYS):
last = run_check(ctx, "lens.travel", on_prompt, 300)
r = last.get("result") or {}
ctx.check(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",
subsystem="setup", kind="operator", hardware="takeover", mode="grbl", est_min=8,
covers=DARK_COVERS + [("forgectrl", "src/super.c"),
("python3-gfhardware", "gfhardware/machine.py"),
("python3-gfhardware", "forgefirm-app/gfcloud.py"),
("python3-gfhardware", "forgefirm-app/ffmachine.py")],
requires=["forgectrl.auth", "cloud.mode-switch"], hands=["workstation"],
steps=["The Glowforge app open on the workstation, signed in to this machine. The test "
"turns cloud mode on itself and puts it back.",
"Once the app shows the machine online, place any small design on the bed image "
"and press Print. That is the only action: the check takes the print's header and "
"cancels the print before it arms. Do not press the machine's button.",
"The machine returns to GRBL mode on its own."],
description="POST /wiz/cloud.header/start with cloud mode on: the wizard writes the "
"client's one-start capture marker, starts cloud mode in the wizard's posture, "
"asks for a print, and takes the header the client writes to the run directory "
"before it cancels the print. The result carries the tag count, the "
"calibration-bearing tags, the limits, and the machine's own numbers; the log "
"has the tagged block; the machine is back in GRBL mode with the marker gone.")
def cloud_header_capture(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
with Restore(ctx, ["cloud_enabled", "homing_mode", "controller_mode"]):
s = fc.settings() or {}
if s.get("cloud_enabled") != "1":
st, body = fc.post("/settings", data={"cloud_enabled": "1"})
ctx.check(st == 200, "cloud_enabled=1 -> %s %s", st, body)
ctx.log("cloud mode turned on for the test")
ctx.notice("Print any small design from the Glowforge app once it shows the machine "
"online. Do not press the machine's button.")
last = run_check(ctx, "cloud.header",
lambda p: "Continue" if p.get("id") == "print" else None, 900)
ctx.clear_notice()
r = last.get("result") or {}
ctx.check(isinstance(r.get("tag_count"), int) and r["tag_count"] > 100,
"the header carried %s tags", r.get("tag_count"))
kept = r.get("calibration_tags") or {}
ctx.check(len(kept) > 10, "only %d calibration-bearing tags kept", len(kept))
ev["kept"] = sorted(kept)[:40]
ctx.check(not os.path.exists("/run/gfcloud-capture"), "the capture marker is still down")
ok = ctx.wait_for(lambda: (fc.get("/mode")[1] or {}).get("mode") == "grbl"
and (fc.get("/mode")[1] or {}).get("controller") == "running", 120)
ctx.check(ok is not None, "the machine did not return to GRBL mode")
ctx.log("header: %d tags, %d kept; job %s", r.get("tag_count", 0), len(kept), r.get("job_id"))
-218
View File
@@ -1,218 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The cipher forgectrl's HTTPS listener chooses, and where its records
are sealed.
The listener puts ChaCha20-Poly1305 first, whatever the client prefers,
and the image seals the records in the kernel: GnuTLS hands each
connection's keys to the socket after the handshake. ChaCha20-Poly1305
runs in the kernel's NEON code; AES-GCM, for a client without ChaCha20,
runs its AES on the CAAM crypto engine and its GHASH in NEON. This test
reads all of it from the outside: the cipher the listener picks for a
client that prefers AES-GCM, the kernel's TLS counters, the crypto
engine's interrupt, and the drivers the kernel resolved, with each
connection carrying the same page, which must arrive identical to the
plain-HTTP copy.
Its own module: a test's fingerprint holds the shared text of the module
it lives in, so a test added to forgectrl.py would move the fingerprint
of every test there.
"""
import os
import random
import shutil
import subprocess
import tempfile
import urllib.request
from ..catalog import test
OPENSSL = "/usr/bin/openssl"
ENGINE_IRQ = "2101000.jr" # the CAAM's first job ring
RECORD = 16384 # the largest TLS record's plaintext
PAGE = "/" # the panel page: large, and the same bytes every time
CHACHA_DRIVER = "rfc7539(chacha20-neon,poly1305-neon)"
# A desktop browser's offer: AES-GCM first, because its CPU has AES instructions.
DESKTOP_13 = "TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256"
DESKTOP_12 = "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305"
def _tls_stat():
"""The kernel's TLS counters, by name."""
out = {}
with open("/proc/net/tls_stat") as f:
for line in f:
p = line.split()
if len(p) == 2 and p[1].isdigit():
out[p[0]] = int(p[1])
return out
def _passive_opens():
"""The TCP connections the machine has accepted, on every port and both
families (the TCP counters are shared)."""
with open("/proc/net/snmp") as f:
rows = [line.split() for line in f if line.startswith("Tcp:")]
return int(rows[1][rows[0].index("PassiveOpens")])
def _engine_irqs():
with open("/proc/interrupts") as f:
for line in f:
p = line.split()
if p and p[-1] == ENGINE_IRQ and len(p) > 1 and p[1].isdigit():
return int(p[1])
return None
def _driver(name):
"""The driver answering an algorithm name: the entry of that name with
the highest priority in /proc/crypto."""
best, drv, entry = -1, None, {}
with open("/proc/crypto") as f:
lines = f.read().splitlines() + [""]
for line in lines:
if not line.strip():
if entry.get("name") == name and int(entry.get("priority", -1)) > best:
best, drv = int(entry["priority"]), entry.get("driver")
entry = {}
continue
k, _, v = line.partition(":")
entry[k.strip()] = v.strip()
return drv
def _fetch(version, offer, work):
"""GET the page over HTTPS on loopback with the given offer. Returns
the suite, the status line, the body, the kernel counters' change,
the crypto engine's interrupts during the connection, and how many
other connections the machine accepted meanwhile."""
req = os.path.join(work, "req")
with open(req, "wb") as f:
f.write(b"GET " + PAGE.encode() + b" HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n")
opt = ["-tls1_3", "-ciphersuites", offer] if version == "1.3" else ["-tls1_2", "-cipher", offer]
s0, i0, p0 = _tls_stat(), _engine_irqs(), _passive_opens()
with open(req, "rb") as stdin:
r = subprocess.run([OPENSSL, "s_client", "-connect", "127.0.0.1:443", "-brief", "-ign_eof"] + opt,
stdin=stdin, capture_output=True, timeout=60)
s1, i1, p1 = _tls_stat(), _engine_irqs(), _passive_opens()
info = {}
for line in r.stderr.decode("utf-8", "replace").splitlines():
k, _, v = line.partition(":")
info[k.strip()] = v.strip()
head, _, body = r.stdout.partition(b"\r\n\r\n")
delta = {k: s1.get(k, 0) - s0.get(k, 0) for k in ("TlsTxSw", "TlsRxSw", "TlsDecryptError")}
return (info.get("Ciphersuite"), head.split(b"\r\n")[0].decode("latin-1"), body, delta,
(i1 or 0) - (i0 or 0), p1 - p0 - 1)
@test("forgectrl.tls-records", title="HTTPS: ChaCha20 chosen, records sealed in the kernel",
subsystem="forgectrl", kind="auto", est_min=1,
covers=[("forgectrl", "src/tls.*"), ("forgectrl", "src/main.c")],
description="forgectrl's HTTPS listener chooses the cipher, ChaCha20-Poly1305 first, and the "
"image seals the records in the kernel: GnuTLS hands each connection's keys to the "
"socket after the handshake (/etc/gnutls/config turns it on). A client that lists "
"AES-GCM first, as a desktop browser does, gets ChaCha20-Poly1305 over TLS 1.3 and "
"TLS 1.2, and the kernel takes both directions' keys; the kernel's rfc7539 is the "
"NEON ChaCha20 and Poly1305. A client offering AES-GCM alone gets it, the kernel "
"takes its keys, and the crypto engine's interrupt counts the records: the AES runs "
"on the CAAM (gcm(aes) is its ctr(aes) with the NEON GHASH), which writes each "
"record by DMA while the CPU fills the next, so those connections run three times "
"over each protocol and every copy must be exact. A TLS 1.2 client "
"offering a CBC suite alone still connects and stays in GnuTLS, which is the control "
"for the kernel's counters; the ChaCha20 runs are the control for the engine's "
"interrupt. Every copy of the panel page must be the plain-HTTP page byte for byte, "
"and the kernel must count no decrypt error. The counters and the interrupt are the "
"machine's, so a connection during which another client connected (a panel "
"reconnecting after a restart) is measured again. Nothing on the machine changes.")
def tls_records(ctx):
ev = ctx.evidence
conf = ""
if os.path.exists("/etc/gnutls/config"):
with open("/etc/gnutls/config") as f:
conf = f.read()
ctx.check(any(line.split() == ["ktls", "=", "true"] for line in conf.splitlines()),
"/etc/gnutls/config does not turn kernel TLS on: %r", conf)
with open("/proc/sys/net/ipv4/tcp_available_ulp") as f:
ulp = f.read().split()
ev["tcp_available_ulp"] = ulp
ctx.check("tls" in ulp, "the kernel has no TLS layer (tcp_available_ulp %s)", ulp)
ctx.check(_engine_irqs() is not None, "/proc/interrupts has no %s", ENGINE_IRQ)
with urllib.request.urlopen("http://127.0.0.1" + PAGE, timeout=30) as r:
plain = r.read()
ev["page_bytes"] = len(plain)
ctx.check(len(plain) > 8 * RECORD, "the page is %d bytes, too small to span records", len(plain))
records = len(plain) // RECORD
work = tempfile.mkdtemp(prefix="forgetest-tls-")
try:
runs = {}
# The AES-GCM connections run three times each: their records are written by the crypto
# engine's DMA while the CPU fills the next one, which is where a record's last bytes were
# once lost.
cases = [("desktop-1.3", "1.3", DESKTOP_13, "TLS_CHACHA20_POLY1305_SHA256", True),
("desktop-1.2", "1.2", DESKTOP_12, "ECDHE-ECDSA-CHACHA20-POLY1305", True)]
for n in (1, 2, 3):
cases += [("aes-1.3#%d" % n, "1.3", "TLS_AES_128_GCM_SHA256", "TLS_AES_128_GCM_SHA256", True),
("aes-1.2#%d" % n, "1.2", "ECDHE-ECDSA-AES128-GCM-SHA256", "ECDHE-ECDSA-AES128-GCM-SHA256",
True)]
cases.append(("cbc-1.2", "1.2", "ECDHE-ECDSA-AES128-SHA", "ECDHE-ECDSA-AES128-SHA", False))
for label, version, offer, want, kernel in cases:
# The kernel's counters and the engine's interrupt are the machine's, not the
# connection's: a window in which another client connected (a panel reconnecting
# after a restart, a page polling this suite once a second) says nothing about this
# one, and is measured again, after a pause that no steady poller stays in step with.
for attempt in range(1, 31):
suite, status, body, delta, irqs, others = _fetch(version, offer, work)
if others <= 0:
break
ctx.log("%s: %d other connection(s) during the window; measured again", label, others)
ctx.sleep(random.uniform(0.1, 0.9))
ctx.check(others <= 0, "%s: another client connected during each of %d windows", label, attempt)
run = {"offer": offer, "suite": suite, "status": status, "bytes": len(body),
"same_as_http": body == plain, "tls_stat": delta, "engine_irqs": irqs, "attempts": attempt}
runs[label] = run
ctx.log("%s: offer %s -> %s, %s, %d bytes, same %s, tls_stat %s, %s +%d",
label, offer, suite, status, len(body), body == plain, delta, ENGINE_IRQ, irqs)
ctx.check(suite == want, "%s: offered %s, the listener chose %s, not %s", label, offer, suite, want)
ctx.check(status.endswith("200 OK") and body == plain,
"%s: the page arrived %s, %d bytes, not the plain-HTTP %d", label, status, len(body),
len(plain))
ctx.check(delta["TlsDecryptError"] == 0, "%s: the kernel counted %d decrypt errors", label,
delta["TlsDecryptError"])
if kernel:
ctx.check(delta["TlsTxSw"] == 1 and delta["TlsRxSw"] == 1,
"%s: the kernel did not take both directions' keys (tls_stat %s)", label, delta)
else:
ctx.check(delta["TlsTxSw"] == 0 and delta["TlsRxSw"] == 0,
"%s: the kernel took keys for a cipher it does not seal (tls_stat %s)", label, delta)
ev["runs"] = runs
# The AES of AES-GCM runs on the crypto engine: its interrupt counts
# the records. ChaCha20 never touches the engine, so those runs are
# the control.
for label in ("aes-1.3#1", "aes-1.2#1"):
ctx.check(runs[label]["engine_irqs"] >= records // 4,
"%s: %d records crossed and the crypto engine interrupted %d times", label, records,
runs[label]["engine_irqs"])
for label in ("desktop-1.3", "desktop-1.2"):
ctx.check(runs[label]["engine_irqs"] < max(2, records // 8),
"%s: the crypto engine interrupted %d times for ChaCha20 records", label,
runs[label]["engine_irqs"])
finally:
shutil.rmtree(work, ignore_errors=True)
# The drivers the kernel resolved for the two record ciphers (both
# exist now: the connections above instantiated them).
chacha, gcm = _driver("rfc7539(chacha20,poly1305)"), _driver("gcm(aes)")
ev["drivers"] = {"rfc7539(chacha20,poly1305)": chacha, "gcm(aes)": gcm}
ctx.log("rfc7539(chacha20,poly1305) is %s; gcm(aes) is %s", chacha, gcm)
ctx.check(chacha == CHACHA_DRIVER, "the kernel's ChaCha20-Poly1305 is %s, not %s", chacha, CHACHA_DRIVER)
ctx.check(gcm and "ctr-aes-caam" in gcm and "ghash-ce" in gcm,
"the kernel's gcm(aes) is %s: not the crypto engine's AES with the NEON GHASH", gcm)
-149
View File
@@ -1,149 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""update.* - the A/B slot inventory and the firmware verification path."""
import os
import tempfile
import time
import shutil
from ..catalog import test
from .. import hw
_UPDATE_COVERS = [("forgectrl", "src/update.c"), ("forgectrl", "src/update.h"),
("forgectrl", "src/relcheck.c"), ("forgectrl", "src/relcheck.h"),
("ffboot", "**")]
@test("update.slots-and-signature", title="Boot slots readable, unsigned and foreign-signed archives refused",
subsystem="update", kind="auto", est_min=2,
covers=_UPDATE_COVERS, requires=["forgectrl.auth"],
description="/slots reports the A/B inventory consistent with `ffboot -l`; /update/status "
"answers; `fwup` refuses a garbage archive. A tiny archive signed with a "
"throwaway key made on the spot verifies with its own key and fails against the "
"shipped release key, and an apply of it without confirm_unsigned is refused by "
"the update job before anything is written, and the forgectrl log records the "
"job's start and its refusal. Nothing is written to any slot.")
def slots_and_signature(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
st, slots = fc.get("/slots")
ctx.log("GET /slots -> %s %s", st, slots)
ctx.check(st == 200 and isinstance(slots, dict), "GET /slots -> %s", st)
ev["slots"] = slots
rc, out = hw.run(["ffboot", "-l"])
ev["ffboot_l_rc"] = rc
ctx.log("ffboot -l -> rc %s\n%s", rc, out.strip())
ctx.check(rc == 0, "ffboot -l failed (%s)", rc)
text = str(slots).lower()
ctx.check("forgefirm" in text or "slot" in text, "/slots does not look like a slot inventory")
st, us = fc.get("/update/status")
ctx.log("GET /update/status -> %s %s", st, us)
ctx.check(st == 200 and isinstance(us, dict) and "running" in us, "GET /update/status -> %s", st)
ctx.check(not us.get("running"), "an update is running")
key = "/etc/forgefirm/keys/forgefirm-release.pub"
ctx.check(os.path.exists(key), "release key %s missing", key)
with tempfile.NamedTemporaryFile(prefix="forgetest-", suffix=".fw", delete=False) as f:
f.write(b"this is not a firmware archive" * 64)
garbage = f.name
try:
rc, out = hw.run(["fwup", "-V", "-i", garbage, "-p", key], timeout=30)
ev["fwup_garbage_rc"] = rc
ctx.log("fwup -V garbage -> rc %s: %s", rc, out.strip()[:200])
ctx.check(rc != 0, "fwup accepted a garbage archive")
finally:
os.unlink(garbage)
# A foreign signature: a throwaway key pair signs a tiny archive. The
# shipped key must refuse it, its own key must accept it, and the
# apply job must refuse it without confirm_unsigned, before it
# touches the slot.
work = tempfile.mkdtemp(prefix="forgetest-fw-")
staged = "/data/forgefirm/upload.fw"
try:
with open(os.path.join(work, "note.txt"), "w") as f:
f.write("forgetest foreign-signature drill\n")
with open(os.path.join(work, "fwup.conf"), "w") as f:
f.write('meta-product = "forgetest"\nmeta-version = "0.0.0-test"\n'
'file-resource note.txt { host-path = "note.txt" }\n'
'task complete { on-resource note.txt { raw_write(0) } }\n')
rc, out = hw.run(["sh", "-c", "cd %s && fwup -g" % work], timeout=60)
ev["fwup_gen_rc"] = rc
ctx.check(rc == 0 and os.path.exists(os.path.join(work, "fwup-key.pub")),
"fwup -g did not make a key pair (rc %s): %s", rc, out.strip()[:200])
plain, signed = os.path.join(work, "plain.fw"), os.path.join(work, "signed.fw")
rc, out = hw.run(["sh", "-c", "cd %s && fwup -c -f fwup.conf -o plain.fw && "
"fwup -S -s fwup-key.priv -i plain.fw -o signed.fw" % work], timeout=60)
ctx.check(rc == 0 and os.path.exists(signed), "could not make the signed archive (rc %s): %s",
rc, out.strip()[:200])
rc_own, _ = hw.run(["fwup", "-V", "-i", signed, "-p", os.path.join(work, "fwup-key.pub")], timeout=30)
rc_ship, out = hw.run(["fwup", "-V", "-i", signed, "-p", key], timeout=30)
ev["fwup_foreign"] = {"own_key_rc": rc_own, "shipped_key_rc": rc_ship}
ctx.log("fwup -V signed: own key rc %s, shipped key rc %s", rc_own, rc_ship)
ctx.check(rc_own == 0, "the archive does not verify with the key that signed it")
ctx.check(rc_ship != 0, "the shipped release key accepted a foreign signature")
# The apply path. The target is the slot not booted; a slot already
# selected for the next boot is refused for its own reason, which
# this drill records and steps over.
target = None
for name, si in (slots.get("slots") or {}).items():
if name in ("a", "b") and isinstance(si, dict) and not si.get("booted"):
target = name
ctx.check(target is not None, "no inactive firmware slot in /slots: %s", slots)
shutil.copyfile(signed, staged)
log_path = "/data/log/forgefirm/forgectrl/forgectrl.log"
try:
log_from = os.path.getsize(log_path)
except OSError:
log_from = 0
st, body = fc.post("/update/apply", params={"slot": target, "file": "upload"})
ev["apply"] = {"status": st, "body": body}
ctx.log("POST /update/apply slot=%s file=upload (no confirm_unsigned) -> %s %s", target, st, body)
if st == 409 and isinstance(body, dict) and "next boot" in str(body.get("error", "")):
ctx.log("slot %s is selected for the next boot: the apply is refused before the "
"signature check, which is its own guard", target)
else:
ctx.check(st == 202 and isinstance(body, dict) and body.get("started") is True,
"the apply job did not start: %s %s", st, body)
result = None
t0 = time.time()
while time.time() - t0 < 60:
ctx.sleep(1)
st, us = fc.get("/update/status")
if isinstance(us, dict) and not us.get("running"):
result = us.get("result")
break
ev["apply_result"] = result
ctx.log("apply result: %s", result)
ctx.check(isinstance(result, dict) and result.get("ok") is False
and "not signed" in str(result.get("error", "")),
"the apply of a foreign-signed archive was not refused for its signature: %s", result)
# The forgectrl log records the job: what it set out to write
# and why it stopped.
want = ["update: writing the uploaded archive into slot %s" % target,
"update: the apply failed: archive is not signed"]
def log_text():
try:
with open(log_path, "r", encoding="utf-8", errors="replace") as f:
f.seek(log_from)
return f.read()
except OSError:
return ""
ctx.wait_for(lambda: all(w in log_text() for w in want), 10, poll=0.5)
text = log_text()
ev["apply_log"] = [ln for ln in text.splitlines() if "update: " in ln][-4:]
for w in want:
ctx.check(w in text, "the forgectrl log lacks %r: %s", w, ev["apply_log"])
finally:
try:
os.unlink(staged)
except OSError:
pass
shutil.rmtree(work, ignore_errors=True)
-339
View File
@@ -1,339 +0,0 @@
/*
* app.js - forgetest: the acceptance page
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*
* Acceptance tab + bench tab. State comes from GET /state, polled every
* 2 s when the machine is idle and every second during a run; the catalog
* and the bench listing are fetched once per tab and refreshed after a
* run finishes.
*
* Rows, prompt buttons and tool entries are built once and thereafter
* only updated in place. A poll that rebuilt them would swallow the click
* it landed on: the button that took the mousedown would be gone before
* the mouseup, so no click event would ever be raised. Every action also
* grays its control out on the press and pulls the next poll forward, so
* the page answers the operator rather than the timer. The help popovers
* (help.js) sit on static elements only, so no rebuild ever orphans one.
*
* TOKEN is the bearer token page.py splices in when it serves the page.
*/
var TOKEN='__TOKEN__';
var state=null, catalog=null, catalogHash=null, bench=null, tab='acceptance', openDetails={};
var lastRunKey=null, stateEtag=null, curPrompt=null, promptKey=null, abortSent=false;
var pollTimer=null, polling=false, pending=0, pendingId=null, rowMsg={};
var rowEls=null, groupsKey=null, benchEls=null, benchKey=null, benchNeedsRefresh=false;
var PENDING_MS=6000;
/* A poll that never answers must not wedge the loop: it times out
and the next one is scheduled as usual. Actions carry no timeout,
because a POST the server did act on must not read as a failure. */
var POLL_TIMEOUT_MS=20000;
var ignoreReq=false;try{ignoreReq=window.localStorage.getItem('forgetest.ignoreReq')==='1'}catch(e){}
function $(id){return document.getElementById(id)}
function nowMs(){return (new Date()).getTime()}
function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]})}
/* Touch the DOM only where the value really changed. Replacing a node
under the operator's finger loses the click: the button that took the
mousedown is gone before the mouseup, so no click event is ever
raised, and the press does nothing. Nothing on a poll may rebuild a
control that is only sitting there unchanged. */
function setHtml(e,h){if(e&&e.__h!==h){e.__h=h;e.innerHTML=h}}
function setText(e,t){if(e&&e.__t!==t){e.__t=t;e.textContent=t}}
function setDis(e,d){if(e&&e.disabled!==d)e.disabled=d}
function setProp(e,k,v){if(e&&e[k]!==v)e[k]=v}
function api(method,path,body,cb,hdrs,timeoutMs){var x=new XMLHttpRequest();x.open(method,path,true);
if(timeoutMs)x.timeout=timeoutMs;
x.setRequestHeader('X-ForgeFIRM-Token',TOKEN);
if(hdrs){for(var k in hdrs){if(hdrs.hasOwnProperty(k))x.setRequestHeader(k,hdrs[k])}}
if(body!==undefined&&body!==null){x.setRequestHeader('Content-Type','application/json')}
x.onreadystatechange=function(){if(x.readyState!==4)return;var d=null;try{d=JSON.parse(x.responseText)}catch(e){d={error:x.responseText}}cb(x.status,d,x)};
x.send(body===undefined||body===null?null:JSON.stringify(body))}
/* The state poll. It carries the last ETag, so an unchanged state costs
a 304 and no re-render; a run gets a faster tick, and every action
pulls the next poll forward instead of waiting out the interval. */
function pollDelay(){return (state&&(state.running||batchActive()))?1000:2000}
function schedule(ms){if(pollTimer)window.clearTimeout(pollTimer);pollTimer=window.setTimeout(poll,ms)}
function kick(){schedule(120)}
function poll(){if(polling){schedule(150);return}polling=true;
/* While an action is still in flight the state is asked for in full:
a 304 would skip the render that releases the grayed-out buttons. */
var h=(stateEtag&&!pending)?{'If-None-Match':stateEtag}:null;
api('GET','/state',null,function(s,d,x){polling=false;
if(s===200){stateEtag=x.getResponseHeader('ETag');state=d;
if(!catalog||catalogHash!==d.catalog_hash){loadCatalog(render)}else{render()}}
schedule(pollDelay())},h,POLL_TIMEOUT_MS)}
function showTab(t){tab=t;closeHelp();$('tab-acceptance').className=t==='acceptance'?'on':'';$('tab-bench').className=t==='bench'?'on':'';
$('pane-acceptance').style.display=t==='acceptance'?'':'none';$('pane-bench').style.display=t==='bench'?'':'none';
if(t==='bench'&&!bench)loadBench()}
function setMsg(id,txt,err){setHtml($(id),txt?"<div class='"+(err?'err':'msg')+"'>"+esc(txt)+"</div>":'')}
function loadCatalog(cb){api('GET','/catalog',null,function(s,d){if(s===200){catalog=d.tests;catalogHash=d.catalog_hash;if(cb)cb()}})}
function loadBench(){api('GET','/bench',null,function(s,d){if(s===200){bench=d;benchKey=null;renderBench()}})}
function setIgnoreReq(on){ignoreReq=!!on;try{window.localStorage.setItem('forgetest.ignoreReq',ignoreReq?'1':'0')}catch(e){}
$('ignreq').checked=ignoreReq;setHtml($('ignreqon'),ignoreReq?"<span class='on'>ON - prerequisites are not enforced</span>":'');
if(state&&catalog)renderGroups()}
/* A start already sent but not yet seen in the state counts as busy, so
the buttons gray out on the click rather than on the next poll. The
window is capped in case the answer never arrives. A queue holds the
machine between its tests as well as during them. */
function batchActive(){return !!(state&&state.batch&&!state.batch.finished)}
function isBusy(){if(state&&(state.running||batchActive()))return true;
return !!(pending&&(nowMs()-pending)<PENDING_MS)}
function fmtTs(t){return t?t.replace('T',' ').replace('Z',' UTC'):'-'}
function render(){if(!state||!catalog)return;
if(state.running||batchActive()){pending=0;pendingId=null}
var m=state.manifest||{};setText($('hdrver'),(m.version||'?'));setText($('hdrsub'),m.image||'');
var a=$('auth');setText(a,'Release authorized: '+(state.authorized?'YES':'NO'));setProp(a,'className','auth'+(state.authorized?' yes':''));
var c=state.campaign,cn=state.counts||{};var b='';
b+='<b>'+cn.satisfied+'</b> of <b>'+cn.total+'</b> satisfied ('+cn.inherited+' inherited), <b>'+cn.required+'</b> required<br>';
b+=c?('campaign <span class="mono">'+esc(c.id)+'</span> opened '+esc(fmtTs(c.ts))):('no open campaign'+(state.closed_by?(' (last one closed by '+esc(state.closed_by)+')'):'')+' - the first Start opens one');
b+='<br>manifest identity <span class="mono">'+esc((m.sha||'').slice(0,16))+'</span> &middot; catalog <span class="mono">'+esc((state.catalog_hash||'').slice(0,12))+'</span>';
if(state.log_corrupt)b+='<br><span class="b-bad">'+state.log_corrupt+' corrupt log line(s) skipped</span>';
setHtml($('banner'),b);
var inv=state.invalidate;setHtml($('invnote'),inv?"<div class='note'>Full campaign required since "+esc(fmtTs(inv.ts))+": "+esc(inv.reason)+"</div>":'');
renderQueue();renderGroups();renderRun();renderSteps();if(bench)renderBench()}
/* The two queues: what each would run now, and how the running one is
getting on. Built from the state, so a reload picks the queue back up
exactly where it is - the queue lives in the runner, not in this tab. */
var QUEUES=[['unattended','Unattended'],['attended','Operator and live']];
function renderFixture(){var f=state.fixture,e=$('fixture');if(!e)return;
if(!f){setHtml(e,'');return}
var ch=(f.channels||[]).filter(function(c){return c!=='button'||f.button_enabled});
setHtml(e,'Bench fixture <b>'+esc(f.hostname)+'</b> at '+esc(f.ip||'?')+' (v'+esc(f.version||'?')+') covers <b>'+
(ch.length?esc(ch.join(', ')):'nothing')+'</b>'+
((f.channels||[]).indexOf('button')>=0&&!f.button_enabled?' (button disabled: enable jumper out)':'')+
'; arm press: '+(f.arm_press?'<b>the fixture</b>':'the operator'))}
function renderQueue(){var av=state.batch_available||{},b=state.batch,busy=isBusy();
renderFixture();
QUEUES.forEach(function(p){var e=$('q-'+p[0]);if(!e)return;
var ids=av[p[0]]||[];
setText(e,ids.length?(p[1]+' ('+ids.length+')'):(p[1]+' (none left)'));
setDis(e,busy||!ids.length);
setProp(e,'title',!ids.length?('nothing left: every '+p[0]+' test is satisfied')
:(busy?'a run is in progress':('in order: '+ids.join(', '))))});
setDis($('q-stop'),!batchActive()||!!(b&&b.stopping));
if(!b){setHtml($('qstate'),'');return}
var total=b.order.length||1,ok=0,bad=0;
b.done.forEach(function(x){if(x.result==='PASS')ok++;else bad++});
function seg(cls,n){return n?("<i class='"+cls+"' style='width:"+(100*n/total)+"%'></i>"):''}
var h="<div class='qbar'>"+seg('ok',ok)+seg('bad',bad)+seg('skip',b.skipped.length)+
(b.current?seg('now',1):'')+"</div><div class='qline'><b>"+esc(b.group)+"</b> queue, opened "+
esc(fmtTs(b.ts))+" &middot; ";
h+=b.finished?('finished '+esc(fmtTs(b.finished))):(b.current?('running <b>'+esc(b.current)+'</b>'):
(b.stopping?'stopping':'starting'));
h+=" &middot; <b>"+ok+"</b> passed, <b>"+bad+"</b> not, <b>"+b.skipped.length+
"</b> skipped, <b>"+b.pending.length+"</b> waiting";
if(b.stopped)h+="<br><span class='req'>stopped: "+esc(b.stopped)+"</span>";
if(b.skipped.length)h+="<br>skipped: "+esc(b.skipped.map(function(x){return x.test+' ('+x.reason+')'}).join('; '));
if(b.pending.length)h+="<br>waiting: <span class='tid'>"+esc(b.pending.join(', '))+"</span>";
setHtml($('qstate'),h+"</div>")}
/* The rows are built once for a given catalog and then only updated in
place: a poll never rewrites the table, so a Start button survives the
press that is landing on it. */
function renderGroups(){var ids=[];catalog.forEach(function(t){ids.push(t.id)});
var sig=catalogHash+'|'+ids.join(',');
if(sig!==groupsKey){groupsKey=sig;buildGroups()}
updateGroups()}
var COLS="<colgroup><col><col class='k'><col class='s'><col class='l'><col class='a'></colgroup>";
function buildGroups(){var groups={},order=[];
catalog.forEach(function(t){if(!groups[t.subsystem]){groups[t.subsystem]=[];order.push(t.subsystem)}groups[t.subsystem].push(t)});
var h='';
order.forEach(function(g){h+="<div class='card grp'><h2>"+esc(g)+"</h2><table>"+COLS+"<tr><th>Test</th><th>Kind</th><th>Status</th><th>Last result</th><th></th></tr>";
groups[g].forEach(function(t){var d=esc(t.id);var tags='';
if(t.always)tags+="<span class='tag core'>core</span>";tags+="<span class='tag "+esc(t.kind)+"'>"+esc(t.kind)+"</span>";
if(t.hardware==='takeover')tags+="<span class='tag takeover'>takeover</span>";
if(t.mode)tags+="<span class='tag mode'>"+esc(t.mode)+"</span>";
var det="<div class='details"+(openDetails[t.id]?' on':'')+"' id='det-"+d+"'>"+
(t.description?("<div class='dsc'>"+esc(t.description)+"</div>"):'')+
(t.steps&&t.steps.length?"<b>Operator steps:</b><ol>"+t.steps.map(function(x){return '<li>'+esc(x)+'</li>'}).join('')+"</ol>":'')+
(t.actions&&t.actions.length?"<b>Machine actions:</b> "+esc(t.actions.join(', '))+"<br>":'')+
"<b>Requires:</b> "+esc((t.requires||[]).join(', ')||'-')+"<br><b>Covers:</b> "+esc((t.covers||[]).map(function(c){return c[0]+':'+c[1]}).join(', ')||'-')+
"<br><span id='detdyn-"+d+"'></span></div>";
h+="<tr><td><div class='tsel' title='show what this test asks of you' onclick='selectTest(\""+d+"\")'>"+esc(t.title)+"</div><div class='tid'>"+d+" <a href='#' onclick='toggleDet(\""+d+"\");return false'>details</a></div>"+
"</td><td>"+tags+"</td>"+
"<td><div id='st-"+d+"'></div><div id='unmet-"+d+"'></div><div id='note-"+d+"'></div></td>"+
"<td id='last-"+d+"'></td>"+
"<td><button class='btn btn-sm btn-primary' id='btn-"+d+"' onclick='startTest(\""+d+"\")'>Start</button></td></tr>"+
"<tr class='detrow'><td colspan='5'>"+det+"</td></tr>"});
h+="</table></div>"});
$('groups').innerHTML=h;rowEls={};
catalog.forEach(function(t){rowEls[t.id]={st:$('st-'+t.id),last:$('last-'+t.id),btn:$('btn-'+t.id),
unmet:$('unmet-'+t.id),note:$('note-'+t.id),detdyn:$('detdyn-'+t.id)}})}
function updateGroups(){if(!rowEls||!state)return;var busy=isBusy();
var queued={};if(batchActive()){(state.batch.pending||[]).forEach(function(x){queued[x]=1})}
catalog.forEach(function(t){var e=rowEls[t.id];if(!e)return;var s=state.tests[t.id]||{};
var st;
if(pendingId===t.id&&isBusy()&&!state.running){st="<span class='st running'>starting&hellip;</span>"}
else{st="<span class='st "+esc(s.status)+"'>"+esc(s.status||'none')+"</span>";
if(s.required&&s.status!=='running')st+="<br><span class='req'>required: "+esc(s.reason)+"</span>"}
if(queued[t.id])st+="<br><span class='tag queued'>queued</span>";
setHtml(e.st,st);
var last=s.last?(esc(s.last.result)+' '+esc(fmtTs(s.last.ts))):'-';
if(s.status==='inherited'&&s.origin)last+="<br><span class='tid'>from "+esc(s.origin.campaign)+" on "+esc(s.origin.image)+"</span>";
setHtml(e.last,last);
var unmet=s.requires_met===false;var can=!busy&&(!unmet||ignoreReq);
setDis(e.btn,!can);
setProp(e.btn,'title',busy?'a run is in progress':(unmet?((ignoreReq?'prerequisites overridden - needs: ':'needs: ')+(s.missing_requires||[]).join(', ')):''));
setProp(e.unmet,'className','req'+(ignoreReq?' over':''));
setHtml(e.unmet,unmet?((ignoreReq?'unmet: ':'needs: ')+esc((s.missing_requires||[]).join(', '))):'');
setHtml(e.note,rowMsg[t.id]?"<div class='err'>"+esc(rowMsg[t.id])+"</div>":'');
setHtml(e.detdyn,"<b>Fingerprint:</b> <span class='mono'>"+esc((s.fingerprint||'').slice(0,16))+"</span> &middot; est. "+esc(t.est_min)+" min"+
(s.last?"<br><a href='/result?test="+encodeURIComponent(t.id)+"&ts="+encodeURIComponent(s.last.ts)+"'>last result record</a>":''))})}
function toggleDet(id){openDetails[id]=!openDetails[id];var e=$('det-'+id);if(e)e.className='details'+(openDetails[id]?' on':'')}
function renderRun(){var r=state.running||state.last_run;var key=r?(r.kind+':'+r.id+':'+r.started):null;
if(key!==lastRunKey)abortSent=false;
document.querySelector('main').classList.toggle('running',!!state.running);
if(!r){setText($('runhead'),'idle');setText($('log'),'');$('prompt').style.display='none';$('notice').style.display='none';
curPrompt=null;promptKey=null;setDis($('abortbtn'),true);setText($('runtitle'),'Run');lastRunKey=null;return}
setText($('runtitle'),(r.kind==='bench'?'Bench: ':'Test: ')+r.title);
var hd=esc(r.id)+' &middot; started '+esc(fmtTs(r.started))+' &middot; '+r.elapsed_s+' s';
if(r.finished){hd+=' &middot; <b class="st '+(r.finished.result==='PASS'||r.finished.result==='OK'?'pass':'fail')+'">'+esc(r.finished.result)+'</b>'+(r.finished.message?' - '+esc(r.finished.message):'')}
else if(r.aborting){hd+=' &middot; <b class="st stale">aborting</b>'}
setHtml($('runhead'),hd);
var lg=$('log');var txt=(r.dropped?('... '+r.dropped+' earlier lines dropped\n'):'')+r.log.join('\n');
if(lg.__t!==txt){var atBottom=lg.scrollTop+lg.clientHeight>=lg.scrollHeight-20;
lg.__t=txt;lg.textContent=txt;if(atBottom||key!==lastRunKey)lg.scrollTop=lg.scrollHeight}
lastRunKey=key;
var nt=(r.notice&&!r.finished)?r.notice.text:'';
setText($('notice'),nt);$('notice').style.display=nt?'':'none';
/* The prompt buttons are rebuilt only when the prompt itself changes -
they are the ones the operator stares at before pressing. */
if(r.prompt&&!r.finished){var pk=r.prompt.id+'|'+r.prompt.question+'|'+r.prompt.options.length+':'+r.prompt.options.join(',');
if(pk!==promptKey){promptKey=pk;curPrompt=r.prompt;
$('promptq').textContent=r.prompt.question;var pb='';
r.prompt.options.forEach(function(o,i){pb+="<button class='btn btn-sm btn-primary' onclick='answerIdx("+i+")'>"+esc(o)+"</button>"});
$('promptb').innerHTML=pb;$('promptb').__h=pb;$('prompt').style.display=''}}
else if(promptKey!==null){promptKey=null;curPrompt=null;$('prompt').style.display='none'}
setDis($('abortbtn'),!!r.finished||abortSent||!!r.aborting);
if(r.finished&&tab==='bench'&&r.kind==='bench'&&benchNeedsRefresh){benchNeedsRefresh=false;loadBench()}}
function findTest(id){for(var i=0;i<catalog.length;i++)if(catalog[i].id===id)return catalog[i];return null}
/* What the operator will be asked to do, shown before it is asked: the
running test's steps for the whole run, the queue's attended tests
before the first one starts, or the test the operator clicked on. The
prompts and notices that follow are these steps, taken in turn. */
var selected=null;
function selectTest(id){selected=(selected===id)?null:id;renderSteps()}
function stepsOf(t,head){var h='';
var acts=(t.actions||[]);
if(head)h+="<div class='sub'>"+head+"</div>";
if(t.kind==='live')h+="<div>This test fires the laser: eye protection, fire watch, exhaust, scrap under the head.</div>";
if(acts.length)h+="<div>Machine actions: <b>"+esc(acts.join(', '))+"</b> (a standing instruction tells you when; the test watches the machine for it)</div>";
if(t.steps&&t.steps.length)h+="<ol>"+t.steps.map(function(x){return '<li>'+esc(x)+'</li>'}).join('')+"</ol>";
return h}
function renderSteps(){var e=$('steps');if(!e||!catalog)return;var h='',r=state.running;
if(r&&r.kind==='test'){var t=findTest(r.id);
if(t&&((t.steps&&t.steps.length)||(t.actions&&t.actions.length)||t.kind==='live'))h="<div class='sh'>What you will do</div>"+stepsOf(t,null)}
else if(batchActive()&&state.batch.pending&&state.batch.pending.length){var parts=[];
state.batch.pending.forEach(function(id){var t=findTest(id);if(t&&t.kind!=='auto')parts.push(stepsOf(t,esc(id)))});
if(parts.length)h="<div class='sh'>Coming up in this queue</div>"+parts.join('')}
else if(selected&&!r){var t=findTest(selected);
if(t)h="<div class='sh'>Before you start "+esc(t.id)+"</div>"+(t.kind==='auto'?"<div class='auto'>Nothing: this test needs nobody at the machine.</div>":stepsOf(t,null))}
setHtml(e,h);e.style.display=h?'':'none'}
function startTest(id){if(isBusy())return;var t=findTest(id);var body={test:id};
if(ignoreReq)body.ignore_requires=true;
if(t&&t.kind==='live'){if(!confirmLive())return;body.ack_live=true}
pending=nowMs();pendingId=id;rowMsg={};updateGroups();
setMsg('actmsg','starting '+id+'...');
api('POST','/start',body,function(s,d){
if(s!==200){pending=0;pendingId=null;rowMsg[id]=d.message||d.error;updateGroups()}
setMsg('actmsg',d.message||d.error,s!==200);kick()})}
/* The live-laser acknowledgment stays a blocking dialog on purpose: it
is the one thing on this page that must not be dismissed by a stray
click or a poll. */
function confirmLive(live){return window.confirm(
(live?('LIVE LASER QUEUE.\n\nThese fire the laser:\n - '+live.join('\n - ')+'\n'):'LIVE LASER TEST.\n')+
'\nConfirm before starting:\n - eye protection on, everyone in the room\n - fire watch present, extinguisher at hand\n - exhaust running, lid closed, scrap in place\n - you will press the physical button to arm when prompted\n\n'+
(live?'Start the queue?':'Start the test?'))}
/* A queue takes the machine for a long stretch, so both what it will run
and the acknowledgment it needs are put in front of the operator once,
before anything starts. */
function startBatch(group){if(isBusy())return;
var ids=(state&&state.batch_available&&state.batch_available[group])||[];
if(!ids.length)return;
var body={group:group};if(ignoreReq)body.ignore_requires=true;
var live=[];catalog.forEach(function(t){if(t.kind==='live'&&ids.indexOf(t.id)>=0)live.push(t.id)});
if(live.length){if(!confirmLive(live))return;body.ack_live=true}
else if(!window.confirm('Run these '+ids.length+' test(s), in this order?\n\n - '+ids.join('\n - ')))return;
pending=nowMs();pendingId=null;rowMsg={};updateGroups();renderQueue();
setMsg('qmsg','starting the '+group+' queue...');
api('POST','/batch',body,function(s,d){
if(s!==200){pending=0;updateGroups();renderQueue()}
setMsg('qmsg',d.message||d.error,s!==200);kick()})}
function stopBatch(){var e=$('q-stop');if(e.disabled)return;setDis(e,true);
setMsg('qmsg','stopping the queue...');
api('POST','/batch/stop',{},function(s,d){setMsg('qmsg',d.message||d.error,s!==200);kick()})}
function promptBusy(on){var b=$('promptb').getElementsByTagName('button');for(var i=0;i<b.length;i++)setDis(b[i],on)}
function answerIdx(i){if(!curPrompt)return;var p=curPrompt,v=p.options[i];
promptBusy(true);setMsg('actmsg','answer sent: '+v);
api('POST','/answer',{prompt_id:p.id,value:v},function(s,d){
if(s!==200){promptBusy(false);setMsg('actmsg',d.message||d.error,true)}kick()})}
function doAbort(){var b=$('abortbtn');if(b.disabled)return;abortSent=true;setDis(b,true);
setMsg('actmsg','aborting...');
api('POST','/abort',{},function(s,d){if(s!==200)abortSent=false;setMsg('actmsg',d.message||d.error,s!==200);kick()})}
function doExport(){var b=$('exportbtn');if(b.disabled)return;setDis(b,true);setMsg('actmsg','exporting...');
api('POST','/export',{},function(s,d){setDis(b,false);
if(s===200){setMsg('actmsg','exported: authorized='+d.authorized+', sha256 '+d.sha256.slice(0,16)+' - download below');$('dljson').style.display='';$('dlmd').style.display=''}
else setMsg('actmsg',d.message||d.error,true)})}
function toggleInv(){var f=$('invform');f.style.display=f.style.display==='none'?'':'none'}
function doInvalidate(){var r=$('invreason').value;if(!r){setMsg('actmsg','a reason is required',true);return}
api('POST','/invalidate',{reason:r},function(s,d){setMsg('actmsg',d.message||d.error,s!==200);
if(s===200){$('invform').style.display='none';$('invreason').value=''}kick()})}
function doReset(){api('POST','/reset',{reason:'operator reset from the page'},function(s,d){setMsg('actmsg',d.message||d.error,s!==200);kick()})}
/* Same rule as the acceptance rows: the tool list is built once and then
only updated, so a run starting never rewrites the arg fields the
operator has typed into or the button being pressed. */
function renderBench(){if(!bench)return;var ids=[];
bench.tools.forEach(function(t){ids.push(t.id+':'+(t.ported?1:0)+(t.installed?1:0))});
var sig=ids.join(',');
if(sig!==benchKey){benchKey=sig;buildBench()}
updateBench()}
function buildBench(){var groups={dry:[],takeover:[],live:[],scope:[]};
bench.tools.forEach(function(t){(groups[t.safety]||(groups[t.safety]=[])).push(t)});var h='';
['dry','takeover','scope','live'].forEach(function(g){if(!groups[g]||!groups[g].length)return;
h+="<div class='card grp'><h2>"+esc(g)+"</h2>";
groups[g].forEach(function(t){var d=esc(t.id);
h+="<div class='tool'><div><b>"+esc(t.title)+"</b> <span class='tid'>"+esc(t.script)+"</span> <span class='tag'>"+esc(t.where)+"</span>"+(t.ported?'':"<span class='tag'>unported</span>")+"</div>";
h+="<div class='hint' style='margin:2px 0 4px'>"+esc(t.desc)+"</div>";
if(t.args&&t.args.length){h+="<div class='argrow'>";t.args.forEach(function(a){var iid='arg-'+t.id+'-'+a.name;
if(a.type==='choice'){h+="<label>"+esc(a.name)+" <select class='form-select form-select-sm' id='"+iid+"'>"+a.choices.map(function(c){return "<option"+(c===a.default?' selected':'')+">"+esc(c)+"</option>"}).join('')+"</select></label>"}
else{h+="<label>"+esc(a.name)+" <input class='form-control form-control-sm' type='"+(a.type==='str'?'text':'number')+"' step='any' id='"+iid+"' value='"+esc(a.default==null?'':a.default)+"' title='"+esc(a.help)+"'></label>"}});h+="</div>"}
h+="<div class='actions'><button class='btn btn-sm btn-primary' id='tbtn-"+d+"' onclick='startTool(\""+d+"\")'>Start</button>";
h+="<span class='hint' id='tlast-"+d+"'></span></div></div>"});
h+="</div>"});
$('tools').innerHTML=h;benchEls={};
bench.tools.forEach(function(t){benchEls[t.id]={btn:$('tbtn-'+t.id),last:$('tlast-'+t.id)}})}
function updateBench(){if(!benchEls)return;var busy=isBusy();
bench.tools.forEach(function(t){var e=benchEls[t.id];if(!e)return;
var can=t.ported&&t.installed&&!busy;
setDis(e.btn,!can);
setProp(e.btn,'title',!t.ported?'not yet ported to the bench page':(!t.installed?'script not installed on this image':(busy?'a run is in progress':'')));
setHtml(e.last,t.last?("last: "+esc(t.last.result?t.last.result.result:'?')+" "+esc(fmtTs(t.last.ts))):'')})}
function startTool(id){if(isBusy())return;var t=null;bench.tools.forEach(function(x){if(x.id===id)t=x});if(!t)return;var args={};
(t.args||[]).forEach(function(a){var e=$('arg-'+id+'-'+a.name);if(e)args[a.name]=e.value});
var body={tool:id,args:args};if(t.safety==='live'){if(!confirmLive())return;body.ack_live=true}
pending=nowMs();pendingId=null;updateBench();updateGroups();
setMsg('benchmsg','starting '+id+'...');
api('POST','/bench/start',body,function(s,d){
if(s!==200){pending=0;updateBench();updateGroups()}
setMsg('benchmsg',d.message||d.error,s!==200);if(s===200)benchNeedsRefresh=true;kick()})}
/* ---- Theme ----------------------------------------------------------
Light, dark, or auto (the system preference); the choice lives in
localStorage under the key forgectrl's panel uses, so the two pages
agree. The head script in index.html applies it before first paint. */
var THEMES=['auto','light','dark'];
function themeChoice(){try{var s=localStorage.getItem('ff_theme');return s==='light'||s==='dark'?s:'auto'}catch(e){return 'auto'}}
function applyTheme(){var c=themeChoice(),t=c;
if(c==='auto')t=window.matchMedia&&matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';
document.documentElement.setAttribute('data-bs-theme',t);
$('themebtn').textContent=c==='auto'?'\u25d0 Auto':c==='dark'?'\u263e Dark':'\u2600 Light';
$('themebtn').title='Theme: '+c+' (click to change)'}
function cycleTheme(){var next=THEMES[(THEMES.indexOf(themeChoice())+1)%THEMES.length];
try{if(next==='auto')localStorage.removeItem('ff_theme');else localStorage.setItem('ff_theme',next)}catch(e){}
applyTheme()}
(function(){applyTheme();if(window.matchMedia){var mq=matchMedia('(prefers-color-scheme: dark)');
if(mq.addEventListener)mq.addEventListener('change',applyTheme);else if(mq.addListener)mq.addListener(applyTheme)}})();
initHelp();
setIgnoreReq(ignoreReq);
poll();
-127
View File
@@ -1,127 +0,0 @@
/*
* help.js - forgetest: the page's help text
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*
* Every explanation the page offers lives here, keyed by the data-help
* attribute of the "?" button that opens it (index.html): a title, its
* paragraphs, and the page of the documentation site it deep-links to
* (DOC_BASE + d). The buttons open Bootstrap popovers, one at a time; a
* click anywhere else, Escape, or a tab change closes the open one. The
* buttons sit on static elements only: the rows, prompts and tool entries
* are rebuilt by app.js, and a popover on one of those would be orphaned.
* What the operator must read mid-run (the steps, prompts, notices and
* the live-laser acknowledgment) stays in the page and never moves here.
*/
var DOC_BASE = 'https://docs.forgefirm.org/';
var HELP = {
campaign: {
t: 'Campaign',
d: 'developers/acceptance/',
p: [
'A release is authorized when a campaign is open on this image and every catalog test is satisfied: by a PASS in the campaign, or (never for the core) by an earlier PASS whose domain fingerprint is unchanged. A FAIL ends the campaign.',
'The manifest identity is the image, the catalog hash is the test set; either changing starts the count over.'
]
},
queues: {
t: 'Run what is left',
d: 'developers/acceptance/',
p: [
'Each queue takes every test of its kind that the campaign does not already count as satisfied, runs them one at a time in prerequisite order, and stops on the first result that is not a PASS.',
'The unattended queue needs nobody in the room; with the bench fixture up, the operator tests it can perform by itself run there too. The other one does: it prompts, and it fires the laser. Stop-the-queue cancels what is still waiting and lets the run in progress finish; Abort ends that one too.'
]
},
actions: {
t: 'Campaign actions',
d: 'developers/acceptance/',
p: [
'Export builds the release artifact the release gate reads (acceptance.json, with acceptance.md for people) from the campaign as it stands. The raw log is the append-only record; the journal is the daemon\'s own.',
'Invalidate all forces a full campaign; give the reason. Reset campaign closes the open campaign without invalidating anything: earlier passes stay inheritable under the campaign rules, and the next Start opens a new one.'
]
},
ignore: {
t: 'Ignore prerequisites',
d: 'developers/acceptance/',
p: [
'A test\'s requires list only orders the runs. With the switch on, any test starts alone and its record notes which prerequisites were unmet; the release still needs every test satisfied.'
]
},
bench: {
t: 'Bench diagnostics',
d: 'developers/bench/',
p: [
'The bench tools under scripts/bench, run on the board with their output in the Run pane. Nothing here enters a campaign. Tools not yet ported are listed for completeness.',
'Live tools need the operator acknowledgment; takeover and scope tools stop forgectrl for the duration and start it again when they finish.'
]
}
};
var helpOpen = null;
function helpContent(h) {
var d = document.createElement('div'),
i,
p,
a;
for (i = 0; i < h.p.length; i++) {
p = document.createElement('p');
p.textContent = h.p[i];
d.appendChild(p);
}
a = document.createElement('a');
a.className = 'doclink';
a.href = DOC_BASE + h.d;
a.target = '_blank';
a.rel = 'noopener';
a.textContent = 'Documentation \u2192';
d.appendChild(a);
return d;
}
function closeHelp() {
if (helpOpen) {
helpOpen.hide();
helpOpen = null;
}
}
function initHelp() {
var els = document.querySelectorAll('[data-help]'),
i;
for (i = 0; i < els.length; i++) {
(function (el) {
var h = HELP[el.getAttribute('data-help')];
if (!h) {
el.style.display = 'none';
return;
}
var pop = new bootstrap.Popover(el, {
trigger: 'manual',
html: true,
placement: 'auto',
title: h.t,
content: helpContent(h),
container: 'body'
});
el.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
if (helpOpen === pop) {
closeHelp();
return;
}
closeHelp();
pop.show();
helpOpen = pop;
});
el.addEventListener('hidden.bs.popover', function () {
if (helpOpen === pop) helpOpen = null;
});
})(els[i]);
}
document.addEventListener('click', function (e) {
var tip = document.querySelector('.popover.show');
if (helpOpen && !(tip && tip.contains(e.target))) closeHelp();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closeHelp();
});
}
-131
View File
@@ -1,131 +0,0 @@
<!--
index.html - forgetest: the acceptance page
Copyright 2026 514 LLC d/b/a OpenGlow
Written by Scott Wiederhold
SPDX-License-Identifier: MIT
Served at "/" as one self-contained page: page.py inlines every
stylesheet and script linked below, in order, in place of its
link/script tag. No external assets. Bootstrap (vendor/) is the UI
library; theme.css is the OpenGlow theme forgectrl's panel uses, byte
for byte, in a light and a dark variant selected by the data-bs-theme
attribute the head script sets before first paint; page.css is this
page's own layout. Design notes are at the top of app.js.
-->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>ForgeFIRM acceptance</title>
<script>
(function () {
var t = 'light';
try {
var s = localStorage.getItem('ff_theme');
if (s === 'light' || s === 'dark') t = s;
else if (window.matchMedia && matchMedia('(prefers-color-scheme: dark)').matches)
t = 'dark';
} catch (e) {}
document.documentElement.setAttribute('data-bs-theme', t);
})();
</script>
<link rel="stylesheet" href="vendor/bootstrap.min.css" />
<link rel="stylesheet" href="theme.css" />
<link rel="stylesheet" href="page.css" />
</head>
<body>
<header>
<div class="hrow">
<span class="appname">ForgeFIRM acceptance</span><span class="sub" id="hdrsub"></span
><span class="ver" id="hdrver"></span
><button id="themebtn" type="button" onclick="cycleTheme()" title="Theme">Theme</button>
</div>
</header>
<nav>
<div class="nrow">
<a id="tab-acceptance" class="on" onclick='showTab("acceptance")'>Release acceptance</a
><a id="tab-bench" onclick='showTab("bench")'>Bench diagnostics</a>
</div>
</nav>
<main>
<div id="left">
<div id="pane-acceptance">
<div class="card">
<h2>Campaign<button class="hlp" data-help="campaign" type="button">?</button></h2>
<div class="banner">
<div class="auth" id="auth">?</div>
<div class="info" id="banner"></div>
</div>
<div id="invnote"></div>
<div class="card queue">
<h2>Run what is left<button class="hlp" data-help="queues" type="button">?</button></h2>
<div class="actions">
<button class="btn btn-sm btn-primary" id="q-unattended" onclick='startBatch("unattended")'>Unattended</button>
<button class="btn btn-sm btn-primary" id="q-attended" onclick='startBatch("attended")'>Operator and live</button>
<button class="btn btn-sm btn-danger" id="q-stop" onclick="stopBatch()" disabled>Stop the queue</button>
</div>
<div id="qstate"></div>
<div id="qmsg"></div>
<div id="fixture" class="hint"></div>
</div>
<div class="actions" style="margin-top: 10px">
<button class="btn btn-sm btn-primary" id="exportbtn" onclick="doExport()">Export release artifact</button>
<a class="btn btn-sm btn-outline-secondary" id="dljson" href="/export/acceptance.json" style="display: none">acceptance.json</a>
<a class="btn btn-sm btn-outline-secondary" id="dlmd" href="/export/acceptance.md" style="display: none">acceptance.md</a>
<a class="btn btn-sm btn-outline-secondary" href="/log">Raw log</a>
<a class="btn btn-sm btn-outline-secondary" href="/journal">Runner journal</a>
<button class="btn btn-sm btn-outline-secondary" onclick="toggleInv()">Invalidate all&hellip;</button>
<button class="btn btn-sm btn-outline-secondary" onclick="doReset()">Reset campaign</button>
<button class="hlp" data-help="actions" type="button">?</button>
</div>
<div id="invform" style="display: none">
<input type="text" class="form-control form-control-sm" id="invreason" placeholder="reason (required): what changed on the bench" />
<button class="btn btn-sm btn-danger" onclick="doInvalidate()">Invalidate all results</button>
</div>
<div id="actmsg"></div>
<div class="switch">
<input class="form-check-input" type="checkbox" id="ignreq" onchange="setIgnoreReq(this.checked)" />
<label for="ignreq">Ignore prerequisites: start any test on its own, whatever its <i>requires</i> list says</label>
<span id="ignreqon"></span>
<button class="hlp" data-help="ignore" type="button">?</button>
</div>
</div>
<div id="groups"></div>
</div>
<div id="pane-bench" style="display: none">
<div class="card">
<h2>Bench diagnostics<button class="hlp" data-help="bench" type="button">?</button></h2>
<p class="hint">
The bench tools (scripts/bench), run on the board with their output in the Run
pane. Runs here never enter a campaign; live tools need the operator
acknowledgment, and takeover tools stop forgectrl for the duration.
</p>
<div id="benchmsg"></div>
</div>
<div id="tools"></div>
</div>
</div>
<div id="right">
<div class="card">
<h2 id="runtitle">Run</h2>
<div id="runhead" class="info">idle</div>
<div id="steps" style="display: none"></div>
<div id="notice" style="display: none"></div>
<div id="prompt" style="display: none">
<div class="q" id="promptq"></div>
<div class="actions" id="promptb"></div>
</div>
<pre id="log"></pre>
<div class="actions">
<button class="btn btn-sm btn-danger" id="abortbtn" onclick="doAbort()" disabled>Abort</button
><span class="hint" id="runfoot"></span>
</div>
</div>
</div>
</main>
<script src="vendor/bootstrap.bundle.min.js"></script>
<script src="help.js"></script>
<script src="app.js"></script>
</body>
</html>
-428
View File
@@ -1,428 +0,0 @@
/*
* page.css - forgetest: the acceptance page's own styles
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*
* Everything specific to this page, on the tokens theme.css defines
* (theme.css is the same file forgectrl's panel uses, so the two look like
* one product and follow the same light and dark themes). This page is a
* wide two-pane layout, so the 900 px column theme.css gives the panel is
* lifted here.
*/
.hrow,
.nrow,
main {
max-width: none;
}
.hrow .appname {
border-left: 0;
padding-left: 0;
}
.hrow .sub {
color: rgba(255, 255, 255, 0.65);
font-size: 12.5px;
}
.hrow .ver {
margin-left: auto;
color: rgba(255, 255, 255, 0.7);
font-size: 12.5px;
font-family: ui-monospace, Consolas, monospace;
}
nav a {
cursor: pointer;
}
main {
display: flex;
gap: 14px;
padding: 12px 16px;
align-items: flex-start;
}
#left {
flex: 1 1 640px;
min-width: 0;
}
#right {
flex: 0 0 420px;
position: sticky;
top: 10px;
}
@media (max-width: 1000px) {
main {
flex-direction: column;
}
#right {
position: static;
flex: 1 1 auto;
width: 100%;
}
/* A run in progress is what the operator is watching: on a phone the
run pane goes to the top for its duration. */
main.running #right {
order: -1;
}
}
.card {
margin: 0 0 12px;
}
.banner {
display: flex;
flex-wrap: wrap;
gap: 18px;
align-items: center;
background: none;
border: 0;
color: inherit;
padding: 0;
margin: 0;
font-size: inherit;
}
.auth {
font-size: 19px;
font-weight: 700;
padding: 6px 14px;
border-radius: 6px;
color: #fff;
background: var(--og-red);
}
.auth.yes {
background: var(--og-ok);
}
.info {
color: var(--og-dim);
font-size: 12.5px;
line-height: 1.7;
}
.info b {
color: var(--og-txt);
font-weight: 600;
}
.mono {
font-size: 12px;
}
table {
width: 100%;
border-collapse: collapse;
}
th {
font-size: 11.5px;
color: var(--og-dim);
text-align: left;
font-weight: 600;
padding: 5px 8px;
border-bottom: 1px solid var(--og-line);
}
td {
padding: 6px 8px;
border-bottom: 1px solid var(--og-line);
vertical-align: top;
}
tr:last-child td {
border-bottom: 0;
}
/* One grid for every subsystem. The groups are separate tables, so
left to themselves each sizes its own columns from its own content
and no two line up. Fixed widths put them all on the same rails. */
#groups .grp {
overflow-x: auto;
}
#groups table {
table-layout: fixed;
min-width: 700px;
}
#groups col.k {
width: 132px;
}
#groups col.s {
width: 250px;
}
#groups col.l {
width: 210px;
}
#groups col.a {
width: 96px;
}
/* Why a test stands where it does reads as one block under Status; a
requires list of three test ids would otherwise wrap into a ragged
stack under the Start button and drag the columns out of line. */
#groups .req {
overflow-wrap: break-word;
}
/* The details sit in a full-width row of their own: inside the Test
cell they would have to be read through a column, and opening one
would push that group's grid out of step with the rest. */
#groups td {
border-bottom: 0;
}
#groups tr.detrow td {
padding: 0 8px;
border-bottom: 1px solid var(--og-line);
}
#groups tr.detrow:last-child td {
border-bottom: 0;
}
#groups .details {
margin: 2px 0 8px;
}
#groups .details .dsc {
margin-bottom: 6px;
}
.tid {
color: var(--og-dim);
font-size: 11.5px;
font-family: ui-monospace, Consolas, monospace;
}
.tag {
display: inline-block;
font-size: 10.5px;
padding: 2px 6px;
border-radius: 9px;
margin-right: 4px;
background: var(--og-line-soft);
color: var(--og-txt);
font-weight: 600;
letter-spacing: 0.2px;
text-transform: uppercase;
}
.tag.live {
background: var(--og-bad-bg);
color: var(--og-red);
}
.tag.operator {
background: var(--og-note-bg);
color: var(--og-note-txt);
}
.tag.takeover {
background: var(--og-line-soft);
color: var(--og-title);
}
.tag.mode {
background: var(--og-line-soft);
color: var(--og-ok);
}
.tag.core {
background: var(--og-navy);
color: #fff;
}
.tag.queued {
background: var(--og-line-soft);
color: var(--og-action);
}
.st {
font-weight: 600;
}
.st.pass {
color: var(--og-ok);
}
.st.inherited {
color: var(--og-title);
}
.st.fail,
.st.error {
color: var(--og-red);
}
.st.stale,
.st.aborted {
color: var(--og-warn);
}
.st.none {
color: var(--og-dim);
}
.st.running {
color: var(--og-action);
}
.req {
font-size: 11.5px;
color: var(--og-warn);
}
.req.over {
color: var(--og-dim);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.actions .hint {
margin: 0;
}
pre#log {
background: var(--og-term-bg);
color: var(--og-term-txt);
font-family: ui-monospace, Consolas, monospace;
font-size: 11.5px;
padding: 10px;
border-radius: 4px;
height: 380px;
overflow: auto;
margin: 8px 0;
white-space: pre-wrap;
word-break: break-all;
}
#prompt {
background: var(--og-note-bg);
border: 1px solid var(--og-note-line);
color: var(--og-note-txt);
border-radius: 6px;
padding: 10px 12px;
margin: 8px 0;
}
#prompt .q {
font-weight: 600;
margin-bottom: 8px;
}
#notice {
background: var(--og-line-soft);
border: 1px solid var(--og-action);
border-radius: 6px;
padding: 10px 12px;
margin: 8px 0;
font-weight: 600;
}
#steps {
background: var(--og-line-soft);
border-radius: 6px;
padding: 8px 12px;
margin: 8px 0;
font-size: 12.5px;
line-height: 1.5;
}
#steps .sh {
font-weight: 600;
margin-bottom: 4px;
}
#steps .sub {
color: var(--og-dim);
margin: 6px 0 2px;
}
#steps ol {
margin: 2px 0 4px 20px;
}
#steps .auto {
color: var(--og-dim);
font-style: italic;
}
.tsel {
cursor: pointer;
}
.tsel:hover {
text-decoration: underline;
}
.details {
display: none;
background: var(--og-line-soft);
padding: 8px 10px;
border-radius: 4px;
font-size: 12.5px;
line-height: 1.55;
margin-top: 6px;
}
.details.on {
display: block;
}
.msg {
margin: 6px 0;
}
.err {
color: var(--og-red);
font-size: 12.5px;
margin: 6px 0;
}
.switch {
display: flex;
align-items: center;
gap: 8px;
margin: 10px 0 0;
font-size: 13px;
}
.switch input {
margin: 0;
}
.switch .on {
color: var(--og-warn);
font-weight: 600;
}
.grp {
margin-top: 6px;
}
.queue {
background: var(--og-line-soft);
margin: 10px 0 0;
padding: 10px 12px;
box-shadow: none;
}
.queue h2 {
margin-bottom: 8px;
}
.qline {
font-size: 12.5px;
color: var(--og-dim);
line-height: 1.7;
margin-top: 8px;
}
.qline b {
color: var(--og-txt);
}
.qbar {
display: flex;
height: 6px;
border-radius: 3px;
overflow: hidden;
background: var(--og-line);
margin: 6px 0;
}
.qbar i {
display: block;
}
.qbar i.ok {
background: var(--og-ok);
}
.qbar i.bad {
background: var(--og-red);
}
.qbar i.skip {
background: var(--og-warn);
}
.qbar i.now {
background: var(--og-action);
}
.tool {
border-bottom: 1px solid var(--og-line);
padding: 8px 0;
}
.tool:last-child {
border-bottom: 0;
}
.tool .argrow {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin: 6px 0;
align-items: center;
}
.tool .argrow label {
font-size: 12px;
color: var(--og-dim);
display: flex;
align-items: center;
gap: 4px;
}
.tool .argrow input,
.tool .argrow select {
width: auto;
display: inline-block;
}
.tool .argrow input {
width: 90px;
}
#invform {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
margin-top: 8px;
}
#invform input {
flex: 1 1 320px;
}
-840
View File
@@ -1,840 +0,0 @@
/*
* theme.css - forgectrl: the OpenGlow theme on top of Bootstrap
* Copyright 2026 514 LLC d/b/a OpenGlow
* Written by Scott Wiederhold
* SPDX-License-Identifier: MIT
*
* Every color the panel uses is a token here, defined once for the light
* theme and once more for the dark one; Bootstrap's own component
* variables are pointed at the tokens, so the stock components follow the
* theme without a Sass build. The visual identity is OpenGlow's
* (community.openglow.org): navy header with the starburst wordmark, blue
* actions, laser red reserved for the brand accent and fault states. The
* header keeps its navy in both themes; the log viewers stay dark in both
* because they are terminals.
*
* Density: the panel is a machine control surface read at a glance, so
* everything runs a size tighter than Bootstrap's defaults (13.5 px body,
* small controls, compact cards).
*/
:root,
[data-bs-theme='light'] {
--og-navy: #2b2b5e;
--og-red: #e8262a;
--og-action: #0088cc;
--og-action-hover: #0077b3;
--og-ok: #3d854d;
--og-warn: #c7760a;
--og-dim: #767a82;
--og-bg: #f0f1f4;
--og-card: #ffffff;
--og-line: #dde0e6;
--og-line-soft: #eceef1;
--og-txt: #222222;
--og-field-bg: #ffffff;
--og-field-line: #c9cdd4;
--og-placeholder: #a6aab2;
--og-note-bg: #fdf3e3;
--og-note-line: #eccb90;
--og-note-txt: #7a5a12;
--og-warn-bg: #fff6e6;
--og-warn-txt: #7a4a06;
--og-bad-bg: #fdeeee;
--og-bad-txt: #7a1f1f;
--og-safety-bg: #fdeaea;
--og-safety-txt: #8a1518;
--og-cam-bg: #e8e9ec;
--og-term-bg: #23252b;
--og-term-txt: #cfd3da;
--og-title: var(--og-navy);
--og-nav-on: var(--og-navy);
}
[data-bs-theme='dark'] {
--og-navy: #2b2b5e;
--og-red: #f05a5e;
--og-action: #2ea3e0;
--og-action-hover: #46b3ec;
--og-ok: #5fb374;
--og-warn: #e09a3a;
--og-dim: #9296a2;
--og-bg: #15161c;
--og-card: #1e2028;
--og-line: #32353f;
--og-line-soft: #2a2d36;
--og-txt: #d9dce3;
--og-field-bg: #14151b;
--og-field-line: #3d404b;
--og-placeholder: #6b6f7a;
--og-note-bg: #2e2717;
--og-note-line: #6a5323;
--og-note-txt: #e3c584;
--og-warn-bg: #2f2415;
--og-warn-txt: #eab86a;
--og-bad-bg: #331c1e;
--og-bad-txt: #f0a3a6;
--og-safety-bg: #361a1c;
--og-safety-txt: #f3a7aa;
--og-cam-bg: #2a2d36;
--og-term-bg: #0f1014;
--og-term-txt: #cfd3da;
--og-title: #b9bcd8;
--og-nav-on: #e6e8f5;
}
/* ---- Bootstrap variables pointed at the tokens ---------------------- */
:root,
[data-bs-theme='light'],
[data-bs-theme='dark'] {
--bs-body-bg: var(--og-bg);
--bs-body-color: var(--og-txt);
--bs-body-font-size: 13.5px;
--bs-body-font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--bs-border-color: var(--og-line);
--bs-secondary-color: var(--og-dim);
--bs-link-color: var(--og-action);
--bs-link-hover-color: var(--og-action-hover);
--bs-link-color-rgb: 0, 136, 204;
--bs-link-hover-color-rgb: 0, 119, 179;
--bs-primary: var(--og-action);
--bs-danger: var(--og-red);
--bs-success: var(--og-ok);
--bs-warning: var(--og-warn);
--bs-font-monospace: ui-monospace, Consolas, monospace;
}
.btn-primary {
--bs-btn-bg: var(--og-action);
--bs-btn-border-color: var(--og-action);
--bs-btn-hover-bg: var(--og-action-hover);
--bs-btn-hover-border-color: var(--og-action-hover);
--bs-btn-active-bg: var(--og-action-hover);
--bs-btn-active-border-color: var(--og-action-hover);
--bs-btn-disabled-bg: var(--og-action);
--bs-btn-disabled-border-color: var(--og-action);
--bs-btn-font-weight: 600;
}
.btn-outline-secondary {
--bs-btn-color: var(--og-txt);
--bs-btn-border-color: var(--og-field-line);
--bs-btn-bg: var(--og-field-bg);
--bs-btn-hover-color: var(--og-txt);
--bs-btn-hover-bg: var(--og-field-bg);
--bs-btn-hover-border-color: var(--og-nav-on);
--bs-btn-active-color: var(--og-txt);
--bs-btn-active-bg: var(--og-line-soft);
--bs-btn-active-border-color: var(--og-nav-on);
--bs-btn-disabled-color: var(--og-txt);
--bs-btn-disabled-bg: var(--og-field-bg);
--bs-btn-disabled-border-color: var(--og-field-line);
}
.btn-danger {
--bs-btn-bg: var(--og-red);
--bs-btn-border-color: var(--og-red);
--bs-btn-font-weight: 600;
}
.btn:disabled,
.btn.disabled {
opacity: 0.55;
cursor: not-allowed;
}
.form-control,
.form-select {
--bs-body-bg: var(--og-field-bg);
--bs-border-color: var(--og-field-line);
color: var(--og-txt);
}
.form-control:focus,
.form-select:focus {
border-color: var(--og-action);
box-shadow: 0 0 0 0.15rem rgba(0, 136, 204, 0.2);
}
.form-control::placeholder {
color: var(--og-placeholder);
}
.form-control.dirty,
.form-select.dirty {
border-color: var(--og-warn);
}
.card {
--bs-card-bg: var(--og-card);
--bs-card-border-color: var(--og-line);
--bs-card-border-radius: 8px;
padding: 12px 14px;
margin: 12px 0;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.popover {
--bs-popover-max-width: 400px;
--bs-popover-font-size: 12.5px;
--bs-popover-bg: var(--og-card);
--bs-popover-border-color: var(--og-line);
--bs-popover-body-color: var(--og-txt);
--bs-popover-header-bg: var(--og-line-soft);
--bs-popover-header-color: var(--og-title);
--bs-popover-header-font-size: 12px;
}
.popover-header {
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.6px;
}
.popover-body p {
margin: 0 0 8px;
line-height: 1.5;
}
.popover-body p:last-child {
margin-bottom: 0;
}
.popover-body .doclink {
display: inline-block;
margin-top: 6px;
font-weight: 600;
}
.modal {
--bs-modal-bg: var(--og-card);
--bs-modal-border-color: var(--og-line);
}
.toast {
--bs-toast-bg: var(--og-card);
--bs-toast-border-color: var(--og-line);
--bs-toast-color: var(--og-txt);
--bs-toast-font-size: 13px;
}
.toast.t-ok {
border-left: 4px solid var(--og-ok);
}
.toast.t-bad {
border-left: 4px solid var(--og-red);
}
/* ---- The release dialog -------------------------------------------- */
#rel-view h3 {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--og-dim);
margin: 12px 0 4px;
}
.relnotes {
max-height: 40vh;
overflow: auto;
border: 1px solid var(--og-line);
border-radius: 6px;
padding: 8px 12px;
margin: 4px 0 8px;
font-size: 13px;
line-height: 1.5;
}
.relnotes h1,
.relnotes h2,
.relnotes h3 {
font-size: 14px;
font-weight: 600;
margin: 10px 0 4px;
}
.relnotes h1:first-child,
.relnotes h2:first-child,
.relnotes h3:first-child {
margin-top: 0;
}
.relnotes p,
.relnotes ul {
margin: 0 0 6px;
}
.relnotes ul {
padding-left: 20px;
}
.relnotes code {
font-size: 12px;
}
.relnotes table {
border-collapse: collapse;
margin: 4px 0 8px;
}
.relnotes th,
.relnotes td {
border: 1px solid var(--og-line);
padding: 2px 8px;
text-align: left;
}
.relwarn {
background: var(--og-warn-bg);
border: 1px solid var(--og-warn);
color: var(--og-warn-txt);
border-radius: 8px;
padding: 10px 12px;
margin: 0 0 12px;
font-size: 14px;
}
.relsteps {
margin: 0 0 12px;
padding-left: 22px;
font-size: 13px;
line-height: 1.7;
}
.relsteps li {
color: var(--og-dim);
}
.relsteps li.cur {
color: var(--og-txt);
font-weight: 600;
}
.relsteps li.done {
color: var(--og-ok);
}
.relsteps li.done::after {
content: ' \2713';
}
.progress {
--bs-progress-bg: var(--og-line-soft);
--bs-progress-bar-bg: var(--og-action);
--bs-progress-height: 10px;
margin: 0 0 8px;
}
#rel-phase {
min-height: 1.4em;
font-size: 12.5px;
margin: 0;
}
#rel-err {
margin: 10px 0 0;
}
/* ---- Chrome: header, tabs, footer ---------------------------------- */
body {
margin: 0;
}
body.has-savebar {
padding-bottom: 64px;
}
header {
background: var(--og-navy);
}
.hrow {
max-width: 900px;
margin: 0 auto;
padding: 10px 20px;
display: flex;
align-items: center;
gap: 14px;
}
.appname {
color: #fff;
font-size: 18px;
font-weight: 600;
letter-spacing: 0.3px;
border-left: 1px solid rgba(255, 255, 255, 0.25);
padding-left: 14px;
}
#host {
color: rgba(255, 255, 255, 0.55);
font-size: 12.5px;
margin-left: auto;
}
#themebtn,
#logsbtn,
#logoutbtn {
background: transparent;
color: rgba(255, 255, 255, 0.75);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 5px;
padding: 3px 9px;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
}
#themebtn:hover,
#logsbtn:hover,
#logoutbtn:hover {
color: #fff;
border-color: rgba(255, 255, 255, 0.6);
}
nav {
background: var(--og-card);
border-bottom: 1px solid var(--og-line);
}
.nrow {
max-width: 900px;
margin: 0 auto;
padding: 0 20px;
display: flex;
gap: 2px;
overflow-x: auto;
overflow-y: hidden;
}
nav a {
padding: 9px 13px;
color: var(--og-dim);
text-decoration: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
white-space: nowrap;
}
nav a.on {
color: var(--og-nav-on);
font-weight: 600;
border-color: var(--og-red);
}
nav a:hover {
color: var(--og-nav-on);
}
main {
max-width: 900px;
margin: 0 auto;
padding: 4px 20px 12px;
}
section {
display: none;
}
section.on {
display: block;
}
footer {
max-width: 900px;
margin: 0 auto;
padding: 6px 20px 28px;
color: var(--og-dim);
font-size: 12.5px;
text-align: center;
}
footer a {
text-decoration: none;
}
/* ---- Cards and settings rows --------------------------------------- */
.card h2 {
font-size: 12px;
margin: 0 0 8px;
color: var(--og-title);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.9px;
display: flex;
align-items: center;
gap: 6px;
}
.card h3 {
font-size: 11.5px;
margin: 12px 0 4px;
color: var(--og-dim);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
display: flex;
align-items: center;
gap: 6px;
}
.card h3:first-of-type {
margin-top: 6px;
}
.grid2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 24px;
}
.frow {
display: grid;
grid-template-columns: 180px 1fr;
gap: 6px 10px;
align-items: center;
margin: 5px 0;
}
.frow label {
color: var(--og-txt);
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
.frow .u {
color: var(--og-dim);
font-size: 11.5px;
margin-left: 2px;
}
.extadvdoc {
max-height: 22rem;
overflow-y: auto;
margin-bottom: 0.5rem;
}
.extadvdoc h1,
.extadvdoc h2 {
font-size: 1rem;
margin: 0.6rem 0 0.3rem;
}
.extcaps {
margin: 0.2rem 0 0.2rem 1.1rem;
padding: 0;
}
.extcaps li {
margin: 0;
}
.hint {
color: var(--og-dim);
font-size: 12.5px;
line-height: 1.5;
margin: 8px 0 0;
}
.btns {
margin-top: 10px;
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.msg {
font-size: 12.5px;
color: var(--og-action);
}
.mono {
font-family: ui-monospace, Consolas, monospace;
}
.brk {
word-break: break-all;
}
/* The help button: a small ring that opens the field's popover. */
.hlp {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 0;
border-radius: 50%;
border: 1px solid var(--og-dim);
background: transparent;
color: var(--og-dim);
font-size: 10.5px;
font-weight: 700;
line-height: 1;
cursor: pointer;
vertical-align: middle;
letter-spacing: 0;
text-transform: none;
flex: 0 0 auto;
}
.hlp:hover,
.hlp[aria-describedby] {
color: var(--og-action);
border-color: var(--og-action);
}
/* ---- Status widgets ------------------------------------------------ */
.kv {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 5px 2px;
border-bottom: 1px solid var(--og-line-soft);
font-size: 13px;
}
.kv:last-child {
border-bottom: none;
}
.kv > span:first-child {
color: var(--og-dim);
}
.kv .btn {
margin-left: 6px;
}
.b-ok {
color: var(--og-ok);
font-weight: 600;
}
.b-run {
color: var(--og-action);
font-weight: 600;
}
.b-bad {
color: var(--og-red);
font-weight: 600;
}
.b-dim {
color: var(--og-dim);
}
.b-warn {
color: var(--og-warn);
font-weight: 600;
}
.bigpass {
font-size: 15px;
font-weight: 700;
color: var(--og-ok);
}
.bigfail {
font-size: 15px;
font-weight: 700;
color: var(--og-red);
}
.seg {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.segbtn {
padding: 7px 20px;
border-radius: 6px;
background: var(--og-field-bg);
color: var(--og-txt);
border: 1px solid var(--og-field-line);
font: inherit;
cursor: pointer;
}
.segbtn:hover {
border-color: var(--og-nav-on);
}
.segbtn.segon {
background: var(--og-navy);
border-color: var(--og-navy);
color: #fff;
font-weight: 600;
}
.segbtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
#cam,
#camvid {
width: 100%;
max-width: 520px;
border-radius: 6px;
border: 1px solid var(--og-line);
display: block;
margin: 0 auto;
background: var(--og-cam-bg);
min-height: 60px;
}
.camwrap {
text-align: center;
}
.camctl {
margin-top: 8px;
display: flex;
gap: 8px;
justify-content: center;
}
.btn.on {
background: var(--og-navy);
border-color: var(--og-navy);
color: #fff;
font-weight: 600;
}
/* ---- Banners ------------------------------------------------------- */
.banner {
background: var(--og-warn-bg);
border: 1px solid var(--og-warn);
color: var(--og-warn-txt);
border-radius: 8px;
padding: 8px 12px;
margin: 0 0 10px;
font-size: 13px;
}
.banner a {
color: inherit;
font-weight: 600;
}
.banner-bad {
border-color: var(--og-red);
background: var(--og-bad-bg);
color: var(--og-bad-txt);
}
.safety {
background: var(--og-safety-bg);
border: 1px solid var(--og-red);
color: var(--og-safety-txt);
border-radius: 8px;
padding: 7px 12px;
margin: 8px 0 10px;
font-size: 12.5px;
line-height: 1.5;
}
#locknote,
.note {
display: none;
background: var(--og-note-bg);
border: 1px solid var(--og-note-line);
color: var(--og-note-txt);
border-radius: 8px;
padding: 8px 12px;
margin: 10px 0;
font-size: 13px;
}
.note {
display: block;
margin: 8px 0 0;
font-size: 12.5px;
line-height: 1.5;
}
.gnotes {
font-size: 12.5px;
line-height: 1.5;
margin: 4px 0 6px;
}
.gnotes .gn-warn {
color: var(--og-warn);
}
.gnotes .gn-off {
color: var(--og-red);
font-weight: 600;
}
/* ---- Terminals: the diagnostic log and the log viewer -------------- */
#diaglog,
#logview {
background: var(--og-term-bg);
color: var(--og-term-txt);
border-radius: 6px;
padding: 10px 12px;
font-family: ui-monospace, Consolas, monospace;
font-size: 12px;
margin-top: 8px;
}
#diaglog {
line-height: 1.6;
max-height: 220px;
overflow-y: auto;
white-space: pre-wrap;
display: none;
}
#logview {
line-height: 1.5;
height: 380px;
overflow: auto;
white-space: pre;
}
.logtbl {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.logtbl th {
text-align: left;
color: var(--og-dim);
font-weight: 600;
font-size: 11.5px;
padding: 3px 6px;
border-bottom: 1px solid var(--og-line);
}
.logtbl td {
padding: 3px 6px;
border-bottom: 1px solid var(--og-line-soft);
}
.logtbl select {
width: auto;
display: inline-block;
}
/* ---- The fuse identity dialog -------------------------------------- */
#fusewrap {
display: none;
position: fixed;
inset: 0;
background: rgba(20, 22, 30, 0.55);
z-index: 1050;
align-items: center;
justify-content: center;
}
#fusewrap.on {
display: flex;
}
.fusebox {
background: var(--og-card);
border: 1px solid var(--og-line);
border-radius: 10px;
padding: 16px 20px;
max-width: 560px;
width: 92%;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35);
}
.fusebox h2 {
font-size: 12px;
margin: 0 0 8px;
color: var(--og-title);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.9px;
}
/* ---- The save bar -------------------------------------------------- */
#savebar {
display: none;
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 1040;
background: var(--og-card);
border-top: 1px solid var(--og-line);
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.12);
}
#savebar.on {
display: block;
}
.sbrow {
max-width: 900px;
margin: 0 auto;
padding: 9px 20px;
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
#sb-count {
font-weight: 600;
color: var(--og-warn);
}
#sb-msg {
font-size: 12.5px;
color: var(--og-red);
}
@media (max-width: 620px) {
.frow {
grid-template-columns: 1fr;
gap: 2px 0;
}
.grid2 {
grid-template-columns: 1fr;
}
.hrow,
.nrow,
main,
.sbrow {
padding-left: 12px;
padding-right: 12px;
}
}
/* The setup's log-bundle line, under the header: the result of the
header's Download logs, or why the machine refused it. */
.wz-logs-msg {
max-width: 1100px;
margin: 6px auto 0;
padding: 0 16px;
min-height: 16px;
text-align: right;
}
/* A package's card on the Extensions tab: the card and its heading (the
label) are the panel's, the frame is the package's, and the frame is
given a size rather than taking one. The frame takes the panel's color
scheme, which the page inside reads as its prefers-color-scheme; it is
transparent, so a page that paints no background sits on the card. */
.extcard h2 .mono {
text-transform: none;
letter-spacing: 0;
font-weight: 400;
color: var(--og-dim);
}
.extframe {
display: block;
width: 100%;
height: 360px;
border: 0;
background: transparent;
color-scheme: light;
}
[data-bs-theme='dark'] .extframe {
color-scheme: dark;
}
-21
View File
@@ -1,21 +0,0 @@
The MIT License (MIT)
Copyright (c) 2011-2025 The Bootstrap Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-72
View File
@@ -1,72 +0,0 @@
2026-08-17T09:42:09.781500+00:00 gfcloud[1522] INFO gfuiservice:run motion (ready) service action motion
2026-08-17T09:42:11.281337+00:00 gfcloud[1522] INFO cnc:set_step_freq 28160
2026-08-17T09:42:11.282627+00:00 gfcloud[1522] INFO cnc:set_x_decay 1
2026-08-17T09:42:11.283850+00:00 gfcloud[1522] INFO cnc:set_x_mode 8
2026-08-17T09:42:11.285121+00:00 gfcloud[1522] INFO cnc:set_x_current 135
2026-08-17T09:42:11.286711+00:00 gfcloud[1522] INFO cnc:set_y_decay 1
2026-08-17T09:42:11.287977+00:00 gfcloud[1522] INFO cnc:set_y_mode 8
2026-08-17T09:42:11.289235+00:00 gfcloud[1522] INFO cnc:set_y_current 22
2026-08-17T09:42:11.297425+00:00 gfcloud[1522] INFO machine:_run_loop starting run
2026-08-17T09:42:11.299141+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:42:11.303264+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.RUNNING
2026-08-17T09:42:12.560917+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:42:12.572022+00:00 gfcloud[1522] INFO machine:_run_loop finished run
2026-08-17T09:42:12.572811+00:00 gfcloud[1522] INFO cnc:laser_latch 1
2026-08-17T09:42:12.580801+00:00 gfcloud[1522] INFO machine:_motion_locked end positions (actual/expected): X (-10313/-10313), Y (-649/-649), Z (0/0)
2026-08-17T09:42:12.581620+00:00 gfcloud[1522] INFO machine:_motion_locked motion bytes actual:34952, expected: 34952
2026-08-17T09:42:12.582366+00:00 gfcloud[1522] INFO machine:_motion_locked start idle
2026-08-17T09:42:12.583540+00:00 gfcloud[1522] INFO cnc:set_x_current 33
2026-08-17T09:42:12.584928+00:00 gfcloud[1522] INFO cnc:set_y_current 5
2026-08-17T09:42:12.593681+00:00 gfcloud[1522] INFO machine:_motion_locked end positions (-10313, -649, 0)
2026-08-17T09:42:12.604394+00:00 gfcloud[1522] INFO machine:_motion end motion
2026-08-17T09:42:12.604757+00:00 gfcloud[1522] INFO basemachine:_finish_action motion [1576550503]: finished with event ":completed"
2026-08-17T09:42:12.605824+00:00 gfcloud[1522] INFO cnc:laser_latch 1
2026-08-17T09:42:15.672518+00:00 gfcloud[1522] INFO gfuiservice:run service action request: print (ready)
2026-08-17T09:42:15.676330+00:00 gfcloud[1522] INFO machine:_motion start motion
2026-08-17T09:42:15.680419+00:00 gfcloud[1522] INFO gfuiservice:run print (ready) service action print
2026-08-17T09:42:21.975587+00:00 gfcloud[1522] INFO cnc:laser_latch 0
2026-08-17T09:42:22.001354+00:00 gfcloud[1522] INFO machine:_button_wait waiting for button
2026-08-17T09:42:24.774117+00:00 gfcloud[1522] INFO machine:_switch_event button pushed
2026-08-17T09:42:24.778509+00:00 gfcloud[1522] INFO cnc:set_step_freq 10000
2026-08-17T09:42:24.781114+00:00 gfcloud[1522] INFO cnc:set_x_decay 1
2026-08-17T09:42:24.783654+00:00 gfcloud[1522] INFO cnc:set_x_mode 8
2026-08-17T09:42:24.786180+00:00 gfcloud[1522] INFO cnc:set_x_current 135
2026-08-17T09:42:24.789088+00:00 gfcloud[1522] INFO cnc:set_y_decay 1
2026-08-17T09:42:24.791559+00:00 gfcloud[1522] INFO cnc:set_y_mode 8
2026-08-17T09:42:24.794380+00:00 gfcloud[1522] INFO cnc:set_y_current 22
2026-08-17T09:42:24.815263+00:00 gfcloud[1522] INFO machine:_run_loop starting run
2026-08-17T09:42:24.816687+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:42:24.820823+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.RUNNING
2026-08-17T09:42:25.049489+00:00 gfcloud[1522] INFO machine:_switch_event button released
2026-08-17T09:42:30.832527+00:00 gfcloud[1522] INFO machine:_switch_event lid opened
2026-08-17T09:42:30.838627+00:00 gfcloud[1522] WARNING machine:_run_loop lid opened mid-run; stopping motion
2026-08-17T09:42:30.941553+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:42:30.947477+00:00 gfcloud[1522] INFO machine:_run_loop finished run
2026-08-17T09:42:30.949099+00:00 gfcloud[1522] INFO cnc:laser_latch 1
2026-08-17T09:42:30.964916+00:00 gfcloud[1522] INFO machine:_motion_locked end positions (actual/expected): X (8454/7573), Y (684/684), Z (3/3)
2026-08-17T09:42:30.965704+00:00 gfcloud[1522] INFO machine:_motion_locked motion bytes actual:60649, expected: 238043
2026-08-17T09:42:30.972180+00:00 gfcloud[1522] INFO machine:_return_home start return home
2026-08-17T09:42:31.847247+00:00 gfcloud[1522] INFO machine:_run_loop starting run
2026-08-17T09:42:31.848617+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:42:31.852977+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.RUNNING
2026-08-17T09:42:36.970521+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:42:36.976059+00:00 gfcloud[1522] INFO machine:_run_loop finished run
2026-08-17T09:42:36.977738+00:00 gfcloud[1522] INFO machine:_return_home return home complete
2026-08-17T09:42:36.979696+00:00 gfcloud[1522] INFO machine:_motion_locked start cool down
2026-08-17T09:42:36.998973+00:00 gfcloud[1522] INFO machine:_motion_locked start idle
2026-08-17T09:42:37.000314+00:00 gfcloud[1522] INFO cnc:set_x_current 33
2026-08-17T09:42:37.001774+00:00 gfcloud[1522] INFO cnc:set_y_current 5
2026-08-17T09:42:37.010089+00:00 gfcloud[1522] INFO machine:_motion_locked end positions (0, 0, 3)
2026-08-17T09:42:37.010885+00:00 gfcloud[1522] INFO machine:_motion end motion
2026-08-17T09:42:37.011563+00:00 gfcloud[1522] INFO basemachine:_finish_action print [1576550507]: finished with event ":cancelled"
2026-08-17T09:42:37.013071+00:00 gfcloud[1522] INFO cnc:laser_latch 1
2026-08-17T09:42:37.449180+00:00 gfcloud[1522] INFO gfuiservice:run service action request: hunt (ready)
2026-08-17T09:42:37.451286+00:00 gfcloud[1522] INFO z_axis:home starting z homing cycle
2026-08-17T09:42:37.452782+00:00 gfcloud[1522] INFO gfuiservice:run hunt (ready) service action hunt
2026-08-17T09:42:37.453691+00:00 gfcloud[1522] INFO z_axis:configure configuring z_enable: True, current: ZCur.HIGH, mode: Microstep.FULL
2026-08-17T09:42:37.458283+00:00 gfcloud[1522] INFO z_axis:_step_until starting z_step until at_home: False
2026-08-17T09:42:37.827610+00:00 gfcloud[1522] INFO z_axis:_step_until took -2 z_steps
2026-08-17T09:42:37.829278+00:00 gfcloud[1522] INFO z_axis:_step_until starting z_step until at_home: True
2026-08-17T09:42:38.200493+00:00 gfcloud[1522] INFO z_axis:_step_until took 2 z_steps
2026-08-17T09:42:38.202234+00:00 gfcloud[1522] INFO z_axis:home pass: 1
2026-08-17T09:42:38.203788+00:00 gfcloud[1522] INFO z_axis:_step_until starting z_step until at_home: False
-54
View File
@@ -1,54 +0,0 @@
2026-08-17T09:42:15.672518+00:00 gfcloud[1522] INFO gfuiservice:run service action request: print (ready)
2026-08-17T09:42:15.676330+00:00 gfcloud[1522] INFO machine:_motion start motion
2026-08-17T09:42:15.680419+00:00 gfcloud[1522] INFO gfuiservice:run print (ready) service action print
2026-08-17T09:42:21.975587+00:00 gfcloud[1522] INFO cnc:laser_latch 0
2026-08-17T09:42:22.001354+00:00 gfcloud[1522] INFO machine:_button_wait waiting for button
2026-08-17T09:42:24.774117+00:00 gfcloud[1522] INFO machine:_switch_event button pushed
2026-08-17T09:42:24.778509+00:00 gfcloud[1522] INFO cnc:set_step_freq 10000
2026-08-17T09:42:24.781114+00:00 gfcloud[1522] INFO cnc:set_x_decay 1
2026-08-17T09:42:24.783654+00:00 gfcloud[1522] INFO cnc:set_x_mode 8
2026-08-17T09:42:24.786180+00:00 gfcloud[1522] INFO cnc:set_x_current 135
2026-08-17T09:42:24.789088+00:00 gfcloud[1522] INFO cnc:set_y_decay 1
2026-08-17T09:42:24.791559+00:00 gfcloud[1522] INFO cnc:set_y_mode 8
2026-08-17T09:42:24.794380+00:00 gfcloud[1522] INFO cnc:set_y_current 22
2026-08-17T09:42:24.815263+00:00 gfcloud[1522] INFO machine:_run_loop starting run
2026-08-17T09:42:24.816687+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:42:24.820823+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.RUNNING
2026-08-17T09:42:25.049489+00:00 gfcloud[1522] INFO machine:_switch_event button released
2026-08-17T09:42:31.104201+00:00 gfcloud[1522] INFO machine:_switch_event button pushed
2026-08-17T09:42:31.106877+00:00 gfcloud[1522] INFO machine:_run_loop button pressed mid-run; pausing
2026-08-17T09:42:31.288114+00:00 gfcloud[1522] INFO machine:_switch_event button released
2026-08-17T09:42:31.612040+00:00 gfcloud[1522] INFO machine:_run_loop paused at Position(x=AxisPosition(steps=1210, mm=22.68, inch=0.89), y=AxisPosition(steps=402, mm=7.54, inch=0.30), z=AxisPosition(steps=3, mm=0.0, inch=0.0), bytes=PulsPosition(total=238043, processed=61200))
2026-08-17T09:42:34.702311+00:00 gfcloud[1522] INFO machine:_switch_event button pushed
2026-08-17T09:42:34.704560+00:00 gfcloud[1522] INFO machine:_run_loop button pressed while paused
2026-08-17T09:42:34.706100+00:00 gfcloud[1522] INFO machine:_resume_retraced resuming (laser lead 1950 ticks)
2026-08-17T09:42:34.901002+00:00 gfcloud[1522] INFO machine:_switch_event button released
2026-08-17T09:42:58.130807+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:42:58.136283+00:00 gfcloud[1522] INFO machine:_run_loop finished run
2026-08-17T09:42:58.137979+00:00 gfcloud[1522] INFO cnc:laser_latch 1
2026-08-17T09:42:58.151159+00:00 gfcloud[1522] INFO machine:_motion_locked end positions (actual/expected): X (7573/7573), Y (684/684), Z (3/3)
2026-08-17T09:42:58.152103+00:00 gfcloud[1522] INFO machine:_motion_locked motion bytes actual:238043, expected: 238043
2026-08-17T09:42:58.160180+00:00 gfcloud[1522] INFO machine:_return_home start return home
2026-08-17T09:42:59.047247+00:00 gfcloud[1522] INFO machine:_run_loop starting run
2026-08-17T09:42:59.048617+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:42:59.052977+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.RUNNING
2026-08-17T09:43:04.170521+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:43:04.176059+00:00 gfcloud[1522] INFO machine:_run_loop finished run
2026-08-17T09:43:04.177738+00:00 gfcloud[1522] INFO machine:_return_home return home complete
2026-08-17T09:43:04.179696+00:00 gfcloud[1522] INFO machine:_motion_locked start cool down
2026-08-17T09:43:04.198973+00:00 gfcloud[1522] INFO machine:_motion_locked start idle
2026-08-17T09:43:04.200314+00:00 gfcloud[1522] INFO cnc:set_x_current 33
2026-08-17T09:43:04.201774+00:00 gfcloud[1522] INFO cnc:set_y_current 5
2026-08-17T09:43:04.210089+00:00 gfcloud[1522] INFO machine:_motion_locked end positions (0, 0, 3)
2026-08-17T09:43:04.210885+00:00 gfcloud[1522] INFO machine:_motion end motion
2026-08-17T09:43:04.211563+00:00 gfcloud[1522] INFO basemachine:_finish_action print [1576550507]: finished with event ":completed"
2026-08-17T09:43:04.213071+00:00 gfcloud[1522] INFO cnc:laser_latch 1
2026-08-17T09:43:04.649180+00:00 gfcloud[1522] INFO gfuiservice:run service action request: hunt (ready)
2026-08-17T09:43:04.651286+00:00 gfcloud[1522] INFO z_axis:home starting z homing cycle
2026-08-17T09:43:04.652782+00:00 gfcloud[1522] INFO gfuiservice:run hunt (ready) service action hunt
2026-08-17T09:43:11.298209+00:00 gfcloud[1522] INFO machine:_motion start motion
2026-08-17T09:43:11.983820+00:00 gfcloud[1522] INFO machine:_run_loop starting run
2026-08-17T09:43:13.630807+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
2026-08-17T09:43:13.636283+00:00 gfcloud[1522] INFO machine:_run_loop finished run
2026-08-17T09:43:13.664853+00:00 gfcloud[1522] INFO machine:_motion end motion
2026-08-17T09:43:13.665575+00:00 gfcloud[1522] INFO basemachine:_finish_action hunt [1576550560]: finished with event ":completed"
-281
View File
@@ -1,281 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""Shared fixtures for the forgetest unit tests: synthetic manifests and
catalog entries that never touch hardware."""
import hashlib
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(HERE))
from forgetest import catalog as catalog_mod # noqa: E402
from forgetest import manifest as manifest_mod # noqa: E402
def blob(text):
"""A git-style blob id for text (what ls-tree would report)."""
data = text.encode()
return hashlib.sha1(b"blob %d\0" % len(data) + data).hexdigest()
def make_manifest(components=None, platform=None, version="20260101000000 (dev)", name="forgefirm-image-dev"):
comps = components if components is not None else {
"forgectrl": {"srcrev": "aaa", "files": [["src/main.c", blob("main")], ["src/ui.c", blob("ui")],
["src/auth.c", blob("auth")], ["src/cool.c", blob("cool")],
["README.md", blob("readme")]]},
"grblhal-glowforge": {"srcrev": "bbb", "files": [["src/driver.c", blob("drv")],
["src/grbl", "cccc"], ["src/grbl/core.c", blob("core")]]},
"kernel-module-glowforge": {"srcrev": "ddd", "files": [["src/cnc.c", blob("cnc")]]},
"linux-fslc": {"srcrev": "eee", "files": [["@srcrev", "eee"], ["@config", "cfg1"]]},
"forgetest": {"srcrev": None, "files": [["forgetest/x.py", blob("x")]]},
}
plat = platform if platform is not None else {
"machine": "glowforge", "kernel_modules": ["6.12.20-fslc+g0e01ec9f0d3f"],
"dtb": {"glowforge.dtb": "d" * 64},
"layers": {"meta-forgefirm": {"content_sha256": "1" * 64}, "poky": {"rev": "p" * 40}},
}
data = {"format": 1, "image": {"name": name, "version": version},
"components": comps, "platform": plat}
data["content_sha256"] = manifest_mod.sha256_text(
manifest_mod.canonical({"components": comps, "platform": plat}))
return manifest_mod.Manifest(data)
def with_file(manifest, component, path, text):
"""A copy of the manifest with one file's content changed/added."""
import copy
data = copy.deepcopy(manifest.data)
files = data["components"][component]["files"]
for f in files:
if f[0] == path:
f[1] = blob(text)
break
else:
files.append([path, blob(text)])
data["content_sha256"] = manifest_mod.sha256_text(
manifest_mod.canonical({"components": data["components"], "platform": data["platform"]}))
return manifest_mod.Manifest(data)
def with_platform(manifest, **changes):
import copy
data = copy.deepcopy(manifest.data)
data["platform"].update(changes)
data["content_sha256"] = manifest_mod.sha256_text(
manifest_mod.canonical({"components": data["components"], "platform": data["platform"]}))
return manifest_mod.Manifest(data)
def _noop(ctx):
pass
def make_test(id, covers, always=False, requires=(), kind="auto", fn=None, subsystem=None, mode=None,
actions=(), precheck=None, steps=(), hands=()):
return catalog_mod.Test(id, "Title " + id, subsystem or id.split(".")[0], kind, "api",
covers, requires, always, 1, steps, "desc", fn or _noop, mode=mode,
actions=actions, precheck=precheck, hands=hands)
def registry(*tests):
return {t.id: t for t in tests}
# ------------------------------------------------------- fake forgectrl
class FakeForgectrl:
"""A stand-in for the machine-services daemon on localhost: canned
JSON for the endpoints the suite reads, a mutable `state` the test
scripts, every POST recorded, and an optional `on_post(path, form)`
hook returning (status, body) to script the daemon's reactions.
Point the suite at it with FORGECTRL_URL (see start/stop)."""
def __init__(self):
import http.server
import json as _json
import threading as _threading
import urllib.parse as _up
self.state = {
"mode": {"mode": "grbl", "controller": "running", "pid": 100, "motion": "verified"},
"status": {"state": "idle", "homed": False, "diag": False, "laser_locked": True,
"pos": {"x": 0.0, "y": 0.0, "z": 0.0},
"switches": {"lid": True, "button": False, "interlock_ok": True,
"head": True, "hv_enable": False}},
"cool": {"phase": "idle", "armed": False, "hold": False},
"cam": {"running": False, "clients": 0},
"diag": {"running": False},
"settings": {"controller_mode": "grbl", "lid_lamp_idle": ""},
"logs_tail": {"name": "forgectrl", "text": "", "truncated": False, "exists": True},
}
self.posts = []
self.on_post = None
self.on_get = None # (path, query) -> (status, body) | (status, bytes, ctype) | None
fake = self
class H(http.server.BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def _send(self, st, body):
data = _json.dumps(body).encode()
self.send_response(st)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_GET(self):
path, _, query = self.path.partition("?")
if fake.on_get:
r = fake.on_get(path, dict(_up.parse_qsl(query, keep_blank_values=True)))
if r is not None:
if isinstance(r[1], (bytes, bytearray)):
self.send_response(r[0])
self.send_header("Content-Type", r[2] if len(r) > 2 else "application/octet-stream")
self.send_header("Content-Length", str(len(r[1])))
self.end_headers()
self.wfile.write(r[1])
return
return self._send(r[0], r[1])
key = {"/mode": "mode", "/status": "status", "/cool/status": "cool", "/cam/status": "cam",
"/diag/status": "diag", "/settings": "settings",
"/logs/tail": "logs_tail"}.get(path)
if key is None:
return self._send(404, {"error": "no " + path})
self._send(200, fake.state[key])
def do_POST(self):
path, _, query = self.path.partition("?")
n = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(n).decode() if n else ""
form = dict(_up.parse_qsl(raw if raw else query, keep_blank_values=True))
fake.posts.append((path, form))
if fake.on_post:
r = fake.on_post(path, form)
if r is not None:
return self._send(*r)
if path == "/mode" and form.get("controller"):
fake.state["mode"] = dict(fake.state["mode"], mode=form["controller"], controller="running",
pid=fake.state["mode"].get("pid", 0) + 1)
fake.state["settings"]["controller_mode"] = form["controller"]
elif path == "/settings":
fake.state["settings"].update(form)
return self._send(200, fake.state["settings"])
self._send(200, {"ok": True})
self._srv = http.server.ThreadingHTTPServer(("127.0.0.1", 0), H)
self._srv.daemon_threads = True
self._srv.block_on_close = False # never wait on a lingering connection
self.url = "http://127.0.0.1:%d" % self._srv.server_address[1]
self._th = _threading.Thread(target=self._srv.serve_forever, daemon=True)
def start(self):
self._th.start()
os.environ["FORGECTRL_URL"] = self.url
os.environ["FORGECTRL_TOKEN_FILE"] = os.devnull
return self
def stop(self):
self._srv.shutdown()
self._srv.server_close()
os.environ.pop("FORGECTRL_URL", None)
os.environ.pop("FORGECTRL_TOKEN_FILE", None)
# ------------------------------------------------------------ fake grbl
class FakeGrbl:
"""A Grbl-over-TCP stand-in for the controller: answers '?' with a
status report built from its mutable `state`/`mpos`, 'ok' to every
command line, and records what it was sent. Point the suite at it
with GRBL_HOST/GRBL_PORT (see start/stop)."""
def __init__(self):
import socket as _socket
import threading as _threading
self.state = "Idle"
self.mpos = [0.0, 0.0, 0.0]
self.sent = []
self.extra = b"" # text pushed to the client on the next poll
self.reset_to = None # the state a soft reset (^X) lands in, when set
self.on_command = None # called with every command line received
self._sock = _socket.socket()
self._sock.setsockopt(_socket.SOL_SOCKET, _socket.SO_REUSEADDR, 1)
self._sock.bind(("127.0.0.1", 0))
self._sock.listen(5)
self.port = self._sock.getsockname()[1]
self._stop = False
self._th = _threading.Thread(target=self._serve, daemon=True)
def _serve(self):
import socket as _socket
import threading as _threading
self._sock.settimeout(0.2)
while not self._stop:
try:
c, _ = self._sock.accept()
except (_socket.timeout, OSError):
continue
_threading.Thread(target=self._client, args=(c,), daemon=True).start()
def _client(self, c):
import socket as _socket
c.settimeout(0.1)
buf = b""
try:
c.sendall(b"\r\nGrblHAL 1.1f ['$' for help]\r\n")
while not self._stop:
try:
d = c.recv(4096)
if not d:
break
buf += d
except _socket.timeout:
continue
except OSError:
break
out = b""
if self.extra:
out += self.extra
self.extra = b""
while buf:
if buf[0:1] == b"?":
buf = buf[1:]
out += ("<%s|MPos:%.3f,%.3f,%.3f|FS:0,0>\r\n" % (self.state, *self.mpos)).encode()
elif buf[0] in (0x18, 0x85, 0x7E, 0x21):
if buf[0] == 0x18:
self.sent.append("^X")
if self.reset_to:
self.state = self.reset_to
buf = buf[1:]
elif b"\n" in buf:
line, buf = buf.split(b"\n", 1)
self.sent.append(line.strip().decode("utf-8", "replace"))
if self.on_command:
self.on_command(self.sent[-1])
out += b"ok\r\n"
else:
break
if out:
c.sendall(out)
finally:
c.close()
def start(self):
self._th.start()
os.environ["GRBL_HOST"] = "127.0.0.1"
os.environ["GRBL_PORT"] = str(self.port)
return self
def stop(self):
self._stop = True
try:
self._sock.close()
except OSError:
pass
for k in ("GRBL_HOST", "GRBL_PORT"):
os.environ.pop(k, None)
-148
View File
@@ -1,148 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
import copy
import json
import unittest
import helpers
from forgetest import artifact, campaign, catalog
from forgetest import manifest as manifest_mod
from test_campaign import rec_campaign, rec_result
class ArtifactTests(unittest.TestCase):
def setUp(self):
self.man = helpers.make_manifest()
self.core = helpers.make_test("image.health", [("linux-fslc", "**")], always=True)
self.ui = helpers.make_test("forgectrl.panel", [("forgectrl", "src/ui.c")])
self.cool = helpers.make_test("cooling.flow", [("forgectrl", "src/cool.c")])
self.reg = helpers.registry(self.core, self.ui, self.cool)
self.tests = list(self.reg.values())
self.chash = catalog.catalog_hash(self.reg)
# campaign 1 on the base image: everything passes
self.recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z")]
for i, t in enumerate(self.tests):
self.recs.append(rec_result("c1", t, self.man, "PASS", "2026-08-20T10:0%d:00Z" % (i + 1)))
# image 2: cool.c changed; campaign 2 reruns core + cool
self.man2 = helpers.with_file(self.man, "forgectrl", "src/cool.c", "cool v2")
self.recs.append(rec_campaign("c2", self.man2, self.chash, "2026-08-21T10:00:00Z"))
self.recs.append(rec_result("c2", self.core, self.man2, "PASS", "2026-08-21T10:01:00Z"))
self.recs.append(rec_result("c2", self.cool, self.man2, "PASS", "2026-08-21T10:02:00Z"))
def build(self, man=None):
man = man or self.man2
st = campaign.compute(self.recs, self.tests, man, self.chash)
return artifact.build(st, self.tests, man, self.recs, self.chash), st
def release_manifest(self, man=None):
"""The release build's manifest: same identity, no forgetest component."""
man = man or self.man2
data = copy.deepcopy(man.data)
data["components"].pop("forgetest", None)
data["image"] = {"name": "forgefirm-image", "version": "v0.1.0"}
return manifest_mod.Manifest(data)
def test_build_and_verify(self):
art, st = self.build()
self.assertTrue(art["authorized"])
by = {t["id"]: t for t in art["tests"]}
self.assertTrue(by["forgectrl.panel"]["inherited"])
self.assertEqual(by["forgectrl.panel"]["record"]["campaign"], "c1")
self.assertFalse(by["image.health"]["inherited"])
text = artifact.to_json(art)
art2 = json.loads(text)
ok, rows, problems = artifact.verify(art2, self.release_manifest(), self.tests, self.chash,
expect_machine="glowforge")
self.assertEqual(problems, [])
self.assertTrue(ok)
md = artifact.to_markdown(art)
self.assertIn("Release authorized: YES", md)
self.assertIn("forgectrl.panel", md)
def test_tamper_detected(self):
art, _ = self.build()
art2 = json.loads(artifact.to_json(art))
art2["tests"][1]["record"]["result"] = "PASS"
art2["counts"]["required"] = 0
art2["authorized"] = True
art2["tests"][2]["record"]["ts"] = "2026-08-21T10:02:01Z"
ok, rows, problems = artifact.verify(art2, self.release_manifest(), self.tests, self.chash)
self.assertFalse(ok)
self.assertIn("self-hash", problems[0])
def test_release_differs_in_covered_file(self):
art, _ = self.build()
rel = self.release_manifest(helpers.with_file(self.man2, "forgectrl", "src/ui.c", "ui v3"))
ok, rows, problems = artifact.verify(art, rel, self.tests, self.chash)
self.assertFalse(ok)
self.assertTrue(any("forgectrl.panel" in p and "fingerprint" in p for p in problems), problems)
# an uncovered change is fine
rel2 = self.release_manifest(helpers.with_file(self.man2, "forgectrl", "README.md", "docs"))
ok, rows, problems = artifact.verify(art, rel2, self.tests, self.chash)
self.assertTrue(ok, problems)
def test_release_platform_differs(self):
art, _ = self.build()
rel = self.release_manifest(helpers.with_platform(self.man2, dtb={"glowforge.dtb": "0" * 64}))
ok, rows, problems = artifact.verify(art, rel, self.tests, self.chash)
self.assertFalse(ok)
self.assertEqual(len([r for r in rows if not r["ok"]]), 3)
def test_unauthorized_artifact(self):
self.recs.append(rec_result("c2", self.ui, self.man2, "FAIL", "2026-08-21T10:03:00Z"))
art, st = self.build()
self.assertFalse(art["authorized"])
ok, rows, problems = artifact.verify(art, self.release_manifest(), self.tests, self.chash)
self.assertFalse(ok)
self.assertTrue(any("does not claim authorization" in p for p in problems))
def test_core_inherited_refused(self):
art, _ = self.build()
# forge an artifact whose core record is marked inherited (self-hash recomputed)
body = json.loads(artifact.to_json(art))
body.pop("sha256")
for t in body["tests"]:
if t["id"] == "image.health":
t["inherited"] = True
body["sha256"] = manifest_mod.sha256_text(manifest_mod.canonical(body))
ok, rows, problems = artifact.verify(body, self.release_manifest(), self.tests, self.chash)
self.assertFalse(ok)
self.assertTrue(any("always-required" in p for p in problems), problems)
def test_invalidate_epoch(self):
art, _ = self.build()
body = json.loads(artifact.to_json(art))
body.pop("sha256")
body["invalidate"] = {"t": "invalidate", "ts": "2026-08-20T12:00:00Z", "reason": "tube"}
body["sha256"] = manifest_mod.sha256_text(manifest_mod.canonical(body))
ok, rows, problems = artifact.verify(body, self.release_manifest(), self.tests, self.chash)
self.assertFalse(ok)
self.assertTrue(any("predates the invalidate" in p for p in problems), problems)
def test_catalog_changed(self):
art, _ = self.build()
reg = helpers.registry(self.core, self.ui, self.cool, helpers.make_test("new.one", []))
tests = list(reg.values())
ok, rows, problems = artifact.verify(art, self.release_manifest(), tests, catalog.catalog_hash(reg))
self.assertFalse(ok)
self.assertTrue(any("catalog changed" in p for p in problems))
self.assertTrue(any("new.one" in p for p in problems))
def test_implementation_changed(self):
art, _ = self.build()
body = json.loads(artifact.to_json(art))
body.pop("sha256")
for t in body["tests"]:
if t["id"] == "cooling.flow":
t["source_sha"] = "0" * 64
body["sha256"] = manifest_mod.sha256_text(manifest_mod.canonical(body))
ok, rows, problems = artifact.verify(body, self.release_manifest(), self.tests, self.chash)
self.assertFalse(ok)
self.assertTrue(any("implementation changed" in p for p in problems), problems)
if __name__ == "__main__":
unittest.main()
-728
View File
@@ -1,728 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The baseline: fixed resting values are restored, preserved values are
handed back, every deviation is a recorded leftover. Runs against a fake
sysfs tree; forgectrl is unreachable (service-side checks skip)."""
import json
import os
import shutil
import struct
import tempfile
import unittest
from forgetest import baseline
# The fixed machine tick at the mode an unset xy_microsteps reads as.
# Taken from baseline rather than typed, so a change to the default mode
# moves the expectations with it.
DEFAULT_TICK = dict(baseline.fixed_sysfs())["cnc/step_freq"]
class 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):
self.tmp = tempfile.mkdtemp(prefix="forgetest-bl-")
self.sysfs = os.path.join(self.tmp, "sysfs") + os.sep
self.leds = os.path.join(self.tmp, "leds") + os.sep
for group in ("cnc", "pic", "head", "thermal"):
os.makedirs(self.sysfs + group)
for name in baseline.BUTTON_LEDS + ("lid_led",):
os.makedirs(self.leds + name)
self._led(name, "0")
# A clean machine at the mode an unset xy_microsteps reads as:
# x/y_mode, step_freq and ramp_rate are the mode's, not the x8
# literals in FIXED_SYSFS, and that is what enforce() compares
# against.
for attr, val in baseline.fixed_sysfs() + baseline.IDLE_READBACKS:
self._attr(attr, val)
self._attr("cnc/interlock_circuit", "45")
self._attr("pic/lid_led", "0")
self._pos(0, 0, 0)
os.environ["GF_SYSFS_ROOT"] = self.sysfs
os.environ["GF_LEDS_ROOT"] = self.leds
os.environ["FORGECTRL_URL"] = "http://127.0.0.1:1" # nothing listens
baseline.Baseline._unreachable_until = 0.0
self.lines = []
def tearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
for k in ("GF_SYSFS_ROOT", "GF_LEDS_ROOT", "FORGECTRL_URL"):
os.environ.pop(k, None)
def _attr(self, attr, val):
with open(self.sysfs + attr, "w") as f:
f.write(str(val))
def _read(self, attr):
with open(self.sysfs + attr) as f:
return f.read().strip()
def _led(self, name, val):
with open(self.leds + name + "/brightness", "w") as f:
f.write(val)
# the class interface writes 'target'; the fake mirrors it into brightness
# only when the test asks (see _sync_leds)
def _sync_leds(self):
for name in baseline.BUTTON_LEDS:
p = self.leds + name + "/target"
if os.path.exists(p):
with open(p) as f:
v = f.read().strip()
with open(self.leds + name + "/brightness", "w") as f:
f.write(v)
def _pos(self, x, y, z):
with open(self.sysfs + "cnc/position", "wb") as f:
f.write(struct.pack("<5i", x, y, z, 0, 0))
def bl(self):
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):
left = self.bl().enforce("pre", captured=None)
self.assertEqual(left, [])
self.assertTrue(any("pre: clean" in l for l in self.lines))
def test_fixed_values_are_restored_and_recorded(self):
self._attr("cnc/motor_lock", "15")
self._attr("cnc/step_freq", "10000")
self._attr("cnc/streaming", "1")
left = self.bl().enforce("post", captured=None)
items = {x.item: x for x in left}
self.assertEqual(set(items), {"cnc/motor_lock", "cnc/step_freq", "cnc/streaming"})
for x in left:
self.assertEqual(x.action, "restored", str(x))
self.assertEqual(self._read("cnc/motor_lock"), "0")
self.assertEqual(self._read("cnc/step_freq"), DEFAULT_TICK)
self.assertEqual(self._read("cnc/streaming"), "0")
self.assertEqual(items["cnc/motor_lock"].found, "15")
self.assertEqual(items["cnc/motor_lock"].expected, "0")
def test_unlocked_latch_is_relocked(self):
self._attr("cnc/interlock_circuit", "5") # bit 3 clear = unlocked
left = self.bl().enforce("post", captured=None)
self.assertEqual([x.item for x in left], ["laser_latch"])
self.assertEqual(self._read("cnc/laser_latch"), "1")
def test_readonly_deviation_is_unrestorable(self):
self._attr("cnc/state", "disabled")
left = self.bl().enforce("post", captured=None)
self.assertEqual([(x.item, x.action) for x in left], [("cnc/state", "unrestorable")])
def test_button_leds_are_turned_off(self):
self._led("button_led_2", "255")
left = self.bl().enforce("post", captured=None)
self.assertEqual([x.item for x in left], ["leds/button_led_2"])
self._sync_leds()
self.assertEqual(baseline.read_led("button_led_2"), "0")
def test_preserved_position(self):
b = self.bl()
cap = b.capture()
self.assertEqual(cap["position"], [0, 0, 0])
# the run shifted the counters
self._pos(1000, 0, 0)
left = b.enforce("post", captured=cap)
items = {x.item: x for x in left}
self.assertEqual(set(items), {"position"})
# no GRBL controller on the host: the head cannot be jogged back
self.assertTrue(items["position"].action.startswith("unrestorable"), items["position"].action)
self.assertEqual(items["position"].found, [1000, 0, 0])
def test_a_lost_counter_frame_never_moves_the_head(self):
# The controller zeroes the step counters at every start and at
# every home, and rewrites its anchor then. A run that began at
# -6400 steps and restarted the controller ends at 0 with the head
# where it was: the difference is not a distance, and the hand-back
# must neither jog the head nor call it a leftover.
anchor = os.path.join(self.tmp, "grblhal.homed")
old_path, baseline.ANCHOR_PATH = baseline.ANCHOR_PATH, anchor
try:
with open(anchor, "w") as f:
f.write("0 0 0 4 startup")
self._pos(-6400, 0, 0)
b = self.bl()
cap = b.capture()
self.assertIsNotNone(cap["frame"])
os.unlink(anchor) # a controller start: a new anchor file
with open(anchor + ".new", "w") as f:
f.write("0 0 0 4 startup")
os.replace(anchor + ".new", anchor)
os.utime(anchor, ns=(5, 5)) # a freed inode can come straight back, inside one clock tick
self._pos(0, 0, 0)
left = b.enforce("post", captured=cap)
self.assertEqual([x.item for x in left], [])
self.assertTrue(any("re-zeroed its counters during the run" in l for l in self.lines), self.lines)
# the same counters with the frame intact are a displaced head, as before
self.lines.clear()
self._pos(-6400, 0, 0)
cap = b.capture()
self._pos(0, 0, 0)
left = b.enforce("post", captured=cap)
self.assertEqual([x.item for x in left], ["position"])
# and a test that vouches for the new frame is held to it
self._pos(-6400, 0, 0)
cap = b.capture()
os.utime(anchor, ns=(1, 1)) # re-zeroed, and declared (ctx.counters_rezeroed)
cap["position"], cap["rezero_declared"] = [0, 0, 0], True
self._pos(0, 0, 0)
self.assertEqual(b.enforce("post", captured=cap), [])
self._pos(1000, 0, 0)
self.assertEqual([x.item for x in b.enforce("post", captured=cap)], ["position"])
finally:
baseline.ANCHOR_PATH = old_path
def test_the_counters_are_read_at_the_modes_scale(self):
# 6400 steps is 30 mm at x32 (a return within the bound; on the
# host it stops at the missing controller) and 120 mm at x8
# (beyond the bound). Read at the x8 scale, an x32 machine's
# displaced head is never jogged back.
b = self.bl()
cap = b.capture()
self._attr("cnc/x_mode", "32")
self._pos(6400, 0, 0)
items = {x.item: x for x in b.enforce("post", captured=cap)}
self.assertEqual(items["position"].action, "unrestorable: no running GRBL controller")
self._attr("cnc/x_mode", "8")
items = {x.item: x for x in b.enforce("post", captured=cap)}
self.assertEqual(items["position"].action, "unrestorable: 120.0/0.0 mm exceeds 100 mm")
def test_a_held_controller_is_reset_before_the_return_jog(self):
# a pause test that failed while held leaves the controller in
# Hold, which refuses a jog: the baseline resets out of it first
import helpers
fc = helpers.FakeForgectrl().start()
dev = helpers.FakeGrbl().start()
try:
dev.state = "Hold:0"
dev.reset_to = "Idle"
dev.on_command = lambda line: self._pos(0, 0, 0) if line.startswith("$J=") else None
b = self.bl()
cap = b.capture()
# 4.144 mm into the held move, in the steps of the mode in force
held_mm = 4.144
self._pos(round(held_mm * baseline.xy_steps_per_mm(baseline.XY_MODE_DEFAULT)), 0, 0)
left = b.enforce("post", captured=cap)
items = {x.item: x for x in left}
self.assertTrue(items["position"].action.startswith("restored"), items["position"].action)
self.assertEqual(dev.sent[0], "^X")
jogs = [l for l in dev.sent if l.startswith("$J=")]
self.assertEqual(len(jogs), 1)
self.assertIn("X-%.3f" % held_mm, jogs[0])
self.assertTrue(any("reset out of Hold:0" in l for l in self.lines), self.lines)
finally:
dev.stop()
fc.stop()
for k in ("GRBL_HOST", "GRBL_PORT"):
os.environ.pop(k, None)
def test_ring_residue_is_a_leftover_and_blocks_the_return_jog(self):
b = self.bl()
cap = b.capture()
# the run left 40 unplayed bytes queued in the kernel ring and the
# head 1000 counts out: the hand-back stands the machine down to
# empty the ring, and only refuses the return jog when the bytes
# are still there afterwards (they are here: no daemon to restart
# a controller)
self._pos_bytes(1000, 0, 0, 100, 140)
left = b.enforce("post", captured=cap)
items = {x.item: x for x in left}
self.assertIn("pulse ring", items)
self.assertEqual(items["pulse ring"].found, "40 unplayed bytes")
self.assertIn("still queued", items["position"].action)
self.assertIn("after a controller restart", items["position"].action)
self.assertEqual(baseline.read_ring_residue(), 40)
def test_the_hand_back_stands_the_machine_down_for_residue(self):
"""It acts, it does not report: a run that left bytes in the ring
gets a controller restart out of the hand-back, because that is
what empties the ring."""
b = self.bl()
cap = b.capture()
called = []
b.stand_down = lambda why: called.append(why)
self._pos_bytes(1000, 0, 0, 100, 140)
b.enforce("post", captured=cap)
self.assertEqual(len(called), 1)
self.assertIn("unplayed bytes", called[0])
def test_clean_ring_reads_zero_residue(self):
self.assertEqual(baseline.read_ring_residue(), 0)
def test_lamp_needs_forgectrl(self):
# the lamp's idle level comes from forgectrl's settings: without the
# daemon there is nothing to compare against
self._attr("pic/lid_led", "77")
left = self.bl().enforce("pre", captured=None)
self.assertEqual(left, [])
self.assertEqual(self._read("pic/lid_led"), "77")
def test_no_sysfs_means_skip(self):
os.environ["GF_SYSFS_ROOT"] = os.path.join(self.tmp, "nope") + os.sep
left = self.bl().enforce("pre", captured=None)
self.assertEqual(left, [])
self.assertTrue(any("kernel sysfs not present" in l for l in self.lines))
def test_boot_reference_needs_a_recent_boot(self):
os.environ["FORGETEST_BOOT_ID"] = "test-boot"
try:
# no reference file, uptime unknown on a host without /proc/uptime,
# or too old: None, with the reason logged
ref = baseline.boot_reference(self.lines.append, self.tmp)
up = baseline.uptime_s()
if up is None or up > baseline.BOOT_MAX_AGE_S:
self.assertIsNone(ref)
self.assertTrue(any("no fresh-boot reference" in l for l in self.lines))
else:
# a young host: the reference is taken from the fake tree
self.assertIsNotNone(ref)
self.assertEqual(ref["sysfs"]["cnc/motor_lock"], "0")
self.assertTrue(os.path.exists(os.path.join(self.tmp, "boot-test-boot.json")))
# and loaded back the second time
self.lines[:] = []
ref2 = baseline.boot_reference(self.lines.append, self.tmp)
self.assertEqual(ref2["ts"], ref["ts"])
self.assertTrue(any("reference loaded" in l for l in self.lines))
finally:
os.environ.pop("FORGETEST_BOOT_ID", None)
def test_fixed_constants_checked_against_a_dump(self):
ref = {"sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000"}}
diffs = baseline.check_fixed_against(ref, self.lines.append)
self.assertEqual(diffs, ["cnc/step_freq: boot=10000 constant=%s" % DEFAULT_TICK])
# -- the reference is taken after the controller applied its config -----
def test_wait_configured_returns_once_the_controller_wrote_its_config(self):
self._probe_state()
calls = {"n": 0}
def sleep(_s):
calls["n"] += 1
if calls["n"] == 3: # the controller's init writes land
# at the mode wait_controller_configured watches by default
for attr, val in baseline.configured_markers():
self._attr(attr, val)
ok = baseline.wait_controller_configured(
self.lines.append, {"controller": "running", "mode": "grbl", "motion": "verified"},
timeout=5, sleep=sleep)
self.assertTrue(ok)
self.assertTrue(any("controller configured" in l for l in self.lines))
self.assertGreaterEqual(calls["n"], 4) # 3 polls + the settle
def test_wait_configured_times_out_and_says_so(self):
self._probe_state()
t = {"now": 0.0}
real_time = baseline.time.time
baseline.time.time = lambda: t["now"]
try:
def sleep(s):
t["now"] += s
ok = baseline.wait_controller_configured(
self.lines.append, {"controller": "running", "mode": "grbl"}, timeout=2, sleep=sleep)
finally:
baseline.time.time = real_time
self.assertFalse(ok)
self.assertTrue(any("did not apply its config" in l for l in self.lines))
def test_wait_configured_is_a_noop_outside_grbl_mode(self):
self._probe_state()
ok = baseline.wait_controller_configured(
self.lines.append, {"controller": "running", "mode": "cloud"}, timeout=1,
sleep=lambda s: self.fail("slept in cloud mode"))
self.assertTrue(ok)
ok = baseline.wait_controller_configured(
self.lines.append, {"controller": "stopped", "mode": "grbl"}, timeout=1,
sleep=lambda s: self.fail("slept with the controller stopped"))
self.assertTrue(ok)
def test_preconfig_reference_is_recognized(self):
self.assertTrue(baseline.reference_preconfig(
{"sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000", "cnc/y_mode": "1"}}))
# The markers are read at the reference's own mode, so a dump taken
# on an x8 machine carries the setting that says so.
self.assertFalse(baseline.reference_preconfig(
{"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "28160", "cnc/y_mode": "8"},
"forgectrl": {"/settings": {"xy_microsteps": "8"}}}))
# a genuinely different single constant is a machine fact, not pre-config
self.assertFalse(baseline.reference_preconfig(
{"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "10000", "cnc/y_mode": "8"},
"forgectrl": {"/settings": {"xy_microsteps": "8"}}}))
self.assertFalse(baseline.reference_preconfig({"sysfs": {}}))
# -- the XY microstep mode drives the fixed values ------------------------
def test_the_fixed_values_follow_the_microstep_mode(self):
x8 = dict(baseline.fixed_sysfs(8))
self.assertEqual(x8, dict(baseline.FIXED_SYSFS))
x16 = dict(baseline.fixed_sysfs(16))
self.assertEqual((x16["cnc/x_mode"], x16["cnc/y_mode"], x16["cnc/step_freq"], x16["cnc/ramp_rate"]),
("16", "16", "56320", "250000"))
x32 = dict(baseline.fixed_sysfs(32))
self.assertEqual((x32["cnc/x_mode"], x32["cnc/step_freq"], x32["cnc/ramp_rate"]),
("32", "112640", "500000"))
# everything else is the same list, in the same order
self.assertEqual([a for a, _ in baseline.fixed_sysfs(32)], [a for a, _ in baseline.FIXED_SYSFS])
self.assertEqual(x32["cnc/x_decay"], x8["cnc/x_decay"])
def test_the_mode_is_read_from_the_settings_with_a_default(self):
# An unset or unusable key reads as the driver's default, x32.
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "16"}), 16)
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "32"}), 32)
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "8"}), 8)
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": ""}), 32)
self.assertEqual(baseline.xy_mode_of({}), 32)
self.assertEqual(baseline.xy_mode_of(None), 32)
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "24"}), 32)
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "abc"}), 32)
self.assertEqual(baseline.ref_xy_mode({"forgectrl": {"/settings": {"xy_microsteps": "16"}}}), 16)
self.assertEqual(baseline.ref_xy_mode({"forgectrl": {"/settings": None}}), 32)
self.assertEqual(baseline.ref_xy_mode({}), 32)
def test_fixed_constants_are_checked_at_the_reference_mode(self):
ref = {"sysfs": {"cnc/x_mode": "16", "cnc/step_freq": "56320", "cnc/ramp_rate": "250000"},
"forgectrl": {"/settings": {"xy_microsteps": "16"}}}
self.assertEqual(baseline.check_fixed_against(ref, self.lines.append), [])
ref["forgectrl"]["/settings"]["xy_microsteps"] = "8"
diffs = baseline.check_fixed_against(ref, self.lines.append)
self.assertEqual(diffs, ["cnc/x_mode: boot=16 constant=8",
"cnc/step_freq: boot=56320 constant=28160",
"cnc/ramp_rate: boot=250000 constant=125000"])
def test_wait_configured_watches_the_mode_in_force(self):
self._probe_state()
calls = {"n": 0}
def sleep(_s):
calls["n"] += 1
if calls["n"] == 2: # an x16 controller's init writes land
self._attr("cnc/step_freq", "56320")
self._attr("cnc/y_mode", "16")
ok = baseline.wait_controller_configured(
self.lines.append, {"controller": "running", "mode": "grbl", "motion": "verified"},
timeout=5, sleep=sleep, xy_mode=16)
self.assertTrue(ok)
self.assertTrue(any("controller configured" in l for l in self.lines))
# a reference taken under x16 is not pre-config
self.assertFalse(baseline.reference_preconfig(
{"sysfs": {"cnc/step_freq": "56320", "cnc/y_mode": "16"},
"forgectrl": {"/settings": {"xy_microsteps": "16"}}}))
self.assertTrue(baseline.reference_preconfig(
{"sysfs": {"cnc/step_freq": "10000", "cnc/y_mode": "1"},
"forgectrl": {"/settings": {"xy_microsteps": "16"}}}))
def test_stale_preconfig_reference_is_retaken_on_a_fresh_boot(self):
# Both sides of BOOT_MAX_AGE_S, with the uptime held: read from the host, it can cross the limit
# between boot_reference()'s reading and the test's (it did, on a CI runner 600 s after its boot).
os.environ["FORGETEST_BOOT_ID"] = "test-boot-2"
real = baseline.uptime_s
try:
for up, fresh in ((baseline.BOOT_MAX_AGE_S - 60, True), (baseline.BOOT_MAX_AGE_S + 60, False)):
self.lines.clear()
path = os.path.join(self.tmp, "boot-test-boot-2.json")
with open(path, "w") as f:
json.dump({"ts": "old", "sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000",
"cnc/y_mode": "1"}}, f)
baseline.uptime_s = lambda up=up: up
ref = baseline.boot_reference(self.lines.append, self.tmp)
if fresh:
self.assertTrue(any("retaking" in l for l in self.lines), self.lines)
self.assertNotEqual(ref["ts"], "old")
self.assertEqual(ref["sysfs"]["cnc/motor_lock"], "0")
else:
# too old to retake: the stale reference stands, marked
self.assertTrue(any("predates the controller's config" in l for l in self.lines), self.lines)
self.assertEqual(ref["ts"], "old")
finally:
baseline.uptime_s = real
os.environ.pop("FORGETEST_BOOT_ID", None)
class ExtensionFreeTests(BaselineFixture, unittest.TestCase):
"""A campaign's machine is extension-free: what a test made is removed,
what is the operator's is reported and left alone."""
def setUp(self):
super().setUp()
self.root = os.path.join(self.tmp, "ext")
for d in ("pkg", "keys", "data"):
os.makedirs(os.path.join(self.root, d))
# a stand-in for the host's command line: remove <id> takes the package away
self.forgeext = os.path.join(self.tmp, "forgeext")
with open(self.forgeext, "w") as f:
f.write('#!/bin/sh\n[ "$1" = remove ] && rm -rf "%s/pkg/$2" && echo \'{"ok": true}\'\n' % self.root)
os.chmod(self.forgeext, 0o755)
self.pool = {}
self._saved = (baseline.hw.EXT_ROOT, baseline.hw.FORGEEXT, baseline.hw.pool_pids, baseline.EXT_STOP_S)
baseline.hw.EXT_ROOT, baseline.hw.FORGEEXT = self.root, self.forgeext
baseline.hw.pool_pids = lambda: dict(self.pool)
baseline.EXT_STOP_S = 0.2
def tearDown(self):
baseline.hw.EXT_ROOT, baseline.hw.FORGEEXT, baseline.hw.pool_pids, baseline.EXT_STOP_S = self._saved
super().tearDown()
def _package(self, id_):
os.makedirs(os.path.join(self.root, "pkg", id_, "1.0.0"))
def _key(self, name):
with open(os.path.join(self.root, "keys", name), "w") as f:
f.write("x")
def test_an_installed_package_that_does_not_run_is_nobodys_leftover(self):
self._package("org.example.notify")
self._key("owner.pub")
self.assertEqual(self.bl().enforce("pre", captured=None), [])
def test_what_a_test_left_behind_is_removed_and_the_operators_is_not(self):
self._package("org.forgetest.reference")
self._package("org.example.notify")
self._key("forgetest-reference.pub")
self._key("owner.pub")
left = {x.item: x for x in self.bl().enforce("post", captured=None)}
self.assertEqual(sorted(left), ["ext.owner_key", "ext.package"])
self.assertEqual((left["ext.package"].found, left["ext.package"].action), ("org.forgetest.reference", "restored"))
self.assertEqual((left["ext.owner_key"].found, left["ext.owner_key"].action), ("forgetest-reference.pub", "restored"))
self.assertEqual(os.listdir(os.path.join(self.root, "pkg")), ["org.example.notify"])
self.assertEqual(os.listdir(os.path.join(self.root, "keys")), ["owner.pub"])
def test_a_package_that_cannot_be_removed_says_so(self):
self._package("org.forgetest.reference")
with open(self.forgeext, "w") as f:
f.write('#!/bin/sh\necho \'{"ok": false, "error": "the lock is held"}\'\nexit 1\n')
left = self.bl().enforce("post", captured=None)
self.assertEqual([x.item for x in left], ["ext.package"])
self.assertTrue(left[0].action.startswith("failed:") and "the lock is held" in left[0].action, left[0].action)
def test_running_extensions_are_reported_and_left_alone(self):
self._package("org.example.notify")
self.pool = {4242: 800}
left = self.bl().enforce("pre", captured=None)
self.assertEqual([(x.item, x.action) for x in left], [("ext.running", "unrestorable")])
self.assertIn("org.example.notify", left[0].found)
self.assertTrue(os.path.isdir(os.path.join(self.root, "pkg", "org.example.notify")))
class CloudOwnedSysfsTests(unittest.TestCase):
"""In cloud mode the cloud client configures the machine from the
pulse header it is playing, the lens included: z_mode comes from
ZSmd through gfhardware's set_mode_from_puls. Handing the GRBL values
back under it would be the baseline configuring another controller's
machine. In GRBL mode the pair is checked as before."""
def test_the_z_pair_is_the_cloud_clients(self):
for attr in ("head/z_current", "head/z_mode"):
self.assertIn(attr, baseline.GRBL_CONTROLLER_SYSFS)
def test_the_z_pair_is_still_checked_in_grbl_mode(self):
fixed = dict(baseline.fixed_sysfs())
self.assertEqual(fixed.get("head/z_current"), "1")
self.assertEqual(fixed.get("head/z_mode"), "1")
class PositionDeadbandTests(unittest.TestCase):
"""The counters count steps and a controller's own return lands within
a few hundredths of a millimeter, not on the step: a difference that
small is quantization, not a leftover. Found on the bench reference,
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."""
def test_past_the_dead_band_is_a_leftover(self):
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], [0, over, 0]))
def test_an_unreadable_reading_is_never_quantization(self):
self.assertFalse(baseline.position_quantized(None, [0, 0, 0]))
self.assertFalse(baseline.position_quantized([0, 0, 0], None))
class BaselineModeTests(BaselineFixture, unittest.TestCase):
"""The baseline against a fake forgectrl: what the mode in force
owns, over the same fake sysfs tree."""
def setUp(self):
super().setUp()
import helpers
self.fc = helpers.FakeForgectrl().start()
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):
baseline.LID_LAMP_SETTLE_S, baseline.LID_LAMP_POLL_S = self._lamp_timing
self.fc.stop()
super().tearDown()
def cloud(self):
self.fc.state["mode"] = {"mode": "cloud", "controller": "running", "pid": 7, "motion": "verified"}
self.fc.state["settings"]["controller_mode"] = "cloud"
def nohunt_marker(self, refuse=False):
"""The no-hunt marker under the fake tree; what each POST found.
The fake client takes the marker down as gfcloud does, first thing
at its start; with refuse the POST is refused and no client starts."""
baseline.NOHUNT_MARKER = os.path.join(self.tmp, "nohunt-marker")
self.fc.state["settings"].setdefault("cloud_enabled", "1")
seen = []
def on_post(path, form):
seen.append((path, dict(form), os.path.exists(baseline.NOHUNT_MARKER)))
if refuse:
return 409, {"error": "busy"}
if os.path.exists(baseline.NOHUNT_MARKER):
os.remove(baseline.NOHUNT_MARKER)
if path == "/mode":
self.fc.state["mode"] = dict(self.fc.state["mode"], mode=form["controller"], controller="running")
elif path == "/controller/start":
self.fc.state["mode"] = dict(self.fc.state["mode"], controller="running")
return None
self.fc.on_post = on_post
return seen
def test_a_refused_switch_leaves_no_marker_for_the_next_start(self):
seen = self.nohunt_marker(refuse=True)
ok, detail = self.bl().switch_mode("cloud")
self.assertFalse(ok)
self.assertEqual(seen, [("/mode", {"controller": "cloud"}, True)])
self.assertFalse(os.path.exists(baseline.NOHUNT_MARKER))
def test_a_switch_to_cloud_starts_the_client_without_the_hunt(self):
seen = self.nohunt_marker()
ok, detail = self.bl().switch_mode("cloud")
self.assertTrue(ok, detail)
self.assertEqual(seen, [("/mode", {"controller": "cloud"}, True)])
self.assertFalse(os.path.exists(baseline.NOHUNT_MARKER)) # one start, never left behind
def test_a_switch_to_cloud_turns_cloud_mode_on_when_it_is_off(self):
# A test that declares cloud mode gets it: cloud_enabled goes to 1
# first, with the typed phrase the daemon asks for and a log line,
# and only then the mode switch.
lines = []
seen = self.nohunt_marker()
self.fc.state["settings"]["cloud_enabled"] = "0"
ok, detail = baseline.Baseline(lines.append).switch_mode("cloud")
self.assertTrue(ok, detail)
self.assertEqual(seen[0], ("/settings", {"cloud_enabled": "1", "phrase": "I UNDERSTAND"}, False))
self.assertEqual(seen[1], ("/mode", {"controller": "cloud"}, True))
self.assertTrue(any("cloud mode turned on for the test" in ln for ln in lines))
def test_a_switch_to_grbl_sets_no_marker(self):
seen = self.nohunt_marker()
self.cloud()
ok, detail = self.bl().switch_mode("grbl")
self.assertFalse(ok) # the fake has no Grbl port: the switch itself happened
self.assertEqual(seen, [("/mode", {"controller": "grbl"}, False)])
self.assertFalse(os.path.exists(baseline.NOHUNT_MARKER))
def test_the_mode_handed_back_is_cloud_without_the_hunt(self):
seen = self.nohunt_marker()
self.cloud()
b = self.bl()
cap = b.capture() # found in cloud
self.fc.state["mode"] = dict(self.fc.state["mode"], mode="grbl") # the run left it in grbl
b.enforce("post", captured=cap)
self.assertEqual(seen[0], ("/mode", {"controller": "cloud"}, True))
self.assertFalse(os.path.exists(baseline.NOHUNT_MARKER))
def test_a_standby_cloud_client_is_started_without_the_hunt(self):
seen = self.nohunt_marker()
self.cloud()
b = self.bl()
cap = b.capture()
self.fc.state["mode"] = dict(self.fc.state["mode"], controller="standby")
b.enforce("post", captured=cap)
self.assertEqual(seen[0], ("/controller/start", {}, True))
self.assertFalse(os.path.exists(baseline.NOHUNT_MARKER))
def test_grbl_mode_restores_the_controller_values_and_the_lamp(self):
self._attr("cnc/step_freq", "10000")
self._attr("pic/lid_led", "77")
left = self.bl().enforce("pre", captured=None)
self.assertEqual(sorted(x.item for x in left), ["cnc/step_freq", "pic/lid_led"])
self.assertEqual(self._read("cnc/step_freq"), DEFAULT_TICK)
self.assertEqual(self._read("pic/lid_led"), "236")
def test_cloud_mode_leaves_the_clients_config_lamp_and_counters(self):
self.cloud()
self._attr("cnc/step_freq", "10000") # the cloud client's tick
self._attr("pic/x_step_current", "135")
self._attr("pic/lid_led", "77") # its lid-image level
b = self.bl()
cap = b.capture()
self.assertEqual(cap["mode"], "cloud")
self.assertNotIn("controller_mode", cap["settings"])
self._pos(-13096, -7400, 0) # the service re-zeroed and moved
self._attr("cnc/streaming", "1") # NOT the client's: still restored
left = b.enforce("post", captured=cap)
self.assertEqual([x.item for x in left], ["cnc/streaming"])
self.assertEqual(self._read("cnc/step_freq"), "10000")
self.assertEqual(self._read("pic/x_step_current"), "135")
self.assertEqual(self._read("pic/lid_led"), "77")
self.assertEqual(self._read("cnc/streaming"), "0")
self.assertEqual(self.fc.posts, []) # no mode switch, no settings write
def test_cloud_mode_still_relocks_the_latch(self):
self.cloud()
self._attr("cnc/interlock_circuit", "37") # bit 3 clear: unlocked
left = self.bl().enforce("post", captured=None)
self.assertEqual([x.item for x in left], ["laser_latch"])
self.assertEqual(self._read("cnc/laser_latch"), "1")
def test_undeclared_mode_change_is_handed_back_through_the_switch(self):
b = self.bl()
cap = b.capture() # found in grbl
self.assertEqual(cap["mode"], "grbl")
self.cloud() # the run left it in cloud, silently
left = b.enforce("post", captured=cap)
items = {x.item: x for x in left}
self.assertIn("mode", items)
self.assertEqual(items["mode"].action, "restored")
self.assertEqual(self.fc.posts, [("/mode", {"controller": "grbl"})])
self.assertEqual(self.fc.state["mode"]["mode"], "grbl")
def test_declared_mode_change_is_kept(self):
b = self.bl()
cap = b.capture()
self.cloud()
cap["mode"] = "cloud" # Context.mode_changed("cloud")
left = b.enforce("post", captured=cap)
self.assertNotIn("mode", [x.item for x in left])
self.assertEqual(self.fc.posts, [])
self.assertEqual(self.fc.state["mode"]["mode"], "cloud")
def test_controller_mode_setting_is_never_written_back_bare(self):
b = self.bl()
cap = b.capture()
self.assertNotIn("controller_mode", cap["settings"])
self.fc.state["settings"]["controller_mode"] = "cloud" # a switch persisted it
self.fc.state["mode"]["mode"] = "cloud"
cap["mode"] = "cloud"
b.enforce("post", captured=cap)
self.assertEqual([p for p, _ in self.fc.posts if p == "/settings"], [])
if __name__ == "__main__":
unittest.main()
-63
View File
@@ -1,63 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The bench registry against scripts/bench: every python tool is
registered (or named as not-a-tool), every registered script exists,
a ported tool builds a command line with its defaults, and every
script compiles."""
import glob
import os
import py_compile
import unittest
import helpers # noqa: F401 (sys.path)
from forgetest import bench as bench_mod
REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
BENCH = os.path.join(REPO, "scripts", "bench")
class RegistryTests(unittest.TestCase):
def setUp(self):
self.bench = bench_mod.Bench(tool_dir=BENCH, index_path=os.devnull)
self.scripts = sorted(os.path.basename(p) for p in glob.glob(os.path.join(BENCH, "*.py")))
def test_every_python_tool_is_registered(self):
registered = {t["script"] for t in bench_mod.TOOLS}
missing = [s for s in self.scripts if s not in registered and s not in bench_mod.NOT_TOOLS]
self.assertEqual(missing, [], "scripts/bench python files missing from the registry")
def test_every_registered_script_exists(self):
for t in bench_mod.TOOLS:
self.assertTrue(os.path.exists(os.path.join(BENCH, t["script"])), t["script"])
def test_ids_unique_and_fields_valid(self):
ids = [t["id"] for t in bench_mod.TOOLS]
self.assertEqual(len(ids), len(set(ids)))
for t in bench_mod.TOOLS:
self.assertIn(t["safety"], ("dry", "takeover", "scope", "live"), t["id"])
self.assertIn(t["where"], ("board", "host"), t["id"])
self.assertIsInstance(t["ported"], bool, t["id"])
self.assertTrue(t["desc"], t["id"])
for a in t.get("args", []):
self.assertIn(a["type"], ("str", "int", "float", "choice"), (t["id"], a["name"]))
if a["type"] == "choice":
self.assertIn(a["default"], a["choices"], (t["id"], a["name"]))
def test_ported_tools_build_a_command_with_defaults(self):
for t in bench_mod.TOOLS:
if not t["ported"]:
continue
ok, argv, err = self.bench.command(t, {})
self.assertTrue(ok, "%s: %s" % (t["id"], err))
self.assertEqual(argv[1], os.path.join(BENCH, t["script"]))
def test_scripts_compile(self):
for s in self.scripts:
py_compile.compile(os.path.join(BENCH, s), doraise=True)
if __name__ == "__main__":
unittest.main()
-202
View File
@@ -1,202 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""A test that lets the service move the head hands it back where it found
it. Every service motion zeroes the step counters at its start, and a home
and every controller start zero them again, so no counter reading says
where the test found the head: the client's own record does. Every motion
logs the counters it ended on, and the travel is their sum (x8 steps, the
one mode a service motion runs at), plus what the counters read since. The
log lines are the machine's own, trimmed to the motion records: a homing
session whose one motion the cooling engine stopped short, and a whole
three-motion one. Runs against scratch logs and fakes for the controller."""
import os
import shutil
import tempfile
import unittest
from forgetest.runner import Failed
from forgetest.suite import cloud, homeoff
STOPPED = """\
2026-09-25T15:56:19.641824+00:00 gfhome[7387] INFO gfhome:home service action: motion (ready)
2026-09-25T15:56:19.644041+00:00 gfhome[7387] INFO machine:_motion start motion
2026-09-25T15:56:19.970342+00:00 gfhome[7387] INFO machine:_run_loop starting run
2026-09-25T15:56:20.905040+00:00 gfhome[7387] ERR machine:_run_loop run stopped short: 8130 of 21756 bytes played; the job did not finish
2026-09-25T15:56:20.918652+00:00 gfhome[7387] INFO machine:_feed_and_run end positions (actual/expected): X (4390/12115), Y (2682/7402), Z (0/0)
2026-09-25T15:56:20.937235+00:00 gfhome[7387] INFO machine:_motion_locked end positions (4390, 2682, 0)
2026-09-25T15:56:20.938026+00:00 gfhome[7387] INFO machine:_motion end motion
2026-09-25T15:56:20.938677+00:00 gfhome[7387] INFO basemachine:_finish_action motion [1588842275]: finished with event ":cancelled"
2026-09-25T15:56:21.145580+00:00 gfhome[7387] INFO gfhome:home motion completed
"""
WHOLE = "".join(
"2026-09-07T20:%s gfhome[28592] INFO machine:_motion start motion\n"
"2026-09-07T20:%s gfhome[28592] INFO machine:_feed_and_run end positions (actual/expected): "
"X (%d/%d), Y (%d/%d), Z (0/0)\n"
"2026-09-07T20:%s gfhome[28592] INFO machine:_motion_locked end positions (%d, %d, 0)\n"
"2026-09-07T20:%s gfhome[28592] INFO machine:_motion end motion\n"
"2026-09-07T20:%s gfhome[28592] INFO basemachine:_finish_action motion [%d]: finished with event "
"\":completed\"\n" % (t, t, x, x, y, y, t, x, y, t, t, i)
for i, (t, x, y) in enumerate((("25:50.1", 4774, 4600), ("25:56.2", -71, -144), ("26:02.9", -13096, -7399))))
class FakeGrbl:
def __init__(self, ctx):
self.ctx = ctx
def __enter__(self):
return self
def __exit__(self, *a):
return False
def command(self, line, timeout=None):
self.ctx.jogs.append(line)
return ["ok"]
class FakeCtx:
def __init__(self):
self.forgectrl = object()
self.jogs = []
self.lines = []
self.rezeroed = 0
def check(self, cond, msg, *args):
if not cond:
raise Failed(msg % args if args else msg)
def log(self, msg, *args):
self.lines.append(msg % args if args else msg)
def grbl(self):
return FakeGrbl(self)
def counters_rezeroed(self):
self.rezeroed += 1
class HandBackTests(unittest.TestCase):
def setUp(self):
self.dir = tempfile.mkdtemp()
self.log = os.path.join(self.dir, "gfhome.log")
self.cloudlog = os.path.join(self.dir, "gfcloud.log")
self.ctx = FakeCtx()
self.anchor = True
self.counters = [0, 0, 0]
self.restarts = 0
self.spm = 213.333 # the controller's x32 scale
saved_logs = (cloud.GFHOME_LOG, cloud.GFCLOUD_LOG)
self.addCleanup(lambda: setattr(cloud, "GFHOME_LOG", saved_logs[0]))
self.addCleanup(lambda: setattr(cloud, "GFCLOUD_LOG", saved_logs[1]))
cloud.GFHOME_LOG, cloud.GFCLOUD_LOG = self.log, self.cloudlog
patches = {
"counter_frame": lambda: [1, 2] if self.anchor else None,
"read_position": lambda: list(self.counters),
"counter_steps_per_mm": lambda: self.spm,
"_drop_reference": self._restart,
"clean_slate": lambda ctx, g: None,
"wait_idle": self._jog_ends,
"machine_idle": lambda ctx: None,
}
saved = {k: getattr(homeoff, k) for k in patches}
self.addCleanup(lambda: [setattr(homeoff, k, v) for k, v in saved.items()])
for k, v in patches.items():
setattr(homeoff, k, v)
self.addCleanup(shutil.rmtree, self.dir, True)
def _restart(self, ctx, fc):
# a controller start zeroes the counters where the head stands and
# removes the anchor
self.restarts += 1
self.counters = [0, 0, 0]
self.anchor = False
def _jog_ends(self, ctx, g, timeout=30.0):
# the jog played: the counters moved by what it asked, at the scale
words = dict((w[0], float(w[1:])) for w in self.ctx.jogs[-1].split()[2:4])
self.counters = [self.counters[0] + round(words["X"] * self.spm),
self.counters[1] + round(words["Y"] * self.spm), 0]
return 0.0, ["Jog", "Idle"], {}
def write(self, text, path=None):
with open(path or self.log, "a", encoding="utf-8", newline="\n") as f:
f.write(text)
def expect_jog(self, dx, dy):
self.assertEqual(self.ctx.jogs, ["$J=G91 G21 X%.3f Y%.3f F2400" % (-dx, -dy)])
# -- the stopped-short judge --------------------------------------------
def test_a_motion_stopped_short_fails_the_judge(self):
self.write(STOPPED)
ev = {}
with self.assertRaises(Failed):
homeoff.judge_whole_motions(self.ctx, ev, 0)
self.assertEqual(len(ev["short_motions"]), 2)
# -- after a camera home -------------------------------------------------
def test_homed_the_head_goes_back_by_the_travel_and_the_counters_since(self):
# the home zeroed the counters; the test's own jogs left a step or so
self.write(STOPPED)
self.counters = [round(1.0 * self.spm), 0, 0]
since = self.counters[0] / self.spm
homeoff.camera_home_return(self.ctx, {"homed": True}, 0)
self.expect_jog(4390 / homeoff.XY_STEPS_PER_MM + since, 2682 / homeoff.XY_STEPS_PER_MM)
# dropped before the jog, and once more with the head back
self.assertEqual(self.restarts, 2)
self.assertEqual(self.counters, [0, 0, 0])
self.assertEqual(self.ctx.rezeroed, 1)
def test_unhomed_the_counters_are_not_counted_twice(self):
# a failed session leaves the last motion's end in the counters,
# which the travel already counts
self.write(STOPPED)
self.counters = [4390, 2682, 0]
self.anchor = False
homeoff.camera_home_return(self.ctx, {"homed": False}, 0)
self.expect_jog(4390 / homeoff.XY_STEPS_PER_MM, 2682 / homeoff.XY_STEPS_PER_MM)
def test_an_unknown_travel_moves_nothing(self):
self.write(STOPPED.split("machine:_motion_locked")[0])
with self.assertRaises(Failed):
homeoff.camera_home_return(self.ctx, {"homed": True}, 0)
self.assertEqual(self.ctx.jogs, [])
self.assertEqual(self.ctx.rezeroed, 0)
def test_a_restart_since_the_home_moves_nothing(self):
# the anchor gone: the counters no longer read from the home
self.write(STOPPED)
self.anchor = False
with self.assertRaises(Failed):
homeoff.camera_home_return(self.ctx, {"homed": True}, 0)
self.assertEqual(self.ctx.jogs, [])
def test_a_head_that_never_moved_is_not_jogged(self):
homeoff.camera_home_return(self.ctx, {}, 0)
self.assertEqual(self.ctx.jogs, [])
self.assertEqual((self.restarts, self.ctx.rezeroed), (1, 1))
# -- after cloud mode ----------------------------------------------------
def test_after_cloud_mode_the_head_goes_back_by_the_clients_record(self):
# the service's re-hunt, cut short by the switch back: to the middle
# of the bed and one correction (the bench reference, 2026-09-25)
self.write(WHOLE[:WHOLE.index("2026-09-07T20:26:02.9")].replace("gfhome[", "gfcloud["), self.cloudlog)
ev = {}
homeoff.cloud_mode_return(self.ctx, ev, 0)
self.expect_jog((4774 - 71) / homeoff.XY_STEPS_PER_MM, (4600 - 144) / homeoff.XY_STEPS_PER_MM)
self.assertEqual(tuple(ev["cloud_return"]["session_travel_steps"]), (4774 - 71, 4600 - 144))
# no drop before the jog (the switch back started the controller), one after
self.assertEqual((self.restarts, self.ctx.rezeroed), (1, 1))
def test_after_cloud_mode_an_unknown_travel_moves_nothing(self):
self.write(STOPPED.replace("gfhome[", "gfcloud[").split("machine:_motion_locked")[0], self.cloudlog)
with self.assertRaises(Failed):
homeoff.cloud_mode_return(self.ctx, {}, 0)
self.assertEqual(self.ctx.jogs, [])
if __name__ == "__main__":
unittest.main()
-206
View File
@@ -1,206 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
import unittest
import helpers
from forgetest import campaign
from forgetest import catalog
def rec_campaign(cid, man, chash, ts):
return {"t": "campaign", "ts": ts, "id": cid, "manifest_sha": man.content_sha,
"catalog_hash": chash, "image": man.version}
def rec_result(cid, t, man, result, ts, fp=None):
return {"t": "result", "ts": ts, "campaign": cid, "test": t.id, "result": result,
"fingerprint": fp or t.fingerprint(man), "manifest_sha": man.content_sha,
"image": man.version, "duration_s": 1, "message": ""}
class CampaignTests(unittest.TestCase):
def setUp(self):
self.man = helpers.make_manifest()
self.core = helpers.make_test("image.health", [("linux-fslc", "**")], always=True)
self.ui = helpers.make_test("forgectrl.panel", [("forgectrl", "src/ui.c")])
self.cool = helpers.make_test("cooling.flow", [("forgectrl", "src/cool.c")])
self.live = helpers.make_test("laser.witness", [("grblhal-glowforge", "src/**")],
requires=("forgectrl.panel",), kind="live")
self.reg = helpers.registry(self.core, self.ui, self.cool, self.live)
self.tests = list(self.reg.values())
self.chash = catalog.catalog_hash(self.reg)
def compute(self, records, man=None, chash=None):
return campaign.compute(records, self.tests, man or self.man, chash or self.chash)
def test_empty(self):
st = self.compute([])
self.assertIsNone(st["campaign"])
self.assertFalse(st["authorized"])
for t in self.tests:
self.assertTrue(st["tests"][t.id]["required"])
self.assertEqual(st["tests"][t.id]["reason"], "always" if t.always else "never-passed")
self.assertFalse(st["tests"][self.live.id]["requires_met"])
def test_full_campaign_authorizes(self):
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z")]
for i, t in enumerate(self.tests):
recs.append(rec_result("c1", t, self.man, "PASS", "2026-08-20T10:0%d:00Z" % (i + 1)))
st = self.compute(recs)
self.assertTrue(st["authorized"])
self.assertEqual(st["counts"], {"total": 4, "satisfied": 4, "inherited": 0, "required": 0})
self.assertEqual(st["tests"][self.core.id]["status"], "pass")
def test_fail_closes_campaign(self):
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z"),
rec_result("c1", self.core, self.man, "PASS", "2026-08-20T10:01:00Z"),
rec_result("c1", self.ui, self.man, "PASS", "2026-08-20T10:02:00Z"),
rec_result("c1", self.cool, self.man, "FAIL", "2026-08-20T10:03:00Z")]
st = self.compute(recs)
self.assertIsNone(st["campaign"])
self.assertEqual(st["closed_by"], "fail")
self.assertFalse(st["authorized"])
# the core PASS in the closed campaign no longer counts; the ui PASS is inheritable
self.assertTrue(st["tests"][self.core.id]["required"])
self.assertEqual(st["tests"][self.core.id]["reason"], "always")
self.assertEqual(st["tests"][self.ui.id]["status"], "inherited")
self.assertEqual(st["tests"][self.cool.id]["status"], "fail")
self.assertEqual(st["tests"][self.cool.id]["reason"], "never-passed")
def test_a_fail_after_a_pass_blocks_inheritance(self):
# The ui test passes in c1, then fails on the same image (an
# intermittent gate): the FAIL closes c1. In c2 the older PASS must
# not be inherited over the newer FAIL: the test is required again.
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z"),
rec_result("c1", self.ui, self.man, "PASS", "2026-08-20T10:01:00Z"),
rec_result("c1", self.ui, self.man, "FAIL", "2026-08-20T10:02:00Z"),
rec_campaign("c2", self.man, self.chash, "2026-08-20T11:00:00Z")]
st = self.compute(recs)
t = st["tests"][self.ui.id]
self.assertEqual(t["status"], "fail")
self.assertEqual(t["reason"], "failed-since")
self.assertTrue(t["required"])
self.assertFalse(t["satisfied"])
self.assertFalse(st["authorized"])
def test_an_abort_after_a_pass_does_not_block_inheritance(self):
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z"),
rec_result("c1", self.ui, self.man, "PASS", "2026-08-20T10:01:00Z"),
rec_result("c1", self.ui, self.man, "ABORTED", "2026-08-20T10:02:00Z"),
rec_campaign("c2", self.man, self.chash, "2026-08-20T11:00:00Z")]
st = self.compute(recs)
self.assertEqual(st["tests"][self.ui.id]["status"], "inherited")
def test_a_pass_after_a_fail_is_inherited(self):
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z"),
rec_result("c1", self.ui, self.man, "FAIL", "2026-08-20T10:01:00Z"),
rec_campaign("c2", self.man, self.chash, "2026-08-20T11:00:00Z"),
rec_result("c2", self.ui, self.man, "PASS", "2026-08-20T11:01:00Z"),
rec_campaign("c3", self.man, self.chash, "2026-08-20T12:00:00Z")]
st = self.compute(recs)
self.assertEqual(st["tests"][self.ui.id]["status"], "inherited")
def test_error_closes_aborted_does_not(self):
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z"),
rec_result("c1", self.ui, self.man, "ABORTED", "2026-08-20T10:01:00Z")]
st = self.compute(recs)
self.assertIsNotNone(st["campaign"])
self.assertEqual(st["tests"][self.ui.id]["status"], "aborted")
recs.append(rec_result("c1", self.ui, self.man, "ERROR", "2026-08-20T10:02:00Z"))
st = self.compute(recs)
self.assertIsNone(st["campaign"])
self.assertEqual(st["closed_by"], "fail")
def test_inheritance_follows_fingerprint(self):
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z")]
for i, t in enumerate(self.tests):
recs.append(rec_result("c1", t, self.man, "PASS", "2026-08-20T10:0%d:00Z" % (i + 1)))
# a new image with only cool.c changed
man2 = helpers.with_file(self.man, "forgectrl", "src/cool.c", "cool v2")
st = self.compute(recs, man=man2)
self.assertIsNone(st["campaign"])
self.assertEqual(st["closed_by"], "image")
self.assertEqual(st["tests"][self.ui.id]["status"], "inherited")
self.assertEqual(st["tests"][self.live.id]["status"], "inherited")
self.assertEqual(st["tests"][self.cool.id]["status"], "stale")
self.assertEqual(st["tests"][self.cool.id]["reason"], "domain-changed")
self.assertEqual(st["tests"][self.core.id]["reason"], "always")
# open a campaign on the new image, run the core + cool -> authorized
recs.append(rec_campaign("c2", man2, self.chash, "2026-08-21T10:00:00Z"))
recs.append(rec_result("c2", self.core, man2, "PASS", "2026-08-21T10:01:00Z"))
st = self.compute(recs, man=man2)
self.assertFalse(st["authorized"])
recs.append(rec_result("c2", self.cool, man2, "PASS", "2026-08-21T10:02:00Z"))
st = self.compute(recs, man=man2)
self.assertTrue(st["authorized"])
self.assertEqual(st["counts"]["inherited"], 2)
self.assertEqual(st["tests"][self.ui.id]["origin"]["campaign"], "c1")
def test_platform_change_invalidates_everything(self):
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z")]
for i, t in enumerate(self.tests):
recs.append(rec_result("c1", t, self.man, "PASS", "2026-08-20T10:0%d:00Z" % (i + 1)))
man2 = helpers.with_platform(self.man, dtb={"glowforge.dtb": "f" * 64})
st = self.compute(recs, man=man2)
for t in self.tests:
self.assertTrue(st["tests"][t.id]["required"], t.id)
def test_invalidate_all(self):
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z")]
for i, t in enumerate(self.tests):
recs.append(rec_result("c1", t, self.man, "PASS", "2026-08-20T10:0%d:00Z" % (i + 1)))
recs.append({"t": "invalidate", "ts": "2026-08-22T09:00:00Z", "reason": "new tube"})
st = self.compute(recs)
self.assertIsNone(st["campaign"])
self.assertEqual(st["closed_by"], "invalidate")
self.assertEqual(st["invalidate"]["reason"], "new tube")
for t in self.tests:
self.assertTrue(st["tests"][t.id]["required"], t.id)
self.assertNotEqual(st["tests"][t.id]["status"], "inherited")
# after the invalidate, a new campaign's passes count and later ones inherit again
recs.append(rec_campaign("c2", self.man, self.chash, "2026-08-22T10:00:00Z"))
st = self.compute(recs)
# the campaign that started after it answers the invalidation: the
# page no longer announces it, while its epoch still holds
self.assertIsNone(st["invalidate"])
for t in self.tests:
self.assertTrue(st["tests"][t.id]["required"], t.id)
self.assertNotEqual(st["tests"][t.id]["status"], "inherited")
for i, t in enumerate(self.tests):
recs.append(rec_result("c2", t, self.man, "PASS", "2026-08-22T10:0%d:00Z" % (i + 1)))
st = self.compute(recs)
self.assertTrue(st["authorized"])
self.assertIsNone(st["invalidate"])
def test_reset_and_catalog_change(self):
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z"),
rec_result("c1", self.core, self.man, "PASS", "2026-08-20T10:01:00Z"),
{"t": "reset", "ts": "2026-08-20T11:00:00Z", "reason": "x"}]
st = self.compute(recs)
self.assertIsNone(st["campaign"])
self.assertEqual(st["closed_by"], "reset")
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z")]
st = self.compute(recs, chash="different")
self.assertIsNone(st["campaign"])
self.assertEqual(st["closed_by"], "catalog")
def test_requires_uses_satisfied(self):
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z"),
rec_result("c1", self.ui, self.man, "PASS", "2026-08-20T10:01:00Z")]
st = self.compute(recs)
self.assertTrue(st["tests"][self.live.id]["requires_met"])
man2 = helpers.with_file(self.man, "forgectrl", "src/ui.c", "ui v2")
st = self.compute(recs, man=man2)
self.assertFalse(st["tests"][self.live.id]["requires_met"])
self.assertEqual(st["tests"][self.live.id]["missing_requires"], ["forgectrl.panel"])
def test_running_marker(self):
st = campaign.compute([], self.tests, self.man, self.chash, running=self.ui.id)
self.assertEqual(st["tests"][self.ui.id]["status"], "running")
if __name__ == "__main__":
unittest.main()
-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
"""Every phrase the cloud suite greps for in the gfcloud log must be a
phrase the pinned cloud app can actually log.
The cloud tests judge a print on the machine's own log lines. When the
app's wording moves (a resume that used to log one line logs two), a
test keeps looking for the old line and fails a print that worked. The
replay fixtures cannot catch that: they are excerpts of the old wording.
This test reads the phrases straight out of suite/cloud.py and checks
each against the logger calls in the app sources at the revisions the
recipes pin (python3-gfhardware and python3-gfutilities, from the
manifest cache the tree manifest builds, or the sibling checkouts when
no cache is at hand), placeholder-aware: "paused at" is covered by
'paused at %s', "warm up: holding" by '%s: holding %.1f s', and
"authenticate_machine SUCCESS" by logger.info('SUCCESS') inside
def authenticate_machine (the log format is module:function message).
"""
import ast
import importlib.util
import os
import re
import subprocess
import unittest
import helpers # noqa: F401 (sys.path)
HERE = os.path.dirname(os.path.abspath(__file__))
FORGETEST = os.path.dirname(HERE)
REPO = os.path.dirname(FORGETEST)
CLOUD_PY = os.path.join(FORGETEST, "forgetest", "suite", "cloud.py")
APP_COMPONENTS = ("python3-gfhardware", "python3-gfutilities")
SIBLINGS = {"python3-gfhardware": "python3-gfhardware", "python3-gfutilities": "Glowforge-Utilities"}
# Phrases cloud.py builds at run time rather than writing out (a "%s: holding"
# with the phase filled in), listed here so they are checked too.
BUILT_PHRASES = ("warm up: holding", "cool down: holding", "warm up: skipped", "cool down: skipped",
'finished with event ":cancelled"', 'finished with event ":completed"',
"motion [", "print [")
# Phrases that are not the app's: forgetest's own log, a line prefix of
# the log format rather than a message, or a line of forgectrl's log (the
# engine's effective-limits line, judged from its own file).
NOT_APP = ("PASS", "gfcloud", "[", "effective limits: coolant ceiling", "header")
PLACEHOLDER = re.compile(r"%(?:\([^)]*\))?[-+ #0]*\d*(?:\.\d+)?[sdifrxXeEgGcu]|%%")
# ------------------------------------------------------------ the needles
def needles_in(path):
"""String constants cloud.py tests against log lines: the left side of
an `x in ln`, the arguments of wait_log, endswith() arguments, and the
names those resolve to (module- or function-level constants)."""
with open(path, encoding="utf-8") as f:
tree = ast.parse(f.read())
consts = {} # name -> list of str
def strings_of(node):
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return [node.value]
if isinstance(node, (ast.List, ast.Tuple)):
return [s for e in node.elts for s in strings_of(e)]
if isinstance(node, ast.Name):
return consts.get(node.id, [])
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
return strings_of(node.left) + strings_of(node.right)
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "list":
return [s for a in node.args for s in strings_of(a)]
return []
for node in ast.walk(tree):
if isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
got = strings_of(node.value)
if got:
consts[node.targets[0].id] = got
found = set()
for node in ast.walk(tree):
if isinstance(node, ast.Compare) and any(isinstance(op, ast.In) for op in node.ops):
found.update(strings_of(node.left))
elif isinstance(node, ast.Call):
fn = node.func
if isinstance(fn, ast.Name) and fn.id == "wait_log" and len(node.args) >= 3:
found.update(strings_of(node.args[2]))
elif isinstance(fn, ast.Attribute) and fn.attr == "endswith":
for a in node.args:
found.update(strings_of(a))
# the mark tuples the suite scans lines against in comprehensions
# (`any(m in ln for m in SESSION_MARKS)`), where the name is a loop variable
for name, vals in consts.items():
if name.endswith(("_MARKS", "_LINES")):
found.update(vals)
found.update(BUILT_PHRASES)
return sorted(s.strip() for s in found if s.strip() and s.strip() not in NOT_APP and " " in s or s in BUILT_PHRASES)
# ------------------------------------------------------------ the app's lines
def log_literals(source, label):
"""(function name, fragments) for every logger call in a Python source:
the message's fixed text split at its placeholders."""
out = []
try:
tree = ast.parse(source)
except SyntaxError:
return out
funcs = {}
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
for sub in ast.walk(node):
funcs.setdefault(id(sub), node.name)
for node in ast.walk(tree):
if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
and node.func.attr in ("debug", "info", "warning", "warn", "error", "critical", "exception")
and node.args):
continue
msg = node.args[0]
if isinstance(msg, ast.BinOp) and isinstance(msg.op, ast.Mod):
msg = msg.left
frags = None
if isinstance(msg, ast.Constant) and isinstance(msg.value, str):
frags = [p.replace("%%", "%") for p in PLACEHOLDER.split(msg.value)]
elif isinstance(msg, ast.JoinedStr):
frags = [""]
for v in msg.values:
if isinstance(v, ast.Constant):
frags[-1] += str(v.value)
else:
frags.append("")
if frags is not None:
out.append((funcs.get(id(node), ""), frags, label))
return out
def covers(frags, needle):
"""True when `needle` is a substring of a rendering of the message whose
fixed text is `frags` (the gaps between fragments are placeholders; the
rendered line is `function message`, so frags[0] carries the function).
A placeholder is a value, never the phrase itself: the needle must lie
inside one fixed fragment, or run through the fragments in order and
contain at least one complete non-empty fragment."""
n = len(frags)
if any(needle in f for f in frags if f):
return True
def in_gap(text, i, complete):
"""`text` starts in the gap before frags[i]."""
if i >= n:
return False
frag = frags[i]
if frag == "":
return complete if i == n - 1 else in_gap(text, i + 1, complete)
start = 0
while True:
j = text.find(frag, start)
if j < 0:
break
if after(text[j + len(frag):], i, True):
return True
start = j + 1
# the needle ends inside frags[i]: a proper prefix of it, which counts
# as the complete fragment the rule wants only when the gap before it
# was preceded by one, or when what is left of the fragment is a
# trailing separator (": holding " is covered by "warm up: holding")
for k in range(1, len(frag)):
if text.endswith(frag[:k]) and (complete or not frag[k:].strip()):
return True
return False
def after(text, i, complete):
"""`text` starts right after the whole of frags[i]."""
if text == "":
return complete
return in_gap(text, i + 1, complete)
for i, frag in enumerate(frags):
for off in range(len(frag)):
suffix = frag[off:]
if needle.startswith(suffix) and after(needle[len(suffix):], i, off == 0):
return True
if i >= 1 and in_gap(needle, i, False):
return True
return False
# ------------------------------------------------------------ sources
def pinned_sources():
"""{component: [(path, text)]} for the app components at their pinned
revisions, through the tree manifest's recipe parser and cache; the
sibling checkouts when the cache cannot be reached."""
spec = importlib.util.spec_from_file_location("mft", os.path.join(REPO, "scripts", "manifest-from-tree.py"))
mft = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mft)
meta = os.path.join(os.path.dirname(REPO), "meta-openglow")
cache = os.path.join(REPO, ".manifest-cache")
out = {}
for name, rel, layer in mft.RECIPES:
if name not in APP_COMPONENTS:
continue
base = REPO if layer == "forgefirm" else meta
files = []
try:
url, rev = mft.parse_recipe(os.path.join(base, rel))
repo = mft.fetch(url, rev, cache)
names = mft.git(["ls-tree", "-r", "--name-only", rev], cwd=repo).decode().split("\n")
for p in names:
if p.endswith(".py"):
files.append((p, mft.git(["show", "%s:%s" % (rev, p)], cwd=repo).decode("utf-8", "replace")))
out[name] = ("pinned %s" % rev[:12], files)
continue
except (OSError, subprocess.CalledProcessError, SystemExit):
pass
sib = os.path.join(os.path.dirname(REPO), SIBLINGS[name])
if os.path.isdir(sib):
for root, _dirs, fnames in os.walk(sib):
if ".git" in root:
continue
for fn in fnames:
if fn.endswith(".py"):
p = os.path.join(root, fn)
files.append((os.path.relpath(p, sib), open(p, encoding="utf-8", errors="replace").read()))
out[name] = ("sibling checkout", files)
return out
class CloudNeedleTests(unittest.TestCase):
def test_every_needle_is_a_line_the_pinned_app_can_log(self):
needles = needles_in(CLOUD_PY)
self.assertGreaterEqual(len(needles), 30, needles)
self.assertIn("authenticate_machine SUCCESS", needles)
self.assertIn("RX-EVENT: ready", needles)
sources = pinned_sources()
missing = [c for c in APP_COMPONENTS if c not in sources]
if missing:
if os.environ.get("CI"):
self.fail("no source for %s (run scripts/manifest-from-tree.py first)" % ", ".join(missing))
self.skipTest("no source for %s" % ", ".join(missing))
literals = []
for comp, (where, files) in sources.items():
for path, text in files:
literals.extend(log_literals(text, "%s:%s" % (comp, path)))
self.assertGreater(len(literals), 100, "too few logger calls found: is the app source complete?")
uncovered = []
for needle in needles:
if not any(covers([fn + " " + frags[0]] + frags[1:], needle) for fn, frags, _ in literals):
uncovered.append(needle)
self.assertEqual(uncovered, [], "cloud.py greps for lines the pinned app never logs (sources: %s)"
% ", ".join("%s=%s" % (c, w) for c, (w, _) in sources.items()))
def test_cover_rules(self):
self.assertTrue(covers(["_run_loop paused at ", ""], "paused at"))
self.assertTrue(covers(["_dwell ", ": holding ", " s"], "warm up: holding"))
self.assertTrue(covers(["authenticate_machine SUCCESS"], "authenticate_machine SUCCESS"))
self.assertTrue(covers(["_resume_retraced resuming (laser lead ", " ticks)"], "resuming (laser lead"))
self.assertTrue(covers(["_finish_action ", " [", "]: finished with event \"", "\""],
'finished with event ":completed"'))
self.assertFalse(covers(["_run_loop button pressed while paused"], "button pressed while paused; resuming"))
self.assertFalse(covers(["_run_loop paused at ", ""], "paused it"))
if __name__ == "__main__":
unittest.main()
-365
View File
@@ -1,365 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The cloud.* tests' false-PASS guards, against a fake forgectrl and a
replayed gfcloud log: the real test functions run under the real runner
Context, the operator prompts answered - and the standing notices of the
machine actions acted on - by a script that also drives the replay. The
log lines are the machine's own: the lid-abort bench excerpt, and for the
pause, the run loop's lines as its host test emits them on the print
skeleton of that excerpt.
What is proven here: each test fails when the machine did the unsafe
thing - a dark print with the latch open at the button wait, a lid stop
that is polled rather than edge-driven, and a resume the kernel refused."""
import contextlib
import os
import shutil
import struct
import tempfile
import threading
import time
import unittest
import helpers
from forgetest.runner import Context, Failed, Run
from forgetest.suite import cloud
HERE = os.path.dirname(os.path.abspath(__file__))
FIX = os.path.join(HERE, "fixtures")
def fixture(name):
with open(os.path.join(FIX, "gfcloud-%s.log" % name), "rb") as f:
return f.read().decode().splitlines()
def cut(lines, marker, count=1):
"""(before, after) at the count-th line containing marker (the line
itself opens `after`)."""
n = 0
for i, ln in enumerate(lines):
if marker in ln:
n += 1
if n == count:
return lines[:i], lines[i:]
raise KeyError(marker)
def dark_print(ctx):
"""The dark print as exthost.armed-freeze runs it: the cloud suite's
dark_print_body between the offline service's setup and cleanup."""
offset = cloud.enter_offline(ctx)
job = cloud.offline_job(ctx, "dark.puls", seconds=30)
off = cloud.Offline().__enter__()
try:
cloud.dark_print_body(ctx, ctx.evidence, off, job, offset, ctx.forgectrl)
finally:
off.__exit__(None, None, None)
cloud.offline_cleanup(ctx)
class Script:
"""Answers every prompt with its first option; a hook per prompt or
notice substring drives the fake machine and the log replay (a
machine action is a notice the test watches the machine for, so the
hook is what makes the machine show it)."""
def __init__(self, run, hooks=None):
self.run = run
self.hooks = hooks or {}
self.asked = []
self.noticed = []
self.th = threading.Thread(target=self._loop, daemon=True)
self.stop = False
def start(self):
self.th.start()
return self
def _fire(self, text):
for key, fn in self.hooks.items():
if key in text:
fn()
def _loop(self):
seen = None
seen_n = None
while not self.stop:
n = self.run.notice
if n and n["id"] != seen_n:
seen_n = n["id"]
self.noticed.append(n["text"])
self._fire(n["text"])
p = self.run.prompt
if p and p["id"] != seen:
seen = p["id"]
self.asked.append(p["question"])
self._fire(p["question"])
self.run.answer(p["id"], p["options"][0])
time.sleep(0.02)
OFFLINE_LINE = ("2026-08-22T23:00:00.100000+00:00 gfcloud[3100] INFO offline:open OFFLINE service: "
"no web session; listening on /run/gfcloud-offline.sock")
class FakeOffline:
"""The offline service's socket, as the tests see it: what was sent,
and a hook that lands the machine's lines for each message."""
on_send = None
sent = []
def __init__(self, path=None):
self.events = []
self.buf = b""
def __enter__(self):
return self
def __exit__(self, *a):
return False
def send(self, msg):
FakeOffline.sent.append(msg)
if FakeOffline.on_send:
FakeOffline.on_send(msg)
def poll(self):
return []
def print_ready(self, action_id, path, settings=None):
self.send({"id": action_id, "action_type": "print", "status": "ready",
"motion_url": "file://" + path, "settings": settings or {}})
def cancel(self, action_id, action_type="print"):
self.send({"id": action_id, "action_type": action_type, "status": "cancelled"})
class CloudSuiteTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp(prefix="forgetest-cloud-")
self.log = os.path.join(self.tmp, "gfcloud.log")
open(self.log, "wb").close()
self.sysfs = os.path.join(self.tmp, "sysfs") + os.sep
os.makedirs(self.sysfs + "cnc")
self._attr("cnc/interlock_circuit", "45") # latch locked (bit 3)
self._pos(0, 0, 3)
os.environ["GF_SYSFS_ROOT"] = self.sysfs
# the button LEDs, dark (the tests check a cancel leaves them so)
for n in ("button_led_1", "button_led_2", "button_led_3"):
os.makedirs(os.path.join(self.tmp, "leds", n), exist_ok=True)
with open(os.path.join(self.tmp, "leds", n, "brightness"), "w") as f:
f.write("0")
os.environ["GF_LEDS_ROOT"] = os.path.join(self.tmp, "leds") + os.sep
self.fc = helpers.FakeForgectrl().start()
self.grbl = None
self.fclog = os.path.join(self.tmp, "forgectrl.log")
open(self.fclog, "wb").close()
self.homelog = os.path.join(self.tmp, "gfhome.log")
open(self.homelog, "wb").close()
self.saved = (cloud.GFCLOUD_LOG, cloud.FORGECTRL_LOG, cloud.QUIET_S, cloud.QUIET_TIMEOUT_S,
cloud.HUNT_TIMEOUT_S, cloud.GFHOME_LOG, cloud.Offline, cloud.OFFLINE_MARKER,
cloud.JOB_DIR)
cloud.GFCLOUD_LOG = self.log
cloud.FORGECTRL_LOG = self.fclog
cloud.GFHOME_LOG = self.homelog
cloud.Offline = FakeOffline
cloud.OFFLINE_MARKER = os.path.join(self.tmp, "offline-marker")
cloud.JOB_DIR = os.path.join(self.tmp, "jobs")
self.saved_emu = (cloud.EMULATE_MARKER, cloud.EMULATOR_WORK, cloud.NOHUNT_MARKER)
cloud.EMULATE_MARKER = os.path.join(self.tmp, "emulate-marker")
cloud.EMULATOR_WORK = os.path.join(self.tmp, "emu-work")
cloud.NOHUNT_MARKER = os.path.join(self.tmp, "nohunt-marker")
FakeOffline.sent = []
FakeOffline.on_send = None
cloud.QUIET_S = 0.4
# A deadline, not a wait: a quiet machine is seen at once. The
# replays land their lines from threads, so under a loaded host a
# tighter deadline expires before the last of them is written.
cloud.QUIET_TIMEOUT_S = 15
cloud.HUNT_TIMEOUT_S = 8
self.script = None
def tearDown(self):
if self.script:
self.script.stop = True
self.fc.stop()
if self.grbl:
self.grbl.stop()
(cloud.GFCLOUD_LOG, cloud.FORGECTRL_LOG, cloud.QUIET_S, cloud.QUIET_TIMEOUT_S,
cloud.HUNT_TIMEOUT_S, cloud.GFHOME_LOG, cloud.Offline, cloud.OFFLINE_MARKER,
cloud.JOB_DIR) = self.saved
(cloud.EMULATE_MARKER, cloud.EMULATOR_WORK, cloud.NOHUNT_MARKER) = self.saved_emu
os.environ.pop("GF_SYSFS_ROOT", None)
os.environ.pop("GF_LEDS_ROOT", None)
shutil.rmtree(self.tmp, ignore_errors=True)
# -- fakes -----------------------------------------------------------
def _attr(self, attr, val):
with open(self.sysfs + attr, "w") as f:
f.write(val)
def _pos(self, x, y, z):
with open(self.sysfs + "cnc/position", "wb") as f:
f.write(struct.pack("<3i2I", x, y, z, 0, 0))
def append(self, lines, delay=0.0):
def go():
if delay:
time.sleep(delay)
with open(self.log, "ab") as f:
f.write(("\n".join(lines) + "\n").encode())
if delay:
threading.Thread(target=go, daemon=True).start()
else:
go()
def in_cloud(self, pid=2278):
self.fc.state["mode"] = {"mode": "cloud", "controller": "running", "pid": pid, "motion": "verified"}
self.fc.state["settings"]["controller_mode"] = "cloud"
def lid(self, closed):
self.fc.state["status"]["switches"]["lid"] = bool(closed)
def hunted(self, pid):
"""The running client homed the machine under the service (a test
that prints reuses only such a session)."""
self.append(["2026-08-17T09:41:20.000000+00:00 gfcloud[%d] INFO basemachine:_finish_action hunt "
"[1578200001]: finished with event \":completed\"" % pid])
def in_offline(self, pid=3100):
"""Cloud mode with the offline service already up (its mark is the
newest websocket-state line for the pid)."""
self.in_cloud(pid=pid)
self.append([OFFLINE_LINE.replace("gfcloud[3100]", "gfcloud[%d]" % pid)])
def offline_print_hooks(self, pre, cancel_tail=None):
"""The fake socket's reactions: a print lands its prologue through
the run (the operator's press is already in it), a cancel lands the
app-cancel tail."""
def on_send(msg):
if msg["action_type"] == "print" and msg["status"] == "ready":
self.append(pre, delay=0.1)
elif msg["status"] == "cancelled" and cancel_tail:
self.append(cancel_tail, delay=0.05)
FakeOffline.on_send = on_send
def run_test(self, fn, hooks=None, test_id="cloud.x"):
run = Run("test", test_id, test_id)
run.baseline_captured = {"mode": self.fc.state["mode"]["mode"], "position": [0, 0, 0]}
ctx = Context(run, None, helpers.make_test(test_id, []))
self.script = Script(run, hooks).start()
try:
fn(ctx)
finally:
self.script.stop = True
return run
def assertFails(self, fn, needle, hooks=None):
with self.assertRaises(Failed) as cm:
self.run_test(fn, hooks)
self.assertIn(needle, str(cm.exception))
return cm.exception
# -- pause / resume ---------------------------------------------------------
def test_pause_resume_fails_when_the_kernel_refuses_the_resume(self):
self.in_cloud(pid=1522)
self.hunted(1522)
self.append(["2026-08-17T09:41:00.500000+00:00 gfcloud[1522] INFO websocket:_on_open RX-EVENT: ready"])
lines = [l.replace("machine:_resume_retraced resuming (laser lead 1950 ticks)",
"machine:_resume_retraced resume refused ([Errno 22] Invalid argument); canceling")
for l in fixture("pause")]
pre, rest = cut(lines, "current state: MachineState.RUNNING")
pre, rest = pre + [rest[0]], rest[1:]
with self.fast_wait_log():
hooks = {"Click Done here": lambda: self.append(pre, delay=0.1),
"the press pauses the print": lambda: self.append(rest, delay=0.05)}
self.assertFails(cloud.pause_resume, "the resume was refused", hooks=hooks)
@contextlib.contextmanager
def fast_wait_log(self):
"""The wait for the pause lines is 90 s: a replay whose line never
comes would sit it out. Cap it through the module's wait."""
saved = cloud.wait_log
def fast(ctx, offset, needles, timeout, poll=0.5):
return saved(ctx, offset, needles, min(timeout, 1.5), poll=0.1)
cloud.wait_log = fast
press = cloud.PRESS_TIMEOUT_S
cloud.PRESS_TIMEOUT_S = 1.5
try:
yield
finally:
cloud.wait_log = saved
cloud.PRESS_TIMEOUT_S = press
# -- the merged lid + interlock abort test -------------------------------
def abort_parts(self):
"""The lid-abort excerpt split for the merged test: the print
prologue (replayed for both prints), the lid stop + park + cancel,
and the same tail with the interlock as the trigger, cut where the
test stops to have the lid opened during the park."""
lines = fixture("lidabort")
pre, rest = cut(lines, "waiting for button")
run_pre, rest = cut(rest, "machine:_run_loop starting run")
pre, rest = pre + run_pre + [rest[0]], rest[1:]
ilk = [l.replace("lid opened mid-run; stopping motion",
"interlock opened mid-run; stopping motion") for l in rest]
stop, tail = cut(ilk, "start return home")
return pre, rest, stop + [tail[0]], tail[1:]
def abort_hooks(self, pre, lid_tail, ilk_stop, ilk_park, prints):
def on_send(msg):
if msg["status"] == "ready":
prints.append(msg["id"])
self.append(pre, delay=0.1)
FakeOffline.on_send = on_send
def pull_interlock():
self.fc.state["status"]["switches"]["interlock_ok"] = False
self.append(ilk_stop, delay=0.05)
def restore():
self.lid(True)
self.fc.state["status"]["switches"]["interlock_ok"] = True
return {"leave the lid open until the head has returned": lambda: (self.lid(False),
self.append(lid_tail, delay=0.05)),
"Close the lid.": lambda: self.lid(True),
"Open the remote-interlock loop": pull_interlock,
"on its way back": lambda: (self.lid(False), self.append(ilk_park, delay=0.05)),
"Restore the remote-interlock loop": restore}
def test_lid_interlock_abort_fails_when_the_lid_stop_is_not_edge_driven(self):
# a polled stop (the pre-parity behavior) shows up as a long edge->stop gap
self.in_offline()
pre, lid_tail, ilk_stop, ilk_park = self.abort_parts()
lid_tail = [l.replace("2026-08-17T09:42:30.838627", "2026-08-17T09:42:31.838627")
if "lid opened mid-run; stopping motion" in l else l for l in lid_tail]
self.assertFails(cloud.lid_interlock_abort, "not edge-driven",
hooks=self.abort_hooks(pre, lid_tail, ilk_stop, ilk_park, []))
# -- the dark print exthost.armed-freeze runs: locked at the button --------
def dark_parts(self):
"""(the print prologue through the button prompt, the run and the
print's end with no pause in it)."""
lines = fixture("pause")
pre, rest = cut(lines, "waiting for button")
pre = pre + [rest[0]]
_, run = cut(rest, "machine:_run_loop starting run")
run = [l for l in run if "paus" not in l and "resum" not in l]
return pre, run
def test_dark_print_fails_when_the_latch_is_open_at_the_button(self):
self.in_offline()
pre, run = self.dark_parts()
self._attr("cnc/interlock_circuit", "37") # unlocked before the press
self.offline_print_hooks(pre)
hooks = {"press it. The print runs dark": lambda: self.append(run, delay=0.05)}
self.assertFails(dark_print, "the latch is unlocked at the button wait", hooks=hooks)
if __name__ == "__main__":
unittest.main()
-24
View File
@@ -1,24 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""cooling.aa-offset-calibrate is registered before the heater tools, so
a queue runs it first: a heater trial leaves a warm slug circulating past
the coolant sensors for minutes, and the tool's edges read it as
disagreement."""
import unittest
from forgetest import catalog
class CoolingOrderTests(unittest.TestCase):
def test_aa_offset_precedes_flow_verify(self):
reg = catalog.load_suite()
tests = catalog.all_tests(reg)
order = catalog.order_by_requires(tests, [t.id for t in tests])
self.assertLess(order.index("cooling.aa-offset-calibrate"), order.index("cooling.flow-verify"))
if __name__ == "__main__":
unittest.main()
-64
View File
@@ -1,64 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""cooling.flow-under-load reads the engine's flow verdict from what the
forgectrl log gained since the job began: only the text after the offset,
never an older verdict before it, and the whole of a log rotated under
the test."""
import os
import shutil
import tempfile
import unittest
from forgetest.suite import cooling
class FlowVerdictLogTests(unittest.TestCase):
"""cooling.flow-under-load finds the engine's verdict in what the log
gained since the job began, not in a count over a tail of fixed length:
there a new verdict line comes in at the bottom as an old one leaves at
the top, the count does not move, and a check that verified reads as
one that never judged."""
VERDICT = ("2026-09-19T21:37:46.869674+00:00 forgectrl[2796] INFO cool: coolant flow verified "
"(heater rise 11.5 C, dT 9.5 C; laser 1.6 off 13.1)\n")
OLD = ("2026-09-19T21:17:27.200840+00:00 forgectrl[965] INFO cool: coolant flow verified "
"(heater rise 9.5 C, dT 8.7 C)\n")
def setUp(self):
self.dir = tempfile.mkdtemp(prefix="flowlog.")
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
self.path = os.path.join(self.dir, "forgectrl.log")
def write(self, text, mode="w"):
with open(self.path, mode, newline="\n") as f:
f.write(text)
def test_only_the_text_after_the_offset_comes_back(self):
self.write(self.OLD + "filler\n" * 400)
off = os.path.getsize(self.path)
self.assertEqual(cooling._log_since(self.path, off), "")
self.write(self.VERDICT, mode="a")
text = cooling._log_since(self.path, off)
self.assertEqual(text, self.VERDICT)
m = cooling._load_verdict_rx().search(text)
self.assertEqual(m.group(1), "coolant flow verified")
self.assertEqual((m.group(2), m.group(5)), ("11.5", "1.6"))
def test_an_old_verdict_before_the_offset_is_not_the_new_one(self):
self.write(self.OLD)
off = os.path.getsize(self.path)
self.write("2026-09-19T21:36:40+00:00 forgectrl[2796] INFO cool: crash watch armed\n", mode="a")
self.assertIsNone(cooling._load_verdict_rx().search(cooling._log_since(self.path, off)))
def test_a_log_rotated_under_the_test_is_read_whole(self):
self.write(self.OLD + "filler\n" * 400)
off = os.path.getsize(self.path)
self.write(self.VERDICT) # the rotation: a new, shorter file
self.assertEqual(cooling._log_since(self.path, off), self.VERDICT)
if __name__ == "__main__":
unittest.main()
-716
View File
@@ -1,716 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The bench actuator as the tool sees it.
The 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
tool speaks its API under the key, falls back to the operator when it
cannot act, moves an operator test it can perform alone into the
unattended queue (never a live one, never one that needs a person for
anything else), refuses a prompt during such a run rather than hanging on
it, releases what the box still holds after every run, and presses the
arm button only where the bench opted in.
"""
import json
import os
import shutil
import tempfile
import threading
import time
import unittest
from http.server import BaseHTTPRequestHandler, HTTPServer
import helpers
from forgetest import fixture as fx
from forgetest import runner as runner_mod
from forgetest.log import Log
from forgetest.runner import Context, Run, Runner
KEY = "0123456789abcdef0123456789abcdef"
class FakeFixture:
"""The device's API, as fixture/README.md describes it."""
def __init__(self, button_enabled=True):
self.state = {"lid": "closed", "interlock": "closed", "button": "idle"}
self.button_enabled = button_enabled
self.calls = []
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
class H(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def _json(self, code, obj):
body = json.dumps(obj).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _state(self):
return {"device": "forgefixture", "hostname": "forgefixture", "version": "1.0.0",
"idf": "v5.5.5", "uptime_s": 12, "channels": dict(srv.state),
"button_enabled": srv.button_enabled,
"button_pulsing": srv.state["button"] == "pressed",
"wifi": {"connected": True, "ip": "127.0.0.1", "rssi": -50}}
def _auth(self):
if self.headers.get("X-Fixture-Key") != KEY:
self._json(401, {"error": "X-Fixture-Key missing or wrong"})
return False
return True
def do_GET(self):
srv.calls.append(("GET", self.path))
if not self._auth():
return
self._json(200, self._state())
def do_POST(self):
n = int(self.headers.get("Content-Length") or 0)
body = json.loads(self.rfile.read(n).decode() or "{}")
srv.calls.append(("POST", self.path, body))
if not self._auth():
return
if self.path in ("/lid", "/interlock"):
st = body.get("state")
if st not in ("open", "close", "closed"):
return self._json(400, {"error": "state must be \"open\" or \"close\""})
srv.state[self.path[1:]] = "open" if st == "open" else "closed"
return self._json(200, self._state())
if self.path == "/button":
if not srv.button_enabled:
return self._json(409, {"error": "button disabled: the enable jumper is out"})
if srv.state["button"] == "pressed":
return self._json(409, {"error": "a button pulse is in progress"})
srv.state["button"] = "pressed"
srv.presses += 1
threading.Timer(0.05, lambda: srv.state.__setitem__("button", "idle")).start()
s = self._state()
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)
if self.path == "/release":
srv.state = {"lid": "closed", "interlock": "closed", "button": "idle"}
return self._json(200, self._state())
self._json(404, {"error": "no such path"})
self.httpd = HTTPServer(("127.0.0.1", 0), H)
self.port = self.httpd.server_address[1]
threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
def stop(self):
self.httpd.shutdown()
self.httpd.server_close()
def wait_replied(self, n, timeout=5.0):
deadline = time.time() + timeout
while self.replied < n and time.time() < deadline:
time.sleep(0.02)
class ConfigTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp(prefix="forgetest-fixture-")
def tearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
def write(self, obj):
p = os.path.join(self.tmp, "fixture.json")
with open(p, "w") as f:
json.dump(obj, f)
return p
def test_no_file_is_no_fixture(self):
self.assertIsNone(fx.load_config(os.path.join(self.tmp, "none.json")))
def test_defaults_and_checks(self):
cfg = fx.load_config(self.write({"key": KEY}))
self.assertEqual(cfg["hostname"], "forgefixture")
self.assertEqual(cfg["channels"], ["lid", "interlock", "button"])
self.assertFalse(cfg["arm_press"])
self.assertIsNone(cfg["ip"])
with self.assertRaises(fx.FixtureError):
fx.load_config(self.write({"hostname": "x"})) # no key
with self.assertRaises(fx.FixtureError):
fx.load_config(self.write({"key": KEY, "channels": ["lid", "laser"]}))
p = self.write({"key": KEY})
with open(p, "w") as f:
f.write("{not json")
with self.assertRaises(fx.FixtureError):
fx.load_config(p)
class ClientTests(unittest.TestCase):
def setUp(self):
self.dev = FakeFixture()
self.resolved = []
def tearDown(self):
self.dev.stop()
def resolver(self, hostname):
self.resolved.append(hostname)
return "127.0.0.1"
def client(self, timeout=3.0, resolver=None, **over):
cfg = {"hostname": "forgefixture", "key": KEY, "channels": ["lid", "interlock", "button"],
"port": self.dev.port}
cfg.update(over)
return fx.Fixture(cfg, timeout=timeout, resolver=resolver or self.resolver)
def test_status_covers_act_release(self):
f = self.client()
st = f.status()
self.assertEqual(st["device"], "forgefixture")
self.assertEqual(self.resolved, ["forgefixture"]) # resolved once
self.assertTrue(f.covers("lid") and f.covers("interlock") and f.covers("button"))
f.act("lid", "open")
self.assertEqual(self.dev.state["lid"], "open")
f.act("interlock", "open")
f.act("button", "press")
self.assertEqual(self.dev.calls[-1], ("POST", "/button", {}))
time.sleep(0.15)
# a pulse length asked for goes to the box as asked; the clamp is the box's
f.act("button", "press", ms=500)
self.assertEqual(self.dev.calls[-1], ("POST", "/button", {"ms": 500}))
time.sleep(0.15) # the fake's pulse ends
self.assertEqual(sorted(fx.Fixture.energized(f.status())), ["interlock", "lid"])
f.release()
self.assertEqual(fx.Fixture.energized(f.status()), [])
self.assertEqual(self.resolved, ["forgefixture"]) # the address is cached
with self.assertRaises(fx.FixtureError):
f.act("button", "hold")
with self.assertRaises(fx.FixtureError):
f.act("lid", "ajar")
def test_the_button_is_covered_only_with_the_jumper_in(self):
self.dev.button_enabled = False
f = self.client()
f.status()
self.assertTrue(f.covers("lid"))
self.assertFalse(f.covers("button"))
with self.assertRaises(fx.FixtureError) as cm:
f.act("button", "press")
self.assertIn("jumper", str(cm.exception))
def test_a_failure_after_the_request_went_out_says_so(self):
# The press made and its reply lost (it comes after the client's
# timeout): sent, one press. A refusal, a failed lookup and a
# refused connect: not sent, no press. The one retry keeps it.
self.dev.reply_delay = 0.6
with self.assertRaises(fx.FixtureError) as cm:
self.client(timeout=0.2, ip="127.0.0.1").act("button", "press")
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):
f = self.client(key="wrong")
with self.assertRaises(fx.FixtureError) as cm:
f.status()
self.assertIn("refused the key", str(cm.exception))
def test_an_ip_override_skips_the_lookup(self):
f = self.client(ip="127.0.0.1")
f.status()
self.assertEqual(self.resolved, [])
def test_channels_not_wired_are_not_covered(self):
f = self.client(channels=["lid"])
f.status()
self.assertTrue(f.covers("lid"))
self.assertFalse(f.covers("interlock"))
def test_probe_without_a_config_or_with_a_silent_box_is_none(self):
lines = []
self.assertIsNone(fx.probe(lines.append, path=os.path.join(tempfile.gettempdir(), "no-such-fixture.json")))
tmp = tempfile.mkdtemp(prefix="forgetest-fixture-")
try:
p = os.path.join(tmp, "fixture.json")
with open(p, "w") as f:
json.dump({"key": KEY, "ip": "127.0.0.1", "port": 1}, f)
# port 1 on localhost: nothing listens there
f2 = fx.probe(lines.append, path=p, resolver=lambda h: None)
self.assertIsNone(f2)
finally:
shutil.rmtree(tmp, ignore_errors=True)
class CatalogTests(unittest.TestCase):
def test_fixture_runnable(self):
op = helpers.make_test("m.op", [], kind="operator", actions=("lid", "button"))
self.assertTrue(op.fixture_runnable(("lid", "interlock", "button")))
self.assertFalse(op.fixture_runnable(("lid",))) # the button is not covered
self.assertFalse(helpers.make_test("m.live", [], kind="live", actions=("lid",))
.fixture_runnable(("lid",))) # live never downgrades
self.assertFalse(helpers.make_test("m.app", [], kind="operator", hands=("app",))
.fixture_runnable(("lid", "interlock", "button")))
self.assertFalse(helpers.make_test("m.none", [], kind="operator")
.fixture_runnable(("lid",))) # no actions: a person does something
self.assertFalse(helpers.make_test("m.auto", []).fixture_runnable(("lid",)))
def test_an_auto_test_asks_nothing_of_a_person(self):
from forgetest import catalog
with self.assertRaises(ValueError):
catalog.test("z.auto", title="t", subsystem="z", hands=("app",))(lambda ctx: None)
def test_every_action_a_test_takes_is_declared(self):
# The queues route a test by its kind and its actions: an auto test
# runs unattended with or without a fixture, and an operator test
# whose actions the fixture covers runs unattended only while it
# does. A test that acts on the lid, the interlock or the button
# without saying so runs unattended with no fixture and waits for a
# person who is not there.
import inspect
import re
from forgetest import catalog
bad = []
for t in catalog.all_tests(catalog.load_suite()):
src = inspect.getsource(t.fn)
taken = set(re.findall(r'ctx\.act\(\s*"(\w+)"', src))
if taken and (t.kind == "auto" or not taken <= set(t.actions)):
bad.append("%s (%s) acts on %s, declares %s" % (t.id, t.kind, sorted(taken), list(t.actions)))
self.assertEqual(bad, [])
class StubFixture:
"""What the runner needs of a fixture, scripted."""
def __init__(self, channels=("lid", "interlock", "button"), button_enabled=True, arm_press=False,
fail=False, fc=None):
self.fc = fc # the fake forgectrl whose switches follow the actions
self.hostname = "forgefixture"
self._ip = "127.0.0.1"
self.channels = tuple(channels)
self.button_enabled = button_enabled
self.arm_press = arm_press
self.fail = fail
self.acts = []
self.held = []
self.released = 0
def covers(self, channel):
return channel in self.channels and (channel != "button" or self.button_enabled)
def act(self, channel, state, ms=None):
if self.fail:
raise fx.FixtureError("the box is off")
self.acts.append((channel, state) if ms is None else (channel, state, ms))
if state == "open":
self.held.append(channel)
if self.fc is not None and channel in ("lid", "interlock"):
sw = self.fc.state["status"]["switches"]
sw["lid" if channel == "lid" else "interlock_ok"] = state != "open"
def status(self):
return {"channels": {c: ("open" if c in self.held else "closed") for c in ("lid", "interlock")}}
@staticmethod
def energized(state):
return fx.Fixture.energized(state)
def release(self):
self.released += 1
self.held = []
def summary(self):
return {"hostname": self.hostname, "ip": self._ip, "channels": list(self.channels),
"button_enabled": self.button_enabled, "arm_press": self.arm_press}
def t_lid(ctx):
ctx.ready("On Ready the lid opens")
ctx.act("lid", "open")
ctx.act("lid", "close")
def t_asks(ctx):
ctx.act("lid", "open")
ctx.confirm("Did it?")
class RoutingTests(unittest.TestCase):
"""The queues with a fixture up: the runner's probe is scripted."""
def setUp(self):
self.tmp = tempfile.mkdtemp(prefix="forgetest-fixture-run-")
os.environ["FORGETEST_DATA"] = self.tmp
os.environ["FORGETEST_MARKER"] = os.path.join(self.tmp, "marker")
self.fc = helpers.FakeForgectrl().start()
self.man = helpers.make_manifest()
self.reg = helpers.registry(
helpers.make_test("r.auto", [("forgectrl", "src/ui.c")]),
helpers.make_test("r.lid", [("forgectrl", "src/auth.c")], kind="operator", actions=("lid",), fn=t_lid),
helpers.make_test("r.btn", [("forgectrl", "src/cool.c")], kind="operator", actions=("button",), fn=t_lid),
helpers.make_test("r.app", [("forgectrl", "src/main.c")], kind="operator", actions=("lid",),
hands=("app",), fn=t_lid),
helpers.make_test("r.live", [("grblhal-glowforge", "src/**")], kind="live", actions=("lid",)),
helpers.make_test("r.asks", [("linux-fslc", "**")], kind="operator", actions=("lid",), fn=t_asks),
)
self.log = Log(os.path.join(self.tmp, "results.jsonl"))
self.runner = Runner(self.log, self.man, self.reg)
self.saved_probe = fx.probe
self.stub = StubFixture(fc=self.fc)
fx.probe = lambda log, path=None, resolver=None: self.stub
def tearDown(self):
fx.probe = self.saved_probe
self.fc.stop()
for k in ("FORGETEST_DATA", "FORGETEST_MARKER"):
os.environ.pop(k, None)
shutil.rmtree(self.tmp, ignore_errors=True)
def selection(self):
st, _ = self.runner.state()
return st["batch_available"], st["fixture"]
def test_operator_tests_the_fixture_covers_move_to_the_unattended_queue(self):
av, summary = self.selection()
self.assertEqual(av["unattended"], ["r.auto", "r.lid", "r.btn", "r.asks"])
self.assertEqual(av["attended"], ["r.app", "r.live"]) # needs the app; live never moves
self.assertEqual(summary["hostname"], "forgefixture")
def run_queue(self, group, ack_live=False):
ok, msg, order = self.runner.start_batch(group, ack_live=ack_live)
self.assertTrue(ok, msg)
deadline = time.time() + 30
while not self.runner.batch["finished"] and time.time() < deadline:
time.sleep(0.05)
return self.runner.batch_snapshot(), order
def last_result(self, tid):
for rec in reversed(self.log.read()):
if rec.get("t") == "result" and rec["test"] == tid:
return rec
return None
def test_the_fixture_performs_the_actions_and_the_ready_gate_passes(self):
# r.asks confirms after its action: a person is asked -> a FAIL that
# says so, and the queue stops there, after r.lid and r.btn passed
b, order = self.run_queue("unattended")
self.assertEqual(order, ["r.auto", "r.lid", "r.btn", "r.asks"])
results = {x["test"]: x["result"] for x in b["done"]}
self.assertEqual(results["r.lid"], "PASS")
self.assertEqual(results["r.btn"], "PASS")
self.assertEqual(results["r.asks"], "FAIL")
rec = self.last_result("r.lid")
acts = rec["evidence"]["actions"]
self.assertEqual([(a["channel"], a["state"], a["by"]) for a in acts],
[("lid", "open", "fixture"), ("lid", "close", "fixture")])
self.assertTrue(any("READY (fixture performs the step)" in l for l in rec["log"]), rec["log"])
self.assertEqual(rec["evidence"]["fixture"]["channels"], ["lid", "interlock", "button"])
asks = self.last_result("r.asks")
self.assertIn("declare the step in hands=", asks["message"])
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
# hand that is not there; it ends at once, as the harness's error
self.stub.fail = True
t0 = time.time()
b, order = self.run_queue("unattended")
results = {x["test"]: x["result"] for x in b["done"]}
self.assertEqual(results["r.lid"], "ERROR")
self.assertLess(time.time() - t0, 20)
rec = self.last_result("r.lid")
self.assertIn("the fixture could not perform a step", rec["message"])
self.assertIn("the box is off", rec["message"])
act = rec["evidence"]["actions"][0]
self.assertEqual((act["by"], act["fixture_error"]), ("fixture", "the box is off"))
self.assertFalse(any("asking the operator" in l for l in rec["log"]), rec["log"])
def test_what_the_box_still_holds_is_released_after_a_run(self):
def holds(ctx):
ctx.act("lid", "open") # and never closes it
self.reg["r.hold"] = helpers.make_test("r.hold", [("forgectrl", "src/ui.c")], kind="operator",
actions=("lid",), fn=holds)
self.runner.registry = self.reg
ok, msg, run = self.runner._start_test("r.hold")
self.assertTrue(ok, msg)
while run.finished is None:
time.sleep(0.05)
self.assertEqual(self.stub.released, 1)
rec = self.last_result("r.hold")
self.assertEqual(rec["evidence"]["fixture"]["released"], ["lid"])
def press_button(self, down):
self.fc.state["status"]["switches"]["button"] = bool(down)
def test_ready_takes_a_press_on_the_machine_as_the_presence_check(self):
"""With an actuator wired to the button, the operator proves they
are at the machine by pressing it, not by clicking the page. The
actuator then owns every press in the test, so no press in a live
cut is a person's and none can go uncounted."""
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("LIVE FIRE. Scrap under the head.")
self.assertEqual(asked, []) # no page click asked for
self.assertTrue(run.fixture_takeover)
rec = [r for r in run.evidence["actions"] if r["state"] == "presence"]
self.assertEqual(len(rec), 1)
self.assertEqual(rec[0]["by"], "operator")
self.assertTrue(any("press the button on the machine" in ln.lower() for ln in run.lines))
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
proved presence: that press is what the opt-in existed to
establish."""
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: True
try:
self.assertTrue(ctx.arm_press())
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)
finally:
runner_mod.hw.button_lit = saved
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
actuator becomes a press nobody can account for afterwards."""
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
self.runner.fixture = None # the box drops off mid-test
self.fc.state["status"]["switches"]["lid"] = True
threading.Timer(0.3, lambda: self.fc.state["status"]["switches"].__setitem__("lid", False)).start()
ctx.act("lid", "open", timeout=5)
rec = run.evidence["actions"][-1]
self.assertTrue(rec.get("fixture_lost"))
self.assertEqual(rec["by"], "operator")
def test_the_arm_press_is_the_operators_unless_the_bench_opted_in(self):
run = Run("test", "r.live", "r.live")
ctx = Context(run, self.runner, self.reg["r.live"])
self.runner.probe_fixture(force=True)
seen = []
run.set_notice = lambda text: seen.append(text)
self.assertFalse(ctx.arm_press())
self.assertEqual(run.evidence["actions"][0]["by"], "operator")
self.assertTrue(seen)
# opted in: the press waits for the button to light
self.stub.arm_press = True
lit = {"v": False}
saved = runner_mod.hw.button_lit
runner_mod.hw.button_lit = lambda: lit["v"]
try:
seen[:] = []
self.assertTrue(ctx.arm_press())
time.sleep(0.3)
self.assertEqual(self.stub.acts, []) # not yet: dark button
lit["v"] = True
deadline = time.time() + 5
while not self.stub.acts and time.time() < deadline:
time.sleep(0.05)
self.assertEqual(self.stub.acts, [("button", "press")])
self.assertEqual(run.evidence["actions"][1]["by"], "fixture")
self.assertEqual(seen, []) # no notice went up
finally:
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__":
unittest.main()
-453
View File
@@ -1,453 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
import json
import os
import shutil
import subprocess
import tarfile
import tempfile
import unittest
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")
# A layer script run as the image runs it, in private mount, UTS and
# network namespaces: /run a fresh tmpfs, the scratch tree's net directory
# over /sys/class/net, and /etc read-only with the scratch tree's files
# 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 run_layer_script(script, macs, etc, net=False):
"""Run a layer script over a scratch tree: macs {interface: address or
None for a directory with no address file}, etc {file: text}. Returns
the namespace's output, or None when no namespace can be made here
(unprivileged where user namespaces are open, else root via sudo -n)."""
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):
STANZA = ("auto wlan0\n"
"iface wlan0 inet dhcp\n"
"\twpa-driver nl80211\n"
"\tudhcpc_opts -x hostname:$(hostname)\n")
def test_ifstate_names_the_interfaces_ifup_finished(self):
self.assertEqual(image.ifstate_names("lo=lo\nwlan0=wlan0\n"), {"lo", "wlan0"})
self.assertEqual(image.ifstate_names("lo=lo\n"), {"lo"})
self.assertEqual(image.ifstate_names(""), set())
self.assertEqual(image.ifstate_names(None), set())
def test_an_interfaces_file_without_a_dhcpv6_client_is_clean(self):
self.assertEqual(image.dhcp6_in_interfaces(self.STANZA), [])
self.assertEqual(image.dhcp6_in_interfaces(None), [])
def test_a_command_that_starts_a_dhcpv6_client_is_named(self):
text = self.STANZA + ("iface wlan0 inet6 manual\n"
"\tup udhcpc6 -i wlan0 -b -S -p /run/udhcpc6.wlan0.pid\n")
self.assertEqual(image.dhcp6_in_interfaces(text),
["up udhcpc6 -i wlan0 -b -S -p /run/udhcpc6.wlan0.pid"])
detached = "\tup start-stop-daemon -S -b -x /usr/bin/udhcpc6 -- -f -i wlan0\n"
self.assertEqual(len(image.dhcp6_in_interfaces(self.STANZA + detached)), 1)
def test_an_inet6_method_that_runs_a_client_is_named(self):
self.assertEqual(image.dhcp6_in_interfaces("iface wlan0 inet6 dhcp\n"),
["iface wlan0 inet6 dhcp"])
self.assertEqual(image.dhcp6_in_interfaces("iface wlan0 inet6 static\n"), [])
self.assertEqual(image.dhcp6_in_interfaces("iface wlan0 inet dhcp\n"), [])
def test_a_comment_starts_nothing(self):
text = self.STANZA + "# No DHCPv6 client runs; udhcpc6 is not on the image.\n"
self.assertEqual(image.dhcp6_in_interfaces(text), [])
def test_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):
self.assertEqual(image.parse_stat("371 (udhcpc) S 1 371 371 0 -1 4194624"),
(371, "udhcpc", 1))
self.assertEqual(image.parse_stat("9 (a (b) c) S 7 9 9 0"), (9, "a (b) c", 7))
self.assertIsNone(image.parse_stat(""))
self.assertIsNone(image.parse_stat("12 (x) S"))
def test_link_addrs_sorts_one_interface_by_scope(self):
text = ("fe800000000000002e6b7dfffe0db00a 02 40 20 80 wlan0\n"
"26001700000000000000000000001234 02 80 00 00 wlan0\n"
"00000000000000000000000000000001 01 80 10 80 lo\n")
local, world = image.link_addrs(text, "wlan0")
self.assertEqual(local, ["fe800000000000002e6b7dfffe0db00a"])
self.assertEqual(world, ["26001700000000000000000000001234"])
self.assertEqual(image.link_addrs(text, "eth0"), ([], []))
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__":
unittest.main()
-160
View File
@@ -1,160 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""scripts/install-forgefirm.sh - the download that retries, and the log.
The installer runs once per machine, on factory firmware, over whatever
network the owner has. These tests run its own functions under sh with a
scripted curl in front of them: a transfer that fails resumes and is
tried again, the failures that waiting cannot fix end the tries at once,
the reason an install stopped is in the install log, and a log that cannot
be written never fails the install."""
import os
import shutil
import stat
import subprocess
import tempfile
import unittest
import helpers # noqa: F401 (sys.path)
REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
SCRIPT = os.path.join(REPO, "scripts", "install-forgefirm.sh")
MARK = "# The install log, before anything can fail."
# A curl that plays a script: each call takes the next "rc http bytes"
# line of $CURL_PLAN, appends that many bytes to the --output file the way
# a resumed transfer does, prints the HTTP code as -w '%{http_code}'
# would, records its arguments, and exits rc.
FAKE_CURL = r"""#!/bin/sh
echo "$*" >> "$CURL_CALLS"
OUT=""
while [ $# -gt 0 ]; do
[ "$1" = "--output" ] && OUT="$2"
shift
done
LINE=$(sed -n 1p "$CURL_PLAN")
sed -i 1d "$CURL_PLAN"
set -- $LINE
[ "${3:-0}" -gt 0 ] && dd if=/dev/zero bs=1 count="$3" 2>/dev/null | tr '\0' 'x' >> "$OUT"
printf '%s' "$2"
exit "$1"
"""
def functions():
"""The installer up to its first action: constants and functions."""
with open(SCRIPT, encoding="utf-8") as f:
text = f.read()
head, mark, _ = text.partition(MARK)
assert mark, "the installer lost the line the tests cut it at"
return head
@unittest.skipUnless(shutil.which("sh") and os.name == "posix", "needs a POSIX sh")
class InstallerTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp(prefix="ffinstall-test.")
self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True)
self.bin = os.path.join(self.tmp, "bin")
os.mkdir(self.bin)
for name, body in (("curl", FAKE_CURL), ("nslookup", "#!/bin/sh\nexit 1\n"),
("ip", "#!/bin/sh\nexit 0\n"), ("sleep", "#!/bin/sh\nexit 0\n")):
path = os.path.join(self.bin, name)
with open(path, "w", newline="\n") as f:
f.write(body)
os.chmod(path, os.stat(path).st_mode | stat.S_IXUSR)
self.fw = os.path.join(self.tmp, "forgefirm.fw")
self.log = os.path.join(self.tmp, "log", "install.log")
self.calls = os.path.join(self.tmp, "curl.calls")
self.plan = os.path.join(self.tmp, "curl.plan")
def run_sh(self, body, plan=()):
with open(self.plan, "w", newline="\n") as f:
f.write("".join("%s\n" % line for line in plan))
open(self.calls, "w").close()
script = "%s\nFW_FILE='%s'\nLOG_DIR='%s'\nLOG_FILE='%s'\nmkdir -p \"$LOG_DIR\"\nLOG_OK=yes\n%s\n" % (
functions(), self.fw, os.path.dirname(self.log), self.log, body)
env = dict(os.environ, PATH=self.bin + os.pathsep + os.environ["PATH"],
CURL_PLAN=self.plan, CURL_CALLS=self.calls)
return subprocess.run(["sh", "-c", script], env=env, capture_output=True, text=True, timeout=60)
def download(self, plan):
r = self.run_sh('download_fw; echo "rc=$? why=$DL_WHY"', plan)
with open(self.calls) as f:
calls = f.read().splitlines()
return r.stdout, calls, self.log_lines()
def log_lines(self):
try:
with open(self.log) as f:
return f.read().splitlines()
except OSError:
return []
def test_a_clean_download_takes_one_try_and_its_name(self):
out, calls, log = self.download(["0 200 5000"])
self.assertIn("rc=0", out)
self.assertEqual(len(calls), 1)
self.assertEqual(os.path.getsize(self.fw), 5000)
self.assertFalse(os.path.exists(self.fw + ".part"))
self.assertTrue(any("complete on try 1, 5000 bytes" in line for line in log), log)
def test_a_failed_transfer_resumes_and_is_tried_again(self):
out, calls, log = self.download(["6 000 0", "56 200 3000", "0 206 2000"])
self.assertIn("rc=0", out)
self.assertEqual(len(calls), 3)
for c in calls: # every try resumes, bounded in time
self.assertIn("-C -", c)
self.assertIn("--connect-timeout", c)
self.assertIn("--speed-time", c)
self.assertEqual(os.path.getsize(self.fw), 5000) # 3000 kept, 2000 resumed
warns = [line for line in log if " WARNING download: try " in line]
self.assertEqual(len(warns), 2, log)
self.assertIn("did not resolve", warns[0])
self.assertIn("dropped mid-transfer (3000 bytes so far)", warns[1])
self.assertIn("Trying again in 5s (try 2 of 5)", out)
self.assertIn("Trying again in 15s (try 3 of 5)", out)
def test_the_tries_run_out(self):
out, calls, log = self.download(["28 000 0"] * 5)
self.assertIn("rc=1", out)
self.assertIn("timed out or stalled", out)
self.assertEqual(len(calls), 5)
self.assertFalse(os.path.exists(self.fw))
self.assertFalse(os.path.exists(self.fw + ".part"))
# each failed try leaves what the network looked like
self.assertEqual(sum("github.com does not resolve" in line for line in log), 5, log)
def test_a_missing_release_and_a_full_disk_end_the_tries_at_once(self):
out, calls, _ = self.download(["22 404 0"])
self.assertIn("rc=1", out)
self.assertIn("HTTP 404", out)
self.assertEqual(len(calls), 1)
out, calls, _ = self.download(["23 200 100"])
self.assertIn("rc=1", out)
self.assertIn("could not be written", out)
self.assertEqual(len(calls), 1)
def test_a_partial_file_the_server_will_not_resume_starts_over(self):
out, calls, _ = self.download(["56 200 4000", "22 416 0", "0 200 5000"])
self.assertIn("rc=0", out)
self.assertEqual(len(calls), 3)
self.assertEqual(os.path.getsize(self.fw), 5000) # not 9000: the stale part went
def test_die_leaves_the_reason_in_the_log(self):
r = self.run_sh('die "archiving slot 1 failed"; echo not-reached')
self.assertEqual(r.returncode, 1)
self.assertNotIn("not-reached", r.stdout)
self.assertTrue(any("archiving slot 1 failed" in line for line in self.log_lines()))
def test_no_writable_log_never_fails_the_install(self):
r = self.run_sh('LOG_OK=""; log INFO "x"; LOG_OK=yes; LOG_FILE=/nonexistent/dir/x.log; '
'log INFO "y"; echo "still-running rc=$?"')
self.assertIn("still-running rc=0", r.stdout)
if __name__ == "__main__":
unittest.main()
-72
View File
@@ -1,72 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""wait_hv_off against a scripted charge-pump readback: a chain that
releases inside the window passes, a chain held past it is the refusal,
and a chain already off costs no wait."""
import time
import unittest
from forgetest.suite import kernel
class Ctx:
def __init__(self):
self.evidence = {}
self.lines = []
def sleep(self, s):
time.sleep(s)
def log(self, fmt, *a):
self.lines.append(fmt % a)
def scripted(alive_for_s):
t0 = time.time()
def rd(path):
if path == "cnc/charge_pump_alive":
return "1" if time.time() - t0 < alive_for_s else "0"
if path == "cnc/state":
return "idle"
return None
return rd
class WaitHvOffTests(unittest.TestCase):
def setUp(self):
self._rd = kernel.rd
def tearDown(self):
kernel.rd = self._rd
def test_release_inside_the_window_passes(self):
kernel.rd = scripted(0.3)
ctx = Ctx()
t0 = time.time()
self.assertIsNone(kernel.wait_hv_off(ctx, timeout_s=2.0))
self.assertGreaterEqual(time.time() - t0, 0.25)
self.assertTrue(any("released" in l for l in ctx.lines))
self.assertGreaterEqual(ctx.evidence["hv_release_s"][0], 0.25)
def test_chain_held_past_the_window_is_the_refusal(self):
kernel.rd = scripted(99)
ctx = Ctx()
why = kernel.wait_hv_off(ctx, timeout_s=0.3)
self.assertIn("charge_pump_alive=1", why)
self.assertTrue(any("still held" in l for l in ctx.lines))
def test_chain_already_off_costs_no_wait(self):
kernel.rd = scripted(0)
ctx = Ctx()
t0 = time.time()
self.assertIsNone(kernel.wait_hv_off(ctx, timeout_s=2.0))
self.assertLess(time.time() - t0, 0.1)
self.assertEqual(ctx.lines, [])
if __name__ == "__main__":
unittest.main()
-209
View File
@@ -1,209 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The dwell-gap verdict of laser.emission-witness over synthetic trails:
the button latch judged from the first emission through every sample
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
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
from forgetest.suite import laser
def smp(emission, latch, locked=False, hv_enable=True, armed=True):
il = (laser.IL_BUTTON_LATCH if latch else 0) | (laser.IL_LASER_LATCH if locked else 0)
return {"emission": emission, "armed": armed, "il": il, "hv_enable": hv_enable}
class DwellGapTests(unittest.TestCase):
def test_the_relocked_tail_is_not_judged(self):
# the arm wait (unlocked, latch set until the press), fire, the
# gap, fire, then the relock sets the latch while the counter
# still reads nonzero
trail = [smp(0, 1), smp(0, 0), smp(10, 0), smp(20, 0, hv_enable=False),
smp(20, 0, hv_enable=False), smp(30, 0), smp(40, 0),
smp(40, 1, locked=True, armed=False), smp(40, 1, locked=True, armed=False),
smp(0, 1, locked=True, armed=False)]
g = laser.dwell_gap(trail)
self.assertEqual(g["button_latch_unlocked_max"], 0)
self.assertEqual(g["button_latch_unlocked_samples"], 5)
self.assertEqual(g["button_latch_set_at"], [])
self.assertTrue(g["hv_enable_dipped"])
self.assertTrue(g["hv_enable_back_lit"])
def test_latch_set_while_unlocked_is_the_defect(self):
trail = [smp(0, 0), smp(10, 0), smp(20, 1, hv_enable=False), smp(30, 0), smp(0, 1, locked=True)]
g = laser.dwell_gap(trail)
self.assertEqual(g["button_latch_unlocked_max"], 1)
self.assertEqual(g["button_latch_set_at"], [1])
def test_no_emission_judges_nothing(self):
g = laser.dwell_gap([smp(0, 0), smp(0, 1, locked=True)])
self.assertIsNone(g["button_latch_unlocked_max"])
self.assertEqual(g["button_latch_unlocked_samples"], 0)
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__":
unittest.main()
-867
View File
@@ -1,867 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
import os
import sys
import unittest
import helpers
from forgetest import manifest as m
from forgetest import catalog
class GlobTests(unittest.TestCase):
def test_star_and_doublestar(self):
rx = m.glob_to_regex("src/*.c")
self.assertTrue(rx.match("src/main.c"))
self.assertFalse(rx.match("src/sub/main.c"))
rx = m.glob_to_regex("src/**")
self.assertTrue(rx.match("src/main.c"))
self.assertTrue(rx.match("src/sub/deep/x.h"))
self.assertFalse(rx.match("docs/x"))
rx = m.glob_to_regex("**/*.md")
self.assertTrue(rx.match("README.md"))
self.assertTrue(rx.match("docs/a/b.md"))
self.assertFalse(rx.match("docs/a/b.txt"))
rx = m.glob_to_regex("**")
self.assertTrue(rx.match("anything/at/all"))
rx = m.glob_to_regex("src/gfcool*")
self.assertTrue(rx.match("src/gfcool_client.c"))
self.assertFalse(rx.match("src/x/gfcool.c"))
def test_question_mark_and_escaping(self):
rx = m.glob_to_regex("a?c.d")
self.assertTrue(rx.match("abc.d"))
self.assertFalse(rx.match("abcxd"))
self.assertFalse(rx.match("a/c.d"))
class FingerprintTests(unittest.TestCase):
def setUp(self):
self.man = helpers.make_manifest()
def test_stable_and_order_independent(self):
c1 = [("forgectrl", "src/ui.c"), ("forgectrl", "src/auth.c")]
c2 = list(reversed(c1))
self.assertEqual(m.fingerprint(self.man, c1), m.fingerprint(self.man, c2))
self.assertEqual(m.fingerprint(self.man, c1), m.fingerprint(self.man, c1))
def test_changes_only_when_covered_file_changes(self):
covers = [("forgectrl", "src/ui.c")]
base = m.fingerprint(self.man, covers)
other = helpers.with_file(self.man, "forgectrl", "src/cool.c", "cool v2")
self.assertEqual(base, m.fingerprint(other, covers), "an uncovered change must not move the fingerprint")
changed = helpers.with_file(self.man, "forgectrl", "src/ui.c", "ui v2")
self.assertNotEqual(base, m.fingerprint(changed, covers))
added = helpers.with_file(self.man, "forgectrl", "src/ui_extra.c", "new")
self.assertEqual(base, m.fingerprint(added, covers), "an unmatched new file does not move it")
added2 = helpers.with_file(self.man, "forgectrl", "src/x.c", "new")
self.assertNotEqual(base, m.fingerprint(added2, [("forgectrl", "src/*.c")]))
def test_platform_is_always_in(self):
covers = [("forgectrl", "src/ui.c")]
base = m.fingerprint(self.man, covers)
p2 = helpers.with_platform(self.man, dtb={"glowforge.dtb": "e" * 64})
self.assertNotEqual(base, m.fingerprint(p2, covers))
p3 = helpers.with_platform(self.man, layers={"meta-forgefirm": {"content_sha256": "2" * 64},
"poky": {"rev": "p" * 40}})
self.assertNotEqual(base, m.fingerprint(p3, covers))
def test_kernel_pseudo_files(self):
covers = [("linux-fslc", "**")]
base = m.fingerprint(self.man, covers)
k2 = helpers.with_file(self.man, "linux-fslc", "@config", "cfg2")
self.assertNotEqual(base, m.fingerprint(k2, covers))
def test_missing_component_marker(self):
f1 = m.fingerprint(self.man, [("nonexistent", "**")])
f2 = m.fingerprint(self.man, [("forgectrl", "nothing-matches-*")])
self.assertNotEqual(f1, f2)
def test_dev_only_refused(self):
with self.assertRaises(ValueError):
m.fingerprint(self.man, [("forgetest", "**")])
def test_extra_folds_in(self):
covers = [("forgectrl", "src/ui.c")]
self.assertNotEqual(m.fingerprint(self.man, covers, extra=["a"]),
m.fingerprint(self.man, covers, extra=["b"]))
def test_submodule_gitlink_and_files(self):
covers = [("grblhal-glowforge", "src/grbl/**")]
base = m.fingerprint(self.man, covers)
core = helpers.with_file(self.man, "grblhal-glowforge", "src/grbl/core.c", "core v2")
self.assertNotEqual(base, m.fingerprint(core, covers))
link = [("grblhal-glowforge", "src/grbl")]
self.assertEqual(m.fingerprint(self.man, link), m.fingerprint(core, link),
"the gitlink alone does not see a file-level change (fixture keeps the link id)")
def test_identity_sha_ignores_dev_only(self):
a = self.man.identity_sha()
b = helpers.with_file(self.man, "forgetest", "forgetest/x.py", "changed").identity_sha()
self.assertEqual(a, b)
c = helpers.with_file(self.man, "forgectrl", "src/ui.c", "changed").identity_sha()
self.assertNotEqual(a, c)
class CoverageReportTests(unittest.TestCase):
def test_report(self):
man = helpers.make_manifest()
t1 = helpers.make_test("a.one", [("forgectrl", "src/ui.c"), ("forgectrl", "src/auth.c")])
t2 = helpers.make_test("a.two", [("grblhal-glowforge", "**"), ("kernel-module-glowforge", "**"),
("linux-fslc", "**")])
rep = m.coverage_report(man, [t1, t2], allow=[("*", "**/*.md")])
self.assertEqual(set(rep), {"forgectrl"})
self.assertEqual(rep["forgectrl"], ["src/cool.c", "src/main.c"])
rep2 = m.coverage_report(man, [t1, t2, helpers.make_test("a.three", [("forgectrl", "src/**")])],
allow=[("*", "**/*.md")])
self.assertEqual(rep2, {})
self.assertNotIn("forgetest", m.coverage_report(man, [], allow=[]),
"dev-only components are outside the report")
def test_behavioral_paths_are_never_allowed_away(self):
# An advisory document is behavior (the consent is to its hash): the allowlist's docs/** and
# **/*.md do not take it out of the report, as they do not take it out of a fingerprint. 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")
rest = helpers.make_test("a.one", [("forgectrl", "src/**"), ("grblhal-glowforge", "**"),
("kernel-module-glowforge", "**"), ("linux-fslc", "**")])
self.assertEqual(m.coverage_report(man, [rest], host=()), {"forgectrl": ["docs/advisories/privacy.md"]})
doc = helpers.make_test("a.two", [("forgectrl", "docs/advisories/privacy.md")])
self.assertEqual(m.coverage_report(man, [rest, doc], host=()), {})
self.assertNotEqual(m.fingerprint(man, doc.covers),
m.fingerprint(helpers.with_file(man, "forgectrl", "docs/advisories/privacy.md", "new"),
doc.covers))
def test_non_behavioral_paths_are_outside_every_fingerprint(self):
man = helpers.make_manifest()
t = helpers.make_test("a.one", [("forgectrl", "**")])
a = m.fingerprint(man, t.covers)
for comp, path in (("forgectrl", "README.md"), ("forgectrl", "docs/SERVICES.md"),
("forgectrl", "tests/test_x.py"), ("forgectrl", ".github/workflows/ci.yml")):
self.assertEqual(a, m.fingerprint(helpers.with_file(man, comp, path, "changed"), t.covers),
path)
self.assertNotEqual(a, m.fingerprint(helpers.with_file(man, "forgectrl", "src/ui.c", "changed"),
t.covers))
self.assertTrue(m.non_behavioral("forgectrl", "tools/devserver.py"))
self.assertFalse(m.non_behavioral("grblhal-glowforge", "tools/devserver.py"))
# forgeext's image content is its binary and init script: the kit, the template, the host tools
# and the shared package workflow are not, and the host's sources are
for path in ("sdk/js/ffx-bridge.js", "template/manifest.json", "tools/ffx", ".github/actions/kit/action.yml"):
self.assertTrue(m.non_behavioral("forgeext", path), path)
for path in ("src/api.c", "init/forgeext.init", "CMakeLists.txt"):
self.assertFalse(m.non_behavioral("forgeext", path), path)
# an advisory is a document forgectrl serves and records consent to by its hash: behavior, though
# it is Markdown under docs/
self.assertFalse(m.non_behavioral("forgectrl", "docs/advisories/extensions.md"))
self.assertTrue(m.non_behavioral("forgectrl", "docs/SERVICES.md"))
self.assertTrue(m.non_behavioral("grblhal-glowforge", "docs/advisories/x.md"))
def test_an_entry_that_selects_nothing_is_reported(self):
man = helpers.make_manifest()
good = helpers.make_test("a.one", [("forgectrl", "src/ui.c")])
# the recipe's subdirectory left out of the glob, and an unknown component
bad = helpers.make_test("a.two", [("forgectrl", "ui.c"), ("no-such", "**")])
self.assertEqual(m.empty_covers(man, [good]), [])
self.assertEqual(m.empty_covers(man, [good, bad]),
[("a.two", "forgectrl", "ui.c"), ("a.two", "no-such", "**")])
# a glob that selects docs only names nothing any fingerprint carries
docs = helpers.make_test("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):
def test_catalog_hash_is_definition_only(self):
r1 = helpers.registry(helpers.make_test("a.one", [("forgectrl", "src/ui.c")]))
r2 = helpers.registry(helpers.make_test("a.one", [("forgectrl", "src/ui.c")], fn=lambda ctx: 1))
self.assertEqual(catalog.catalog_hash(r1), catalog.catalog_hash(r2))
r3 = helpers.registry(helpers.make_test("a.one", [("forgectrl", "src/**")]))
self.assertNotEqual(catalog.catalog_hash(r1), catalog.catalog_hash(r3))
r4 = helpers.registry(helpers.make_test("a.one", [("forgectrl", "src/ui.c")], always=True))
self.assertNotEqual(catalog.catalog_hash(r1), catalog.catalog_hash(r4))
def test_validate(self):
r = helpers.registry(helpers.make_test("a.one", [], requires=("a.two",)))
with self.assertRaises(ValueError):
catalog.validate(r)
r = helpers.registry(helpers.make_test("a.one", [], requires=("a.two",)),
helpers.make_test("a.two", [], requires=("a.one",)))
with self.assertRaises(ValueError):
catalog.validate(r)
def test_decorator_rules(self):
with self.assertRaises(ValueError):
catalog.test("bad id", title="x", subsystem="s")(lambda c: None)
with self.assertRaises(ValueError):
catalog.test("s.x", title="x", subsystem="s", covers=[("forgetest", "**")])(lambda c: None)
def test_real_suite_loads_and_validates(self):
reg = catalog.load_suite()
self.assertIn("image.health", reg)
self.assertTrue(reg["image.health"].always)
catalog.validate(reg)
for t in catalog.all_tests(reg):
self.assertEqual(len(t.source_sha), 64)
def test_the_shared_judges_exist_and_take_every_package_import_of_the_suite(self):
# The list is kept by hand: the Context reaches runner.py, hw.py
# 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):
"""A test's implementation hash is its own function plus the code its
module shares: a body edit moves one test, a helper edit moves them
all, and a file without @test functions hashes whole."""
MODULE = '''"""a suite module"""
from forgetest.catalog import test
def helper(x):
return x + 1
@test("m.one", title="one", subsystem="m")
def one(ctx):
ctx.log(helper(1))
@test("m.two", title="two", subsystem="m",
steps=["a step"])
def two(ctx):
ctx.log(helper(2))
'''
def setUp(self):
import tempfile
self.tmp = tempfile.mkdtemp(prefix="forgetest-impl-")
self.path = os.path.join(self.tmp, "mod.py")
self.write(self.MODULE)
def tearDown(self):
import shutil
shutil.rmtree(self.tmp, ignore_errors=True)
def write(self, text):
catalog.forget(self.path)
with open(self.path, "w", newline="\n") as f:
f.write(text)
def shas(self):
return catalog.implementation_sha(self.path, "m.one"), catalog.implementation_sha(self.path, "m.two")
def test_a_body_edit_moves_that_test_only(self):
a1, b1 = self.shas()
self.write(self.MODULE.replace("ctx.log(helper(2))", "ctx.log(helper(3))"))
a2, b2 = self.shas()
self.assertEqual(a1, a2)
self.assertNotEqual(b1, b2)
def test_a_decorator_edit_moves_that_test_only(self):
a1, b1 = self.shas()
self.write(self.MODULE.replace('steps=["a step"]', 'steps=["another step"]'))
a2, b2 = self.shas()
self.assertEqual(a1, a2)
self.assertNotEqual(b1, b2)
def test_a_helper_edit_moves_every_test_of_the_module(self):
a1, b1 = self.shas()
self.write(self.MODULE.replace("return x + 1", "return x + 2"))
a2, b2 = self.shas()
self.assertNotEqual(a1, a2)
self.assertNotEqual(b1, b2)
def test_a_helper_edit_in_an_imported_sibling_moves_the_tests(self):
# laser.py imports its judges from motion.py: an edit to such a
# helper changes the judge, so it has to move the fingerprints of
# the tests that import it, transitively.
sib = os.path.join(self.tmp, "judge.py")
with open(sib, "w", newline="\n") as f:
f.write("def judge(x):\n return x > 1\n")
catalog.forget(sib)
self.write("from .judge import judge\n" + self.MODULE)
a1, b1 = self.shas()
with open(sib, "w", newline="\n") as f:
f.write("def judge(x):\n return x > 2\n")
catalog.forget(sib)
a2, b2 = self.shas()
self.assertNotEqual(a1, a2)
self.assertNotEqual(b1, b2)
# a module that is not imported changes nothing
other = os.path.join(self.tmp, "other.py")
with open(other, "w", newline="\n") as f:
f.write("def o():\n return 1\n")
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):
a1, b1 = self.shas()
catalog.forget(self.path)
with open(self.path, "wb") as f:
f.write(self.MODULE.replace("\n", "\r\n").encode())
self.assertEqual((a1, b1), self.shas())
def test_an_unknown_id_hashes_the_whole_file(self):
self.assertEqual(catalog.implementation_sha(self.path, "m.none"), catalog.source_file_sha(self.path))
def test_every_suite_test_is_spanned(self):
import inspect
reg = catalog.load_suite()
for t in catalog.all_tests(reg):
path = inspect.getsourcefile(t.fn)
self.assertIn(t.id, catalog.module_parts(path)[1], t.id)
if __name__ == "__main__":
unittest.main()
-200
View File
@@ -1,200 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The controller mode a test declares: the runner puts the machine there
before the test and the baseline keeps it there afterward.
The cloud tests enter cloud mode and stay (the operator rule: no switch
back after every test), so a queue that runs on past them reaches the
motion tests with gfcloud as the controller and no grblHAL process to
find. A test that names its mode gets the switch made for it, once, in the
pre pass - after the leftovers are handled and before the preserved state
is captured, so the post pass hands back the mode the test asked for
rather than the one the run found.
"""
import os
import shutil
import socket
import tempfile
import threading
import time
import unittest
import helpers
from forgetest import baseline
from forgetest.log import Log
from forgetest.runner import Runner
class GrblPort:
"""A listening socket standing in for the Grbl port of grblHAL."""
def __init__(self):
self.sock = socket.socket()
self.sock.bind(("127.0.0.1", 0))
self.sock.listen(5)
os.environ["GRBL_HOST"] = "127.0.0.1"
os.environ["GRBL_PORT"] = str(self.sock.getsockname()[1])
self._stop = False
self._th = threading.Thread(target=self._accept, daemon=True)
self._th.start()
def _accept(self):
self.sock.settimeout(0.2)
while not self._stop:
try:
c, _ = self.sock.accept()
c.close()
except OSError:
pass
def close(self):
self._stop = True
self.sock.close()
for k in ("GRBL_HOST", "GRBL_PORT"):
os.environ.pop(k, None)
def cloud(fc):
fc.state["mode"] = {"mode": "cloud", "controller": "running", "pid": 7, "motion": "verified"}
fc.state["settings"]["controller_mode"] = "cloud"
class SwitchModeTests(unittest.TestCase):
"""Baseline.switch_mode against the fake forgectrl."""
def setUp(self):
self.fc = helpers.FakeForgectrl().start()
self.port = GrblPort()
baseline.Baseline._unreachable_until = 0.0
self.lines = []
def tearDown(self):
self.port.close()
self.fc.stop()
def bl(self):
return baseline.Baseline(self.lines.append)
def test_already_in_the_mode_posts_nothing(self):
ok, detail = self.bl().switch_mode("grbl")
self.assertTrue(ok, detail)
self.assertEqual(self.fc.posts, [])
def test_cloud_to_grbl_switches_and_waits_for_the_port(self):
cloud(self.fc)
b = self.bl()
ok, detail = b.switch_mode("grbl")
self.assertTrue(ok, detail)
self.assertEqual(self.fc.posts, [("/mode", {"controller": "grbl"})])
self.assertEqual(self.fc.state["mode"]["mode"], "grbl")
self.assertEqual(b.mode, "grbl")
def test_a_controller_that_never_comes_up_is_reported(self):
cloud(self.fc)
self.fc.on_post = lambda path, form: (200, {"ok": True}) # accepted, nothing happens
ok, detail = self.bl().switch_mode("grbl", timeout=2)
self.assertFalse(ok)
self.assertIn("did not come up", detail)
def test_a_refused_switch_is_reported(self):
cloud(self.fc)
self.fc.on_post = lambda path, form: (409, {"error": "busy"})
ok, detail = self.bl().switch_mode("grbl")
self.assertFalse(ok)
self.assertIn("409", detail)
def test_grbl_needs_the_port_open(self):
cloud(self.fc)
self.port.close() # grblHAL running, port never listening
os.environ["GRBL_HOST"], os.environ["GRBL_PORT"] = "127.0.0.1", "1"
old = baseline.GRBL_PORT_S
baseline.GRBL_PORT_S = 2
try:
ok, detail = self.bl().switch_mode("grbl")
finally:
baseline.GRBL_PORT_S = old
self.assertFalse(ok)
self.assertIn("Grbl port", detail)
class RunnerModeTests(unittest.TestCase):
"""The runner end to end: a declared mode is established before the
test function runs and kept by the post pass."""
def setUp(self):
self.tmp = tempfile.mkdtemp(prefix="forgetest-mode-")
os.environ["FORGETEST_DATA"] = self.tmp
os.environ["FORGETEST_MARKER"] = os.path.join(self.tmp, "marker")
self.fc = helpers.FakeForgectrl().start()
self.port = GrblPort()
baseline.Baseline._unreachable_until = 0.0
self.seen = {}
fc, seen = self.fc, self.seen
def t_sees_mode(ctx):
seen["mode"] = fc.state["mode"]["mode"]
reg = helpers.registry(
helpers.make_test("m.grbl", [("forgectrl", "src/ui.c")], fn=t_sees_mode, mode="grbl"),
helpers.make_test("m.any", [("forgectrl", "src/ui.c")], fn=t_sees_mode),
)
self.runner = Runner(Log(os.path.join(self.tmp, "results.jsonl")), helpers.make_manifest(), reg)
def tearDown(self):
self.port.close()
self.fc.stop()
shutil.rmtree(self.tmp, ignore_errors=True)
for k in ("FORGETEST_DATA", "FORGETEST_MARKER"):
os.environ.pop(k, None)
def run_test(self, tid):
ok, msg = self.runner.start_test(tid)
self.assertTrue(ok, msg)
run = self.runner.current
deadline = time.time() + 30
while not run.finished and time.time() < deadline:
time.sleep(0.05)
self.assertTrue(run.finished, "run did not finish")
return run
def test_grbl_test_found_in_cloud_mode_gets_the_switch_and_keeps_it(self):
cloud(self.fc)
run = self.run_test("m.grbl")
self.assertEqual(run.finished["result"], "PASS", run.finished)
self.assertEqual(self.seen["mode"], "grbl")
# one switch, made before the test; the post pass keeps grbl
self.assertEqual(self.fc.posts, [("/mode", {"controller": "grbl"})])
self.assertEqual(self.fc.state["mode"]["mode"], "grbl")
self.assertEqual(run.baseline_captured["mode"], "grbl")
self.assertEqual(run.evidence["mode"]["found"], "cloud")
self.assertTrue(run.evidence["mode"]["switched"])
self.assertTrue(any("mode: test needs grbl" in ln for ln in run.lines), run.lines)
def test_grbl_test_in_grbl_mode_switches_nothing(self):
run = self.run_test("m.grbl")
self.assertEqual(run.finished["result"], "PASS", run.finished)
self.assertEqual(self.fc.posts, [])
self.assertFalse(run.evidence["mode"]["switched"])
def test_undeclared_test_runs_in_the_mode_it_finds(self):
cloud(self.fc)
run = self.run_test("m.any")
self.assertEqual(run.finished["result"], "PASS", run.finished)
self.assertEqual(self.seen["mode"], "cloud")
self.assertEqual(self.fc.posts, [])
self.assertNotIn("mode", run.evidence)
def test_a_switch_that_fails_fails_the_test_before_it_runs(self):
cloud(self.fc)
self.fc.on_post = lambda path, form: (503, {"error": "supervisor busy"})
run = self.run_test("m.grbl")
self.assertEqual(run.finished["result"], "FAIL")
self.assertIn("needs grbl mode", run.finished["message"])
self.assertNotIn("mode", self.seen)
if __name__ == "__main__":
unittest.main()
-269
View File
@@ -1,269 +0,0 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The operator channel: notices, `ready`, `act`, and the precheck.
A test asks for the operator's part in a way the page can show before
it is needed and a bench actuator can later perform: `act` names a
machine action and watches the machine for it (a standing notice for
the human, no button to race), `ready` pre-announces a timed step, and
a `precheck` refuses a start the machine cannot honor instead of
recording a FAIL.
"""
import os
import shutil
import tempfile
import threading
import time
import unittest
import helpers
from forgetest import catalog
from forgetest.log import Log
from forgetest.runner import Context, Failed, Run, Runner
class Answerer:
"""Answers every prompt with its first option and records it, and
records every notice it sees."""
def __init__(self, run, on_prompt=None):
self.run = run
self.on_prompt = on_prompt
self.asked = []
self.notices = []
self.stop = False
threading.Thread(target=self._loop, daemon=True).start()
def _loop(self):
seen = None
seen_n = None
while not self.stop:
n = self.run.notice
if n and n["id"] != seen_n:
seen_n = n["id"]
self.notices.append(n["text"])
p = self.run.prompt
if p and p["id"] != seen:
seen = p["id"]
self.asked.append((p["question"], list(p["options"])))
if self.on_prompt:
self.on_prompt(p)
self.run.answer(p["id"], p["options"][0])
time.sleep(0.01)
class ActTests(unittest.TestCase):
def setUp(self):
self.fc = helpers.FakeForgectrl().start()
self.run = Run("test", "t.x", "t.x")
self.ctx = Context(self.run, None, helpers.make_test("t.x", [], kind="operator"))
self.ans = Answerer(self.run)
def tearDown(self):
self.ans.stop = True
self.fc.stop()
def lid(self, closed):
self.fc.state["status"]["switches"]["lid"] = bool(closed)
def interlock(self, ok):
self.fc.state["status"]["switches"]["interlock_ok"] = bool(ok)
def test_lid_open_is_a_notice_until_the_switch_shows_it(self):
threading.Timer(0.3, self.lid, args=(False,)).start()
dt = self.ctx.act("lid", "open", timeout=5)
self.assertGreater(dt, 0.2)
self.assertEqual(self.ans.asked, []) # no button to click
self.assertEqual(self.ans.notices, ["Open the lid."])
self.assertIsNone(self.run.notice) # cleared when seen
rec = self.run.evidence["actions"][0]
self.assertEqual((rec["channel"], rec["state"], rec["by"]), ("lid", "open", "operator"))
self.assertIsNotNone(rec["took_s"])
self.assertTrue(any("ACT lid open: done" in ln for ln in self.run.lines))
def test_already_done_returns_at_once(self):
self.lid(False)
dt = self.ctx.act("lid", "open", timeout=2)
self.assertLess(dt, 1.0)
def test_interlock_watches_interlock_ok(self):
threading.Timer(0.2, self.interlock, args=(False,)).start()
self.ctx.act("interlock", "open", timeout=5)
threading.Timer(0.2, self.interlock, args=(True,)).start()
self.ctx.act("interlock", "close", timeout=5)
self.assertEqual([r["state"] for r in self.run.evidence["actions"]], ["open", "close"])
def test_a_timeout_fails_the_test_and_clears_the_notice(self):
with self.assertRaises(Failed) as cm:
self.ctx.act("lid", "open", timeout=0.6)
self.assertIn("lid open was not seen", str(cm.exception))
self.assertIsNone(self.run.notice)
self.assertIsNone(self.run.evidence["actions"][0]["took_s"])
def test_a_timeout_can_be_handed_back_instead(self):
self.assertIsNone(self.ctx.act("lid", "open", timeout=0.4, fail=False))
def test_a_button_press_needs_what_it_is_expected_to_do(self):
with self.assertRaises(ValueError):
self.ctx.act("button", "press")
held = []
threading.Timer(0.2, held.append, args=(1,)).start()
self.ctx.act("button", "press", until=lambda: bool(held), timeout=5)
self.assertEqual(self.ans.notices, ["Press the button once."])
def test_unknown_actions_are_refused(self):
with self.assertRaises(ValueError):
self.ctx.act("lid", "press")
def test_ready_is_a_single_button_prompt(self):
self.ctx.ready("On Ready the head moves.")
self.assertEqual(self.ans.asked, [("On Ready the head moves.", ["Ready", "Cannot"])])
def test_cannot_on_ready_fails(self):
self.ans.stop = True
ans = Answerer(self.run, on_prompt=lambda p: None)
ans.stop = True
time.sleep(0.05)
def say_cannot():
while self.run.prompt is None:
time.sleep(0.01)
self.run.answer(self.run.prompt["id"], "Cannot")
threading.Thread(target=say_cannot, daemon=True).start()
with self.assertRaises(Failed):
self.ctx.ready("x")
class PrecheckTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp(prefix="forgetest-pre-")
os.environ["FORGETEST_DATA"] = self.tmp
os.environ["FORGETEST_MARKER"] = os.path.join(self.tmp, "marker")
self.man = helpers.make_manifest()
self.blocked = {"why": "HV reports good: open the lid"}
self.reg = helpers.registry(
helpers.make_test("p.gated", [("forgectrl", "src/main.c")],
precheck=lambda: self.blocked["why"]),
helpers.make_test("p.free", [("forgectrl", "src/ui.c")]),
)
self.log = Log(os.path.join(self.tmp, "r.jsonl"))
self.runner = Runner(self.log, self.man, self.reg)
def tearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
def test_a_precheck_reason_refuses_the_start_without_a_result(self):
ok, msg = self.runner.start_test("p.gated")
self.assertFalse(ok)
self.assertEqual(msg, "cannot start: HV reports good: open the lid")
self.assertEqual([r for r in self.log.read() if r["t"] == "result"], [])
# the campaign is not even opened by a refused start
self.assertEqual([r for r in self.log.read() if r["t"] == "campaign"], [])
def test_a_clear_precheck_starts(self):
self.blocked["why"] = None
ok, msg = self.runner.start_test("p.gated")
self.assertTrue(ok, msg)
while self.runner.busy():
time.sleep(0.02)
self.assertEqual(self.runner.state()[0]["tests"]["p.gated"]["status"], "pass")
def test_the_queue_skips_it_with_the_reason_and_carries_on(self):
ok, msg, order = self.runner.start_batch("unattended")
self.assertTrue(ok, msg)
deadline = time.time() + 20
while not self.runner.batch["finished"] and time.time() < deadline:
time.sleep(0.02)
b = self.runner.batch_snapshot()
self.assertEqual([x["test"] for x in b["skipped"]], ["p.gated"])
self.assertIn("cannot start: HV reports good", b["skipped"][0]["reason"])
self.assertEqual([x["test"] for x in b["done"]], ["p.free"])
def test_an_erroring_precheck_refuses_rather_than_crashes(self):
def boom():
raise RuntimeError("sysfs gone")
t = helpers.make_test("p.boom", [], precheck=boom)
self.assertIn("precheck errored: RuntimeError: sysfs gone", t.cannot_start())
def test_the_catalog_describes_actions_and_the_precheck(self):
t = helpers.make_test("p.x", [], kind="operator", actions=("lid", "button"),
precheck=lambda: None)
d = t.describe()
self.assertEqual(d["actions"], ["lid", "button"])
self.assertTrue(d["precheck"])
# neither is part of the gate-visible definition
self.assertNotIn("actions", t.definition())
def test_the_decorator_checks_actions(self):
saved = dict(catalog.REGISTRY)
try:
with self.assertRaises(ValueError):
catalog.test("x.a", title="t", subsystem="x", kind="operator", actions=("foot",))(lambda c: None)
with self.assertRaises(ValueError):
catalog.test("x.b", title="t", subsystem="x", kind="auto", actions=("lid",))(lambda c: None)
with self.assertRaises(ValueError):
catalog.test("x.c", title="t", subsystem="x", precheck="no")(lambda c: None)
catalog.test("x.d", title="t", subsystem="x", kind="operator", actions=("lid",),
precheck=lambda: None)(lambda c: None)
self.assertIn("x.d", catalog.REGISTRY)
finally:
catalog.REGISTRY.clear()
catalog.REGISTRY.update(saved)
class AccelSamplerTests(unittest.TestCase):
"""The head accelerometer sampler over a fake iio tree."""
def setUp(self):
from forgetest import hw
self.hw = hw
self.tmp = tempfile.mkdtemp(prefix="forgetest-iio-")
d = os.path.join(self.tmp, "iio_device1") # no colon: the host may be Windows
os.makedirs(d)
with open(os.path.join(d, "name"), "w") as f:
f.write("lis2hh12 3-001e\n")
self.dir = d
self.write(100, 200)
os.environ["GF_IIO_ROOT"] = self.tmp
def tearDown(self):
os.environ.pop("GF_IIO_ROOT", None)
shutil.rmtree(self.tmp, ignore_errors=True)
def write(self, x, y):
for axis, v in (("x", x), ("y", y)):
with open(os.path.join(self.dir, "in_accel_%s_raw" % axis), "w") as f:
f.write("%d\n" % v)
def test_found_by_bus_address_and_sampled(self):
self.assertEqual(self.hw.head_accel_dir(), self.dir)
s = self.hw.AccelSampler(period=0.02)
self.assertTrue(s.available)
with s:
t0 = time.time()
time.sleep(0.1)
self.write(1100, 200)
time.sleep(0.1)
self.write(100, 200)
time.sleep(0.1)
t1 = time.time()
p2px, p2py, n = s.p2p(t0, t1)
self.assertGreaterEqual(n, 5)
self.assertEqual((p2px, p2py), (1000, 0))
# a window with no samples reads as nothing, not as an error
self.assertEqual(s.p2p(t1 + 10, t1 + 20), (0, 0, 0))
def test_absent_device_is_unavailable(self):
os.environ["GF_IIO_ROOT"] = os.path.join(self.tmp, "nowhere")
s = self.hw.AccelSampler()
self.assertFalse(s.available)
with s:
pass
self.assertEqual(s.p2p(0), (0, 0, 0))
if __name__ == "__main__":
unittest.main()

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