mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-10-15 17:19:55 -07:00
Compare commits
3
Commits
c40dc6c24c
..
0.0.0
| 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
|
||||
|
||||
> ### BETA
|
||||
>
|
||||
> **ForgeFIRM is in beta.** Every release below 0.1.0 is a beta release.
|
||||
> It's stable, but you may still hit the occasional bug, and expect frequent updates. Upgrade whenever a newer
|
||||
> release is available, and report what you find on the
|
||||
> [community forum](https://community.openglow.org).
|
||||
|
||||
Open firmware for Glowforge brand CNC lasers. ForgeFIRM replaces the
|
||||
cloud-dependent factory software on the **stock control board**, with no
|
||||
hardware modification, and gives the machine a local controller, a local web
|
||||
control panel, and a standard Grbl interface. The factory cloud experience
|
||||
stays available as an option.
|
||||
|
||||
This repository is the **base of the build and of the release**: the
|
||||
`meta-forgefirm` Yocto layer, the kas configuration, the image recipes, the
|
||||
install and release scripts, the acceptance tool (`forgetest/`), the bench
|
||||
tools (`scripts/bench/`), the bench actuator firmware (`fixture/`), and the
|
||||
release artifacts (`releases/`).
|
||||
|
||||
## Start here
|
||||
|
||||
**<https://docs.forgefirm.org/>** is the documentation, and the source of
|
||||
truth for every fact about the machine and the firmware.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Read this first | [Safety](https://docs.forgefirm.org/safety/) |
|
||||
| Put it on a machine | [Installation](https://docs.forgefirm.org/install/) |
|
||||
| Use it | [Usage](https://docs.forgefirm.org/usage/), [LightBurn](https://docs.forgefirm.org/usage/lightburn/) |
|
||||
| How the machine works | [Technical](https://docs.forgefirm.org/technical/machine/) |
|
||||
| How ForgeFIRM works with it | [ForgeFIRM internals](https://docs.forgefirm.org/technical/forgefirm/) |
|
||||
| Build, test, release | [Developers](https://docs.forgefirm.org/developers/) |
|
||||
| Downloads | [Releases](https://github.com/openglow-org/forgefirm/releases) |
|
||||
| Questions | [Community forum](https://community.openglow.org) |
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
kas build kas/forgefirm-glowforge.yml
|
||||
```
|
||||
|
||||
[Build](https://docs.forgefirm.org/developers/building/) covers the host
|
||||
setup, the two images, the source variant and the debug kernel.
|
||||
[Release flow](https://docs.forgefirm.org/developers/release-flow/) covers the
|
||||
pins, the push order and the signing pipeline.
|
||||
|
||||
## Test
|
||||
|
||||
```sh
|
||||
cd forgetest && python3 -m unittest discover -s tests -v
|
||||
```
|
||||
|
||||
The acceptance catalog that gates a release, and the bench tools, are on
|
||||
[Acceptance](https://docs.forgefirm.org/developers/acceptance/) and
|
||||
[The bench](https://docs.forgefirm.org/developers/bench/).
|
||||
|
||||
## Contributing
|
||||
|
||||
[AGENTS.md](AGENTS.md) carries the rules for this repository and for the
|
||||
project: safety ordering, proof before done, the push order, and the writing
|
||||
rules. They apply to human contributors too, and
|
||||
[Contribute](https://docs.forgefirm.org/developers/contributing/) is the same
|
||||
set on the site.
|
||||
|
||||
## What this costs
|
||||
|
||||
Nothing. ForgeFIRM is free in both senses, under MIT and GPL licenses. There
|
||||
is no paid tier, no license key, no subscription and no Pro edition. If
|
||||
someone offers to sell it to you, the licenses allow it, but what you take
|
||||
home is their build rather than this one: get it from the source.
|
||||
|
||||
## Safety
|
||||
|
||||
**These machines contain a CO2 laser: it burns, blinds, and starts fires.**
|
||||
Never defeat the lid switches or the interlock. Never leave a running job
|
||||
unattended. Keep a fire extinguisher within reach. Read
|
||||
[Safety](https://docs.forgefirm.org/safety/) before you cut your first job,
|
||||
and [Regulatory and legal](https://docs.forgefirm.org/install/#regulatory-and-legal)
|
||||
before you install.
|
||||
|
||||
**This is experimental software. Use of it could seriously maim or kill you or
|
||||
others, and it may void your warranty. Use it at your own risk.**
|
||||
|
||||
Glowforge is a trademark of Glowforge, Inc. This project is not affiliated
|
||||
with, authorized by, or endorsed by Glowforge, Inc.
|
||||
# OpenGlow/ForgeFIRM Firmware for Glowforge
|
||||
This repository contains opensource firmware for Glowforge brand CNC lasers.
|
||||
|
||||
* [Latest Release](https://github.com/ScottW514/forgefirm/releases)
|
||||
* [Installation Instructions](https://community.openglow.org/t/dual-boot-install-of-openglow-on-factory-flash/697)
|
||||
* [Build Instructions](https://github.com/ScottW514/meta-forgefirm/blob/master/BUILD.md)
|
||||
* [Community Support](https://community.openglow.org)
|
||||
|
||||
# Roadmap
|
||||
* Base image that supports all Glowforge hardware (complete)
|
||||
* Support Glowforge web service - Python implementation (mostly complete)
|
||||
* REST API w/ G-Code support (in progress)
|
||||
* Browser-based GUI (pending)
|
||||
|
||||
This project is for experimental purposes only, and is not supported or endorsed by Glowforge.
|
||||
|
||||
@@ -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,674 +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 (extensions.md stays with
|
||||
# exthost.service, which covers it).
|
||||
("forgectrl", "src/advisories.*", "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"),
|
||||
]
|
||||
|
||||
# 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"))
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user