mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-10-15 17:19:55 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16d27f0887 | ||
|
|
1bd7793744 | ||
|
|
a1d158514b |
@@ -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
|
||||
@@ -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
|
||||
@@ -1,168 +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
|
||||
run: |
|
||||
pip install pyflakes
|
||||
if python -B -m pyflakes forgetest tests | grep -E "undefined name"; 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
|
||||
run: 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
|
||||
@@ -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
@@ -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
|
||||
@@ -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 LightBurn and other senders 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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,87 +1,15 @@
|
||||
# OpenGlow / ForgeFIRM firmware for Glowforge
|
||||
# OpenGlow/ForgeFIRM Firmware for Glowforge
|
||||
This repository contains opensource firmware for Glowforge brand CNC lasers.
|
||||
|
||||
> ### 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).
|
||||
* [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)
|
||||
|
||||
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.
|
||||
# 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 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.
|
||||
This project is for experimental purposes only, and is not supported or endorsed by Glowforge.
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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@"
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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"
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1 +0,0 @@
|
||||
{"seq":268,"ts_mono":1268.000,"fire_ok":true,"verdict":"OK","hold":false,"resume_ok":true,"armed":true,"reason":"","down_c":22.30,"up_c":22.30}
|
||||
@@ -1 +0,0 @@
|
||||
0
|
||||
@@ -1 +0,0 @@
|
||||
0
|
||||
@@ -1 +0,0 @@
|
||||
255
|
||||
@@ -1 +0,0 @@
|
||||
idle
|
||||
@@ -1 +0,0 @@
|
||||
1023
|
||||
@@ -1 +0,0 @@
|
||||
300
|
||||
@@ -1 +0,0 @@
|
||||
629
|
||||
@@ -1 +0,0 @@
|
||||
0
|
||||
@@ -1 +0,0 @@
|
||||
700
|
||||
@@ -1 +0,0 @@
|
||||
700
|
||||
@@ -1 +0,0 @@
|
||||
65535
|
||||
@@ -1 +0,0 @@
|
||||
26214
|
||||
@@ -1 +0,0 @@
|
||||
43278
|
||||
@@ -1 +0,0 @@
|
||||
300
|
||||
@@ -1 +0,0 @@
|
||||
300
|
||||
@@ -1 +0,0 @@
|
||||
300
|
||||
@@ -1 +0,0 @@
|
||||
{"seq":1517,"ts_mono":3127.000,"fire_ok":true,"verdict":"OK","hold":false,"resume_ok":true,"armed":false,"reason":"HEAD CRASH SIGNAL (axes x) - motion stopped, laser locked","down_c":22.30,"up_c":22.30}
|
||||
@@ -1 +0,0 @@
|
||||
0
|
||||
@@ -1 +0,0 @@
|
||||
1
|
||||
@@ -1 +0,0 @@
|
||||
0
|
||||
@@ -1 +0,0 @@
|
||||
0
|
||||
@@ -1 +0,0 @@
|
||||
idle
|
||||
@@ -1 +0,0 @@
|
||||
1
|
||||
@@ -1 +0,0 @@
|
||||
204
|
||||
@@ -1 +0,0 @@
|
||||
300
|
||||
@@ -1 +0,0 @@
|
||||
629
|
||||
@@ -1 +0,0 @@
|
||||
0
|
||||
@@ -1 +0,0 @@
|
||||
100
|
||||
@@ -1 +0,0 @@
|
||||
100
|
||||
@@ -1 +0,0 @@
|
||||
100
|
||||
@@ -1 +0,0 @@
|
||||
100
|
||||
@@ -1 +0,0 @@
|
||||
700
|
||||
@@ -1 +0,0 @@
|
||||
700
|
||||
@@ -1 +0,0 @@
|
||||
0
|
||||
@@ -1 +0,0 @@
|
||||
0
|
||||
@@ -1 +0,0 @@
|
||||
0
|
||||
@@ -1 +0,0 @@
|
||||
300
|
||||
@@ -1 +0,0 @@
|
||||
300
|
||||
@@ -1 +0,0 @@
|
||||
300
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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())
|
||||
@@ -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
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -1,380 +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. 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. 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 files never change under a run, so each is read and parsed once:
|
||||
# path -> (text, tree or None), path -> (shared sha, {test id: own sha}),
|
||||
# path -> the sibling modules it imports itself.
|
||||
_PARSED = {}
|
||||
_PARTS = {}
|
||||
_SIBLINGS = {}
|
||||
_PARTS_LOCK = threading.RLock()
|
||||
_IMPL_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def forget(path):
|
||||
"""Drop what is known about a module: its file changed (the unit tests
|
||||
edit their modules between readings)."""
|
||||
with _PARTS_LOCK:
|
||||
for cache in (_PARSED, _PARTS, _SIBLINGS):
|
||||
cache.pop(path, None)
|
||||
|
||||
|
||||
def _parsed(path):
|
||||
"""(text, tree) of a suite module, line endings normalized; the tree
|
||||
is None when the file does not parse."""
|
||||
with _PARTS_LOCK:
|
||||
hit = _PARSED.get(path)
|
||||
if hit is None:
|
||||
with open(path, "rb") as f:
|
||||
text = f.read().replace(b"\r\n", b"\n").decode("utf-8")
|
||||
try:
|
||||
tree = ast.parse(text)
|
||||
except SyntaxError:
|
||||
tree = None
|
||||
hit = _PARSED[path] = (text, tree)
|
||||
return hit
|
||||
|
||||
|
||||
def module_parts(path):
|
||||
"""(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 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)):
|
||||
start = min([dd.lineno for dd in node.decorator_list] + [node.lineno])
|
||||
spans[d.args[0].value] = (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 imports with `from .x import ...`, 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 _direct_siblings(path):
|
||||
"""The suite modules one module imports itself, in import order."""
|
||||
with _PARTS_LOCK:
|
||||
hit = _SIBLINGS.get(path)
|
||||
if hit is None:
|
||||
_text, tree = _parsed(path)
|
||||
here = os.path.dirname(path)
|
||||
hit = []
|
||||
for node in (tree.body if tree is not None else []):
|
||||
if isinstance(node, ast.ImportFrom) and node.level == 1 and node.module:
|
||||
sib = os.path.join(here, node.module.replace(".", os.sep) + ".py")
|
||||
if os.path.isfile(sib):
|
||||
hit.append(sib)
|
||||
_SIBLINGS[path] = hit
|
||||
return hit
|
||||
|
||||
|
||||
def implementation_sha(path, test_id):
|
||||
"""The implementation hash of one test (see Test.source_sha): the
|
||||
test's own text, the shared text of its module, and the shared text
|
||||
of every sibling suite module the module imports."""
|
||||
shared, own = module_parts(path)
|
||||
if test_id not in own:
|
||||
return source_file_sha(path)
|
||||
helpers = [module_parts(p)[0] for p in sorted(sibling_imports(path))]
|
||||
return _manifest.sha256_text("%s:%s" % (":".join([shared] + helpers), 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")
|
||||
@@ -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())
|
||||
@@ -1,395 +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):
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 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:
|
||||
if retry and not self.ip_override:
|
||||
# the address may have moved: one fresh lookup, one retry
|
||||
self.address(refresh=True)
|
||||
return self._request(method, path, body, retry=False)
|
||||
raise FixtureError("fixture %s: %s %s: %s" % (self.where(), method, path, e))
|
||||
|
||||
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 firmware clamps it into 20 to 500, and takes 200 when
|
||||
it is not said): a check that must reach the machine *while* the
|
||||
button is down asks for the longest press the box can make. The controller detects
|
||||
the button's rising edge, so a press must follow a release it has
|
||||
seen: the next press waits for the last pulse to end (the
|
||||
fixture's own pulse_ms) plus BUTTON_GAP_S. A 409 for a pulse in
|
||||
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"]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -1,704 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The image manifest and the domain fingerprint.
|
||||
|
||||
/etc/forgefirm-manifest.json (written by forgefirm-image-manifest.bbclass)
|
||||
identifies the build's inputs: for every component the pinned revision and
|
||||
one [path, blob-id] pair per source file, plus the platform identity
|
||||
(machine, kernel modules directory, device tree hashes, layer content
|
||||
hashes). This module loads it and computes a test's *domain fingerprint*:
|
||||
the hash of the source files its coverage globs select, plus the platform,
|
||||
plus the test's own implementation. A recorded PASS applies to a build
|
||||
exactly when the fingerprint recomputed from that build's manifest is the
|
||||
same - the same code runs on the board and in the release gate.
|
||||
"""
|
||||
import functools
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
|
||||
DEFAULT_PATH = "/etc/forgefirm-manifest.json"
|
||||
|
||||
# Components that ship only on the dev image. They can never be part of a
|
||||
# fingerprint (the release manifest lacks them, so the gate could not
|
||||
# recompute it); the test implementation is folded in separately.
|
||||
DEV_ONLY_COMPONENTS = ("forgetest",)
|
||||
|
||||
|
||||
def canonical(obj):
|
||||
"""Canonical JSON: sorted keys, no whitespace - the hashing form."""
|
||||
return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
||||
|
||||
|
||||
def sha256_text(text):
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=512)
|
||||
def glob_to_regex(pattern):
|
||||
"""Coverage glob -> anchored regex. '**' spans directories, '*' and '?'
|
||||
stay inside one path segment. Paths use '/' (git paths). Cached: the
|
||||
catalog matches the same few hundred globs over and over."""
|
||||
out = []
|
||||
i, n = 0, len(pattern)
|
||||
while i < n:
|
||||
c = pattern[i]
|
||||
if c == "*":
|
||||
if pattern[i:i + 2] == "**":
|
||||
# '**/' also matches zero directories
|
||||
if pattern[i:i + 3] == "**/":
|
||||
out.append("(?:.*/)?")
|
||||
i += 3
|
||||
continue
|
||||
out.append(".*")
|
||||
i += 2
|
||||
continue
|
||||
out.append("[^/]*")
|
||||
elif c == "?":
|
||||
out.append("[^/]")
|
||||
else:
|
||||
out.append(re.escape(c))
|
||||
i += 1
|
||||
return re.compile("^" + "".join(out) + "$")
|
||||
|
||||
|
||||
def match_files(files, pattern):
|
||||
"""(path, blob) pairs from a component's file list that the glob selects."""
|
||||
rx = glob_to_regex(pattern)
|
||||
return [(p, b) for p, b in files if rx.match(p)]
|
||||
|
||||
|
||||
# Paths that carry no target behavior: docs, CI, the components' own unit
|
||||
# tests, licenses, editor setup. Outside every fingerprint (a README edit
|
||||
# re-requires nothing) and outside the coverage lint (no test has to name
|
||||
# them). Reviewed with the catalog: widening this list is a change like
|
||||
# any other. "*" applies to every component.
|
||||
NON_BEHAVIORAL = [
|
||||
("*", ".github/**"),
|
||||
("*", ".gitignore"),
|
||||
("*", ".gitattributes"),
|
||||
("*", ".gitmodules"),
|
||||
("*", "**/*.md"),
|
||||
("*", "LICENSE*"),
|
||||
("*", "COPYING*"),
|
||||
("*", "docs/**"),
|
||||
("*", "tests/**"),
|
||||
("*", "graphify-out/**"),
|
||||
("*", "**/.gitkeep"),
|
||||
("*", ".devcontainer/**"),
|
||||
("*", ".vscode/**"),
|
||||
("*", ".env.example"),
|
||||
("forgectrl", "tools/**"), # host-side dev tools (panel dev server)
|
||||
("forgectrl", "examples/**"), # clients of the remote API, run on another computer
|
||||
# forgeext's recipe installs the binary and its init script, and nothing of these: the author's kit
|
||||
# and the host tools run off the image (OpenGlow's own packages have repositories of their own).
|
||||
("forgeext", "sdk/**"),
|
||||
("forgeext", "template/**"),
|
||||
("forgeext", "tools/**"),
|
||||
]
|
||||
|
||||
|
||||
# Paths the list above would take out that are behavior all the same: forgectrl embeds its advisory
|
||||
# documents in the binary, serves them, and records the operator's consent to a document by its hash, so
|
||||
# an edited advisory is a changed consent and must move every fingerprint that covers it.
|
||||
BEHAVIORAL = [
|
||||
("forgectrl", "docs/advisories/**"),
|
||||
]
|
||||
|
||||
|
||||
# Paths a host test in CI proves, which no catalog test covers. The catalog
|
||||
# is for what needs the machine or an operator; software whose inputs can be
|
||||
# emulated is proven on a host. A row is (component, glob, host test, CI
|
||||
# workflow), the last two named as "<repository>:<path>": a component of
|
||||
# the manifest (its tests/ and .github/ files are listed there), or
|
||||
# "forgefirm" for this repository (the null-sink harnesses under
|
||||
# scripts/bench/, which grblhal-glowforge's workflow runs). The workflow
|
||||
# must run the host test: name it, or discover it (a glob, unittest
|
||||
# discover, pytest on its directory). A host-tested path counts as covered
|
||||
# in the coverage lint, and like every path no test covers it is in no
|
||||
# fingerprint: a change there makes no acceptance run necessary. A path a
|
||||
# catalog test exercises on the hardware belongs in that test's covers, not
|
||||
# here.
|
||||
HOST_TESTED = [
|
||||
# An extension package's holds as the engine reads them (forgeext's
|
||||
# holdkeep_test and run_test prove the writer's side).
|
||||
("forgectrl", "src/holds.*", "forgectrl:tests/holds_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||
# The M-code relay to the package that answers (the controller's side is
|
||||
# the null-sink mcode_test harness, under grblhal-glowforge's src/**).
|
||||
("forgectrl", "src/mcode.*", "forgectrl:tests/mcode_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||
# The digest primitives (SHA-256, HMAC, the constant-time compare) that
|
||||
# the advisories' hashes, the tokens and the camera key are made with.
|
||||
("forgectrl", "src/sha256.*", "forgectrl:tests/sha256_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||
# The built-in extensions' table: the cloud entry, its refusals while it
|
||||
# is off, the sweep when it goes off, the list.
|
||||
("forgectrl", "src/builtin.*", "forgectrl:tests/builtin_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||
# The setup record as a printable page: no script, the sheet id, a
|
||||
# heading for each completed step, everything escaped.
|
||||
("forgectrl", "src/recordhtml.*", "forgectrl:tests/recordhtml_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||
# An extension package's claim on the Grbl sender as the machine keeps
|
||||
# it: the claim and its refusals, its end by the package, the operator
|
||||
# or a stale keeper (forgeext's run_test proves the writer's side).
|
||||
("forgectrl", "src/senderout.*", "forgectrl:tests/senderout_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||
# A package's check on the Setup page: each step held to its form, the
|
||||
# check run through the host, the result the package's own (the routes
|
||||
# against a live host's stand-in are tests/host_ext.sh).
|
||||
("forgectrl", "src/wizpkg.*", "forgectrl:tests/wizpkg_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||
# The camera key: made, kept, checked and rotated (the reads it opens
|
||||
# from the LAN with the reads closed are tests/host_lan.sh).
|
||||
("forgectrl", "src/camkey.*", "forgectrl:tests/camkey_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||
# The log export's sanitizer: every identifying class taken out, the
|
||||
# ordinary log text left as it was (the export's routes and the
|
||||
# installer's directory on a host are tests/host_guard.sh).
|
||||
("forgectrl", "src/sanitize.*", "forgectrl:tests/sanitize_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||
# The package routes' relay to the extension host: the argument vector,
|
||||
# the closed list of actions, the host's words, the catalog's fetch (the
|
||||
# routes against a live host's stand-in are tests/host_ext.sh).
|
||||
("forgectrl", "src/extpkg.*", "forgectrl:tests/extpkg_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||
# The event stream: a button edge, a lease changing hands, telemetry
|
||||
# (the live order, the take before the free, is tests/host_lease.sh).
|
||||
("forgectrl", "src/events.*", "forgectrl:tests/events_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||
# The crumb tray's mode as the daemon keeps it: its offset and what it
|
||||
# refuses (the route and the panel's switch are tests/host_routes.sh).
|
||||
("forgectrl", "src/tray.*", "forgectrl:tests/tray_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||
# The product gate on the firmware paths: firmware only, an extension
|
||||
# package refused in words that say what it is (the upload route over
|
||||
# a stand-in fwup is tests/host_update.sh).
|
||||
("forgectrl", "src/fwproduct.*", "forgectrl:tests/fwproduct_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||
# The controller port's client and its two operation sets (the motion
|
||||
# routes and a package's jog through the host's credential are
|
||||
# tests/host_routes.sh).
|
||||
("forgectrl", "src/grblport.*", "forgectrl:tests/grblport_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||
# The panel page as the daemon serves it: bundled, the theme and the
|
||||
# save bar in it, and its policy frame-src 'none'.
|
||||
("forgectrl", "src/ui.h", "forgectrl:tests/host_routes.sh", "forgectrl:.github/workflows/build.yml"),
|
||||
# The data and run directories from the environment: the SSH flag in the
|
||||
# run directory, written and removed by POST /system/ssh.
|
||||
("forgectrl", "src/paths.h", "forgectrl:tests/host_routes.sh", "forgectrl:.github/workflows/build.yml"),
|
||||
# The first run in the page's order: the four documents at their hashes,
|
||||
# the press step breathing teal, the press taken as the acceptance, the
|
||||
# form steps through main.c's hooks; and the on-demand Extensions
|
||||
# advisory, accepted at its hash with the typed phrase.
|
||||
("forgectrl", "src/advisories.*", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
|
||||
("forgectrl", "docs/advisories/extensions.md", "forgectrl:tests/host_setup.sh",
|
||||
"forgectrl:.github/workflows/build.yml"),
|
||||
("forgectrl", "docs/advisories/safety-and-risk.md", "forgectrl:tests/host_setup.sh",
|
||||
"forgectrl:.github/workflows/build.yml"),
|
||||
("forgectrl", "docs/advisories/licenses.md", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
|
||||
("forgectrl", "docs/advisories/privacy.md", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
|
||||
("forgectrl", "docs/advisories/cloud-service.md", "forgectrl:tests/host_setup.sh",
|
||||
"forgectrl:.github/workflows/build.yml"),
|
||||
("forgectrl", "src/button.*", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
|
||||
("forgectrl", "src/led.*", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
|
||||
("forgectrl", "src/hooks.h", "forgectrl:tests/host_setup.sh", "forgectrl:.github/workflows/build.yml"),
|
||||
# The setup sheet id from the fuse serial, against golden ids.
|
||||
("forgectrl", "src/sheetid.*", "forgectrl:tests/sheetid_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||
# The logging path: the image's rsyslog.conf and the rendered rules, each
|
||||
# logger in its own file at its level, a stray tag in system/, the kern
|
||||
# facility in kernel/.
|
||||
("forgectrl", "src/fflog.*", "forgectrl:tests/fflog_e2e.sh", "forgectrl:.github/workflows/build.yml"),
|
||||
# POST /job's multipart reader: the form's parts reach the runner's
|
||||
# start; a missing name, a $ line and a malformed boundary are refused
|
||||
# with nothing started.
|
||||
("forgectrl", "src/jobpost.*", "forgectrl:tests/jobpost_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||
# The scoped tokens: the closed capability list, the judgment, the
|
||||
# store that holds only hashes, and the extension host's credential
|
||||
# (mode 0600, exactly the host's capabilities).
|
||||
("forgectrl", "src/tokens.*", "forgectrl:tests/tokens_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||
# The logging tree's paths, the level and target validators, the
|
||||
# configured levels.
|
||||
("forgectrl", "src/logs.*", "forgectrl:tests/logs_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||
# The extension host, each part to the test that drives it (the
|
||||
# freeze, the supervisor, the machine client and the groups stay with
|
||||
# exthost.armed-freeze, which covers them).
|
||||
("forgeext", "src/api.*", "forgeext:tests/api_test.c", "forgeext:.github/workflows/build.yml"),
|
||||
("forgeext", "src/caps.*", "forgeext:tests/caps_test.c", "forgeext:.github/workflows/build.yml"),
|
||||
("forgeext", "src/evfeed.*", "forgeext:tests/evfeed_test.c", "forgeext:.github/workflows/build.yml"),
|
||||
("forgeext", "src/holdkeep.*", "forgeext:tests/holdkeep_test.c", "forgeext:.github/workflows/build.yml"),
|
||||
("forgeext", "src/httpreq.*", "forgeext:tests/httpreq_test.c", "forgeext:.github/workflows/build.yml"),
|
||||
("forgeext", "src/manifest.*", "forgeext:tests/manifest_test.c", "forgeext:.github/workflows/build.yml"),
|
||||
("forgeext", "src/quota.*", "forgeext:tests/quota_test.c", "forgeext:.github/workflows/build.yml"),
|
||||
("forgeext", "src/sandbox.*", "forgeext:tests/sandbox_test.c", "forgeext:.github/workflows/build.yml"),
|
||||
("forgeext", "src/settings.*", "forgeext:tests/settings_test.c", "forgeext:.github/workflows/build.yml"),
|
||||
("forgeext", "src/state.*", "forgeext:tests/state_test.c", "forgeext:.github/workflows/build.yml"),
|
||||
("forgeext", "src/netrules.*", "forgeext:tests/netrules_test.py", "forgeext:.github/workflows/build.yml"),
|
||||
# A package's page asking its own service (forgeext call).
|
||||
("forgeext", "src/call.*", "forgeext:tests/sdk_test.py", "forgeext:.github/workflows/build.yml"),
|
||||
# Archives in and refused, and the signed index.
|
||||
("forgeext", "src/install.*", "forgeext:tests/install_test.py", "forgeext:.github/workflows/build.yml"),
|
||||
("forgeext", "src/pkg.*", "forgeext:tests/install_test.py", "forgeext:.github/workflows/build.yml"),
|
||||
("forgeext", "src/index.*", "forgeext:tests/install_test.py", "forgeext:.github/workflows/build.yml"),
|
||||
# The daemon as built, end to end: its command line, its log, the
|
||||
# killed host under the init script's wrapper.
|
||||
("forgeext", "src/main.c", "forgeext:tests/run_test.py", "forgeext:.github/workflows/build.yml"),
|
||||
("forgeext", "src/fflog.*", "forgeext:tests/run_test.py", "forgeext:.github/workflows/build.yml"),
|
||||
("forgeext", "CMakeLists.txt", "forgeext:tests/run_test.py", "forgeext:.github/workflows/build.yml"),
|
||||
]
|
||||
|
||||
# This repository's root, when the package runs from a checkout of it; a
|
||||
# "forgefirm:" file is looked for there. Installed on an image, the package
|
||||
# is not in a checkout, and such a reference is not checked.
|
||||
_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
REPO_ROOT = _ROOT if os.path.isdir(os.path.join(_ROOT, "meta-forgefirm")) else None
|
||||
# The pinned sources scripts/manifest-from-tree.py fetches (one bare repo
|
||||
# per source URL); a component's workflow is read from there. Without them
|
||||
# (on an image) whether a workflow runs a host test is not checked.
|
||||
SOURCES = os.path.join(REPO_ROOT, ".manifest-cache") if REPO_ROOT else None
|
||||
|
||||
|
||||
def non_behavioral(comp, path, allow=NON_BEHAVIORAL):
|
||||
for c, pat in BEHAVIORAL:
|
||||
if c == comp and glob_to_regex(pat).match(path):
|
||||
return False
|
||||
for c, pat in allow:
|
||||
if c in ("*", comp) and glob_to_regex(pat).match(path):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class Manifest:
|
||||
def __init__(self, data):
|
||||
self.data = data
|
||||
self.components = data.get("components", {}) or {}
|
||||
self.platform = data.get("platform", {}) or {}
|
||||
self.image = data.get("image", {}) or {}
|
||||
self.content_sha = data.get("content_sha256")
|
||||
self._files = {}
|
||||
|
||||
@classmethod
|
||||
def load(cls, path=None):
|
||||
path = path or os.environ.get("FORGETEST_MANIFEST") or DEFAULT_PATH
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return cls(json.load(f))
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, text):
|
||||
return cls(json.loads(text))
|
||||
|
||||
@property
|
||||
def version(self):
|
||||
return self.image.get("version") or "unknown"
|
||||
|
||||
@property
|
||||
def image_name(self):
|
||||
return self.image.get("name") or "unknown"
|
||||
|
||||
def files(self, component):
|
||||
"""The (path, blob) pairs of a component, or None if the component
|
||||
is not in this manifest. Built once per component: the manifest is
|
||||
immutable and every coverage glob asks for the same lists."""
|
||||
if component in self._files:
|
||||
return self._files[component]
|
||||
c = self.components.get(component)
|
||||
out = None if c is None else [tuple(x) for x in c.get("files", [])]
|
||||
self._files[component] = out
|
||||
return out
|
||||
|
||||
def component_names(self):
|
||||
return sorted(self.components)
|
||||
|
||||
def identity_sha(self):
|
||||
"""sha256 of the acceptance-relevant identity: every component
|
||||
except the dev-only ones, plus the platform. Informational (the
|
||||
gate decides per test, by fingerprint)."""
|
||||
comps = {k: v for k, v in self.components.items() if k not in DEV_ONLY_COMPONENTS}
|
||||
return sha256_text(canonical({"components": comps, "platform": self.platform}))
|
||||
|
||||
|
||||
def fingerprint(manifest, covers, extra=()):
|
||||
"""The domain fingerprint of a coverage map on a manifest.
|
||||
|
||||
covers: iterable of (component, glob). extra: strings folded in after
|
||||
the files (the test's own implementation hash). A component the
|
||||
manifest lacks contributes a marker so the fingerprint is still
|
||||
defined and distinct.
|
||||
"""
|
||||
parts = set()
|
||||
for comp, pat in covers:
|
||||
if comp in DEV_ONLY_COMPONENTS:
|
||||
raise ValueError("coverage may not name the dev-only component %r" % comp)
|
||||
files = manifest.files(comp)
|
||||
if files is None:
|
||||
parts.add((comp, "@missing", ""))
|
||||
continue
|
||||
for p, b in match_files(files, pat):
|
||||
if non_behavioral(comp, p):
|
||||
continue
|
||||
parts.add((comp, p, b))
|
||||
h = hashlib.sha256()
|
||||
h.update(canonical(sorted(parts)).encode("utf-8"))
|
||||
h.update(b"\n")
|
||||
h.update(canonical(manifest.platform).encode("utf-8"))
|
||||
for e in extra:
|
||||
h.update(b"\n")
|
||||
h.update(str(e).encode("utf-8"))
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def empty_covers(manifest, tests):
|
||||
"""Coverage entries that select no file of their component: a glob
|
||||
that never matched (paths anchor at the repository root, so a file
|
||||
under a recipe's subdirectory needs that directory in the glob), or
|
||||
a component not in the manifest. Such an entry covers nothing, and
|
||||
the test's fingerprint would not move with the file it meant.
|
||||
Returns [(test id, component, glob)]. A glob selecting only
|
||||
non-behavioral paths is hollow too: nothing it names is fingerprinted."""
|
||||
out = []
|
||||
for t in tests:
|
||||
for comp, pat in t.covers:
|
||||
files = manifest.files(comp)
|
||||
if files is None or not [p for p, _b in match_files(files, pat) if not non_behavioral(comp, p)]:
|
||||
out.append((t.id, comp, pat))
|
||||
return out
|
||||
|
||||
|
||||
def _ref_problem(manifest, ref, what, root):
|
||||
"""Why a "<repository>:<path>" reference names no file, or None. A
|
||||
component's file must be in the manifest; a "forgefirm:" one must exist
|
||||
under `root`, and is not checked without one."""
|
||||
repo, sep, path = ref.partition(":")
|
||||
if not sep or not path:
|
||||
return "names no repository:path for its %s" % what
|
||||
if repo == "forgefirm":
|
||||
if root is not None and not os.path.isfile(os.path.join(root, path)):
|
||||
return "no such %s" % what
|
||||
return None
|
||||
if repo in DEV_ONLY_COMPONENTS or manifest.files(repo) is None:
|
||||
return "no repository %r for its %s" % (repo, what)
|
||||
if path not in {p for p, _b in manifest.files(repo)}:
|
||||
return "no such %s" % what
|
||||
return None
|
||||
|
||||
|
||||
def read_source(manifest, ref, root=REPO_ROOT, sources=SOURCES):
|
||||
"""(text, why) for a "<repository>:<path>" file: a "forgefirm:" file
|
||||
from `root`, a component's from its pinned blob in the bare repo
|
||||
`sources` holds for its source URL. (None, None) when it cannot be read
|
||||
here (no root, no sources); (None, why) when it should have been and
|
||||
was not, which is a defect, never a pass."""
|
||||
import subprocess
|
||||
repo, _sep, path = ref.partition(":")
|
||||
if repo == "forgefirm":
|
||||
if root is None:
|
||||
return None, None
|
||||
try:
|
||||
with open(os.path.join(root, path), encoding="utf-8") as f:
|
||||
return f.read(), None
|
||||
except (OSError, UnicodeDecodeError) as e:
|
||||
return None, "cannot be read: %s" % e
|
||||
if not sources:
|
||||
return None, None
|
||||
comp = manifest.components.get(repo) or {}
|
||||
blob = dict(manifest.files(repo) or []).get(path)
|
||||
url = comp.get("source")
|
||||
if not (blob and url):
|
||||
return None, "cannot be read: no blob or source URL in the manifest"
|
||||
cache = os.path.join(sources, hashlib.sha1(url.encode()).hexdigest()[:16])
|
||||
try:
|
||||
return subprocess.run(["git", "cat-file", "-p", blob], cwd=cache, capture_output=True,
|
||||
check=True).stdout.decode("utf-8", "replace"), None
|
||||
except (OSError, subprocess.CalledProcessError) as e:
|
||||
return None, "cannot be read from %s: %s" % (cache, e)
|
||||
|
||||
|
||||
def repo_name(manifest, repo):
|
||||
"""A repository's GitHub name: its source URL's last part, or this
|
||||
repository's."""
|
||||
if repo == "forgefirm":
|
||||
return "forgefirm"
|
||||
url = ((manifest.components.get(repo) or {}).get("source") or "").rstrip("/")
|
||||
name = url.rsplit("/", 1)[-1]
|
||||
return name[:-4] if name.endswith(".git") else name
|
||||
|
||||
|
||||
_STEP_KEYS = ("name", "uses", "run", "id", "if", "with", "env", "working-directory", "shell",
|
||||
"continue-on-error", "timeout-minutes")
|
||||
_STEP_RX = re.compile(r"^(\s*)-\s+(%s)\s*:" % "|".join(re.escape(k) for k in _STEP_KEYS))
|
||||
_KEY_RX = re.compile(r"^(\s*)([\w-]+)\s*:\s*(.*)$")
|
||||
|
||||
|
||||
def _indent(line):
|
||||
return len(line) - len(line.lstrip(" "))
|
||||
|
||||
|
||||
def workflow_steps(text):
|
||||
"""A workflow's steps, each a dict of its keys: "uses", "run" (the
|
||||
command text, a block's lines joined), "working-directory", and "with"
|
||||
(a dict). YAML read by its indentation, as far as these workflows use
|
||||
it; comments, whole lines and from ' #' to the end of a line, are not
|
||||
read. Anything outside a step (a trigger's paths, a job's name) is not
|
||||
a step."""
|
||||
lines = []
|
||||
for ln in text.splitlines():
|
||||
if ln.lstrip().startswith("#"):
|
||||
continue
|
||||
lines.append(re.sub(r"\s+#.*$", "", ln).rstrip())
|
||||
steps, i = [], 0
|
||||
while i < len(lines):
|
||||
m = _STEP_RX.match(lines[i])
|
||||
if not m:
|
||||
i += 1
|
||||
continue
|
||||
dash = len(m.group(1))
|
||||
body = [" " * (dash + 2) + lines[i][dash + 1:].lstrip()]
|
||||
i += 1
|
||||
while i < len(lines) and (not lines[i].strip() or _indent(lines[i]) > dash):
|
||||
body.append(lines[i])
|
||||
i += 1
|
||||
step, k = {}, 0
|
||||
key_col = _indent(body[0])
|
||||
while k < len(body):
|
||||
km = _KEY_RX.match(body[k])
|
||||
if not km or _indent(body[k]) != key_col:
|
||||
k += 1
|
||||
continue
|
||||
key, val = km.group(2), km.group(3).strip()
|
||||
if len(val) > 1 and val[0] == val[-1] and val[0] in "'\"":
|
||||
val = val[1:-1] # a quoted scalar, not a quote inside a command
|
||||
k += 1
|
||||
child = []
|
||||
while k < len(body) and (not body[k].strip() or _indent(body[k]) > key_col):
|
||||
child.append(body[k])
|
||||
k += 1
|
||||
if key == "with":
|
||||
step["with"] = {c.group(2): c.group(3).strip().strip("'\"")
|
||||
for c in (_KEY_RX.match(x) for x in child) if c}
|
||||
elif key == "run":
|
||||
step["run"] = "\n".join(x.strip() for x in child) if val[:1] in ("|", ">") else val
|
||||
else:
|
||||
step[key] = val
|
||||
steps.append(step)
|
||||
return steps
|
||||
|
||||
|
||||
def checkout_path(steps, name, own):
|
||||
"""Where a workflow checks the repository `name` out (its path, "" for
|
||||
the workspace), or None when it does not: its own repository is the
|
||||
checkout that names no repository, another one the checkout whose
|
||||
repository is <owner>/<name>."""
|
||||
for s in steps:
|
||||
if not s.get("uses", "").startswith("actions/checkout"):
|
||||
continue
|
||||
w = s.get("with", {})
|
||||
repo = w.get("repository")
|
||||
if (repo is None and own) or (repo is not None and repo.rsplit("/", 1)[-1].lower() == name.lower()):
|
||||
return posixpath.normpath(w.get("path") or ".")
|
||||
return None
|
||||
|
||||
|
||||
_SPLIT_RX = re.compile(r"&&|\|\||[;|\n]|(?:^|(?<=\s))(?:then|do|else)(?=\s|$)")
|
||||
_PREFIXES = ("sudo", "env", "time", "exec", "command", "nice")
|
||||
_INTERPRETER_RX = re.compile(r"^(?:python[0-9.]*|bash|sh)$")
|
||||
|
||||
|
||||
def _commands(run):
|
||||
"""The simple commands of a step's run text, each as its words, with
|
||||
what only starts the real command taken off: sudo and its flags, env,
|
||||
time, exec, and variable assignments."""
|
||||
import shlex
|
||||
out = []
|
||||
for part in _SPLIT_RX.split(run.replace("\\\n", " ")):
|
||||
try:
|
||||
words = shlex.split(part)
|
||||
except ValueError:
|
||||
words = part.split()
|
||||
while words:
|
||||
if words[0] in _PREFIXES:
|
||||
words = words[1:]
|
||||
while words and words[0].startswith("-"):
|
||||
words = words[1:]
|
||||
elif re.match(r"^[A-Za-z_]\w*=", words[0]):
|
||||
words = words[1:]
|
||||
else:
|
||||
break
|
||||
if words:
|
||||
out.append(words)
|
||||
return out
|
||||
|
||||
|
||||
def _segments_match(path, pattern):
|
||||
"""A path against a path or a glob, one segment against one segment
|
||||
('*' never crosses a '/')."""
|
||||
import fnmatch
|
||||
a, b = path.split("/"), pattern.split("/")
|
||||
return len(a) == len(b) and all(fnmatch.fnmatchcase(x, y) for x, y in zip(a, b))
|
||||
|
||||
|
||||
def _discovers(args, full, at):
|
||||
"""Whether `python -m unittest discover ARGS` finds the test: its
|
||||
directory is the start directory (-s, default the working directory)
|
||||
and its file name matches the pattern (-p, default test*.py)."""
|
||||
import fnmatch
|
||||
start, pattern = ".", "test*.py"
|
||||
for i, a in enumerate(args):
|
||||
if a in ("-s", "--start-directory") and i + 1 < len(args):
|
||||
start = args[i + 1]
|
||||
elif a in ("-p", "--pattern") and i + 1 < len(args):
|
||||
pattern = args[i + 1]
|
||||
return posixpath.dirname(full) == at(start) and fnmatch.fnmatchcase(posixpath.basename(full), pattern)
|
||||
|
||||
|
||||
def _unittest_names(args, full, at):
|
||||
"""Whether `python -m unittest ARGS` (not discover) runs the test: an
|
||||
argument is its file (tests/test_x.py), or a dotted name whose module
|
||||
is its file (tests.test_x, tests.test_x.Case.test_one)."""
|
||||
skip = False
|
||||
for a in args:
|
||||
if skip:
|
||||
skip = False
|
||||
continue
|
||||
if a in ("-k",):
|
||||
skip = True
|
||||
continue
|
||||
if a.startswith("-") or "$" in a:
|
||||
continue
|
||||
if a.endswith(".py"):
|
||||
if at(a) == full:
|
||||
return True
|
||||
continue
|
||||
parts = a.split(".")
|
||||
if any(at("/".join(parts[:k]) + ".py") == full for k in range(len(parts), 0, -1)):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _pytest_runs(args, full, at):
|
||||
"""Whether pytest ARGS collects the test: named, or its directory given
|
||||
and its file name one pytest collects (test_*.py, *_test.py)."""
|
||||
import fnmatch
|
||||
name = posixpath.basename(full)
|
||||
collected = fnmatch.fnmatchcase(name, "test_*.py") or fnmatch.fnmatchcase(name, "*_test.py")
|
||||
for a in args:
|
||||
if a.startswith("-") or "$" in a:
|
||||
continue
|
||||
if at(a) == full or (collected and at(a) == posixpath.dirname(full)):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def workflow_runs(steps, test_path):
|
||||
"""Whether a step of the workflow runs the test at test_path (its path
|
||||
in the workspace: the checkout's path joined to the test's path in its
|
||||
repository). Only what a step's run commands execute counts, each path
|
||||
resolved against the step's working-directory: the command itself
|
||||
(a script run directly, or a C test's built binary, <dir>/<stem>);
|
||||
the script an interpreter runs (python, bash, sh), a glob matching the
|
||||
whole path; a for loop's list over the test; python -m unittest given
|
||||
the test's file or a dotted name whose module is that file
|
||||
(tests.test_x, tests.test_x.Case.test_one); unittest discover from the
|
||||
test's own directory with a pattern its name matches; pytest on the
|
||||
test or its own directory. An argument to anything else (echo, a build,
|
||||
an install) does not run the test."""
|
||||
full = posixpath.normpath(test_path)
|
||||
stem, ext = posixpath.splitext(posixpath.basename(full))
|
||||
compiled = ext in (".c", ".cc", ".cpp")
|
||||
for s in steps:
|
||||
run = s.get("run")
|
||||
if not run:
|
||||
continue
|
||||
wd = s.get("working-directory") or "."
|
||||
|
||||
def at(p):
|
||||
return posixpath.normpath(posixpath.join(wd, p))
|
||||
|
||||
def names(p):
|
||||
return "$" not in p and _segments_match(full, at(p))
|
||||
for words in _commands(run):
|
||||
cmd, args = words[0], words[1:]
|
||||
if cmd == "for" and "in" in args:
|
||||
if any(names(p) for p in args[args.index("in") + 1:]):
|
||||
return True
|
||||
elif _INTERPRETER_RX.match(posixpath.basename(cmd)):
|
||||
if "-m" in args:
|
||||
i = args.index("-m")
|
||||
mod, rest = (args[i + 1], args[i + 2:]) if i + 1 < len(args) else ("", [])
|
||||
if mod == "unittest" and rest[:1] == ["discover"]:
|
||||
if _discovers(rest[1:], full, at):
|
||||
return True
|
||||
elif (mod == "unittest" and _unittest_names(rest, full, at)) or \
|
||||
(mod == "pytest" and _pytest_runs(rest, full, at)):
|
||||
return True
|
||||
else:
|
||||
script = next((a for a in args if not a.startswith("-")), None)
|
||||
if script and names(script):
|
||||
return True
|
||||
elif posixpath.basename(cmd) == "pytest":
|
||||
if _pytest_runs(args, full, at):
|
||||
return True
|
||||
elif names(cmd) or (compiled and "/" in cmd and posixpath.basename(cmd) == stem):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def host_row_problems(manifest, host=HOST_TESTED, root=REPO_ROOT, sources=SOURCES):
|
||||
"""HOST_TESTED rows that prove nothing: a glob that selects no
|
||||
behavioral file of its component (the same defect as an empty covers
|
||||
entry), a host test or a workflow that does not exist, a workflow that
|
||||
cannot be read where it should be, one that does not check out the
|
||||
test's repository, or one with no step that runs the test. Whether a
|
||||
workflow runs its test is not checked where it cannot be read (see
|
||||
read_source). Returns [(component, glob, host test, why)]."""
|
||||
out = []
|
||||
for comp, pat, ref, ci in host:
|
||||
files = manifest.files(comp)
|
||||
if files is None or not [p for p, _b in match_files(files, pat) if not non_behavioral(comp, p)]:
|
||||
out.append((comp, pat, ref, "selects nothing"))
|
||||
why = _ref_problem(manifest, ref, "host test", root) or _ref_problem(manifest, ci, "workflow", root)
|
||||
if why:
|
||||
out.append((comp, pat, ref, why))
|
||||
continue
|
||||
text, why = read_source(manifest, ci, root, sources)
|
||||
if why:
|
||||
out.append((comp, pat, ref, "%s %s" % (ci, why)))
|
||||
continue
|
||||
if text is None:
|
||||
continue
|
||||
steps = workflow_steps(text)
|
||||
test_repo, _sep, test_path = ref.partition(":")
|
||||
prefix = checkout_path(steps, repo_name(manifest, test_repo), test_repo == ci.partition(":")[0])
|
||||
if prefix is None:
|
||||
out.append((comp, pat, ref, "%s does not check out %s" % (ci, test_repo)))
|
||||
elif not workflow_runs(steps, posixpath.join(prefix, test_path)):
|
||||
out.append((comp, pat, ref, "%s does not run it" % ci))
|
||||
return out
|
||||
|
||||
|
||||
def coverage_report(manifest, tests, allow=NON_BEHAVIORAL, host=HOST_TESTED):
|
||||
"""Which manifest paths neither a test nor a host test covers.
|
||||
|
||||
tests: iterable with .covers. allow: iterable of (component, glob)
|
||||
that need no coverage (docs, CI, licenses...); a BEHAVIORAL path is
|
||||
never allowed away, as it is never taken out of a fingerprint. host:
|
||||
the HOST_TESTED rows, whose paths count as covered. Returns
|
||||
{component: [uncovered paths]} for the non-dev-only components.
|
||||
"""
|
||||
covered = {}
|
||||
for t in tests:
|
||||
for comp, pat in t.covers:
|
||||
covered.setdefault(comp, []).append(glob_to_regex(pat))
|
||||
for comp, pat, _ref, _ci in host:
|
||||
covered.setdefault(comp, []).append(glob_to_regex(pat))
|
||||
allowed = {}
|
||||
for comp, pat in allow:
|
||||
allowed.setdefault(comp, []).append(glob_to_regex(pat))
|
||||
report = {}
|
||||
for comp in manifest.component_names():
|
||||
if comp in DEV_ONLY_COMPONENTS:
|
||||
continue
|
||||
rxs = covered.get(comp, [])
|
||||
free = allowed.get(comp, []) + allowed.get("*", [])
|
||||
behavior = [glob_to_regex(pat) for c, pat in BEHAVIORAL if c == comp]
|
||||
missing = []
|
||||
for p, _b in manifest.files(comp):
|
||||
if any(rx.match(p) for rx in rxs):
|
||||
continue
|
||||
if any(rx.match(p) for rx in free) and not any(rx.match(p) for rx in behavior):
|
||||
continue
|
||||
missing.append(p)
|
||||
if missing:
|
||||
report[comp] = sorted(missing)
|
||||
return report
|
||||
@@ -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)
|
||||
@@ -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:]
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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"))
|
||||
@@ -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)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user