mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-10-15 17:19:55 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16d27f0887 | ||
|
|
1bd7793744 | ||
|
|
a1d158514b |
@@ -1,4 +0,0 @@
|
||||
# Every text file is stored and checked out with LF; a patch keeps its bytes.
|
||||
* text=auto eol=lf
|
||||
*.patch -text
|
||||
*.diff -text
|
||||
@@ -1,57 +0,0 @@
|
||||
# forgefixture: the bench actuator's firmware, proven on the host.
|
||||
#
|
||||
# - the policy test: the decisions that need no hardware (channel
|
||||
# names, the loop request words, the button pulse clamp, the key
|
||||
# comparison), compiled with gcc and run
|
||||
# - the firmware build: the whole project in the pinned ESP-IDF
|
||||
# container, with a placeholder fixture.env, so a change that does
|
||||
# not compile never reaches a bench
|
||||
|
||||
name: fixture-ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'fixture/**'
|
||||
- '.github/workflows/fixture-ci.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'fixture/**'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
policy:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Policy host test
|
||||
working-directory: fixture
|
||||
run: CC=gcc sh test/run.sh
|
||||
|
||||
firmware:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
container:
|
||||
image: espressif/idf:v5.5.5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Placeholder fixture.env (the build needs one; the values are not used)
|
||||
working-directory: fixture
|
||||
run: |
|
||||
printf 'WIFI_SSID=ci-network\nWIFI_PSK=ci-passphrase\nAPI_KEY=%s\nHOSTNAME=forgefixture\n' \
|
||||
0123456789abcdef0123456789abcdef > fixture.env
|
||||
- name: Build
|
||||
working-directory: fixture
|
||||
shell: bash
|
||||
run: |
|
||||
. /opt/esp/idf/export.sh >/dev/null
|
||||
idf.py set-target esp32s3 >/dev/null
|
||||
idf.py build
|
||||
- name: Size
|
||||
working-directory: fixture
|
||||
shell: bash
|
||||
run: |
|
||||
. /opt/esp/idf/export.sh >/dev/null
|
||||
idf.py size | tail -n 20
|
||||
@@ -1,91 +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
|
||||
# - coverage lint: every source path of every component pinned by the
|
||||
# recipes must be selected by some catalog test's coverage globs (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/acceptance/).
|
||||
|
||||
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: 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: Unit tests
|
||||
working-directory: forgefirm/forgetest
|
||||
# -B: no bytecode caches under the package directory (the recipe's fetch
|
||||
# checksum would otherwise move without a source change)
|
||||
run: python -B -m unittest discover -s tests -v
|
||||
|
||||
- name: No undefined names (the live drills never execute on the host)
|
||||
working-directory: forgefirm/forgetest
|
||||
run: |
|
||||
pip install pyflakes
|
||||
if python -B -m pyflakes forgetest tests | grep -E "undefined name"; then exit 1; fi
|
||||
|
||||
- name: Shared UI files match forgectrl at its pinned revision
|
||||
working-directory: forgefirm
|
||||
run: python scripts/check-ui-vendor.py
|
||||
|
||||
- name: Coverage lint (enforced)
|
||||
working-directory: forgefirm/forgetest
|
||||
run: python -B -m forgetest.coverage --manifest ../tree-manifest.json --enforce | tee "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Gate self-check on the tree manifest
|
||||
working-directory: forgefirm
|
||||
run: |
|
||||
# The gate must load the catalog and refuse a missing/unauthorized
|
||||
# artifact cleanly (exit 1/2, never a traceback).
|
||||
set +e
|
||||
echo '{}' > empty.json
|
||||
python scripts/acceptance-gate.py empty.json tree-manifest.json --quiet
|
||||
rc=$?
|
||||
set -e
|
||||
test "$rc" -eq 1
|
||||
@@ -1,39 +0,0 @@
|
||||
# The extension sandbox's deny rules, proven on real traffic.
|
||||
#
|
||||
# scripts/sandbox-rules-test.py loads meta-forgefirm's ffx.nft into a network
|
||||
# namespace of its own, joins a second namespace to it with a veth pair (a
|
||||
# peer that is not the machine), and sends from real uids: a pool uid is
|
||||
# refused on loopback, at its own LAN address, and at the peer (TCP at once,
|
||||
# UDP with EPERM), the uids on either side of the pool and root are not
|
||||
# touched, an allowlist chain opens one port of the peer to one uid, the
|
||||
# machine's own addresses stay shut even on an allowlist, and loading the
|
||||
# file again takes the allowlist away. The
|
||||
# runner's nft is the version the image carries or newer; the kernel side is
|
||||
# the image's own business and is proven on the machine (exthost.platform).
|
||||
|
||||
name: sandbox-ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'meta-forgefirm/recipes-forgefirm/forgefirm-sandbox/**'
|
||||
- 'scripts/sandbox-rules-test.py'
|
||||
- '.github/workflows/sandbox-ci.yml'
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
rules:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: nft
|
||||
run: sudo apt-get update && sudo apt-get install -y nftables
|
||||
|
||||
- name: The deny rules on real traffic
|
||||
run: |
|
||||
# 77 is the script's "could not run here": on this runner that is a failure.
|
||||
sudo python3 -B scripts/sandbox-rules-test.py
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
# kas-managed upstream layer checkouts (poky, meta-openembedded, meta-freescale...)
|
||||
/layers/
|
||||
|
||||
# Yocto build output (incl. deploy/images) and caches
|
||||
/build/
|
||||
/downloads/
|
||||
/sstate-cache/
|
||||
|
||||
# kas
|
||||
*.lock.yml.bak
|
||||
|
||||
# release.sh staging output
|
||||
/release-staging/
|
||||
|
||||
# Python bytecode
|
||||
__pycache__/
|
||||
|
||||
# manifest-from-tree.py fetch cache
|
||||
/.manifest-cache/
|
||||
|
||||
# manifest-from-tree.py default output
|
||||
/tree-manifest.json
|
||||
@@ -1,407 +0,0 @@
|
||||
# AGENTS.md
|
||||
|
||||
Instructions for AI coding agents that work in this repository. A human
|
||||
contributor is welcome to read it too: it is the same set of rules.
|
||||
|
||||
## ForgeFIRM repository rules
|
||||
|
||||
### Safety on the machine
|
||||
|
||||
- The laser never fires with the coolant pump deliberately off. The heater-only
|
||||
flow-calibrate and flow-verify tools are the only pump-off state, and the
|
||||
tube stays dark during them.
|
||||
- The 40 V motor rail stays up while the machine is on. Never cycle it for a
|
||||
controller handover. A rail glitch can leave the stepper drivers
|
||||
unserviceable.
|
||||
|
||||
### The iteration loop
|
||||
|
||||
- Edit, cross-build in the Yocto build environment, hot-deploy to the
|
||||
machine, prove. Then one commit, one push, one pin bump for the proven
|
||||
change.
|
||||
- A full image build happens once, at the end of a work item, never per fix.
|
||||
- Kernel and BSP changes ride one image flash, batched. Validate a `.ko` or
|
||||
overlay change on the image that ships it. Never hot-swap a `.ko` onto a
|
||||
board that is about to be reflashed.
|
||||
|
||||
### Acceptance coverage
|
||||
|
||||
- Evaluate every change against the acceptance catalog in
|
||||
`forgetest/forgetest/suite/`. Does an existing test exercise the changed
|
||||
behavior? Does its `covers` map name the files you touched? If not, add or
|
||||
widen one in the same change. The coverage lint
|
||||
(`python3 -m forgetest.coverage --enforce`) is the floor.
|
||||
- The `SRCREV` of a component and the `PV` that moves with it live in
|
||||
`<recipe>-pin.inc` next to the recipe. Nothing else goes in that file. The
|
||||
image manifest keeps `*-pin.inc` out of the layer content hash. Thus a pin
|
||||
bump invalidates only the acceptance tests that cover that component. A
|
||||
pin in a recipe body counts as a platform change and needs a full
|
||||
acceptance campaign.
|
||||
- Test a harness change yourself, on the host, before you hand it to the
|
||||
operator for a bench run. A harness that fails on its own defect costs the
|
||||
operator a full re-run cycle.
|
||||
- A test declares its `mode`, and the runner switches. Cloud tests stay in
|
||||
cloud mode. Keep `requires` lists minimal, and never gate one test behind a
|
||||
chain of others.
|
||||
- Never exercise a gate or a limit through the `GFCOOL_*` environment
|
||||
overrides in a test. Use the settings API, and restore it in the teardown.
|
||||
- A silence-while-armed injection and a crash during `$H` are catalog
|
||||
drills, not one-off bench scripts.
|
||||
|
||||
### CI couplings that set the push order
|
||||
|
||||
- The CI of `grblHAL-glowforge` gets the laser harnesses from `scripts/bench/`
|
||||
at the head of `master`, unpinned. Push a harness change here before the
|
||||
driver change that needs it.
|
||||
- The acceptance page shares its theme and its vendored Bootstrap with the
|
||||
`forgectrl` panel, byte for byte. Land a UI change in `forgectrl` first.
|
||||
Push it and pin it. Only then push this repository.
|
||||
- The CI of `kernel-module-glowforge` cross-builds against the BSP from
|
||||
`meta-openglow`. Push `meta-openglow` before a module change that needs a
|
||||
BSP change.
|
||||
|
||||
### Builds and images
|
||||
|
||||
- Builds run only in the dedicated Yocto build environment that the Build page
|
||||
of the site defines (kas and bitbake). Never use a native host build of a
|
||||
component as proof, not even for a compile check.
|
||||
- Every build makes both images: `forgefirm-image` (the release image) and
|
||||
`forgefirm-image-dev` (the bench image, with `forgetest` on port 8090). The
|
||||
dev image needs an explicit `--target`. Report both artifact names.
|
||||
- After a kernel-module pin bump, rebuild the kernel and the module in the
|
||||
same run: `bitbake -c cleansstate linux-fslc kernel-module-glowforge`, then
|
||||
the images. The kernel version suffix is not reproducible across
|
||||
re-checkouts, and a rootfs whose kernel and module disagree fails.
|
||||
- Read the layer HEAD lines at the top of a build log before you trust a build
|
||||
that moves a pin.
|
||||
|
||||
### The record of the work
|
||||
|
||||
This repository carries no status document and no dated log. Both were
|
||||
retired: every fact about the machine and the firmware is on the
|
||||
documentation site, and **the record of a piece of work is the commit message
|
||||
that carries it**: what was done, how it was proven, and what it replaced, in
|
||||
the same place as the change itself.
|
||||
|
||||
A measurement session or a bench drill is written up in the commit that
|
||||
lands its result. Nothing accumulates in a file.
|
||||
|
||||
### On the bench
|
||||
|
||||
These rules apply whenever an agent works with the operator at a machine.
|
||||
|
||||
- **One armed run per turn.** Start the run, let it finish, stop, report what
|
||||
happened against what was expected, and wait for the operator's
|
||||
confirmation before any further armed run or machine command. Never chain a
|
||||
jog, a fix, or a re-run onto a burn in the same step. Dry runs (latch
|
||||
locked, no emission) can be batched. Say which is which before you start.
|
||||
- **Zero inference during live fire.** Report only what the combined
|
||||
observations confirm: the operator's eyes, the drill output, the logs, the
|
||||
sysfs readbacks. Everything else is labeled "unconfirmed" and is not acted
|
||||
on.
|
||||
- **One Grbl connection.** Never open a second connection to TCP port 23 while
|
||||
a drill, an acceptance test, or a sender holds one. The last connection
|
||||
wins, the displaced client's cleanup never arrives, and its queued commands
|
||||
wait behind the arm gate. Read state through `forgectrl` (HTTP) and sysfs
|
||||
only. After a displaced or aborted drill: soft reset, confirm that the RX
|
||||
buffer reads full, `M5`, confirm that `armed` is false, before anyone
|
||||
touches the button.
|
||||
- **The operator touches the machine.** The operator runs the install, the
|
||||
service restart, and the reflash on the machine. Never modify the software
|
||||
of a running machine unless the operator asks for that exact step.
|
||||
- **Bench hygiene.** Stage test files in `/tmp`. A file that must survive a
|
||||
reboot goes in `/data/bench-scratch/`, and that directory is deleted whole
|
||||
at the end of the session. Nothing goes loose in `/data`. Remove everything
|
||||
you put on the board, in the same session, and list `/data` before you end.
|
||||
A bench tool worth a second use goes in `scripts/bench/`. The dev image
|
||||
installs those tools under `/usr/share/forgetest/bench/`. Run them from
|
||||
there.
|
||||
|
||||
## Project-wide rules
|
||||
|
||||
The sections above are specific to this repository. The rules below apply to
|
||||
every repository of the OpenGlow ForgeFIRM project: the firmware components,
|
||||
the build base, the documentation, and the hardware designs. Where a
|
||||
repository rule and a rule below conflict, the safety, proof, and hygiene
|
||||
rules below win. The documentation site, <https://docs.forgefirm.org/>, is
|
||||
the source of truth for every fact about the machine and the firmware. Read
|
||||
its Developers section before you change code.
|
||||
|
||||
### What ForgeFIRM is
|
||||
|
||||
ForgeFIRM is open firmware for stock Glowforge lasers (Basic, Plus, Pro). It
|
||||
runs on the factory NXP i.MX6 control board with no hardware modification. It
|
||||
replaces the cloud-dependent factory userspace with an open Linux image that
|
||||
controls the machine locally. In GRBL mode the machine is a standard grblHAL
|
||||
controller for LightBurn and other senders on TCP port 23. Cloud mode runs the
|
||||
factory experience through the Glowforge web service, and it is a deliberate,
|
||||
maintained feature. The stack is hardware validated: GRBL mode cuts real jobs,
|
||||
and cloud mode runs end to end.
|
||||
|
||||
One controller runs at a time under `forgectrl`, the machine-services daemon,
|
||||
which owns the pulse device, the cooling engine, the cameras, and the control
|
||||
panel. The kernel module plays the pulse stream into the stepper drivers and
|
||||
owns the laser latch and the safety readbacks. The Technical section of the
|
||||
site describes the whole stack.
|
||||
|
||||
### The repositories
|
||||
|
||||
All repositories live under the OpenGlow organization on GitHub,
|
||||
<https://github.com/openglow-org>. For development, check every repository out
|
||||
as a sibling in one base directory named `openglow-forgefirm`. The build
|
||||
scripts expect that layout. Each repository is its own git repository. The
|
||||
base directory is not.
|
||||
|
||||
| Repository | Role | Default branch | License |
|
||||
|---|---|---|---|
|
||||
| `forgefirm` | The build and release base: the `meta-forgefirm` Yocto layer, the kas configuration, the image recipes, the install and release scripts, the acceptance tool `forgetest/`, the bench tools `scripts/bench/`, and the bench actuator firmware `fixture/`. It carries no project documentation. | `master` | MIT (layer metadata) |
|
||||
| `meta-openglow` | The BSP layers `meta-glowforge-bsp` and `meta-openglow-core`: kernel, device tree, U-Boot, board recipes. | `scarthgap` | MIT (layer metadata) |
|
||||
| `forgectrl` | The machine-services daemon (C) on HTTPS port 443, with the read-only routes on HTTP port 80: supervisor, pulse-device broker, motion-liveness gate, cooling engine, cameras, telemetry, settings, diagnostics, control panel, A/B updates. | `main` | MIT |
|
||||
| `grblHAL-glowforge` | The grblHAL driver for the stock board, the GRBL-mode controller. The core is a submodule at `src/grbl`. Machine constants are in `src/boards/glowforge.h`. | `main` | GPL-3.0-or-later |
|
||||
| `grblHAL-core` | The fork of the grblHAL core that the driver uses. Minimum change, no rationale comments. | `forgefirm` | The upstream grblHAL license |
|
||||
| `kernel-module-glowforge` | `glowforge.ko`: the SDMA + EPIT pulse engine, the laser latch, the safety readbacks, the sensors. | `master` | GPL-2.0-or-later |
|
||||
| `python3-gfhardware` | The `gfhardware` hardware library and the cloud-mode applications in `forgefirm-app/`. | `master` | MIT, with one LGPL-2.1-or-later component |
|
||||
| `Glowforge-Utilities` | `gfutilities` on PyPI: the factory protocol and service layer that cloud mode uses. | `master` | MIT |
|
||||
| `forgefirm-docs` | The documentation site. | `main` | CC BY-SA 4.0 |
|
||||
| `openglow-serial-adapter` | The USB-C serial-console adapter for the control board. | `main` | CC BY-NC-SA 4.0 |
|
||||
|
||||
The full table, the license details, and the component diagram are on the
|
||||
site: <https://docs.forgefirm.org/developers/>.
|
||||
|
||||
### Safety first, and in that order
|
||||
|
||||
The machine is a laser, and the operator is the safety authority at it. These
|
||||
rules bind every change, in every repository, that can affect what the machine
|
||||
does.
|
||||
|
||||
- The hardware chain is the safety boundary. Software only adds gates on top
|
||||
of it. `LASER_ON` is never a bare GPIO. The kernel laser latch is locked by
|
||||
default, and every close of the pulse device locks it again.
|
||||
- Never defeat a switch, an interlock, or a readback, in hardware or in
|
||||
software, not even for a test.
|
||||
- Never change a fire gate, a thermal limit, or a safety default to make a
|
||||
test pass.
|
||||
- The order of work is absolute: emission and motion first, robustness and
|
||||
hygiene after. Review a change that touches emission or motion for that
|
||||
before anything else.
|
||||
- A change that can put energy where it was not commanded gets a regression
|
||||
test with the fix, in the same commit, never after.
|
||||
- Position counters, homing anchors, and a homed flag are not proof of
|
||||
physical motion. The head accelerometer is, and so are the operator's eyes.
|
||||
- Unreadable is fail-closed. A failed read of a safety input, a state file,
|
||||
or a verdict is never treated as "still running" or "keep waiting" without
|
||||
a bound.
|
||||
|
||||
### Proof before done
|
||||
|
||||
A feature or fix is not complete until it is proven. The order of preference:
|
||||
|
||||
1. A host test that runs in CI. The Test page of the site lists the host tests
|
||||
of each repository.
|
||||
2. A bench drill on the bench reference, recorded in the commit message that
|
||||
carries the change.
|
||||
3. Documented reasoning.
|
||||
|
||||
Also:
|
||||
|
||||
- Run the host tests of this repository before every commit. Where the build
|
||||
uses `-Werror`, a warning is a failure.
|
||||
- A component that ships in the image is also evaluated against the release
|
||||
acceptance catalog in the `forgefirm` repository (Test page, "Coverage
|
||||
currency"). A behavior change with no catalog consequence gets a sentence
|
||||
of justification in the commit message.
|
||||
- For a component that ships in the image, the proof build is the Yocto
|
||||
cross-build that the Build page of the site defines, never a native host
|
||||
build.
|
||||
- Report outcomes faithfully. Quote failing output as it is. Name a skipped
|
||||
step as skipped. Never narrate an expected result as an observed one.
|
||||
|
||||
### The workflow: prove locally, push when proven
|
||||
|
||||
- Nothing goes to a public repository until it is proven: in the host tests
|
||||
for host-only changes, on the bench for changes that alter what the machine
|
||||
does.
|
||||
- Iterate locally. Then make one commit and one push for the proven change.
|
||||
No chains of fix-up pushes. Every push runs CI, and CI minutes are a budget.
|
||||
- Commit only finished work, and only when asked. Never commit intermediate
|
||||
findings, working notes, plans, audits, or status files. If a conclusion is
|
||||
still moving, it is not ready for a repository. Unpushed mistakes come out
|
||||
with `git reset --mixed <base>`, which keeps the working tree.
|
||||
- "Commit" and "commit and build" include the push of every touched
|
||||
repository, in dependency order. Never hold a push back to protect earlier
|
||||
unpushed commits on the same branch. The only exception is an explicit "do
|
||||
not push" for that session.
|
||||
- Never push, force-push, tag, cut a release, refresh a lockfile, bump a
|
||||
submodule pointer, or bump a pin unless asked.
|
||||
|
||||
### Pins
|
||||
|
||||
A firmware component reaches the image only through its pin: an exact
|
||||
`SRCREV` in `meta-forgefirm` (in the `forgefirm` repository) for ForgeFIRM
|
||||
components, or in `meta-openglow` for BSP components. There is no `AUTOREV`.
|
||||
A commit in a component repository changes nothing on a machine until its pin
|
||||
moves. The order is always: push the source repository, bump its pin, then
|
||||
run `bitbake -c fetch <recipe>` to make sure that the pin resolves. Some
|
||||
pairs of repositories have a CI coupling that fixes the push order between
|
||||
them. The Release flow page of the site lists them, and each affected
|
||||
repository names its own in the section above.
|
||||
|
||||
### Git conventions
|
||||
|
||||
- A commit is attributed to the human who is responsible for it, in both the
|
||||
author and the committer fields. Never list an AI assistant as an author or
|
||||
a co-author. Never add an AI attribution trailer or a "Generated with" line
|
||||
to a commit message or a pull request. In a fresh clone, set the local
|
||||
`user.name` and `user.email` before the first commit.
|
||||
- A commit message says what changed and why, in the present tense, in
|
||||
Simplified Technical English. The "why" of a change to fork code lives here
|
||||
and nowhere else.
|
||||
- Keep line endings LF in every file that Linux tooling consumes: patches,
|
||||
shell scripts, recipes, device trees. Check the bytes after an edit from a
|
||||
Windows tool. A CRLF patch breaks `do_patch`.
|
||||
- Never commit build output, `__pycache__`, virtual environments, editor
|
||||
files, a local knowledge graph (`graphify-out/`), captured machine data that
|
||||
carries an identity, tokens, keys, or passwords.
|
||||
- Never rewrite published history unless the operator asks for it.
|
||||
- A pull request to an upstream repository is never automatic. The developer
|
||||
decides if and when to send one. You can remind them that upstream
|
||||
candidates exist, but the decision is theirs.
|
||||
|
||||
### Writing rules
|
||||
|
||||
They apply to everything: documentation, README files, code comments,
|
||||
docstrings, log and console text, commit messages, issue text, and replies.
|
||||
|
||||
- **American English.** analyze, behavior, color, center, gray, catalog,
|
||||
license, judgment, program, artifact, percent, toward, among, while,
|
||||
learned. The style lint in `forgefirm-docs` rejects the British forms.
|
||||
- **ASD-STE100 Simplified Technical English.** Short sentences: 20 words in
|
||||
a procedure, 25 in a description. One topic per sentence, one instruction
|
||||
per sentence. Active voice, imperative in procedures, simple present in
|
||||
descriptions. "Must" for necessity, "can" for possibility. No "should",
|
||||
"would", "could", "may". Prefer: do, make sure, use, start, show, occur,
|
||||
before, after, because, for example, that is. No "etc.", no "and/or".
|
||||
Noun clusters of three words at most. Warnings and cautions come before the
|
||||
step they protect. Warm and direct is good. A joke that shades a fact is
|
||||
not.
|
||||
- **No em dashes.** Never, anywhere. Use a colon, a period, or a comma.
|
||||
- **Present tense, present state.** Text describes the code and the machine
|
||||
as they are. No history narrative, no "the old version did", no "evolved
|
||||
from", no story of how the code got here. Upstream copyright and
|
||||
derivation attribution to public projects stays: that is license credit,
|
||||
not narrative.
|
||||
- **No workstation paths.** Nothing outside a repository appears as a path in
|
||||
it: no drive letters, no WSL mounts, no home directories, no sibling-repo
|
||||
relative paths, no private reference directories. Name the artifact
|
||||
instead: "the captured factory pulse files", "that bench session".
|
||||
- **No bench-machine identity.** Never a hostname, a serial number, a fuse
|
||||
value, a private IP address, or a login of the bench machine, in any file,
|
||||
comment, commit message, sample, or test fixture. Use neutral placeholders
|
||||
of the `ABC-123` form. Record the fact of a verification, never the values.
|
||||
- **No factory firmware code.** Never cite the factory firmware binary or its
|
||||
code: no offsets, no addresses, no quoted or decompiled code. State the
|
||||
recovered fact (a formula, a constant, a behavior) and stop. Hardware
|
||||
addresses (IOMUX pad values, sensor registers, boot offsets) are fine.
|
||||
- **No rationale comments in fork code.** Code destined upstream (for
|
||||
example, the grblHAL core fork) carries the minimum change and no "why we
|
||||
did this" comments. The reasoning goes in the commit message. Patches
|
||||
carried in the Yocto layers are exempt. The `ForgeFIRM:` comment convention
|
||||
there is deliberate.
|
||||
- **Interface docs describe the lever, not the policy.** A sysfs attribute
|
||||
doc says what the attribute is, its range, its units, its conversion. How
|
||||
the factory firmware drives it does not belong there.
|
||||
- **No definitive legal or regulatory statements.** The project is not a
|
||||
lawyer. The only permitted form: modifying the machine, including
|
||||
replacing its firmware, may have legal and regulatory ramifications, and it
|
||||
is the end user's responsibility to adhere to the laws, regulations,
|
||||
certifications, and insurance terms that apply where they are. Warranty
|
||||
wording is "may void your warranty", never "voids". Never cite a regulation,
|
||||
a standard, or a certification regime as applying or not applying. The
|
||||
affiliation disclaimer lives in the site footer only. It is also the user's
|
||||
responsibility to make sure that they stay within the vendor's terms of
|
||||
service when they use a feature that calls that vendor's cloud services.
|
||||
- **Copyright.** Copyright in this project belongs to 514 LLC d/b/a
|
||||
OpenGlow. Every new source file carries two lines in its header, in the
|
||||
comment style of the file: `Copyright <year> 514 LLC d/b/a OpenGlow` and
|
||||
`Written by Scott Wiederhold`. Keep the year, or the year range, that
|
||||
the file already has. The copyright notices of other holders stay
|
||||
unchanged. Those two lines are for files this project authors: an
|
||||
outside contributor who writes a new file puts their own name there, or
|
||||
leaves the lines out, and keeps their copyright in what they wrote.
|
||||
Editing an existing file changes no header - a contribution is recorded
|
||||
by its `Signed-off-by` line and by git, not by a copyright line.
|
||||
- **SPDX.** Every new source file carries an `SPDX-License-Identifier` line
|
||||
under the license of its repository.
|
||||
|
||||
### Where documentation goes
|
||||
|
||||
- **One home.** A fact lives on the documentation site, or it does not exist.
|
||||
A repository README says what the repository is, how to build and test it,
|
||||
and where the documentation is. Nothing else.
|
||||
- **The currency rule.** A change carries a documentation commit when it adds,
|
||||
removes, or renames an interface (a sysfs attribute, an HTTP route, a
|
||||
settings key, a G-code or `$` setting), or when it corrects a measured
|
||||
fact. A measured fact says how it was obtained, and on what: unless the page
|
||||
says otherwise, a measurement on the site was taken on the bench reference,
|
||||
which the site defines.
|
||||
- **A moved document is deleted.** No stub and no redirect stays at the old
|
||||
path.
|
||||
- **Every diagram is Mermaid.** No ASCII art and no image of a diagram.
|
||||
- There is no roadmap page. Open items are tracked as GitHub issues once the
|
||||
repositories accept them.
|
||||
- Do not add plan files, audit files, working notes, or status files to any
|
||||
repository. The project has **no status document and no dated log**: the
|
||||
site describes the present, and the record of what was done, how it was
|
||||
proven, and what it replaced goes in the commit message that carries the
|
||||
change.
|
||||
|
||||
### Analysis and reporting
|
||||
|
||||
- Rank engineering options on technical merit only: performance, correctness,
|
||||
robustness, verifiability. Process cost (an acceptance campaign, an image
|
||||
flash, a new dependency) is a one-line footnote, never a ranking factor.
|
||||
The operator decides the process cost.
|
||||
- Never infer or assume a hardware fact. Label every unverified claim as
|
||||
unverified, and say how to verify it.
|
||||
- Report only what the observations confirm. Everything else is labeled
|
||||
"unconfirmed" and is not acted on.
|
||||
|
||||
### General practice for agents
|
||||
|
||||
- Read the README of this repository, this file, and the Developers section
|
||||
of the site before you change code.
|
||||
- Orient in the code before you edit. If a local knowledge graph
|
||||
(`graphify-out/`) exists in the working tree or its parent, query it first.
|
||||
It is a local artifact and is never committed.
|
||||
- Keep a change minimal and scoped to the request. No drive-by reformatting,
|
||||
no unrelated cleanups, no renames for taste. Match the style of the
|
||||
surrounding code.
|
||||
- Do not add a dependency without a stated reason. The target is a
|
||||
single-core machine with a small eMMC, and every package rides the image.
|
||||
- Extend an existing test or tool instead of adding a parallel one.
|
||||
- Never create a file in the repository that the request does not need.
|
||||
- Ask before anything destructive or outward-facing: a push, a force-push, a
|
||||
tag, a release, a history rewrite, or any change to a machine.
|
||||
- When a fact is unknown, say so and say how to find out. Never fill a gap
|
||||
with a plausible number.
|
||||
- Use the exact names of the project: kas, bitbake, pin, SRCREV, campaign,
|
||||
fingerprint, drill, sysfs, the latch, the armed window. Do not invent names.
|
||||
|
||||
### Before you commit
|
||||
|
||||
1. The host tests of this repository pass, with `-Werror` where the build
|
||||
uses it.
|
||||
2. The change is proven at the highest level available: CI test, bench drill,
|
||||
or documented reasoning.
|
||||
3. For a component that ships in the image: the acceptance catalog covers the
|
||||
changed behavior, or the commit message says why it has no catalog
|
||||
consequence.
|
||||
4. New files carry the 514 LLC d/b/a OpenGlow copyright, the `Written by`
|
||||
line, and an SPDX line. Line endings are LF.
|
||||
5. No em dashes, no British spellings, no workstation paths, no bench
|
||||
identity, no factory firmware code, no history narrative, no AI
|
||||
attribution.
|
||||
6. Documentation is current: interfaces and measured facts (the currency
|
||||
rule).
|
||||
7. The commit is one settled change, attributed to the human author, and it
|
||||
is pushed only when the operator asks.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Build
|
||||
Instructions are for a Linux environment supported by Yocto.
|
||||
|
||||
To get the build OpenGlow/ForgeFIRM you need to have `repo` installed:
|
||||
|
||||
## Install the `repo` utility:
|
||||
|
||||
```console
|
||||
mkdir ~/bin
|
||||
curl http://commondatastorage.googleapis.com/git-repo-downloads/repo > ~/bin/repo
|
||||
chmod a+x ~/bin/repo
|
||||
PATH=${PATH}:~/bin
|
||||
```
|
||||
Add the following to the end of ```~/.bashrc``` to permanently add ```repo``` to your path:
|
||||
```console
|
||||
export PATH=~/bin:$PATH
|
||||
```
|
||||
## Download the BSP source:
|
||||
|
||||
```console
|
||||
mkdir forgefirm
|
||||
cd forgefirm
|
||||
repo init -u https://github.com/ScottW514/forgefirm.git -b master -m default.xml
|
||||
repo sync
|
||||
```
|
||||
|
||||
At the end of the commands you have all meta packages you need to build the basic firmware images.
|
||||
|
||||
## Build the OpenGlow/ForgeFIRM image for factory control boards
|
||||
First time environment setup:
|
||||
```console
|
||||
(in forgefirm directory)
|
||||
MACHINE=glowforge DISTRO=forgefirm . setup-environment build
|
||||
bitbake forgefirm-image
|
||||
```
|
||||
Subsequent builds:
|
||||
```console
|
||||
(in forgefirm directory)
|
||||
. setup-environment build
|
||||
bitbake forgefirm-image
|
||||
```
|
||||
|
||||
## Bootable Image:
|
||||
The bootable image can be found in ```forgefirm/build/tmp/deploy/images/```.
|
||||
|
||||
It can be written directly to an SDCARD:
|
||||
```console
|
||||
(in forgefirm/build directory)
|
||||
cd tmp/deploy/images/glowforge
|
||||
sudo zcat forgefirm-image-glowforge.wic.gz | dd of=/dev/sdX bs=1M
|
||||
```
|
||||
@@ -1,54 +0,0 @@
|
||||
# Contributing
|
||||
|
||||
Patches are welcome. How the project works, what proof a change needs, and the
|
||||
writing rules are on the documentation site:
|
||||
<https://docs.forgefirm.org/developers/contributing/>.
|
||||
|
||||
## Sign your work
|
||||
|
||||
Every commit carries a `Signed-off-by` line:
|
||||
|
||||
Signed-off-by: Your Name <your.email@example.com>
|
||||
|
||||
`git commit -s` adds it, using your `user.name` and `user.email`. Use your real
|
||||
name; an anonymous or pseudonymous sign-off certifies nothing.
|
||||
|
||||
The line certifies the Developer Certificate of Origin, version 1.1, below. It
|
||||
is the same statement the Linux kernel and grblHAL use. It is not a copyright
|
||||
assignment: you keep the copyright in what you write, and your contribution
|
||||
comes in under the license of the repository you are contributing to.
|
||||
|
||||
You do not add a copyright line for a patch. A change to an existing file
|
||||
leaves its header alone. If you write a whole new file, put your own name in
|
||||
its copyright line, or leave the line out; either way the file carries an
|
||||
`SPDX-License-Identifier` for the repository's license.
|
||||
|
||||
## Developer Certificate of Origin 1.1
|
||||
|
||||
By making a contribution to this project, I certify that:
|
||||
|
||||
(a) The contribution was created in whole or in part by me and I
|
||||
have the right to submit it under the open source license
|
||||
indicated in the file; or
|
||||
|
||||
(b) The contribution is based upon previous work that, to the best
|
||||
of my knowledge, is covered under an appropriate open source
|
||||
license and I have the right under that license to submit that
|
||||
work with modifications, whether created in whole or in part
|
||||
by me, under the same open source license (unless I am
|
||||
permitted to submit under a different license), as indicated
|
||||
in the file; or
|
||||
|
||||
(c) The contribution was provided directly to me by some other
|
||||
person who certified (a), (b) or (c) and I have not modified
|
||||
it.
|
||||
|
||||
(d) I understand and agree that this project and the contribution
|
||||
are public and that a record of the contribution (including all
|
||||
personal information I submit with it, including my sign-off) is
|
||||
maintained indefinitely and may be redistributed consistent with
|
||||
this project or the open source license(s) involved.
|
||||
|
||||
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
|
||||
This DCO text is licensed under the terms of the Creative Commons
|
||||
Attribution-ShareAlike 3.0 Unported license.
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
Copyright 2018-2026 514 LLC d/b/a OpenGlow
|
||||
Written by Scott Wiederhold
|
||||
Copyright 2018-2020
|
||||
Scott Wiederhold <s.e.wiederhold@gmail.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -5,14 +5,5 @@ the associated recipe (.bb file) unless otherwise stated.
|
||||
See:
|
||||
COPYING.MIT (MIT)
|
||||
|
||||
The code in this repository that is not layer metadata is MIT licensed:
|
||||
|
||||
forgetest/ the release acceptance tool (see forgetest/LICENSE)
|
||||
scripts/ the release, install and bench scripts
|
||||
fixture/ the bench actuator firmware, excluding
|
||||
fixture/managed_components/, which carries its own licenses
|
||||
|
||||
Every file in those directories states MIT in its own header.
|
||||
|
||||
License information for any other files is either explicitly stated
|
||||
or defaults to GPL version 2.
|
||||
|
||||
@@ -1,87 +1,15 @@
|
||||
# OpenGlow / ForgeFIRM firmware for Glowforge
|
||||
# OpenGlow/ForgeFIRM Firmware for Glowforge
|
||||
This repository contains opensource firmware for Glowforge brand CNC lasers.
|
||||
|
||||
> ### BETA
|
||||
>
|
||||
> **ForgeFIRM is in beta.** Every release below 0.1.0 is a beta release.
|
||||
> It's stable, but you may still hit the occasional bug, and expect frequent updates. Upgrade whenever a newer
|
||||
> release is available, and report what you find on the
|
||||
> [community forum](https://community.openglow.org).
|
||||
* [Latest Release](https://github.com/ScottW514/forgefirm/releases)
|
||||
* [Installation Instructions](https://community.openglow.org/t/dual-boot-install-of-openglow-on-factory-flash/697)
|
||||
* [Build Instructions](https://github.com/ScottW514/meta-forgefirm/blob/master/BUILD.md)
|
||||
* [Community Support](https://community.openglow.org)
|
||||
|
||||
Open firmware for Glowforge brand CNC lasers. ForgeFIRM replaces the
|
||||
cloud-dependent factory software on the **stock control board**, with no
|
||||
hardware modification, and gives the machine a local controller, a local web
|
||||
control panel, and a standard Grbl interface. The factory cloud experience
|
||||
stays available as an option.
|
||||
# Roadmap
|
||||
* Base image that supports all Glowforge hardware (complete)
|
||||
* Support Glowforge web service - Python implementation (mostly complete)
|
||||
* REST API w/ G-Code support (in progress)
|
||||
* Browser-based GUI (pending)
|
||||
|
||||
This repository is the **base of the build and of the release**: the
|
||||
`meta-forgefirm` Yocto layer, the kas configuration, the image recipes, the
|
||||
install and release scripts, the acceptance tool (`forgetest/`), the bench
|
||||
tools (`scripts/bench/`), the bench actuator firmware (`fixture/`), and the
|
||||
release artifacts (`releases/`).
|
||||
|
||||
## Start here
|
||||
|
||||
**<https://docs.forgefirm.org/>** is the documentation, and the source of
|
||||
truth for every fact about the machine and the firmware.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Read this first | [Safety](https://docs.forgefirm.org/safety/) |
|
||||
| Put it on a machine | [Installation](https://docs.forgefirm.org/install/) |
|
||||
| Use it | [Usage](https://docs.forgefirm.org/usage/), [LightBurn](https://docs.forgefirm.org/usage/lightburn/) |
|
||||
| How the machine works | [Technical](https://docs.forgefirm.org/technical/machine/) |
|
||||
| How ForgeFIRM works with it | [ForgeFIRM internals](https://docs.forgefirm.org/technical/forgefirm/) |
|
||||
| Build, test, release | [Developers](https://docs.forgefirm.org/developers/) |
|
||||
| Downloads | [Releases](https://github.com/openglow-org/forgefirm/releases) |
|
||||
| Questions | [Community forum](https://community.openglow.org) |
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
kas build kas/forgefirm-glowforge.yml
|
||||
```
|
||||
|
||||
[Build](https://docs.forgefirm.org/developers/building/) covers the host
|
||||
setup, the two images, the source variant and the debug kernel.
|
||||
[Release flow](https://docs.forgefirm.org/developers/release-flow/) covers the
|
||||
pins, the push order and the signing pipeline.
|
||||
|
||||
## Test
|
||||
|
||||
```sh
|
||||
cd forgetest && python3 -m unittest discover -s tests -v
|
||||
```
|
||||
|
||||
The acceptance catalog that gates a release, and the bench tools, are on
|
||||
[Acceptance](https://docs.forgefirm.org/developers/acceptance/) and
|
||||
[The bench](https://docs.forgefirm.org/developers/bench/).
|
||||
|
||||
## Contributing
|
||||
|
||||
[AGENTS.md](AGENTS.md) carries the rules for this repository and for the
|
||||
project: safety ordering, proof before done, the push order, and the writing
|
||||
rules. They apply to human contributors too, and
|
||||
[Contribute](https://docs.forgefirm.org/developers/contributing/) is the same
|
||||
set on the site.
|
||||
|
||||
## What this costs
|
||||
|
||||
Nothing. ForgeFIRM is free in both senses, under MIT and GPL licenses. There
|
||||
is no paid tier, no license key, no subscription and no Pro edition. If
|
||||
someone offers to sell it to you, the licenses allow it, but what you take
|
||||
home is their build rather than this one: get it from the source.
|
||||
|
||||
## Safety
|
||||
|
||||
**These machines contain a CO2 laser: it burns, blinds, and starts fires.**
|
||||
Never defeat the lid switches or the interlock. Never leave a running job
|
||||
unattended. Keep a fire extinguisher within reach. Read
|
||||
[Safety](https://docs.forgefirm.org/safety/) before you cut your first job,
|
||||
and [Regulatory and legal](https://docs.forgefirm.org/install/#regulatory-and-legal)
|
||||
before you install.
|
||||
|
||||
**This is experimental software. Use of it could seriously maim or kill you or
|
||||
others, and it may void your warranty. Use it at your own risk.**
|
||||
|
||||
Glowforge is a trademark of Glowforge, Inc. This project is not affiliated
|
||||
with, authorized by, or endorsed by Glowforge, Inc.
|
||||
This project is for experimental purposes only, and is not supported or endorsed by Glowforge.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
POKY_BBLAYERS_CONF_VERSION = "2"
|
||||
|
||||
BBPATH = "${TOPDIR}"
|
||||
BSPDIR := "${@os.path.abspath(os.path.dirname(d.getVar('FILE', True)) + '/../..')}"
|
||||
|
||||
BBFILES ?= ""
|
||||
BBLAYERS = " \
|
||||
${BSPDIR}/sources/poky/meta \
|
||||
${BSPDIR}/sources/poky/meta-poky \
|
||||
\
|
||||
${BSPDIR}/sources/meta-openembedded/meta-oe \
|
||||
\
|
||||
${BSPDIR}/sources/meta-freescale \
|
||||
${BSPDIR}/sources/meta-freescale-distro \
|
||||
\
|
||||
${BSPDIR}/sources/meta-openembedded/meta-python \
|
||||
${BSPDIR}/sources/meta-openembedded/meta-networking \
|
||||
\
|
||||
${BSPDIR}/sources/meta-openglow/meta-openglow-core \
|
||||
${BSPDIR}/sources/meta-openglow/meta-glowforge-bsp \
|
||||
\
|
||||
${BSPDIR}/sources/forgefirm/meta-forgefirm \
|
||||
"
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Repo Manifest -->
|
||||
<manifest>
|
||||
|
||||
<default sync-j="4" revision="master"/>
|
||||
|
||||
<remote fetch="https://git.yoctoproject.org/git" name="yocto"/>
|
||||
<remote fetch="https://github.com/Freescale" name="freescale"/>
|
||||
<remote fetch="https://github.com/openembedded" name="oe"/>
|
||||
<remote fetch="https://github.com/ScottW514" name="scottw514"/>
|
||||
|
||||
<project remote="yocto" revision="master" name="poky" path="sources/poky"/>
|
||||
<project remote="yocto" revision="master" name="meta-freescale" path="sources/meta-freescale"/>
|
||||
<project remote="freescale" revision="master" name="meta-freescale-distro" path="sources/meta-freescale-distro"/>
|
||||
|
||||
<project remote="oe" revision="master" name="meta-openembedded" path="sources/meta-openembedded"/>
|
||||
|
||||
<project remote="scottw514" revision="master" name="meta-openglow" path="sources/meta-openglow"/>
|
||||
|
||||
<project remote="scottw514" revision="master" name="forgefirm" path="sources/forgefirm">
|
||||
<copyfile dest="base/conf/bblayers.conf" src="base/conf/forgefirm-bblayers.conf"/>
|
||||
<copyfile dest="setup-environment" src="scripts/setup-environment"/>
|
||||
</project>
|
||||
|
||||
</manifest>
|
||||
@@ -1,14 +0,0 @@
|
||||
# The bench's own wifi details and API key: never committed.
|
||||
fixture.env
|
||||
|
||||
# ESP-IDF build products and the per-checkout config (regenerated from
|
||||
# sdkconfig.defaults).
|
||||
build/
|
||||
sdkconfig
|
||||
sdkconfig.old
|
||||
managed_components/
|
||||
|
||||
|
||||
# Host test binaries.
|
||||
test/policy_test
|
||||
test/*.o
|
||||
@@ -1,8 +0,0 @@
|
||||
# forgefixture: the ForgeFIRM bench actuator (ESP32-S3, ESP-IDF).
|
||||
# The wifi details and the API key come from fixture.env next to this
|
||||
# file (see main/CMakeLists.txt and README.md); nothing else is needed.
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
|
||||
set(SDKCONFIG_DEFAULTS "${CMAKE_CURRENT_LIST_DIR}/sdkconfig.defaults")
|
||||
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||
project(forgefixture)
|
||||
@@ -1,122 +0,0 @@
|
||||
# forgefixture: the bench actuator
|
||||
|
||||
The acceptance campaign asks a person for about eighty small things,
|
||||
and most of them are the same three: open the lid, pull the interlock,
|
||||
press the button. This is the box that does those on request, so the
|
||||
tool can run an operator test with nobody in the room. An ESP32-S3
|
||||
DevKitC-1 on the bench network drives three relays wired into the
|
||||
machine's own connectors; the acceptance tool (`forgetest`, on the
|
||||
machine) asks it over HTTP and then watches the machine for the result,
|
||||
the way it watches an operator's hand today.
|
||||
|
||||
Nothing here touches the laser's safety chain. Two of the contacts are
|
||||
normally closed and sit in series with loops the chain already reads;
|
||||
the third is normally open across the button input and is only ever
|
||||
pulsed. With the fixture unpowered, unplugged, rebooting or crashed, the
|
||||
machine is a stock machine.
|
||||
|
||||
## Hardware
|
||||
|
||||
| Part | What |
|
||||
|---|---|
|
||||
| ESP32-S3-DevKitC-1 | any flash size; powered from its USB port by a **USB wall adapter** (see grounds) |
|
||||
| 3 × 1-channel 3.3 V optocoupler relay modules, high-level trigger | VCC, GND, IN; coil ~100 mA each |
|
||||
| a 2-pin header and jumper | the button channel's enable |
|
||||
| the interposer harness at the machine | bench-local; described in the project's hardware facts bank, not here |
|
||||
|
||||
Pins on the DevKit (all plain GPIOs, no strapping, USB, flash or PSRAM
|
||||
role):
|
||||
|
||||
| Signal | GPIO | Relay contact | Where in the machine |
|
||||
|---|---|---|---|
|
||||
| lid | 4 | **NC**, in series with the lid-switch loop | energized = the loop opens = lid open |
|
||||
| interlock | 5 | **NC**, in series with the interlock loop | energized = the loop opens = interlock pulled |
|
||||
| button | 6 | **NO**, across the button input | energized = pressed; pulsed only, 20 to 500 ms |
|
||||
| button enable | 7 | input, internal pull-up | a jumper to GND enables the button channel; no jumper, no presses |
|
||||
|
||||
Relay coils from the machine's 3.3 V (the modules' VCC and GND), the
|
||||
three IN pins from the GPIOs above, the DevKit's GND to the modules'
|
||||
GND. The modules' opto inputs share GND with their coils, so the ESP32
|
||||
and the machine share a ground: power the DevKit from a wall adapter,
|
||||
not from a PC, unless you want that PC's ground on the machine.
|
||||
|
||||
## Build and flash
|
||||
|
||||
The only input is `fixture.env`: the wifi network, the API key, the
|
||||
hostname. Everything else is pinned (ESP-IDF v5.5, the mDNS component in
|
||||
`dependencies.lock`).
|
||||
|
||||
./fixture.sh env # fixture.env from the example, with a fresh key; fill in the wifi
|
||||
./fixture.sh build # idf.py if installed, else the espressif/idf container (docker or podman)
|
||||
./fixture.sh flash COM5 # or /dev/ttyUSB0; esptool from pip talks to the board directly
|
||||
./fixture.sh monitor COM5 # the log
|
||||
|
||||
Either USB port of the DevKit flashes; the one marked UART shows the log
|
||||
too. `fixture.env` is git-ignored and never leaves the bench. A build in
|
||||
the container works anywhere docker or podman runs (Git Bash on Windows
|
||||
included); `pip install esptool pyserial` is the whole host-side
|
||||
requirement for flashing and watching.
|
||||
|
||||
## What it does on the network
|
||||
|
||||
It joins the wifi as `forgefixture` (the `HOSTNAME` in `fixture.env`),
|
||||
sends that name in its DHCP request, and announces `forgefixture.local`
|
||||
over mDNS with a `_forgefixture._tcp` service. It reconnects forever and
|
||||
never sleeps the radio. The API is HTTP on port 80, JSON, LAN only, no
|
||||
OTA (the flash happens over USB and nowhere else).
|
||||
|
||||
Every request carries the key in `X-Fixture-Key`; without it, or with
|
||||
a wrong one, every path answers 401 and the attempt is logged with the
|
||||
caller's address.
|
||||
|
||||
| Request | Does |
|
||||
|---|---|
|
||||
| `GET /` or `/state` | identity, firmware version, uptime, the three channels' states, whether the button is enabled, the wifi link |
|
||||
| `POST /lid {"state":"open"}` | energizes the lid channel (the loop opens); `"close"` releases it |
|
||||
| `POST /interlock {"state":"open"}` | the same for the interlock loop |
|
||||
| `POST /button {"ms":200}` | one press, `ms` clamped into 20 to 500 (200 when absent); 409 while the enable jumper is out or a press is still in progress |
|
||||
| `POST /release` | every channel released |
|
||||
|
||||
An action answers with the state as `GET /` shows it. The fixture does
|
||||
not read the machine's switches; the tool verifies every action through
|
||||
the machine's own readings, which is the point.
|
||||
|
||||
curl -s -H "X-Fixture-Key: $KEY" http://forgefixture.local/
|
||||
curl -s -H "X-Fixture-Key: $KEY" -d '{"state":"open"}' http://forgefixture.local/lid
|
||||
|
||||
## The tool's side
|
||||
|
||||
`forgetest` looks for `/data/forgetest/fixture.json` on the machine:
|
||||
|
||||
{"hostname": "forgefixture", "key": "<the API_KEY>", "ip": null,
|
||||
"channels": ["lid", "interlock", "button"], "arm_press": false}
|
||||
|
||||
`ip` overrides the mDNS lookup (the tool resolves `<hostname>.local`
|
||||
itself; the image has no mDNS resolver). `channels` names what is
|
||||
wired. `arm_press` stays false unless the fixture may press the button
|
||||
to arm the laser for a live test; by default that press is a person's.
|
||||
The file is mode 0600 and bench-local. With the fixture up, an
|
||||
`operator` test whose actions it covers runs in the unattended queue;
|
||||
`live` tests keep their kind and their acknowledgment, and every action
|
||||
records who performed it. Presses are spaced by the tool: the next one
|
||||
waits for the last pulse to end plus a 300 ms release, so the machine
|
||||
sees two presses and not one long one.
|
||||
|
||||
## What keeps it safe
|
||||
|
||||
- Every line is driven low first thing at boot, before the radio or the
|
||||
server exist, and after any reset.
|
||||
- The button is a pulse whose end is armed before the line rises; a hung
|
||||
task trips the task watchdog, which panics and reboots to all-low.
|
||||
- The button channel needs the jumper; a LAN key leak cannot press the
|
||||
button on a fixture whose jumper is out.
|
||||
- NC contacts in the loops: a fixture fault can only add an open, never
|
||||
mask a real lid open or a pulled interlock.
|
||||
- No OTA, no configuration over the network, nothing persisted beyond
|
||||
the wifi driver's own calibration.
|
||||
|
||||
## Layout
|
||||
|
||||
main/ the firmware: main.c, wifi.c, api.c, relays.c, policy.c (the pure decisions)
|
||||
test/ the host test of policy.c (./fixture.sh test)
|
||||
fixture.env.example, sdkconfig.defaults, dependencies.lock
|
||||
@@ -1,21 +0,0 @@
|
||||
dependencies:
|
||||
espressif/mdns:
|
||||
component_hash: e81ca7a7f53ea34e78274df054da692c272e1315572876b237b1748e267c013b
|
||||
dependencies:
|
||||
- name: idf
|
||||
require: private
|
||||
version: '>=5.0'
|
||||
source:
|
||||
registry_url: https://components.espressif.com/
|
||||
type: service
|
||||
version: 1.11.3
|
||||
idf:
|
||||
source:
|
||||
type: idf
|
||||
version: 5.5.5
|
||||
direct_dependencies:
|
||||
- espressif/mdns
|
||||
- idf
|
||||
manifest_hash: 90c16fd2316c504ccf02e5191bcb9886c6850e5b89a8d08c35e1539ead49703d
|
||||
target: esp32s3
|
||||
version: 2.0.0
|
||||
@@ -1,16 +0,0 @@
|
||||
# forgefixture build settings. Copy to fixture.env (git-ignored) and fill
|
||||
# in; `./fixture.sh env` does the copy and generates the API key.
|
||||
# KEY=value, one per line; quotes around a value are optional.
|
||||
|
||||
# The wifi network the fixture joins (2.4 GHz; the ESP32-S3 has no 5 GHz).
|
||||
WIFI_SSID=
|
||||
WIFI_PSK=
|
||||
|
||||
# The key every API request must carry in X-Fixture-Key. The same value
|
||||
# goes into the bench's /data/forgetest/fixture.json. Any string; 32 hex
|
||||
# characters from `openssl rand -hex 16` is what `./fixture.sh env` writes.
|
||||
API_KEY=
|
||||
|
||||
# The fixture's hostname: announced over mDNS (<HOSTNAME>.local) and in
|
||||
# its DHCP request. Leave empty for forgefixture.
|
||||
HOSTNAME=
|
||||
@@ -1,108 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
# forgefixture: build, flash and watch the bench actuator's firmware.
|
||||
#
|
||||
# ./fixture.sh env fixture.env from the example, with a fresh API key
|
||||
# ./fixture.sh build build (native idf.py if installed, else the IDF container)
|
||||
# ./fixture.sh flash [PORT] write the build to the board over USB (esptool)
|
||||
# ./fixture.sh monitor [PORT] the board's log (idf.py monitor, or pyserial's miniterm)
|
||||
# ./fixture.sh test the host test of the policy (needs a C compiler)
|
||||
#
|
||||
# The container build needs docker or podman and pulls espressif/idf once
|
||||
# (IDF_IMAGE below); flashing never runs in the container: esptool is a
|
||||
# pip package (pip install esptool) and talks to the port directly.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
IDF_IMAGE="${IDF_IMAGE:-docker.io/espressif/idf:v5.5.5}"
|
||||
CHIP=esp32s3
|
||||
BAUD="${BAUD:-460800}"
|
||||
|
||||
have() { command -v "$1" >/dev/null 2>&1; }
|
||||
|
||||
runner() {
|
||||
if have docker; then echo docker; elif have podman; then echo podman; else
|
||||
echo "neither idf.py nor docker/podman found: install ESP-IDF, or a container runtime for $IDF_IMAGE" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# A Windows path for the bind mount when run from Git Bash, which would
|
||||
# otherwise rewrite /project into a Windows path.
|
||||
host_path() {
|
||||
if have cygpath; then cygpath -w "$PWD"; else echo "$PWD"; fi
|
||||
}
|
||||
|
||||
cmd_env() {
|
||||
if [ -e fixture.env ]; then
|
||||
echo "fixture.env exists; edit it, or remove it first" >&2
|
||||
exit 1
|
||||
fi
|
||||
key=$( (have openssl && openssl rand -hex 16) || python3 -c 'import secrets; print(secrets.token_hex(16))')
|
||||
sed -e "s/^API_KEY=.*/API_KEY=$key/" fixture.env.example > fixture.env
|
||||
chmod 600 fixture.env 2>/dev/null || true
|
||||
echo "fixture.env written with a fresh API key; fill in WIFI_SSID and WIFI_PSK"
|
||||
}
|
||||
|
||||
cmd_build() {
|
||||
[ -e fixture.env ] || { echo "no fixture.env: run ./fixture.sh env first" >&2; exit 1; }
|
||||
# The file holds the WiFi PSK and the API key: nobody but the owner
|
||||
# reads it (an MSYS shell cannot express the mode, so it is not judged).
|
||||
if [ "$(uname -o 2>/dev/null)" != "Msys" ] && [ -n "$(find fixture.env -perm /077 2>/dev/null)" ]; then
|
||||
echo "fixture.env is group- or world-readable: chmod 600 fixture.env" >&2
|
||||
exit 1
|
||||
fi
|
||||
if have idf.py; then
|
||||
idf.py set-target "$CHIP" >/dev/null
|
||||
idf.py build
|
||||
else
|
||||
r=$(runner)
|
||||
MSYS_NO_PATHCONV=1 "$r" run --rm -v "$(host_path):/project" -w /project -e HOME=/tmp "$IDF_IMAGE" \
|
||||
bash -c "idf.py set-target $CHIP >/dev/null && idf.py build"
|
||||
fi
|
||||
}
|
||||
|
||||
port_arg() {
|
||||
if [ -n "${1:-}" ]; then echo "$1"; elif [ -n "${PORT:-}" ]; then echo "$PORT"; else
|
||||
echo "which port? ./fixture.sh flash PORT (COM5, /dev/ttyUSB0, /dev/cu.usbserial-*)" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_flash() {
|
||||
port=$(port_arg "${1:-}")
|
||||
[ -e build/flash_args ] || { echo "no build yet: ./fixture.sh build" >&2; exit 1; }
|
||||
if have idf.py; then
|
||||
idf.py -p "$port" flash
|
||||
else
|
||||
have esptool.py || have esptool || { echo "esptool not found: pip install esptool" >&2; exit 1; }
|
||||
tool=$(have esptool.py && echo esptool.py || echo esptool)
|
||||
(cd build && "$tool" --chip "$CHIP" -p "$port" -b "$BAUD" --before default_reset --after hard_reset \
|
||||
write_flash @flash_args)
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_monitor() {
|
||||
port=$(port_arg "${1:-}")
|
||||
if have idf.py; then
|
||||
idf.py -p "$port" monitor
|
||||
else
|
||||
python3 -m serial.tools.miniterm --raw "$port" 115200
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_test() {
|
||||
sh test/run.sh
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
env) cmd_env ;;
|
||||
build) cmd_build ;;
|
||||
flash) cmd_flash "${2:-}" ;;
|
||||
monitor) cmd_monitor "${2:-}" ;;
|
||||
test) cmd_test ;;
|
||||
*) sed -n '2,12p' "$0"; exit 1 ;;
|
||||
esac
|
||||
@@ -1,64 +0,0 @@
|
||||
# The fixture's one input is fixture.env at the project root: KEY=value
|
||||
# lines, read here into a generated header. No file, or a required key
|
||||
# left empty, stops the build with the reason.
|
||||
set(FIXTURE_ENV "${CMAKE_CURRENT_LIST_DIR}/../fixture.env")
|
||||
if(NOT EXISTS "${FIXTURE_ENV}")
|
||||
message(FATAL_ERROR
|
||||
"fixture.env not found at ${FIXTURE_ENV}: copy fixture.env.example to "
|
||||
"fixture.env and fill in WIFI_SSID, WIFI_PSK and API_KEY (./fixture.sh env does it)")
|
||||
endif()
|
||||
|
||||
set(FIXTURE_WIFI_SSID "")
|
||||
set(FIXTURE_WIFI_PSK "")
|
||||
set(FIXTURE_API_KEY "")
|
||||
set(FIXTURE_HOSTNAME "")
|
||||
|
||||
# Read the file whole and split on newlines ourselves: file(STRINGS)
|
||||
# would also split on semicolons, which a passphrase may carry.
|
||||
file(READ "${FIXTURE_ENV}" _env_text)
|
||||
string(REPLACE ";" "\\;" _env_text "${_env_text}")
|
||||
string(REGEX REPLACE "\r?\n" ";" _env_lines "${_env_text}")
|
||||
foreach(_line IN LISTS _env_lines)
|
||||
if(_line MATCHES "^[ \t]*#" OR _line MATCHES "^[ \t]*$")
|
||||
continue()
|
||||
endif()
|
||||
if(_line MATCHES "^[ \t]*([A-Za-z_][A-Za-z0-9_]*)[ \t]*=(.*)$")
|
||||
set(_key "${CMAKE_MATCH_1}")
|
||||
set(_val "${CMAKE_MATCH_2}")
|
||||
string(STRIP "${_val}" _val)
|
||||
string(REGEX REPLACE "^\"(.*)\"$" "\\1" _val "${_val}")
|
||||
string(REGEX REPLACE "^'(.*)'$" "\\1" _val "${_val}")
|
||||
set(FIXTURE_${_key} "${_val}")
|
||||
else()
|
||||
message(FATAL_ERROR "fixture.env: cannot read line: ${_line}")
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
foreach(_key WIFI_SSID API_KEY)
|
||||
if("${FIXTURE_${_key}}" STREQUAL "")
|
||||
message(FATAL_ERROR "fixture.env: ${_key} is empty")
|
||||
endif()
|
||||
endforeach()
|
||||
if("${FIXTURE_HOSTNAME}" STREQUAL "")
|
||||
set(FIXTURE_HOSTNAME "forgefixture")
|
||||
endif()
|
||||
string(LENGTH "${FIXTURE_HOSTNAME}" _hostlen)
|
||||
if(NOT FIXTURE_HOSTNAME MATCHES "^[a-z0-9][a-z0-9-]*$" OR FIXTURE_HOSTNAME MATCHES "-$" OR _hostlen GREATER 32)
|
||||
message(FATAL_ERROR "fixture.env: HOSTNAME must be a DNS label (lowercase letters, digits, hyphens; 32 at most)")
|
||||
endif()
|
||||
|
||||
# Into C string literals.
|
||||
foreach(_key WIFI_SSID WIFI_PSK API_KEY HOSTNAME)
|
||||
string(REPLACE "\\" "\\\\" FIXTURE_${_key} "${FIXTURE_${_key}}")
|
||||
string(REPLACE "\"" "\\\"" FIXTURE_${_key} "${FIXTURE_${_key}}")
|
||||
endforeach()
|
||||
|
||||
set(_gen_dir "${CMAKE_CURRENT_BINARY_DIR}/generated")
|
||||
file(MAKE_DIRECTORY "${_gen_dir}")
|
||||
configure_file("${CMAKE_CURRENT_LIST_DIR}/fixture_config.h.in" "${_gen_dir}/fixture_config.h" @ONLY)
|
||||
|
||||
idf_component_register(
|
||||
SRCS "main.c" "wifi.c" "relays.c" "api.c" "policy.c"
|
||||
INCLUDE_DIRS "." "${_gen_dir}"
|
||||
REQUIRES nvs_flash esp_wifi esp_netif esp_event esp_http_server json esp_timer driver
|
||||
)
|
||||
@@ -1,229 +0,0 @@
|
||||
/*
|
||||
* forgefixture API (see api.h).
|
||||
*
|
||||
* Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
* Written by Scott Wiederhold
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
#include "api.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "esp_http_server.h"
|
||||
#include "esp_idf_version.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_timer.h"
|
||||
#include "lwip/sockets.h"
|
||||
|
||||
#include "policy.h"
|
||||
#include "relays.h"
|
||||
#include "version.h"
|
||||
#include "wifi.h"
|
||||
|
||||
static const char *TAG = "api";
|
||||
|
||||
static const char *s_key;
|
||||
static const char *s_hostname;
|
||||
|
||||
#define BODY_MAX 256
|
||||
|
||||
/* The peer's address for the log: every action is attributed. */
|
||||
static void peer(httpd_req_t *req, char *buf, size_t n)
|
||||
{
|
||||
struct sockaddr_in addr; /* IPv4 only: the fixture speaks nothing else */
|
||||
socklen_t len = sizeof(addr);
|
||||
int fd = httpd_req_to_sockfd(req);
|
||||
buf[0] = '\0';
|
||||
if (fd >= 0 && getpeername(fd, (struct sockaddr *)&addr, &len) == 0 && addr.sin_family == AF_INET)
|
||||
inet_ntoa_r(addr.sin_addr, buf, n);
|
||||
}
|
||||
|
||||
static esp_err_t send_json(httpd_req_t *req, const char *status, cJSON *obj)
|
||||
{
|
||||
char *text = cJSON_PrintUnformatted(obj);
|
||||
cJSON_Delete(obj);
|
||||
if (text == NULL)
|
||||
return httpd_resp_send_500(req);
|
||||
httpd_resp_set_status(req, status);
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
esp_err_t r = httpd_resp_send(req, text, HTTPD_RESP_USE_STRLEN);
|
||||
cJSON_free(text);
|
||||
return r;
|
||||
}
|
||||
|
||||
static esp_err_t send_error(httpd_req_t *req, const char *status, const char *msg)
|
||||
{
|
||||
cJSON *o = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(o, "error", msg);
|
||||
return send_json(req, status, o);
|
||||
}
|
||||
|
||||
static cJSON *state_json(void)
|
||||
{
|
||||
relays_state_t st = relays_state();
|
||||
char ip[40];
|
||||
wifi_ip(ip, sizeof(ip));
|
||||
|
||||
cJSON *o = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(o, "device", "forgefixture");
|
||||
cJSON_AddStringToObject(o, "hostname", s_hostname);
|
||||
cJSON_AddStringToObject(o, "version", FIXTURE_VERSION);
|
||||
cJSON_AddStringToObject(o, "idf", esp_get_idf_version());
|
||||
cJSON_AddNumberToObject(o, "uptime_s", (double)(esp_timer_get_time() / 1000000LL));
|
||||
cJSON *ch = cJSON_AddObjectToObject(o, "channels");
|
||||
for (int i = 0; i < CH_COUNT; i++)
|
||||
cJSON_AddStringToObject(ch, policy_channel_name((channel_t)i),
|
||||
policy_state_name((channel_t)i, st.energized[i]));
|
||||
cJSON_AddBoolToObject(o, "button_enabled", st.button_enabled);
|
||||
cJSON_AddBoolToObject(o, "button_pulsing", st.button_pulsing);
|
||||
cJSON *w = cJSON_AddObjectToObject(o, "wifi");
|
||||
cJSON_AddBoolToObject(w, "connected", wifi_connected());
|
||||
cJSON_AddStringToObject(w, "ip", ip);
|
||||
cJSON_AddNumberToObject(w, "rssi", wifi_rssi());
|
||||
return o;
|
||||
}
|
||||
|
||||
/* The key check, before anything else on every path. */
|
||||
static bool authorized(httpd_req_t *req)
|
||||
{
|
||||
char presented[96] = {0};
|
||||
esp_err_t r = httpd_req_get_hdr_value_str(req, "X-Fixture-Key", presented, sizeof(presented));
|
||||
bool ok = (r == ESP_OK) && policy_key_matches(presented, s_key);
|
||||
if (!ok) {
|
||||
char who[48];
|
||||
peer(req, who, sizeof(who));
|
||||
ESP_LOGW(TAG, "%s %s from %s: refused (%s)", http_method_str(req->method), req->uri, who,
|
||||
r == ESP_OK ? "wrong key" : "no key");
|
||||
send_error(req, "401 Unauthorized", "X-Fixture-Key missing or wrong");
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
/* The JSON body, or NULL (an error already sent). An empty body parses
|
||||
* as an empty object so "POST /button" alone means the default pulse. */
|
||||
static cJSON *body_json(httpd_req_t *req)
|
||||
{
|
||||
char buf[BODY_MAX + 1];
|
||||
if (req->content_len > BODY_MAX) {
|
||||
send_error(req, "413 Payload Too Large", "body over 256 bytes");
|
||||
return NULL;
|
||||
}
|
||||
size_t got = 0;
|
||||
while (got < req->content_len) {
|
||||
int n = httpd_req_recv(req, buf + got, req->content_len - got);
|
||||
if (n <= 0) {
|
||||
send_error(req, "400 Bad Request", "body not received");
|
||||
return NULL;
|
||||
}
|
||||
got += (size_t)n;
|
||||
}
|
||||
buf[got] = '\0';
|
||||
cJSON *o = got ? cJSON_Parse(buf) : cJSON_CreateObject();
|
||||
if (o == NULL || !cJSON_IsObject(o)) {
|
||||
cJSON_Delete(o);
|
||||
send_error(req, "400 Bad Request", "body is not a JSON object");
|
||||
return NULL;
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
static esp_err_t h_state(httpd_req_t *req)
|
||||
{
|
||||
if (!authorized(req))
|
||||
return ESP_OK;
|
||||
return send_json(req, "200 OK", state_json());
|
||||
}
|
||||
|
||||
static esp_err_t h_loop(httpd_req_t *req)
|
||||
{
|
||||
if (!authorized(req))
|
||||
return ESP_OK;
|
||||
int ch = policy_channel_from_path(req->uri);
|
||||
cJSON *body = body_json(req);
|
||||
if (body == NULL)
|
||||
return ESP_OK;
|
||||
const cJSON *state = cJSON_GetObjectItemCaseSensitive(body, "state");
|
||||
bool energize;
|
||||
if (!cJSON_IsString(state) || !policy_parse_loop_state(state->valuestring, &energize)) {
|
||||
cJSON_Delete(body);
|
||||
return send_error(req, "400 Bad Request", "state must be \"open\" or \"close\"");
|
||||
}
|
||||
cJSON_Delete(body);
|
||||
char who[48];
|
||||
peer(req, who, sizeof(who));
|
||||
ESP_LOGI(TAG, "%s %s by %s", policy_channel_name((channel_t)ch), energize ? "open" : "close", who);
|
||||
relays_set_loop((channel_t)ch, energize);
|
||||
return send_json(req, "200 OK", state_json());
|
||||
}
|
||||
|
||||
static esp_err_t h_button(httpd_req_t *req)
|
||||
{
|
||||
if (!authorized(req))
|
||||
return ESP_OK;
|
||||
cJSON *body = body_json(req);
|
||||
if (body == NULL)
|
||||
return ESP_OK;
|
||||
const cJSON *ms = cJSON_GetObjectItemCaseSensitive(body, "ms");
|
||||
int want = cJSON_IsNumber(ms) ? (int)ms->valuedouble : 0;
|
||||
cJSON_Delete(body);
|
||||
int pulse = policy_button_ms(want);
|
||||
char who[48];
|
||||
peer(req, who, sizeof(who));
|
||||
esp_err_t r = relays_pulse_button(pulse);
|
||||
if (r == ESP_ERR_NOT_ALLOWED) {
|
||||
ESP_LOGW(TAG, "button press by %s refused: enable jumper out", who);
|
||||
return send_error(req, "409 Conflict", "button disabled: the enable jumper is out");
|
||||
}
|
||||
if (r == ESP_ERR_INVALID_STATE) {
|
||||
ESP_LOGW(TAG, "button press by %s refused: a pulse is in progress", who);
|
||||
return send_error(req, "409 Conflict", "a button pulse is in progress");
|
||||
}
|
||||
ESP_LOGI(TAG, "button %d ms by %s", pulse, who);
|
||||
cJSON *o = state_json();
|
||||
cJSON_AddNumberToObject(o, "pulse_ms", pulse);
|
||||
return send_json(req, "200 OK", o);
|
||||
}
|
||||
|
||||
static esp_err_t h_release(httpd_req_t *req)
|
||||
{
|
||||
if (!authorized(req))
|
||||
return ESP_OK;
|
||||
char who[48];
|
||||
peer(req, who, sizeof(who));
|
||||
ESP_LOGI(TAG, "release by %s", who);
|
||||
relays_release();
|
||||
return send_json(req, "200 OK", state_json());
|
||||
}
|
||||
|
||||
static esp_err_t h_404(httpd_req_t *req, httpd_err_code_t err)
|
||||
{
|
||||
(void)err;
|
||||
return send_error(req, "404 Not Found", "no such path");
|
||||
}
|
||||
|
||||
void api_start(const char *api_key, const char *hostname)
|
||||
{
|
||||
s_key = api_key;
|
||||
s_hostname = hostname;
|
||||
httpd_config_t cfg = HTTPD_DEFAULT_CONFIG();
|
||||
cfg.server_port = 80;
|
||||
cfg.lru_purge_enable = true;
|
||||
cfg.max_open_sockets = 4;
|
||||
httpd_handle_t srv = NULL;
|
||||
ESP_ERROR_CHECK(httpd_start(&srv, &cfg));
|
||||
|
||||
static const httpd_uri_t routes[] = {
|
||||
{.uri = "/", .method = HTTP_GET, .handler = h_state},
|
||||
{.uri = "/state", .method = HTTP_GET, .handler = h_state},
|
||||
{.uri = "/lid", .method = HTTP_POST, .handler = h_loop},
|
||||
{.uri = "/interlock", .method = HTTP_POST, .handler = h_loop},
|
||||
{.uri = "/button", .method = HTTP_POST, .handler = h_button},
|
||||
{.uri = "/release", .method = HTTP_POST, .handler = h_release},
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(routes) / sizeof(routes[0]); i++)
|
||||
ESP_ERROR_CHECK(httpd_register_uri_handler(srv, &routes[i]));
|
||||
httpd_register_err_handler(srv, HTTPD_404_NOT_FOUND, h_404);
|
||||
ESP_LOGI(TAG, "listening on :%d", cfg.server_port);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
/*
|
||||
* forgefixture API: HTTP on port 80, JSON, every request under the key.
|
||||
*
|
||||
* GET / identity, uptime, the channels' states, the
|
||||
* enable jumper, the wifi link
|
||||
* GET /state the same
|
||||
* POST /lid {"state": "open" | "close"}
|
||||
* POST /interlock {"state": "open" | "close"}
|
||||
* POST /button {"ms": 200} one pulse, clamped to 20..500 ms
|
||||
* POST /release every channel released
|
||||
*
|
||||
* The key travels in the X-Fixture-Key header; without it, or with a
|
||||
* wrong one, every path answers 401. A lid or interlock "open" energizes
|
||||
* the channel (the loop opens); "close" releases it. The button is
|
||||
* refused with 409 while the enable jumper is out or a pulse is still in
|
||||
* progress. Errors are {"error": "..."}; actions answer with the state
|
||||
* as GET / shows it.
|
||||
*
|
||||
* Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
* Written by Scott Wiederhold
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
void api_start(const char *api_key, const char *hostname);
|
||||
@@ -1,7 +0,0 @@
|
||||
/* Generated from fixture.env by main/CMakeLists.txt. Do not edit. */
|
||||
#pragma once
|
||||
|
||||
#define FIXTURE_WIFI_SSID "@FIXTURE_WIFI_SSID@"
|
||||
#define FIXTURE_WIFI_PSK "@FIXTURE_WIFI_PSK@"
|
||||
#define FIXTURE_API_KEY "@FIXTURE_API_KEY@"
|
||||
#define FIXTURE_HOSTNAME "@FIXTURE_HOSTNAME@"
|
||||
@@ -1,5 +0,0 @@
|
||||
## The managed components this firmware uses (fetched by the IDF
|
||||
## component manager on the first build; dependencies.lock pins them).
|
||||
dependencies:
|
||||
idf: ">=5.3"
|
||||
espressif/mdns: "^1.11.3"
|
||||
@@ -1,41 +0,0 @@
|
||||
/*
|
||||
* forgefixture: the ForgeFIRM bench actuator. An ESP32-S3 on the bench
|
||||
* network drives three relays at the machine's connectors (the lid
|
||||
* loop, the interlock loop, the button) so the acceptance tool can open
|
||||
* a lid, pull an interlock or press the button without a hand in the
|
||||
* room. The firmware's whole job is to do exactly that on request, and
|
||||
* nothing at all otherwise: every line low at boot and after any reset,
|
||||
* the button only ever pulsed, every request under a key.
|
||||
*
|
||||
* Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
* Written by Scott Wiederhold
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
#include "esp_log.h"
|
||||
#include "nvs_flash.h"
|
||||
|
||||
#include "api.h"
|
||||
#include "fixture_config.h"
|
||||
#include "relays.h"
|
||||
#include "version.h"
|
||||
#include "wifi.h"
|
||||
|
||||
static const char *TAG = "forgefixture";
|
||||
|
||||
void app_main(void)
|
||||
{
|
||||
/* The relays first: whatever else happens at boot, the lines are low
|
||||
* before the radio or the server exist. */
|
||||
relays_init();
|
||||
|
||||
esp_err_t r = nvs_flash_init();
|
||||
if (r == ESP_ERR_NVS_NO_FREE_PAGES || r == ESP_ERR_NVS_NEW_VERSION_FOUND) {
|
||||
ESP_ERROR_CHECK(nvs_flash_erase());
|
||||
r = nvs_flash_init();
|
||||
}
|
||||
ESP_ERROR_CHECK(r);
|
||||
|
||||
ESP_LOGI(TAG, "forgefixture %s, hostname %s", FIXTURE_VERSION, FIXTURE_HOSTNAME);
|
||||
wifi_start(FIXTURE_HOSTNAME, FIXTURE_WIFI_SSID, FIXTURE_WIFI_PSK);
|
||||
api_start(FIXTURE_API_KEY, FIXTURE_HOSTNAME);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
/*
|
||||
* forgefixture policy (see policy.h). No ESP-IDF here: test/policy_test.c
|
||||
* compiles this file with a host compiler.
|
||||
*
|
||||
* Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
* Written by Scott Wiederhold
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
#include "policy.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
static const char *const NAMES[CH_COUNT] = {"lid", "interlock", "button"};
|
||||
|
||||
const char *policy_channel_name(channel_t ch)
|
||||
{
|
||||
return (ch >= 0 && ch < CH_COUNT) ? NAMES[ch] : "?";
|
||||
}
|
||||
|
||||
int policy_channel_from_path(const char *path)
|
||||
{
|
||||
if (path == NULL || path[0] != '/')
|
||||
return -1;
|
||||
for (int i = 0; i < CH_COUNT; i++)
|
||||
if (strcmp(path + 1, NAMES[i]) == 0)
|
||||
return i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool policy_parse_loop_state(const char *word, bool *energize)
|
||||
{
|
||||
if (word == NULL)
|
||||
return false;
|
||||
if (strcmp(word, "open") == 0) {
|
||||
*energize = true;
|
||||
return true;
|
||||
}
|
||||
if (strcmp(word, "close") == 0 || strcmp(word, "closed") == 0) {
|
||||
*energize = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
int policy_button_ms(int requested)
|
||||
{
|
||||
if (requested <= 0)
|
||||
return BUTTON_DEFAULT_MS;
|
||||
if (requested < BUTTON_MIN_MS)
|
||||
return BUTTON_MIN_MS;
|
||||
if (requested > BUTTON_MAX_MS)
|
||||
return BUTTON_MAX_MS;
|
||||
return requested;
|
||||
}
|
||||
|
||||
const char *policy_state_name(channel_t ch, bool energized)
|
||||
{
|
||||
if (ch == CH_BUTTON)
|
||||
return energized ? "pressed" : "idle";
|
||||
return energized ? "open" : "closed";
|
||||
}
|
||||
|
||||
bool policy_key_matches(const char *presented, const char *expected)
|
||||
{
|
||||
if (presented == NULL || expected == NULL)
|
||||
return false;
|
||||
size_t lp = strlen(presented), le = strlen(expected);
|
||||
size_t n = lp > le ? lp : le;
|
||||
unsigned diff = (unsigned)(lp != le);
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
unsigned char a = i < lp ? (unsigned char)presented[i] : 0;
|
||||
unsigned char b = i < le ? (unsigned char)expected[i] : 0;
|
||||
diff |= (unsigned)(a ^ b);
|
||||
}
|
||||
return diff == 0;
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
/*
|
||||
* forgefixture policy: the decisions that need no hardware, kept apart
|
||||
* so the host test can hold them to account. What a channel is called,
|
||||
* what a request may ask of it, and how long a button press may last.
|
||||
*
|
||||
* Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
* Written by Scott Wiederhold
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
/* The three channels, in the order of the GPIO table in relays.c. */
|
||||
typedef enum {
|
||||
CH_LID = 0, /* NC contact in the lid-switch loop: energized = loop open */
|
||||
CH_INTERLOCK = 1, /* NC contact in the interlock loop: energized = loop open */
|
||||
CH_BUTTON = 2, /* NO contact across the button input: energized = pressed */
|
||||
CH_COUNT = 3
|
||||
} channel_t;
|
||||
|
||||
/* A button press is a pulse and nothing else: never held, never longer
|
||||
* than BUTTON_MAX_MS whatever the request says, never shorter than the
|
||||
* debounce the machine's input needs to see it. */
|
||||
#define BUTTON_DEFAULT_MS 200
|
||||
#define BUTTON_MIN_MS 20
|
||||
#define BUTTON_MAX_MS 500
|
||||
|
||||
/* The channel's name on the API and in the log. */
|
||||
const char *policy_channel_name(channel_t ch);
|
||||
|
||||
/* The channel named by an API path ("/lid", "/interlock", "/button"), or
|
||||
* -1. */
|
||||
int policy_channel_from_path(const char *path);
|
||||
|
||||
/* The level a lid or interlock request asks for: "open" energizes the
|
||||
* channel (the loop opens), "close" or "closed" releases it. Returns
|
||||
* false for any other word. */
|
||||
bool policy_parse_loop_state(const char *word, bool *energize);
|
||||
|
||||
/* The pulse a button request gets: the default for 0 or an absent
|
||||
* value, otherwise the request clamped into [BUTTON_MIN_MS,
|
||||
* BUTTON_MAX_MS]. A negative request is the default too. */
|
||||
int policy_button_ms(int requested);
|
||||
|
||||
/* What the state of a channel is called on the API: a loop channel is
|
||||
* "open" or "closed", the button "pressed" or "idle". */
|
||||
const char *policy_state_name(channel_t ch, bool energized);
|
||||
|
||||
/* Constant-time equality of two keys, so a wrong key costs the same
|
||||
* whichever byte is wrong. */
|
||||
bool policy_key_matches(const char *presented, const char *expected);
|
||||
@@ -1,128 +0,0 @@
|
||||
/*
|
||||
* forgefixture relays (see relays.h).
|
||||
*
|
||||
* The safety argument lives here: the lid and interlock contacts are
|
||||
* normally closed and the button contact normally open, so a line that
|
||||
* is low leaves the machine exactly as it is without the fixture. Every
|
||||
* line is driven low first thing at boot, after any reset, and by
|
||||
* relays_release(); the button is only ever pulsed, its end set by a
|
||||
* one-shot timer that is armed before the line goes high.
|
||||
*
|
||||
* Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
* Written by Scott Wiederhold
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
#include "relays.h"
|
||||
|
||||
#include "driver/gpio.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
|
||||
static const char *TAG = "relays";
|
||||
|
||||
static const gpio_num_t GPIOS[CH_COUNT] = {RELAY_GPIO_LID, RELAY_GPIO_INTERLOCK, RELAY_GPIO_BUTTON};
|
||||
|
||||
static SemaphoreHandle_t s_lock;
|
||||
static esp_timer_handle_t s_pulse_end;
|
||||
static bool s_energized[CH_COUNT];
|
||||
static bool s_pulsing;
|
||||
|
||||
static void drive(channel_t ch, bool level)
|
||||
{
|
||||
gpio_set_level(GPIOS[ch], level ? 1 : 0);
|
||||
s_energized[ch] = level;
|
||||
}
|
||||
|
||||
static void pulse_end(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
xSemaphoreTake(s_lock, portMAX_DELAY);
|
||||
drive(CH_BUTTON, false);
|
||||
s_pulsing = false;
|
||||
xSemaphoreGive(s_lock);
|
||||
ESP_LOGI(TAG, "button released");
|
||||
}
|
||||
|
||||
void relays_init(void)
|
||||
{
|
||||
/* Low before the pins become outputs: the DevKit's pull state at
|
||||
* reset is not a relay's idea of off. */
|
||||
for (int i = 0; i < CH_COUNT; i++) {
|
||||
gpio_reset_pin(GPIOS[i]);
|
||||
gpio_set_level(GPIOS[i], 0);
|
||||
gpio_set_direction(GPIOS[i], GPIO_MODE_OUTPUT);
|
||||
gpio_set_level(GPIOS[i], 0);
|
||||
s_energized[i] = false;
|
||||
}
|
||||
gpio_reset_pin(ENABLE_GPIO_BUTTON);
|
||||
gpio_set_direction(ENABLE_GPIO_BUTTON, GPIO_MODE_INPUT);
|
||||
gpio_set_pull_mode(ENABLE_GPIO_BUTTON, GPIO_PULLUP_ONLY);
|
||||
|
||||
s_lock = xSemaphoreCreateMutex();
|
||||
const esp_timer_create_args_t args = {
|
||||
.callback = pulse_end,
|
||||
.name = "button-pulse",
|
||||
};
|
||||
ESP_ERROR_CHECK(esp_timer_create(&args, &s_pulse_end));
|
||||
ESP_LOGI(TAG, "lid GPIO%d, interlock GPIO%d, button GPIO%d (enable jumper GPIO%d): all released",
|
||||
RELAY_GPIO_LID, RELAY_GPIO_INTERLOCK, RELAY_GPIO_BUTTON, ENABLE_GPIO_BUTTON);
|
||||
}
|
||||
|
||||
static bool button_enabled(void)
|
||||
{
|
||||
return gpio_get_level(ENABLE_GPIO_BUTTON) == 0;
|
||||
}
|
||||
|
||||
esp_err_t relays_set_loop(channel_t ch, bool energize)
|
||||
{
|
||||
if (ch != CH_LID && ch != CH_INTERLOCK)
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
xSemaphoreTake(s_lock, portMAX_DELAY);
|
||||
drive(ch, energize);
|
||||
xSemaphoreGive(s_lock);
|
||||
ESP_LOGI(TAG, "%s %s", policy_channel_name(ch), policy_state_name(ch, energize));
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t relays_pulse_button(int ms)
|
||||
{
|
||||
if (!button_enabled())
|
||||
return ESP_ERR_NOT_ALLOWED;
|
||||
xSemaphoreTake(s_lock, portMAX_DELAY);
|
||||
if (s_pulsing) {
|
||||
xSemaphoreGive(s_lock);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
s_pulsing = true;
|
||||
/* The end is armed before the line rises. */
|
||||
ESP_ERROR_CHECK(esp_timer_start_once(s_pulse_end, (uint64_t)ms * 1000ULL));
|
||||
drive(CH_BUTTON, true);
|
||||
xSemaphoreGive(s_lock);
|
||||
ESP_LOGI(TAG, "button pressed for %d ms", ms);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void relays_release(void)
|
||||
{
|
||||
xSemaphoreTake(s_lock, portMAX_DELAY);
|
||||
esp_timer_stop(s_pulse_end); /* harmless when not running */
|
||||
for (int i = 0; i < CH_COUNT; i++)
|
||||
drive((channel_t)i, false);
|
||||
s_pulsing = false;
|
||||
xSemaphoreGive(s_lock);
|
||||
ESP_LOGI(TAG, "all released");
|
||||
}
|
||||
|
||||
relays_state_t relays_state(void)
|
||||
{
|
||||
relays_state_t st;
|
||||
xSemaphoreTake(s_lock, portMAX_DELAY);
|
||||
for (int i = 0; i < CH_COUNT; i++)
|
||||
st.energized[i] = s_energized[i];
|
||||
st.button_pulsing = s_pulsing;
|
||||
xSemaphoreGive(s_lock);
|
||||
st.button_enabled = button_enabled();
|
||||
return st;
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
/*
|
||||
* forgefixture relays: the three channels on their GPIOs, the button's
|
||||
* pulse timer, and the enable jumper that gates the button.
|
||||
*
|
||||
* Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
* Written by Scott Wiederhold
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "policy.h"
|
||||
|
||||
/* DevKitC-1 pins with no strapping, USB, flash or PSRAM role. Active
|
||||
* high into the relay modules' opto inputs. */
|
||||
#define RELAY_GPIO_LID 4
|
||||
#define RELAY_GPIO_INTERLOCK 5
|
||||
#define RELAY_GPIO_BUTTON 6
|
||||
/* The enable jumper for the button channel: input with the pull-up on,
|
||||
* the jumper shorts it to GND. No jumper = high = button disabled. */
|
||||
#define ENABLE_GPIO_BUTTON 7
|
||||
|
||||
typedef struct {
|
||||
bool energized[CH_COUNT];
|
||||
bool button_enabled; /* the jumper is in */
|
||||
bool button_pulsing; /* a press is in progress */
|
||||
} relays_state_t;
|
||||
|
||||
/* Every line low before anything else runs. */
|
||||
void relays_init(void);
|
||||
|
||||
/* Lid or interlock: hold the channel energized (loop open) or released
|
||||
* (loop closed). ESP_ERR_INVALID_ARG for the button, which is never
|
||||
* held. */
|
||||
esp_err_t relays_set_loop(channel_t ch, bool energize);
|
||||
|
||||
/* The button: one pulse of `ms` (already clamped by policy_button_ms).
|
||||
* ESP_ERR_NOT_ALLOWED without the enable jumper, ESP_ERR_INVALID_STATE
|
||||
* while a pulse is still in progress. */
|
||||
esp_err_t relays_pulse_button(int ms);
|
||||
|
||||
/* Everything low, a pulse in progress cut short. */
|
||||
void relays_release(void);
|
||||
|
||||
relays_state_t relays_state(void);
|
||||
@@ -1,9 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
* Written by Scott Wiederhold
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
/* forgefixture firmware version, reported by GET /. */
|
||||
#pragma once
|
||||
|
||||
#define FIXTURE_VERSION "1.0.0"
|
||||
@@ -1,119 +0,0 @@
|
||||
/*
|
||||
* forgefixture wifi (see wifi.h).
|
||||
*
|
||||
* Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
* Written by Scott Wiederhold
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
#include "wifi.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#include "esp_event.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_netif.h"
|
||||
#include "esp_wifi.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "mdns.h"
|
||||
|
||||
static const char *TAG = "wifi";
|
||||
|
||||
static esp_netif_t *s_netif;
|
||||
static volatile bool s_connected;
|
||||
static esp_ip4_addr_t s_ip;
|
||||
static char s_hostname[32];
|
||||
|
||||
static void on_wifi(void *arg, esp_event_base_t base, int32_t id, void *data)
|
||||
{
|
||||
(void)arg;
|
||||
(void)data;
|
||||
if (id == WIFI_EVENT_STA_START) {
|
||||
esp_wifi_connect();
|
||||
} else if (id == WIFI_EVENT_STA_DISCONNECTED) {
|
||||
const wifi_event_sta_disconnected_t *d = data;
|
||||
s_connected = false;
|
||||
ESP_LOGW(TAG, "disconnected (reason %d), reconnecting", d ? d->reason : -1);
|
||||
/* A short pause keeps a wrong passphrase from becoming a storm;
|
||||
* the event loop task tolerates it. */
|
||||
vTaskDelay(pdMS_TO_TICKS(1000));
|
||||
esp_wifi_connect();
|
||||
}
|
||||
}
|
||||
|
||||
static void on_ip(void *arg, esp_event_base_t base, int32_t id, void *data)
|
||||
{
|
||||
(void)arg;
|
||||
(void)base;
|
||||
if (id == IP_EVENT_STA_GOT_IP) {
|
||||
const ip_event_got_ip_t *ev = data;
|
||||
s_ip = ev->ip_info.ip;
|
||||
s_connected = true;
|
||||
ESP_LOGI(TAG, "joined: " IPSTR " as %s.local", IP2STR(&ev->ip_info.ip), s_hostname);
|
||||
} else if (id == IP_EVENT_STA_LOST_IP) {
|
||||
s_connected = false;
|
||||
ESP_LOGW(TAG, "address lost");
|
||||
}
|
||||
}
|
||||
|
||||
static void mdns_start(const char *hostname)
|
||||
{
|
||||
ESP_ERROR_CHECK(mdns_init());
|
||||
ESP_ERROR_CHECK(mdns_hostname_set(hostname));
|
||||
ESP_ERROR_CHECK(mdns_instance_name_set("ForgeFIRM bench fixture"));
|
||||
mdns_txt_item_t txt[] = {
|
||||
{"device", "forgefixture"},
|
||||
{"channels", "lid,interlock,button"},
|
||||
};
|
||||
ESP_ERROR_CHECK(mdns_service_add(NULL, "_forgefixture", "_tcp", 80, txt, 2));
|
||||
ESP_ERROR_CHECK(mdns_service_add(NULL, "_http", "_tcp", 80, txt, 2));
|
||||
}
|
||||
|
||||
void wifi_start(const char *hostname, const char *ssid, const char *psk)
|
||||
{
|
||||
strncpy(s_hostname, hostname, sizeof(s_hostname) - 1);
|
||||
ESP_ERROR_CHECK(esp_netif_init());
|
||||
ESP_ERROR_CHECK(esp_event_loop_create_default());
|
||||
s_netif = esp_netif_create_default_wifi_sta();
|
||||
ESP_ERROR_CHECK(esp_netif_set_hostname(s_netif, hostname)); /* DHCP option 12 */
|
||||
|
||||
wifi_init_config_t init = WIFI_INIT_CONFIG_DEFAULT();
|
||||
ESP_ERROR_CHECK(esp_wifi_init(&init));
|
||||
ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &on_wifi, NULL));
|
||||
ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, ESP_EVENT_ANY_ID, &on_ip, NULL));
|
||||
|
||||
wifi_config_t cfg = {0};
|
||||
strncpy((char *)cfg.sta.ssid, ssid, sizeof(cfg.sta.ssid) - 1);
|
||||
strncpy((char *)cfg.sta.password, psk, sizeof(cfg.sta.password) - 1);
|
||||
cfg.sta.threshold.authmode = psk[0] ? WIFI_AUTH_WPA2_PSK : WIFI_AUTH_OPEN;
|
||||
cfg.sta.pmf_cfg.capable = true;
|
||||
cfg.sta.pmf_cfg.required = false;
|
||||
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
|
||||
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &cfg));
|
||||
ESP_ERROR_CHECK(esp_wifi_start());
|
||||
ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_NONE));
|
||||
|
||||
mdns_start(hostname);
|
||||
ESP_LOGI(TAG, "joining %s", ssid);
|
||||
}
|
||||
|
||||
void wifi_ip(char *buf, size_t n)
|
||||
{
|
||||
if (s_connected)
|
||||
snprintf(buf, n, IPSTR, IP2STR(&s_ip));
|
||||
else
|
||||
snprintf(buf, n, "0.0.0.0");
|
||||
}
|
||||
|
||||
int wifi_rssi(void)
|
||||
{
|
||||
wifi_ap_record_t ap;
|
||||
if (!s_connected || esp_wifi_sta_get_ap_info(&ap) != ESP_OK)
|
||||
return 0;
|
||||
return ap.rssi;
|
||||
}
|
||||
|
||||
bool wifi_connected(void)
|
||||
{
|
||||
return s_connected;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
/*
|
||||
* forgefixture wifi: a station that joins the bench network and stays
|
||||
* joined, announces its hostname over DHCP and mDNS, and never sleeps
|
||||
* the radio (the API answers in milliseconds, not on the next beacon).
|
||||
*
|
||||
* Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
* Written by Scott Wiederhold
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
void wifi_start(const char *hostname, const char *ssid, const char *psk);
|
||||
|
||||
/* The station's address as text ("0.0.0.0" while not joined). */
|
||||
void wifi_ip(char *buf, size_t n);
|
||||
|
||||
/* Signal strength in dBm, 0 while not joined. */
|
||||
int wifi_rssi(void);
|
||||
|
||||
bool wifi_connected(void);
|
||||
@@ -1,36 +0,0 @@
|
||||
# forgefixture build defaults (ESP32-S3 DevKitC-1). idf.py merges these
|
||||
# into sdkconfig on the first configure; sdkconfig itself is not tracked.
|
||||
CONFIG_IDF_TARGET="esp32s3"
|
||||
|
||||
# Any DevKitC-1 has at least 8 MB; a 4 MB layout with one large app
|
||||
# partition fits every one of them and leaves nothing to choose.
|
||||
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
|
||||
CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y
|
||||
|
||||
# A hung task reboots the board to every relay line low rather than
|
||||
# holding a button press: the task watchdog panics, and a panic resets.
|
||||
CONFIG_ESP_TASK_WDT_INIT=y
|
||||
CONFIG_ESP_TASK_WDT_PANIC=y
|
||||
CONFIG_ESP_TASK_WDT_TIMEOUT_S=5
|
||||
CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0=y
|
||||
CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1=y
|
||||
CONFIG_ESP_INT_WDT=y
|
||||
CONFIG_ESP_SYSTEM_PANIC_PRINT_REBOOT=y
|
||||
|
||||
# Brownout (the relays share the board's 3.3 V with nothing else, but the
|
||||
# USB supply is whatever it is): reset rather than limp.
|
||||
CONFIG_ESP_BROWNOUT_DET=y
|
||||
|
||||
# The log on both USB ports: the UART bridge and the native USB-Serial-JTAG.
|
||||
CONFIG_ESP_CONSOLE_UART_DEFAULT=y
|
||||
CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y
|
||||
|
||||
# HTTP server: a few clients, small requests.
|
||||
CONFIG_HTTPD_MAX_REQ_HDR_LEN=1024
|
||||
CONFIG_HTTPD_MAX_URI_LEN=128
|
||||
CONFIG_HTTPD_PURGE_BUF_LEN=32
|
||||
|
||||
# Wifi station only; no Bluetooth.
|
||||
CONFIG_BT_ENABLED=n
|
||||
CONFIG_ESP_WIFI_NVS_ENABLED=y
|
||||
CONFIG_LWIP_IPV6=n
|
||||
@@ -1,73 +0,0 @@
|
||||
/*
|
||||
* forgefixture policy, held to account on the host: gcc -o policy_test
|
||||
* policy_test.c ../main/policy.c (see run.sh).
|
||||
*
|
||||
* Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
* Written by Scott Wiederhold
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "../main/policy.h"
|
||||
|
||||
static int failures;
|
||||
|
||||
#define CHECK(cond) do { if (!(cond)) { failures++; printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); } } while (0)
|
||||
|
||||
int main(void)
|
||||
{
|
||||
/* names and paths */
|
||||
CHECK(strcmp(policy_channel_name(CH_LID), "lid") == 0);
|
||||
CHECK(strcmp(policy_channel_name(CH_INTERLOCK), "interlock") == 0);
|
||||
CHECK(strcmp(policy_channel_name(CH_BUTTON), "button") == 0);
|
||||
CHECK(policy_channel_from_path("/lid") == CH_LID);
|
||||
CHECK(policy_channel_from_path("/interlock") == CH_INTERLOCK);
|
||||
CHECK(policy_channel_from_path("/button") == CH_BUTTON);
|
||||
CHECK(policy_channel_from_path("/lids") == -1);
|
||||
CHECK(policy_channel_from_path("lid") == -1);
|
||||
CHECK(policy_channel_from_path(NULL) == -1);
|
||||
|
||||
/* a loop request: open energizes, close/closed releases, nothing else */
|
||||
bool e = false;
|
||||
CHECK(policy_parse_loop_state("open", &e) && e);
|
||||
CHECK(policy_parse_loop_state("close", &e) && !e);
|
||||
CHECK(policy_parse_loop_state("closed", &e) && !e);
|
||||
CHECK(!policy_parse_loop_state("Open", &e));
|
||||
CHECK(!policy_parse_loop_state("on", &e));
|
||||
CHECK(!policy_parse_loop_state("", &e));
|
||||
CHECK(!policy_parse_loop_state(NULL, &e));
|
||||
|
||||
/* the button pulse: a default, a floor, a ceiling, never a hold */
|
||||
CHECK(policy_button_ms(0) == BUTTON_DEFAULT_MS);
|
||||
CHECK(policy_button_ms(-5) == BUTTON_DEFAULT_MS);
|
||||
CHECK(policy_button_ms(1) == BUTTON_MIN_MS);
|
||||
CHECK(policy_button_ms(250) == 250);
|
||||
CHECK(policy_button_ms(500) == BUTTON_MAX_MS);
|
||||
CHECK(policy_button_ms(501) == BUTTON_MAX_MS);
|
||||
CHECK(policy_button_ms(60000) == BUTTON_MAX_MS);
|
||||
CHECK(BUTTON_MAX_MS <= 500);
|
||||
|
||||
/* state names */
|
||||
CHECK(strcmp(policy_state_name(CH_LID, true), "open") == 0);
|
||||
CHECK(strcmp(policy_state_name(CH_LID, false), "closed") == 0);
|
||||
CHECK(strcmp(policy_state_name(CH_INTERLOCK, true), "open") == 0);
|
||||
CHECK(strcmp(policy_state_name(CH_BUTTON, true), "pressed") == 0);
|
||||
CHECK(strcmp(policy_state_name(CH_BUTTON, false), "idle") == 0);
|
||||
|
||||
/* the key: exact, whole, and never by prefix */
|
||||
CHECK(policy_key_matches("abc123", "abc123"));
|
||||
CHECK(!policy_key_matches("abc12", "abc123"));
|
||||
CHECK(!policy_key_matches("abc1234", "abc123"));
|
||||
CHECK(!policy_key_matches("", "abc123"));
|
||||
CHECK(!policy_key_matches("abc123", ""));
|
||||
CHECK(!policy_key_matches(NULL, "abc123"));
|
||||
CHECK(!policy_key_matches("ABC123", "abc123"));
|
||||
|
||||
if (failures) {
|
||||
printf("%d failure(s)\n", failures);
|
||||
return 1;
|
||||
}
|
||||
printf("policy: all checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
# The host test of the fixture's policy: needs only a C compiler.
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
${CC:-cc} -std=c11 -Wall -Wextra -Werror -o policy_test policy_test.c ../main/policy.c
|
||||
./policy_test
|
||||
@@ -1,23 +0,0 @@
|
||||
ForgeFIRM release acceptance tool (forgetest/) and the bench scripts it
|
||||
drives (scripts/bench/).
|
||||
|
||||
Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
Written by Scott Wiederhold
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is furnished
|
||||
to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -1,47 +0,0 @@
|
||||
# forgetest - the ForgeFIRM release acceptance tool
|
||||
|
||||
The daemon behind `http://<machine>:8090/` on the dev image: runs the
|
||||
acceptance catalog against the machine, keeps the append-only result log,
|
||||
decides which results still apply to the image that is running, exports
|
||||
the release artifact `scripts/release.sh` gates on, and serves the bench
|
||||
diagnostics page. The contract - catalog, campaigns, fingerprints,
|
||||
inheritance, the gate, the coverage rule - is
|
||||
[the Acceptance page of the documentation site](https://docs.forgefirm.org/developers/acceptance/).
|
||||
|
||||
## Run the host tests
|
||||
|
||||
cd forgetest
|
||||
python3 -m unittest discover -s tests -v
|
||||
|
||||
## Run the daemon on a workstation (against a mock or a manifest file)
|
||||
|
||||
FORGETEST_DATA=/tmp/ft FORGETEST_MANIFEST=../tree-manifest.json \
|
||||
FORGECTRL_URL=http://<machine> python3 -m forgetest --port 8090
|
||||
|
||||
`scripts/manifest-from-tree.py` produces `tree-manifest.json` from the recipe
|
||||
pins; the coverage lint is `python3 -m forgetest.coverage --manifest ...`.
|
||||
|
||||
## Environment
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `FORGETEST_DATA` | `/data/forgetest` | results.jsonl, bench.jsonl, token, export/ |
|
||||
| `FORGETEST_MANIFEST` | `/etc/forgefirm-manifest.json` | the image manifest |
|
||||
| `FORGETEST_PORT`, `FORGETEST_HOST` | 8090, 0.0.0.0 | listener |
|
||||
| `FORGETEST_BENCH_DIR` | `/usr/share/forgetest/bench` | the installed bench scripts |
|
||||
| `FORGETEST_BENCH_DATA` | `<FORGETEST_DATA>/bench` | passed to bench tools: where they keep their data files (with `GF_HOST=127.0.0.1` and the panel token in `GF_TOKEN`) |
|
||||
| `FORGETEST_MARKER` | `/run/forgetest.active` | takeover marker |
|
||||
| `FORGECTRL_URL`, `FORGECTRL_TOKEN_FILE` | `http://127.0.0.1`, `/data/forgefirm/panel.token` | forgectrl client (HTTP; the token authorizes writes from the board) |
|
||||
| `FORGECTRL_TLS_URL` | `https://127.0.0.1` | forgectrl over HTTPS (self-signed, unverified), for the login test |
|
||||
| `GF_SYSFS_ROOT` | `/sys/glowforge/` | kernel module sysfs |
|
||||
| `GRBL_HOST`, `GRBL_PORT` | 127.0.0.1, 23 | Grbl TCP |
|
||||
|
||||
## Adding a test
|
||||
|
||||
Register it in the subsystem module under `forgetest/suite/` with
|
||||
`@test(...)`: id `subsystem.name`, kind, hardware, `mode` (the controller
|
||||
mode the test needs; the runner switches to it first), `covers`,
|
||||
`requires`, `always`, steps. The body gets a `Context` (`log`, `check`, `fail`,
|
||||
`prompt`, `confirm`, `instruct`, `sleep`, `evidence`, `forgectrl`, `sysfs`,
|
||||
`grbl`, `takeover`). Return normally for PASS, raise `runner.Failed` for
|
||||
FAIL. Then run the unit tests and the coverage lint.
|
||||
@@ -1 +0,0 @@
|
||||
{"seq":268,"ts_mono":1268.000,"fire_ok":true,"verdict":"OK","hold":false,"resume_ok":true,"armed":true,"reason":"","down_c":22.30,"up_c":22.30}
|
||||
@@ -1 +0,0 @@
|
||||
0
|
||||
@@ -1 +0,0 @@
|
||||
0
|
||||
@@ -1 +0,0 @@
|
||||
255
|
||||
@@ -1 +0,0 @@
|
||||
idle
|
||||
@@ -1 +0,0 @@
|
||||
1023
|
||||
@@ -1 +0,0 @@
|
||||
300
|
||||
@@ -1 +0,0 @@
|
||||
629
|
||||
@@ -1 +0,0 @@
|
||||
0
|
||||
@@ -1 +0,0 @@
|
||||
700
|
||||
@@ -1 +0,0 @@
|
||||
700
|
||||
@@ -1 +0,0 @@
|
||||
65535
|
||||
@@ -1 +0,0 @@
|
||||
26214
|
||||
@@ -1 +0,0 @@
|
||||
43278
|
||||
@@ -1 +0,0 @@
|
||||
300
|
||||
@@ -1 +0,0 @@
|
||||
300
|
||||
@@ -1 +0,0 @@
|
||||
300
|
||||
@@ -1 +0,0 @@
|
||||
{"seq":1517,"ts_mono":3127.000,"fire_ok":true,"verdict":"OK","hold":false,"resume_ok":true,"armed":false,"reason":"HEAD CRASH SIGNAL (axes x) - motion stopped, laser locked","down_c":22.30,"up_c":22.30}
|
||||
@@ -1 +0,0 @@
|
||||
0
|
||||
@@ -1 +0,0 @@
|
||||
1
|
||||
@@ -1 +0,0 @@
|
||||
0
|
||||
@@ -1 +0,0 @@
|
||||
0
|
||||
@@ -1 +0,0 @@
|
||||
idle
|
||||
@@ -1 +0,0 @@
|
||||
1
|
||||
@@ -1 +0,0 @@
|
||||
204
|
||||
@@ -1 +0,0 @@
|
||||
300
|
||||
@@ -1 +0,0 @@
|
||||
629
|
||||
@@ -1 +0,0 @@
|
||||
0
|
||||
@@ -1 +0,0 @@
|
||||
100
|
||||
@@ -1 +0,0 @@
|
||||
100
|
||||
@@ -1 +0,0 @@
|
||||
100
|
||||
@@ -1 +0,0 @@
|
||||
100
|
||||
@@ -1 +0,0 @@
|
||||
700
|
||||
@@ -1 +0,0 @@
|
||||
700
|
||||
@@ -1 +0,0 @@
|
||||
0
|
||||
@@ -1 +0,0 @@
|
||||
0
|
||||
@@ -1 +0,0 @@
|
||||
0
|
||||
@@ -1 +0,0 @@
|
||||
300
|
||||
@@ -1 +0,0 @@
|
||||
300
|
||||
@@ -1 +0,0 @@
|
||||
300
|
||||
@@ -1,62 +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"
|
||||
# The bench seed: the setup override lifts the wizard part of
|
||||
# the controller gate until the next reboot (/run is tmpfs), so a
|
||||
# campaign runs on a machine whose required wizards are not complete.
|
||||
# Nothing lifts the agreements or the account: the bench accepts them
|
||||
# once, and that stays in /data.
|
||||
mkdir -p /run/forgefirm
|
||||
: > /run/forgefirm/setup-override
|
||||
start-stop-daemon -S -q -p $PIDFILE -m -b -x /bin/sh -- -c \
|
||||
"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,77 +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, except the allowlisted non-behavioral paths,
|
||||
and every coverage glob must select at least one path of its component.
|
||||
|
||||
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); 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.
|
||||
|
||||
python3 -m forgetest.coverage [--manifest PATH] [--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 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)
|
||||
|
||||
|
||||
def empty(manifest, tests):
|
||||
return _manifest.empty_covers(manifest, tests)
|
||||
|
||||
|
||||
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("--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)
|
||||
if args.json:
|
||||
print(json.dumps({"uncovered": report, "total": total, "tests": len(tests),
|
||||
"empty": [list(e) for e in hollow]}, indent=1, sort_keys=True))
|
||||
else:
|
||||
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))
|
||||
print("coverage: %d uncovered path(s) across %d component(s), %d empty entr%s, %d tests"
|
||||
% (total, len(report), len(hollow), "y" if len(hollow) == 1 else "ies", len(tests)))
|
||||
if args.enforce and (total or hollow):
|
||||
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,598 +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 json
|
||||
import os
|
||||
import socket
|
||||
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)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- 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,250 +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 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/**"),
|
||||
]
|
||||
|
||||
|
||||
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 coverage_report(manifest, tests, allow=NON_BEHAVIORAL):
|
||||
"""Which manifest paths no test covers.
|
||||
|
||||
tests: iterable with .covers. allow: iterable of (component, glob)
|
||||
that need no coverage (docs, CI, licenses...); a BEHAVIORAL path is
|
||||
never allowed away, as it is never taken out of a fingerprint. Returns
|
||||
{component: [uncovered paths]} for the non-dev-only components.
|
||||
"""
|
||||
covered = {}
|
||||
for t in tests:
|
||||
for comp, pat in t.covers:
|
||||
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,38 +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_sheet # noqa: F401,E402
|
||||
from . import logs # noqa: F401,E402
|
||||
from . import motion # noqa: F401,E402
|
||||
from . import cooling # noqa: F401,E402
|
||||
from . import cooling_intake # noqa: F401,E402
|
||||
from . import laser # noqa: F401,E402
|
||||
from . import camera # noqa: F401,E402
|
||||
from . import update # noqa: F401,E402
|
||||
from . import cloud # noqa: F401,E402
|
||||
# Last: it stands on the setup record's consent and on the cloud suite's dark print.
|
||||
from . import exthost # noqa: F401,E402
|
||||
from . import extcore # noqa: F401,E402
|
||||
from . import extcall # noqa: F401,E402
|
||||
from . import extdest # noqa: F401,E402
|
||||
from . import extlife # noqa: F401,E402
|
||||
from . import evmore # noqa: F401,E402
|
||||
from . import extcat # noqa: F401,E402
|
||||
from . import extmcode # noqa: F401,E402
|
||||
from . import extwizard # noqa: F401,E402
|
||||
from . import updlock # noqa: F401,E402
|
||||
from . import homeoff # noqa: F401,E402
|
||||
from . import bedsize # noqa: F401,E402
|
||||
from . import extsender # noqa: F401,E402
|
||||
from . import tray # noqa: F401,E402
|
||||
from . import tlsrec # noqa: F401,E402
|
||||
@@ -1,287 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The Setup page's bed check, on the machine.
|
||||
|
||||
Its own module, so that no other test's fingerprint moves. The check is
|
||||
driven from here inside the travel the machine already knows: a few jogs
|
||||
out from the camera home, never toward an end, so it needs no operator.
|
||||
Measuring the real ends is the operator's own run of the check. The
|
||||
homing is the web-service session homing.cloud-offsets runs.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
from ..catalog import test
|
||||
from .cloud import _HOMING_PATH, gfhome_homing
|
||||
from .homeoff import camera_home_return, judge_whole_motions, session_mark
|
||||
from .setup import read_file, record_path, write_file
|
||||
from .setup_dark import DARK_COVERS, answer, dark, start
|
||||
|
||||
WID = "motion.envelope"
|
||||
END = "This is the end"
|
||||
# The operator's answers, as the page sends them: out 60.1 mm in X and
|
||||
# 61.1 mm in Y from the home, the fine steps included. A far edge is 50 mm
|
||||
# at the least, so the ends are well past that and well inside the bed.
|
||||
X_STEPS = ["X+10"] * 6 + ["X+1", "X+0.1", "X-1"]
|
||||
Y_STEPS = ["Y+10"] * 6 + ["Y+1", "Y+0.1"]
|
||||
X_OUT, Y_OUT = 60.1, 61.1
|
||||
MARGIN_MM = 1.0
|
||||
# The head is back on the home's step: half a step (213.3 steps/mm) and
|
||||
# the port's three decimals.
|
||||
HOME_TOL_MM = 0.003
|
||||
KEYS =("envelope_x_mm", "envelope_y_mm", "homing_mode")
|
||||
CLOSED = "closed the check's envelope"
|
||||
CLOSED_MSG = "Bed check envelope closed: a sender line"
|
||||
|
||||
|
||||
def _port(fc):
|
||||
st, body = fc.get("/motion/state")
|
||||
return body if st == 200 and isinstance(body, dict) else {}
|
||||
|
||||
|
||||
def _drive(ctx, fc, x_steps, y_steps, hook=None, timeout=300):
|
||||
"""Start the check and answer each prompt from the steps in turn, then
|
||||
This is the end; hook(prompt_id, n) runs before the answer to the n-th
|
||||
prompt. The final status document, and the answers given."""
|
||||
# The daemon calls the machine busy for a moment after the last jog.
|
||||
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "the machine is not idle: %s", fc.status().get("state"))
|
||||
start(ctx, fc, WID)
|
||||
queue = {"x-end": list(x_steps) + [END], "y-end": list(y_steps) + [END]}
|
||||
seen, given = set(), []
|
||||
t0 = time.time()
|
||||
try:
|
||||
while time.time() - t0 < timeout:
|
||||
ctx.checkpoint()
|
||||
d = dark(fc)
|
||||
p = d.get("prompt")
|
||||
if d.get("running") and p and p.get("seq") not in seen:
|
||||
seen.add(p["seq"])
|
||||
pid = p.get("id")
|
||||
ctx.check(pid in queue and queue[pid], "the check asked %s: %s (the lid must be closed)",
|
||||
pid, p.get("text"))
|
||||
ctx.check(p.get("kind") == "jog" and END in (p.get("options") or []),
|
||||
"the %s prompt is not a jog with %r: %s", pid, END, p)
|
||||
if hook:
|
||||
hook(pid, len(given))
|
||||
value = queue[pid].pop(0)
|
||||
st, body = answer(fc, WID, p, value)
|
||||
ctx.check(st == 200, "the answer %s to %s -> %s %s", value, pid, st, body)
|
||||
given.append(value)
|
||||
if not d.get("running"):
|
||||
return d, given
|
||||
time.sleep(0.3)
|
||||
except BaseException:
|
||||
fc.post("/wiz/%s/abort" % WID)
|
||||
raise
|
||||
fc.post("/wiz/%s/abort" % WID)
|
||||
ctx.fail("the bed check did not end within %d s", timeout)
|
||||
|
||||
|
||||
def _jog(fc, dx=0.0, dy=0.0):
|
||||
params = {"feed": "1200"}
|
||||
if dx:
|
||||
params["x"] = "%.3f" % dx
|
||||
if dy:
|
||||
params["y"] = "%.3f" % dy
|
||||
return fc.post("/motion/jog", params=params)
|
||||
|
||||
|
||||
def _settled(ctx, fc):
|
||||
"""After the suite's Grbl client closes, the daemon calls the machine
|
||||
busy for a moment: the check and a settings write each wait for it."""
|
||||
ctx.wait_for(lambda: _port(fc).get("sender") is False, 20, poll=0.5)
|
||||
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "the machine is not idle: %s", fc.status().get("state"))
|
||||
|
||||
|
||||
def _put_back(ctx, fc, found):
|
||||
for k in KEYS:
|
||||
v = found.get(k) or ""
|
||||
|
||||
def put():
|
||||
st_, body_ = fc.post("/settings", params={k: ""}) if v == "" else fc.post("/settings", data={k: v})
|
||||
if st_ != 200:
|
||||
ctx.log("restore %s=%r -> %s %s", k, v, st_, body_)
|
||||
return st_ == 200 or None
|
||||
took = ctx.wait_for(put, 20, poll=0.5)
|
||||
ctx.log("restore %s=%r -> %s", k, v, "taken" if took is not None else "not taken")
|
||||
|
||||
|
||||
def _back_home(ctx, fc, home):
|
||||
"""The head back where the home put it: one jog to the home in machine
|
||||
coordinates, from a Grbl client. A relative jog aimed at the home from
|
||||
the port's position can be refused whole: the port reports the step
|
||||
counters, the core adds a relative jog to its own position, which the
|
||||
check's jogs leave up to half a step off the counters, and the envelope
|
||||
begins at the home, so the target can land microns behind it (error:15).
|
||||
Where the head stands after, from the port; None when unread."""
|
||||
# 0.5 um inside an envelope that begins at the home (a camera home
|
||||
# behind the origin), far under half a step: the head lands on the
|
||||
# home's own step.
|
||||
line = "$J=G90 G53 X%.4f Y%.4f F1200" % (home[0] + 0.0005, home[1] + 0.0005)
|
||||
try:
|
||||
with ctx.grbl() as g:
|
||||
reply = g.command(line, timeout=10)
|
||||
ctx.log("the jog back to the home: %s -> %s", line, " | ".join(reply))
|
||||
if reply[-1:] == ["ok"]:
|
||||
t0 = time.time()
|
||||
ctx.wait_for(lambda: time.time() - t0 > 0.5 and g.status_report()["state"].startswith("Idle"),
|
||||
60, poll=0.2)
|
||||
except (OSError, ValueError) as e:
|
||||
ctx.log("the jog back to the home: %s", e)
|
||||
ctx.wait_for(lambda: _port(fc).get("sender") is False and _port(fc).get("state") == "Idle", 20, poll=0.5)
|
||||
at = _port(fc).get("mpos")
|
||||
ctx.log("the head after the jog back: %s (the home %s)", at, home)
|
||||
return at
|
||||
|
||||
|
||||
def _port_idle(ctx, fc):
|
||||
ok = ctx.wait_for(lambda: (lambda s: s.get("state") == "Idle" and not s.get("port_jog"))(_port(fc)), 30,
|
||||
poll=0.1)
|
||||
ctx.check(ok is not None, "the port jog did not end: %s", _port(fc))
|
||||
|
||||
|
||||
def _edge(ctx, fc, ev, axis, edge):
|
||||
"""From where the head stands, a jog 0.5 mm short of the far edge runs
|
||||
and comes back, and one 0.5 mm past it is refused with nothing moved."""
|
||||
s = _port(fc)
|
||||
at = s["mpos"]["XY".index(axis)]
|
||||
short, past = edge - 0.5 - at, edge + 0.5 - at
|
||||
kw = (lambda d: {"dx": d}) if axis == "X" else (lambda d: {"dy": d})
|
||||
st1, b1 = _jog(fc, **kw(short))
|
||||
_port_idle(ctx, fc)
|
||||
st2, b2 = _jog(fc, **kw(-short))
|
||||
_port_idle(ctx, fc)
|
||||
st3, b3 = _jog(fc, **kw(past))
|
||||
_port_idle(ctx, fc)
|
||||
back = _port(fc)["mpos"]["XY".index(axis)]
|
||||
ev["edge_" + axis] = {"edge": edge, "short": [st1, st2], "past": [st3, b3], "at": [at, back]}
|
||||
ctx.check(st1 == 200 and st2 == 200, "a jog 0.5 mm short of the %s edge %.2f and back -> %s %s, %s %s",
|
||||
axis, edge, st1, b1, st2, b2)
|
||||
ctx.check(st3 == 409 and "envelope" in json.dumps(b3) and abs(back - at) < 0.02,
|
||||
"a jog 0.5 mm past the %s edge %.2f -> %s %s, the head at %.3f from %.3f", axis, edge, st3, b3, back, at)
|
||||
|
||||
|
||||
@test("setup.check-envelope", title="The bed check measures the far edges from the operator's jogs, and a Grbl "
|
||||
"client's line ends it with nothing written",
|
||||
subsystem="setup", kind="auto", mode="grbl", est_min=10,
|
||||
covers=DARK_COVERS + _HOMING_PATH + [("forgectrl", "src/grblport.*"),
|
||||
("grblhal-glowforge", "src/glowforge_homing.*"),
|
||||
("grblhal-glowforge", "src/ctlport.*"),
|
||||
("grblhal-glowforge", "src/driver.c")],
|
||||
requires=["forgectrl.auth", "motion.pacing"],
|
||||
steps=["Bed clear, lid closed; cloud credentials configured; the machine on the network. The head moves "
|
||||
"at most about 62 mm out from the camera home in X and in Y, and ends where it was found."],
|
||||
description="With cloud mode on, the machine is homed with the camera (the web-service session), every "
|
||||
"service motion of it run whole. "
|
||||
"POST /wiz/motion.envelope/start runs the Bed size check; its prompts are jogs with This is the "
|
||||
"end among the options, and are answered as the page would, out 60.1 mm in X and 61.1 mm in Y "
|
||||
"(steps of 10, 1, and 0.1 mm). The check ends complete: the ends it reports are the home plus "
|
||||
"those, the far edges 1 mm short of them are written as envelope_x_mm and envelope_y_mm, and the "
|
||||
"controller holds them at once, without a home: a panel jog 0.5 mm short of each edge runs, "
|
||||
"and one 0.5 mm past it is refused with nothing moved. A second run is opened, and while its "
|
||||
"first prompt waits a Grbl client sends one line, which draws the controller's word that it "
|
||||
"closed the envelope and then ok; the port's state then says the envelope is closed, and the "
|
||||
"next answer ends the check in words, with the keys as the "
|
||||
"first run left them and the edges still in force. The keys, the homing mode, and the setup "
|
||||
"record are put back as found, the record under a restart, and the head goes back to the "
|
||||
"home by a Grbl client's jog in machine coordinates; the run fails unless the port reads "
|
||||
"the head on the home's step before the restart. Then the camera home is dropped and the "
|
||||
"head goes back to where the test found it, by the travel the session's motions logged.")
|
||||
def check_envelope(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
s0 = fc.settings() or {}
|
||||
ctx.check(s0.get("cloud_enabled") == "1", "cloud mode is off: a camera home needs it on")
|
||||
raw = read_file(record_path())
|
||||
found = {k: s0.get(k) or "" for k in KEYS}
|
||||
ev["found"] = found
|
||||
home = back = session_at = None
|
||||
try:
|
||||
st, body = fc.post("/settings", data={"homing_mode": "gfcloud"})
|
||||
ctx.check(st == 200, "homing_mode=gfcloud -> %s %s", st, body)
|
||||
with ctx.grbl() as g:
|
||||
session_at = session_mark()
|
||||
gfhome_homing(ctx, ev, g)
|
||||
home = g.status_report().get("MPos")
|
||||
judge_whole_motions(ctx, ev, session_at)
|
||||
ev["home"] = home
|
||||
ctx.log("the camera home declared %s", home)
|
||||
ctx.check(home and home[0] is not None, "no position after the home: %s", home)
|
||||
_settled(ctx, fc)
|
||||
|
||||
last, given = _drive(ctx, fc, X_STEPS, Y_STEPS)
|
||||
r = last.get("result") or {}
|
||||
ev["run1"] = {"answers": given, "error": last.get("error"), "result": r, "log": last.get("log", [])[-8:]}
|
||||
ctx.log("the bed check ended: %s", json.dumps(r))
|
||||
ctx.check(not last.get("error"), "the bed check failed: %s", last.get("error"))
|
||||
want_x, want_y = home[0] + X_OUT, home[1] + Y_OUT
|
||||
ctx.check(abs((r.get("x_end_mm") or -1) - want_x) < 0.02 and abs((r.get("y_end_mm") or -1) - want_y) < 0.02,
|
||||
"the ends are not the home plus the jogs (%.2f, %.2f): %s", want_x, want_y, r)
|
||||
edge_x, edge_y = round(want_x - MARGIN_MM, 2), round(want_y - MARGIN_MM, 2)
|
||||
s1 = fc.settings() or {}
|
||||
ev["keys_after_run1"] = {k: s1.get(k) for k in ("envelope_x_mm", "envelope_y_mm")}
|
||||
ctx.check(abs(float(s1.get("envelope_x_mm") or 0) - edge_x) < 0.011 and
|
||||
abs(float(s1.get("envelope_y_mm") or 0) - edge_y) < 0.011,
|
||||
"the keys are not the ends less %.1f mm (%.2f, %.2f): %s", MARGIN_MM, edge_x, edge_y,
|
||||
ev["keys_after_run1"])
|
||||
ctx.check(_port(fc).get("envelope_open") is False, "the envelope is still open after the check: %s",
|
||||
_port(fc))
|
||||
_edge(ctx, fc, ev, "X", edge_x)
|
||||
_edge(ctx, fc, ev, "Y", edge_y)
|
||||
|
||||
# A Grbl client's line while the envelope is open ends the check.
|
||||
said = {}
|
||||
|
||||
def client_line(pid, n):
|
||||
if n:
|
||||
return
|
||||
ctx.check(_port(fc).get("envelope_open") is True, "the second run did not open the envelope: %s",
|
||||
_port(fc))
|
||||
with ctx.grbl() as g2:
|
||||
said["reply"] = g2.command("G90", timeout=10)
|
||||
said["text"] = g2.drain()
|
||||
said["port"] = _port(fc)
|
||||
|
||||
last2, given2 = _drive(ctx, fc, ["X+1"], [], hook=client_line)
|
||||
ev["run2"] = {"answers": given2, "said": said, "error": last2.get("error"), "log": last2.get("log", [])[-6:]}
|
||||
ctx.log("the second run: %s", json.dumps(ev["run2"]))
|
||||
ctx.check((said.get("reply") or [])[-1:] == ["ok"], "the client's line did not draw ok: %s", said)
|
||||
ctx.check(any(CLOSED_MSG in l for l in (said.get("reply") or [])) or CLOSED_MSG in (said.get("text") or ""),
|
||||
"the client was not told its line closed the envelope: %s", said)
|
||||
ctx.check((said.get("port") or {}).get("envelope_open") is False,
|
||||
"the client's line did not close the envelope: %s", said.get("port"))
|
||||
ctx.check(CLOSED in (last2.get("error") or ""), "the second run did not end on the closed envelope: %s",
|
||||
last2.get("error"))
|
||||
s2 = fc.settings() or {}
|
||||
ctx.check({k: s2.get(k) for k in ("envelope_x_mm", "envelope_y_mm")} == ev["keys_after_run1"],
|
||||
"the second run wrote the keys: %s", s2)
|
||||
_settled(ctx, fc)
|
||||
_edge(ctx, fc, ev, "X", edge_x)
|
||||
finally:
|
||||
fc.post("/wiz/%s/abort" % WID)
|
||||
if home and home[0] is not None:
|
||||
ctx.wait_for(lambda: not dark(fc).get("running"), 30, poll=0.5)
|
||||
back = _back_home(ctx, fc, home)
|
||||
_put_back(ctx, fc, found)
|
||||
try:
|
||||
# Before the record's restart: the counters since the home say
|
||||
# where the head stands until a controller start zeroes them.
|
||||
if session_at is not None:
|
||||
camera_home_return(ctx, ev, session_at)
|
||||
finally:
|
||||
# The check records itself when it completes; the record goes back
|
||||
# as found under a restart, which also ends the envelope it set.
|
||||
if read_file(record_path()) != raw:
|
||||
with ctx.takeover():
|
||||
write_file(record_path(), raw)
|
||||
ctx.log("the previous setup record is back under a restart")
|
||||
# Judged from the reading before the restart: the restart zeroes the
|
||||
# counters where the head stands, so the baseline's position check
|
||||
# after it cannot see a head left out.
|
||||
ev["back"] = back
|
||||
ctx.check(back and all(abs(back[i] - home[i]) < HOME_TOL_MM for i in (0, 1)),
|
||||
"the head is not back at the home %s: %s", home, back)
|
||||
@@ -1,561 +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."""
|
||||
import json
|
||||
|
||||
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, requires=["forgectrl.panel-serves"],
|
||||
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, requires=["forgectrl.panel-serves"],
|
||||
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, requires=["forgectrl.panel-serves"],
|
||||
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, requires=["forgectrl.panel-serves"],
|
||||
description="The capture queue flags a frame errored when it is short, torn, or arrived "
|
||||
"after the CSI-2 receiver lost sync; forgectrl drops those rather than "
|
||||
"demosaicing them, 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.*")], requires=["forgectrl.panel-serves"],
|
||||
actions=["lid"],
|
||||
steps=["Start with the lid closed; you will be told to open it, then close it again.",
|
||||
"Nothing moves and the laser is not involved."],
|
||||
description="The privacy gate: with the lid open 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))
|
||||
|
||||
|
||||
@test("camera.key-read", title="The camera key reads without a login", subsystem="camera",
|
||||
kind="auto", est_min=1,
|
||||
covers=[("forgectrl", "src/camkey.*"), ("forgectrl", "src/auth.c"),
|
||||
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/panel.js"),
|
||||
("forgectrl", "src/ui/index.html"), ("forgectrl", "src/ui/help.js")],
|
||||
requires=["forgectrl.auth"],
|
||||
description="With the read routes closed to the network (panel_open_reads=0), a camera "
|
||||
"status read from the board's LAN address is refused without the key and "
|
||||
"served with it, on HTTP and on HTTPS; a wrong key is refused; the key never "
|
||||
"authorizes a write; a rotation from the panel route stops the old key and "
|
||||
"the new one reads. The setting is put back after.")
|
||||
def key_read(ctx):
|
||||
from .setup import request
|
||||
from .forgectrl import lan_ip
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
ip = lan_ip()
|
||||
ctx.check(ip, "cannot determine the board's LAN address")
|
||||
http_base = "http://%s" % ip
|
||||
tls_base = "https://%s" % ip
|
||||
token = {"X-ForgeFIRM-Token": fc.token}
|
||||
|
||||
st, body, _ = request(fc.base, "GET", "/system/camera-key", headers=token)
|
||||
ctx.check(st == 200, "GET /system/camera-key from loopback -> %s", st)
|
||||
key = json.loads(body)["key"]
|
||||
ctx.check(len(key) == 32, "the key is not 32 characters: %r", key)
|
||||
ev["key_len"] = len(key)
|
||||
|
||||
before = fc.settings().get("panel_open_reads", "")
|
||||
st, _ = fc.post("/settings", params={"panel_open_reads": "0"})
|
||||
ctx.check(st == 200, "closing the reads -> %s", st)
|
||||
try:
|
||||
for base in (http_base, tls_base):
|
||||
st, _, _ = request(base, "GET", "/cam/status")
|
||||
ctx.log("GET %s/cam/status (no key) -> %s", base, st)
|
||||
ctx.check(st == 403, "a closed read without the key -> %s, expected 403", st)
|
||||
st, _, _ = request(base, "GET", "/cam/status?key=" + key)
|
||||
ctx.log("GET %s/cam/status?key=... -> %s", base, st)
|
||||
ctx.check(st == 200, "a read with the key -> %s, expected 200", st)
|
||||
st, _, _ = request(base, "GET", "/cam/status", headers={"X-ForgeFIRM-Camera-Key": key})
|
||||
ctx.check(st == 200, "a read with the key header -> %s, expected 200", st)
|
||||
wrong = ("0" if key[0] != "0" else "1") + key[1:]
|
||||
st, _, _ = request(base, "GET", "/cam/status?key=" + wrong)
|
||||
ctx.check(st == 403, "a wrong key -> %s, expected 403", st)
|
||||
# the key is a read credential only
|
||||
st, _, _ = request(tls_base, "POST", "/settings", data={"panel_open_reads": "0"},
|
||||
headers={"X-ForgeFIRM-Camera-Key": key})
|
||||
ctx.log("POST /settings with the camera key -> %s", st)
|
||||
ctx.check(st == 403, "the camera key authorized a write (%s)", st)
|
||||
# rotation
|
||||
st, body, _ = request(fc.base, "POST", "/system/camera-key?rotate=1", headers=token)
|
||||
ctx.check(st == 200, "rotate -> %s", st)
|
||||
new = json.loads(body)["key"]
|
||||
ctx.check(new != key, "rotation kept the same key")
|
||||
st, _, _ = request(http_base, "GET", "/cam/status?key=" + key)
|
||||
ctx.check(st == 403, "the old key still reads after rotation (%s)", st)
|
||||
st, _, _ = request(http_base, "GET", "/cam/status?key=" + new)
|
||||
ctx.check(st == 200, "the new key does not read (%s)", st)
|
||||
ev["rotated"] = True
|
||||
finally:
|
||||
fc.post("/settings", params={"panel_open_reads": before})
|
||||
File diff suppressed because it is too large
Load Diff
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