mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-10-18 18:41:41 -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,181 +0,0 @@
|
||||
# forgetest: the release acceptance tool's host-side proof.
|
||||
#
|
||||
# - unit tests: campaign rules, fingerprints, artifact build/verify (the
|
||||
# release gate's decision, including the negative fixtures), runner +
|
||||
# HTTP API end to end with a fake catalog and a fake bench tool, the
|
||||
# suites replayed on the machine's own log lines, and the check that
|
||||
# every log phrase the cloud suite greps for is one the pinned cloud
|
||||
# app can log (it reads the app sources from the manifest cache)
|
||||
# - the source bundle of a release: which recipe of the image needs
|
||||
# source, which archive answers for it, and the refusal that stops a
|
||||
# release with source missing
|
||||
# - shared UI files: theme.css and the vendored Bootstrap the page
|
||||
# carries are byte-identical to forgectrl's at its pinned revision
|
||||
# - the extension key: the public key the image installs for the official
|
||||
# tier is byte-identical to the one the extension catalog publishes
|
||||
# (keys/forgefirm-ext.pub on its main branch)
|
||||
# - the catalog's address: EXTPKG_INDEX_URL in the pinned forgectrl's
|
||||
# src/extpkg.h is the catalog's latest-release download of the file its
|
||||
# tools/publish.py passes to gh release create; read, never requested
|
||||
# (every request of it is counted as a download)
|
||||
# - coverage lint: every source path of every component pinned by the
|
||||
# recipes must be selected by some catalog test's coverage globs or by a
|
||||
# host-tested row naming the host test that proves it (the tree manifest
|
||||
# is generated from the pins with git - no Yocto build); enforced: an
|
||||
# uncovered path fails the job (https://docs.forgefirm.org/developers/testing/).
|
||||
|
||||
name: forgetest-ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'forgetest/**'
|
||||
- 'scripts/acceptance-gate.py'
|
||||
- 'scripts/manifest-from-tree.py'
|
||||
- 'scripts/source-bundle.py'
|
||||
- 'kas/source-bundle.yml'
|
||||
- 'meta-forgefirm/recipes-forgefirm/**'
|
||||
- '.github/workflows/forgetest-ci.yml'
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout forgefirm
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
path: forgefirm
|
||||
|
||||
- name: Checkout meta-openglow (sibling)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: openglow-org/meta-openglow
|
||||
ref: scarthgap
|
||||
path: meta-openglow
|
||||
|
||||
- name: Checkout forgefirm-extensions-catalog (sibling)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: openglow-org/forgefirm-extensions-catalog
|
||||
ref: main
|
||||
path: forgefirm-extensions-catalog
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Tree manifest from the recipe pins (also fetches the pinned app sources the needle check reads)
|
||||
working-directory: forgefirm
|
||||
run: python scripts/manifest-from-tree.py --out tree-manifest.json
|
||||
|
||||
- name: The image's extension key is the catalog's published key
|
||||
run: |
|
||||
image=forgefirm/meta-forgefirm/recipes-forgefirm/forgefirm-keys/files/ext/forgefirm-ext.pub
|
||||
published=forgefirm-extensions-catalog/keys/forgefirm-ext.pub
|
||||
for f in "$image" "$published"; do
|
||||
test -f "$f" || { echo "missing: $f"; exit 1; }
|
||||
done
|
||||
cmp -s "$image" "$published" || { echo "$image is not $published"; exit 1; }
|
||||
|
||||
- name: The catalog's address in the pinned forgectrl is the file the catalog publishes (read, never requested)
|
||||
run: |
|
||||
python3 - <<'EOF'
|
||||
import ast, glob, os, re, subprocess, sys
|
||||
|
||||
PREFIX = "https://github.com/openglow-org/forgefirm-extensions-catalog/releases/latest/download/"
|
||||
|
||||
def fail(why):
|
||||
print(why)
|
||||
sys.exit(1)
|
||||
|
||||
# forgectrl's pin, and its src/extpkg.h from the tree manifest's cache
|
||||
pin = open("forgefirm/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl-pin.inc").read()
|
||||
m = re.search(r'^SRCREV = "([0-9a-f]{40})"$', pin, re.M)
|
||||
if not m:
|
||||
fail("forgectrl-pin.inc names no SRCREV")
|
||||
rev = m.group(1)
|
||||
header = None
|
||||
for repo in sorted(glob.glob("forgefirm/.manifest-cache/*")):
|
||||
p = subprocess.run(["git", "-C", repo, "show", rev + ":src/extpkg.h"], capture_output=True, text=True)
|
||||
if p.returncode == 0:
|
||||
header = p.stdout
|
||||
break
|
||||
if header is None:
|
||||
fail("forgectrl %s is not in forgefirm/.manifest-cache" % rev)
|
||||
m = re.search(r'#define\s+EXTPKG_INDEX_URL\s*(?:\\\n\s*)?"([^"]*)"', header)
|
||||
if not m:
|
||||
fail("forgectrl %s's src/extpkg.h defines no EXTPKG_INDEX_URL" % rev[:7])
|
||||
url = m.group(1)
|
||||
|
||||
# the file the catalog's publish tool passes to gh release create
|
||||
tree = ast.parse(open("forgefirm-extensions-catalog/tools/publish.py").read())
|
||||
assigned = {n.targets[0].id: n.value for n in ast.walk(tree)
|
||||
if isinstance(n, ast.Assign) and len(n.targets) == 1 and isinstance(n.targets[0], ast.Name)}
|
||||
|
||||
def file_name(v):
|
||||
if isinstance(v, ast.Name):
|
||||
v = assigned.get(v.id)
|
||||
if isinstance(v, ast.Call) and v.args:
|
||||
v = v.args[-1]
|
||||
return os.path.basename(v.value) if isinstance(v, ast.Constant) and isinstance(v.value, str) else None
|
||||
|
||||
names = [file_name(n.args[3]) if len(n.args) > 3 else None for n in ast.walk(tree)
|
||||
if isinstance(n, ast.Call) and isinstance(n.func, ast.Name) and n.func.id == "gh"
|
||||
and [a.value for a in n.args[:2] if isinstance(a, ast.Constant)] == ["release", "create"]]
|
||||
if len(names) != 1 or not names[0]:
|
||||
fail("tools/publish.py passes no one file to gh release create: %s" % names)
|
||||
|
||||
print("forgectrl %s: %s; the catalog publishes %s" % (rev[:7], url, names[0]))
|
||||
if url != PREFIX + names[0]:
|
||||
fail("the address is not %s" % (PREFIX + names[0]))
|
||||
EOF
|
||||
|
||||
- name: Unit tests
|
||||
working-directory: forgefirm/forgetest
|
||||
# -B: no bytecode caches under the package directory (the recipe's fetch
|
||||
# checksum would otherwise move without a source change)
|
||||
run: python -B -m unittest discover -s tests -v
|
||||
|
||||
- name: No undefined names (the live drills never execute on the host)
|
||||
working-directory: forgefirm/forgetest
|
||||
# pyflakes exits 1 for any message, unused imports included, so the
|
||||
# grep is the gate. A run stopped by SIGINT exits 1 too, and its
|
||||
# "... stopped" line fails the grep. Any other status (a usage
|
||||
# error, a signal pyflakes does not handle) fails the step and
|
||||
# prints the capture, and a capture that cannot be written fails it
|
||||
# too. pyflakes ends a SIGPIPE with status 1 and no line, and a
|
||||
# write to the capture file raises none.
|
||||
run: |
|
||||
python -m pip install pyflakes
|
||||
python -B -m pyflakes --version
|
||||
{ python -B -m pyflakes forgetest tests || test $? -eq 1; } > pyflakes.txt 2>&1 || { cat pyflakes.txt; exit 1; }
|
||||
if grep -E "undefined name|Traceback|^\.\.\. stopped$" pyflakes.txt; then exit 1; fi
|
||||
|
||||
- name: Shared UI files match forgectrl at its pinned revision
|
||||
working-directory: forgefirm
|
||||
run: python scripts/check-ui-vendor.py
|
||||
|
||||
- name: Coverage lint (enforced)
|
||||
working-directory: forgefirm/forgetest
|
||||
# The step runs under bash -e with no pipefail of its own: set here,
|
||||
# the status of the step is the lint's, not tee's.
|
||||
run: |
|
||||
set -o pipefail
|
||||
python -B -m forgetest.coverage --manifest ../tree-manifest.json --enforce | tee "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Gate self-check on the tree manifest
|
||||
working-directory: forgefirm
|
||||
run: |
|
||||
# The gate must load the catalog and refuse a missing/unauthorized
|
||||
# artifact cleanly (exit 1/2, never a traceback).
|
||||
set +e
|
||||
echo '{}' > empty.json
|
||||
python scripts/acceptance-gate.py empty.json tree-manifest.json --quiet
|
||||
rc=$?
|
||||
set -e
|
||||
test "$rc" -eq 1
|
||||
@@ -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 a Grbl sender on TCP port 23. Cloud mode runs the
|
||||
factory experience through the Glowforge web service, and it is a deliberate,
|
||||
maintained feature. The stack is hardware validated: GRBL mode cuts real jobs,
|
||||
and cloud mode runs end to end.
|
||||
|
||||
One controller runs at a time under `forgectrl`, the machine-services daemon,
|
||||
which owns the pulse device, the cooling engine, the cameras, and the control
|
||||
panel. The kernel module plays the pulse stream into the stepper drivers and
|
||||
owns the laser latch and the safety readbacks. The Technical section of the
|
||||
site describes the whole stack.
|
||||
|
||||
### The repositories
|
||||
|
||||
All repositories live under the OpenGlow organization on GitHub,
|
||||
<https://github.com/openglow-org>. For development, check every repository out
|
||||
as a sibling in one base directory named `openglow-forgefirm`. The build
|
||||
scripts expect that layout. Each repository is its own git repository. The
|
||||
base directory is not.
|
||||
|
||||
| Repository | Role | Default branch | License |
|
||||
|---|---|---|---|
|
||||
| `forgefirm` | The build and release base: the `meta-forgefirm` Yocto layer, the kas configuration, the image recipes, the install and release scripts, the acceptance tool `forgetest/`, the bench tools `scripts/bench/`, and the bench actuator firmware `fixture/`. It carries no project documentation. | `master` | MIT (layer metadata) |
|
||||
| `meta-openglow` | The BSP layers `meta-glowforge-bsp` and `meta-openglow-core`: kernel, device tree, U-Boot, board recipes. | `scarthgap` | MIT (layer metadata) |
|
||||
| `forgectrl` | The machine-services daemon (C) on HTTPS port 443, with the read-only routes on HTTP port 80: supervisor, pulse-device broker, motion-liveness gate, cooling engine, cameras, telemetry, settings, diagnostics, control panel, A/B updates. | `main` | MIT |
|
||||
| `grblHAL-glowforge` | The grblHAL driver for the stock board, the GRBL-mode controller. The core is a submodule at `src/grbl`. Machine constants are in `src/boards/glowforge.h`. | `main` | GPL-3.0-or-later |
|
||||
| `grblHAL-core` | The fork of the grblHAL core that the driver uses. Minimum change, no rationale comments. | `forgefirm` | The upstream grblHAL license |
|
||||
| `kernel-module-glowforge` | `glowforge.ko`: the SDMA + EPIT pulse engine, the laser latch, the safety readbacks, the sensors. | `master` | GPL-2.0-or-later |
|
||||
| `python3-gfhardware` | The `gfhardware` hardware library and the cloud-mode applications in `forgefirm-app/`. | `master` | MIT, with one LGPL-2.1-or-later component |
|
||||
| `Glowforge-Utilities` | `gfutilities` on PyPI: the factory protocol and service layer that cloud mode uses. | `master` | MIT |
|
||||
| `forgefirm-docs` | The documentation site. | `main` | CC BY-SA 4.0 |
|
||||
| `openglow-serial-adapter` | The USB-C serial-console adapter for the control board. | `main` | CC BY-NC-SA 4.0 |
|
||||
|
||||
The full table, the license details, and the component diagram are on the
|
||||
site: <https://docs.forgefirm.org/developers/>.
|
||||
|
||||
### Safety first, and in that order
|
||||
|
||||
The machine is a laser, and the operator is the safety authority at it. These
|
||||
rules bind every change, in every repository, that can affect what the machine
|
||||
does.
|
||||
|
||||
- The hardware chain is the safety boundary. Software only adds gates on top
|
||||
of it. `LASER_ON` is never a bare GPIO. The kernel laser latch is locked by
|
||||
default, and every close of the pulse device locks it again.
|
||||
- Never defeat a switch, an interlock, or a readback, in hardware or in
|
||||
software, not even for a test.
|
||||
- Never change a fire gate, a thermal limit, or a safety default to make a
|
||||
test pass.
|
||||
- The order of work is absolute: emission and motion first, robustness and
|
||||
hygiene after. Review a change that touches emission or motion for that
|
||||
before anything else.
|
||||
- A change that can put energy where it was not commanded gets a regression
|
||||
test with the fix, in the same commit, never after.
|
||||
- Position counters, homing anchors, and a homed flag are not proof of
|
||||
physical motion. The head accelerometer is, and so are the operator's eyes.
|
||||
- Unreadable is fail-closed. A failed read of a safety input, a state file,
|
||||
or a verdict is never treated as "still running" or "keep waiting" without
|
||||
a bound.
|
||||
|
||||
### Proof before done
|
||||
|
||||
A feature or fix is not complete until it is proven. The order of preference:
|
||||
|
||||
1. A host test that runs in CI. The Test page of the site lists the host tests
|
||||
of each repository.
|
||||
2. A bench drill on the bench reference, recorded in the commit message that
|
||||
carries the change.
|
||||
3. Documented reasoning.
|
||||
|
||||
Also:
|
||||
|
||||
- Run the host tests of this repository before every commit. Where the build
|
||||
uses `-Werror`, a warning is a failure.
|
||||
- A component that ships in the image is also evaluated against the release
|
||||
acceptance catalog in the `forgefirm` repository (Test page, "Coverage
|
||||
currency"). A behavior change with no catalog consequence gets a sentence
|
||||
of justification in the commit message.
|
||||
- For a component that ships in the image, the proof build is the Yocto
|
||||
cross-build that the Build page of the site defines, never a native host
|
||||
build.
|
||||
- Report outcomes faithfully. Quote failing output as it is. Name a skipped
|
||||
step as skipped. Never narrate an expected result as an observed one.
|
||||
|
||||
### The workflow: prove locally, push when proven
|
||||
|
||||
- Nothing goes to a public repository until it is proven: in the host tests
|
||||
for host-only changes, on the bench for changes that alter what the machine
|
||||
does.
|
||||
- Iterate locally. Then make one commit and one push for the proven change.
|
||||
No chains of fix-up pushes. Every push runs CI, and CI minutes are a budget.
|
||||
- Commit only finished work, and only when asked. Never commit intermediate
|
||||
findings, working notes, plans, audits, or status files. If a conclusion is
|
||||
still moving, it is not ready for a repository. Unpushed mistakes come out
|
||||
with `git reset --mixed <base>`, which keeps the working tree.
|
||||
- "Commit" and "commit and build" include the push of every touched
|
||||
repository, in dependency order. Never hold a push back to protect earlier
|
||||
unpushed commits on the same branch. The only exception is an explicit "do
|
||||
not push" for that session.
|
||||
- Never push, force-push, tag, cut a release, refresh a lockfile, bump a
|
||||
submodule pointer, or bump a pin unless asked.
|
||||
|
||||
### Pins
|
||||
|
||||
A firmware component reaches the image only through its pin: an exact
|
||||
`SRCREV` in `meta-forgefirm` (in the `forgefirm` repository) for ForgeFIRM
|
||||
components, or in `meta-openglow` for BSP components. There is no `AUTOREV`.
|
||||
A commit in a component repository changes nothing on a machine until its pin
|
||||
moves. The order is always: push the source repository, bump its pin, then
|
||||
run `bitbake -c fetch <recipe>` to make sure that the pin resolves. Some
|
||||
pairs of repositories have a CI coupling that fixes the push order between
|
||||
them. The Release flow page of the site lists them, and each affected
|
||||
repository names its own in the section above.
|
||||
|
||||
### Git conventions
|
||||
|
||||
- A commit is attributed to the human who is responsible for it, in both the
|
||||
author and the committer fields. Never list an AI assistant as an author or
|
||||
a co-author. Never add an AI attribution trailer or a "Generated with" line
|
||||
to a commit message or a pull request. In a fresh clone, set the local
|
||||
`user.name` and `user.email` before the first commit.
|
||||
- A commit message says what changed and why, in the present tense, in
|
||||
Simplified Technical English. The "why" of a change to fork code lives here
|
||||
and nowhere else.
|
||||
- Keep line endings LF in every file that Linux tooling consumes: patches,
|
||||
shell scripts, recipes, device trees. Check the bytes after an edit from a
|
||||
Windows tool. A CRLF patch breaks `do_patch`.
|
||||
- Never commit build output, `__pycache__`, virtual environments, editor
|
||||
files, a local knowledge graph (`graphify-out/`), captured machine data that
|
||||
carries an identity, tokens, keys, or passwords.
|
||||
- Never rewrite published history unless the operator asks for it.
|
||||
- A pull request to an upstream repository is never automatic. The developer
|
||||
decides if and when to send one. You can remind them that upstream
|
||||
candidates exist, but the decision is theirs.
|
||||
|
||||
### Writing rules
|
||||
|
||||
They apply to everything: documentation, README files, code comments,
|
||||
docstrings, log and console text, commit messages, issue text, and replies.
|
||||
|
||||
- **American English.** analyze, behavior, color, center, gray, catalog,
|
||||
license, judgment, program, artifact, percent, toward, among, while,
|
||||
learned. The style lint in `forgefirm-docs` rejects the British forms.
|
||||
- **ASD-STE100 Simplified Technical English.** Short sentences: 20 words in
|
||||
a procedure, 25 in a description. One topic per sentence, one instruction
|
||||
per sentence. Active voice, imperative in procedures, simple present in
|
||||
descriptions. "Must" for necessity, "can" for possibility. No "should",
|
||||
"would", "could", "may". Prefer: do, make sure, use, start, show, occur,
|
||||
before, after, because, for example, that is. No "etc.", no "and/or".
|
||||
Noun clusters of three words at most. Warnings and cautions come before the
|
||||
step they protect. Warm and direct is good. A joke that shades a fact is
|
||||
not.
|
||||
- **No em dashes.** Never, anywhere. Use a colon, a period, or a comma.
|
||||
- **Present tense, present state.** Text describes the code and the machine
|
||||
as they are. No history narrative, no "the old version did", no "evolved
|
||||
from", no story of how the code got here. Upstream copyright and
|
||||
derivation attribution to public projects stays: that is license credit,
|
||||
not narrative.
|
||||
- **No workstation paths.** Nothing outside a repository appears as a path in
|
||||
it: no drive letters, no WSL mounts, no home directories, no sibling-repo
|
||||
relative paths, no private reference directories. Name the artifact
|
||||
instead: "the captured factory pulse files", "that bench session".
|
||||
- **No bench-machine identity.** Never a hostname, a serial number, a fuse
|
||||
value, a private IP address, or a login of the bench machine, in any file,
|
||||
comment, commit message, sample, or test fixture. Use neutral placeholders
|
||||
of the `ABC-123` form. Record the fact of a verification, never the values.
|
||||
- **No factory firmware code.** Never cite the factory firmware binary or its
|
||||
code: no offsets, no addresses, no quoted or decompiled code. State the
|
||||
recovered fact (a formula, a constant, a behavior) and stop. Hardware
|
||||
addresses (IOMUX pad values, sensor registers, boot offsets) are fine.
|
||||
- **No rationale comments in fork code.** Code destined upstream (for
|
||||
example, the grblHAL core fork) carries the minimum change and no "why we
|
||||
did this" comments. The reasoning goes in the commit message. Patches
|
||||
carried in the Yocto layers are exempt. The `ForgeFIRM:` comment convention
|
||||
there is deliberate.
|
||||
- **Interface docs describe the lever, not the policy.** A sysfs attribute
|
||||
doc says what the attribute is, its range, its units, its conversion. How
|
||||
the factory firmware drives it does not belong there.
|
||||
- **No definitive legal or regulatory statements.** The project is not a
|
||||
lawyer. The only permitted form: modifying the machine, including
|
||||
replacing its firmware, may have legal and regulatory ramifications, and it
|
||||
is the end user's responsibility to adhere to the laws, regulations,
|
||||
certifications, and insurance terms that apply where they are. Warranty
|
||||
wording is "may void your warranty", never "voids". Never cite a regulation,
|
||||
a standard, or a certification regime as applying or not applying. The
|
||||
affiliation disclaimer lives in the site footer only. It is also the user's
|
||||
responsibility to make sure that they stay within the vendor's terms of
|
||||
service when they use a feature that calls that vendor's cloud services.
|
||||
- **Copyright.** Copyright in this project belongs to 514 LLC d/b/a
|
||||
OpenGlow. Every new source file carries two lines in its header, in the
|
||||
comment style of the file: `Copyright <year> 514 LLC d/b/a OpenGlow` and
|
||||
`Written by Scott Wiederhold`. Keep the year, or the year range, that
|
||||
the file already has. The copyright notices of other holders stay
|
||||
unchanged. Those two lines are for files this project authors: an
|
||||
outside contributor who writes a new file puts their own name there, or
|
||||
leaves the lines out, and keeps their copyright in what they wrote.
|
||||
Editing an existing file changes no header - a contribution is recorded
|
||||
by its `Signed-off-by` line and by git, not by a copyright line.
|
||||
- **SPDX.** Every new source file carries an `SPDX-License-Identifier` line
|
||||
under the license of its repository.
|
||||
|
||||
### Where documentation goes
|
||||
|
||||
- **One home.** A fact lives on the documentation site, or it does not exist.
|
||||
A repository README says what the repository is, how to build and test it,
|
||||
and where the documentation is. Nothing else.
|
||||
- **The currency rule.** A change carries a documentation commit when it adds,
|
||||
removes, or renames an interface (a sysfs attribute, an HTTP route, a
|
||||
settings key, a G-code or `$` setting), or when it corrects a measured
|
||||
fact. A measured fact says how it was obtained, and on what: unless the page
|
||||
says otherwise, a measurement on the site was taken on the bench reference,
|
||||
which the site defines.
|
||||
- **A moved document is deleted.** No stub and no redirect stays at the old
|
||||
path.
|
||||
- **Every diagram is Mermaid.** No ASCII art and no image of a diagram.
|
||||
- There is no roadmap page. Open items are tracked as GitHub issues once the
|
||||
repositories accept them.
|
||||
- Do not add plan files, audit files, working notes, or status files to any
|
||||
repository. The project has **no status document and no dated log**: the
|
||||
site describes the present, and the record of what was done, how it was
|
||||
proven, and what it replaced goes in the commit message that carries the
|
||||
change.
|
||||
|
||||
### Analysis and reporting
|
||||
|
||||
- Rank engineering options on technical merit only: performance, correctness,
|
||||
robustness, verifiability. Process cost (an acceptance campaign, an image
|
||||
flash, a new dependency) is a one-line footnote, never a ranking factor.
|
||||
The operator decides the process cost.
|
||||
- Never infer or assume a hardware fact. Label every unverified claim as
|
||||
unverified, and say how to verify it.
|
||||
- Report only what the observations confirm. Everything else is labeled
|
||||
"unconfirmed" and is not acted on.
|
||||
|
||||
### General practice for agents
|
||||
|
||||
- Read the README of this repository, this file, and the Developers section
|
||||
of the site before you change code.
|
||||
- Orient in the code before you edit. If a local knowledge graph
|
||||
(`graphify-out/`) exists in the working tree or its parent, query it first.
|
||||
It is a local artifact and is never committed.
|
||||
- Keep a change minimal and scoped to the request. No drive-by reformatting,
|
||||
no unrelated cleanups, no renames for taste. Match the style of the
|
||||
surrounding code.
|
||||
- Do not add a dependency without a stated reason. The target is a
|
||||
single-core machine with a small eMMC, and every package rides the image.
|
||||
- Extend an existing test or tool instead of adding a parallel one.
|
||||
- Never create a file in the repository that the request does not need.
|
||||
- Ask before anything destructive or outward-facing: a push, a force-push, a
|
||||
tag, a release, a history rewrite, or any change to a machine.
|
||||
- When a fact is unknown, say so and say how to find out. Never fill a gap
|
||||
with a plausible number.
|
||||
- Use the exact names of the project: kas, bitbake, pin, SRCREV, campaign,
|
||||
fingerprint, drill, sysfs, the latch, the armed window. Do not invent names.
|
||||
|
||||
### Before you commit
|
||||
|
||||
1. The host tests of this repository pass, with `-Werror` where the build
|
||||
uses it.
|
||||
2. The change is proven at the highest level available: CI test, bench drill,
|
||||
or documented reasoning.
|
||||
3. For a component that ships in the image: the acceptance catalog covers the
|
||||
changed behavior, or the commit message says why it has no catalog
|
||||
consequence.
|
||||
4. New files carry the 514 LLC d/b/a OpenGlow copyright, the `Written by`
|
||||
line, and an SPDX line. Line endings are LF.
|
||||
5. No em dashes, no British spellings, no workstation paths, no bench
|
||||
identity, no factory firmware code, no history narrative, no AI
|
||||
attribution.
|
||||
6. Documentation is current: interfaces and measured facts (the currency
|
||||
rule).
|
||||
7. The commit is one settled change, attributed to the human author, and it
|
||||
is pushed only when the operator asks.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Build
|
||||
Instructions are for a Linux environment supported by Yocto.
|
||||
|
||||
To get the build OpenGlow/ForgeFIRM you need to have `repo` installed:
|
||||
|
||||
## Install the `repo` utility:
|
||||
|
||||
```console
|
||||
mkdir ~/bin
|
||||
curl http://commondatastorage.googleapis.com/git-repo-downloads/repo > ~/bin/repo
|
||||
chmod a+x ~/bin/repo
|
||||
PATH=${PATH}:~/bin
|
||||
```
|
||||
Add the following to the end of ```~/.bashrc``` to permanently add ```repo``` to your path:
|
||||
```console
|
||||
export PATH=~/bin:$PATH
|
||||
```
|
||||
## Download the BSP source:
|
||||
|
||||
```console
|
||||
mkdir forgefirm
|
||||
cd forgefirm
|
||||
repo init -u https://github.com/ScottW514/forgefirm.git -b master -m default.xml
|
||||
repo sync
|
||||
```
|
||||
|
||||
At the end of the commands you have all meta packages you need to build the basic firmware images.
|
||||
|
||||
## Build the OpenGlow/ForgeFIRM image for factory control boards
|
||||
First time environment setup:
|
||||
```console
|
||||
(in forgefirm directory)
|
||||
MACHINE=glowforge DISTRO=forgefirm . setup-environment build
|
||||
bitbake forgefirm-image
|
||||
```
|
||||
Subsequent builds:
|
||||
```console
|
||||
(in forgefirm directory)
|
||||
. setup-environment build
|
||||
bitbake forgefirm-image
|
||||
```
|
||||
|
||||
## Bootable Image:
|
||||
The bootable image can be found in ```forgefirm/build/tmp/deploy/images/```.
|
||||
|
||||
It can be written directly to an SDCARD:
|
||||
```console
|
||||
(in forgefirm/build directory)
|
||||
cd tmp/deploy/images/glowforge
|
||||
sudo zcat forgefirm-image-glowforge.wic.gz | dd of=/dev/sdX bs=1M
|
||||
```
|
||||
@@ -1,54 +0,0 @@
|
||||
# Contributing
|
||||
|
||||
Patches are welcome. How the project works, what proof a change needs, and the
|
||||
writing rules are on the documentation site:
|
||||
<https://docs.forgefirm.org/developers/contributing/>.
|
||||
|
||||
## Sign your work
|
||||
|
||||
Every commit carries a `Signed-off-by` line:
|
||||
|
||||
Signed-off-by: Your Name <your.email@example.com>
|
||||
|
||||
`git commit -s` adds it, using your `user.name` and `user.email`. Use your real
|
||||
name; an anonymous or pseudonymous sign-off certifies nothing.
|
||||
|
||||
The line certifies the Developer Certificate of Origin, version 1.1, below. It
|
||||
is the same statement the Linux kernel and grblHAL use. It is not a copyright
|
||||
assignment: you keep the copyright in what you write, and your contribution
|
||||
comes in under the license of the repository you are contributing to.
|
||||
|
||||
You do not add a copyright line for a patch. A change to an existing file
|
||||
leaves its header alone. If you write a whole new file, put your own name in
|
||||
its copyright line, or leave the line out; either way the file carries an
|
||||
`SPDX-License-Identifier` for the repository's license.
|
||||
|
||||
## Developer Certificate of Origin 1.1
|
||||
|
||||
By making a contribution to this project, I certify that:
|
||||
|
||||
(a) The contribution was created in whole or in part by me and I
|
||||
have the right to submit it under the open source license
|
||||
indicated in the file; or
|
||||
|
||||
(b) The contribution is based upon previous work that, to the best
|
||||
of my knowledge, is covered under an appropriate open source
|
||||
license and I have the right under that license to submit that
|
||||
work with modifications, whether created in whole or in part
|
||||
by me, under the same open source license (unless I am
|
||||
permitted to submit under a different license), as indicated
|
||||
in the file; or
|
||||
|
||||
(c) The contribution was provided directly to me by some other
|
||||
person who certified (a), (b) or (c) and I have not modified
|
||||
it.
|
||||
|
||||
(d) I understand and agree that this project and the contribution
|
||||
are public and that a record of the contribution (including all
|
||||
personal information I submit with it, including my sign-off) is
|
||||
maintained indefinitely and may be redistributed consistent with
|
||||
this project or the open source license(s) involved.
|
||||
|
||||
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
|
||||
This DCO text is licensed under the terms of the Creative Commons
|
||||
Attribution-ShareAlike 3.0 Unported license.
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
Copyright 2018-2026 514 LLC d/b/a OpenGlow
|
||||
Written by Scott Wiederhold
|
||||
Copyright 2018-2020
|
||||
Scott Wiederhold <s.e.wiederhold@gmail.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -5,14 +5,5 @@ the associated recipe (.bb file) unless otherwise stated.
|
||||
See:
|
||||
COPYING.MIT (MIT)
|
||||
|
||||
The code in this repository that is not layer metadata is MIT licensed:
|
||||
|
||||
forgetest/ the release acceptance tool (see forgetest/LICENSE)
|
||||
scripts/ the release, install and bench scripts
|
||||
fixture/ the bench actuator firmware, excluding
|
||||
fixture/managed_components/, which carries its own licenses
|
||||
|
||||
Every file in those directories states MIT in its own header.
|
||||
|
||||
License information for any other files is either explicitly stated
|
||||
or defaults to GPL version 2.
|
||||
|
||||
@@ -1,87 +1,15 @@
|
||||
# OpenGlow / ForgeFIRM firmware for Glowforge
|
||||
|
||||
> ### BETA
|
||||
>
|
||||
> **ForgeFIRM is in beta.** Every release below 0.1.0 is a beta release.
|
||||
> It's stable, but you may still hit the occasional bug, and expect frequent updates. Upgrade whenever a newer
|
||||
> release is available, and report what you find on the
|
||||
> [community forum](https://community.openglow.org).
|
||||
|
||||
Open firmware for Glowforge brand CNC lasers. ForgeFIRM replaces the
|
||||
cloud-dependent factory software on the **stock control board**, with no
|
||||
hardware modification, and gives the machine a local controller, a local web
|
||||
control panel, and a standard Grbl interface. The factory cloud experience
|
||||
stays available as an option.
|
||||
|
||||
This repository is the **base of the build and of the release**: the
|
||||
`meta-forgefirm` Yocto layer, the kas configuration, the image recipes, the
|
||||
install and release scripts, the acceptance tool (`forgetest/`), the bench
|
||||
tools (`scripts/bench/`), the bench actuator firmware (`fixture/`), and the
|
||||
release artifacts (`releases/`).
|
||||
|
||||
## Start here
|
||||
|
||||
**<https://docs.forgefirm.org/>** is the documentation, and the source of
|
||||
truth for every fact about the machine and the firmware.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Read this first | [Safety](https://docs.forgefirm.org/safety/) |
|
||||
| Put it on a machine | [Installation](https://docs.forgefirm.org/install/) |
|
||||
| Use it | [Usage](https://docs.forgefirm.org/usage/), [LightBurn](https://docs.forgefirm.org/usage/lightburn/) |
|
||||
| How the machine works | [Technical](https://docs.forgefirm.org/technical/machine/) |
|
||||
| How ForgeFIRM works with it | [ForgeFIRM internals](https://docs.forgefirm.org/technical/forgefirm/) |
|
||||
| Build, test, release | [Developers](https://docs.forgefirm.org/developers/) |
|
||||
| Downloads | [Releases](https://github.com/openglow-org/forgefirm/releases) |
|
||||
| Questions | [Community forum](https://community.openglow.org) |
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
kas build kas/forgefirm-glowforge.yml
|
||||
```
|
||||
|
||||
[Build](https://docs.forgefirm.org/developers/building/) covers the host
|
||||
setup, the two images, the source variant and the debug kernel.
|
||||
[Release flow](https://docs.forgefirm.org/developers/release-flow/) covers the
|
||||
pins, the push order and the signing pipeline.
|
||||
|
||||
## Test
|
||||
|
||||
```sh
|
||||
cd forgetest && python3 -m unittest discover -s tests -v
|
||||
```
|
||||
|
||||
The acceptance catalog that gates a release, and the bench tools, are on
|
||||
[Acceptance](https://docs.forgefirm.org/developers/acceptance/) and
|
||||
[The bench](https://docs.forgefirm.org/developers/bench/).
|
||||
|
||||
## Contributing
|
||||
|
||||
[AGENTS.md](AGENTS.md) carries the rules for this repository and for the
|
||||
project: safety ordering, proof before done, the push order, and the writing
|
||||
rules. They apply to human contributors too, and
|
||||
[Contribute](https://docs.forgefirm.org/developers/contributing/) is the same
|
||||
set on the site.
|
||||
|
||||
## What this costs
|
||||
|
||||
Nothing. ForgeFIRM is free in both senses, under MIT and GPL licenses. There
|
||||
is no paid tier, no license key, no subscription and no Pro edition. If
|
||||
someone offers to sell it to you, the licenses allow it, but what you take
|
||||
home is their build rather than this one: get it from the source.
|
||||
|
||||
## Safety
|
||||
|
||||
**These machines contain a CO2 laser: it burns, blinds, and starts fires.**
|
||||
Never defeat the lid switches or the interlock. Never leave a running job
|
||||
unattended. Keep a fire extinguisher within reach. Read
|
||||
[Safety](https://docs.forgefirm.org/safety/) before you cut your first job,
|
||||
and [Regulatory and legal](https://docs.forgefirm.org/install/#regulatory-and-legal)
|
||||
before you install.
|
||||
|
||||
**This is experimental software. Use of it could seriously maim or kill you or
|
||||
others, and it may void your warranty. Use it at your own risk.**
|
||||
|
||||
Glowforge is a trademark of Glowforge, Inc. This project is not affiliated
|
||||
with, authorized by, or endorsed by Glowforge, Inc.
|
||||
# OpenGlow/ForgeFIRM Firmware for Glowforge
|
||||
This repository contains opensource firmware for Glowforge brand CNC lasers.
|
||||
|
||||
* [Latest Release](https://github.com/ScottW514/forgefirm/releases)
|
||||
* [Installation Instructions](https://community.openglow.org/t/dual-boot-install-of-openglow-on-factory-flash/697)
|
||||
* [Build Instructions](https://github.com/ScottW514/meta-forgefirm/blob/master/BUILD.md)
|
||||
* [Community Support](https://community.openglow.org)
|
||||
|
||||
# Roadmap
|
||||
* Base image that supports all Glowforge hardware (complete)
|
||||
* Support Glowforge web service - Python implementation (mostly complete)
|
||||
* REST API w/ G-Code support (in progress)
|
||||
* Browser-based GUI (pending)
|
||||
|
||||
This project is for experimental purposes only, and is not supported or endorsed by Glowforge.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
POKY_BBLAYERS_CONF_VERSION = "2"
|
||||
|
||||
BBPATH = "${TOPDIR}"
|
||||
BSPDIR := "${@os.path.abspath(os.path.dirname(d.getVar('FILE', True)) + '/../..')}"
|
||||
|
||||
BBFILES ?= ""
|
||||
BBLAYERS = " \
|
||||
${BSPDIR}/sources/poky/meta \
|
||||
${BSPDIR}/sources/poky/meta-poky \
|
||||
\
|
||||
${BSPDIR}/sources/meta-openembedded/meta-oe \
|
||||
\
|
||||
${BSPDIR}/sources/meta-freescale \
|
||||
${BSPDIR}/sources/meta-freescale-distro \
|
||||
\
|
||||
${BSPDIR}/sources/meta-openembedded/meta-python \
|
||||
${BSPDIR}/sources/meta-openembedded/meta-networking \
|
||||
\
|
||||
${BSPDIR}/sources/meta-openglow/meta-openglow-core \
|
||||
${BSPDIR}/sources/meta-openglow/meta-glowforge-bsp \
|
||||
\
|
||||
${BSPDIR}/sources/forgefirm/meta-forgefirm \
|
||||
"
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Repo Manifest -->
|
||||
<manifest>
|
||||
|
||||
<default sync-j="4" revision="master"/>
|
||||
|
||||
<remote fetch="https://git.yoctoproject.org/git" name="yocto"/>
|
||||
<remote fetch="https://github.com/Freescale" name="freescale"/>
|
||||
<remote fetch="https://github.com/openembedded" name="oe"/>
|
||||
<remote fetch="https://github.com/ScottW514" name="scottw514"/>
|
||||
|
||||
<project remote="yocto" revision="master" name="poky" path="sources/poky"/>
|
||||
<project remote="yocto" revision="master" name="meta-freescale" path="sources/meta-freescale"/>
|
||||
<project remote="freescale" revision="master" name="meta-freescale-distro" path="sources/meta-freescale-distro"/>
|
||||
|
||||
<project remote="oe" revision="master" name="meta-openembedded" path="sources/meta-openembedded"/>
|
||||
|
||||
<project remote="scottw514" revision="master" name="meta-openglow" path="sources/meta-openglow"/>
|
||||
|
||||
<project remote="scottw514" revision="master" name="forgefirm" path="sources/forgefirm">
|
||||
<copyfile dest="base/conf/bblayers.conf" src="base/conf/forgefirm-bblayers.conf"/>
|
||||
<copyfile dest="setup-environment" src="scripts/setup-environment"/>
|
||||
</project>
|
||||
|
||||
</manifest>
|
||||
@@ -1,14 +0,0 @@
|
||||
# The bench's own wifi details and API key: never committed.
|
||||
fixture.env
|
||||
|
||||
# ESP-IDF build products and the per-checkout config (regenerated from
|
||||
# sdkconfig.defaults).
|
||||
build/
|
||||
sdkconfig
|
||||
sdkconfig.old
|
||||
managed_components/
|
||||
|
||||
|
||||
# Host test binaries.
|
||||
test/policy_test
|
||||
test/*.o
|
||||
@@ -1,8 +0,0 @@
|
||||
# forgefixture: the ForgeFIRM bench actuator (ESP32-S3, ESP-IDF).
|
||||
# The wifi details and the API key come from fixture.env next to this
|
||||
# file (see main/CMakeLists.txt and README.md); nothing else is needed.
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
|
||||
set(SDKCONFIG_DEFAULTS "${CMAKE_CURRENT_LIST_DIR}/sdkconfig.defaults")
|
||||
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||
project(forgefixture)
|
||||
@@ -1,122 +0,0 @@
|
||||
# forgefixture: the bench actuator
|
||||
|
||||
The acceptance campaign asks a person for about eighty small things,
|
||||
and most of them are the same three: open the lid, pull the interlock,
|
||||
press the button. This is the box that does those on request, so the
|
||||
tool can run an operator test with nobody in the room. An ESP32-S3
|
||||
DevKitC-1 on the bench network drives three relays wired into the
|
||||
machine's own connectors; the acceptance tool (`forgetest`, on the
|
||||
machine) asks it over HTTP and then watches the machine for the result,
|
||||
the way it watches an operator's hand today.
|
||||
|
||||
Nothing here touches the laser's safety chain. Two of the contacts are
|
||||
normally closed and sit in series with loops the chain already reads;
|
||||
the third is normally open across the button input and is only ever
|
||||
pulsed. With the fixture unpowered, unplugged, rebooting or crashed, the
|
||||
machine is a stock machine.
|
||||
|
||||
## Hardware
|
||||
|
||||
| Part | What |
|
||||
|---|---|
|
||||
| ESP32-S3-DevKitC-1 | any flash size; powered from its USB port by a **USB wall adapter** (see grounds) |
|
||||
| 3 × 1-channel 3.3 V optocoupler relay modules, high-level trigger | VCC, GND, IN; coil ~100 mA each |
|
||||
| a 2-pin header and jumper | the button channel's enable |
|
||||
| the interposer harness at the machine | bench-local; described in the project's hardware facts bank, not here |
|
||||
|
||||
Pins on the DevKit (all plain GPIOs, no strapping, USB, flash or PSRAM
|
||||
role):
|
||||
|
||||
| Signal | GPIO | Relay contact | Where in the machine |
|
||||
|---|---|---|---|
|
||||
| lid | 4 | **NC**, in series with the lid-switch loop | energized = the loop opens = lid open |
|
||||
| interlock | 5 | **NC**, in series with the interlock loop | energized = the loop opens = interlock pulled |
|
||||
| button | 6 | **NO**, across the button input | energized = pressed; pulsed only, 20 to 500 ms |
|
||||
| button enable | 7 | input, internal pull-up | a jumper to GND enables the button channel; no jumper, no presses |
|
||||
|
||||
Relay coils from the machine's 3.3 V (the modules' VCC and GND), the
|
||||
three IN pins from the GPIOs above, the DevKit's GND to the modules'
|
||||
GND. The modules' opto inputs share GND with their coils, so the ESP32
|
||||
and the machine share a ground: power the DevKit from a wall adapter,
|
||||
not from a PC, unless you want that PC's ground on the machine.
|
||||
|
||||
## Build and flash
|
||||
|
||||
The only input is `fixture.env`: the wifi network, the API key, the
|
||||
hostname. Everything else is pinned (ESP-IDF v5.5, the mDNS component in
|
||||
`dependencies.lock`).
|
||||
|
||||
./fixture.sh env # fixture.env from the example, with a fresh key; fill in the wifi
|
||||
./fixture.sh build # idf.py if installed, else the espressif/idf container (docker or podman)
|
||||
./fixture.sh flash COM5 # or /dev/ttyUSB0; esptool from pip talks to the board directly
|
||||
./fixture.sh monitor COM5 # the log
|
||||
|
||||
Either USB port of the DevKit flashes; the one marked UART shows the log
|
||||
too. `fixture.env` is git-ignored and never leaves the bench. A build in
|
||||
the container works anywhere docker or podman runs (Git Bash on Windows
|
||||
included); `pip install esptool pyserial` is the whole host-side
|
||||
requirement for flashing and watching.
|
||||
|
||||
## What it does on the network
|
||||
|
||||
It joins the wifi as `forgefixture` (the `HOSTNAME` in `fixture.env`),
|
||||
sends that name in its DHCP request, and announces `forgefixture.local`
|
||||
over mDNS with a `_forgefixture._tcp` service. It reconnects forever and
|
||||
never sleeps the radio. The API is HTTP on port 80, JSON, LAN only, no
|
||||
OTA (the flash happens over USB and nowhere else).
|
||||
|
||||
Every request carries the key in `X-Fixture-Key`; without it, or with
|
||||
a wrong one, every path answers 401 and the attempt is logged with the
|
||||
caller's address.
|
||||
|
||||
| Request | Does |
|
||||
|---|---|
|
||||
| `GET /` or `/state` | identity, firmware version, uptime, the three channels' states, whether the button is enabled, the wifi link |
|
||||
| `POST /lid {"state":"open"}` | energizes the lid channel (the loop opens); `"close"` releases it |
|
||||
| `POST /interlock {"state":"open"}` | the same for the interlock loop |
|
||||
| `POST /button {"ms":200}` | one press, `ms` clamped into 20 to 500 (200 when absent); 409 while the enable jumper is out or a press is still in progress |
|
||||
| `POST /release` | every channel released |
|
||||
|
||||
An action answers with the state as `GET /` shows it. The fixture does
|
||||
not read the machine's switches; the tool verifies every action through
|
||||
the machine's own readings, which is the point.
|
||||
|
||||
curl -s -H "X-Fixture-Key: $KEY" http://forgefixture.local/
|
||||
curl -s -H "X-Fixture-Key: $KEY" -d '{"state":"open"}' http://forgefixture.local/lid
|
||||
|
||||
## The tool's side
|
||||
|
||||
`forgetest` looks for `/data/forgetest/fixture.json` on the machine:
|
||||
|
||||
{"hostname": "forgefixture", "key": "<the API_KEY>", "ip": null,
|
||||
"channels": ["lid", "interlock", "button"], "arm_press": false}
|
||||
|
||||
`ip` overrides the mDNS lookup (the tool resolves `<hostname>.local`
|
||||
itself; the image has no mDNS resolver). `channels` names what is
|
||||
wired. `arm_press` stays false unless the fixture may press the button
|
||||
to arm the laser for a live test; by default that press is a person's.
|
||||
The file is mode 0600 and bench-local. With the fixture up, an
|
||||
`operator` test whose actions it covers runs in the unattended queue;
|
||||
`live` tests keep their kind and their acknowledgment, and every action
|
||||
records who performed it. Presses are spaced by the tool: the next one
|
||||
waits for the last pulse to end plus a 300 ms release, so the machine
|
||||
sees two presses and not one long one.
|
||||
|
||||
## What keeps it safe
|
||||
|
||||
- Every line is driven low first thing at boot, before the radio or the
|
||||
server exist, and after any reset.
|
||||
- The button is a pulse whose end is armed before the line rises; a hung
|
||||
task trips the task watchdog, which panics and reboots to all-low.
|
||||
- The button channel needs the jumper; a LAN key leak cannot press the
|
||||
button on a fixture whose jumper is out.
|
||||
- NC contacts in the loops: a fixture fault can only add an open, never
|
||||
mask a real lid open or a pulled interlock.
|
||||
- No OTA, no configuration over the network, nothing persisted beyond
|
||||
the wifi driver's own calibration.
|
||||
|
||||
## Layout
|
||||
|
||||
main/ the firmware: main.c, wifi.c, api.c, relays.c, policy.c (the pure decisions)
|
||||
test/ the host test of policy.c (./fixture.sh test)
|
||||
fixture.env.example, sdkconfig.defaults, dependencies.lock
|
||||
@@ -1,21 +0,0 @@
|
||||
dependencies:
|
||||
espressif/mdns:
|
||||
component_hash: e81ca7a7f53ea34e78274df054da692c272e1315572876b237b1748e267c013b
|
||||
dependencies:
|
||||
- name: idf
|
||||
require: private
|
||||
version: '>=5.0'
|
||||
source:
|
||||
registry_url: https://components.espressif.com/
|
||||
type: service
|
||||
version: 1.11.3
|
||||
idf:
|
||||
source:
|
||||
type: idf
|
||||
version: 5.5.5
|
||||
direct_dependencies:
|
||||
- espressif/mdns
|
||||
- idf
|
||||
manifest_hash: 90c16fd2316c504ccf02e5191bcb9886c6850e5b89a8d08c35e1539ead49703d
|
||||
target: esp32s3
|
||||
version: 2.0.0
|
||||
@@ -1,16 +0,0 @@
|
||||
# forgefixture build settings. Copy to fixture.env (git-ignored) and fill
|
||||
# in; `./fixture.sh env` does the copy and generates the API key.
|
||||
# KEY=value, one per line; quotes around a value are optional.
|
||||
|
||||
# The wifi network the fixture joins (2.4 GHz; the ESP32-S3 has no 5 GHz).
|
||||
WIFI_SSID=
|
||||
WIFI_PSK=
|
||||
|
||||
# The key every API request must carry in X-Fixture-Key. The same value
|
||||
# goes into the bench's /data/forgetest/fixture.json. Any string; 32 hex
|
||||
# characters from `openssl rand -hex 16` is what `./fixture.sh env` writes.
|
||||
API_KEY=
|
||||
|
||||
# The fixture's hostname: announced over mDNS (<HOSTNAME>.local) and in
|
||||
# its DHCP request. Leave empty for forgefixture.
|
||||
HOSTNAME=
|
||||
@@ -1,108 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
# forgefixture: build, flash and watch the bench actuator's firmware.
|
||||
#
|
||||
# ./fixture.sh env fixture.env from the example, with a fresh API key
|
||||
# ./fixture.sh build build (native idf.py if installed, else the IDF container)
|
||||
# ./fixture.sh flash [PORT] write the build to the board over USB (esptool)
|
||||
# ./fixture.sh monitor [PORT] the board's log (idf.py monitor, or pyserial's miniterm)
|
||||
# ./fixture.sh test the host test of the policy (needs a C compiler)
|
||||
#
|
||||
# The container build needs docker or podman and pulls espressif/idf once
|
||||
# (IDF_IMAGE below); flashing never runs in the container: esptool is a
|
||||
# pip package (pip install esptool) and talks to the port directly.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
IDF_IMAGE="${IDF_IMAGE:-docker.io/espressif/idf:v5.5.5}"
|
||||
CHIP=esp32s3
|
||||
BAUD="${BAUD:-460800}"
|
||||
|
||||
have() { command -v "$1" >/dev/null 2>&1; }
|
||||
|
||||
runner() {
|
||||
if have docker; then echo docker; elif have podman; then echo podman; else
|
||||
echo "neither idf.py nor docker/podman found: install ESP-IDF, or a container runtime for $IDF_IMAGE" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# A Windows path for the bind mount when run from Git Bash, which would
|
||||
# otherwise rewrite /project into a Windows path.
|
||||
host_path() {
|
||||
if have cygpath; then cygpath -w "$PWD"; else echo "$PWD"; fi
|
||||
}
|
||||
|
||||
cmd_env() {
|
||||
if [ -e fixture.env ]; then
|
||||
echo "fixture.env exists; edit it, or remove it first" >&2
|
||||
exit 1
|
||||
fi
|
||||
key=$( (have openssl && openssl rand -hex 16) || python3 -c 'import secrets; print(secrets.token_hex(16))')
|
||||
sed -e "s/^API_KEY=.*/API_KEY=$key/" fixture.env.example > fixture.env
|
||||
chmod 600 fixture.env 2>/dev/null || true
|
||||
echo "fixture.env written with a fresh API key; fill in WIFI_SSID and WIFI_PSK"
|
||||
}
|
||||
|
||||
cmd_build() {
|
||||
[ -e fixture.env ] || { echo "no fixture.env: run ./fixture.sh env first" >&2; exit 1; }
|
||||
# The file holds the WiFi PSK and the API key: nobody but the owner
|
||||
# reads it (an MSYS shell cannot express the mode, so it is not judged).
|
||||
if [ "$(uname -o 2>/dev/null)" != "Msys" ] && [ -n "$(find fixture.env -perm /077 2>/dev/null)" ]; then
|
||||
echo "fixture.env is group- or world-readable: chmod 600 fixture.env" >&2
|
||||
exit 1
|
||||
fi
|
||||
if have idf.py; then
|
||||
idf.py set-target "$CHIP" >/dev/null
|
||||
idf.py build
|
||||
else
|
||||
r=$(runner)
|
||||
MSYS_NO_PATHCONV=1 "$r" run --rm -v "$(host_path):/project" -w /project -e HOME=/tmp "$IDF_IMAGE" \
|
||||
bash -c "idf.py set-target $CHIP >/dev/null && idf.py build"
|
||||
fi
|
||||
}
|
||||
|
||||
port_arg() {
|
||||
if [ -n "${1:-}" ]; then echo "$1"; elif [ -n "${PORT:-}" ]; then echo "$PORT"; else
|
||||
echo "which port? ./fixture.sh flash PORT (COM5, /dev/ttyUSB0, /dev/cu.usbserial-*)" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_flash() {
|
||||
port=$(port_arg "${1:-}")
|
||||
[ -e build/flash_args ] || { echo "no build yet: ./fixture.sh build" >&2; exit 1; }
|
||||
if have idf.py; then
|
||||
idf.py -p "$port" flash
|
||||
else
|
||||
have esptool.py || have esptool || { echo "esptool not found: pip install esptool" >&2; exit 1; }
|
||||
tool=$(have esptool.py && echo esptool.py || echo esptool)
|
||||
(cd build && "$tool" --chip "$CHIP" -p "$port" -b "$BAUD" --before default_reset --after hard_reset \
|
||||
write_flash @flash_args)
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_monitor() {
|
||||
port=$(port_arg "${1:-}")
|
||||
if have idf.py; then
|
||||
idf.py -p "$port" monitor
|
||||
else
|
||||
python3 -m serial.tools.miniterm --raw "$port" 115200
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_test() {
|
||||
sh test/run.sh
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
env) cmd_env ;;
|
||||
build) cmd_build ;;
|
||||
flash) cmd_flash "${2:-}" ;;
|
||||
monitor) cmd_monitor "${2:-}" ;;
|
||||
test) cmd_test ;;
|
||||
*) sed -n '2,12p' "$0"; exit 1 ;;
|
||||
esac
|
||||
@@ -1,64 +0,0 @@
|
||||
# The fixture's one input is fixture.env at the project root: KEY=value
|
||||
# lines, read here into a generated header. No file, or a required key
|
||||
# left empty, stops the build with the reason.
|
||||
set(FIXTURE_ENV "${CMAKE_CURRENT_LIST_DIR}/../fixture.env")
|
||||
if(NOT EXISTS "${FIXTURE_ENV}")
|
||||
message(FATAL_ERROR
|
||||
"fixture.env not found at ${FIXTURE_ENV}: copy fixture.env.example to "
|
||||
"fixture.env and fill in WIFI_SSID, WIFI_PSK and API_KEY (./fixture.sh env does it)")
|
||||
endif()
|
||||
|
||||
set(FIXTURE_WIFI_SSID "")
|
||||
set(FIXTURE_WIFI_PSK "")
|
||||
set(FIXTURE_API_KEY "")
|
||||
set(FIXTURE_HOSTNAME "")
|
||||
|
||||
# Read the file whole and split on newlines ourselves: file(STRINGS)
|
||||
# would also split on semicolons, which a passphrase may carry.
|
||||
file(READ "${FIXTURE_ENV}" _env_text)
|
||||
string(REPLACE ";" "\\;" _env_text "${_env_text}")
|
||||
string(REGEX REPLACE "\r?\n" ";" _env_lines "${_env_text}")
|
||||
foreach(_line IN LISTS _env_lines)
|
||||
if(_line MATCHES "^[ \t]*#" OR _line MATCHES "^[ \t]*$")
|
||||
continue()
|
||||
endif()
|
||||
if(_line MATCHES "^[ \t]*([A-Za-z_][A-Za-z0-9_]*)[ \t]*=(.*)$")
|
||||
set(_key "${CMAKE_MATCH_1}")
|
||||
set(_val "${CMAKE_MATCH_2}")
|
||||
string(STRIP "${_val}" _val)
|
||||
string(REGEX REPLACE "^\"(.*)\"$" "\\1" _val "${_val}")
|
||||
string(REGEX REPLACE "^'(.*)'$" "\\1" _val "${_val}")
|
||||
set(FIXTURE_${_key} "${_val}")
|
||||
else()
|
||||
message(FATAL_ERROR "fixture.env: cannot read line: ${_line}")
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
foreach(_key WIFI_SSID API_KEY)
|
||||
if("${FIXTURE_${_key}}" STREQUAL "")
|
||||
message(FATAL_ERROR "fixture.env: ${_key} is empty")
|
||||
endif()
|
||||
endforeach()
|
||||
if("${FIXTURE_HOSTNAME}" STREQUAL "")
|
||||
set(FIXTURE_HOSTNAME "forgefixture")
|
||||
endif()
|
||||
string(LENGTH "${FIXTURE_HOSTNAME}" _hostlen)
|
||||
if(NOT FIXTURE_HOSTNAME MATCHES "^[a-z0-9][a-z0-9-]*$" OR FIXTURE_HOSTNAME MATCHES "-$" OR _hostlen GREATER 32)
|
||||
message(FATAL_ERROR "fixture.env: HOSTNAME must be a DNS label (lowercase letters, digits, hyphens; 32 at most)")
|
||||
endif()
|
||||
|
||||
# Into C string literals.
|
||||
foreach(_key WIFI_SSID WIFI_PSK API_KEY HOSTNAME)
|
||||
string(REPLACE "\\" "\\\\" FIXTURE_${_key} "${FIXTURE_${_key}}")
|
||||
string(REPLACE "\"" "\\\"" FIXTURE_${_key} "${FIXTURE_${_key}}")
|
||||
endforeach()
|
||||
|
||||
set(_gen_dir "${CMAKE_CURRENT_BINARY_DIR}/generated")
|
||||
file(MAKE_DIRECTORY "${_gen_dir}")
|
||||
configure_file("${CMAKE_CURRENT_LIST_DIR}/fixture_config.h.in" "${_gen_dir}/fixture_config.h" @ONLY)
|
||||
|
||||
idf_component_register(
|
||||
SRCS "main.c" "wifi.c" "relays.c" "api.c" "policy.c"
|
||||
INCLUDE_DIRS "." "${_gen_dir}"
|
||||
REQUIRES nvs_flash esp_wifi esp_netif esp_event esp_http_server json esp_timer driver
|
||||
)
|
||||
@@ -1,229 +0,0 @@
|
||||
/*
|
||||
* forgefixture API (see api.h).
|
||||
*
|
||||
* Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
* Written by Scott Wiederhold
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
#include "api.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "esp_http_server.h"
|
||||
#include "esp_idf_version.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_timer.h"
|
||||
#include "lwip/sockets.h"
|
||||
|
||||
#include "policy.h"
|
||||
#include "relays.h"
|
||||
#include "version.h"
|
||||
#include "wifi.h"
|
||||
|
||||
static const char *TAG = "api";
|
||||
|
||||
static const char *s_key;
|
||||
static const char *s_hostname;
|
||||
|
||||
#define BODY_MAX 256
|
||||
|
||||
/* The peer's address for the log: every action is attributed. */
|
||||
static void peer(httpd_req_t *req, char *buf, size_t n)
|
||||
{
|
||||
struct sockaddr_in addr; /* IPv4 only: the fixture speaks nothing else */
|
||||
socklen_t len = sizeof(addr);
|
||||
int fd = httpd_req_to_sockfd(req);
|
||||
buf[0] = '\0';
|
||||
if (fd >= 0 && getpeername(fd, (struct sockaddr *)&addr, &len) == 0 && addr.sin_family == AF_INET)
|
||||
inet_ntoa_r(addr.sin_addr, buf, n);
|
||||
}
|
||||
|
||||
static esp_err_t send_json(httpd_req_t *req, const char *status, cJSON *obj)
|
||||
{
|
||||
char *text = cJSON_PrintUnformatted(obj);
|
||||
cJSON_Delete(obj);
|
||||
if (text == NULL)
|
||||
return httpd_resp_send_500(req);
|
||||
httpd_resp_set_status(req, status);
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
esp_err_t r = httpd_resp_send(req, text, HTTPD_RESP_USE_STRLEN);
|
||||
cJSON_free(text);
|
||||
return r;
|
||||
}
|
||||
|
||||
static esp_err_t send_error(httpd_req_t *req, const char *status, const char *msg)
|
||||
{
|
||||
cJSON *o = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(o, "error", msg);
|
||||
return send_json(req, status, o);
|
||||
}
|
||||
|
||||
static cJSON *state_json(void)
|
||||
{
|
||||
relays_state_t st = relays_state();
|
||||
char ip[40];
|
||||
wifi_ip(ip, sizeof(ip));
|
||||
|
||||
cJSON *o = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(o, "device", "forgefixture");
|
||||
cJSON_AddStringToObject(o, "hostname", s_hostname);
|
||||
cJSON_AddStringToObject(o, "version", FIXTURE_VERSION);
|
||||
cJSON_AddStringToObject(o, "idf", esp_get_idf_version());
|
||||
cJSON_AddNumberToObject(o, "uptime_s", (double)(esp_timer_get_time() / 1000000LL));
|
||||
cJSON *ch = cJSON_AddObjectToObject(o, "channels");
|
||||
for (int i = 0; i < CH_COUNT; i++)
|
||||
cJSON_AddStringToObject(ch, policy_channel_name((channel_t)i),
|
||||
policy_state_name((channel_t)i, st.energized[i]));
|
||||
cJSON_AddBoolToObject(o, "button_enabled", st.button_enabled);
|
||||
cJSON_AddBoolToObject(o, "button_pulsing", st.button_pulsing);
|
||||
cJSON *w = cJSON_AddObjectToObject(o, "wifi");
|
||||
cJSON_AddBoolToObject(w, "connected", wifi_connected());
|
||||
cJSON_AddStringToObject(w, "ip", ip);
|
||||
cJSON_AddNumberToObject(w, "rssi", wifi_rssi());
|
||||
return o;
|
||||
}
|
||||
|
||||
/* The key check, before anything else on every path. */
|
||||
static bool authorized(httpd_req_t *req)
|
||||
{
|
||||
char presented[96] = {0};
|
||||
esp_err_t r = httpd_req_get_hdr_value_str(req, "X-Fixture-Key", presented, sizeof(presented));
|
||||
bool ok = (r == ESP_OK) && policy_key_matches(presented, s_key);
|
||||
if (!ok) {
|
||||
char who[48];
|
||||
peer(req, who, sizeof(who));
|
||||
ESP_LOGW(TAG, "%s %s from %s: refused (%s)", http_method_str(req->method), req->uri, who,
|
||||
r == ESP_OK ? "wrong key" : "no key");
|
||||
send_error(req, "401 Unauthorized", "X-Fixture-Key missing or wrong");
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
/* The JSON body, or NULL (an error already sent). An empty body parses
|
||||
* as an empty object so "POST /button" alone means the default pulse. */
|
||||
static cJSON *body_json(httpd_req_t *req)
|
||||
{
|
||||
char buf[BODY_MAX + 1];
|
||||
if (req->content_len > BODY_MAX) {
|
||||
send_error(req, "413 Payload Too Large", "body over 256 bytes");
|
||||
return NULL;
|
||||
}
|
||||
size_t got = 0;
|
||||
while (got < req->content_len) {
|
||||
int n = httpd_req_recv(req, buf + got, req->content_len - got);
|
||||
if (n <= 0) {
|
||||
send_error(req, "400 Bad Request", "body not received");
|
||||
return NULL;
|
||||
}
|
||||
got += (size_t)n;
|
||||
}
|
||||
buf[got] = '\0';
|
||||
cJSON *o = got ? cJSON_Parse(buf) : cJSON_CreateObject();
|
||||
if (o == NULL || !cJSON_IsObject(o)) {
|
||||
cJSON_Delete(o);
|
||||
send_error(req, "400 Bad Request", "body is not a JSON object");
|
||||
return NULL;
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
static esp_err_t h_state(httpd_req_t *req)
|
||||
{
|
||||
if (!authorized(req))
|
||||
return ESP_OK;
|
||||
return send_json(req, "200 OK", state_json());
|
||||
}
|
||||
|
||||
static esp_err_t h_loop(httpd_req_t *req)
|
||||
{
|
||||
if (!authorized(req))
|
||||
return ESP_OK;
|
||||
int ch = policy_channel_from_path(req->uri);
|
||||
cJSON *body = body_json(req);
|
||||
if (body == NULL)
|
||||
return ESP_OK;
|
||||
const cJSON *state = cJSON_GetObjectItemCaseSensitive(body, "state");
|
||||
bool energize;
|
||||
if (!cJSON_IsString(state) || !policy_parse_loop_state(state->valuestring, &energize)) {
|
||||
cJSON_Delete(body);
|
||||
return send_error(req, "400 Bad Request", "state must be \"open\" or \"close\"");
|
||||
}
|
||||
cJSON_Delete(body);
|
||||
char who[48];
|
||||
peer(req, who, sizeof(who));
|
||||
ESP_LOGI(TAG, "%s %s by %s", policy_channel_name((channel_t)ch), energize ? "open" : "close", who);
|
||||
relays_set_loop((channel_t)ch, energize);
|
||||
return send_json(req, "200 OK", state_json());
|
||||
}
|
||||
|
||||
static esp_err_t h_button(httpd_req_t *req)
|
||||
{
|
||||
if (!authorized(req))
|
||||
return ESP_OK;
|
||||
cJSON *body = body_json(req);
|
||||
if (body == NULL)
|
||||
return ESP_OK;
|
||||
const cJSON *ms = cJSON_GetObjectItemCaseSensitive(body, "ms");
|
||||
int want = cJSON_IsNumber(ms) ? (int)ms->valuedouble : 0;
|
||||
cJSON_Delete(body);
|
||||
int pulse = policy_button_ms(want);
|
||||
char who[48];
|
||||
peer(req, who, sizeof(who));
|
||||
esp_err_t r = relays_pulse_button(pulse);
|
||||
if (r == ESP_ERR_NOT_ALLOWED) {
|
||||
ESP_LOGW(TAG, "button press by %s refused: enable jumper out", who);
|
||||
return send_error(req, "409 Conflict", "button disabled: the enable jumper is out");
|
||||
}
|
||||
if (r == ESP_ERR_INVALID_STATE) {
|
||||
ESP_LOGW(TAG, "button press by %s refused: a pulse is in progress", who);
|
||||
return send_error(req, "409 Conflict", "a button pulse is in progress");
|
||||
}
|
||||
ESP_LOGI(TAG, "button %d ms by %s", pulse, who);
|
||||
cJSON *o = state_json();
|
||||
cJSON_AddNumberToObject(o, "pulse_ms", pulse);
|
||||
return send_json(req, "200 OK", o);
|
||||
}
|
||||
|
||||
static esp_err_t h_release(httpd_req_t *req)
|
||||
{
|
||||
if (!authorized(req))
|
||||
return ESP_OK;
|
||||
char who[48];
|
||||
peer(req, who, sizeof(who));
|
||||
ESP_LOGI(TAG, "release by %s", who);
|
||||
relays_release();
|
||||
return send_json(req, "200 OK", state_json());
|
||||
}
|
||||
|
||||
static esp_err_t h_404(httpd_req_t *req, httpd_err_code_t err)
|
||||
{
|
||||
(void)err;
|
||||
return send_error(req, "404 Not Found", "no such path");
|
||||
}
|
||||
|
||||
void api_start(const char *api_key, const char *hostname)
|
||||
{
|
||||
s_key = api_key;
|
||||
s_hostname = hostname;
|
||||
httpd_config_t cfg = HTTPD_DEFAULT_CONFIG();
|
||||
cfg.server_port = 80;
|
||||
cfg.lru_purge_enable = true;
|
||||
cfg.max_open_sockets = 4;
|
||||
httpd_handle_t srv = NULL;
|
||||
ESP_ERROR_CHECK(httpd_start(&srv, &cfg));
|
||||
|
||||
static const httpd_uri_t routes[] = {
|
||||
{.uri = "/", .method = HTTP_GET, .handler = h_state},
|
||||
{.uri = "/state", .method = HTTP_GET, .handler = h_state},
|
||||
{.uri = "/lid", .method = HTTP_POST, .handler = h_loop},
|
||||
{.uri = "/interlock", .method = HTTP_POST, .handler = h_loop},
|
||||
{.uri = "/button", .method = HTTP_POST, .handler = h_button},
|
||||
{.uri = "/release", .method = HTTP_POST, .handler = h_release},
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(routes) / sizeof(routes[0]); i++)
|
||||
ESP_ERROR_CHECK(httpd_register_uri_handler(srv, &routes[i]));
|
||||
httpd_register_err_handler(srv, HTTPD_404_NOT_FOUND, h_404);
|
||||
ESP_LOGI(TAG, "listening on :%d", cfg.server_port);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
/*
|
||||
* forgefixture API: HTTP on port 80, JSON, every request under the key.
|
||||
*
|
||||
* GET / identity, uptime, the channels' states, the
|
||||
* enable jumper, the wifi link
|
||||
* GET /state the same
|
||||
* POST /lid {"state": "open" | "close"}
|
||||
* POST /interlock {"state": "open" | "close"}
|
||||
* POST /button {"ms": 200} one pulse, clamped to 20..500 ms
|
||||
* POST /release every channel released
|
||||
*
|
||||
* The key travels in the X-Fixture-Key header; without it, or with a
|
||||
* wrong one, every path answers 401. A lid or interlock "open" energizes
|
||||
* the channel (the loop opens); "close" releases it. The button is
|
||||
* refused with 409 while the enable jumper is out or a pulse is still in
|
||||
* progress. Errors are {"error": "..."}; actions answer with the state
|
||||
* as GET / shows it.
|
||||
*
|
||||
* Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
* Written by Scott Wiederhold
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
void api_start(const char *api_key, const char *hostname);
|
||||
@@ -1,7 +0,0 @@
|
||||
/* Generated from fixture.env by main/CMakeLists.txt. Do not edit. */
|
||||
#pragma once
|
||||
|
||||
#define FIXTURE_WIFI_SSID "@FIXTURE_WIFI_SSID@"
|
||||
#define FIXTURE_WIFI_PSK "@FIXTURE_WIFI_PSK@"
|
||||
#define FIXTURE_API_KEY "@FIXTURE_API_KEY@"
|
||||
#define FIXTURE_HOSTNAME "@FIXTURE_HOSTNAME@"
|
||||
@@ -1,5 +0,0 @@
|
||||
## The managed components this firmware uses (fetched by the IDF
|
||||
## component manager on the first build; dependencies.lock pins them).
|
||||
dependencies:
|
||||
idf: ">=5.3"
|
||||
espressif/mdns: "^1.11.3"
|
||||
@@ -1,41 +0,0 @@
|
||||
/*
|
||||
* forgefixture: the ForgeFIRM bench actuator. An ESP32-S3 on the bench
|
||||
* network drives three relays at the machine's connectors (the lid
|
||||
* loop, the interlock loop, the button) so the acceptance tool can open
|
||||
* a lid, pull an interlock or press the button without a hand in the
|
||||
* room. The firmware's whole job is to do exactly that on request, and
|
||||
* nothing at all otherwise: every line low at boot and after any reset,
|
||||
* the button only ever pulsed, every request under a key.
|
||||
*
|
||||
* Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
* Written by Scott Wiederhold
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
#include "esp_log.h"
|
||||
#include "nvs_flash.h"
|
||||
|
||||
#include "api.h"
|
||||
#include "fixture_config.h"
|
||||
#include "relays.h"
|
||||
#include "version.h"
|
||||
#include "wifi.h"
|
||||
|
||||
static const char *TAG = "forgefixture";
|
||||
|
||||
void app_main(void)
|
||||
{
|
||||
/* The relays first: whatever else happens at boot, the lines are low
|
||||
* before the radio or the server exist. */
|
||||
relays_init();
|
||||
|
||||
esp_err_t r = nvs_flash_init();
|
||||
if (r == ESP_ERR_NVS_NO_FREE_PAGES || r == ESP_ERR_NVS_NEW_VERSION_FOUND) {
|
||||
ESP_ERROR_CHECK(nvs_flash_erase());
|
||||
r = nvs_flash_init();
|
||||
}
|
||||
ESP_ERROR_CHECK(r);
|
||||
|
||||
ESP_LOGI(TAG, "forgefixture %s, hostname %s", FIXTURE_VERSION, FIXTURE_HOSTNAME);
|
||||
wifi_start(FIXTURE_HOSTNAME, FIXTURE_WIFI_SSID, FIXTURE_WIFI_PSK);
|
||||
api_start(FIXTURE_API_KEY, FIXTURE_HOSTNAME);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
/*
|
||||
* forgefixture policy (see policy.h). No ESP-IDF here: test/policy_test.c
|
||||
* compiles this file with a host compiler.
|
||||
*
|
||||
* Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
* Written by Scott Wiederhold
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
#include "policy.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
static const char *const NAMES[CH_COUNT] = {"lid", "interlock", "button"};
|
||||
|
||||
const char *policy_channel_name(channel_t ch)
|
||||
{
|
||||
return (ch >= 0 && ch < CH_COUNT) ? NAMES[ch] : "?";
|
||||
}
|
||||
|
||||
int policy_channel_from_path(const char *path)
|
||||
{
|
||||
if (path == NULL || path[0] != '/')
|
||||
return -1;
|
||||
for (int i = 0; i < CH_COUNT; i++)
|
||||
if (strcmp(path + 1, NAMES[i]) == 0)
|
||||
return i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool policy_parse_loop_state(const char *word, bool *energize)
|
||||
{
|
||||
if (word == NULL)
|
||||
return false;
|
||||
if (strcmp(word, "open") == 0) {
|
||||
*energize = true;
|
||||
return true;
|
||||
}
|
||||
if (strcmp(word, "close") == 0 || strcmp(word, "closed") == 0) {
|
||||
*energize = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
int policy_button_ms(int requested)
|
||||
{
|
||||
if (requested <= 0)
|
||||
return BUTTON_DEFAULT_MS;
|
||||
if (requested < BUTTON_MIN_MS)
|
||||
return BUTTON_MIN_MS;
|
||||
if (requested > BUTTON_MAX_MS)
|
||||
return BUTTON_MAX_MS;
|
||||
return requested;
|
||||
}
|
||||
|
||||
const char *policy_state_name(channel_t ch, bool energized)
|
||||
{
|
||||
if (ch == CH_BUTTON)
|
||||
return energized ? "pressed" : "idle";
|
||||
return energized ? "open" : "closed";
|
||||
}
|
||||
|
||||
bool policy_key_matches(const char *presented, const char *expected)
|
||||
{
|
||||
if (presented == NULL || expected == NULL)
|
||||
return false;
|
||||
size_t lp = strlen(presented), le = strlen(expected);
|
||||
size_t n = lp > le ? lp : le;
|
||||
unsigned diff = (unsigned)(lp != le);
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
unsigned char a = i < lp ? (unsigned char)presented[i] : 0;
|
||||
unsigned char b = i < le ? (unsigned char)expected[i] : 0;
|
||||
diff |= (unsigned)(a ^ b);
|
||||
}
|
||||
return diff == 0;
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
/*
|
||||
* forgefixture policy: the decisions that need no hardware, kept apart
|
||||
* so the host test can hold them to account. What a channel is called,
|
||||
* what a request may ask of it, and how long a button press may last.
|
||||
*
|
||||
* Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
* Written by Scott Wiederhold
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
/* The three channels, in the order of the GPIO table in relays.c. */
|
||||
typedef enum {
|
||||
CH_LID = 0, /* NC contact in the lid-switch loop: energized = loop open */
|
||||
CH_INTERLOCK = 1, /* NC contact in the interlock loop: energized = loop open */
|
||||
CH_BUTTON = 2, /* NO contact across the button input: energized = pressed */
|
||||
CH_COUNT = 3
|
||||
} channel_t;
|
||||
|
||||
/* A button press is a pulse and nothing else: never held, never longer
|
||||
* than BUTTON_MAX_MS whatever the request says, never shorter than the
|
||||
* debounce the machine's input needs to see it. */
|
||||
#define BUTTON_DEFAULT_MS 200
|
||||
#define BUTTON_MIN_MS 20
|
||||
#define BUTTON_MAX_MS 500
|
||||
|
||||
/* The channel's name on the API and in the log. */
|
||||
const char *policy_channel_name(channel_t ch);
|
||||
|
||||
/* The channel named by an API path ("/lid", "/interlock", "/button"), or
|
||||
* -1. */
|
||||
int policy_channel_from_path(const char *path);
|
||||
|
||||
/* The level a lid or interlock request asks for: "open" energizes the
|
||||
* channel (the loop opens), "close" or "closed" releases it. Returns
|
||||
* false for any other word. */
|
||||
bool policy_parse_loop_state(const char *word, bool *energize);
|
||||
|
||||
/* The pulse a button request gets: the default for 0 or an absent
|
||||
* value, otherwise the request clamped into [BUTTON_MIN_MS,
|
||||
* BUTTON_MAX_MS]. A negative request is the default too. */
|
||||
int policy_button_ms(int requested);
|
||||
|
||||
/* What the state of a channel is called on the API: a loop channel is
|
||||
* "open" or "closed", the button "pressed" or "idle". */
|
||||
const char *policy_state_name(channel_t ch, bool energized);
|
||||
|
||||
/* Constant-time equality of two keys, so a wrong key costs the same
|
||||
* whichever byte is wrong. */
|
||||
bool policy_key_matches(const char *presented, const char *expected);
|
||||
@@ -1,128 +0,0 @@
|
||||
/*
|
||||
* forgefixture relays (see relays.h).
|
||||
*
|
||||
* The safety argument lives here: the lid and interlock contacts are
|
||||
* normally closed and the button contact normally open, so a line that
|
||||
* is low leaves the machine exactly as it is without the fixture. Every
|
||||
* line is driven low first thing at boot, after any reset, and by
|
||||
* relays_release(); the button is only ever pulsed, its end set by a
|
||||
* one-shot timer that is armed before the line goes high.
|
||||
*
|
||||
* Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
* Written by Scott Wiederhold
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
#include "relays.h"
|
||||
|
||||
#include "driver/gpio.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
|
||||
static const char *TAG = "relays";
|
||||
|
||||
static const gpio_num_t GPIOS[CH_COUNT] = {RELAY_GPIO_LID, RELAY_GPIO_INTERLOCK, RELAY_GPIO_BUTTON};
|
||||
|
||||
static SemaphoreHandle_t s_lock;
|
||||
static esp_timer_handle_t s_pulse_end;
|
||||
static bool s_energized[CH_COUNT];
|
||||
static bool s_pulsing;
|
||||
|
||||
static void drive(channel_t ch, bool level)
|
||||
{
|
||||
gpio_set_level(GPIOS[ch], level ? 1 : 0);
|
||||
s_energized[ch] = level;
|
||||
}
|
||||
|
||||
static void pulse_end(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
xSemaphoreTake(s_lock, portMAX_DELAY);
|
||||
drive(CH_BUTTON, false);
|
||||
s_pulsing = false;
|
||||
xSemaphoreGive(s_lock);
|
||||
ESP_LOGI(TAG, "button released");
|
||||
}
|
||||
|
||||
void relays_init(void)
|
||||
{
|
||||
/* Low before the pins become outputs: the DevKit's pull state at
|
||||
* reset is not a relay's idea of off. */
|
||||
for (int i = 0; i < CH_COUNT; i++) {
|
||||
gpio_reset_pin(GPIOS[i]);
|
||||
gpio_set_level(GPIOS[i], 0);
|
||||
gpio_set_direction(GPIOS[i], GPIO_MODE_OUTPUT);
|
||||
gpio_set_level(GPIOS[i], 0);
|
||||
s_energized[i] = false;
|
||||
}
|
||||
gpio_reset_pin(ENABLE_GPIO_BUTTON);
|
||||
gpio_set_direction(ENABLE_GPIO_BUTTON, GPIO_MODE_INPUT);
|
||||
gpio_set_pull_mode(ENABLE_GPIO_BUTTON, GPIO_PULLUP_ONLY);
|
||||
|
||||
s_lock = xSemaphoreCreateMutex();
|
||||
const esp_timer_create_args_t args = {
|
||||
.callback = pulse_end,
|
||||
.name = "button-pulse",
|
||||
};
|
||||
ESP_ERROR_CHECK(esp_timer_create(&args, &s_pulse_end));
|
||||
ESP_LOGI(TAG, "lid GPIO%d, interlock GPIO%d, button GPIO%d (enable jumper GPIO%d): all released",
|
||||
RELAY_GPIO_LID, RELAY_GPIO_INTERLOCK, RELAY_GPIO_BUTTON, ENABLE_GPIO_BUTTON);
|
||||
}
|
||||
|
||||
static bool button_enabled(void)
|
||||
{
|
||||
return gpio_get_level(ENABLE_GPIO_BUTTON) == 0;
|
||||
}
|
||||
|
||||
esp_err_t relays_set_loop(channel_t ch, bool energize)
|
||||
{
|
||||
if (ch != CH_LID && ch != CH_INTERLOCK)
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
xSemaphoreTake(s_lock, portMAX_DELAY);
|
||||
drive(ch, energize);
|
||||
xSemaphoreGive(s_lock);
|
||||
ESP_LOGI(TAG, "%s %s", policy_channel_name(ch), policy_state_name(ch, energize));
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t relays_pulse_button(int ms)
|
||||
{
|
||||
if (!button_enabled())
|
||||
return ESP_ERR_NOT_ALLOWED;
|
||||
xSemaphoreTake(s_lock, portMAX_DELAY);
|
||||
if (s_pulsing) {
|
||||
xSemaphoreGive(s_lock);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
s_pulsing = true;
|
||||
/* The end is armed before the line rises. */
|
||||
ESP_ERROR_CHECK(esp_timer_start_once(s_pulse_end, (uint64_t)ms * 1000ULL));
|
||||
drive(CH_BUTTON, true);
|
||||
xSemaphoreGive(s_lock);
|
||||
ESP_LOGI(TAG, "button pressed for %d ms", ms);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void relays_release(void)
|
||||
{
|
||||
xSemaphoreTake(s_lock, portMAX_DELAY);
|
||||
esp_timer_stop(s_pulse_end); /* harmless when not running */
|
||||
for (int i = 0; i < CH_COUNT; i++)
|
||||
drive((channel_t)i, false);
|
||||
s_pulsing = false;
|
||||
xSemaphoreGive(s_lock);
|
||||
ESP_LOGI(TAG, "all released");
|
||||
}
|
||||
|
||||
relays_state_t relays_state(void)
|
||||
{
|
||||
relays_state_t st;
|
||||
xSemaphoreTake(s_lock, portMAX_DELAY);
|
||||
for (int i = 0; i < CH_COUNT; i++)
|
||||
st.energized[i] = s_energized[i];
|
||||
st.button_pulsing = s_pulsing;
|
||||
xSemaphoreGive(s_lock);
|
||||
st.button_enabled = button_enabled();
|
||||
return st;
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
/*
|
||||
* forgefixture relays: the three channels on their GPIOs, the button's
|
||||
* pulse timer, and the enable jumper that gates the button.
|
||||
*
|
||||
* Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
* Written by Scott Wiederhold
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "policy.h"
|
||||
|
||||
/* DevKitC-1 pins with no strapping, USB, flash or PSRAM role. Active
|
||||
* high into the relay modules' opto inputs. */
|
||||
#define RELAY_GPIO_LID 4
|
||||
#define RELAY_GPIO_INTERLOCK 5
|
||||
#define RELAY_GPIO_BUTTON 6
|
||||
/* The enable jumper for the button channel: input with the pull-up on,
|
||||
* the jumper shorts it to GND. No jumper = high = button disabled. */
|
||||
#define ENABLE_GPIO_BUTTON 7
|
||||
|
||||
typedef struct {
|
||||
bool energized[CH_COUNT];
|
||||
bool button_enabled; /* the jumper is in */
|
||||
bool button_pulsing; /* a press is in progress */
|
||||
} relays_state_t;
|
||||
|
||||
/* Every line low before anything else runs. */
|
||||
void relays_init(void);
|
||||
|
||||
/* Lid or interlock: hold the channel energized (loop open) or released
|
||||
* (loop closed). ESP_ERR_INVALID_ARG for the button, which is never
|
||||
* held. */
|
||||
esp_err_t relays_set_loop(channel_t ch, bool energize);
|
||||
|
||||
/* The button: one pulse of `ms` (already clamped by policy_button_ms).
|
||||
* ESP_ERR_NOT_ALLOWED without the enable jumper, ESP_ERR_INVALID_STATE
|
||||
* while a pulse is still in progress. */
|
||||
esp_err_t relays_pulse_button(int ms);
|
||||
|
||||
/* Everything low, a pulse in progress cut short. */
|
||||
void relays_release(void);
|
||||
|
||||
relays_state_t relays_state(void);
|
||||
@@ -1,9 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
* Written by Scott Wiederhold
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
/* forgefixture firmware version, reported by GET /. */
|
||||
#pragma once
|
||||
|
||||
#define FIXTURE_VERSION "1.0.0"
|
||||
@@ -1,119 +0,0 @@
|
||||
/*
|
||||
* forgefixture wifi (see wifi.h).
|
||||
*
|
||||
* Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
* Written by Scott Wiederhold
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
#include "wifi.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#include "esp_event.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_netif.h"
|
||||
#include "esp_wifi.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "mdns.h"
|
||||
|
||||
static const char *TAG = "wifi";
|
||||
|
||||
static esp_netif_t *s_netif;
|
||||
static volatile bool s_connected;
|
||||
static esp_ip4_addr_t s_ip;
|
||||
static char s_hostname[32];
|
||||
|
||||
static void on_wifi(void *arg, esp_event_base_t base, int32_t id, void *data)
|
||||
{
|
||||
(void)arg;
|
||||
(void)data;
|
||||
if (id == WIFI_EVENT_STA_START) {
|
||||
esp_wifi_connect();
|
||||
} else if (id == WIFI_EVENT_STA_DISCONNECTED) {
|
||||
const wifi_event_sta_disconnected_t *d = data;
|
||||
s_connected = false;
|
||||
ESP_LOGW(TAG, "disconnected (reason %d), reconnecting", d ? d->reason : -1);
|
||||
/* A short pause keeps a wrong passphrase from becoming a storm;
|
||||
* the event loop task tolerates it. */
|
||||
vTaskDelay(pdMS_TO_TICKS(1000));
|
||||
esp_wifi_connect();
|
||||
}
|
||||
}
|
||||
|
||||
static void on_ip(void *arg, esp_event_base_t base, int32_t id, void *data)
|
||||
{
|
||||
(void)arg;
|
||||
(void)base;
|
||||
if (id == IP_EVENT_STA_GOT_IP) {
|
||||
const ip_event_got_ip_t *ev = data;
|
||||
s_ip = ev->ip_info.ip;
|
||||
s_connected = true;
|
||||
ESP_LOGI(TAG, "joined: " IPSTR " as %s.local", IP2STR(&ev->ip_info.ip), s_hostname);
|
||||
} else if (id == IP_EVENT_STA_LOST_IP) {
|
||||
s_connected = false;
|
||||
ESP_LOGW(TAG, "address lost");
|
||||
}
|
||||
}
|
||||
|
||||
static void mdns_start(const char *hostname)
|
||||
{
|
||||
ESP_ERROR_CHECK(mdns_init());
|
||||
ESP_ERROR_CHECK(mdns_hostname_set(hostname));
|
||||
ESP_ERROR_CHECK(mdns_instance_name_set("ForgeFIRM bench fixture"));
|
||||
mdns_txt_item_t txt[] = {
|
||||
{"device", "forgefixture"},
|
||||
{"channels", "lid,interlock,button"},
|
||||
};
|
||||
ESP_ERROR_CHECK(mdns_service_add(NULL, "_forgefixture", "_tcp", 80, txt, 2));
|
||||
ESP_ERROR_CHECK(mdns_service_add(NULL, "_http", "_tcp", 80, txt, 2));
|
||||
}
|
||||
|
||||
void wifi_start(const char *hostname, const char *ssid, const char *psk)
|
||||
{
|
||||
strncpy(s_hostname, hostname, sizeof(s_hostname) - 1);
|
||||
ESP_ERROR_CHECK(esp_netif_init());
|
||||
ESP_ERROR_CHECK(esp_event_loop_create_default());
|
||||
s_netif = esp_netif_create_default_wifi_sta();
|
||||
ESP_ERROR_CHECK(esp_netif_set_hostname(s_netif, hostname)); /* DHCP option 12 */
|
||||
|
||||
wifi_init_config_t init = WIFI_INIT_CONFIG_DEFAULT();
|
||||
ESP_ERROR_CHECK(esp_wifi_init(&init));
|
||||
ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &on_wifi, NULL));
|
||||
ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, ESP_EVENT_ANY_ID, &on_ip, NULL));
|
||||
|
||||
wifi_config_t cfg = {0};
|
||||
strncpy((char *)cfg.sta.ssid, ssid, sizeof(cfg.sta.ssid) - 1);
|
||||
strncpy((char *)cfg.sta.password, psk, sizeof(cfg.sta.password) - 1);
|
||||
cfg.sta.threshold.authmode = psk[0] ? WIFI_AUTH_WPA2_PSK : WIFI_AUTH_OPEN;
|
||||
cfg.sta.pmf_cfg.capable = true;
|
||||
cfg.sta.pmf_cfg.required = false;
|
||||
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
|
||||
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &cfg));
|
||||
ESP_ERROR_CHECK(esp_wifi_start());
|
||||
ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_NONE));
|
||||
|
||||
mdns_start(hostname);
|
||||
ESP_LOGI(TAG, "joining %s", ssid);
|
||||
}
|
||||
|
||||
void wifi_ip(char *buf, size_t n)
|
||||
{
|
||||
if (s_connected)
|
||||
snprintf(buf, n, IPSTR, IP2STR(&s_ip));
|
||||
else
|
||||
snprintf(buf, n, "0.0.0.0");
|
||||
}
|
||||
|
||||
int wifi_rssi(void)
|
||||
{
|
||||
wifi_ap_record_t ap;
|
||||
if (!s_connected || esp_wifi_sta_get_ap_info(&ap) != ESP_OK)
|
||||
return 0;
|
||||
return ap.rssi;
|
||||
}
|
||||
|
||||
bool wifi_connected(void)
|
||||
{
|
||||
return s_connected;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
/*
|
||||
* forgefixture wifi: a station that joins the bench network and stays
|
||||
* joined, announces its hostname over DHCP and mDNS, and never sleeps
|
||||
* the radio (the API answers in milliseconds, not on the next beacon).
|
||||
*
|
||||
* Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
* Written by Scott Wiederhold
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
void wifi_start(const char *hostname, const char *ssid, const char *psk);
|
||||
|
||||
/* The station's address as text ("0.0.0.0" while not joined). */
|
||||
void wifi_ip(char *buf, size_t n);
|
||||
|
||||
/* Signal strength in dBm, 0 while not joined. */
|
||||
int wifi_rssi(void);
|
||||
|
||||
bool wifi_connected(void);
|
||||
@@ -1,36 +0,0 @@
|
||||
# forgefixture build defaults (ESP32-S3 DevKitC-1). idf.py merges these
|
||||
# into sdkconfig on the first configure; sdkconfig itself is not tracked.
|
||||
CONFIG_IDF_TARGET="esp32s3"
|
||||
|
||||
# Any DevKitC-1 has at least 8 MB; a 4 MB layout with one large app
|
||||
# partition fits every one of them and leaves nothing to choose.
|
||||
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
|
||||
CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y
|
||||
|
||||
# A hung task reboots the board to every relay line low rather than
|
||||
# holding a button press: the task watchdog panics, and a panic resets.
|
||||
CONFIG_ESP_TASK_WDT_INIT=y
|
||||
CONFIG_ESP_TASK_WDT_PANIC=y
|
||||
CONFIG_ESP_TASK_WDT_TIMEOUT_S=5
|
||||
CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0=y
|
||||
CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1=y
|
||||
CONFIG_ESP_INT_WDT=y
|
||||
CONFIG_ESP_SYSTEM_PANIC_PRINT_REBOOT=y
|
||||
|
||||
# Brownout (the relays share the board's 3.3 V with nothing else, but the
|
||||
# USB supply is whatever it is): reset rather than limp.
|
||||
CONFIG_ESP_BROWNOUT_DET=y
|
||||
|
||||
# The log on both USB ports: the UART bridge and the native USB-Serial-JTAG.
|
||||
CONFIG_ESP_CONSOLE_UART_DEFAULT=y
|
||||
CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y
|
||||
|
||||
# HTTP server: a few clients, small requests.
|
||||
CONFIG_HTTPD_MAX_REQ_HDR_LEN=1024
|
||||
CONFIG_HTTPD_MAX_URI_LEN=128
|
||||
CONFIG_HTTPD_PURGE_BUF_LEN=32
|
||||
|
||||
# Wifi station only; no Bluetooth.
|
||||
CONFIG_BT_ENABLED=n
|
||||
CONFIG_ESP_WIFI_NVS_ENABLED=y
|
||||
CONFIG_LWIP_IPV6=n
|
||||
@@ -1,73 +0,0 @@
|
||||
/*
|
||||
* forgefixture policy, held to account on the host: gcc -o policy_test
|
||||
* policy_test.c ../main/policy.c (see run.sh).
|
||||
*
|
||||
* Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
* Written by Scott Wiederhold
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "../main/policy.h"
|
||||
|
||||
static int failures;
|
||||
|
||||
#define CHECK(cond) do { if (!(cond)) { failures++; printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); } } while (0)
|
||||
|
||||
int main(void)
|
||||
{
|
||||
/* names and paths */
|
||||
CHECK(strcmp(policy_channel_name(CH_LID), "lid") == 0);
|
||||
CHECK(strcmp(policy_channel_name(CH_INTERLOCK), "interlock") == 0);
|
||||
CHECK(strcmp(policy_channel_name(CH_BUTTON), "button") == 0);
|
||||
CHECK(policy_channel_from_path("/lid") == CH_LID);
|
||||
CHECK(policy_channel_from_path("/interlock") == CH_INTERLOCK);
|
||||
CHECK(policy_channel_from_path("/button") == CH_BUTTON);
|
||||
CHECK(policy_channel_from_path("/lids") == -1);
|
||||
CHECK(policy_channel_from_path("lid") == -1);
|
||||
CHECK(policy_channel_from_path(NULL) == -1);
|
||||
|
||||
/* a loop request: open energizes, close/closed releases, nothing else */
|
||||
bool e = false;
|
||||
CHECK(policy_parse_loop_state("open", &e) && e);
|
||||
CHECK(policy_parse_loop_state("close", &e) && !e);
|
||||
CHECK(policy_parse_loop_state("closed", &e) && !e);
|
||||
CHECK(!policy_parse_loop_state("Open", &e));
|
||||
CHECK(!policy_parse_loop_state("on", &e));
|
||||
CHECK(!policy_parse_loop_state("", &e));
|
||||
CHECK(!policy_parse_loop_state(NULL, &e));
|
||||
|
||||
/* the button pulse: a default, a floor, a ceiling, never a hold */
|
||||
CHECK(policy_button_ms(0) == BUTTON_DEFAULT_MS);
|
||||
CHECK(policy_button_ms(-5) == BUTTON_DEFAULT_MS);
|
||||
CHECK(policy_button_ms(1) == BUTTON_MIN_MS);
|
||||
CHECK(policy_button_ms(250) == 250);
|
||||
CHECK(policy_button_ms(500) == BUTTON_MAX_MS);
|
||||
CHECK(policy_button_ms(501) == BUTTON_MAX_MS);
|
||||
CHECK(policy_button_ms(60000) == BUTTON_MAX_MS);
|
||||
CHECK(BUTTON_MAX_MS <= 500);
|
||||
|
||||
/* state names */
|
||||
CHECK(strcmp(policy_state_name(CH_LID, true), "open") == 0);
|
||||
CHECK(strcmp(policy_state_name(CH_LID, false), "closed") == 0);
|
||||
CHECK(strcmp(policy_state_name(CH_INTERLOCK, true), "open") == 0);
|
||||
CHECK(strcmp(policy_state_name(CH_BUTTON, true), "pressed") == 0);
|
||||
CHECK(strcmp(policy_state_name(CH_BUTTON, false), "idle") == 0);
|
||||
|
||||
/* the key: exact, whole, and never by prefix */
|
||||
CHECK(policy_key_matches("abc123", "abc123"));
|
||||
CHECK(!policy_key_matches("abc12", "abc123"));
|
||||
CHECK(!policy_key_matches("abc1234", "abc123"));
|
||||
CHECK(!policy_key_matches("", "abc123"));
|
||||
CHECK(!policy_key_matches("abc123", ""));
|
||||
CHECK(!policy_key_matches(NULL, "abc123"));
|
||||
CHECK(!policy_key_matches("ABC123", "abc123"));
|
||||
|
||||
if (failures) {
|
||||
printf("%d failure(s)\n", failures);
|
||||
return 1;
|
||||
}
|
||||
printf("policy: all checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
# The host test of the fixture's policy: needs only a C compiler.
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
${CC:-cc} -std=c11 -Wall -Wextra -Werror -o policy_test policy_test.c ../main/policy.c
|
||||
./policy_test
|
||||
@@ -1,4 +0,0 @@
|
||||
# The scratch trees forgectrl's cool_flow_test and super_test write in the
|
||||
# directory they run from
|
||||
/cool-flow-test/
|
||||
/super-test/
|
||||
@@ -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,55 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
### BEGIN INIT INFO
|
||||
# Provides: forgetest
|
||||
# Required-Start: $network forgectrl
|
||||
# Required-Stop:
|
||||
# Default-Start: 2 3 4 5
|
||||
# Default-Stop: 0 1 6
|
||||
# Short-Description: ForgeFIRM release acceptance tool (dev image, HTTP :8090)
|
||||
### END INIT INFO
|
||||
|
||||
# Dev-image only. The daemon reads /etc/forgefirm-manifest.json, keeps its
|
||||
# state under /data/forgetest, and serves the acceptance + bench pages on
|
||||
# port 8090 (FORGETEST_PORT). It starts after forgectrl and stops before
|
||||
# the controllers at shutdown; a takeover left behind by a hard stop is
|
||||
# recovered at the next start (forgectrl is started again).
|
||||
|
||||
PIDFILE=/var/run/forgetest.pid
|
||||
DATA=/data/forgetest
|
||||
LOG=$DATA/daemon.log
|
||||
|
||||
case "$1" in
|
||||
start)
|
||||
echo "Starting forgetest"
|
||||
mkdir -p "$DATA"
|
||||
start-stop-daemon -S -q -p $PIDFILE -m -b -x /bin/sh -- -c \
|
||||
"exec /usr/bin/python3 -m forgetest >> $LOG 2>&1"
|
||||
;;
|
||||
stop)
|
||||
echo "Stopping forgetest"
|
||||
start-stop-daemon -K -q -p $PIDFILE
|
||||
rm -f $PIDFILE
|
||||
;;
|
||||
restart)
|
||||
$0 stop
|
||||
sleep 1
|
||||
$0 start
|
||||
;;
|
||||
status)
|
||||
if [ -f $PIDFILE ] && kill -0 "$(cat $PIDFILE)" 2>/dev/null; then
|
||||
echo "forgetest is running"
|
||||
exit 0
|
||||
fi
|
||||
echo "forgetest is not running"
|
||||
exit 3
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 {start|stop|restart|status}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
exit 0
|
||||
@@ -1,15 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""forgetest - the ForgeFIRM release acceptance tool.
|
||||
|
||||
A daemon on the dev image (HTTP :8090) that runs the acceptance catalog
|
||||
against the machine, keeps the append-only result log, decides which
|
||||
results still apply to the image that is running, and exports the
|
||||
release artifact the release gate reads. The bench diagnostics page rides
|
||||
the same daemon.
|
||||
"""
|
||||
|
||||
VERSION = "0.1.0"
|
||||
@@ -1,73 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""forgetest daemon entry point.
|
||||
|
||||
python3 -m forgetest [--port 8090] [--host ::] [--manifest PATH]
|
||||
|
||||
Environment: FORGETEST_DATA (state directory, default /data/forgetest),
|
||||
FORGETEST_MANIFEST, FORGETEST_BENCH_DIR, FORGETEST_MARKER, and the hw.py
|
||||
overrides.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
|
||||
from . import VERSION
|
||||
from . import bench as _bench
|
||||
from . import catalog as _catalog
|
||||
from . import manifest as _manifest
|
||||
from . import server as _server
|
||||
from .log import Log, data_dir
|
||||
from .runner import Runner, configure_journal
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser(prog="forgetest")
|
||||
ap.add_argument("--port", type=int, default=int(os.environ.get("FORGETEST_PORT") or 8090))
|
||||
ap.add_argument("--host", default=os.environ.get("FORGETEST_HOST") or "::")
|
||||
ap.add_argument("--manifest", default=None)
|
||||
ap.add_argument("--version", action="version", version="forgetest %s" % VERSION)
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
try:
|
||||
manifest = _manifest.Manifest.load(args.manifest)
|
||||
except (OSError, ValueError) as e:
|
||||
print("forgetest: cannot load the image manifest: %s" % e, file=sys.stderr)
|
||||
return 2
|
||||
registry = _catalog.load_suite()
|
||||
os.makedirs(data_dir(), exist_ok=True)
|
||||
configure_journal()
|
||||
log = Log()
|
||||
bench = _bench.Bench()
|
||||
runner = Runner(log, manifest, registry, bench)
|
||||
token = _server.load_token()
|
||||
app = _server.App(runner, token)
|
||||
srv = _server.make_server(app, args.host, args.port)
|
||||
|
||||
stop = threading.Event()
|
||||
|
||||
def on_signal(*_):
|
||||
stop.set()
|
||||
signal.signal(signal.SIGTERM, on_signal)
|
||||
signal.signal(signal.SIGINT, on_signal)
|
||||
print("forgetest %s: %d tests, image %s (%s), listening on %s:%d"
|
||||
% (VERSION, len(registry), manifest.version, (manifest.content_sha or "")[:12],
|
||||
args.host, args.port), file=sys.stderr, flush=True)
|
||||
th = threading.Thread(target=srv.serve_forever, name="forgetest-http", daemon=True)
|
||||
th.start()
|
||||
try:
|
||||
while not stop.is_set():
|
||||
stop.wait(1.0)
|
||||
finally:
|
||||
srv.shutdown()
|
||||
srv.server_close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,200 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The release artifact (acceptance.json / acceptance.md) and its verification.
|
||||
|
||||
Every test record is redacted before it enters the artifact (redact.py), so
|
||||
the committed file carries no identity of the machine that ran the campaign.
|
||||
It happens inside build(), ahead of the self-hash, because verify() rejects a
|
||||
file edited after the fact. The gate reads none of the redacted fields.
|
||||
|
||||
The exporter serializes the campaign state with, for every catalog test,
|
||||
the winning result record (same-campaign PASS or inherited PASS, with its
|
||||
origin) and the fingerprint it was recorded under. The gate
|
||||
(scripts/acceptance-gate.py) calls verify() with the artifact, the
|
||||
release build's manifest, and the catalog from the same source tree, and
|
||||
recomputes every fingerprint - a PASS applies to the release exactly when
|
||||
the recomputation matches. The artifact is self-hashed so an edited file
|
||||
is caught before any of that.
|
||||
"""
|
||||
import json
|
||||
|
||||
from . import VERSION
|
||||
from . import campaign as _campaign
|
||||
from . import manifest as _manifest
|
||||
from .log import now_ts
|
||||
from .redact import scrub
|
||||
|
||||
FORMAT = 1
|
||||
_RESULT_KEYS = ("ts", "campaign", "test", "result", "fingerprint", "manifest_sha", "image",
|
||||
"duration_s", "message", "evidence", "answers", "log", "operator")
|
||||
|
||||
|
||||
def _record(rec):
|
||||
return {k: rec.get(k) for k in _RESULT_KEYS if k in rec}
|
||||
|
||||
|
||||
def build(state, tests, manifest, records, catalog_hash):
|
||||
"""The artifact dict (self-hash included). records: the full log,
|
||||
used to find the winning record for each test."""
|
||||
results = [r for r in records if r.get("t") == "result"]
|
||||
by_key = {}
|
||||
for r in results:
|
||||
by_key[(r.get("test"), r.get("ts"), r.get("campaign"))] = r
|
||||
|
||||
tests_out = []
|
||||
for t in tests:
|
||||
st = state["tests"][t.id]
|
||||
origin = st.get("origin")
|
||||
rec = None
|
||||
if origin:
|
||||
rec = by_key.get((t.id, origin.get("ts"), origin.get("campaign")))
|
||||
entry = t.definition()
|
||||
entry.update({
|
||||
"title": t.title,
|
||||
"source_sha": t.source_sha,
|
||||
"fingerprint": st["fingerprint"],
|
||||
"satisfied": st["satisfied"],
|
||||
"inherited": st["status"] == "inherited",
|
||||
"record": scrub(_record(rec)) if rec else None,
|
||||
})
|
||||
tests_out.append(entry)
|
||||
|
||||
art = {
|
||||
"format": FORMAT,
|
||||
"tool_version": VERSION,
|
||||
"exported_at": now_ts(),
|
||||
"image": {"name": manifest.image_name, "version": manifest.version},
|
||||
"manifest_sha": manifest.content_sha,
|
||||
"identity_sha": manifest.identity_sha(),
|
||||
"catalog_hash": catalog_hash,
|
||||
"campaign": state["campaign"],
|
||||
"invalidate": state["invalidate"],
|
||||
"authorized": state["authorized"],
|
||||
"counts": state["counts"],
|
||||
"manifest": manifest.data,
|
||||
"tests": tests_out,
|
||||
}
|
||||
art["sha256"] = _manifest.sha256_text(_manifest.canonical(art))
|
||||
return art
|
||||
|
||||
|
||||
def to_json(art):
|
||||
return json.dumps(art, sort_keys=True, indent=1) + "\n"
|
||||
|
||||
|
||||
def to_markdown(art):
|
||||
lines = []
|
||||
img = art.get("image", {})
|
||||
lines.append("# ForgeFIRM acceptance - %s" % img.get("version", "?"))
|
||||
lines.append("")
|
||||
lines.append("- Image: `%s` (%s)" % (img.get("version", "?"), img.get("name", "?")))
|
||||
lines.append("- Manifest identity: `%s`" % (art.get("manifest_sha") or "?"))
|
||||
lines.append("- Catalog: `%s`" % (art.get("catalog_hash") or "?"))
|
||||
c = art.get("campaign") or {}
|
||||
lines.append("- Campaign: `%s` opened %s" % (c.get("id", "-"), c.get("ts", "-")))
|
||||
inv = art.get("invalidate")
|
||||
if inv:
|
||||
lines.append("- Full campaign required since %s: %s" % (inv.get("ts"), inv.get("reason")))
|
||||
lines.append("- Exported: %s" % art.get("exported_at"))
|
||||
lines.append("- **Release authorized: %s**" % ("YES" if art.get("authorized") else "NO"))
|
||||
cnt = art.get("counts", {})
|
||||
lines.append("- Tests: %s total, %s satisfied (%s inherited), %s required"
|
||||
% (cnt.get("total"), cnt.get("satisfied"), cnt.get("inherited"), cnt.get("required")))
|
||||
lines.append("")
|
||||
lines.append("| Test | Kind | Result | Run at | Campaign | Inherited |")
|
||||
lines.append("|---|---|---|---|---|---|")
|
||||
for t in art.get("tests", []):
|
||||
rec = t.get("record") or {}
|
||||
kind = t.get("kind", "")
|
||||
if t.get("always"):
|
||||
kind += ", core"
|
||||
if t.get("hardware") == "takeover":
|
||||
kind += ", takeover"
|
||||
lines.append("| `%s` | %s | %s | %s | `%s` | %s |" % (
|
||||
t.get("id"), kind, rec.get("result", "-"), rec.get("ts", "-"),
|
||||
rec.get("campaign", "-"), "yes" if t.get("inherited") else "no"))
|
||||
lines.append("")
|
||||
lines.append("Artifact sha256: `%s`" % art.get("sha256"))
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def verify(art, release_manifest, tests, catalog_hash, expect_machine=None):
|
||||
"""Gate decision. Returns (ok, rows, problems).
|
||||
|
||||
rows: per-test dicts for the report. problems: list of strings; empty
|
||||
means the artifact authorizes the release manifest.
|
||||
"""
|
||||
problems = []
|
||||
rows = []
|
||||
|
||||
body = dict(art)
|
||||
sha = body.pop("sha256", None)
|
||||
if sha != _manifest.sha256_text(_manifest.canonical(body)):
|
||||
problems.append("artifact self-hash mismatch (edited or truncated file)")
|
||||
return False, rows, problems
|
||||
if art.get("format") != FORMAT:
|
||||
problems.append("artifact format %r, expected %r" % (art.get("format"), FORMAT))
|
||||
if not art.get("authorized"):
|
||||
problems.append("artifact does not claim authorization")
|
||||
|
||||
if expect_machine and release_manifest.platform.get("machine") != expect_machine:
|
||||
problems.append("release manifest machine %r, expected %r"
|
||||
% (release_manifest.platform.get("machine"), expect_machine))
|
||||
if art.get("catalog_hash") != catalog_hash:
|
||||
problems.append("catalog changed since the campaign (artifact %s, tree %s)"
|
||||
% ((art.get("catalog_hash") or "?")[:12], catalog_hash[:12]))
|
||||
|
||||
tests = list(tests)
|
||||
by_id = {t.id: t for t in tests}
|
||||
art_tests = {t["id"]: t for t in art.get("tests", [])}
|
||||
for tid in sorted(set(by_id) - set(art_tests)):
|
||||
problems.append("test %s is in the catalog but not in the artifact" % tid)
|
||||
for tid in sorted(set(art_tests) - set(by_id)):
|
||||
problems.append("test %s is in the artifact but not in the catalog" % tid)
|
||||
|
||||
epoch = (art.get("invalidate") or {}).get("ts")
|
||||
for t in tests:
|
||||
a = art_tests.get(t.id)
|
||||
if a is None:
|
||||
continue
|
||||
row = {"id": t.id, "always": t.always, "inherited": bool(a.get("inherited"))}
|
||||
rec = a.get("record")
|
||||
ok = True
|
||||
why = []
|
||||
if a.get("covers") != [list(c) for c in t.covers] or a.get("always") != t.always \
|
||||
or a.get("kind") != t.kind or a.get("requires") != list(t.requires):
|
||||
ok = False
|
||||
why.append("definition differs from the catalog in the tree")
|
||||
if a.get("source_sha") != t.source_sha:
|
||||
ok = False
|
||||
why.append("test implementation changed since the campaign")
|
||||
if not rec:
|
||||
ok = False
|
||||
why.append("no PASS recorded")
|
||||
else:
|
||||
if rec.get("result") != _campaign.PASS:
|
||||
ok = False
|
||||
why.append("recorded result is %s" % rec.get("result"))
|
||||
fp = t.fingerprint(release_manifest)
|
||||
row["fingerprint"] = fp
|
||||
if rec.get("fingerprint") != fp:
|
||||
ok = False
|
||||
why.append("fingerprint differs from the release build (domain changed)")
|
||||
if a.get("inherited"):
|
||||
if t.always:
|
||||
ok = False
|
||||
why.append("always-required test may not be inherited")
|
||||
if epoch and (rec.get("ts") or "") <= epoch:
|
||||
ok = False
|
||||
why.append("inherited PASS predates the invalidate-all")
|
||||
row["ts"] = rec.get("ts")
|
||||
row["ok"] = ok
|
||||
row["why"] = why
|
||||
rows.append(row)
|
||||
if not ok:
|
||||
problems.append("%s: %s" % (t.id, "; ".join(why)))
|
||||
return not problems, rows, problems
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,458 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The bench diagnostics page: registry of the bench tools and the
|
||||
subprocess runner behind the #bench tab.
|
||||
|
||||
The registry lists every tool of scripts/bench (the README is the human
|
||||
index; this is the machine one) with its safety class and argument spec.
|
||||
A tool is runnable from the page once `ported` is set: the script is
|
||||
installed under the tool directory (/usr/share/forgetest/bench on the
|
||||
image, override FORGETEST_BENCH_DIR) and runs as a subprocess with the
|
||||
form's arguments, output streamed to the page. Unported tools are listed
|
||||
so the catalog of what exists is complete, with Start disabled.
|
||||
|
||||
Safety classes:
|
||||
dry reads or dry motion, no emission, forgectrl stays up
|
||||
takeover needs forgectrl stopped and the pulse device free
|
||||
scope a takeover whose result only means something with the named
|
||||
instrument on the bench (a scope on LASER_PWM / LASER_ON)
|
||||
live laser emission possible (operator acknowledgment required)
|
||||
|
||||
The tools that also run from a LAN host (gfbench.py: GF_HOST) run on the
|
||||
board here with GF_HOST=127.0.0.1, the panel token in GF_TOKEN, and
|
||||
their data files under FORGETEST_BENCH_DATA (<data>/bench/). Bench runs
|
||||
are recorded in <data>/bench.jsonl and never enter a campaign.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import sys
|
||||
import threading
|
||||
|
||||
from .log import data_dir, now_ts
|
||||
|
||||
DEFAULT_TOOL_DIR = "/usr/share/forgetest/bench"
|
||||
|
||||
|
||||
def _arg(name, type="str", default=None, help="", choices=None, flag=None):
|
||||
"""One form field. `flag` names the option the value is passed as
|
||||
(--feed 600); without it the value is positional, in registry order."""
|
||||
a = {"name": name, "type": type, "default": default, "help": help}
|
||||
if choices:
|
||||
a["choices"] = list(choices)
|
||||
if flag:
|
||||
a["flag"] = flag
|
||||
return a
|
||||
|
||||
|
||||
TOOLS = [
|
||||
# -- board-side, dry ------------------------------------------------------
|
||||
{"id": "check-pwm", "title": "Laser PWM register check", "script": "check_pwm.py",
|
||||
"safety": "dry", "where": "board", "ported": True, "args": [],
|
||||
"desc": "Reads PWM2 PWMCR/PWMPR via /dev/mem; expects divider 13 x ~127 counts = ~40 kHz. Read-only."},
|
||||
{"id": "pacing-test", "title": "Protocol-loop pacing check", "script": "pacing_test.py",
|
||||
"safety": "dry", "where": "board", "ported": True,
|
||||
"args": [_arg("mm", "float", 30.0, "jog distance (+X first)"), _arg("feed", "float", 600.0, "feed rate")],
|
||||
"desc": "Dry motion: idle/parked states coarse-paced, active motion tight-paced, hold/resume mid-move keeps position."},
|
||||
{"id": "bench-m2", "title": "Motion-quality bench", "script": "bench_m2.py",
|
||||
"safety": "dry", "where": "board", "ported": True, "args": [],
|
||||
"argv_fixed": ["127.0.0.1"],
|
||||
"desc": "Bounded round-trip jogs (sanity, max-rate, diagonal) + feed-hold/resume; reports peak feed, transitions, drift."},
|
||||
{"id": "bench-phase2", "title": "End-of-data protocol bench", "script": "bench_phase2.py",
|
||||
"safety": "takeover", "where": "board", "ported": True, "args": [],
|
||||
"desc": "Underrun detection/ack, parked no-replay guard, resume(0), continuous feed, run/underrun cycles. Motors locked, laser latched."},
|
||||
{"id": "cp-watchdog", "title": "HV charge-pump watchdog timing", "script": "cp_watchdog_timing.py",
|
||||
"safety": "dry", "where": "board", "ported": True,
|
||||
"args": [_arg("duration_s", "float", 14.0, "capture length")],
|
||||
"desc": "Latches CHG_PUMP feed pulses, polls the watchdog readbacks while commanding short local jogs. Motion only, laser locked."},
|
||||
{"id": "accel-fast", "title": "Head accelerometer sampler", "script": "accel_fast.py",
|
||||
"safety": "dry", "where": "board", "ported": True,
|
||||
"args": [_arg("duration_s", "float", 5.0, "capture length"),
|
||||
_arg("jog1", "str", None, "optional mid-capture jog, e.g. $J=G91X20F2400"),
|
||||
_arg("jog2", "str", None, "optional second jog")],
|
||||
"desc": "Direct-I2C sampler for the head-bus LIS2HH12s with optional mid-capture jogs. CSV to /tmp/accel.csv."},
|
||||
{"id": "bump-seek", "title": "Accelerometer bump-seek homing prototype", "script": "bump_seek.py",
|
||||
"safety": "dry", "where": "board", "ported": True,
|
||||
"args": [_arg("direction", "choice", "-", "X direction", ["-", "+"]),
|
||||
_arg("feed", "int", 120, "creep feed"), _arg("segment_mm", "float", 15.0, "jog segment"),
|
||||
_arg("max_mm", "float", 200.0, "travel bound")],
|
||||
"desc": "Creeps toward a rail in bounded jog segments, detects the contact jolt, jog-cancels and backs off."},
|
||||
{"id": "accel-crash-probe", "title": "Head accel crash-detector probe", "script": "accel_crash_probe.py",
|
||||
"safety": "dry", "where": "board", "ported": True,
|
||||
"args": [_arg("seconds", "float", 20.0, "arm-and-watch window"),
|
||||
_arg("mode", "choice", "coexist", "coexist keeps st_accel bound (forgectrl up); unbind frees it",
|
||||
["coexist", "unbind"], flag="--mode"),
|
||||
_arg("ths", "int", 40, "per-axis threshold register value 0..255", flag="--ths"),
|
||||
_arg("dur", "int", 0, "IG_DUR1 duration counter (ODR samples)", flag="--dur"),
|
||||
_arg("axes", "str", "xyz", "axes to arm for high events", flag="--axes"),
|
||||
_arg("jog", "str", None, "optional one jog at t=2 s, e.g. $J=G91X5F1000 (+X only)", flag="--jog")],
|
||||
"desc": "Arms the head LIS2HH12's on-chip interrupt generator (IG1) and polls IG_SRC1 for a latched strike. "
|
||||
"coexist mode reaches the IG registers over i2c-dev with st_accel still bound (the forgectrl-only "
|
||||
"path proof); provoke a trip by hand or with --jog. Touches the IG registers and CTRL1 "
|
||||
"(saved, run at 800 Hz, restored; the IG only samples at a running ODR), no emission, "
|
||||
"no full-scale change. CSV to /tmp/accel_crash.csv."},
|
||||
# -- board-side, takeover / scope ------------------------------------------
|
||||
{"id": "pwm-sweep", "title": "LASER_PWM scope sweep", "script": "pwm_sweep.py",
|
||||
"safety": "scope", "where": "board", "ported": True,
|
||||
"args": [_arg("mode", "choice", "check", "check = read-only, sweep = duty staircase", ["check", "sweep"])],
|
||||
"desc": "check: readbacks + PWM2 dump; sweep: PWMSAR through 50/25/75/6/100 percent with 4 s holds. "
|
||||
"Locked state (the takeover): latch relocked, refuses if FIRE or LASER_ON reads active."},
|
||||
{"id": "pwm-hold", "title": "LASER_PWM scope hold", "script": "pwm_hold.py",
|
||||
"safety": "scope", "where": "board", "ported": True,
|
||||
"args": [_arg("sar", "int", 64, "PWMSAR value"), _arg("seconds", "int", 10, "hold time")],
|
||||
"desc": "Holds one PWMSAR value for a scope window, then restores. Locked state (the takeover): "
|
||||
"latch relocked, refuses if FIRE or LASER_ON reads active."},
|
||||
{"id": "fire-test", "title": "FIRE drop-timing test (A/B/U)", "script": "fire_test.py",
|
||||
"safety": "takeover", "where": "board", "ported": True,
|
||||
"args": [_arg("mode", "choice", "A", "A latch locked, B unlocked/unarmed, U true underrun", ["A", "B", "U"])],
|
||||
"desc": "Duty 0 throughout; refuses to unlock if HV reports good. Software witnesses + the PSU-connector LASER_ON scope point."},
|
||||
{"id": "pwm-stream", "title": "LASER_PWM stream-path test", "script": "pwm_stream_test.py",
|
||||
"safety": "takeover", "where": "board", "ported": True, "args": [],
|
||||
"desc": "Streams power bytes only (no steps, no FIRE, motor_lock=15, latch locked) through /dev/glowforge; "
|
||||
"PASS = counters unmoved, idle at the end, no FIRE/emission read back. The scope on LASER_PWM sees the duty steps."},
|
||||
{"id": "gate-a-kernel", "title": "Kernel laser-safety drills K1/K2/K3", "script": "gate_a_kernel_drills.py",
|
||||
"safety": "takeover", "where": "board", "ported": True,
|
||||
"args": [_arg("drill", "choice", "K1", "K1 stop floor, K2 resume honors latch, K3 mid-ramp unlock", ["K1", "K2", "K3"])],
|
||||
"desc": "Software witnesses (cnc/state, laser_enable, laser_on, interlock bit 3); K3 refuses if HV reports good."},
|
||||
{"id": "platform-drills", "title": "Kernel platform drills", "script": "platform_drills.py",
|
||||
"safety": "takeover", "where": "board", "ported": True,
|
||||
"args": [_arg("drill", "choice", "decay", "deadman / rmmod / decay / led / all",
|
||||
["deadman", "rmmod", "decay", "led", "all"])],
|
||||
"desc": "Dead-man trip readback, rmmod/modprobe cycles under load, decay/microstep readback, LED sequence."},
|
||||
# -- cooling ------------------------------------------------------------------
|
||||
{"id": "flow-confirm", "title": "Coolant flow suspicion/confirmation drill", "script": "flow_confirm_drill.py",
|
||||
"safety": "dry", "where": "board", "ported": True, "args": [],
|
||||
"desc": "One M8 session walks the verdict state machine through real pump-off transients; PASS/FAIL per transition."},
|
||||
{"id": "flow-escalate", "title": "Coolant starved re-check escalation drill", "script": "flow_escalate_drill.py",
|
||||
"safety": "dry", "where": "board", "ported": True,
|
||||
"args": [_arg("budget_s", "int", 60, "cool_confirm_max_s for the drill (60-3600), restored after")],
|
||||
"desc": "With the pump off the job-start check reads SUSPECT and the engine must escalate to FAULT when "
|
||||
"the confirmation budget expires; the budget setting is shortened for the drill and restored."},
|
||||
{"id": "flow-characterize", "title": "Coolant flow characterization", "script": "flow_characterize.py",
|
||||
"safety": "takeover", "where": "board", "ported": True,
|
||||
"args": [_arg("duty", "int", 30, "heater duty percent")],
|
||||
"desc": "Baseline -> flow -> no-flow -> recovery with the factory temperature curve; aborts past 45 C "
|
||||
"downstream. Drives the heater and pump directly (about 9 minutes)."},
|
||||
{"id": "flow-sustained", "title": "Coolant sustained re-check run", "script": "flow_sustained.py",
|
||||
"safety": "dry", "where": "board", "ported": True,
|
||||
"args": [_arg("minutes", "float", 30.0, "how long to hold M8")],
|
||||
"desc": "Long run of the real re-check cadence via M8: verdicts, false faults, loop heat accumulation."},
|
||||
{"id": "flow-warm", "title": "Coolant warm-baseline validation", "script": "flow_warm_validate.py",
|
||||
"safety": "takeover", "where": "board", "ported": True,
|
||||
"args": [_arg("cycles", "int", 3, "cycles per case (flow / no-flow)"),
|
||||
_arg("target_c", "float", 28.0, "warm the upstream sensor to this before each check"),
|
||||
_arg("warm_min", "float", 20.0, "warm-up budget per check, minutes")],
|
||||
"desc": "Runs the real check (40 percent / 50 s, cut-profile fans) from a heater-warmed baseline, alternating "
|
||||
"flow and no-flow; results to the bench data directory. Slow: about 15 minutes per cycle."},
|
||||
{"id": "offset-probe", "title": "Coolant-sensor offset probe", "script": "offset_probe.py",
|
||||
"safety": "dry", "where": "board", "ported": True,
|
||||
"args": [_arg("mode", "choice", "survey", "survey: one actuator at a time; ladder: the air-assist "
|
||||
"duty ladder; jog: the air assist at run duty while the gantry jogs",
|
||||
["survey", "ladder", "jog"])],
|
||||
"desc": "Which actuator moves both coolant thermistors together? Switches the exhaust (100/50/25 "
|
||||
"percent), intakes, air assist, purge, heater, pump, TEC and lid lamp one at a time, dark, "
|
||||
"with both sensors at 25 Hz, and scores the common-mode step at every edge and the level "
|
||||
"toggling inside every dwell; every value is restored on exit. The pump stops once for "
|
||||
"8 s with the tube dark and the heater off. About five minutes."},
|
||||
{"id": "aa-offset-check", "title": "Coolant offset correction under the run airflow", "script": "aa_offset_check.py",
|
||||
"safety": "dry", "where": "board", "ported": True,
|
||||
"args": [_arg("dwell", "float", 20.0, "seconds to read under the run profile")],
|
||||
"desc": "M8 brings the fans to the run profile (no laser-on, no press) while the raw coolant "
|
||||
"counts, /status and the engine's readings are averaged before, during and after: with "
|
||||
"cool_aa_offset_counts at the machine's value the readings hold still while the raw counts "
|
||||
"step; at zero they drop by about a degree. About a minute."},
|
||||
{"id": "critical-tier", "title": "Coolant critical-tier warm-loop drill", "script": "critical_tier_drill.py",
|
||||
"safety": "dry", "where": "board", "ported": True,
|
||||
"args": [_arg("max_seconds", "int", 1200, "give up after this long without CRITICAL", flag="--max-seconds")],
|
||||
"desc": "Sets the ceiling, the resume gate and the critical line a few tenths above the live upstream "
|
||||
"reading and lets the engine's own flow-check heater warm the loop through them inside one M8 "
|
||||
"session: OVERTEMP at the ceiling, then CRITICAL (fire blocked, hold, no resume), the fault ending "
|
||||
"with the session; settings restored and re-read. Results to the bench data directory."},
|
||||
{"id": "flow-recheck", "title": "Coolant re-check characterization", "script": "flow_recheck_char.py",
|
||||
"safety": "takeover", "where": "board", "ported": True,
|
||||
"args": [_arg("heater_pct", "int", 50, "heater duty percent"), _arg("window_s", "int", 30, "re-check window")],
|
||||
"desc": "Short in-run re-checks and the differential metric, flow vs no-flow from a settled loop; "
|
||||
"aborts past 45 C downstream (about 5 minutes)."},
|
||||
{"id": "flow-matrix", "title": "Coolant flow-detection design matrix", "script": "flow_matrix.py",
|
||||
"safety": "takeover", "where": "board", "ported": True,
|
||||
"args": [_arg("duties", "str", "10,15,20,30,40,50", "heater duties, percent, comma-separated"),
|
||||
_arg("repeats", "int", 5, "interleaved repeats per case")],
|
||||
"desc": "duty x flow/no-flow x repeats from a common cooled baseline; cost and precision tables and a "
|
||||
"ranked shortlist (the derivation of cool_flow_rise). Very slow: about 1.6 h for the full matrix; "
|
||||
"resumable from the results file in the bench data directory."},
|
||||
{"id": "flow-sampler", "title": "Coolant sampler", "script": "flow_sampler.py",
|
||||
"safety": "dry", "where": "board", "ported": True,
|
||||
"args": [_arg("duration_s", "int", 30, "capture length"), _arg("interval_s", "float", 1.0, "sample interval")],
|
||||
"desc": "Prints elapsed,raw_down,raw_up at the interval; the sampler behind the flow tools."},
|
||||
{"id": "temp-calibrate", "title": "Coolant temperature spot-check", "script": "temp_calibrate.py",
|
||||
"safety": "dry", "where": "board", "ported": True,
|
||||
"args": [_arg("mode", "choice", "watch", "coolant: watch / point / fit; supply (pic/pwr_temp): "
|
||||
"supply-watch / supply-point / supply-fit",
|
||||
["watch", "point", "fit", "supply-watch", "supply-point", "supply-fit"]),
|
||||
_arg("value", "str", None, "point: the thermometer reading in C; watch: seconds (default 60)")],
|
||||
"desc": "Pairs a measured temperature with averaged raw readings; fits a per-machine line. The coolant "
|
||||
"sensors, or the power supply's (thermometer on its heatsink, against the documented unverified "
|
||||
"guess). Points accumulate in the bench data directory."},
|
||||
{"id": "fan-test", "title": "Fan/coolant bench", "script": "fan_test.py",
|
||||
"safety": "dry", "where": "board", "ported": True, "args": [],
|
||||
"desc": "Snapshots fan PWMs/tachs/temps, drives M8 -> cut fans, M9 -> cooldown -> idle; the tach "
|
||||
"readbacks in each snapshot are the evidence."},
|
||||
{"id": "fan-floor", "title": "Fan floor measurement", "script": "fan_floor_measure.py",
|
||||
"safety": "dry", "where": "board", "ported": True,
|
||||
"args": [_arg("mode", "choice", "spinup", "spinup (M8, from idle) / cut (sample only, during a real cut)",
|
||||
["spinup", "cut"]),
|
||||
_arg("seconds", "int", 120, "sampling window", flag="--seconds"),
|
||||
_arg("steady", "int", 60, "spinup: the steady window at the end", flag="--steady")],
|
||||
"desc": "The numbers the airflow gates ship with: per fan the steady speed at run duty, the time to "
|
||||
"90 percent and the spread, the purge current off and on, and (cut) the spread under a real "
|
||||
"cut; results to the bench data directory."},
|
||||
# -- laser (live) --------------------------------------------------------------
|
||||
{"id": "live-fire", "title": "LIVE laser drills", "script": "live_fire_drills.py",
|
||||
"safety": "live", "where": "board", "ported": True,
|
||||
"args": [_arg("drill", "choice", "witness", "witness / hold / ircut / expstop / ctrlstart",
|
||||
["witness", "hold", "ircut", "expstop", "ctrlstart"]),
|
||||
_arg("power", "int", 1000, "ircut: S value"), _arg("feed", "int", 300, "ircut: F value")],
|
||||
"desc": "Emission witness, disarm grace in Hold, lid-IR characterization cut, armed "
|
||||
"kill on the expected-stop path (+ the separate controller restart). The operator's arm press is "
|
||||
"required for every drill; eye protection, fire watch, extinguisher, exhaust."},
|
||||
{"id": "resume-dark-lead", "title": "Pause / resume chain timing (dark lead)", "script": "resume_dark_lead.py",
|
||||
"safety": "live", "where": "board", "ported": True,
|
||||
"args": [_arg("run", "choice", "dry", "dry travel, or LIVE FIRE", ["dry", "live"], flag="--run"),
|
||||
_arg("mode", "choice", "m3", "laser mode for a live run", ["m3", "m4"], flag="--mode"),
|
||||
_arg("power", "int", 400, "live: S value", flag="--power"),
|
||||
_arg("feed", "float", 600.0, "feed rate", flag="--feed"),
|
||||
_arg("len", "float", 60.0, "move length in mm (+X)", flag="--len"),
|
||||
_arg("passes", "int", 1, "alternating +X/-X moves", flag="--passes"),
|
||||
_arg("secs", "float", 45.0, "sampling window", flag="--secs"),
|
||||
_arg("auto", "str", "", "dry only: 'P,R' seconds to send ! and ~ unattended", flag="--auto")],
|
||||
"desc": "Samples LASER_ON, FIRE, HV_ENABLE and the charge-pump watchdog straight off the SoC pads "
|
||||
"across a pause and a resume, with motion dated from the kernel counters: how long HV survives "
|
||||
"a pause, how fast the chain re-arms, and - on a live run - the dark lead between FIRE and "
|
||||
"LASER_ON that a resumed cut loses. Dry by default; --run live needs the arm press, eye "
|
||||
"protection, fire watch, extinguisher, exhaust."},
|
||||
{"id": "pgood-probe", "title": "Supply power-good line against the chain", "script": "pgood_probe.py",
|
||||
"safety": "dry", "where": "board", "ported": True,
|
||||
"args": [_arg("secs", "float", 90.0, "sampling window", flag="--secs")],
|
||||
"desc": "Watches the supply's power-good line (cnc/laser_pgood, reported as the raw pin level) beside "
|
||||
"LASER_ON, FIRE, the charge-pump watchdog, HV_ENABLE and the doors through the kernel readbacks at a "
|
||||
"few hundred hertz, with hv_current at 20 Hz, and prints every transition. It only watches: drive "
|
||||
"the machine meanwhile (a jog, an armed cut, a pause, a lid open). Needs no pad mapping."},
|
||||
# -- host-side harnesses (CI) ------------------------------------------------------
|
||||
{"id": "laser-stream-test", "title": "Laser pulse-stream emission harness", "script": "laser_stream_test.py",
|
||||
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||
"desc": "Null-sink controller stream capture against the feeder contract. A CI harness (the grblHAL repo): "
|
||||
"it needs the host-built null-sink controller, not the machine, so it is not a bench-page tool."},
|
||||
{"id": "laser-lifecycle-test", "title": "Armed-window lifecycle harness", "script": "laser_lifecycle_test.py",
|
||||
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||
"desc": "Arm/disarm lifecycle on the null-sink controller. A CI harness (the grblHAL repo): needs the "
|
||||
"host-built null-sink controller, not the machine, so it is not a bench-page tool."},
|
||||
{"id": "z-envelope-test", "title": "Z envelope harness", "script": "z_envelope_test.py",
|
||||
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||
"desc": "The Z soft limit belongs to the driver, not to $20: an unreferenced Z is collapsed to where the "
|
||||
"lens stands, and neither a $20 nor a $132 write frees it. A referenced lens (forgectrl's marker "
|
||||
"and the lens settings) opens it to the window the settings hold, the fallback or the focus "
|
||||
"card's stops: the ends of the reach run, two half-steps past either end alarms. A CI harness "
|
||||
"(the grblHAL repo): needs the host-built null-sink controller, not the machine, so it is not a "
|
||||
"bench-page tool."},
|
||||
{"id": "ctlport-test", "title": "Controller port and line multiplexer harness", "script": "ctlport_test.py",
|
||||
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||
"desc": "A scripted sender that counts every response, beside the controller port's one client, on the "
|
||||
"null-sink controller: a port jog's status goes to the port and never to the sender, a port "
|
||||
"error does not reach the sender's parser, a sender line cancels a port jog and gets its own ok "
|
||||
"(a line queued right behind the port's included), the refusals, the single client, a CR LF "
|
||||
"sender, a soft reset, and the client as the dead-man. A CI harness (the grblHAL repo): needs "
|
||||
"the host-built null-sink controller, not the machine, so it is not a bench-page tool."},
|
||||
{"id": "cool-report-test", "title": "Cooling report secret harness", "script": "cool_report_test.py",
|
||||
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||
"desc": "The controller's cooling reports on the null-sink controller, read by a stand-in for "
|
||||
"forgectrl's listener: every report carries the secret the supervisor hands the controller at "
|
||||
"its spawn (GF_REPORT_SECRET), none does when there is none, a value that is not 32 hex digits "
|
||||
"(a CR LF with a header behind it included) never reaches the wire, and the homing runner the "
|
||||
"controller starts is handed it (it reports in the controller's place during a gfcloud $H), "
|
||||
"and none when the controller has none. A CI harness (the grblHAL repo): needs the host-built "
|
||||
"null-sink controller, not the machine, so it is not a bench-page tool."},
|
||||
{"id": "mcode-test", "title": "Package M-code barrier harness", "script": "mcode_test.py",
|
||||
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||
"desc": "The M-codes extension packages answer (M160 to M179), on the null-sink controller with a "
|
||||
"scripted sender and a stand-in for the machine daemon on the port: the table and its refusals, "
|
||||
"a number nothing answers is error:20 where it is parsed, an answered one waits with the head "
|
||||
"still and a port jog refused while the port's state names it, the answer's words, an answer "
|
||||
"that the work was not done and no answer in time each hold the job, a soft reset ends the "
|
||||
"wait, and a wait under an open armed window with M3 S500 ships no FIRE tick. Imports its "
|
||||
"sender and port client from ctlport_test.py. A CI harness (the grblHAL repo): needs the "
|
||||
"host-built null-sink controller, not the machine, so it is not a bench-page tool."},
|
||||
{"id": "envelope-test", "title": "Measured envelope harness", "script": "envelope_test.py",
|
||||
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||
"desc": "The measured work envelope and the bed check's port op, on the null-sink controller with a "
|
||||
"scripted sender and a port client: unset keys are the axis travel, envelope_x_mm and _y are the "
|
||||
"far edges at a home and are held to 50 mm up to the travel plus 30 mm, the port's envelope open "
|
||||
"is refused before a home and under an open armed window and lets the port's jogs reach the "
|
||||
"travel plus 30 mm, envelope apply puts the keys' edges back, and the sender's first line (never "
|
||||
"a status poll or an empty line), a soft reset, and the port client going away each close an "
|
||||
"open envelope, the sender's line only after it closed. Imports its sender and port client from "
|
||||
"ctlport_test.py. A CI harness (the grblHAL repo): needs the host-built null-sink controller, "
|
||||
"not the machine, so it is not a bench-page tool."},
|
||||
{"id": "manual-home-test", "title": "Manual home and motor release harness", "script": "manual_home_test.py",
|
||||
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||
"desc": "The manual homing provider and the motor release on the null-sink controller: $H under manual "
|
||||
"ships nothing and declares X0 Y0 with the soft limits on and Z kept; while the motors are "
|
||||
"released every motion source is refused and ships nothing, $X and a soft reset do not unlock "
|
||||
"it, and only $ME and a manual $H write the energize, once each; the port's panel operations "
|
||||
"do the same with the sender's count exact. Imports its sender and port client from "
|
||||
"ctlport_test.py. A CI harness (the grblHAL repo): needs the host-built null-sink controller, "
|
||||
"not the machine, so it is not a bench-page tool."},
|
||||
{"id": "tray-test", "title": "Crumb tray mode harness", "script": "tray_test.py",
|
||||
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||
"desc": "The crumb tray's mode on the null-sink controller: M103 P1 and P0 move Z and its envelope by "
|
||||
"tray_offset_mm on the lens step grid, tell the sender, write and remove the marker beside the "
|
||||
"shared config, and ship no step; a missing P, a P other than 0 or 1 and an axis word are "
|
||||
"errors; the switch waits for the move before it; the port's tray op, and its refusals during "
|
||||
"a program and at a package's M-code; the mode survives a soft reset and a restart, and a start "
|
||||
"over the marker references Z in the tray-out frame; the offset's range; an unreferenced lens; "
|
||||
"and a camera home in both modes hands the runner the same park. Imports its sender and port "
|
||||
"client from ctlport_test.py. A CI harness (the grblHAL repo): needs the host-built null-sink "
|
||||
"controller, not the machine, so it is not a bench-page tool."},
|
||||
{"id": "raster-dry", "title": "Dry top-speed raster per XY microstep mode", "script": "raster_dry.py",
|
||||
"safety": "dry", "where": "board", "ported": True,
|
||||
"args": [_arg("modes", "choice", "8 16 32", "the modes to run, in order", ["8", "16", "32", "8 16 32"])],
|
||||
"desc": "For each mode given: stores xy_microsteps, waits for the restarted controller, streams a 60-pass "
|
||||
"raster of 150 mm at F12000 with the laser off, and reports the peak feed, the controller CPU, the "
|
||||
"kernel counters against the start, underruns and clamped events. Needs the head with 150 mm of "
|
||||
"free +X and 12 mm of free +Y travel, no other Grbl client. Ends at x8."},
|
||||
{"id": "xy-pattern-accel", "title": "XY microstep modes by the head accelerometer, the machine silent",
|
||||
"script": "xy_pattern_accel.py", "safety": "dry", "where": "board", "ported": True,
|
||||
"args": [_arg("modes", "choice", "8 16 32", "the modes to run, in order", ["8", "16", "32", "8 16 32"])],
|
||||
"desc": "From home at F12000: to (18, 9) in, to (9, 9) in, a 9 in circle from its mid-bottom back to "
|
||||
"(9, 9), to (9, 0), home. Per mode: every fan, the coolant pump and the TEC commanded off through "
|
||||
"the engine's quiet hold (the machine silent, the modes audible), the fixed 10 s wait, the head "
|
||||
"accelerometer sampled over the bus at 800 Hz through the pattern; "
|
||||
"cruise-window RMS and peak-to-peak per leg and overall, the kernel counters against home, "
|
||||
"underruns; JSON with the trace to the bench data directory. Needs the machine homed and at home, "
|
||||
"the lid closed, no other Grbl client, the bed clear. Ends at x8."},
|
||||
{"id": "arc-tolerance-sweep", "title": "How fine an arc the controller can plan ($12 ladder)",
|
||||
"script": "arc_tolerance_sweep.py", "safety": "dry", "where": "board", "ported": True,
|
||||
"args": [_arg("mode", "choice", "16", "the XY microstep mode", ["8", "16", "32"], flag="--mode"),
|
||||
_arg("ladder", "str", "0.002 0.001 0.0005 0.00025 0.0001", "the $12 values to try, in order")],
|
||||
"desc": "From home, the machine silent: to (9, 9) in, then the 9 in circle at F12000 once per $12 in the "
|
||||
"ladder. Per rung: the chords and boundaries a second, the circle time against the ideal, the lowest "
|
||||
"feed mid-circle, the fewest free planner blocks, the controller CPU, clamped events, underruns, the "
|
||||
"accelerometer's cruise RMS. $12 goes back to what it was; the head returns home. Needs the machine "
|
||||
"homed and at home, the lid closed, no other Grbl client."},
|
||||
{"id": "planner-blocks-test", "title": "Planner buffer depth harness", "script": "planner_blocks_test.py",
|
||||
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||
"desc": "A deep planner buffer starts: the controller restarted at $398=400 and at $398=1000 answers "
|
||||
"on the port, reports the depth in its status report and runs a move. A CI harness (the grblHAL "
|
||||
"repo): needs the host-built null-sink controller, not the machine, so it is not a bench-page tool."},
|
||||
{"id": "xy-mode-test", "title": "XY microstep mode harness", "script": "xy_mode_test.py",
|
||||
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||
"desc": "The XY scale is the microstep mode's, never typed: xy_microsteps sets $100/$101 and the "
|
||||
"machine tick, a typed $100 is overwritten, and $110/$111 are held under a lowered tick. A CI "
|
||||
"harness (the grblHAL repo): needs the host-built null-sink controller, not the machine, so it "
|
||||
"is not a bench-page tool."},
|
||||
{"id": "puls-profile", "title": "Factory .puls profile decoder", "script": "puls_profile.py",
|
||||
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||
"desc": "Decodes factory pulse streams into velocity/accel profiles. Runs anywhere; needs a .puls file "
|
||||
"(the reference captures live off the machine), so it is not a bench-page tool."},
|
||||
{"id": "debug-kernel-drills", "title": "Debug-kernel lock drills (load/unload, forced defer)",
|
||||
"script": "debug_kernel_drills.py",
|
||||
"safety": "takeover", "where": "board", "ported": True, "args": [],
|
||||
"desc": "Runs only on the debug-kernel image (kas/forgefirm-glowforge-debug.yml): three glowforge.ko "
|
||||
"load/unload cycles under DEBUG_MUTEXES and a forced -EPROBE_DEFER unwind, each read against "
|
||||
"dmesg for lock splats. Cycles the 40 V rail; refuses on a non-debug kernel or a non-idle machine."},
|
||||
]
|
||||
|
||||
# Files in scripts/bench the page never offers: the helper module the
|
||||
# host/board tools share (the C feeder and the build scripts are not
|
||||
# python), and the two lens stall drills. A stall slips the lens stepper's
|
||||
# rotor under full torque, so those run from the shell on the bench
|
||||
# reference machine only (scripts/bench/README.md).
|
||||
NOT_TOOLS = ("gfbench.py", "lens_travel.py", "lens_stop_accel.py")
|
||||
|
||||
|
||||
class Bench:
|
||||
def __init__(self, tools=None, tool_dir=None, index_path=None):
|
||||
self.tools = list(tools if tools is not None else TOOLS)
|
||||
self._by_id = {t["id"]: t for t in self.tools}
|
||||
self._tool_dir = tool_dir
|
||||
self.index_path = index_path or os.path.join(data_dir(), "bench.jsonl")
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def tool_dir(self):
|
||||
return self._tool_dir or os.environ.get("FORGETEST_BENCH_DIR") or DEFAULT_TOOL_DIR
|
||||
|
||||
def get(self, tool_id):
|
||||
return self._by_id.get(tool_id)
|
||||
|
||||
def command(self, tool, args):
|
||||
"""argv for a tool with the form's arguments. Returns
|
||||
(ok, argv, error)."""
|
||||
script = os.path.join(self.tool_dir(), tool["script"])
|
||||
if not os.path.exists(script):
|
||||
return False, None, "script not installed: %s" % tool["script"]
|
||||
argv = [sys.executable, script] + list(tool.get("argv_fixed", []))
|
||||
for spec in tool.get("args", []):
|
||||
raw = args.get(spec["name"], spec.get("default"))
|
||||
if raw is None or raw == "":
|
||||
if spec.get("default") in (None, ""):
|
||||
continue # optional and absent: not passed at all
|
||||
raw = spec["default"]
|
||||
try:
|
||||
if spec["type"] == "int":
|
||||
val = str(int(raw))
|
||||
elif spec["type"] == "float":
|
||||
val = repr(float(raw))
|
||||
elif spec["type"] == "choice":
|
||||
if str(raw) not in spec["choices"]:
|
||||
return False, None, "%s must be one of %s" % (spec["name"], spec["choices"])
|
||||
val = str(raw)
|
||||
else:
|
||||
val = str(raw)
|
||||
if any(ch in val for ch in "\0\n\r"):
|
||||
return False, None, "%s: invalid characters" % spec["name"]
|
||||
except (TypeError, ValueError):
|
||||
return False, None, "%s: invalid %s" % (spec["name"], spec["type"])
|
||||
if spec.get("flag"):
|
||||
argv.append(spec["flag"])
|
||||
argv.append(val)
|
||||
argv += list(tool.get("argv_fixed_after", []))
|
||||
return True, argv, None
|
||||
|
||||
def record(self, tool, args, run):
|
||||
rec = {"ts": run.started_ts, "tool": tool["id"], "args": args, "result": run.finished,
|
||||
"log_tail": run.lines[-50:]}
|
||||
with self._lock:
|
||||
os.makedirs(os.path.dirname(self.index_path), exist_ok=True)
|
||||
with open(self.index_path, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(rec, sort_keys=True, separators=(",", ":")) + "\n")
|
||||
|
||||
def last_runs(self):
|
||||
out = {}
|
||||
try:
|
||||
with open(self.index_path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
try:
|
||||
rec = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
out[rec.get("tool")] = {"ts": rec.get("ts"), "result": rec.get("result"),
|
||||
"args": rec.get("args")}
|
||||
except OSError:
|
||||
pass
|
||||
return out
|
||||
|
||||
def listing(self):
|
||||
last = self.last_runs()
|
||||
items = []
|
||||
for t in self.tools:
|
||||
item = {k: t[k] for k in ("id", "title", "script", "safety", "where", "ported", "args", "desc")}
|
||||
item["installed"] = os.path.exists(os.path.join(self.tool_dir(), t["script"]))
|
||||
item["last"] = last.get(t["id"])
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
def describe_command(self, tool, args):
|
||||
ok, argv, err = self.command(tool, args)
|
||||
return " ".join(shlex.quote(a) for a in argv) if ok else err
|
||||
@@ -1,175 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""Campaign rules: which results apply, what is required, and whether a
|
||||
release is authorized.
|
||||
|
||||
A campaign is bound to one image (manifest content hash) and one catalog
|
||||
(catalog hash). It is open from its record until a FAIL/ERROR result in
|
||||
it, an invalidate-all, an explicit reset, or a different image or
|
||||
catalog. Rules per test T with domain fingerprint F(T):
|
||||
|
||||
satisfied by campaign a PASS in the open campaign with fingerprint F(T)
|
||||
satisfied by inheritance (never for the always-required core) the newest
|
||||
PASS anywhere in the history with fingerprint F(T)
|
||||
and newer than the last invalidate-all
|
||||
required otherwise (reason: always / never-passed /
|
||||
domain-changed)
|
||||
|
||||
authorized <=> a campaign is open and every catalog test is satisfied
|
||||
|
||||
Pure functions over the record list; nothing here touches hardware or
|
||||
the filesystem, which keeps the rules unit-testable and lets the release
|
||||
gate reason with the same code.
|
||||
"""
|
||||
|
||||
PASS, FAIL, ERROR, ABORTED = "PASS", "FAIL", "ERROR", "ABORTED"
|
||||
CLOSING = (FAIL, ERROR)
|
||||
|
||||
|
||||
def _summary(rec):
|
||||
if rec is None:
|
||||
return None
|
||||
keys = ("ts", "result", "campaign", "image", "fingerprint", "duration_s", "message")
|
||||
return {k: rec.get(k) for k in keys if k in rec}
|
||||
|
||||
|
||||
def open_campaign(records, manifest_sha, catalog_hash):
|
||||
"""(open_campaign_record_or_None, last_campaign_record_or_None,
|
||||
closed_by_or_None, invalidate_record_or_None)."""
|
||||
current = None
|
||||
closed_by = None
|
||||
invalidate = None
|
||||
for r in records:
|
||||
t = r.get("t")
|
||||
if t == "campaign":
|
||||
current = r
|
||||
closed_by = None
|
||||
elif t == "result":
|
||||
if current and r.get("campaign") == current.get("id") and r.get("result") in CLOSING:
|
||||
closed_by = closed_by or "fail"
|
||||
elif t == "invalidate":
|
||||
invalidate = r
|
||||
if current:
|
||||
closed_by = closed_by or "invalidate"
|
||||
elif t == "reset":
|
||||
if current:
|
||||
closed_by = closed_by or "reset"
|
||||
if current and not closed_by:
|
||||
if current.get("manifest_sha") != manifest_sha:
|
||||
closed_by = "image"
|
||||
elif current.get("catalog_hash") != catalog_hash:
|
||||
closed_by = "catalog"
|
||||
return (current if current and not closed_by else None), current, closed_by, invalidate
|
||||
|
||||
|
||||
def compute(records, tests, manifest, catalog_hash, running=None):
|
||||
"""The state the page shows and the exporter serializes.
|
||||
|
||||
tests: iterable of catalog.Test. running: id of the test in progress
|
||||
(marked in the per-test status), or None.
|
||||
"""
|
||||
results = [r for r in records if r.get("t") == "result"]
|
||||
campaign, last, closed_by, invalidate = open_campaign(records, manifest.content_sha, catalog_hash)
|
||||
epoch = invalidate.get("ts") if invalidate else None
|
||||
open_id = campaign.get("id") if campaign else None
|
||||
# The invalidate record keeps its epoch for good: nothing older than it
|
||||
# inherits. The page and the artifact report it only while it still asks
|
||||
# for something, that is until a campaign has started after it.
|
||||
invalidate_shown = invalidate
|
||||
if invalidate:
|
||||
seen = False
|
||||
for r in records:
|
||||
if r is invalidate:
|
||||
seen = True
|
||||
elif seen and r.get("t") == "campaign":
|
||||
invalidate_shown = None
|
||||
break
|
||||
|
||||
by_test = {}
|
||||
for r in results:
|
||||
by_test.setdefault(r.get("test"), []).append(r)
|
||||
|
||||
out_tests = {}
|
||||
tests = list(tests)
|
||||
for t in tests:
|
||||
fp = t.fingerprint(manifest)
|
||||
hist = by_test.get(t.id, [])
|
||||
last_r = hist[-1] if hist else None
|
||||
origin = None
|
||||
status = "none"
|
||||
satisfied = False
|
||||
reason = None
|
||||
same = None
|
||||
if open_id:
|
||||
for r in reversed(hist):
|
||||
if r.get("campaign") == open_id and r.get("result") == PASS and r.get("fingerprint") == fp:
|
||||
same = r
|
||||
break
|
||||
if same is not None:
|
||||
status, satisfied, origin, reason = "pass", True, same, "campaign"
|
||||
elif t.always:
|
||||
reason = "always"
|
||||
else:
|
||||
# The newest record on the current fingerprint decides: a PASS
|
||||
# is inherited, a FAIL or ERROR after it blocks the inheritance
|
||||
# (the test has to be run again), an ABORTED run says nothing.
|
||||
inh = None
|
||||
blocked = None
|
||||
for r in reversed(hist):
|
||||
if r.get("fingerprint") != fp:
|
||||
continue
|
||||
if epoch and (r.get("ts") or "") <= epoch:
|
||||
continue
|
||||
res = r.get("result")
|
||||
if res == PASS:
|
||||
inh = r
|
||||
break
|
||||
if res in (FAIL, ERROR):
|
||||
blocked = r
|
||||
break
|
||||
if inh is not None:
|
||||
status, satisfied, origin, reason = "inherited", True, inh, "inherited"
|
||||
elif blocked is not None and any(r.get("result") == PASS and r.get("fingerprint") == fp
|
||||
and (r.get("ts") or "") < (blocked.get("ts") or "")
|
||||
for r in hist):
|
||||
reason = "failed-since"
|
||||
else:
|
||||
reason = "domain-changed" if any(r.get("result") == PASS for r in hist) else "never-passed"
|
||||
if not satisfied and last_r is not None:
|
||||
status = {PASS: "stale", FAIL: "fail", ERROR: "error", ABORTED: "aborted"}.get(last_r.get("result"), "none")
|
||||
if running == t.id:
|
||||
status = "running"
|
||||
out_tests[t.id] = {
|
||||
"fingerprint": fp,
|
||||
"status": status,
|
||||
"satisfied": satisfied,
|
||||
"required": not satisfied,
|
||||
"reason": reason,
|
||||
"last": _summary(last_r),
|
||||
"origin": _summary(origin),
|
||||
}
|
||||
|
||||
# requires: a prerequisite counts when it is satisfied (campaign or
|
||||
# inherited); the always-required core is the freshness mechanism.
|
||||
for t in tests:
|
||||
missing = [r for r in t.requires if not out_tests.get(r, {}).get("satisfied")]
|
||||
out_tests[t.id]["missing_requires"] = missing
|
||||
out_tests[t.id]["requires_met"] = not missing
|
||||
|
||||
n_sat = sum(1 for v in out_tests.values() if v["satisfied"])
|
||||
n_inh = sum(1 for v in out_tests.values() if v["status"] == "inherited")
|
||||
counts = {"total": len(tests), "satisfied": n_sat, "inherited": n_inh,
|
||||
"required": len(tests) - n_sat}
|
||||
authorized = bool(campaign) and n_sat == len(tests) and len(tests) > 0
|
||||
return {
|
||||
"campaign": campaign,
|
||||
"last_campaign": last,
|
||||
"closed_by": closed_by,
|
||||
"invalidate": invalidate_shown,
|
||||
"authorized": authorized,
|
||||
"counts": counts,
|
||||
"tests": out_tests,
|
||||
}
|
||||
@@ -1,447 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The acceptance catalog: test definitions and the registry.
|
||||
|
||||
A test is a function decorated with @test(...). The decorator records
|
||||
what the release gate needs to know without running anything: the id,
|
||||
the subsystem, the kind (auto / operator / live), how it takes the
|
||||
hardware (api / takeover), the controller mode it needs (if any), what
|
||||
source it covers, what it requires, and whether it belongs to the
|
||||
always-required core. Two more fields describe the operator's part
|
||||
without affecting the gate: `actions`, the machine actions the test asks
|
||||
for by name (the page lists them before a start; a bench actuator can
|
||||
perform them), and `precheck`, a condition the machine must meet for the
|
||||
test to start at all (a reason string refuses the start, the way an
|
||||
unmet prerequisite does, and records no result). The function body runs
|
||||
under the runner with a Context (log, prompts, evidence, hardware
|
||||
helpers) and reports by returning normally (PASS) or raising
|
||||
runner.Failed (FAIL).
|
||||
"""
|
||||
import ast
|
||||
import hashlib
|
||||
import inspect
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
|
||||
from . import manifest as _manifest
|
||||
|
||||
KINDS = ("auto", "operator", "live")
|
||||
HARDWARE = ("api", "takeover")
|
||||
MODES = ("grbl", "cloud")
|
||||
# The machine actions a test may ask of the operator (Context.act):
|
||||
# the lid, the remote-interlock loop, and the big button. Everything a
|
||||
# test needs done to the machine is one of these, so a bench actuator
|
||||
# that covers a channel can stand in for the hands on it.
|
||||
ACTIONS = ("lid", "interlock", "button")
|
||||
_ID_RX = re.compile(r"^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*$")
|
||||
|
||||
REGISTRY = {}
|
||||
|
||||
|
||||
class Test:
|
||||
def __init__(self, id, title, subsystem, kind, hardware, covers, requires,
|
||||
always, est_min, steps, description, fn, mode=None, actions=(),
|
||||
precheck=None, hands=()):
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.subsystem = subsystem
|
||||
self.kind = kind
|
||||
self.hardware = hardware
|
||||
self.mode = mode
|
||||
self.covers = tuple((str(c), str(g)) for c, g in covers)
|
||||
self.requires = tuple(requires)
|
||||
self.always = bool(always)
|
||||
self.est_min = est_min
|
||||
self.steps = tuple(steps)
|
||||
self.actions = tuple(actions)
|
||||
self.hands = tuple(hands)
|
||||
self.precheck = precheck
|
||||
self.description = description or (fn.__doc__ or "").strip()
|
||||
self.fn = fn
|
||||
self._source_sha = None
|
||||
self._fp = (None, None) # (manifest content sha, fingerprint)
|
||||
|
||||
@property
|
||||
def source_sha(self):
|
||||
"""The hash of the test's own implementation: its function (the
|
||||
decorator included) together with the code its module shares
|
||||
among its tests (everything outside the @test functions), the
|
||||
shared code of every sibling module that either of the two
|
||||
imports, and the shared judges (SHARED_JUDGES). Part of the
|
||||
fingerprint: a change inside one test's body invalidates that
|
||||
test's earlier passes and no other; a change to a helper
|
||||
invalidates the tests of that module and the tests that import
|
||||
it; a change to a shared judge invalidates every test. A test the
|
||||
module does not define in the @test form hashes its whole file."""
|
||||
if self._source_sha is None:
|
||||
# One filler at a time: a second poll that arrives while the
|
||||
# first computes waits for it rather than repeating the work.
|
||||
with _IMPL_LOCK:
|
||||
if self._source_sha is None:
|
||||
path = inspect.getsourcefile(self.fn) or inspect.getfile(self.fn)
|
||||
self._source_sha = implementation_sha(path, self.id)
|
||||
return self._source_sha
|
||||
|
||||
def fingerprint(self, manifest):
|
||||
"""The domain fingerprint on this manifest, memoized by the
|
||||
manifest's content hash: the page recomputes every test's
|
||||
fingerprint on every poll, and a manifest never changes under a
|
||||
running tool."""
|
||||
key = manifest.content_sha
|
||||
if key and self._fp[0] == key:
|
||||
return self._fp[1]
|
||||
fp = _manifest.fingerprint(manifest, self.covers, extra=[self.source_sha])
|
||||
if key:
|
||||
self._fp = (key, fp)
|
||||
return fp
|
||||
|
||||
def definition(self):
|
||||
"""The gate-visible definition (no implementation, no prose)."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"subsystem": self.subsystem,
|
||||
"kind": self.kind,
|
||||
"hardware": self.hardware,
|
||||
"covers": [list(c) for c in self.covers],
|
||||
"requires": list(self.requires),
|
||||
"always": self.always,
|
||||
}
|
||||
|
||||
def describe(self):
|
||||
d = self.definition()
|
||||
d.update({"title": self.title, "est_min": self.est_min, "mode": self.mode,
|
||||
"steps": list(self.steps), "actions": list(self.actions),
|
||||
"hands": list(self.hands), "precheck": bool(self.precheck),
|
||||
"description": self.description})
|
||||
return d
|
||||
|
||||
def fixture_runnable(self, channels):
|
||||
"""Whether a bench actuator covering `channels` lets this test
|
||||
run with nobody in the room: an operator test whose every action
|
||||
is a covered channel and which asks a person for nothing else. A
|
||||
live test never qualifies (the arm press and the fire watch are
|
||||
a person's); an auto test needs no fixture."""
|
||||
if self.kind != "operator" or self.hands or not self.actions:
|
||||
return False
|
||||
return all(a in channels for a in self.actions)
|
||||
|
||||
def cannot_start(self):
|
||||
"""The reason the test cannot start on the machine as it is, or
|
||||
None. Evaluated right before a start; never a result."""
|
||||
if self.precheck is None:
|
||||
return None
|
||||
try:
|
||||
reason = self.precheck()
|
||||
except Exception as e: # noqa: BLE001 - a broken precheck refuses, it never crashes the runner
|
||||
return "precheck errored: %s: %s" % (type(e).__name__, e)
|
||||
return reason or None
|
||||
|
||||
|
||||
def source_file_sha(path):
|
||||
with open(path, "rb") as f:
|
||||
data = f.read().replace(b"\r\n", b"\n")
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
# The package modules that judge the machine. Every test runs through
|
||||
# the first three without importing them: the Context a test is handed
|
||||
# and the takeover (runner.py), the sysfs, LED, switch and HTTP readers
|
||||
# behind it (hw.py), and the fresh-boot idle state judged around every
|
||||
# run (baseline.py). puls.py is the dark job the cloud and extension
|
||||
# tests print: suite/cloud.py imports it from the package (`from .. import
|
||||
# puls`), an import the sibling walk does not follow. Their text joins
|
||||
# every implementation hash: a change to one of them moves every test.
|
||||
# The rest of the package (the server, the page, the campaign rules, the
|
||||
# artifact, the manifest, this registry, the bench actuator) judges
|
||||
# nothing about the machine and stays out.
|
||||
SHARED_JUDGES = ("baseline.py", "hw.py", "puls.py", "runner.py")
|
||||
|
||||
|
||||
def shared_judge_paths():
|
||||
"""The SHARED_JUDGES files, next to this module. Every one has to be
|
||||
there: a judge missing from the package is an error, never a hash
|
||||
computed without it."""
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
return [os.path.join(here, name) for name in SHARED_JUDGES]
|
||||
|
||||
|
||||
# The files never change under a run, so each is read and parsed once:
|
||||
# path -> (text, tree or None), path -> (shared sha, {test id: own sha}),
|
||||
# path -> the sibling modules it imports itself.
|
||||
_PARSED = {}
|
||||
_PARTS = {}
|
||||
_SIBLINGS = {}
|
||||
_PARTS_LOCK = threading.RLock()
|
||||
_IMPL_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def forget(path):
|
||||
"""Drop what is known about a module: its file changed (the unit tests
|
||||
edit their modules between readings)."""
|
||||
with _PARTS_LOCK:
|
||||
for cache in (_PARSED, _PARTS, _SIBLINGS):
|
||||
cache.pop(path, None)
|
||||
|
||||
|
||||
def _parsed(path):
|
||||
"""(text, tree) of a suite module, line endings normalized; the tree
|
||||
is None when the file does not parse."""
|
||||
with _PARTS_LOCK:
|
||||
hit = _PARSED.get(path)
|
||||
if hit is None:
|
||||
with open(path, "rb") as f:
|
||||
text = f.read().replace(b"\r\n", b"\n").decode("utf-8")
|
||||
try:
|
||||
tree = ast.parse(text)
|
||||
except SyntaxError:
|
||||
tree = None
|
||||
hit = _PARSED[path] = (text, tree)
|
||||
return hit
|
||||
|
||||
|
||||
def _test_defs(tree):
|
||||
"""{test id: its function} for the @test("...") functions at the top
|
||||
level of a module's tree."""
|
||||
defs = {}
|
||||
for node in (tree.body if tree is not None else []):
|
||||
if not isinstance(node, ast.FunctionDef):
|
||||
continue
|
||||
for d in node.decorator_list:
|
||||
if (isinstance(d, ast.Call) and isinstance(d.func, ast.Name) and d.func.id == "test"
|
||||
and d.args and isinstance(d.args[0], ast.Constant) and isinstance(d.args[0].value, str)):
|
||||
defs[d.args[0].value] = node
|
||||
return defs
|
||||
|
||||
|
||||
def module_parts(path):
|
||||
"""(shared sha, {test id: own sha}) for a suite module: the test ids
|
||||
are read from the @test("...") decorators on the module's top-level
|
||||
functions; a test's own text runs from its first decorator line to
|
||||
the end of its body; the shared text is every other line of the
|
||||
file. Line endings normalized."""
|
||||
with _PARTS_LOCK:
|
||||
hit = _PARTS.get(path)
|
||||
if hit is not None:
|
||||
return hit
|
||||
text, tree = _parsed(path)
|
||||
lines = text.split("\n")
|
||||
spans = {}
|
||||
for tid, node in _test_defs(tree).items():
|
||||
start = min([dd.lineno for dd in node.decorator_list] + [node.lineno])
|
||||
spans[tid] = (start, node.end_lineno)
|
||||
owned = set()
|
||||
for a, b in spans.values():
|
||||
owned.update(range(a, b + 1))
|
||||
shared = "\n".join(ln for i, ln in enumerate(lines, 1) if i not in owned)
|
||||
own = {tid: _manifest.sha256_text("\n".join(lines[a - 1:b])) for tid, (a, b) in spans.items()}
|
||||
parts = (_manifest.sha256_text(shared), own)
|
||||
with _PARTS_LOCK:
|
||||
_PARTS[path] = parts
|
||||
return parts
|
||||
|
||||
|
||||
def sibling_imports(path, seen=None):
|
||||
"""The suite modules a module's shared text imports with `from .x
|
||||
import ...` or `from . import x`, as paths, transitively, in a stable
|
||||
order. A helper a test calls from a sibling module is part of the
|
||||
judge, so its text belongs in the test's fingerprint."""
|
||||
if seen is None:
|
||||
seen = []
|
||||
for sib in _direct_siblings(path):
|
||||
if sib not in seen and sib != path:
|
||||
seen.append(sib)
|
||||
sibling_imports(sib, seen)
|
||||
return seen
|
||||
|
||||
|
||||
def _imported(path, nodes):
|
||||
"""The suite modules the statements `nodes` import with `from .x
|
||||
import` or `from . import x` (each name a module of the suite), at any
|
||||
depth (inside a function or a block too), in the order they are
|
||||
written."""
|
||||
here = os.path.dirname(path)
|
||||
found = sorted((n for top in nodes for n in ast.walk(top)
|
||||
if isinstance(n, ast.ImportFrom) and n.level == 1),
|
||||
key=lambda n: (n.lineno, n.col_offset))
|
||||
out = []
|
||||
for node in found:
|
||||
for name in ([node.module] if node.module else [a.name for a in node.names]):
|
||||
sib = os.path.join(here, name.replace(".", os.sep) + ".py")
|
||||
if os.path.isfile(sib) and sib not in out:
|
||||
out.append(sib)
|
||||
return out
|
||||
|
||||
|
||||
def _direct_siblings(path):
|
||||
"""The suite modules one module's shared text imports itself: at its
|
||||
top level, and inside its helpers and blocks. What a @test function
|
||||
imports is that test's own (_own_siblings)."""
|
||||
with _PARTS_LOCK:
|
||||
hit = _SIBLINGS.get(path)
|
||||
if hit is None:
|
||||
_text, tree = _parsed(path)
|
||||
tests = list(_test_defs(tree).values())
|
||||
shared = [n for n in (tree.body if tree is not None else []) if not any(n is t for t in tests)]
|
||||
hit = _SIBLINGS[path] = _imported(path, shared)
|
||||
return hit
|
||||
|
||||
|
||||
def _own_siblings(path, test_id):
|
||||
"""The suite modules one test's own function imports: part of that
|
||||
test's judge, and of no other test's."""
|
||||
_text, tree = _parsed(path)
|
||||
node = _test_defs(tree).get(test_id)
|
||||
return _imported(path, [node]) if node is not None else []
|
||||
|
||||
|
||||
def implementation_sha(path, test_id, judges=None):
|
||||
"""The implementation hash of one test (see Test.source_sha): the
|
||||
test's own text, the shared text of its module, the shared text of
|
||||
every sibling suite module the module's shared text or the test's
|
||||
own function imports (transitively), and the text of the shared
|
||||
judges. `judges` names files to take as the judges in place of
|
||||
SHARED_JUDGES (the unit tests edit their own)."""
|
||||
shared, own = module_parts(path)
|
||||
if test_id not in own:
|
||||
return source_file_sha(path)
|
||||
sibs = sibling_imports(path)
|
||||
for sib in _own_siblings(path, test_id):
|
||||
if sib not in sibs and sib != path:
|
||||
sibs.append(sib)
|
||||
sibling_imports(sib, sibs)
|
||||
helpers = [module_parts(p)[0] for p in sorted(sibs) if p != path]
|
||||
paths = shared_judge_paths() if judges is None else list(judges)
|
||||
judged = [module_parts(p)[0] for p in paths]
|
||||
return _manifest.sha256_text("%s:%s" % (":".join([shared] + helpers + judged), own[test_id]))
|
||||
|
||||
|
||||
def test(id, *, title, subsystem, kind="auto", hardware="api", mode=None, covers=(),
|
||||
requires=(), always=False, est_min=1, steps=(), description="", actions=(),
|
||||
precheck=None, hands=()):
|
||||
"""`mode` names the controller mode the test needs live when it starts
|
||||
("grbl" or "cloud"); the runner switches the machine there before the
|
||||
test and leaves it there, so a queue crosses modes only where a test
|
||||
asks it to. None means the test runs in whatever mode it finds (or
|
||||
manages the mode itself, as the cloud tests do through enter_cloud,
|
||||
which also waits for the service session).
|
||||
|
||||
`actions` names the machine actions (ACTIONS) the test performs
|
||||
through Context.act; an `auto` test declares none. `hands` names what
|
||||
the test asks of a person beyond those actions ("app" for a job in
|
||||
the Glowforge app, "scrap", "mark" for an eye on the result...): an
|
||||
operator test with none, whose actions a bench actuator covers, runs
|
||||
unattended when one is up. `precheck` is a callable returning a
|
||||
reason string when the machine cannot run the test as it is (None
|
||||
when it can)."""
|
||||
if not _ID_RX.match(id):
|
||||
raise ValueError("test id %r must look like subsystem.name" % id)
|
||||
if kind not in KINDS:
|
||||
raise ValueError("test %s: kind %r" % (id, kind))
|
||||
if hardware not in HARDWARE:
|
||||
raise ValueError("test %s: hardware %r" % (id, hardware))
|
||||
if mode is not None and mode not in MODES:
|
||||
raise ValueError("test %s: mode %r" % (id, mode))
|
||||
for c, g in covers:
|
||||
if c in _manifest.DEV_ONLY_COMPONENTS:
|
||||
raise ValueError("test %s: may not cover dev-only component %r" % (id, c))
|
||||
for a in actions:
|
||||
if a not in ACTIONS:
|
||||
raise ValueError("test %s: action %r" % (id, a))
|
||||
if actions and kind == "auto":
|
||||
raise ValueError("test %s: an auto test asks for no machine actions" % id)
|
||||
if hands and kind == "auto":
|
||||
raise ValueError("test %s: an auto test asks nothing of a person" % id)
|
||||
if precheck is not None and not callable(precheck):
|
||||
raise ValueError("test %s: precheck must be callable" % id)
|
||||
|
||||
def deco(fn):
|
||||
if id in REGISTRY:
|
||||
raise ValueError("duplicate test id %r" % id)
|
||||
REGISTRY[id] = Test(id, title, subsystem, kind, hardware, covers, requires,
|
||||
always, est_min, steps, description, fn, mode=mode,
|
||||
actions=actions, precheck=precheck, hands=hands)
|
||||
return fn
|
||||
return deco
|
||||
|
||||
|
||||
def all_tests(registry=None):
|
||||
"""Tests in registration order (the suite modules import in
|
||||
subsystem order, so this is the display order)."""
|
||||
return list((registry if registry is not None else REGISTRY).values())
|
||||
|
||||
|
||||
def get(id, registry=None):
|
||||
return (registry if registry is not None else REGISTRY).get(id)
|
||||
|
||||
|
||||
def order_by_requires(tests, selected):
|
||||
"""`selected` ids in an order that runs a prerequisite before the test
|
||||
that names it.
|
||||
|
||||
Registration order otherwise, so a run reads down the page. A
|
||||
prerequisite outside the selection places nothing: either it is
|
||||
already satisfied, or the run that needs it will be refused and
|
||||
recorded as skipped. validate() has ruled out cycles; the stack check
|
||||
only keeps a malformed registry from recursing forever.
|
||||
"""
|
||||
want = set(selected)
|
||||
by_id = {t.id: t for t in tests}
|
||||
out, placed = [], set()
|
||||
|
||||
def visit(tid, stack):
|
||||
if tid in placed or tid not in want or tid not in by_id or tid in stack:
|
||||
return
|
||||
stack.add(tid)
|
||||
for r in by_id[tid].requires:
|
||||
visit(r, stack)
|
||||
stack.discard(tid)
|
||||
placed.add(tid)
|
||||
out.append(tid)
|
||||
|
||||
for t in tests:
|
||||
visit(t.id, set())
|
||||
return out
|
||||
|
||||
|
||||
def validate(registry=None):
|
||||
"""Every `requires` names a known test and there are no cycles."""
|
||||
reg = registry if registry is not None else REGISTRY
|
||||
for t in reg.values():
|
||||
for r in t.requires:
|
||||
if r not in reg:
|
||||
raise ValueError("test %s requires unknown test %s" % (t.id, r))
|
||||
seen = {}
|
||||
|
||||
def visit(tid, stack):
|
||||
if tid in stack:
|
||||
raise ValueError("requires cycle: %s" % " -> ".join(stack + [tid]))
|
||||
if seen.get(tid):
|
||||
return
|
||||
for r in reg[tid].requires:
|
||||
visit(r, stack + [tid])
|
||||
seen[tid] = True
|
||||
for tid in reg:
|
||||
visit(tid, [])
|
||||
|
||||
|
||||
def catalog_hash(registry=None):
|
||||
"""Identity of the catalog's definitions (ids, kinds, coverage,
|
||||
requirements, core membership) - not of the implementations, which
|
||||
the per-test fingerprints carry."""
|
||||
defs = sorted((t.definition() for t in all_tests(registry)), key=lambda d: d["id"])
|
||||
return _manifest.sha256_text(_manifest.canonical(defs))
|
||||
|
||||
|
||||
def load_suite():
|
||||
"""Import the suite modules (each registers its tests) and validate."""
|
||||
from . import suite # noqa: F401 (registers on import)
|
||||
validate()
|
||||
return REGISTRY
|
||||
|
||||
|
||||
def suite_dir():
|
||||
return os.path.join(os.path.dirname(os.path.abspath(__file__)), "suite")
|
||||
@@ -1,103 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""Coverage lint: every source path in the manifest must be selected by
|
||||
some test's coverage globs or by a host-tested row (manifest.HOST_TESTED),
|
||||
except the allowlisted non-behavioral paths; every coverage glob and every
|
||||
host-tested glob must select at least one path of its component; and every
|
||||
host-tested row must name a host test that exists and a CI workflow that
|
||||
runs it.
|
||||
|
||||
Why a hard rule: under the domain model an uncovered file is worse than an
|
||||
untested one - a change there leaves every inherited PASS valid when it
|
||||
should have invalidated them. The lint is the floor (the file is
|
||||
fingerprinted by at least one test, or a named host test that CI runs
|
||||
answers for it); whether that test exercises the change stays with the
|
||||
change author. An entry that selects nothing is the same defect from the
|
||||
other side: the test's fingerprint ignores the file it named.
|
||||
|
||||
Whether a workflow runs its host test is read from the workflow's text at
|
||||
the pinned revision, in the sources scripts/manifest-from-tree.py fetched
|
||||
(--sources, default the checkout's .manifest-cache). Without them (on an
|
||||
image) that part is not checked, and the report says so.
|
||||
|
||||
python3 -m forgetest.coverage [--manifest PATH] [--sources DIR] [--enforce] [--json]
|
||||
|
||||
Exit 0 when nothing is uncovered (or when only reporting), 1 under
|
||||
--enforce with uncovered paths, 2 on a usage or load error.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
from . import catalog as _catalog
|
||||
from . import manifest as _manifest
|
||||
|
||||
# Non-behavioral paths need no acceptance coverage, and no fingerprint
|
||||
# carries them: the one list, in the manifest module.
|
||||
ALLOW = _manifest.NON_BEHAVIORAL
|
||||
|
||||
|
||||
def run(manifest, tests, allow=ALLOW):
|
||||
return _manifest.coverage_report(manifest, tests, allow, host=_manifest.HOST_TESTED)
|
||||
|
||||
|
||||
def empty(manifest, tests):
|
||||
return _manifest.empty_covers(manifest, tests)
|
||||
|
||||
|
||||
def host_problems(manifest, sources=_manifest.SOURCES):
|
||||
return _manifest.host_row_problems(manifest, host=_manifest.HOST_TESTED, sources=sources)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser(description="forgetest coverage lint")
|
||||
ap.add_argument("--manifest", default=None, help="manifest JSON (default: the running image's)")
|
||||
ap.add_argument("--sources", default=_manifest.SOURCES,
|
||||
help="the pinned sources manifest-from-tree.py fetched (default: the checkout's .manifest-cache)")
|
||||
ap.add_argument("--enforce", action="store_true", help="exit 1 when any path is uncovered")
|
||||
ap.add_argument("--json", action="store_true", help="machine-readable report")
|
||||
args = ap.parse_args(argv)
|
||||
try:
|
||||
manifest = _manifest.Manifest.load(args.manifest)
|
||||
except (OSError, ValueError) as e:
|
||||
print("cannot load manifest: %s" % e, file=sys.stderr)
|
||||
return 2
|
||||
registry = _catalog.load_suite()
|
||||
tests = _catalog.all_tests(registry)
|
||||
report = run(manifest, tests)
|
||||
total = sum(len(v) for v in report.values())
|
||||
hollow = empty(manifest, tests)
|
||||
sources = args.sources if args.sources and os.path.isdir(args.sources) else None
|
||||
rows = host_problems(manifest, sources)
|
||||
if args.json:
|
||||
print(json.dumps({"uncovered": report, "total": total, "tests": len(tests),
|
||||
"empty": [list(e) for e in hollow],
|
||||
"host_tested": [list(r) for r in rows],
|
||||
"workflows_read": sources is not None and not any("cannot be read" in r[3] for r in rows)},
|
||||
indent=1, sort_keys=True))
|
||||
else:
|
||||
if _manifest.HOST_TESTED and sources is None:
|
||||
print("host-tested: no sources, so whether a workflow runs each host test is not checked")
|
||||
for comp in sorted(report):
|
||||
print("%s: %d uncovered path(s)" % (comp, len(report[comp])))
|
||||
for p in report[comp]:
|
||||
print(" %s" % p)
|
||||
for tid, comp, pat in hollow:
|
||||
print("%s: covers (%s, %s) selects nothing" % (tid, comp, pat))
|
||||
for comp, pat, ref, why in rows:
|
||||
print("host-tested (%s, %s) by %s: %s" % (comp, pat, ref, why))
|
||||
print("coverage: %d uncovered path(s) across %d component(s), %d empty entr%s, "
|
||||
"%d host-tested row problem(s), %d tests"
|
||||
% (total, len(report), len(hollow), "y" if len(hollow) == 1 else "ies", len(rows),
|
||||
len(tests)))
|
||||
if args.enforce and (total or hollow or rows):
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,412 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The bench actuator (forgefixture): the box on the bench network that
|
||||
opens the lid loop, pulls the interlock loop and presses the button on
|
||||
request, so an operator test can run with nobody in the room.
|
||||
|
||||
Configured by a bench-local file, FIXTURE_CONFIG (default
|
||||
/data/forgetest/fixture.json, mode 0600, never in the repo):
|
||||
|
||||
{"hostname": "forgefixture", "key": "<the fixture's API key>",
|
||||
"ip": null, "channels": ["lid", "interlock", "button"],
|
||||
"arm_press": false}
|
||||
|
||||
`hostname` is resolved as <hostname>.local by the resolver here (the
|
||||
image carries no mDNS resolver; the fixture answers the query itself);
|
||||
`ip` overrides that. `channels` names what is wired. `arm_press` is the
|
||||
one policy switch: whether the fixture may press the button to arm the
|
||||
laser for a live test (a person's press by default).
|
||||
|
||||
The fixture does not read the machine: every action the runner asks of
|
||||
it is verified through the machine's own readings afterward, exactly as
|
||||
an operator's hand is (Context.act). Contract: fixture/README.md.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
FIXTURE_CONFIG = os.environ.get("FORGETEST_FIXTURE_CONFIG", "/data/forgetest/fixture.json")
|
||||
CHANNELS = ("lid", "interlock", "button")
|
||||
MDNS_GROUP = "224.0.0.251"
|
||||
MDNS_PORT = 5353
|
||||
RESOLVE_TTL_S = 300.0 # a resolved address is trusted this long
|
||||
BUTTON_GAP_S = 0.3 # the release between two presses: long enough for the controller to see it
|
||||
PULSE_WAIT_S = 1.5 # a pulse in progress ends within the firmware's 500 ms clamp
|
||||
|
||||
|
||||
class FixtureError(Exception):
|
||||
"""A request the fixture did not carry out as asked. `sent` is True
|
||||
when the failure came after the request went out (no reply, or a
|
||||
reply cut off), so the box may have acted on it; a refusal, a failed
|
||||
lookup and a failed connect leave it False."""
|
||||
|
||||
def __init__(self, *args, sent=False):
|
||||
super().__init__(*args)
|
||||
self.sent = sent
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- mDNS
|
||||
|
||||
def mdns_query(name):
|
||||
"""One mDNS A query for `name` (e.g. "forgefixture.local"), as the
|
||||
bytes on the wire: a standard DNS header with id 0, one question,
|
||||
class IN with the unicast-response bit set so the answer comes back
|
||||
to this socket rather than the group."""
|
||||
labels = b"".join(struct.pack("B", len(p)) + p.encode("ascii") for p in name.rstrip(".").split("."))
|
||||
header = struct.pack("!HHHHHH", 0, 0, 1, 0, 0, 0)
|
||||
return header + labels + b"\x00" + struct.pack("!HH", 1, 0x8001)
|
||||
|
||||
|
||||
def _read_name(data, off):
|
||||
"""A DNS name at `off`, compression pointers followed; (name, next
|
||||
offset)."""
|
||||
parts = []
|
||||
jumped = False
|
||||
end = off
|
||||
guard = 0
|
||||
while True:
|
||||
if off >= len(data):
|
||||
raise ValueError("truncated name")
|
||||
n = data[off]
|
||||
if n == 0:
|
||||
off += 1
|
||||
break
|
||||
if n & 0xC0 == 0xC0:
|
||||
ptr = struct.unpack("!H", data[off:off + 2])[0] & 0x3FFF
|
||||
if not jumped:
|
||||
end = off + 2
|
||||
jumped = True
|
||||
off = ptr
|
||||
guard += 1
|
||||
if guard > 32:
|
||||
raise ValueError("pointer loop")
|
||||
continue
|
||||
parts.append(data[off + 1:off + 1 + n].decode("ascii", "replace"))
|
||||
off += 1 + n
|
||||
if not jumped:
|
||||
end = off
|
||||
return ".".join(parts).lower(), end
|
||||
|
||||
|
||||
def mdns_answers(data, name):
|
||||
"""The IPv4 addresses in an mDNS response's A records for `name`
|
||||
(answers and additionals; a response is anything with QR set)."""
|
||||
name = name.rstrip(".").lower()
|
||||
out = []
|
||||
try:
|
||||
_id, flags, qd, an, ns, ar = struct.unpack("!HHHHHH", data[:12])
|
||||
if not flags & 0x8000:
|
||||
return out
|
||||
off = 12
|
||||
for _ in range(qd):
|
||||
_n, off = _read_name(data, off)
|
||||
off += 4
|
||||
for _ in range(an + ns + ar):
|
||||
rname, off = _read_name(data, off)
|
||||
rtype, rclass, _ttl, rdlen = struct.unpack("!HHIH", data[off:off + 10])
|
||||
off += 10
|
||||
rdata = data[off:off + rdlen]
|
||||
off += rdlen
|
||||
if rtype == 1 and rdlen == 4 and rname == name:
|
||||
out.append(socket.inet_ntoa(rdata))
|
||||
except (struct.error, ValueError):
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def _local_ipv4s():
|
||||
"""The interface addresses to send a multicast query from: the
|
||||
default first (None), then every IPv4 address this host answers to."""
|
||||
out = [None]
|
||||
try:
|
||||
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET, socket.SOCK_DGRAM):
|
||||
ip = info[4][0]
|
||||
if ip not in out and not ip.startswith("127."):
|
||||
out.append(ip)
|
||||
except OSError:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def resolve_mdns(hostname, timeout=2.0, tries=3):
|
||||
"""The IPv4 address of <hostname>.local, asked of the network
|
||||
directly; None when nothing answers."""
|
||||
name = hostname if hostname.endswith(".local") else hostname + ".local"
|
||||
query = mdns_query(name)
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
||||
try:
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 1)
|
||||
# On the mDNS port and in the group, so a responder that answers
|
||||
# the group rather than the asker is heard too; a port already
|
||||
# taken (a resolver daemon) means that daemon would have served
|
||||
# the name, so the ephemeral port and unicast replies will do.
|
||||
try:
|
||||
sock.bind(("", MDNS_PORT))
|
||||
except OSError:
|
||||
sock.bind(("", 0))
|
||||
try:
|
||||
sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP,
|
||||
socket.inet_aton(MDNS_GROUP) + socket.inet_aton("0.0.0.0"))
|
||||
except OSError:
|
||||
pass # no group membership: unicast replies still arrive
|
||||
for _ in range(tries):
|
||||
# The query goes out of every local IPv4 interface that will
|
||||
# take it (a host with a VPN or a VM adapter as its default
|
||||
# route would otherwise never reach the bench network).
|
||||
sent = False
|
||||
for ifaddr in _local_ipv4s():
|
||||
try:
|
||||
if ifaddr is not None:
|
||||
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(ifaddr))
|
||||
sock.sendto(query, (MDNS_GROUP, MDNS_PORT))
|
||||
sent = True
|
||||
except OSError:
|
||||
continue
|
||||
if not sent:
|
||||
return None
|
||||
deadline = time.time() + timeout
|
||||
while True:
|
||||
left = deadline - time.time()
|
||||
if left <= 0:
|
||||
break
|
||||
sock.settimeout(left)
|
||||
try:
|
||||
data, _peer = sock.recvfrom(2048)
|
||||
except socket.timeout:
|
||||
break
|
||||
except OSError:
|
||||
break
|
||||
for ip in mdns_answers(data, name):
|
||||
return ip
|
||||
return None
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
# -------------------------------------------------------------- client
|
||||
|
||||
def load_config(path=None):
|
||||
"""The bench's fixture config, or None when there is none (a bench
|
||||
without a fixture: the runner behaves exactly as before)."""
|
||||
path = path or FIXTURE_CONFIG
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
cfg = json.load(f)
|
||||
except OSError:
|
||||
return None
|
||||
except ValueError as e:
|
||||
raise FixtureError("%s: not JSON: %s" % (path, e))
|
||||
if not isinstance(cfg, dict) or not cfg.get("key"):
|
||||
raise FixtureError("%s: needs at least a key" % path)
|
||||
cfg.setdefault("hostname", "forgefixture")
|
||||
cfg.setdefault("ip", None)
|
||||
cfg.setdefault("port", 80)
|
||||
chans = cfg.get("channels")
|
||||
if chans is None:
|
||||
chans = list(CHANNELS)
|
||||
bad = [c for c in chans if c not in CHANNELS]
|
||||
if bad:
|
||||
raise FixtureError("%s: unknown channel(s) %s" % (path, ", ".join(bad)))
|
||||
cfg["channels"] = list(chans)
|
||||
cfg["arm_press"] = bool(cfg.get("arm_press", False))
|
||||
return cfg
|
||||
|
||||
|
||||
class Fixture:
|
||||
"""The actuator as the runner sees it: which channels it covers, an
|
||||
action per channel, a release, its state. Every request carries the
|
||||
key; the address comes from the config's ip or the mDNS lookup,
|
||||
re-resolved when a request fails to connect."""
|
||||
|
||||
def __init__(self, cfg, timeout=3.0, resolver=resolve_mdns):
|
||||
self.hostname = cfg["hostname"]
|
||||
self.ip_override = cfg.get("ip") or None
|
||||
self.port = int(cfg.get("port") or 80)
|
||||
self.key = cfg["key"]
|
||||
self.channels = tuple(cfg["channels"])
|
||||
self.arm_press = bool(cfg.get("arm_press", False))
|
||||
self.timeout = timeout
|
||||
self._resolver = resolver
|
||||
self._ip = self.ip_override
|
||||
self._resolved_at = time.time() if self.ip_override else 0.0
|
||||
self._lock = threading.Lock()
|
||||
self.last_state = None
|
||||
self._pulse_end = 0.0 # when this client's last press ends (its pulse_ms)
|
||||
|
||||
# -- address -------------------------------------------------------
|
||||
def address(self, refresh=False):
|
||||
with self._lock:
|
||||
if self.ip_override:
|
||||
return self.ip_override
|
||||
stale = time.time() - self._resolved_at > RESOLVE_TTL_S
|
||||
if self._ip is None or refresh or stale:
|
||||
ip = self._resolver(self.hostname)
|
||||
if ip is None:
|
||||
raise FixtureError("%s.local did not answer the mDNS lookup (set ip in the config "
|
||||
"to skip it)" % self.hostname)
|
||||
self._ip = ip
|
||||
self._resolved_at = time.time()
|
||||
return self._ip
|
||||
|
||||
def where(self):
|
||||
return "%s (%s)" % (self.hostname, self._ip or "unresolved")
|
||||
|
||||
# -- requests ------------------------------------------------------
|
||||
def _request(self, method, path, body=None, retry=True):
|
||||
ip = self.address()
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
host = ip if self.port == 80 else "%s:%d" % (ip, self.port)
|
||||
req = urllib.request.Request("http://%s%s" % (host, path), data=data, method=method,
|
||||
headers={"X-Fixture-Key": self.key, "Host": host,
|
||||
"Content-Type": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
||||
return resp.status, json.loads(resp.read().decode("utf-8", "replace") or "{}")
|
||||
except urllib.error.HTTPError as e:
|
||||
try:
|
||||
body = json.loads(e.read().decode("utf-8", "replace") or "{}")
|
||||
except ValueError:
|
||||
body = {}
|
||||
return e.code, body
|
||||
except (urllib.error.URLError, socket.timeout, OSError) as e:
|
||||
# urllib raises a failed connect or send as a URLError; a
|
||||
# timeout or a reset after the request went out comes bare, and
|
||||
# the box may have acted on the request
|
||||
sent = not isinstance(e, urllib.error.URLError)
|
||||
if retry and not self.ip_override:
|
||||
# the address may have moved: one fresh lookup, one retry,
|
||||
# and a failure of either still says the first one went out
|
||||
try:
|
||||
self.address(refresh=True)
|
||||
return self._request(method, path, body, retry=False)
|
||||
except FixtureError as again:
|
||||
again.sent = again.sent or sent
|
||||
raise
|
||||
raise FixtureError("fixture %s: %s %s: %s" % (self.where(), method, path, e), sent=sent)
|
||||
|
||||
def status(self):
|
||||
st, body = self._request("GET", "/")
|
||||
if st == 401:
|
||||
raise FixtureError("fixture %s refused the key" % self.where())
|
||||
if st != 200 or body.get("device") != "forgefixture":
|
||||
raise FixtureError("fixture %s: unexpected answer %s %s" % (self.where(), st, body))
|
||||
self.last_state = body
|
||||
return body
|
||||
|
||||
def covers(self, channel):
|
||||
"""The runner asks this per action. The button needs the jumper
|
||||
in, which the fixture reports; a channel it does not cover falls
|
||||
back to the operator."""
|
||||
if channel not in self.channels:
|
||||
return False
|
||||
if channel == "button":
|
||||
st = self.last_state
|
||||
return bool(st and st.get("button_enabled"))
|
||||
return True
|
||||
|
||||
def act(self, channel, state, ms=None):
|
||||
if channel == "button":
|
||||
if state != "press":
|
||||
raise FixtureError("the button is only ever pressed")
|
||||
st, body = self._press(ms)
|
||||
else:
|
||||
if state not in ("open", "close"):
|
||||
raise FixtureError("%s: unknown state %r" % (channel, state))
|
||||
st, body = self._request("POST", "/" + channel, {"state": state})
|
||||
if st != 200:
|
||||
raise FixtureError("fixture %s: %s %s -> %s %s" % (self.where(), channel, state, st,
|
||||
body.get("error") or body))
|
||||
self.last_state = body
|
||||
return body
|
||||
|
||||
def _press(self, ms=None):
|
||||
"""One press, spaced from the last one. `ms` is how long the box
|
||||
holds it: the box's own 200 ms when it is not said, and the box
|
||||
clamps a request into 20 to 500, so no press is ever held. Every
|
||||
reader of the button catches the default press (an edge, or a poll
|
||||
of 100 ms or less), so no check needs a longer one. The controller
|
||||
detects the button's rising edge, so a press must follow a release
|
||||
it has seen: the next press waits for the last pulse to end (the
|
||||
fixture's own pulse_ms) plus BUTTON_GAP_S. A 409 for a pulse in
|
||||
progress (a press this client did not time) is waited out once
|
||||
against the fixture's state, then retried."""
|
||||
wait = self._pulse_end + BUTTON_GAP_S - time.time()
|
||||
if wait > 0:
|
||||
time.sleep(wait)
|
||||
st, body = self._request("POST", "/button", {"ms": int(ms)} if ms else {})
|
||||
if st == 409 and "in progress" in str(body.get("error", "")):
|
||||
deadline = time.time() + PULSE_WAIT_S
|
||||
while time.time() < deadline:
|
||||
time.sleep(0.1)
|
||||
s, b = self._request("GET", "/")
|
||||
if s == 200 and not b.get("button_pulsing"):
|
||||
break
|
||||
time.sleep(BUTTON_GAP_S)
|
||||
st, body = self._request("POST", "/button", {"ms": int(ms)} if ms else {})
|
||||
if st == 200:
|
||||
self._pulse_end = time.time() + float(body.get("pulse_ms") or 500) / 1000.0
|
||||
return st, body
|
||||
|
||||
def release(self):
|
||||
st, body = self._request("POST", "/release", {})
|
||||
if st != 200:
|
||||
raise FixtureError("fixture %s: release -> %s %s" % (self.where(), st, body.get("error") or body))
|
||||
self.last_state = body
|
||||
return body
|
||||
|
||||
@staticmethod
|
||||
def energized(state):
|
||||
"""The channels a state report shows energized (a loop open, the
|
||||
button pressed)."""
|
||||
ch = (state or {}).get("channels") or {}
|
||||
return [c for c, s in ch.items() if s in ("open", "pressed")]
|
||||
|
||||
def summary(self):
|
||||
# The radio signal and the uptime go in the record: this actuator
|
||||
# is on wifi, and a run that lost it wants both numbers to say
|
||||
# whether the link faded or the box restarted. Without them a
|
||||
# drop is only ever "it was gone".
|
||||
st = self.last_state or {}
|
||||
wifi = st.get("wifi") or {}
|
||||
return {"hostname": self.hostname, "ip": self._ip, "channels": list(self.channels),
|
||||
"button_enabled": bool(st.get("button_enabled")), "arm_press": self.arm_press,
|
||||
"version": st.get("version"), "uptime_s": st.get("uptime_s"),
|
||||
"rssi": wifi.get("rssi")}
|
||||
|
||||
|
||||
def probe(log, path=None, resolver=resolve_mdns):
|
||||
"""The bench's fixture, up and answering, or None: no config means
|
||||
no fixture; a config whose fixture does not answer is logged and
|
||||
treated the same, so the run goes to the operator."""
|
||||
try:
|
||||
cfg = load_config(path)
|
||||
except FixtureError as e:
|
||||
log("fixture: %s - running without it" % e)
|
||||
return None
|
||||
if cfg is None:
|
||||
return None
|
||||
fx = Fixture(cfg, resolver=resolver)
|
||||
try:
|
||||
st = fx.status()
|
||||
except FixtureError as e:
|
||||
log("fixture: %s - running without it" % e)
|
||||
return None
|
||||
chans = ", ".join(fx.channels)
|
||||
log("fixture up: %s at %s, v%s, covers %s%s" % (
|
||||
fx.hostname, fx._ip, st.get("version"), chans,
|
||||
"" if st.get("button_enabled") or "button" not in fx.channels
|
||||
else " (button disabled: the enable jumper is out)"))
|
||||
return fx
|
||||
|
||||
|
||||
__all__ = ["Fixture", "FixtureError", "CHANNELS", "FIXTURE_CONFIG", "load_config", "probe",
|
||||
"resolve_mdns", "mdns_query", "mdns_answers"]
|
||||
@@ -1,684 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""Hardware and service access for the suite: forgectrl's HTTP API, the
|
||||
kernel module's sysfs, the init scripts, and the Grbl TCP port.
|
||||
|
||||
Everything is reachable through environment overrides so the suite can be
|
||||
exercised against a mock on a host:
|
||||
FORGECTRL_URL default http://127.0.0.1
|
||||
FORGECTRL_TOKEN_FILE default /data/forgefirm/panel.token
|
||||
GF_SYSFS_ROOT default /sys/glowforge/ (must end with '/')
|
||||
GRBL_HOST / GRBL_PORT default 127.0.0.1 / 23
|
||||
FORGETEST_INITD default /etc/init.d
|
||||
"""
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import select
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
|
||||
class HwError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
# ------------------------------------------------------------ forgectrl
|
||||
|
||||
# The supervisor's levers answer only when the switch is done: a mode
|
||||
# switch waits for the old controller to exit, the new one to start (a
|
||||
# pending liveness probe first) and its first job-state report (up to
|
||||
# 15 s; the emulator's comes with its start, a real client's with its
|
||||
# machine); a stop waits for the child to go. They get their own timeout,
|
||||
# above the daemon's own deadlines, so a slow but honest switch is never
|
||||
# read as a dead daemon.
|
||||
SLOW_PATHS = ("/mode", "/controller/start", "/controller/stop")
|
||||
SLOW_TIMEOUT_S = 120.0
|
||||
|
||||
|
||||
class Forgectrl:
|
||||
"""Thin client for the machine-services daemon."""
|
||||
|
||||
def __init__(self, base=None, token=None, timeout=10.0, slow_timeout=SLOW_TIMEOUT_S):
|
||||
self.base = (base or os.environ.get("FORGECTRL_URL") or "http://127.0.0.1").rstrip("/")
|
||||
self.timeout = timeout
|
||||
self.slow_timeout = slow_timeout
|
||||
self._token = token
|
||||
|
||||
def timeout_for(self, method, path):
|
||||
return self.slow_timeout if method == "POST" and path in SLOW_PATHS else self.timeout
|
||||
|
||||
@property
|
||||
def token(self):
|
||||
if self._token is None:
|
||||
path = os.environ.get("FORGECTRL_TOKEN_FILE") or "/data/forgefirm/panel.token"
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
self._token = f.read().strip()
|
||||
except OSError:
|
||||
self._token = ""
|
||||
return self._token
|
||||
|
||||
def host_header(self):
|
||||
return urllib.parse.urlsplit(self.base).netloc
|
||||
|
||||
def request(self, method, path, params=None, data=None, headers=None, auth=True, raw=False):
|
||||
"""Returns (status, body). body is parsed JSON when the response
|
||||
is JSON, else text (or bytes when raw=True). Never raises on an
|
||||
HTTP error status - the suite asserts on codes."""
|
||||
url = self.base + path
|
||||
if params:
|
||||
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(params)
|
||||
body = None
|
||||
hdrs = {"Host": self.host_header()}
|
||||
if headers:
|
||||
hdrs.update(headers)
|
||||
if data is not None:
|
||||
if isinstance(data, (dict, list)):
|
||||
body = urllib.parse.urlencode(data).encode()
|
||||
hdrs.setdefault("Content-Type", "application/x-www-form-urlencoded")
|
||||
elif isinstance(data, str):
|
||||
body = data.encode()
|
||||
else:
|
||||
body = data
|
||||
if auth and self.token:
|
||||
hdrs.setdefault("X-ForgeFIRM-Token", self.token)
|
||||
req = urllib.request.Request(url, data=body, method=method, headers=hdrs)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self.timeout_for(method, path)) as resp:
|
||||
status = resp.status
|
||||
content = resp.read()
|
||||
ctype = resp.headers.get("Content-Type", "")
|
||||
except urllib.error.HTTPError as e:
|
||||
status = e.code
|
||||
content = e.read()
|
||||
ctype = e.headers.get("Content-Type", "") if e.headers else ""
|
||||
except (urllib.error.URLError, socket.timeout, OSError) as e:
|
||||
raise HwError("forgectrl %s %s: %s" % (method, path, e))
|
||||
if raw:
|
||||
return status, content
|
||||
text = content.decode("utf-8", "replace")
|
||||
if "json" in ctype:
|
||||
try:
|
||||
return status, json.loads(text)
|
||||
except ValueError:
|
||||
pass
|
||||
return status, text
|
||||
|
||||
def get(self, path, **kw):
|
||||
return self.request("GET", path, **kw)
|
||||
|
||||
def post(self, path, **kw):
|
||||
return self.request("POST", path, **kw)
|
||||
|
||||
def status(self):
|
||||
st, body = self.get("/status")
|
||||
if st != 200 or not isinstance(body, dict):
|
||||
raise HwError("forgectrl /status -> %s" % st)
|
||||
return body
|
||||
|
||||
def settings(self):
|
||||
st, body = self.get("/settings")
|
||||
if st != 200 or not isinstance(body, dict):
|
||||
raise HwError("forgectrl /settings -> %s" % st)
|
||||
return body
|
||||
|
||||
def wait_idle(self, timeout=60.0, poll=0.5, abort=None):
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if abort and abort():
|
||||
raise HwError("aborted while waiting for idle")
|
||||
try:
|
||||
if self.status().get("state") == "idle":
|
||||
return True
|
||||
except HwError:
|
||||
pass
|
||||
time.sleep(poll)
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- sysfs
|
||||
|
||||
def sysfs_root():
|
||||
r = os.environ.get("GF_SYSFS_ROOT") or "/sys/glowforge/"
|
||||
return r if r.endswith("/") else r + "/"
|
||||
|
||||
|
||||
def sysfs_read(attr, default=None):
|
||||
try:
|
||||
with open(sysfs_root() + attr, "r") as f:
|
||||
return f.read().strip()
|
||||
except OSError:
|
||||
return default
|
||||
|
||||
|
||||
def sysfs_int(attr, default=None):
|
||||
v = sysfs_read(attr)
|
||||
if v is None or v == "":
|
||||
return default
|
||||
try:
|
||||
return int(v.split()[0], 0)
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def sysfs_write(attr, value):
|
||||
with open(sysfs_root() + attr, "w") as f:
|
||||
f.write(str(value))
|
||||
|
||||
|
||||
# ------------------------------------------------------ head accelerometer
|
||||
|
||||
# The head accelerometer (LIS2HH12 on i2c-3 at 0x1e): the supervisor's
|
||||
# motion-liveness witness, and the catalog's. Resolved by bus address,
|
||||
# never by iio index. The raw sysfs read is slow (~150 ms), which still
|
||||
# lands several samples in a one-second move; the verdict is peak-to-peak
|
||||
# on X or Y, against the thresholds forgectrl's probe established (a live
|
||||
# head reads 1800-2900 on its probe move, a wedged one <= 250).
|
||||
HEAD_ACCEL_I2C = "3-001e"
|
||||
ACCEL_P2P_MOVING = 800
|
||||
|
||||
|
||||
def head_accel_dir():
|
||||
"""The head accel's iio directory, or None. GF_IIO_ROOT overrides the
|
||||
/sys/bus/iio/devices root (host tests)."""
|
||||
root = os.environ.get("GF_IIO_ROOT") or "/sys/bus/iio/devices"
|
||||
try:
|
||||
names = sorted(os.listdir(root))
|
||||
except OSError:
|
||||
return None
|
||||
for n in names:
|
||||
d = os.path.join(root, n)
|
||||
try:
|
||||
target = os.readlink(d)
|
||||
except OSError:
|
||||
target = ""
|
||||
if HEAD_ACCEL_I2C in target or HEAD_ACCEL_I2C in n:
|
||||
return d
|
||||
# a plain directory (host fixture): its name carries the address
|
||||
try:
|
||||
with open(os.path.join(d, "name")) as f:
|
||||
if HEAD_ACCEL_I2C in f.read():
|
||||
return d
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def head_accel_read(d):
|
||||
"""(x, y) raw counts, or None."""
|
||||
out = []
|
||||
for axis in ("x", "y"):
|
||||
try:
|
||||
with open(os.path.join(d, "in_accel_%s_raw" % axis)) as f:
|
||||
out.append(int(f.read().strip()))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return tuple(out)
|
||||
|
||||
|
||||
class AccelSampler:
|
||||
"""Samples the head accelerometer in a thread for as long as it is
|
||||
running; `p2p(t0, t1)` is the peak-to-peak on X and Y over the samples
|
||||
in that window and how many there were. Used as a context manager
|
||||
around a motion the test wants the head to have made."""
|
||||
|
||||
def __init__(self, period=0.0):
|
||||
self.dir = head_accel_dir()
|
||||
self.period = period
|
||||
self.samples = [] # (t, x, y)
|
||||
self._stop = threading.Event()
|
||||
self._th = None
|
||||
self.errors = 0
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
return self.dir is not None
|
||||
|
||||
def __enter__(self):
|
||||
if self.dir is not None:
|
||||
self._th = threading.Thread(target=self._loop, daemon=True, name="forgetest-accel")
|
||||
self._th.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
self._stop.set()
|
||||
if self._th is not None:
|
||||
self._th.join(2.0)
|
||||
return False
|
||||
|
||||
def _loop(self):
|
||||
while not self._stop.is_set():
|
||||
v = head_accel_read(self.dir)
|
||||
if v is None:
|
||||
self.errors += 1
|
||||
else:
|
||||
self.samples.append((time.time(), v[0], v[1]))
|
||||
if self.period:
|
||||
self._stop.wait(self.period)
|
||||
|
||||
def p2p(self, t0, t1=None):
|
||||
"""(p2p_x, p2p_y, n) over [t0, t1]; (0, 0, 0) with no samples."""
|
||||
t1 = time.time() if t1 is None else t1
|
||||
xs = [x for t, x, _y in self.samples if t0 <= t <= t1]
|
||||
ys = [y for t, _x, y in self.samples if t0 <= t <= t1]
|
||||
if not xs:
|
||||
return 0, 0, 0
|
||||
return max(xs) - min(xs), max(ys) - min(ys), len(xs)
|
||||
|
||||
|
||||
# ----------------------------------------------------------- button LEDs
|
||||
|
||||
BUTTON_LEDS = ("button_led_1", "button_led_2", "button_led_3")
|
||||
|
||||
|
||||
def leds_root():
|
||||
r = os.environ.get("GF_LEDS_ROOT") or "/sys/class/leds/"
|
||||
return r if r.endswith("/") else r + "/"
|
||||
|
||||
|
||||
def button_leds():
|
||||
"""The three button LEDs' commanded levels (the smooth trigger's
|
||||
`target`, which the controller writes; `brightness` follows it with
|
||||
a fade and is read where no target exists), or None where unreadable."""
|
||||
out = []
|
||||
for name in BUTTON_LEDS:
|
||||
val = None
|
||||
for attr in ("target", "brightness"):
|
||||
try:
|
||||
with open(leds_root() + name + "/" + attr) as f:
|
||||
val = int(f.read().strip())
|
||||
break
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
out.append(val)
|
||||
return out
|
||||
|
||||
|
||||
def button_lit():
|
||||
"""True when any button LED is on, False when all three read 0, None
|
||||
when none is readable."""
|
||||
vals = [v for v in button_leds() if v is not None]
|
||||
if not vals:
|
||||
return None
|
||||
return any(v > 0 for v in vals)
|
||||
|
||||
|
||||
# ----------------------------------------------------------- the button
|
||||
|
||||
EV_SW = 0x05
|
||||
SW_BUTTON = 2 # the button's EV_SW code (bit 2 of the switch word)
|
||||
|
||||
|
||||
def switches_device():
|
||||
"""The machine's switches as forgectrl and the controller find them:
|
||||
GF_SWITCH_DEV, else the input device the device tree names
|
||||
"switches". None when there is none."""
|
||||
dev = os.environ.get("GF_SWITCH_DEV")
|
||||
if dev:
|
||||
return dev
|
||||
for d in sorted(glob.glob("/sys/class/input/event*")):
|
||||
try:
|
||||
with open(os.path.join(d, "device", "name")) as f:
|
||||
if f.read().strip() == "switches":
|
||||
return "/dev/input/" + os.path.basename(d)
|
||||
except OSError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
class ButtonEdges:
|
||||
"""The button as edges, from the switches' input events. A press is an
|
||||
event however short it is, so nothing has to be held long enough for a
|
||||
poll to land on it: an operator's press is never longer than a quarter
|
||||
second. Open it before the press is asked for. It reads without a
|
||||
grab, so forgectrl and the controller see the switches as always."""
|
||||
|
||||
EVENT = struct.Struct("llHHi") # struct input_event: time, type, code, value
|
||||
|
||||
def __init__(self):
|
||||
self.fd = None
|
||||
self.pending = b"" # events read and not yet looked at
|
||||
dev = switches_device()
|
||||
if dev:
|
||||
try:
|
||||
self.fd = os.open(dev, os.O_RDONLY | os.O_NONBLOCK | getattr(os, "O_CLOEXEC", 0))
|
||||
except OSError:
|
||||
self.fd = None
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
return self.fd is not None
|
||||
|
||||
def wait(self, value, timeout, checkpoint=None):
|
||||
"""The seconds until the button's next edge to `value` (1 pressed,
|
||||
0 released), or None at the timeout. checkpoint() is called about
|
||||
every 0.1 s (an abort raises out of it)."""
|
||||
size = self.EVENT.size
|
||||
t0 = time.time()
|
||||
while True:
|
||||
# The events already read come first: a short press arrives
|
||||
# with its release in one read, and the release is the next
|
||||
# wait's.
|
||||
while len(self.pending) >= size:
|
||||
_sec, _usec, typ, code, val = self.EVENT.unpack_from(self.pending, 0)
|
||||
self.pending = self.pending[size:]
|
||||
if typ == EV_SW and code == SW_BUTTON and val == value:
|
||||
return time.time() - t0
|
||||
if time.time() - t0 >= timeout:
|
||||
return None
|
||||
if checkpoint:
|
||||
checkpoint()
|
||||
ready, _, _ = select.select([self.fd], [], [], 0.1)
|
||||
if not ready:
|
||||
continue
|
||||
try:
|
||||
data = os.read(self.fd, size * 64)
|
||||
except BlockingIOError:
|
||||
continue
|
||||
if not data:
|
||||
time.sleep(0.02) # a FIFO with no writer (the host tests) reads empty
|
||||
continue
|
||||
self.pending += data
|
||||
|
||||
def close(self):
|
||||
if self.fd is not None:
|
||||
os.close(self.fd)
|
||||
self.fd = None
|
||||
|
||||
|
||||
# --------------------------------------------------------------- init.d
|
||||
|
||||
def initd(service, action, timeout=60):
|
||||
"""Run /etc/init.d/<service> <action>; returns (rc, output)."""
|
||||
base = os.environ.get("FORGETEST_INITD") or "/etc/init.d"
|
||||
script = os.path.join(base, service)
|
||||
try:
|
||||
p = subprocess.run([script, action], stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
timeout=timeout)
|
||||
except (OSError, subprocess.TimeoutExpired) as e:
|
||||
return 127, str(e)
|
||||
return p.returncode, p.stdout.decode("utf-8", "replace")
|
||||
|
||||
|
||||
# The extension host's side of the machine (forgefirm-sandbox, forgeext).
|
||||
EXT_POOL_FIRST, EXT_POOL_LAST = 800, 831
|
||||
EXT_ROOT = "/data/forgefirm/ext"
|
||||
EXT_TEST_PREFIX = "org.forgetest." # a package a test made; every other one is the operator's
|
||||
FORGEEXT = "/usr/bin/forgeext"
|
||||
|
||||
|
||||
def pool_pids():
|
||||
"""{pid: uid} of every process that runs under an extension pool account."""
|
||||
out = {}
|
||||
try:
|
||||
pids = [p for p in os.listdir("/proc") if p.isdigit()]
|
||||
except OSError:
|
||||
return out
|
||||
for pid in pids:
|
||||
try:
|
||||
with open("/proc/%s/status" % pid) as f:
|
||||
line = next((x for x in f if x.startswith("Uid:")), "")
|
||||
uid = int(line.split()[1]) # the real uid; a service has all four alike
|
||||
except (OSError, IndexError, ValueError):
|
||||
continue
|
||||
if EXT_POOL_FIRST <= uid <= EXT_POOL_LAST:
|
||||
out[int(pid)] = uid
|
||||
return out
|
||||
|
||||
|
||||
def ext_packages():
|
||||
"""The ids of the installed extension packages."""
|
||||
try:
|
||||
return sorted(d for d in os.listdir(EXT_ROOT + "/pkg") if os.path.isdir(os.path.join(EXT_ROOT, "pkg", d)))
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def pidof(comm):
|
||||
"""PIDs whose /proc/<pid>/comm equals comm (15-char kernel limit applies)."""
|
||||
out = []
|
||||
try:
|
||||
for pid in os.listdir("/proc"):
|
||||
if not pid.isdigit():
|
||||
continue
|
||||
try:
|
||||
with open("/proc/%s/comm" % pid) as f:
|
||||
if f.read().strip() == comm:
|
||||
out.append(int(pid))
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def pgrep_f(needle):
|
||||
"""PIDs whose /proc/<pid>/cmdline contains needle (a script run by an
|
||||
interpreter has the interpreter's comm, so pidof cannot see it)."""
|
||||
out = []
|
||||
try:
|
||||
for pid in os.listdir("/proc"):
|
||||
if not pid.isdigit():
|
||||
continue
|
||||
try:
|
||||
with open("/proc/%s/cmdline" % pid, "rb") as f:
|
||||
if needle.encode() in f.read():
|
||||
out.append(int(pid))
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def listens_on(pid, path):
|
||||
"""Process pid holds a listening UNIX socket bound at path: one of its
|
||||
own descriptors is that socket, so a socket file another process left
|
||||
behind does not count. Reads /proc (FORGETEST_PROC_ROOT for tests)."""
|
||||
if not pid:
|
||||
return False
|
||||
proc = os.environ.get("FORGETEST_PROC_ROOT") or "/proc"
|
||||
inodes = set()
|
||||
try:
|
||||
with open(os.path.join(proc, "net", "unix")) as f:
|
||||
next(f, None) # the header line
|
||||
for ln in f:
|
||||
# Num RefCount Protocol Flags Type St Inode Path
|
||||
parts = ln.split()
|
||||
try:
|
||||
if len(parts) >= 8 and parts[7] == path and int(parts[3], 16) & 0x10000:
|
||||
inodes.add(parts[6]) # __SO_ACCEPTCON: a listener
|
||||
except ValueError:
|
||||
continue
|
||||
except OSError:
|
||||
return False
|
||||
if not inodes:
|
||||
return False
|
||||
fd_dir = os.path.join(proc, str(pid), "fd")
|
||||
try:
|
||||
fds = os.listdir(fd_dir)
|
||||
except OSError:
|
||||
return False
|
||||
for fd in fds:
|
||||
try:
|
||||
link = os.readlink(os.path.join(fd_dir, fd))
|
||||
except OSError:
|
||||
continue
|
||||
if link.startswith("socket:[") and link[8:-1] in inodes:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def run(cmd, timeout=60):
|
||||
"""Run a command list; returns (rc, combined output)."""
|
||||
try:
|
||||
p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=timeout)
|
||||
except (OSError, subprocess.TimeoutExpired) as e:
|
||||
return 127, str(e)
|
||||
return p.returncode, p.stdout.decode("utf-8", "replace")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ grbl
|
||||
|
||||
def grbl_port_open(timeout=5):
|
||||
"""True when the Grbl port accepts a connection (closed again at once)."""
|
||||
try:
|
||||
s = socket.create_connection((os.environ.get("GRBL_HOST") or "127.0.0.1",
|
||||
int(os.environ.get("GRBL_PORT") or 23)), timeout=timeout)
|
||||
s.close()
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
class Grbl:
|
||||
"""Minimal Grbl-over-TCP client. The suite is the only client while a
|
||||
motion test runs; nothing here is used to poll status when a sender
|
||||
may be attached (position for display comes from forgectrl)."""
|
||||
|
||||
def __init__(self, host=None, port=None, timeout=5.0):
|
||||
self.host = host or os.environ.get("GRBL_HOST") or "127.0.0.1"
|
||||
self.port = int(port or os.environ.get("GRBL_PORT") or 23)
|
||||
self.timeout = timeout
|
||||
self.sock = None
|
||||
self.buf = b""
|
||||
|
||||
def __enter__(self):
|
||||
self.connect()
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
self.close()
|
||||
|
||||
def connect(self):
|
||||
self.sock = socket.create_connection((self.host, self.port), timeout=self.timeout)
|
||||
# Drain the greeting.
|
||||
time.sleep(0.3)
|
||||
self.drain()
|
||||
|
||||
def close(self):
|
||||
if self.sock:
|
||||
try:
|
||||
self.sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
self.sock = None
|
||||
|
||||
def _recv(self):
|
||||
"""Pull whatever is waiting into the buffer; never blocks long."""
|
||||
self.sock.settimeout(0.05)
|
||||
try:
|
||||
while True:
|
||||
d = self.sock.recv(4096)
|
||||
if not d:
|
||||
break
|
||||
self.buf += d
|
||||
except (socket.timeout, OSError):
|
||||
pass
|
||||
|
||||
def _take_report(self):
|
||||
"""Remove every complete <...> report from the buffer and return the
|
||||
last one, keeping the text around them. Status reports are the only
|
||||
thing consumed here: the driver's [MSG:] lines stay for drain()."""
|
||||
last = None
|
||||
while True:
|
||||
i = self.buf.find(b"<")
|
||||
j = self.buf.find(b">", i + 1) if i >= 0 else -1
|
||||
if i < 0 or j <= i:
|
||||
return last
|
||||
last = self.buf[i + 1:j].decode("utf-8", "replace")
|
||||
self.buf = self.buf[:i] + self.buf[j + 1:]
|
||||
|
||||
def drain(self):
|
||||
self._recv()
|
||||
out, self.buf = self.buf, b""
|
||||
return out.decode("utf-8", "replace")
|
||||
|
||||
def send_raw(self, data):
|
||||
self.sock.settimeout(self.timeout)
|
||||
self.sock.sendall(data)
|
||||
|
||||
def command(self, line, timeout=None):
|
||||
"""Send one line, return the response lines up to ok/error."""
|
||||
self.send_raw((line.strip() + "\n").encode())
|
||||
return self.wait_response(timeout or self.timeout)
|
||||
|
||||
def wait_response(self, timeout):
|
||||
deadline = time.time() + timeout
|
||||
lines = []
|
||||
self.sock.settimeout(0.2)
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
d = self.sock.recv(4096)
|
||||
if not d:
|
||||
break
|
||||
self.buf += d
|
||||
except socket.timeout:
|
||||
pass
|
||||
while b"\n" in self.buf:
|
||||
raw, self.buf = self.buf.split(b"\n", 1)
|
||||
s = raw.decode("utf-8", "replace").strip()
|
||||
if not s:
|
||||
continue
|
||||
lines.append(s)
|
||||
if s == "ok" or s.startswith("error:"):
|
||||
return lines
|
||||
return lines
|
||||
|
||||
def realtime(self, byte):
|
||||
self.send_raw(bytes([byte]))
|
||||
|
||||
def status_report(self):
|
||||
"""One '?' report, parsed: {'state': 'Idle', 'MPos': (x,y,z), ...}.
|
||||
The '?' is re-sent every 0.5 s until a report arrives: a soft
|
||||
reset (^X) flushes the controller's read buffer and eats a '?'
|
||||
that lands in it. Reports already buffered are stale and dropped -
|
||||
but only the reports: anything else the controller said is left in
|
||||
the buffer, so a test that polls for a state does not lose the
|
||||
[MSG:] line that explains it."""
|
||||
self._recv()
|
||||
self._take_report() # stale: predates this '?'
|
||||
self.send_raw(b"?")
|
||||
deadline = time.time() + self.timeout
|
||||
resend = time.time() + 0.5
|
||||
self.sock.settimeout(0.2)
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
d = self.sock.recv(4096)
|
||||
if d:
|
||||
self.buf += d
|
||||
except socket.timeout:
|
||||
pass
|
||||
if time.time() >= resend:
|
||||
self.send_raw(b"?")
|
||||
resend = time.time() + 0.5
|
||||
rep = self._take_report()
|
||||
if rep is not None:
|
||||
return parse_report(rep)
|
||||
raise HwError("no status report from grbl")
|
||||
|
||||
|
||||
def parse_report(rep):
|
||||
parts = rep.split("|")
|
||||
out = {"state": parts[0]}
|
||||
for p in parts[1:]:
|
||||
if ":" in p:
|
||||
k, v = p.split(":", 1)
|
||||
if k in ("MPos", "WPos", "WCO"):
|
||||
try:
|
||||
out[k] = tuple(float(x) for x in v.split(","))
|
||||
except ValueError:
|
||||
out[k] = v
|
||||
else:
|
||||
out[k] = v
|
||||
return out
|
||||
@@ -1,118 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The append-only result log (JSONL) and the export helpers.
|
||||
|
||||
One JSON object per line under the data directory (default
|
||||
/data/forgetest/results.jsonl, override FORGETEST_DATA). Records:
|
||||
|
||||
campaign {"id","manifest_sha","catalog_hash","image"} a campaign opened
|
||||
result {"campaign","test","result","fingerprint","manifest_sha",
|
||||
"image","duration_s","evidence","answers","log","message"}
|
||||
invalidate {"reason"} manual invalidate-all (full campaign required)
|
||||
reset {"reason"} explicit campaign reset
|
||||
export {"artifact_sha256","authorized","campaign"}
|
||||
|
||||
Every record carries "t" (type) and "ts" (UTC, ISO 8601, seconds).
|
||||
The file is only ever appended; a corrupt line is skipped, counted, and
|
||||
reported, never repaired in place.
|
||||
|
||||
Because the file only grows, `read` parses each line once and keeps what
|
||||
it parsed; a later call reads only the bytes appended since. A result
|
||||
record carries the whole run log, so the file reaches megabytes over a
|
||||
campaign, and the page asks for the state every second or two.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
DEFAULT_DATA_DIR = "/data/forgetest"
|
||||
|
||||
|
||||
def now_ts():
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
|
||||
|
||||
def data_dir():
|
||||
return os.environ.get("FORGETEST_DATA") or DEFAULT_DATA_DIR
|
||||
|
||||
|
||||
class Log:
|
||||
def __init__(self, path=None):
|
||||
self.path = path or os.path.join(data_dir(), "results.jsonl")
|
||||
self._lock = threading.Lock()
|
||||
self.corrupt = 0
|
||||
self._recs = [] # every record parsed so far, in file order
|
||||
self._offset = 0 # bytes of the file already consumed
|
||||
self._tail = b"" # bytes past the last newline: not a record yet
|
||||
|
||||
def append(self, rec):
|
||||
rec = dict(rec)
|
||||
rec.setdefault("ts", now_ts())
|
||||
line = json.dumps(rec, sort_keys=True, separators=(",", ":"))
|
||||
with self._lock:
|
||||
os.makedirs(os.path.dirname(self.path), exist_ok=True)
|
||||
with open(self.path, "a", encoding="utf-8") as f:
|
||||
f.write(line + "\n")
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
return rec
|
||||
|
||||
def _forget(self):
|
||||
"""Drop what was parsed: the next read starts from the top."""
|
||||
self._recs = []
|
||||
self._offset = 0
|
||||
self._tail = b""
|
||||
self.corrupt = 0
|
||||
|
||||
def _consume(self, chunk):
|
||||
"""Parse the whole lines in `chunk`, holding back a partial tail."""
|
||||
data = self._tail + chunk
|
||||
cut = data.rfind(b"\n")
|
||||
if cut < 0:
|
||||
self._tail = data
|
||||
return
|
||||
self._tail = data[cut + 1:]
|
||||
for line in data[:cut].split(b"\n"):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
rec = json.loads(line.decode("utf-8"))
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
self.corrupt += 1
|
||||
continue
|
||||
if isinstance(rec, dict) and "t" in rec:
|
||||
self._recs.append(rec)
|
||||
else:
|
||||
self.corrupt += 1
|
||||
|
||||
def read(self):
|
||||
"""Every record, in file order. Only the bytes appended since the
|
||||
last call are parsed; a file that shrank or was replaced is read
|
||||
again from the top."""
|
||||
with self._lock:
|
||||
try:
|
||||
size = os.path.getsize(self.path)
|
||||
except OSError:
|
||||
self._forget()
|
||||
return []
|
||||
if size < self._offset:
|
||||
self._forget()
|
||||
if size != self._offset:
|
||||
with open(self.path, "rb") as f:
|
||||
f.seek(self._offset)
|
||||
chunk = f.read()
|
||||
self._offset += len(chunk)
|
||||
self._consume(chunk)
|
||||
return list(self._recs)
|
||||
|
||||
def raw(self):
|
||||
if not os.path.exists(self.path):
|
||||
return ""
|
||||
with self._lock:
|
||||
with open(self.path, "r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,66 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The single page: acceptance tab + bench tab, assembled from ui/ into
|
||||
one self-contained response (no external assets). Bootstrap and the
|
||||
OpenGlow theme are inlined; theme.css and the vendor/ files are the same
|
||||
bytes forgectrl's panel carries, so the two pages look like one product
|
||||
and follow the same light and dark themes (scripts/check-ui-vendor.py
|
||||
holds them identical).
|
||||
|
||||
ui/ holds index.html, theme.css, page.css, help.js, app.js and vendor/.
|
||||
A plain file is read when it is there (a checkout); otherwise its .gz
|
||||
sibling is, which is what the recipe installs on the dev image: the
|
||||
rootfs is raw ext4, so bytes in the package are bytes on the image, and
|
||||
the page is inflated once at first request. The token placeholder is
|
||||
spliced in per render."""
|
||||
import gzip
|
||||
import os
|
||||
|
||||
UI_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui")
|
||||
# The files index.html links, in load order: one marker tag per file.
|
||||
CSS_FILES = ("vendor/bootstrap.min.css", "theme.css", "page.css")
|
||||
JS_FILES = ("vendor/bootstrap.bundle.min.js", "help.js", "app.js")
|
||||
TOKEN_MARK = "__TOKEN__"
|
||||
|
||||
|
||||
def read_ui(name, ui_dir=UI_DIR):
|
||||
"""A ui/ file as text: the plain file, or its gzipped install."""
|
||||
path = os.path.join(ui_dir, *name.split("/"))
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
return f.read().decode("utf-8")
|
||||
except FileNotFoundError:
|
||||
with gzip.open(path + ".gz", "rb") as f:
|
||||
return f.read().decode("utf-8")
|
||||
|
||||
|
||||
def assemble(ui_dir=UI_DIR):
|
||||
"""index.html with every linked stylesheet and script inlined in place
|
||||
of its tag. A missing marker is a broken page, not a silent one."""
|
||||
html = read_ui("index.html", ui_dir)
|
||||
for name in CSS_FILES:
|
||||
tag = '<link rel="stylesheet" href="%s" />' % name
|
||||
if tag not in html:
|
||||
raise ValueError("index.html lacks the marker %s" % tag)
|
||||
html = html.replace(tag, "<style>\n" + read_ui(name, ui_dir) + "</style>", 1)
|
||||
for name in JS_FILES:
|
||||
tag = '<script src="%s"></script>' % name
|
||||
if tag not in html:
|
||||
raise ValueError("index.html lacks the marker %s" % tag)
|
||||
html = html.replace(tag, "<script>\n" + read_ui(name, ui_dir) + "</script>", 1)
|
||||
if html.count(TOKEN_MARK) != 1:
|
||||
raise ValueError("the page must carry %s exactly once" % TOKEN_MARK)
|
||||
return html
|
||||
|
||||
|
||||
_html = None
|
||||
|
||||
|
||||
def render(token):
|
||||
global _html
|
||||
if _html is None:
|
||||
_html = assemble()
|
||||
return _html.replace(TOKEN_MARK, token)
|
||||
@@ -1,148 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""Pulse files for the offline cloud service: a job the machine will run
|
||||
without the service having cut it.
|
||||
|
||||
A pulse file is a header (`\\x80GF1`, a little-endian total length, then
|
||||
8-byte records of a 4-character tag and a uint32) followed by the step
|
||||
stream, one byte per tick of the header's STfr (the kernel feeder
|
||||
contract: bit 0 X step, bit 1 X dir, bit 2 Y step, bit 3 Y dir, bit 4
|
||||
LASER, bits 5-6 Z, bit 7 marks a power byte whose low 7 bits set the
|
||||
duty). The header here is the one a factory print of this machine type
|
||||
carries (134 tags, MCsn 0 = not locked to a serial, PDfm 0), so the
|
||||
client's header check, its job limits (the coolant window, the air
|
||||
assist floor), its fan duties and its stepper settings all come from the
|
||||
same place a service job's do. The stream is a square traced at a steady
|
||||
feed with the laser never commanded: a leading power byte of zero, no
|
||||
LASER bit anywhere. Nothing in a file from here can put energy into the
|
||||
tube; the arm and the latch are exercised, the beam is not.
|
||||
|
||||
A file longer than the kernel ring (32 MiB of ticks) is written gzip-
|
||||
compressed, whole file in the stream, as the service serves one: the
|
||||
client inflates it as the ring drains, and the gzip trailer gives it the
|
||||
job's length for its progress report.
|
||||
"""
|
||||
import gzip
|
||||
import struct
|
||||
|
||||
STEPS_PER_MM = 53.333 # x8 microstepping: the service's scale, which cloud mode runs at
|
||||
|
||||
# The header of a factory print for this machine type, as captured.
|
||||
PRINT_HEADER = {
|
||||
"AAfc": 3632880242, "AAid": 204, "AAin": 0, "AAix": 0, "AArd": 1023, "AArn": 0,
|
||||
"AArx": 64500, "AAsn": 0, "AAsx": 0, "AAwd": 1023, "AAwn": 0, "AAwx": 64500,
|
||||
"BDbs": 0, "BDpe": 0, "BDps": 0, "BTcx": 12800, "BTfc": 4294711296, "BTfo": 4294942296,
|
||||
"BTin": 2147483648, "BTix": 2147483647, "BTrn": 2147483648, "BTrx": 9440,
|
||||
"BTwn": 2147483648, "BTwx": 8928, "BTxb": 15, "CCrp": 10000, "CCup": 1, "CCwp": 5000,
|
||||
"CFrh": 1, "CMin": 10000, "CMix": 50000, "CMrn": 5000, "CMrx": 33000, "CMts": 0,
|
||||
"CMwn": 7000, "CMwx": 31000, "EFid": 0, "EFin": 0, "EFix": 0, "EFrd": 65535, "EFrn": 0,
|
||||
"EFrx": 0, "EFwd": 65535, "EFwn": 0, "EFwx": 0, "FTcx": 30000, "FTin": 2147483648,
|
||||
"FTix": 2147483647, "FTrn": 2147483648, "FTrx": 14575, "FTwn": 2147483648, "FTwx": 14075,
|
||||
"FTxb": 15, "HAai": 0, "HAar": 0, "HAsi": 2, "HAsr": 4, "HAxi": 0, "HAxr": 132, "HAyi": 0,
|
||||
"HAyr": 112, "HAzi": 0, "HAzr": 0, "HIix": 12, "HIrx": 1023, "HTcx": 12800, "HTfc": 51200,
|
||||
"HTfo": 5000, "HTin": 2147483648, "HTix": 2147483647, "HTrn": 2147483648, "HTrx": 9440,
|
||||
"HTwn": 2147483648, "HTwx": 8928, "HTxb": 15, "IFid": 0, "IFin": 0, "IFix": 0,
|
||||
"IFrd": 43278, "IFrn": 0, "IFrx": 0, "IFwd": 43278, "IFwn": 0, "IFwx": 0, "IRpd": 1000,
|
||||
"IRwb": 3, "IRwc": 688, "IRwx": 275, "IRxb": 3, "IRxc": 1022, "IRxx": 374, "IRyb": 3,
|
||||
"IRyc": 0, "IRyx": 0, "IRzb": 3, "IRzc": 0, "IRzx": 0, "ITcx": 19200, "ITfc": 153600,
|
||||
"ITfo": 0, "ITin": 2147483648, "ITix": 2147483647, "ITrn": 2147483648, "ITrx": 15840,
|
||||
"ITwn": 2147483648, "ITwx": 15328, "ITxb": 15, "LTcx": 12800, "LTfc": 51200, "LTfo": 5000,
|
||||
"LTin": 2147483648, "LTix": 2147483647, "LTrn": 2147483648, "LTrx": 9440,
|
||||
"LTwn": 2147483648, "LTwx": 8928, "LTxb": 15, "MCsn": 0, "PCid": 19795, "PDct": 5,
|
||||
"PDfm": 0, "PTmn": 0, "PTmx": 1023, "STfr": 10000, "TRuc": 0, "XSdm": 1, "XShc": 33,
|
||||
"XSmm": 8, "XSrc": 135, "YSdm": 1, "YShc": 5, "YSmm": 8, "YSrc": 22, "ZSmd": 0,
|
||||
}
|
||||
|
||||
X_STEP, X_DIR, Y_STEP, Y_DIR, LASER = 0x01, 0x02, 0x04, 0x08, 0x10
|
||||
POWER = 0x80
|
||||
|
||||
|
||||
def header_bytes(tags=None):
|
||||
"""The header record block, the template with `tags` laid over it."""
|
||||
h = dict(PRINT_HEADER)
|
||||
if tags:
|
||||
h.update(tags)
|
||||
recs = b"".join(k.encode("ascii") + struct.pack("<I", int(v)) for k, v in sorted(h.items()))
|
||||
total = 8 + len(recs)
|
||||
return bytes([POWER]) + b"GF1" + struct.pack("<I", total) + recs
|
||||
|
||||
|
||||
def leg(dx_steps, dy_steps, period):
|
||||
"""One straight leg: Bresenham over the major axis, one step every
|
||||
`period` ticks, laser never commanded. X: DIR set = negative; Y: DIR
|
||||
set = positive (the hardware convention)."""
|
||||
ax, ay = abs(dx_steps), abs(dy_steps)
|
||||
major = max(ax, ay)
|
||||
if major == 0:
|
||||
return b""
|
||||
xbit = X_STEP | (X_DIR if dx_steps < 0 else 0)
|
||||
ybit = Y_STEP | (Y_DIR if dy_steps > 0 else 0)
|
||||
out = bytearray()
|
||||
gap = bytes(period - 1)
|
||||
err = 0
|
||||
x_major = ax >= ay
|
||||
minor = ay if x_major else ax
|
||||
for _ in range(major):
|
||||
b = xbit if x_major else ybit
|
||||
err += minor
|
||||
if err >= major:
|
||||
err -= major
|
||||
b |= ybit if x_major else xbit
|
||||
out.append(b)
|
||||
out += gap
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def square_stream(side_mm=40.0, feed_mm_min=600.0, step_hz=10000, repeats=1):
|
||||
"""A closed square traced `repeats` times at a steady feed: +X, +Y,
|
||||
-X, -Y, the head back where it started. Leads with a power byte of
|
||||
zero, as the feeder contract wants before any laser-on byte (there
|
||||
is none)."""
|
||||
steps = int(round(side_mm * STEPS_PER_MM))
|
||||
period = max(1, int(round(step_hz / (feed_mm_min / 60.0 * STEPS_PER_MM))))
|
||||
one = (leg(steps, 0, period) + leg(0, steps, period)
|
||||
+ leg(-steps, 0, period) + leg(0, -steps, period))
|
||||
return bytes([POWER]) + bytes(period) + one * repeats
|
||||
|
||||
|
||||
def seconds_of(stream, step_hz=10000):
|
||||
return len(stream) / float(step_hz)
|
||||
|
||||
|
||||
def write_job(path, side_mm=40.0, feed_mm_min=600.0, seconds=None, step_hz=10000,
|
||||
compress=None, tags=None):
|
||||
"""Write a job to `path`: a square of `side_mm` at `feed_mm_min`,
|
||||
repeated until the stream is at least `seconds` long (one square if
|
||||
None). Compressed when `compress` is true, or, left None, when the
|
||||
stream is longer than the kernel ring (32 MiB of ticks). Returns
|
||||
(payload bytes, seconds, compressed)."""
|
||||
one = square_stream(side_mm, feed_mm_min, step_hz, 1)
|
||||
repeats = 1
|
||||
if seconds:
|
||||
per = seconds_of(one, step_hz)
|
||||
repeats = max(1, int(seconds / per) + (1 if seconds % per else 0))
|
||||
body = square_stream(side_mm, feed_mm_min, step_hz, repeats)
|
||||
head = header_bytes(dict(tags or {}, STfr=step_hz))
|
||||
if compress is None:
|
||||
compress = len(body) > 32 * 1024 * 1024
|
||||
data = head + body
|
||||
if compress:
|
||||
data = gzip.compress(data, compresslevel=6)
|
||||
with open(path, "wb") as f:
|
||||
f.write(data)
|
||||
return len(body), seconds_of(body, step_hz), compress
|
||||
|
||||
|
||||
def parse(data):
|
||||
"""(header dict, payload) of a raw or gzip-compressed pulse file."""
|
||||
if data[:2] == b"\x1f\x8b":
|
||||
data = gzip.decompress(data)
|
||||
if data[1:4] != b"GF1":
|
||||
raise ValueError("not a GF1 pulse file")
|
||||
total = struct.unpack_from("<I", data, 4)[0]
|
||||
tags = {data[p:p + 4].decode("ascii"): struct.unpack_from("<I", data, p + 4)[0]
|
||||
for p in range(8, total - 7, 8)}
|
||||
return tags, data[total:]
|
||||
@@ -1,135 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""Redaction for the release artifact.
|
||||
|
||||
The artifact is committed to a public repository, so it carries no
|
||||
identity of the machine that ran the campaign. This applies the same two
|
||||
layers forgectrl's log export applies (forgectrl/src/sanitize.c), in the
|
||||
same order, to every value the exporter writes:
|
||||
|
||||
1. Known keys: a value held under a key that names identifying data -
|
||||
the serial, the machine id, the hostname, the wireless credentials,
|
||||
a token - is replaced whole. Naming the key is far more reliable
|
||||
than guessing the shape of what is under it.
|
||||
2. Pattern classes over every remaining string: MAC addresses, IPv4 and
|
||||
IPv6 addresses (loopback and unspecified kept), e-mail addresses,
|
||||
bearer and basic credentials, JWTs, and long hex blobs. Each distinct
|
||||
value in a class keeps a stable number for the life of one export, so
|
||||
"<IP-2>" is the same host wherever it appears and the artifact stays
|
||||
readable as evidence.
|
||||
|
||||
Integrity values are never touched: the self-hash, the manifest and
|
||||
catalog hashes, each test's source hash and fingerprint. Those are what
|
||||
the gate recomputes, and a long hex blob is exactly what they look like.
|
||||
|
||||
Over-redaction is accepted where the alternative is a leak.
|
||||
"""
|
||||
import re
|
||||
|
||||
# Layer 1: the value under one of these keys is replaced whole.
|
||||
REDACT_KEYS = frozenset((
|
||||
"gf_serial", "machine_id", "serial", "serial_number",
|
||||
"hostname", "hostname_file", "host",
|
||||
"ssid", "psk", "passphrase", "password", "passwd",
|
||||
"token", "panel_token", "secret", "api_key", "key",
|
||||
"mac", "macaddr", "mac_suffix", "wlan0", "eth0",
|
||||
"gf_username", "gf_password", "username", "user", "email",
|
||||
))
|
||||
|
||||
# Never touched, by key: the gate recomputes these and they look like
|
||||
# exactly what layer 2 hunts for.
|
||||
KEEP_KEYS = frozenset((
|
||||
"sha256", "manifest_sha", "identity_sha", "catalog_hash",
|
||||
"source_sha", "fingerprint", "content_sha", "srcrev",
|
||||
))
|
||||
|
||||
_KEEP_IP = frozenset(("0.0.0.0", "255.255.255.255"))
|
||||
|
||||
_CLASSES = (
|
||||
("SECRET", re.compile(r"(?i)\b(?:bearer|basic)\s+[A-Za-z0-9._~+/=-]{8,}")),
|
||||
("JWT", re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}")),
|
||||
("EMAIL", re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")),
|
||||
("MAC", re.compile(r"\b(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}\b")),
|
||||
# Three or more colons, or a "::" run: two colons is a clock time, and
|
||||
# tagging every timestamp would make the evidence unreadable.
|
||||
("IP6", re.compile(
|
||||
r"(?:[0-9A-Fa-f]{1,4}:){3,7}[0-9A-Fa-f]{1,4}"
|
||||
r"|[0-9A-Fa-f]{0,4}(?::[0-9A-Fa-f]{1,4})*::(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?")),
|
||||
("IP", re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")),
|
||||
("HEX", re.compile(r"\b[0-9A-Fa-f]{32,}\b")),
|
||||
)
|
||||
|
||||
|
||||
class Redactor:
|
||||
"""Stable per-export numbering, one instance per artifact."""
|
||||
|
||||
# A known value shorter than this is too generic to replace blind.
|
||||
MIN_KNOWN = 4
|
||||
|
||||
def __init__(self):
|
||||
self._n = {}
|
||||
self._known = {}
|
||||
|
||||
def learn(self, obj, key=None):
|
||||
"""Collect the values held under the layer-1 keys, so they are
|
||||
replaced wherever else they appear - a hostname in a log line is
|
||||
the same leak as a hostname in its own field."""
|
||||
if key is not None and key in KEEP_KEYS:
|
||||
return
|
||||
if isinstance(obj, dict):
|
||||
for k, v in obj.items():
|
||||
self.learn(v, k)
|
||||
elif isinstance(obj, list):
|
||||
for v in obj:
|
||||
self.learn(v)
|
||||
elif key in REDACT_KEYS and isinstance(obj, str) and len(obj) >= self.MIN_KNOWN:
|
||||
if obj not in self._known:
|
||||
self._known[obj] = self._tag("REDACTED", "%s=%s" % (key, obj))
|
||||
|
||||
def _tag(self, cls, value):
|
||||
seen = self._n.setdefault(cls, {})
|
||||
if value not in seen:
|
||||
seen[value] = len(seen) + 1
|
||||
return "<%s-%d>" % (cls, seen[value])
|
||||
|
||||
def text(self, s):
|
||||
for known in sorted(self._known, key=len, reverse=True):
|
||||
if known in s:
|
||||
s = s.replace(known, self._known[known])
|
||||
for cls, rx in _CLASSES:
|
||||
def sub(m, cls=cls):
|
||||
v = m.group(0)
|
||||
if cls == "IP" and (v.startswith("127.") or v in _KEEP_IP):
|
||||
return v
|
||||
if cls == "IP6" and v in ("::", "::1"):
|
||||
return v
|
||||
return self._tag(cls, v)
|
||||
s = rx.sub(sub, s)
|
||||
return s
|
||||
|
||||
def value(self, v, key=None):
|
||||
if key is not None and key in KEEP_KEYS:
|
||||
return v
|
||||
if key is not None and key in REDACT_KEYS and not isinstance(v, (dict, list)):
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, str) and v in self._known:
|
||||
return self._known[v]
|
||||
return self._tag("REDACTED", "%s=%s" % (key, v))
|
||||
if isinstance(v, dict):
|
||||
return {k: self.value(x, k) for k, x in v.items()}
|
||||
if isinstance(v, list):
|
||||
return [self.value(x) for x in v]
|
||||
if isinstance(v, str):
|
||||
return self.text(v)
|
||||
return v
|
||||
|
||||
|
||||
def scrub(obj):
|
||||
"""Redact a value tree with one export's numbering."""
|
||||
r = Redactor()
|
||||
r.learn(obj)
|
||||
return r.value(obj)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,347 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""HTTP: the page, the JSON API, and the access rules.
|
||||
|
||||
Access follows forgectrl's panel (auth.c): the Host header must be an
|
||||
address literal or localhost (no DNS names - the rebinding vehicle), a
|
||||
cross-site Sec-Fetch-Site is refused, an Origin must itself be a literal,
|
||||
and every state-changing call needs the bearer token that is generated on
|
||||
first start, stored 0600 under the data directory, and embedded in the
|
||||
page. Read-only calls need the origin checks only.
|
||||
|
||||
Routes
|
||||
GET / the page
|
||||
GET /state campaign + per-test state + running run.
|
||||
Carries an ETag; If-None-Match on an
|
||||
unchanged state gets a 304, which is what an
|
||||
idle page polls for.
|
||||
GET /catalog test definitions (title, steps, covers...)
|
||||
GET /bench bench tool listing
|
||||
GET /result?test&ts one full result record (log, evidence)
|
||||
GET /log the raw JSONL
|
||||
GET /journal the daemon's journal (daemon.log tail)
|
||||
GET /export/acceptance.json | .md the last export
|
||||
POST /start {test, ack_live, ignore_requires} start an acceptance test
|
||||
POST /batch {group, ack_live, ignore_requires} run everything a queue
|
||||
still owes, in prerequisite order
|
||||
POST /batch/stop cancel what is still queued
|
||||
POST /bench/start {tool, args, ack_live}
|
||||
POST /answer {prompt_id, value}
|
||||
POST /abort
|
||||
POST /invalidate {reason}
|
||||
POST /reset {reason}
|
||||
POST /export build + save the artifact, returns it
|
||||
"""
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import sys
|
||||
import urllib.parse
|
||||
import socket
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
from . import artifact as _artifact
|
||||
from . import page as _page
|
||||
from .log import data_dir
|
||||
|
||||
JOURNAL_TAIL_BYTES = 256 << 10
|
||||
|
||||
|
||||
def journal_tail(path=None, max_bytes=JOURNAL_TAIL_BYTES):
|
||||
"""The last max_bytes of the daemon's journal (daemon.log, written by
|
||||
the init script from the daemon's stderr), or a note that there is
|
||||
none yet."""
|
||||
path = path or os.path.join(data_dir(), "daemon.log")
|
||||
try:
|
||||
size = os.path.getsize(path)
|
||||
with open(path, "rb") as f:
|
||||
if size > max_bytes:
|
||||
f.seek(size - max_bytes)
|
||||
f.readline()
|
||||
return f.read()
|
||||
except OSError:
|
||||
return b"(no journal yet: %s)\n" % path.encode()
|
||||
|
||||
TOKEN_HEX = 32
|
||||
_LITERAL_RX = re.compile(r"^[0-9.]+$")
|
||||
|
||||
|
||||
def load_token(path=None):
|
||||
path = path or os.path.join(data_dir(), "token")
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
tok = f.read().strip()
|
||||
if len(tok) == TOKEN_HEX and all(c in "0123456789abcdef" for c in tok):
|
||||
return tok
|
||||
except OSError:
|
||||
pass
|
||||
tok = secrets.token_hex(TOKEN_HEX // 2)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
f.write(tok + "\n")
|
||||
return tok
|
||||
|
||||
|
||||
def host_is_literal(h):
|
||||
if not h:
|
||||
return False
|
||||
if h.startswith("["):
|
||||
return True
|
||||
host = h.split(":", 1)[0]
|
||||
if host == "localhost":
|
||||
return True
|
||||
return bool(_LITERAL_RX.match(host))
|
||||
|
||||
|
||||
def origin_ok(headers):
|
||||
if not host_is_literal(headers.get("Host")):
|
||||
return False
|
||||
sfs = headers.get("Sec-Fetch-Site")
|
||||
if sfs and sfs not in ("same-origin", "none"):
|
||||
return False
|
||||
origin = headers.get("Origin")
|
||||
if origin and origin != "null":
|
||||
p = urllib.parse.urlsplit(origin)
|
||||
if not host_is_literal(p.netloc):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class App:
|
||||
"""What the handler needs: the runner, the token, the export dir."""
|
||||
|
||||
def __init__(self, runner, token, export_dir=None):
|
||||
self.runner = runner
|
||||
self.token = token
|
||||
self.export_dir = export_dir or os.path.join(data_dir(), "export")
|
||||
|
||||
def token_ok(self, given):
|
||||
return bool(given) and hmac.compare_digest(given, self.token)
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "forgetest"
|
||||
# Keep-alive: the page polls, so a handshake and a fresh thread per
|
||||
# request is pure overhead. Idle connections are dropped after the
|
||||
# timeout rather than holding a thread forever.
|
||||
protocol_version = "HTTP/1.1"
|
||||
timeout = 30
|
||||
app = None # set on the server class
|
||||
|
||||
# -- plumbing ---------------------------------------------------------
|
||||
def log_message(self, fmt, *args):
|
||||
if os.environ.get("FORGETEST_HTTP_LOG"):
|
||||
sys.stderr.write("forgetest: %s - %s\n" % (self.address_string(), fmt % args))
|
||||
|
||||
def _send(self, status, body, ctype="application/json", extra=None):
|
||||
if isinstance(body, (dict, list)):
|
||||
body = json.dumps(body, sort_keys=True).encode("utf-8")
|
||||
elif isinstance(body, str):
|
||||
body = body.encode("utf-8")
|
||||
self.responded = True
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", ctype + ("; charset=utf-8" if ctype.startswith("text/") else ""))
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
for k, v in (extra or {}).items():
|
||||
self.send_header(k, v)
|
||||
self.end_headers()
|
||||
if self.command != "HEAD":
|
||||
self.wfile.write(body)
|
||||
|
||||
def _send_304(self, etag):
|
||||
self.responded = True
|
||||
self.send_response(304)
|
||||
self.send_header("ETag", etag)
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
|
||||
def _deny(self, status, msg):
|
||||
# One response per request: on a kept-alive connection a second
|
||||
# one would desynchronize the stream.
|
||||
if getattr(self, "responded", False):
|
||||
return
|
||||
# A refused POST still has its body in the socket. Leaving it
|
||||
# there would make the next read on this connection take the
|
||||
# body for a request line, so the connection ends here.
|
||||
# send_header sets close_connection from this.
|
||||
extra = None if getattr(self, "body_read", True) else {"Connection": "close"}
|
||||
self._send(status, {"error": msg}, extra=extra)
|
||||
|
||||
def _read_ok(self):
|
||||
if not origin_ok(self.headers):
|
||||
self._deny(403, "request origin refused")
|
||||
return False
|
||||
return True
|
||||
|
||||
def _write_ok(self, query):
|
||||
if not origin_ok(self.headers):
|
||||
self._deny(403, "request origin refused")
|
||||
return False
|
||||
tok = self.headers.get("X-ForgeFIRM-Token") or (query.get("token") or [None])[0]
|
||||
if not self.app.token_ok(tok):
|
||||
self._deny(403, "authentication required")
|
||||
return False
|
||||
return True
|
||||
|
||||
def _body_json(self):
|
||||
n = int(self.headers.get("Content-Length") or 0)
|
||||
if n <= 0:
|
||||
self.body_read = True
|
||||
return {}
|
||||
if n > 1 << 20:
|
||||
raise ValueError("body too large")
|
||||
raw = self.rfile.read(n)
|
||||
self.body_read = True
|
||||
ctype = self.headers.get("Content-Type", "")
|
||||
if "json" in ctype:
|
||||
data = json.loads(raw.decode("utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
# form-encoded fallback
|
||||
return {k: v[0] for k, v in urllib.parse.parse_qs(raw.decode("utf-8")).items()}
|
||||
|
||||
# -- GET ---------------------------------------------------------------
|
||||
def do_HEAD(self):
|
||||
self.do_GET()
|
||||
|
||||
def do_GET(self):
|
||||
url = urllib.parse.urlsplit(self.path)
|
||||
path = url.path
|
||||
query = urllib.parse.parse_qs(url.query)
|
||||
r = self.app.runner
|
||||
self.responded = False
|
||||
if not self._read_ok():
|
||||
return
|
||||
try:
|
||||
if path == "/":
|
||||
self._send(200, _page.render(self.app.token), "text/html")
|
||||
elif path == "/state":
|
||||
# Conditional: an idle page polls an unchanged state, and
|
||||
# a 304 spares both ends the payload and the re-render.
|
||||
state, _ = r.state()
|
||||
body = json.dumps(state, sort_keys=True).encode("utf-8")
|
||||
etag = '"%s"' % hashlib.sha256(body).hexdigest()[:32]
|
||||
if self.headers.get("If-None-Match") == etag:
|
||||
self._send_304(etag)
|
||||
else:
|
||||
self._send(200, body, extra={"ETag": etag})
|
||||
elif path == "/catalog":
|
||||
self._send(200, {"tests": [t.describe() for t in r.tests()],
|
||||
"catalog_hash": r.catalog_hash})
|
||||
elif path == "/bench":
|
||||
b = r.bench
|
||||
self._send(200, {"tools": b.listing() if b else [], "tool_dir": b.tool_dir() if b else None})
|
||||
elif path == "/result":
|
||||
test = (query.get("test") or [""])[0]
|
||||
ts = (query.get("ts") or [""])[0]
|
||||
rec = None
|
||||
for x in reversed(r.log.read()):
|
||||
if x.get("t") == "result" and x.get("test") == test and (not ts or x.get("ts") == ts):
|
||||
rec = x
|
||||
break
|
||||
if rec is None:
|
||||
self._deny(404, "no such result")
|
||||
else:
|
||||
self._send(200, rec)
|
||||
elif path == "/log":
|
||||
self._send(200, r.log.raw(), "text/plain")
|
||||
elif path == "/journal":
|
||||
self._send(200, journal_tail(), "text/plain")
|
||||
elif path in ("/export/acceptance.json", "/export/acceptance.md"):
|
||||
fn = os.path.join(self.app.export_dir, os.path.basename(path))
|
||||
if not os.path.exists(fn):
|
||||
self._deny(404, "nothing exported yet")
|
||||
return
|
||||
with open(fn, "rb") as f:
|
||||
data = f.read()
|
||||
ctype = "application/json" if path.endswith(".json") else "text/markdown"
|
||||
self._send(200, data, ctype, {"Content-Disposition": "attachment; filename=%s"
|
||||
% os.path.basename(path)})
|
||||
else:
|
||||
self._deny(404, "not found")
|
||||
except Exception as e: # noqa: BLE001
|
||||
self._deny(500, "%s: %s" % (type(e).__name__, e))
|
||||
|
||||
# -- POST -----------------------------------------------------------------
|
||||
def do_POST(self):
|
||||
url = urllib.parse.urlsplit(self.path)
|
||||
path = url.path
|
||||
query = urllib.parse.parse_qs(url.query)
|
||||
r = self.app.runner
|
||||
self.responded = False
|
||||
self.body_read = False
|
||||
if not self._write_ok(query):
|
||||
return
|
||||
try:
|
||||
body = self._body_json()
|
||||
except ValueError as e:
|
||||
self._deny(400, "bad body: %s" % e)
|
||||
return
|
||||
try:
|
||||
if path == "/start":
|
||||
ok, msg = r.start_test(str(body.get("test", "")), ack_live=bool(body.get("ack_live")),
|
||||
ignore_requires=bool(body.get("ignore_requires")))
|
||||
self._send(200 if ok else 409, {"ok": ok, "message": msg})
|
||||
elif path == "/batch":
|
||||
ok, msg, order = r.start_batch(str(body.get("group", "")),
|
||||
ack_live=bool(body.get("ack_live")),
|
||||
ignore_requires=bool(body.get("ignore_requires")))
|
||||
self._send(200 if ok else 409, {"ok": ok, "message": msg, "order": order})
|
||||
elif path == "/batch/stop":
|
||||
ok, msg = r.stop_batch()
|
||||
self._send(200 if ok else 409, {"ok": ok, "message": msg})
|
||||
elif path == "/bench/start":
|
||||
args = body.get("args") or {}
|
||||
if not isinstance(args, dict):
|
||||
self._deny(400, "args must be an object")
|
||||
return
|
||||
ok, msg = r.start_bench(str(body.get("tool", "")), args, ack_live=bool(body.get("ack_live")))
|
||||
self._send(200 if ok else 409, {"ok": ok, "message": msg})
|
||||
elif path == "/answer":
|
||||
ok, msg = r.answer(str(body.get("prompt_id", "")), str(body.get("value", "")))
|
||||
self._send(200 if ok else 409, {"ok": ok, "message": msg})
|
||||
elif path == "/abort":
|
||||
ok, msg = r.abort()
|
||||
self._send(200 if ok else 409, {"ok": ok, "message": msg})
|
||||
elif path == "/invalidate":
|
||||
ok, msg = r.invalidate(str(body.get("reason", "")))
|
||||
self._send(200 if ok else 400, {"ok": ok, "message": msg})
|
||||
elif path == "/reset":
|
||||
ok, msg = r.reset(str(body.get("reason", "")))
|
||||
self._send(200 if ok else 409, {"ok": ok, "message": msg})
|
||||
elif path == "/export":
|
||||
art = r.export()
|
||||
os.makedirs(self.app.export_dir, exist_ok=True)
|
||||
with open(os.path.join(self.app.export_dir, "acceptance.json"), "w", encoding="utf-8") as f:
|
||||
f.write(_artifact.to_json(art))
|
||||
with open(os.path.join(self.app.export_dir, "acceptance.md"), "w", encoding="utf-8") as f:
|
||||
f.write(_artifact.to_markdown(art))
|
||||
self._send(200, {"ok": True, "authorized": art["authorized"], "sha256": art["sha256"],
|
||||
"counts": art["counts"]})
|
||||
else:
|
||||
self._deny(404, "not found")
|
||||
except Exception as e: # noqa: BLE001
|
||||
self._deny(500, "%s: %s" % (type(e).__name__, e))
|
||||
|
||||
|
||||
def make_server(app, host="::", port=8090):
|
||||
handler = type("ForgetestHandler", (Handler,), {"app": app})
|
||||
ThreadingHTTPServer.allow_reuse_address = True
|
||||
# An IPv6 bind address (the default "::") gives one dual-stack socket
|
||||
# that serves IPv4 clients too; a dotted address keeps the IPv4 family.
|
||||
server_cls = ThreadingHTTPServer
|
||||
if ":" in host:
|
||||
server_cls = type("ForgetestServer6", (ThreadingHTTPServer,),
|
||||
{"address_family": socket.AF_INET6})
|
||||
srv = server_cls((host, port), handler)
|
||||
srv.daemon_threads = True
|
||||
return srv
|
||||
@@ -1,24 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The acceptance suite: one module per subsystem, imported in display
|
||||
order. Each module registers its tests with @catalog.test."""
|
||||
|
||||
from . import image # noqa: F401,E402
|
||||
from . import kernel # noqa: F401,E402
|
||||
from . import forgectrl # noqa: F401,E402
|
||||
from . import setup # noqa: F401,E402
|
||||
from . import setup_dark # noqa: F401,E402
|
||||
from . import setup_burns # noqa: F401,E402
|
||||
from . import motion # noqa: F401,E402
|
||||
from . import cooling # noqa: F401,E402
|
||||
from . import laser # noqa: F401,E402
|
||||
from . import camera # noqa: F401,E402
|
||||
from . import update # noqa: F401,E402
|
||||
from . import cloud # noqa: F401,E402
|
||||
# Last: it stands on the setup record's consent and on the cloud suite's dark print.
|
||||
from . import exthost # noqa: F401,E402
|
||||
from . import homeoff # noqa: F401,E402
|
||||
from . import tlsrec # noqa: F401,E402
|
||||
@@ -1,496 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""camera.* - the lid camera pipeline through forgectrl."""
|
||||
from ..catalog import test
|
||||
from .. import hw
|
||||
from ..baseline import LID_LAMP_ATTR
|
||||
|
||||
_CAM_COVERS = [("forgectrl", "src/cam.*"), ("forgectrl", "src/camhealth.*"),
|
||||
("forgectrl", "src/debayer.*"), ("forgectrl", "src/vpu_jpeg.*"),
|
||||
("forgectrl", "src/vpu_h264.*"), ("forgectrl", "src/mp4mux.*"),
|
||||
("forgectrl", "src/gpu_debayer.*"), ("forgectrl", "src/ipu_copy.*"),
|
||||
("forgectrl", "src/main.c"),
|
||||
("python3-gfhardware", "gfhardware/src/**"), ("python3-gfhardware", "gfhardware/cam*")]
|
||||
|
||||
# The privacy gate spans the camera path and the lid read it depends on,
|
||||
# in both processes that can reach a sensor.
|
||||
_PRIVACY_COVERS = _CAM_COVERS + [("forgectrl", "src/status.*"),
|
||||
("python3-gfhardware", "gfhardware/switches.py"),
|
||||
("forgefirm-app", "forgefirm-app/ffmachine.py")]
|
||||
|
||||
# The sensors the machine ships with, and the geometry each one implies.
|
||||
# A machine reports exactly one of these; anything else means the sensor
|
||||
# bound to a driver the capture path has no profile for.
|
||||
_SENSOR_GEOMETRY = {
|
||||
"OV5648": (2592, 1944),
|
||||
"OV8856": (3264, 2448),
|
||||
}
|
||||
|
||||
|
||||
def _jpeg_size(data):
|
||||
"""(width, height) from a JPEG's first SOFn marker, or None."""
|
||||
i = 2
|
||||
n = len(data)
|
||||
while i + 3 < n:
|
||||
if data[i] != 0xFF:
|
||||
i += 1
|
||||
continue
|
||||
marker = data[i + 1]
|
||||
if marker in (0xD8, 0xD9) or 0xD0 <= marker <= 0xD7:
|
||||
i += 2
|
||||
continue
|
||||
seglen = (data[i + 2] << 8) | data[i + 3]
|
||||
# SOFn, excluding the non-frame markers in the same range
|
||||
if 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC):
|
||||
if i + 9 > n:
|
||||
return None
|
||||
return ((data[i + 7] << 8) | data[i + 8],
|
||||
(data[i + 5] << 8) | data[i + 6])
|
||||
i += 2 + seglen
|
||||
return None
|
||||
|
||||
|
||||
@test("camera.snapshot", title="Lid camera snapshot and stream", subsystem="camera",
|
||||
kind="auto", est_min=2,
|
||||
covers=_CAM_COVERS,
|
||||
steps=["Setup: lid closed."],
|
||||
description="/cam/snapshot returns a JPEG of a plausible size (a black frame compresses "
|
||||
"far smaller), a second snapshot with the lid lamp switched off differs from it "
|
||||
"and is smaller (the capture is live, not a frame the pipeline is re-serving, "
|
||||
"and the lamp lights what the camera sees), and the MJPEG stream starts and "
|
||||
"stops. A capture marked background=1 yields to a viewer: while the stream is "
|
||||
"open it is refused in words, the operator's own unmarked capture is still "
|
||||
"served, and once the viewer stops the background capture is served again. A "
|
||||
"background value that is neither 0 nor 1 is refused.")
|
||||
def snapshot(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
st, body = fc.get("/cam/status")
|
||||
ctx.check(st == 200 and isinstance(body, dict), "GET /cam/status -> %s", st)
|
||||
ev["cam_status"] = body
|
||||
ctx.log("cam status: %s", body)
|
||||
|
||||
st, data = fc.get("/cam/snapshot", params={"cam": "lid", "res": "half"}, raw=True)
|
||||
ctx.log("GET /cam/snapshot?cam=lid&res=half -> %s (%d bytes)", st, len(data) if data else 0)
|
||||
ctx.check(st == 200, "snapshot -> %s %s", st, data[:120] if data else "")
|
||||
ctx.check(data[:2] == b"\xff\xd8" and data[-2:] == b"\xff\xd9", "snapshot is not a complete JPEG")
|
||||
ev["snapshot_bytes"] = len(data)
|
||||
ctx.check(len(data) > 20000, "snapshot is only %d bytes - a dark or empty frame?", len(data))
|
||||
|
||||
# Live, and lit: the same frame with the lid lamp off must be a
|
||||
# different and smaller picture. The lamp is put back where it was.
|
||||
lamp0 = hw.sysfs_read(LID_LAMP_ATTR)
|
||||
ev["lid_lamp"] = lamp0
|
||||
ctx.check(lamp0 is not None, "the lid lamp (%s) is not readable", LID_LAMP_ATTR)
|
||||
try:
|
||||
hw.sysfs_write(LID_LAMP_ATTR, "0")
|
||||
ctx.sleep(1.5)
|
||||
st, dark = fc.get("/cam/snapshot", params={"cam": "lid", "res": "half"}, raw=True)
|
||||
finally:
|
||||
hw.sysfs_write(LID_LAMP_ATTR, lamp0)
|
||||
ctx.check(st == 200 and dark and dark[:2] == b"\xff\xd8", "snapshot with the lamp off -> %s", st)
|
||||
ev["snapshot_dark_bytes"] = len(dark)
|
||||
ctx.log("lamp %s -> %d bytes; lamp off -> %d bytes", lamp0, len(data), len(dark))
|
||||
ctx.check(dark != data, "the snapshot with the lamp off is byte-identical to the lit one: the "
|
||||
"capture is not live")
|
||||
ctx.check(len(dark) < len(data), "the frame with the lamp off is not smaller than the lit one "
|
||||
"(%d vs %d bytes): the lamp is not lighting what the camera "
|
||||
"sees", len(dark), len(data))
|
||||
|
||||
st, data = fc.get("/cam/snapshot", params={"cam": "lid", "res": "full"}, raw=True)
|
||||
ctx.log("GET /cam/snapshot?cam=lid&res=full -> %s (%d bytes)", st, len(data) if data else 0)
|
||||
ctx.check(st == 200 and data[:2] == b"\xff\xd8", "full-resolution snapshot -> %s", st)
|
||||
ev["snapshot_full_bytes"] = len(data)
|
||||
|
||||
# the stream: fetch a little of it and let it close
|
||||
import urllib.request
|
||||
req = urllib.request.Request(fc.base + "/cam/stream", headers={"Host": fc.host_header()})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
ctype = r.headers.get("Content-Type", "")
|
||||
chunk = r.read(65536)
|
||||
except Exception as e: # noqa: BLE001
|
||||
ctx.fail("stream did not open: %s", e)
|
||||
ev["stream_ctype"] = ctype
|
||||
ctx.log("stream: %s, first %d bytes", ctype, len(chunk))
|
||||
ctx.check("multipart" in ctype and b"\xff\xd8" in chunk, "stream is not an MJPEG multipart")
|
||||
|
||||
# A capture nobody is waiting for yields to one somebody is: a
|
||||
# snapshot borrows the mux and stutters a running stream, which is a
|
||||
# price worth paying for an operator and not for a program. The
|
||||
# stream above is closed by now, so one is opened and held here.
|
||||
bad = fc.get("/cam/snapshot", params={"cam": "lid", "background": "maybe"})
|
||||
ev["background_bad_value"] = bad
|
||||
ctx.check(bad[0] == 400, "background=maybe -> %s", bad)
|
||||
|
||||
held = urllib.request.urlopen(
|
||||
urllib.request.Request(fc.base + "/cam/stream", headers={"Host": fc.host_header()}), timeout=10)
|
||||
try:
|
||||
held.read(8192)
|
||||
watching = None
|
||||
for _ in range(40):
|
||||
st_, body_ = fc.get("/cam/status")
|
||||
if isinstance(body_, dict) and body_.get("clients"):
|
||||
watching = body_
|
||||
break
|
||||
ctx.sleep(0.25)
|
||||
ev["clients_while_watching"] = (watching or {}).get("clients")
|
||||
ctx.check(watching, "no viewer was counted while the stream was held: %s", body_)
|
||||
st_, why = fc.get("/cam/snapshot", params={"cam": "lid", "res": "half", "background": "1"})
|
||||
ev["background_while_watched"] = [st_, why]
|
||||
ctx.log("background capture while a viewer watches -> %s %s", st_, why)
|
||||
ctx.check(st_ == 409, "a background capture did not yield to a viewer: %s %s", st_, why)
|
||||
st_, data_ = fc.get("/cam/snapshot", params={"cam": "lid", "res": "half"}, raw=True)
|
||||
ev["operator_while_watched"] = [st_, len(data_ or b"")]
|
||||
ctx.check(st_ == 200 and data_ and data_[:2] == b"\xff\xd8",
|
||||
"the operator's own capture was refused while watching: %s", st_)
|
||||
finally:
|
||||
held.close()
|
||||
for _ in range(40):
|
||||
st_, body_ = fc.get("/cam/status")
|
||||
if isinstance(body_, dict) and not body_.get("clients"):
|
||||
break
|
||||
ctx.sleep(0.25)
|
||||
st_, data_ = fc.get("/cam/snapshot", params={"cam": "lid", "res": "half", "background": "1"}, raw=True)
|
||||
ev["background_after_watching"] = [st_, len(data_ or b"")]
|
||||
ctx.check(st_ == 200 and data_ and data_[:2] == b"\xff\xd8",
|
||||
"a background capture was still refused after the viewer stopped: %s", st_)
|
||||
|
||||
ctx.sleep(2)
|
||||
st, body = fc.get("/cam/status")
|
||||
ev["cam_status_after"] = body
|
||||
ctx.log("cam status after: %s", body)
|
||||
ctx.log("PASS: half-res snapshot %d bytes, %d with the lamp off; full-res %d; stream %s",
|
||||
ev["snapshot_bytes"], len(dark), len(data), ctype)
|
||||
|
||||
|
||||
@test("camera.sensor-profile", title="Camera geometry follows the fitted sensor", subsystem="camera",
|
||||
kind="auto", est_min=1,
|
||||
covers=_CAM_COVERS,
|
||||
description="/cam/status names the sensor that bound (OV5648 on a 5 MP machine, OV8856 on an "
|
||||
"8 MP 'HD' one) and reports the geometry that sensor implies, and the JPEGs it "
|
||||
"actually returns are that size. A machine that reports 'unknown', or whose frames "
|
||||
"do not match what it advertises, is running the wrong capture profile.")
|
||||
def sensor_profile(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
|
||||
st, body = fc.get("/cam/status")
|
||||
ctx.check(st == 200 and isinstance(body, dict), "GET /cam/status -> %s", st)
|
||||
sensor = body.get("sensor")
|
||||
ev["sensor"] = sensor
|
||||
ctx.log("sensor: %s", sensor)
|
||||
ctx.check(sensor in _SENSOR_GEOMETRY,
|
||||
"sensor %r is not one this build has a capture profile for", sensor)
|
||||
|
||||
want_w, want_h = _SENSOR_GEOMETRY[sensor]
|
||||
snap = body.get("snapshot") or {}
|
||||
stream = body.get("stream") or {}
|
||||
ev["geometry"] = {"snapshot": snap, "stream": stream}
|
||||
ctx.log("advertised: snapshot %sx%s, stream %sx%s",
|
||||
snap.get("width"), snap.get("height"), stream.get("width"), stream.get("height"))
|
||||
ctx.check(snap.get("width") == want_w and snap.get("height") == want_h,
|
||||
"%s should advertise %dx%d snapshots, got %sx%s",
|
||||
sensor, want_w, want_h, snap.get("width"), snap.get("height"))
|
||||
ctx.check(stream.get("width") == want_w // 2 and stream.get("height") == want_h // 2,
|
||||
"%s should advertise %dx%d stream frames, got %sx%s",
|
||||
sensor, want_w // 2, want_h // 2, stream.get("width"), stream.get("height"))
|
||||
|
||||
# What it advertises is what it delivers.
|
||||
for res, (w, h) in (("full", (want_w, want_h)), ("half", (want_w // 2, want_h // 2))):
|
||||
st, data = fc.get("/cam/snapshot", params={"cam": "lid", "res": res}, raw=True)
|
||||
ctx.check(st == 200 and data and data[:2] == b"\xff\xd8",
|
||||
"%s snapshot -> %s", res, st)
|
||||
got = _jpeg_size(data)
|
||||
ev["jpeg_%s" % res] = got
|
||||
ctx.log("%s snapshot: %s (%d bytes)", res, got, len(data))
|
||||
ctx.check(got == (w, h), "%s snapshot is %s, not %dx%d", res, got, w, h)
|
||||
|
||||
|
||||
@test("camera.h264-stream", title="H.264 live stream", subsystem="camera",
|
||||
kind="auto", est_min=2,
|
||||
covers=_CAM_COVERS,
|
||||
steps=["Setup: lid closed."],
|
||||
description="/cam/h264 answers a fragmented MP4: a codec header naming the SPS profile, an "
|
||||
"init segment (ftyp+moov) followed by media fragments (moof+mdat) within a few "
|
||||
"seconds, and /cam/status reporting the H.264 encoder active with this client "
|
||||
"counted. Also records which demosaic path (GPU or CPU) served the stream. On a "
|
||||
"machine whose image lacks Mesa or whose encoder refused, the endpoint must "
|
||||
"answer 503 rather than hang - that is a pass for the endpoint but is recorded "
|
||||
"in the evidence.")
|
||||
def h264_stream(ctx):
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
|
||||
req = urllib.request.Request(fc.base + "/cam/h264?cam=lid",
|
||||
headers={"Host": fc.host_header()})
|
||||
try:
|
||||
r = urllib.request.urlopen(req, timeout=20)
|
||||
except urllib.error.HTTPError as e:
|
||||
ev["status"] = e.code
|
||||
ev["body"] = e.read()[:200].decode("utf-8", "replace")
|
||||
ctx.check(e.code == 503, "H.264 refused with %s, not 503: %s", e.code, ev["body"])
|
||||
ctx.log("H.264 unavailable on this machine (503): %s", ev["body"])
|
||||
return
|
||||
with r:
|
||||
codec = r.headers.get("X-H264-Codec", "")
|
||||
ctype = r.headers.get("Content-Type", "")
|
||||
ev["codec"] = codec
|
||||
ev["content_type"] = ctype
|
||||
ctx.log("codec %s, content-type %s", codec, ctype)
|
||||
ctx.check(ctype == "video/mp4", "content type is %r", ctype)
|
||||
ctx.check(codec.startswith("avc1."), "codec header is %r", codec)
|
||||
|
||||
# Init segment, then at least one media fragment.
|
||||
data = b""
|
||||
while len(data) < 512 * 1024:
|
||||
chunk = r.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
if b"moof" in data and b"mdat" in data:
|
||||
break
|
||||
ev["bytes"] = len(data)
|
||||
ctx.check(data[4:8] == b"ftyp", "stream does not start with ftyp")
|
||||
ctx.check(b"moov" in data, "no moov (init segment)")
|
||||
ctx.check(b"avcC" in data, "no avcC in the init segment")
|
||||
ctx.check(b"moof" in data and b"mdat" in data,
|
||||
"no media fragment arrived in %d bytes", len(data))
|
||||
|
||||
st, body = fc.get("/cam/status")
|
||||
ctx.check(st == 200 and isinstance(body, dict), "GET /cam/status -> %s", st)
|
||||
ev["cam_status"] = body
|
||||
ctx.log("convert=%s h264=%s", body.get("convert"), body.get("h264"))
|
||||
h = body.get("h264") or {}
|
||||
ctx.check(h.get("active") is True, "/cam/status does not report the H.264 encoder active: %s",
|
||||
body)
|
||||
|
||||
|
||||
@test("camera.frame-health", title="Capture delivers whole frames", subsystem="camera",
|
||||
kind="auto", est_min=1,
|
||||
covers=_CAM_COVERS,
|
||||
description="The capture queue flags a frame errored when it is short, torn, or arrived "
|
||||
"after the CSI-2 receiver lost sync; forgectrl drops those rather than "
|
||||
"demosaicing them, restarts the stream if they persist, and withholds the "
|
||||
"stream frames on either side of one. A healthy machine captures a burst of "
|
||||
"snapshots and a few seconds of stream with none of them flagged and none "
|
||||
"withheld, and reports the stream's per-stage timing while a viewer watches. "
|
||||
"A nonzero corrupt count here is a real signal - a marginal camera ribbon, a "
|
||||
"mistimed D-PHY - even though the frames themselves never reach a client.")
|
||||
def frame_health(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
|
||||
def health():
|
||||
st, body = fc.get("/cam/status")
|
||||
ctx.check(st == 200 and isinstance(body, dict), "GET /cam/status -> %s", st)
|
||||
h = body.get("health")
|
||||
ctx.check(isinstance(h, dict), "/cam/status carries no health block: %s", body)
|
||||
return h
|
||||
|
||||
before = health()
|
||||
ev["health_before"] = before
|
||||
ctx.log("before: %s", before)
|
||||
|
||||
# A burst, so the count covers a run of frames rather than a single grab.
|
||||
for _ in range(3):
|
||||
st, data = fc.get("/cam/snapshot", params={"cam": "lid", "res": "half"}, raw=True)
|
||||
ctx.check(st == 200 and data and data[:2] == b"\xff\xd8", "snapshot -> %s", st)
|
||||
|
||||
after = health()
|
||||
ev["health_after"] = after
|
||||
ctx.log("after: %s", after)
|
||||
|
||||
captured = (after.get("captured") or 0) - (before.get("captured") or 0)
|
||||
corrupt = (after.get("corrupt") or 0) - (before.get("corrupt") or 0)
|
||||
restarts = (after.get("restarts") or 0) - (before.get("restarts") or 0)
|
||||
ev["captured"] = captured
|
||||
ev["corrupt"] = corrupt
|
||||
ev["restarts"] = restarts
|
||||
ctx.log("captured %d frames, %d corrupt, %d stream restarts", captured, corrupt, restarts)
|
||||
|
||||
ctx.check(captured > 0, "no frames were dequeued during three snapshots")
|
||||
ctx.check(corrupt == 0, "%d of %d captured frames came back errored", corrupt, captured)
|
||||
ctx.check(restarts == 0, "the capture stream was restarted %d time(s)", restarts)
|
||||
|
||||
# The stream path: five seconds of MJPEG, so the frame gate judges a
|
||||
# few dozen frames and the timing covers two whole windows. A frame is
|
||||
# withheld only next to one the receiver flagged, so a healthy machine
|
||||
# withholds none, and the per-stage timing is reported while the
|
||||
# viewer watches.
|
||||
import time
|
||||
import urllib.request
|
||||
s0 = health()
|
||||
stream = urllib.request.urlopen(
|
||||
urllib.request.Request(fc.base + "/cam/stream", headers={"Host": fc.host_header()}),
|
||||
timeout=10)
|
||||
try:
|
||||
got = 0
|
||||
t_end = time.monotonic() + 5.0
|
||||
while time.monotonic() < t_end:
|
||||
got += len(stream.read(65536))
|
||||
st, body = fc.get("/cam/status")
|
||||
timing = body.get("timing") if isinstance(body, dict) else None
|
||||
finally:
|
||||
stream.close()
|
||||
s1 = health()
|
||||
ev["stream_bytes"] = got
|
||||
ev["stream_timing"] = timing
|
||||
ev["stream_health"] = [s0, s1]
|
||||
ctx.log("stream: %d bytes in 5 s, timing %s", got, timing)
|
||||
s_captured = (s1.get("captured") or 0) - (s0.get("captured") or 0)
|
||||
s_corrupt = (s1.get("corrupt") or 0) - (s0.get("corrupt") or 0)
|
||||
s_withheld = (s1.get("withheld") or 0) - (s0.get("withheld") or 0)
|
||||
ev["stream_captured"] = s_captured
|
||||
ev["stream_withheld"] = s_withheld
|
||||
ctx.check(s_captured >= 30, "only %d frames were dequeued in 5 s of stream", s_captured)
|
||||
ctx.check(s_corrupt == 0, "%d stream frames came back errored", s_corrupt)
|
||||
ctx.check("withheld" in s1, "/cam/status health carries no withheld count: %s", s1)
|
||||
ctx.check(s_withheld == 0, "%d stream frames were withheld with none flagged", s_withheld)
|
||||
ctx.check(isinstance(timing, dict) and
|
||||
all(k in timing for k in ("latency_ms", "convert_ms", "copy_ms", "encode_ms")),
|
||||
"/cam/status carries no timing block while a viewer watches: %s", body)
|
||||
latency = (timing or {}).get("latency_ms") or 0
|
||||
ctx.check(0 < latency < 250, "capture to publication took %.1f ms while streaming", latency)
|
||||
|
||||
|
||||
@test("camera.lid-privacy", title="Cameras see the room only with the lid closed", subsystem="camera",
|
||||
kind="operator", est_min=3,
|
||||
covers=_PRIVACY_COVERS + [("forgectrl", "src/auth.*")],
|
||||
actions=["lid"],
|
||||
steps=["Start with the lid closed; you will be told to open it, then close it again.",
|
||||
"Nothing moves and the laser is not involved."],
|
||||
description="The privacy gate: with the lid open the lid camera does not capture, and the head "
|
||||
"camera captures only for a local viewer. A running lid-camera stream stops within a "
|
||||
"frame or so of the lid opening, /cam/status reports capture as not allowed, and the "
|
||||
"lid camera's snapshot and stream are refused with 409 and a reason naming the lid, "
|
||||
"to the panel too. The head camera, which looks down at the bed, still answers the "
|
||||
"panel (its token) and the extension host (its client header from this host), while "
|
||||
"the same snapshot and stream asked without either - the cloud client's form - are "
|
||||
"refused for the lid. Closing the lid restores all of it. This is what stops the "
|
||||
"machine - and in cloud mode the Glowforge service - from imaging the room through "
|
||||
"an open lid.")
|
||||
def lid_privacy(ctx):
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
|
||||
def status():
|
||||
st, body = fc.get("/cam/status")
|
||||
ctx.check(st == 200 and isinstance(body, dict), "GET /cam/status -> %s", st)
|
||||
return body
|
||||
|
||||
def snapshot():
|
||||
"""(status, body) for a half-res lid snapshot."""
|
||||
st, data = fc.get("/cam/snapshot", params={"cam": "lid", "res": "half"}, raw=True)
|
||||
return st, data or b""
|
||||
|
||||
# --- lid closed: the baseline the rest is measured against ----------
|
||||
ctx.act("lid", "close")
|
||||
body = status()
|
||||
ctx.check(body.get("capture_allowed") is True,
|
||||
"with the lid closed /cam/status should allow capture, got %r",
|
||||
body.get("capture_allowed"))
|
||||
st, data = snapshot()
|
||||
ctx.check(st == 200 and data[:2] == b"\xff\xd8",
|
||||
"snapshot with the lid closed -> %s", st)
|
||||
ev["closed_snapshot_bytes"] = len(data)
|
||||
ctx.log("lid closed: snapshot %d bytes", len(data))
|
||||
|
||||
# --- a live stream must die when the lid opens ----------------------
|
||||
req = urllib.request.Request(fc.base + "/cam/stream?cam=lid",
|
||||
headers={"Host": fc.host_header()})
|
||||
stream = urllib.request.urlopen(req, timeout=15)
|
||||
try:
|
||||
first = stream.read(4096)
|
||||
ctx.check(b"\xff\xd8" in first, "the stream did not start before the lid test")
|
||||
ctx.act("lid", "open", text="The stream is running.")
|
||||
# The engine tears the pipeline down on the next frame and the
|
||||
# stream ends. Draining now returns whatever was buffered before
|
||||
# the lid opened and then EOF; what must not happen is frames
|
||||
# continuing to arrive indefinitely.
|
||||
drained, ended = 0, False
|
||||
try:
|
||||
while True:
|
||||
chunk = stream.read(65536)
|
||||
if not chunk:
|
||||
ended = True
|
||||
break
|
||||
drained += len(chunk)
|
||||
ctx.check(drained < 8 * 1024 * 1024,
|
||||
"the stream was still delivering after %d bytes with the lid open",
|
||||
drained)
|
||||
except Exception as e: # noqa: BLE001 - a reset connection ends it too
|
||||
ended = True
|
||||
ctx.log("stream ended with %s", type(e).__name__)
|
||||
ev["stream_bytes_after_lid_open"] = drained
|
||||
ctx.log("stream ended after %d further buffered bytes", drained)
|
||||
ctx.check(ended, "the stream never ended after the lid opened")
|
||||
finally:
|
||||
stream.close()
|
||||
|
||||
# --- lid open: everything is refused, and says why ------------------
|
||||
body = status()
|
||||
ev["status_lid_open"] = body
|
||||
ctx.check(body.get("capture_allowed") is False,
|
||||
"with the lid open /cam/status should refuse capture, got %r",
|
||||
body.get("capture_allowed"))
|
||||
ctx.check(body.get("stopped_by_lid") is True,
|
||||
"the engine should record that the lid stopped it, got %r",
|
||||
body.get("stopped_by_lid"))
|
||||
ctx.check(body.get("running") is False,
|
||||
"the capture engine should not still be running with the lid open")
|
||||
|
||||
st, data = snapshot()
|
||||
ev["snapshot_lid_open"] = [st, data[:120].decode("utf-8", "replace")]
|
||||
ctx.log("lid open: snapshot -> %s %s", st, data[:120])
|
||||
ctx.check(st == 409, "snapshot with the lid open should be refused with 409, got %s", st)
|
||||
ctx.check(b"lid" in data.lower(), "the refusal should name the lid: %r", data[:120])
|
||||
ctx.check(data[:2] != b"\xff\xd8", "a JPEG was returned with the lid open")
|
||||
|
||||
# The head camera looks at the bed: a local viewer still has it, the cloud client's form does not.
|
||||
st, data = fc.get("/cam/snapshot", params={"cam": "head", "res": "half"}, raw=True)
|
||||
ctx.log("lid open: the panel's head snapshot -> %s", st)
|
||||
ctx.check(st == 200 and data[:2] == b"\xff\xd8", "the panel's head snapshot with the lid open -> %s", st)
|
||||
st, data = fc.get("/cam/snapshot", params={"cam": "head", "res": "half"}, raw=True, auth=False,
|
||||
headers={"X-ForgeFIRM-Client": "extension-host"})
|
||||
ctx.log("lid open: the extension host's head snapshot -> %s", st)
|
||||
ctx.check(st == 200 and data[:2] == b"\xff\xd8", "the extension host's head snapshot with the lid open -> %s", st)
|
||||
st, data = fc.get("/cam/snapshot", params={"cam": "head", "res": "half"}, raw=True, auth=False)
|
||||
ev["head_cloud_form_lid_open"] = [st, (data or b"")[:120].decode("utf-8", "replace")]
|
||||
ctx.log("lid open: head snapshot without a token or the header -> %s %s", st, (data or b"")[:120])
|
||||
ctx.check(st == 409 and b"lid" in (data or b"").lower(),
|
||||
"the head camera without a token or the header should be refused for the lid, got %s", st)
|
||||
try:
|
||||
hreq = urllib.request.Request(fc.base + "/cam/stream?cam=head", headers={"Host": fc.host_header()})
|
||||
with urllib.request.urlopen(hreq, timeout=10) as r:
|
||||
ctx.fail("the head stream opened with the lid open, for no local viewer (%s)", r.status)
|
||||
except urllib.error.HTTPError as e:
|
||||
ctx.log("lid open: head stream for no local viewer -> %s", e.code)
|
||||
ctx.check(e.code == 409, "the head stream for no local viewer should be refused with 409, got %s", e.code)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
ctx.fail("the stream opened with the lid open (%s)", r.status)
|
||||
except urllib.error.HTTPError as e:
|
||||
ctx.log("lid open: stream -> %s", e.code)
|
||||
ctx.check(e.code == 409, "the stream should be refused with 409, got %s", e.code)
|
||||
|
||||
# --- closing the lid restores it -----------------------------------
|
||||
ctx.act("lid", "close")
|
||||
body = status()
|
||||
ctx.check(body.get("capture_allowed") is True,
|
||||
"closing the lid should allow capture again, got %r",
|
||||
body.get("capture_allowed"))
|
||||
st, data = snapshot()
|
||||
ctx.check(st == 200 and data[:2] == b"\xff\xd8",
|
||||
"snapshot after closing the lid -> %s", st)
|
||||
ctx.log("lid closed again: snapshot %d bytes", len(data))
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,402 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""cooling.* - the cooling engine on the machine: the air-assist ground
|
||||
shift on the coolant readings, flow verification through forgectrl's
|
||||
diagnostics runner (the same check the fire gate runs) and under a lit
|
||||
tube, and the fail tier ending the controller on the head's own move
|
||||
inside an armed window. The engine's decisions on a reading a host can
|
||||
emulate (the gate settings, the phases' fan duties, the TEC, the fire and
|
||||
crash watches' tiers and gates) are forgectrl's cool_flow_test and
|
||||
cool_gate_test."""
|
||||
import os
|
||||
import time
|
||||
|
||||
from ..catalog import test
|
||||
from .. import hw
|
||||
|
||||
_COOL_COVERS = [("forgectrl", "src/cool.*"), ("forgectrl", "src/coolfmt.*"), ("forgectrl", "src/diag.*"),
|
||||
("forgectrl", "src/gates.*"), ("forgectrl", "src/airflow.*"),
|
||||
("forgectrl", "src/accel.*"),
|
||||
("forgectrl", "src/settings.*"),
|
||||
("forgectrl", "src/status.*"), ("forgectrl", "src/ui/**"),
|
||||
("grblhal-glowforge", "src/glowforge_cooling.*"),
|
||||
("kernel-module-glowforge", "src/thermal*"),
|
||||
("kernel-module-glowforge", "src/pic*")]
|
||||
|
||||
|
||||
# Registered before the heater tools: a queue runs the catalog in this
|
||||
# order, and a heater trial leaves a warm slug circulating past the
|
||||
# coolant sensors for minutes, which the tool's edges read as disagreement.
|
||||
@test("cooling.aa-offset-calibrate", title="The air-assist ground shift on the coolant readings measures cleanly",
|
||||
subsystem="cooling", kind="auto", est_min=6,
|
||||
covers=_COOL_COVERS, requires=["kernel.latch-locked-idle"],
|
||||
steps=["Coolant loop normal (pump on); the machine idle. The controller is suspended by "
|
||||
"forgectrl for the duration; the tool first waits for a stationary loop (up to "
|
||||
"7 minutes after a heater trial), then the air assist cycles three times."],
|
||||
description="forgectrl's aa-offset-calibrate diagnostic: the loop settled to the flow "
|
||||
"tools' stationary gate, then the air-assist fan stepped idle to run and back "
|
||||
"three times with the tube dark and the heater off, both coolant sensors read "
|
||||
"at every edge. The fan's return current shifts both readings by a number of "
|
||||
"ADC counts the engine adds back (cool_aa_offset_counts). PASS = the tool "
|
||||
"reports a recommendation, the six edges agree within its spread limit, and "
|
||||
"the value sits inside the setting's legal range. The setting is not written.")
|
||||
def aa_offset_calibrate(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
st, body = fc.get("/diag/status")
|
||||
ctx.check(st == 200 and isinstance(body, dict), "GET /diag/status -> %s", st)
|
||||
ctx.check(not body.get("running"), "a diagnostic is already running (%s)", body.get("tool"))
|
||||
st, body = fc.post("/diag/aa-offset-calibrate")
|
||||
ctx.log("POST /diag/aa-offset-calibrate -> %s %s", st, body if isinstance(body, dict) else "")
|
||||
ctx.check(st == 202 and isinstance(body, dict) and body.get("started") is True,
|
||||
"could not start aa-offset-calibrate (%s %s)", st, body)
|
||||
result = None
|
||||
last_phase = None
|
||||
t0 = time.time()
|
||||
try:
|
||||
while time.time() - t0 < AA_CAL_TIMEOUT_S:
|
||||
ctx.checkpoint()
|
||||
st, d = fc.get("/diag/status")
|
||||
if st == 200 and isinstance(d, dict):
|
||||
if d.get("phase") != last_phase:
|
||||
last_phase = d.get("phase")
|
||||
ctx.log("phase: %s", last_phase)
|
||||
if not d.get("running") and d.get("result") is not None:
|
||||
result = d.get("result")
|
||||
for line in d.get("log", [])[-10:]:
|
||||
ctx.log(" diag: %s", line)
|
||||
break
|
||||
time.sleep(2)
|
||||
except BaseException:
|
||||
fc.post("/diag/abort")
|
||||
raise
|
||||
ctx.check(result is not None, "aa-offset-calibrate did not finish within %d s", AA_CAL_TIMEOUT_S)
|
||||
ev["result"] = result
|
||||
ctx.check("error" not in result, "aa-offset-calibrate error: %s", result.get("error"))
|
||||
ctx.log("offset %s counts, spread %s, recommend %s, edges %s", result.get("offset_counts"),
|
||||
result.get("spread_counts"), result.get("recommend"), result.get("steps"))
|
||||
ctx.check("recommend" in result, "no recommendation in %s", result)
|
||||
ctx.check(0 <= float(result["recommend"]) <= 60,
|
||||
"recommendation %s outside the setting's range 0..60", result["recommend"])
|
||||
ctx.check(fc.wait_idle(120, abort=ctx.aborted), "machine did not return to idle after the diagnostic")
|
||||
|
||||
|
||||
@test("cooling.flow-verify", title="Coolant flow check separates flow from no-flow",
|
||||
subsystem="cooling", kind="auto", est_min=4,
|
||||
covers=_COOL_COVERS, requires=["kernel.latch-locked-idle"],
|
||||
steps=["Coolant loop normal (pump on); the machine idle. The controller is suspended by "
|
||||
"forgectrl for the duration (about 3 minutes)."],
|
||||
description="forgectrl's flow-verify diagnostic: one heater window with the pump on and "
|
||||
"one with it commanded off, judged against the configured threshold. PASS = "
|
||||
"the threshold separates the two readings with the margins forgectrl reports; "
|
||||
"a thin margin is recorded as a warning.")
|
||||
def flow_verify(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
st, body = fc.get("/diag/status")
|
||||
ctx.check(st == 200 and isinstance(body, dict), "GET /diag/status -> %s", st)
|
||||
ctx.check(not body.get("running"), "a diagnostic is already running (%s)", body.get("tool"))
|
||||
st, body = fc.post("/diag/flow-verify")
|
||||
ctx.log("POST /diag/flow-verify -> %s %s", st, body if isinstance(body, dict) else "")
|
||||
ctx.check(st == 202 and isinstance(body, dict) and body.get("started") is True,
|
||||
"could not start flow-verify (%s %s)", st, body)
|
||||
last_phase = None
|
||||
result = None
|
||||
t0 = time.time()
|
||||
try:
|
||||
while time.time() - t0 < 900:
|
||||
ctx.checkpoint()
|
||||
st, d = fc.get("/diag/status")
|
||||
if st == 200 and isinstance(d, dict):
|
||||
if d.get("phase") != last_phase:
|
||||
last_phase = d.get("phase")
|
||||
ctx.log("phase: %s (down %.1f C, up %.1f C)", last_phase, d.get("down_c", 0), d.get("up_c", 0))
|
||||
if not d.get("running") and d.get("result") is not None:
|
||||
result = d.get("result")
|
||||
for line in d.get("log", [])[-12:]:
|
||||
ctx.log(" diag: %s", line)
|
||||
break
|
||||
time.sleep(2)
|
||||
except BaseException:
|
||||
fc.post("/diag/abort")
|
||||
raise
|
||||
ctx.check(result is not None, "flow-verify did not finish within 15 minutes")
|
||||
ev["result"] = result
|
||||
ctx.check("error" not in result, "flow-verify error: %s", result.get("error"))
|
||||
ctx.log("verdict: pass=%s threshold=%s flow_rise=%s noflow_rise=%s margins %s/%s thin=%s",
|
||||
result.get("pass"), result.get("threshold"), result.get("flow_rise"),
|
||||
result.get("noflow_rise"), result.get("margin_flow"), result.get("margin_noflow"),
|
||||
result.get("thin_margin"))
|
||||
ctx.check(result.get("pass") is True, "the threshold does not separate flow from no-flow: %s", result)
|
||||
if result.get("thin_margin"):
|
||||
ctx.log("WARNING: thin margin - run flow-calibrate")
|
||||
ctx.check(fc.wait_idle(120, abort=ctx.aborted), "machine did not return to idle after the diagnostic")
|
||||
|
||||
|
||||
AA_CAL_TIMEOUT_S = 540 # the tool's 420 s settle limit plus its six edges
|
||||
|
||||
|
||||
def _cool(fc):
|
||||
st, c = fc.get("/cool/status")
|
||||
return c if st == 200 and isinstance(c, dict) else {}
|
||||
|
||||
|
||||
def _gate_state(fc, key):
|
||||
g = (fc.settings().get("gates") or {}).get(key) or {}
|
||||
return g.get("state"), g.get("value")
|
||||
|
||||
|
||||
def _set_gates(ctx, fc, values):
|
||||
"""POST the gate settings and confirm the reply carries them."""
|
||||
st, body = fc.post("/settings", params=values)
|
||||
ctx.check(st == 200 and isinstance(body, dict), "POST /settings %s -> %s %s", values, st, body)
|
||||
for k, v in values.items():
|
||||
ctx.check(body.get(k) == v, "settings reply has %s=%r, posted %r", k, body.get(k), v)
|
||||
return body
|
||||
|
||||
|
||||
CRASH_KEYS = ("cool_accel_x_alert", "cool_accel_y_alert", "cool_accel_abort")
|
||||
|
||||
|
||||
# The flow check under a lit tube. With a prompt press the tube is lit for
|
||||
# most of the arm-time window, which adds about 1.5 C to the heater rise at
|
||||
# full power; the engine takes that share off from the tube current before
|
||||
# the limit (cool_laser_heat_*), reads its baseline and end as means, and
|
||||
# says so on the verdict line: "verified (heater rise 11.3 C, dT 9.6 C;
|
||||
# laser 0.8 off 12.0)". The share on the line is the proof the window and
|
||||
# the fire overlapped; the judged rise inside the dark band is the proof
|
||||
# the share was right.
|
||||
LOAD_FEED = 1500 # mm/min: 25 mm/s, the engrave dose that does not char
|
||||
LOAD_PITCH = 0.3 # mm between lines
|
||||
LOAD_WIDTH = 30.0 # mm, the line length (+X)
|
||||
LOAD_LINES = 27 # two 30 x 4 mm fills back to back, about 35 s lit
|
||||
LOAD_SHARE_MIN_C = 0.3 # the tube was lit inside the window
|
||||
LOAD_MARGIN_C = 1.0 # the judged rise this far under the limit
|
||||
LOAD_VERDICT_WAIT_S = 120 # the window opens ~15 s into the session and runs 50 s
|
||||
_LOAD_LINE_RX = None
|
||||
|
||||
|
||||
def _load_verdict_rx():
|
||||
global _LOAD_LINE_RX
|
||||
if _LOAD_LINE_RX is None:
|
||||
import re
|
||||
_LOAD_LINE_RX = re.compile(
|
||||
r"(COOLANT FLOW SUSPECT|COOLANT FLOW FAULT|coolant flow verified|"
|
||||
r"coolant flow suspicion cleared|coolant flow recovered)[^\n]*?heater rise ([0-9.]+) C"
|
||||
r"(?: \(limit ([0-9.]+))?, dT ([0-9.]+)(?: C)?(?:; laser ([0-9.]+) off ([0-9.]+))?\)?")
|
||||
return _LOAD_LINE_RX
|
||||
|
||||
|
||||
def _load_fill():
|
||||
lines = []
|
||||
for i in range(LOAD_LINES):
|
||||
lines.append("G1 X%g F%d" % (LOAD_WIDTH if i % 2 == 0 else -LOAD_WIDTH, LOAD_FEED))
|
||||
if i < LOAD_LINES - 1:
|
||||
lines.append("G1 Y%g F%d" % (LOAD_PITCH, LOAD_FEED))
|
||||
return lines
|
||||
|
||||
|
||||
def _log_since(path, offset):
|
||||
"""The text a log gained since byte `offset`. A count of lines in a
|
||||
tail of fixed length cannot tell a new line from an old one: the new
|
||||
line comes in at the bottom as an old one leaves at the top. A file now
|
||||
shorter than the offset was rotated under the test, and all of it is
|
||||
newer."""
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
if os.fstat(f.fileno()).st_size >= offset:
|
||||
f.seek(offset)
|
||||
return f.read().decode("utf-8", "replace")
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
@test("cooling.flow-under-load", title="The flow check reads true with the tube lit through its window",
|
||||
subsystem="cooling", kind="live", mode="grbl", est_min=4,
|
||||
covers=_COOL_COVERS + [("forgectrl", "src/main.c")],
|
||||
requires=["laser.emission-witness", "cooling.flow-verify"], actions=["button"],
|
||||
steps=["Scrap under the head with 35 mm of free +X and 10 mm of +Y travel; lid closed; exhaust on.",
|
||||
"Press the physical button AS SOON AS it lights white: the check must run while the "
|
||||
"tube is lit, and the share on the verdict line is the proof it did."],
|
||||
description="Two 30 x 4 mm fills at full power on the press (about 35 s lit), the window "
|
||||
"held open until the engine's flow verdict lands. The verdict must be "
|
||||
"'verified' with the laser's share on the line (at least 0.3 C: the tube was "
|
||||
"lit inside the window) and the judged rise at least 1 C under the limit, "
|
||||
"where the same check read within tenths of the limit before the engine took "
|
||||
"the tube's share off and read its baseline as a mean.")
|
||||
def flow_under_load(ctx):
|
||||
from .laser import sample, prepare, LiveJob, ARM_CUE, run_and_sample, wait_disarm
|
||||
from .motion import FORGECTRL_LOG, _log_offset
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
state, check_s = _gate_state(fc, "cool_flow_check_s")
|
||||
_st, limit = _gate_state(fc, "cool_flow_rise")
|
||||
ctx.check(state != "off" and check_s, "the flow check is off (cool_flow_check_s=%s)", check_s)
|
||||
ctx.check(limit, "cool_flow_rise unreadable")
|
||||
ev.update({"check_s": check_s, "limit_c": limit})
|
||||
off = _log_offset(FORGECTRL_LOG) # the verdict is a line written after this
|
||||
# The fill ends one line length out and (LOAD_LINES - 1) pitches up;
|
||||
# the job brings the head back so the baseline finds it where it began.
|
||||
back = "G0 X%g Y%g" % (-LOAD_WIDTH if LOAD_LINES % 2 else 0.0, -LOAD_PITCH * (LOAD_LINES - 1))
|
||||
job = ["G91", "G21", "M3 S1000"] + _load_fill() + ["M5", back]
|
||||
line = None
|
||||
heater_seen = False
|
||||
with ctx.grbl() as g, LiveJob(ctx, g):
|
||||
prepare(ctx, g)
|
||||
base = sample(ctx)
|
||||
ctx.check(base, "forgectrl /status or /cool/status unavailable")
|
||||
ctx.check(not base["emission"], "emission_samples nonzero before the job (%s)", base["emission"])
|
||||
ctx.ready(ARM_CUE % "35 mm +X and 10 mm +Y")
|
||||
ctx.arm_press()
|
||||
t_job = time.time()
|
||||
try:
|
||||
samples = run_and_sample(ctx, g, job, overall_timeout=240)
|
||||
finally:
|
||||
ctx.clear_notice()
|
||||
emis = [s["emission"] for s in samples if s["emission"] is not None]
|
||||
ev["emission_peak"] = max(emis) if emis else 0
|
||||
# An arm refused by a gate names itself: the engine's verdict left OK
|
||||
# during the wait (AIRFLOW, OVERTEMP, a flow FAULT).
|
||||
gates = sorted({s["verdict"] for s in samples if s.get("verdict") not in (None, "OK")})
|
||||
ev["verdicts_seen"] = gates
|
||||
ctx.check(ev["emission_peak"] > 0,
|
||||
"no emission witnessed: %s" % ("the engine held the run (%s) before the arm"
|
||||
% ", ".join(gates) if gates
|
||||
else "arm refused, or no button press"))
|
||||
# The window stays open (no M2) until the check has judged.
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < LOAD_VERDICT_WAIT_S:
|
||||
ctx.checkpoint()
|
||||
if hw.sysfs_int("thermal/heater_pwm", 0) > 0:
|
||||
heater_seen = True
|
||||
m = None
|
||||
for m in _load_verdict_rx().finditer(_log_since(FORGECTRL_LOG, off)):
|
||||
pass
|
||||
if m:
|
||||
line = m.group(0)
|
||||
break
|
||||
ctx.sleep(1)
|
||||
ev["heater_seen"] = heater_seen
|
||||
ctx.log("M2: %s", g.command("G90\nM2", timeout=10))
|
||||
dt = wait_disarm(ctx, 30)
|
||||
ev["disarm_after_m2_s"] = round(dt, 1) if dt is not None else None
|
||||
ctx.check(line, "the engine published no flow verdict within %d s of the job (heater seen: %s)",
|
||||
LOAD_VERDICT_WAIT_S, heater_seen)
|
||||
m = _load_verdict_rx().search(line)
|
||||
kind, rise, _lim, dtc, share, raw = m.groups()
|
||||
rise = float(rise)
|
||||
share = float(share) if share else 0.0
|
||||
raw = float(raw) if raw else rise
|
||||
ev.update({"verdict_line": line, "kind": kind, "rise_c": rise, "dt_c": float(dtc),
|
||||
"laser_share_c": share, "raw_rise_c": raw, "job_to_verdict_s": round(time.time() - t_job, 1)})
|
||||
ctx.log("engine: %s", line)
|
||||
ctx.check(kind == "coolant flow verified", "the check did not verify flow under load: %s", line)
|
||||
ctx.check(share >= LOAD_SHARE_MIN_C,
|
||||
"the tube's share on the line is %.1f C (under %.1f): the window and the fire did not "
|
||||
"overlap - press the button as soon as it lights", share, LOAD_SHARE_MIN_C)
|
||||
ctx.check(rise <= float(limit) - LOAD_MARGIN_C,
|
||||
"judged rise %.1f C is within %.1f C of the %.1f C limit", rise, LOAD_MARGIN_C, float(limit))
|
||||
ctx.check(dt is not None, "the window did not close after M2")
|
||||
ctx.log("PASS: lit through the window, raw rise %.1f C, laser %.1f C off, judged %.1f C against %.1f "
|
||||
"(dT %.1f), window closed %.1f s after M2", raw, share, rise, float(limit), float(dtc), dt)
|
||||
|
||||
|
||||
@test("cooling.fail-tier-stop", title="A fail-tier verdict ends the controller: CRASH stops motion, "
|
||||
"locks the laser, and the supervisor restarts the controller",
|
||||
subsystem="cooling", kind="operator", mode="grbl", est_min=4,
|
||||
covers=_COOL_COVERS + [("forgectrl", "src/super.*")],
|
||||
requires=["motion.deadman"], actions=["button"],
|
||||
steps=["Bed clear, lid closed, 40 mm of free +X travel. Nothing fires: the job is dark (S0).",
|
||||
"Press the physical button when it lights white, once. The head moves and stops on "
|
||||
"its own; the controller restarts."],
|
||||
description="The engine's fail tiers (a lid IR fire signal, a head crash signal) do not "
|
||||
"leave the job to the controller: after the kernel writes (motion stopped, the "
|
||||
"latch locked) the engine ends the controller through the supervisor, which "
|
||||
"starts it again, so no run start can relight what was locked and the sender "
|
||||
"sees the job end. Provable without emission or a physical crash: the crash "
|
||||
"watch's thresholds at their lowest (1) make the head's own move trip the abort "
|
||||
"generator inside an armed, dark (S0) job. Expected: the engine logs the crash "
|
||||
"signal, the supervisor logs the stop and starts a new controller (new pid), the "
|
||||
"latch is locked, the kernel idle, no emission, the crash fault ends with the run "
|
||||
"session (the engine's next tick after the controller is gone), and the "
|
||||
"thresholds are put back.")
|
||||
def fail_tier_stop(ctx):
|
||||
from .laser import prepare, stream, sample
|
||||
from .motion import FORGECTRL_LOG, _log_lines, _log_offset
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
before = fc.settings()
|
||||
orig = {k: before.get(k, "") for k in CRASH_KEYS}
|
||||
ev["orig"] = orig
|
||||
ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle at the start")
|
||||
st, m0 = fc.get("/mode")
|
||||
ctx.check(st == 200 and isinstance(m0, dict) and m0.get("controller") == "running", "controller not running: %s", m0)
|
||||
pid0 = m0.get("pid")
|
||||
_set_gates(ctx, fc, {k: "1" for k in CRASH_KEYS})
|
||||
off = _log_offset(FORGECTRL_LOG)
|
||||
t_arm = None
|
||||
try:
|
||||
try:
|
||||
with ctx.grbl() as g:
|
||||
prepare(ctx, g)
|
||||
ctx.ready("DARK JOB (S0, nothing fires). Bed clear with 40 mm of free +X travel, lid closed. "
|
||||
"The head moves 20 mm and the controller restarts on its own.")
|
||||
stream(g, ["G91", "G21", "M3 S0", "G4 P1", "G1 X20 F1200", "G1 X-20 F1200", "M5", "G90", "M2"])
|
||||
ctx.arm_press()
|
||||
t_arm = ctx.wait_for(lambda: bool((sample(ctx) or {}).get("armed")), 240)
|
||||
ctx.clear_notice()
|
||||
ctx.check(t_arm is not None, "the window never opened (arm refused, or no press)")
|
||||
t0 = time.time()
|
||||
tripped = ctx.wait_for(lambda: _cool(fc).get("verdict") == "CRASH"
|
||||
or (fc.get("/mode")[1] or {}).get("pid") not in (None, pid0), 30)
|
||||
ctx.log("the crash tier tripped after %s s", tripped)
|
||||
except (hw.HwError, OSError) as e:
|
||||
ctx.log("the Grbl connection ended with the restart: %s", e)
|
||||
t_kill = time.time()
|
||||
m1 = None
|
||||
while time.time() - t_kill < 60:
|
||||
st, m1 = fc.get("/mode")
|
||||
if isinstance(m1, dict) and m1.get("controller") == "running" and m1.get("pid") != pid0:
|
||||
break
|
||||
ctx.sleep(0.5)
|
||||
# the daemon's lines reach the log file through syslog, a moment
|
||||
# after the events they name
|
||||
ctx.wait_for(lambda: bool(_log_lines(FORGECTRL_LOG, off, "HEAD CRASH SIGNAL"))
|
||||
and bool(_log_lines(FORGECTRL_LOG, off, "head crash signal - the controller is stopped")), 15)
|
||||
engine = _log_lines(FORGECTRL_LOG, off, "HEAD CRASH SIGNAL")
|
||||
super_ = _log_lines(FORGECTRL_LOG, off, "head crash signal - the controller is stopped")
|
||||
smp = sample(ctx)
|
||||
ilk = hw.sysfs_int("cnc/interlock_circuit")
|
||||
ev["result"] = {"mode_after": m1, "engine_lines": [ln[-160:] for ln in engine[-2:]],
|
||||
"super_lines": [ln[-160:] for ln in super_[-2:]],
|
||||
"emission": smp and smp["emission"], "kernel": smp and smp["kstate"],
|
||||
"latch_locked": ilk is not None and bool(ilk & (1 << 3))}
|
||||
ctx.log("after the crash tier: %s", ev["result"])
|
||||
ctx.check(engine, "the engine did not log the crash signal")
|
||||
ctx.check(super_, "the supervisor did not log the fail-tier stop")
|
||||
ctx.check(m1 and m1.get("pid") != pid0, "the controller was not stopped and started again: %s", m1)
|
||||
ctx.check(ev["result"]["latch_locked"], "latch not locked after the crash tier")
|
||||
ctx.check(not (smp and smp["emission"]), "emission during a dark job: %s", smp and smp["emission"])
|
||||
ctx.check(fc.wait_idle(30, abort=ctx.aborted), "machine not idle after the restart")
|
||||
# The crash fault is the run session's: the engine ends the session
|
||||
# on its next 1 Hz tick after the controller is gone, and the fault
|
||||
# and its hold go with it. The supervisor has the new controller up
|
||||
# inside that second, so a test that returned here would hand the
|
||||
# machine back with the fault it raised still standing.
|
||||
cleared = ctx.wait_for(lambda: _cool(fc).get("verdict") != "CRASH", 10)
|
||||
ev["result"]["fault_cleared_s"] = cleared
|
||||
ctx.log("the crash fault ended with the run session after %s s: %s", cleared,
|
||||
{k: _cool(fc).get(k) for k in ("phase", "verdict", "hold", "armed")})
|
||||
ctx.check(cleared is not None, "the crash fault did not end with the run session: %s", _cool(fc))
|
||||
finally:
|
||||
fc.wait_idle(30, abort=ctx.aborted)
|
||||
st, body = fc.post("/settings", params=orig)
|
||||
ctx.log("restore thresholds: POST /settings %s -> %s", orig, st)
|
||||
after = fc.settings()
|
||||
ctx.check(all(after.get(k, "") == orig[k] for k in CRASH_KEYS),
|
||||
"thresholds not restored: %s", {k: after.get(k) for k in CRASH_KEYS})
|
||||
ctx.log("PASS: the crash tier stopped the job, locked the latch, and the supervisor restarted "
|
||||
"the controller (pid %s -> %s)", pid0, m1 and m1.get("pid"))
|
||||
@@ -1,985 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""exthost.* - what holds an extension package: the image's sandbox platform."""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
from ..catalog import test
|
||||
from .forgectrl import lan_ip
|
||||
from .image import kernel_config
|
||||
from .cloud import OFFLINE_STEP, Offline, dark_print_body, enter_offline, latch_locked, offline_cleanup, offline_job
|
||||
from .setup import SAFETY_PHRASE, read_file, record_path, request, write_file
|
||||
|
||||
CG = "/sys/fs/cgroup"
|
||||
POOL_GROUP = CG + "/ffx"
|
||||
PROBE_GROUP = POOL_GROUP + "/forgetest-probe"
|
||||
POOL_FIRST, POOL_SIZE = 800, 32
|
||||
CONTROLLERS = ("cpu", "memory", "pids")
|
||||
NFT = "/usr/sbin/nft"
|
||||
|
||||
# The probe: one short program, started as root. It puts itself into the
|
||||
# group named in argv, becomes the uid named there (0 stays root), gives up
|
||||
# new privileges, and then does one thing and prints one JSON line.
|
||||
PROBE = r'''
|
||||
import ctypes, errno, json, os, platform, socket, sys, time
|
||||
group, uid, mode, arg = sys.argv[1], int(sys.argv[2]), sys.argv[3], sys.argv[4]
|
||||
libc = ctypes.CDLL(None, use_errno=True)
|
||||
if group != "-":
|
||||
with open(group + "/cgroup.procs", "w") as f:
|
||||
f.write(str(os.getpid()))
|
||||
if uid:
|
||||
os.setgroups([])
|
||||
os.setgid(uid)
|
||||
os.setuid(uid)
|
||||
libc.prctl(38, 1, 0, 0, 0) # PR_SET_NO_NEW_PRIVS
|
||||
|
||||
|
||||
def say(**kw):
|
||||
print(json.dumps(kw), flush=True)
|
||||
|
||||
|
||||
def word(e):
|
||||
return {errno.ECONNREFUSED: "refused", errno.EPERM: "eperm", errno.EACCES: "eacces"}.get(
|
||||
e.errno, errno.errorcode.get(e.errno, str(e.errno)))
|
||||
|
||||
|
||||
def reach(family, addr, port, udp):
|
||||
s = socket.socket(socket.AF_INET6 if family == 6 else socket.AF_INET,
|
||||
socket.SOCK_DGRAM if udp else socket.SOCK_STREAM)
|
||||
s.settimeout(4.0)
|
||||
t0 = time.time()
|
||||
try:
|
||||
if udp:
|
||||
s.sendto(b"x", (addr, port))
|
||||
else:
|
||||
s.connect((addr, port))
|
||||
out = "ok"
|
||||
except socket.timeout:
|
||||
out = "timeout"
|
||||
except OSError as e:
|
||||
out = word(e)
|
||||
s.close()
|
||||
return [out, round(time.time() - t0, 2)]
|
||||
|
||||
|
||||
if mode == "spin":
|
||||
say(started=True)
|
||||
while True:
|
||||
pass
|
||||
elif mode == "forks":
|
||||
kids = []
|
||||
err = ""
|
||||
for _ in range(int(arg)):
|
||||
try:
|
||||
pid = os.fork()
|
||||
except OSError as e:
|
||||
err = word(e)
|
||||
break
|
||||
if pid == 0:
|
||||
time.sleep(30)
|
||||
os._exit(0)
|
||||
kids.append(pid)
|
||||
for pid in kids:
|
||||
os.kill(pid, 9)
|
||||
os.waitpid(pid, 0)
|
||||
say(forked=len(kids), stopped_by=err)
|
||||
elif mode == "eat":
|
||||
say(started=True)
|
||||
held = []
|
||||
for _ in range(int(arg)):
|
||||
held.append(bytearray(os.urandom(4096)) * 256) # 1 MiB, every page written
|
||||
say(survived=True)
|
||||
elif mode == "net":
|
||||
say(results=[[t, reach(*t)] for t in json.loads(arg)])
|
||||
elif mode == "unix":
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
s.settimeout(4.0)
|
||||
try:
|
||||
s.connect(arg)
|
||||
say(result="connected")
|
||||
except OSError as e:
|
||||
say(result=word(e))
|
||||
s.close()
|
||||
elif mode == "landlock":
|
||||
target = json.loads(arg)
|
||||
before = {"etc": None, "usr": None, "tcp": reach(*target)[0]}
|
||||
for key, path in (("etc", "/etc/hostname"), ("usr", sys.executable)):
|
||||
try:
|
||||
open(path, "rb").close()
|
||||
before[key] = "ok"
|
||||
except OSError as e:
|
||||
before[key] = word(e)
|
||||
abi = libc.syscall(444, None, 0, 1) # LANDLOCK_CREATE_RULESET_VERSION
|
||||
after = {}
|
||||
if abi >= 1:
|
||||
# write, read file, read dir; and from ABI 4 on, connect TCP (an older kernel takes the first field alone)
|
||||
attr = (ctypes.c_uint64 * 2)((1 << 1) | (1 << 2) | (1 << 3), 1 << 1)
|
||||
ruleset = libc.syscall(444, ctypes.byref(attr), 16 if abi >= 4 else 8, 0)
|
||||
usr = os.open("/usr", os.O_PATH | os.O_CLOEXEC)
|
||||
|
||||
class PathBeneath(ctypes.Structure):
|
||||
_pack_ = 1
|
||||
_fields_ = [("allowed_access", ctypes.c_uint64), ("parent_fd", ctypes.c_int32)]
|
||||
rule = PathBeneath((1 << 2) | (1 << 3), usr)
|
||||
added = libc.syscall(445, ruleset, 1, ctypes.byref(rule), 0) # LANDLOCK_RULE_PATH_BENEATH
|
||||
applied = libc.syscall(446, ruleset, 0)
|
||||
after = {"ruleset": ruleset >= 0, "added": added, "applied": applied, "tcp": reach(*target)[0]}
|
||||
for key, path in (("etc", "/etc/hostname"), ("usr", sys.executable)):
|
||||
try:
|
||||
open(path, "rb").close()
|
||||
after[key] = "ok"
|
||||
except OSError as e:
|
||||
after[key] = word(e)
|
||||
say(abi=abi, before=before, after=after)
|
||||
elif mode == "seccomp":
|
||||
arch, nr = {"armv7l": (0x40000028, 122), "aarch64": (0xC00000B7, 160),
|
||||
"x86_64": (0xC000003E, 63)}[platform.machine()] # uname
|
||||
|
||||
|
||||
class Filter(ctypes.Structure):
|
||||
_fields_ = [("code", ctypes.c_uint16), ("jt", ctypes.c_uint8), ("jf", ctypes.c_uint8),
|
||||
("k", ctypes.c_uint32)]
|
||||
|
||||
|
||||
class Prog(ctypes.Structure):
|
||||
_fields_ = [("len", ctypes.c_ushort), ("filter", ctypes.POINTER(Filter))]
|
||||
before = os.uname().sysname
|
||||
ops = (Filter * 7)(Filter(0x20, 0, 0, 4), # load arch
|
||||
Filter(0x15, 1, 0, arch), # ours: go on
|
||||
Filter(0x06, 0, 0, 0x80000000), # another ABI: kill the process
|
||||
Filter(0x20, 0, 0, 0), # load the syscall number
|
||||
Filter(0x15, 0, 1, nr), # uname?
|
||||
Filter(0x06, 0, 0, 0x00050000 | errno.EPERM),
|
||||
Filter(0x06, 0, 0, 0x7FFF0000)) # everything else: allow
|
||||
prog = Prog(7, ops)
|
||||
rc = libc.prctl(22, 2, ctypes.byref(prog), 0, 0) # PR_SET_SECCOMP, SECCOMP_MODE_FILTER
|
||||
try:
|
||||
os.uname()
|
||||
after = "ok"
|
||||
except OSError as e:
|
||||
after = word(e)
|
||||
mode_now = [l.split()[1] for l in open("/proc/self/status") if l.startswith("Seccomp:")]
|
||||
say(installed=rc, before=before, after=after, status=mode_now)
|
||||
'''
|
||||
|
||||
|
||||
def _read(path, default=""):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||
return f.read()
|
||||
except OSError:
|
||||
return default
|
||||
|
||||
|
||||
def _write(path, text):
|
||||
with open(path, "w") as f:
|
||||
f.write(text)
|
||||
|
||||
|
||||
def _flat(path):
|
||||
"""A cgroup flat-keyed file as {key: int}."""
|
||||
out = {}
|
||||
for line in _read(path).splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) == 2 and parts[1].isdigit():
|
||||
out[parts[0]] = int(parts[1])
|
||||
return out
|
||||
|
||||
|
||||
def _probe(group, uid, mode, arg="", wait=30):
|
||||
"""Runs the probe to its end; (returncode, its JSON lines)."""
|
||||
p = subprocess.run([sys.executable, "-c", PROBE, group, str(uid), mode, arg],
|
||||
capture_output=True, text=True, timeout=wait)
|
||||
lines = []
|
||||
for line in p.stdout.splitlines():
|
||||
try:
|
||||
lines.append(json.loads(line))
|
||||
except ValueError:
|
||||
pass
|
||||
return p.returncode, lines, p.stderr.strip()[-300:]
|
||||
|
||||
|
||||
def _pids_of(comm):
|
||||
out = []
|
||||
for d in os.listdir("/proc"):
|
||||
if d.isdigit() and _read("/proc/%s/comm" % d).strip() == comm:
|
||||
out.append(int(d))
|
||||
return out
|
||||
|
||||
|
||||
def _pool_counters():
|
||||
"""The packet counts of chain refuse's two rules: [tcp reset, drop]."""
|
||||
p = subprocess.run([NFT, "-j", "list", "chain", "inet", "ffx", "refuse"], capture_output=True, text=True)
|
||||
if p.returncode != 0:
|
||||
return None
|
||||
return [e["counter"]["packets"] for c in json.loads(p.stdout)["nftables"] if "rule" in c
|
||||
for e in c["rule"]["expr"] if "counter" in e]
|
||||
|
||||
|
||||
@test("exthost.platform", title="The extension sandbox platform holds a process",
|
||||
subsystem="exthost", kind="auto", est_min=2,
|
||||
covers=[("linux-fslc", "**")],
|
||||
description="What the image holds ready before any extension package exists, proven on a "
|
||||
"probe process rather than read off a config. The kernel has the cpu, memory, "
|
||||
"and pids controllers, seccomp filters, and landlock, and has no realtime "
|
||||
"group scheduler (the pulse feeder is SCHED_FIFO and must not need a group's "
|
||||
"leave to run). cgroup v2 is mounted with the three controllers handed down "
|
||||
"to /sys/fs/cgroup/ffx, which is idle-class, and forgectrl and the controller "
|
||||
"sit in the root group. In a probe group under it, as the last pool uid: a "
|
||||
"spinning process is held to its cpu.max, stops when the group is frozen and "
|
||||
"runs again when thawed; a fork loop stops at pids.max; and a process that "
|
||||
"takes more than memory.max is killed by the group's own OOM while forgectrl "
|
||||
"keeps its pid. The account pool is ffx0 to ffx31, uid and gid 800 to 831, no "
|
||||
"shell, no home, locked, in the account files as forgefirm-users rendered "
|
||||
"them. The deny rules are in the kernel: a pool uid's TCP connect to "
|
||||
"forgectrl on loopback (both ports, IPv4 and IPv6), to the machine's LAN "
|
||||
"address, and to the Grbl port is refused at once, its UDP send gets EPERM, "
|
||||
"and the rules' counters saw every attempt, while root reaches the same "
|
||||
"listeners. A process that restricts itself with landlock loses /etc and TCP "
|
||||
"connects and keeps /usr; one that installs a seccomp filter gets EPERM from "
|
||||
"the filtered call. The layer content (the recipe, the rules, the distro "
|
||||
"option) is in the platform identity of every fingerprint.")
|
||||
def platform(ctx):
|
||||
ev = ctx.evidence
|
||||
|
||||
# 1. the kernel
|
||||
cfg = kernel_config()
|
||||
ctx.check(cfg is not None, "kernel config unavailable (/proc/config.gz, /boot/config-*)")
|
||||
want = {"CONFIG_CGROUPS": "y", "CONFIG_CGROUP_SCHED": "y", "CONFIG_FAIR_GROUP_SCHED": "y",
|
||||
"CONFIG_CFS_BANDWIDTH": "y", "CONFIG_MEMCG": "y", "CONFIG_CGROUP_PIDS": "y",
|
||||
"CONFIG_SECCOMP_FILTER": "y", "CONFIG_SECURITY_LANDLOCK": "y", "CONFIG_NF_TABLES": "y",
|
||||
"CONFIG_NF_TABLES_INET": "y", "CONFIG_NFT_REJECT_INET": "y", "CONFIG_NFT_LIMIT": "y",
|
||||
"CONFIG_RT_GROUP_SCHED": "n", "CONFIG_NF_CONNTRACK": "n"}
|
||||
got = {k: cfg.get(k, "n") for k in want}
|
||||
ev["kernel"] = got
|
||||
wrong = {k: v for k, v in got.items() if v != want[k]}
|
||||
ctx.check(not wrong, "kernel options: %s (wanted %s)", wrong, {k: want[k] for k in wrong})
|
||||
ctx.check("landlock" in cfg.get("CONFIG_LSM", ""), "landlock is not in CONFIG_LSM: %s", cfg.get("CONFIG_LSM"))
|
||||
|
||||
# 2. the cgroup tree
|
||||
mounts = [l.split() for l in _read("/proc/mounts").splitlines()]
|
||||
ctx.check(any(m[1] == CG and m[2] == "cgroup2" for m in mounts if len(m) > 2), "cgroup2 is not mounted at %s", CG)
|
||||
tree = {"controllers": _read(CG + "/cgroup.controllers").split(),
|
||||
"root_subtree": _read(CG + "/cgroup.subtree_control").split(),
|
||||
"ffx_subtree": _read(POOL_GROUP + "/cgroup.subtree_control").split(),
|
||||
"ffx_cpu_idle": _read(POOL_GROUP + "/cpu.idle").strip()}
|
||||
ev["cgroup_tree"] = tree
|
||||
ctx.log("cgroup tree: %s", tree)
|
||||
for key in ("controllers", "root_subtree", "ffx_subtree"):
|
||||
ctx.check(set(CONTROLLERS) <= set(tree[key]), "%s lacks a controller: %s", key, tree[key])
|
||||
ctx.check(tree["ffx_cpu_idle"] == "1", "the ffx group is not idle-class: cpu.idle=%r", tree["ffx_cpu_idle"])
|
||||
homes = {}
|
||||
for comm in ("forgectrl", "grblHAL_glowfor", "forgetest"):
|
||||
for pid in _pids_of(comm):
|
||||
homes["%s/%d" % (comm, pid)] = _read("/proc/%d/cgroup" % pid).strip()
|
||||
ev["firmware_groups"] = homes
|
||||
ctx.check(any(k.startswith("forgectrl/") for k in homes), "forgectrl is not running")
|
||||
ctx.check(all(v == "0::/" for v in homes.values()), "a firmware process is outside the root group: %s", homes)
|
||||
|
||||
# 3. the account pool, as the boot's render left it
|
||||
passwd = {l.split(":")[0]: l.split(":") for l in _read("/etc/passwd").splitlines() if l.count(":") >= 6}
|
||||
shadow = {l.split(":")[0]: l.split(":") for l in _read("/etc/shadow").splitlines() if ":" in l}
|
||||
group = {l.split(":")[0]: l.split(":") for l in _read("/etc/group").splitlines() if l.count(":") >= 3}
|
||||
bad = []
|
||||
for n in range(POOL_SIZE):
|
||||
name, uid = "ffx%d" % n, str(POOL_FIRST + n)
|
||||
p, s, g = passwd.get(name), shadow.get(name), group.get(name)
|
||||
if not p or p[2] != uid or p[3] != uid or p[5] != "/nonexistent" or p[6] != "/bin/false":
|
||||
bad.append("passwd %s: %s" % (name, p))
|
||||
if not s or s[1][:1] not in ("!", "*"):
|
||||
bad.append("shadow %s is not locked" % name)
|
||||
if not g or g[2] != uid or g[3] != "":
|
||||
bad.append("group %s: %s" % (name, g))
|
||||
others = [p[0] for p in passwd.values() if p[2].isdigit()
|
||||
and POOL_FIRST <= int(p[2]) < POOL_FIRST + POOL_SIZE and not re.match(r"^ffx\d+$", p[0])]
|
||||
ev["pool"] = {"accounts": sum(1 for n in passwd if re.match(r"^ffx\d+$", n)), "problems": bad, "others": others}
|
||||
ctx.check(not bad, "the account pool: %s", bad[:6])
|
||||
ctx.check(not others, "another account sits in the pool's uid range: %s", others)
|
||||
ctx.check(ev["pool"]["accounts"] == POOL_SIZE, "%d ffx accounts, expected %d", ev["pool"]["accounts"], POOL_SIZE)
|
||||
|
||||
uid = POOL_FIRST + POOL_SIZE - 1
|
||||
fc_pid = _pids_of("forgectrl")
|
||||
spin = None
|
||||
try:
|
||||
# 4. a probe group holds a process: cpu.max, the freezer, pids.max, memory.max
|
||||
os.makedirs(PROBE_GROUP, exist_ok=True)
|
||||
_write(PROBE_GROUP + "/cpu.max", "5000 100000")
|
||||
_write(PROBE_GROUP + "/pids.max", "6")
|
||||
_write(PROBE_GROUP + "/memory.max", str(24 << 20))
|
||||
if os.path.exists(PROBE_GROUP + "/memory.swap.max"):
|
||||
_write(PROBE_GROUP + "/memory.swap.max", "0")
|
||||
|
||||
spin = subprocess.Popen([sys.executable, "-c", PROBE, PROBE_GROUP, str(uid), "spin", ""],
|
||||
stdout=subprocess.PIPE, text=True)
|
||||
ctx.check("started" in (spin.stdout.readline() or ""), "the spinning probe did not start")
|
||||
ctx.check(_read("/proc/%d/cgroup" % spin.pid).strip() == "0::/ffx/forgetest-probe",
|
||||
"the probe is in %r", _read("/proc/%d/cgroup" % spin.pid).strip())
|
||||
ctx.check("Uid:\t%d\t%d" % (uid, uid) in _read("/proc/%d/status" % spin.pid), "the probe is not uid %d", uid)
|
||||
ctx.sleep(1.0)
|
||||
u0, t0 = _flat(PROBE_GROUP + "/cpu.stat")["usage_usec"], time.time()
|
||||
ctx.sleep(3.0)
|
||||
u1, t1 = _flat(PROBE_GROUP + "/cpu.stat")["usage_usec"], time.time()
|
||||
share = (u1 - u0) / 1e6 / (t1 - t0)
|
||||
throttled = _flat(PROBE_GROUP + "/cpu.stat").get("nr_throttled", 0)
|
||||
ctx.log("a spinning probe under cpu.max 5%%: %.1f%% of the core, throttled %d times", share * 100, throttled)
|
||||
ctx.check(0.01 < share < 0.08 and throttled > 0, "cpu.max 5%% held the probe to %.1f%% (throttled %d)",
|
||||
share * 100, throttled)
|
||||
|
||||
_write(PROBE_GROUP + "/cgroup.freeze", "1")
|
||||
ok = ctx.wait_for(lambda: _flat(PROBE_GROUP + "/cgroup.events").get("frozen") == 1, 3, poll=0.05)
|
||||
ctx.check(ok is not None, "the group did not freeze: %s", _flat(PROBE_GROUP + "/cgroup.events"))
|
||||
# The kernel reports a task frozen before its last way into
|
||||
# schedule() is charged, so the window opens once that has landed.
|
||||
ctx.sleep(0.2)
|
||||
f0 = _flat(PROBE_GROUP + "/cpu.stat")["usage_usec"]
|
||||
ctx.sleep(1.5)
|
||||
f1 = _flat(PROBE_GROUP + "/cpu.stat")["usage_usec"]
|
||||
_write(PROBE_GROUP + "/cgroup.freeze", "0")
|
||||
ok = ctx.wait_for(lambda: _flat(PROBE_GROUP + "/cgroup.events").get("frozen") == 0, 3, poll=0.05)
|
||||
ctx.check(ok is not None, "the group did not thaw")
|
||||
ctx.sleep(1.5)
|
||||
f2 = _flat(PROBE_GROUP + "/cpu.stat")["usage_usec"]
|
||||
ctx.log("frozen for 1.5 s the probe ran %d us; thawed for 1.5 s it ran %d us", f1 - f0, f2 - f1)
|
||||
ctx.check(f1 - f0 == 0, "a frozen probe ran %d us", f1 - f0)
|
||||
ctx.check(f2 - f1 > 10000, "a thawed probe ran only %d us", f2 - f1)
|
||||
_write(PROBE_GROUP + "/cgroup.kill", "1")
|
||||
spin.wait(timeout=5)
|
||||
ctx.check(spin.returncode == -signal.SIGKILL, "cgroup.kill left the probe with %s", spin.returncode)
|
||||
spin = None
|
||||
ev["cpu"] = {"share": round(share, 4), "throttled": throttled, "frozen_us": f1 - f0, "thawed_us": f2 - f1}
|
||||
|
||||
rc, lines, err = _probe(PROBE_GROUP, uid, "forks", "12")
|
||||
forks = lines[-1] if lines else {}
|
||||
hits = _flat(PROBE_GROUP + "/pids.events").get("max", 0)
|
||||
ev["pids"] = {"result": forks, "events_max": hits}
|
||||
ctx.log("pids.max 6: the fork loop made %s of 12, stopped by %s; pids.events max %d",
|
||||
forks.get("forked"), forks.get("stopped_by"), hits)
|
||||
ctx.check(rc == 0 and forks.get("forked") == 5 and forks.get("stopped_by") == "EAGAIN" and hits > 0,
|
||||
"pids.max 6 did not stop the fork loop at 5: rc %s %s %s, events max %d", rc, forks, err, hits)
|
||||
|
||||
rc, lines, err = _probe(PROBE_GROUP, uid, "eat", "96", wait=60)
|
||||
kills = _flat(PROBE_GROUP + "/memory.events").get("oom_kill", 0)
|
||||
ev["memory"] = {"rc": rc, "lines": lines, "oom_kill": kills, "peak": _read(PROBE_GROUP + "/memory.peak").strip()}
|
||||
ctx.log("memory.max 24 MiB against a 96 MiB appetite: rc %s, oom_kill %d, peak %s", rc, kills, ev["memory"]["peak"])
|
||||
ctx.check(rc == -signal.SIGKILL and kills > 0 and not any(l.get("survived") for l in lines),
|
||||
"memory.max did not kill the probe: rc %s, oom_kill %d, %s %s", rc, kills, lines, err)
|
||||
ctx.check(_pids_of("forgectrl") == fc_pid, "forgectrl's pid moved across the group's OOM: %s -> %s",
|
||||
fc_pid, _pids_of("forgectrl"))
|
||||
|
||||
# 5. the deny rules
|
||||
p = subprocess.run([NFT, "list", "table", "inet", "ffx"], capture_output=True, text=True)
|
||||
ctx.check(p.returncode == 0, "table inet ffx is not loaded: %s", p.stderr.strip()[:200])
|
||||
ev["rules"] = p.stdout
|
||||
ctx.check("meta skuid %d-%d jump pool" % (POOL_FIRST, uid) in p.stdout and "hook output" in p.stdout
|
||||
and "policy accept" in p.stdout, "table inet ffx is not the image's: %s", p.stdout[:400])
|
||||
pool = p.stdout[p.stdout.find("chain pool"):].split("}")[0]
|
||||
ctx.check(0 <= pool.find('oifname "lo" jump refuse') < pool.find("vmap @allow"),
|
||||
"the machine itself is not refused ahead of the allow map: %s", pool)
|
||||
lan = lan_ip()
|
||||
ctx.check(lan, "the machine has no LAN address to aim at")
|
||||
tcp = [[4, "127.0.0.1", 443, False], [4, "127.0.0.1", 80, False], [6, "::1", 443, False],
|
||||
[6, "::1", 23, False], [4, lan, 443, False], [4, lan, 23, False]]
|
||||
udp = [[4, "127.0.0.1", 9, True], [4, lan, 9, True]]
|
||||
control = [[4, "127.0.0.1", 443, False], [4, lan, 443, False], [4, "127.0.0.1", 9, True]]
|
||||
rc, lines, err = _probe("-", 0, "net", json.dumps(control))
|
||||
as_root = (lines[-1] if lines else {}).get("results", [])
|
||||
ev["as_root"] = as_root
|
||||
ctx.check(len(as_root) == len(control) and all(r[1][0] == "ok" for r in as_root),
|
||||
"root does not reach its own listeners: %s %s", as_root, err)
|
||||
c0 = _pool_counters()
|
||||
heard = {}
|
||||
for who in (POOL_FIRST, uid):
|
||||
rc, lines, err = _probe("-", who, "net", json.dumps(tcp + udp))
|
||||
res = (lines[-1] if lines else {}).get("results", [])
|
||||
heard[str(who)] = res
|
||||
ctx.check(len(res) == len(tcp + udp), "the probe as uid %d said %s %s", who, lines, err)
|
||||
for target, (word, took) in res:
|
||||
if target[3]:
|
||||
ctx.check(word == "eperm", "uid %d UDP to %s -> %s", who, target[1], word)
|
||||
else:
|
||||
ctx.check(word == "refused" and took < 1.5, "uid %d TCP to %s port %d -> %s in %.2f s",
|
||||
who, target[1], target[2], word, took)
|
||||
c1 = _pool_counters()
|
||||
ev["as_pool_uid"] = heard
|
||||
ev["counters"] = [c0, c1]
|
||||
ctx.log("two pool uids: %d TCP connects each refused at once, %d UDP sends each EPERM; the rules "
|
||||
"counted %s -> %s", len(tcp), len(udp), c0, c1)
|
||||
ctx.check(c0 is not None and c1 is not None and len(c1) == 2 and c1[0] - c0[0] >= 2 * len(tcp)
|
||||
and c1[1] - c0[1] >= 2 * len(udp), "the rules did not count the attempts: %s -> %s", c0, c1)
|
||||
|
||||
# The machine itself is not a destination, whatever the allow map says: an
|
||||
# allow chain that names forgectrl on loopback and on the LAN address
|
||||
# opens neither.
|
||||
chain = "u%d" % uid
|
||||
rules = ["add chain inet ffx %s" % chain,
|
||||
"add rule inet ffx %s ip daddr { 127.0.0.1, %s } tcp dport 443 accept" % (chain, lan),
|
||||
"add element inet ffx allow { %d : jump %s }" % (uid, chain), ""]
|
||||
opened = subprocess.run([NFT, "-f", "-"], capture_output=True, text=True, input=os.linesep.join(rules))
|
||||
try:
|
||||
ctx.check(opened.returncode == 0, "could not add an allow chain: %s", opened.stderr.strip()[:200])
|
||||
rc, lines, err = _probe("-", uid, "net", json.dumps([[4, "127.0.0.1", 443, False], [4, lan, 443, False]]))
|
||||
res = (lines[-1] if lines else {}).get("results", [])
|
||||
ev["allowlisted_self"] = res
|
||||
ctx.log("uid %d with the machine's own addresses on its allowlist: %s", uid, res)
|
||||
ctx.check(len(res) == 2 and all(r[1][0] == "refused" for r in res),
|
||||
"an allowlist opened the machine itself to a pool uid: %s %s", res, err)
|
||||
finally:
|
||||
subprocess.run([NFT, "delete", "element", "inet", "ffx", "allow", "{ %d }" % uid], capture_output=True)
|
||||
subprocess.run([NFT, "flush", "chain", "inet", "ffx", chain], capture_output=True)
|
||||
subprocess.run([NFT, "delete", "chain", "inet", "ffx", chain], capture_output=True)
|
||||
left = subprocess.run([NFT, "list", "chain", "inet", "ffx", chain], capture_output=True)
|
||||
ctx.check(left.returncode != 0, "the test's allow chain stayed behind")
|
||||
|
||||
# 6. landlock and seccomp, on a root process so that neither the uid nor the rules explain the refusal
|
||||
rc, lines, err = _probe("-", 0, "landlock", json.dumps([4, "127.0.0.1", 443, False]))
|
||||
ll = lines[-1] if lines else {}
|
||||
ev["landlock"] = ll
|
||||
ctx.log("landlock ABI %s: before %s, after %s", ll.get("abi"), ll.get("before"), ll.get("after"))
|
||||
ctx.check(rc == 0 and ll.get("abi", 0) >= 4, "landlock ABI %s (4 brings the TCP rules) %s", ll.get("abi"), err)
|
||||
ctx.check(ll["before"] == {"etc": "ok", "usr": "ok", "tcp": "ok"}, "before the restriction: %s", ll["before"])
|
||||
after = ll["after"]
|
||||
ctx.check(after.get("ruleset") and after.get("added") == 0 and after.get("applied") == 0,
|
||||
"the ruleset did not apply: %s", after)
|
||||
ctx.check(after.get("etc") == "eacces" and after.get("tcp") == "eacces" and after.get("usr") == "ok",
|
||||
"after the restriction (/etc and TCP lost, /usr kept): %s", after)
|
||||
|
||||
rc, lines, err = _probe("-", 0, "seccomp")
|
||||
sc = lines[-1] if lines else {}
|
||||
ev["seccomp"] = sc
|
||||
ctx.log("seccomp: %s", sc)
|
||||
ctx.check(rc == 0 and sc.get("installed") == 0 and sc.get("before") == "Linux" and sc.get("after") == "eperm"
|
||||
and sc.get("status") == ["2"], "a seccomp filter did not take: rc %s %s %s", rc, sc, err)
|
||||
finally:
|
||||
if spin is not None:
|
||||
spin.kill()
|
||||
spin.wait(timeout=5)
|
||||
if os.path.isdir(PROBE_GROUP):
|
||||
try:
|
||||
_write(PROBE_GROUP + "/cgroup.freeze", "0")
|
||||
_write(PROBE_GROUP + "/cgroup.kill", "1")
|
||||
except OSError:
|
||||
pass
|
||||
for _ in range(50):
|
||||
if not _read(PROBE_GROUP + "/cgroup.procs").strip():
|
||||
break
|
||||
time.sleep(0.1)
|
||||
try:
|
||||
os.rmdir(PROBE_GROUP)
|
||||
except OSError as e:
|
||||
ctx.log("the probe group stayed behind: %s", e)
|
||||
ctx.check(not os.path.isdir(PROBE_GROUP), "the probe group stayed behind")
|
||||
ctx.log("PASS: the kernel, the cgroup tree, the account pool, and the deny rules are in place, and each "
|
||||
"held a probe process the way it will hold a package")
|
||||
|
||||
|
||||
# ------------------------------------------------------------ the host
|
||||
|
||||
FORGEEXT = "/usr/bin/forgeext"
|
||||
FWUP = "/usr/bin/fwup"
|
||||
EXT_ROOT = "/data/forgefirm/ext"
|
||||
HOST_STATUS = "/run/forgefirm/ext/status.json"
|
||||
SAFE_FILE = "/run/forgefirm/ext-safe"
|
||||
REF_ID = "org.forgetest.reference"
|
||||
REF_KEY = "forgetest-reference"
|
||||
REF_DEST = "192.0.2.1" # TEST-NET-1: declared so that port 443 is, and never dialed
|
||||
|
||||
# The reference package's service. It looks at its own confinement from the
|
||||
# inside, leaves what it found in its data directory, and stays up.
|
||||
REF_SERVICE = r'''
|
||||
import errno, json, os, socket, sys, time
|
||||
lan = sys.argv[1]
|
||||
data, pkg = os.environ["FFX_DATA"], os.environ["FFX_PKG"]
|
||||
if os.path.exists(os.path.join(data, "stop")):
|
||||
sys.exit(3)
|
||||
|
||||
|
||||
def word(e):
|
||||
return errno.errorcode.get(e.errno, str(e.errno))
|
||||
|
||||
|
||||
def api(method, path, body=None):
|
||||
"""One request to the host over the socket named in FFX_API: (status, JSON)."""
|
||||
payload = json.dumps(body).encode() if body is not None else b""
|
||||
head = "%s %s HTTP/1.1\r\nHost: forgeext\r\n" % (method, path)
|
||||
if body is not None:
|
||||
head += "Content-Type: application/json\r\nContent-Length: %d\r\n" % len(payload)
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
s.settimeout(5)
|
||||
try:
|
||||
s.connect(os.environ["FFX_API"])
|
||||
s.sendall(head.encode() + b"\r\n" + payload)
|
||||
buf = b""
|
||||
while True:
|
||||
c = s.recv(65536)
|
||||
if not c:
|
||||
break
|
||||
buf += c
|
||||
top, _, text = buf.partition(b"\r\n\r\n")
|
||||
return int(top.split()[1]), json.loads(text or b"null")
|
||||
except (OSError, ValueError, IndexError) as e:
|
||||
return 599, {"error": str(e)}
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def shot(body):
|
||||
"""POST /v0/camera: (status, bytes or the error's words). A frame is
|
||||
bytes, so this one does not try to read the answer as JSON."""
|
||||
payload = json.dumps(body).encode()
|
||||
head = ("POST /v0/camera HTTP/1.1\r\nHost: forgeext\r\nContent-Type: application/json\r\n"
|
||||
"Content-Length: %d\r\n\r\n" % len(payload))
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
s.settimeout(40)
|
||||
try:
|
||||
s.connect(os.environ["FFX_API"])
|
||||
s.sendall(head.encode() + payload)
|
||||
buf = b""
|
||||
while True:
|
||||
c = s.recv(65536)
|
||||
if not c:
|
||||
break
|
||||
buf += c
|
||||
top, _, rest = buf.partition(b"\r\n\r\n")
|
||||
code = int(top.split()[1])
|
||||
if code == 200:
|
||||
return code, len(rest), rest[:2] == b"\xff\xd8", b"image/jpeg" in top
|
||||
try:
|
||||
return code, json.loads(rest or b"{}").get("error", ""), False, False
|
||||
except ValueError:
|
||||
return code, rest[:80].decode("utf-8", "replace"), False, False
|
||||
except (OSError, ValueError, IndexError) as e:
|
||||
return 599, str(e), False, False
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def opened(path, mode="r"):
|
||||
try:
|
||||
with open(path, mode) as f:
|
||||
if "r" in mode:
|
||||
f.read(1)
|
||||
else:
|
||||
f.write("x")
|
||||
return "ok"
|
||||
except OSError as e:
|
||||
return word(e)
|
||||
|
||||
|
||||
def dial(addr, port):
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(4.0)
|
||||
try:
|
||||
s.connect((addr, port))
|
||||
return "connected"
|
||||
except socket.timeout:
|
||||
return "timeout"
|
||||
except OSError as e:
|
||||
return word(e)
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def family(af):
|
||||
try:
|
||||
socket.socket(af, socket.SOCK_RAW if af == 16 else socket.SOCK_DGRAM).close()
|
||||
return "ok"
|
||||
except OSError as e:
|
||||
return word(e)
|
||||
|
||||
|
||||
report = {
|
||||
"uid": os.getuid(), "gid": os.getgid(), "groups": os.getgroups(), "cwd": os.getcwd(),
|
||||
"env": sorted(os.environ),
|
||||
"read_settings": opened("/data/forgefirm/forgefirm.conf"),
|
||||
"read_setup": opened("/data/forgefirm/setup.json"),
|
||||
"read_etc": opened("/etc/hostname"),
|
||||
"read_pkg": opened(os.path.join(pkg, "manifest.json")),
|
||||
"write_pkg": opened(os.path.join(pkg, "x"), "w"),
|
||||
"write_data": opened(os.path.join(data, "scratch"), "w"),
|
||||
"write_tmp": opened("/tmp/forgetest-reference", "w"),
|
||||
"pulse_device": opened("/dev/glowforge"),
|
||||
"loopback_declared_port": dial("127.0.0.1", 443),
|
||||
"lan_declared_port": dial(lan, 443) if lan != "-" else "skipped",
|
||||
"loopback_undeclared_port": dial("127.0.0.1", 80),
|
||||
"netlink_socket": family(16),
|
||||
"unix_socket": family(1),
|
||||
"api_camera": shot({"camera": "lid", "resolution": "half"}),
|
||||
"api_camera_head": shot({"camera": "head"}),
|
||||
"api_camera_bad": shot({"camera": "bed"}),
|
||||
"api_camera_dark": shot({"camera": "lid", "resolution": "half", "lamp": 0}),
|
||||
"api_camera_lit": shot({"camera": "lid", "resolution": "half", "lamp": 1023}),
|
||||
"api_camera_lamp_bad": shot({"camera": "lid", "lamp": 5000}),
|
||||
"api_settings": api("GET", "/v0/settings"),
|
||||
"api_settings_set": api("POST", "/v0/settings", {"threshold": 70, "note": "set from inside"}),
|
||||
"api_settings_undeclared": api("POST", "/v0/settings", {"nothere": 1}),
|
||||
"api_settings_out_of_bounds": api("POST", "/v0/settings", {"threshold": 9000}),
|
||||
"api_self": api("GET", "/v0/self"),
|
||||
"api_mode": api("GET", "/v0/machine/mode"),
|
||||
"api_hold": api("GET", "/v0/hold"),
|
||||
"api_nowhere": api("GET", "/v0/nowhere"),
|
||||
"api_traversal": api("GET", "/v0/machine/../../settings"),
|
||||
}
|
||||
with open(os.path.join(data, "report.json.new"), "w") as f:
|
||||
json.dump(report, f)
|
||||
os.rename(os.path.join(data, "report.json.new"), os.path.join(data, "report.json"))
|
||||
print("reference service up", flush=True)
|
||||
mine = api("GET", "/v0/self")[1] or {}
|
||||
watching = "events" in (mine.get("capabilities") or [])
|
||||
place, seen, polls = None, [], []
|
||||
n = 0
|
||||
while True:
|
||||
n += 1
|
||||
with open(os.path.join(data, "beat"), "w") as f:
|
||||
f.write(str(n))
|
||||
if watching:
|
||||
# A poll that waits: it comes back with an event as soon as there
|
||||
# is one, and with nothing when its two seconds are up.
|
||||
ask = {"wait": 2} if place is None else {"since": place, "wait": 2}
|
||||
began = time.time()
|
||||
st, ans = api("POST", "/v0/events", ask)
|
||||
took = round(time.time() - began, 2)
|
||||
if st == 200:
|
||||
place = ans.get("next")
|
||||
seen = (seen + [e.get("event") for e in ans.get("events") or []])[-40:]
|
||||
polls = (polls + [{"status": st, "took": took, "placed": "since" in ask,
|
||||
"got": len(ans.get("events") or []) if isinstance(ans, dict) else 0}])[-20:]
|
||||
with open(os.path.join(data, "events.json.new"), "w") as f:
|
||||
json.dump({"place": place, "seen": seen, "polls": polls, "last": ans,
|
||||
"connected": ans.get("connected") if isinstance(ans, dict) else None}, f)
|
||||
os.rename(os.path.join(data, "events.json.new"), os.path.join(data, "events.json"))
|
||||
# the test's word to the service: write a program and run it
|
||||
runf = os.path.join(data, "run")
|
||||
if os.path.exists(runf):
|
||||
with open(runf) as f:
|
||||
words = json.load(f)
|
||||
os.remove(runf)
|
||||
if words.get("program_text") is not None:
|
||||
with open(os.path.join(data, words.get("program", "job.gcode")), "w") as f:
|
||||
f.write(words["program_text"])
|
||||
body = {k: v for k, v in words.items() if k not in ("program_text",)}
|
||||
with open(os.path.join(data, "ran.new"), "w") as f:
|
||||
json.dump(api("POST", "/v0/motion/job", body), f)
|
||||
os.rename(os.path.join(data, "ran.new"), os.path.join(data, "ran"))
|
||||
# the test's word to the service: jog now
|
||||
jog = os.path.join(data, "jog")
|
||||
if os.path.exists(jog):
|
||||
with open(jog) as f:
|
||||
words = json.load(f)
|
||||
os.remove(jog)
|
||||
with open(os.path.join(data, "jogged.new"), "w") as f:
|
||||
json.dump(api("POST", "/v0/motion/jog", words), f)
|
||||
os.rename(os.path.join(data, "jogged.new"), os.path.join(data, "jogged"))
|
||||
# the test's word to the service: take a capture now
|
||||
ask = os.path.join(data, "shoot")
|
||||
if os.path.exists(ask):
|
||||
os.remove(ask)
|
||||
with open(os.path.join(data, "shot.new"), "w") as f:
|
||||
json.dump(shot({"camera": "lid", "resolution": "half"}), f)
|
||||
os.rename(os.path.join(data, "shot.new"), os.path.join(data, "shot"))
|
||||
# the test's word to the service: what to say of its hold
|
||||
say = os.path.join(data, "say")
|
||||
if os.path.exists(say):
|
||||
with open(say) as f:
|
||||
words = json.load(f)
|
||||
os.remove(say)
|
||||
with open(os.path.join(data, "said.new"), "w") as f:
|
||||
json.dump(api("POST", "/v0/hold", words), f)
|
||||
os.rename(os.path.join(data, "said.new"), os.path.join(data, "said"))
|
||||
time.sleep(0.5)
|
||||
'''
|
||||
|
||||
|
||||
def _host_pids():
|
||||
"""The extension host: /usr/bin/forgeext run, and not an install or a
|
||||
check someone has under way."""
|
||||
out = []
|
||||
for d in os.listdir("/proc"):
|
||||
if not d.isdigit():
|
||||
continue
|
||||
try:
|
||||
if os.readlink("/proc/%s/exe" % d) != FORGEEXT:
|
||||
continue
|
||||
with open("/proc/%s/cmdline" % d, "rb") as f:
|
||||
argv = f.read().split(b"\0")
|
||||
except OSError:
|
||||
continue
|
||||
if argv[:2] == [FORGEEXT.encode(), b"run"]:
|
||||
out.append(int(d))
|
||||
return out
|
||||
|
||||
|
||||
def _host_status():
|
||||
try:
|
||||
with open(HOST_STATUS) as f:
|
||||
return json.load(f)
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def _svc(id_):
|
||||
return next((x for x in _host_status().get("services", []) if x.get("id") == id_), {})
|
||||
|
||||
|
||||
def _forgeext(*args, wait=120):
|
||||
p = subprocess.run([FORGEEXT] + list(args), capture_output=True, text=True, timeout=wait)
|
||||
try:
|
||||
return json.loads(p.stdout)
|
||||
except ValueError:
|
||||
return {"ok": False, "error": "no JSON answer (exit %s): %s %s" % (p.returncode, p.stdout[-200:], p.stderr[-200:])}
|
||||
|
||||
|
||||
def _until(ctx, cond, seconds, poll=0.25):
|
||||
end = time.time() + seconds
|
||||
while time.time() < end:
|
||||
v = cond()
|
||||
if v:
|
||||
return v
|
||||
ctx.checkpoint()
|
||||
time.sleep(poll)
|
||||
return cond()
|
||||
|
||||
|
||||
def _tree(root):
|
||||
"""Every path under the extension root but the host's own bookkeeping."""
|
||||
out = []
|
||||
for base, dirs, files in os.walk(root):
|
||||
for n in dirs + files:
|
||||
rel = os.path.relpath(os.path.join(base, n), root)
|
||||
if rel not in ("state.json", "lock"):
|
||||
out.append(rel)
|
||||
return sorted(out)
|
||||
|
||||
|
||||
REF_UI = ("<!doctype html><title>forgetest reference</title>\n"
|
||||
"<p>the reference package's own page</p>\n")
|
||||
|
||||
|
||||
def _pack_reference(work, lan, more_caps=(), ui=None):
|
||||
"""The reference package, signed with a key made here: (archive, public key)."""
|
||||
import io
|
||||
import tarfile
|
||||
manifest = {"manifest": 1, "id": REF_ID, "name": "forgetest reference", "version": "1.0.0",
|
||||
"author": "forgetest", "license": "MIT", "api": "0.1", "runtime": "python",
|
||||
"service": {"exec": "bin/reference.py", "args": [lan or "-"]},
|
||||
"settings": {"threshold": {"type": "number", "default": 40, "min": 0, "max": 100},
|
||||
"note": {"type": "string", "default": "", "max": 32, "label": "A note"},
|
||||
"when": {"type": "choice", "default": "end",
|
||||
"choices": ["start", "end", "never"]}},
|
||||
"capabilities": ["net.outbound:%s:443" % REF_DEST, "storage:1", "machine.read",
|
||||
"settings.own", "camera.lid"] + list(more_caps)}
|
||||
payload = os.path.join(work, "payload.tar.gz")
|
||||
with tarfile.open(payload, "w:gz") as t:
|
||||
files = [("manifest.json", json.dumps(manifest), 0o644),
|
||||
("bin/reference.py", REF_SERVICE, 0o755)]
|
||||
if ui is None:
|
||||
ui = "ui" in (manifest.get("capabilities") or [])
|
||||
if ui:
|
||||
files.append(("ui/index.html", REF_UI, 0o644))
|
||||
for name, text, mode in files:
|
||||
info = tarfile.TarInfo(name)
|
||||
data = text.encode()
|
||||
info.size, info.mode = len(data), mode
|
||||
t.addfile(info, io.BytesIO(data))
|
||||
conf = os.path.join(work, "fwup.conf")
|
||||
_write(conf, 'meta-product = "ForgeFIRM extension"\nmeta-description = "%s"\nmeta-version = "1.0.0"\n'
|
||||
'meta-platform = "forgefirm-ext"\nfile-resource payload.tar.gz {\n host-path = "%s"\n}\n'
|
||||
% (REF_ID, payload))
|
||||
key = os.path.join(work, REF_KEY)
|
||||
raw, signed = os.path.join(work, "raw.ffx"), os.path.join(work, "reference.ffx")
|
||||
for cmd in ([FWUP, "-g", "-o", key], [FWUP, "-c", "-f", conf, "-o", raw],
|
||||
[FWUP, "-S", "-s", key + ".priv", "-i", raw, "-o", signed]):
|
||||
subprocess.run(cmd, check=True, capture_output=True, timeout=60, cwd=work)
|
||||
return signed, key + ".pub"
|
||||
|
||||
|
||||
def _put_back(ctx, fc, work, prior, etag, raw, dir_mode):
|
||||
"""Everything a test of the host put on the machine, taken away again:
|
||||
safe mode, the package, the owner key, the work directory, the
|
||||
setting, the data directory's mode, and the setup record (under a
|
||||
forgectrl restart)."""
|
||||
import shutil
|
||||
if os.path.exists(SAFE_FILE):
|
||||
os.remove(SAFE_FILE)
|
||||
st, reply = fc.post("/settings", data={"ext_enabled": "0"})
|
||||
r = _forgeext("remove", REF_ID)
|
||||
ctx.log("remove %s -> %s", REF_ID, "ok" if r.get("ok") else r.get("error"))
|
||||
owner_key = os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub")
|
||||
if os.path.exists(owner_key):
|
||||
os.remove(owner_key)
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
if prior == "1":
|
||||
st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE})
|
||||
elif prior == "":
|
||||
st, reply = fc.post("/settings", params={"ext_enabled": ""})
|
||||
ctx.log("restore ext_enabled=%r -> %s", prior, st)
|
||||
if prior != "1":
|
||||
os.chmod(os.path.dirname(EXT_ROOT), dir_mode)
|
||||
with ctx.takeover():
|
||||
write_file(record_path(), raw)
|
||||
ctx.log("the previous record is back under a restart")
|
||||
|
||||
|
||||
def _as_found(ctx, fc, prior, raw, dir_mode, found_tree):
|
||||
ctx.check((fc.settings().get("ext_enabled") or "") == prior, "ext_enabled not restored: %r, was %r",
|
||||
fc.settings().get("ext_enabled"), prior)
|
||||
ctx.check(read_file(record_path()) == raw, "the setup record on disk is not the one found")
|
||||
ctx.check(prior == "1" or os.stat(os.path.dirname(EXT_ROOT)).st_mode & 0o7777 == dir_mode,
|
||||
"the data directory's mode is not the one found")
|
||||
left = _tree(EXT_ROOT)
|
||||
ctx.check(left == found_tree, "the extension root is not as found: %s", sorted(set(left) ^ set(found_tree)))
|
||||
ctx.check(len(_host_pids()) == 1, "the extension host is not running at the end")
|
||||
|
||||
|
||||
# ------------------------------------------------- the armed window
|
||||
|
||||
def _frozen(id_):
|
||||
ev = _read("%s/%s/cgroup.events" % (POOL_GROUP, id_))
|
||||
return "frozen 1" in ev if ev else None
|
||||
|
||||
|
||||
@test("exthost.armed-freeze", title="A package's service is frozen for the armed window",
|
||||
subsystem="exthost", kind="operator", hardware="takeover", est_min=6,
|
||||
covers=[("forgeext", "src/super.*"), ("forgeext", "src/run.*"), ("forgeext", "src/machine.*"),
|
||||
("forgeext", "src/cgroup.*"), ("forgectrl", "src/cool.*"), ("forgectrl", "src/cam.*"),
|
||||
("forgectrl", "src/main.c")],
|
||||
requires=["exthost.platform"], actions=["button"],
|
||||
steps=[OFFLINE_STEP,
|
||||
"Bed clear (the job is dark: a 30 s square at S0, nothing fires). Press the button "
|
||||
"when it lights."],
|
||||
description="The reference package runs, its heartbeat advancing twice a second, and a dark "
|
||||
"cloud print opens a real armed window over it: the cloud suite's dark-print "
|
||||
"helper, whose latch checks still run (locked at the button, unlocked for the run, "
|
||||
"locked after it), and the print completes. Sampled five times a second from before the button to after the end: the "
|
||||
"engine's armed flag, the group's frozen state as the kernel reports it, and the "
|
||||
"heartbeat. The window is open for at least 10 s. From 2 s after it opens to its "
|
||||
"close the group reads frozen in every sample and the heartbeat does not move, the "
|
||||
"freeze is in place before the run starts (the latch unlocks only for the run), and "
|
||||
"within 3 s of the close the group is thawed and the heartbeat advances again. The "
|
||||
"service is the same process throughout. A background capture is refused in words "
|
||||
"while the window is open and served again once it closes, which is the other half "
|
||||
"of keeping a package off the step stream: a capture costs kernel-side work that a "
|
||||
"thread priority does not cover. The package, the key, the setting and the setup "
|
||||
"record are put back as found, the record under a forgectrl restart.")
|
||||
def armed_freeze(ctx):
|
||||
import tempfile
|
||||
import threading
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
ctx.check(len(_host_pids()) == 1, "the extension host is not one running process: %s", _host_pids())
|
||||
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle: settings are locked")
|
||||
prior = fc.settings().get("ext_enabled") or ""
|
||||
raw = read_file(record_path())
|
||||
ctx.check(raw, "no setup record at %s", record_path())
|
||||
ctx.check(not os.path.exists(SAFE_FILE), "%s exists: the machine is in safe mode", SAFE_FILE)
|
||||
found_tree = _tree(EXT_ROOT)
|
||||
dir_mode = os.stat(os.path.dirname(EXT_ROOT)).st_mode & 0o7777
|
||||
st, body, hdrs = request(fc.base, "GET", "/advisories/extensions", headers={"Host": fc.host_header()})
|
||||
etag = hdrs.get("etag")
|
||||
ctx.check(st == 200 and etag, "GET /advisories/extensions -> %s", st)
|
||||
work = tempfile.mkdtemp(prefix="forgetest-ffx.")
|
||||
beat_path = os.path.join(EXT_ROOT, "data", REF_ID, "beat")
|
||||
samples = []
|
||||
probe = {}
|
||||
stop = threading.Event()
|
||||
|
||||
def sampler():
|
||||
while not stop.is_set():
|
||||
st_, cool = fc.get("/cool/status")
|
||||
armed = bool(cool.get("armed")) if st_ == 200 and isinstance(cool, dict) else None
|
||||
samples.append((time.time(), armed, _frozen(REF_ID), _read(beat_path).strip(),
|
||||
(_svc(REF_ID) or {}).get("pid"), latch_locked()))
|
||||
# One background capture from inside the window. The refusal is
|
||||
# decided before any frame is taken, so it costs the cut nothing;
|
||||
# if it were served instead, that is what this case is here to
|
||||
# catch, and the capture is the one the rule forbids.
|
||||
if armed and "in" not in probe:
|
||||
probe["in"] = fc.get("/cam/snapshot", params={"cam": "lid", "background": "1"}, raw=True)
|
||||
time.sleep(0.2)
|
||||
|
||||
try:
|
||||
archive, pub = _pack_reference(work, lan_ip())
|
||||
with open(pub, "rb") as f:
|
||||
write_file(os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub"), f.read())
|
||||
os.chmod(os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub"), 0o644)
|
||||
r = _forgeext("install", archive, "--consent-community")
|
||||
ctx.check(r.get("ok") is True, "the install -> %s", r.get("error"))
|
||||
st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE})
|
||||
ctx.check(st == 200, "ext_enabled=1 over the advisory -> %s %r", st, reply)
|
||||
seen = set()
|
||||
ctx.check(_until(ctx, lambda: seen.add(_read(beat_path)) or len(seen) >= 4, 120, poll=0.2),
|
||||
"the reference service's heartbeat does not advance: %s", _svc(REF_ID) or _host_status())
|
||||
pid = _svc(REF_ID).get("pid")
|
||||
|
||||
offset = enter_offline(ctx)
|
||||
job = offline_job(ctx, "dark.puls", seconds=30)
|
||||
off = Offline().__enter__()
|
||||
t = threading.Thread(target=sampler, daemon=True)
|
||||
t.start()
|
||||
try:
|
||||
dark_print_body(ctx, ev, off, job, offset, fc)
|
||||
ctx.sleep(6) # past the close, for the thaw
|
||||
finally:
|
||||
stop.set()
|
||||
t.join(timeout=5)
|
||||
off.__exit__(None, None, None)
|
||||
offline_cleanup(ctx)
|
||||
|
||||
opens = [x for x in samples if x[1]]
|
||||
ctx.check(opens, "the engine never read armed across the print: %d samples", len(samples))
|
||||
t_open, t_close = opens[0][0], opens[-1][0]
|
||||
ev["window_s"] = round(t_close - t_open, 1)
|
||||
ctx.check(t_close - t_open >= 10, "the armed window was open for %.1f s only", t_close - t_open)
|
||||
inside = [x for x in samples if t_open + 2.0 <= x[0] <= t_close]
|
||||
thawed = [x for x in inside if x[2] is not True]
|
||||
ev["samples"] = {"all": len(samples), "inside": len(inside), "not_frozen_inside": len(thawed)}
|
||||
first_frozen = next((x[0] for x in samples if x[0] >= t_open and x[2] is True), None)
|
||||
ev["freeze_lag_s"] = round(first_frozen - t_open, 2) if first_frozen else None
|
||||
ctx.check(inside and not thawed, "inside the armed window the group read not frozen in %d of %d samples "
|
||||
"(the first %.1f s after it opened)", len(thawed), len(inside), (thawed[0][0] - t_open) if thawed else 0)
|
||||
beats = {x[3] for x in inside}
|
||||
ctx.check(len(beats) == 1, "the heartbeat moved inside the armed window: %s", sorted(beats)[:6])
|
||||
# the latch unlocks only for the run: its first unlocked sample is the run's start
|
||||
run_start = next((x[0] for x in samples if x[5] is False), None)
|
||||
ctx.check(run_start is not None, "the latch never read unlocked: the print did not run under the samples")
|
||||
ctx.check(first_frozen is not None and first_frozen <= run_start,
|
||||
"the run started before the freeze was in place (frozen %s, the latch unlocked %s after the window opened)",
|
||||
None if first_frozen is None else round(first_frozen - t_open, 2), round(run_start - t_open, 2))
|
||||
ev["freeze_led_the_run_s"] = round(run_start - first_frozen, 2)
|
||||
after = [x for x in samples if x[0] >= t_close + 3.0]
|
||||
ctx.check(after and all(x[2] is False for x in after), "3 s after the close the group is not thawed: %s",
|
||||
[x[2] for x in after][:8])
|
||||
ctx.check(len({x[3] for x in after}) >= 2, "the heartbeat did not advance again after the close")
|
||||
last_frozen = max((x[0] for x in samples if x[2] is True), default=None)
|
||||
ev["thaw_lag_s"] = round(last_frozen - t_close, 2) if last_frozen else None
|
||||
ctx.check({x[4] for x in samples if x[4]} == {pid}, "the service did not stay the same process: %s",
|
||||
sorted({x[4] for x in samples if x[4]}))
|
||||
|
||||
# The camera, inside the window and after it.
|
||||
ctx.check("in" in probe, "no background capture was tried inside the armed window")
|
||||
cam_st, cam_body = probe.get("in", (None, b""))
|
||||
words = cam_body.decode("utf-8", "replace")[:200] if isinstance(cam_body, bytes) else str(cam_body)[:200]
|
||||
ev["camera_in_window"] = {"status": cam_st, "said": words}
|
||||
ctx.check(cam_st == 409, "a background capture inside the armed window -> %s (%d bytes)", cam_st,
|
||||
len(cam_body or b""))
|
||||
ctx.check("armed" in words, "the refusal does not say why: %s", words)
|
||||
st, after_body = fc.get("/cam/snapshot", params={"cam": "lid", "background": "1"}, raw=True)
|
||||
ev["camera_after_window"] = {"status": st, "bytes": len(after_body or b"")}
|
||||
ctx.check(st == 200 and (after_body or b"")[:2].hex() == "ffd8",
|
||||
"a background capture after the window -> %s (%d bytes)", st, len(after_body or b""))
|
||||
ctx.log("a background capture inside the window -> %s (%s); after it -> %s, %d bytes", cam_st, words, st,
|
||||
len(after_body or b""))
|
||||
ctx.log("the armed window was open %.1f s; frozen %.2f s after it opened and %.2f s before the latch unlocked "
|
||||
"for the run, in every one of %d samples from 2 s in to the close, the heartbeat still; thawed %.2f s "
|
||||
"after the close", ev["window_s"], ev["freeze_lag_s"], ev["freeze_led_the_run_s"], len(inside),
|
||||
ev["thaw_lag_s"] if ev["thaw_lag_s"] is not None else -1)
|
||||
finally:
|
||||
_put_back(ctx, fc, work, prior, etag, raw, dir_mode)
|
||||
_as_found(ctx, fc, prior, raw, dir_mode, found_tree)
|
||||
@@ -1,211 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""forgectrl.* - the machine-services daemon's API, access control, and panel."""
|
||||
import socket
|
||||
|
||||
from ..catalog import test
|
||||
|
||||
# The write guard reads the account and the session store (a machine with
|
||||
# an account needs a session from the LAN; this host and the dev image
|
||||
# write with the token), so both are the guard's domain. The fuse view
|
||||
# is two-factor: the token and the physical button, read by button.c.
|
||||
_COVERS_AUTH = [("forgectrl", "src/auth.*"), ("forgectrl", "src/peer.*"), ("forgectrl", "src/main.c"),
|
||||
("forgectrl", "src/session.*"), ("forgectrl", "src/users.*"), ("forgectrl", "src/button.*"),
|
||||
("forgectrl", "src/ui/login.*"), ("forgectrl", "src/ui/wizard.js")]
|
||||
|
||||
|
||||
def lan_ip():
|
||||
"""The board's own non-loopback IPv4 (the address a LAN client would
|
||||
use), or None."""
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
s.connect(("10.255.255.255", 9))
|
||||
ip = s.getsockname()[0]
|
||||
except OSError:
|
||||
ip = None
|
||||
finally:
|
||||
s.close()
|
||||
if ip and not ip.startswith("127."):
|
||||
return ip
|
||||
return None
|
||||
|
||||
|
||||
@test("forgectrl.auth", title="API access control", subsystem="forgectrl", kind="auto", est_min=1,
|
||||
covers=_COVERS_AUTH,
|
||||
description="Every state-changing endpoint refuses an unauthenticated write (the factory "
|
||||
"return, the SSH switch and the wizard's own routes included); a non-literal "
|
||||
"Host, a non-literal Origin and a cross-site Sec-Fetch-Site are refused, while "
|
||||
"the machine's own hostname passes and that name with a domain on it does not; "
|
||||
"the cooling report channel accepts the loopback peer and refuses a non-loopback "
|
||||
"one (over HTTP the write is sent to HTTPS first, 302; over HTTPS the route "
|
||||
"answers 403 loopback only); the fuse view is two-factor "
|
||||
"(token and the physical button) and refused without either; "
|
||||
"the flash and factory-restore chain is refused unauthenticated; a page "
|
||||
"asked for without a session is sent to the login carrying its path.")
|
||||
def auth(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
|
||||
st, body = fc.get("/status")
|
||||
ctx.log("GET /status -> %s", st)
|
||||
ctx.check(st == 200, "GET /status -> %s", st)
|
||||
|
||||
# unauthenticated writes: every one must be refused before it acts
|
||||
for path, params in (("/controller/stop", None), ("/controller/start", None),
|
||||
("/mode", {"controller": "grbl"}), ("/settings", {"ui_units": "mm"}),
|
||||
("/diag/flow-verify", None), ("/diag/abort", None),
|
||||
("/update/apply", None), ("/boot", {"target": "a"}),
|
||||
("/update/check", None), ("/update/dismiss", {"version": "v0.0.0"}),
|
||||
("/system/reboot", None), ("/restore/factory", None),
|
||||
("/restore/factory-return", {"confirm": "1"}), ("/system/ssh", {"enable": "1"}),
|
||||
("/wiz/advisories/accept", None), ("/wiz/account", None),
|
||||
("/wiz/complete", None)):
|
||||
st, body = fc.post(path, params=params, auth=False)
|
||||
ctx.log("POST %s (no token) -> %s %s", path, st, body if isinstance(body, dict) else "")
|
||||
ev["noauth " + path] = st
|
||||
ctx.check(st == 403, "POST %s without a token -> %s, expected 403", path, st)
|
||||
ctx.check(isinstance(body, dict) and body.get("error") == "authentication required",
|
||||
"POST %s without a token: unexpected body %r", path, body)
|
||||
|
||||
# the upload sink refuses during body parse; only the status is asserted
|
||||
st, body = fc.post("/update/upload", data=b"not a firmware archive", auth=False,
|
||||
headers={"Content-Type": "application/octet-stream"})
|
||||
ev["noauth /update/upload"] = st
|
||||
ctx.log("POST /update/upload (no token) -> %s", st)
|
||||
ctx.check(st in (400, 403), "POST /update/upload without a token -> %s", st)
|
||||
|
||||
# An oversized body from an unauthenticated client must not be
|
||||
# accumulated in memory before the token check. The framework buffers
|
||||
# every POST body ahead of the callback, and its default is no limit,
|
||||
# so without a ceiling this is a way to take the daemon and any job
|
||||
# with it from the network, unauthenticated. The body here is far over
|
||||
# the cap; what matters is that the daemon answers and is still
|
||||
# serving afterwards, having refused the write.
|
||||
big = b"ui_units=mm&pad=" + (b"x" * (4 * 1024 * 1024))
|
||||
st, body = fc.post("/settings", data=big, auth=False,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"})
|
||||
ev["oversize_body_status"] = st
|
||||
ctx.log("POST /settings (no token, %d MiB body) -> %s", len(big) // (1024 * 1024), st)
|
||||
ctx.check(st == 403, "an oversized unauthenticated body -> %s, expected 403", st)
|
||||
st, body = fc.get("/status")
|
||||
ev["alive_after_oversize"] = st
|
||||
ctx.log("GET /status after the oversized body -> %s", st)
|
||||
ctx.check(st == 200, "the daemon did not survive an oversized body (%s)", st)
|
||||
|
||||
# origin checks (read endpoint, so only the origin layer decides)
|
||||
st, body = fc.get("/status", headers={"Host": "evil.example.net"})
|
||||
ev["host_name"] = st
|
||||
ctx.log("GET /status Host=evil.example.net -> %s", st)
|
||||
ctx.check(st == 403, "a DNS-name Host was accepted (%s)", st)
|
||||
# the machine's own name passes; the same name with a domain on it
|
||||
# does not, because anyone can register one
|
||||
own = socket.gethostname()
|
||||
st, body = fc.get("/status", headers={"Host": own})
|
||||
ev["host_own_name"] = st
|
||||
ctx.log("GET /status Host=%s -> %s", own, st)
|
||||
ctx.check(st == 200, "the machine's own hostname was refused as a Host (%s)", st)
|
||||
st, body = fc.get("/status", headers={"Host": own + ".example.net"})
|
||||
ev["host_own_name_domain"] = st
|
||||
ctx.log("GET /status Host=%s.example.net -> %s", own, st)
|
||||
ctx.check(st == 403, "a domain name built on the machine's name was accepted (%s)", st)
|
||||
st, body = fc.get("/status", headers={"Origin": "http://evil.example.net"})
|
||||
ev["origin_name"] = st
|
||||
ctx.log("GET /status Origin=http://evil.example.net -> %s", st)
|
||||
ctx.check(st == 403, "a DNS-name Origin was accepted (%s)", st)
|
||||
st, body = fc.get("/status", headers={"Sec-Fetch-Site": "cross-site"})
|
||||
ev["sfs_cross"] = st
|
||||
ctx.log("GET /status Sec-Fetch-Site=cross-site -> %s", st)
|
||||
ctx.check(st == 403, "a cross-site fetch was accepted (%s)", st)
|
||||
st, body = fc.get("/status", headers={"Sec-Fetch-Site": "same-origin", "Origin": "http://127.0.0.1"})
|
||||
ctx.check(st == 200, "same-origin literal Origin refused (%s)", st)
|
||||
|
||||
# the fuse view is two-factor: the token AND the physical button held.
|
||||
# Without the token: authentication refused; with the token and nobody
|
||||
# at the button: refused with the button message. The identity itself is
|
||||
# never fetched (it would land in this log).
|
||||
st, body = fc.get("/fuse-identity", auth=False)
|
||||
ev["fuse_noauth"] = st
|
||||
ctx.log("GET /fuse-identity (no token) -> %s %s", st, body if isinstance(body, dict) else "")
|
||||
ctx.check(st == 403 and isinstance(body, dict) and body.get("error") == "authentication required",
|
||||
"GET /fuse-identity without token -> %s %r", st, body)
|
||||
st, body = fc.get("/fuse-identity")
|
||||
ev["fuse_token_no_button"] = st
|
||||
ctx.log("GET /fuse-identity (token, button not held) -> %s %s", st, body if isinstance(body, dict) else "")
|
||||
msg = body.get("error", "") if isinstance(body, dict) else str(body)
|
||||
ctx.check(st == 403 and "button" in msg,
|
||||
"GET /fuse-identity with the token but no button -> %s %r (expected the two-factor refusal)",
|
||||
st, body)
|
||||
|
||||
# the cooling report channel is the running controller's alone. A
|
||||
# loopback peer is not enough: anything on this host is one. The
|
||||
# supervisor hands the controller a secret at its spawn, and the route
|
||||
# asks for it. An idle report is what the controller sends every
|
||||
# period; the engine is idle here, so the accepted one changes nothing.
|
||||
# A dual-stack listener reports this peer as ::ffff:127.0.0.1, which
|
||||
# the loopback check must recognize in full.
|
||||
report = {"mode": "idle", "armed": "0"}
|
||||
st, body = fc.post("/cool/state", params=report)
|
||||
ev["cool_state_from_loopback_no_secret"] = st
|
||||
ctx.log("POST /cool/state from loopback, no secret -> %s %s", st, body if isinstance(body, str) else "")
|
||||
ctx.check(st == 403 and "controller's alone" in str(body),
|
||||
"/cool/state from loopback without the secret -> %s %r", st, body)
|
||||
st, body = fc.post("/cool/state", params=report, headers={"X-ForgeFIRM-Report": "0" * 32})
|
||||
ctx.check(st == 403, "/cool/state with a secret that is not the controller's -> %s %r", st, body)
|
||||
# The secret itself, as only root on this host can read it: out of
|
||||
# the running controller's environment. It is never logged.
|
||||
secret = None
|
||||
pid = (fc.get("/mode")[1] or {}).get("pid")
|
||||
if pid:
|
||||
try:
|
||||
with open("/proc/%d/environ" % int(pid), "rb") as f:
|
||||
for item in f.read().split(b"\0"):
|
||||
if item.startswith(b"GF_REPORT_SECRET="):
|
||||
secret = item.split(b"=", 1)[1].decode("ascii", "replace")
|
||||
except OSError:
|
||||
pass
|
||||
ev["controller_has_a_secret"] = bool(secret)
|
||||
ctx.check(secret and len(secret) == 32, "the running controller (pid %s) was handed no report secret", pid)
|
||||
st, body = fc.post("/cool/state", params=report, headers={"X-ForgeFIRM-Report": secret})
|
||||
ev["cool_state_from_loopback"] = st
|
||||
ctx.log("POST /cool/state from loopback with the controller's secret -> %s", st)
|
||||
ctx.check(st == 200, "/cool/state refused the controller's own secret (%s %r): the controller's "
|
||||
"reports never reach the engine", st, body)
|
||||
|
||||
# ...and a non-loopback peer is refused, even with a token. Over HTTP
|
||||
# the write is sent to HTTPS first (302, the listener's rule); over
|
||||
# HTTPS the route itself refuses the peer (403 loopback only). Neither
|
||||
# request follows the redirect, and the self-signed certificate is
|
||||
# not verified.
|
||||
from .setup import request, decode
|
||||
ip = lan_ip()
|
||||
ev["lan_ip"] = ip
|
||||
ctx.check(ip, "cannot determine the board's LAN address")
|
||||
token = {"X-ForgeFIRM-Token": fc.token, "X-ForgeFIRM-Report": secret}
|
||||
st, body, hdrs = request("http://%s" % ip, "POST", "/cool/state", data=report, headers=token)
|
||||
loc = hdrs.get("location", "")
|
||||
ev["cool_state_from_lan_http"] = {"status": st, "location": loc}
|
||||
ctx.log("POST http://%s/cool/state -> %s %s", ip, st, loc)
|
||||
ctx.check(st == 302 and loc.startswith("https://"),
|
||||
"a LAN write over HTTP -> %s %r, expected a 302 to HTTPS", st, loc)
|
||||
st, body, hdrs = request("https://%s" % ip, "POST", "/cool/state", data=report, headers=token)
|
||||
b = decode(body)
|
||||
ev["cool_state_from_lan"] = st
|
||||
ctx.log("POST https://%s/cool/state -> %s %s", ip, st, b if isinstance(b, dict) else "")
|
||||
ctx.check(st == 403 and isinstance(b, dict) and b.get("error") == "loopback only",
|
||||
"/cool/state accepted a non-loopback peer (%s %r)", st, b)
|
||||
|
||||
# the login return path: a page asked for from the LAN without a
|
||||
# session is sent to the login carrying the path it asked for (with
|
||||
# its query), so the login can come back to it. Before the setup has
|
||||
# made the account the page is served instead (200).
|
||||
st, body, hdrs = request("https://%s" % ip, "GET", "/setup?step=laser.focus")
|
||||
loc = hdrs.get("location", "")
|
||||
ev["setup_from_lan"] = {"status": st, "location": loc}
|
||||
ctx.log("GET https://%s/setup?step=laser.focus (no session) -> %s %s", ip, st, loc)
|
||||
ctx.check(st in (200, 302), "the setup page from the LAN -> %s", st)
|
||||
if st == 302:
|
||||
ctx.check(loc == "/login?next=/setup%3Fstep%3Dlaser.focus",
|
||||
"the login redirect does not carry the path: %r", loc)
|
||||
@@ -1,168 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The helpers for a test that lets the web service move the head.
|
||||
|
||||
The homing is the web-service session cloud.mode-switch runs. These judge
|
||||
that its motions ran whole, and take the head back to where the test found
|
||||
it by the travel the service's motions logged. No counter reading can say
|
||||
where that was: every
|
||||
service motion zeroes the step counters at its start, and a home and every
|
||||
controller start zero them again.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
|
||||
from ..baseline import XY_STEPS_PER_MM, counter_frame, counter_steps_per_mm, read_position
|
||||
from ..runner import Failed
|
||||
from . import cloud as logs # the log paths, read at each call
|
||||
from .cloud import log_lines_since, log_size
|
||||
from .motion import _drop_reference, clean_slate, machine_idle, wait_idle
|
||||
|
||||
RETURN_MAX_MM = 600.0 # no hand-back jogs farther on an axis: the bed is smaller
|
||||
# The client's own record of a motion's end: the kernel counters, which
|
||||
# the motion zeroed at its start (the "(actual/expected)" line of the same
|
||||
# motion is not this one). A print's park keeps the counters and drives
|
||||
# them back, so the line after it is the print's net travel.
|
||||
MOTION_END = re.compile(r"end positions \((-?\d+), (-?\d+), (-?\d+)\)")
|
||||
|
||||
|
||||
def session_mark(path=None):
|
||||
"""Where a client's log ends now (gfhome's by default): the start of
|
||||
the next session's record."""
|
||||
return log_size(path or logs.GFHOME_LOG)
|
||||
|
||||
|
||||
def session_travel(offset, path=None):
|
||||
"""The X/Y travel of the service motions logged in a client's log
|
||||
(gfhome's by default) since offset, in steps, or None when it cannot be
|
||||
known. Every service motion zeroes the kernel counters at its start and
|
||||
logs where they ended, so the travel is the sum of those ends. A service
|
||||
motion runs at x8 or not at all (the client refuses any other mode), so
|
||||
the steps are x8 steps. A motion refused before it moved logs no end
|
||||
and moved nothing; a motion with no "end motion" (one that raised after
|
||||
it moved, or a client killed inside it) leaves the travel unknown, and
|
||||
so does a log rotated under the run."""
|
||||
path = path or logs.GFHOME_LOG
|
||||
if log_size(path) < offset:
|
||||
return None
|
||||
x = y = 0
|
||||
inside, end = False, None
|
||||
for ln in log_lines_since(path, offset):
|
||||
ln = ln.rstrip()
|
||||
m = MOTION_END.search(ln)
|
||||
if ln.endswith(" start motion"):
|
||||
if inside:
|
||||
return None
|
||||
inside, end = True, None
|
||||
elif m and inside:
|
||||
end = (int(m.group(1)), int(m.group(2)))
|
||||
elif ln.endswith(" end motion") and inside:
|
||||
if end:
|
||||
x, y = x + end[0], y + end[1]
|
||||
inside = False
|
||||
return None if inside else (x, y)
|
||||
|
||||
|
||||
def judge_whole_motions(ctx, ev, offset):
|
||||
"""Every service motion of the homing session since offset ran whole.
|
||||
One that stopped short (the cooling engine's stop, a fault) left the
|
||||
head short of the camera home, and the position declared there is
|
||||
false."""
|
||||
short = [ln.strip()[:160] for ln in log_lines_since(logs.GFHOME_LOG, offset)
|
||||
if "run stopped short" in ln
|
||||
or ("_finish_action motion [" in ln and 'event ":completed"' not in ln)]
|
||||
ev["short_motions"] = short
|
||||
ctx.check(not short, "a service motion of the homing did not run whole, so the head stopped short of "
|
||||
"the camera home and the declared position is false: %s", short)
|
||||
|
||||
|
||||
def camera_home_return(ctx, ev, offset):
|
||||
"""Hand the head back to where the test found it after gfhome_homing
|
||||
ran from gfhome's log offset, whatever the session decided. The head is
|
||||
the session's logged travel from there, plus, once homed, whatever the
|
||||
counters read since: the home zeroed them where the session left the
|
||||
head and wrote the anchor, and a controller start since would have
|
||||
removed it. Unhomed, the counters still hold the last motion's end,
|
||||
which the travel already counts. The camera home is dropped first,
|
||||
since its envelope need not hold the spot the head came from."""
|
||||
def back():
|
||||
now = [0, 0, 0]
|
||||
if ev.get("homed"):
|
||||
now = read_position() if counter_frame() is not None else None
|
||||
_drop_reference(ctx, ctx.forgectrl)
|
||||
# Read after the stop: a runner the stop ended has written its last line.
|
||||
travel = session_travel(offset)
|
||||
rec = ev["return"] = {"session_travel_steps": travel, "counters_since_home": now}
|
||||
ctx.check(travel is not None and now is not None,
|
||||
"the homing session's travel cannot be known (gfhome log %s, counters since the home %s): "
|
||||
"the head is left where it stands and the hand-back moves nothing", travel, now)
|
||||
_jog_back(ctx, rec, travel, now)
|
||||
_hand_back(ctx, ev, "return", back)
|
||||
|
||||
|
||||
def cloud_mode_return(ctx, ev, offset):
|
||||
"""Hand the head back to where the test found it after a stay in cloud
|
||||
mode that began at the cloud client's log offset, on the GRBL
|
||||
controller the switch back started: that start zeroed the counters
|
||||
where the service left the head, and they hold whatever moved since.
|
||||
The client's record of its motions is the rest (a hunt's lens travel is
|
||||
Z, which a hand-back never touches)."""
|
||||
def back():
|
||||
travel = session_travel(offset, logs.GFCLOUD_LOG)
|
||||
now = read_position()
|
||||
rec = ev["cloud_return"] = {"session_travel_steps": travel, "counters_since_start": now}
|
||||
ctx.check(travel is not None and now is not None,
|
||||
"the travel in cloud mode cannot be known (gfcloud log %s, counters %s): the head is left "
|
||||
"where it stands and the hand-back moves nothing", travel, now)
|
||||
_jog_back(ctx, rec, travel, now)
|
||||
_hand_back(ctx, ev, "cloud_return", back)
|
||||
|
||||
|
||||
def _hand_back(ctx, ev, key, back):
|
||||
"""Run a hand-back from a test's finally. The first failure wins: when
|
||||
the test has failed already (the exception in flight), a hand-back that
|
||||
fails as well is logged and recorded, and the test's own failure is the
|
||||
one reported."""
|
||||
first = sys.exc_info()[1]
|
||||
try:
|
||||
back()
|
||||
except Failed as e:
|
||||
if first is None:
|
||||
raise
|
||||
ev.setdefault(key, {})["failed"] = str(e)
|
||||
ctx.log("the head is not back, and the run has failed already: %s", e)
|
||||
|
||||
|
||||
def _jog_back(ctx, rec, travel, since):
|
||||
"""Jog the head back by the service's travel (x8 steps) and what the
|
||||
counters read since (the controller's own steps), on a controller
|
||||
without a home. One more start then zeroes the counters where the head
|
||||
began, which the rest of the catalog counts on."""
|
||||
spm = counter_steps_per_mm()
|
||||
dx = travel[0] / XY_STEPS_PER_MM + since[0] / spm
|
||||
dy = travel[1] / XY_STEPS_PER_MM + since[1] / spm
|
||||
rec["mm"] = [round(dx, 3), round(dy, 3)]
|
||||
ctx.log("the head is %.3f/%.3f mm from where the test found it", dx, dy)
|
||||
ctx.check(abs(dx) <= RETURN_MAX_MM and abs(dy) <= RETURN_MAX_MM,
|
||||
"a travel of %.1f/%.1f mm exceeds %.0f mm - not jogging back", dx, dy, RETURN_MAX_MM)
|
||||
if abs(dx) >= 0.05 or abs(dy) >= 0.05:
|
||||
with ctx.grbl() as g:
|
||||
clean_slate(ctx, g)
|
||||
start = read_position()
|
||||
r = g.command("$J=G91 G21 X%.3f Y%.3f F2400" % (-dx, -dy))
|
||||
ctx.check(not any(x.startswith("error") for x in r), "the return jog was refused: %s", r)
|
||||
states = wait_idle(ctx, g, timeout=60)[1]
|
||||
ctx.check(states[-1:] != ["TIMEOUT"], "the return jog did not end: %s", states)
|
||||
machine_idle(ctx)
|
||||
end = read_position()
|
||||
rec["counters_across_jog"] = [start, end]
|
||||
ctx.check(start is not None and end is not None and abs((end[0] - start[0]) / spm + dx) < 0.1
|
||||
and abs((end[1] - start[1]) / spm + dy) < 0.1,
|
||||
"the return jog did not travel %.3f/%.3f mm: counters %s -> %s", -dx, -dy, start, end)
|
||||
_drop_reference(ctx, ctx.forgectrl)
|
||||
ctx.counters_rezeroed()
|
||||
ctx.log("head returned to where the test found it (%.3f/%.3f mm)", -dx, -dy)
|
||||
@@ -1,497 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""image.* - the post-flash health of the running image (always-required)."""
|
||||
import gzip
|
||||
import mmap
|
||||
import os
|
||||
import struct
|
||||
import re
|
||||
import stat
|
||||
|
||||
from ..catalog import test
|
||||
from .. import hw
|
||||
|
||||
|
||||
WDOG1_BASE = 0x020BC000 # i.MX6 WDOG1; WCR is the 16-bit word at offset 0
|
||||
|
||||
|
||||
def _wdog1_wcr():
|
||||
"""WDOG1's control register, read through /dev/mem (MMIO stays readable
|
||||
under STRICT_DEVMEM), or None."""
|
||||
try:
|
||||
with open("/dev/mem", "r+b") as f:
|
||||
m = mmap.mmap(f.fileno(), 4096, offset=WDOG1_BASE)
|
||||
try:
|
||||
return struct.unpack_from("<H", m, 0)[0]
|
||||
finally:
|
||||
m.close()
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _plain(line):
|
||||
"""A line without its ANSI color sequences: what a terminal shows,
|
||||
counted in columns rather than in bytes."""
|
||||
return re.sub(r"\x1b\[[0-9;]*m", "", line)
|
||||
|
||||
|
||||
def _read(path, default=None):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||
return f.read()
|
||||
except OSError:
|
||||
return default
|
||||
|
||||
|
||||
def kernel_config():
|
||||
"""The running kernel's config as a dict, from /proc/config.gz or
|
||||
/boot/config-<release>. None when neither is available."""
|
||||
try:
|
||||
with gzip.open("/proc/config.gz", "rb") as f:
|
||||
text = f.read().decode("utf-8", "replace")
|
||||
except OSError:
|
||||
text = None
|
||||
if text is None:
|
||||
rel = _read("/proc/sys/kernel/osrelease", "").strip()
|
||||
text = _read("/boot/config-%s" % rel) if rel else None
|
||||
if text is None:
|
||||
return None
|
||||
cfg = {}
|
||||
for line in text.splitlines():
|
||||
m = re.match(r"^(CONFIG_[A-Z0-9_]+)=(.*)$", line)
|
||||
if m:
|
||||
cfg[m.group(1)] = m.group(2)
|
||||
m = re.match(r"^# (CONFIG_[A-Z0-9_]+) is not set$", line)
|
||||
if m:
|
||||
cfg[m.group(1)] = "n"
|
||||
return cfg
|
||||
|
||||
|
||||
def kernel_ident(name):
|
||||
"""A kernel release or modules-directory name less its LOCALVERSION_AUTO
|
||||
hash (+g<hash> or -g<hash>). The hash does not reproduce across a
|
||||
re-patch of the same source; the manifest lists modules directories
|
||||
without it, and the kernel's identity is its @srcrev and @config."""
|
||||
return re.sub(r"[+-]g[0-9a-f]{7,}$", "", name)
|
||||
|
||||
|
||||
def kernel_matches(release, modules_dirs):
|
||||
"""True when the running release names one of the manifest's modules
|
||||
directories, hashes aside (a manifest without them constrains nothing)."""
|
||||
return not modules_dirs or kernel_ident(release) in {kernel_ident(m) for m in modules_dirs}
|
||||
|
||||
|
||||
def _dt_u32(path):
|
||||
"""A device-tree cell as an int (big-endian), or None."""
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
raw = f.read(4)
|
||||
except OSError:
|
||||
return None
|
||||
return int.from_bytes(raw, "big") if len(raw) == 4 else None
|
||||
|
||||
|
||||
def fds_of(pid):
|
||||
out = []
|
||||
try:
|
||||
for fd in os.listdir("/proc/%d/fd" % pid):
|
||||
try:
|
||||
out.append(os.readlink("/proc/%d/fd/%s" % (pid, fd)))
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
@test("image.health", title="Post-flash image health", subsystem="image", kind="auto",
|
||||
always=True, est_min=1,
|
||||
covers=[("forgectrl", "init/**"), ("forgeext", "init/**"), ("forgectrl", "src/main.c"), ("forgectrl", "src/auth.c"),
|
||||
("forgectrl", "src/status.*"),
|
||||
("forgectrl", "CMakeLists.txt"), ("grblhal-glowforge", "src/boards/**"),
|
||||
("grblhal-glowforge", "CMakeLists.txt"), ("kernel-module-glowforge", "**"),
|
||||
("linux-fslc", "**")],
|
||||
description="The image that is running is the image the manifest describes, with the "
|
||||
"version stamped in /etc/forgefirm-version and under the machine mark in the "
|
||||
"console banner and the motd, "
|
||||
"the kernel options, the module, the pulse ring it maps and the SDMA clocks it holds, "
|
||||
"the daemon ownership, the board temperatures /status reports (the chassis and "
|
||||
"the SoC die in a plausible range, the supply as a 10-bit count, the CPU "
|
||||
"unthrottled), "
|
||||
"the extension host as one process on a machine with no package installed and "
|
||||
"nothing running under a pool account, no userspace watchdog running, "
|
||||
"the data directory open for search on a machine with an account, the file modes the release depends on, and the mounts: the "
|
||||
"rootfs read-only, /data writable, the account files and the banner rendered "
|
||||
"into tmpfs, the sshd host keys on /data, the dev image's factory slots read-only. "
|
||||
"And the boot does not wait on the network: ifup has returned for wlan0 (it is "
|
||||
"in the state file and no ifup is running) and the console login is up; each "
|
||||
"DHCP client runs with -b and has left ifup (its parent is pid 1); no DHCPv6 "
|
||||
"client is running; and IPv6 is the "
|
||||
"kernel's own: enabled on wlan0, router advertisements accepted, a link-local "
|
||||
"address up (a global address is logged, not checked).")
|
||||
def image_health(ctx):
|
||||
ev = ctx.evidence
|
||||
manifest = ctx.runner.manifest
|
||||
|
||||
# 1. version stamp: the machine-readable file, and the two a person
|
||||
# reads - the console banner (/etc/issue, which forgefirm-banner
|
||||
# renders from the image's own text) and the motd a login prints.
|
||||
# Each carries the machine mark with the version on the mark's own
|
||||
# last line, right-justified to the mark's last column.
|
||||
ver = (_read("/etc/forgefirm-version", "") or "").strip()
|
||||
ev["forgefirm_version"] = ver
|
||||
ctx.log("forgefirm-version: %s (manifest: %s)", ver, manifest.version)
|
||||
ctx.check(ver == manifest.version, "/etc/forgefirm-version %r != manifest %r", ver, manifest.version)
|
||||
for path in ("/etc/issue", "/etc/motd"):
|
||||
text = _read(path, "") or ""
|
||||
ev[path] = text
|
||||
lines = text.splitlines()
|
||||
at = [i for i, line in enumerate(lines) if line.rstrip().endswith(ver)]
|
||||
ctx.check(at, "%s carries no line ending in the version %r", path, ver)
|
||||
if not at:
|
||||
continue
|
||||
i = at[0]
|
||||
ctx.check(lines[i].rstrip() == lines[i], "%s pads the version line: %r", path, lines[i])
|
||||
ctx.check(_plain(lines[i]).strip() != ver,
|
||||
"%s puts the version on a line of its own, not on the mark's last line", path)
|
||||
# Right-justified to the mark's last column: the stamped line is
|
||||
# exactly as wide as the widest line above it. Only the motd is
|
||||
# measured, because /etc/issue holds the mark with its
|
||||
# backslashes doubled for the getty that reads it, so its bytes
|
||||
# are wider than the columns a person sees.
|
||||
if path == "/etc/motd":
|
||||
above = [len(_plain(line)) for line in lines[:i] if line.strip()]
|
||||
width = max(above) if above else 0
|
||||
ev["motd_columns"] = [len(_plain(lines[i])), width]
|
||||
ctx.check(len(_plain(lines[i])) == width,
|
||||
"/etc/motd stamped line is %d columns, the mark above is %d",
|
||||
len(_plain(lines[i])), width)
|
||||
|
||||
# 2. kernel options
|
||||
cfg = kernel_config()
|
||||
ctx.check(cfg is not None, "kernel config unavailable (/proc/config.gz, /boot/config-*)")
|
||||
for opt, want in (("CONFIG_PREEMPT", ("y",)), ("CONFIG_IMX2_WDT", ("y", "m")),
|
||||
("CONFIG_PANIC_ON_OOPS", ("y",))):
|
||||
val = cfg.get(opt, "n")
|
||||
ev[opt] = val
|
||||
ctx.log("%s=%s", opt, val)
|
||||
ctx.check(val in want, "%s=%s, expected one of %s", opt, val, want)
|
||||
# The boot-armed hardware watchdog: U-Boot arms WDOG1 at 60 s and the
|
||||
# kernel's core keeps it fed (no userspace opens it, so the sysfs state
|
||||
# reads "inactive": that attribute says whether a process holds the
|
||||
# device, not whether the hardware runs). The hardware's own word is
|
||||
# WCR: WDE set, and WT giving the period. The sysfs view needs
|
||||
# CONFIG_WATCHDOG_SYSFS; bootstatus carries the last reset's cause (32 =
|
||||
# the watchdog's timeout).
|
||||
wd = {k: (_read("/sys/class/watchdog/watchdog0/" + k) or "").strip()
|
||||
for k in ("identity", "state", "timeout", "bootstatus")}
|
||||
wcr = _wdog1_wcr()
|
||||
wd["wcr"] = ("%#06x" % wcr) if wcr is not None else None
|
||||
wd["wde"] = bool(wcr & 0x4) if wcr is not None else None
|
||||
wd["period_s"] = ((wcr >> 8) + 1) / 2.0 if wcr is not None else None
|
||||
ev["watchdog"] = wd
|
||||
ctx.log("watchdog0: %s", wd)
|
||||
ctx.check(wd["identity"], "watchdog0 has no sysfs view (CONFIG_WATCHDOG_SYSFS)")
|
||||
ctx.check(wd["timeout"] == "60", "watchdog0 timeout %r, expected 60 s", wd["timeout"] or None)
|
||||
ctx.check(wcr is not None, "WDOG1 WCR unreadable through /dev/mem")
|
||||
ctx.check(wd["wde"], "WDOG1 is not enabled (WCR %s): nothing resets a hung kernel", wd["wcr"])
|
||||
ctx.check(wd["period_s"] == 60.0, "WDOG1 period %s s (WCR %s), expected 60", wd["period_s"], wd["wcr"])
|
||||
ctx.check(wd["state"] == "inactive", "watchdog0 is %r: a process holds the device, which the image "
|
||||
"does not do", wd["state"] or None)
|
||||
rel = _read("/proc/sys/kernel/osrelease", "").strip()
|
||||
ev["kernel_release"] = rel
|
||||
mods = manifest.platform.get("kernel_modules") or []
|
||||
ctx.log("kernel release %s (manifest modules dirs: %s)", rel, ",".join(mods))
|
||||
ctx.check(kernel_matches(rel, mods), "running kernel %r is not the manifest's %s", rel, mods)
|
||||
|
||||
# 3. the module and its sysfs
|
||||
ctx.check(os.path.isdir("/sys/module/glowforge"), "glowforge.ko is not loaded")
|
||||
state = hw.sysfs_read("cnc/state")
|
||||
ev["cnc_state"] = state
|
||||
ctx.check(state is not None, "/sys/glowforge/cnc/state unreadable")
|
||||
ctx.log("cnc/state: %s", state)
|
||||
free = hw.sysfs_int("cnc/free")
|
||||
ev["cnc_free"] = free
|
||||
ctx.check(free is not None and free > 0, "cnc/free unreadable or zero (ring not mapped?)")
|
||||
ctx.log("cnc/free: %s bytes", free)
|
||||
|
||||
# The ring the platform ships: the DT pool is the promise, ring_mb is what
|
||||
# the module took. They must agree - a pool the parameter does not use is
|
||||
# no-map RAM burned for nothing, and a parameter the pool cannot back would
|
||||
# have failed the probe. free can only ever be size less the 32 KiB gap.
|
||||
pool = _dt_u32("/proc/device-tree/reserved-memory/cnc-pulsebuf/size")
|
||||
try:
|
||||
ring = int((_read("/sys/module/glowforge/parameters/ring_mb", "") or "").strip()) << 20
|
||||
except ValueError:
|
||||
ring = None
|
||||
ev["cnc_pool_bytes"] = pool
|
||||
ev["cnc_ring_bytes"] = ring
|
||||
ctx.log("cnc-pulsebuf pool: %s bytes, ring_mb: %s bytes", pool, ring)
|
||||
ctx.check(pool is not None, "no cnc-pulsebuf reserved-memory node (the ring fell back to CMA?)")
|
||||
ctx.check(ring is not None, "/sys/module/glowforge/parameters/ring_mb unreadable")
|
||||
ctx.check(pool is None or ring is None or pool == ring,
|
||||
"DT pool %s and ring_mb %s disagree", pool, ring)
|
||||
ctx.check(ring is None or free is None or free <= ring - 32 * 1024,
|
||||
"cnc/free %s exceeds the ring less its 32 KiB gap (%s)", free, ring)
|
||||
ctx.check(hw.sysfs_read("cnc/interlock_circuit") is not None, "cnc/interlock_circuit unreadable")
|
||||
|
||||
# The SDMA engine runs only while a channel holder keeps its ipg/ahb
|
||||
# clocks enabled; imx-sdma leaves them off after probe and only a channel
|
||||
# allocation turns them on. glowforge.ko takes its channel outside
|
||||
# dmaengine and holds the clocks itself. With the block gated every
|
||||
# channel-0 transfer is a silent no-op: the probe cannot start, and the
|
||||
# ring reads back a stale bounce page (free above the ring size, a
|
||||
# position counter that never moved).
|
||||
clk = (_read("/sys/kernel/debug/clk/sdma/clk_enable_count", "") or "").strip()
|
||||
ev["sdma_clk_enable_count"] = clk
|
||||
ctx.log("sdma clk_enable_count: %s", clk or "(unreadable)")
|
||||
ctx.check(clk.isdigit() and int(clk) >= 1,
|
||||
"SDMA clk_enable_count %r: the engine's ipg/ahb clocks are not held", clk)
|
||||
|
||||
# 4. forgectrl holds /dev/glowforge and supervises the controller
|
||||
pids = hw.pidof("forgectrl")
|
||||
ev["forgectrl_pids"] = pids
|
||||
ctx.check(pids, "forgectrl is not running")
|
||||
holders = [p for p in pids if any(l == "/dev/glowforge" for l in fds_of(p))]
|
||||
ev["pulse_device_holders"] = holders
|
||||
ctx.log("forgectrl pids %s, holding /dev/glowforge: %s", pids, holders)
|
||||
ctx.check(holders, "no forgectrl process holds /dev/glowforge")
|
||||
st, mode = ctx.forgectrl.get("/mode")
|
||||
ev["mode"] = mode
|
||||
ctx.check(st == 200 and isinstance(mode, dict), "GET /mode -> %s", st)
|
||||
ctx.log("mode: %s", mode)
|
||||
ctx.check(mode.get("controller") in ("running", "standby"),
|
||||
"controller is %r (expected running or standby)", mode.get("controller"))
|
||||
ctx.check(mode.get("motion") != "fault", "supervisor reports a motion fault")
|
||||
|
||||
# 4b. the board temperatures /status carries, watched and not gated:
|
||||
# the chassis and the SoC die in degrees, the supply as its raw count,
|
||||
# and the CPU unthrottled at idle.
|
||||
temps = (ctx.forgectrl.status() or {}).get("temps") or {}
|
||||
ev["temps"] = temps
|
||||
ctx.log("temps: %s", temps)
|
||||
ctx.check(isinstance(temps.get("chassis_c"), (int, float)) and 5.0 <= temps["chassis_c"] <= 80.0,
|
||||
"/status temps.chassis_c is not a plausible chassis temperature: %s", temps)
|
||||
ctx.check(isinstance(temps.get("soc_c"), (int, float)) and 10.0 <= temps["soc_c"] <= 100.0,
|
||||
"/status temps.soc_c is not a plausible die temperature: %s", temps)
|
||||
ctx.check(isinstance(temps.get("supply_raw"), int) and 0 <= temps["supply_raw"] <= 1023,
|
||||
"/status temps.supply_raw is not a 10-bit count: %s", temps)
|
||||
ctx.check(temps.get("soc_throttle") == 0,
|
||||
"the CPU is throttled (or its cooling device is missing) at idle: %s", temps)
|
||||
|
||||
# 5. the extension host: one process, and a campaign's machine is
|
||||
# extension-free (its init order is the rootfs lint's)
|
||||
host = []
|
||||
for pid in hw.pidof("forgeext"):
|
||||
argv = _read("/proc/%d/cmdline" % pid, "").split("\0")
|
||||
if argv[:2] == [hw.FORGEEXT, "run"]:
|
||||
host.append(pid)
|
||||
ev["extension_host_pids"] = host
|
||||
ctx.check(len(host) == 1, "the extension host is not one running process: %s", host)
|
||||
ev["extension_packages"] = hw.ext_packages()
|
||||
ev["pool_processes"] = len(hw.pool_pids())
|
||||
ctx.log("extension host pid %s, packages %s, pool processes %d", host, ev["extension_packages"], ev["pool_processes"])
|
||||
ctx.check(not ev["extension_packages"], "a campaign wants an extension-free machine: installed %s",
|
||||
ev["extension_packages"])
|
||||
ctx.check(not ev["pool_processes"], "%d process(es) run under extension pool accounts", ev["pool_processes"])
|
||||
|
||||
# 6. no userspace watchdog daemon running (the logging init and the
|
||||
# absent watchdog init script are the rootfs lint's); kernel threads
|
||||
# (the imx2_wdt kthread is expected) have an empty cmdline
|
||||
user_wd = [p for p in hw.pidof("watchdog") if _read("/proc/%d/cmdline" % p, "")]
|
||||
ev["userspace_watchdog_pids"] = user_wd
|
||||
ctx.check(not user_wd, "a userspace watchdog daemon is running: %s", user_wd)
|
||||
|
||||
# 7. file modes and space
|
||||
for path in ("/data/forgefirm/panel.token", "/data/forgefirm/forgefirm.conf"):
|
||||
if os.path.exists(path):
|
||||
m = stat.S_IMODE(os.stat(path).st_mode)
|
||||
ev[path] = "%o" % m
|
||||
ctx.log("%s mode %o", path, m)
|
||||
ctx.check(m == 0o600, "%s mode %o, expected 600", path, m)
|
||||
# an operator account walks through the data directory to its home, and a
|
||||
# package's account to its files: forgefirm-users opens it for search at
|
||||
# every render, whoever made it and under whatever umask
|
||||
if os.path.exists("/data/forgefirm/users"):
|
||||
m = stat.S_IMODE(os.stat("/data/forgefirm").st_mode)
|
||||
ev["/data/forgefirm"] = "%o" % m
|
||||
ctx.log("/data/forgefirm mode %o", m)
|
||||
ctx.check(m & 0o011 == 0o011, "/data/forgefirm mode %o: no account can walk through it to its home", m)
|
||||
if os.path.isdir("/data"):
|
||||
s = os.statvfs("/data")
|
||||
free_mb = s.f_bavail * s.f_frsize // (1024 * 1024)
|
||||
ev["data_free_mb"] = free_mb
|
||||
ctx.log("/data free: %d MiB", free_mb)
|
||||
ctx.check(free_mb >= 20, "/data has only %d MiB free", free_mb)
|
||||
|
||||
# 8. the mounts: the rootfs read-only, /data the writable partition,
|
||||
# the state a read-only rootfs hands off (the read-only-rootfs image
|
||||
# feature, forgefirm-users, forgefirm-hostname, forgefirm-banner, the
|
||||
# sshd host keys). The dev image's factory slots under /factory are
|
||||
# read-only; rcS's ROOTFS_READ_ONLY and the release fstab with no
|
||||
# factory slot are the rootfs lint's.
|
||||
mounts = {}
|
||||
for line in (_read("/proc/mounts", "") or "").splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) >= 4:
|
||||
mounts[parts[1]] = {"source": parts[0], "type": parts[2], "opts": parts[3].split(",")}
|
||||
|
||||
def mount_opts(path):
|
||||
return (mounts.get(path) or {}).get("opts") or []
|
||||
|
||||
ev["mounts"] = {p: mounts[p] for p in ("/", "/data", "/var/lib", "/etc/passwd",
|
||||
"/etc/hostname", "/etc/issue") if p in mounts}
|
||||
ctx.log("/ mounted %s; /data %s; /var/lib %s", ",".join(mount_opts("/")) or "(absent)",
|
||||
",".join(mount_opts("/data")) or "(absent)", ",".join(mount_opts("/var/lib")) or "(absent)")
|
||||
ctx.check("ro" in mount_opts("/"), "the rootfs is not mounted read-only: %s", mounts.get("/"))
|
||||
ctx.check("rw" in mount_opts("/data"), "/data is not mounted read-write: %s", mounts.get("/data"))
|
||||
ctx.check("rw" in mount_opts("/var/lib"),
|
||||
"/var/lib is not a writable copy (read-only-rootfs-hook.sh): %s", mounts.get("/var/lib"))
|
||||
factory = sorted(p for p in mounts if p.startswith("/factory/"))
|
||||
ev["factory_mounts"] = factory
|
||||
dev_image = os.path.exists("/etc/forgefirm-dev")
|
||||
ev["dev_image"] = dev_image
|
||||
ctx.log("dev image %s; /factory mounts: %s", dev_image, factory or "none")
|
||||
if dev_image:
|
||||
for n in (1, 2):
|
||||
if os.path.exists("/dev/mmcblk2p%d" % n):
|
||||
p = "/factory/img%d" % n
|
||||
ctx.check("ro" in mount_opts(p), "%s is not mounted read-only on the dev image: %s", p, mounts.get(p))
|
||||
if os.path.isfile("/data/forgefirm/users"):
|
||||
names = [l.split(":")[0] for l in (_read("/data/forgefirm/users", "") or "").splitlines()
|
||||
if l.strip() and not l.startswith("#")]
|
||||
passwd_names = {l.split(":")[0] for l in (_read("/etc/passwd", "") or "").splitlines()}
|
||||
ev["record_accounts"] = names
|
||||
for f in ("/etc/passwd", "/etc/shadow", "/etc/group"):
|
||||
ctx.check(f in mounts, "%s is not the tmpfs render of the account record", f)
|
||||
for n in names:
|
||||
ctx.check(n in passwd_names, "record account %r is missing from /etc/passwd", n)
|
||||
ctx.check("/etc/hostname" in mounts, "/etc/hostname is not the bind-mounted name copy")
|
||||
ctx.check("/etc/issue" in mounts, "/etc/issue is not the bind-mounted banner copy")
|
||||
if hw.pidof("sshd"):
|
||||
key = "/data/forgefirm/ssh/ssh_host_ed25519_key"
|
||||
ctx.check(os.path.isfile(key), "sshd runs but %s is missing", key)
|
||||
|
||||
# 9. the manifest itself is coherent
|
||||
ctx.check(manifest.content_sha and len(manifest.content_sha) == 64, "manifest content_sha256 missing")
|
||||
ctx.check("kernel-module-glowforge" in manifest.components, "manifest lacks kernel-module-glowforge")
|
||||
ctx.check("linux-fslc" in manifest.components, "manifest lacks the kernel entry")
|
||||
ctx.log("manifest %s: %d components", manifest.content_sha[:12], len(manifest.components))
|
||||
|
||||
# 10. the network boot. init starts the rest of the boot, the console
|
||||
# login last, only after the networking script returns, so a client
|
||||
# that waits in ifup's foreground on a server's answer holds the
|
||||
# whole machine. The interfaces file starts no DHCPv6 client
|
||||
# (test_image.py reads the layer's file); the bounded DHCP client
|
||||
# (-b, which leaves ifup after three unanswered discovers) is checked
|
||||
# here, on the client that runs.
|
||||
procs = _procs()
|
||||
up = ifstate_names(_read(IFSTATE, ""))
|
||||
ev["ifstate"] = sorted(up)
|
||||
ctx.check("wlan0" in up, "ifup has not finished wlan0: %s holds %s", IFSTATE, sorted(up))
|
||||
stuck = [p for p in procs if p[2] in ("ifup", "ifdown")]
|
||||
ev["ifup_running"] = [p[3] for p in stuck]
|
||||
ctx.check(not stuck, "ifup is still running: %s", [p[3] for p in stuck])
|
||||
logins = [p[3] for p in procs if p[2] == "getty"]
|
||||
ev["getty"] = logins
|
||||
ctx.check(any("ttymxc0" in c for c in logins), "no console login is running: %s", logins)
|
||||
v4 = [p for p in procs if p[2] == "udhcpc"]
|
||||
ev["udhcpc"] = [p[3] for p in v4]
|
||||
ctx.check(v4, "no DHCP client is running")
|
||||
for pid, ppid, _comm, cmd in v4:
|
||||
ctx.check("-b" in cmd.split(), "the DHCP client runs without -b: %s", cmd)
|
||||
ctx.check(ppid == 1, "the DHCP client (pid %d) is still a child of pid %d", pid, ppid)
|
||||
# no DHCPv6 client running (none on the image is the rootfs lint's)
|
||||
alive = [p[3] for p in procs if p[2] in DHCP6_CLIENTS]
|
||||
ev["dhcp6_running"] = alive
|
||||
ctx.check(not alive, "a DHCPv6 client is running: %s", alive)
|
||||
# IPv6 is the kernel's own. A global address is evidence only: a
|
||||
# network whose router advertisement offers no SLAAC prefix gives none.
|
||||
conf = "/proc/sys/net/ipv6/conf/wlan0/"
|
||||
ev["disable_ipv6"] = (_read(conf + "disable_ipv6", "") or "").strip()
|
||||
ev["accept_ra"] = (_read(conf + "accept_ra", "") or "").strip()
|
||||
ctx.check(ev["disable_ipv6"] == "0", "IPv6 is off on wlan0 (disable_ipv6=%r)", ev["disable_ipv6"])
|
||||
ctx.check(ev["accept_ra"] not in ("", "0"),
|
||||
"wlan0 does not accept router advertisements (accept_ra=%r)", ev["accept_ra"])
|
||||
local, world = link_addrs(_read("/proc/net/if_inet6", ""), "wlan0")
|
||||
ev["link_local"] = local
|
||||
ev["global"] = world
|
||||
ctx.check(local, "wlan0 has no link-local IPv6 address")
|
||||
ctx.log("wlan0: %d link-local, %d global IPv6 address(es)", len(local), len(world))
|
||||
|
||||
|
||||
|
||||
|
||||
# ------------------------------------------------------- the network boot
|
||||
|
||||
IFSTATE = "/var/run/ifstate" # busybox ifupdown's state file
|
||||
INTERFACES = "/etc/network/interfaces"
|
||||
DHCP6_CLIENTS = ("udhcpc6", "dhcp6c", "odhcp6c")
|
||||
|
||||
|
||||
def ifstate_names(text):
|
||||
"""The interfaces ifup finished configuring: one name=name line each
|
||||
in busybox's state file, written when ifup returns for the name."""
|
||||
return {line.split("=", 1)[0].strip() for line in (text or "").splitlines() if "=" in line}
|
||||
|
||||
|
||||
def dhcp6_in_interfaces(text):
|
||||
"""The lines of an interfaces file that start a DHCPv6 client: an
|
||||
inet6 stanza with a method that runs one, or a command that names
|
||||
one. A comment starts nothing."""
|
||||
bad = []
|
||||
for raw in (text or "").splitlines():
|
||||
words = raw.split("#", 1)[0].split()
|
||||
if not words:
|
||||
continue
|
||||
if words[0] == "iface":
|
||||
if len(words) >= 4 and words[2] == "inet6" and words[3] in ("dhcp", "auto"):
|
||||
bad.append(" ".join(words))
|
||||
elif any(w.split("/")[-1] in DHCP6_CLIENTS for w in words[1:]):
|
||||
bad.append(" ".join(words))
|
||||
return bad
|
||||
|
||||
|
||||
def parse_stat(text):
|
||||
"""(pid, comm, ppid) from a /proc/<pid>/stat line, or None. The comm
|
||||
sits in parentheses and may itself hold spaces and parentheses."""
|
||||
try:
|
||||
head, tail = text.split("(", 1)
|
||||
comm, rest = tail.rsplit(")", 1)
|
||||
return int(head), comm, int(rest.split()[1])
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def _procs():
|
||||
"""(pid, ppid, comm, command line) of every process."""
|
||||
out = []
|
||||
for name in os.listdir("/proc"):
|
||||
if not name.isdigit():
|
||||
continue
|
||||
st = parse_stat(_read("/proc/%s/stat" % name, ""))
|
||||
if st is None:
|
||||
continue
|
||||
cmd = (_read("/proc/%s/cmdline" % name, "") or "").replace("\0", " ").strip()
|
||||
out.append((st[0], st[2], st[1], cmd))
|
||||
return out
|
||||
|
||||
|
||||
def link_addrs(text, iface):
|
||||
"""(link-local, global) IPv6 addresses of one interface, as the hex
|
||||
strings of /proc/net/if_inet6 (address, index, prefix length, scope,
|
||||
flags, name; scope 20 is the link, 00 the world)."""
|
||||
local, world = [], []
|
||||
for line in (text or "").splitlines():
|
||||
f = line.split()
|
||||
if len(f) == 6 and f[5] == iface:
|
||||
if f[3] == "20":
|
||||
local.append(f[0])
|
||||
elif f[3] == "00":
|
||||
world.append(f[0])
|
||||
return local, world
|
||||
|
||||
|
||||
@@ -1,924 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""kernel.* - glowforge.ko safety readbacks and the pulse-engine drills.
|
||||
|
||||
The drills are the bench scripts `scripts/bench/gate_a_kernel_drills.py`
|
||||
(K1/K2/K3) and `scripts/bench/fire_test.py` (A/B/U) with their proven
|
||||
sequences kept intact; they run under a hardware takeover (forgectrl and
|
||||
the controller stopped, the pulse device free) and judge the software
|
||||
witnesses: cnc/state, laser_enable (the FIRE line), laser_on and
|
||||
laser_on_sampled (the gated LASER_ON output), interlock_circuit bit 3
|
||||
(the commanded latch), faults and underruns. Every drill forces duty to
|
||||
zero before any FIRE bit, keeps motor_lock=15 (no axis moves), and
|
||||
re-locks the latch on every exit path. K3 and fire B/U unlock the latch
|
||||
for their run and therefore refuse to proceed unless the safety chain
|
||||
holds HV off: the charge-pump watchdog dead and the pulse engine idle
|
||||
(HV_ENABLE follows the pump; laser_pgood is the supply's power-good and
|
||||
says nothing about HV).
|
||||
"""
|
||||
import errno
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError: # host unit tests import the suite off-target
|
||||
fcntl = None
|
||||
|
||||
from ..catalog import test
|
||||
from .. import hw
|
||||
from ..runner import Failed
|
||||
|
||||
# interlock_circuit bits (docs.forgefirm.org, the kernel module page): bit 3 = the driven latch line, set = locked.
|
||||
LATCH_BIT = 1 << 3
|
||||
|
||||
TICK_HZ = 10000
|
||||
FIRE = b"\x10"
|
||||
PAD = b"\x00"
|
||||
XSTEP = b"\x01" # +X (DIR clear)
|
||||
XSTEP_BACK = b"\x03" # -X (DIR set)
|
||||
POWER0 = bytes([0x80]) # power byte, duty 0
|
||||
|
||||
_KERNEL_COVERS = [("kernel-module-glowforge", "**"), ("linux-fslc", "**")]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- helpers
|
||||
|
||||
def wr(attr, val):
|
||||
hw.sysfs_write(attr, val)
|
||||
|
||||
|
||||
def rd(attr):
|
||||
v = hw.sysfs_read(attr)
|
||||
if v is None:
|
||||
raise Failed("cannot read %s" % attr)
|
||||
return v
|
||||
|
||||
|
||||
def rd_pos():
|
||||
with open(hw.sysfs_root() + "cnc/position", "rb") as f:
|
||||
raw = f.read(32)
|
||||
return struct.unpack("<5i", raw[:20])
|
||||
|
||||
|
||||
def snap(ctx, tag):
|
||||
line = ("%s: state=%s laser_enable=%s laser_on=%s laser_on_sampled=%s interlock=%s"
|
||||
% (tag, rd("cnc/state"), rd("cnc/laser_enable"), rd("cnc/laser_on"),
|
||||
rd("cnc/laser_on_sampled"), rd("cnc/interlock_circuit")))
|
||||
ctx.log(line)
|
||||
return line
|
||||
|
||||
|
||||
def wait_state(ctx, want, timeout, poll=0.05):
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < timeout:
|
||||
ctx.checkpoint()
|
||||
s = rd("cnc/state")
|
||||
if s == want:
|
||||
return s
|
||||
time.sleep(poll)
|
||||
return rd("cnc/state")
|
||||
|
||||
|
||||
def watch_laser_until_idle(ctx, timeout):
|
||||
"""Tight-loop laser_enable/laser_on watch; returns (hits, end_state)."""
|
||||
hits = []
|
||||
t0 = time.time()
|
||||
state = "running"
|
||||
n = 0
|
||||
while time.time() - t0 < timeout:
|
||||
en = rd("cnc/laser_enable")
|
||||
on = rd("cnc/laser_on")
|
||||
if en != "0" or on != "0":
|
||||
hits.append((round(time.time() - t0, 4), en, on))
|
||||
state = rd("cnc/state")
|
||||
if state != "running":
|
||||
break
|
||||
n += 1
|
||||
if n % 200 == 0:
|
||||
ctx.checkpoint()
|
||||
return hits, state
|
||||
|
||||
|
||||
class PulseDevice:
|
||||
"""Exclusive hold of /dev/glowforge for one drill."""
|
||||
|
||||
def __init__(self, ctx):
|
||||
self.ctx = ctx
|
||||
self.fd = None
|
||||
|
||||
def __enter__(self):
|
||||
try:
|
||||
self.fd = os.open("/dev/glowforge", os.O_WRONLY)
|
||||
except OSError as e:
|
||||
if e.errno == errno.EBUSY:
|
||||
raise Failed("/dev/glowforge is busy - the takeover did not free the pulse device")
|
||||
raise
|
||||
fcntl.flock(self.fd, fcntl.LOCK_EX)
|
||||
return self
|
||||
|
||||
def write(self, data):
|
||||
os.write(self.fd, data)
|
||||
|
||||
def rewind(self):
|
||||
os.lseek(self.fd, 1, os.SEEK_SET)
|
||||
|
||||
def drop(self):
|
||||
"""Close the device with the lock still held: the dead man's switch."""
|
||||
fd, self.fd = self.fd, None
|
||||
os.close(fd)
|
||||
|
||||
def __exit__(self, *exc):
|
||||
try:
|
||||
wr("cnc/laser_latch", 1) # re-lock unconditionally
|
||||
finally:
|
||||
if self.fd is not None:
|
||||
fcntl.flock(self.fd, fcntl.LOCK_UN)
|
||||
os.close(self.fd)
|
||||
return False
|
||||
|
||||
|
||||
def hv_off_reason():
|
||||
"""Why the safety chain is NOT holding HV off right now, or None. The
|
||||
chain asserts HV_ENABLE only while a run feeds the charge-pump
|
||||
watchdog, so a dead watchdog and an idle pulse engine mean HV off.
|
||||
laser_pgood is the supply's power-good, high whenever the supply is
|
||||
healthy, and says nothing about HV, so it plays no part here."""
|
||||
alive = rd("cnc/charge_pump_alive")
|
||||
state = rd("cnc/state")
|
||||
if alive is None or state is None:
|
||||
return "cnc/charge_pump_alive or cnc/state unreadable"
|
||||
if alive != "0" or state != "idle":
|
||||
return ("charge_pump_alive=%s state=%s: the chain may be holding HV_ENABLE up" % (alive, state))
|
||||
return None
|
||||
|
||||
|
||||
def hv_off():
|
||||
"""Precheck for the drills that unlock the latch with a zero-duty
|
||||
stream: they run only while the chain holds HV off. At idle nothing
|
||||
feeds the watchdog, so this refuses a start only on a machine that is
|
||||
not idle the way it should be."""
|
||||
why = hv_off_reason()
|
||||
if why:
|
||||
return ("%s; this drill unlocks the latch with a zero-duty stream and needs HV off "
|
||||
"(let the machine go idle, then start again)" % why)
|
||||
return None
|
||||
|
||||
|
||||
HV_RELEASE_S = 3.0
|
||||
|
||||
|
||||
def wait_hv_off(ctx, timeout_s=HV_RELEASE_S):
|
||||
"""hv_off_reason() once the chain has released, or the reason that still
|
||||
stands after timeout_s. A run feeds the charge-pump watchdog every
|
||||
200 ms and the one-shot holds ALIVE for 0.45 s after the last feed (the
|
||||
feed's soft timer can add one more feed after the state leaves
|
||||
running), so a phase that follows a run finds the chain still up for
|
||||
under a second. The wait covers that release and nothing more; the
|
||||
time it took is logged when it was not immediate and kept in the
|
||||
evidence."""
|
||||
t0 = time.time()
|
||||
why = hv_off_reason()
|
||||
while why is not None and time.time() - t0 < timeout_s:
|
||||
ctx.sleep(0.05)
|
||||
why = hv_off_reason()
|
||||
dt = round(time.time() - t0, 2)
|
||||
ctx.evidence.setdefault("hv_release_s", []).append(dt)
|
||||
if why is not None or dt >= 0.1:
|
||||
ctx.log("hv off: %s after %.2f s", "released" if why is None else "still held", dt)
|
||||
return why
|
||||
|
||||
|
||||
def require_hv_off(ctx):
|
||||
"""The same rule at the start of the run (the precheck ran a moment
|
||||
earlier; the machine must still agree)."""
|
||||
why = wait_hv_off(ctx)
|
||||
ctx.evidence["charge_pump_alive"] = rd("cnc/charge_pump_alive")
|
||||
ctx.evidence["kernel_state"] = rd("cnc/state")
|
||||
ctx.check(why is None, "%s - refusing the latch unlock", why)
|
||||
|
||||
|
||||
def check_hv_off(ctx):
|
||||
"""The hard check right before an unlock (no prompt: forgectrl is down)."""
|
||||
why = wait_hv_off(ctx)
|
||||
ctx.check(why is None, "%s - refusing the latch unlock", why)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- readbacks
|
||||
|
||||
@test("kernel.latch-locked-idle", title="Laser latch locked at idle", subsystem="kernel",
|
||||
kind="auto", always=True, est_min=1,
|
||||
covers=_KERNEL_COVERS + [("forgectrl", "src/super.c"),
|
||||
("grblhal-glowforge", "src/glowforge_laser.c"),
|
||||
("grblhal-glowforge", "src/driver.c")],
|
||||
description="With the machine idle the kernel latch reads locked, the FIRE line is not "
|
||||
"driven, no LASER_ON sample is seen, and no stepper fault is pending; forgectrl "
|
||||
"agrees.")
|
||||
def latch_locked_idle(ctx):
|
||||
ev = ctx.evidence
|
||||
state = hw.sysfs_read("cnc/state")
|
||||
ev["cnc_state"] = state
|
||||
ctx.log("cnc/state: %s", state)
|
||||
ctx.check(state is not None, "cnc/state unreadable")
|
||||
ctx.check(state in ("idle", "disabled"), "machine is %r, run this test at idle", state)
|
||||
|
||||
ilk = hw.sysfs_int("cnc/interlock_circuit")
|
||||
ev["interlock_circuit"] = ilk
|
||||
ctx.check(ilk is not None, "cnc/interlock_circuit unreadable")
|
||||
ctx.log("interlock_circuit: %d (0x%x)", ilk, ilk)
|
||||
ctx.check(ilk & LATCH_BIT, "latch line reads unlocked at idle (bit 3 clear)")
|
||||
|
||||
fire = hw.sysfs_int("cnc/laser_enable")
|
||||
ev["laser_enable"] = fire
|
||||
ctx.log("laser_enable (FIRE line): %s", fire)
|
||||
ctx.check(fire == 0, "FIRE line driven at idle (laser_enable=%s)", fire)
|
||||
|
||||
on = hw.sysfs_int("cnc/laser_on")
|
||||
on_s = hw.sysfs_int("cnc/laser_on_sampled")
|
||||
ev["laser_on"] = on
|
||||
ev["laser_on_sampled"] = on_s
|
||||
ctx.log("laser_on: %s, laser_on_sampled: %s", on, on_s)
|
||||
ctx.check(on == 0, "LASER_ON active at idle")
|
||||
ctx.check(on_s == 0, "LASER_ON samples seen at idle (%s)", on_s)
|
||||
|
||||
faults = hw.sysfs_int("cnc/faults")
|
||||
ev["faults"] = faults
|
||||
ctx.log("faults: %s", faults)
|
||||
ctx.check(faults == 0, "stepper faults pending: %s", faults)
|
||||
|
||||
st = ctx.forgectrl.status()
|
||||
ev["forgectrl_laser_locked"] = st.get("laser_locked")
|
||||
ctx.log("forgectrl /status laser_locked=%s state=%s", st.get("laser_locked"), st.get("state"))
|
||||
ctx.check(st.get("laser_locked") is True, "forgectrl reports the latch unlocked")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- K1 + K2
|
||||
|
||||
@test("kernel.k1-k2", title="Controlled-stop floor and resume honors the locked latch",
|
||||
subsystem="kernel", kind="auto", hardware="takeover", always=True, est_min=2,
|
||||
covers=_KERNEL_COVERS,
|
||||
requires=["kernel.latch-locked-idle"],
|
||||
description="K1: a controlled stop mid-run ramps the step frequency down (tens of ms), "
|
||||
"never consumes the tail as a burst or hangs. K2: with the latch locked, a "
|
||||
"stop inside the leading pads and a resume with a positive waypoint replays "
|
||||
"a 2 s FIRE window with laser_enable/laser_on at 0 throughout. Motors locked; "
|
||||
"duty zero.")
|
||||
def k1_k2(ctx):
|
||||
ev = ctx.evidence
|
||||
with ctx.takeover():
|
||||
# ---- K1
|
||||
stream = POWER0 + PAD * (6 * TICK_HZ)
|
||||
ctx.log("K1: %d bytes = %.1f s of pads at %d Hz, ramp 125000 Hz/s",
|
||||
len(stream), len(stream) / TICK_HZ, TICK_HZ)
|
||||
snap(ctx, "K1 pre")
|
||||
wr("cnc/motor_lock", 15)
|
||||
wr("cnc/laser_latch", 1)
|
||||
wr("cnc/ramp_rate", 125000)
|
||||
wr("cnc/step_freq", TICK_HZ)
|
||||
with PulseDevice(ctx) as dev:
|
||||
dev.rewind()
|
||||
wr("cnc/enable", 1)
|
||||
ctx.sleep(0.5)
|
||||
dev.write(stream)
|
||||
wr("cnc/run", 1)
|
||||
ctx.sleep(1.5) # well past the accel ramp
|
||||
st = rd("cnc/state")
|
||||
ctx.check(st == "running", "K1: expected running before the stop, got %s", st)
|
||||
t0 = time.time()
|
||||
wr("cnc/stop", 1)
|
||||
while time.time() - t0 < 5:
|
||||
if rd("cnc/state") != "running":
|
||||
break
|
||||
dt = time.time() - t0
|
||||
state = rd("cnc/state")
|
||||
faults = rd("cnc/faults")
|
||||
ev["k1"] = {"stop_to_idle_s": round(dt, 4), "state": state, "faults": faults}
|
||||
ctx.log("K1 controlled stop: state=%s after %.4f s, faults=%s", state, dt, faults)
|
||||
# drain the paused remainder laser-less so the device ends clean
|
||||
wr("cnc/resume", 0)
|
||||
wait_state(ctx, "running", 2, poll=0.005)
|
||||
wait_state(ctx, "idle", 10)
|
||||
ctx.check(state == "idle", "K1: state %s after the stop", state)
|
||||
ctx.check(dt >= 0.02, "K1: stop consumed the tail as a burst (%.4f s) - decel floor broken", dt)
|
||||
ctx.check(dt <= 3.0, "K1: stop took %.4f s", dt)
|
||||
ctx.check(faults == "0", "K1: faults=%s", faults)
|
||||
ctx.log("K1 PASS: decelerating tail %.4f s, no burst, no fault", dt)
|
||||
|
||||
# ---- K2
|
||||
# 1000 X steps out and 1000 back at 2 kHz (masked by motor_lock):
|
||||
# the position counters end where they started
|
||||
step_sec = (XSTEP + PAD * 4) * 1000 + (XSTEP_BACK + PAD * 4) * 1000
|
||||
stream = (POWER0 + PAD * TICK_HZ + step_sec + PAD * (TICK_HZ // 2)
|
||||
+ FIRE * (2 * TICK_HZ) + PAD * TICK_HZ)
|
||||
ctx.log("K2: %d bytes = %.1f s; latch stays LOCKED; waypoint +200; motor_lock=15",
|
||||
len(stream), len(stream) / TICK_HZ)
|
||||
snap(ctx, "K2 pre")
|
||||
wr("cnc/motor_lock", 15)
|
||||
wr("cnc/laser_latch", 1)
|
||||
wr("cnc/ramp_rate", 125000)
|
||||
wr("cnc/step_freq", TICK_HZ)
|
||||
with PulseDevice(ctx) as dev:
|
||||
dev.rewind()
|
||||
wr("cnc/enable", 1)
|
||||
ctx.sleep(0.5)
|
||||
dev.write(stream)
|
||||
pos_before = rd_pos()
|
||||
wr("cnc/run", 1)
|
||||
ctx.sleep(0.4) # inside the initial pads
|
||||
wr("cnc/stop", 1)
|
||||
state = wait_state(ctx, "idle", 5, poll=0.01)
|
||||
ctx.check(state == "idle", "K2: controlled stop did not reach idle (state=%s)", state)
|
||||
ctx.log("K2: paused inside the pads; resuming with waypoint +200 (latch LOCKED)")
|
||||
wr("cnc/resume", 200)
|
||||
wait_state(ctx, "running", 2, poll=0.005)
|
||||
hits, state = watch_laser_until_idle(ctx, 20)
|
||||
pos_after = rd_pos()
|
||||
snap(ctx, "K2 post")
|
||||
ev["k2"] = {"hits": hits[:10], "end_state": state, "pos_before": pos_before,
|
||||
"pos_after": pos_after, "laser_on_sampled": rd("cnc/laser_on_sampled"),
|
||||
"underruns": rd("cnc/underruns"), "faults": rd("cnc/faults")}
|
||||
ctx.log("K2 done: state=%s pos before=%s after=%s", state, pos_before, pos_after)
|
||||
ctx.check(not hits, "K2: laser asserted with the latch locked: %s", hits[:10])
|
||||
ctx.check(pos_before[:3] == pos_after[:3],
|
||||
"K2: position counters did not return to start (%s -> %s)", pos_before[:3], pos_after[:3])
|
||||
ctx.log("K2 PASS: FIRE window replayed after the resume waypoint with "
|
||||
"laser_enable/laser_on at 0 throughout")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- dead man
|
||||
|
||||
@test("kernel.deadman-close", title="The final close of a running pulse device halts the engine and safes the head",
|
||||
subsystem="kernel", kind="auto", hardware="takeover", always=True, est_min=1,
|
||||
covers=_KERNEL_COVERS,
|
||||
requires=["kernel.k1-k2"],
|
||||
description="The kernel dead man's switch: a pulse device closed while locked and running "
|
||||
"halts the engine at once, locks the laser latch, and puts the head in its safe "
|
||||
"state - lens driver disabled at low current, measure laser and UV LED off. The "
|
||||
"lens driver is enabled at low current before the close, so the safe state has "
|
||||
"something to undo. Motors locked; duty zero.")
|
||||
def deadman_close(ctx):
|
||||
ev = ctx.evidence
|
||||
with ctx.takeover():
|
||||
stream = POWER0 + PAD * (6 * TICK_HZ)
|
||||
snap(ctx, "deadman pre")
|
||||
wr("cnc/motor_lock", 15)
|
||||
wr("cnc/laser_latch", 1)
|
||||
wr("cnc/ramp_rate", 125000)
|
||||
wr("cnc/step_freq", TICK_HZ)
|
||||
wr("head/z_current", 1)
|
||||
wr("head/z_enable", 0)
|
||||
before = {"z_enable": rd("head/z_enable"), "z_current": rd("head/z_current")}
|
||||
ev["head_before"] = before
|
||||
ctx.check(before["z_enable"] == "0", "the lens driver did not enable (z_enable %s)", before["z_enable"])
|
||||
with PulseDevice(ctx) as dev:
|
||||
dev.rewind()
|
||||
wr("cnc/enable", 1)
|
||||
ctx.sleep(0.5)
|
||||
dev.write(stream)
|
||||
wr("cnc/run", 1)
|
||||
ctx.sleep(1.0)
|
||||
st = rd("cnc/state")
|
||||
ctx.check(st == "running", "expected running before the close, got %s", st)
|
||||
t0 = time.time()
|
||||
dev.drop()
|
||||
while time.time() - t0 < 5:
|
||||
if rd("cnc/state") != "running":
|
||||
break
|
||||
dt = time.time() - t0
|
||||
state = rd("cnc/state")
|
||||
after = {"z_enable": rd("head/z_enable"), "z_current": rd("head/z_current"),
|
||||
"measure_laser": rd("head/measure_laser"), "uv_led": rd("head/uv_led")}
|
||||
latch = hw.sysfs_int("cnc/interlock_circuit")
|
||||
ev["close"] = {"halt_s": round(dt, 4), "state": state, "faults": rd("cnc/faults"),
|
||||
"interlock_circuit": latch}
|
||||
ev["head_after"] = after
|
||||
snap(ctx, "deadman post")
|
||||
ctx.log("final close: state=%s after %.4f s; head %s", state, dt, after)
|
||||
ctx.check(state == "idle", "the engine did not halt on the final close (state %s)", state)
|
||||
ctx.check(dt < 1.0, "the halt took %.3f s", dt)
|
||||
ctx.check(latch is not None and latch & (1 << 3), "the latch is not locked after the close")
|
||||
ctx.check(after["z_enable"] == "1" and after["z_current"] == "1",
|
||||
"the head was not safed: z_enable %s z_current %s (want 1, 1: driver disabled, low current)",
|
||||
after["z_enable"], after["z_current"])
|
||||
ctx.check(after["measure_laser"] == "0" and after["uv_led"] == "0",
|
||||
"measure laser %s, UV LED %s after the close", after["measure_laser"], after["uv_led"])
|
||||
wr("cnc/stop", 1)
|
||||
ctx.log("PASS: the final close halted the engine in %.3f s, latch locked, head safed", dt)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- backtrack
|
||||
|
||||
@test("kernel.backtrack-bounds", title="A backward run is bounded by the history the ring still holds",
|
||||
subsystem="kernel", kind="auto", hardware="takeover", always=True, est_min=2,
|
||||
covers=_KERNEL_COVERS,
|
||||
requires=["kernel.k1-k2"],
|
||||
description="A pause walks the program backward to put the beam back over ground the job "
|
||||
"already cut, and the ring is what remembers that ground. cnc/max_backtrack is "
|
||||
"the distance still available: what has played, less the tail the deceleration "
|
||||
"spends. This drills the boundary on real hardware - the readback matches the "
|
||||
"bytes played, a step beyond it is refused rather than quietly shortened, and "
|
||||
"the run at the boundary plays out and returns to idle. Motors locked, latch "
|
||||
"locked, duty zero: nothing moves and nothing fires.")
|
||||
def backtrack_bounds(ctx):
|
||||
ev = ctx.evidence
|
||||
tail = TICK_HZ * TICK_HZ // (2 * 125000) # v^2/2a: 400 steps at the print tick
|
||||
with ctx.takeover():
|
||||
stream = POWER0 + PAD * (6 * TICK_HZ)
|
||||
ctx.log("%d bytes = %.1f s of pads at %d Hz; decel tail %d steps",
|
||||
len(stream), len(stream) / TICK_HZ, TICK_HZ, tail)
|
||||
snap(ctx, "pre")
|
||||
wr("cnc/motor_lock", 15)
|
||||
wr("cnc/laser_latch", 1)
|
||||
wr("cnc/ramp_rate", 125000)
|
||||
wr("cnc/step_freq", TICK_HZ)
|
||||
with PulseDevice(ctx) as dev:
|
||||
dev.rewind()
|
||||
wr("cnc/enable", 1)
|
||||
ctx.sleep(0.5)
|
||||
dev.write(stream)
|
||||
wr("cnc/run", 1)
|
||||
ctx.sleep(1.5) # well past the accel ramp
|
||||
wr("cnc/stop", 1)
|
||||
state = wait_state(ctx, "idle", 5, poll=0.01)
|
||||
ctx.check(state == "idle", "the controlled stop did not reach idle (state=%s)", state)
|
||||
|
||||
played = rd_pos()[3]
|
||||
budget = int(rd("cnc/max_backtrack"))
|
||||
ev["played"] = played
|
||||
ev["max_backtrack"] = budget
|
||||
ctx.log("played %d bytes; max_backtrack %d", played, budget)
|
||||
ctx.check(budget > 0, "max_backtrack is %d after %d bytes played", budget, played)
|
||||
# The two numbers come from different SDMA registers (the byte
|
||||
# counter and the ring head), so allow a few bytes of skew - but
|
||||
# not the whole gap, and not the whole played span.
|
||||
ctx.check(abs(budget - (played - tail)) <= 8,
|
||||
"max_backtrack %d is not the %d bytes played less the %d-step decel tail",
|
||||
budget, played, tail)
|
||||
|
||||
# One step past the boundary: refused, and the device stays idle.
|
||||
refused = None
|
||||
try:
|
||||
wr("cnc/resume", -(budget + 1))
|
||||
except OSError as e:
|
||||
refused = e.errno
|
||||
ev["over_long_errno"] = refused
|
||||
ctx.check(refused == errno.EPERM,
|
||||
"a backtrack one step past the boundary was not refused with EPERM (%s)",
|
||||
refused)
|
||||
state = rd("cnc/state")
|
||||
ctx.check(state == "idle", "the refused backtrack left the device %s", state)
|
||||
|
||||
# At the boundary: runs, decelerates inside genuine data, ends idle.
|
||||
wr("cnc/resume", -budget)
|
||||
wait_state(ctx, "running", 2, poll=0.005)
|
||||
hits, state = watch_laser_until_idle(ctx, 30)
|
||||
after = int(rd("cnc/max_backtrack"))
|
||||
ev["after"] = {"state": state, "max_backtrack": after,
|
||||
"faults": rd("cnc/faults"), "underruns": rd("cnc/underruns")}
|
||||
ctx.log("backtrack of %d done: state=%s max_backtrack now %d", budget, state, after)
|
||||
# Leave the program drained so the device ends where the other
|
||||
# drills expect it.
|
||||
wr("cnc/resume", 0)
|
||||
wait_state(ctx, "running", 2, poll=0.005)
|
||||
wait_state(ctx, "idle", 30)
|
||||
ctx.check(not hits, "the laser asserted during the backward run: %s", hits[:10])
|
||||
ctx.check(state == "idle", "the backward run ended in %s", state)
|
||||
ctx.check(ev["after"]["faults"] == "0", "faults=%s after the backward run",
|
||||
ev["after"]["faults"])
|
||||
ctx.log("PASS: max_backtrack tracks the played history, the boundary is enforced, "
|
||||
"and the run at it plays out clean")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- K3
|
||||
|
||||
def _k3_phase(ctx):
|
||||
"""K3: the latch unlocked during the accel ramp must not restore the FIRE
|
||||
drive while the run is in flight. Runs inside the caller's takeover."""
|
||||
ev = ctx.evidence
|
||||
check_hv_off(ctx)
|
||||
stream = POWER0 + FIRE * (3 * TICK_HZ) + PAD * (TICK_HZ // 2)
|
||||
ctx.log("K3: %d bytes = %.1f s of FIRE bits; ramp_rate 10000 Hz/s (~0.9 s accel "
|
||||
"window); unlock at t=+0.15 s", len(stream), len(stream) / TICK_HZ)
|
||||
snap(ctx, "K3 pre")
|
||||
wr("cnc/motor_lock", 15)
|
||||
wr("cnc/laser_latch", 1)
|
||||
wr("cnc/step_freq", TICK_HZ)
|
||||
wr("cnc/ramp_rate", 10000)
|
||||
try:
|
||||
with PulseDevice(ctx) as dev:
|
||||
dev.rewind()
|
||||
wr("cnc/enable", 1)
|
||||
ctx.sleep(0.5)
|
||||
dev.write(stream)
|
||||
wr("cnc/run", 1)
|
||||
time.sleep(0.15) # inside the accel ramp
|
||||
wr("cnc/laser_latch", 0)
|
||||
ilk = rd("cnc/interlock_circuit")
|
||||
ctx.log("K3: latch UNLOCKED mid-ramp; interlock=%s (bit 3 should read 0)", ilk)
|
||||
hits, state = watch_laser_until_idle(ctx, 20)
|
||||
snap(ctx, "K3 post")
|
||||
ev["k3"] = {"interlock_after_unlock": ilk, "hits": hits[:10], "end_state": state,
|
||||
"laser_on_sampled": rd("cnc/laser_on_sampled"),
|
||||
"underruns": rd("cnc/underruns"), "faults": rd("cnc/faults")}
|
||||
finally:
|
||||
wr("cnc/laser_latch", 1)
|
||||
try:
|
||||
wr("cnc/ramp_rate", 125000)
|
||||
except OSError:
|
||||
ctx.log("WARNING: could not restore ramp_rate=125000")
|
||||
ctx.check((int(ilk) & LATCH_BIT) == 0, "K3: the unlock did not drive the latch pin (interlock=%s)", ilk)
|
||||
ctx.check(not hits, "K3: FIRE drive re-armed by a mid-run unlock: %s", hits[:10])
|
||||
ctx.log("K3 PASS: laser_enable stayed 0 for the entire run after the mid-ramp unlock")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- FIRE A/B/U
|
||||
|
||||
def _fire_stream():
|
||||
return (POWER0 + # duty zero before any FIRE bit
|
||||
PAD * TICK_HZ + # 1 s baseline
|
||||
FIRE * (2 * TICK_HZ) + # 2.000 s FIRE window (bounded by pads)
|
||||
PAD * TICK_HZ + # 1 s gap
|
||||
FIRE * (2 * TICK_HZ)) # 2.000 s FIRE window ending AT end-of-data
|
||||
|
||||
|
||||
def _fire_phase(ctx, mode):
|
||||
"""One phase of fire_test.py; returns the evidence dict."""
|
||||
unlock = mode in ("B", "U")
|
||||
underrun_mode = mode == "U"
|
||||
stream = _fire_stream()
|
||||
ctx.log("fire %s: stream %d bytes = %.3f s", mode, len(stream), len(stream) / TICK_HZ)
|
||||
if unlock:
|
||||
check_hv_off(ctx)
|
||||
snap(ctx, "fire %s pre" % mode)
|
||||
wr("cnc/motor_lock", 15)
|
||||
wr("cnc/step_freq", TICK_HZ)
|
||||
wr("cnc/laser_latch", 1)
|
||||
underruns_before = int(rd("cnc/underruns"))
|
||||
mid = None
|
||||
tail = None
|
||||
with PulseDevice(ctx) as dev:
|
||||
dev.rewind()
|
||||
wr("cnc/enable", 1)
|
||||
ctx.sleep(0.5)
|
||||
dev.write(stream)
|
||||
pos_before = rd_pos()
|
||||
if underrun_mode:
|
||||
wr("cnc/streaming", 1) # end-of-data mid-run = true underrun
|
||||
ctx.log("fire U: streaming=1, the terminal end-of-data will be a TRUE UNDERRUN")
|
||||
try:
|
||||
if unlock:
|
||||
wr("cnc/laser_latch", 0)
|
||||
ctx.log("fire %s: latch UNLOCKED for this run", mode)
|
||||
wr("cnc/run", 1)
|
||||
t0 = time.time()
|
||||
state = ""
|
||||
samples = []
|
||||
while time.time() - t0 < 20:
|
||||
ctx.checkpoint()
|
||||
state = rd("cnc/state")
|
||||
dt = time.time() - t0
|
||||
en, on, ons = rd("cnc/laser_enable"), rd("cnc/laser_on"), rd("cnc/laser_on_sampled")
|
||||
samples.append((round(dt, 2), state, en, on, ons))
|
||||
if mid is None and 1.5 < dt < 3.0:
|
||||
mid = {"t": round(dt, 2), "laser_enable": en, "laser_on": on,
|
||||
"laser_on_sampled": ons, "interlock": rd("cnc/interlock_circuit")}
|
||||
ctx.log("fire %s mid (inside FIRE window): laser_enable=%s laser_on=%s "
|
||||
"laser_on_sampled=%s interlock=%s", mode, en, on, ons, mid["interlock"])
|
||||
if state != "running":
|
||||
break
|
||||
time.sleep(0.05)
|
||||
end_dt = time.time() - t0
|
||||
tail = {"state": state, "after_s": round(end_dt, 2), "laser_enable": rd("cnc/laser_enable"),
|
||||
"laser_on": rd("cnc/laser_on")}
|
||||
ctx.log("fire %s done: state=%s after %.1f s (laser_enable=%s)", mode, state, end_dt,
|
||||
tail["laser_enable"])
|
||||
if underrun_mode:
|
||||
if state == "underrun":
|
||||
ctx.log("fire U: underrun state reached as EXPECTED; acking via stop")
|
||||
else:
|
||||
ctx.log("fire U: WARNING expected underrun state, got %s", state)
|
||||
wr("cnc/stop", 1)
|
||||
wr("cnc/streaming", 0)
|
||||
tail["acked_state"] = rd("cnc/state")
|
||||
ctx.log("fire U: acked: state=%s", tail["acked_state"])
|
||||
finally:
|
||||
wr("cnc/laser_latch", 1)
|
||||
if underrun_mode:
|
||||
try:
|
||||
wr("cnc/streaming", 0)
|
||||
except OSError:
|
||||
pass
|
||||
pos_after = rd_pos()
|
||||
snap(ctx, "fire %s post" % mode)
|
||||
ev = {"mid": mid, "tail": tail, "moved": pos_before[:3] != pos_after[:3],
|
||||
"underruns_before": underruns_before, "underruns_after": int(rd("cnc/underruns")),
|
||||
"faults": rd("cnc/faults"),
|
||||
"any_laser_on": any(s[3] != "0" or s[4] != "0" for s in samples),
|
||||
"any_fire_driven": any(s[2] != "0" for s in samples)}
|
||||
ctx.log("fire %s: moved=%s underruns %d->%d faults=%s", mode, ev["moved"],
|
||||
ev["underruns_before"], ev["underruns_after"], ev["faults"])
|
||||
return ev
|
||||
|
||||
|
||||
@test("kernel.fire-line", title="FIRE line: latch locked, unlocked-unarmed, true underrun, and a "
|
||||
"mid-run unlock",
|
||||
subsystem="kernel", kind="auto", hardware="takeover", always=True, est_min=4,
|
||||
covers=_KERNEL_COVERS,
|
||||
requires=["kernel.k1-k2"], precheck=hv_off,
|
||||
steps=["Phases B, U and K3 unlock the latch with a zero-duty stream, so the drill starts only "
|
||||
"while the HV supply does not report good (true at idle; if it is refused, open the "
|
||||
"lid - the safety chain holds HV off - and start it again)."],
|
||||
description="Four phases behind one takeover of the pulse device, all zero duty. A: latch "
|
||||
"locked, 40 000 streamed FIRE bits, nothing on the FIRE/LASER_ON nets. B: latch "
|
||||
"unlocked with the chain unarmed, the FIRE line is driven mid-window and "
|
||||
"LASER_ON stays off (the safety AND-gate holds), FIRE clear at end-of-data. U: "
|
||||
"streaming declared, the terminal end-of-data is a true underrun, the backstop "
|
||||
"drops FIRE and stop acks it. K3: the latch unlocked during the accel ramp "
|
||||
"drives the latch pin but never restores the FIRE drive to a run already in "
|
||||
"flight - laser_enable stays 0 for the whole run.")
|
||||
def fire_line(ctx):
|
||||
ev = ctx.evidence
|
||||
require_hv_off(ctx)
|
||||
with ctx.takeover():
|
||||
try:
|
||||
a = _fire_phase(ctx, "A")
|
||||
ev["A"] = a
|
||||
ctx.check(a["mid"] is not None, "A: no mid-window sample")
|
||||
ctx.check(not a["any_fire_driven"], "A: FIRE line driven with the latch locked")
|
||||
ctx.check(not a["any_laser_on"], "A: LASER_ON seen with the latch locked")
|
||||
ctx.check(a["tail"]["state"] == "idle", "A: ended in %s", a["tail"]["state"])
|
||||
ctx.check(not a["moved"], "A: position moved with motors locked")
|
||||
ctx.log("fire A PASS: latch locked, no FIRE drive, no LASER_ON")
|
||||
|
||||
b = _fire_phase(ctx, "B")
|
||||
ev["B"] = b
|
||||
ctx.check(b["mid"] is not None, "B: no mid-window sample")
|
||||
ctx.check(b["mid"]["laser_enable"] != "0",
|
||||
"B: FIRE line not driven mid-window with the latch unlocked (%s)", b["mid"])
|
||||
ctx.check(not b["any_laser_on"], "B: LASER_ON active with the chain unarmed - the AND-gate did not hold")
|
||||
ctx.check(b["tail"]["state"] == "idle", "B: ended in %s", b["tail"]["state"])
|
||||
ctx.check(b["tail"]["laser_enable"] == "0", "B: FIRE still driven after end-of-data")
|
||||
ctx.check(b["underruns_after"] == b["underruns_before"], "B: underrun counted on a normal completion")
|
||||
ctx.log("fire B PASS: FIRE driven mid-window, LASER_ON off, FIRE clear at end-of-data")
|
||||
|
||||
u = _fire_phase(ctx, "U")
|
||||
ev["U"] = u
|
||||
ctx.check(u["tail"]["state"] == "underrun", "U: expected the underrun state, got %s", u["tail"]["state"])
|
||||
ctx.check(u["tail"]["laser_enable"] == "0", "U: FIRE still driven after the underrun")
|
||||
ctx.check(not u["any_laser_on"], "U: LASER_ON active with the chain unarmed")
|
||||
ctx.check(u["tail"].get("acked_state") == "idle", "U: stop did not ack the underrun (state %s)",
|
||||
u["tail"].get("acked_state"))
|
||||
ctx.check(u["underruns_after"] == u["underruns_before"] + 1,
|
||||
"U: underrun counter %d -> %d", u["underruns_before"], u["underruns_after"])
|
||||
ctx.log("fire U PASS: true underrun, backstop dropped FIRE, stop acked")
|
||||
|
||||
_k3_phase(ctx)
|
||||
finally:
|
||||
wr("cnc/laser_latch", 1)
|
||||
try:
|
||||
wr("cnc/disable", 1) # the script's safe state
|
||||
except OSError:
|
||||
pass
|
||||
ctx.log("safe state restored: state=%s latch=LOCKED", rd("cnc/state"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- resume lead
|
||||
|
||||
@test("kernel.resume-lead", title="A resume's laser-off lead, and an end-of-data before the waypoint",
|
||||
subsystem="kernel", kind="auto", hardware="takeover", est_min=2,
|
||||
covers=_KERNEL_COVERS,
|
||||
requires=["kernel.k1-k2"], precheck=hv_off,
|
||||
steps=["Phase L unlocks the latch with a zero-duty stream, so the drill starts only while "
|
||||
"the HV supply does not report good (true at idle; if it is refused, open the lid - "
|
||||
"the safety chain holds HV off - and start it again)."],
|
||||
description="Two phases behind one takeover, zero duty, motors locked, a 1 kHz tick. E: a "
|
||||
"resume whose lead is longer than the data (the ring drains before the waypoint) "
|
||||
"ends the run at end-of-data within a few ticks, not one re-notify period (255 "
|
||||
"ticks) later: the script publishes end-of-data in its mailbox and the host "
|
||||
"decodes on that, not on the waypoint it was still waiting for. L: with the "
|
||||
"latch unlocked and the chain unarmed, a resume with a 1000-byte lead over a "
|
||||
"stream of FIRE bits keeps the FIRE line low through the lead and drives it from "
|
||||
"the waypoint byte on. The script masks the laser bits itself, so no host write "
|
||||
"reaches the GPIO data register while the script runs; LASER_ON stays off (the "
|
||||
"safety AND-gate holds).")
|
||||
def resume_lead(ctx):
|
||||
ev = ctx.evidence
|
||||
require_hv_off(ctx)
|
||||
tick = 1000
|
||||
with ctx.takeover():
|
||||
try:
|
||||
# ---- E: end-of-data before the waypoint
|
||||
stream = POWER0 + PAD * (tick // 2) # 0.5 s of pads
|
||||
expected = len(stream) / tick
|
||||
ctx.log("E: %d bytes = %.1f s of pads at %d Hz; resume with a %d-byte lead (never reached)",
|
||||
len(stream), expected, tick, 10 * tick)
|
||||
wr("cnc/motor_lock", 15)
|
||||
wr("cnc/laser_latch", 1)
|
||||
wr("cnc/ramp_rate", 125000)
|
||||
wr("cnc/step_freq", tick)
|
||||
snap(ctx, "E pre")
|
||||
with PulseDevice(ctx) as dev:
|
||||
dev.rewind()
|
||||
wr("cnc/enable", 1)
|
||||
ctx.sleep(0.5)
|
||||
dev.write(stream)
|
||||
underruns_before = int(rd("cnc/underruns"))
|
||||
t0 = time.time()
|
||||
wr("cnc/resume", 10 * tick)
|
||||
state = wait_state(ctx, "running", 2, poll=0.002)
|
||||
ctx.check(state == "running", "E: the resume did not start (state=%s)", state)
|
||||
state = wait_state(ctx, "idle", 5, poll=0.002)
|
||||
dt = time.time() - t0
|
||||
ev["E"] = {"run_to_idle_s": round(dt, 3), "data_s": expected, "state": state,
|
||||
"underruns_before": underruns_before,
|
||||
"underruns_after": int(rd("cnc/underruns")), "faults": rd("cnc/faults")}
|
||||
ctx.log("E: %s after %.3f s (the data is %.3f s; a lost end-of-data would add %.3f s)",
|
||||
state, dt, expected, 255.0 / tick)
|
||||
ctx.check(state == "idle", "E: state %s after the data ran out", state)
|
||||
ctx.check(dt < expected + 0.15,
|
||||
"E: the run ended %.3f s after its data: end-of-data decoded late", dt - expected)
|
||||
ctx.check(ev["E"]["underruns_after"] == underruns_before,
|
||||
"E: an underrun was counted on a normal completion")
|
||||
ctx.check(ev["E"]["faults"] == "0", "E: faults=%s", ev["E"]["faults"])
|
||||
ctx.log("E PASS: end-of-data before the waypoint ended the run on time")
|
||||
|
||||
# ---- L: the lead, latch unlocked, chain unarmed
|
||||
check_hv_off(ctx)
|
||||
lead = tick # 1.0 s of FIRE bits held off
|
||||
lead_s = lead / tick
|
||||
stream = POWER0 + FIRE * (2 * tick) + PAD * (tick // 5)
|
||||
ctx.log("L: %d bytes = %.1f s (%.1f s of FIRE bits then pads); lead %d bytes = %.1f s; "
|
||||
"latch UNLOCKED, chain unarmed", len(stream), len(stream) / tick,
|
||||
2 * tick / tick, lead, lead_s)
|
||||
wr("cnc/motor_lock", 15)
|
||||
wr("cnc/laser_latch", 1)
|
||||
wr("cnc/step_freq", tick)
|
||||
snap(ctx, "L pre")
|
||||
samples = []
|
||||
state = ""
|
||||
with PulseDevice(ctx) as dev:
|
||||
dev.rewind()
|
||||
wr("cnc/enable", 1)
|
||||
ctx.sleep(0.5)
|
||||
dev.write(stream)
|
||||
try:
|
||||
wr("cnc/laser_latch", 0)
|
||||
t0 = time.time()
|
||||
wr("cnc/resume", lead)
|
||||
n = 0
|
||||
while time.time() - t0 < 10:
|
||||
en, on = rd("cnc/laser_enable"), rd("cnc/laser_on")
|
||||
samples.append((round(time.time() - t0, 4), en, on))
|
||||
state = rd("cnc/state")
|
||||
if state != "running" and time.time() - t0 > 0.2:
|
||||
break
|
||||
n += 1
|
||||
if n % 200 == 0:
|
||||
ctx.checkpoint()
|
||||
finally:
|
||||
wr("cnc/laser_latch", 1)
|
||||
snap(ctx, "L post")
|
||||
driven = [s for s in samples if s[1] != "0"]
|
||||
first = driven[0][0] if driven else None
|
||||
last = driven[-1][0] if driven else None
|
||||
inside_lead = [s for s in driven if s[0] < lead_s - 0.02]
|
||||
ev["L"] = {"samples": len(samples), "driven_samples": len(driven),
|
||||
"first_driven_s": first, "last_driven_s": last, "end_state": state,
|
||||
"any_laser_on": any(s[2] != "0" for s in samples),
|
||||
"inside_lead": inside_lead[:5], "laser_on_sampled": rd("cnc/laser_on_sampled"),
|
||||
"faults": rd("cnc/faults")}
|
||||
ctx.log("L: %d samples, FIRE driven in %d, first at %s s, last at %s s, end state %s",
|
||||
len(samples), len(driven), first, last, state)
|
||||
ctx.check(state == "idle", "L: ended in %s", state)
|
||||
ctx.check(not inside_lead, "L: FIRE driven inside the lead: %s", inside_lead[:5])
|
||||
ctx.check(driven, "L: FIRE never driven after the lead (the inhibit never cleared)")
|
||||
ctx.check(first is not None and first < lead_s + 0.3,
|
||||
"L: FIRE first driven at %s s; the lead ends at %.1f s", first, lead_s)
|
||||
ctx.check(not ev["L"]["any_laser_on"],
|
||||
"L: LASER_ON active with the chain unarmed - the AND-gate did not hold")
|
||||
ctx.check(rd("cnc/laser_enable") == "0", "L: FIRE still driven after end-of-data")
|
||||
ctx.log("L PASS: FIRE low through the %.1f s lead, driven from the waypoint on, LASER_ON off",
|
||||
lead_s)
|
||||
finally:
|
||||
wr("cnc/laser_latch", 1)
|
||||
try:
|
||||
wr("cnc/ramp_rate", 125000)
|
||||
wr("cnc/disable", 1) # the script's safe state
|
||||
except OSError:
|
||||
pass
|
||||
ctx.log("safe state restored: state=%s latch=LOCKED", rd("cnc/state"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- PIC and the SoC's load
|
||||
|
||||
PIC_SETTLE_PARAM = "/sys/module/glowforge/parameters/pic_settle_us"
|
||||
|
||||
|
||||
def _param_read(path):
|
||||
try:
|
||||
with open(path) as f:
|
||||
return f.read().strip()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _param_write(path, value):
|
||||
with open(path, "w") as f:
|
||||
f.write("%s\n" % value)
|
||||
|
||||
|
||||
def _level_stats(xs):
|
||||
s = sorted(xs)
|
||||
n = len(s)
|
||||
return {"n": n, "mean": round(sum(s) / float(n), 2), "med": s[n // 2],
|
||||
"iqr": s[(3 * n) // 4] - s[n // 4], "min": s[0], "max": s[-1]}
|
||||
|
||||
|
||||
def _spin(sec):
|
||||
t0 = time.perf_counter()
|
||||
x = 1.0
|
||||
while time.perf_counter() - t0 < sec:
|
||||
x = x * 1.000001 + 0.5
|
||||
return x
|
||||
|
||||
|
||||
@test("kernel.pic-soc-load", title="A PIC reading does not depend on what the reader was doing",
|
||||
subsystem="kernel", kind="auto", est_min=1,
|
||||
covers=_KERNEL_COVERS + [("forgectrl", "src/diag.c")],
|
||||
description="The PIC converts its sensors in a free-running loop and a read returns the "
|
||||
"last conversion of that channel; the count depends on the SoC's load when the "
|
||||
"conversion was made (the PIC converts against its own supply, the dividers hang "
|
||||
"on the board's reference), about 6 counts between an idle and a busy CPU on the "
|
||||
"coolant thermistors. The module keeps the CPU busy for pic_settle_us before every "
|
||||
"transaction, longer than one PIC loop, so the value read was converted under the "
|
||||
"same load whoever reads. The drill reads a coolant thermistor 200 times each after "
|
||||
"3 ms of sleep and after 3 ms of spinning, with the settle off (the control, "
|
||||
"reported) and on (the claim): settled, the two agree.")
|
||||
def pic_soc_load(ctx):
|
||||
ev = ctx.evidence
|
||||
saved = _param_read(PIC_SETTLE_PARAM)
|
||||
ctx.check(saved is not None, "the module has no pic_settle_us parameter (%s)", PIC_SETTLE_PARAM)
|
||||
ev["pic_settle_us_before"] = saved
|
||||
fd = os.open(hw.sysfs_root() + "pic/water_temp_1", os.O_RDONLY)
|
||||
|
||||
def reads(regime, n=200):
|
||||
xs = []
|
||||
for i in range(n):
|
||||
if regime == "idle":
|
||||
time.sleep(0.003)
|
||||
else:
|
||||
_spin(0.003)
|
||||
xs.append(int(os.pread(fd, 32, 0).strip()))
|
||||
if i % 100 == 99:
|
||||
ctx.checkpoint()
|
||||
return _level_stats(xs)
|
||||
|
||||
try:
|
||||
_param_write(PIC_SETTLE_PARAM, 0)
|
||||
ctx.sleep(0.05)
|
||||
control = {"idle": reads("idle"), "busy": reads("busy")}
|
||||
_param_write(PIC_SETTLE_PARAM, 500)
|
||||
ctx.sleep(0.05)
|
||||
settled = {"idle": reads("idle"), "busy": reads("busy")}
|
||||
finally:
|
||||
os.close(fd)
|
||||
_param_write(PIC_SETTLE_PARAM, saved if saved not in (None, "0") else 500)
|
||||
ev["control"] = control
|
||||
ev["settled"] = settled
|
||||
ev["pic_settle_us_after"] = _param_read(PIC_SETTLE_PARAM)
|
||||
for name, r in (("control (settle off)", control), ("settled (500 us)", settled)):
|
||||
ctx.log("%s: after sleep median %d (iqr %d, %d..%d); after spin median %d (iqr %d, %d..%d)",
|
||||
name, r["idle"]["med"], r["idle"]["iqr"], r["idle"]["min"], r["idle"]["max"],
|
||||
r["busy"]["med"], r["busy"]["iqr"], r["busy"]["min"], r["busy"]["max"])
|
||||
ev["control_split"] = control["busy"]["med"] - control["idle"]["med"]
|
||||
ev["settled_split"] = settled["busy"]["med"] - settled["idle"]["med"]
|
||||
ctx.log("split busy minus idle: control %+d, settled %+d", ev["control_split"], ev["settled_split"])
|
||||
ctx.check(abs(ev["settled_split"]) <= 2,
|
||||
"settled reads still split by %+d counts between an idle and a busy reader", ev["settled_split"])
|
||||
ctx.check(settled["idle"]["iqr"] <= 4 and settled["busy"]["iqr"] <= 4,
|
||||
"settled reads spread %d / %d counts (interquartile)", settled["idle"]["iqr"], settled["busy"]["iqr"])
|
||||
# The kernel's spin is its own load level, a couple of counts under a
|
||||
# Python spin, so a settled reader lands above the idle regime without
|
||||
# reaching the busy one. The failure this guards is a settle that
|
||||
# overshoots and lets the conversion fall back to idle, which reads as
|
||||
# no move at all; the split collapsing above is the primary proof.
|
||||
#
|
||||
# The bound is a third of the control split, not half. Measured over
|
||||
# eleven runs on this bench the move is 3 counts (once 2, once 4)
|
||||
# against a control split of 6 (once 7), so a half-split bound sits
|
||||
# exactly on the median and decides on one count of noise: two of those
|
||||
# eleven runs failed with the machine reading identically to the nine
|
||||
# that passed. A third keeps the guard against no move at all and
|
||||
# leaves a count of margin either way.
|
||||
moved = settled["idle"]["med"] - control["idle"]["med"]
|
||||
ev["settled_idle_moved"] = moved
|
||||
ctx.check(ev["control_split"] <= 2 or moved * 3 >= ev["control_split"],
|
||||
"a settled idle reader moved %+d counts off the idle regime, the control split is %+d: "
|
||||
"the settle did not land the conversion under load", moved, ev["control_split"])
|
||||
ctx.check(ev["pic_settle_us_after"] == "500", "pic_settle_us left at %s", ev["pic_settle_us_after"])
|
||||
ctx.log("PASS: settled, an idle reader and a busy reader read the same (split %+d); control split %+d",
|
||||
ev["settled_split"], ev["control_split"])
|
||||
@@ -1,946 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""laser.* - LIVE laser tests, ported from `scripts/bench/live_fire_drills.py`.
|
||||
|
||||
The live tests here emit. The page starts a firing one only with the
|
||||
operator's eye-protection / fire-watch / exhaust acknowledgment; the
|
||||
test then prompts for the scrap and the button, streams a small job
|
||||
through the controller, and the machine fires only after the operator
|
||||
presses the physical arm button - nothing here defeats that gate, and
|
||||
forgetest never touches the laser latch. One emission per test; on abort
|
||||
or error the job is soft-reset (`^X`: controlled stop, latch relocked).
|
||||
The witnesses are forgectrl's `/status` (`laser.emission_samples` = the
|
||||
kernel's LASER_ON sample count, `hv_current_raw`, `lid_ir`),
|
||||
`/cool/status` (`armed`), the head's beam detector
|
||||
(`head/beam_detect_analog`, the tube's own emission seen from the head:
|
||||
a few hundred counts over its idle level while the beam is on, and
|
||||
`beam_detect_digital` asserted), and the button LEDs (lit while the arm
|
||||
waits or the window is open, dark after the disarm) - all sampled at
|
||||
~8 Hz through the arm -> fire -> disarm lifecycle. The one thing the
|
||||
operator still judges by eye is the mark the emission witness leaves,
|
||||
once per campaign: the calibration of the sensor witnesses.
|
||||
"""
|
||||
import time
|
||||
|
||||
from ..catalog import test
|
||||
from .. import hw
|
||||
from ..runner import Failed
|
||||
from .motion import kernel_start, check_kernel_returned, Watch
|
||||
|
||||
_LASER_COVERS = [("grblhal-glowforge", "src/**"), ("kernel-module-glowforge", "**"),
|
||||
("forgectrl", "src/super.c"), ("forgectrl", "src/cool.c"),
|
||||
("forgectrl", "src/status.c"), ("forgectrl", "src/main.c")]
|
||||
|
||||
|
||||
ARM_CUE = ("LIVE FIRE. Eye protection on. Exhaust on. Extinguisher in reach. "
|
||||
"Put scrap under the head with %s clear, and close the lid.")
|
||||
|
||||
# The beam detector's emission signature: this much over its pre-fire
|
||||
# level while the beam is on (bench: ~1834 idle, 2600-2890 at S300/S400).
|
||||
BEAM_DELTA_MIN = 300
|
||||
|
||||
|
||||
def sample(ctx):
|
||||
"""One combined /status + /cool/status sample, or None on error."""
|
||||
fc = ctx.forgectrl
|
||||
try:
|
||||
st1, st = fc.get("/status")
|
||||
st2, cs = fc.get("/cool/status")
|
||||
except hw.HwError:
|
||||
return None
|
||||
if st1 != 200 or st2 != 200 or not isinstance(st, dict) or not isinstance(cs, dict):
|
||||
return None
|
||||
return {
|
||||
"t": time.time(),
|
||||
"kstate": st.get("state"),
|
||||
"emission": (st.get("laser") or {}).get("emission_samples"),
|
||||
"pgood": (st.get("laser") or {}).get("pgood_samples"),
|
||||
"faults": st.get("faults"),
|
||||
"hv": st.get("hv_current_raw"),
|
||||
"ir": st.get("lid_ir"),
|
||||
"homed": st.get("homed"),
|
||||
"armed": cs.get("armed"),
|
||||
"phase": cs.get("phase"),
|
||||
"fire_watch": cs.get("fire_watch"),
|
||||
"verdict": cs.get("verdict"),
|
||||
"beam": hw.sysfs_int("head/beam_detect_analog"),
|
||||
"beam_d": hw.sysfs_int("head/beam_detect_digital"),
|
||||
"exhaust": (st.get("fans") or {}).get("exhaust"),
|
||||
"button_lit": hw.button_lit(),
|
||||
"hv_enable": (st.get("switches") or {}).get("hv_enable"),
|
||||
"button_latch": hw.sysfs_int("cnc/button_latch"),
|
||||
# the whole readback word (bit 0 LASER_ON, 1 LASER_ENABLE, 2 button
|
||||
# latch, 3 laser latch, 4 interlock latch reset, 5 charge pump alive)
|
||||
# and the debounced switches, so a latch that sets mid-job says
|
||||
# which of its two inputs did it
|
||||
"il": hw.sysfs_int("cnc/interlock_circuit"),
|
||||
"lid": (st.get("switches") or {}).get("lid"),
|
||||
"button": (st.get("switches") or {}).get("button"),
|
||||
"locked": st.get("laser_locked"),
|
||||
"gstate": None,
|
||||
"msgs": "",
|
||||
}
|
||||
|
||||
|
||||
TRAIL_FIELDS = ("t", "gstate", "kstate", "emission", "armed", "phase", "il", "button_latch",
|
||||
"locked", "lid", "button", "hv_enable", "beam", "hv", "msgs")
|
||||
|
||||
# The cooling phases that run the fans at their idle duty: the engine has
|
||||
# no run session, so no cut airflow and no flow interrogation. Emission
|
||||
# in one of these is the beam on the work with idle airflow.
|
||||
IDLE_AIRFLOW_PHASES = ("idle", "warm-up")
|
||||
|
||||
|
||||
def trail(samples):
|
||||
"""The sample trail as rows for the evidence: relative seconds and the
|
||||
fields TRAIL_FIELDS names, so a run's timeline can be read back
|
||||
without a rerun."""
|
||||
if not samples:
|
||||
return []
|
||||
t0 = samples[0]["t"]
|
||||
rows = []
|
||||
for s in samples:
|
||||
row = [round(s["t"] - t0, 2)]
|
||||
for f in TRAIL_FIELDS[1:]:
|
||||
v = s.get(f)
|
||||
row.append(v if v != "" else None)
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def beam_witness(ctx, ev, samples, base, tag=""):
|
||||
"""The head's beam detector over a sample trail: its peak against the
|
||||
pre-fire level, and whether the digital flag asserted. Recorded, and
|
||||
judged where the trail carried emission."""
|
||||
beams = [s["beam"] for s in samples if s.get("beam") is not None]
|
||||
base_beam = base.get("beam") if base else None
|
||||
peak = max(beams) if beams else None
|
||||
digital = any(s.get("beam_d") for s in samples)
|
||||
key = ("beam" + ("_" + tag if tag else ""))
|
||||
ev[key] = {"idle": base_beam, "peak": peak, "delta": (peak - base_beam) if (peak is not None and
|
||||
base_beam is not None) else None,
|
||||
"digital_seen": digital, "samples": len(beams)}
|
||||
ctx.log("beam detector%s: idle %s, peak %s, digital asserted %s", (" [%s]" % tag) if tag else "",
|
||||
base_beam, peak, digital)
|
||||
return ev[key]
|
||||
|
||||
|
||||
def judge_beam(ctx, b, what="the burn"):
|
||||
ctx.check(b["samples"] > 0, "the beam detector was not readable during %s", what)
|
||||
ctx.check(b["delta"] is not None and b["delta"] >= BEAM_DELTA_MIN,
|
||||
"the head's beam detector did not see %s (idle %s, peak %s; the tube did not lase, "
|
||||
"or the detector is not reading)", what, b["idle"], b["peak"])
|
||||
|
||||
|
||||
def check_button_dark(ctx, ev, key="button_dark", settle_s=5.0):
|
||||
"""The button commanded dark (the LEDs' target level) within a few
|
||||
seconds: the controller writes it a moment after the cancel or the
|
||||
disarm it reports."""
|
||||
dt = ctx.wait_for(lambda: hw.button_lit() is False, settle_s)
|
||||
lit = hw.button_lit()
|
||||
ev[key] = lit
|
||||
ctx.check(lit is not None, "the button LEDs are not readable")
|
||||
ctx.check(lit is False, "the button is still lit %.0f s after it should have gone dark", settle_s)
|
||||
if dt:
|
||||
ctx.log("button dark after %.1f s", dt)
|
||||
|
||||
|
||||
def prepare(ctx, g):
|
||||
"""Guarantee a clean Idle start: clear a latched Door hold or an Alarm."""
|
||||
st = g.status_report()
|
||||
if "Door" in st["state"] or "Hold" in st["state"]:
|
||||
g.realtime(0x18)
|
||||
ctx.sleep(2)
|
||||
g.drain()
|
||||
st = g.status_report()
|
||||
if "Alarm" in st["state"]:
|
||||
ctx.log("unlock: %s", g.command("$X"))
|
||||
st = g.status_report()
|
||||
ctx.log("connect: %s", st["state"])
|
||||
ctx.check(st["state"].startswith("Idle"), "controller is %s, expected Idle", st["state"])
|
||||
return st
|
||||
|
||||
|
||||
def stream(g, lines):
|
||||
for ln in lines:
|
||||
g.send_raw((ln + "\n").encode())
|
||||
|
||||
|
||||
MARK_JOB = ["G91", "G21", "M4", "S400",
|
||||
"G1 X40 F200", "G1 Y40 F200", "G1 X-40 F200", "G1 Y-40 F200",
|
||||
"M5", "G90", "M2"]
|
||||
|
||||
# The same square at constant power. M4 scales power with speed, which hides
|
||||
# what a restart does to the cut; M3 puts it on the scrap where the operator
|
||||
# can see it - the deeper spot where a resumed cut accelerates from zero.
|
||||
MARK_JOB_M3 = ["M3" if ln == "M4" else ln for ln in MARK_JOB]
|
||||
|
||||
|
||||
def arm_and_fire(ctx, g, room="40 mm +X and +Y", job=None, timeout=240):
|
||||
"""The arm cue, the job, and the wait for the emission witness - the
|
||||
prologue every live test shares. Returns the first sample with emission,
|
||||
or soft-resets and fails: no emission means the arm was refused or the
|
||||
button was never pressed."""
|
||||
ctx.ready(ARM_CUE % room)
|
||||
stream(g, job or MARK_JOB)
|
||||
ctx.arm_press()
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < timeout:
|
||||
ctx.checkpoint()
|
||||
smp = sample(ctx)
|
||||
if smp and smp["emission"] and smp["emission"] > 0:
|
||||
ctx.clear_notice()
|
||||
return smp
|
||||
time.sleep(0.15)
|
||||
ctx.clear_notice()
|
||||
g.realtime(0x18)
|
||||
raise Failed("no emission seen within %d s (arm refused, or no button press)" % timeout)
|
||||
|
||||
|
||||
def wait_grbl_port(ctx, timeout=30):
|
||||
"""The controller's Grbl listener is accepting again. /mode reporting the
|
||||
process running is not the same thing: the supervisor has spawned it, but
|
||||
the socket may not be bound yet, and a bare connect would fail the test on
|
||||
a race rather than on the behavior it is about."""
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
ctx.checkpoint()
|
||||
try:
|
||||
with ctx.grbl():
|
||||
return True
|
||||
except OSError:
|
||||
time.sleep(1)
|
||||
return False
|
||||
|
||||
|
||||
def kill_trail(ctx, t0, seconds=5.0):
|
||||
"""Sample emission / kernel state / armed for `seconds` after a kill."""
|
||||
trail = []
|
||||
for _ in range(int(seconds / 0.12)):
|
||||
s = sample(ctx)
|
||||
if s:
|
||||
trail.append((round(time.time() - t0, 2), s["emission"], s["kstate"], s["armed"]))
|
||||
time.sleep(0.12)
|
||||
return trail
|
||||
|
||||
|
||||
def kill_fast_trail(t0, seconds=1.5):
|
||||
"""The latch and the kernel state read from sysfs every few
|
||||
milliseconds after a kill: the supervisor's own reaction, which the
|
||||
HTTP samples of kill_trail cannot resolve. A controller death is a
|
||||
signal to the supervisor, not a poll, so the relock lands within
|
||||
milliseconds; the kernel leaves running once its stop ramp is done."""
|
||||
locked_at = stopped_at = None
|
||||
end = t0 + seconds
|
||||
while time.time() < end and (locked_at is None or stopped_at is None):
|
||||
now = time.time() - t0
|
||||
ilk = hw.sysfs_int("cnc/interlock_circuit")
|
||||
if locked_at is None and ilk is not None and ilk & IL_LASER_LATCH:
|
||||
locked_at = round(now, 3)
|
||||
st = hw.sysfs_read("cnc/state")
|
||||
if stopped_at is None and st is not None and st != "running":
|
||||
stopped_at = round(now, 3)
|
||||
time.sleep(0.005)
|
||||
return {"latch_locked_at_s": locked_at, "kernel_stopped_at_s": stopped_at}
|
||||
|
||||
|
||||
def judge_kill(ctx, trail, what):
|
||||
"""(first zero, tail stayed zero, kernel stopped running) from a trail."""
|
||||
for t in trail:
|
||||
ctx.log(" post-%s %s", what, t)
|
||||
zero_at = next((t for t, e, _, _ in trail if e == 0), None)
|
||||
tail_zero = all(e == 0 for _, e, _, _ in trail[-16:])
|
||||
not_running = all(k != "running" for _, _, k, _ in trail[-16:])
|
||||
ctx.log("emission first 0 at +%s s; last 2 s all zero: %s; kernel not running: %s",
|
||||
zero_at, tail_zero, not_running)
|
||||
return zero_at, tail_zero, not_running
|
||||
|
||||
|
||||
IL_BUTTON_LATCH = 4 # cnc/interlock_circuit bit 2
|
||||
IL_LASER_LATCH = 8 # bit 3: the SoC lock line, 1 = locked
|
||||
|
||||
|
||||
def dwell_gap(samples):
|
||||
"""The dwell-gap verdict over a sample trail. The button latch has
|
||||
two set inputs, the lid and the SoC lock, so while the lock line
|
||||
reads released the latch must read clear: from the first sample
|
||||
with emission (the press has cleared it by then) through every
|
||||
sample whose readback word shows the laser latch unlocked, which
|
||||
spans the kernel-run gap of the dwell and ends at the relock. Both
|
||||
bits come from the same word, so no lagging flag can misplace the
|
||||
window: the engine's armed flag follows the controller's next report,
|
||||
and the emission counter latches once per second and stays nonzero
|
||||
about two seconds past the relock (three runs on the bench failed on
|
||||
windows drawn from those). HV_ENABLE's dip in the gap and its return
|
||||
with emission after it are judged from the first emission on."""
|
||||
first = next((i for i, s in enumerate(samples) if s["emission"]), None)
|
||||
span = ([s for s in samples[first:] if s.get("il") is not None and not (s["il"] & IL_LASER_LATCH)]
|
||||
if first is not None else [])
|
||||
latch = [1 if (s["il"] & IL_BUTTON_LATCH) else 0 for s in span]
|
||||
set_at = [i for i, v in enumerate(latch) if v]
|
||||
hv_en = [s.get("hv_enable") for s in samples[first:]] if first is not None else []
|
||||
dip = next((i for i, v in enumerate(hv_en) if v is False), None)
|
||||
back_lit = dip is not None and any(
|
||||
s.get("hv_enable") and s["emission"] for s in samples[first + dip:])
|
||||
return {"button_latch_unlocked_max": max(latch) if latch else None,
|
||||
"button_latch_unlocked_samples": len(latch),
|
||||
"button_latch_set_at": set_at[:8],
|
||||
"hv_enable_dipped": dip is not None, "hv_enable_back_lit": back_lit}
|
||||
|
||||
|
||||
def read_gaps(samples, key, t_end=None):
|
||||
"""Over a sample trail: how many samples carry a reading for `key`,
|
||||
how many do not, and the longest stretch of seconds between two
|
||||
readings, the trail's ends counted as edges (an unread lead or tail
|
||||
is a gap too; a trail with no reading is one gap, its whole span).
|
||||
The trail ends when its sampling stopped (`t_end`), not at the last
|
||||
sample served: a tail the daemon did not serve at all is a gap too.
|
||||
A reading the daemon could not serve is a gap, never a dark sample:
|
||||
/status leaves the laser block out when the kernel's count did not
|
||||
read, and the count latches once per ~1 s window, so a window with
|
||||
no read in it is a window in which a fire is not witnessed."""
|
||||
if not samples:
|
||||
return {"read": 0, "unread": 0, "gap_max_s": None}
|
||||
ts = [s["t"] for s in samples if s.get(key) is not None]
|
||||
last = samples[-1]["t"] if t_end is None else max(t_end, samples[-1]["t"])
|
||||
edges = [samples[0]["t"]] + ts + [last]
|
||||
gap = max(b - a for a, b in zip(edges, edges[1:]))
|
||||
return {"read": len(ts), "unread": len(samples) - len(ts), "gap_max_s": round(gap, 3)}
|
||||
|
||||
|
||||
def rapid_place(stretch, x0):
|
||||
"""Where the head's reports place the rapid after the M5, judged over
|
||||
the armed part of the dark stretch alone: its samples before the first
|
||||
disarmed one. Returns (i_out, i_back, i_closed): the first sample with
|
||||
the head at least 19.5 mm out, the first after it with the head back
|
||||
at x0 (within 0.05 mm), and the first disarmed sample; each None when
|
||||
there is none. A window that closes before the head is back leaves
|
||||
i_back None: only a sample that read the window open places the head
|
||||
back, and sample_to_disarm reads its X before its armed flag, so the
|
||||
head was back with the window open. The first disarmed sample does
|
||||
not tell whether the head came back before the close or after it."""
|
||||
i_closed = next((i for i, s in enumerate(stretch) if not s["armed"]), None)
|
||||
part = stretch if i_closed is None else stretch[:i_closed]
|
||||
i_out = next((i for i, s in enumerate(part) if s.get("x") is not None and s["x"] - x0 >= 19.5), None)
|
||||
i_back = (next((i for i in range(i_out + 1, len(part))
|
||||
if part[i].get("x") is not None and abs(part[i]["x"] - x0) < 0.05), None)
|
||||
if i_out is not None else None)
|
||||
return i_out, i_back, i_closed
|
||||
|
||||
|
||||
def sample_to_disarm(ctx, g, timeout=20.0, sample_hz=8):
|
||||
"""Sample until the armed window closes, or the timeout, with the
|
||||
controller's state and machine X beside each sample ("x"), read just
|
||||
before the sample: a sample that reads the window open read its X
|
||||
with the window open (rapid_place). A G4 dwell reports Idle, so Idle
|
||||
cannot end this; the window closing at the program end does, after
|
||||
two more kernel windows and the read gap allowed: the LASER_ON count
|
||||
for the window in flight at the close latches up to one window later
|
||||
and is held through the next, so the sampling runs on until that
|
||||
count has been read. Returns the samples served and the time the
|
||||
sampling stopped, the trail's end for the read rule."""
|
||||
samples = []
|
||||
period = 1.0 / sample_hz
|
||||
t0 = time.time()
|
||||
end = t0 + timeout
|
||||
t_closed = None
|
||||
while time.time() < end:
|
||||
ctx.checkpoint()
|
||||
rep = g.status_report()
|
||||
smp = sample(ctx)
|
||||
if smp:
|
||||
smp["gstate"] = rep.get("state")
|
||||
smp["x"] = (rep.get("MPos") or (None,))[0]
|
||||
samples.append(smp)
|
||||
if t_closed is None and not smp["armed"]:
|
||||
t_closed = time.time()
|
||||
end = t_closed + 2 * EMISSION_WINDOW_S + EMISSION_READ_GAP_S
|
||||
time.sleep(period)
|
||||
return samples, time.time()
|
||||
|
||||
|
||||
def run_and_sample(ctx, g, job, sample_hz=8, overall_timeout=200):
|
||||
"""Stream the job; sample forgectrl through arm -> fire -> disarm.
|
||||
Completes on: emission seen then Idle > 3 s; or armed then disarmed
|
||||
with no fire, Idle > 3 s, > 15 s in; or the overall timeout."""
|
||||
samples = []
|
||||
period = 1.0 / sample_hz
|
||||
s0 = sample(ctx)
|
||||
if s0:
|
||||
samples.append(s0)
|
||||
stream(g, job)
|
||||
t_start = time.time()
|
||||
next_t = t_start
|
||||
seen_emission = seen_armed = disarmed_now = False
|
||||
idle_since = None
|
||||
while time.time() - t_start < overall_timeout:
|
||||
ctx.checkpoint()
|
||||
now = time.time()
|
||||
if now >= next_t:
|
||||
smp = sample(ctx)
|
||||
if smp:
|
||||
samples.append(smp)
|
||||
if smp["emission"] and smp["emission"] > 0:
|
||||
seen_emission = True
|
||||
if smp["armed"]:
|
||||
seen_armed = True
|
||||
disarmed_now = seen_armed and not smp["armed"]
|
||||
next_t = now + period
|
||||
st = g.status_report()["state"]
|
||||
if samples:
|
||||
# the controller's state and whatever it said since the last
|
||||
# sample, next to the sample they belong to
|
||||
samples[-1]["gstate"] = st
|
||||
said = " ".join(l.strip() for l in g.drain().splitlines()
|
||||
if l.strip() and l.strip() != "ok")
|
||||
if said:
|
||||
samples[-1]["msgs"] = (samples[-1]["msgs"] + " " + said).strip()[:400]
|
||||
if st.startswith("Idle"):
|
||||
if idle_since is None:
|
||||
idle_since = now
|
||||
idle_for = now - idle_since
|
||||
if seen_emission and idle_for > 3.0:
|
||||
break
|
||||
if disarmed_now and (now - t_start) > 15 and idle_for > 3.0:
|
||||
break
|
||||
else:
|
||||
idle_since = None
|
||||
time.sleep(0.05)
|
||||
return samples
|
||||
|
||||
|
||||
class LiveJob:
|
||||
"""Leaves the laser commanded off on every exit; a soft reset on
|
||||
abort/failure stops motion, relocks, and closes the armed window."""
|
||||
|
||||
def __init__(self, ctx, g):
|
||||
self.ctx, self.g = ctx, g
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
try:
|
||||
if exc_type is not None:
|
||||
self.ctx.log("stopping the job: soft reset (%s)", exc_type.__name__)
|
||||
self.g.realtime(0x18)
|
||||
time.sleep(1)
|
||||
self.g.command("M5", timeout=1)
|
||||
except Exception: # noqa: BLE001 - best effort on the way out
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def wait_disarm(ctx, timeout):
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < timeout:
|
||||
ctx.checkpoint()
|
||||
s = sample(ctx)
|
||||
if s and not s["armed"]:
|
||||
return time.time() - t0
|
||||
time.sleep(0.5)
|
||||
return None
|
||||
|
||||
|
||||
@test("laser.emission-witness", title="Live emission witness (S400 vector mark) and job-based disarm",
|
||||
subsystem="laser", kind="live", mode="grbl", always=True, est_min=5,
|
||||
covers=_LASER_COVERS,
|
||||
requires=["kernel.latch-locked-idle", "kernel.k1-k2", "motion.jog-roundtrip"],
|
||||
actions=["button"],
|
||||
steps=["Scrap under the head with 20 mm of free +X and +Y travel; lid closed; exhaust on.",
|
||||
"Press the physical button when it lights white (the arm).",
|
||||
"At the end, confirm the square the laser marked, all four sides: the one judgment "
|
||||
"by eye in the campaign, the calibration of the sensor witnesses."],
|
||||
description="A 20 mm square outline at S400/F600 in dynamic laser mode with a 2 s dwell "
|
||||
"(G4) between its second and third sides: emission_samples (the kernel's "
|
||||
"LASER_ON sample count) goes nonzero during the fire window and returns to 0 "
|
||||
"at Idle, HV current rises during the burn, the head's beam detector sees the "
|
||||
"beam, and the armed window is observed. Across the dwell the kernel run ends "
|
||||
"and restarts: HV_ENABLE drops with the charge-pump watchdog and is back for "
|
||||
"the third side, while the hardware button latch stays clear (cnc/button_latch "
|
||||
"0 in every armed sample), so the second half of the square marks without a "
|
||||
"second press. The square ends at M5 with the window still open; once the "
|
||||
"LASER_ON count reads 0 (the dark point), a rapid runs 20 mm out over the "
|
||||
"first side and back, then the program ends. From the dark point until the "
|
||||
"window closes, and on until the count of the window in flight at the close is "
|
||||
"read, every sample reads no emission and the HV current at its idle reading, "
|
||||
"the kernel's LASER_ON count read in every ~1 s window of that stretch (a "
|
||||
"reading the daemon could not serve is a gap, never a dark sample, and so is a "
|
||||
"tail it served none of), and the head's reports place the rapid inside the "
|
||||
"window, between the dark point and the close: LASER_ON stays off with FIRE "
|
||||
"clear while the chain can fire. The M2 program end disarms promptly after the "
|
||||
"head is back (job-based, not the 60 s idle grace) and the button goes "
|
||||
"dark. The operator confirms all four sides.")
|
||||
def emission_witness(ctx):
|
||||
ev = ctx.evidence
|
||||
# The rapid after the M5 (G91 is modal from the square): out over the
|
||||
# first side and back, then the program end. The head stands back for
|
||||
# 1 s before the M2, so that a sample of a slow sampling loop still
|
||||
# reads it back with the window open (rapid_place).
|
||||
rapid_tail = ["G0 X20", "G4 P2.5", "G0 X-20", "G4 P1", "G90", "M2"]
|
||||
|
||||
with ctx.grbl() as g, LiveJob(ctx, g):
|
||||
prepare(ctx, g)
|
||||
base = sample(ctx)
|
||||
ctx.check(base, "forgectrl /status or /cool/status unavailable")
|
||||
ev["pre_fire"] = base
|
||||
ctx.log("pre-fire: emission=%s hv=%s armed=%s verdict=%s", base["emission"], base["hv"],
|
||||
base["armed"], base["verdict"])
|
||||
ctx.check(not base["emission"], "emission_samples nonzero before the job (%s)", base["emission"])
|
||||
ctx.ready(ARM_CUE % "20 mm +X and +Y")
|
||||
# The square ends at M5 with no program end: the window stays open
|
||||
# for the rapid that follows.
|
||||
job = ["G91", "G21", "M4", "S400",
|
||||
"G1 X20 F600", "G1 Y20 F600", "G4 P2", "G1 X-20 F600", "G1 Y-20 F600",
|
||||
"M5"]
|
||||
ctx.arm_press()
|
||||
try:
|
||||
cut = run_and_sample(ctx, g, job)
|
||||
# The dark point: the M5 has run, the LASER_ON count reads 0, and
|
||||
# the window is still open. The rapid is sent only after it, so
|
||||
# the dark stretch covers the rapid by construction, and the
|
||||
# mid-square dwell's own zero cannot be taken for it.
|
||||
ctx.wait_for(lambda: (sample(ctx) or {}).get("emission") == 0, 10, poll=0.125)
|
||||
dark = sample(ctx)
|
||||
ctx.check(dark and dark["emission"] == 0,
|
||||
"the LASER_ON count did not return to 0 after the M5 (%s)", dark and dark["emission"])
|
||||
ctx.check(dark["armed"], "the armed window closed after the M5, before the rapid "
|
||||
"(phase %s): the rapid would not run under it", dark["phase"])
|
||||
# The engine's armed flag can still read true after the latch has
|
||||
# locked, and a dark reading with the latch locked is the unarmed
|
||||
# case this sample is here to exclude: the SoC lock line must read
|
||||
# released.
|
||||
ctx.check(dark["locked"] is False, "the laser latch is locked at the dark point "
|
||||
"(laser_locked %s): the rapid would run unarmed", dark["locked"])
|
||||
rep = g.status_report()
|
||||
dark["gstate"] = rep.get("state")
|
||||
dark["x"] = (rep.get("MPos") or (None,))[0]
|
||||
ctx.check(dark["x"] is not None, "the controller reports no machine position: %s", rep)
|
||||
stream(g, rapid_tail)
|
||||
tail, t_end = sample_to_disarm(ctx, g)
|
||||
finally:
|
||||
ctx.clear_notice()
|
||||
samples = cut + [dark] + tail
|
||||
ev["trail_fields"] = list(TRAIL_FIELDS)
|
||||
ev["trail"] = trail(samples)
|
||||
beam = beam_witness(ctx, ev, cut, base)
|
||||
emis = [s["emission"] for s in cut if s["emission"] is not None]
|
||||
peak = max(emis) if emis else 0
|
||||
end = next((s["emission"] for s in reversed(samples) if s["emission"] is not None), None)
|
||||
hv = [s["hv"] for s in cut if s["hv"] is not None]
|
||||
ir_peak = [0, 0, 0, 0]
|
||||
for s in samples:
|
||||
if s["ir"] and len(s["ir"]) == 4:
|
||||
for i in range(4):
|
||||
ir_peak[i] = max(ir_peak[i], s["ir"][i])
|
||||
armed_seen = any(s["armed"] for s in samples)
|
||||
# The beam may only come on inside a run session. Until the engine
|
||||
# has taken the armed window it is still holding the fans at their
|
||||
# idle duty, and the verdict standing on file is the one it
|
||||
# computed for the idle session before the arm.
|
||||
idle_fire = [s for s in samples
|
||||
if s["emission"] and s["phase"] in IDLE_AIRFLOW_PHASES]
|
||||
ev.update({"samples": len(samples), "emission_peak": peak, "emission_end": end,
|
||||
"hv_min": min(hv) if hv else None, "hv_max": max(hv) if hv else None,
|
||||
"lid_ir_peak": ir_peak, "armed_seen": armed_seen,
|
||||
"idle_airflow_fire": [(round(s["t"] - samples[0]["t"], 2),
|
||||
s["phase"], s["emission"]) for s in idle_fire],
|
||||
"pgood_peak": max((s["pgood"] for s in samples if s["pgood"] is not None), default=None)})
|
||||
ctx.log("emission_samples peak=%s end=%s; hv %s..%s; lid_ir peak %s; armed seen %s",
|
||||
peak, end, ev["hv_min"], ev["hv_max"], ir_peak, armed_seen)
|
||||
# The dwell: a kernel-run gap inside the unlocked window. The button
|
||||
# latch has no input from the charge-pump watchdog (its SET inputs
|
||||
# are the lid and the SoC lock), so it must read clear from the
|
||||
# first emission until the relock, across the gap, while HV_ENABLE
|
||||
# drops with the watchdog and returns for the third side.
|
||||
gap = dwell_gap(samples)
|
||||
ev.update(gap)
|
||||
ctx.log("dwell gap: button latch through the unlocked window max=%s over %d samples (set at %s); "
|
||||
"HV_ENABLE dipped=%s, back with emission after it=%s", gap["button_latch_unlocked_max"],
|
||||
gap["button_latch_unlocked_samples"], gap["button_latch_set_at"], gap["hv_enable_dipped"],
|
||||
gap["hv_enable_back_lit"])
|
||||
# The first fire waits for the airflow: the exhaust reads at or
|
||||
# above its floor on the first sample that shows emission.
|
||||
floor = (ctx.forgectrl.settings() or {}).get("cool_tach_exhaust_min_rpm") or ""
|
||||
try:
|
||||
floor = float(floor) if floor else 6400.0
|
||||
except ValueError:
|
||||
floor = 6400.0
|
||||
first_lit = next((s for s in samples if s["emission"]), None)
|
||||
ev["exhaust_at_first_fire"] = first_lit["exhaust"] if first_lit else None
|
||||
ev["exhaust_floor"] = floor
|
||||
ctx.log("exhaust at the first emission sample: %s rpm (floor %.0f)",
|
||||
ev["exhaust_at_first_fire"], floor)
|
||||
# The rapid after the M5, with the window open and FIRE clear: every
|
||||
# sample from the dark point until the sampling stops (two windows and
|
||||
# the read gap allowed past the close) reads no emission and the HV
|
||||
# current at its idle reading, and the kernel's LASER_ON count was read
|
||||
# in every ~1 s window of that stretch, the one that latches after the
|
||||
# close included: the end of the sampling is the trail's last edge. A
|
||||
# reading the daemon could not serve is a gap, never a dark sample.
|
||||
# The head's reports place the rapid inside the window, from the dark
|
||||
# point to the close: out to +20 mm, then back at the start.
|
||||
stretch = [dark] + tail
|
||||
x0 = dark["x"]
|
||||
i_out, i_back, i_closed = rapid_place(stretch, x0)
|
||||
closed = stretch[i_closed] if i_closed is not None else None
|
||||
armed_part = stretch if i_closed is None else stretch[:i_closed + 1]
|
||||
relit = [s for s in stretch if s["emission"]]
|
||||
hv_dark = max((s["hv"] for s in stretch if s["hv"] is not None), default=0)
|
||||
reads = read_gaps(stretch, "emission", t_end)
|
||||
hv_reads = read_gaps(stretch, "hv", t_end)
|
||||
dt = (closed["t"] - stretch[i_back]["t"]) if (closed and i_back is not None) else None
|
||||
ev["rapid"] = {"samples": len(armed_part), "span_s": round((closed or stretch[-1])["t"] - dark["t"], 2),
|
||||
"x_start": x0, "x_max": max((s["x"] for s in armed_part if s.get("x") is not None), default=None),
|
||||
"out_at_s": round(stretch[i_out]["t"] - dark["t"], 2) if i_out is not None else None,
|
||||
"back_at_s": round(stretch[i_back]["t"] - dark["t"], 2) if i_back is not None else None,
|
||||
"relit": [(round(s["t"] - dark["t"], 2), s["emission"]) for s in relit][:8],
|
||||
"hv_max": hv_dark, "locked_at_dark": dark["locked"],
|
||||
"locked_at_out": stretch[i_out]["locked"] if i_out is not None else None,
|
||||
"sampled_past_close_s": round(t_end - closed["t"], 2) if closed else None,
|
||||
"emission_reads": reads, "hv_reads": hv_reads}
|
||||
ev["disarm_after_idle_s"] = round(dt, 1) if dt is not None else None
|
||||
ctx.log("the rapid after the M5: %d samples over %.1f s from the dark point to the close, out at +%s s, "
|
||||
"back at +%s s; sampled %s s past the close, %d of %d samples with emission, HV max %s, %d of %d "
|
||||
"carried the LASER_ON count, longest unread stretch %s s; the window closed %s s after the head "
|
||||
"was back", len(armed_part), ev["rapid"]["span_s"], ev["rapid"]["out_at_s"], ev["rapid"]["back_at_s"],
|
||||
ev["rapid"]["sampled_past_close_s"], len(relit), len(stretch), hv_dark, reads["read"],
|
||||
len(stretch), reads["gap_max_s"], ev["disarm_after_idle_s"])
|
||||
ctx.check(armed_seen, "the armed window was never observed (arm refused, or no button press)")
|
||||
ctx.check(peak > 0, "no emission witnessed on the square (emission_samples stayed 0)")
|
||||
first_idle_fire = ev["idle_airflow_fire"][0] if idle_fire else (None, None, None)
|
||||
ctx.check(not idle_fire,
|
||||
"the laser fired while the cooling engine had no run session, so the fans were at "
|
||||
"their idle duty (phase %s at t=%s s, emission %s)",
|
||||
first_idle_fire[1], first_idle_fire[0], first_idle_fire[2])
|
||||
ctx.check(end == 0, "emission_samples did not return to 0 at Idle (%s)", end)
|
||||
ctx.check(ev["exhaust_at_first_fire"] is not None and ev["exhaust_at_first_fire"] >= floor,
|
||||
"the beam started before the exhaust reached its floor (%s rpm at the first "
|
||||
"emission sample, floor %.0f)", ev["exhaust_at_first_fire"], floor)
|
||||
ctx.check(hv and max(hv) > min(hv), "HV current did not rise during the burn (%s..%s)",
|
||||
ev["hv_min"], ev["hv_max"])
|
||||
ctx.check(i_out is not None and i_back is not None,
|
||||
"the rapid did not run inside the dark stretch with the window open: out at %s, back at %s "
|
||||
"(X from %s, max %s), the window closed at %s", ev["rapid"]["out_at_s"], ev["rapid"]["back_at_s"],
|
||||
x0, ev["rapid"]["x_max"], round(closed["t"] - dark["t"], 2) if closed else None)
|
||||
ctx.check(stretch[i_out]["locked"] is False, "the laser latch was locked with the head at +20 mm "
|
||||
"(laser_locked %s): the rapid ran unarmed", stretch[i_out]["locked"])
|
||||
ctx.check(reads["read"] >= RAPID_MIN_READS and reads["gap_max_s"] is not None
|
||||
and reads["gap_max_s"] <= EMISSION_READ_GAP_S,
|
||||
"the LASER_ON count was not read through the rapid: %d of %d samples carried it, the longest "
|
||||
"unread stretch %s s (the kernel latches the count once per ~1 s window, so a fire in an "
|
||||
"unread window is not witnessed)", reads["read"], len(stretch), reads["gap_max_s"])
|
||||
ctx.check(not relit, "the laser emitted after the M5: %d samples, first at +%s s (emission_samples %s) "
|
||||
"- a rapid after M5 fired", len(relit), relit[0]["t"] - dark["t"] if relit else None,
|
||||
relit[0]["emission"] if relit else None)
|
||||
ctx.check(hv_reads["read"] >= RAPID_MIN_READS,
|
||||
"the HV current was readable on only %d of %d samples through the rapid (%d needed)",
|
||||
hv_reads["read"], len(stretch), RAPID_MIN_READS)
|
||||
ctx.check(hv_dark <= HV_DARK_MAX, "HV current %s after the M5 (idle reads ~0): the discharge ran "
|
||||
"through the rapid", hv_dark)
|
||||
ctx.check(dt is not None and 0 <= dt < 10.0,
|
||||
"the M2 job did not disarm promptly (%s s after the head was back; the idle grace is ~60 s)",
|
||||
ev["disarm_after_idle_s"])
|
||||
ctx.check(gap["button_latch_unlocked_samples"] and gap["button_latch_unlocked_max"] == 0,
|
||||
"the hardware button latch read SET while the laser latch was unlocked (max %s over %d "
|
||||
"samples, at %s)", gap["button_latch_unlocked_max"], gap["button_latch_unlocked_samples"],
|
||||
gap["button_latch_set_at"])
|
||||
ctx.check(ev["hv_enable_dipped"], "HV_ENABLE never dropped across the 2 s dwell (the kernel run did not end)")
|
||||
ctx.check(gap["hv_enable_back_lit"],
|
||||
"HV_ENABLE did not return with emission after the dwell (the third side ran dark)")
|
||||
judge_beam(ctx, beam)
|
||||
check_button_dark(ctx, ev)
|
||||
ctx.confirm("Did the laser mark a 20 mm square outline on the scrap, all four sides?")
|
||||
ctx.log("PASS: emission peak %s -> 0, HV %s..%s, beam +%s; the rapid after the M5 dark (HV max %s); "
|
||||
"disarmed %.1f s after the head was back, button dark, mark confirmed", peak, ev["hv_min"],
|
||||
ev["hv_max"], beam["delta"], hv_dark, dt)
|
||||
|
||||
|
||||
HV_DARK_MAX = 20 # hv_current_raw reads 0 with the tube off; hundreds under fire
|
||||
# The kernel latches cnc/laser_on_sampled once per ~1 s window (cnc.c
|
||||
# LASER_SAMPLE_WINDOW) and holds the count through the next window, so a
|
||||
# trail whose valid reads are never farther apart than EMISSION_READ_GAP_S
|
||||
# has read every window's count at least once.
|
||||
EMISSION_WINDOW_S = 1.0
|
||||
EMISSION_READ_GAP_S = 0.9
|
||||
RAPID_MIN_READS = 4 # the fewest samples with the count (and with the HV current) over the rapid
|
||||
|
||||
|
||||
@test("laser.armed-kill", title="Armed kill mid-fire: the expected stop, then a SIGKILL",
|
||||
subsystem="laser", kind="live", mode="grbl", est_min=6,
|
||||
covers=_LASER_COVERS + [("forgectrl", "src/main.c")],
|
||||
requires=["laser.emission-witness", "motion.deadman"], actions=["button"],
|
||||
steps=["Scrap under the head with 40 mm of free +X and +Y travel; lid closed; exhaust on.",
|
||||
"Press the physical button when it lights white - twice over the test, once per burn. "
|
||||
"Each burn is cut short by the test; the sample trails judge the stops."],
|
||||
description="Both ways an armed job is killed, on one setup. Expected: mid-burn "
|
||||
"POST /controller/stop - the supervisor writes cnc/stop and relocks before the "
|
||||
"SIGTERM, so emission drops within 2.5 s and stays 0, the kernel is not running, "
|
||||
"and the restart is a separate operator-judged step. Unexpected: mid-burn SIGKILL "
|
||||
"of the controller - the supervisor's exit safing must end the fire tail inside "
|
||||
"the ring's in-flight window, leave the latch locked, and respawn the controller. "
|
||||
"The death is a signal to the supervisor, not a poll: the latch relocks within "
|
||||
"300 ms of the kill and the kernel is out of running within a second.")
|
||||
def armed_kill(ctx):
|
||||
ev = ctx.evidence
|
||||
fc = ctx.forgectrl
|
||||
|
||||
# -- 1. the expected stop -------------------------------------------------
|
||||
with ctx.grbl() as g, LiveJob(ctx, g):
|
||||
prepare(ctx, g)
|
||||
smp = arm_and_fire(ctx, g)
|
||||
ctx.log("emission live (%s) - stopping the controller NOW", smp["emission"])
|
||||
t_stop = time.time()
|
||||
code, body = fc.post("/controller/stop")
|
||||
post_dt = time.time() - t_stop
|
||||
ctx.log("POST /controller/stop -> %s %s (%.2f s)", code, body, post_dt)
|
||||
trail = kill_trail(ctx, t_stop)
|
||||
zero_at, tail_zero, not_running = judge_kill(ctx, trail, "stop")
|
||||
st_mode, mode = fc.get("/mode")
|
||||
ev["expected"] = {"post_status": code, "post_s": round(post_dt, 2), "zero_at_s": zero_at,
|
||||
"tail_zero": tail_zero, "kernel_not_running": not_running,
|
||||
"mode_after_stop": mode, "trail": trail}
|
||||
ctx.check(code == 200, "POST /controller/stop -> %s", code)
|
||||
ctx.check(zero_at is not None and zero_at < 2.5,
|
||||
"emission did not drop within 2.5 s of the stop (first 0 at %s)", zero_at)
|
||||
ctx.check(tail_zero, "emission returned after the stop")
|
||||
ctx.check(not_running, "the kernel was still running after the stop")
|
||||
ev["beam_at_fire_stop"] = smp.get("beam")
|
||||
ctx.log("the controller is stopped (supervision held); restarting it (no motion, no laser)")
|
||||
st, body = fc.post("/controller/start")
|
||||
ctx.log("POST /controller/start -> %s %s", st, body)
|
||||
ctx.check(st == 200, "POST /controller/start -> %s", st)
|
||||
ctx.sleep(6)
|
||||
st, mode = fc.get("/mode")
|
||||
ev["mode_after_start"] = mode
|
||||
ctx.log("/mode after start: %s", mode)
|
||||
ctx.check(isinstance(mode, dict) and mode.get("controller") == "running",
|
||||
"controller not running after the restart: %s", mode)
|
||||
ctx.log("expected stop PASS: returned in %.2f s, emission 0 at +%s s, controller restarted",
|
||||
post_dt, zero_at)
|
||||
|
||||
# -- 2. the SIGKILL -------------------------------------------------------
|
||||
# The pid is the RESTARTED controller's, not the one phase 1 stopped.
|
||||
import os as _os
|
||||
import signal as _signal
|
||||
st, m0 = fc.get("/mode")
|
||||
ctx.check(st == 200 and isinstance(m0, dict) and m0.get("controller") == "running",
|
||||
"controller not running before the kill: %s", m0)
|
||||
pid = m0.get("pid")
|
||||
ctx.check(wait_grbl_port(ctx), "the restarted controller never accepted a Grbl connection")
|
||||
with ctx.grbl() as g, LiveJob(ctx, g):
|
||||
prepare(ctx, g)
|
||||
smp = arm_and_fire(ctx, g)
|
||||
ctx.log("emission live (%s) - SIGKILL controller pid %s NOW", smp["emission"], pid)
|
||||
t_kill = time.time()
|
||||
_os.kill(pid, _signal.SIGKILL)
|
||||
fast = kill_fast_trail(t_kill)
|
||||
trail = kill_trail(ctx, t_kill)
|
||||
ctx.log("after the kill (sysfs): %s", fast)
|
||||
zero_at, tail_zero, not_running = judge_kill(ctx, trail, "kill")
|
||||
ilk = hw.sysfs_int("cnc/interlock_circuit")
|
||||
locked = ilk is not None and bool(ilk & (1 << 3))
|
||||
ev["sigkill"] = {"pid": pid, "zero_at_s": zero_at, "tail_zero": tail_zero,
|
||||
"kernel_not_running": not_running, "latch_locked": locked, "trail": trail,
|
||||
"fast": fast}
|
||||
ctx.log("latch locked after the kill: %s", locked)
|
||||
ctx.check(fast["latch_locked_at_s"] is not None and fast["latch_locked_at_s"] < 0.3,
|
||||
"the latch was not locked within 300 ms of the kill: %s", fast)
|
||||
ctx.check(fast["kernel_stopped_at_s"] is not None and fast["kernel_stopped_at_s"] < 1.0,
|
||||
"the kernel was still running 1 s after the kill: %s", fast)
|
||||
ctx.check(zero_at is not None and zero_at < 2.5,
|
||||
"emission did not drop within 2.5 s of the kill (first 0 at %s)", zero_at)
|
||||
ctx.check(tail_zero, "emission returned after the kill")
|
||||
ctx.check(not_running, "the kernel was still running after the kill")
|
||||
ctx.check(locked, "latch not locked after the kill")
|
||||
t0 = time.time()
|
||||
m1 = None
|
||||
while time.time() - t0 < 60:
|
||||
st, m1 = fc.get("/mode")
|
||||
if isinstance(m1, dict) and m1.get("controller") == "running" and m1.get("pid") != pid:
|
||||
break
|
||||
ctx.sleep(1)
|
||||
ev["mode_after_kill"] = m1
|
||||
ctx.log("/mode after the kill: %s", m1)
|
||||
ctx.check(m1 and m1.get("controller") == "running" and m1.get("pid") != pid,
|
||||
"supervisor did not respawn the controller: %s", m1)
|
||||
# The respawned controller starts locked and stays locked: a latch it
|
||||
# did not unlock is never relit by a run start, and no arm has asked.
|
||||
ctx.check(wait_grbl_port(ctx), "the respawned controller never accepted a Grbl connection")
|
||||
ctx.sleep(2)
|
||||
ilk = hw.sysfs_int("cnc/interlock_circuit")
|
||||
ev["latch_locked_after_respawn"] = ilk is not None and bool(ilk & IL_LASER_LATCH)
|
||||
ctx.check(ev["latch_locked_after_respawn"],
|
||||
"the respawned controller unlocked the latch with no arm (interlock_circuit=%s)", ilk)
|
||||
ev["beam_at_fire_kill"] = smp.get("beam")
|
||||
check_button_dark(ctx, ev)
|
||||
ctx.log("PASS: expected stop 0 at +%s s and SIGKILL 0 at +%s s, latch locked, controller "
|
||||
"respawned with the latch still locked, button dark", ev["expected"]["zero_at_s"], zero_at)
|
||||
|
||||
|
||||
@test("laser.pause-resume-lid-cancel", title="One live cut: the button pauses and resumes it, the lid "
|
||||
"cancels it and sends the head home",
|
||||
subsystem="laser", kind="live", mode="grbl", est_min=7,
|
||||
covers=_LASER_COVERS + [("grblhal-glowforge", "src/glowforge_switches.c"),
|
||||
("grblhal-glowforge", "src/glowforge_switch_map.h")],
|
||||
requires=["laser.emission-witness"],
|
||||
actions=["button", "lid"],
|
||||
steps=["Scrap under the head with 40 mm of free +X and +Y travel; lid closed; exhaust on.",
|
||||
"Press the physical button when it lights white (arm). Once the cut is under way the "
|
||||
"test asks for a press (pause), then another (resume), then for the lid: open it and "
|
||||
"leave it open until the head has come back, then close it.",
|
||||
"A pause longer than the armed window's idle grace (about a minute) closes the window; "
|
||||
"the resume then lights the button again and waits for a press."],
|
||||
description="The machine's own controls during one armed burn, in the order the factory uses "
|
||||
"them. Press: the job feed-holds, emission stops, and the latch stays UNLOCKED "
|
||||
"with the armed window open - a pause is not a cancel. Press again: the cut "
|
||||
"resumes from where it stopped, lit from the first step (a pause is a sharp corner "
|
||||
"in time; the cut is M3 so the corner dose is there to look at). Lid: emission "
|
||||
"stops in hardware, the job is canceled "
|
||||
"with the reason reported, the controller resets with the position kept and no "
|
||||
"alarm, the armed window closes and the kernel latch relocks, the hardware button "
|
||||
"latch reads SET, and the head returns to the job start with the lid still open.")
|
||||
def pause_resume_lid_cancel(ctx):
|
||||
ev = ctx.evidence
|
||||
with ctx.grbl() as g, LiveJob(ctx, g):
|
||||
prepare(ctx, g)
|
||||
k0 = kernel_start(ctx)
|
||||
start = g.status_report()["MPos"]
|
||||
ev["start"] = start
|
||||
ev["kernel_start"] = k0
|
||||
base_beam = sample(ctx)
|
||||
beams = [] # (analog, digital) through the cut
|
||||
smp = arm_and_fire(ctx, g, job=MARK_JOB_M3)
|
||||
ev["emission_running"] = smp["emission"]
|
||||
beams.append((smp.get("beam"), smp.get("beam_d")))
|
||||
ctx.log("emission live (%s) - asking the operator to pause", smp["emission"])
|
||||
|
||||
# -- the button pauses ------------------------------------------------
|
||||
g.drain() # the message window opens here
|
||||
w = Watch(g)
|
||||
ctx.act("button", "press", text="The laser is cutting: the press pauses it.",
|
||||
until=w.in_state("Hold"), timeout=20, fail=False)
|
||||
st, text = w.last, w.text
|
||||
if st is not None and not st["state"].startswith("Hold"):
|
||||
st = None
|
||||
ctx.check(st is not None, "the press did not hold the job (state %s)", g.status_report()["state"])
|
||||
ev["hold_state"] = st["state"]
|
||||
ev["pause_message"] = "job paused" in text
|
||||
paused = []
|
||||
t1 = time.time()
|
||||
while time.time() - t1 < 4:
|
||||
s = sample(ctx)
|
||||
if s:
|
||||
paused.append((round(time.time() - t1, 2), s["emission"], s["armed"]))
|
||||
time.sleep(0.2)
|
||||
for p in paused:
|
||||
ctx.log(" paused %s", p)
|
||||
ev["paused_trail"] = paused
|
||||
ev["emission_zero_when_paused"] = all(e == 0 for _t, e, _a in paused[-8:]) if paused else None
|
||||
ev["armed_while_paused"] = paused[-1][2] if paused else None
|
||||
ilk = hw.sysfs_int("cnc/interlock_circuit")
|
||||
ev["latch_locked_while_paused"] = ilk is not None and bool(ilk & (1 << 3))
|
||||
ctx.log("paused: %s, emission 0 = %s, armed = %s, latch locked = %s", ev["hold_state"],
|
||||
ev["emission_zero_when_paused"], ev["armed_while_paused"],
|
||||
ev["latch_locked_while_paused"])
|
||||
ctx.check(ev["emission_zero_when_paused"], "emission did not stop while the job was paused: %s", paused)
|
||||
ctx.check(ev["armed_while_paused"], "the armed window closed on the pause (a pause is not a cancel)")
|
||||
ctx.check(not ev["latch_locked_while_paused"],
|
||||
"the kernel latch relocked on the pause - the resume could not fire without a new arm press")
|
||||
|
||||
# -- the button resumes -----------------------------------------------
|
||||
g.drain()
|
||||
w = Watch(g)
|
||||
ctx.act("button", "press", text="The cut is paused: the press resumes it.",
|
||||
until=w.left_state("Hold"), timeout=40, fail=False)
|
||||
st, text = w.last, w.text
|
||||
if st is not None and st["state"].startswith("Hold"):
|
||||
st = None
|
||||
ev["resumed_state"] = st["state"] if st else g.status_report()["state"]
|
||||
ev["resume_message"] = "job resumed" in text
|
||||
ctx.check(st is not None, "the second press did not resume the job (still held: %s)",
|
||||
ev["resumed_state"])
|
||||
ctx.check(st["state"].startswith(("Run", "Idle")),
|
||||
"the job left the hold into %s, not into motion", st["state"])
|
||||
back = False
|
||||
t2 = time.time()
|
||||
trail = []
|
||||
while time.time() - t2 < 20:
|
||||
ctx.checkpoint()
|
||||
s = sample(ctx)
|
||||
if s:
|
||||
trail.append((round(time.time() - t2, 2), s["emission"]))
|
||||
beams.append((s.get("beam"), s.get("beam_d")))
|
||||
if s["emission"] and s["emission"] > 0:
|
||||
back = True
|
||||
break
|
||||
time.sleep(0.15)
|
||||
ev["emission_back_after_s"] = trail[-1][0] if trail else None
|
||||
ev["resume_trail"] = trail[-6:]
|
||||
ctx.log("emission after the resume: %s", trail[-6:])
|
||||
ctx.check(back, "emission did not return after the resume: %s", trail[-6:])
|
||||
|
||||
# -- the lid cancels --------------------------------------------------
|
||||
g.drain()
|
||||
ctx.act("lid", "open", text="The cut is running again: leave the lid open until the head "
|
||||
"has come back.", timeout=30)
|
||||
t_lid = time.time()
|
||||
lid_trail = []
|
||||
text = ""
|
||||
while time.time() - t_lid < 8:
|
||||
s = sample(ctx)
|
||||
if s:
|
||||
lid_trail.append((round(time.time() - t_lid, 2), s["emission"], s["kstate"], s["armed"]))
|
||||
text += g.drain()
|
||||
time.sleep(0.12)
|
||||
for t in lid_trail:
|
||||
ctx.log(" post-lid %s", t)
|
||||
ev["messages"] = [ln for ln in text.splitlines() if ln.startswith("[MSG:") or "help]" in ln
|
||||
or ln.startswith("ALARM")]
|
||||
ctx.log("controller: %s", ev["messages"])
|
||||
zero_at = next((t for t, e, _, _ in lid_trail if e == 0), None)
|
||||
tail_zero = all(e == 0 for _, e, _, _ in lid_trail[-16:])
|
||||
ev.update({"lid_zero_at_s": zero_at, "lid_tail_zero": tail_zero})
|
||||
ctx.check(zero_at is not None and zero_at < 3.0,
|
||||
"emission did not stop after the lid opened (first 0 at %s)", zero_at)
|
||||
ctx.check(tail_zero, "emission returned after the lid opened")
|
||||
ctx.check("lid opened - job canceled" in text, "the lid open was not reported as canceling the job")
|
||||
ctx.check("help]" in text, "no reset banner after the cancel")
|
||||
ctx.check("ALARM" not in text, "an alarm was raised on the cancel (position should be kept)")
|
||||
t3 = time.time()
|
||||
returned = "returned to the job start" in text
|
||||
while not returned and time.time() - t3 < 30:
|
||||
ctx.checkpoint()
|
||||
text += g.drain()
|
||||
returned = "returned to the job start" in text
|
||||
time.sleep(0.2)
|
||||
ev["returned_message"] = returned
|
||||
ctx.check(returned, "the head did not report returning to the job start")
|
||||
st = g.status_report()
|
||||
drift = max(abs(st["MPos"][i] - start[i]) for i in range(2))
|
||||
ev["drift_mm"] = round(drift, 3)
|
||||
s = sample(ctx)
|
||||
ev["armed_after"] = s["armed"] if s else None
|
||||
ilk = hw.sysfs_int("cnc/interlock_circuit")
|
||||
ev["latch_locked"] = ilk is not None and bool(ilk & (1 << 3))
|
||||
ev["button_latch"] = hw.sysfs_int("cnc/button_latch")
|
||||
ctx.log("returned: drift %.3f mm; armed=%s latch_locked=%s button_latch=%s", drift,
|
||||
ev["armed_after"], ev["latch_locked"], ev["button_latch"])
|
||||
ctx.check(drift <= 0.05, "head not back at the job start (drift %.3f mm)", drift)
|
||||
ctx.check(ctx.forgectrl.wait_idle(10, abort=ctx.aborted), "machine not idle after the return")
|
||||
check_kernel_returned(ctx, ev, k0)
|
||||
ctx.check(not ev["armed_after"], "armed window still open after the cancel")
|
||||
ctx.check(ev["latch_locked"], "kernel latch not locked after the cancel")
|
||||
ctx.check(ev["button_latch"] == 1, "hardware button latch not SET after the lid open (%s)",
|
||||
ev["button_latch"])
|
||||
beam_witness(ctx, ev, [{"beam": b, "beam_d": d} for b, d in beams], base_beam)
|
||||
judge_beam(ctx, ev["beam"], "the cut")
|
||||
check_button_dark(ctx, ev)
|
||||
ctx.act("lid", "close")
|
||||
ctx.sleep(1)
|
||||
ctx.log("PASS: button paused the burn (emission 0, armed kept, latch unlocked) and resumed it; "
|
||||
"the lid then canceled it - emission 0 at +%s s, reset without alarm, returned (drift "
|
||||
"%.3f mm), button latch SET", zero_at, drift)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,304 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""setup.* - what the setup tests share: the setup record and its
|
||||
builders, the files of the data directory, a takeover that installs a
|
||||
record, and the HTTPS client. The tests are setup_dark's and
|
||||
setup_burns'; this module registers none.
|
||||
|
||||
The daemon reads the setup record (setup.json in the
|
||||
data directory) and the account record (users) once, at its start, and
|
||||
keeps them in memory; only its own routes write them. A test that needs
|
||||
a different record installs the file under a forgectrl restart (a
|
||||
takeover) and puts the previous file back the same way. Thus the daemon
|
||||
never saves a test record over the real one, and a restore is complete
|
||||
when the daemon is up again.
|
||||
|
||||
The layer content of this work (the account replay init script, the
|
||||
sshd policy, the hostname and console banner scripts) is part of the
|
||||
platform identity that every fingerprint carries, not of a component.
|
||||
No coverage map names it: a change there makes every test necessary
|
||||
again.
|
||||
"""
|
||||
import contextlib
|
||||
import posixpath
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import ssl
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
from .. import hw
|
||||
|
||||
RECORD = "setup.json"
|
||||
USERS = "users"
|
||||
SSH_FLAG = "ssh-enabled"
|
||||
|
||||
# The first run's form wizards, all at version 1 (wiz.c).
|
||||
FORM_WIZARDS = ("advisories", "account", "preferences", "machine", "cloud")
|
||||
# The steps the gate requires after the account, all at version 1, in
|
||||
# the order the gate's reason names them (setup.c): the form steps, then
|
||||
# the coolant flow calibration. The first-run tests seed the checks done,
|
||||
# the calibration among them, so the form steps are what they walk.
|
||||
REQUIRED_FORM_STEPS = ("preferences", "machine", "cloud")
|
||||
REQUIRED_STEPS = REQUIRED_FORM_STEPS + ("cooling.flow",)
|
||||
# The setting that counts the intake fans: the gate holds without it.
|
||||
INTAKE_KEY = "cool_intake_fans"
|
||||
SAFETY_PHRASE = "I UNDERSTAND"
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- files
|
||||
|
||||
def data_dir():
|
||||
return os.environ.get("FORGECTRL_DATA_DIR") or "/data/forgefirm"
|
||||
|
||||
|
||||
def run_dir():
|
||||
return os.environ.get("GF_RUN_DIR") or "/run/forgefirm"
|
||||
|
||||
|
||||
# The daemon's own paths on the board, POSIX on every host that names them.
|
||||
def record_path():
|
||||
return posixpath.join(data_dir(), RECORD)
|
||||
|
||||
|
||||
def users_path():
|
||||
return posixpath.join(data_dir(), USERS)
|
||||
|
||||
|
||||
def ssh_flag_path():
|
||||
return posixpath.join(run_dir(), SSH_FLAG)
|
||||
|
||||
|
||||
def read_file(path):
|
||||
"""The bytes of a file, or None when it does not exist."""
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
return f.read()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def write_file(path, data):
|
||||
"""Write data to path (temp file and rename, mode 0600), or remove
|
||||
the file when data is None."""
|
||||
if data is None:
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
return
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
tmp = path + ".forgetest"
|
||||
# the bytes as given: O_BINARY keeps a Windows host (the unit tests)
|
||||
# from translating newlines; it does not exist elsewhere
|
||||
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_BINARY", 0), 0o600)
|
||||
try:
|
||||
os.write(fd, data)
|
||||
finally:
|
||||
os.close(fd)
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- records
|
||||
|
||||
class Restore:
|
||||
"""Settings a test or a wizard writes, put back as found at the end,
|
||||
in the order of `keys` (a value that depends on another goes after
|
||||
it)."""
|
||||
|
||||
def __init__(self, ctx, keys):
|
||||
self.ctx = ctx
|
||||
self.keys = keys
|
||||
self.before = {}
|
||||
|
||||
def __enter__(self):
|
||||
s = self.ctx.forgectrl.settings() or {}
|
||||
self.before = {k: s.get(k) for k in self.keys}
|
||||
self.ctx.evidence["settings_before"] = dict(self.before)
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
fc = self.ctx.forgectrl
|
||||
for k, v in self.before.items():
|
||||
if v is None or v == "":
|
||||
st, _ = fc.post("/settings", params={k: ""})
|
||||
else:
|
||||
st, _ = fc.post("/settings", data={k: v})
|
||||
self.ctx.log("restore %s=%r -> %s", k, v, st)
|
||||
return False
|
||||
|
||||
|
||||
def now_iso():
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
|
||||
|
||||
def record_without_wizards(record):
|
||||
"""A copy of a record with no completed wizard and no flag: the
|
||||
consent and the account stay, so the gate closes on the required
|
||||
steps."""
|
||||
out = json.loads(json.dumps(record))
|
||||
out["wizards"] = {}
|
||||
out["flags"] = {}
|
||||
return out
|
||||
|
||||
|
||||
def record_with_required_steps(record):
|
||||
"""A copy of a record with the required steps done (kept from the
|
||||
record, or written at version 1) and nothing else, and no flag: on a
|
||||
machine whose intake fans are counted the gate is open and every
|
||||
other step is recommended as not run yet."""
|
||||
out = record_without_wizards(record)
|
||||
have = record.get("wizards") or {}
|
||||
for wid in REQUIRED_STEPS:
|
||||
out["wizards"][wid] = have.get(wid) or {"version": 1, "completed": now_iso(), "result": {},
|
||||
"applied": {}}
|
||||
return out
|
||||
|
||||
|
||||
def record_without_consent(record):
|
||||
"""A copy of a record with no advisory accepted and no press: the
|
||||
gate closes on the consent, whatever else the record holds."""
|
||||
out = json.loads(json.dumps(record))
|
||||
out["advisories"] = {}
|
||||
out.pop("acceptance", None)
|
||||
return out
|
||||
|
||||
|
||||
def record_without_account(record):
|
||||
"""A copy of a record with the consent and no account: the gate
|
||||
closes on the account."""
|
||||
out = json.loads(json.dumps(record))
|
||||
out.pop("account", None)
|
||||
return out
|
||||
|
||||
|
||||
def complete_record(status, base=None, account_name="bench"):
|
||||
"""A complete setup record for a machine that must count as
|
||||
set up: every advisory at the hash GET /wiz reports, the
|
||||
press recorded, an account (the one of `base` when it has one),
|
||||
every wizard of the catalog at its version, and the run complete.
|
||||
`status` is a GET /wiz body; `base` is an existing record to keep
|
||||
the account, the machine facts, and the sheet id from."""
|
||||
base = base or {}
|
||||
ts = now_iso()
|
||||
rec = {"schema": 1, "created": base.get("created") or ts, "completed": ts,
|
||||
"advisories": {}, "wizards": {}, "flags": {}}
|
||||
if base.get("sheet_id"):
|
||||
rec["sheet_id"] = base["sheet_id"]
|
||||
elif status.get("sheet_id"):
|
||||
rec["sheet_id"] = status["sheet_id"]
|
||||
for d in status.get("documents") or []:
|
||||
rec["advisories"][d["id"]] = {"hash": d["hash"], "accepted": ts,
|
||||
"method": d.get("consent") or "check"}
|
||||
rec["acceptance"] = {"pressed_at": ts}
|
||||
acct = base.get("account") if isinstance(base.get("account"), dict) else None
|
||||
rec["account"] = acct or {"name": account_name, "uid": 1000, "created": ts}
|
||||
if isinstance(base.get("machine"), dict):
|
||||
rec["machine"] = base["machine"]
|
||||
wizards = status.get("wizards") or [{"id": w, "version": 1} for w in FORM_WIZARDS]
|
||||
for w in wizards:
|
||||
rec["wizards"][w["id"]] = {"version": int(w.get("version") or 1), "completed": ts,
|
||||
"result": {}, "applied": {}}
|
||||
return rec
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def installed(ctx, files):
|
||||
"""Install files ({path: bytes, or None to remove}) under a forgectrl
|
||||
restart, and put the previous content back under another one when
|
||||
the block ends, on every exit path. Yields the saved content. The
|
||||
restart is the runner's takeover: the controller is stopped through
|
||||
the supervisor, the marker makes a crash recoverable, and the exit
|
||||
waits for the supervisor to settle."""
|
||||
saved = {p: read_file(p) for p in files}
|
||||
with ctx.takeover():
|
||||
for p, data in files.items():
|
||||
write_file(p, data)
|
||||
ctx.log("installed under a restart: %s",
|
||||
", ".join("%s=%s" % (os.path.basename(p), "removed" if d is None else "%d B" % len(d))
|
||||
for p, d in files.items()))
|
||||
try:
|
||||
yield saved
|
||||
finally:
|
||||
with ctx.takeover():
|
||||
for p, data in saved.items():
|
||||
write_file(p, data)
|
||||
ctx.log("restored under a restart: %s", ", ".join(os.path.basename(p) for p in saved))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- client
|
||||
|
||||
def request(base, method, path, data=None, headers=None, timeout=10.0):
|
||||
"""One HTTP or HTTPS request without redirect following: (status,
|
||||
body bytes, {header: value}). HTTPS is served with a self-signed
|
||||
certificate, so the peer is not verified. The Host header is the
|
||||
address literal of base, which the origin check requires."""
|
||||
u = urllib.parse.urlsplit(base)
|
||||
port = u.port or (443 if u.scheme == "https" else 80)
|
||||
if u.scheme == "https":
|
||||
cx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
cx.check_hostname = False
|
||||
cx.verify_mode = ssl.CERT_NONE
|
||||
conn = http.client.HTTPSConnection(u.hostname, port, timeout=timeout, context=cx)
|
||||
else:
|
||||
conn = http.client.HTTPConnection(u.hostname, port, timeout=timeout)
|
||||
hdrs = {"Host": u.netloc}
|
||||
hdrs.update(headers or {})
|
||||
body = None
|
||||
if isinstance(data, dict):
|
||||
body = urllib.parse.urlencode(data).encode()
|
||||
hdrs.setdefault("Content-Type", "application/x-www-form-urlencoded")
|
||||
elif isinstance(data, str):
|
||||
body = data.encode()
|
||||
elif data is not None:
|
||||
body = data
|
||||
if body is not None:
|
||||
hdrs.setdefault("Content-Length", str(len(body)))
|
||||
try:
|
||||
conn.request(method, path, body=body, headers=hdrs)
|
||||
r = conn.getresponse()
|
||||
content = r.read()
|
||||
return r.status, content, {k.lower(): v for k, v in r.getheaders()}
|
||||
except (OSError, http.client.HTTPException) as e:
|
||||
raise hw.HwError("%s %s%s: %s" % (method, base, path, e))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def decode(content):
|
||||
"""JSON when the body parses as JSON, else text."""
|
||||
text = content.decode("utf-8", "replace")
|
||||
try:
|
||||
return json.loads(text)
|
||||
except ValueError:
|
||||
return text
|
||||
|
||||
|
||||
def session_from_cookie(value):
|
||||
"""The ffsid session id out of a Set-Cookie value, or None."""
|
||||
for part in (value or "").split(";"):
|
||||
part = part.strip()
|
||||
if part.startswith("ffsid="):
|
||||
sid = part[len("ffsid="):]
|
||||
return sid or None
|
||||
return None
|
||||
|
||||
|
||||
def cookie_flags(value):
|
||||
return set(p.strip().split("=", 1)[0].lower() for p in (value or "").split(";")[1:])
|
||||
|
||||
|
||||
def wiz(fc):
|
||||
st, body = fc.get("/wiz")
|
||||
if st != 200 or not isinstance(body, dict):
|
||||
raise hw.HwError("GET /wiz -> %s" % st)
|
||||
return body
|
||||
|
||||
|
||||
def recommended(w):
|
||||
"""The steps a GET /wiz body recommends: {id: reason}."""
|
||||
return {x.get("id"): x.get("reason") or "" for x in w.get("recommended") or [] if isinstance(x, dict)}
|
||||
@@ -1,466 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""setup.focus - the setup's focus burn (a live wizard), driven the way
|
||||
the page drives it: POST /wiz/<id>/start, GET /wiz/dark polled, the
|
||||
prompts answered from here, the burn armed by a press, the result
|
||||
judged, and the setting the burn wrote put back as found. The burn
|
||||
stands alone on its own scrap of wood in the back-left corner, drawn
|
||||
from the head's home against the gantry stops, and re-runs on the real
|
||||
record, so a completed burn completes again at the same version.
|
||||
|
||||
The operator's part is the scrap and one press on the machine at the
|
||||
start (the presence check of the ready gate; with the bench actuator up
|
||||
it then makes the arm press). The judgment a person makes by eye on the
|
||||
scrap (the narrowest line) is answered here with a fixed pick: the test
|
||||
proves the wizard's mechanics and the emission witnesses, not the
|
||||
operator's eye, and the setting it writes is restored. The page is where
|
||||
the real numbers go in.
|
||||
"""
|
||||
import re
|
||||
import time
|
||||
|
||||
from .. import baseline
|
||||
from .. import hw
|
||||
from ..catalog import test
|
||||
from .setup_dark import run_check, Restore, FALLBACK_WINDOW, LENS_WINDOW_KEYS, dark
|
||||
|
||||
BURN_COVERS = [("forgectrl", "src/wizlive.*"), ("forgectrl", "src/wizrun.h"),
|
||||
("forgectrl", "src/lens.*"), ("forgectrl", "src/sheet.*"),
|
||||
("forgectrl", "src/lenshome.*"),
|
||||
("forgectrl", "src/font_hershey.*"), ("forgectrl", "src/jobstream.*"),
|
||||
("forgectrl", "src/jobrun.*"), ("forgectrl", "src/lease.*"),
|
||||
("forgectrl", "src/curverec.*"), ("forgectrl", "src/wizdark.*"),
|
||||
("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"),
|
||||
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*"),
|
||||
("forgectrl", "src/super.c"),
|
||||
("forgectrl", "src/status.c"), ("forgectrl", "src/cool.c"),
|
||||
("grblhal-glowforge", "src/glowforge_laser.c"),
|
||||
("grblhal-glowforge", "src/glowforge_status.c"),
|
||||
("grblhal-glowforge", "src/glowforge_homing.*"),
|
||||
("grblhal-glowforge", "src/glowforge_io.*")]
|
||||
|
||||
# Every burn asks first that the gantry stops are installed and the head
|
||||
# is at its home against them; the bench's head stands there, and every
|
||||
# burn's tail brings it back. head_at_home reads the head's place from
|
||||
# /status before the wizard starts and judges it, within HOME_TOL_MM,
|
||||
# against the stops as the manual home that references X and Y declared
|
||||
# them (its home anchor): X0 Y0 for a home with no manual home offset,
|
||||
# minus manual_home_x and manual_home_y for a home made with them.
|
||||
HOME = "The head is home"
|
||||
HOME_TOL_MM = 0.05
|
||||
HOME_OFFSET_KEYS = ("manual_home_x", "manual_home_y")
|
||||
EDGE_KEY = "lens_hall_edge_z_mm"
|
||||
FOCUS_KEYS = ["thickness_mm", "z_passes", "ladder_z", "window", "emission", "pick", "picked_lines",
|
||||
"pick_half_steps", "edge_z_mm", "steps_per_mm", "max_height_mm", "focus_range_mm", "summary"]
|
||||
# pic/hv_current above this: the discharge is present (the daemon's own
|
||||
# rule for a lit burn, jobstream.h JOBSTREAM_HV_ON).
|
||||
HV_ON = 30
|
||||
|
||||
|
||||
class Witnesses:
|
||||
"""The two emission witnesses read from sysfs by the test itself at
|
||||
every poll of the burn: the kernel's LASER_ON sample count
|
||||
(cnc/laser_on_sampled) and the tube current (pic/hv_current), each
|
||||
at its peak, and how many polls read both. The burn's result reports
|
||||
the daemon's own reading of the same two; this one is taken past the
|
||||
daemon."""
|
||||
|
||||
def __init__(self):
|
||||
self.laser_on_max = None
|
||||
self.hv_max = None
|
||||
self.reads = 0
|
||||
|
||||
def __call__(self, _doc):
|
||||
lon = hw.sysfs_int("cnc/laser_on_sampled")
|
||||
hv = hw.sysfs_int("pic/hv_current")
|
||||
if lon is not None:
|
||||
self.laser_on_max = lon if self.laser_on_max is None else max(self.laser_on_max, lon)
|
||||
if hv is not None:
|
||||
self.hv_max = hv if self.hv_max is None else max(self.hv_max, hv)
|
||||
if lon is not None and hv is not None:
|
||||
self.reads += 1
|
||||
|
||||
def record(self):
|
||||
return {"laser_on_max": self.laser_on_max, "hv_max": self.hv_max, "reads": self.reads}
|
||||
|
||||
|
||||
def anchor_stops():
|
||||
"""Where the manual home that references X and Y declared the gantry
|
||||
stops, in /status coordinates: the X and Y of the controller's home
|
||||
anchor (baseline.ANCHOR_PATH), to which /status adds the step
|
||||
counters. None when the anchor does not read as a manual home of X
|
||||
and Y. The settings do not place the stops: a manual home reads
|
||||
manual_home_x and manual_home_y once, and a change to them after the
|
||||
home leaves its reference as it is."""
|
||||
try:
|
||||
with open(baseline.ANCHOR_PATH, encoding="ascii", errors="replace") as f:
|
||||
fields = f.read().split()
|
||||
x, y, axes, source = float(fields[0]), float(fields[1]), int(fields[3]), fields[4]
|
||||
except (OSError, ValueError, IndexError):
|
||||
return None
|
||||
if (axes & 3) != 3 or source != "manual":
|
||||
return None
|
||||
return x, y
|
||||
|
||||
|
||||
def home_offset_remedy(ctx):
|
||||
"""What brings the head to its home against the stops, from what the
|
||||
next manual home reads: with no manual home offset, a home there;
|
||||
with one, Set home here moves the head off the stops to X0 Y0, so the
|
||||
head is pushed there with the motors released, and the release ends
|
||||
with Energize motors."""
|
||||
s = ctx.forgectrl.settings() or {}
|
||||
raw = [str(s.get(key) or "").strip() for key in HOME_OFFSET_KEYS]
|
||||
|
||||
def no_offset(v):
|
||||
try:
|
||||
return v == "" or float(v) == 0
|
||||
except ValueError:
|
||||
return False
|
||||
if no_offset(raw[0]) and no_offset(raw[1]):
|
||||
return "home it there before the burn"
|
||||
return ("with manual_home_x %s and manual_home_y %s, Set home here moves the head off the stops to X0 Y0; "
|
||||
"release the motors, push the head against the stops, and end the release with Energize motors "
|
||||
"($ME), not with Set home here, before the burn" % (raw[0] or "blank", raw[1] or "blank"))
|
||||
|
||||
|
||||
def head_at_home(ctx):
|
||||
"""The head's place on record before the wizard starts, read from
|
||||
/status under the campaign's own controller: the wizard restarts the
|
||||
controller before its home prompt, and a fresh controller drops the
|
||||
X/Y reference, so a read at that prompt places nothing. When X and Y
|
||||
are referenced by a manual home, the head is judged within
|
||||
HOME_TOL_MM against the gantry stops, where the wizard draws from,
|
||||
as that home declared them (anchor_stops): X0 Y0 for a home with no
|
||||
offset, and minus manual_home_x and manual_home_y for a home made
|
||||
with them, which moves the head to X0 Y0, that far in front of the
|
||||
stops. Unreferenced, the head's place rests on the operator's word
|
||||
at the ready gate, and the record says so."""
|
||||
fc = ctx.forgectrl
|
||||
read = {}
|
||||
|
||||
def served():
|
||||
st = fc.status()
|
||||
if not isinstance(st.get("pos"), dict):
|
||||
return False
|
||||
read.update(st)
|
||||
return True
|
||||
took = ctx.wait_for(served, 10)
|
||||
ctx.check(took is not None, "/status served no head position within 10 s: the head's place cannot "
|
||||
"go on record before the burn")
|
||||
pos = read.get("pos") or {}
|
||||
axes = read.get("homed_axes")
|
||||
source = read.get("home_source")
|
||||
referenced = isinstance(axes, int) and (axes & 3) == 3 and source == "manual"
|
||||
rec = ctx.evidence["head_at_start"] = {"pos": pos, "homed_axes": axes, "home_source": source,
|
||||
"judged": referenced}
|
||||
if not referenced:
|
||||
ctx.log("the head's place rests on the operator's word at the ready gate (X and Y unreferenced: "
|
||||
"homed_axes %s, home_source %s, pos %s)", axes, source, pos)
|
||||
return
|
||||
found = anchor_stops()
|
||||
ctx.check(found is not None, "the home anchor (%s) does not read as the manual home /status reports: the "
|
||||
"gantry stops cannot be placed before the burn", baseline.ANCHOR_PATH)
|
||||
stops = rec["stops"] = {"x": found[0], "y": found[1]}
|
||||
x, y = pos.get("x"), pos.get("y")
|
||||
at_home = (isinstance(x, (int, float)) and isinstance(y, (int, float))
|
||||
and abs(x - stops["x"]) <= HOME_TOL_MM and abs(y - stops["y"]) <= HOME_TOL_MM)
|
||||
ctx.check(at_home, "the head is not at its home against the gantry stops: /status reads pos %s with X "
|
||||
"and Y referenced by a manual home that declared the stops at X%.2f Y%.2f; %s", pos, stops["x"],
|
||||
stops["y"], "" if at_home else home_offset_remedy(ctx))
|
||||
ctx.log("the head is home on record: pos %s, X and Y referenced by a manual home, the stops at X%.2f Y%.2f",
|
||||
pos, stops["x"], stops["y"])
|
||||
|
||||
|
||||
def preview_ok(ctx, wid):
|
||||
"""The page's preview and the program for the burn are served."""
|
||||
fc = ctx.forgectrl
|
||||
st, body = fc.get("/wiz/sheet.svg?card=%s" % wid, raw=True)
|
||||
ctx.check(st == 200 and body.lstrip().startswith(b"<svg"), "no preview for %s (%s)", wid, st)
|
||||
ctx.evidence["preview_bytes"] = len(body)
|
||||
st, body = fc.get("/wiz/sheet.gcode?card=%s" % wid, raw=True)
|
||||
# The text is M4 at the mark dose; the ladders bring their own M3 or
|
||||
# M4 lines. A program is one with a laser-on command.
|
||||
ctx.check(st == 200 and re.search(rb"^M[34] S\d", body or b"", re.M) is not None,
|
||||
"no program for %s (%s)", wid, st)
|
||||
ctx.evidence["program_lines"] = body.count(b"\n")
|
||||
# Every Z the program commands is a height the head reaches in the
|
||||
# window the machine holds now: the Lens travel check's, or the
|
||||
# fallback on a head whose travel is not known yet. The controller
|
||||
# refuses a Z outside it with ALARM:2, and it refuses it while the
|
||||
# body is still burning, so the burn dies where the parser is and not
|
||||
# where the bad line is.
|
||||
lens = (fc.status() or {}).get("lens") or {}
|
||||
lo, hi = lens.get("reach_min"), lens.get("reach_max")
|
||||
zs = [float(m) for m in re.findall(rb"^G0 Z(-?[0-9.]+)", body or b"", re.M)]
|
||||
ctx.evidence.setdefault("program_z", {})[wid] = {
|
||||
"z": zs, "reach": [lo, hi], "stops_found": lens.get("stops_found")}
|
||||
ctx.check(lo is not None and hi is not None, "/status carries no lens reach: %s", lens)
|
||||
if lo is not None and hi is not None:
|
||||
out = [z for z in zs if z < lo - 0.01 or z > hi + 0.01]
|
||||
ctx.check(not out, "%s commands Z outside the lens window %.2f..%.2f: %s",
|
||||
wid, lo, hi, out)
|
||||
# The tail the wizard streams after the body: the lens back on the
|
||||
# reference the program's head declared, the head back to X0 Y0, the
|
||||
# G92 origin cleared (the controller keeps a G92 offset and restores it
|
||||
# at its next start, where it would shift a sender's own work offsets),
|
||||
# and the program end.
|
||||
lines = [ln.strip() for ln in (body or b"").decode("utf-8", "replace").splitlines()]
|
||||
tail = [ln for ln in lines if ln and not ln.startswith(";")][-4:]
|
||||
edge = lens.get("edge_z")
|
||||
want = ["G0 Z%.2f" % edge, "G0 X0 Y0", "G92.1", "M2"] if isinstance(edge, (int, float)) else None
|
||||
ctx.evidence.setdefault("program_tail", {})[wid] = tail
|
||||
ctx.check(tail == want, "the %s program ends %s, not the tail %s", wid, tail, want)
|
||||
|
||||
|
||||
def emission_ok(ctx, last, seen):
|
||||
"""The two witnesses of the burn, from the wizard's result and from
|
||||
the test's own reads of them at every poll (`seen`, a Witnesses).
|
||||
The head's beam sensor is recorded, never required: the result
|
||||
reports its rise and whether it read as the beam seen."""
|
||||
e = (last.get("result") or {}).get("emission") or {}
|
||||
ctx.evidence["emission"] = e
|
||||
ctx.check(e.get("hv_max", 0) > HV_ON, "the tube current never rose (%s)", e.get("hv_max"))
|
||||
ctx.check(e.get("laser_on_samples", 0) > 0, "the kernel never sampled LASER_ON")
|
||||
ctx.check(isinstance(e.get("thermopile_delta"), int) and isinstance(e.get("beam_sensor_seen"), bool),
|
||||
"the result does not record the beam sensor: %s", e)
|
||||
ctx.log("the beam sensor rose %s (seen: %s)", e.get("thermopile_delta"), e.get("beam_sensor_seen"))
|
||||
w = seen.record()
|
||||
ctx.check(w["reads"] > 0, "the test could not read the witnesses from sysfs during the burn")
|
||||
ctx.check((w["laser_on_max"] or 0) > 0,
|
||||
"the kernel's LASER_ON count never read nonzero from here during the burn (%d reads); the "
|
||||
"burn reports %s", w["reads"], e.get("laser_on_samples"))
|
||||
ctx.check((w["hv_max"] or 0) > HV_ON,
|
||||
"the tube current never read over %d from here during the burn (peak %s over %d reads); the "
|
||||
"burn reports %s", HV_ON, w["hv_max"], w["reads"], e.get("hv_max"))
|
||||
|
||||
|
||||
def thickness_answer(ctx):
|
||||
"""The thickness the burn is answered with, in the machine's units
|
||||
(the number prompts read ui_units), and the millimeters the record
|
||||
must carry for it."""
|
||||
imperial = (ctx.forgectrl.settings() or {}).get("ui_units") == "imperial"
|
||||
return ("0.125", 3.175) if imperial else ("3.2", 3.2)
|
||||
|
||||
|
||||
def burn(ctx, wid, arm, pick, want):
|
||||
"""One burn: the preview, then the burn after the press with every
|
||||
prompt answered (the gantry stops installed, the head at home, the
|
||||
scrap in the corner, its thickness in the machine's units, the arm,
|
||||
and the pick in `pick`), the witnesses (the burn's own, and the two
|
||||
read from sysfs here at every poll), and the keys of the result in
|
||||
`want`. run_check makes the arm press when the burn asks for it. A
|
||||
prompt the test does not answer, or one asked again because its
|
||||
answer was refused, ends the burn. Returns the result."""
|
||||
answer, want_mm = thickness_answer(ctx)
|
||||
ctx.evidence["thickness_answer"] = answer
|
||||
answers = {"gantry-stops": "Yes", "home": HOME, "scrap": "Continue", "thickness": answer,
|
||||
arm: "Continue"}
|
||||
answers.update(pick)
|
||||
preview_ok(ctx, wid)
|
||||
asked = set()
|
||||
seen = Witnesses()
|
||||
|
||||
def on_prompt(p):
|
||||
pid = p.get("id")
|
||||
if pid == "press":
|
||||
return None
|
||||
if pid not in answers:
|
||||
# the motors found released ask for the head pushed home by
|
||||
# hand, which the bench's head never needs
|
||||
ctx.fail("the %s burn asked %s, which the test does not answer: %s", wid, pid, p.get("text"))
|
||||
if pid in asked:
|
||||
ctx.fail("the %s burn asked %s again: it refused %r", wid, pid, answers[pid])
|
||||
asked.add(pid)
|
||||
return answers[pid]
|
||||
|
||||
try:
|
||||
last = run_check(ctx, wid, on_prompt, 900, on_poll=seen)
|
||||
finally:
|
||||
ctx.clear_notice()
|
||||
# What the test read itself goes on record however the burn ends:
|
||||
# reported dark, failed, timed out, or aborted.
|
||||
w = seen.record()
|
||||
ctx.evidence["emission_seen"] = w
|
||||
ctx.log("seen from here: LASER_ON count peak %s, tube current peak %s over %d polls",
|
||||
w["laser_on_max"], w["hv_max"], w["reads"])
|
||||
emission_ok(ctx, last, seen)
|
||||
r = last.get("result") or {}
|
||||
missing = [k for k in want if k not in r]
|
||||
ctx.check(not missing, "the result lacks %s: %s", missing, sorted(r))
|
||||
ctx.check(abs((r.get("thickness_mm") or 0) - want_mm) < 0.01,
|
||||
"thickness %s mm, expected %s (answered %s)", r.get("thickness_mm"), want_mm, answer)
|
||||
ctx.check(len(r.get("z_passes") or []) >= 1, "the lens was not referenced: %s", r.get("z_passes"))
|
||||
return r
|
||||
|
||||
|
||||
def window_held(ctx):
|
||||
"""The lens window the machine holds: the two settings, and the lens
|
||||
block of /status, which reads them (the fallback where they are
|
||||
unset)."""
|
||||
fc = ctx.forgectrl
|
||||
s = fc.settings() or {}
|
||||
lens = (fc.status() or {}).get("lens") or {}
|
||||
return {"settings": {k: s.get(k) for k in LENS_WINDOW_KEYS},
|
||||
"window": (lens.get("below"), lens.get("above")), "stops_found": lens.get("stops_found")}
|
||||
|
||||
|
||||
def focus_window_ok(ctx, r, before):
|
||||
"""The ladder burned in the window the machine held before the burn
|
||||
(`before`, from window_held): the Lens travel check's, or the fallback
|
||||
while the settings are unset. The burn left that window as it found
|
||||
it and wrote the edge alone, the one its result reports; every ladder
|
||||
height lies in the reach, and the program served now agrees with
|
||||
/status."""
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
w = r.get("window") or {}
|
||||
window = (w.get("below"), w.get("above"))
|
||||
after = window_held(ctx)
|
||||
ctx.check(after["settings"] == before["settings"], "the focus burn wrote the lens window: %s, was %s",
|
||||
after["settings"], before["settings"])
|
||||
ctx.check(window == before["window"], "the ladder ran in %s/%s, the machine holds %s",
|
||||
window[0], window[1], before["window"])
|
||||
held = before["settings"]
|
||||
if before["stops_found"]:
|
||||
ctx.check(tuple(str(held[k]) for k in LENS_WINDOW_KEYS) == tuple(str(v) for v in window),
|
||||
"the ladder ran in %s/%s, the settings hold %s", window[0], window[1], held)
|
||||
else:
|
||||
ctx.check(window == FALLBACK_WINDOW, "the settings hold no window (%s), but the ladder ran in %s/%s",
|
||||
held, window[0], window[1])
|
||||
edge = (fc.settings() or {}).get(EDGE_KEY)
|
||||
try:
|
||||
edge_ok = abs(float(edge) - float(r.get("edge_z_mm"))) < 0.006
|
||||
except (TypeError, ValueError):
|
||||
edge_ok = False
|
||||
ctx.check(edge_ok, "%s reads %r, the burn reports %s", EDGE_KEY, edge, r.get("edge_z_mm"))
|
||||
zs = r.get("ladder_z") or []
|
||||
lo, hi = w.get("reach_min"), w.get("reach_max")
|
||||
out = [z for z in zs if lo is None or hi is None or z < lo - 0.01 or z > hi + 0.01]
|
||||
ctx.check(len(zs) == 12 and not out, "ladder heights outside the reach %s..%s: %s", lo, hi, out or zs)
|
||||
first = dict(ev.get("program_z") or {}).get("laser.focus")
|
||||
preview_ok(ctx, "laser.focus")
|
||||
ev["focus_window"] = {"window": w, "ladder_z": zs, "held": before, "edge": edge,
|
||||
"program_z_before": first,
|
||||
"program_z_after": (ev.get("program_z") or {}).get("laser.focus"),
|
||||
"preview_bytes": ev.pop("preview_bytes", None),
|
||||
"program_lines": ev.pop("program_lines", None)}
|
||||
|
||||
|
||||
@test("setup.focus", title="The focus burn: the ladder in the lens window, and the edge it writes",
|
||||
subsystem="setup", kind="live", hardware="takeover", mode="grbl", est_min=10,
|
||||
covers=BURN_COVERS,
|
||||
requires=["forgectrl.auth", "setup.check-motion", "laser.emission-witness", "setup.check-lens"],
|
||||
actions=["button"], hands=["scrap"],
|
||||
steps=["The gantry stops installed and the head at its home against them. A fresh flat scrap "
|
||||
"of wood about 3 mm (1/8 in) thick and at least 78 x 68 mm (3.1 x 2.7 in), pushed into "
|
||||
"the back-left corner of the cut area: as far left as it goes, its top edge at the top "
|
||||
"of the cut area; lid closed. Eye protection on, exhaust on, extinguisher in reach.",
|
||||
"Start the test and press the machine's button once. That press says you are at the "
|
||||
"machine, and the bench actuator makes the arm press after it, when the burn itself "
|
||||
"asks for it. With no actuator the start is the Ready answer instead, and you press "
|
||||
"the button again at the arm, as the page asks.",
|
||||
"Nothing else: the test answers every prompt itself and puts back the setting the "
|
||||
"burn writes."],
|
||||
description="The focus burn as a fresh head runs it. Before anything starts, the head's "
|
||||
"place is read from /status and recorded: judged against the gantry stops when "
|
||||
"a manual home references X and Y (where that home declared them: X0 Y0, or "
|
||||
"minus the manual_home_x and manual_home_y it read), else left to the "
|
||||
"operator's word at the ready gate. The "
|
||||
"edge setting is cleared, so the burn starts from the bench reference's edge, "
|
||||
"while the lens window stays the one the Lens travel check wrote (or the "
|
||||
"fallback). POST "
|
||||
"/wiz/laser.focus/start is answered that the gantry stops are installed, "
|
||||
"references the lens, takes the controller in loopback posture, is answered that "
|
||||
"the head is home and that the scrap is in the corner, and takes the thickness "
|
||||
"in the machine's units (3.2 mm or 0.125 in); its program declares X0 Y0 at the "
|
||||
"head, and its tail puts the lens back on the reference, returns the head there, "
|
||||
"clears the G92 origin (G92.1), and ends (M2). With its preview and program "
|
||||
"served by GET /wiz/sheet.svg and /wiz/sheet.gcode, every Z the program commands "
|
||||
"inside the reach /status reports, and the program ending in that tail at the "
|
||||
"edge /status reports, it burns twelve lines after the press, from the top of "
|
||||
"the window to the bottom, seen by the two witnesses (the tube current and the "
|
||||
"kernel's LASER_ON samples) as the burn reports them and as the test reads them "
|
||||
"from sysfs itself at every poll, with the beam sensor's rise recorded and not "
|
||||
"required. The pick is line 11. The "
|
||||
"result carries its keys; the ladder ran in the window the machine held, which "
|
||||
"the burn leaves as found (it writes the edge alone, the one its result "
|
||||
"reports); every ladder height lies in the reach; the program served afterward "
|
||||
"agrees with /status; the record carries laser.focus at version 1. The edge and "
|
||||
"the window are restored at the end. grblHAL reads them only at its start, and "
|
||||
"the burn started it before the restore, so the controller is stopped and "
|
||||
"started again on the settings as found (on every exit path), and it must be "
|
||||
"running with motion verified under a new process.")
|
||||
def focus(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
restart = {}
|
||||
|
||||
def restart_on_found():
|
||||
"""A fresh controller start on the settings as found, once the
|
||||
burn has ended. Plain waits, not the run's, so an abort gets it
|
||||
too; a burn still ending starts its own controller after the
|
||||
restore, which reads them already."""
|
||||
try:
|
||||
t_end = time.time() + 120
|
||||
while time.time() < t_end and dark(fc).get("running"):
|
||||
time.sleep(1)
|
||||
if dark(fc).get("running"):
|
||||
restart["skipped"] = "the burn is still ending"
|
||||
ctx.log("the burn is still ending: its own controller start reads the settings as found")
|
||||
return
|
||||
st, m = fc.get("/mode")
|
||||
m = m if isinstance(m, dict) else {}
|
||||
restart.update(mode=m.get("mode"), pid=m.get("pid"))
|
||||
if m.get("mode") != "grbl":
|
||||
return # the cloud client reads neither the edge nor the window
|
||||
restart["stop"], body = fc.post("/controller/stop")
|
||||
ctx.log("restarting the controller on the edge and the window as found: POST "
|
||||
"/controller/stop -> %s %s", restart["stop"], body)
|
||||
for _ in range(10):
|
||||
restart["start"], body = fc.post("/controller/start")
|
||||
if restart["start"] != 409:
|
||||
break
|
||||
time.sleep(1) # the burn's lease goes just after its end
|
||||
ctx.log("POST /controller/start -> %s %s", restart["start"], body)
|
||||
except hw.HwError as e:
|
||||
restart["error"] = str(e)
|
||||
ctx.log("the controller could not be restarted on the settings as found: %s", e)
|
||||
ctx.ready("The focus burn: twelve lines on the scrap after a press. The bench presses when the "
|
||||
"button lights.")
|
||||
head_at_home(ctx)
|
||||
try:
|
||||
with Restore(ctx, [EDGE_KEY] + LENS_WINDOW_KEYS):
|
||||
# A fresh head's focus: the edge unknown, so the burn runs from the
|
||||
# bench reference's number and writes the head's own. The window
|
||||
# stays: it is the Lens travel check's.
|
||||
st, _ = fc.post("/settings", params={EDGE_KEY: ""})
|
||||
ctx.check(st == 200, "clearing %s -> %s", EDGE_KEY, st)
|
||||
before = window_held(ctx)
|
||||
ev["lens_fresh"] = (fc.status() or {}).get("lens") or {}
|
||||
r = burn(ctx, "laser.focus", "focus-arm", {"focus-pick": "11"}, FOCUS_KEYS)
|
||||
ctx.check(r.get("pick") == 11 and r.get("picked_lines") == [11, 11],
|
||||
"the pick reads %s (lines %s), answered 11", r.get("pick"), r.get("picked_lines"))
|
||||
ctx.check("stops" not in r, "the focus burn searched for the stops: %s", r.get("stops"))
|
||||
focus_window_ok(ctx, r, before)
|
||||
ctx.log("focus: line %s, the edge at %s mm, the window %s/%s", r.get("pick"), r.get("edge_z_mm"),
|
||||
(r.get("window") or {}).get("below"), (r.get("window") or {}).get("above"))
|
||||
finally:
|
||||
restart_on_found()
|
||||
ev["restart"] = dict(restart)
|
||||
if restart.get("mode") == "grbl":
|
||||
ctx.check(restart.get("stop") == 200 and restart.get("start") == 200,
|
||||
"the controller was not restarted on the settings as found: %s", restart)
|
||||
|
||||
def back():
|
||||
st, m = fc.get("/mode")
|
||||
m = m if isinstance(m, dict) else {}
|
||||
return (m.get("controller") == "running" and m.get("motion") == "verified"
|
||||
and (restart.get("start") != 200 or m.get("pid") != restart.get("pid")))
|
||||
ok = ctx.wait_for(back, 150)
|
||||
ctx.check(ok is not None, "the controller did not come back on the settings as found")
|
||||
ctx.log("PASS: the focus burn ran in the window the machine holds and wrote the edge alone; "
|
||||
"settings restored and the controller restarted on them")
|
||||
@@ -1,455 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""setup.check-* - the setup's checks (the dark wizards), driven the
|
||||
way the page drives them: POST /wiz/<id>/start, GET /wiz/dark polled,
|
||||
the prompts answered from here (the bench fixture works the lid and the
|
||||
button; a confirmation is answered yes once the snapshot exists), the
|
||||
result judged, and every setting a check wrote put back as found. Each
|
||||
check re-runs on the real record: a completed check completes again at
|
||||
the same version, so the record reads as before.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from ..catalog import test
|
||||
from .. import hw
|
||||
from ..baseline import read_position
|
||||
from .setup import wiz, Restore, INTAKE_KEY
|
||||
|
||||
POLL_S = 1.0
|
||||
# What the machine's own press prompt says, used when it sends no text.
|
||||
PRESS_TEXT = "The button is lit white. Press it now: the laser fires after your press."
|
||||
# How long the purge fan's current is given to come up after the airflow
|
||||
# check switches it back on. The bench reference draws its off current
|
||||
# (74) for the first seconds and settles near 630.
|
||||
PURGE_SPINUP_S = 30
|
||||
# The checks' press prompts read the button (button.c) and light it
|
||||
# (led.c) on the machine; the switches check reads both back.
|
||||
DARK_COVERS = [("forgectrl", "src/wizdark.*"), ("forgectrl", "src/wizcalc.*"),
|
||||
("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"),
|
||||
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*"),
|
||||
("forgectrl", "src/button.*"), ("forgectrl", "src/led.*")]
|
||||
# The lens window: the free half-steps below and above the hall edge the
|
||||
# Lens travel check writes, and the fallback the lens frame holds while
|
||||
# they are unset (lens.h).
|
||||
LENS_WINDOW_KEYS = ["lens_stop_below_steps", "lens_stop_above_steps"]
|
||||
FALLBACK_WINDOW = (10, 12)
|
||||
|
||||
|
||||
def intakes_counted():
|
||||
"""Precheck: the intake fan count is set. The setup gate holds every
|
||||
controller without it, and a test that puts the count back as found
|
||||
would put an unset count back and close the gate on the tests after
|
||||
it."""
|
||||
try:
|
||||
s = hw.Forgectrl().settings() or {}
|
||||
except hw.HwError as e:
|
||||
return "forgectrl unreachable: %s" % e
|
||||
if not s.get(INTAKE_KEY):
|
||||
return ("the intake fans are not counted, so the setup gate holds every controller; this "
|
||||
"test puts the count back as it finds it, and an uncounted machine handed back "
|
||||
"would hold the controllers of every test after it")
|
||||
return None
|
||||
|
||||
|
||||
def dark(fc):
|
||||
st, body = fc.get("/wiz/dark")
|
||||
if st != 200 or not isinstance(body, dict):
|
||||
raise hw.HwError("GET /wiz/dark -> %s" % st)
|
||||
return body
|
||||
|
||||
|
||||
def start(ctx, fc, wid):
|
||||
ev = ctx.evidence
|
||||
st, body = fc.post("/wiz/%s/start" % wid)
|
||||
if st == 409 and dark(fc).get("id") == wid and dark(fc).get("running"):
|
||||
# The same check left running by an earlier run of this test (an
|
||||
# abort mid-wait): this run owns the machine, so it ends that one
|
||||
# and starts its own.
|
||||
ctx.log("the %s check is still running from an earlier run: aborting it", wid)
|
||||
fc.post("/wiz/%s/abort" % wid)
|
||||
ctx.wait_for(lambda: not dark(fc).get("running"), 30)
|
||||
st, body = fc.post("/wiz/%s/start" % wid)
|
||||
ctx.log("POST /wiz/%s/start -> %s %s", wid, st, body if isinstance(body, dict) else "")
|
||||
ctx.check(st == 200, "the %s check did not start (%s %s)", wid, st, body)
|
||||
ev["started"] = st == 200
|
||||
|
||||
|
||||
def answer(fc, wid, prompt, value):
|
||||
return fc.post("/wiz/%s/answer" % wid, data={"seq": str(prompt["seq"]), "value": value})
|
||||
|
||||
|
||||
def run_check(ctx, wid, on_prompt, timeout_s, on_poll=None):
|
||||
"""Start `wid` and follow it to its end. on_prompt(prompt) is called
|
||||
once per prompt (by sequence number) and returns the value to answer
|
||||
with, or None when it handled the prompt another way (a fixture
|
||||
action). on_poll(doc), when given, is called once per poll of
|
||||
/wiz/dark with the status document: a caller's own witness read
|
||||
beside the check's. Returns the final status document."""
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
start(ctx, fc, wid)
|
||||
t0 = time.time()
|
||||
seen = set()
|
||||
phases = []
|
||||
last = None
|
||||
try:
|
||||
while time.time() - t0 < timeout_s:
|
||||
ctx.checkpoint()
|
||||
d = dark(fc)
|
||||
if d.get("id") != wid:
|
||||
raise hw.HwError("another check took the slot: %s" % d.get("id"))
|
||||
if on_poll is not None:
|
||||
on_poll(d)
|
||||
if d.get("phase") and (not phases or phases[-1] != d["phase"]):
|
||||
phases.append(d["phase"])
|
||||
ctx.log("%s: %s", wid, d["phase"])
|
||||
p = d.get("prompt")
|
||||
if p and p.get("seq") not in seen:
|
||||
seen.add(p["seq"])
|
||||
ctx.log("prompt %s (%s): %s", p.get("id"), p.get("kind"), p.get("text"))
|
||||
value = on_prompt(p)
|
||||
if value is not None:
|
||||
st, body = answer(fc, wid, p, value)
|
||||
ctx.check(st == 200, "the answer to %s -> %s %s", p.get("id"), st, body)
|
||||
elif p.get("id") == "press":
|
||||
# The arm press, asked for by the machine itself: the
|
||||
# check opens this prompt when the button is lit and
|
||||
# the tube is still dark. Nothing answers it - the
|
||||
# press does, and the discharge closes it.
|
||||
ctx.press_now(p.get("text") or PRESS_TEXT)
|
||||
if not d.get("running"):
|
||||
last = d
|
||||
break
|
||||
time.sleep(POLL_S)
|
||||
except BaseException:
|
||||
# An abort or a failure on the way out leaves no check running
|
||||
# behind this test: the next run must be able to start its own.
|
||||
fc.post("/wiz/%s/abort" % wid)
|
||||
raise
|
||||
ev["elapsed_s"] = round(time.time() - t0, 1)
|
||||
ev["phases"] = phases[-12:]
|
||||
if last is None:
|
||||
# A check this test stops following is aborted before the test
|
||||
# fails: no check runs on behind the FAIL.
|
||||
fc.post("/wiz/%s/abort" % wid)
|
||||
ctx.check(last is not None, "the %s check did not end within %d s", wid, timeout_s)
|
||||
ev["log"] = last.get("log", [])[-12:]
|
||||
ev["error"] = last.get("error")
|
||||
ev["result"] = last.get("result")
|
||||
ctx.check(not last.get("error"), "the %s check failed: %s", wid, last.get("error"))
|
||||
ctx.check(isinstance(last.get("result"), dict), "the %s check ended without a result", wid)
|
||||
w = wiz(fc)
|
||||
ctx.check((w.get("versions") or {}).get(wid) == 1, "the record does not carry %s at version 1", wid)
|
||||
return last
|
||||
|
||||
|
||||
@test("setup.check-switches", title="The switches check follows the lid and the button",
|
||||
subsystem="setup", kind="operator", est_min=3,
|
||||
covers=DARK_COVERS + [("forgectrl", "src/status.c")],
|
||||
requires=["forgectrl.auth"], actions=["lid", "button", "interlock"],
|
||||
description="POST /wiz/switches/start; the check asks for the lid to open and close and for "
|
||||
"a press, each as a 'wait' prompt that the switch edge itself answers; the bench "
|
||||
"fixture works the lid and the button. On a Basic or Plus the interlock loop reads "
|
||||
"satisfied without a prompt. The result says lid, button, head present, and "
|
||||
"whether the HV enable readback followed the lid; the record carries switches at "
|
||||
"version 1.")
|
||||
def check_switches(ctx):
|
||||
def on_prompt(p):
|
||||
pid = p.get("id", "")
|
||||
if pid in ("lid-open", "lid-close-first", "lid-close"):
|
||||
ctx.act("lid", "open" if pid == "lid-open" else "close",
|
||||
until=lambda: (dark(ctx.forgectrl).get("prompt") or {}).get("seq") != p["seq"],
|
||||
text=p.get("text", ""))
|
||||
elif pid == "button-press":
|
||||
ctx.act("button", "press",
|
||||
until=lambda: (dark(ctx.forgectrl).get("prompt") or {}).get("id") != "button-press",
|
||||
text=p.get("text", ""))
|
||||
elif pid == "button-release":
|
||||
pass # the fixture's press releases on its own
|
||||
elif pid.startswith("interlock-"):
|
||||
ctx.act("interlock", "open" if pid == "interlock-open" else "close",
|
||||
until=lambda: (dark(ctx.forgectrl).get("prompt") or {}).get("seq") != p["seq"],
|
||||
text=p.get("text", ""))
|
||||
return None
|
||||
last = run_check(ctx, "switches", on_prompt, 600)
|
||||
r = last.get("result") or {}
|
||||
ctx.check(r.get("lid") is True and r.get("button") is True, "lid or button not proven: %s", r)
|
||||
ctx.check(r.get("head_present") is True, "the head did not read present: %s", r)
|
||||
ctx.log("switches: %s", json.dumps(r))
|
||||
|
||||
|
||||
@test("setup.check-airflow", title="The airflow check measures the fans and sets the floors",
|
||||
subsystem="setup", kind="auto", hardware="takeover", est_min=3,
|
||||
covers=DARK_COVERS + [("forgectrl", "src/cool.c"), ("forgectrl", "src/airflow.*"),
|
||||
("forgectrl", "src/gates.c"), ("forgectrl", "src/super.c")],
|
||||
requires=["forgectrl.auth"], precheck=intakes_counted,
|
||||
description="POST /wiz/airflow/start: the controller stops, the fans run at the cut profile "
|
||||
"for 35 s, the purge is measured on and off, and the five floors are written: the "
|
||||
"four at 55 percent of steady and the grace from the slowest spin-up. The intake "
|
||||
"fan count is the machine's: intake 2 turning proves two fans, both intakes are "
|
||||
"judged, and the count is written as 2; intake 2 silent on a machine counted with "
|
||||
"one is reported not fitted and not turning, intake 1 alone sets the intake "
|
||||
"floor, and the count is not written; intake 2 silent on a machine counted with "
|
||||
"two fails the check. Both intakes turn on the bench reference, so the bench "
|
||||
"path writes 2. Each fan judged must read at least 1000 rpm. The settings are put "
|
||||
"back as found at the end, the purge fan is on again and drawing over the floor "
|
||||
"the check just wrote, and the controller must be running again.")
|
||||
def check_airflow(ctx):
|
||||
keys = ["cool_tach_exhaust_min_rpm", "cool_tach_intake_min_rpm", "cool_tach_air_assist_min_rpm",
|
||||
"cool_purge_min_current", "cool_fan_grace_s"]
|
||||
with Restore(ctx, keys + ["cool_intake_fans"]) as held:
|
||||
counted = held.before.get("cool_intake_fans")
|
||||
last = run_check(ctx, "airflow", lambda p: None, 240)
|
||||
r = last.get("result") or {}
|
||||
fans = r.get("fans") or {}
|
||||
intakes = r.get("intake_fans")
|
||||
silent2 = (fans.get("intake 2") or {}).get("turning") is False
|
||||
ctx.evidence["intakes"] = {"counted": counted, "found": intakes, "intake_2_silent": silent2}
|
||||
ctx.check(intakes in (1, 2), "the result carries no intake fan count: %r", intakes)
|
||||
if silent2:
|
||||
# one intake fan, as the machine is counted (on a machine
|
||||
# counted with two the check itself fails, and run_check says so)
|
||||
ctx.check(counted == "1" and intakes == 1,
|
||||
"intake 2 gave no reading on a machine counted with %r intake fans, and the check "
|
||||
"found %r", counted, intakes)
|
||||
else:
|
||||
ctx.check(intakes == 2, "intake 2 turned, and the check found %r intake fans", intakes)
|
||||
ctx.log("intake fans: %s (the machine counted %s)", intakes, counted)
|
||||
for name in ("exhaust", "intake 1", "intake 2", "air assist"):
|
||||
f = fans.get(name) or {}
|
||||
if name == "intake 2" and silent2:
|
||||
ctx.check(f == {"fitted": False, "turning": False}, "intake 2 with one intake fan: %s", f)
|
||||
continue
|
||||
ctx.check(f.get("ok") is True, "%s: %s", name, f)
|
||||
ctx.log("%s: %s rpm steady, 90 percent at %s s", name, f.get("steady_rpm"), f.get("spinup_s"))
|
||||
floors = r.get("floors") or {}
|
||||
for k in keys:
|
||||
ctx.check(k in floors, "no floor for %s: %s", k, floors)
|
||||
s = ctx.forgectrl.settings() or {}
|
||||
for k in keys:
|
||||
ctx.check(s.get(k) == floors.get(k), "%s reads %r, the check wrote %r", k, s.get(k), floors.get(k))
|
||||
# The count: 2 written when intake 2 turned; left as counted when it
|
||||
# was silent.
|
||||
want = counted if silent2 else "2"
|
||||
ctx.check(s.get("cool_intake_fans") == want,
|
||||
"cool_intake_fans reads %r, expected %r (intake 2 %s, the machine counted %r)",
|
||||
s.get("cool_intake_fans"), want, "silent" if silent2 else "turning", counted)
|
||||
ctx.log("floors: %s", json.dumps(floors))
|
||||
# The check switches purge air off to read its off current. Leaving
|
||||
# it off costs the next job an airflow hold mid-cut, judged against
|
||||
# the very floor written above, and nothing puts it back until the
|
||||
# daemon restarts: the engine's idle phase never re-applies its own
|
||||
# duties. Prove the machine is whole, not just measured.
|
||||
purge_on = ctx.sysfs("head/purge_air")
|
||||
ctx.check(purge_on == "1", "purge air reads %r after the check, expected 1 (on)", purge_on)
|
||||
|
||||
def purge_gate():
|
||||
return ((ctx.forgectrl.get("/cool/status")[1] or {}).get("fan_gates") or {}).get("purge") or {}
|
||||
|
||||
# The check read its off current with the fan off, and the stand-down
|
||||
# commands it back on. The current follows the command, it does not
|
||||
# arrive with it: read in the same second and what comes back is the
|
||||
# off current the check just measured. Wait for the draw the way the
|
||||
# controller below is waited for.
|
||||
took = ctx.wait_for(lambda: (purge_gate().get("reading") or 0) >= (purge_gate().get("floor") or 0),
|
||||
PURGE_SPINUP_S)
|
||||
gate = purge_gate()
|
||||
ctx.evidence["purge_after"] = {"purge_air": purge_on, "gate": gate, "took_s": took}
|
||||
ctx.log("purge after the check: commanded %s, drawing %s against the %s floor (%s)",
|
||||
purge_on, gate.get("reading"), gate.get("floor"),
|
||||
"after %.1f s" % took if took is not None else "never reached the floor")
|
||||
ctx.check(took is not None,
|
||||
"purge draws %s after %d s, under the %s floor the check just wrote: the next job "
|
||||
"would be held", gate.get("reading"), PURGE_SPINUP_S, gate.get("floor"))
|
||||
ok = ctx.wait_for(lambda: (ctx.forgectrl.get("/mode")[1] or {}).get("controller") == "running", 60)
|
||||
ctx.check(ok is not None, "the controller did not come back after the check")
|
||||
|
||||
|
||||
@test("setup.check-motion", title="The motion check proves the rail, the lens reference, and the jogs",
|
||||
subsystem="setup", kind="auto", hardware="takeover", est_min=5,
|
||||
covers=DARK_COVERS + [("forgectrl", "src/super.c"), ("forgectrl", "src/liveness.c"),
|
||||
("forgectrl", "src/lenshome.*"),
|
||||
("forgectrl", "src/accel.c"), ("forgectrl", "src/cool.c")],
|
||||
requires=["forgectrl.auth", "motion.pacing"],
|
||||
description="POST /wiz/motion/start: the controller stops, the liveness probe runs and the "
|
||||
"lens takes its hall-edge reference behind it (a lens that cannot reach its "
|
||||
"edge is a motion fault, and no controller starts), the lens "
|
||||
"finds the hall reference in five agreeing passes, the controller comes back in "
|
||||
"loopback posture, and the head jogs 50 mm each way on X and Y with the "
|
||||
"accelerometer as the witness. The one prompt (the jogs are about to move) is "
|
||||
"answered Continue. The result carries the probe line, the passes, and a "
|
||||
"witnessed reading for each jog; the controller must be back in its normal "
|
||||
"posture, and the kernel position counters must read what they read before: "
|
||||
"every move played to its end (the probe's return leg under the cooling "
|
||||
"engine's dead-man, the last jog under the controller stop).")
|
||||
def check_motion(ctx):
|
||||
fc = ctx.forgectrl
|
||||
before = read_position()
|
||||
ctx.notice("The head moves 50 mm each way on X and on Y. Keep the bed clear.")
|
||||
last = run_check(ctx, "motion", lambda p: "Continue" if p.get("id") == "jogs" else None, 420)
|
||||
ctx.clear_notice()
|
||||
r = last.get("result") or {}
|
||||
ctx.check(r.get("z_referenced") is True, "the lens was not referenced: %s", r)
|
||||
ctx.check(len(r.get("z_passes") or []) == 5, "not five reference passes: %s", r.get("z_passes"))
|
||||
moves = r.get("moves") or {}
|
||||
for name in ("+X", "-X", "+Y", "-Y"):
|
||||
m = moves.get(name) or {}
|
||||
ctx.check(m.get("witnessed") is True, "the %s jog was not witnessed: %s", name, m)
|
||||
ok = ctx.wait_for(lambda: (fc.get("/mode")[1] or {}).get("local") is False
|
||||
and (fc.get("/mode")[1] or {}).get("controller") == "running", 90)
|
||||
ctx.check(ok is not None, "the controller is not back in its normal posture")
|
||||
after = read_position()
|
||||
if before is not None and after is not None:
|
||||
ctx.check(all(abs(a - b) <= 2 for a, b in zip(after[:2], before[:2])),
|
||||
"the check lost motion: position %s before, %s after (steps)", before, after)
|
||||
ctx.log("motion: probe '%s', passes %s, rest %s, moves %s", r.get("probe"), r.get("z_passes"),
|
||||
r.get("rest"), {k: (v.get("p2p_lp_x"), v.get("p2p_lp_y")) for k, v in moves.items()})
|
||||
|
||||
|
||||
@test("setup.check-lens", title="The lens travel check finds the stops and writes the window",
|
||||
subsystem="setup", kind="auto", hardware="takeover", est_min=4,
|
||||
covers=DARK_COVERS + [("forgectrl", "src/lensfind.*"), ("forgectrl", "src/lens.*"),
|
||||
("forgectrl", "src/lenshome.*"), ("forgectrl", "src/accel.c"),
|
||||
("forgectrl", "src/cool.c"), ("forgectrl", "src/super.c")],
|
||||
requires=["forgectrl.auth", "setup.check-motion"],
|
||||
description="POST /wiz/lens.travel/start: the controller stops, the lens takes its hall-edge "
|
||||
"reference, the fans stop and the head accelerometer listens while the lens "
|
||||
"steps to each stop and back (nothing fires), the two window settings are "
|
||||
"written, and the controller comes back. No question is asked. The result "
|
||||
"carries the reference passes, the stops (found, the free half-steps each way, "
|
||||
"the contact counts, or why not), and the window: the stops found, or the "
|
||||
"fallback (10 half-steps below, 12 above) when they were not. The settings and "
|
||||
"the lens block of /status hold that window, the record carries lens.travel at "
|
||||
"version 1, and the two settings are put back as found. grblHAL reads the "
|
||||
"window only at its start, and the check started it before the put-back, so in "
|
||||
"GRBL mode the controller is stopped and started again on the window as found "
|
||||
"(on every exit path), and it must be running with motion verified under a new "
|
||||
"process.")
|
||||
def check_lens(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
restart = {}
|
||||
|
||||
def on_prompt(p):
|
||||
ctx.fail("the lens travel check asked a question: %s", p)
|
||||
|
||||
def restart_on_found():
|
||||
"""A fresh controller start on the settings as found, once the
|
||||
check has ended. Plain waits, not the run's, so an abort gets it
|
||||
too; a check still ending starts its own controller after the
|
||||
put-back, which reads them already."""
|
||||
try:
|
||||
t_end = time.time() + 120
|
||||
while time.time() < t_end and dark(fc).get("running"):
|
||||
time.sleep(1)
|
||||
if dark(fc).get("running"):
|
||||
restart["skipped"] = "the check is still ending"
|
||||
ctx.log("the check is still ending: its own controller start reads the settings as found")
|
||||
return
|
||||
st, m = fc.get("/mode")
|
||||
m = m if isinstance(m, dict) else {}
|
||||
restart.update(mode=m.get("mode"), pid=m.get("pid"))
|
||||
if m.get("mode") != "grbl":
|
||||
return # the cloud client reads no lens window
|
||||
restart["stop"], body = fc.post("/controller/stop")
|
||||
ctx.log("restarting the controller on the window as found: POST /controller/stop -> %s %s",
|
||||
restart["stop"], body)
|
||||
for _ in range(10):
|
||||
restart["start"], body = fc.post("/controller/start")
|
||||
if restart["start"] != 409:
|
||||
break
|
||||
time.sleep(1) # the check's lease goes just after its end
|
||||
ctx.log("POST /controller/start -> %s %s", restart["start"], body)
|
||||
except hw.HwError as e:
|
||||
restart["error"] = str(e)
|
||||
ctx.log("the controller could not be restarted on the window as found: %s", e)
|
||||
try:
|
||||
with Restore(ctx, LENS_WINDOW_KEYS):
|
||||
last = run_check(ctx, "lens.travel", on_prompt, 300)
|
||||
r = last.get("result") or {}
|
||||
ctx.check(len(r.get("z_passes") or []) >= 1, "the lens was not referenced: %s", r.get("z_passes"))
|
||||
stops, w = r.get("stops") or {}, r.get("window") or {}
|
||||
ctx.check(isinstance(stops.get("found"), bool), "the result files no stops: %s", r)
|
||||
window = (w.get("below"), w.get("above"))
|
||||
if stops.get("found"):
|
||||
ctx.check(window == (stops.get("below"), stops.get("above")) and w.get("found") is True,
|
||||
"the window %s is not the stops found %s", w, stops)
|
||||
else:
|
||||
ctx.check(window == FALLBACK_WINDOW and w.get("found") is False,
|
||||
"the stops were not found (%s), but the window is %s", stops.get("why"), w)
|
||||
s = fc.settings() or {}
|
||||
held = tuple(str(s.get(k)) for k in LENS_WINDOW_KEYS)
|
||||
lens = (fc.status() or {}).get("lens") or {}
|
||||
ev["window"] = {"stops": stops, "window": w, "settings": held, "status": lens}
|
||||
ctx.check(held == tuple(str(v) for v in window), "the settings hold %s, the check wrote %s",
|
||||
held, window)
|
||||
ctx.check((lens.get("below"), lens.get("above")) == window,
|
||||
"/status reads the window %s/%s, the check wrote %s", lens.get("below"), lens.get("above"),
|
||||
window)
|
||||
ctx.log("lens travel: %s half-steps below the edge, %s above (%s); contact %s/%s", window[0],
|
||||
window[1], "the stops found" if stops.get("found") else "the fallback: %s" % stops.get("why"),
|
||||
stops.get("contact_below"), stops.get("contact_above"))
|
||||
finally:
|
||||
restart_on_found()
|
||||
ev["restart"] = dict(restart)
|
||||
if restart.get("mode") == "grbl":
|
||||
ctx.check(restart.get("stop") == 200 and restart.get("start") == 200,
|
||||
"the controller was not restarted on the window as found: %s", restart)
|
||||
|
||||
def back():
|
||||
st, m = fc.get("/mode")
|
||||
m = m if isinstance(m, dict) else {}
|
||||
return (m.get("controller") == "running" and m.get("motion") == "verified"
|
||||
and (restart.get("start") != 200 or m.get("pid") != restart.get("pid")))
|
||||
ok = ctx.wait_for(back, 150)
|
||||
ctx.check(ok is not None, "the controller did not come back after the check")
|
||||
|
||||
|
||||
@test("setup.cloud-header-capture", title="The cloud header check takes one print's envelope",
|
||||
subsystem="setup", kind="operator", hardware="takeover", mode="grbl", est_min=8,
|
||||
covers=DARK_COVERS + [("forgectrl", "src/super.c"),
|
||||
("python3-gfhardware", "gfhardware/machine.py"),
|
||||
("python3-gfhardware", "forgefirm-app/gfcloud.py"),
|
||||
("python3-gfhardware", "forgefirm-app/ffmachine.py")],
|
||||
requires=["forgectrl.auth", "cloud.mode-switch"], hands=["workstation"],
|
||||
steps=["The Glowforge app open on the workstation, signed in to this machine. The test "
|
||||
"turns cloud mode on itself and puts it back.",
|
||||
"Once the app shows the machine online, place any small design on the bed image "
|
||||
"and press Print. That is the only action: the check takes the print's header and "
|
||||
"cancels the print before it arms. Do not press the machine's button.",
|
||||
"The machine returns to GRBL mode on its own."],
|
||||
description="POST /wiz/cloud.header/start with cloud mode on: the wizard writes the "
|
||||
"client's one-start capture marker, starts cloud mode in the wizard's posture, "
|
||||
"asks for a print, and takes the header the client writes to the run directory "
|
||||
"before it cancels the print. The result carries the tag count, the "
|
||||
"calibration-bearing tags, the limits, and the machine's own numbers; the log "
|
||||
"has the tagged block; the machine is back in GRBL mode with the marker gone.")
|
||||
def cloud_header_capture(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
with Restore(ctx, ["cloud_enabled", "homing_mode", "controller_mode"]):
|
||||
s = fc.settings() or {}
|
||||
if s.get("cloud_enabled") != "1":
|
||||
st, body = fc.post("/settings", data={"cloud_enabled": "1"})
|
||||
ctx.check(st == 200, "cloud_enabled=1 -> %s %s", st, body)
|
||||
ctx.log("cloud mode turned on for the test")
|
||||
ctx.notice("Print any small design from the Glowforge app once it shows the machine "
|
||||
"online. Do not press the machine's button.")
|
||||
last = run_check(ctx, "cloud.header",
|
||||
lambda p: "Continue" if p.get("id") == "print" else None, 900)
|
||||
ctx.clear_notice()
|
||||
r = last.get("result") or {}
|
||||
ctx.check(isinstance(r.get("tag_count"), int) and r["tag_count"] > 100,
|
||||
"the header carried %s tags", r.get("tag_count"))
|
||||
kept = r.get("calibration_tags") or {}
|
||||
ctx.check(len(kept) > 10, "only %d calibration-bearing tags kept", len(kept))
|
||||
ev["kept"] = sorted(kept)[:40]
|
||||
ctx.check(not os.path.exists("/run/gfcloud-capture"), "the capture marker is still down")
|
||||
ok = ctx.wait_for(lambda: (fc.get("/mode")[1] or {}).get("mode") == "grbl"
|
||||
and (fc.get("/mode")[1] or {}).get("controller") == "running", 120)
|
||||
ctx.check(ok is not None, "the machine did not return to GRBL mode")
|
||||
ctx.log("header: %d tags, %d kept; job %s", r.get("tag_count", 0), len(kept), r.get("job_id"))
|
||||
@@ -1,218 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The cipher forgectrl's HTTPS listener chooses, and where its records
|
||||
are sealed.
|
||||
|
||||
The listener puts ChaCha20-Poly1305 first, whatever the client prefers,
|
||||
and the image seals the records in the kernel: GnuTLS hands each
|
||||
connection's keys to the socket after the handshake. ChaCha20-Poly1305
|
||||
runs in the kernel's NEON code; AES-GCM, for a client without ChaCha20,
|
||||
runs its AES on the CAAM crypto engine and its GHASH in NEON. This test
|
||||
reads all of it from the outside: the cipher the listener picks for a
|
||||
client that prefers AES-GCM, the kernel's TLS counters, the crypto
|
||||
engine's interrupt, and the drivers the kernel resolved, with each
|
||||
connection carrying the same page, which must arrive identical to the
|
||||
plain-HTTP copy.
|
||||
|
||||
Its own module: a test's fingerprint holds the shared text of the module
|
||||
it lives in, so a test added to forgectrl.py would move the fingerprint
|
||||
of every test there.
|
||||
"""
|
||||
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import urllib.request
|
||||
|
||||
from ..catalog import test
|
||||
|
||||
OPENSSL = "/usr/bin/openssl"
|
||||
ENGINE_IRQ = "2101000.jr" # the CAAM's first job ring
|
||||
RECORD = 16384 # the largest TLS record's plaintext
|
||||
PAGE = "/" # the panel page: large, and the same bytes every time
|
||||
CHACHA_DRIVER = "rfc7539(chacha20-neon,poly1305-neon)"
|
||||
# A desktop browser's offer: AES-GCM first, because its CPU has AES instructions.
|
||||
DESKTOP_13 = "TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256"
|
||||
DESKTOP_12 = "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305"
|
||||
|
||||
|
||||
def _tls_stat():
|
||||
"""The kernel's TLS counters, by name."""
|
||||
out = {}
|
||||
with open("/proc/net/tls_stat") as f:
|
||||
for line in f:
|
||||
p = line.split()
|
||||
if len(p) == 2 and p[1].isdigit():
|
||||
out[p[0]] = int(p[1])
|
||||
return out
|
||||
|
||||
|
||||
def _passive_opens():
|
||||
"""The TCP connections the machine has accepted, on every port and both
|
||||
families (the TCP counters are shared)."""
|
||||
with open("/proc/net/snmp") as f:
|
||||
rows = [line.split() for line in f if line.startswith("Tcp:")]
|
||||
return int(rows[1][rows[0].index("PassiveOpens")])
|
||||
|
||||
|
||||
def _engine_irqs():
|
||||
with open("/proc/interrupts") as f:
|
||||
for line in f:
|
||||
p = line.split()
|
||||
if p and p[-1] == ENGINE_IRQ and len(p) > 1 and p[1].isdigit():
|
||||
return int(p[1])
|
||||
return None
|
||||
|
||||
|
||||
def _driver(name):
|
||||
"""The driver answering an algorithm name: the entry of that name with
|
||||
the highest priority in /proc/crypto."""
|
||||
best, drv, entry = -1, None, {}
|
||||
with open("/proc/crypto") as f:
|
||||
lines = f.read().splitlines() + [""]
|
||||
for line in lines:
|
||||
if not line.strip():
|
||||
if entry.get("name") == name and int(entry.get("priority", -1)) > best:
|
||||
best, drv = int(entry["priority"]), entry.get("driver")
|
||||
entry = {}
|
||||
continue
|
||||
k, _, v = line.partition(":")
|
||||
entry[k.strip()] = v.strip()
|
||||
return drv
|
||||
|
||||
|
||||
def _fetch(version, offer, work):
|
||||
"""GET the page over HTTPS on loopback with the given offer. Returns
|
||||
the suite, the status line, the body, the kernel counters' change,
|
||||
the crypto engine's interrupts during the connection, and how many
|
||||
other connections the machine accepted meanwhile."""
|
||||
req = os.path.join(work, "req")
|
||||
with open(req, "wb") as f:
|
||||
f.write(b"GET " + PAGE.encode() + b" HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n")
|
||||
opt = ["-tls1_3", "-ciphersuites", offer] if version == "1.3" else ["-tls1_2", "-cipher", offer]
|
||||
s0, i0, p0 = _tls_stat(), _engine_irqs(), _passive_opens()
|
||||
with open(req, "rb") as stdin:
|
||||
r = subprocess.run([OPENSSL, "s_client", "-connect", "127.0.0.1:443", "-brief", "-ign_eof"] + opt,
|
||||
stdin=stdin, capture_output=True, timeout=60)
|
||||
s1, i1, p1 = _tls_stat(), _engine_irqs(), _passive_opens()
|
||||
info = {}
|
||||
for line in r.stderr.decode("utf-8", "replace").splitlines():
|
||||
k, _, v = line.partition(":")
|
||||
info[k.strip()] = v.strip()
|
||||
head, _, body = r.stdout.partition(b"\r\n\r\n")
|
||||
delta = {k: s1.get(k, 0) - s0.get(k, 0) for k in ("TlsTxSw", "TlsRxSw", "TlsDecryptError")}
|
||||
return (info.get("Ciphersuite"), head.split(b"\r\n")[0].decode("latin-1"), body, delta,
|
||||
(i1 or 0) - (i0 or 0), p1 - p0 - 1)
|
||||
|
||||
|
||||
@test("forgectrl.tls-records", title="HTTPS: ChaCha20 chosen, records sealed in the kernel",
|
||||
subsystem="forgectrl", kind="auto", est_min=1,
|
||||
covers=[("forgectrl", "src/tls.*"), ("forgectrl", "src/main.c")],
|
||||
description="forgectrl's HTTPS listener chooses the cipher, ChaCha20-Poly1305 first, and the "
|
||||
"image seals the records in the kernel: GnuTLS hands each connection's keys to the "
|
||||
"socket after the handshake (/etc/gnutls/config turns it on). A client that lists "
|
||||
"AES-GCM first, as a desktop browser does, gets ChaCha20-Poly1305 over TLS 1.3 and "
|
||||
"TLS 1.2, and the kernel takes both directions' keys; the kernel's rfc7539 is the "
|
||||
"NEON ChaCha20 and Poly1305. A client offering AES-GCM alone gets it, the kernel "
|
||||
"takes its keys, and the crypto engine's interrupt counts the records: the AES runs "
|
||||
"on the CAAM (gcm(aes) is its ctr(aes) with the NEON GHASH), which writes each "
|
||||
"record by DMA while the CPU fills the next, so those connections run three times "
|
||||
"over each protocol and every copy must be exact. A TLS 1.2 client "
|
||||
"offering a CBC suite alone still connects and stays in GnuTLS, which is the control "
|
||||
"for the kernel's counters; the ChaCha20 runs are the control for the engine's "
|
||||
"interrupt. Every copy of the panel page must be the plain-HTTP page byte for byte, "
|
||||
"and the kernel must count no decrypt error. The counters and the interrupt are the "
|
||||
"machine's, so a connection during which another client connected (a panel "
|
||||
"reconnecting after a restart) is measured again. Nothing on the machine changes.")
|
||||
def tls_records(ctx):
|
||||
ev = ctx.evidence
|
||||
|
||||
conf = ""
|
||||
if os.path.exists("/etc/gnutls/config"):
|
||||
with open("/etc/gnutls/config") as f:
|
||||
conf = f.read()
|
||||
ctx.check(any(line.split() == ["ktls", "=", "true"] for line in conf.splitlines()),
|
||||
"/etc/gnutls/config does not turn kernel TLS on: %r", conf)
|
||||
with open("/proc/sys/net/ipv4/tcp_available_ulp") as f:
|
||||
ulp = f.read().split()
|
||||
ev["tcp_available_ulp"] = ulp
|
||||
ctx.check("tls" in ulp, "the kernel has no TLS layer (tcp_available_ulp %s)", ulp)
|
||||
ctx.check(_engine_irqs() is not None, "/proc/interrupts has no %s", ENGINE_IRQ)
|
||||
|
||||
with urllib.request.urlopen("http://127.0.0.1" + PAGE, timeout=30) as r:
|
||||
plain = r.read()
|
||||
ev["page_bytes"] = len(plain)
|
||||
ctx.check(len(plain) > 8 * RECORD, "the page is %d bytes, too small to span records", len(plain))
|
||||
records = len(plain) // RECORD
|
||||
|
||||
work = tempfile.mkdtemp(prefix="forgetest-tls-")
|
||||
try:
|
||||
runs = {}
|
||||
# The AES-GCM connections run three times each: their records are written by the crypto
|
||||
# engine's DMA while the CPU fills the next one, which is where a record's last bytes were
|
||||
# once lost.
|
||||
cases = [("desktop-1.3", "1.3", DESKTOP_13, "TLS_CHACHA20_POLY1305_SHA256", True),
|
||||
("desktop-1.2", "1.2", DESKTOP_12, "ECDHE-ECDSA-CHACHA20-POLY1305", True)]
|
||||
for n in (1, 2, 3):
|
||||
cases += [("aes-1.3#%d" % n, "1.3", "TLS_AES_128_GCM_SHA256", "TLS_AES_128_GCM_SHA256", True),
|
||||
("aes-1.2#%d" % n, "1.2", "ECDHE-ECDSA-AES128-GCM-SHA256", "ECDHE-ECDSA-AES128-GCM-SHA256",
|
||||
True)]
|
||||
cases.append(("cbc-1.2", "1.2", "ECDHE-ECDSA-AES128-SHA", "ECDHE-ECDSA-AES128-SHA", False))
|
||||
for label, version, offer, want, kernel in cases:
|
||||
# The kernel's counters and the engine's interrupt are the machine's, not the
|
||||
# connection's: a window in which another client connected (a panel reconnecting
|
||||
# after a restart, a page polling this suite once a second) says nothing about this
|
||||
# one, and is measured again, after a pause that no steady poller stays in step with.
|
||||
for attempt in range(1, 31):
|
||||
suite, status, body, delta, irqs, others = _fetch(version, offer, work)
|
||||
if others <= 0:
|
||||
break
|
||||
ctx.log("%s: %d other connection(s) during the window; measured again", label, others)
|
||||
ctx.sleep(random.uniform(0.1, 0.9))
|
||||
ctx.check(others <= 0, "%s: another client connected during each of %d windows", label, attempt)
|
||||
run = {"offer": offer, "suite": suite, "status": status, "bytes": len(body),
|
||||
"same_as_http": body == plain, "tls_stat": delta, "engine_irqs": irqs, "attempts": attempt}
|
||||
runs[label] = run
|
||||
ctx.log("%s: offer %s -> %s, %s, %d bytes, same %s, tls_stat %s, %s +%d",
|
||||
label, offer, suite, status, len(body), body == plain, delta, ENGINE_IRQ, irqs)
|
||||
ctx.check(suite == want, "%s: offered %s, the listener chose %s, not %s", label, offer, suite, want)
|
||||
ctx.check(status.endswith("200 OK") and body == plain,
|
||||
"%s: the page arrived %s, %d bytes, not the plain-HTTP %d", label, status, len(body),
|
||||
len(plain))
|
||||
ctx.check(delta["TlsDecryptError"] == 0, "%s: the kernel counted %d decrypt errors", label,
|
||||
delta["TlsDecryptError"])
|
||||
if kernel:
|
||||
ctx.check(delta["TlsTxSw"] == 1 and delta["TlsRxSw"] == 1,
|
||||
"%s: the kernel did not take both directions' keys (tls_stat %s)", label, delta)
|
||||
else:
|
||||
ctx.check(delta["TlsTxSw"] == 0 and delta["TlsRxSw"] == 0,
|
||||
"%s: the kernel took keys for a cipher it does not seal (tls_stat %s)", label, delta)
|
||||
ev["runs"] = runs
|
||||
|
||||
# The AES of AES-GCM runs on the crypto engine: its interrupt counts
|
||||
# the records. ChaCha20 never touches the engine, so those runs are
|
||||
# the control.
|
||||
for label in ("aes-1.3#1", "aes-1.2#1"):
|
||||
ctx.check(runs[label]["engine_irqs"] >= records // 4,
|
||||
"%s: %d records crossed and the crypto engine interrupted %d times", label, records,
|
||||
runs[label]["engine_irqs"])
|
||||
for label in ("desktop-1.3", "desktop-1.2"):
|
||||
ctx.check(runs[label]["engine_irqs"] < max(2, records // 8),
|
||||
"%s: the crypto engine interrupted %d times for ChaCha20 records", label,
|
||||
runs[label]["engine_irqs"])
|
||||
finally:
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
|
||||
# The drivers the kernel resolved for the two record ciphers (both
|
||||
# exist now: the connections above instantiated them).
|
||||
chacha, gcm = _driver("rfc7539(chacha20,poly1305)"), _driver("gcm(aes)")
|
||||
ev["drivers"] = {"rfc7539(chacha20,poly1305)": chacha, "gcm(aes)": gcm}
|
||||
ctx.log("rfc7539(chacha20,poly1305) is %s; gcm(aes) is %s", chacha, gcm)
|
||||
ctx.check(chacha == CHACHA_DRIVER, "the kernel's ChaCha20-Poly1305 is %s, not %s", chacha, CHACHA_DRIVER)
|
||||
ctx.check(gcm and "ctr-aes-caam" in gcm and "ghash-ce" in gcm,
|
||||
"the kernel's gcm(aes) is %s: not the crypto engine's AES with the NEON GHASH", gcm)
|
||||
@@ -1,149 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""update.* - the A/B slot inventory and the firmware verification path."""
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
import shutil
|
||||
|
||||
from ..catalog import test
|
||||
from .. import hw
|
||||
|
||||
_UPDATE_COVERS = [("forgectrl", "src/update.c"), ("forgectrl", "src/update.h"),
|
||||
("forgectrl", "src/relcheck.c"), ("forgectrl", "src/relcheck.h"),
|
||||
("ffboot", "**")]
|
||||
|
||||
|
||||
@test("update.slots-and-signature", title="Boot slots readable, unsigned and foreign-signed archives refused",
|
||||
subsystem="update", kind="auto", est_min=2,
|
||||
covers=_UPDATE_COVERS, requires=["forgectrl.auth"],
|
||||
description="/slots reports the A/B inventory consistent with `ffboot -l`; /update/status "
|
||||
"answers; `fwup` refuses a garbage archive. A tiny archive signed with a "
|
||||
"throwaway key made on the spot verifies with its own key and fails against the "
|
||||
"shipped release key, and an apply of it without confirm_unsigned is refused by "
|
||||
"the update job before anything is written, and the forgectrl log records the "
|
||||
"job's start and its refusal. Nothing is written to any slot.")
|
||||
def slots_and_signature(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
st, slots = fc.get("/slots")
|
||||
ctx.log("GET /slots -> %s %s", st, slots)
|
||||
ctx.check(st == 200 and isinstance(slots, dict), "GET /slots -> %s", st)
|
||||
ev["slots"] = slots
|
||||
rc, out = hw.run(["ffboot", "-l"])
|
||||
ev["ffboot_l_rc"] = rc
|
||||
ctx.log("ffboot -l -> rc %s\n%s", rc, out.strip())
|
||||
ctx.check(rc == 0, "ffboot -l failed (%s)", rc)
|
||||
text = str(slots).lower()
|
||||
ctx.check("forgefirm" in text or "slot" in text, "/slots does not look like a slot inventory")
|
||||
|
||||
st, us = fc.get("/update/status")
|
||||
ctx.log("GET /update/status -> %s %s", st, us)
|
||||
ctx.check(st == 200 and isinstance(us, dict) and "running" in us, "GET /update/status -> %s", st)
|
||||
ctx.check(not us.get("running"), "an update is running")
|
||||
|
||||
key = "/etc/forgefirm/keys/forgefirm-release.pub"
|
||||
ctx.check(os.path.exists(key), "release key %s missing", key)
|
||||
with tempfile.NamedTemporaryFile(prefix="forgetest-", suffix=".fw", delete=False) as f:
|
||||
f.write(b"this is not a firmware archive" * 64)
|
||||
garbage = f.name
|
||||
try:
|
||||
rc, out = hw.run(["fwup", "-V", "-i", garbage, "-p", key], timeout=30)
|
||||
ev["fwup_garbage_rc"] = rc
|
||||
ctx.log("fwup -V garbage -> rc %s: %s", rc, out.strip()[:200])
|
||||
ctx.check(rc != 0, "fwup accepted a garbage archive")
|
||||
finally:
|
||||
os.unlink(garbage)
|
||||
|
||||
# A foreign signature: a throwaway key pair signs a tiny archive. The
|
||||
# shipped key must refuse it, its own key must accept it, and the
|
||||
# apply job must refuse it without confirm_unsigned, before it
|
||||
# touches the slot.
|
||||
work = tempfile.mkdtemp(prefix="forgetest-fw-")
|
||||
staged = "/data/forgefirm/upload.fw"
|
||||
try:
|
||||
with open(os.path.join(work, "note.txt"), "w") as f:
|
||||
f.write("forgetest foreign-signature drill\n")
|
||||
with open(os.path.join(work, "fwup.conf"), "w") as f:
|
||||
f.write('meta-product = "forgetest"\nmeta-version = "0.0.0-test"\n'
|
||||
'file-resource note.txt { host-path = "note.txt" }\n'
|
||||
'task complete { on-resource note.txt { raw_write(0) } }\n')
|
||||
rc, out = hw.run(["sh", "-c", "cd %s && fwup -g" % work], timeout=60)
|
||||
ev["fwup_gen_rc"] = rc
|
||||
ctx.check(rc == 0 and os.path.exists(os.path.join(work, "fwup-key.pub")),
|
||||
"fwup -g did not make a key pair (rc %s): %s", rc, out.strip()[:200])
|
||||
plain, signed = os.path.join(work, "plain.fw"), os.path.join(work, "signed.fw")
|
||||
rc, out = hw.run(["sh", "-c", "cd %s && fwup -c -f fwup.conf -o plain.fw && "
|
||||
"fwup -S -s fwup-key.priv -i plain.fw -o signed.fw" % work], timeout=60)
|
||||
ctx.check(rc == 0 and os.path.exists(signed), "could not make the signed archive (rc %s): %s",
|
||||
rc, out.strip()[:200])
|
||||
rc_own, _ = hw.run(["fwup", "-V", "-i", signed, "-p", os.path.join(work, "fwup-key.pub")], timeout=30)
|
||||
rc_ship, out = hw.run(["fwup", "-V", "-i", signed, "-p", key], timeout=30)
|
||||
ev["fwup_foreign"] = {"own_key_rc": rc_own, "shipped_key_rc": rc_ship}
|
||||
ctx.log("fwup -V signed: own key rc %s, shipped key rc %s", rc_own, rc_ship)
|
||||
ctx.check(rc_own == 0, "the archive does not verify with the key that signed it")
|
||||
ctx.check(rc_ship != 0, "the shipped release key accepted a foreign signature")
|
||||
|
||||
# The apply path. The target is the slot not booted; a slot already
|
||||
# selected for the next boot is refused for its own reason, which
|
||||
# this drill records and steps over.
|
||||
target = None
|
||||
for name, si in (slots.get("slots") or {}).items():
|
||||
if name in ("a", "b") and isinstance(si, dict) and not si.get("booted"):
|
||||
target = name
|
||||
ctx.check(target is not None, "no inactive firmware slot in /slots: %s", slots)
|
||||
shutil.copyfile(signed, staged)
|
||||
log_path = "/data/log/forgefirm/forgectrl/forgectrl.log"
|
||||
try:
|
||||
log_from = os.path.getsize(log_path)
|
||||
except OSError:
|
||||
log_from = 0
|
||||
st, body = fc.post("/update/apply", params={"slot": target, "file": "upload"})
|
||||
ev["apply"] = {"status": st, "body": body}
|
||||
ctx.log("POST /update/apply slot=%s file=upload (no confirm_unsigned) -> %s %s", target, st, body)
|
||||
if st == 409 and isinstance(body, dict) and "next boot" in str(body.get("error", "")):
|
||||
ctx.log("slot %s is selected for the next boot: the apply is refused before the "
|
||||
"signature check, which is its own guard", target)
|
||||
else:
|
||||
ctx.check(st == 202 and isinstance(body, dict) and body.get("started") is True,
|
||||
"the apply job did not start: %s %s", st, body)
|
||||
result = None
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < 60:
|
||||
ctx.sleep(1)
|
||||
st, us = fc.get("/update/status")
|
||||
if isinstance(us, dict) and not us.get("running"):
|
||||
result = us.get("result")
|
||||
break
|
||||
ev["apply_result"] = result
|
||||
ctx.log("apply result: %s", result)
|
||||
ctx.check(isinstance(result, dict) and result.get("ok") is False
|
||||
and "not signed" in str(result.get("error", "")),
|
||||
"the apply of a foreign-signed archive was not refused for its signature: %s", result)
|
||||
|
||||
# The forgectrl log records the job: what it set out to write
|
||||
# and why it stopped.
|
||||
want = ["update: writing the uploaded archive into slot %s" % target,
|
||||
"update: the apply failed: archive is not signed"]
|
||||
|
||||
def log_text():
|
||||
try:
|
||||
with open(log_path, "r", encoding="utf-8", errors="replace") as f:
|
||||
f.seek(log_from)
|
||||
return f.read()
|
||||
except OSError:
|
||||
return ""
|
||||
ctx.wait_for(lambda: all(w in log_text() for w in want), 10, poll=0.5)
|
||||
text = log_text()
|
||||
ev["apply_log"] = [ln for ln in text.splitlines() if "update: " in ln][-4:]
|
||||
for w in want:
|
||||
ctx.check(w in text, "the forgectrl log lacks %r: %s", w, ev["apply_log"])
|
||||
finally:
|
||||
try:
|
||||
os.unlink(staged)
|
||||
except OSError:
|
||||
pass
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
@@ -1,339 +0,0 @@
|
||||
/*
|
||||
* app.js - forgetest: the acceptance page
|
||||
* Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
* Written by Scott Wiederhold
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Acceptance tab + bench tab. State comes from GET /state, polled every
|
||||
* 2 s when the machine is idle and every second during a run; the catalog
|
||||
* and the bench listing are fetched once per tab and refreshed after a
|
||||
* run finishes.
|
||||
*
|
||||
* Rows, prompt buttons and tool entries are built once and thereafter
|
||||
* only updated in place. A poll that rebuilt them would swallow the click
|
||||
* it landed on: the button that took the mousedown would be gone before
|
||||
* the mouseup, so no click event would ever be raised. Every action also
|
||||
* grays its control out on the press and pulls the next poll forward, so
|
||||
* the page answers the operator rather than the timer. The help popovers
|
||||
* (help.js) sit on static elements only, so no rebuild ever orphans one.
|
||||
*
|
||||
* TOKEN is the bearer token page.py splices in when it serves the page.
|
||||
*/
|
||||
var TOKEN='__TOKEN__';
|
||||
var state=null, catalog=null, catalogHash=null, bench=null, tab='acceptance', openDetails={};
|
||||
var lastRunKey=null, stateEtag=null, curPrompt=null, promptKey=null, abortSent=false;
|
||||
var pollTimer=null, polling=false, pending=0, pendingId=null, rowMsg={};
|
||||
var rowEls=null, groupsKey=null, benchEls=null, benchKey=null, benchNeedsRefresh=false;
|
||||
var PENDING_MS=6000;
|
||||
/* A poll that never answers must not wedge the loop: it times out
|
||||
and the next one is scheduled as usual. Actions carry no timeout,
|
||||
because a POST the server did act on must not read as a failure. */
|
||||
var POLL_TIMEOUT_MS=20000;
|
||||
var ignoreReq=false;try{ignoreReq=window.localStorage.getItem('forgetest.ignoreReq')==='1'}catch(e){}
|
||||
function $(id){return document.getElementById(id)}
|
||||
function nowMs(){return (new Date()).getTime()}
|
||||
function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,function(c){return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]})}
|
||||
/* Touch the DOM only where the value really changed. Replacing a node
|
||||
under the operator's finger loses the click: the button that took the
|
||||
mousedown is gone before the mouseup, so no click event is ever
|
||||
raised, and the press does nothing. Nothing on a poll may rebuild a
|
||||
control that is only sitting there unchanged. */
|
||||
function setHtml(e,h){if(e&&e.__h!==h){e.__h=h;e.innerHTML=h}}
|
||||
function setText(e,t){if(e&&e.__t!==t){e.__t=t;e.textContent=t}}
|
||||
function setDis(e,d){if(e&&e.disabled!==d)e.disabled=d}
|
||||
function setProp(e,k,v){if(e&&e[k]!==v)e[k]=v}
|
||||
function api(method,path,body,cb,hdrs,timeoutMs){var x=new XMLHttpRequest();x.open(method,path,true);
|
||||
if(timeoutMs)x.timeout=timeoutMs;
|
||||
x.setRequestHeader('X-ForgeFIRM-Token',TOKEN);
|
||||
if(hdrs){for(var k in hdrs){if(hdrs.hasOwnProperty(k))x.setRequestHeader(k,hdrs[k])}}
|
||||
if(body!==undefined&&body!==null){x.setRequestHeader('Content-Type','application/json')}
|
||||
x.onreadystatechange=function(){if(x.readyState!==4)return;var d=null;try{d=JSON.parse(x.responseText)}catch(e){d={error:x.responseText}}cb(x.status,d,x)};
|
||||
x.send(body===undefined||body===null?null:JSON.stringify(body))}
|
||||
/* The state poll. It carries the last ETag, so an unchanged state costs
|
||||
a 304 and no re-render; a run gets a faster tick, and every action
|
||||
pulls the next poll forward instead of waiting out the interval. */
|
||||
function pollDelay(){return (state&&(state.running||batchActive()))?1000:2000}
|
||||
function schedule(ms){if(pollTimer)window.clearTimeout(pollTimer);pollTimer=window.setTimeout(poll,ms)}
|
||||
function kick(){schedule(120)}
|
||||
function poll(){if(polling){schedule(150);return}polling=true;
|
||||
/* While an action is still in flight the state is asked for in full:
|
||||
a 304 would skip the render that releases the grayed-out buttons. */
|
||||
var h=(stateEtag&&!pending)?{'If-None-Match':stateEtag}:null;
|
||||
api('GET','/state',null,function(s,d,x){polling=false;
|
||||
if(s===200){stateEtag=x.getResponseHeader('ETag');state=d;
|
||||
if(!catalog||catalogHash!==d.catalog_hash){loadCatalog(render)}else{render()}}
|
||||
schedule(pollDelay())},h,POLL_TIMEOUT_MS)}
|
||||
function showTab(t){tab=t;closeHelp();$('tab-acceptance').className=t==='acceptance'?'on':'';$('tab-bench').className=t==='bench'?'on':'';
|
||||
$('pane-acceptance').style.display=t==='acceptance'?'':'none';$('pane-bench').style.display=t==='bench'?'':'none';
|
||||
if(t==='bench'&&!bench)loadBench()}
|
||||
function setMsg(id,txt,err){setHtml($(id),txt?"<div class='"+(err?'err':'msg')+"'>"+esc(txt)+"</div>":'')}
|
||||
function loadCatalog(cb){api('GET','/catalog',null,function(s,d){if(s===200){catalog=d.tests;catalogHash=d.catalog_hash;if(cb)cb()}})}
|
||||
function loadBench(){api('GET','/bench',null,function(s,d){if(s===200){bench=d;benchKey=null;renderBench()}})}
|
||||
function setIgnoreReq(on){ignoreReq=!!on;try{window.localStorage.setItem('forgetest.ignoreReq',ignoreReq?'1':'0')}catch(e){}
|
||||
$('ignreq').checked=ignoreReq;setHtml($('ignreqon'),ignoreReq?"<span class='on'>ON - prerequisites are not enforced</span>":'');
|
||||
if(state&&catalog)renderGroups()}
|
||||
/* A start already sent but not yet seen in the state counts as busy, so
|
||||
the buttons gray out on the click rather than on the next poll. The
|
||||
window is capped in case the answer never arrives. A queue holds the
|
||||
machine between its tests as well as during them. */
|
||||
function batchActive(){return !!(state&&state.batch&&!state.batch.finished)}
|
||||
function isBusy(){if(state&&(state.running||batchActive()))return true;
|
||||
return !!(pending&&(nowMs()-pending)<PENDING_MS)}
|
||||
function fmtTs(t){return t?t.replace('T',' ').replace('Z',' UTC'):'-'}
|
||||
function render(){if(!state||!catalog)return;
|
||||
if(state.running||batchActive()){pending=0;pendingId=null}
|
||||
var m=state.manifest||{};setText($('hdrver'),(m.version||'?'));setText($('hdrsub'),m.image||'');
|
||||
var a=$('auth');setText(a,'Release authorized: '+(state.authorized?'YES':'NO'));setProp(a,'className','auth'+(state.authorized?' yes':''));
|
||||
var c=state.campaign,cn=state.counts||{};var b='';
|
||||
b+='<b>'+cn.satisfied+'</b> of <b>'+cn.total+'</b> satisfied ('+cn.inherited+' inherited), <b>'+cn.required+'</b> required<br>';
|
||||
b+=c?('campaign <span class="mono">'+esc(c.id)+'</span> opened '+esc(fmtTs(c.ts))):('no open campaign'+(state.closed_by?(' (last one closed by '+esc(state.closed_by)+')'):'')+' - the first Start opens one');
|
||||
b+='<br>manifest identity <span class="mono">'+esc((m.sha||'').slice(0,16))+'</span> · catalog <span class="mono">'+esc((state.catalog_hash||'').slice(0,12))+'</span>';
|
||||
if(state.log_corrupt)b+='<br><span class="b-bad">'+state.log_corrupt+' corrupt log line(s) skipped</span>';
|
||||
setHtml($('banner'),b);
|
||||
var inv=state.invalidate;setHtml($('invnote'),inv?"<div class='note'>Full campaign required since "+esc(fmtTs(inv.ts))+": "+esc(inv.reason)+"</div>":'');
|
||||
renderQueue();renderGroups();renderRun();renderSteps();if(bench)renderBench()}
|
||||
/* The two queues: what each would run now, and how the running one is
|
||||
getting on. Built from the state, so a reload picks the queue back up
|
||||
exactly where it is - the queue lives in the runner, not in this tab. */
|
||||
var QUEUES=[['unattended','Unattended'],['attended','Operator and live']];
|
||||
function renderFixture(){var f=state.fixture,e=$('fixture');if(!e)return;
|
||||
if(!f){setHtml(e,'');return}
|
||||
var ch=(f.channels||[]).filter(function(c){return c!=='button'||f.button_enabled});
|
||||
setHtml(e,'Bench fixture <b>'+esc(f.hostname)+'</b> at '+esc(f.ip||'?')+' (v'+esc(f.version||'?')+') covers <b>'+
|
||||
(ch.length?esc(ch.join(', ')):'nothing')+'</b>'+
|
||||
((f.channels||[]).indexOf('button')>=0&&!f.button_enabled?' (button disabled: enable jumper out)':'')+
|
||||
'; arm press: '+(f.arm_press?'<b>the fixture</b>':'the operator'))}
|
||||
function renderQueue(){var av=state.batch_available||{},b=state.batch,busy=isBusy();
|
||||
renderFixture();
|
||||
QUEUES.forEach(function(p){var e=$('q-'+p[0]);if(!e)return;
|
||||
var ids=av[p[0]]||[];
|
||||
setText(e,ids.length?(p[1]+' ('+ids.length+')'):(p[1]+' (none left)'));
|
||||
setDis(e,busy||!ids.length);
|
||||
setProp(e,'title',!ids.length?('nothing left: every '+p[0]+' test is satisfied')
|
||||
:(busy?'a run is in progress':('in order: '+ids.join(', '))))});
|
||||
setDis($('q-stop'),!batchActive()||!!(b&&b.stopping));
|
||||
if(!b){setHtml($('qstate'),'');return}
|
||||
var total=b.order.length||1,ok=0,bad=0;
|
||||
b.done.forEach(function(x){if(x.result==='PASS')ok++;else bad++});
|
||||
function seg(cls,n){return n?("<i class='"+cls+"' style='width:"+(100*n/total)+"%'></i>"):''}
|
||||
var h="<div class='qbar'>"+seg('ok',ok)+seg('bad',bad)+seg('skip',b.skipped.length)+
|
||||
(b.current?seg('now',1):'')+"</div><div class='qline'><b>"+esc(b.group)+"</b> queue, opened "+
|
||||
esc(fmtTs(b.ts))+" · ";
|
||||
h+=b.finished?('finished '+esc(fmtTs(b.finished))):(b.current?('running <b>'+esc(b.current)+'</b>'):
|
||||
(b.stopping?'stopping':'starting'));
|
||||
h+=" · <b>"+ok+"</b> passed, <b>"+bad+"</b> not, <b>"+b.skipped.length+
|
||||
"</b> skipped, <b>"+b.pending.length+"</b> waiting";
|
||||
if(b.stopped)h+="<br><span class='req'>stopped: "+esc(b.stopped)+"</span>";
|
||||
if(b.skipped.length)h+="<br>skipped: "+esc(b.skipped.map(function(x){return x.test+' ('+x.reason+')'}).join('; '));
|
||||
if(b.pending.length)h+="<br>waiting: <span class='tid'>"+esc(b.pending.join(', '))+"</span>";
|
||||
setHtml($('qstate'),h+"</div>")}
|
||||
/* The rows are built once for a given catalog and then only updated in
|
||||
place: a poll never rewrites the table, so a Start button survives the
|
||||
press that is landing on it. */
|
||||
function renderGroups(){var ids=[];catalog.forEach(function(t){ids.push(t.id)});
|
||||
var sig=catalogHash+'|'+ids.join(',');
|
||||
if(sig!==groupsKey){groupsKey=sig;buildGroups()}
|
||||
updateGroups()}
|
||||
var COLS="<colgroup><col><col class='k'><col class='s'><col class='l'><col class='a'></colgroup>";
|
||||
function buildGroups(){var groups={},order=[];
|
||||
catalog.forEach(function(t){if(!groups[t.subsystem]){groups[t.subsystem]=[];order.push(t.subsystem)}groups[t.subsystem].push(t)});
|
||||
var h='';
|
||||
order.forEach(function(g){h+="<div class='card grp'><h2>"+esc(g)+"</h2><table>"+COLS+"<tr><th>Test</th><th>Kind</th><th>Status</th><th>Last result</th><th></th></tr>";
|
||||
groups[g].forEach(function(t){var d=esc(t.id);var tags='';
|
||||
if(t.always)tags+="<span class='tag core'>core</span>";tags+="<span class='tag "+esc(t.kind)+"'>"+esc(t.kind)+"</span>";
|
||||
if(t.hardware==='takeover')tags+="<span class='tag takeover'>takeover</span>";
|
||||
if(t.mode)tags+="<span class='tag mode'>"+esc(t.mode)+"</span>";
|
||||
var det="<div class='details"+(openDetails[t.id]?' on':'')+"' id='det-"+d+"'>"+
|
||||
(t.description?("<div class='dsc'>"+esc(t.description)+"</div>"):'')+
|
||||
(t.steps&&t.steps.length?"<b>Operator steps:</b><ol>"+t.steps.map(function(x){return '<li>'+esc(x)+'</li>'}).join('')+"</ol>":'')+
|
||||
(t.actions&&t.actions.length?"<b>Machine actions:</b> "+esc(t.actions.join(', '))+"<br>":'')+
|
||||
"<b>Requires:</b> "+esc((t.requires||[]).join(', ')||'-')+"<br><b>Covers:</b> "+esc((t.covers||[]).map(function(c){return c[0]+':'+c[1]}).join(', ')||'-')+
|
||||
"<br><span id='detdyn-"+d+"'></span></div>";
|
||||
h+="<tr><td><div class='tsel' title='show what this test asks of you' onclick='selectTest(\""+d+"\")'>"+esc(t.title)+"</div><div class='tid'>"+d+" <a href='#' onclick='toggleDet(\""+d+"\");return false'>details</a></div>"+
|
||||
"</td><td>"+tags+"</td>"+
|
||||
"<td><div id='st-"+d+"'></div><div id='unmet-"+d+"'></div><div id='note-"+d+"'></div></td>"+
|
||||
"<td id='last-"+d+"'></td>"+
|
||||
"<td><button class='btn btn-sm btn-primary' id='btn-"+d+"' onclick='startTest(\""+d+"\")'>Start</button></td></tr>"+
|
||||
"<tr class='detrow'><td colspan='5'>"+det+"</td></tr>"});
|
||||
h+="</table></div>"});
|
||||
$('groups').innerHTML=h;rowEls={};
|
||||
catalog.forEach(function(t){rowEls[t.id]={st:$('st-'+t.id),last:$('last-'+t.id),btn:$('btn-'+t.id),
|
||||
unmet:$('unmet-'+t.id),note:$('note-'+t.id),detdyn:$('detdyn-'+t.id)}})}
|
||||
function updateGroups(){if(!rowEls||!state)return;var busy=isBusy();
|
||||
var queued={};if(batchActive()){(state.batch.pending||[]).forEach(function(x){queued[x]=1})}
|
||||
catalog.forEach(function(t){var e=rowEls[t.id];if(!e)return;var s=state.tests[t.id]||{};
|
||||
var st;
|
||||
if(pendingId===t.id&&isBusy()&&!state.running){st="<span class='st running'>starting…</span>"}
|
||||
else{st="<span class='st "+esc(s.status)+"'>"+esc(s.status||'none')+"</span>";
|
||||
if(s.required&&s.status!=='running')st+="<br><span class='req'>required: "+esc(s.reason)+"</span>"}
|
||||
if(queued[t.id])st+="<br><span class='tag queued'>queued</span>";
|
||||
setHtml(e.st,st);
|
||||
var last=s.last?(esc(s.last.result)+' '+esc(fmtTs(s.last.ts))):'-';
|
||||
if(s.status==='inherited'&&s.origin)last+="<br><span class='tid'>from "+esc(s.origin.campaign)+" on "+esc(s.origin.image)+"</span>";
|
||||
setHtml(e.last,last);
|
||||
var unmet=s.requires_met===false;var can=!busy&&(!unmet||ignoreReq);
|
||||
setDis(e.btn,!can);
|
||||
setProp(e.btn,'title',busy?'a run is in progress':(unmet?((ignoreReq?'prerequisites overridden - needs: ':'needs: ')+(s.missing_requires||[]).join(', ')):''));
|
||||
setProp(e.unmet,'className','req'+(ignoreReq?' over':''));
|
||||
setHtml(e.unmet,unmet?((ignoreReq?'unmet: ':'needs: ')+esc((s.missing_requires||[]).join(', '))):'');
|
||||
setHtml(e.note,rowMsg[t.id]?"<div class='err'>"+esc(rowMsg[t.id])+"</div>":'');
|
||||
setHtml(e.detdyn,"<b>Fingerprint:</b> <span class='mono'>"+esc((s.fingerprint||'').slice(0,16))+"</span> · est. "+esc(t.est_min)+" min"+
|
||||
(s.last?"<br><a href='/result?test="+encodeURIComponent(t.id)+"&ts="+encodeURIComponent(s.last.ts)+"'>last result record</a>":''))})}
|
||||
function toggleDet(id){openDetails[id]=!openDetails[id];var e=$('det-'+id);if(e)e.className='details'+(openDetails[id]?' on':'')}
|
||||
function renderRun(){var r=state.running||state.last_run;var key=r?(r.kind+':'+r.id+':'+r.started):null;
|
||||
if(key!==lastRunKey)abortSent=false;
|
||||
document.querySelector('main').classList.toggle('running',!!state.running);
|
||||
if(!r){setText($('runhead'),'idle');setText($('log'),'');$('prompt').style.display='none';$('notice').style.display='none';
|
||||
curPrompt=null;promptKey=null;setDis($('abortbtn'),true);setText($('runtitle'),'Run');lastRunKey=null;return}
|
||||
setText($('runtitle'),(r.kind==='bench'?'Bench: ':'Test: ')+r.title);
|
||||
var hd=esc(r.id)+' · started '+esc(fmtTs(r.started))+' · '+r.elapsed_s+' s';
|
||||
if(r.finished){hd+=' · <b class="st '+(r.finished.result==='PASS'||r.finished.result==='OK'?'pass':'fail')+'">'+esc(r.finished.result)+'</b>'+(r.finished.message?' - '+esc(r.finished.message):'')}
|
||||
else if(r.aborting){hd+=' · <b class="st stale">aborting</b>'}
|
||||
setHtml($('runhead'),hd);
|
||||
var lg=$('log');var txt=(r.dropped?('... '+r.dropped+' earlier lines dropped\n'):'')+r.log.join('\n');
|
||||
if(lg.__t!==txt){var atBottom=lg.scrollTop+lg.clientHeight>=lg.scrollHeight-20;
|
||||
lg.__t=txt;lg.textContent=txt;if(atBottom||key!==lastRunKey)lg.scrollTop=lg.scrollHeight}
|
||||
lastRunKey=key;
|
||||
var nt=(r.notice&&!r.finished)?r.notice.text:'';
|
||||
setText($('notice'),nt);$('notice').style.display=nt?'':'none';
|
||||
/* The prompt buttons are rebuilt only when the prompt itself changes -
|
||||
they are the ones the operator stares at before pressing. */
|
||||
if(r.prompt&&!r.finished){var pk=r.prompt.id+'|'+r.prompt.question+'|'+r.prompt.options.length+':'+r.prompt.options.join(',');
|
||||
if(pk!==promptKey){promptKey=pk;curPrompt=r.prompt;
|
||||
$('promptq').textContent=r.prompt.question;var pb='';
|
||||
r.prompt.options.forEach(function(o,i){pb+="<button class='btn btn-sm btn-primary' onclick='answerIdx("+i+")'>"+esc(o)+"</button>"});
|
||||
$('promptb').innerHTML=pb;$('promptb').__h=pb;$('prompt').style.display=''}}
|
||||
else if(promptKey!==null){promptKey=null;curPrompt=null;$('prompt').style.display='none'}
|
||||
setDis($('abortbtn'),!!r.finished||abortSent||!!r.aborting);
|
||||
if(r.finished&&tab==='bench'&&r.kind==='bench'&&benchNeedsRefresh){benchNeedsRefresh=false;loadBench()}}
|
||||
function findTest(id){for(var i=0;i<catalog.length;i++)if(catalog[i].id===id)return catalog[i];return null}
|
||||
/* What the operator will be asked to do, shown before it is asked: the
|
||||
running test's steps for the whole run, the queue's attended tests
|
||||
before the first one starts, or the test the operator clicked on. The
|
||||
prompts and notices that follow are these steps, taken in turn. */
|
||||
var selected=null;
|
||||
function selectTest(id){selected=(selected===id)?null:id;renderSteps()}
|
||||
function stepsOf(t,head){var h='';
|
||||
var acts=(t.actions||[]);
|
||||
if(head)h+="<div class='sub'>"+head+"</div>";
|
||||
if(t.kind==='live')h+="<div>This test fires the laser: eye protection, fire watch, exhaust, scrap under the head.</div>";
|
||||
if(acts.length)h+="<div>Machine actions: <b>"+esc(acts.join(', '))+"</b> (a standing instruction tells you when; the test watches the machine for it)</div>";
|
||||
if(t.steps&&t.steps.length)h+="<ol>"+t.steps.map(function(x){return '<li>'+esc(x)+'</li>'}).join('')+"</ol>";
|
||||
return h}
|
||||
function renderSteps(){var e=$('steps');if(!e||!catalog)return;var h='',r=state.running;
|
||||
if(r&&r.kind==='test'){var t=findTest(r.id);
|
||||
if(t&&((t.steps&&t.steps.length)||(t.actions&&t.actions.length)||t.kind==='live'))h="<div class='sh'>What you will do</div>"+stepsOf(t,null)}
|
||||
else if(batchActive()&&state.batch.pending&&state.batch.pending.length){var parts=[];
|
||||
state.batch.pending.forEach(function(id){var t=findTest(id);if(t&&t.kind!=='auto')parts.push(stepsOf(t,esc(id)))});
|
||||
if(parts.length)h="<div class='sh'>Coming up in this queue</div>"+parts.join('')}
|
||||
else if(selected&&!r){var t=findTest(selected);
|
||||
if(t)h="<div class='sh'>Before you start "+esc(t.id)+"</div>"+(t.kind==='auto'?"<div class='auto'>Nothing: this test needs nobody at the machine.</div>":stepsOf(t,null))}
|
||||
setHtml(e,h);e.style.display=h?'':'none'}
|
||||
function startTest(id){if(isBusy())return;var t=findTest(id);var body={test:id};
|
||||
if(ignoreReq)body.ignore_requires=true;
|
||||
if(t&&t.kind==='live'){if(!confirmLive())return;body.ack_live=true}
|
||||
pending=nowMs();pendingId=id;rowMsg={};updateGroups();
|
||||
setMsg('actmsg','starting '+id+'...');
|
||||
api('POST','/start',body,function(s,d){
|
||||
if(s!==200){pending=0;pendingId=null;rowMsg[id]=d.message||d.error;updateGroups()}
|
||||
setMsg('actmsg',d.message||d.error,s!==200);kick()})}
|
||||
/* The live-laser acknowledgment stays a blocking dialog on purpose: it
|
||||
is the one thing on this page that must not be dismissed by a stray
|
||||
click or a poll. */
|
||||
function confirmLive(live){return window.confirm(
|
||||
(live?('LIVE LASER QUEUE.\n\nThese fire the laser:\n - '+live.join('\n - ')+'\n'):'LIVE LASER TEST.\n')+
|
||||
'\nConfirm before starting:\n - eye protection on, everyone in the room\n - fire watch present, extinguisher at hand\n - exhaust running, lid closed, scrap in place\n - you will press the physical button to arm when prompted\n\n'+
|
||||
(live?'Start the queue?':'Start the test?'))}
|
||||
/* A queue takes the machine for a long stretch, so both what it will run
|
||||
and the acknowledgment it needs are put in front of the operator once,
|
||||
before anything starts. */
|
||||
function startBatch(group){if(isBusy())return;
|
||||
var ids=(state&&state.batch_available&&state.batch_available[group])||[];
|
||||
if(!ids.length)return;
|
||||
var body={group:group};if(ignoreReq)body.ignore_requires=true;
|
||||
var live=[];catalog.forEach(function(t){if(t.kind==='live'&&ids.indexOf(t.id)>=0)live.push(t.id)});
|
||||
if(live.length){if(!confirmLive(live))return;body.ack_live=true}
|
||||
else if(!window.confirm('Run these '+ids.length+' test(s), in this order?\n\n - '+ids.join('\n - ')))return;
|
||||
pending=nowMs();pendingId=null;rowMsg={};updateGroups();renderQueue();
|
||||
setMsg('qmsg','starting the '+group+' queue...');
|
||||
api('POST','/batch',body,function(s,d){
|
||||
if(s!==200){pending=0;updateGroups();renderQueue()}
|
||||
setMsg('qmsg',d.message||d.error,s!==200);kick()})}
|
||||
function stopBatch(){var e=$('q-stop');if(e.disabled)return;setDis(e,true);
|
||||
setMsg('qmsg','stopping the queue...');
|
||||
api('POST','/batch/stop',{},function(s,d){setMsg('qmsg',d.message||d.error,s!==200);kick()})}
|
||||
function promptBusy(on){var b=$('promptb').getElementsByTagName('button');for(var i=0;i<b.length;i++)setDis(b[i],on)}
|
||||
function answerIdx(i){if(!curPrompt)return;var p=curPrompt,v=p.options[i];
|
||||
promptBusy(true);setMsg('actmsg','answer sent: '+v);
|
||||
api('POST','/answer',{prompt_id:p.id,value:v},function(s,d){
|
||||
if(s!==200){promptBusy(false);setMsg('actmsg',d.message||d.error,true)}kick()})}
|
||||
function doAbort(){var b=$('abortbtn');if(b.disabled)return;abortSent=true;setDis(b,true);
|
||||
setMsg('actmsg','aborting...');
|
||||
api('POST','/abort',{},function(s,d){if(s!==200)abortSent=false;setMsg('actmsg',d.message||d.error,s!==200);kick()})}
|
||||
function doExport(){var b=$('exportbtn');if(b.disabled)return;setDis(b,true);setMsg('actmsg','exporting...');
|
||||
api('POST','/export',{},function(s,d){setDis(b,false);
|
||||
if(s===200){setMsg('actmsg','exported: authorized='+d.authorized+', sha256 '+d.sha256.slice(0,16)+' - download below');$('dljson').style.display='';$('dlmd').style.display=''}
|
||||
else setMsg('actmsg',d.message||d.error,true)})}
|
||||
function toggleInv(){var f=$('invform');f.style.display=f.style.display==='none'?'':'none'}
|
||||
function doInvalidate(){var r=$('invreason').value;if(!r){setMsg('actmsg','a reason is required',true);return}
|
||||
api('POST','/invalidate',{reason:r},function(s,d){setMsg('actmsg',d.message||d.error,s!==200);
|
||||
if(s===200){$('invform').style.display='none';$('invreason').value=''}kick()})}
|
||||
function doReset(){api('POST','/reset',{reason:'operator reset from the page'},function(s,d){setMsg('actmsg',d.message||d.error,s!==200);kick()})}
|
||||
/* Same rule as the acceptance rows: the tool list is built once and then
|
||||
only updated, so a run starting never rewrites the arg fields the
|
||||
operator has typed into or the button being pressed. */
|
||||
function renderBench(){if(!bench)return;var ids=[];
|
||||
bench.tools.forEach(function(t){ids.push(t.id+':'+(t.ported?1:0)+(t.installed?1:0))});
|
||||
var sig=ids.join(',');
|
||||
if(sig!==benchKey){benchKey=sig;buildBench()}
|
||||
updateBench()}
|
||||
function buildBench(){var groups={dry:[],takeover:[],live:[],scope:[]};
|
||||
bench.tools.forEach(function(t){(groups[t.safety]||(groups[t.safety]=[])).push(t)});var h='';
|
||||
['dry','takeover','scope','live'].forEach(function(g){if(!groups[g]||!groups[g].length)return;
|
||||
h+="<div class='card grp'><h2>"+esc(g)+"</h2>";
|
||||
groups[g].forEach(function(t){var d=esc(t.id);
|
||||
h+="<div class='tool'><div><b>"+esc(t.title)+"</b> <span class='tid'>"+esc(t.script)+"</span> <span class='tag'>"+esc(t.where)+"</span>"+(t.ported?'':"<span class='tag'>unported</span>")+"</div>";
|
||||
h+="<div class='hint' style='margin:2px 0 4px'>"+esc(t.desc)+"</div>";
|
||||
if(t.args&&t.args.length){h+="<div class='argrow'>";t.args.forEach(function(a){var iid='arg-'+t.id+'-'+a.name;
|
||||
if(a.type==='choice'){h+="<label>"+esc(a.name)+" <select class='form-select form-select-sm' id='"+iid+"'>"+a.choices.map(function(c){return "<option"+(c===a.default?' selected':'')+">"+esc(c)+"</option>"}).join('')+"</select></label>"}
|
||||
else{h+="<label>"+esc(a.name)+" <input class='form-control form-control-sm' type='"+(a.type==='str'?'text':'number')+"' step='any' id='"+iid+"' value='"+esc(a.default==null?'':a.default)+"' title='"+esc(a.help)+"'></label>"}});h+="</div>"}
|
||||
h+="<div class='actions'><button class='btn btn-sm btn-primary' id='tbtn-"+d+"' onclick='startTool(\""+d+"\")'>Start</button>";
|
||||
h+="<span class='hint' id='tlast-"+d+"'></span></div></div>"});
|
||||
h+="</div>"});
|
||||
$('tools').innerHTML=h;benchEls={};
|
||||
bench.tools.forEach(function(t){benchEls[t.id]={btn:$('tbtn-'+t.id),last:$('tlast-'+t.id)}})}
|
||||
function updateBench(){if(!benchEls)return;var busy=isBusy();
|
||||
bench.tools.forEach(function(t){var e=benchEls[t.id];if(!e)return;
|
||||
var can=t.ported&&t.installed&&!busy;
|
||||
setDis(e.btn,!can);
|
||||
setProp(e.btn,'title',!t.ported?'not yet ported to the bench page':(!t.installed?'script not installed on this image':(busy?'a run is in progress':'')));
|
||||
setHtml(e.last,t.last?("last: "+esc(t.last.result?t.last.result.result:'?')+" "+esc(fmtTs(t.last.ts))):'')})}
|
||||
function startTool(id){if(isBusy())return;var t=null;bench.tools.forEach(function(x){if(x.id===id)t=x});if(!t)return;var args={};
|
||||
(t.args||[]).forEach(function(a){var e=$('arg-'+id+'-'+a.name);if(e)args[a.name]=e.value});
|
||||
var body={tool:id,args:args};if(t.safety==='live'){if(!confirmLive())return;body.ack_live=true}
|
||||
pending=nowMs();pendingId=null;updateBench();updateGroups();
|
||||
setMsg('benchmsg','starting '+id+'...');
|
||||
api('POST','/bench/start',body,function(s,d){
|
||||
if(s!==200){pending=0;updateBench();updateGroups()}
|
||||
setMsg('benchmsg',d.message||d.error,s!==200);if(s===200)benchNeedsRefresh=true;kick()})}
|
||||
|
||||
/* ---- Theme ----------------------------------------------------------
|
||||
Light, dark, or auto (the system preference); the choice lives in
|
||||
localStorage under the key forgectrl's panel uses, so the two pages
|
||||
agree. The head script in index.html applies it before first paint. */
|
||||
var THEMES=['auto','light','dark'];
|
||||
function themeChoice(){try{var s=localStorage.getItem('ff_theme');return s==='light'||s==='dark'?s:'auto'}catch(e){return 'auto'}}
|
||||
function applyTheme(){var c=themeChoice(),t=c;
|
||||
if(c==='auto')t=window.matchMedia&&matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';
|
||||
document.documentElement.setAttribute('data-bs-theme',t);
|
||||
$('themebtn').textContent=c==='auto'?'\u25d0 Auto':c==='dark'?'\u263e Dark':'\u2600 Light';
|
||||
$('themebtn').title='Theme: '+c+' (click to change)'}
|
||||
function cycleTheme(){var next=THEMES[(THEMES.indexOf(themeChoice())+1)%THEMES.length];
|
||||
try{if(next==='auto')localStorage.removeItem('ff_theme');else localStorage.setItem('ff_theme',next)}catch(e){}
|
||||
applyTheme()}
|
||||
(function(){applyTheme();if(window.matchMedia){var mq=matchMedia('(prefers-color-scheme: dark)');
|
||||
if(mq.addEventListener)mq.addEventListener('change',applyTheme);else if(mq.addListener)mq.addListener(applyTheme)}})();
|
||||
|
||||
initHelp();
|
||||
setIgnoreReq(ignoreReq);
|
||||
poll();
|
||||
@@ -1,127 +0,0 @@
|
||||
/*
|
||||
* help.js - forgetest: the page's help text
|
||||
* Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
* Written by Scott Wiederhold
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Every explanation the page offers lives here, keyed by the data-help
|
||||
* attribute of the "?" button that opens it (index.html): a title, its
|
||||
* paragraphs, and the page of the documentation site it deep-links to
|
||||
* (DOC_BASE + d). The buttons open Bootstrap popovers, one at a time; a
|
||||
* click anywhere else, Escape, or a tab change closes the open one. The
|
||||
* buttons sit on static elements only: the rows, prompts and tool entries
|
||||
* are rebuilt by app.js, and a popover on one of those would be orphaned.
|
||||
* What the operator must read mid-run (the steps, prompts, notices and
|
||||
* the live-laser acknowledgment) stays in the page and never moves here.
|
||||
*/
|
||||
var DOC_BASE = 'https://docs.forgefirm.org/';
|
||||
var HELP = {
|
||||
campaign: {
|
||||
t: 'Campaign',
|
||||
d: 'developers/acceptance/',
|
||||
p: [
|
||||
'A release is authorized when a campaign is open on this image and every catalog test is satisfied: by a PASS in the campaign, or (never for the core) by an earlier PASS whose domain fingerprint is unchanged. A FAIL ends the campaign.',
|
||||
'The manifest identity is the image, the catalog hash is the test set; either changing starts the count over.'
|
||||
]
|
||||
},
|
||||
queues: {
|
||||
t: 'Run what is left',
|
||||
d: 'developers/acceptance/',
|
||||
p: [
|
||||
'Each queue takes every test of its kind that the campaign does not already count as satisfied, runs them one at a time in prerequisite order, and stops on the first result that is not a PASS.',
|
||||
'The unattended queue needs nobody in the room; with the bench fixture up, the operator tests it can perform by itself run there too. The other one does: it prompts, and it fires the laser. Stop-the-queue cancels what is still waiting and lets the run in progress finish; Abort ends that one too.'
|
||||
]
|
||||
},
|
||||
actions: {
|
||||
t: 'Campaign actions',
|
||||
d: 'developers/acceptance/',
|
||||
p: [
|
||||
'Export builds the release artifact the release gate reads (acceptance.json, with acceptance.md for people) from the campaign as it stands. The raw log is the append-only record; the journal is the daemon\'s own.',
|
||||
'Invalidate all forces a full campaign; give the reason. Reset campaign closes the open campaign without invalidating anything: earlier passes stay inheritable under the campaign rules, and the next Start opens a new one.'
|
||||
]
|
||||
},
|
||||
ignore: {
|
||||
t: 'Ignore prerequisites',
|
||||
d: 'developers/acceptance/',
|
||||
p: [
|
||||
'A test\'s requires list only orders the runs. With the switch on, any test starts alone and its record notes which prerequisites were unmet; the release still needs every test satisfied.'
|
||||
]
|
||||
},
|
||||
bench: {
|
||||
t: 'Bench diagnostics',
|
||||
d: 'developers/bench/',
|
||||
p: [
|
||||
'The bench tools under scripts/bench, run on the board with their output in the Run pane. Nothing here enters a campaign. Tools not yet ported are listed for completeness.',
|
||||
'Live tools need the operator acknowledgment; takeover and scope tools stop forgectrl for the duration and start it again when they finish.'
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
var helpOpen = null;
|
||||
function helpContent(h) {
|
||||
var d = document.createElement('div'),
|
||||
i,
|
||||
p,
|
||||
a;
|
||||
for (i = 0; i < h.p.length; i++) {
|
||||
p = document.createElement('p');
|
||||
p.textContent = h.p[i];
|
||||
d.appendChild(p);
|
||||
}
|
||||
a = document.createElement('a');
|
||||
a.className = 'doclink';
|
||||
a.href = DOC_BASE + h.d;
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener';
|
||||
a.textContent = 'Documentation \u2192';
|
||||
d.appendChild(a);
|
||||
return d;
|
||||
}
|
||||
function closeHelp() {
|
||||
if (helpOpen) {
|
||||
helpOpen.hide();
|
||||
helpOpen = null;
|
||||
}
|
||||
}
|
||||
function initHelp() {
|
||||
var els = document.querySelectorAll('[data-help]'),
|
||||
i;
|
||||
for (i = 0; i < els.length; i++) {
|
||||
(function (el) {
|
||||
var h = HELP[el.getAttribute('data-help')];
|
||||
if (!h) {
|
||||
el.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
var pop = new bootstrap.Popover(el, {
|
||||
trigger: 'manual',
|
||||
html: true,
|
||||
placement: 'auto',
|
||||
title: h.t,
|
||||
content: helpContent(h),
|
||||
container: 'body'
|
||||
});
|
||||
el.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (helpOpen === pop) {
|
||||
closeHelp();
|
||||
return;
|
||||
}
|
||||
closeHelp();
|
||||
pop.show();
|
||||
helpOpen = pop;
|
||||
});
|
||||
el.addEventListener('hidden.bs.popover', function () {
|
||||
if (helpOpen === pop) helpOpen = null;
|
||||
});
|
||||
})(els[i]);
|
||||
}
|
||||
document.addEventListener('click', function (e) {
|
||||
var tip = document.querySelector('.popover.show');
|
||||
if (helpOpen && !(tip && tip.contains(e.target))) closeHelp();
|
||||
});
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') closeHelp();
|
||||
});
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
<!--
|
||||
index.html - forgetest: the acceptance page
|
||||
Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
Written by Scott Wiederhold
|
||||
SPDX-License-Identifier: MIT
|
||||
|
||||
Served at "/" as one self-contained page: page.py inlines every
|
||||
stylesheet and script linked below, in order, in place of its
|
||||
link/script tag. No external assets. Bootstrap (vendor/) is the UI
|
||||
library; theme.css is the OpenGlow theme forgectrl's panel uses, byte
|
||||
for byte, in a light and a dark variant selected by the data-bs-theme
|
||||
attribute the head script sets before first paint; page.css is this
|
||||
page's own layout. Design notes are at the top of app.js.
|
||||
-->
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>ForgeFIRM acceptance</title>
|
||||
<script>
|
||||
(function () {
|
||||
var t = 'light';
|
||||
try {
|
||||
var s = localStorage.getItem('ff_theme');
|
||||
if (s === 'light' || s === 'dark') t = s;
|
||||
else if (window.matchMedia && matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
t = 'dark';
|
||||
} catch (e) {}
|
||||
document.documentElement.setAttribute('data-bs-theme', t);
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="vendor/bootstrap.min.css" />
|
||||
<link rel="stylesheet" href="theme.css" />
|
||||
<link rel="stylesheet" href="page.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="hrow">
|
||||
<span class="appname">ForgeFIRM acceptance</span><span class="sub" id="hdrsub"></span
|
||||
><span class="ver" id="hdrver"></span
|
||||
><button id="themebtn" type="button" onclick="cycleTheme()" title="Theme">Theme</button>
|
||||
</div>
|
||||
</header>
|
||||
<nav>
|
||||
<div class="nrow">
|
||||
<a id="tab-acceptance" class="on" onclick='showTab("acceptance")'>Release acceptance</a
|
||||
><a id="tab-bench" onclick='showTab("bench")'>Bench diagnostics</a>
|
||||
</div>
|
||||
</nav>
|
||||
<main>
|
||||
<div id="left">
|
||||
<div id="pane-acceptance">
|
||||
<div class="card">
|
||||
<h2>Campaign<button class="hlp" data-help="campaign" type="button">?</button></h2>
|
||||
<div class="banner">
|
||||
<div class="auth" id="auth">?</div>
|
||||
<div class="info" id="banner"></div>
|
||||
</div>
|
||||
<div id="invnote"></div>
|
||||
<div class="card queue">
|
||||
<h2>Run what is left<button class="hlp" data-help="queues" type="button">?</button></h2>
|
||||
<div class="actions">
|
||||
<button class="btn btn-sm btn-primary" id="q-unattended" onclick='startBatch("unattended")'>Unattended</button>
|
||||
<button class="btn btn-sm btn-primary" id="q-attended" onclick='startBatch("attended")'>Operator and live</button>
|
||||
<button class="btn btn-sm btn-danger" id="q-stop" onclick="stopBatch()" disabled>Stop the queue</button>
|
||||
</div>
|
||||
<div id="qstate"></div>
|
||||
<div id="qmsg"></div>
|
||||
<div id="fixture" class="hint"></div>
|
||||
</div>
|
||||
<div class="actions" style="margin-top: 10px">
|
||||
<button class="btn btn-sm btn-primary" id="exportbtn" onclick="doExport()">Export release artifact</button>
|
||||
<a class="btn btn-sm btn-outline-secondary" id="dljson" href="/export/acceptance.json" style="display: none">acceptance.json</a>
|
||||
<a class="btn btn-sm btn-outline-secondary" id="dlmd" href="/export/acceptance.md" style="display: none">acceptance.md</a>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="/log">Raw log</a>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="/journal">Runner journal</a>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="toggleInv()">Invalidate all…</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="doReset()">Reset campaign</button>
|
||||
<button class="hlp" data-help="actions" type="button">?</button>
|
||||
</div>
|
||||
<div id="invform" style="display: none">
|
||||
<input type="text" class="form-control form-control-sm" id="invreason" placeholder="reason (required): what changed on the bench" />
|
||||
<button class="btn btn-sm btn-danger" onclick="doInvalidate()">Invalidate all results</button>
|
||||
</div>
|
||||
<div id="actmsg"></div>
|
||||
<div class="switch">
|
||||
<input class="form-check-input" type="checkbox" id="ignreq" onchange="setIgnoreReq(this.checked)" />
|
||||
<label for="ignreq">Ignore prerequisites: start any test on its own, whatever its <i>requires</i> list says</label>
|
||||
<span id="ignreqon"></span>
|
||||
<button class="hlp" data-help="ignore" type="button">?</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="groups"></div>
|
||||
</div>
|
||||
<div id="pane-bench" style="display: none">
|
||||
<div class="card">
|
||||
<h2>Bench diagnostics<button class="hlp" data-help="bench" type="button">?</button></h2>
|
||||
<p class="hint">
|
||||
The bench tools (scripts/bench), run on the board with their output in the Run
|
||||
pane. Runs here never enter a campaign; live tools need the operator
|
||||
acknowledgment, and takeover tools stop forgectrl for the duration.
|
||||
</p>
|
||||
<div id="benchmsg"></div>
|
||||
</div>
|
||||
<div id="tools"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="right">
|
||||
<div class="card">
|
||||
<h2 id="runtitle">Run</h2>
|
||||
<div id="runhead" class="info">idle</div>
|
||||
<div id="steps" style="display: none"></div>
|
||||
<div id="notice" style="display: none"></div>
|
||||
<div id="prompt" style="display: none">
|
||||
<div class="q" id="promptq"></div>
|
||||
<div class="actions" id="promptb"></div>
|
||||
</div>
|
||||
<pre id="log"></pre>
|
||||
<div class="actions">
|
||||
<button class="btn btn-sm btn-danger" id="abortbtn" onclick="doAbort()" disabled>Abort</button
|
||||
><span class="hint" id="runfoot"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<script src="vendor/bootstrap.bundle.min.js"></script>
|
||||
<script src="help.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,428 +0,0 @@
|
||||
/*
|
||||
* page.css - forgetest: the acceptance page's own styles
|
||||
* Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
* Written by Scott Wiederhold
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Everything specific to this page, on the tokens theme.css defines
|
||||
* (theme.css is the same file forgectrl's panel uses, so the two look like
|
||||
* one product and follow the same light and dark themes). This page is a
|
||||
* wide two-pane layout, so the 900 px column theme.css gives the panel is
|
||||
* lifted here.
|
||||
*/
|
||||
.hrow,
|
||||
.nrow,
|
||||
main {
|
||||
max-width: none;
|
||||
}
|
||||
.hrow .appname {
|
||||
border-left: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
.hrow .sub {
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.hrow .ver {
|
||||
margin-left: auto;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 12.5px;
|
||||
font-family: ui-monospace, Consolas, monospace;
|
||||
}
|
||||
nav a {
|
||||
cursor: pointer;
|
||||
}
|
||||
main {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
padding: 12px 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
#left {
|
||||
flex: 1 1 640px;
|
||||
min-width: 0;
|
||||
}
|
||||
#right {
|
||||
flex: 0 0 420px;
|
||||
position: sticky;
|
||||
top: 10px;
|
||||
}
|
||||
@media (max-width: 1000px) {
|
||||
main {
|
||||
flex-direction: column;
|
||||
}
|
||||
#right {
|
||||
position: static;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
}
|
||||
/* A run in progress is what the operator is watching: on a phone the
|
||||
run pane goes to the top for its duration. */
|
||||
main.running #right {
|
||||
order: -1;
|
||||
}
|
||||
}
|
||||
.card {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.banner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 18px;
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-size: inherit;
|
||||
}
|
||||
.auth {
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
background: var(--og-red);
|
||||
}
|
||||
.auth.yes {
|
||||
background: var(--og-ok);
|
||||
}
|
||||
.info {
|
||||
color: var(--og-dim);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.info b {
|
||||
color: var(--og-txt);
|
||||
font-weight: 600;
|
||||
}
|
||||
.mono {
|
||||
font-size: 12px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
th {
|
||||
font-size: 11.5px;
|
||||
color: var(--og-dim);
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
padding: 5px 8px;
|
||||
border-bottom: 1px solid var(--og-line);
|
||||
}
|
||||
td {
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--og-line);
|
||||
vertical-align: top;
|
||||
}
|
||||
tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
/* One grid for every subsystem. The groups are separate tables, so
|
||||
left to themselves each sizes its own columns from its own content
|
||||
and no two line up. Fixed widths put them all on the same rails. */
|
||||
#groups .grp {
|
||||
overflow-x: auto;
|
||||
}
|
||||
#groups table {
|
||||
table-layout: fixed;
|
||||
min-width: 700px;
|
||||
}
|
||||
#groups col.k {
|
||||
width: 132px;
|
||||
}
|
||||
#groups col.s {
|
||||
width: 250px;
|
||||
}
|
||||
#groups col.l {
|
||||
width: 210px;
|
||||
}
|
||||
#groups col.a {
|
||||
width: 96px;
|
||||
}
|
||||
/* Why a test stands where it does reads as one block under Status; a
|
||||
requires list of three test ids would otherwise wrap into a ragged
|
||||
stack under the Start button and drag the columns out of line. */
|
||||
#groups .req {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
/* The details sit in a full-width row of their own: inside the Test
|
||||
cell they would have to be read through a column, and opening one
|
||||
would push that group's grid out of step with the rest. */
|
||||
#groups td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
#groups tr.detrow td {
|
||||
padding: 0 8px;
|
||||
border-bottom: 1px solid var(--og-line);
|
||||
}
|
||||
#groups tr.detrow:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
#groups .details {
|
||||
margin: 2px 0 8px;
|
||||
}
|
||||
#groups .details .dsc {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.tid {
|
||||
color: var(--og-dim);
|
||||
font-size: 11.5px;
|
||||
font-family: ui-monospace, Consolas, monospace;
|
||||
}
|
||||
.tag {
|
||||
display: inline-block;
|
||||
font-size: 10.5px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 9px;
|
||||
margin-right: 4px;
|
||||
background: var(--og-line-soft);
|
||||
color: var(--og-txt);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.tag.live {
|
||||
background: var(--og-bad-bg);
|
||||
color: var(--og-red);
|
||||
}
|
||||
.tag.operator {
|
||||
background: var(--og-note-bg);
|
||||
color: var(--og-note-txt);
|
||||
}
|
||||
.tag.takeover {
|
||||
background: var(--og-line-soft);
|
||||
color: var(--og-title);
|
||||
}
|
||||
.tag.mode {
|
||||
background: var(--og-line-soft);
|
||||
color: var(--og-ok);
|
||||
}
|
||||
.tag.core {
|
||||
background: var(--og-navy);
|
||||
color: #fff;
|
||||
}
|
||||
.tag.queued {
|
||||
background: var(--og-line-soft);
|
||||
color: var(--og-action);
|
||||
}
|
||||
.st {
|
||||
font-weight: 600;
|
||||
}
|
||||
.st.pass {
|
||||
color: var(--og-ok);
|
||||
}
|
||||
.st.inherited {
|
||||
color: var(--og-title);
|
||||
}
|
||||
.st.fail,
|
||||
.st.error {
|
||||
color: var(--og-red);
|
||||
}
|
||||
.st.stale,
|
||||
.st.aborted {
|
||||
color: var(--og-warn);
|
||||
}
|
||||
.st.none {
|
||||
color: var(--og-dim);
|
||||
}
|
||||
.st.running {
|
||||
color: var(--og-action);
|
||||
}
|
||||
.req {
|
||||
font-size: 11.5px;
|
||||
color: var(--og-warn);
|
||||
}
|
||||
.req.over {
|
||||
color: var(--og-dim);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.actions .hint {
|
||||
margin: 0;
|
||||
}
|
||||
pre#log {
|
||||
background: var(--og-term-bg);
|
||||
color: var(--og-term-txt);
|
||||
font-family: ui-monospace, Consolas, monospace;
|
||||
font-size: 11.5px;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
height: 380px;
|
||||
overflow: auto;
|
||||
margin: 8px 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
#prompt {
|
||||
background: var(--og-note-bg);
|
||||
border: 1px solid var(--og-note-line);
|
||||
color: var(--og-note-txt);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
#prompt .q {
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
#notice {
|
||||
background: var(--og-line-soft);
|
||||
border: 1px solid var(--og-action);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
margin: 8px 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
#steps {
|
||||
background: var(--og-line-soft);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
margin: 8px 0;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
#steps .sh {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
#steps .sub {
|
||||
color: var(--og-dim);
|
||||
margin: 6px 0 2px;
|
||||
}
|
||||
#steps ol {
|
||||
margin: 2px 0 4px 20px;
|
||||
}
|
||||
#steps .auto {
|
||||
color: var(--og-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
.tsel {
|
||||
cursor: pointer;
|
||||
}
|
||||
.tsel:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.details {
|
||||
display: none;
|
||||
background: var(--og-line-soft);
|
||||
padding: 8px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.details.on {
|
||||
display: block;
|
||||
}
|
||||
.msg {
|
||||
margin: 6px 0;
|
||||
}
|
||||
.err {
|
||||
color: var(--og-red);
|
||||
font-size: 12.5px;
|
||||
margin: 6px 0;
|
||||
}
|
||||
.switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 10px 0 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.switch input {
|
||||
margin: 0;
|
||||
}
|
||||
.switch .on {
|
||||
color: var(--og-warn);
|
||||
font-weight: 600;
|
||||
}
|
||||
.grp {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.queue {
|
||||
background: var(--og-line-soft);
|
||||
margin: 10px 0 0;
|
||||
padding: 10px 12px;
|
||||
box-shadow: none;
|
||||
}
|
||||
.queue h2 {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.qline {
|
||||
font-size: 12.5px;
|
||||
color: var(--og-dim);
|
||||
line-height: 1.7;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.qline b {
|
||||
color: var(--og-txt);
|
||||
}
|
||||
.qbar {
|
||||
display: flex;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
background: var(--og-line);
|
||||
margin: 6px 0;
|
||||
}
|
||||
.qbar i {
|
||||
display: block;
|
||||
}
|
||||
.qbar i.ok {
|
||||
background: var(--og-ok);
|
||||
}
|
||||
.qbar i.bad {
|
||||
background: var(--og-red);
|
||||
}
|
||||
.qbar i.skip {
|
||||
background: var(--og-warn);
|
||||
}
|
||||
.qbar i.now {
|
||||
background: var(--og-action);
|
||||
}
|
||||
.tool {
|
||||
border-bottom: 1px solid var(--og-line);
|
||||
padding: 8px 0;
|
||||
}
|
||||
.tool:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.tool .argrow {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin: 6px 0;
|
||||
align-items: center;
|
||||
}
|
||||
.tool .argrow label {
|
||||
font-size: 12px;
|
||||
color: var(--og-dim);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.tool .argrow input,
|
||||
.tool .argrow select {
|
||||
width: auto;
|
||||
display: inline-block;
|
||||
}
|
||||
.tool .argrow input {
|
||||
width: 90px;
|
||||
}
|
||||
#invform {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
#invform input {
|
||||
flex: 1 1 320px;
|
||||
}
|
||||
@@ -1,840 +0,0 @@
|
||||
/*
|
||||
* theme.css - forgectrl: the OpenGlow theme on top of Bootstrap
|
||||
* Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
* Written by Scott Wiederhold
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Every color the panel uses is a token here, defined once for the light
|
||||
* theme and once more for the dark one; Bootstrap's own component
|
||||
* variables are pointed at the tokens, so the stock components follow the
|
||||
* theme without a Sass build. The visual identity is OpenGlow's
|
||||
* (community.openglow.org): navy header with the starburst wordmark, blue
|
||||
* actions, laser red reserved for the brand accent and fault states. The
|
||||
* header keeps its navy in both themes; the log viewers stay dark in both
|
||||
* because they are terminals.
|
||||
*
|
||||
* Density: the panel is a machine control surface read at a glance, so
|
||||
* everything runs a size tighter than Bootstrap's defaults (13.5 px body,
|
||||
* small controls, compact cards).
|
||||
*/
|
||||
:root,
|
||||
[data-bs-theme='light'] {
|
||||
--og-navy: #2b2b5e;
|
||||
--og-red: #e8262a;
|
||||
--og-action: #0088cc;
|
||||
--og-action-hover: #0077b3;
|
||||
--og-ok: #3d854d;
|
||||
--og-warn: #c7760a;
|
||||
--og-dim: #767a82;
|
||||
--og-bg: #f0f1f4;
|
||||
--og-card: #ffffff;
|
||||
--og-line: #dde0e6;
|
||||
--og-line-soft: #eceef1;
|
||||
--og-txt: #222222;
|
||||
--og-field-bg: #ffffff;
|
||||
--og-field-line: #c9cdd4;
|
||||
--og-placeholder: #a6aab2;
|
||||
--og-note-bg: #fdf3e3;
|
||||
--og-note-line: #eccb90;
|
||||
--og-note-txt: #7a5a12;
|
||||
--og-warn-bg: #fff6e6;
|
||||
--og-warn-txt: #7a4a06;
|
||||
--og-bad-bg: #fdeeee;
|
||||
--og-bad-txt: #7a1f1f;
|
||||
--og-safety-bg: #fdeaea;
|
||||
--og-safety-txt: #8a1518;
|
||||
--og-cam-bg: #e8e9ec;
|
||||
--og-term-bg: #23252b;
|
||||
--og-term-txt: #cfd3da;
|
||||
--og-title: var(--og-navy);
|
||||
--og-nav-on: var(--og-navy);
|
||||
}
|
||||
[data-bs-theme='dark'] {
|
||||
--og-navy: #2b2b5e;
|
||||
--og-red: #f05a5e;
|
||||
--og-action: #2ea3e0;
|
||||
--og-action-hover: #46b3ec;
|
||||
--og-ok: #5fb374;
|
||||
--og-warn: #e09a3a;
|
||||
--og-dim: #9296a2;
|
||||
--og-bg: #15161c;
|
||||
--og-card: #1e2028;
|
||||
--og-line: #32353f;
|
||||
--og-line-soft: #2a2d36;
|
||||
--og-txt: #d9dce3;
|
||||
--og-field-bg: #14151b;
|
||||
--og-field-line: #3d404b;
|
||||
--og-placeholder: #6b6f7a;
|
||||
--og-note-bg: #2e2717;
|
||||
--og-note-line: #6a5323;
|
||||
--og-note-txt: #e3c584;
|
||||
--og-warn-bg: #2f2415;
|
||||
--og-warn-txt: #eab86a;
|
||||
--og-bad-bg: #331c1e;
|
||||
--og-bad-txt: #f0a3a6;
|
||||
--og-safety-bg: #361a1c;
|
||||
--og-safety-txt: #f3a7aa;
|
||||
--og-cam-bg: #2a2d36;
|
||||
--og-term-bg: #0f1014;
|
||||
--og-term-txt: #cfd3da;
|
||||
--og-title: #b9bcd8;
|
||||
--og-nav-on: #e6e8f5;
|
||||
}
|
||||
|
||||
/* ---- Bootstrap variables pointed at the tokens ---------------------- */
|
||||
:root,
|
||||
[data-bs-theme='light'],
|
||||
[data-bs-theme='dark'] {
|
||||
--bs-body-bg: var(--og-bg);
|
||||
--bs-body-color: var(--og-txt);
|
||||
--bs-body-font-size: 13.5px;
|
||||
--bs-body-font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
--bs-border-color: var(--og-line);
|
||||
--bs-secondary-color: var(--og-dim);
|
||||
--bs-link-color: var(--og-action);
|
||||
--bs-link-hover-color: var(--og-action-hover);
|
||||
--bs-link-color-rgb: 0, 136, 204;
|
||||
--bs-link-hover-color-rgb: 0, 119, 179;
|
||||
--bs-primary: var(--og-action);
|
||||
--bs-danger: var(--og-red);
|
||||
--bs-success: var(--og-ok);
|
||||
--bs-warning: var(--og-warn);
|
||||
--bs-font-monospace: ui-monospace, Consolas, monospace;
|
||||
}
|
||||
.btn-primary {
|
||||
--bs-btn-bg: var(--og-action);
|
||||
--bs-btn-border-color: var(--og-action);
|
||||
--bs-btn-hover-bg: var(--og-action-hover);
|
||||
--bs-btn-hover-border-color: var(--og-action-hover);
|
||||
--bs-btn-active-bg: var(--og-action-hover);
|
||||
--bs-btn-active-border-color: var(--og-action-hover);
|
||||
--bs-btn-disabled-bg: var(--og-action);
|
||||
--bs-btn-disabled-border-color: var(--og-action);
|
||||
--bs-btn-font-weight: 600;
|
||||
}
|
||||
.btn-outline-secondary {
|
||||
--bs-btn-color: var(--og-txt);
|
||||
--bs-btn-border-color: var(--og-field-line);
|
||||
--bs-btn-bg: var(--og-field-bg);
|
||||
--bs-btn-hover-color: var(--og-txt);
|
||||
--bs-btn-hover-bg: var(--og-field-bg);
|
||||
--bs-btn-hover-border-color: var(--og-nav-on);
|
||||
--bs-btn-active-color: var(--og-txt);
|
||||
--bs-btn-active-bg: var(--og-line-soft);
|
||||
--bs-btn-active-border-color: var(--og-nav-on);
|
||||
--bs-btn-disabled-color: var(--og-txt);
|
||||
--bs-btn-disabled-bg: var(--og-field-bg);
|
||||
--bs-btn-disabled-border-color: var(--og-field-line);
|
||||
}
|
||||
.btn-danger {
|
||||
--bs-btn-bg: var(--og-red);
|
||||
--bs-btn-border-color: var(--og-red);
|
||||
--bs-btn-font-weight: 600;
|
||||
}
|
||||
.btn:disabled,
|
||||
.btn.disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.form-control,
|
||||
.form-select {
|
||||
--bs-body-bg: var(--og-field-bg);
|
||||
--bs-border-color: var(--og-field-line);
|
||||
color: var(--og-txt);
|
||||
}
|
||||
.form-control:focus,
|
||||
.form-select:focus {
|
||||
border-color: var(--og-action);
|
||||
box-shadow: 0 0 0 0.15rem rgba(0, 136, 204, 0.2);
|
||||
}
|
||||
.form-control::placeholder {
|
||||
color: var(--og-placeholder);
|
||||
}
|
||||
.form-control.dirty,
|
||||
.form-select.dirty {
|
||||
border-color: var(--og-warn);
|
||||
}
|
||||
.card {
|
||||
--bs-card-bg: var(--og-card);
|
||||
--bs-card-border-color: var(--og-line);
|
||||
--bs-card-border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
margin: 12px 0;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.popover {
|
||||
--bs-popover-max-width: 400px;
|
||||
--bs-popover-font-size: 12.5px;
|
||||
--bs-popover-bg: var(--og-card);
|
||||
--bs-popover-border-color: var(--og-line);
|
||||
--bs-popover-body-color: var(--og-txt);
|
||||
--bs-popover-header-bg: var(--og-line-soft);
|
||||
--bs-popover-header-color: var(--og-title);
|
||||
--bs-popover-header-font-size: 12px;
|
||||
}
|
||||
.popover-header {
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
}
|
||||
.popover-body p {
|
||||
margin: 0 0 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.popover-body p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.popover-body .doclink {
|
||||
display: inline-block;
|
||||
margin-top: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.modal {
|
||||
--bs-modal-bg: var(--og-card);
|
||||
--bs-modal-border-color: var(--og-line);
|
||||
}
|
||||
.toast {
|
||||
--bs-toast-bg: var(--og-card);
|
||||
--bs-toast-border-color: var(--og-line);
|
||||
--bs-toast-color: var(--og-txt);
|
||||
--bs-toast-font-size: 13px;
|
||||
}
|
||||
.toast.t-ok {
|
||||
border-left: 4px solid var(--og-ok);
|
||||
}
|
||||
.toast.t-bad {
|
||||
border-left: 4px solid var(--og-red);
|
||||
}
|
||||
|
||||
/* ---- The release dialog -------------------------------------------- */
|
||||
#rel-view h3 {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--og-dim);
|
||||
margin: 12px 0 4px;
|
||||
}
|
||||
.relnotes {
|
||||
max-height: 40vh;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--og-line);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
margin: 4px 0 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.relnotes h1,
|
||||
.relnotes h2,
|
||||
.relnotes h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin: 10px 0 4px;
|
||||
}
|
||||
.relnotes h1:first-child,
|
||||
.relnotes h2:first-child,
|
||||
.relnotes h3:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.relnotes p,
|
||||
.relnotes ul {
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
.relnotes ul {
|
||||
padding-left: 20px;
|
||||
}
|
||||
.relnotes code {
|
||||
font-size: 12px;
|
||||
}
|
||||
.relnotes table {
|
||||
border-collapse: collapse;
|
||||
margin: 4px 0 8px;
|
||||
}
|
||||
.relnotes th,
|
||||
.relnotes td {
|
||||
border: 1px solid var(--og-line);
|
||||
padding: 2px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
.relwarn {
|
||||
background: var(--og-warn-bg);
|
||||
border: 1px solid var(--og-warn);
|
||||
color: var(--og-warn-txt);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
margin: 0 0 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.relsteps {
|
||||
margin: 0 0 12px;
|
||||
padding-left: 22px;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.relsteps li {
|
||||
color: var(--og-dim);
|
||||
}
|
||||
.relsteps li.cur {
|
||||
color: var(--og-txt);
|
||||
font-weight: 600;
|
||||
}
|
||||
.relsteps li.done {
|
||||
color: var(--og-ok);
|
||||
}
|
||||
.relsteps li.done::after {
|
||||
content: ' \2713';
|
||||
}
|
||||
.progress {
|
||||
--bs-progress-bg: var(--og-line-soft);
|
||||
--bs-progress-bar-bg: var(--og-action);
|
||||
--bs-progress-height: 10px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
#rel-phase {
|
||||
min-height: 1.4em;
|
||||
font-size: 12.5px;
|
||||
margin: 0;
|
||||
}
|
||||
#rel-err {
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
|
||||
/* ---- Chrome: header, tabs, footer ---------------------------------- */
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
body.has-savebar {
|
||||
padding-bottom: 64px;
|
||||
}
|
||||
header {
|
||||
background: var(--og-navy);
|
||||
}
|
||||
.hrow {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 10px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
.appname {
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.25);
|
||||
padding-left: 14px;
|
||||
}
|
||||
#host {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
font-size: 12.5px;
|
||||
margin-left: auto;
|
||||
}
|
||||
#themebtn,
|
||||
#logsbtn,
|
||||
#logoutbtn {
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 5px;
|
||||
padding: 3px 9px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#themebtn:hover,
|
||||
#logsbtn:hover,
|
||||
#logoutbtn:hover {
|
||||
color: #fff;
|
||||
border-color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
nav {
|
||||
background: var(--og-card);
|
||||
border-bottom: 1px solid var(--og-line);
|
||||
}
|
||||
.nrow {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
nav a {
|
||||
padding: 9px 13px;
|
||||
color: var(--og-dim);
|
||||
text-decoration: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
nav a.on {
|
||||
color: var(--og-nav-on);
|
||||
font-weight: 600;
|
||||
border-color: var(--og-red);
|
||||
}
|
||||
nav a:hover {
|
||||
color: var(--og-nav-on);
|
||||
}
|
||||
main {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 4px 20px 12px;
|
||||
}
|
||||
section {
|
||||
display: none;
|
||||
}
|
||||
section.on {
|
||||
display: block;
|
||||
}
|
||||
footer {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 6px 20px 28px;
|
||||
color: var(--og-dim);
|
||||
font-size: 12.5px;
|
||||
text-align: center;
|
||||
}
|
||||
footer a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* ---- Cards and settings rows --------------------------------------- */
|
||||
.card h2 {
|
||||
font-size: 12px;
|
||||
margin: 0 0 8px;
|
||||
color: var(--og-title);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.9px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.card h3 {
|
||||
font-size: 11.5px;
|
||||
margin: 12px 0 4px;
|
||||
color: var(--og-dim);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.card h3:first-of-type {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.grid2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0 24px;
|
||||
}
|
||||
.frow {
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr;
|
||||
gap: 6px 10px;
|
||||
align-items: center;
|
||||
margin: 5px 0;
|
||||
}
|
||||
.frow label {
|
||||
color: var(--og-txt);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.frow .u {
|
||||
color: var(--og-dim);
|
||||
font-size: 11.5px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.extadvdoc {
|
||||
max-height: 22rem;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.extadvdoc h1,
|
||||
.extadvdoc h2 {
|
||||
font-size: 1rem;
|
||||
margin: 0.6rem 0 0.3rem;
|
||||
}
|
||||
.extcaps {
|
||||
margin: 0.2rem 0 0.2rem 1.1rem;
|
||||
padding: 0;
|
||||
}
|
||||
.extcaps li {
|
||||
margin: 0;
|
||||
}
|
||||
.hint {
|
||||
color: var(--og-dim);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
.btns {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.msg {
|
||||
font-size: 12.5px;
|
||||
color: var(--og-action);
|
||||
}
|
||||
.mono {
|
||||
font-family: ui-monospace, Consolas, monospace;
|
||||
}
|
||||
.brk {
|
||||
word-break: break-all;
|
||||
}
|
||||
/* The help button: a small ring that opens the field's popover. */
|
||||
.hlp {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--og-dim);
|
||||
background: transparent;
|
||||
color: var(--og-dim);
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.hlp:hover,
|
||||
.hlp[aria-describedby] {
|
||||
color: var(--og-action);
|
||||
border-color: var(--og-action);
|
||||
}
|
||||
|
||||
/* ---- Status widgets ------------------------------------------------ */
|
||||
.kv {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 5px 2px;
|
||||
border-bottom: 1px solid var(--og-line-soft);
|
||||
font-size: 13px;
|
||||
}
|
||||
.kv:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.kv > span:first-child {
|
||||
color: var(--og-dim);
|
||||
}
|
||||
.kv .btn {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.b-ok {
|
||||
color: var(--og-ok);
|
||||
font-weight: 600;
|
||||
}
|
||||
.b-run {
|
||||
color: var(--og-action);
|
||||
font-weight: 600;
|
||||
}
|
||||
.b-bad {
|
||||
color: var(--og-red);
|
||||
font-weight: 600;
|
||||
}
|
||||
.b-dim {
|
||||
color: var(--og-dim);
|
||||
}
|
||||
.b-warn {
|
||||
color: var(--og-warn);
|
||||
font-weight: 600;
|
||||
}
|
||||
.bigpass {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--og-ok);
|
||||
}
|
||||
.bigfail {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--og-red);
|
||||
}
|
||||
.seg {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.segbtn {
|
||||
padding: 7px 20px;
|
||||
border-radius: 6px;
|
||||
background: var(--og-field-bg);
|
||||
color: var(--og-txt);
|
||||
border: 1px solid var(--og-field-line);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.segbtn:hover {
|
||||
border-color: var(--og-nav-on);
|
||||
}
|
||||
.segbtn.segon {
|
||||
background: var(--og-navy);
|
||||
border-color: var(--og-navy);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.segbtn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
#cam,
|
||||
#camvid {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--og-line);
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
background: var(--og-cam-bg);
|
||||
min-height: 60px;
|
||||
}
|
||||
.camwrap {
|
||||
text-align: center;
|
||||
}
|
||||
.camctl {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
}
|
||||
.btn.on {
|
||||
background: var(--og-navy);
|
||||
border-color: var(--og-navy);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ---- Banners ------------------------------------------------------- */
|
||||
.banner {
|
||||
background: var(--og-warn-bg);
|
||||
border: 1px solid var(--og-warn);
|
||||
color: var(--og-warn-txt);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
margin: 0 0 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.banner a {
|
||||
color: inherit;
|
||||
font-weight: 600;
|
||||
}
|
||||
.banner-bad {
|
||||
border-color: var(--og-red);
|
||||
background: var(--og-bad-bg);
|
||||
color: var(--og-bad-txt);
|
||||
}
|
||||
.safety {
|
||||
background: var(--og-safety-bg);
|
||||
border: 1px solid var(--og-red);
|
||||
color: var(--og-safety-txt);
|
||||
border-radius: 8px;
|
||||
padding: 7px 12px;
|
||||
margin: 8px 0 10px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
#locknote,
|
||||
.note {
|
||||
display: none;
|
||||
background: var(--og-note-bg);
|
||||
border: 1px solid var(--og-note-line);
|
||||
color: var(--og-note-txt);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
margin: 10px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.note {
|
||||
display: block;
|
||||
margin: 8px 0 0;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.gnotes {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
margin: 4px 0 6px;
|
||||
}
|
||||
.gnotes .gn-warn {
|
||||
color: var(--og-warn);
|
||||
}
|
||||
.gnotes .gn-off {
|
||||
color: var(--og-red);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ---- Terminals: the diagnostic log and the log viewer -------------- */
|
||||
#diaglog,
|
||||
#logview {
|
||||
background: var(--og-term-bg);
|
||||
color: var(--og-term-txt);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
font-family: ui-monospace, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
#diaglog {
|
||||
line-height: 1.6;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
display: none;
|
||||
}
|
||||
#logview {
|
||||
line-height: 1.5;
|
||||
height: 380px;
|
||||
overflow: auto;
|
||||
white-space: pre;
|
||||
}
|
||||
.logtbl {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
.logtbl th {
|
||||
text-align: left;
|
||||
color: var(--og-dim);
|
||||
font-weight: 600;
|
||||
font-size: 11.5px;
|
||||
padding: 3px 6px;
|
||||
border-bottom: 1px solid var(--og-line);
|
||||
}
|
||||
.logtbl td {
|
||||
padding: 3px 6px;
|
||||
border-bottom: 1px solid var(--og-line-soft);
|
||||
}
|
||||
.logtbl select {
|
||||
width: auto;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* ---- The fuse identity dialog -------------------------------------- */
|
||||
#fusewrap {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(20, 22, 30, 0.55);
|
||||
z-index: 1050;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
#fusewrap.on {
|
||||
display: flex;
|
||||
}
|
||||
.fusebox {
|
||||
background: var(--og-card);
|
||||
border: 1px solid var(--og-line);
|
||||
border-radius: 10px;
|
||||
padding: 16px 20px;
|
||||
max-width: 560px;
|
||||
width: 92%;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.fusebox h2 {
|
||||
font-size: 12px;
|
||||
margin: 0 0 8px;
|
||||
color: var(--og-title);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.9px;
|
||||
}
|
||||
|
||||
/* ---- The save bar -------------------------------------------------- */
|
||||
#savebar {
|
||||
display: none;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1040;
|
||||
background: var(--og-card);
|
||||
border-top: 1px solid var(--og-line);
|
||||
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
#savebar.on {
|
||||
display: block;
|
||||
}
|
||||
.sbrow {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 9px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#sb-count {
|
||||
font-weight: 600;
|
||||
color: var(--og-warn);
|
||||
}
|
||||
#sb-msg {
|
||||
font-size: 12.5px;
|
||||
color: var(--og-red);
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.frow {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2px 0;
|
||||
}
|
||||
.grid2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.hrow,
|
||||
.nrow,
|
||||
main,
|
||||
.sbrow {
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* The setup's log-bundle line, under the header: the result of the
|
||||
header's Download logs, or why the machine refused it. */
|
||||
.wz-logs-msg {
|
||||
max-width: 1100px;
|
||||
margin: 6px auto 0;
|
||||
padding: 0 16px;
|
||||
min-height: 16px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* A package's card on the Extensions tab: the card and its heading (the
|
||||
label) are the panel's, the frame is the package's, and the frame is
|
||||
given a size rather than taking one. The frame takes the panel's color
|
||||
scheme, which the page inside reads as its prefers-color-scheme; it is
|
||||
transparent, so a page that paints no background sits on the card. */
|
||||
.extcard h2 .mono {
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
font-weight: 400;
|
||||
color: var(--og-dim);
|
||||
}
|
||||
.extframe {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 360px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color-scheme: light;
|
||||
}
|
||||
[data-bs-theme='dark'] .extframe {
|
||||
color-scheme: dark;
|
||||
}
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2011-2025 The Bootstrap Authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-72
@@ -1,72 +0,0 @@
|
||||
2026-08-17T09:42:09.781500+00:00 gfcloud[1522] INFO gfuiservice:run motion (ready) service action motion
|
||||
2026-08-17T09:42:11.281337+00:00 gfcloud[1522] INFO cnc:set_step_freq 28160
|
||||
2026-08-17T09:42:11.282627+00:00 gfcloud[1522] INFO cnc:set_x_decay 1
|
||||
2026-08-17T09:42:11.283850+00:00 gfcloud[1522] INFO cnc:set_x_mode 8
|
||||
2026-08-17T09:42:11.285121+00:00 gfcloud[1522] INFO cnc:set_x_current 135
|
||||
2026-08-17T09:42:11.286711+00:00 gfcloud[1522] INFO cnc:set_y_decay 1
|
||||
2026-08-17T09:42:11.287977+00:00 gfcloud[1522] INFO cnc:set_y_mode 8
|
||||
2026-08-17T09:42:11.289235+00:00 gfcloud[1522] INFO cnc:set_y_current 22
|
||||
2026-08-17T09:42:11.297425+00:00 gfcloud[1522] INFO machine:_run_loop starting run
|
||||
2026-08-17T09:42:11.299141+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
|
||||
2026-08-17T09:42:11.303264+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.RUNNING
|
||||
2026-08-17T09:42:12.560917+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
|
||||
2026-08-17T09:42:12.572022+00:00 gfcloud[1522] INFO machine:_run_loop finished run
|
||||
2026-08-17T09:42:12.572811+00:00 gfcloud[1522] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:42:12.580801+00:00 gfcloud[1522] INFO machine:_motion_locked end positions (actual/expected): X (-10313/-10313), Y (-649/-649), Z (0/0)
|
||||
2026-08-17T09:42:12.581620+00:00 gfcloud[1522] INFO machine:_motion_locked motion bytes actual:34952, expected: 34952
|
||||
2026-08-17T09:42:12.582366+00:00 gfcloud[1522] INFO machine:_motion_locked start idle
|
||||
2026-08-17T09:42:12.583540+00:00 gfcloud[1522] INFO cnc:set_x_current 33
|
||||
2026-08-17T09:42:12.584928+00:00 gfcloud[1522] INFO cnc:set_y_current 5
|
||||
2026-08-17T09:42:12.593681+00:00 gfcloud[1522] INFO machine:_motion_locked end positions (-10313, -649, 0)
|
||||
2026-08-17T09:42:12.604394+00:00 gfcloud[1522] INFO machine:_motion end motion
|
||||
2026-08-17T09:42:12.604757+00:00 gfcloud[1522] INFO basemachine:_finish_action motion [1576550503]: finished with event ":completed"
|
||||
2026-08-17T09:42:12.605824+00:00 gfcloud[1522] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:42:15.672518+00:00 gfcloud[1522] INFO gfuiservice:run service action request: print (ready)
|
||||
2026-08-17T09:42:15.676330+00:00 gfcloud[1522] INFO machine:_motion start motion
|
||||
2026-08-17T09:42:15.680419+00:00 gfcloud[1522] INFO gfuiservice:run print (ready) service action print
|
||||
2026-08-17T09:42:21.975587+00:00 gfcloud[1522] INFO cnc:laser_latch 0
|
||||
2026-08-17T09:42:22.001354+00:00 gfcloud[1522] INFO machine:_button_wait waiting for button
|
||||
2026-08-17T09:42:24.774117+00:00 gfcloud[1522] INFO machine:_switch_event button pushed
|
||||
2026-08-17T09:42:24.778509+00:00 gfcloud[1522] INFO cnc:set_step_freq 10000
|
||||
2026-08-17T09:42:24.781114+00:00 gfcloud[1522] INFO cnc:set_x_decay 1
|
||||
2026-08-17T09:42:24.783654+00:00 gfcloud[1522] INFO cnc:set_x_mode 8
|
||||
2026-08-17T09:42:24.786180+00:00 gfcloud[1522] INFO cnc:set_x_current 135
|
||||
2026-08-17T09:42:24.789088+00:00 gfcloud[1522] INFO cnc:set_y_decay 1
|
||||
2026-08-17T09:42:24.791559+00:00 gfcloud[1522] INFO cnc:set_y_mode 8
|
||||
2026-08-17T09:42:24.794380+00:00 gfcloud[1522] INFO cnc:set_y_current 22
|
||||
2026-08-17T09:42:24.815263+00:00 gfcloud[1522] INFO machine:_run_loop starting run
|
||||
2026-08-17T09:42:24.816687+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
|
||||
2026-08-17T09:42:24.820823+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.RUNNING
|
||||
2026-08-17T09:42:25.049489+00:00 gfcloud[1522] INFO machine:_switch_event button released
|
||||
2026-08-17T09:42:30.832527+00:00 gfcloud[1522] INFO machine:_switch_event lid opened
|
||||
2026-08-17T09:42:30.838627+00:00 gfcloud[1522] WARNING machine:_run_loop lid opened mid-run; stopping motion
|
||||
2026-08-17T09:42:30.941553+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
|
||||
2026-08-17T09:42:30.947477+00:00 gfcloud[1522] INFO machine:_run_loop finished run
|
||||
2026-08-17T09:42:30.949099+00:00 gfcloud[1522] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:42:30.964916+00:00 gfcloud[1522] INFO machine:_motion_locked end positions (actual/expected): X (8454/7573), Y (684/684), Z (3/3)
|
||||
2026-08-17T09:42:30.965704+00:00 gfcloud[1522] INFO machine:_motion_locked motion bytes actual:60649, expected: 238043
|
||||
2026-08-17T09:42:30.972180+00:00 gfcloud[1522] INFO machine:_return_home start return home
|
||||
2026-08-17T09:42:31.847247+00:00 gfcloud[1522] INFO machine:_run_loop starting run
|
||||
2026-08-17T09:42:31.848617+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
|
||||
2026-08-17T09:42:31.852977+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.RUNNING
|
||||
2026-08-17T09:42:36.970521+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
|
||||
2026-08-17T09:42:36.976059+00:00 gfcloud[1522] INFO machine:_run_loop finished run
|
||||
2026-08-17T09:42:36.977738+00:00 gfcloud[1522] INFO machine:_return_home return home complete
|
||||
2026-08-17T09:42:36.979696+00:00 gfcloud[1522] INFO machine:_motion_locked start cool down
|
||||
2026-08-17T09:42:36.998973+00:00 gfcloud[1522] INFO machine:_motion_locked start idle
|
||||
2026-08-17T09:42:37.000314+00:00 gfcloud[1522] INFO cnc:set_x_current 33
|
||||
2026-08-17T09:42:37.001774+00:00 gfcloud[1522] INFO cnc:set_y_current 5
|
||||
2026-08-17T09:42:37.010089+00:00 gfcloud[1522] INFO machine:_motion_locked end positions (0, 0, 3)
|
||||
2026-08-17T09:42:37.010885+00:00 gfcloud[1522] INFO machine:_motion end motion
|
||||
2026-08-17T09:42:37.011563+00:00 gfcloud[1522] INFO basemachine:_finish_action print [1576550507]: finished with event ":cancelled"
|
||||
2026-08-17T09:42:37.013071+00:00 gfcloud[1522] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:42:37.449180+00:00 gfcloud[1522] INFO gfuiservice:run service action request: hunt (ready)
|
||||
2026-08-17T09:42:37.451286+00:00 gfcloud[1522] INFO z_axis:home starting z homing cycle
|
||||
2026-08-17T09:42:37.452782+00:00 gfcloud[1522] INFO gfuiservice:run hunt (ready) service action hunt
|
||||
2026-08-17T09:42:37.453691+00:00 gfcloud[1522] INFO z_axis:configure configuring z_enable: True, current: ZCur.HIGH, mode: Microstep.FULL
|
||||
2026-08-17T09:42:37.458283+00:00 gfcloud[1522] INFO z_axis:_step_until starting z_step until at_home: False
|
||||
2026-08-17T09:42:37.827610+00:00 gfcloud[1522] INFO z_axis:_step_until took -2 z_steps
|
||||
2026-08-17T09:42:37.829278+00:00 gfcloud[1522] INFO z_axis:_step_until starting z_step until at_home: True
|
||||
2026-08-17T09:42:38.200493+00:00 gfcloud[1522] INFO z_axis:_step_until took 2 z_steps
|
||||
2026-08-17T09:42:38.202234+00:00 gfcloud[1522] INFO z_axis:home pass: 1
|
||||
2026-08-17T09:42:38.203788+00:00 gfcloud[1522] INFO z_axis:_step_until starting z_step until at_home: False
|
||||
-54
@@ -1,54 +0,0 @@
|
||||
2026-08-17T09:42:15.672518+00:00 gfcloud[1522] INFO gfuiservice:run service action request: print (ready)
|
||||
2026-08-17T09:42:15.676330+00:00 gfcloud[1522] INFO machine:_motion start motion
|
||||
2026-08-17T09:42:15.680419+00:00 gfcloud[1522] INFO gfuiservice:run print (ready) service action print
|
||||
2026-08-17T09:42:21.975587+00:00 gfcloud[1522] INFO cnc:laser_latch 0
|
||||
2026-08-17T09:42:22.001354+00:00 gfcloud[1522] INFO machine:_button_wait waiting for button
|
||||
2026-08-17T09:42:24.774117+00:00 gfcloud[1522] INFO machine:_switch_event button pushed
|
||||
2026-08-17T09:42:24.778509+00:00 gfcloud[1522] INFO cnc:set_step_freq 10000
|
||||
2026-08-17T09:42:24.781114+00:00 gfcloud[1522] INFO cnc:set_x_decay 1
|
||||
2026-08-17T09:42:24.783654+00:00 gfcloud[1522] INFO cnc:set_x_mode 8
|
||||
2026-08-17T09:42:24.786180+00:00 gfcloud[1522] INFO cnc:set_x_current 135
|
||||
2026-08-17T09:42:24.789088+00:00 gfcloud[1522] INFO cnc:set_y_decay 1
|
||||
2026-08-17T09:42:24.791559+00:00 gfcloud[1522] INFO cnc:set_y_mode 8
|
||||
2026-08-17T09:42:24.794380+00:00 gfcloud[1522] INFO cnc:set_y_current 22
|
||||
2026-08-17T09:42:24.815263+00:00 gfcloud[1522] INFO machine:_run_loop starting run
|
||||
2026-08-17T09:42:24.816687+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
|
||||
2026-08-17T09:42:24.820823+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.RUNNING
|
||||
2026-08-17T09:42:25.049489+00:00 gfcloud[1522] INFO machine:_switch_event button released
|
||||
2026-08-17T09:42:31.104201+00:00 gfcloud[1522] INFO machine:_switch_event button pushed
|
||||
2026-08-17T09:42:31.106877+00:00 gfcloud[1522] INFO machine:_run_loop button pressed mid-run; pausing
|
||||
2026-08-17T09:42:31.288114+00:00 gfcloud[1522] INFO machine:_switch_event button released
|
||||
2026-08-17T09:42:31.612040+00:00 gfcloud[1522] INFO machine:_run_loop paused at Position(x=AxisPosition(steps=1210, mm=22.68, inch=0.89), y=AxisPosition(steps=402, mm=7.54, inch=0.30), z=AxisPosition(steps=3, mm=0.0, inch=0.0), bytes=PulsPosition(total=238043, processed=61200))
|
||||
2026-08-17T09:42:34.702311+00:00 gfcloud[1522] INFO machine:_switch_event button pushed
|
||||
2026-08-17T09:42:34.704560+00:00 gfcloud[1522] INFO machine:_run_loop button pressed while paused
|
||||
2026-08-17T09:42:34.706100+00:00 gfcloud[1522] INFO machine:_resume_retraced resuming (laser lead 1950 ticks)
|
||||
2026-08-17T09:42:34.901002+00:00 gfcloud[1522] INFO machine:_switch_event button released
|
||||
2026-08-17T09:42:58.130807+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
|
||||
2026-08-17T09:42:58.136283+00:00 gfcloud[1522] INFO machine:_run_loop finished run
|
||||
2026-08-17T09:42:58.137979+00:00 gfcloud[1522] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:42:58.151159+00:00 gfcloud[1522] INFO machine:_motion_locked end positions (actual/expected): X (7573/7573), Y (684/684), Z (3/3)
|
||||
2026-08-17T09:42:58.152103+00:00 gfcloud[1522] INFO machine:_motion_locked motion bytes actual:238043, expected: 238043
|
||||
2026-08-17T09:42:58.160180+00:00 gfcloud[1522] INFO machine:_return_home start return home
|
||||
2026-08-17T09:42:59.047247+00:00 gfcloud[1522] INFO machine:_run_loop starting run
|
||||
2026-08-17T09:42:59.048617+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
|
||||
2026-08-17T09:42:59.052977+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.RUNNING
|
||||
2026-08-17T09:43:04.170521+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
|
||||
2026-08-17T09:43:04.176059+00:00 gfcloud[1522] INFO machine:_run_loop finished run
|
||||
2026-08-17T09:43:04.177738+00:00 gfcloud[1522] INFO machine:_return_home return home complete
|
||||
2026-08-17T09:43:04.179696+00:00 gfcloud[1522] INFO machine:_motion_locked start cool down
|
||||
2026-08-17T09:43:04.198973+00:00 gfcloud[1522] INFO machine:_motion_locked start idle
|
||||
2026-08-17T09:43:04.200314+00:00 gfcloud[1522] INFO cnc:set_x_current 33
|
||||
2026-08-17T09:43:04.201774+00:00 gfcloud[1522] INFO cnc:set_y_current 5
|
||||
2026-08-17T09:43:04.210089+00:00 gfcloud[1522] INFO machine:_motion_locked end positions (0, 0, 3)
|
||||
2026-08-17T09:43:04.210885+00:00 gfcloud[1522] INFO machine:_motion end motion
|
||||
2026-08-17T09:43:04.211563+00:00 gfcloud[1522] INFO basemachine:_finish_action print [1576550507]: finished with event ":completed"
|
||||
2026-08-17T09:43:04.213071+00:00 gfcloud[1522] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:43:04.649180+00:00 gfcloud[1522] INFO gfuiservice:run service action request: hunt (ready)
|
||||
2026-08-17T09:43:04.651286+00:00 gfcloud[1522] INFO z_axis:home starting z homing cycle
|
||||
2026-08-17T09:43:04.652782+00:00 gfcloud[1522] INFO gfuiservice:run hunt (ready) service action hunt
|
||||
2026-08-17T09:43:11.298209+00:00 gfcloud[1522] INFO machine:_motion start motion
|
||||
2026-08-17T09:43:11.983820+00:00 gfcloud[1522] INFO machine:_run_loop starting run
|
||||
2026-08-17T09:43:13.630807+00:00 gfcloud[1522] INFO machine:_run_loop current state: MachineState.IDLE
|
||||
2026-08-17T09:43:13.636283+00:00 gfcloud[1522] INFO machine:_run_loop finished run
|
||||
2026-08-17T09:43:13.664853+00:00 gfcloud[1522] INFO machine:_motion end motion
|
||||
2026-08-17T09:43:13.665575+00:00 gfcloud[1522] INFO basemachine:_finish_action hunt [1576550560]: finished with event ":completed"
|
||||
@@ -1,281 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""Shared fixtures for the forgetest unit tests: synthetic manifests and
|
||||
catalog entries that never touch hardware."""
|
||||
import hashlib
|
||||
import os
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.dirname(HERE))
|
||||
|
||||
from forgetest import catalog as catalog_mod # noqa: E402
|
||||
from forgetest import manifest as manifest_mod # noqa: E402
|
||||
|
||||
|
||||
def blob(text):
|
||||
"""A git-style blob id for text (what ls-tree would report)."""
|
||||
data = text.encode()
|
||||
return hashlib.sha1(b"blob %d\0" % len(data) + data).hexdigest()
|
||||
|
||||
|
||||
def make_manifest(components=None, platform=None, version="20260101000000 (dev)", name="forgefirm-image-dev"):
|
||||
comps = components if components is not None else {
|
||||
"forgectrl": {"srcrev": "aaa", "files": [["src/main.c", blob("main")], ["src/ui.c", blob("ui")],
|
||||
["src/auth.c", blob("auth")], ["src/cool.c", blob("cool")],
|
||||
["README.md", blob("readme")]]},
|
||||
"grblhal-glowforge": {"srcrev": "bbb", "files": [["src/driver.c", blob("drv")],
|
||||
["src/grbl", "cccc"], ["src/grbl/core.c", blob("core")]]},
|
||||
"kernel-module-glowforge": {"srcrev": "ddd", "files": [["src/cnc.c", blob("cnc")]]},
|
||||
"linux-fslc": {"srcrev": "eee", "files": [["@srcrev", "eee"], ["@config", "cfg1"]]},
|
||||
"forgetest": {"srcrev": None, "files": [["forgetest/x.py", blob("x")]]},
|
||||
}
|
||||
plat = platform if platform is not None else {
|
||||
"machine": "glowforge", "kernel_modules": ["6.12.20-fslc+g0e01ec9f0d3f"],
|
||||
"dtb": {"glowforge.dtb": "d" * 64},
|
||||
"layers": {"meta-forgefirm": {"content_sha256": "1" * 64}, "poky": {"rev": "p" * 40}},
|
||||
}
|
||||
data = {"format": 1, "image": {"name": name, "version": version},
|
||||
"components": comps, "platform": plat}
|
||||
data["content_sha256"] = manifest_mod.sha256_text(
|
||||
manifest_mod.canonical({"components": comps, "platform": plat}))
|
||||
return manifest_mod.Manifest(data)
|
||||
|
||||
|
||||
def with_file(manifest, component, path, text):
|
||||
"""A copy of the manifest with one file's content changed/added."""
|
||||
import copy
|
||||
data = copy.deepcopy(manifest.data)
|
||||
files = data["components"][component]["files"]
|
||||
for f in files:
|
||||
if f[0] == path:
|
||||
f[1] = blob(text)
|
||||
break
|
||||
else:
|
||||
files.append([path, blob(text)])
|
||||
data["content_sha256"] = manifest_mod.sha256_text(
|
||||
manifest_mod.canonical({"components": data["components"], "platform": data["platform"]}))
|
||||
return manifest_mod.Manifest(data)
|
||||
|
||||
|
||||
def with_platform(manifest, **changes):
|
||||
import copy
|
||||
data = copy.deepcopy(manifest.data)
|
||||
data["platform"].update(changes)
|
||||
data["content_sha256"] = manifest_mod.sha256_text(
|
||||
manifest_mod.canonical({"components": data["components"], "platform": data["platform"]}))
|
||||
return manifest_mod.Manifest(data)
|
||||
|
||||
|
||||
def _noop(ctx):
|
||||
pass
|
||||
|
||||
|
||||
def make_test(id, covers, always=False, requires=(), kind="auto", fn=None, subsystem=None, mode=None,
|
||||
actions=(), precheck=None, steps=(), hands=()):
|
||||
return catalog_mod.Test(id, "Title " + id, subsystem or id.split(".")[0], kind, "api",
|
||||
covers, requires, always, 1, steps, "desc", fn or _noop, mode=mode,
|
||||
actions=actions, precheck=precheck, hands=hands)
|
||||
|
||||
|
||||
def registry(*tests):
|
||||
return {t.id: t for t in tests}
|
||||
|
||||
|
||||
# ------------------------------------------------------- fake forgectrl
|
||||
|
||||
class FakeForgectrl:
|
||||
"""A stand-in for the machine-services daemon on localhost: canned
|
||||
JSON for the endpoints the suite reads, a mutable `state` the test
|
||||
scripts, every POST recorded, and an optional `on_post(path, form)`
|
||||
hook returning (status, body) to script the daemon's reactions.
|
||||
Point the suite at it with FORGECTRL_URL (see start/stop)."""
|
||||
|
||||
def __init__(self):
|
||||
import http.server
|
||||
import json as _json
|
||||
import threading as _threading
|
||||
import urllib.parse as _up
|
||||
self.state = {
|
||||
"mode": {"mode": "grbl", "controller": "running", "pid": 100, "motion": "verified"},
|
||||
"status": {"state": "idle", "homed": False, "diag": False, "laser_locked": True,
|
||||
"pos": {"x": 0.0, "y": 0.0, "z": 0.0},
|
||||
"switches": {"lid": True, "button": False, "interlock_ok": True,
|
||||
"head": True, "hv_enable": False}},
|
||||
"cool": {"phase": "idle", "armed": False, "hold": False},
|
||||
"cam": {"running": False, "clients": 0},
|
||||
"diag": {"running": False},
|
||||
"settings": {"controller_mode": "grbl", "lid_lamp_idle": ""},
|
||||
"logs_tail": {"name": "forgectrl", "text": "", "truncated": False, "exists": True},
|
||||
}
|
||||
self.posts = []
|
||||
self.on_post = None
|
||||
self.on_get = None # (path, query) -> (status, body) | (status, bytes, ctype) | None
|
||||
fake = self
|
||||
|
||||
class H(http.server.BaseHTTPRequestHandler):
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
def _send(self, st, body):
|
||||
data = _json.dumps(body).encode()
|
||||
self.send_response(st)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def do_GET(self):
|
||||
path, _, query = self.path.partition("?")
|
||||
if fake.on_get:
|
||||
r = fake.on_get(path, dict(_up.parse_qsl(query, keep_blank_values=True)))
|
||||
if r is not None:
|
||||
if isinstance(r[1], (bytes, bytearray)):
|
||||
self.send_response(r[0])
|
||||
self.send_header("Content-Type", r[2] if len(r) > 2 else "application/octet-stream")
|
||||
self.send_header("Content-Length", str(len(r[1])))
|
||||
self.end_headers()
|
||||
self.wfile.write(r[1])
|
||||
return
|
||||
return self._send(r[0], r[1])
|
||||
key = {"/mode": "mode", "/status": "status", "/cool/status": "cool", "/cam/status": "cam",
|
||||
"/diag/status": "diag", "/settings": "settings",
|
||||
"/logs/tail": "logs_tail"}.get(path)
|
||||
if key is None:
|
||||
return self._send(404, {"error": "no " + path})
|
||||
self._send(200, fake.state[key])
|
||||
|
||||
def do_POST(self):
|
||||
path, _, query = self.path.partition("?")
|
||||
n = int(self.headers.get("Content-Length") or 0)
|
||||
raw = self.rfile.read(n).decode() if n else ""
|
||||
form = dict(_up.parse_qsl(raw if raw else query, keep_blank_values=True))
|
||||
fake.posts.append((path, form))
|
||||
if fake.on_post:
|
||||
r = fake.on_post(path, form)
|
||||
if r is not None:
|
||||
return self._send(*r)
|
||||
if path == "/mode" and form.get("controller"):
|
||||
fake.state["mode"] = dict(fake.state["mode"], mode=form["controller"], controller="running",
|
||||
pid=fake.state["mode"].get("pid", 0) + 1)
|
||||
fake.state["settings"]["controller_mode"] = form["controller"]
|
||||
elif path == "/settings":
|
||||
fake.state["settings"].update(form)
|
||||
return self._send(200, fake.state["settings"])
|
||||
self._send(200, {"ok": True})
|
||||
|
||||
self._srv = http.server.ThreadingHTTPServer(("127.0.0.1", 0), H)
|
||||
self._srv.daemon_threads = True
|
||||
self._srv.block_on_close = False # never wait on a lingering connection
|
||||
self.url = "http://127.0.0.1:%d" % self._srv.server_address[1]
|
||||
self._th = _threading.Thread(target=self._srv.serve_forever, daemon=True)
|
||||
|
||||
def start(self):
|
||||
self._th.start()
|
||||
os.environ["FORGECTRL_URL"] = self.url
|
||||
os.environ["FORGECTRL_TOKEN_FILE"] = os.devnull
|
||||
return self
|
||||
|
||||
def stop(self):
|
||||
self._srv.shutdown()
|
||||
self._srv.server_close()
|
||||
os.environ.pop("FORGECTRL_URL", None)
|
||||
os.environ.pop("FORGECTRL_TOKEN_FILE", None)
|
||||
|
||||
|
||||
# ------------------------------------------------------------ fake grbl
|
||||
|
||||
class FakeGrbl:
|
||||
"""A Grbl-over-TCP stand-in for the controller: answers '?' with a
|
||||
status report built from its mutable `state`/`mpos`, 'ok' to every
|
||||
command line, and records what it was sent. Point the suite at it
|
||||
with GRBL_HOST/GRBL_PORT (see start/stop)."""
|
||||
|
||||
def __init__(self):
|
||||
import socket as _socket
|
||||
import threading as _threading
|
||||
self.state = "Idle"
|
||||
self.mpos = [0.0, 0.0, 0.0]
|
||||
self.sent = []
|
||||
self.extra = b"" # text pushed to the client on the next poll
|
||||
self.reset_to = None # the state a soft reset (^X) lands in, when set
|
||||
self.on_command = None # called with every command line received
|
||||
self._sock = _socket.socket()
|
||||
self._sock.setsockopt(_socket.SOL_SOCKET, _socket.SO_REUSEADDR, 1)
|
||||
self._sock.bind(("127.0.0.1", 0))
|
||||
self._sock.listen(5)
|
||||
self.port = self._sock.getsockname()[1]
|
||||
self._stop = False
|
||||
self._th = _threading.Thread(target=self._serve, daemon=True)
|
||||
|
||||
def _serve(self):
|
||||
import socket as _socket
|
||||
import threading as _threading
|
||||
self._sock.settimeout(0.2)
|
||||
while not self._stop:
|
||||
try:
|
||||
c, _ = self._sock.accept()
|
||||
except (_socket.timeout, OSError):
|
||||
continue
|
||||
_threading.Thread(target=self._client, args=(c,), daemon=True).start()
|
||||
|
||||
def _client(self, c):
|
||||
import socket as _socket
|
||||
c.settimeout(0.1)
|
||||
buf = b""
|
||||
try:
|
||||
c.sendall(b"\r\nGrblHAL 1.1f ['$' for help]\r\n")
|
||||
while not self._stop:
|
||||
try:
|
||||
d = c.recv(4096)
|
||||
if not d:
|
||||
break
|
||||
buf += d
|
||||
except _socket.timeout:
|
||||
continue
|
||||
except OSError:
|
||||
break
|
||||
out = b""
|
||||
if self.extra:
|
||||
out += self.extra
|
||||
self.extra = b""
|
||||
while buf:
|
||||
if buf[0:1] == b"?":
|
||||
buf = buf[1:]
|
||||
out += ("<%s|MPos:%.3f,%.3f,%.3f|FS:0,0>\r\n" % (self.state, *self.mpos)).encode()
|
||||
elif buf[0] in (0x18, 0x85, 0x7E, 0x21):
|
||||
if buf[0] == 0x18:
|
||||
self.sent.append("^X")
|
||||
if self.reset_to:
|
||||
self.state = self.reset_to
|
||||
buf = buf[1:]
|
||||
elif b"\n" in buf:
|
||||
line, buf = buf.split(b"\n", 1)
|
||||
self.sent.append(line.strip().decode("utf-8", "replace"))
|
||||
if self.on_command:
|
||||
self.on_command(self.sent[-1])
|
||||
out += b"ok\r\n"
|
||||
else:
|
||||
break
|
||||
if out:
|
||||
c.sendall(out)
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
def start(self):
|
||||
self._th.start()
|
||||
os.environ["GRBL_HOST"] = "127.0.0.1"
|
||||
os.environ["GRBL_PORT"] = str(self.port)
|
||||
return self
|
||||
|
||||
def stop(self):
|
||||
self._stop = True
|
||||
try:
|
||||
self._sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
for k in ("GRBL_HOST", "GRBL_PORT"):
|
||||
os.environ.pop(k, None)
|
||||
@@ -1,148 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
import copy
|
||||
import json
|
||||
import unittest
|
||||
|
||||
import helpers
|
||||
from forgetest import artifact, campaign, catalog
|
||||
from forgetest import manifest as manifest_mod
|
||||
from test_campaign import rec_campaign, rec_result
|
||||
|
||||
|
||||
class ArtifactTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.man = helpers.make_manifest()
|
||||
self.core = helpers.make_test("image.health", [("linux-fslc", "**")], always=True)
|
||||
self.ui = helpers.make_test("forgectrl.panel", [("forgectrl", "src/ui.c")])
|
||||
self.cool = helpers.make_test("cooling.flow", [("forgectrl", "src/cool.c")])
|
||||
self.reg = helpers.registry(self.core, self.ui, self.cool)
|
||||
self.tests = list(self.reg.values())
|
||||
self.chash = catalog.catalog_hash(self.reg)
|
||||
# campaign 1 on the base image: everything passes
|
||||
self.recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z")]
|
||||
for i, t in enumerate(self.tests):
|
||||
self.recs.append(rec_result("c1", t, self.man, "PASS", "2026-08-20T10:0%d:00Z" % (i + 1)))
|
||||
# image 2: cool.c changed; campaign 2 reruns core + cool
|
||||
self.man2 = helpers.with_file(self.man, "forgectrl", "src/cool.c", "cool v2")
|
||||
self.recs.append(rec_campaign("c2", self.man2, self.chash, "2026-08-21T10:00:00Z"))
|
||||
self.recs.append(rec_result("c2", self.core, self.man2, "PASS", "2026-08-21T10:01:00Z"))
|
||||
self.recs.append(rec_result("c2", self.cool, self.man2, "PASS", "2026-08-21T10:02:00Z"))
|
||||
|
||||
def build(self, man=None):
|
||||
man = man or self.man2
|
||||
st = campaign.compute(self.recs, self.tests, man, self.chash)
|
||||
return artifact.build(st, self.tests, man, self.recs, self.chash), st
|
||||
|
||||
def release_manifest(self, man=None):
|
||||
"""The release build's manifest: same identity, no forgetest component."""
|
||||
man = man or self.man2
|
||||
data = copy.deepcopy(man.data)
|
||||
data["components"].pop("forgetest", None)
|
||||
data["image"] = {"name": "forgefirm-image", "version": "v0.1.0"}
|
||||
return manifest_mod.Manifest(data)
|
||||
|
||||
def test_build_and_verify(self):
|
||||
art, st = self.build()
|
||||
self.assertTrue(art["authorized"])
|
||||
by = {t["id"]: t for t in art["tests"]}
|
||||
self.assertTrue(by["forgectrl.panel"]["inherited"])
|
||||
self.assertEqual(by["forgectrl.panel"]["record"]["campaign"], "c1")
|
||||
self.assertFalse(by["image.health"]["inherited"])
|
||||
text = artifact.to_json(art)
|
||||
art2 = json.loads(text)
|
||||
ok, rows, problems = artifact.verify(art2, self.release_manifest(), self.tests, self.chash,
|
||||
expect_machine="glowforge")
|
||||
self.assertEqual(problems, [])
|
||||
self.assertTrue(ok)
|
||||
md = artifact.to_markdown(art)
|
||||
self.assertIn("Release authorized: YES", md)
|
||||
self.assertIn("forgectrl.panel", md)
|
||||
|
||||
def test_tamper_detected(self):
|
||||
art, _ = self.build()
|
||||
art2 = json.loads(artifact.to_json(art))
|
||||
art2["tests"][1]["record"]["result"] = "PASS"
|
||||
art2["counts"]["required"] = 0
|
||||
art2["authorized"] = True
|
||||
art2["tests"][2]["record"]["ts"] = "2026-08-21T10:02:01Z"
|
||||
ok, rows, problems = artifact.verify(art2, self.release_manifest(), self.tests, self.chash)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("self-hash", problems[0])
|
||||
|
||||
def test_release_differs_in_covered_file(self):
|
||||
art, _ = self.build()
|
||||
rel = self.release_manifest(helpers.with_file(self.man2, "forgectrl", "src/ui.c", "ui v3"))
|
||||
ok, rows, problems = artifact.verify(art, rel, self.tests, self.chash)
|
||||
self.assertFalse(ok)
|
||||
self.assertTrue(any("forgectrl.panel" in p and "fingerprint" in p for p in problems), problems)
|
||||
# an uncovered change is fine
|
||||
rel2 = self.release_manifest(helpers.with_file(self.man2, "forgectrl", "README.md", "docs"))
|
||||
ok, rows, problems = artifact.verify(art, rel2, self.tests, self.chash)
|
||||
self.assertTrue(ok, problems)
|
||||
|
||||
def test_release_platform_differs(self):
|
||||
art, _ = self.build()
|
||||
rel = self.release_manifest(helpers.with_platform(self.man2, dtb={"glowforge.dtb": "0" * 64}))
|
||||
ok, rows, problems = artifact.verify(art, rel, self.tests, self.chash)
|
||||
self.assertFalse(ok)
|
||||
self.assertEqual(len([r for r in rows if not r["ok"]]), 3)
|
||||
|
||||
def test_unauthorized_artifact(self):
|
||||
self.recs.append(rec_result("c2", self.ui, self.man2, "FAIL", "2026-08-21T10:03:00Z"))
|
||||
art, st = self.build()
|
||||
self.assertFalse(art["authorized"])
|
||||
ok, rows, problems = artifact.verify(art, self.release_manifest(), self.tests, self.chash)
|
||||
self.assertFalse(ok)
|
||||
self.assertTrue(any("does not claim authorization" in p for p in problems))
|
||||
|
||||
def test_core_inherited_refused(self):
|
||||
art, _ = self.build()
|
||||
# forge an artifact whose core record is marked inherited (self-hash recomputed)
|
||||
body = json.loads(artifact.to_json(art))
|
||||
body.pop("sha256")
|
||||
for t in body["tests"]:
|
||||
if t["id"] == "image.health":
|
||||
t["inherited"] = True
|
||||
body["sha256"] = manifest_mod.sha256_text(manifest_mod.canonical(body))
|
||||
ok, rows, problems = artifact.verify(body, self.release_manifest(), self.tests, self.chash)
|
||||
self.assertFalse(ok)
|
||||
self.assertTrue(any("always-required" in p for p in problems), problems)
|
||||
|
||||
def test_invalidate_epoch(self):
|
||||
art, _ = self.build()
|
||||
body = json.loads(artifact.to_json(art))
|
||||
body.pop("sha256")
|
||||
body["invalidate"] = {"t": "invalidate", "ts": "2026-08-20T12:00:00Z", "reason": "tube"}
|
||||
body["sha256"] = manifest_mod.sha256_text(manifest_mod.canonical(body))
|
||||
ok, rows, problems = artifact.verify(body, self.release_manifest(), self.tests, self.chash)
|
||||
self.assertFalse(ok)
|
||||
self.assertTrue(any("predates the invalidate" in p for p in problems), problems)
|
||||
|
||||
def test_catalog_changed(self):
|
||||
art, _ = self.build()
|
||||
reg = helpers.registry(self.core, self.ui, self.cool, helpers.make_test("new.one", []))
|
||||
tests = list(reg.values())
|
||||
ok, rows, problems = artifact.verify(art, self.release_manifest(), tests, catalog.catalog_hash(reg))
|
||||
self.assertFalse(ok)
|
||||
self.assertTrue(any("catalog changed" in p for p in problems))
|
||||
self.assertTrue(any("new.one" in p for p in problems))
|
||||
|
||||
def test_implementation_changed(self):
|
||||
art, _ = self.build()
|
||||
body = json.loads(artifact.to_json(art))
|
||||
body.pop("sha256")
|
||||
for t in body["tests"]:
|
||||
if t["id"] == "cooling.flow":
|
||||
t["source_sha"] = "0" * 64
|
||||
body["sha256"] = manifest_mod.sha256_text(manifest_mod.canonical(body))
|
||||
ok, rows, problems = artifact.verify(body, self.release_manifest(), self.tests, self.chash)
|
||||
self.assertFalse(ok)
|
||||
self.assertTrue(any("implementation changed" in p for p in problems), problems)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,728 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The baseline: fixed resting values are restored, preserved values are
|
||||
handed back, every deviation is a recorded leftover. Runs against a fake
|
||||
sysfs tree; forgectrl is unreachable (service-side checks skip)."""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import struct
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from forgetest import baseline
|
||||
|
||||
# The fixed machine tick at the mode an unset xy_microsteps reads as.
|
||||
# Taken from baseline rather than typed, so a change to the default mode
|
||||
# moves the expectations with it.
|
||||
DEFAULT_TICK = dict(baseline.fixed_sysfs())["cnc/step_freq"]
|
||||
|
||||
|
||||
class BaselineFixture:
|
||||
"""The fake sysfs tree and LED class a baseline runs against. It has
|
||||
no tests of its own, so a class that mixes it in runs only its own."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.mkdtemp(prefix="forgetest-bl-")
|
||||
self.sysfs = os.path.join(self.tmp, "sysfs") + os.sep
|
||||
self.leds = os.path.join(self.tmp, "leds") + os.sep
|
||||
for group in ("cnc", "pic", "head", "thermal"):
|
||||
os.makedirs(self.sysfs + group)
|
||||
for name in baseline.BUTTON_LEDS + ("lid_led",):
|
||||
os.makedirs(self.leds + name)
|
||||
self._led(name, "0")
|
||||
# A clean machine at the mode an unset xy_microsteps reads as:
|
||||
# x/y_mode, step_freq and ramp_rate are the mode's, not the x8
|
||||
# literals in FIXED_SYSFS, and that is what enforce() compares
|
||||
# against.
|
||||
for attr, val in baseline.fixed_sysfs() + baseline.IDLE_READBACKS:
|
||||
self._attr(attr, val)
|
||||
self._attr("cnc/interlock_circuit", "45")
|
||||
self._attr("pic/lid_led", "0")
|
||||
self._pos(0, 0, 0)
|
||||
os.environ["GF_SYSFS_ROOT"] = self.sysfs
|
||||
os.environ["GF_LEDS_ROOT"] = self.leds
|
||||
os.environ["FORGECTRL_URL"] = "http://127.0.0.1:1" # nothing listens
|
||||
baseline.Baseline._unreachable_until = 0.0
|
||||
self.lines = []
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||
for k in ("GF_SYSFS_ROOT", "GF_LEDS_ROOT", "FORGECTRL_URL"):
|
||||
os.environ.pop(k, None)
|
||||
|
||||
def _attr(self, attr, val):
|
||||
with open(self.sysfs + attr, "w") as f:
|
||||
f.write(str(val))
|
||||
|
||||
def _read(self, attr):
|
||||
with open(self.sysfs + attr) as f:
|
||||
return f.read().strip()
|
||||
|
||||
def _led(self, name, val):
|
||||
with open(self.leds + name + "/brightness", "w") as f:
|
||||
f.write(val)
|
||||
# the class interface writes 'target'; the fake mirrors it into brightness
|
||||
# only when the test asks (see _sync_leds)
|
||||
|
||||
def _sync_leds(self):
|
||||
for name in baseline.BUTTON_LEDS:
|
||||
p = self.leds + name + "/target"
|
||||
if os.path.exists(p):
|
||||
with open(p) as f:
|
||||
v = f.read().strip()
|
||||
with open(self.leds + name + "/brightness", "w") as f:
|
||||
f.write(v)
|
||||
|
||||
def _pos(self, x, y, z):
|
||||
with open(self.sysfs + "cnc/position", "wb") as f:
|
||||
f.write(struct.pack("<5i", x, y, z, 0, 0))
|
||||
|
||||
def bl(self):
|
||||
return baseline.Baseline(self.lines.append)
|
||||
|
||||
def _pos_bytes(self, x, y, z, processed, total):
|
||||
with open(self.sysfs + "cnc/position", "wb") as f:
|
||||
f.write(struct.pack("<3i2I", x, y, z, processed, total))
|
||||
|
||||
def _probe_state(self):
|
||||
# what the kernel shows between the supervisor's motion probe and
|
||||
# the GRBL controller's init writes
|
||||
self._attr("cnc/motor_lock", "0")
|
||||
self._attr("cnc/step_freq", "10000")
|
||||
self._attr("cnc/y_mode", "1")
|
||||
|
||||
|
||||
class BaselineTests(BaselineFixture, unittest.TestCase):
|
||||
def test_clean_machine_has_no_leftovers(self):
|
||||
left = self.bl().enforce("pre", captured=None)
|
||||
self.assertEqual(left, [])
|
||||
self.assertTrue(any("pre: clean" in l for l in self.lines))
|
||||
|
||||
def test_fixed_values_are_restored_and_recorded(self):
|
||||
self._attr("cnc/motor_lock", "15")
|
||||
self._attr("cnc/step_freq", "10000")
|
||||
self._attr("cnc/streaming", "1")
|
||||
left = self.bl().enforce("post", captured=None)
|
||||
items = {x.item: x for x in left}
|
||||
self.assertEqual(set(items), {"cnc/motor_lock", "cnc/step_freq", "cnc/streaming"})
|
||||
for x in left:
|
||||
self.assertEqual(x.action, "restored", str(x))
|
||||
self.assertEqual(self._read("cnc/motor_lock"), "0")
|
||||
self.assertEqual(self._read("cnc/step_freq"), DEFAULT_TICK)
|
||||
self.assertEqual(self._read("cnc/streaming"), "0")
|
||||
self.assertEqual(items["cnc/motor_lock"].found, "15")
|
||||
self.assertEqual(items["cnc/motor_lock"].expected, "0")
|
||||
|
||||
def test_unlocked_latch_is_relocked(self):
|
||||
self._attr("cnc/interlock_circuit", "5") # bit 3 clear = unlocked
|
||||
left = self.bl().enforce("post", captured=None)
|
||||
self.assertEqual([x.item for x in left], ["laser_latch"])
|
||||
self.assertEqual(self._read("cnc/laser_latch"), "1")
|
||||
|
||||
def test_readonly_deviation_is_unrestorable(self):
|
||||
self._attr("cnc/state", "disabled")
|
||||
left = self.bl().enforce("post", captured=None)
|
||||
self.assertEqual([(x.item, x.action) for x in left], [("cnc/state", "unrestorable")])
|
||||
|
||||
def test_button_leds_are_turned_off(self):
|
||||
self._led("button_led_2", "255")
|
||||
left = self.bl().enforce("post", captured=None)
|
||||
self.assertEqual([x.item for x in left], ["leds/button_led_2"])
|
||||
self._sync_leds()
|
||||
self.assertEqual(baseline.read_led("button_led_2"), "0")
|
||||
|
||||
def test_preserved_position(self):
|
||||
b = self.bl()
|
||||
cap = b.capture()
|
||||
self.assertEqual(cap["position"], [0, 0, 0])
|
||||
# the run shifted the counters
|
||||
self._pos(1000, 0, 0)
|
||||
left = b.enforce("post", captured=cap)
|
||||
items = {x.item: x for x in left}
|
||||
self.assertEqual(set(items), {"position"})
|
||||
# no GRBL controller on the host: the head cannot be jogged back
|
||||
self.assertTrue(items["position"].action.startswith("unrestorable"), items["position"].action)
|
||||
self.assertEqual(items["position"].found, [1000, 0, 0])
|
||||
|
||||
def test_a_lost_counter_frame_never_moves_the_head(self):
|
||||
# The controller zeroes the step counters at every start and at
|
||||
# every home, and rewrites its anchor then. A run that began at
|
||||
# -6400 steps and restarted the controller ends at 0 with the head
|
||||
# where it was: the difference is not a distance, and the hand-back
|
||||
# must neither jog the head nor call it a leftover.
|
||||
anchor = os.path.join(self.tmp, "grblhal.homed")
|
||||
old_path, baseline.ANCHOR_PATH = baseline.ANCHOR_PATH, anchor
|
||||
try:
|
||||
with open(anchor, "w") as f:
|
||||
f.write("0 0 0 4 startup")
|
||||
self._pos(-6400, 0, 0)
|
||||
b = self.bl()
|
||||
cap = b.capture()
|
||||
self.assertIsNotNone(cap["frame"])
|
||||
os.unlink(anchor) # a controller start: a new anchor file
|
||||
with open(anchor + ".new", "w") as f:
|
||||
f.write("0 0 0 4 startup")
|
||||
os.replace(anchor + ".new", anchor)
|
||||
os.utime(anchor, ns=(5, 5)) # a freed inode can come straight back, inside one clock tick
|
||||
self._pos(0, 0, 0)
|
||||
left = b.enforce("post", captured=cap)
|
||||
self.assertEqual([x.item for x in left], [])
|
||||
self.assertTrue(any("re-zeroed its counters during the run" in l for l in self.lines), self.lines)
|
||||
# the same counters with the frame intact are a displaced head, as before
|
||||
self.lines.clear()
|
||||
self._pos(-6400, 0, 0)
|
||||
cap = b.capture()
|
||||
self._pos(0, 0, 0)
|
||||
left = b.enforce("post", captured=cap)
|
||||
self.assertEqual([x.item for x in left], ["position"])
|
||||
# and a test that vouches for the new frame is held to it
|
||||
self._pos(-6400, 0, 0)
|
||||
cap = b.capture()
|
||||
os.utime(anchor, ns=(1, 1)) # re-zeroed, and declared (ctx.counters_rezeroed)
|
||||
cap["position"], cap["rezero_declared"] = [0, 0, 0], True
|
||||
self._pos(0, 0, 0)
|
||||
self.assertEqual(b.enforce("post", captured=cap), [])
|
||||
self._pos(1000, 0, 0)
|
||||
self.assertEqual([x.item for x in b.enforce("post", captured=cap)], ["position"])
|
||||
finally:
|
||||
baseline.ANCHOR_PATH = old_path
|
||||
|
||||
def test_the_counters_are_read_at_the_modes_scale(self):
|
||||
# 6400 steps is 30 mm at x32 (a return within the bound; on the
|
||||
# host it stops at the missing controller) and 120 mm at x8
|
||||
# (beyond the bound). Read at the x8 scale, an x32 machine's
|
||||
# displaced head is never jogged back.
|
||||
b = self.bl()
|
||||
cap = b.capture()
|
||||
self._attr("cnc/x_mode", "32")
|
||||
self._pos(6400, 0, 0)
|
||||
items = {x.item: x for x in b.enforce("post", captured=cap)}
|
||||
self.assertEqual(items["position"].action, "unrestorable: no running GRBL controller")
|
||||
self._attr("cnc/x_mode", "8")
|
||||
items = {x.item: x for x in b.enforce("post", captured=cap)}
|
||||
self.assertEqual(items["position"].action, "unrestorable: 120.0/0.0 mm exceeds 100 mm")
|
||||
|
||||
def test_a_held_controller_is_reset_before_the_return_jog(self):
|
||||
# a pause test that failed while held leaves the controller in
|
||||
# Hold, which refuses a jog: the baseline resets out of it first
|
||||
import helpers
|
||||
fc = helpers.FakeForgectrl().start()
|
||||
dev = helpers.FakeGrbl().start()
|
||||
try:
|
||||
dev.state = "Hold:0"
|
||||
dev.reset_to = "Idle"
|
||||
dev.on_command = lambda line: self._pos(0, 0, 0) if line.startswith("$J=") else None
|
||||
b = self.bl()
|
||||
cap = b.capture()
|
||||
# 4.144 mm into the held move, in the steps of the mode in force
|
||||
held_mm = 4.144
|
||||
self._pos(round(held_mm * baseline.xy_steps_per_mm(baseline.XY_MODE_DEFAULT)), 0, 0)
|
||||
left = b.enforce("post", captured=cap)
|
||||
items = {x.item: x for x in left}
|
||||
self.assertTrue(items["position"].action.startswith("restored"), items["position"].action)
|
||||
self.assertEqual(dev.sent[0], "^X")
|
||||
jogs = [l for l in dev.sent if l.startswith("$J=")]
|
||||
self.assertEqual(len(jogs), 1)
|
||||
self.assertIn("X-%.3f" % held_mm, jogs[0])
|
||||
self.assertTrue(any("reset out of Hold:0" in l for l in self.lines), self.lines)
|
||||
finally:
|
||||
dev.stop()
|
||||
fc.stop()
|
||||
for k in ("GRBL_HOST", "GRBL_PORT"):
|
||||
os.environ.pop(k, None)
|
||||
|
||||
def test_ring_residue_is_a_leftover_and_blocks_the_return_jog(self):
|
||||
b = self.bl()
|
||||
cap = b.capture()
|
||||
# the run left 40 unplayed bytes queued in the kernel ring and the
|
||||
# head 1000 counts out: the hand-back stands the machine down to
|
||||
# empty the ring, and only refuses the return jog when the bytes
|
||||
# are still there afterwards (they are here: no daemon to restart
|
||||
# a controller)
|
||||
self._pos_bytes(1000, 0, 0, 100, 140)
|
||||
left = b.enforce("post", captured=cap)
|
||||
items = {x.item: x for x in left}
|
||||
self.assertIn("pulse ring", items)
|
||||
self.assertEqual(items["pulse ring"].found, "40 unplayed bytes")
|
||||
self.assertIn("still queued", items["position"].action)
|
||||
self.assertIn("after a controller restart", items["position"].action)
|
||||
self.assertEqual(baseline.read_ring_residue(), 40)
|
||||
|
||||
def test_the_hand_back_stands_the_machine_down_for_residue(self):
|
||||
"""It acts, it does not report: a run that left bytes in the ring
|
||||
gets a controller restart out of the hand-back, because that is
|
||||
what empties the ring."""
|
||||
b = self.bl()
|
||||
cap = b.capture()
|
||||
called = []
|
||||
b.stand_down = lambda why: called.append(why)
|
||||
self._pos_bytes(1000, 0, 0, 100, 140)
|
||||
b.enforce("post", captured=cap)
|
||||
self.assertEqual(len(called), 1)
|
||||
self.assertIn("unplayed bytes", called[0])
|
||||
|
||||
def test_clean_ring_reads_zero_residue(self):
|
||||
self.assertEqual(baseline.read_ring_residue(), 0)
|
||||
|
||||
def test_lamp_needs_forgectrl(self):
|
||||
# the lamp's idle level comes from forgectrl's settings: without the
|
||||
# daemon there is nothing to compare against
|
||||
self._attr("pic/lid_led", "77")
|
||||
left = self.bl().enforce("pre", captured=None)
|
||||
self.assertEqual(left, [])
|
||||
self.assertEqual(self._read("pic/lid_led"), "77")
|
||||
|
||||
def test_no_sysfs_means_skip(self):
|
||||
os.environ["GF_SYSFS_ROOT"] = os.path.join(self.tmp, "nope") + os.sep
|
||||
left = self.bl().enforce("pre", captured=None)
|
||||
self.assertEqual(left, [])
|
||||
self.assertTrue(any("kernel sysfs not present" in l for l in self.lines))
|
||||
|
||||
def test_boot_reference_needs_a_recent_boot(self):
|
||||
os.environ["FORGETEST_BOOT_ID"] = "test-boot"
|
||||
try:
|
||||
# no reference file, uptime unknown on a host without /proc/uptime,
|
||||
# or too old: None, with the reason logged
|
||||
ref = baseline.boot_reference(self.lines.append, self.tmp)
|
||||
up = baseline.uptime_s()
|
||||
if up is None or up > baseline.BOOT_MAX_AGE_S:
|
||||
self.assertIsNone(ref)
|
||||
self.assertTrue(any("no fresh-boot reference" in l for l in self.lines))
|
||||
else:
|
||||
# a young host: the reference is taken from the fake tree
|
||||
self.assertIsNotNone(ref)
|
||||
self.assertEqual(ref["sysfs"]["cnc/motor_lock"], "0")
|
||||
self.assertTrue(os.path.exists(os.path.join(self.tmp, "boot-test-boot.json")))
|
||||
# and loaded back the second time
|
||||
self.lines[:] = []
|
||||
ref2 = baseline.boot_reference(self.lines.append, self.tmp)
|
||||
self.assertEqual(ref2["ts"], ref["ts"])
|
||||
self.assertTrue(any("reference loaded" in l for l in self.lines))
|
||||
finally:
|
||||
os.environ.pop("FORGETEST_BOOT_ID", None)
|
||||
|
||||
def test_fixed_constants_checked_against_a_dump(self):
|
||||
ref = {"sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000"}}
|
||||
diffs = baseline.check_fixed_against(ref, self.lines.append)
|
||||
self.assertEqual(diffs, ["cnc/step_freq: boot=10000 constant=%s" % DEFAULT_TICK])
|
||||
|
||||
|
||||
# -- the reference is taken after the controller applied its config -----
|
||||
|
||||
def test_wait_configured_returns_once_the_controller_wrote_its_config(self):
|
||||
self._probe_state()
|
||||
calls = {"n": 0}
|
||||
|
||||
def sleep(_s):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 3: # the controller's init writes land
|
||||
# at the mode wait_controller_configured watches by default
|
||||
for attr, val in baseline.configured_markers():
|
||||
self._attr(attr, val)
|
||||
ok = baseline.wait_controller_configured(
|
||||
self.lines.append, {"controller": "running", "mode": "grbl", "motion": "verified"},
|
||||
timeout=5, sleep=sleep)
|
||||
self.assertTrue(ok)
|
||||
self.assertTrue(any("controller configured" in l for l in self.lines))
|
||||
self.assertGreaterEqual(calls["n"], 4) # 3 polls + the settle
|
||||
|
||||
def test_wait_configured_times_out_and_says_so(self):
|
||||
self._probe_state()
|
||||
t = {"now": 0.0}
|
||||
real_time = baseline.time.time
|
||||
baseline.time.time = lambda: t["now"]
|
||||
try:
|
||||
def sleep(s):
|
||||
t["now"] += s
|
||||
ok = baseline.wait_controller_configured(
|
||||
self.lines.append, {"controller": "running", "mode": "grbl"}, timeout=2, sleep=sleep)
|
||||
finally:
|
||||
baseline.time.time = real_time
|
||||
self.assertFalse(ok)
|
||||
self.assertTrue(any("did not apply its config" in l for l in self.lines))
|
||||
|
||||
def test_wait_configured_is_a_noop_outside_grbl_mode(self):
|
||||
self._probe_state()
|
||||
ok = baseline.wait_controller_configured(
|
||||
self.lines.append, {"controller": "running", "mode": "cloud"}, timeout=1,
|
||||
sleep=lambda s: self.fail("slept in cloud mode"))
|
||||
self.assertTrue(ok)
|
||||
ok = baseline.wait_controller_configured(
|
||||
self.lines.append, {"controller": "stopped", "mode": "grbl"}, timeout=1,
|
||||
sleep=lambda s: self.fail("slept with the controller stopped"))
|
||||
self.assertTrue(ok)
|
||||
|
||||
def test_preconfig_reference_is_recognized(self):
|
||||
self.assertTrue(baseline.reference_preconfig(
|
||||
{"sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000", "cnc/y_mode": "1"}}))
|
||||
# The markers are read at the reference's own mode, so a dump taken
|
||||
# on an x8 machine carries the setting that says so.
|
||||
self.assertFalse(baseline.reference_preconfig(
|
||||
{"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "28160", "cnc/y_mode": "8"},
|
||||
"forgectrl": {"/settings": {"xy_microsteps": "8"}}}))
|
||||
# a genuinely different single constant is a machine fact, not pre-config
|
||||
self.assertFalse(baseline.reference_preconfig(
|
||||
{"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "10000", "cnc/y_mode": "8"},
|
||||
"forgectrl": {"/settings": {"xy_microsteps": "8"}}}))
|
||||
self.assertFalse(baseline.reference_preconfig({"sysfs": {}}))
|
||||
|
||||
# -- the XY microstep mode drives the fixed values ------------------------
|
||||
|
||||
def test_the_fixed_values_follow_the_microstep_mode(self):
|
||||
x8 = dict(baseline.fixed_sysfs(8))
|
||||
self.assertEqual(x8, dict(baseline.FIXED_SYSFS))
|
||||
x16 = dict(baseline.fixed_sysfs(16))
|
||||
self.assertEqual((x16["cnc/x_mode"], x16["cnc/y_mode"], x16["cnc/step_freq"], x16["cnc/ramp_rate"]),
|
||||
("16", "16", "56320", "250000"))
|
||||
x32 = dict(baseline.fixed_sysfs(32))
|
||||
self.assertEqual((x32["cnc/x_mode"], x32["cnc/step_freq"], x32["cnc/ramp_rate"]),
|
||||
("32", "112640", "500000"))
|
||||
# everything else is the same list, in the same order
|
||||
self.assertEqual([a for a, _ in baseline.fixed_sysfs(32)], [a for a, _ in baseline.FIXED_SYSFS])
|
||||
self.assertEqual(x32["cnc/x_decay"], x8["cnc/x_decay"])
|
||||
|
||||
def test_the_mode_is_read_from_the_settings_with_a_default(self):
|
||||
# An unset or unusable key reads as the driver's default, x32.
|
||||
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "16"}), 16)
|
||||
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "32"}), 32)
|
||||
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "8"}), 8)
|
||||
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": ""}), 32)
|
||||
self.assertEqual(baseline.xy_mode_of({}), 32)
|
||||
self.assertEqual(baseline.xy_mode_of(None), 32)
|
||||
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "24"}), 32)
|
||||
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "abc"}), 32)
|
||||
self.assertEqual(baseline.ref_xy_mode({"forgectrl": {"/settings": {"xy_microsteps": "16"}}}), 16)
|
||||
self.assertEqual(baseline.ref_xy_mode({"forgectrl": {"/settings": None}}), 32)
|
||||
self.assertEqual(baseline.ref_xy_mode({}), 32)
|
||||
|
||||
def test_fixed_constants_are_checked_at_the_reference_mode(self):
|
||||
ref = {"sysfs": {"cnc/x_mode": "16", "cnc/step_freq": "56320", "cnc/ramp_rate": "250000"},
|
||||
"forgectrl": {"/settings": {"xy_microsteps": "16"}}}
|
||||
self.assertEqual(baseline.check_fixed_against(ref, self.lines.append), [])
|
||||
ref["forgectrl"]["/settings"]["xy_microsteps"] = "8"
|
||||
diffs = baseline.check_fixed_against(ref, self.lines.append)
|
||||
self.assertEqual(diffs, ["cnc/x_mode: boot=16 constant=8",
|
||||
"cnc/step_freq: boot=56320 constant=28160",
|
||||
"cnc/ramp_rate: boot=250000 constant=125000"])
|
||||
|
||||
def test_wait_configured_watches_the_mode_in_force(self):
|
||||
self._probe_state()
|
||||
calls = {"n": 0}
|
||||
|
||||
def sleep(_s):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 2: # an x16 controller's init writes land
|
||||
self._attr("cnc/step_freq", "56320")
|
||||
self._attr("cnc/y_mode", "16")
|
||||
ok = baseline.wait_controller_configured(
|
||||
self.lines.append, {"controller": "running", "mode": "grbl", "motion": "verified"},
|
||||
timeout=5, sleep=sleep, xy_mode=16)
|
||||
self.assertTrue(ok)
|
||||
self.assertTrue(any("controller configured" in l for l in self.lines))
|
||||
# a reference taken under x16 is not pre-config
|
||||
self.assertFalse(baseline.reference_preconfig(
|
||||
{"sysfs": {"cnc/step_freq": "56320", "cnc/y_mode": "16"},
|
||||
"forgectrl": {"/settings": {"xy_microsteps": "16"}}}))
|
||||
self.assertTrue(baseline.reference_preconfig(
|
||||
{"sysfs": {"cnc/step_freq": "10000", "cnc/y_mode": "1"},
|
||||
"forgectrl": {"/settings": {"xy_microsteps": "16"}}}))
|
||||
|
||||
def test_stale_preconfig_reference_is_retaken_on_a_fresh_boot(self):
|
||||
# Both sides of BOOT_MAX_AGE_S, with the uptime held: read from the host, it can cross the limit
|
||||
# between boot_reference()'s reading and the test's (it did, on a CI runner 600 s after its boot).
|
||||
os.environ["FORGETEST_BOOT_ID"] = "test-boot-2"
|
||||
real = baseline.uptime_s
|
||||
try:
|
||||
for up, fresh in ((baseline.BOOT_MAX_AGE_S - 60, True), (baseline.BOOT_MAX_AGE_S + 60, False)):
|
||||
self.lines.clear()
|
||||
path = os.path.join(self.tmp, "boot-test-boot-2.json")
|
||||
with open(path, "w") as f:
|
||||
json.dump({"ts": "old", "sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000",
|
||||
"cnc/y_mode": "1"}}, f)
|
||||
baseline.uptime_s = lambda up=up: up
|
||||
ref = baseline.boot_reference(self.lines.append, self.tmp)
|
||||
if fresh:
|
||||
self.assertTrue(any("retaking" in l for l in self.lines), self.lines)
|
||||
self.assertNotEqual(ref["ts"], "old")
|
||||
self.assertEqual(ref["sysfs"]["cnc/motor_lock"], "0")
|
||||
else:
|
||||
# too old to retake: the stale reference stands, marked
|
||||
self.assertTrue(any("predates the controller's config" in l for l in self.lines), self.lines)
|
||||
self.assertEqual(ref["ts"], "old")
|
||||
finally:
|
||||
baseline.uptime_s = real
|
||||
os.environ.pop("FORGETEST_BOOT_ID", None)
|
||||
|
||||
|
||||
class ExtensionFreeTests(BaselineFixture, unittest.TestCase):
|
||||
"""A campaign's machine is extension-free: what a test made is removed,
|
||||
what is the operator's is reported and left alone."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.root = os.path.join(self.tmp, "ext")
|
||||
for d in ("pkg", "keys", "data"):
|
||||
os.makedirs(os.path.join(self.root, d))
|
||||
# a stand-in for the host's command line: remove <id> takes the package away
|
||||
self.forgeext = os.path.join(self.tmp, "forgeext")
|
||||
with open(self.forgeext, "w") as f:
|
||||
f.write('#!/bin/sh\n[ "$1" = remove ] && rm -rf "%s/pkg/$2" && echo \'{"ok": true}\'\n' % self.root)
|
||||
os.chmod(self.forgeext, 0o755)
|
||||
self.pool = {}
|
||||
self._saved = (baseline.hw.EXT_ROOT, baseline.hw.FORGEEXT, baseline.hw.pool_pids, baseline.EXT_STOP_S)
|
||||
baseline.hw.EXT_ROOT, baseline.hw.FORGEEXT = self.root, self.forgeext
|
||||
baseline.hw.pool_pids = lambda: dict(self.pool)
|
||||
baseline.EXT_STOP_S = 0.2
|
||||
|
||||
def tearDown(self):
|
||||
baseline.hw.EXT_ROOT, baseline.hw.FORGEEXT, baseline.hw.pool_pids, baseline.EXT_STOP_S = self._saved
|
||||
super().tearDown()
|
||||
|
||||
def _package(self, id_):
|
||||
os.makedirs(os.path.join(self.root, "pkg", id_, "1.0.0"))
|
||||
|
||||
def _key(self, name):
|
||||
with open(os.path.join(self.root, "keys", name), "w") as f:
|
||||
f.write("x")
|
||||
|
||||
def test_an_installed_package_that_does_not_run_is_nobodys_leftover(self):
|
||||
self._package("org.example.notify")
|
||||
self._key("owner.pub")
|
||||
self.assertEqual(self.bl().enforce("pre", captured=None), [])
|
||||
|
||||
def test_what_a_test_left_behind_is_removed_and_the_operators_is_not(self):
|
||||
self._package("org.forgetest.reference")
|
||||
self._package("org.example.notify")
|
||||
self._key("forgetest-reference.pub")
|
||||
self._key("owner.pub")
|
||||
left = {x.item: x for x in self.bl().enforce("post", captured=None)}
|
||||
self.assertEqual(sorted(left), ["ext.owner_key", "ext.package"])
|
||||
self.assertEqual((left["ext.package"].found, left["ext.package"].action), ("org.forgetest.reference", "restored"))
|
||||
self.assertEqual((left["ext.owner_key"].found, left["ext.owner_key"].action), ("forgetest-reference.pub", "restored"))
|
||||
self.assertEqual(os.listdir(os.path.join(self.root, "pkg")), ["org.example.notify"])
|
||||
self.assertEqual(os.listdir(os.path.join(self.root, "keys")), ["owner.pub"])
|
||||
|
||||
def test_a_package_that_cannot_be_removed_says_so(self):
|
||||
self._package("org.forgetest.reference")
|
||||
with open(self.forgeext, "w") as f:
|
||||
f.write('#!/bin/sh\necho \'{"ok": false, "error": "the lock is held"}\'\nexit 1\n')
|
||||
left = self.bl().enforce("post", captured=None)
|
||||
self.assertEqual([x.item for x in left], ["ext.package"])
|
||||
self.assertTrue(left[0].action.startswith("failed:") and "the lock is held" in left[0].action, left[0].action)
|
||||
|
||||
def test_running_extensions_are_reported_and_left_alone(self):
|
||||
self._package("org.example.notify")
|
||||
self.pool = {4242: 800}
|
||||
left = self.bl().enforce("pre", captured=None)
|
||||
self.assertEqual([(x.item, x.action) for x in left], [("ext.running", "unrestorable")])
|
||||
self.assertIn("org.example.notify", left[0].found)
|
||||
self.assertTrue(os.path.isdir(os.path.join(self.root, "pkg", "org.example.notify")))
|
||||
|
||||
|
||||
class CloudOwnedSysfsTests(unittest.TestCase):
|
||||
"""In cloud mode the cloud client configures the machine from the
|
||||
pulse header it is playing, the lens included: z_mode comes from
|
||||
ZSmd through gfhardware's set_mode_from_puls. Handing the GRBL values
|
||||
back under it would be the baseline configuring another controller's
|
||||
machine. In GRBL mode the pair is checked as before."""
|
||||
|
||||
def test_the_z_pair_is_the_cloud_clients(self):
|
||||
for attr in ("head/z_current", "head/z_mode"):
|
||||
self.assertIn(attr, baseline.GRBL_CONTROLLER_SYSFS)
|
||||
|
||||
def test_the_z_pair_is_still_checked_in_grbl_mode(self):
|
||||
fixed = dict(baseline.fixed_sysfs())
|
||||
self.assertEqual(fixed.get("head/z_current"), "1")
|
||||
self.assertEqual(fixed.get("head/z_mode"), "1")
|
||||
|
||||
|
||||
class PositionDeadbandTests(unittest.TestCase):
|
||||
"""The counters count steps and a controller's own return lands within
|
||||
a few hundredths of a millimeter, not on the step: a difference that
|
||||
small is quantization, not a leftover. Found on the bench reference,
|
||||
where motion.lid-cancel-home returned the head twice, each within
|
||||
0.04 mm, and failed the hand-back on the four steps left over."""
|
||||
|
||||
def test_past_the_dead_band_is_a_leftover(self):
|
||||
over = int(baseline.POSITION_DEADBAND_MM * baseline.XY_STEPS_PER_MM) + 2
|
||||
self.assertFalse(baseline.position_quantized([0, 0, 0], [over, 0, 0]))
|
||||
self.assertFalse(baseline.position_quantized([0, 0, 0], [0, over, 0]))
|
||||
|
||||
def test_an_unreadable_reading_is_never_quantization(self):
|
||||
self.assertFalse(baseline.position_quantized(None, [0, 0, 0]))
|
||||
self.assertFalse(baseline.position_quantized([0, 0, 0], None))
|
||||
|
||||
|
||||
class BaselineModeTests(BaselineFixture, unittest.TestCase):
|
||||
"""The baseline against a fake forgectrl: what the mode in force
|
||||
owns, over the same fake sysfs tree."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
import helpers
|
||||
self.fc = helpers.FakeForgectrl().start()
|
||||
baseline.Baseline._unreachable_until = 0.0
|
||||
# the lamp's settle, short: five polls, so a lamp that never holds
|
||||
# costs the suite no real seconds
|
||||
self._lamp_timing = (baseline.LID_LAMP_SETTLE_S, baseline.LID_LAMP_POLL_S)
|
||||
baseline.LID_LAMP_SETTLE_S, baseline.LID_LAMP_POLL_S = 0.05, 0.01
|
||||
|
||||
def tearDown(self):
|
||||
baseline.LID_LAMP_SETTLE_S, baseline.LID_LAMP_POLL_S = self._lamp_timing
|
||||
self.fc.stop()
|
||||
super().tearDown()
|
||||
|
||||
def cloud(self):
|
||||
self.fc.state["mode"] = {"mode": "cloud", "controller": "running", "pid": 7, "motion": "verified"}
|
||||
self.fc.state["settings"]["controller_mode"] = "cloud"
|
||||
|
||||
def nohunt_marker(self, refuse=False):
|
||||
"""The no-hunt marker under the fake tree; what each POST found.
|
||||
The fake client takes the marker down as gfcloud does, first thing
|
||||
at its start; with refuse the POST is refused and no client starts."""
|
||||
baseline.NOHUNT_MARKER = os.path.join(self.tmp, "nohunt-marker")
|
||||
self.fc.state["settings"].setdefault("cloud_enabled", "1")
|
||||
seen = []
|
||||
|
||||
def on_post(path, form):
|
||||
seen.append((path, dict(form), os.path.exists(baseline.NOHUNT_MARKER)))
|
||||
if refuse:
|
||||
return 409, {"error": "busy"}
|
||||
if os.path.exists(baseline.NOHUNT_MARKER):
|
||||
os.remove(baseline.NOHUNT_MARKER)
|
||||
if path == "/mode":
|
||||
self.fc.state["mode"] = dict(self.fc.state["mode"], mode=form["controller"], controller="running")
|
||||
elif path == "/controller/start":
|
||||
self.fc.state["mode"] = dict(self.fc.state["mode"], controller="running")
|
||||
return None
|
||||
self.fc.on_post = on_post
|
||||
return seen
|
||||
|
||||
def test_a_refused_switch_leaves_no_marker_for_the_next_start(self):
|
||||
seen = self.nohunt_marker(refuse=True)
|
||||
ok, detail = self.bl().switch_mode("cloud")
|
||||
self.assertFalse(ok)
|
||||
self.assertEqual(seen, [("/mode", {"controller": "cloud"}, True)])
|
||||
self.assertFalse(os.path.exists(baseline.NOHUNT_MARKER))
|
||||
|
||||
def test_a_switch_to_cloud_starts_the_client_without_the_hunt(self):
|
||||
seen = self.nohunt_marker()
|
||||
ok, detail = self.bl().switch_mode("cloud")
|
||||
self.assertTrue(ok, detail)
|
||||
self.assertEqual(seen, [("/mode", {"controller": "cloud"}, True)])
|
||||
self.assertFalse(os.path.exists(baseline.NOHUNT_MARKER)) # one start, never left behind
|
||||
|
||||
def test_a_switch_to_cloud_turns_cloud_mode_on_when_it_is_off(self):
|
||||
# A test that declares cloud mode gets it: cloud_enabled goes to 1
|
||||
# first, with the typed phrase the daemon asks for and a log line,
|
||||
# and only then the mode switch.
|
||||
lines = []
|
||||
seen = self.nohunt_marker()
|
||||
self.fc.state["settings"]["cloud_enabled"] = "0"
|
||||
ok, detail = baseline.Baseline(lines.append).switch_mode("cloud")
|
||||
self.assertTrue(ok, detail)
|
||||
self.assertEqual(seen[0], ("/settings", {"cloud_enabled": "1", "phrase": "I UNDERSTAND"}, False))
|
||||
self.assertEqual(seen[1], ("/mode", {"controller": "cloud"}, True))
|
||||
self.assertTrue(any("cloud mode turned on for the test" in ln for ln in lines))
|
||||
|
||||
def test_a_switch_to_grbl_sets_no_marker(self):
|
||||
seen = self.nohunt_marker()
|
||||
self.cloud()
|
||||
ok, detail = self.bl().switch_mode("grbl")
|
||||
self.assertFalse(ok) # the fake has no Grbl port: the switch itself happened
|
||||
self.assertEqual(seen, [("/mode", {"controller": "grbl"}, False)])
|
||||
self.assertFalse(os.path.exists(baseline.NOHUNT_MARKER))
|
||||
|
||||
def test_the_mode_handed_back_is_cloud_without_the_hunt(self):
|
||||
seen = self.nohunt_marker()
|
||||
self.cloud()
|
||||
b = self.bl()
|
||||
cap = b.capture() # found in cloud
|
||||
self.fc.state["mode"] = dict(self.fc.state["mode"], mode="grbl") # the run left it in grbl
|
||||
b.enforce("post", captured=cap)
|
||||
self.assertEqual(seen[0], ("/mode", {"controller": "cloud"}, True))
|
||||
self.assertFalse(os.path.exists(baseline.NOHUNT_MARKER))
|
||||
|
||||
def test_a_standby_cloud_client_is_started_without_the_hunt(self):
|
||||
seen = self.nohunt_marker()
|
||||
self.cloud()
|
||||
b = self.bl()
|
||||
cap = b.capture()
|
||||
self.fc.state["mode"] = dict(self.fc.state["mode"], controller="standby")
|
||||
b.enforce("post", captured=cap)
|
||||
self.assertEqual(seen[0], ("/controller/start", {}, True))
|
||||
self.assertFalse(os.path.exists(baseline.NOHUNT_MARKER))
|
||||
|
||||
def test_grbl_mode_restores_the_controller_values_and_the_lamp(self):
|
||||
self._attr("cnc/step_freq", "10000")
|
||||
self._attr("pic/lid_led", "77")
|
||||
left = self.bl().enforce("pre", captured=None)
|
||||
self.assertEqual(sorted(x.item for x in left), ["cnc/step_freq", "pic/lid_led"])
|
||||
self.assertEqual(self._read("cnc/step_freq"), DEFAULT_TICK)
|
||||
self.assertEqual(self._read("pic/lid_led"), "236")
|
||||
|
||||
def test_cloud_mode_leaves_the_clients_config_lamp_and_counters(self):
|
||||
self.cloud()
|
||||
self._attr("cnc/step_freq", "10000") # the cloud client's tick
|
||||
self._attr("pic/x_step_current", "135")
|
||||
self._attr("pic/lid_led", "77") # its lid-image level
|
||||
b = self.bl()
|
||||
cap = b.capture()
|
||||
self.assertEqual(cap["mode"], "cloud")
|
||||
self.assertNotIn("controller_mode", cap["settings"])
|
||||
self._pos(-13096, -7400, 0) # the service re-zeroed and moved
|
||||
self._attr("cnc/streaming", "1") # NOT the client's: still restored
|
||||
left = b.enforce("post", captured=cap)
|
||||
self.assertEqual([x.item for x in left], ["cnc/streaming"])
|
||||
self.assertEqual(self._read("cnc/step_freq"), "10000")
|
||||
self.assertEqual(self._read("pic/x_step_current"), "135")
|
||||
self.assertEqual(self._read("pic/lid_led"), "77")
|
||||
self.assertEqual(self._read("cnc/streaming"), "0")
|
||||
self.assertEqual(self.fc.posts, []) # no mode switch, no settings write
|
||||
|
||||
def test_cloud_mode_still_relocks_the_latch(self):
|
||||
self.cloud()
|
||||
self._attr("cnc/interlock_circuit", "37") # bit 3 clear: unlocked
|
||||
left = self.bl().enforce("post", captured=None)
|
||||
self.assertEqual([x.item for x in left], ["laser_latch"])
|
||||
self.assertEqual(self._read("cnc/laser_latch"), "1")
|
||||
|
||||
def test_undeclared_mode_change_is_handed_back_through_the_switch(self):
|
||||
b = self.bl()
|
||||
cap = b.capture() # found in grbl
|
||||
self.assertEqual(cap["mode"], "grbl")
|
||||
self.cloud() # the run left it in cloud, silently
|
||||
left = b.enforce("post", captured=cap)
|
||||
items = {x.item: x for x in left}
|
||||
self.assertIn("mode", items)
|
||||
self.assertEqual(items["mode"].action, "restored")
|
||||
self.assertEqual(self.fc.posts, [("/mode", {"controller": "grbl"})])
|
||||
self.assertEqual(self.fc.state["mode"]["mode"], "grbl")
|
||||
|
||||
def test_declared_mode_change_is_kept(self):
|
||||
b = self.bl()
|
||||
cap = b.capture()
|
||||
self.cloud()
|
||||
cap["mode"] = "cloud" # Context.mode_changed("cloud")
|
||||
left = b.enforce("post", captured=cap)
|
||||
self.assertNotIn("mode", [x.item for x in left])
|
||||
self.assertEqual(self.fc.posts, [])
|
||||
self.assertEqual(self.fc.state["mode"]["mode"], "cloud")
|
||||
|
||||
def test_controller_mode_setting_is_never_written_back_bare(self):
|
||||
b = self.bl()
|
||||
cap = b.capture()
|
||||
self.assertNotIn("controller_mode", cap["settings"])
|
||||
self.fc.state["settings"]["controller_mode"] = "cloud" # a switch persisted it
|
||||
self.fc.state["mode"]["mode"] = "cloud"
|
||||
cap["mode"] = "cloud"
|
||||
b.enforce("post", captured=cap)
|
||||
self.assertEqual([p for p, _ in self.fc.posts if p == "/settings"], [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,63 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The bench registry against scripts/bench: every python tool is
|
||||
registered (or named as not-a-tool), every registered script exists,
|
||||
a ported tool builds a command line with its defaults, and every
|
||||
script compiles."""
|
||||
import glob
|
||||
import os
|
||||
import py_compile
|
||||
import unittest
|
||||
|
||||
import helpers # noqa: F401 (sys.path)
|
||||
from forgetest import bench as bench_mod
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
BENCH = os.path.join(REPO, "scripts", "bench")
|
||||
|
||||
|
||||
class RegistryTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.bench = bench_mod.Bench(tool_dir=BENCH, index_path=os.devnull)
|
||||
self.scripts = sorted(os.path.basename(p) for p in glob.glob(os.path.join(BENCH, "*.py")))
|
||||
|
||||
def test_every_python_tool_is_registered(self):
|
||||
registered = {t["script"] for t in bench_mod.TOOLS}
|
||||
missing = [s for s in self.scripts if s not in registered and s not in bench_mod.NOT_TOOLS]
|
||||
self.assertEqual(missing, [], "scripts/bench python files missing from the registry")
|
||||
|
||||
def test_every_registered_script_exists(self):
|
||||
for t in bench_mod.TOOLS:
|
||||
self.assertTrue(os.path.exists(os.path.join(BENCH, t["script"])), t["script"])
|
||||
|
||||
def test_ids_unique_and_fields_valid(self):
|
||||
ids = [t["id"] for t in bench_mod.TOOLS]
|
||||
self.assertEqual(len(ids), len(set(ids)))
|
||||
for t in bench_mod.TOOLS:
|
||||
self.assertIn(t["safety"], ("dry", "takeover", "scope", "live"), t["id"])
|
||||
self.assertIn(t["where"], ("board", "host"), t["id"])
|
||||
self.assertIsInstance(t["ported"], bool, t["id"])
|
||||
self.assertTrue(t["desc"], t["id"])
|
||||
for a in t.get("args", []):
|
||||
self.assertIn(a["type"], ("str", "int", "float", "choice"), (t["id"], a["name"]))
|
||||
if a["type"] == "choice":
|
||||
self.assertIn(a["default"], a["choices"], (t["id"], a["name"]))
|
||||
|
||||
def test_ported_tools_build_a_command_with_defaults(self):
|
||||
for t in bench_mod.TOOLS:
|
||||
if not t["ported"]:
|
||||
continue
|
||||
ok, argv, err = self.bench.command(t, {})
|
||||
self.assertTrue(ok, "%s: %s" % (t["id"], err))
|
||||
self.assertEqual(argv[1], os.path.join(BENCH, t["script"]))
|
||||
|
||||
def test_scripts_compile(self):
|
||||
for s in self.scripts:
|
||||
py_compile.compile(os.path.join(BENCH, s), doraise=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,202 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""A test that lets the service move the head hands it back where it found
|
||||
it. Every service motion zeroes the step counters at its start, and a home
|
||||
and every controller start zero them again, so no counter reading says
|
||||
where the test found the head: the client's own record does. Every motion
|
||||
logs the counters it ended on, and the travel is their sum (x8 steps, the
|
||||
one mode a service motion runs at), plus what the counters read since. The
|
||||
log lines are the machine's own, trimmed to the motion records: a homing
|
||||
session whose one motion the cooling engine stopped short, and a whole
|
||||
three-motion one. Runs against scratch logs and fakes for the controller."""
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from forgetest.runner import Failed
|
||||
from forgetest.suite import cloud, homeoff
|
||||
|
||||
STOPPED = """\
|
||||
2026-09-25T15:56:19.641824+00:00 gfhome[7387] INFO gfhome:home service action: motion (ready)
|
||||
2026-09-25T15:56:19.644041+00:00 gfhome[7387] INFO machine:_motion start motion
|
||||
2026-09-25T15:56:19.970342+00:00 gfhome[7387] INFO machine:_run_loop starting run
|
||||
2026-09-25T15:56:20.905040+00:00 gfhome[7387] ERR machine:_run_loop run stopped short: 8130 of 21756 bytes played; the job did not finish
|
||||
2026-09-25T15:56:20.918652+00:00 gfhome[7387] INFO machine:_feed_and_run end positions (actual/expected): X (4390/12115), Y (2682/7402), Z (0/0)
|
||||
2026-09-25T15:56:20.937235+00:00 gfhome[7387] INFO machine:_motion_locked end positions (4390, 2682, 0)
|
||||
2026-09-25T15:56:20.938026+00:00 gfhome[7387] INFO machine:_motion end motion
|
||||
2026-09-25T15:56:20.938677+00:00 gfhome[7387] INFO basemachine:_finish_action motion [1588842275]: finished with event ":cancelled"
|
||||
2026-09-25T15:56:21.145580+00:00 gfhome[7387] INFO gfhome:home motion completed
|
||||
"""
|
||||
|
||||
WHOLE = "".join(
|
||||
"2026-09-07T20:%s gfhome[28592] INFO machine:_motion start motion\n"
|
||||
"2026-09-07T20:%s gfhome[28592] INFO machine:_feed_and_run end positions (actual/expected): "
|
||||
"X (%d/%d), Y (%d/%d), Z (0/0)\n"
|
||||
"2026-09-07T20:%s gfhome[28592] INFO machine:_motion_locked end positions (%d, %d, 0)\n"
|
||||
"2026-09-07T20:%s gfhome[28592] INFO machine:_motion end motion\n"
|
||||
"2026-09-07T20:%s gfhome[28592] INFO basemachine:_finish_action motion [%d]: finished with event "
|
||||
"\":completed\"\n" % (t, t, x, x, y, y, t, x, y, t, t, i)
|
||||
for i, (t, x, y) in enumerate((("25:50.1", 4774, 4600), ("25:56.2", -71, -144), ("26:02.9", -13096, -7399))))
|
||||
|
||||
|
||||
class FakeGrbl:
|
||||
def __init__(self, ctx):
|
||||
self.ctx = ctx
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def command(self, line, timeout=None):
|
||||
self.ctx.jogs.append(line)
|
||||
return ["ok"]
|
||||
|
||||
|
||||
class FakeCtx:
|
||||
def __init__(self):
|
||||
self.forgectrl = object()
|
||||
self.jogs = []
|
||||
self.lines = []
|
||||
self.rezeroed = 0
|
||||
|
||||
def check(self, cond, msg, *args):
|
||||
if not cond:
|
||||
raise Failed(msg % args if args else msg)
|
||||
|
||||
def log(self, msg, *args):
|
||||
self.lines.append(msg % args if args else msg)
|
||||
|
||||
def grbl(self):
|
||||
return FakeGrbl(self)
|
||||
|
||||
def counters_rezeroed(self):
|
||||
self.rezeroed += 1
|
||||
|
||||
|
||||
class HandBackTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dir = tempfile.mkdtemp()
|
||||
self.log = os.path.join(self.dir, "gfhome.log")
|
||||
self.cloudlog = os.path.join(self.dir, "gfcloud.log")
|
||||
self.ctx = FakeCtx()
|
||||
self.anchor = True
|
||||
self.counters = [0, 0, 0]
|
||||
self.restarts = 0
|
||||
self.spm = 213.333 # the controller's x32 scale
|
||||
saved_logs = (cloud.GFHOME_LOG, cloud.GFCLOUD_LOG)
|
||||
self.addCleanup(lambda: setattr(cloud, "GFHOME_LOG", saved_logs[0]))
|
||||
self.addCleanup(lambda: setattr(cloud, "GFCLOUD_LOG", saved_logs[1]))
|
||||
cloud.GFHOME_LOG, cloud.GFCLOUD_LOG = self.log, self.cloudlog
|
||||
patches = {
|
||||
"counter_frame": lambda: [1, 2] if self.anchor else None,
|
||||
"read_position": lambda: list(self.counters),
|
||||
"counter_steps_per_mm": lambda: self.spm,
|
||||
"_drop_reference": self._restart,
|
||||
"clean_slate": lambda ctx, g: None,
|
||||
"wait_idle": self._jog_ends,
|
||||
"machine_idle": lambda ctx: None,
|
||||
}
|
||||
saved = {k: getattr(homeoff, k) for k in patches}
|
||||
self.addCleanup(lambda: [setattr(homeoff, k, v) for k, v in saved.items()])
|
||||
for k, v in patches.items():
|
||||
setattr(homeoff, k, v)
|
||||
self.addCleanup(shutil.rmtree, self.dir, True)
|
||||
|
||||
def _restart(self, ctx, fc):
|
||||
# a controller start zeroes the counters where the head stands and
|
||||
# removes the anchor
|
||||
self.restarts += 1
|
||||
self.counters = [0, 0, 0]
|
||||
self.anchor = False
|
||||
|
||||
def _jog_ends(self, ctx, g, timeout=30.0):
|
||||
# the jog played: the counters moved by what it asked, at the scale
|
||||
words = dict((w[0], float(w[1:])) for w in self.ctx.jogs[-1].split()[2:4])
|
||||
self.counters = [self.counters[0] + round(words["X"] * self.spm),
|
||||
self.counters[1] + round(words["Y"] * self.spm), 0]
|
||||
return 0.0, ["Jog", "Idle"], {}
|
||||
|
||||
def write(self, text, path=None):
|
||||
with open(path or self.log, "a", encoding="utf-8", newline="\n") as f:
|
||||
f.write(text)
|
||||
|
||||
def expect_jog(self, dx, dy):
|
||||
self.assertEqual(self.ctx.jogs, ["$J=G91 G21 X%.3f Y%.3f F2400" % (-dx, -dy)])
|
||||
|
||||
# -- the stopped-short judge --------------------------------------------
|
||||
def test_a_motion_stopped_short_fails_the_judge(self):
|
||||
self.write(STOPPED)
|
||||
ev = {}
|
||||
with self.assertRaises(Failed):
|
||||
homeoff.judge_whole_motions(self.ctx, ev, 0)
|
||||
self.assertEqual(len(ev["short_motions"]), 2)
|
||||
|
||||
# -- after a camera home -------------------------------------------------
|
||||
def test_homed_the_head_goes_back_by_the_travel_and_the_counters_since(self):
|
||||
# the home zeroed the counters; the test's own jogs left a step or so
|
||||
self.write(STOPPED)
|
||||
self.counters = [round(1.0 * self.spm), 0, 0]
|
||||
since = self.counters[0] / self.spm
|
||||
homeoff.camera_home_return(self.ctx, {"homed": True}, 0)
|
||||
self.expect_jog(4390 / homeoff.XY_STEPS_PER_MM + since, 2682 / homeoff.XY_STEPS_PER_MM)
|
||||
# dropped before the jog, and once more with the head back
|
||||
self.assertEqual(self.restarts, 2)
|
||||
self.assertEqual(self.counters, [0, 0, 0])
|
||||
self.assertEqual(self.ctx.rezeroed, 1)
|
||||
|
||||
def test_unhomed_the_counters_are_not_counted_twice(self):
|
||||
# a failed session leaves the last motion's end in the counters,
|
||||
# which the travel already counts
|
||||
self.write(STOPPED)
|
||||
self.counters = [4390, 2682, 0]
|
||||
self.anchor = False
|
||||
homeoff.camera_home_return(self.ctx, {"homed": False}, 0)
|
||||
self.expect_jog(4390 / homeoff.XY_STEPS_PER_MM, 2682 / homeoff.XY_STEPS_PER_MM)
|
||||
|
||||
def test_an_unknown_travel_moves_nothing(self):
|
||||
self.write(STOPPED.split("machine:_motion_locked")[0])
|
||||
with self.assertRaises(Failed):
|
||||
homeoff.camera_home_return(self.ctx, {"homed": True}, 0)
|
||||
self.assertEqual(self.ctx.jogs, [])
|
||||
self.assertEqual(self.ctx.rezeroed, 0)
|
||||
|
||||
def test_a_restart_since_the_home_moves_nothing(self):
|
||||
# the anchor gone: the counters no longer read from the home
|
||||
self.write(STOPPED)
|
||||
self.anchor = False
|
||||
with self.assertRaises(Failed):
|
||||
homeoff.camera_home_return(self.ctx, {"homed": True}, 0)
|
||||
self.assertEqual(self.ctx.jogs, [])
|
||||
|
||||
def test_a_head_that_never_moved_is_not_jogged(self):
|
||||
homeoff.camera_home_return(self.ctx, {}, 0)
|
||||
self.assertEqual(self.ctx.jogs, [])
|
||||
self.assertEqual((self.restarts, self.ctx.rezeroed), (1, 1))
|
||||
|
||||
# -- after cloud mode ----------------------------------------------------
|
||||
def test_after_cloud_mode_the_head_goes_back_by_the_clients_record(self):
|
||||
# the service's re-hunt, cut short by the switch back: to the middle
|
||||
# of the bed and one correction (the bench reference, 2026-09-25)
|
||||
self.write(WHOLE[:WHOLE.index("2026-09-07T20:26:02.9")].replace("gfhome[", "gfcloud["), self.cloudlog)
|
||||
ev = {}
|
||||
homeoff.cloud_mode_return(self.ctx, ev, 0)
|
||||
self.expect_jog((4774 - 71) / homeoff.XY_STEPS_PER_MM, (4600 - 144) / homeoff.XY_STEPS_PER_MM)
|
||||
self.assertEqual(tuple(ev["cloud_return"]["session_travel_steps"]), (4774 - 71, 4600 - 144))
|
||||
# no drop before the jog (the switch back started the controller), one after
|
||||
self.assertEqual((self.restarts, self.ctx.rezeroed), (1, 1))
|
||||
|
||||
def test_after_cloud_mode_an_unknown_travel_moves_nothing(self):
|
||||
self.write(STOPPED.replace("gfhome[", "gfcloud[").split("machine:_motion_locked")[0], self.cloudlog)
|
||||
with self.assertRaises(Failed):
|
||||
homeoff.cloud_mode_return(self.ctx, {}, 0)
|
||||
self.assertEqual(self.ctx.jogs, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,206 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
import unittest
|
||||
|
||||
import helpers
|
||||
from forgetest import campaign
|
||||
from forgetest import catalog
|
||||
|
||||
|
||||
def rec_campaign(cid, man, chash, ts):
|
||||
return {"t": "campaign", "ts": ts, "id": cid, "manifest_sha": man.content_sha,
|
||||
"catalog_hash": chash, "image": man.version}
|
||||
|
||||
|
||||
def rec_result(cid, t, man, result, ts, fp=None):
|
||||
return {"t": "result", "ts": ts, "campaign": cid, "test": t.id, "result": result,
|
||||
"fingerprint": fp or t.fingerprint(man), "manifest_sha": man.content_sha,
|
||||
"image": man.version, "duration_s": 1, "message": ""}
|
||||
|
||||
|
||||
class CampaignTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.man = helpers.make_manifest()
|
||||
self.core = helpers.make_test("image.health", [("linux-fslc", "**")], always=True)
|
||||
self.ui = helpers.make_test("forgectrl.panel", [("forgectrl", "src/ui.c")])
|
||||
self.cool = helpers.make_test("cooling.flow", [("forgectrl", "src/cool.c")])
|
||||
self.live = helpers.make_test("laser.witness", [("grblhal-glowforge", "src/**")],
|
||||
requires=("forgectrl.panel",), kind="live")
|
||||
self.reg = helpers.registry(self.core, self.ui, self.cool, self.live)
|
||||
self.tests = list(self.reg.values())
|
||||
self.chash = catalog.catalog_hash(self.reg)
|
||||
|
||||
def compute(self, records, man=None, chash=None):
|
||||
return campaign.compute(records, self.tests, man or self.man, chash or self.chash)
|
||||
|
||||
def test_empty(self):
|
||||
st = self.compute([])
|
||||
self.assertIsNone(st["campaign"])
|
||||
self.assertFalse(st["authorized"])
|
||||
for t in self.tests:
|
||||
self.assertTrue(st["tests"][t.id]["required"])
|
||||
self.assertEqual(st["tests"][t.id]["reason"], "always" if t.always else "never-passed")
|
||||
self.assertFalse(st["tests"][self.live.id]["requires_met"])
|
||||
|
||||
def test_full_campaign_authorizes(self):
|
||||
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z")]
|
||||
for i, t in enumerate(self.tests):
|
||||
recs.append(rec_result("c1", t, self.man, "PASS", "2026-08-20T10:0%d:00Z" % (i + 1)))
|
||||
st = self.compute(recs)
|
||||
self.assertTrue(st["authorized"])
|
||||
self.assertEqual(st["counts"], {"total": 4, "satisfied": 4, "inherited": 0, "required": 0})
|
||||
self.assertEqual(st["tests"][self.core.id]["status"], "pass")
|
||||
|
||||
def test_fail_closes_campaign(self):
|
||||
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z"),
|
||||
rec_result("c1", self.core, self.man, "PASS", "2026-08-20T10:01:00Z"),
|
||||
rec_result("c1", self.ui, self.man, "PASS", "2026-08-20T10:02:00Z"),
|
||||
rec_result("c1", self.cool, self.man, "FAIL", "2026-08-20T10:03:00Z")]
|
||||
st = self.compute(recs)
|
||||
self.assertIsNone(st["campaign"])
|
||||
self.assertEqual(st["closed_by"], "fail")
|
||||
self.assertFalse(st["authorized"])
|
||||
# the core PASS in the closed campaign no longer counts; the ui PASS is inheritable
|
||||
self.assertTrue(st["tests"][self.core.id]["required"])
|
||||
self.assertEqual(st["tests"][self.core.id]["reason"], "always")
|
||||
self.assertEqual(st["tests"][self.ui.id]["status"], "inherited")
|
||||
self.assertEqual(st["tests"][self.cool.id]["status"], "fail")
|
||||
self.assertEqual(st["tests"][self.cool.id]["reason"], "never-passed")
|
||||
|
||||
def test_a_fail_after_a_pass_blocks_inheritance(self):
|
||||
# The ui test passes in c1, then fails on the same image (an
|
||||
# intermittent gate): the FAIL closes c1. In c2 the older PASS must
|
||||
# not be inherited over the newer FAIL: the test is required again.
|
||||
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z"),
|
||||
rec_result("c1", self.ui, self.man, "PASS", "2026-08-20T10:01:00Z"),
|
||||
rec_result("c1", self.ui, self.man, "FAIL", "2026-08-20T10:02:00Z"),
|
||||
rec_campaign("c2", self.man, self.chash, "2026-08-20T11:00:00Z")]
|
||||
st = self.compute(recs)
|
||||
t = st["tests"][self.ui.id]
|
||||
self.assertEqual(t["status"], "fail")
|
||||
self.assertEqual(t["reason"], "failed-since")
|
||||
self.assertTrue(t["required"])
|
||||
self.assertFalse(t["satisfied"])
|
||||
self.assertFalse(st["authorized"])
|
||||
|
||||
def test_an_abort_after_a_pass_does_not_block_inheritance(self):
|
||||
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z"),
|
||||
rec_result("c1", self.ui, self.man, "PASS", "2026-08-20T10:01:00Z"),
|
||||
rec_result("c1", self.ui, self.man, "ABORTED", "2026-08-20T10:02:00Z"),
|
||||
rec_campaign("c2", self.man, self.chash, "2026-08-20T11:00:00Z")]
|
||||
st = self.compute(recs)
|
||||
self.assertEqual(st["tests"][self.ui.id]["status"], "inherited")
|
||||
|
||||
def test_a_pass_after_a_fail_is_inherited(self):
|
||||
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z"),
|
||||
rec_result("c1", self.ui, self.man, "FAIL", "2026-08-20T10:01:00Z"),
|
||||
rec_campaign("c2", self.man, self.chash, "2026-08-20T11:00:00Z"),
|
||||
rec_result("c2", self.ui, self.man, "PASS", "2026-08-20T11:01:00Z"),
|
||||
rec_campaign("c3", self.man, self.chash, "2026-08-20T12:00:00Z")]
|
||||
st = self.compute(recs)
|
||||
self.assertEqual(st["tests"][self.ui.id]["status"], "inherited")
|
||||
|
||||
def test_error_closes_aborted_does_not(self):
|
||||
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z"),
|
||||
rec_result("c1", self.ui, self.man, "ABORTED", "2026-08-20T10:01:00Z")]
|
||||
st = self.compute(recs)
|
||||
self.assertIsNotNone(st["campaign"])
|
||||
self.assertEqual(st["tests"][self.ui.id]["status"], "aborted")
|
||||
recs.append(rec_result("c1", self.ui, self.man, "ERROR", "2026-08-20T10:02:00Z"))
|
||||
st = self.compute(recs)
|
||||
self.assertIsNone(st["campaign"])
|
||||
self.assertEqual(st["closed_by"], "fail")
|
||||
|
||||
def test_inheritance_follows_fingerprint(self):
|
||||
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z")]
|
||||
for i, t in enumerate(self.tests):
|
||||
recs.append(rec_result("c1", t, self.man, "PASS", "2026-08-20T10:0%d:00Z" % (i + 1)))
|
||||
# a new image with only cool.c changed
|
||||
man2 = helpers.with_file(self.man, "forgectrl", "src/cool.c", "cool v2")
|
||||
st = self.compute(recs, man=man2)
|
||||
self.assertIsNone(st["campaign"])
|
||||
self.assertEqual(st["closed_by"], "image")
|
||||
self.assertEqual(st["tests"][self.ui.id]["status"], "inherited")
|
||||
self.assertEqual(st["tests"][self.live.id]["status"], "inherited")
|
||||
self.assertEqual(st["tests"][self.cool.id]["status"], "stale")
|
||||
self.assertEqual(st["tests"][self.cool.id]["reason"], "domain-changed")
|
||||
self.assertEqual(st["tests"][self.core.id]["reason"], "always")
|
||||
# open a campaign on the new image, run the core + cool -> authorized
|
||||
recs.append(rec_campaign("c2", man2, self.chash, "2026-08-21T10:00:00Z"))
|
||||
recs.append(rec_result("c2", self.core, man2, "PASS", "2026-08-21T10:01:00Z"))
|
||||
st = self.compute(recs, man=man2)
|
||||
self.assertFalse(st["authorized"])
|
||||
recs.append(rec_result("c2", self.cool, man2, "PASS", "2026-08-21T10:02:00Z"))
|
||||
st = self.compute(recs, man=man2)
|
||||
self.assertTrue(st["authorized"])
|
||||
self.assertEqual(st["counts"]["inherited"], 2)
|
||||
self.assertEqual(st["tests"][self.ui.id]["origin"]["campaign"], "c1")
|
||||
|
||||
def test_platform_change_invalidates_everything(self):
|
||||
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z")]
|
||||
for i, t in enumerate(self.tests):
|
||||
recs.append(rec_result("c1", t, self.man, "PASS", "2026-08-20T10:0%d:00Z" % (i + 1)))
|
||||
man2 = helpers.with_platform(self.man, dtb={"glowforge.dtb": "f" * 64})
|
||||
st = self.compute(recs, man=man2)
|
||||
for t in self.tests:
|
||||
self.assertTrue(st["tests"][t.id]["required"], t.id)
|
||||
|
||||
def test_invalidate_all(self):
|
||||
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z")]
|
||||
for i, t in enumerate(self.tests):
|
||||
recs.append(rec_result("c1", t, self.man, "PASS", "2026-08-20T10:0%d:00Z" % (i + 1)))
|
||||
recs.append({"t": "invalidate", "ts": "2026-08-22T09:00:00Z", "reason": "new tube"})
|
||||
st = self.compute(recs)
|
||||
self.assertIsNone(st["campaign"])
|
||||
self.assertEqual(st["closed_by"], "invalidate")
|
||||
self.assertEqual(st["invalidate"]["reason"], "new tube")
|
||||
for t in self.tests:
|
||||
self.assertTrue(st["tests"][t.id]["required"], t.id)
|
||||
self.assertNotEqual(st["tests"][t.id]["status"], "inherited")
|
||||
# after the invalidate, a new campaign's passes count and later ones inherit again
|
||||
recs.append(rec_campaign("c2", self.man, self.chash, "2026-08-22T10:00:00Z"))
|
||||
st = self.compute(recs)
|
||||
# the campaign that started after it answers the invalidation: the
|
||||
# page no longer announces it, while its epoch still holds
|
||||
self.assertIsNone(st["invalidate"])
|
||||
for t in self.tests:
|
||||
self.assertTrue(st["tests"][t.id]["required"], t.id)
|
||||
self.assertNotEqual(st["tests"][t.id]["status"], "inherited")
|
||||
for i, t in enumerate(self.tests):
|
||||
recs.append(rec_result("c2", t, self.man, "PASS", "2026-08-22T10:0%d:00Z" % (i + 1)))
|
||||
st = self.compute(recs)
|
||||
self.assertTrue(st["authorized"])
|
||||
self.assertIsNone(st["invalidate"])
|
||||
|
||||
def test_reset_and_catalog_change(self):
|
||||
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z"),
|
||||
rec_result("c1", self.core, self.man, "PASS", "2026-08-20T10:01:00Z"),
|
||||
{"t": "reset", "ts": "2026-08-20T11:00:00Z", "reason": "x"}]
|
||||
st = self.compute(recs)
|
||||
self.assertIsNone(st["campaign"])
|
||||
self.assertEqual(st["closed_by"], "reset")
|
||||
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z")]
|
||||
st = self.compute(recs, chash="different")
|
||||
self.assertIsNone(st["campaign"])
|
||||
self.assertEqual(st["closed_by"], "catalog")
|
||||
|
||||
def test_requires_uses_satisfied(self):
|
||||
recs = [rec_campaign("c1", self.man, self.chash, "2026-08-20T10:00:00Z"),
|
||||
rec_result("c1", self.ui, self.man, "PASS", "2026-08-20T10:01:00Z")]
|
||||
st = self.compute(recs)
|
||||
self.assertTrue(st["tests"][self.live.id]["requires_met"])
|
||||
man2 = helpers.with_file(self.man, "forgectrl", "src/ui.c", "ui v2")
|
||||
st = self.compute(recs, man=man2)
|
||||
self.assertFalse(st["tests"][self.live.id]["requires_met"])
|
||||
self.assertEqual(st["tests"][self.live.id]["missing_requires"], ["forgectrl.panel"])
|
||||
|
||||
def test_running_marker(self):
|
||||
st = campaign.compute([], self.tests, self.man, self.chash, running=self.ui.id)
|
||||
self.assertEqual(st["tests"][self.ui.id]["status"], "running")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,265 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""Every phrase the cloud suite greps for in the gfcloud log must be a
|
||||
phrase the pinned cloud app can actually log.
|
||||
|
||||
The cloud tests judge a print on the machine's own log lines. When the
|
||||
app's wording moves (a resume that used to log one line logs two), a
|
||||
test keeps looking for the old line and fails a print that worked. The
|
||||
replay fixtures cannot catch that: they are excerpts of the old wording.
|
||||
This test reads the phrases straight out of suite/cloud.py and checks
|
||||
each against the logger calls in the app sources at the revisions the
|
||||
recipes pin (python3-gfhardware and python3-gfutilities, from the
|
||||
manifest cache the tree manifest builds, or the sibling checkouts when
|
||||
no cache is at hand), placeholder-aware: "paused at" is covered by
|
||||
'paused at %s', "warm up: holding" by '%s: holding %.1f s', and
|
||||
"authenticate_machine SUCCESS" by logger.info('SUCCESS') inside
|
||||
def authenticate_machine (the log format is module:function message).
|
||||
"""
|
||||
import ast
|
||||
import importlib.util
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
import helpers # noqa: F401 (sys.path)
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
FORGETEST = os.path.dirname(HERE)
|
||||
REPO = os.path.dirname(FORGETEST)
|
||||
CLOUD_PY = os.path.join(FORGETEST, "forgetest", "suite", "cloud.py")
|
||||
APP_COMPONENTS = ("python3-gfhardware", "python3-gfutilities")
|
||||
SIBLINGS = {"python3-gfhardware": "python3-gfhardware", "python3-gfutilities": "Glowforge-Utilities"}
|
||||
|
||||
# Phrases cloud.py builds at run time rather than writing out (a "%s: holding"
|
||||
# with the phase filled in), listed here so they are checked too.
|
||||
BUILT_PHRASES = ("warm up: holding", "cool down: holding", "warm up: skipped", "cool down: skipped",
|
||||
'finished with event ":cancelled"', 'finished with event ":completed"',
|
||||
"motion [", "print [")
|
||||
# Phrases that are not the app's: forgetest's own log, a line prefix of
|
||||
# the log format rather than a message, or a line of forgectrl's log (the
|
||||
# engine's effective-limits line, judged from its own file).
|
||||
NOT_APP = ("PASS", "gfcloud", "[", "effective limits: coolant ceiling", "header")
|
||||
|
||||
PLACEHOLDER = re.compile(r"%(?:\([^)]*\))?[-+ #0]*\d*(?:\.\d+)?[sdifrxXeEgGcu]|%%")
|
||||
|
||||
|
||||
# ------------------------------------------------------------ the needles
|
||||
|
||||
def needles_in(path):
|
||||
"""String constants cloud.py tests against log lines: the left side of
|
||||
an `x in ln`, the arguments of wait_log, endswith() arguments, and the
|
||||
names those resolve to (module- or function-level constants)."""
|
||||
with open(path, encoding="utf-8") as f:
|
||||
tree = ast.parse(f.read())
|
||||
consts = {} # name -> list of str
|
||||
|
||||
def strings_of(node):
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
||||
return [node.value]
|
||||
if isinstance(node, (ast.List, ast.Tuple)):
|
||||
return [s for e in node.elts for s in strings_of(e)]
|
||||
if isinstance(node, ast.Name):
|
||||
return consts.get(node.id, [])
|
||||
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
|
||||
return strings_of(node.left) + strings_of(node.right)
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "list":
|
||||
return [s for a in node.args for s in strings_of(a)]
|
||||
return []
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
|
||||
got = strings_of(node.value)
|
||||
if got:
|
||||
consts[node.targets[0].id] = got
|
||||
found = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Compare) and any(isinstance(op, ast.In) for op in node.ops):
|
||||
found.update(strings_of(node.left))
|
||||
elif isinstance(node, ast.Call):
|
||||
fn = node.func
|
||||
if isinstance(fn, ast.Name) and fn.id == "wait_log" and len(node.args) >= 3:
|
||||
found.update(strings_of(node.args[2]))
|
||||
elif isinstance(fn, ast.Attribute) and fn.attr == "endswith":
|
||||
for a in node.args:
|
||||
found.update(strings_of(a))
|
||||
# the mark tuples the suite scans lines against in comprehensions
|
||||
# (`any(m in ln for m in SESSION_MARKS)`), where the name is a loop variable
|
||||
for name, vals in consts.items():
|
||||
if name.endswith(("_MARKS", "_LINES")):
|
||||
found.update(vals)
|
||||
found.update(BUILT_PHRASES)
|
||||
return sorted(s.strip() for s in found if s.strip() and s.strip() not in NOT_APP and " " in s or s in BUILT_PHRASES)
|
||||
|
||||
|
||||
# ------------------------------------------------------------ the app's lines
|
||||
|
||||
def log_literals(source, label):
|
||||
"""(function name, fragments) for every logger call in a Python source:
|
||||
the message's fixed text split at its placeholders."""
|
||||
out = []
|
||||
try:
|
||||
tree = ast.parse(source)
|
||||
except SyntaxError:
|
||||
return out
|
||||
funcs = {}
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
for sub in ast.walk(node):
|
||||
funcs.setdefault(id(sub), node.name)
|
||||
for node in ast.walk(tree):
|
||||
if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
|
||||
and node.func.attr in ("debug", "info", "warning", "warn", "error", "critical", "exception")
|
||||
and node.args):
|
||||
continue
|
||||
msg = node.args[0]
|
||||
if isinstance(msg, ast.BinOp) and isinstance(msg.op, ast.Mod):
|
||||
msg = msg.left
|
||||
frags = None
|
||||
if isinstance(msg, ast.Constant) and isinstance(msg.value, str):
|
||||
frags = [p.replace("%%", "%") for p in PLACEHOLDER.split(msg.value)]
|
||||
elif isinstance(msg, ast.JoinedStr):
|
||||
frags = [""]
|
||||
for v in msg.values:
|
||||
if isinstance(v, ast.Constant):
|
||||
frags[-1] += str(v.value)
|
||||
else:
|
||||
frags.append("")
|
||||
if frags is not None:
|
||||
out.append((funcs.get(id(node), ""), frags, label))
|
||||
return out
|
||||
|
||||
|
||||
def covers(frags, needle):
|
||||
"""True when `needle` is a substring of a rendering of the message whose
|
||||
fixed text is `frags` (the gaps between fragments are placeholders; the
|
||||
rendered line is `function message`, so frags[0] carries the function).
|
||||
A placeholder is a value, never the phrase itself: the needle must lie
|
||||
inside one fixed fragment, or run through the fragments in order and
|
||||
contain at least one complete non-empty fragment."""
|
||||
n = len(frags)
|
||||
if any(needle in f for f in frags if f):
|
||||
return True
|
||||
|
||||
def in_gap(text, i, complete):
|
||||
"""`text` starts in the gap before frags[i]."""
|
||||
if i >= n:
|
||||
return False
|
||||
frag = frags[i]
|
||||
if frag == "":
|
||||
return complete if i == n - 1 else in_gap(text, i + 1, complete)
|
||||
start = 0
|
||||
while True:
|
||||
j = text.find(frag, start)
|
||||
if j < 0:
|
||||
break
|
||||
if after(text[j + len(frag):], i, True):
|
||||
return True
|
||||
start = j + 1
|
||||
# the needle ends inside frags[i]: a proper prefix of it, which counts
|
||||
# as the complete fragment the rule wants only when the gap before it
|
||||
# was preceded by one, or when what is left of the fragment is a
|
||||
# trailing separator (": holding " is covered by "warm up: holding")
|
||||
for k in range(1, len(frag)):
|
||||
if text.endswith(frag[:k]) and (complete or not frag[k:].strip()):
|
||||
return True
|
||||
return False
|
||||
|
||||
def after(text, i, complete):
|
||||
"""`text` starts right after the whole of frags[i]."""
|
||||
if text == "":
|
||||
return complete
|
||||
return in_gap(text, i + 1, complete)
|
||||
|
||||
for i, frag in enumerate(frags):
|
||||
for off in range(len(frag)):
|
||||
suffix = frag[off:]
|
||||
if needle.startswith(suffix) and after(needle[len(suffix):], i, off == 0):
|
||||
return True
|
||||
if i >= 1 and in_gap(needle, i, False):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ------------------------------------------------------------ sources
|
||||
|
||||
def pinned_sources():
|
||||
"""{component: [(path, text)]} for the app components at their pinned
|
||||
revisions, through the tree manifest's recipe parser and cache; the
|
||||
sibling checkouts when the cache cannot be reached."""
|
||||
spec = importlib.util.spec_from_file_location("mft", os.path.join(REPO, "scripts", "manifest-from-tree.py"))
|
||||
mft = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mft)
|
||||
meta = os.path.join(os.path.dirname(REPO), "meta-openglow")
|
||||
cache = os.path.join(REPO, ".manifest-cache")
|
||||
out = {}
|
||||
for name, rel, layer in mft.RECIPES:
|
||||
if name not in APP_COMPONENTS:
|
||||
continue
|
||||
base = REPO if layer == "forgefirm" else meta
|
||||
files = []
|
||||
try:
|
||||
url, rev = mft.parse_recipe(os.path.join(base, rel))
|
||||
repo = mft.fetch(url, rev, cache)
|
||||
names = mft.git(["ls-tree", "-r", "--name-only", rev], cwd=repo).decode().split("\n")
|
||||
for p in names:
|
||||
if p.endswith(".py"):
|
||||
files.append((p, mft.git(["show", "%s:%s" % (rev, p)], cwd=repo).decode("utf-8", "replace")))
|
||||
out[name] = ("pinned %s" % rev[:12], files)
|
||||
continue
|
||||
except (OSError, subprocess.CalledProcessError, SystemExit):
|
||||
pass
|
||||
sib = os.path.join(os.path.dirname(REPO), SIBLINGS[name])
|
||||
if os.path.isdir(sib):
|
||||
for root, _dirs, fnames in os.walk(sib):
|
||||
if ".git" in root:
|
||||
continue
|
||||
for fn in fnames:
|
||||
if fn.endswith(".py"):
|
||||
p = os.path.join(root, fn)
|
||||
files.append((os.path.relpath(p, sib), open(p, encoding="utf-8", errors="replace").read()))
|
||||
out[name] = ("sibling checkout", files)
|
||||
return out
|
||||
|
||||
|
||||
class CloudNeedleTests(unittest.TestCase):
|
||||
def test_every_needle_is_a_line_the_pinned_app_can_log(self):
|
||||
needles = needles_in(CLOUD_PY)
|
||||
self.assertGreaterEqual(len(needles), 30, needles)
|
||||
self.assertIn("authenticate_machine SUCCESS", needles)
|
||||
self.assertIn("RX-EVENT: ready", needles)
|
||||
sources = pinned_sources()
|
||||
missing = [c for c in APP_COMPONENTS if c not in sources]
|
||||
if missing:
|
||||
if os.environ.get("CI"):
|
||||
self.fail("no source for %s (run scripts/manifest-from-tree.py first)" % ", ".join(missing))
|
||||
self.skipTest("no source for %s" % ", ".join(missing))
|
||||
literals = []
|
||||
for comp, (where, files) in sources.items():
|
||||
for path, text in files:
|
||||
literals.extend(log_literals(text, "%s:%s" % (comp, path)))
|
||||
self.assertGreater(len(literals), 100, "too few logger calls found: is the app source complete?")
|
||||
uncovered = []
|
||||
for needle in needles:
|
||||
if not any(covers([fn + " " + frags[0]] + frags[1:], needle) for fn, frags, _ in literals):
|
||||
uncovered.append(needle)
|
||||
self.assertEqual(uncovered, [], "cloud.py greps for lines the pinned app never logs (sources: %s)"
|
||||
% ", ".join("%s=%s" % (c, w) for c, (w, _) in sources.items()))
|
||||
|
||||
def test_cover_rules(self):
|
||||
self.assertTrue(covers(["_run_loop paused at ", ""], "paused at"))
|
||||
self.assertTrue(covers(["_dwell ", ": holding ", " s"], "warm up: holding"))
|
||||
self.assertTrue(covers(["authenticate_machine SUCCESS"], "authenticate_machine SUCCESS"))
|
||||
self.assertTrue(covers(["_resume_retraced resuming (laser lead ", " ticks)"], "resuming (laser lead"))
|
||||
self.assertTrue(covers(["_finish_action ", " [", "]: finished with event \"", "\""],
|
||||
'finished with event ":completed"'))
|
||||
self.assertFalse(covers(["_run_loop button pressed while paused"], "button pressed while paused; resuming"))
|
||||
self.assertFalse(covers(["_run_loop paused at ", ""], "paused it"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,365 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The cloud.* tests' false-PASS guards, against a fake forgectrl and a
|
||||
replayed gfcloud log: the real test functions run under the real runner
|
||||
Context, the operator prompts answered - and the standing notices of the
|
||||
machine actions acted on - by a script that also drives the replay. The
|
||||
log lines are the machine's own: the lid-abort bench excerpt, and for the
|
||||
pause, the run loop's lines as its host test emits them on the print
|
||||
skeleton of that excerpt.
|
||||
|
||||
What is proven here: each test fails when the machine did the unsafe
|
||||
thing - a dark print with the latch open at the button wait, a lid stop
|
||||
that is polled rather than edge-driven, and a resume the kernel refused."""
|
||||
import contextlib
|
||||
import os
|
||||
import shutil
|
||||
import struct
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
|
||||
import helpers
|
||||
from forgetest.runner import Context, Failed, Run
|
||||
from forgetest.suite import cloud
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
FIX = os.path.join(HERE, "fixtures")
|
||||
|
||||
|
||||
def fixture(name):
|
||||
with open(os.path.join(FIX, "gfcloud-%s.log" % name), "rb") as f:
|
||||
return f.read().decode().splitlines()
|
||||
|
||||
|
||||
def cut(lines, marker, count=1):
|
||||
"""(before, after) at the count-th line containing marker (the line
|
||||
itself opens `after`)."""
|
||||
n = 0
|
||||
for i, ln in enumerate(lines):
|
||||
if marker in ln:
|
||||
n += 1
|
||||
if n == count:
|
||||
return lines[:i], lines[i:]
|
||||
raise KeyError(marker)
|
||||
|
||||
|
||||
def dark_print(ctx):
|
||||
"""The dark print as exthost.armed-freeze runs it: the cloud suite's
|
||||
dark_print_body between the offline service's setup and cleanup."""
|
||||
offset = cloud.enter_offline(ctx)
|
||||
job = cloud.offline_job(ctx, "dark.puls", seconds=30)
|
||||
off = cloud.Offline().__enter__()
|
||||
try:
|
||||
cloud.dark_print_body(ctx, ctx.evidence, off, job, offset, ctx.forgectrl)
|
||||
finally:
|
||||
off.__exit__(None, None, None)
|
||||
cloud.offline_cleanup(ctx)
|
||||
|
||||
|
||||
class Script:
|
||||
"""Answers every prompt with its first option; a hook per prompt or
|
||||
notice substring drives the fake machine and the log replay (a
|
||||
machine action is a notice the test watches the machine for, so the
|
||||
hook is what makes the machine show it)."""
|
||||
|
||||
def __init__(self, run, hooks=None):
|
||||
self.run = run
|
||||
self.hooks = hooks or {}
|
||||
self.asked = []
|
||||
self.noticed = []
|
||||
self.th = threading.Thread(target=self._loop, daemon=True)
|
||||
self.stop = False
|
||||
|
||||
def start(self):
|
||||
self.th.start()
|
||||
return self
|
||||
|
||||
def _fire(self, text):
|
||||
for key, fn in self.hooks.items():
|
||||
if key in text:
|
||||
fn()
|
||||
|
||||
def _loop(self):
|
||||
seen = None
|
||||
seen_n = None
|
||||
while not self.stop:
|
||||
n = self.run.notice
|
||||
if n and n["id"] != seen_n:
|
||||
seen_n = n["id"]
|
||||
self.noticed.append(n["text"])
|
||||
self._fire(n["text"])
|
||||
p = self.run.prompt
|
||||
if p and p["id"] != seen:
|
||||
seen = p["id"]
|
||||
self.asked.append(p["question"])
|
||||
self._fire(p["question"])
|
||||
self.run.answer(p["id"], p["options"][0])
|
||||
time.sleep(0.02)
|
||||
|
||||
|
||||
OFFLINE_LINE = ("2026-08-22T23:00:00.100000+00:00 gfcloud[3100] INFO offline:open OFFLINE service: "
|
||||
"no web session; listening on /run/gfcloud-offline.sock")
|
||||
|
||||
|
||||
class FakeOffline:
|
||||
"""The offline service's socket, as the tests see it: what was sent,
|
||||
and a hook that lands the machine's lines for each message."""
|
||||
on_send = None
|
||||
sent = []
|
||||
|
||||
def __init__(self, path=None):
|
||||
self.events = []
|
||||
self.buf = b""
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def send(self, msg):
|
||||
FakeOffline.sent.append(msg)
|
||||
if FakeOffline.on_send:
|
||||
FakeOffline.on_send(msg)
|
||||
|
||||
def poll(self):
|
||||
return []
|
||||
|
||||
def print_ready(self, action_id, path, settings=None):
|
||||
self.send({"id": action_id, "action_type": "print", "status": "ready",
|
||||
"motion_url": "file://" + path, "settings": settings or {}})
|
||||
|
||||
def cancel(self, action_id, action_type="print"):
|
||||
self.send({"id": action_id, "action_type": action_type, "status": "cancelled"})
|
||||
|
||||
|
||||
class CloudSuiteTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.mkdtemp(prefix="forgetest-cloud-")
|
||||
self.log = os.path.join(self.tmp, "gfcloud.log")
|
||||
open(self.log, "wb").close()
|
||||
self.sysfs = os.path.join(self.tmp, "sysfs") + os.sep
|
||||
os.makedirs(self.sysfs + "cnc")
|
||||
self._attr("cnc/interlock_circuit", "45") # latch locked (bit 3)
|
||||
self._pos(0, 0, 3)
|
||||
os.environ["GF_SYSFS_ROOT"] = self.sysfs
|
||||
# the button LEDs, dark (the tests check a cancel leaves them so)
|
||||
for n in ("button_led_1", "button_led_2", "button_led_3"):
|
||||
os.makedirs(os.path.join(self.tmp, "leds", n), exist_ok=True)
|
||||
with open(os.path.join(self.tmp, "leds", n, "brightness"), "w") as f:
|
||||
f.write("0")
|
||||
os.environ["GF_LEDS_ROOT"] = os.path.join(self.tmp, "leds") + os.sep
|
||||
self.fc = helpers.FakeForgectrl().start()
|
||||
self.grbl = None
|
||||
self.fclog = os.path.join(self.tmp, "forgectrl.log")
|
||||
open(self.fclog, "wb").close()
|
||||
self.homelog = os.path.join(self.tmp, "gfhome.log")
|
||||
open(self.homelog, "wb").close()
|
||||
self.saved = (cloud.GFCLOUD_LOG, cloud.FORGECTRL_LOG, cloud.QUIET_S, cloud.QUIET_TIMEOUT_S,
|
||||
cloud.HUNT_TIMEOUT_S, cloud.GFHOME_LOG, cloud.Offline, cloud.OFFLINE_MARKER,
|
||||
cloud.JOB_DIR)
|
||||
cloud.GFCLOUD_LOG = self.log
|
||||
cloud.FORGECTRL_LOG = self.fclog
|
||||
cloud.GFHOME_LOG = self.homelog
|
||||
cloud.Offline = FakeOffline
|
||||
cloud.OFFLINE_MARKER = os.path.join(self.tmp, "offline-marker")
|
||||
cloud.JOB_DIR = os.path.join(self.tmp, "jobs")
|
||||
self.saved_emu = (cloud.EMULATE_MARKER, cloud.EMULATOR_WORK, cloud.NOHUNT_MARKER)
|
||||
cloud.EMULATE_MARKER = os.path.join(self.tmp, "emulate-marker")
|
||||
cloud.EMULATOR_WORK = os.path.join(self.tmp, "emu-work")
|
||||
cloud.NOHUNT_MARKER = os.path.join(self.tmp, "nohunt-marker")
|
||||
FakeOffline.sent = []
|
||||
FakeOffline.on_send = None
|
||||
cloud.QUIET_S = 0.4
|
||||
# A deadline, not a wait: a quiet machine is seen at once. The
|
||||
# replays land their lines from threads, so under a loaded host a
|
||||
# tighter deadline expires before the last of them is written.
|
||||
cloud.QUIET_TIMEOUT_S = 15
|
||||
cloud.HUNT_TIMEOUT_S = 8
|
||||
self.script = None
|
||||
|
||||
def tearDown(self):
|
||||
if self.script:
|
||||
self.script.stop = True
|
||||
self.fc.stop()
|
||||
if self.grbl:
|
||||
self.grbl.stop()
|
||||
(cloud.GFCLOUD_LOG, cloud.FORGECTRL_LOG, cloud.QUIET_S, cloud.QUIET_TIMEOUT_S,
|
||||
cloud.HUNT_TIMEOUT_S, cloud.GFHOME_LOG, cloud.Offline, cloud.OFFLINE_MARKER,
|
||||
cloud.JOB_DIR) = self.saved
|
||||
(cloud.EMULATE_MARKER, cloud.EMULATOR_WORK, cloud.NOHUNT_MARKER) = self.saved_emu
|
||||
os.environ.pop("GF_SYSFS_ROOT", None)
|
||||
os.environ.pop("GF_LEDS_ROOT", None)
|
||||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||
|
||||
# -- fakes -----------------------------------------------------------
|
||||
def _attr(self, attr, val):
|
||||
with open(self.sysfs + attr, "w") as f:
|
||||
f.write(val)
|
||||
|
||||
def _pos(self, x, y, z):
|
||||
with open(self.sysfs + "cnc/position", "wb") as f:
|
||||
f.write(struct.pack("<3i2I", x, y, z, 0, 0))
|
||||
|
||||
def append(self, lines, delay=0.0):
|
||||
def go():
|
||||
if delay:
|
||||
time.sleep(delay)
|
||||
with open(self.log, "ab") as f:
|
||||
f.write(("\n".join(lines) + "\n").encode())
|
||||
if delay:
|
||||
threading.Thread(target=go, daemon=True).start()
|
||||
else:
|
||||
go()
|
||||
|
||||
def in_cloud(self, pid=2278):
|
||||
self.fc.state["mode"] = {"mode": "cloud", "controller": "running", "pid": pid, "motion": "verified"}
|
||||
self.fc.state["settings"]["controller_mode"] = "cloud"
|
||||
|
||||
def lid(self, closed):
|
||||
self.fc.state["status"]["switches"]["lid"] = bool(closed)
|
||||
|
||||
def hunted(self, pid):
|
||||
"""The running client homed the machine under the service (a test
|
||||
that prints reuses only such a session)."""
|
||||
self.append(["2026-08-17T09:41:20.000000+00:00 gfcloud[%d] INFO basemachine:_finish_action hunt "
|
||||
"[1578200001]: finished with event \":completed\"" % pid])
|
||||
|
||||
def in_offline(self, pid=3100):
|
||||
"""Cloud mode with the offline service already up (its mark is the
|
||||
newest websocket-state line for the pid)."""
|
||||
self.in_cloud(pid=pid)
|
||||
self.append([OFFLINE_LINE.replace("gfcloud[3100]", "gfcloud[%d]" % pid)])
|
||||
|
||||
def offline_print_hooks(self, pre, cancel_tail=None):
|
||||
"""The fake socket's reactions: a print lands its prologue through
|
||||
the run (the operator's press is already in it), a cancel lands the
|
||||
app-cancel tail."""
|
||||
def on_send(msg):
|
||||
if msg["action_type"] == "print" and msg["status"] == "ready":
|
||||
self.append(pre, delay=0.1)
|
||||
elif msg["status"] == "cancelled" and cancel_tail:
|
||||
self.append(cancel_tail, delay=0.05)
|
||||
FakeOffline.on_send = on_send
|
||||
|
||||
def run_test(self, fn, hooks=None, test_id="cloud.x"):
|
||||
run = Run("test", test_id, test_id)
|
||||
run.baseline_captured = {"mode": self.fc.state["mode"]["mode"], "position": [0, 0, 0]}
|
||||
ctx = Context(run, None, helpers.make_test(test_id, []))
|
||||
self.script = Script(run, hooks).start()
|
||||
try:
|
||||
fn(ctx)
|
||||
finally:
|
||||
self.script.stop = True
|
||||
return run
|
||||
|
||||
def assertFails(self, fn, needle, hooks=None):
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test(fn, hooks)
|
||||
self.assertIn(needle, str(cm.exception))
|
||||
return cm.exception
|
||||
|
||||
# -- pause / resume ---------------------------------------------------------
|
||||
def test_pause_resume_fails_when_the_kernel_refuses_the_resume(self):
|
||||
self.in_cloud(pid=1522)
|
||||
self.hunted(1522)
|
||||
self.append(["2026-08-17T09:41:00.500000+00:00 gfcloud[1522] INFO websocket:_on_open RX-EVENT: ready"])
|
||||
lines = [l.replace("machine:_resume_retraced resuming (laser lead 1950 ticks)",
|
||||
"machine:_resume_retraced resume refused ([Errno 22] Invalid argument); canceling")
|
||||
for l in fixture("pause")]
|
||||
pre, rest = cut(lines, "current state: MachineState.RUNNING")
|
||||
pre, rest = pre + [rest[0]], rest[1:]
|
||||
with self.fast_wait_log():
|
||||
hooks = {"Click Done here": lambda: self.append(pre, delay=0.1),
|
||||
"the press pauses the print": lambda: self.append(rest, delay=0.05)}
|
||||
self.assertFails(cloud.pause_resume, "the resume was refused", hooks=hooks)
|
||||
|
||||
@contextlib.contextmanager
|
||||
def fast_wait_log(self):
|
||||
"""The wait for the pause lines is 90 s: a replay whose line never
|
||||
comes would sit it out. Cap it through the module's wait."""
|
||||
saved = cloud.wait_log
|
||||
|
||||
def fast(ctx, offset, needles, timeout, poll=0.5):
|
||||
return saved(ctx, offset, needles, min(timeout, 1.5), poll=0.1)
|
||||
cloud.wait_log = fast
|
||||
press = cloud.PRESS_TIMEOUT_S
|
||||
cloud.PRESS_TIMEOUT_S = 1.5
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
cloud.wait_log = saved
|
||||
cloud.PRESS_TIMEOUT_S = press
|
||||
|
||||
# -- the merged lid + interlock abort test -------------------------------
|
||||
def abort_parts(self):
|
||||
"""The lid-abort excerpt split for the merged test: the print
|
||||
prologue (replayed for both prints), the lid stop + park + cancel,
|
||||
and the same tail with the interlock as the trigger, cut where the
|
||||
test stops to have the lid opened during the park."""
|
||||
lines = fixture("lidabort")
|
||||
pre, rest = cut(lines, "waiting for button")
|
||||
run_pre, rest = cut(rest, "machine:_run_loop starting run")
|
||||
pre, rest = pre + run_pre + [rest[0]], rest[1:]
|
||||
ilk = [l.replace("lid opened mid-run; stopping motion",
|
||||
"interlock opened mid-run; stopping motion") for l in rest]
|
||||
stop, tail = cut(ilk, "start return home")
|
||||
return pre, rest, stop + [tail[0]], tail[1:]
|
||||
|
||||
def abort_hooks(self, pre, lid_tail, ilk_stop, ilk_park, prints):
|
||||
def on_send(msg):
|
||||
if msg["status"] == "ready":
|
||||
prints.append(msg["id"])
|
||||
self.append(pre, delay=0.1)
|
||||
FakeOffline.on_send = on_send
|
||||
|
||||
def pull_interlock():
|
||||
self.fc.state["status"]["switches"]["interlock_ok"] = False
|
||||
self.append(ilk_stop, delay=0.05)
|
||||
|
||||
def restore():
|
||||
self.lid(True)
|
||||
self.fc.state["status"]["switches"]["interlock_ok"] = True
|
||||
return {"leave the lid open until the head has returned": lambda: (self.lid(False),
|
||||
self.append(lid_tail, delay=0.05)),
|
||||
"Close the lid.": lambda: self.lid(True),
|
||||
"Open the remote-interlock loop": pull_interlock,
|
||||
"on its way back": lambda: (self.lid(False), self.append(ilk_park, delay=0.05)),
|
||||
"Restore the remote-interlock loop": restore}
|
||||
|
||||
def test_lid_interlock_abort_fails_when_the_lid_stop_is_not_edge_driven(self):
|
||||
# a polled stop (the pre-parity behavior) shows up as a long edge->stop gap
|
||||
self.in_offline()
|
||||
pre, lid_tail, ilk_stop, ilk_park = self.abort_parts()
|
||||
lid_tail = [l.replace("2026-08-17T09:42:30.838627", "2026-08-17T09:42:31.838627")
|
||||
if "lid opened mid-run; stopping motion" in l else l for l in lid_tail]
|
||||
self.assertFails(cloud.lid_interlock_abort, "not edge-driven",
|
||||
hooks=self.abort_hooks(pre, lid_tail, ilk_stop, ilk_park, []))
|
||||
|
||||
# -- the dark print exthost.armed-freeze runs: locked at the button --------
|
||||
def dark_parts(self):
|
||||
"""(the print prologue through the button prompt, the run and the
|
||||
print's end with no pause in it)."""
|
||||
lines = fixture("pause")
|
||||
pre, rest = cut(lines, "waiting for button")
|
||||
pre = pre + [rest[0]]
|
||||
_, run = cut(rest, "machine:_run_loop starting run")
|
||||
run = [l for l in run if "paus" not in l and "resum" not in l]
|
||||
return pre, run
|
||||
|
||||
def test_dark_print_fails_when_the_latch_is_open_at_the_button(self):
|
||||
self.in_offline()
|
||||
pre, run = self.dark_parts()
|
||||
self._attr("cnc/interlock_circuit", "37") # unlocked before the press
|
||||
self.offline_print_hooks(pre)
|
||||
hooks = {"press it. The print runs dark": lambda: self.append(run, delay=0.05)}
|
||||
self.assertFails(dark_print, "the latch is unlocked at the button wait", hooks=hooks)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,24 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""cooling.aa-offset-calibrate is registered before the heater tools, so
|
||||
a queue runs it first: a heater trial leaves a warm slug circulating past
|
||||
the coolant sensors for minutes, and the tool's edges read it as
|
||||
disagreement."""
|
||||
import unittest
|
||||
|
||||
from forgetest import catalog
|
||||
|
||||
|
||||
class CoolingOrderTests(unittest.TestCase):
|
||||
def test_aa_offset_precedes_flow_verify(self):
|
||||
reg = catalog.load_suite()
|
||||
tests = catalog.all_tests(reg)
|
||||
order = catalog.order_by_requires(tests, [t.id for t in tests])
|
||||
self.assertLess(order.index("cooling.aa-offset-calibrate"), order.index("cooling.flow-verify"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,64 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""cooling.flow-under-load reads the engine's flow verdict from what the
|
||||
forgectrl log gained since the job began: only the text after the offset,
|
||||
never an older verdict before it, and the whole of a log rotated under
|
||||
the test."""
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from forgetest.suite import cooling
|
||||
|
||||
|
||||
class FlowVerdictLogTests(unittest.TestCase):
|
||||
"""cooling.flow-under-load finds the engine's verdict in what the log
|
||||
gained since the job began, not in a count over a tail of fixed length:
|
||||
there a new verdict line comes in at the bottom as an old one leaves at
|
||||
the top, the count does not move, and a check that verified reads as
|
||||
one that never judged."""
|
||||
|
||||
VERDICT = ("2026-09-19T21:37:46.869674+00:00 forgectrl[2796] INFO cool: coolant flow verified "
|
||||
"(heater rise 11.5 C, dT 9.5 C; laser 1.6 off 13.1)\n")
|
||||
OLD = ("2026-09-19T21:17:27.200840+00:00 forgectrl[965] INFO cool: coolant flow verified "
|
||||
"(heater rise 9.5 C, dT 8.7 C)\n")
|
||||
|
||||
def setUp(self):
|
||||
self.dir = tempfile.mkdtemp(prefix="flowlog.")
|
||||
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
|
||||
self.path = os.path.join(self.dir, "forgectrl.log")
|
||||
|
||||
def write(self, text, mode="w"):
|
||||
with open(self.path, mode, newline="\n") as f:
|
||||
f.write(text)
|
||||
|
||||
def test_only_the_text_after_the_offset_comes_back(self):
|
||||
self.write(self.OLD + "filler\n" * 400)
|
||||
off = os.path.getsize(self.path)
|
||||
self.assertEqual(cooling._log_since(self.path, off), "")
|
||||
self.write(self.VERDICT, mode="a")
|
||||
text = cooling._log_since(self.path, off)
|
||||
self.assertEqual(text, self.VERDICT)
|
||||
m = cooling._load_verdict_rx().search(text)
|
||||
self.assertEqual(m.group(1), "coolant flow verified")
|
||||
self.assertEqual((m.group(2), m.group(5)), ("11.5", "1.6"))
|
||||
|
||||
def test_an_old_verdict_before_the_offset_is_not_the_new_one(self):
|
||||
self.write(self.OLD)
|
||||
off = os.path.getsize(self.path)
|
||||
self.write("2026-09-19T21:36:40+00:00 forgectrl[2796] INFO cool: crash watch armed\n", mode="a")
|
||||
self.assertIsNone(cooling._load_verdict_rx().search(cooling._log_since(self.path, off)))
|
||||
|
||||
def test_a_log_rotated_under_the_test_is_read_whole(self):
|
||||
self.write(self.OLD + "filler\n" * 400)
|
||||
off = os.path.getsize(self.path)
|
||||
self.write(self.VERDICT) # the rotation: a new, shorter file
|
||||
self.assertEqual(cooling._log_since(self.path, off), self.VERDICT)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,716 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The bench actuator as the tool sees it.
|
||||
|
||||
The fixture is a box on the network that opens the lid loop, pulls the
|
||||
interlock loop and presses the button on request. What has to hold: the
|
||||
tool speaks its API under the key, falls back to the operator when it
|
||||
cannot act, moves an operator test it can perform alone into the
|
||||
unattended queue (never a live one, never one that needs a person for
|
||||
anything else), refuses a prompt during such a run rather than hanging on
|
||||
it, releases what the box still holds after every run, and presses the
|
||||
arm button only where the bench opted in.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
import helpers
|
||||
from forgetest import fixture as fx
|
||||
from forgetest import runner as runner_mod
|
||||
from forgetest.log import Log
|
||||
from forgetest.runner import Context, Run, Runner
|
||||
|
||||
KEY = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
|
||||
class FakeFixture:
|
||||
"""The device's API, as fixture/README.md describes it."""
|
||||
|
||||
def __init__(self, button_enabled=True):
|
||||
self.state = {"lid": "closed", "interlock": "closed", "button": "idle"}
|
||||
self.button_enabled = button_enabled
|
||||
self.calls = []
|
||||
self.presses = 0 # presses the device performed
|
||||
self.reply_delay = 0.0 # seconds between a press and its reply
|
||||
self.replied = 0 # delayed replies the device is done with (sent or lost)
|
||||
srv = self
|
||||
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
def _json(self, code, obj):
|
||||
body = json.dumps(obj).encode()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _state(self):
|
||||
return {"device": "forgefixture", "hostname": "forgefixture", "version": "1.0.0",
|
||||
"idf": "v5.5.5", "uptime_s": 12, "channels": dict(srv.state),
|
||||
"button_enabled": srv.button_enabled,
|
||||
"button_pulsing": srv.state["button"] == "pressed",
|
||||
"wifi": {"connected": True, "ip": "127.0.0.1", "rssi": -50}}
|
||||
|
||||
def _auth(self):
|
||||
if self.headers.get("X-Fixture-Key") != KEY:
|
||||
self._json(401, {"error": "X-Fixture-Key missing or wrong"})
|
||||
return False
|
||||
return True
|
||||
|
||||
def do_GET(self):
|
||||
srv.calls.append(("GET", self.path))
|
||||
if not self._auth():
|
||||
return
|
||||
self._json(200, self._state())
|
||||
|
||||
def do_POST(self):
|
||||
n = int(self.headers.get("Content-Length") or 0)
|
||||
body = json.loads(self.rfile.read(n).decode() or "{}")
|
||||
srv.calls.append(("POST", self.path, body))
|
||||
if not self._auth():
|
||||
return
|
||||
if self.path in ("/lid", "/interlock"):
|
||||
st = body.get("state")
|
||||
if st not in ("open", "close", "closed"):
|
||||
return self._json(400, {"error": "state must be \"open\" or \"close\""})
|
||||
srv.state[self.path[1:]] = "open" if st == "open" else "closed"
|
||||
return self._json(200, self._state())
|
||||
if self.path == "/button":
|
||||
if not srv.button_enabled:
|
||||
return self._json(409, {"error": "button disabled: the enable jumper is out"})
|
||||
if srv.state["button"] == "pressed":
|
||||
return self._json(409, {"error": "a button pulse is in progress"})
|
||||
srv.state["button"] = "pressed"
|
||||
srv.presses += 1
|
||||
threading.Timer(0.05, lambda: srv.state.__setitem__("button", "idle")).start()
|
||||
s = self._state()
|
||||
s["pulse_ms"] = 200
|
||||
if srv.reply_delay:
|
||||
time.sleep(srv.reply_delay) # the reply comes after the client gave up
|
||||
try:
|
||||
self._json(200, s)
|
||||
except OSError:
|
||||
pass
|
||||
srv.replied += 1
|
||||
return None
|
||||
return self._json(200, s)
|
||||
if self.path == "/release":
|
||||
srv.state = {"lid": "closed", "interlock": "closed", "button": "idle"}
|
||||
return self._json(200, self._state())
|
||||
self._json(404, {"error": "no such path"})
|
||||
|
||||
self.httpd = HTTPServer(("127.0.0.1", 0), H)
|
||||
self.port = self.httpd.server_address[1]
|
||||
threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
|
||||
|
||||
def stop(self):
|
||||
self.httpd.shutdown()
|
||||
self.httpd.server_close()
|
||||
|
||||
def wait_replied(self, n, timeout=5.0):
|
||||
deadline = time.time() + timeout
|
||||
while self.replied < n and time.time() < deadline:
|
||||
time.sleep(0.02)
|
||||
|
||||
|
||||
class ConfigTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.mkdtemp(prefix="forgetest-fixture-")
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||
|
||||
def write(self, obj):
|
||||
p = os.path.join(self.tmp, "fixture.json")
|
||||
with open(p, "w") as f:
|
||||
json.dump(obj, f)
|
||||
return p
|
||||
|
||||
def test_no_file_is_no_fixture(self):
|
||||
self.assertIsNone(fx.load_config(os.path.join(self.tmp, "none.json")))
|
||||
|
||||
def test_defaults_and_checks(self):
|
||||
cfg = fx.load_config(self.write({"key": KEY}))
|
||||
self.assertEqual(cfg["hostname"], "forgefixture")
|
||||
self.assertEqual(cfg["channels"], ["lid", "interlock", "button"])
|
||||
self.assertFalse(cfg["arm_press"])
|
||||
self.assertIsNone(cfg["ip"])
|
||||
with self.assertRaises(fx.FixtureError):
|
||||
fx.load_config(self.write({"hostname": "x"})) # no key
|
||||
with self.assertRaises(fx.FixtureError):
|
||||
fx.load_config(self.write({"key": KEY, "channels": ["lid", "laser"]}))
|
||||
p = self.write({"key": KEY})
|
||||
with open(p, "w") as f:
|
||||
f.write("{not json")
|
||||
with self.assertRaises(fx.FixtureError):
|
||||
fx.load_config(p)
|
||||
|
||||
|
||||
class ClientTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dev = FakeFixture()
|
||||
self.resolved = []
|
||||
|
||||
def tearDown(self):
|
||||
self.dev.stop()
|
||||
|
||||
def resolver(self, hostname):
|
||||
self.resolved.append(hostname)
|
||||
return "127.0.0.1"
|
||||
|
||||
def client(self, timeout=3.0, resolver=None, **over):
|
||||
cfg = {"hostname": "forgefixture", "key": KEY, "channels": ["lid", "interlock", "button"],
|
||||
"port": self.dev.port}
|
||||
cfg.update(over)
|
||||
return fx.Fixture(cfg, timeout=timeout, resolver=resolver or self.resolver)
|
||||
|
||||
def test_status_covers_act_release(self):
|
||||
f = self.client()
|
||||
st = f.status()
|
||||
self.assertEqual(st["device"], "forgefixture")
|
||||
self.assertEqual(self.resolved, ["forgefixture"]) # resolved once
|
||||
self.assertTrue(f.covers("lid") and f.covers("interlock") and f.covers("button"))
|
||||
f.act("lid", "open")
|
||||
self.assertEqual(self.dev.state["lid"], "open")
|
||||
f.act("interlock", "open")
|
||||
f.act("button", "press")
|
||||
self.assertEqual(self.dev.calls[-1], ("POST", "/button", {}))
|
||||
time.sleep(0.15)
|
||||
# a pulse length asked for goes to the box as asked; the clamp is the box's
|
||||
f.act("button", "press", ms=500)
|
||||
self.assertEqual(self.dev.calls[-1], ("POST", "/button", {"ms": 500}))
|
||||
time.sleep(0.15) # the fake's pulse ends
|
||||
self.assertEqual(sorted(fx.Fixture.energized(f.status())), ["interlock", "lid"])
|
||||
f.release()
|
||||
self.assertEqual(fx.Fixture.energized(f.status()), [])
|
||||
self.assertEqual(self.resolved, ["forgefixture"]) # the address is cached
|
||||
with self.assertRaises(fx.FixtureError):
|
||||
f.act("button", "hold")
|
||||
with self.assertRaises(fx.FixtureError):
|
||||
f.act("lid", "ajar")
|
||||
|
||||
def test_the_button_is_covered_only_with_the_jumper_in(self):
|
||||
self.dev.button_enabled = False
|
||||
f = self.client()
|
||||
f.status()
|
||||
self.assertTrue(f.covers("lid"))
|
||||
self.assertFalse(f.covers("button"))
|
||||
with self.assertRaises(fx.FixtureError) as cm:
|
||||
f.act("button", "press")
|
||||
self.assertIn("jumper", str(cm.exception))
|
||||
|
||||
def test_a_failure_after_the_request_went_out_says_so(self):
|
||||
# The press made and its reply lost (it comes after the client's
|
||||
# timeout): sent, one press. A refusal, a failed lookup and a
|
||||
# refused connect: not sent, no press. The one retry keeps it.
|
||||
self.dev.reply_delay = 0.6
|
||||
with self.assertRaises(fx.FixtureError) as cm:
|
||||
self.client(timeout=0.2, ip="127.0.0.1").act("button", "press")
|
||||
self.assertTrue(cm.exception.sent)
|
||||
self.dev.wait_replied(1)
|
||||
self.assertEqual(self.dev.presses, 1)
|
||||
answers = ["127.0.0.1"] # the refresh lookup after the timeout finds nothing
|
||||
with self.assertRaises(fx.FixtureError) as cm:
|
||||
self.client(timeout=0.2, resolver=lambda h: answers.pop(0) if answers else None).act("button", "press")
|
||||
self.assertTrue(cm.exception.sent)
|
||||
self.dev.wait_replied(2)
|
||||
self.assertEqual(self.dev.presses, 2)
|
||||
self.dev.reply_delay = 0.0
|
||||
self.dev.button_enabled = False
|
||||
with self.assertRaises(fx.FixtureError) as cm:
|
||||
self.client(ip="127.0.0.1").act("button", "press")
|
||||
self.assertFalse(cm.exception.sent)
|
||||
with self.assertRaises(fx.FixtureError) as cm:
|
||||
self.client(resolver=lambda h: None).act("button", "press")
|
||||
self.assertFalse(cm.exception.sent)
|
||||
with self.assertRaises(fx.FixtureError) as cm:
|
||||
self.client(timeout=0.5, ip="127.0.0.1", port=1).act("button", "press")
|
||||
self.assertFalse(cm.exception.sent)
|
||||
self.assertEqual(self.dev.presses, 2)
|
||||
|
||||
def test_a_wrong_key_is_refused(self):
|
||||
f = self.client(key="wrong")
|
||||
with self.assertRaises(fx.FixtureError) as cm:
|
||||
f.status()
|
||||
self.assertIn("refused the key", str(cm.exception))
|
||||
|
||||
def test_an_ip_override_skips_the_lookup(self):
|
||||
f = self.client(ip="127.0.0.1")
|
||||
f.status()
|
||||
self.assertEqual(self.resolved, [])
|
||||
|
||||
def test_channels_not_wired_are_not_covered(self):
|
||||
f = self.client(channels=["lid"])
|
||||
f.status()
|
||||
self.assertTrue(f.covers("lid"))
|
||||
self.assertFalse(f.covers("interlock"))
|
||||
|
||||
def test_probe_without_a_config_or_with_a_silent_box_is_none(self):
|
||||
lines = []
|
||||
self.assertIsNone(fx.probe(lines.append, path=os.path.join(tempfile.gettempdir(), "no-such-fixture.json")))
|
||||
tmp = tempfile.mkdtemp(prefix="forgetest-fixture-")
|
||||
try:
|
||||
p = os.path.join(tmp, "fixture.json")
|
||||
with open(p, "w") as f:
|
||||
json.dump({"key": KEY, "ip": "127.0.0.1", "port": 1}, f)
|
||||
# port 1 on localhost: nothing listens there
|
||||
f2 = fx.probe(lines.append, path=p, resolver=lambda h: None)
|
||||
self.assertIsNone(f2)
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
class CatalogTests(unittest.TestCase):
|
||||
def test_fixture_runnable(self):
|
||||
op = helpers.make_test("m.op", [], kind="operator", actions=("lid", "button"))
|
||||
self.assertTrue(op.fixture_runnable(("lid", "interlock", "button")))
|
||||
self.assertFalse(op.fixture_runnable(("lid",))) # the button is not covered
|
||||
self.assertFalse(helpers.make_test("m.live", [], kind="live", actions=("lid",))
|
||||
.fixture_runnable(("lid",))) # live never downgrades
|
||||
self.assertFalse(helpers.make_test("m.app", [], kind="operator", hands=("app",))
|
||||
.fixture_runnable(("lid", "interlock", "button")))
|
||||
self.assertFalse(helpers.make_test("m.none", [], kind="operator")
|
||||
.fixture_runnable(("lid",))) # no actions: a person does something
|
||||
self.assertFalse(helpers.make_test("m.auto", []).fixture_runnable(("lid",)))
|
||||
|
||||
def test_an_auto_test_asks_nothing_of_a_person(self):
|
||||
from forgetest import catalog
|
||||
with self.assertRaises(ValueError):
|
||||
catalog.test("z.auto", title="t", subsystem="z", hands=("app",))(lambda ctx: None)
|
||||
|
||||
def test_every_action_a_test_takes_is_declared(self):
|
||||
# The queues route a test by its kind and its actions: an auto test
|
||||
# runs unattended with or without a fixture, and an operator test
|
||||
# whose actions the fixture covers runs unattended only while it
|
||||
# does. A test that acts on the lid, the interlock or the button
|
||||
# without saying so runs unattended with no fixture and waits for a
|
||||
# person who is not there.
|
||||
import inspect
|
||||
import re
|
||||
from forgetest import catalog
|
||||
bad = []
|
||||
for t in catalog.all_tests(catalog.load_suite()):
|
||||
src = inspect.getsource(t.fn)
|
||||
taken = set(re.findall(r'ctx\.act\(\s*"(\w+)"', src))
|
||||
if taken and (t.kind == "auto" or not taken <= set(t.actions)):
|
||||
bad.append("%s (%s) acts on %s, declares %s" % (t.id, t.kind, sorted(taken), list(t.actions)))
|
||||
self.assertEqual(bad, [])
|
||||
|
||||
|
||||
class StubFixture:
|
||||
"""What the runner needs of a fixture, scripted."""
|
||||
|
||||
def __init__(self, channels=("lid", "interlock", "button"), button_enabled=True, arm_press=False,
|
||||
fail=False, fc=None):
|
||||
self.fc = fc # the fake forgectrl whose switches follow the actions
|
||||
self.hostname = "forgefixture"
|
||||
self._ip = "127.0.0.1"
|
||||
self.channels = tuple(channels)
|
||||
self.button_enabled = button_enabled
|
||||
self.arm_press = arm_press
|
||||
self.fail = fail
|
||||
self.acts = []
|
||||
self.held = []
|
||||
self.released = 0
|
||||
|
||||
def covers(self, channel):
|
||||
return channel in self.channels and (channel != "button" or self.button_enabled)
|
||||
|
||||
def act(self, channel, state, ms=None):
|
||||
if self.fail:
|
||||
raise fx.FixtureError("the box is off")
|
||||
self.acts.append((channel, state) if ms is None else (channel, state, ms))
|
||||
if state == "open":
|
||||
self.held.append(channel)
|
||||
if self.fc is not None and channel in ("lid", "interlock"):
|
||||
sw = self.fc.state["status"]["switches"]
|
||||
sw["lid" if channel == "lid" else "interlock_ok"] = state != "open"
|
||||
|
||||
def status(self):
|
||||
return {"channels": {c: ("open" if c in self.held else "closed") for c in ("lid", "interlock")}}
|
||||
|
||||
@staticmethod
|
||||
def energized(state):
|
||||
return fx.Fixture.energized(state)
|
||||
|
||||
def release(self):
|
||||
self.released += 1
|
||||
self.held = []
|
||||
|
||||
def summary(self):
|
||||
return {"hostname": self.hostname, "ip": self._ip, "channels": list(self.channels),
|
||||
"button_enabled": self.button_enabled, "arm_press": self.arm_press}
|
||||
|
||||
|
||||
def t_lid(ctx):
|
||||
ctx.ready("On Ready the lid opens")
|
||||
ctx.act("lid", "open")
|
||||
ctx.act("lid", "close")
|
||||
|
||||
|
||||
def t_asks(ctx):
|
||||
ctx.act("lid", "open")
|
||||
ctx.confirm("Did it?")
|
||||
|
||||
|
||||
class RoutingTests(unittest.TestCase):
|
||||
"""The queues with a fixture up: the runner's probe is scripted."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.mkdtemp(prefix="forgetest-fixture-run-")
|
||||
os.environ["FORGETEST_DATA"] = self.tmp
|
||||
os.environ["FORGETEST_MARKER"] = os.path.join(self.tmp, "marker")
|
||||
self.fc = helpers.FakeForgectrl().start()
|
||||
self.man = helpers.make_manifest()
|
||||
self.reg = helpers.registry(
|
||||
helpers.make_test("r.auto", [("forgectrl", "src/ui.c")]),
|
||||
helpers.make_test("r.lid", [("forgectrl", "src/auth.c")], kind="operator", actions=("lid",), fn=t_lid),
|
||||
helpers.make_test("r.btn", [("forgectrl", "src/cool.c")], kind="operator", actions=("button",), fn=t_lid),
|
||||
helpers.make_test("r.app", [("forgectrl", "src/main.c")], kind="operator", actions=("lid",),
|
||||
hands=("app",), fn=t_lid),
|
||||
helpers.make_test("r.live", [("grblhal-glowforge", "src/**")], kind="live", actions=("lid",)),
|
||||
helpers.make_test("r.asks", [("linux-fslc", "**")], kind="operator", actions=("lid",), fn=t_asks),
|
||||
)
|
||||
self.log = Log(os.path.join(self.tmp, "results.jsonl"))
|
||||
self.runner = Runner(self.log, self.man, self.reg)
|
||||
self.saved_probe = fx.probe
|
||||
self.stub = StubFixture(fc=self.fc)
|
||||
fx.probe = lambda log, path=None, resolver=None: self.stub
|
||||
|
||||
def tearDown(self):
|
||||
fx.probe = self.saved_probe
|
||||
self.fc.stop()
|
||||
for k in ("FORGETEST_DATA", "FORGETEST_MARKER"):
|
||||
os.environ.pop(k, None)
|
||||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||
|
||||
def selection(self):
|
||||
st, _ = self.runner.state()
|
||||
return st["batch_available"], st["fixture"]
|
||||
|
||||
def test_operator_tests_the_fixture_covers_move_to_the_unattended_queue(self):
|
||||
av, summary = self.selection()
|
||||
self.assertEqual(av["unattended"], ["r.auto", "r.lid", "r.btn", "r.asks"])
|
||||
self.assertEqual(av["attended"], ["r.app", "r.live"]) # needs the app; live never moves
|
||||
self.assertEqual(summary["hostname"], "forgefixture")
|
||||
|
||||
def run_queue(self, group, ack_live=False):
|
||||
ok, msg, order = self.runner.start_batch(group, ack_live=ack_live)
|
||||
self.assertTrue(ok, msg)
|
||||
deadline = time.time() + 30
|
||||
while not self.runner.batch["finished"] and time.time() < deadline:
|
||||
time.sleep(0.05)
|
||||
return self.runner.batch_snapshot(), order
|
||||
|
||||
def last_result(self, tid):
|
||||
for rec in reversed(self.log.read()):
|
||||
if rec.get("t") == "result" and rec["test"] == tid:
|
||||
return rec
|
||||
return None
|
||||
|
||||
def test_the_fixture_performs_the_actions_and_the_ready_gate_passes(self):
|
||||
# r.asks confirms after its action: a person is asked -> a FAIL that
|
||||
# says so, and the queue stops there, after r.lid and r.btn passed
|
||||
b, order = self.run_queue("unattended")
|
||||
self.assertEqual(order, ["r.auto", "r.lid", "r.btn", "r.asks"])
|
||||
results = {x["test"]: x["result"] for x in b["done"]}
|
||||
self.assertEqual(results["r.lid"], "PASS")
|
||||
self.assertEqual(results["r.btn"], "PASS")
|
||||
self.assertEqual(results["r.asks"], "FAIL")
|
||||
rec = self.last_result("r.lid")
|
||||
acts = rec["evidence"]["actions"]
|
||||
self.assertEqual([(a["channel"], a["state"], a["by"]) for a in acts],
|
||||
[("lid", "open", "fixture"), ("lid", "close", "fixture")])
|
||||
self.assertTrue(any("READY (fixture performs the step)" in l for l in rec["log"]), rec["log"])
|
||||
self.assertEqual(rec["evidence"]["fixture"]["channels"], ["lid", "interlock", "button"])
|
||||
asks = self.last_result("r.asks")
|
||||
self.assertIn("declare the step in hands=", asks["message"])
|
||||
|
||||
def test_a_failing_box_in_an_unattended_run_ends_the_test_as_an_error(self):
|
||||
# nobody is in the room: the run must not wait ACT_TIMEOUT_S for a
|
||||
# hand that is not there; it ends at once, as the harness's error
|
||||
self.stub.fail = True
|
||||
t0 = time.time()
|
||||
b, order = self.run_queue("unattended")
|
||||
results = {x["test"]: x["result"] for x in b["done"]}
|
||||
self.assertEqual(results["r.lid"], "ERROR")
|
||||
self.assertLess(time.time() - t0, 20)
|
||||
rec = self.last_result("r.lid")
|
||||
self.assertIn("the fixture could not perform a step", rec["message"])
|
||||
self.assertIn("the box is off", rec["message"])
|
||||
act = rec["evidence"]["actions"][0]
|
||||
self.assertEqual((act["by"], act["fixture_error"]), ("fixture", "the box is off"))
|
||||
self.assertFalse(any("asking the operator" in l for l in rec["log"]), rec["log"])
|
||||
|
||||
def test_what_the_box_still_holds_is_released_after_a_run(self):
|
||||
def holds(ctx):
|
||||
ctx.act("lid", "open") # and never closes it
|
||||
self.reg["r.hold"] = helpers.make_test("r.hold", [("forgectrl", "src/ui.c")], kind="operator",
|
||||
actions=("lid",), fn=holds)
|
||||
self.runner.registry = self.reg
|
||||
ok, msg, run = self.runner._start_test("r.hold")
|
||||
self.assertTrue(ok, msg)
|
||||
while run.finished is None:
|
||||
time.sleep(0.05)
|
||||
self.assertEqual(self.stub.released, 1)
|
||||
rec = self.last_result("r.hold")
|
||||
self.assertEqual(rec["evidence"]["fixture"]["released"], ["lid"])
|
||||
|
||||
def press_button(self, down):
|
||||
self.fc.state["status"]["switches"]["button"] = bool(down)
|
||||
|
||||
def test_ready_takes_a_press_on_the_machine_as_the_presence_check(self):
|
||||
"""With an actuator wired to the button, the operator proves they
|
||||
are at the machine by pressing it, not by clicking the page. The
|
||||
actuator then owns every press in the test, so no press in a live
|
||||
cut is a person's and none can go uncounted."""
|
||||
run = Run("test", "r.live", "r.live")
|
||||
ctx = Context(run, self.runner, self.reg["r.live"])
|
||||
self.runner.probe_fixture(force=True)
|
||||
asked = []
|
||||
run.ask = lambda q, o: asked.append(q)
|
||||
self.press_button(False)
|
||||
threading.Timer(0.3, self.press_button, args=(True,)).start()
|
||||
threading.Timer(0.8, self.press_button, args=(False,)).start()
|
||||
ctx.ready("LIVE FIRE. Scrap under the head.")
|
||||
self.assertEqual(asked, []) # no page click asked for
|
||||
self.assertTrue(run.fixture_takeover)
|
||||
rec = [r for r in run.evidence["actions"] if r["state"] == "presence"]
|
||||
self.assertEqual(len(rec), 1)
|
||||
self.assertEqual(rec[0]["by"], "operator")
|
||||
self.assertTrue(any("press the button on the machine" in ln.lower() for ln in run.lines))
|
||||
|
||||
def test_the_presence_press_hands_the_arm_press_to_the_actuator(self):
|
||||
"""The bench's standing opt-in is not needed once the operator has
|
||||
proved presence: that press is what the opt-in existed to
|
||||
establish."""
|
||||
run = Run("test", "r.live", "r.live")
|
||||
ctx = Context(run, self.runner, self.reg["r.live"])
|
||||
self.runner.probe_fixture(force=True)
|
||||
self.stub.arm_press = False
|
||||
run.fixture_takeover = True
|
||||
saved = runner_mod.hw.button_lit
|
||||
runner_mod.hw.button_lit = lambda: True
|
||||
try:
|
||||
self.assertTrue(ctx.arm_press())
|
||||
deadline = time.time() + 5
|
||||
while ("button", "press") not in self.stub.acts and time.time() < deadline:
|
||||
time.sleep(0.05)
|
||||
self.assertIn(("button", "press"), self.stub.acts)
|
||||
finally:
|
||||
runner_mod.hw.button_lit = saved
|
||||
|
||||
def test_an_actuator_lost_after_the_takeover_is_said_out_loud(self):
|
||||
"""Falling back to the operator without a word is how one dropped
|
||||
actuator becomes a press nobody can account for afterwards."""
|
||||
run = Run("test", "r.live", "r.live")
|
||||
ctx = Context(run, self.runner, self.reg["r.live"])
|
||||
self.runner.probe_fixture(force=True)
|
||||
run.fixture_takeover = True
|
||||
self.runner.fixture = None # the box drops off mid-test
|
||||
self.fc.state["status"]["switches"]["lid"] = True
|
||||
threading.Timer(0.3, lambda: self.fc.state["status"]["switches"].__setitem__("lid", False)).start()
|
||||
ctx.act("lid", "open", timeout=5)
|
||||
rec = run.evidence["actions"][-1]
|
||||
self.assertTrue(rec.get("fixture_lost"))
|
||||
self.assertEqual(rec["by"], "operator")
|
||||
|
||||
def test_the_arm_press_is_the_operators_unless_the_bench_opted_in(self):
|
||||
run = Run("test", "r.live", "r.live")
|
||||
ctx = Context(run, self.runner, self.reg["r.live"])
|
||||
self.runner.probe_fixture(force=True)
|
||||
seen = []
|
||||
run.set_notice = lambda text: seen.append(text)
|
||||
self.assertFalse(ctx.arm_press())
|
||||
self.assertEqual(run.evidence["actions"][0]["by"], "operator")
|
||||
self.assertTrue(seen)
|
||||
# opted in: the press waits for the button to light
|
||||
self.stub.arm_press = True
|
||||
lit = {"v": False}
|
||||
saved = runner_mod.hw.button_lit
|
||||
runner_mod.hw.button_lit = lambda: lit["v"]
|
||||
try:
|
||||
seen[:] = []
|
||||
self.assertTrue(ctx.arm_press())
|
||||
time.sleep(0.3)
|
||||
self.assertEqual(self.stub.acts, []) # not yet: dark button
|
||||
lit["v"] = True
|
||||
deadline = time.time() + 5
|
||||
while not self.stub.acts and time.time() < deadline:
|
||||
time.sleep(0.05)
|
||||
self.assertEqual(self.stub.acts, [("button", "press")])
|
||||
self.assertEqual(run.evidence["actions"][1]["by"], "fixture")
|
||||
self.assertEqual(seen, []) # no notice went up
|
||||
finally:
|
||||
runner_mod.hw.button_lit = saved
|
||||
|
||||
@unittest.skipUnless(hasattr(os, "mkfifo"), "needs a FIFO for the switches' event device")
|
||||
def test_ready_reads_a_short_press_as_an_edge(self):
|
||||
"""The presence press is the switches' input events, so a press
|
||||
far shorter than a quarter second counts: the press and release
|
||||
land back to back, and the status the old poll read never shows
|
||||
the button down at all."""
|
||||
dev = os.path.join(self.tmp, "switches")
|
||||
os.mkfifo(dev)
|
||||
os.environ["GF_SWITCH_DEV"] = dev
|
||||
ev = runner_mod.hw.ButtonEdges.EVENT
|
||||
|
||||
def press():
|
||||
with open(dev, "wb") as w:
|
||||
w.write(ev.pack(0, 0, runner_mod.hw.EV_SW, runner_mod.hw.SW_BUTTON, 1) +
|
||||
ev.pack(0, 0, 0, 0, 0) +
|
||||
ev.pack(0, 0, runner_mod.hw.EV_SW, runner_mod.hw.SW_BUTTON, 0))
|
||||
try:
|
||||
run = Run("test", "r.live", "r.live")
|
||||
ctx = Context(run, self.runner, self.reg["r.live"])
|
||||
self.runner.probe_fixture(force=True)
|
||||
self.press_button(False) # the status never shows it down
|
||||
threading.Timer(0.3, press).start()
|
||||
t0 = time.time()
|
||||
ctx.ready("LIVE FIRE. Scrap under the head.")
|
||||
self.assertLess(time.time() - t0, 5)
|
||||
self.assertTrue(run.fixture_takeover)
|
||||
rec = [r for r in run.evidence["actions"] if r["state"] == "presence"]
|
||||
self.assertEqual(len(rec), 1)
|
||||
self.assertIsNone(run.notice) # the notice is down with the press
|
||||
finally:
|
||||
os.environ.pop("GF_SWITCH_DEV", None)
|
||||
|
||||
def test_the_arm_notice_comes_down_when_the_press_is_taken(self):
|
||||
"""The controller puts the button out at the press: the operator's
|
||||
arm notice goes down then, not at the test's next step, so nobody
|
||||
presses again (a press during the job pauses it)."""
|
||||
run = Run("test", "r.live", "r.live")
|
||||
ctx = Context(run, self.runner, self.reg["r.live"])
|
||||
self.runner.probe_fixture(force=True)
|
||||
lit = {"v": False}
|
||||
saved = runner_mod.hw.button_lit
|
||||
runner_mod.hw.button_lit = lambda: lit["v"]
|
||||
try:
|
||||
self.assertFalse(ctx.arm_press()) # the operator's press
|
||||
self.assertIsNotNone(run.notice)
|
||||
time.sleep(0.2)
|
||||
self.assertIsNotNone(run.notice) # dark before the arm: still asked for
|
||||
lit["v"] = True
|
||||
time.sleep(0.2)
|
||||
self.assertIsNotNone(run.notice) # lit: the arm waits for the press
|
||||
lit["v"] = False # the press taken
|
||||
deadline = time.time() + 2
|
||||
while run.notice is not None and time.time() < deadline:
|
||||
time.sleep(0.02)
|
||||
self.assertIsNone(run.notice)
|
||||
# A notice put up meanwhile is not the arm's: it stays.
|
||||
lit["v"] = False
|
||||
self.assertFalse(ctx.arm_press())
|
||||
ctx.notice("Something else.")
|
||||
lit["v"] = True
|
||||
time.sleep(0.2)
|
||||
lit["v"] = False
|
||||
time.sleep(0.3)
|
||||
self.assertEqual((run.notice or {}).get("text"), "Something else.")
|
||||
# The watch ends with the run: a test that ends before the press
|
||||
# (a FAIL on the way to the arm, say) leaves no thread polling the
|
||||
# LEDs for up to the arm's whole timeout.
|
||||
def watching():
|
||||
return [t for t in threading.enumerate() if t.name == "forgetest-arm-notice"]
|
||||
deadline = time.time() + 2
|
||||
while watching() and time.time() < deadline:
|
||||
time.sleep(0.02) # the earlier watches are gone
|
||||
self.assertFalse(watching())
|
||||
lit["v"] = False
|
||||
self.assertFalse(ctx.arm_press())
|
||||
self.assertTrue(watching()) # up while the arm is still dark
|
||||
run.finished = {"result": "FAIL", "message": "", "duration_s": 0}
|
||||
deadline = time.time() + 2
|
||||
while watching() and time.time() < deadline:
|
||||
time.sleep(0.02)
|
||||
self.assertFalse(watching())
|
||||
self.assertIsNotNone(run.notice) # the finished run's notice is left as it was
|
||||
# A press the fixture could not make is asked of the operator, and
|
||||
# that notice comes down at the press like any other: press_now and
|
||||
# arm_press with the box refusing, and arm_press with the button
|
||||
# never lit for the box.
|
||||
self.stub.arm_press = True
|
||||
self.stub.fail = True
|
||||
run = Run("test", "r.live", "r.live")
|
||||
ctx = Context(run, self.runner, self.reg["r.live"])
|
||||
|
||||
def settles(up):
|
||||
deadline = time.time() + 2
|
||||
while (run.notice is not None) != up and time.time() < deadline:
|
||||
time.sleep(0.02)
|
||||
return (run.notice is not None) == up
|
||||
lit["v"] = True # the machine waits for the press
|
||||
self.assertFalse(ctx.press_now()) # the box is off: the operator is asked
|
||||
self.assertIsNotNone(run.notice)
|
||||
time.sleep(0.2) # lit: the arm waits for the press
|
||||
lit["v"] = False # the operator's press is taken
|
||||
self.assertTrue(settles(False))
|
||||
lit["v"] = True
|
||||
self.assertTrue(ctx.arm_press()) # the fixture is asked, and refuses
|
||||
self.assertTrue(settles(True))
|
||||
time.sleep(0.2)
|
||||
lit["v"] = False
|
||||
self.assertTrue(settles(False))
|
||||
self.assertTrue(ctx.arm_press(lit_timeout=0)) # the button never lit for the fixture
|
||||
self.assertTrue(settles(True))
|
||||
lit["v"] = True
|
||||
time.sleep(0.2)
|
||||
lit["v"] = False
|
||||
self.assertTrue(settles(False))
|
||||
# A press the box made and reported failed (its reply lost after
|
||||
# the press): the button is out when the error arrives, so the
|
||||
# press was taken, and no notice asks for a second one.
|
||||
def pressed_then_failed(channel, state, ms=None):
|
||||
lit["v"] = False # the controller takes the press
|
||||
raise fx.FixtureError("timed out", sent=True)
|
||||
self.stub.act = pressed_then_failed
|
||||
lit["v"] = True
|
||||
self.assertTrue(ctx.press_now())
|
||||
self.assertIsNone(run.notice)
|
||||
self.assertEqual(ctx.evidence["actions"][-1]["by"], "fixture")
|
||||
lit["v"] = True
|
||||
self.assertTrue(ctx.arm_press())
|
||||
deadline = time.time() + 2
|
||||
while ctx.evidence["actions"][-1]["by"] != "fixture" and time.time() < deadline:
|
||||
time.sleep(0.02)
|
||||
time.sleep(0.1)
|
||||
self.assertEqual(ctx.evidence["actions"][-1]["by"], "fixture")
|
||||
self.assertIsNone(run.notice)
|
||||
# A refusal (or a request that never reached the box) while the
|
||||
# button went out: a press at the machine was taken, so no notice
|
||||
# goes up either, and the record does not give it to the box.
|
||||
def refused_while_pressed(channel, state, ms=None):
|
||||
lit["v"] = False # a person's press is taken
|
||||
raise fx.FixtureError("button press -> 409 button disabled: the enable jumper is out")
|
||||
self.stub.act = refused_while_pressed
|
||||
lit["v"] = True
|
||||
self.assertFalse(ctx.press_now())
|
||||
self.assertIsNone(run.notice)
|
||||
self.assertEqual(ctx.evidence["actions"][-1]["by"], "operator")
|
||||
lit["v"] = True
|
||||
self.assertTrue(ctx.arm_press())
|
||||
deadline = time.time() + 2
|
||||
while "fixture_error" not in ctx.evidence["actions"][-1] and time.time() < deadline:
|
||||
time.sleep(0.02)
|
||||
time.sleep(0.1)
|
||||
self.assertEqual(ctx.evidence["actions"][-1]["by"], "operator")
|
||||
self.assertIsNone(run.notice)
|
||||
finally:
|
||||
runner_mod.hw.button_lit = saved
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,453 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from forgetest.suite import image
|
||||
|
||||
# The layer's scripts, when the tests run from a checkout of forgefirm.
|
||||
LAYER = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||
"meta-forgefirm", "recipes-forgefirm")
|
||||
# The BSP layer's interfaces file, from the meta-openglow checkout beside
|
||||
# forgefirm's (forgetest-ci checks out its scarthgap branch there).
|
||||
BSP_INTERFACES = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))),
|
||||
"meta-openglow", "meta-openglow-core", "recipes-core", "init-ifupdown", "init-ifupdown", "interfaces")
|
||||
|
||||
# A layer script run as the image runs it, in private mount, UTS and
|
||||
# network namespaces: /run a fresh tmpfs, the scratch tree's net directory
|
||||
# over /sys/class/net, and /etc read-only with the scratch tree's files
|
||||
# over the host's (an overlay with no upper layer, as the image's rootfs is
|
||||
# read-only), so the script makes its own copy under /run/forgefirm and
|
||||
# writes through its own bind mount. With "net", wlan0 is a veth carrying a
|
||||
# global IPv4 and IPv6 address and a link-local one. Exit 97: the
|
||||
# namespace could not be set up.
|
||||
NAMESPACE = """
|
||||
T=$1; SCRIPT=$2
|
||||
mount --make-rprivate / && mount -t tmpfs tmpfs /run &&
|
||||
mount --bind "$T/net" /sys/class/net &&
|
||||
mount -t overlay overlay -o "lowerdir=$T/etc:/etc" /etc || exit 97
|
||||
if [ "$3" = net ]; then
|
||||
ip link add wlan0 type veth peer name vpeer0 &&
|
||||
ip addr add 192.0.2.5/24 dev wlan0 &&
|
||||
ip -6 addr add 2001:db8::5/64 dev wlan0 nodad &&
|
||||
ip -6 addr add fe80::5/64 dev wlan0 nodad &&
|
||||
ip link set wlan0 up || exit 97
|
||||
fi
|
||||
sh "$SCRIPT"
|
||||
echo "rc=$?"
|
||||
echo "name=$(hostname)"
|
||||
echo "file=$(cat /etc/hostname)"
|
||||
echo "--- issue"
|
||||
cat /etc/issue
|
||||
"""
|
||||
|
||||
|
||||
def run_layer_script(script, macs, etc, net=False):
|
||||
"""Run a layer script over a scratch tree: macs {interface: address or
|
||||
None for a directory with no address file}, etc {file: text}. Returns
|
||||
the namespace's output, or None when no namespace can be made here
|
||||
(unprivileged where user namespaces are open, else root via sudo -n)."""
|
||||
tree = tempfile.mkdtemp(prefix="forgetest-net.")
|
||||
try:
|
||||
for dev, mac in macs.items():
|
||||
os.makedirs(os.path.join(tree, "net", dev))
|
||||
if mac is not None:
|
||||
with open(os.path.join(tree, "net", dev, "address"), "w") as f:
|
||||
f.write(mac + "\n")
|
||||
os.makedirs(os.path.join(tree, "net"), exist_ok=True)
|
||||
os.makedirs(os.path.join(tree, "etc"))
|
||||
for name, text in etc.items():
|
||||
with open(os.path.join(tree, "etc", name), "w") as f:
|
||||
f.write(text)
|
||||
args = ["sh", "-c", NAMESPACE, "sh", tree, os.path.join(LAYER, script), "net" if net else ""]
|
||||
setup = []
|
||||
for cmd in (["unshare", "-Urmun"], ["sudo", "-n", "unshare", "-mun"]):
|
||||
try:
|
||||
p = subprocess.run(cmd + args, capture_output=True, text=True, timeout=60)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
continue
|
||||
if "rc=" in p.stdout:
|
||||
return p.stdout
|
||||
if p.returncode == 97:
|
||||
setup.append(p.stderr.strip())
|
||||
if setup:
|
||||
raise AssertionError("the namespace was made but not set up: %s" % setup)
|
||||
return None
|
||||
finally:
|
||||
shutil.rmtree(tree, ignore_errors=True)
|
||||
|
||||
|
||||
def field(out, key):
|
||||
return [ln[len(key) + 1:] for ln in out.splitlines() if ln.startswith(key + "=")][0]
|
||||
|
||||
|
||||
@unittest.skipUnless(os.path.isdir(LAYER) and shutil.which("unshare"), "needs Linux and a forgefirm checkout")
|
||||
class MachineNameTests(unittest.TestCase):
|
||||
"""forgefirm-hostname and forgefirm-banner, the layer's own files."""
|
||||
|
||||
def named(self, macs):
|
||||
out = run_layer_script("forgefirm-hostname/files/forgefirm-hostname", macs, {"hostname": "forgefirm\n"})
|
||||
if out is None:
|
||||
self.skipTest("no namespace can be made here")
|
||||
self.assertEqual(field(out, "rc"), "0", out)
|
||||
self.assertEqual(field(out, "file"), field(out, "name"), out)
|
||||
return field(out, "name")
|
||||
|
||||
def test_the_name_is_built_from_the_first_interface_with_an_address(self):
|
||||
self.assertEqual(self.named({"wlan0": "02:00:00:00:12:AB", "eth0": "02:00:00:00:34:cd"}), "forgefirm-12ab")
|
||||
self.assertEqual(self.named({"eth0": "02:00:00:00:34:cd"}), "forgefirm-34cd")
|
||||
self.assertEqual(self.named({"wlan0": "00:00:00:00:00:00", "eth0": "02:00:00:00:34:cd"}), "forgefirm-34cd")
|
||||
|
||||
def test_the_banner_adds_the_global_addresses_and_nothing_else(self):
|
||||
base = "ForgeFIRM\n"
|
||||
out = run_layer_script("forgefirm-banner/files/forgefirm-banner", {"wlan0": "02:00:00:00:12:ab"},
|
||||
{"issue": base}, net=True)
|
||||
if out is None:
|
||||
self.skipTest("no namespace can be made here")
|
||||
self.assertEqual(field(out, "rc"), "0", out)
|
||||
issue = out.split("--- issue\n", 1)[1]
|
||||
self.assertTrue(issue.startswith(base + "\n"), issue)
|
||||
heading, *urls = issue[len(base) + 1:].strip("\n").split("\n")
|
||||
self.assertEqual(urls, [" https://192.0.2.5/", " https://[2001:db8::5]/"], issue)
|
||||
self.assertFalse(heading.startswith("#") or ".local" in issue, issue)
|
||||
|
||||
|
||||
# forgefirm-users rendering a record, as the image runs it, in a private
|
||||
# mount namespace as root: /run a fresh tmpfs, /etc read-only with the
|
||||
# scratch tree's account files over the host's, and the scratch data
|
||||
# directory over /data (made for the run where the host has none, as
|
||||
# fflog_e2e.sh does, and removed after). The data directory is root's, as
|
||||
# on the machine, whoever made the scratch tree. Then, as the account's
|
||||
# own uid, what it can do with its home and with the data directory
|
||||
# around it. Exit 97: the namespace could not be set up.
|
||||
ACCOUNTS = r"""
|
||||
T=$1; SCRIPT=$2
|
||||
made=
|
||||
[ -d /data ] || { mkdir /data && made=1; } || exit 97
|
||||
chown -R 0:0 "$T/data" &&
|
||||
mount --make-rprivate / && mount -t tmpfs tmpfs /run &&
|
||||
mount -t overlay overlay -o "lowerdir=$T/etc:/etc" /etc &&
|
||||
mount --bind "$T/data" /data || { [ -z "$made" ] || rmdir /data; exit 97; }
|
||||
sh "$SCRIPT" reload
|
||||
echo "rc=$?"
|
||||
echo "mode=$(stat -c %a /data/forgefirm)"
|
||||
python3 - <<'PY'
|
||||
import errno, json, os, pwd
|
||||
p = pwd.getpwnam("bench")
|
||||
os.setgroups([])
|
||||
os.setgid(p.pw_gid)
|
||||
os.setuid(p.pw_uid)
|
||||
def tried(fn):
|
||||
try:
|
||||
fn()
|
||||
return "ok"
|
||||
except OSError as e:
|
||||
return errno.errorcode.get(e.errno, str(e.errno))
|
||||
def write():
|
||||
path = os.path.join(p.pw_dir, ".reach")
|
||||
with open(path, "w") as f:
|
||||
f.write("x")
|
||||
os.remove(path)
|
||||
print("reach=" + json.dumps({"uid": os.getuid(), "home": p.pw_dir, "cd": tried(lambda: os.chdir(p.pw_dir)),
|
||||
"write": tried(write), "list_data_dir": tried(lambda: os.listdir("/data/forgefirm")),
|
||||
"read_record": tried(lambda: open("/data/forgefirm/users").read())}))
|
||||
PY
|
||||
cd /
|
||||
umount /data
|
||||
[ -z "$made" ] || rmdir /data
|
||||
rm -rf "$T/data"
|
||||
"""
|
||||
|
||||
|
||||
@unittest.skipUnless(os.path.isdir(LAYER) and shutil.which("unshare"), "needs Linux and a forgefirm checkout")
|
||||
class AccountRenderTests(unittest.TestCase):
|
||||
"""forgefirm-users, the layer's own init script, rendering the record."""
|
||||
|
||||
def test_the_render_opens_the_data_directory_to_its_account_and_no_further(self):
|
||||
tree = tempfile.mkdtemp(prefix="forgetest-users.")
|
||||
self.addCleanup(shutil.rmtree, tree, ignore_errors=True)
|
||||
os.makedirs(os.path.join(tree, "etc"))
|
||||
for name, text in (("passwd", "root:x:0:0:root:/root:/bin/sh\n"), ("shadow", "root::19000:0:99999:7:::\n"),
|
||||
("group", "root:x:0:\n"), ("gshadow", "root:*::\n")):
|
||||
with open(os.path.join(tree, "etc", name), "w") as f:
|
||||
f.write(text)
|
||||
data = os.path.join(tree, "data", "forgefirm")
|
||||
os.makedirs(data)
|
||||
os.chmod(data, 0o700) # as a strict umask leaves it
|
||||
with open(os.path.join(data, "users"), "w") as f:
|
||||
f.write("bench:$6$salt$hash:1000\n")
|
||||
os.chmod(os.path.join(data, "users"), 0o600)
|
||||
try:
|
||||
p = subprocess.run(["sudo", "-n", "unshare", "-m", "sh", "-c", ACCOUNTS, "sh", tree,
|
||||
os.path.join(LAYER, "forgefirm-users", "files", "forgefirm-users.init")],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
p = None
|
||||
if p is None or "rc=" not in p.stdout:
|
||||
if p is not None and p.returncode == 97:
|
||||
raise AssertionError("the namespace was made but not set up: %s" % p.stderr.strip())
|
||||
self.skipTest("no namespace can be made here (root through sudo -n)")
|
||||
self.assertEqual(field(p.stdout, "rc"), "0", p.stdout + p.stderr)
|
||||
self.assertEqual(field(p.stdout, "mode"), "711", p.stdout)
|
||||
reach = json.loads(field(p.stdout, "reach"))
|
||||
self.assertEqual((reach["uid"], reach["home"]), (1000, "/data/forgefirm/home/bench"), reach)
|
||||
self.assertEqual((reach["cd"], reach["write"]), ("ok", "ok"), reach)
|
||||
self.assertEqual((reach["list_data_dir"], reach["read_record"]), ("EACCES", "EACCES"), reach)
|
||||
|
||||
|
||||
class NetworkBootTests(unittest.TestCase):
|
||||
STANZA = ("auto wlan0\n"
|
||||
"iface wlan0 inet dhcp\n"
|
||||
"\twpa-driver nl80211\n"
|
||||
"\tudhcpc_opts -x hostname:$(hostname)\n")
|
||||
|
||||
def test_ifstate_names_the_interfaces_ifup_finished(self):
|
||||
self.assertEqual(image.ifstate_names("lo=lo\nwlan0=wlan0\n"), {"lo", "wlan0"})
|
||||
self.assertEqual(image.ifstate_names("lo=lo\n"), {"lo"})
|
||||
self.assertEqual(image.ifstate_names(""), set())
|
||||
self.assertEqual(image.ifstate_names(None), set())
|
||||
|
||||
def test_an_interfaces_file_without_a_dhcpv6_client_is_clean(self):
|
||||
self.assertEqual(image.dhcp6_in_interfaces(self.STANZA), [])
|
||||
self.assertEqual(image.dhcp6_in_interfaces(None), [])
|
||||
|
||||
def test_a_command_that_starts_a_dhcpv6_client_is_named(self):
|
||||
text = self.STANZA + ("iface wlan0 inet6 manual\n"
|
||||
"\tup udhcpc6 -i wlan0 -b -S -p /run/udhcpc6.wlan0.pid\n")
|
||||
self.assertEqual(image.dhcp6_in_interfaces(text),
|
||||
["up udhcpc6 -i wlan0 -b -S -p /run/udhcpc6.wlan0.pid"])
|
||||
detached = "\tup start-stop-daemon -S -b -x /usr/bin/udhcpc6 -- -f -i wlan0\n"
|
||||
self.assertEqual(len(image.dhcp6_in_interfaces(self.STANZA + detached)), 1)
|
||||
|
||||
def test_an_inet6_method_that_runs_a_client_is_named(self):
|
||||
self.assertEqual(image.dhcp6_in_interfaces("iface wlan0 inet6 dhcp\n"),
|
||||
["iface wlan0 inet6 dhcp"])
|
||||
self.assertEqual(image.dhcp6_in_interfaces("iface wlan0 inet6 static\n"), [])
|
||||
self.assertEqual(image.dhcp6_in_interfaces("iface wlan0 inet dhcp\n"), [])
|
||||
|
||||
def test_a_comment_starts_nothing(self):
|
||||
text = self.STANZA + "# No DHCPv6 client runs; udhcpc6 is not on the image.\n"
|
||||
self.assertEqual(image.dhcp6_in_interfaces(text), [])
|
||||
|
||||
def test_the_layer_interfaces_file_starts_no_dhcpv6_client(self):
|
||||
self.assertTrue(os.path.isfile(BSP_INTERFACES),
|
||||
"%s is missing: meta-openglow is checked out beside forgefirm" % BSP_INTERFACES)
|
||||
with open(BSP_INTERFACES, encoding="utf-8") as f:
|
||||
self.assertEqual(image.dhcp6_in_interfaces(f.read()), [])
|
||||
|
||||
def test_parse_stat_reads_the_parent_past_an_awkward_comm(self):
|
||||
self.assertEqual(image.parse_stat("371 (udhcpc) S 1 371 371 0 -1 4194624"),
|
||||
(371, "udhcpc", 1))
|
||||
self.assertEqual(image.parse_stat("9 (a (b) c) S 7 9 9 0"), (9, "a (b) c", 7))
|
||||
self.assertIsNone(image.parse_stat(""))
|
||||
self.assertIsNone(image.parse_stat("12 (x) S"))
|
||||
|
||||
def test_link_addrs_sorts_one_interface_by_scope(self):
|
||||
text = ("fe800000000000002e6b7dfffe0db00a 02 40 20 80 wlan0\n"
|
||||
"26001700000000000000000000001234 02 80 00 00 wlan0\n"
|
||||
"00000000000000000000000000000001 01 80 10 80 lo\n")
|
||||
local, world = image.link_addrs(text, "wlan0")
|
||||
self.assertEqual(local, ["fe800000000000002e6b7dfffe0db00a"])
|
||||
self.assertEqual(world, ["26001700000000000000000000001234"])
|
||||
self.assertEqual(image.link_addrs(text, "eth0"), ([], []))
|
||||
|
||||
|
||||
class PackLicensesTests(unittest.TestCase):
|
||||
"""pack-licenses.sh, the image's license step, on a fixture tree."""
|
||||
|
||||
RECIPES = ("forgectrl", "grblhal-glowforge", "kernel-module-glowforge")
|
||||
|
||||
def setUp(self):
|
||||
self.root = tempfile.mkdtemp(prefix="forgetest-lic.")
|
||||
self.addCleanup(shutil.rmtree, self.root, ignore_errors=True)
|
||||
self.tree = os.path.join(self.root, "common-licenses")
|
||||
self.manifest = os.path.join(self.tree, "license.manifest")
|
||||
self.out = os.path.join(self.root, "forgefirm", "licenses.tar.gz")
|
||||
|
||||
def make_tree(self, recipes=None):
|
||||
"""The tree, with a manifest naming `recipes` (None: no manifest)."""
|
||||
os.makedirs(os.path.join(self.tree, "generic_MIT"))
|
||||
with open(os.path.join(self.tree, "generic_MIT", "generic_MIT"), "w") as f:
|
||||
f.write("MIT License\n")
|
||||
if recipes is not None:
|
||||
with open(self.manifest, "w") as f:
|
||||
for r in recipes:
|
||||
f.write("PACKAGE NAME: %s\nPACKAGE VERSION: 1.0\nRECIPE NAME: %s\nLICENSE: MIT\n\n" % (r, r))
|
||||
|
||||
def pack(self):
|
||||
return subprocess.run(["sh", os.path.join(LAYER, "images", "pack-licenses.sh"), self.tree, self.out],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
|
||||
def assertRefused(self, p):
|
||||
self.assertNotEqual(p.returncode, 0, p.stdout)
|
||||
self.assertTrue(p.stderr.strip(), "a refusal with no message on stderr")
|
||||
self.assertFalse(os.path.exists(self.out), "a refusal wrote the tar")
|
||||
|
||||
def test_a_missing_tree_is_refused(self):
|
||||
self.assertRefused(self.pack())
|
||||
|
||||
def test_a_tree_with_no_manifest_is_refused_and_left(self):
|
||||
self.make_tree()
|
||||
self.assertRefused(self.pack())
|
||||
self.assertTrue(os.path.isdir(self.tree))
|
||||
|
||||
def test_a_manifest_that_leaves_out_a_recipe_is_refused_and_left(self):
|
||||
for left_out in self.RECIPES:
|
||||
with self.subTest(left_out=left_out):
|
||||
shutil.rmtree(self.tree, ignore_errors=True)
|
||||
self.make_tree([r for r in self.RECIPES if r != left_out])
|
||||
self.assertRefused(self.pack())
|
||||
self.assertTrue(os.path.isfile(self.manifest))
|
||||
|
||||
def test_a_manifest_that_names_all_three_is_packed_and_the_tree_removed(self):
|
||||
self.make_tree(self.RECIPES)
|
||||
p = self.pack()
|
||||
self.assertEqual(p.returncode, 0, p.stderr)
|
||||
with tarfile.open(self.out, "r:gz") as t:
|
||||
self.assertIn("common-licenses/license.manifest", t.getnames())
|
||||
self.assertFalse(os.path.exists(self.tree))
|
||||
|
||||
def test_a_license_text_tar_cannot_read_is_refused_and_the_tree_left(self):
|
||||
"""A member tar cannot read fails the pack, bundle removed and tree
|
||||
left: tar skips that member, and the manifest check alone passes
|
||||
the bundle without it."""
|
||||
if os.geteuid() == 0:
|
||||
self.skipTest("root reads every file")
|
||||
self.make_tree(self.RECIPES)
|
||||
d = os.path.join(self.tree, "zlib")
|
||||
os.makedirs(d)
|
||||
locked = os.path.join(d, "generic_Zlib")
|
||||
with open(locked, "w") as f:
|
||||
f.write("zlib license\n")
|
||||
os.chmod(locked, 0) # rmtree still unlinks it
|
||||
self.assertRefused(self.pack())
|
||||
self.assertTrue(os.path.isfile(self.manifest))
|
||||
|
||||
|
||||
class RootfsLintTests(unittest.TestCase):
|
||||
"""rootfs-lint.sh, the image's last rootfs step, on a fixture tree: a
|
||||
tree that holds every rule passes, and a drift fails the lint naming
|
||||
its rule."""
|
||||
|
||||
SSHD = ("Include /etc/ssh/sshd_config.d/*.conf\n"
|
||||
"PermitRootLogin no\n"
|
||||
"PasswordAuthentication yes\n"
|
||||
"PermitEmptyPasswords no\n"
|
||||
"Subsystem\tsftp\t/usr/libexec/sftp-server\n")
|
||||
|
||||
def setUp(self):
|
||||
self.root = tempfile.mkdtemp(prefix="forgetest-lint.")
|
||||
self.addCleanup(shutil.rmtree, self.root, ignore_errors=True)
|
||||
self.put("usr/sbin/rsyslogd", "", mode=0o755)
|
||||
self.put("etc/init.d/syslog", "#!/bin/sh\nRSYSLOGD=rsyslogd\nRSYSLOGD_BIN=/usr/sbin/rsyslogd\n", mode=0o755)
|
||||
os.makedirs(self.path("etc/rc5.d"))
|
||||
os.symlink("../init.d/syslog", self.path("etc/rc5.d/S20syslog"))
|
||||
self.put("etc/ssh/sshd_config", self.SSHD)
|
||||
self.put("etc/ssh/sshd_config_readonly", self.SSHD)
|
||||
self.put("etc/network/interfaces", "auto lo\niface lo inet loopback\n" + NetworkBootTests.STANZA)
|
||||
self.put("etc/nsswitch.conf", "hosts: files dns\n")
|
||||
for name in ("forgectrl", "forgeext", "grblhal", "forgefirm-logging"):
|
||||
self.put("etc/init.d/" + name, "#!/bin/sh\n", mode=0o755)
|
||||
os.makedirs(self.path("etc/rc6.d"))
|
||||
for link in ("rc5.d/S90forgectrl", "rc5.d/S91forgeext", "rc6.d/K09forgeext", "rc6.d/K80grblhal",
|
||||
"rc6.d/K90forgectrl"):
|
||||
os.symlink("../init.d/" + link.split("/")[1][3:], self.path("etc/" + link))
|
||||
self.put("etc/default/rcS", "VERBOSE=no\nROOTFS_READ_ONLY=yes\n")
|
||||
self.put("etc/fstab", "/dev/root / auto ro 1 0\n/dev/mmcblk2p3 /data auto defaults 0 0\n")
|
||||
|
||||
def path(self, rel):
|
||||
return os.path.join(self.root, rel)
|
||||
|
||||
def put(self, rel, text, mode=0o644):
|
||||
os.makedirs(os.path.dirname(self.path(rel)), exist_ok=True)
|
||||
with open(self.path(rel), "w") as f:
|
||||
f.write(text)
|
||||
os.chmod(self.path(rel), mode)
|
||||
|
||||
def lint(self, kind="release"):
|
||||
return subprocess.run(["sh", os.path.join(LAYER, "images", "rootfs-lint.sh"), self.root, kind],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
|
||||
def assertFails(self, rule, kind="release"):
|
||||
p = self.lint(kind)
|
||||
self.assertEqual(p.returncode, 1, p.stderr)
|
||||
self.assertIn("rootfs-lint: %s:" % rule, p.stderr)
|
||||
|
||||
def test_a_tree_that_holds_every_rule_passes(self):
|
||||
for kind in ("release", "dev"):
|
||||
p = self.lint(kind)
|
||||
self.assertEqual(p.returncode, 0, "%s: %s" % (kind, p.stderr))
|
||||
|
||||
def test_the_layer_interfaces_file_sends_the_name(self):
|
||||
with open(BSP_INTERFACES, encoding="utf-8") as f:
|
||||
self.put("etc/network/interfaces", f.read())
|
||||
p = self.lint()
|
||||
self.assertEqual(p.returncode, 0, p.stderr)
|
||||
|
||||
def test_busybox_syslogd_beside_rsyslog_fails(self):
|
||||
os.makedirs(self.path("sbin"))
|
||||
os.symlink("/bin/busybox.nosuid", self.path("sbin/syslogd"))
|
||||
self.assertFails("syslog")
|
||||
|
||||
def test_a_pre_authentication_banner_fails_either_image(self):
|
||||
self.put("etc/ssh/sshd_config_readonly", self.SSHD + "Banner /etc/issue.net\n")
|
||||
for kind in ("release", "dev"):
|
||||
self.assertFails("sshd", kind)
|
||||
|
||||
def test_root_and_an_empty_password_over_ssh_fail_a_release_image_only(self):
|
||||
dev = self.SSHD.replace("PermitRootLogin no", "PermitRootLogin yes").replace(
|
||||
"PermitEmptyPasswords no", "PermitEmptyPasswords yes")
|
||||
self.put("etc/ssh/sshd_config", dev)
|
||||
self.put("etc/ssh/sshd_config_readonly", dev)
|
||||
self.assertFails("sshd", "release")
|
||||
p = self.lint("dev")
|
||||
self.assertEqual(p.returncode, 0, p.stderr)
|
||||
|
||||
def test_a_dhcp_stanza_that_does_not_send_the_name_fails(self):
|
||||
self.put("etc/network/interfaces", NetworkBootTests.STANZA.replace(
|
||||
"\tudhcpc_opts -x hostname:$(hostname)\n", ""))
|
||||
self.assertFails("dhcp-name")
|
||||
|
||||
def test_an_mdns_responder_fails(self):
|
||||
os.makedirs(self.path("etc/avahi"))
|
||||
self.assertFails("mdns")
|
||||
|
||||
def test_the_extension_host_starting_before_forgectrl_fails(self):
|
||||
os.rename(self.path("etc/rc5.d/S91forgeext"), self.path("etc/rc5.d/S89forgeext"))
|
||||
self.assertFails("init-order")
|
||||
|
||||
def test_no_logging_init_script_fails(self):
|
||||
os.remove(self.path("etc/init.d/forgefirm-logging"))
|
||||
self.assertFails("logging")
|
||||
|
||||
def test_a_userspace_watchdog_init_script_fails(self):
|
||||
self.put("etc/init.d/watchdog", "#!/bin/sh\n", mode=0o755)
|
||||
self.assertFails("watchdog")
|
||||
|
||||
def test_rcs_without_the_read_only_rootfs_fails(self):
|
||||
self.put("etc/default/rcS", "VERBOSE=no\n")
|
||||
self.assertFails("read-only")
|
||||
|
||||
def test_a_dhcpv6_client_on_the_image_fails(self):
|
||||
os.makedirs(self.path("sbin"))
|
||||
os.symlink("/bin/busybox.nosuid", self.path("sbin/udhcpc6"))
|
||||
self.assertFails("dhcpv6")
|
||||
|
||||
def test_a_factory_slot_in_fstab_fails_a_release_image_only(self):
|
||||
self.put("etc/fstab", "/dev/root / auto ro 1 0\n/dev/mmcblk2p1 /factory/img1 auto ro,noatime 0 0\n")
|
||||
self.assertFails("factory", "release")
|
||||
p = self.lint("dev")
|
||||
self.assertEqual(p.returncode, 0, p.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,160 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""scripts/install-forgefirm.sh - the download that retries, and the log.
|
||||
|
||||
The installer runs once per machine, on factory firmware, over whatever
|
||||
network the owner has. These tests run its own functions under sh with a
|
||||
scripted curl in front of them: a transfer that fails resumes and is
|
||||
tried again, the failures that waiting cannot fix end the tries at once,
|
||||
the reason an install stopped is in the install log, and a log that cannot
|
||||
be written never fails the install."""
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import helpers # noqa: F401 (sys.path)
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
SCRIPT = os.path.join(REPO, "scripts", "install-forgefirm.sh")
|
||||
MARK = "# The install log, before anything can fail."
|
||||
|
||||
# A curl that plays a script: each call takes the next "rc http bytes"
|
||||
# line of $CURL_PLAN, appends that many bytes to the --output file the way
|
||||
# a resumed transfer does, prints the HTTP code as -w '%{http_code}'
|
||||
# would, records its arguments, and exits rc.
|
||||
FAKE_CURL = r"""#!/bin/sh
|
||||
echo "$*" >> "$CURL_CALLS"
|
||||
OUT=""
|
||||
while [ $# -gt 0 ]; do
|
||||
[ "$1" = "--output" ] && OUT="$2"
|
||||
shift
|
||||
done
|
||||
LINE=$(sed -n 1p "$CURL_PLAN")
|
||||
sed -i 1d "$CURL_PLAN"
|
||||
set -- $LINE
|
||||
[ "${3:-0}" -gt 0 ] && dd if=/dev/zero bs=1 count="$3" 2>/dev/null | tr '\0' 'x' >> "$OUT"
|
||||
printf '%s' "$2"
|
||||
exit "$1"
|
||||
"""
|
||||
|
||||
|
||||
def functions():
|
||||
"""The installer up to its first action: constants and functions."""
|
||||
with open(SCRIPT, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
head, mark, _ = text.partition(MARK)
|
||||
assert mark, "the installer lost the line the tests cut it at"
|
||||
return head
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("sh") and os.name == "posix", "needs a POSIX sh")
|
||||
class InstallerTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.mkdtemp(prefix="ffinstall-test.")
|
||||
self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True)
|
||||
self.bin = os.path.join(self.tmp, "bin")
|
||||
os.mkdir(self.bin)
|
||||
for name, body in (("curl", FAKE_CURL), ("nslookup", "#!/bin/sh\nexit 1\n"),
|
||||
("ip", "#!/bin/sh\nexit 0\n"), ("sleep", "#!/bin/sh\nexit 0\n")):
|
||||
path = os.path.join(self.bin, name)
|
||||
with open(path, "w", newline="\n") as f:
|
||||
f.write(body)
|
||||
os.chmod(path, os.stat(path).st_mode | stat.S_IXUSR)
|
||||
self.fw = os.path.join(self.tmp, "forgefirm.fw")
|
||||
self.log = os.path.join(self.tmp, "log", "install.log")
|
||||
self.calls = os.path.join(self.tmp, "curl.calls")
|
||||
self.plan = os.path.join(self.tmp, "curl.plan")
|
||||
|
||||
def run_sh(self, body, plan=()):
|
||||
with open(self.plan, "w", newline="\n") as f:
|
||||
f.write("".join("%s\n" % line for line in plan))
|
||||
open(self.calls, "w").close()
|
||||
script = "%s\nFW_FILE='%s'\nLOG_DIR='%s'\nLOG_FILE='%s'\nmkdir -p \"$LOG_DIR\"\nLOG_OK=yes\n%s\n" % (
|
||||
functions(), self.fw, os.path.dirname(self.log), self.log, body)
|
||||
env = dict(os.environ, PATH=self.bin + os.pathsep + os.environ["PATH"],
|
||||
CURL_PLAN=self.plan, CURL_CALLS=self.calls)
|
||||
return subprocess.run(["sh", "-c", script], env=env, capture_output=True, text=True, timeout=60)
|
||||
|
||||
def download(self, plan):
|
||||
r = self.run_sh('download_fw; echo "rc=$? why=$DL_WHY"', plan)
|
||||
with open(self.calls) as f:
|
||||
calls = f.read().splitlines()
|
||||
return r.stdout, calls, self.log_lines()
|
||||
|
||||
def log_lines(self):
|
||||
try:
|
||||
with open(self.log) as f:
|
||||
return f.read().splitlines()
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
def test_a_clean_download_takes_one_try_and_its_name(self):
|
||||
out, calls, log = self.download(["0 200 5000"])
|
||||
self.assertIn("rc=0", out)
|
||||
self.assertEqual(len(calls), 1)
|
||||
self.assertEqual(os.path.getsize(self.fw), 5000)
|
||||
self.assertFalse(os.path.exists(self.fw + ".part"))
|
||||
self.assertTrue(any("complete on try 1, 5000 bytes" in line for line in log), log)
|
||||
|
||||
def test_a_failed_transfer_resumes_and_is_tried_again(self):
|
||||
out, calls, log = self.download(["6 000 0", "56 200 3000", "0 206 2000"])
|
||||
self.assertIn("rc=0", out)
|
||||
self.assertEqual(len(calls), 3)
|
||||
for c in calls: # every try resumes, bounded in time
|
||||
self.assertIn("-C -", c)
|
||||
self.assertIn("--connect-timeout", c)
|
||||
self.assertIn("--speed-time", c)
|
||||
self.assertEqual(os.path.getsize(self.fw), 5000) # 3000 kept, 2000 resumed
|
||||
warns = [line for line in log if " WARNING download: try " in line]
|
||||
self.assertEqual(len(warns), 2, log)
|
||||
self.assertIn("did not resolve", warns[0])
|
||||
self.assertIn("dropped mid-transfer (3000 bytes so far)", warns[1])
|
||||
self.assertIn("Trying again in 5s (try 2 of 5)", out)
|
||||
self.assertIn("Trying again in 15s (try 3 of 5)", out)
|
||||
|
||||
def test_the_tries_run_out(self):
|
||||
out, calls, log = self.download(["28 000 0"] * 5)
|
||||
self.assertIn("rc=1", out)
|
||||
self.assertIn("timed out or stalled", out)
|
||||
self.assertEqual(len(calls), 5)
|
||||
self.assertFalse(os.path.exists(self.fw))
|
||||
self.assertFalse(os.path.exists(self.fw + ".part"))
|
||||
# each failed try leaves what the network looked like
|
||||
self.assertEqual(sum("github.com does not resolve" in line for line in log), 5, log)
|
||||
|
||||
def test_a_missing_release_and_a_full_disk_end_the_tries_at_once(self):
|
||||
out, calls, _ = self.download(["22 404 0"])
|
||||
self.assertIn("rc=1", out)
|
||||
self.assertIn("HTTP 404", out)
|
||||
self.assertEqual(len(calls), 1)
|
||||
out, calls, _ = self.download(["23 200 100"])
|
||||
self.assertIn("rc=1", out)
|
||||
self.assertIn("could not be written", out)
|
||||
self.assertEqual(len(calls), 1)
|
||||
|
||||
def test_a_partial_file_the_server_will_not_resume_starts_over(self):
|
||||
out, calls, _ = self.download(["56 200 4000", "22 416 0", "0 200 5000"])
|
||||
self.assertIn("rc=0", out)
|
||||
self.assertEqual(len(calls), 3)
|
||||
self.assertEqual(os.path.getsize(self.fw), 5000) # not 9000: the stale part went
|
||||
|
||||
def test_die_leaves_the_reason_in_the_log(self):
|
||||
r = self.run_sh('die "archiving slot 1 failed"; echo not-reached')
|
||||
self.assertEqual(r.returncode, 1)
|
||||
self.assertNotIn("not-reached", r.stdout)
|
||||
self.assertTrue(any("archiving slot 1 failed" in line for line in self.log_lines()))
|
||||
|
||||
def test_no_writable_log_never_fails_the_install(self):
|
||||
r = self.run_sh('LOG_OK=""; log INFO "x"; LOG_OK=yes; LOG_FILE=/nonexistent/dir/x.log; '
|
||||
'log INFO "y"; echo "still-running rc=$?"')
|
||||
self.assertIn("still-running rc=0", r.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,72 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""wait_hv_off against a scripted charge-pump readback: a chain that
|
||||
releases inside the window passes, a chain held past it is the refusal,
|
||||
and a chain already off costs no wait."""
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from forgetest.suite import kernel
|
||||
|
||||
|
||||
class Ctx:
|
||||
def __init__(self):
|
||||
self.evidence = {}
|
||||
self.lines = []
|
||||
|
||||
def sleep(self, s):
|
||||
time.sleep(s)
|
||||
|
||||
def log(self, fmt, *a):
|
||||
self.lines.append(fmt % a)
|
||||
|
||||
|
||||
def scripted(alive_for_s):
|
||||
t0 = time.time()
|
||||
|
||||
def rd(path):
|
||||
if path == "cnc/charge_pump_alive":
|
||||
return "1" if time.time() - t0 < alive_for_s else "0"
|
||||
if path == "cnc/state":
|
||||
return "idle"
|
||||
return None
|
||||
return rd
|
||||
|
||||
|
||||
class WaitHvOffTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._rd = kernel.rd
|
||||
|
||||
def tearDown(self):
|
||||
kernel.rd = self._rd
|
||||
|
||||
def test_release_inside_the_window_passes(self):
|
||||
kernel.rd = scripted(0.3)
|
||||
ctx = Ctx()
|
||||
t0 = time.time()
|
||||
self.assertIsNone(kernel.wait_hv_off(ctx, timeout_s=2.0))
|
||||
self.assertGreaterEqual(time.time() - t0, 0.25)
|
||||
self.assertTrue(any("released" in l for l in ctx.lines))
|
||||
self.assertGreaterEqual(ctx.evidence["hv_release_s"][0], 0.25)
|
||||
|
||||
def test_chain_held_past_the_window_is_the_refusal(self):
|
||||
kernel.rd = scripted(99)
|
||||
ctx = Ctx()
|
||||
why = kernel.wait_hv_off(ctx, timeout_s=0.3)
|
||||
self.assertIn("charge_pump_alive=1", why)
|
||||
self.assertTrue(any("still held" in l for l in ctx.lines))
|
||||
|
||||
def test_chain_already_off_costs_no_wait(self):
|
||||
kernel.rd = scripted(0)
|
||||
ctx = Ctx()
|
||||
t0 = time.time()
|
||||
self.assertIsNone(kernel.wait_hv_off(ctx, timeout_s=2.0))
|
||||
self.assertLess(time.time() - t0, 0.1)
|
||||
self.assertEqual(ctx.lines, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,209 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The dwell-gap verdict of laser.emission-witness over synthetic trails:
|
||||
the button latch judged from the first emission through every sample
|
||||
whose readback word shows the laser latch unlocked, both bits from that
|
||||
word, so the relocked tail (where the relock itself sets the latch and
|
||||
the emission counter still reads nonzero) is never judged. And its
|
||||
read-gap verdict over the dark stretch after the M5: a sample whose
|
||||
/status carried no laser block is a gap, never a dark sample, a tail the
|
||||
daemon did not serve before the sampling stopped is a gap too, and a
|
||||
stretch with a kernel window unread fails the rule; the sampling runs on
|
||||
past the close; and the head's reports place the rapid inside the armed
|
||||
window only."""
|
||||
import unittest
|
||||
|
||||
from forgetest.suite import laser
|
||||
|
||||
|
||||
def smp(emission, latch, locked=False, hv_enable=True, armed=True):
|
||||
il = (laser.IL_BUTTON_LATCH if latch else 0) | (laser.IL_LASER_LATCH if locked else 0)
|
||||
return {"emission": emission, "armed": armed, "il": il, "hv_enable": hv_enable}
|
||||
|
||||
|
||||
class DwellGapTests(unittest.TestCase):
|
||||
def test_the_relocked_tail_is_not_judged(self):
|
||||
# the arm wait (unlocked, latch set until the press), fire, the
|
||||
# gap, fire, then the relock sets the latch while the counter
|
||||
# still reads nonzero
|
||||
trail = [smp(0, 1), smp(0, 0), smp(10, 0), smp(20, 0, hv_enable=False),
|
||||
smp(20, 0, hv_enable=False), smp(30, 0), smp(40, 0),
|
||||
smp(40, 1, locked=True, armed=False), smp(40, 1, locked=True, armed=False),
|
||||
smp(0, 1, locked=True, armed=False)]
|
||||
g = laser.dwell_gap(trail)
|
||||
self.assertEqual(g["button_latch_unlocked_max"], 0)
|
||||
self.assertEqual(g["button_latch_unlocked_samples"], 5)
|
||||
self.assertEqual(g["button_latch_set_at"], [])
|
||||
self.assertTrue(g["hv_enable_dipped"])
|
||||
self.assertTrue(g["hv_enable_back_lit"])
|
||||
|
||||
def test_latch_set_while_unlocked_is_the_defect(self):
|
||||
trail = [smp(0, 0), smp(10, 0), smp(20, 1, hv_enable=False), smp(30, 0), smp(0, 1, locked=True)]
|
||||
g = laser.dwell_gap(trail)
|
||||
self.assertEqual(g["button_latch_unlocked_max"], 1)
|
||||
self.assertEqual(g["button_latch_set_at"], [1])
|
||||
|
||||
def test_no_emission_judges_nothing(self):
|
||||
g = laser.dwell_gap([smp(0, 0), smp(0, 1, locked=True)])
|
||||
self.assertIsNone(g["button_latch_unlocked_max"])
|
||||
self.assertEqual(g["button_latch_unlocked_samples"], 0)
|
||||
self.assertFalse(g["hv_enable_dipped"])
|
||||
|
||||
|
||||
def rapid_trail(n, dt=0.2, holes=()):
|
||||
"""A dark stretch of n samples dt seconds apart, the emission count
|
||||
and the HV current read on every sample but the ones in `holes`: a
|
||||
/status served without its laser block, which sample() records as
|
||||
None."""
|
||||
out = []
|
||||
for i in range(n):
|
||||
s = {"t": 100.0 + i * dt, "emission": 0, "hv": 0, "armed": True}
|
||||
if i in holes:
|
||||
s["emission"] = None
|
||||
s["hv"] = None
|
||||
out.append(s)
|
||||
return out
|
||||
|
||||
|
||||
def covered(r):
|
||||
"""The rule emission_witness applies to read_gaps' report."""
|
||||
return (r["read"] >= laser.RAPID_MIN_READS and r["gap_max_s"] is not None
|
||||
and r["gap_max_s"] <= laser.EMISSION_READ_GAP_S)
|
||||
|
||||
|
||||
class RapidReadTests(unittest.TestCase):
|
||||
def test_every_sample_read_is_inside_the_rule(self):
|
||||
r = laser.read_gaps(rapid_trail(25), "emission")
|
||||
self.assertEqual((r["read"], r["unread"]), (25, 0))
|
||||
self.assertAlmostEqual(r["gap_max_s"], 0.2)
|
||||
self.assertTrue(covered(r))
|
||||
|
||||
def test_one_transient_miss_is_inside_the_rule(self):
|
||||
r = laser.read_gaps(rapid_trail(25, holes=(12,)), "emission")
|
||||
self.assertEqual((r["read"], r["unread"]), (24, 1))
|
||||
self.assertAlmostEqual(r["gap_max_s"], 0.4)
|
||||
self.assertTrue(covered(r))
|
||||
|
||||
def test_a_hole_shorter_than_a_window_is_inside_the_rule(self):
|
||||
r = laser.read_gaps(rapid_trail(25, holes=(10, 11, 12)), "emission")
|
||||
self.assertAlmostEqual(r["gap_max_s"], 0.8)
|
||||
self.assertTrue(covered(r))
|
||||
|
||||
def test_a_hole_longer_than_a_window_fails(self):
|
||||
r = laser.read_gaps(rapid_trail(25, holes=(10, 11, 12, 13, 14)), "emission")
|
||||
self.assertAlmostEqual(r["gap_max_s"], 1.2)
|
||||
self.assertFalse(covered(r))
|
||||
|
||||
def test_the_block_gone_after_the_dark_point_fails(self):
|
||||
r = laser.read_gaps(rapid_trail(25, holes=tuple(range(1, 25))), "emission")
|
||||
self.assertEqual((r["read"], r["unread"]), (1, 24))
|
||||
self.assertAlmostEqual(r["gap_max_s"], 4.8)
|
||||
self.assertFalse(covered(r))
|
||||
|
||||
def test_the_block_gone_for_the_last_window_and_a_half_fails(self):
|
||||
# the tail is an edge: the window that latches after the close
|
||||
# goes unread
|
||||
r = laser.read_gaps(rapid_trail(25, holes=tuple(range(17, 25))), "emission")
|
||||
self.assertAlmostEqual(r["gap_max_s"], 1.6)
|
||||
self.assertFalse(covered(r))
|
||||
|
||||
def test_a_tail_the_daemon_never_served_fails(self):
|
||||
# every sample served was read, and the served samples end at the
|
||||
# close: the sampling ran on past it with nothing served, so the
|
||||
# stretch from the last reading to where the sampling stopped is a gap
|
||||
trail = rapid_trail(25)
|
||||
tail = 2 * laser.EMISSION_WINDOW_S + laser.EMISSION_READ_GAP_S
|
||||
r = laser.read_gaps(trail, "emission", trail[-1]["t"] + tail)
|
||||
self.assertAlmostEqual(r["gap_max_s"], tail)
|
||||
self.assertFalse(covered(r))
|
||||
# served through: the sampling stops one loop after the last sample
|
||||
self.assertTrue(covered(laser.read_gaps(trail, "emission", trail[-1]["t"] + 0.125)))
|
||||
|
||||
def test_a_stretch_of_one_sample_fails(self):
|
||||
r = laser.read_gaps(rapid_trail(1), "emission")
|
||||
self.assertEqual(r, {"read": 1, "unread": 0, "gap_max_s": 0.0})
|
||||
self.assertFalse(covered(r))
|
||||
|
||||
|
||||
class Clock:
|
||||
"""time.time and time.sleep on a clock the test moves."""
|
||||
|
||||
def __init__(self, now=1000.0):
|
||||
self.now = now
|
||||
|
||||
def time(self):
|
||||
return self.now
|
||||
|
||||
def sleep(self, s):
|
||||
self.now += s
|
||||
|
||||
|
||||
class RapidTailTests(unittest.TestCase):
|
||||
def test_the_sampling_runs_on_past_the_close_and_ends_where_it_stopped(self):
|
||||
# The window closes 3 s in, and the daemon serves the closed
|
||||
# sample and nothing after it: the sampling still runs two kernel
|
||||
# windows and the read gap allowed past the close, and the time it
|
||||
# stopped is the trail's end, so the tail it never served fails the
|
||||
# read rule.
|
||||
clock = Clock()
|
||||
close = clock.now + 3.0
|
||||
served = []
|
||||
|
||||
def sample(_ctx):
|
||||
clock.now += 0.06 # the two GETs
|
||||
if served and not served[-1]["armed"]:
|
||||
return None # nothing served after the close
|
||||
s = {"t": clock.now, "armed": clock.now < close, "emission": 0, "hv": 0}
|
||||
served.append(s)
|
||||
return s
|
||||
|
||||
class Ctx:
|
||||
def checkpoint(self):
|
||||
pass
|
||||
|
||||
class Grbl:
|
||||
def status_report(self):
|
||||
# the time of the read, as the head's X
|
||||
return {"state": "Idle", "MPos": (clock.now, 0.0, 0.0)}
|
||||
saved = laser.time, laser.sample
|
||||
self.addCleanup(setattr, laser, "time", saved[0])
|
||||
self.addCleanup(setattr, laser, "sample", saved[1])
|
||||
laser.time, laser.sample = clock, sample
|
||||
tail, t_end = laser.sample_to_disarm(Ctx(), Grbl())
|
||||
closed = tail[-1]
|
||||
self.assertFalse(closed["armed"])
|
||||
self.assertTrue(all(s["armed"] for s in tail[:-1]))
|
||||
self.assertGreaterEqual(t_end - closed["t"], 2 * laser.EMISSION_WINDOW_S + laser.EMISSION_READ_GAP_S)
|
||||
self.assertFalse(covered(laser.read_gaps(tail, "emission", t_end)))
|
||||
# each sample's X is read before its armed flag: an X that places
|
||||
# the head was read with the window open
|
||||
self.assertTrue(all(s["x"] < s["t"] for s in tail))
|
||||
|
||||
|
||||
def place_trail(xs, closes_at):
|
||||
"""A dark stretch with the head at machine X `xs` (one sample each,
|
||||
0.2 s apart), the window closing at sample `closes_at`."""
|
||||
return [{"t": 100.0 + i * 0.2, "x": float(x), "armed": i < closes_at} for i, x in enumerate(xs)]
|
||||
|
||||
|
||||
class RapidPlaceTests(unittest.TestCase):
|
||||
# out to +20 mm, the dwell there, back at the start, the dwell before the M2
|
||||
XS = [0, 5, 20, 20, 20, 10, 0, 0, 0, 0]
|
||||
|
||||
def test_a_window_closed_before_the_head_is_back_does_not_place_the_rapid(self):
|
||||
# the window closes after the head is back: out at 2, back at 6, closed at 8
|
||||
self.assertEqual(laser.rapid_place(place_trail(self.XS, 8), 0.0), (2, 6, 8))
|
||||
# the window closes in the dwell at +20 mm and the head comes back
|
||||
# after the close: the samples past the close do not place it
|
||||
self.assertEqual(laser.rapid_place(place_trail(self.XS, 4), 0.0), (2, None, 4))
|
||||
# the window reads closed at the first sample with the head back:
|
||||
# that sample does not tell whether the head was back before the
|
||||
# close, so it does not place it
|
||||
self.assertEqual(laser.rapid_place(place_trail(self.XS, 6), 0.0), (2, None, 6))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,867 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
import helpers
|
||||
from forgetest import manifest as m
|
||||
from forgetest import catalog
|
||||
|
||||
|
||||
class GlobTests(unittest.TestCase):
|
||||
def test_star_and_doublestar(self):
|
||||
rx = m.glob_to_regex("src/*.c")
|
||||
self.assertTrue(rx.match("src/main.c"))
|
||||
self.assertFalse(rx.match("src/sub/main.c"))
|
||||
rx = m.glob_to_regex("src/**")
|
||||
self.assertTrue(rx.match("src/main.c"))
|
||||
self.assertTrue(rx.match("src/sub/deep/x.h"))
|
||||
self.assertFalse(rx.match("docs/x"))
|
||||
rx = m.glob_to_regex("**/*.md")
|
||||
self.assertTrue(rx.match("README.md"))
|
||||
self.assertTrue(rx.match("docs/a/b.md"))
|
||||
self.assertFalse(rx.match("docs/a/b.txt"))
|
||||
rx = m.glob_to_regex("**")
|
||||
self.assertTrue(rx.match("anything/at/all"))
|
||||
rx = m.glob_to_regex("src/gfcool*")
|
||||
self.assertTrue(rx.match("src/gfcool_client.c"))
|
||||
self.assertFalse(rx.match("src/x/gfcool.c"))
|
||||
|
||||
def test_question_mark_and_escaping(self):
|
||||
rx = m.glob_to_regex("a?c.d")
|
||||
self.assertTrue(rx.match("abc.d"))
|
||||
self.assertFalse(rx.match("abcxd"))
|
||||
self.assertFalse(rx.match("a/c.d"))
|
||||
|
||||
|
||||
class FingerprintTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.man = helpers.make_manifest()
|
||||
|
||||
def test_stable_and_order_independent(self):
|
||||
c1 = [("forgectrl", "src/ui.c"), ("forgectrl", "src/auth.c")]
|
||||
c2 = list(reversed(c1))
|
||||
self.assertEqual(m.fingerprint(self.man, c1), m.fingerprint(self.man, c2))
|
||||
self.assertEqual(m.fingerprint(self.man, c1), m.fingerprint(self.man, c1))
|
||||
|
||||
def test_changes_only_when_covered_file_changes(self):
|
||||
covers = [("forgectrl", "src/ui.c")]
|
||||
base = m.fingerprint(self.man, covers)
|
||||
other = helpers.with_file(self.man, "forgectrl", "src/cool.c", "cool v2")
|
||||
self.assertEqual(base, m.fingerprint(other, covers), "an uncovered change must not move the fingerprint")
|
||||
changed = helpers.with_file(self.man, "forgectrl", "src/ui.c", "ui v2")
|
||||
self.assertNotEqual(base, m.fingerprint(changed, covers))
|
||||
added = helpers.with_file(self.man, "forgectrl", "src/ui_extra.c", "new")
|
||||
self.assertEqual(base, m.fingerprint(added, covers), "an unmatched new file does not move it")
|
||||
added2 = helpers.with_file(self.man, "forgectrl", "src/x.c", "new")
|
||||
self.assertNotEqual(base, m.fingerprint(added2, [("forgectrl", "src/*.c")]))
|
||||
|
||||
def test_platform_is_always_in(self):
|
||||
covers = [("forgectrl", "src/ui.c")]
|
||||
base = m.fingerprint(self.man, covers)
|
||||
p2 = helpers.with_platform(self.man, dtb={"glowforge.dtb": "e" * 64})
|
||||
self.assertNotEqual(base, m.fingerprint(p2, covers))
|
||||
p3 = helpers.with_platform(self.man, layers={"meta-forgefirm": {"content_sha256": "2" * 64},
|
||||
"poky": {"rev": "p" * 40}})
|
||||
self.assertNotEqual(base, m.fingerprint(p3, covers))
|
||||
|
||||
def test_kernel_pseudo_files(self):
|
||||
covers = [("linux-fslc", "**")]
|
||||
base = m.fingerprint(self.man, covers)
|
||||
k2 = helpers.with_file(self.man, "linux-fslc", "@config", "cfg2")
|
||||
self.assertNotEqual(base, m.fingerprint(k2, covers))
|
||||
|
||||
def test_missing_component_marker(self):
|
||||
f1 = m.fingerprint(self.man, [("nonexistent", "**")])
|
||||
f2 = m.fingerprint(self.man, [("forgectrl", "nothing-matches-*")])
|
||||
self.assertNotEqual(f1, f2)
|
||||
|
||||
def test_dev_only_refused(self):
|
||||
with self.assertRaises(ValueError):
|
||||
m.fingerprint(self.man, [("forgetest", "**")])
|
||||
|
||||
def test_extra_folds_in(self):
|
||||
covers = [("forgectrl", "src/ui.c")]
|
||||
self.assertNotEqual(m.fingerprint(self.man, covers, extra=["a"]),
|
||||
m.fingerprint(self.man, covers, extra=["b"]))
|
||||
|
||||
def test_submodule_gitlink_and_files(self):
|
||||
covers = [("grblhal-glowforge", "src/grbl/**")]
|
||||
base = m.fingerprint(self.man, covers)
|
||||
core = helpers.with_file(self.man, "grblhal-glowforge", "src/grbl/core.c", "core v2")
|
||||
self.assertNotEqual(base, m.fingerprint(core, covers))
|
||||
link = [("grblhal-glowforge", "src/grbl")]
|
||||
self.assertEqual(m.fingerprint(self.man, link), m.fingerprint(core, link),
|
||||
"the gitlink alone does not see a file-level change (fixture keeps the link id)")
|
||||
|
||||
def test_identity_sha_ignores_dev_only(self):
|
||||
a = self.man.identity_sha()
|
||||
b = helpers.with_file(self.man, "forgetest", "forgetest/x.py", "changed").identity_sha()
|
||||
self.assertEqual(a, b)
|
||||
c = helpers.with_file(self.man, "forgectrl", "src/ui.c", "changed").identity_sha()
|
||||
self.assertNotEqual(a, c)
|
||||
|
||||
|
||||
class CoverageReportTests(unittest.TestCase):
|
||||
def test_report(self):
|
||||
man = helpers.make_manifest()
|
||||
t1 = helpers.make_test("a.one", [("forgectrl", "src/ui.c"), ("forgectrl", "src/auth.c")])
|
||||
t2 = helpers.make_test("a.two", [("grblhal-glowforge", "**"), ("kernel-module-glowforge", "**"),
|
||||
("linux-fslc", "**")])
|
||||
rep = m.coverage_report(man, [t1, t2], allow=[("*", "**/*.md")])
|
||||
self.assertEqual(set(rep), {"forgectrl"})
|
||||
self.assertEqual(rep["forgectrl"], ["src/cool.c", "src/main.c"])
|
||||
rep2 = m.coverage_report(man, [t1, t2, helpers.make_test("a.three", [("forgectrl", "src/**")])],
|
||||
allow=[("*", "**/*.md")])
|
||||
self.assertEqual(rep2, {})
|
||||
self.assertNotIn("forgetest", m.coverage_report(man, [], allow=[]),
|
||||
"dev-only components are outside the report")
|
||||
|
||||
def test_behavioral_paths_are_never_allowed_away(self):
|
||||
# An advisory document is behavior (the consent is to its hash): the allowlist's docs/** and
|
||||
# **/*.md do not take it out of the report, as they do not take it out of a fingerprint. No
|
||||
# host-tested rows here: the real table's rows name the first-run documents.
|
||||
man = helpers.with_file(helpers.make_manifest(), "forgectrl", "docs/advisories/privacy.md", "text")
|
||||
rest = helpers.make_test("a.one", [("forgectrl", "src/**"), ("grblhal-glowforge", "**"),
|
||||
("kernel-module-glowforge", "**"), ("linux-fslc", "**")])
|
||||
self.assertEqual(m.coverage_report(man, [rest], host=()), {"forgectrl": ["docs/advisories/privacy.md"]})
|
||||
doc = helpers.make_test("a.two", [("forgectrl", "docs/advisories/privacy.md")])
|
||||
self.assertEqual(m.coverage_report(man, [rest, doc], host=()), {})
|
||||
self.assertNotEqual(m.fingerprint(man, doc.covers),
|
||||
m.fingerprint(helpers.with_file(man, "forgectrl", "docs/advisories/privacy.md", "new"),
|
||||
doc.covers))
|
||||
|
||||
def test_non_behavioral_paths_are_outside_every_fingerprint(self):
|
||||
man = helpers.make_manifest()
|
||||
t = helpers.make_test("a.one", [("forgectrl", "**")])
|
||||
a = m.fingerprint(man, t.covers)
|
||||
for comp, path in (("forgectrl", "README.md"), ("forgectrl", "docs/SERVICES.md"),
|
||||
("forgectrl", "tests/test_x.py"), ("forgectrl", ".github/workflows/ci.yml")):
|
||||
self.assertEqual(a, m.fingerprint(helpers.with_file(man, comp, path, "changed"), t.covers),
|
||||
path)
|
||||
self.assertNotEqual(a, m.fingerprint(helpers.with_file(man, "forgectrl", "src/ui.c", "changed"),
|
||||
t.covers))
|
||||
self.assertTrue(m.non_behavioral("forgectrl", "tools/devserver.py"))
|
||||
self.assertFalse(m.non_behavioral("grblhal-glowforge", "tools/devserver.py"))
|
||||
# forgeext's image content is its binary and init script: the kit, the template, the host tools
|
||||
# and the shared package workflow are not, and the host's sources are
|
||||
for path in ("sdk/js/ffx-bridge.js", "template/manifest.json", "tools/ffx", ".github/actions/kit/action.yml"):
|
||||
self.assertTrue(m.non_behavioral("forgeext", path), path)
|
||||
for path in ("src/api.c", "init/forgeext.init", "CMakeLists.txt"):
|
||||
self.assertFalse(m.non_behavioral("forgeext", path), path)
|
||||
# an advisory is a document forgectrl serves and records consent to by its hash: behavior, though
|
||||
# it is Markdown under docs/
|
||||
self.assertFalse(m.non_behavioral("forgectrl", "docs/advisories/extensions.md"))
|
||||
self.assertTrue(m.non_behavioral("forgectrl", "docs/SERVICES.md"))
|
||||
self.assertTrue(m.non_behavioral("grblhal-glowforge", "docs/advisories/x.md"))
|
||||
|
||||
def test_an_entry_that_selects_nothing_is_reported(self):
|
||||
man = helpers.make_manifest()
|
||||
good = helpers.make_test("a.one", [("forgectrl", "src/ui.c")])
|
||||
# the recipe's subdirectory left out of the glob, and an unknown component
|
||||
bad = helpers.make_test("a.two", [("forgectrl", "ui.c"), ("no-such", "**")])
|
||||
self.assertEqual(m.empty_covers(man, [good]), [])
|
||||
self.assertEqual(m.empty_covers(man, [good, bad]),
|
||||
[("a.two", "forgectrl", "ui.c"), ("a.two", "no-such", "**")])
|
||||
# a glob that selects docs only names nothing any fingerprint carries
|
||||
docs = helpers.make_test("a.three", [("forgectrl", "**/*.md")])
|
||||
self.assertEqual(m.empty_covers(man, [docs]), [("a.three", "forgectrl", "**/*.md")])
|
||||
|
||||
def test_a_host_tested_path_is_covered_and_in_no_fingerprint(self):
|
||||
man = helpers.with_file(helpers.make_manifest(), "forgectrl", "tests/cool_test.c", "test")
|
||||
t = helpers.make_test("a.one", [("forgectrl", "src/ui.c"), ("forgectrl", "src/auth.c"),
|
||||
("grblhal-glowforge", "**"), ("kernel-module-glowforge", "**"),
|
||||
("linux-fslc", "**")])
|
||||
self.assertEqual(m.coverage_report(man, [t], host=[]), {"forgectrl": ["src/cool.c", "src/main.c"]})
|
||||
ci = "forgectrl:.github/workflows/build.yml"
|
||||
host = [("forgectrl", "src/cool.c", "forgectrl:tests/cool_test.c", ci),
|
||||
("forgectrl", "src/main.c", "forgectrl:tests/cool_test.c", ci)]
|
||||
self.assertEqual(m.coverage_report(man, [t], host=host), {})
|
||||
# a change to a host-tested path makes no acceptance run necessary
|
||||
self.assertEqual(m.fingerprint(man, t.covers),
|
||||
m.fingerprint(helpers.with_file(man, "forgectrl", "src/cool.c", "changed"), t.covers))
|
||||
|
||||
def _root(self, files):
|
||||
"""A scratch checkout of this repository: {path: text}."""
|
||||
import shutil
|
||||
import tempfile
|
||||
root = tempfile.mkdtemp()
|
||||
self.addCleanup(shutil.rmtree, root)
|
||||
for path, text in files.items():
|
||||
os.makedirs(os.path.dirname(os.path.join(root, path)), exist_ok=True)
|
||||
with open(os.path.join(root, path), "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
return root
|
||||
|
||||
# A workflow of this repository as the scratch checkouts carry it: on
|
||||
# every push, and on the pull requests that touch one bench tool, it
|
||||
# checks itself out at the workspace root and runs the one harness.
|
||||
CI_YML = ("on:\n push:\n pull_request:\n paths:\n - 'scripts/bench/drill.py'\n"
|
||||
"jobs:\n test:\n steps:\n"
|
||||
" - uses: actions/checkout@v4\n"
|
||||
" # drill.py is a bench tool\n"
|
||||
" - name: The stream\n"
|
||||
" run: python3 scripts/bench/stream_test.py # not drill.py\n")
|
||||
|
||||
def test_a_host_tested_row_selects_a_path_and_names_a_real_test_and_workflow(self):
|
||||
man = helpers.make_manifest()
|
||||
for path in ("tests/cool_test.c", ".github/workflows/build.yml"):
|
||||
man = helpers.with_file(man, "forgectrl", path, path)
|
||||
root = self._root({"scripts/bench/stream_test.py": "", ".github/workflows/ci.yml": self.CI_YML})
|
||||
ci = "forgectrl:.github/workflows/build.yml"
|
||||
good = [("forgectrl", "src/cool.c", "forgectrl:tests/cool_test.c", ci),
|
||||
("grblhal-glowforge", "src/driver.c", "forgefirm:scripts/bench/stream_test.py",
|
||||
"forgefirm:.github/workflows/ci.yml")]
|
||||
self.assertEqual(m.host_row_problems(man, good, root=root, sources=None), [])
|
||||
bad = [("forgectrl", "cool.c", "forgectrl:tests/cool_test.c", ci), # globs anchor at the root
|
||||
("forgectrl", "**/*.md", "forgectrl:tests/cool_test.c", ci), # docs only
|
||||
("forgectrl", "src/main.c", "forgectrl:tests/gone_test.c", ci),
|
||||
("forgectrl", "src/main.c", "tests/cool_test.c", ci),
|
||||
("forgectrl", "src/main.c", "forgetest:forgetest/x.py", ci), # the acceptance tool itself
|
||||
("forgectrl", "src/main.c", "forgefirm:scripts/bench/gone_test.py", ci),
|
||||
("forgectrl", "src/main.c", "forgectrl:tests/cool_test.c", "forgectrl:.github/workflows/gone.yml"),
|
||||
("forgectrl", "src/main.c", "forgectrl:tests/cool_test.c", "build.yml")]
|
||||
self.assertEqual([r[3] for r in m.host_row_problems(man, bad, root=root, sources=None)],
|
||||
["selects nothing", "selects nothing", "no such host test",
|
||||
"names no repository:path for its host test", "no repository 'forgetest' for its host test",
|
||||
"no such host test", "no such workflow", "names no repository:path for its workflow"])
|
||||
# installed on an image the package is in no checkout: a "forgefirm:" file is not looked for
|
||||
self.assertEqual(m.host_row_problems(man, bad[5:6], root=None, sources=None), [])
|
||||
|
||||
def test_the_workflow_has_to_run_the_host_test(self):
|
||||
man = helpers.make_manifest()
|
||||
root = self._root({"scripts/bench/stream_test.py": "", "scripts/bench/drill.py": "",
|
||||
".github/workflows/ci.yml": self.CI_YML})
|
||||
ci = "forgefirm:.github/workflows/ci.yml"
|
||||
runs = ("grblhal-glowforge", "src/driver.c", "forgefirm:scripts/bench/stream_test.py", ci)
|
||||
# drill.py is in the trigger's paths, a comment line and an inline comment, and run by no step
|
||||
idle = ("grblhal-glowforge", "src/driver.c", "forgefirm:scripts/bench/drill.py", ci)
|
||||
self.assertEqual(m.host_row_problems(man, [runs], root=root, sources=None), [])
|
||||
self.assertEqual(m.host_row_problems(man, [idle], root=root, sources=None),
|
||||
[(idle[0], idle[1], idle[2], ci + " does not run it")])
|
||||
# a workflow whose own on: names neither push nor pull_request is listed, and a step whose
|
||||
# failure the run tolerates does not run it
|
||||
job = "jobs:\n test:\n steps:\n - uses: actions/checkout@v4\n - run: python3 scripts/bench/stream_test.py\n"
|
||||
root = self._root({"scripts/bench/stream_test.py": "",
|
||||
".github/workflows/manual.yml": "on:\n workflow_dispatch:\n" + job,
|
||||
".github/workflows/soft.yml": "on: [push]\n" + job.replace(
|
||||
" - run:", " - continue-on-error: true\n run:")})
|
||||
manual = runs[:3] + ("forgefirm:.github/workflows/manual.yml",)
|
||||
soft = runs[:3] + ("forgefirm:.github/workflows/soft.yml",)
|
||||
self.assertEqual(m.host_row_problems(man, [manual, soft], root=root, sources=None),
|
||||
[manual[:3] + (manual[3] + ": the lint reads no push or pull_request in its own on:",),
|
||||
soft[:3] + (soft[3] + " does not run it",)])
|
||||
# a step in a job that needs a conditional job does not run it (the checkout in a job of
|
||||
# its own, as the lint reads a checkout for the whole workflow)
|
||||
root = self._root({"scripts/bench/stream_test.py": "",
|
||||
".github/workflows/needs.yml": "on: [push]\njobs:\n lint:\n steps:\n"
|
||||
" - uses: actions/checkout@v4\n build:\n if: false\n steps:\n"
|
||||
" - run: make\n test:\n needs: build\n steps:\n"
|
||||
" - run: python3 scripts/bench/stream_test.py\n"})
|
||||
needs = runs[:3] + ("forgefirm:.github/workflows/needs.yml",)
|
||||
self.assertEqual(m.host_row_problems(man, [needs], root=root, sources=None),
|
||||
[needs[:3] + (needs[3] + " does not run it",)])
|
||||
|
||||
def _runs(self, yml, path):
|
||||
return m.workflow_runs(m.workflow_steps(yml), path)
|
||||
|
||||
def _block(self, *lines, keys=""):
|
||||
"""A one-step workflow whose run text is these lines, the step's other keys before it."""
|
||||
return "steps:\n - name: x\n%s run: |\n%s" % (keys, "".join(" %s\n" % ln for ln in lines))
|
||||
|
||||
def test_what_running_a_host_test_looks_like(self):
|
||||
step = "steps:\n - name: x\n run: %s\n"
|
||||
self.assertTrue(self._runs(step % "./build/tray_test", "tests/tray_test.c"))
|
||||
self.assertTrue(self._runs(step % "sudo ./build/sandbox_test", "tests/sandbox_test.c"))
|
||||
self.assertTrue(self._runs(step % "python3 -B tests/run_test.py", "tests/run_test.py"))
|
||||
self.assertTrue(self._runs(step % "python3 forgefirm/scripts/bench/ctlport_test.py build/x",
|
||||
"forgefirm/scripts/bench/ctlport_test.py"))
|
||||
self.assertTrue(self._runs(step % "python -m pytest -q tests", "tests/test_frame.py"))
|
||||
# a block, with the step's working-directory: the words are its paths (the loop as
|
||||
# python3-gfhardware's CI writes it, an expansion in an assignment)
|
||||
block = ("steps:\n - name: every module\n working-directory: %s\n run: |\n"
|
||||
" set -e\n for t in tests/test_*.py; do\n m=$(basename \"$t\" .py)\n"
|
||||
" python -m unittest -v \"tests.$m\"\n"
|
||||
" done\n - working-directory: forgefirm/forgetest\n"
|
||||
" run: python -B -m unittest discover -s tests -v\n")
|
||||
self.assertTrue(self._runs(block % "gfh", "gfh/tests/test_feeder.py"))
|
||||
self.assertTrue(self._runs(block % "gfh", "forgefirm/forgetest/tests/test_x.py"))
|
||||
self.assertFalse(self._runs(block % "other", "gfh/tests/test_feeder.py"))
|
||||
# what the review found accepted: a trigger's paths, an inline comment, a glob or a discover
|
||||
# that the path only ends like
|
||||
self.assertFalse(self._runs("on:\n push:\n paths:\n - 'kas/source-bundle.yml'\n"
|
||||
+ step % "python3 x.py", "kas/source-bundle.yml"))
|
||||
self.assertFalse(self._runs(step % "./build/lens_test # tray_test is manual", "tests/tray_test.c"))
|
||||
self.assertFalse(self._runs("steps:\n - run: |\n for t in tests/test_*.py; do\n python $t\n"
|
||||
" done\n", "tools/tests/test_feeder.py"))
|
||||
self.assertFalse(self._runs(step % "python -m unittest discover -s tests",
|
||||
"scripts/bench/tests/foo_test.py"))
|
||||
# a longer name, another file, a step's name, a comment line
|
||||
self.assertFalse(self._runs(step % "./build/tray_test_more", "tests/tray_test.c"))
|
||||
self.assertFalse(self._runs(step % "./build/lens_test", "tests/fflog_e2e.sh"))
|
||||
self.assertFalse(self._runs("steps:\n - name: tests/run_test.py\n run: make\n", "tests/run_test.py"))
|
||||
self.assertFalse(self._runs("steps:\n - run: |\n # python3 tests/run_test.py\n make\n",
|
||||
"tests/run_test.py"))
|
||||
# the second review: an argument to a command that is not the test does not run it, and a
|
||||
# discover or pytest runs its own directory's matching files only
|
||||
self.assertFalse(self._runs(step % "echo tray_test", "tests/tray_test.c"))
|
||||
self.assertFalse(self._runs(step % "echo ./build/tray_test", "tests/tray_test.c"))
|
||||
self.assertFalse(self._runs(step % "command -v ./build/tray_test", "tests/tray_test.c")) # prints it
|
||||
self.assertFalse(self._runs(step % "cmake --build build --target tray_test", "tests/tray_test.c"))
|
||||
self.assertFalse(self._runs(step % "cat tests/run_test.py", "tests/run_test.py"))
|
||||
self.assertFalse(self._runs(step % "python -m unittest discover -s forgefirm",
|
||||
"forgefirm/scripts/bench/fire_test.py"))
|
||||
self.assertFalse(self._runs(step % "python -m unittest discover -s tests", "tests/foo_test.py"))
|
||||
self.assertFalse(self._runs(step % "python -m pytest forgefirm", "forgefirm/tests/test_a.py"))
|
||||
self.assertTrue(self._runs(step % "python -m unittest discover -s tests -p '*_test.py'", "tests/foo_test.py"))
|
||||
self.assertTrue(self._runs(step % "pytest -q tests/test_a.py", "tests/test_a.py"))
|
||||
# a script run directly or by its shell, and a command continued across lines
|
||||
self.assertTrue(self._runs(step % "./tests/fflog_e2e.sh", "tests/fflog_e2e.sh"))
|
||||
self.assertTrue(self._runs(step % "bash tests/fflog_e2e.sh", "tests/fflog_e2e.sh"))
|
||||
self.assertTrue(self._runs("steps:\n - run: |\n sudo -E FORGEEXT=build/forgeext \\\n"
|
||||
" python3 -B tests/run_test.py\n", "tests/run_test.py"))
|
||||
# python -m unittest given the test's file or its module (the third review: forgectrl's CI
|
||||
# runs its mock test as a file), and not a module beside it or a -k pattern
|
||||
self.assertTrue(self._runs(step % "python3 -B -m unittest -v tests/test_devserver_mock.py",
|
||||
"tests/test_devserver_mock.py"))
|
||||
self.assertTrue(self._runs(step % "python -m unittest tests.test_feeder", "tests/test_feeder.py"))
|
||||
self.assertTrue(self._runs(step % "python -m unittest tests.test_feeder.FeederTests.test_one",
|
||||
"tests/test_feeder.py"))
|
||||
self.assertFalse(self._runs(step % "python -m unittest tests.test_lens", "tests/test_feeder.py"))
|
||||
self.assertFalse(self._runs(step % "python -m unittest -k test_feeder tests.test_lens",
|
||||
"tests/test_feeder.py"))
|
||||
|
||||
def test_a_step_counts_only_when_every_run_of_the_workflow_runs_it_and_its_failure_fails_the_run(self):
|
||||
step = "steps:\n - name: x\n%s run: ./build/tray_test\n"
|
||||
test = "tests/tray_test.c"
|
||||
# the lint evaluates no condition: an if: other than the literal true counts as no step,
|
||||
# always() included; literally true is no condition
|
||||
self.assertFalse(self._runs(step % " if: false\n", test))
|
||||
self.assertFalse(self._runs(step % " if: github.event_name == 'push'\n", test))
|
||||
self.assertFalse(self._runs(step % " if: ${{ always() }}\n", test))
|
||||
self.assertTrue(self._runs(step % " if: true\n", test))
|
||||
self.assertTrue(self._runs(step % " if: 'true'\n", test))
|
||||
# however the step is spelled: more than one space after its dash, or a quoted key
|
||||
self.assertFalse(self._runs("steps:\n - run: ./build/tray_test\n if: false\n", test))
|
||||
self.assertFalse(self._runs(step % " \"if\": false\n", test))
|
||||
# a key with one stray quote is no key the lint reads: at a step's key (its first one on
|
||||
# its dash line too), a job's key or the top level, no step of the workflow counts
|
||||
self.assertFalse(self._runs(step % " if\": false\n", test))
|
||||
self.assertFalse(self._runs("steps:\n - name: x\n run\": ./build/tray_test\n", test))
|
||||
self.assertFalse(self._runs("steps:\n - run\": make\n - run: ./build/tray_test\n", test))
|
||||
self.assertFalse(self._runs("jobs:\n t:\n if\": false\n steps:\n - run: ./build/tray_test\n",
|
||||
test))
|
||||
self.assertFalse(self._runs("jobs\":\n t:\n if: false\n steps:\n - run: ./build/tray_test\n",
|
||||
test))
|
||||
# a step whose failure the run tolerates proves nothing; literally false is no tolerance
|
||||
self.assertFalse(self._runs(step % " continue-on-error: true\n", test))
|
||||
self.assertFalse(self._runs(step % " continue-on-error: ${{ inputs.soft }}\n", test))
|
||||
self.assertTrue(self._runs(step % " continue-on-error: false\n", test))
|
||||
# the job's own if: or continue-on-error: holds for each of its steps, wherever it is
|
||||
# written, over a step's own, and not for another job's steps
|
||||
job = "jobs:\n t:\n%s steps:\n - run: ./build/tray_test\n%s"
|
||||
self.assertFalse(self._runs(job % (" if: github.ref == 'refs/heads/main'\n", ""), test))
|
||||
self.assertFalse(self._runs(job % ("", " if: false\n"), test))
|
||||
self.assertFalse(self._runs(job % (" continue-on-error: true\n", ""), test))
|
||||
self.assertFalse(self._runs("jobs:\n t:\n continue-on-error: true\n steps:\n"
|
||||
" - continue-on-error: false\n run: ./build/tray_test\n", test))
|
||||
self.assertTrue(self._runs(job % (" if: true\n runs-on: ubuntu-latest\n", ""), test))
|
||||
self.assertTrue(self._runs(job % (" permissions:\n contents: read\n", ""), test))
|
||||
two = "jobs:\n a:\n if: false\n steps:\n - run: %s\n b:\n steps:\n - run: %s\n"
|
||||
self.assertTrue(self._runs(two % ("make", "./build/tray_test"), test))
|
||||
self.assertFalse(self._runs(two % ("./build/tray_test", "make"), test))
|
||||
# the run text is read as bash -e or sh -e runs it: the test counts on a line that holds it
|
||||
# alone, or in a pipeline under pipefail; any other operator on the line, or a pipe with no
|
||||
# pipefail, keeps it from counting
|
||||
for line in ("./build/tray_test || true", "true || ./build/tray_test", "./build/tray_test; true",
|
||||
"./build/tray_test &", "./build/tray_test | tee log"):
|
||||
self.assertFalse(self._runs(self._block(line), test), line)
|
||||
self.assertTrue(self._runs(self._block("set -o pipefail", "./build/tray_test | tee log"), test))
|
||||
self.assertTrue(self._runs(self._block("./build/tray_test | tee log", keys=" shell: bash\n"), test))
|
||||
# no line counts after one that ends the step, turns errexit off, sets an option the lint
|
||||
# does not follow (noexec), or opens a here-document or a compound command
|
||||
for before in ("exit 0", "set +e", "set -n", "python3 - <<'EOF'"):
|
||||
self.assertFalse(self._runs(self._block(before, "./build/tray_test"), test), before)
|
||||
self.assertFalse(self._runs(self._block("{", "./build/tray_test", "} || true"), test))
|
||||
# a loop counts only with a body whose lines count
|
||||
for body in ("python $t || true", "python $t | tee log"):
|
||||
self.assertFalse(self._runs(self._block("for t in tests/test_*.py; do", body, "done"), "tests/test_x.py"),
|
||||
body)
|
||||
# a folded block, and a scalar continued under its key, are one line to the shell
|
||||
self.assertFalse(self._runs("steps:\n - run: >\n ./build/tray_test\n || true\n", test))
|
||||
self.assertFalse(self._runs("steps:\n - run: ./build/tray_test\n || true\n", test))
|
||||
# the lint reads the text of bash and sh alone: the step's shell, or its job's or its
|
||||
# workflow's default
|
||||
self.assertFalse(self._runs(self._block("./build/tray_test", keys=" shell: bash {0}\n"), test))
|
||||
self.assertTrue(self._runs(self._block("./build/tray_test", keys=" shell: sh\n"), test))
|
||||
self.assertFalse(self._runs("jobs:\n t:\n defaults:\n run:\n shell: pwsh\n steps:\n"
|
||||
" - run: ./build/tray_test\n", test))
|
||||
self.assertFalse(self._runs("defaults:\n run:\n shell: pwsh\njobs:\n t:\n steps:\n"
|
||||
" - run: ./build/tray_test\n", test))
|
||||
# a checkout under a condition is no checkout
|
||||
yml = "steps:\n - uses: actions/checkout@v4\n%s with:\n path: own\n"
|
||||
self.assertEqual(m.checkout_path(m.workflow_steps(yml % ""), "x", True), "own")
|
||||
self.assertIsNone(m.checkout_path(m.workflow_steps(yml % " if: false\n"), "x", True))
|
||||
|
||||
def test_a_job_runs_only_when_every_job_it_needs_runs(self):
|
||||
test = "tests/tray_test.c"
|
||||
# GitHub skips a job whose needed job did not run: a job that needs a conditional job runs
|
||||
# on not every run, however the needs: is written
|
||||
yml = "jobs:\n build:\n%s steps:\n - run: make\n test:\n%s steps:\n - run: ./build/tray_test\n"
|
||||
for needs in (" needs: build\n", " needs: [build]\n", " needs: ['build']\n",
|
||||
" needs:\n - build\n", " needs:\n - build\n",
|
||||
" needs: [\n build\n ]\n"):
|
||||
self.assertTrue(self._runs(yml % ("", needs), test), needs)
|
||||
self.assertFalse(self._runs(yml % (" if: false\n", needs), test), needs)
|
||||
self.assertFalse(self._runs(yml % (" if: github.event_name == 'push'\n", needs), test), needs)
|
||||
# the needed job's continue-on-error: does not bear: its failure fails neither the run nor
|
||||
# the jobs after it
|
||||
self.assertTrue(self._runs(yml % (" continue-on-error: true\n", " needs: build\n"), test))
|
||||
# transitively; a job the workflow does not have, a cycle, and a needs: the lint does not
|
||||
# read each fail closed
|
||||
chain = "jobs:\n a:\n%s steps:\n - run: make\n b:\n needs: a\n steps:\n - run: make\n" \
|
||||
" c:\n needs: [b]\n steps:\n - run: ./build/tray_test\n"
|
||||
self.assertTrue(self._runs(chain % "", test))
|
||||
self.assertFalse(self._runs(chain % " if: false\n", test))
|
||||
self.assertFalse(self._runs(yml % ("", " needs: lint\n"), test))
|
||||
self.assertFalse(self._runs(yml % (" needs: test\n", " needs: build\n"), test))
|
||||
self.assertFalse(self._runs(yml % ("", " needs: test\n"), test))
|
||||
self.assertFalse(self._runs(yml % ("", " needs:\n build: true\n"), test))
|
||||
# a flow list split after a comma keeps the name on its second line: a conditional job
|
||||
# there holds the dependent job back
|
||||
split = (chain % "").replace(" needs: [b]\n", " needs: [a,\n b]\n")
|
||||
self.assertTrue(self._runs(split, test))
|
||||
self.assertFalse(self._runs(split.replace(" b:\n needs: a\n", " b:\n needs: a\n if: false\n"), test))
|
||||
# an empty needs: (which GitHub refuses), a flow list a key interrupts before its ], and
|
||||
# list items at two indentations each fail closed
|
||||
for needs in (" needs: []\n", " needs:\n", " needs: [\n build\n",
|
||||
" needs:\n - build\n - build\n"):
|
||||
self.assertFalse(self._runs(yml % ("", needs), test), needs)
|
||||
# through a row: the test job needs, in a block list at the key's own indentation, a
|
||||
# build job under if: false (the checkout in a job of its own, as the lint reads a
|
||||
# checkout for the whole workflow)
|
||||
man = helpers.make_manifest()
|
||||
root = self._root({"scripts/bench/stream_test.py": "",
|
||||
".github/workflows/needs.yml": "on: [push]\njobs:\n lint:\n steps:\n"
|
||||
" - uses: actions/checkout@v4\n build:\n if: false\n steps:\n"
|
||||
" - run: make\n test:\n needs:\n - build\n steps:\n"
|
||||
" - run: python3 scripts/bench/stream_test.py\n"})
|
||||
row = ("grblhal-glowforge", "src/driver.c", "forgefirm:scripts/bench/stream_test.py",
|
||||
"forgefirm:.github/workflows/needs.yml")
|
||||
self.assertEqual(m.host_row_problems(man, [row], root=root, sources=None),
|
||||
[row[:3] + (row[3] + " does not run it",)])
|
||||
# a job under always() runs on every run, and the lint evaluates no condition: it counts as
|
||||
# no job for its own steps and for the jobs after it
|
||||
self.assertFalse(self._runs(yml % (" if: false\n", " needs: build\n if: always()\n"), test))
|
||||
self.assertFalse(self._runs((chain % " if: false\n").replace(
|
||||
" b:\n needs: a\n", " b:\n needs: a\n if: ${{ always() }}\n"), test))
|
||||
|
||||
def test_the_events_a_workflow_runs_on(self):
|
||||
self.assertEqual(m.workflow_triggers("on:\n push:\n branches: [main]\n pull_request:\njobs:\n t:\n"),
|
||||
{"push", "pull_request"})
|
||||
self.assertEqual(m.workflow_triggers("on: [push, pull_request]\n"), {"push", "pull_request"})
|
||||
self.assertEqual(m.workflow_triggers("on: push\n"), {"push"})
|
||||
self.assertEqual(m.workflow_triggers("on:\n - push\n - workflow_dispatch\n"), {"push", "workflow_dispatch"})
|
||||
self.assertEqual(m.workflow_triggers("\"on\":\n workflow_dispatch:\n inputs:\n push:\n"
|
||||
" type: string\n"), {"workflow_dispatch"})
|
||||
self.assertEqual(m.workflow_triggers("on:\n workflow_call:\n"), {"workflow_call"})
|
||||
self.assertEqual(m.workflow_triggers("name: x\njobs:\n t:\n steps: []\n"), set())
|
||||
# an event key with one stray quote is no event, and the workflow then names none
|
||||
self.assertEqual(m.workflow_triggers("on:\n push\":\n"), set())
|
||||
self.assertEqual(m.workflow_triggers("on:\n push:\n 'pull_request:\n"), set())
|
||||
|
||||
def test_a_push_or_pull_request_under_a_filter_runs_on_not_every_change(self):
|
||||
f = m.workflow_filters
|
||||
self.assertEqual(f("on:\n push:\n branches: [main]\n pull_request:\njobs:\n t:\n"), {"push": ["branches"]})
|
||||
self.assertEqual(f("on:\n push:\n branches-ignore:\n - wip/*\n paths-ignore:\n - 'docs/**'\n"
|
||||
" pull_request:\n paths:\n - 'src/**'\n"),
|
||||
{"push": ["branches-ignore", "paths-ignore"], "pull_request": ["paths"]})
|
||||
self.assertEqual(f("on:\n push:\n tags: ['v*']\n"), {"push": ["tags"]})
|
||||
self.assertEqual(f("on:\n push:\n tags-ignore: ['v*']\n"), {"push": ["tags-ignore"]})
|
||||
# a pull_request under types: runs on the activity types it names and no other; the lint
|
||||
# reads no type, so any types: is a filter, on the pull_request alone
|
||||
self.assertEqual(f("on:\n push:\n pull_request:\n types: [closed]\n"), {"pull_request": ["types"]})
|
||||
self.assertEqual(f("on:\n pull_request:\n types:\n - opened\n - synchronize\n paths-ignore:\n"
|
||||
" - '**.md'\n"), {"pull_request": ["types", "paths-ignore"]})
|
||||
self.assertEqual(f("on:\n push:\n types: [x]\n"), {})
|
||||
# a value on the event's line, and a key with one stray quote under it, are filters the
|
||||
# lint does not read
|
||||
self.assertEqual(f("on:\n push: {branches: [main]}\n"), {"push": ["a value the lint does not read"]})
|
||||
self.assertEqual(f("on:\n push:\n paths\":\n - x\n"), {"push": ["a key the lint does not read"]})
|
||||
# a filter is read at the event's own level: a dispatch's inputs are none, and an on: list
|
||||
# or scalar carries none
|
||||
self.assertEqual(f("on:\n workflow_dispatch:\n inputs:\n paths:\n type: string\n push:\n"),
|
||||
{})
|
||||
self.assertEqual(f("on: [push, pull_request]\n"), {})
|
||||
self.assertEqual(f("on: push\n"), {})
|
||||
self.assertEqual(f("on:\n - push\n"), {})
|
||||
# a row's workflow counts when its push or its pull_request carries no filter: a push to
|
||||
# the default branch alone with every pull request does, a push with paths-ignore alone
|
||||
# does not, nor do a push and a pull request each under a filter; every push with a pull
|
||||
# request under types: does, a pull request under types: alone does not
|
||||
man = helpers.make_manifest()
|
||||
job = "jobs:\n test:\n steps:\n - uses: actions/checkout@v4\n - run: python3 scripts/bench/stream_test.py\n"
|
||||
root = self._root({"scripts/bench/stream_test.py": "",
|
||||
".github/workflows/main.yml": "on:\n push:\n branches: [main]\n pull_request:\n" + job,
|
||||
".github/workflows/docs.yml": "on:\n push:\n paths-ignore:\n - 'docs/**'\n" + job,
|
||||
".github/workflows/both.yml": "on:\n push:\n branches: [main]\n pull_request:\n"
|
||||
" paths:\n - 'src/**'\n" + job,
|
||||
".github/workflows/push.yml": "on:\n push:\n pull_request:\n types: [closed]\n" + job,
|
||||
".github/workflows/closed.yml": "on:\n pull_request:\n types: [closed]\n" + job})
|
||||
rows = [("grblhal-glowforge", "src/driver.c", "forgefirm:scripts/bench/stream_test.py",
|
||||
"forgefirm:.github/workflows/%s.yml" % w) for w in ("main", "docs", "both", "push", "closed")]
|
||||
filtered = ": every push or pull_request in its own on: carries a filter (%s)"
|
||||
self.assertEqual(m.host_row_problems(man, rows, root=root, sources=None),
|
||||
[rows[1][:3] + (rows[1][3] + filtered % "push: paths-ignore",),
|
||||
rows[2][:3] + (rows[2][3] + filtered % "pull_request: paths; push: branches",),
|
||||
rows[4][:3] + (rows[4][3] + filtered % "pull_request: types",)])
|
||||
|
||||
def test_the_lint_says_when_it_could_not_read_a_workflow(self):
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import tempfile
|
||||
from unittest import mock
|
||||
from forgetest import coverage
|
||||
work = tempfile.mkdtemp()
|
||||
self.addCleanup(__import__("shutil").rmtree, work)
|
||||
comps = {"forgectrl": {"srcrev": "aaa", "source": "https://example.invalid/forgectrl.git", "files": [
|
||||
["src/tray.c", "1" * 40], ["tests/tray_test.c", "2" * 40], [".github/workflows/build.yml", "3" * 40]]}}
|
||||
path = os.path.join(work, "manifest.json")
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(json.dumps(helpers.make_manifest(components=comps).data))
|
||||
row = ("forgectrl", "src/tray.c", "forgectrl:tests/tray_test.c", "forgectrl:.github/workflows/build.yml")
|
||||
out = io.StringIO()
|
||||
with mock.patch.object(m, "HOST_TESTED", [row]), contextlib.redirect_stdout(out):
|
||||
rc = coverage.main(["--manifest", path, "--sources", work, "--json"])
|
||||
self.assertEqual(rc, 0) # reporting only
|
||||
rep = json.loads(out.getvalue())
|
||||
self.assertFalse(rep["workflows_read"])
|
||||
self.assertTrue(any("cannot be read" in r[3] for r in rep["host_tested"]))
|
||||
out = io.StringIO()
|
||||
with mock.patch.object(m, "HOST_TESTED", [row]), contextlib.redirect_stdout(out):
|
||||
self.assertEqual(coverage.main(["--manifest", path, "--sources", work, "--enforce"]), 1)
|
||||
|
||||
def test_where_a_workflow_checks_a_repository_out(self):
|
||||
steps = m.workflow_steps("jobs:\n t:\n steps:\n"
|
||||
" - uses: actions/checkout@v4\n with:\n path: own\n"
|
||||
" - name: Fetch the harnesses\n uses: actions/checkout@v4\n"
|
||||
" with:\n repository: openglow-org/forgefirm\n path: forgefirm\n")
|
||||
self.assertEqual(m.checkout_path(steps, "grblHAL-glowforge", True), "own")
|
||||
self.assertEqual(m.checkout_path(steps, "forgefirm", False), "forgefirm")
|
||||
self.assertIsNone(m.checkout_path(steps, "forgectrl", False))
|
||||
self.assertEqual(m.checkout_path(m.workflow_steps("steps:\n - uses: actions/checkout@v4\n"), "x", True), ".")
|
||||
self.assertEqual(m.repo_name(helpers.make_manifest(components={"python3-gfutilities": {
|
||||
"source": "https://github.com/openglow-org/Glowforge-Utilities.git", "files": []}}),
|
||||
"python3-gfutilities"), "Glowforge-Utilities")
|
||||
|
||||
def test_a_components_workflow_is_read_at_its_pinned_blob(self):
|
||||
import hashlib
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
if not shutil.which("git"):
|
||||
self.skipTest("no git")
|
||||
sources = tempfile.mkdtemp()
|
||||
|
||||
def writable(func, path, _exc): # git writes its objects read-only
|
||||
os.chmod(path, 0o700)
|
||||
func(path)
|
||||
self.addCleanup(shutil.rmtree, sources, **({"onexc": writable} if sys.version_info >= (3, 12)
|
||||
else {"onerror": writable}))
|
||||
url = "https://example.invalid/forgectrl.git"
|
||||
cache = os.path.join(sources, hashlib.sha1(url.encode()).hexdigest()[:16])
|
||||
os.makedirs(cache)
|
||||
subprocess.run(["git", "init", "-q", "--bare"], cwd=cache, check=True)
|
||||
|
||||
def blob(text):
|
||||
return subprocess.run(["git", "hash-object", "-w", "--stdin"], cwd=cache, input=text.encode(),
|
||||
capture_output=True, check=True).stdout.decode().strip()
|
||||
yml = "on: [push, pull_request]\nsteps:\n - uses: actions/checkout@v4\n - run: ./build/tray_test\n"
|
||||
comps = {"forgectrl": {"srcrev": "aaa", "source": url, "files": [
|
||||
["src/tray.c", "1" * 40], ["tests/tray_test.c", "2" * 40], ["tests/lens_test.c", "3" * 40],
|
||||
[".github/workflows/build.yml", blob(yml)], [".github/workflows/lost.yml", "4" * 40]]}}
|
||||
man = helpers.make_manifest(components=comps)
|
||||
ci = "forgectrl:.github/workflows/build.yml"
|
||||
self.assertEqual(m.read_source(man, ci, root=None, sources=sources), (yml, None))
|
||||
tray = ("forgectrl", "src/tray.c", "forgectrl:tests/tray_test.c", ci)
|
||||
lens = ("forgectrl", "src/tray.c", "forgectrl:tests/lens_test.c", ci)
|
||||
self.assertEqual(m.host_row_problems(man, [tray, lens], root=None, sources=sources),
|
||||
[(lens[0], lens[1], lens[2], ci + " does not run it")])
|
||||
# without the sources the workflow cannot be read, and is not judged
|
||||
self.assertEqual(m.host_row_problems(man, [lens], root=None, sources=None), [])
|
||||
# with them, a workflow that should be read and is not is a problem, never a pass
|
||||
lost = ("forgectrl", "src/tray.c", "forgectrl:tests/tray_test.c", "forgectrl:.github/workflows/lost.yml")
|
||||
probs = m.host_row_problems(man, [lost], root=None, sources=sources)
|
||||
self.assertEqual(len(probs), 1)
|
||||
self.assertIn("cannot be read", probs[0][3])
|
||||
|
||||
def test_the_real_host_tested_rows_are_well_formed(self):
|
||||
for row in m.HOST_TESTED:
|
||||
self.assertEqual(len(row), 4, row)
|
||||
self.assertIn(":", row[2], row)
|
||||
self.assertIn(":", row[3], row)
|
||||
|
||||
|
||||
class CatalogTests(unittest.TestCase):
|
||||
def test_catalog_hash_is_definition_only(self):
|
||||
r1 = helpers.registry(helpers.make_test("a.one", [("forgectrl", "src/ui.c")]))
|
||||
r2 = helpers.registry(helpers.make_test("a.one", [("forgectrl", "src/ui.c")], fn=lambda ctx: 1))
|
||||
self.assertEqual(catalog.catalog_hash(r1), catalog.catalog_hash(r2))
|
||||
r3 = helpers.registry(helpers.make_test("a.one", [("forgectrl", "src/**")]))
|
||||
self.assertNotEqual(catalog.catalog_hash(r1), catalog.catalog_hash(r3))
|
||||
r4 = helpers.registry(helpers.make_test("a.one", [("forgectrl", "src/ui.c")], always=True))
|
||||
self.assertNotEqual(catalog.catalog_hash(r1), catalog.catalog_hash(r4))
|
||||
|
||||
def test_validate(self):
|
||||
r = helpers.registry(helpers.make_test("a.one", [], requires=("a.two",)))
|
||||
with self.assertRaises(ValueError):
|
||||
catalog.validate(r)
|
||||
r = helpers.registry(helpers.make_test("a.one", [], requires=("a.two",)),
|
||||
helpers.make_test("a.two", [], requires=("a.one",)))
|
||||
with self.assertRaises(ValueError):
|
||||
catalog.validate(r)
|
||||
|
||||
def test_decorator_rules(self):
|
||||
with self.assertRaises(ValueError):
|
||||
catalog.test("bad id", title="x", subsystem="s")(lambda c: None)
|
||||
with self.assertRaises(ValueError):
|
||||
catalog.test("s.x", title="x", subsystem="s", covers=[("forgetest", "**")])(lambda c: None)
|
||||
|
||||
def test_real_suite_loads_and_validates(self):
|
||||
reg = catalog.load_suite()
|
||||
self.assertIn("image.health", reg)
|
||||
self.assertTrue(reg["image.health"].always)
|
||||
catalog.validate(reg)
|
||||
for t in catalog.all_tests(reg):
|
||||
self.assertEqual(len(t.source_sha), 64)
|
||||
|
||||
def test_the_shared_judges_exist_and_take_every_package_import_of_the_suite(self):
|
||||
# The list is kept by hand: the Context reaches runner.py, hw.py
|
||||
# and baseline.py with no import to find. So every package module
|
||||
# a suite module does import, at its top level or inside a function
|
||||
# or a block, has to be on it (or be the registry, which judges
|
||||
# nothing), or that judge would be in no fingerprint. The sibling
|
||||
# walk follows the relative spellings alone (`from .x import` and
|
||||
# `from . import x`), so a suite module imports the package by no
|
||||
# absolute name.
|
||||
import ast
|
||||
import glob
|
||||
paths = catalog.shared_judge_paths()
|
||||
self.assertEqual([os.path.basename(p) for p in paths], list(catalog.SHARED_JUDGES))
|
||||
for p in paths:
|
||||
self.assertTrue(os.path.isfile(p), p)
|
||||
allowed = set(catalog.SHARED_JUDGES) | {"catalog.py"}
|
||||
for mod in sorted(glob.glob(os.path.join(catalog.suite_dir(), "*.py"))):
|
||||
with open(mod, encoding="utf-8") as f:
|
||||
tree = ast.parse(f.read())
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ImportFrom) and node.level == 2:
|
||||
for name in ([node.module] if node.module else [a.name for a in node.names]):
|
||||
self.assertIn(name + ".py", allowed,
|
||||
"%s imports %s, which no fingerprint carries" % (os.path.basename(mod), name))
|
||||
absolute = ([node.module or ""] if isinstance(node, ast.ImportFrom) and node.level == 0
|
||||
else [a.name for a in node.names] if isinstance(node, ast.Import) else [])
|
||||
for name in absolute:
|
||||
self.assertFalse(name == "forgetest" or name.startswith("forgetest."),
|
||||
"%s imports %s by its absolute name, which no hash follows: import it "
|
||||
"relatively (from .. or from .)" % (os.path.basename(mod), name))
|
||||
|
||||
|
||||
class ImplementationHashTests(unittest.TestCase):
|
||||
"""A test's implementation hash is its own function plus the code its
|
||||
module shares: a body edit moves one test, a helper edit moves them
|
||||
all, and a file without @test functions hashes whole."""
|
||||
|
||||
MODULE = '''"""a suite module"""
|
||||
from forgetest.catalog import test
|
||||
|
||||
|
||||
def helper(x):
|
||||
return x + 1
|
||||
|
||||
|
||||
@test("m.one", title="one", subsystem="m")
|
||||
def one(ctx):
|
||||
ctx.log(helper(1))
|
||||
|
||||
|
||||
@test("m.two", title="two", subsystem="m",
|
||||
steps=["a step"])
|
||||
def two(ctx):
|
||||
ctx.log(helper(2))
|
||||
'''
|
||||
|
||||
def setUp(self):
|
||||
import tempfile
|
||||
self.tmp = tempfile.mkdtemp(prefix="forgetest-impl-")
|
||||
self.path = os.path.join(self.tmp, "mod.py")
|
||||
self.write(self.MODULE)
|
||||
|
||||
def tearDown(self):
|
||||
import shutil
|
||||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||
|
||||
def write(self, text):
|
||||
catalog.forget(self.path)
|
||||
with open(self.path, "w", newline="\n") as f:
|
||||
f.write(text)
|
||||
|
||||
def shas(self):
|
||||
return catalog.implementation_sha(self.path, "m.one"), catalog.implementation_sha(self.path, "m.two")
|
||||
|
||||
def test_a_body_edit_moves_that_test_only(self):
|
||||
a1, b1 = self.shas()
|
||||
self.write(self.MODULE.replace("ctx.log(helper(2))", "ctx.log(helper(3))"))
|
||||
a2, b2 = self.shas()
|
||||
self.assertEqual(a1, a2)
|
||||
self.assertNotEqual(b1, b2)
|
||||
|
||||
def test_a_decorator_edit_moves_that_test_only(self):
|
||||
a1, b1 = self.shas()
|
||||
self.write(self.MODULE.replace('steps=["a step"]', 'steps=["another step"]'))
|
||||
a2, b2 = self.shas()
|
||||
self.assertEqual(a1, a2)
|
||||
self.assertNotEqual(b1, b2)
|
||||
|
||||
def test_a_helper_edit_moves_every_test_of_the_module(self):
|
||||
a1, b1 = self.shas()
|
||||
self.write(self.MODULE.replace("return x + 1", "return x + 2"))
|
||||
a2, b2 = self.shas()
|
||||
self.assertNotEqual(a1, a2)
|
||||
self.assertNotEqual(b1, b2)
|
||||
|
||||
def test_a_helper_edit_in_an_imported_sibling_moves_the_tests(self):
|
||||
# laser.py imports its judges from motion.py: an edit to such a
|
||||
# helper changes the judge, so it has to move the fingerprints of
|
||||
# the tests that import it, transitively.
|
||||
sib = os.path.join(self.tmp, "judge.py")
|
||||
with open(sib, "w", newline="\n") as f:
|
||||
f.write("def judge(x):\n return x > 1\n")
|
||||
catalog.forget(sib)
|
||||
self.write("from .judge import judge\n" + self.MODULE)
|
||||
a1, b1 = self.shas()
|
||||
with open(sib, "w", newline="\n") as f:
|
||||
f.write("def judge(x):\n return x > 2\n")
|
||||
catalog.forget(sib)
|
||||
a2, b2 = self.shas()
|
||||
self.assertNotEqual(a1, a2)
|
||||
self.assertNotEqual(b1, b2)
|
||||
# a module that is not imported changes nothing
|
||||
other = os.path.join(self.tmp, "other.py")
|
||||
with open(other, "w", newline="\n") as f:
|
||||
f.write("def o():\n return 1\n")
|
||||
self.assertEqual((a2, b2), self.shas())
|
||||
|
||||
def test_a_shared_judge_edit_moves_every_test(self):
|
||||
# runner.py, hw.py and baseline.py are in every test's path with no
|
||||
# import to follow (the Context, the baseline around each run), and
|
||||
# puls.py comes in by a package import the walk does not follow, so
|
||||
# their text joins every implementation hash.
|
||||
judge = os.path.join(self.tmp, "hwlike.py")
|
||||
|
||||
def write_judge(text):
|
||||
with open(judge, "w", newline="\n") as f:
|
||||
f.write(text)
|
||||
catalog.forget(judge)
|
||||
|
||||
def shas():
|
||||
return tuple(catalog.implementation_sha(self.path, t, judges=[judge]) for t in ("m.one", "m.two"))
|
||||
|
||||
write_judge("def sysfs_int(attr, default=None):\n return default\n")
|
||||
a1, b1 = shas()
|
||||
write_judge("def sysfs_int(attr, default=None):\n return 8\n")
|
||||
a2, b2 = shas()
|
||||
self.assertNotEqual(a1, a2)
|
||||
self.assertNotEqual(b1, b2)
|
||||
# a package file outside the list changes nothing
|
||||
other = os.path.join(self.tmp, "server.py")
|
||||
with open(other, "w", newline="\n") as f:
|
||||
f.write("def serve():\n return 1\n")
|
||||
self.assertEqual((a2, b2), shas())
|
||||
# Test.source_sha, which the fingerprint, the campaign and the gate
|
||||
# read, takes the shared judges itself: a suite test's hash is the
|
||||
# one with SHARED_JUDGES folded in, not the one without them.
|
||||
import inspect
|
||||
t = catalog.load_suite()["image.health"]
|
||||
src = inspect.getsourcefile(t.fn)
|
||||
self.assertEqual(t.source_sha, catalog.implementation_sha(src, t.id, judges=catalog.shared_judge_paths()))
|
||||
self.assertNotEqual(t.source_sha, catalog.implementation_sha(src, t.id, judges=[]))
|
||||
|
||||
def test_a_sibling_imported_inside_a_function_moves_the_tests_that_run_it(self):
|
||||
# A test that imports a judge inside its own function carries the
|
||||
# judge in its hash, and what the judge imports in turn, at any
|
||||
# depth, and no other test of its module does; a helper of the
|
||||
# module that imports one inside its body carries it into every
|
||||
# test of the module.
|
||||
def put(name, text):
|
||||
path = os.path.join(self.tmp, name)
|
||||
with open(path, "w", newline="\n") as f:
|
||||
f.write(text)
|
||||
catalog.forget(path)
|
||||
|
||||
def write_judge(text):
|
||||
put("judge.py", text)
|
||||
write_judge("def judge(x):\n return x > 1\n")
|
||||
self.write(self.MODULE.replace("def two(ctx):\n",
|
||||
"def two(ctx):\n from .judge import judge\n judge(2)\n"))
|
||||
a1, b1 = self.shas()
|
||||
write_judge("def judge(x):\n return x > 2\n")
|
||||
a2, b2 = self.shas()
|
||||
self.assertEqual(a1, a2)
|
||||
self.assertNotEqual(b1, b2)
|
||||
# judge.py imports limit.py, which imports base.py: an edit to
|
||||
# either moves m.two, and not m.one
|
||||
put("base.py", "BASE = 1\n")
|
||||
put("limit.py", "from .base import BASE\n\n\nLIMIT = BASE\n")
|
||||
write_judge("from .limit import LIMIT\n\n\ndef judge(x):\n return x > LIMIT\n")
|
||||
a5, b5 = self.shas()
|
||||
put("limit.py", "from .base import BASE\n\n\nLIMIT = BASE + 1\n")
|
||||
a6, b6 = self.shas()
|
||||
self.assertEqual(a5, a6)
|
||||
self.assertNotEqual(b5, b6)
|
||||
put("base.py", "BASE = 2\n")
|
||||
a7, b7 = self.shas()
|
||||
self.assertEqual(a6, a7)
|
||||
self.assertNotEqual(b6, b7)
|
||||
self.write(self.MODULE.replace(" return x + 1\n",
|
||||
" from .judge import judge\n return judge(x)\n"))
|
||||
a3, b3 = self.shas()
|
||||
write_judge("def judge(x):\n return x > 3\n")
|
||||
a4, b4 = self.shas()
|
||||
self.assertNotEqual(a3, a4)
|
||||
self.assertNotEqual(b3, b4)
|
||||
# the spelling with no module (`from . import x`, as homeoff.py imports cloud.py) is
|
||||
# walked the same
|
||||
self.write(self.MODULE.replace("def two(ctx):\n",
|
||||
"def two(ctx):\n from . import judge as j\n j.judge(2)\n"))
|
||||
a8, b8 = self.shas()
|
||||
write_judge("def judge(x):\n return x > 4\n")
|
||||
a9, b9 = self.shas()
|
||||
self.assertEqual(a8, a9)
|
||||
self.assertNotEqual(b8, b9)
|
||||
|
||||
def test_line_endings_do_not_count(self):
|
||||
a1, b1 = self.shas()
|
||||
catalog.forget(self.path)
|
||||
with open(self.path, "wb") as f:
|
||||
f.write(self.MODULE.replace("\n", "\r\n").encode())
|
||||
self.assertEqual((a1, b1), self.shas())
|
||||
|
||||
def test_an_unknown_id_hashes_the_whole_file(self):
|
||||
self.assertEqual(catalog.implementation_sha(self.path, "m.none"), catalog.source_file_sha(self.path))
|
||||
|
||||
def test_every_suite_test_is_spanned(self):
|
||||
import inspect
|
||||
reg = catalog.load_suite()
|
||||
for t in catalog.all_tests(reg):
|
||||
path = inspect.getsourcefile(t.fn)
|
||||
self.assertIn(t.id, catalog.module_parts(path)[1], t.id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,200 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The controller mode a test declares: the runner puts the machine there
|
||||
before the test and the baseline keeps it there afterward.
|
||||
|
||||
The cloud tests enter cloud mode and stay (the operator rule: no switch
|
||||
back after every test), so a queue that runs on past them reaches the
|
||||
motion tests with gfcloud as the controller and no grblHAL process to
|
||||
find. A test that names its mode gets the switch made for it, once, in the
|
||||
pre pass - after the leftovers are handled and before the preserved state
|
||||
is captured, so the post pass hands back the mode the test asked for
|
||||
rather than the one the run found.
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
|
||||
import helpers
|
||||
from forgetest import baseline
|
||||
from forgetest.log import Log
|
||||
from forgetest.runner import Runner
|
||||
|
||||
|
||||
class GrblPort:
|
||||
"""A listening socket standing in for the Grbl port of grblHAL."""
|
||||
|
||||
def __init__(self):
|
||||
self.sock = socket.socket()
|
||||
self.sock.bind(("127.0.0.1", 0))
|
||||
self.sock.listen(5)
|
||||
os.environ["GRBL_HOST"] = "127.0.0.1"
|
||||
os.environ["GRBL_PORT"] = str(self.sock.getsockname()[1])
|
||||
self._stop = False
|
||||
self._th = threading.Thread(target=self._accept, daemon=True)
|
||||
self._th.start()
|
||||
|
||||
def _accept(self):
|
||||
self.sock.settimeout(0.2)
|
||||
while not self._stop:
|
||||
try:
|
||||
c, _ = self.sock.accept()
|
||||
c.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
self._stop = True
|
||||
self.sock.close()
|
||||
for k in ("GRBL_HOST", "GRBL_PORT"):
|
||||
os.environ.pop(k, None)
|
||||
|
||||
|
||||
def cloud(fc):
|
||||
fc.state["mode"] = {"mode": "cloud", "controller": "running", "pid": 7, "motion": "verified"}
|
||||
fc.state["settings"]["controller_mode"] = "cloud"
|
||||
|
||||
|
||||
class SwitchModeTests(unittest.TestCase):
|
||||
"""Baseline.switch_mode against the fake forgectrl."""
|
||||
|
||||
def setUp(self):
|
||||
self.fc = helpers.FakeForgectrl().start()
|
||||
self.port = GrblPort()
|
||||
baseline.Baseline._unreachable_until = 0.0
|
||||
self.lines = []
|
||||
|
||||
def tearDown(self):
|
||||
self.port.close()
|
||||
self.fc.stop()
|
||||
|
||||
def bl(self):
|
||||
return baseline.Baseline(self.lines.append)
|
||||
|
||||
def test_already_in_the_mode_posts_nothing(self):
|
||||
ok, detail = self.bl().switch_mode("grbl")
|
||||
self.assertTrue(ok, detail)
|
||||
self.assertEqual(self.fc.posts, [])
|
||||
|
||||
def test_cloud_to_grbl_switches_and_waits_for_the_port(self):
|
||||
cloud(self.fc)
|
||||
b = self.bl()
|
||||
ok, detail = b.switch_mode("grbl")
|
||||
self.assertTrue(ok, detail)
|
||||
self.assertEqual(self.fc.posts, [("/mode", {"controller": "grbl"})])
|
||||
self.assertEqual(self.fc.state["mode"]["mode"], "grbl")
|
||||
self.assertEqual(b.mode, "grbl")
|
||||
|
||||
def test_a_controller_that_never_comes_up_is_reported(self):
|
||||
cloud(self.fc)
|
||||
self.fc.on_post = lambda path, form: (200, {"ok": True}) # accepted, nothing happens
|
||||
ok, detail = self.bl().switch_mode("grbl", timeout=2)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("did not come up", detail)
|
||||
|
||||
def test_a_refused_switch_is_reported(self):
|
||||
cloud(self.fc)
|
||||
self.fc.on_post = lambda path, form: (409, {"error": "busy"})
|
||||
ok, detail = self.bl().switch_mode("grbl")
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("409", detail)
|
||||
|
||||
def test_grbl_needs_the_port_open(self):
|
||||
cloud(self.fc)
|
||||
self.port.close() # grblHAL running, port never listening
|
||||
os.environ["GRBL_HOST"], os.environ["GRBL_PORT"] = "127.0.0.1", "1"
|
||||
old = baseline.GRBL_PORT_S
|
||||
baseline.GRBL_PORT_S = 2
|
||||
try:
|
||||
ok, detail = self.bl().switch_mode("grbl")
|
||||
finally:
|
||||
baseline.GRBL_PORT_S = old
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("Grbl port", detail)
|
||||
|
||||
|
||||
class RunnerModeTests(unittest.TestCase):
|
||||
"""The runner end to end: a declared mode is established before the
|
||||
test function runs and kept by the post pass."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.mkdtemp(prefix="forgetest-mode-")
|
||||
os.environ["FORGETEST_DATA"] = self.tmp
|
||||
os.environ["FORGETEST_MARKER"] = os.path.join(self.tmp, "marker")
|
||||
self.fc = helpers.FakeForgectrl().start()
|
||||
self.port = GrblPort()
|
||||
baseline.Baseline._unreachable_until = 0.0
|
||||
self.seen = {}
|
||||
fc, seen = self.fc, self.seen
|
||||
|
||||
def t_sees_mode(ctx):
|
||||
seen["mode"] = fc.state["mode"]["mode"]
|
||||
|
||||
reg = helpers.registry(
|
||||
helpers.make_test("m.grbl", [("forgectrl", "src/ui.c")], fn=t_sees_mode, mode="grbl"),
|
||||
helpers.make_test("m.any", [("forgectrl", "src/ui.c")], fn=t_sees_mode),
|
||||
)
|
||||
self.runner = Runner(Log(os.path.join(self.tmp, "results.jsonl")), helpers.make_manifest(), reg)
|
||||
|
||||
def tearDown(self):
|
||||
self.port.close()
|
||||
self.fc.stop()
|
||||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||
for k in ("FORGETEST_DATA", "FORGETEST_MARKER"):
|
||||
os.environ.pop(k, None)
|
||||
|
||||
def run_test(self, tid):
|
||||
ok, msg = self.runner.start_test(tid)
|
||||
self.assertTrue(ok, msg)
|
||||
run = self.runner.current
|
||||
deadline = time.time() + 30
|
||||
while not run.finished and time.time() < deadline:
|
||||
time.sleep(0.05)
|
||||
self.assertTrue(run.finished, "run did not finish")
|
||||
return run
|
||||
|
||||
def test_grbl_test_found_in_cloud_mode_gets_the_switch_and_keeps_it(self):
|
||||
cloud(self.fc)
|
||||
run = self.run_test("m.grbl")
|
||||
self.assertEqual(run.finished["result"], "PASS", run.finished)
|
||||
self.assertEqual(self.seen["mode"], "grbl")
|
||||
# one switch, made before the test; the post pass keeps grbl
|
||||
self.assertEqual(self.fc.posts, [("/mode", {"controller": "grbl"})])
|
||||
self.assertEqual(self.fc.state["mode"]["mode"], "grbl")
|
||||
self.assertEqual(run.baseline_captured["mode"], "grbl")
|
||||
self.assertEqual(run.evidence["mode"]["found"], "cloud")
|
||||
self.assertTrue(run.evidence["mode"]["switched"])
|
||||
self.assertTrue(any("mode: test needs grbl" in ln for ln in run.lines), run.lines)
|
||||
|
||||
def test_grbl_test_in_grbl_mode_switches_nothing(self):
|
||||
run = self.run_test("m.grbl")
|
||||
self.assertEqual(run.finished["result"], "PASS", run.finished)
|
||||
self.assertEqual(self.fc.posts, [])
|
||||
self.assertFalse(run.evidence["mode"]["switched"])
|
||||
|
||||
def test_undeclared_test_runs_in_the_mode_it_finds(self):
|
||||
cloud(self.fc)
|
||||
run = self.run_test("m.any")
|
||||
self.assertEqual(run.finished["result"], "PASS", run.finished)
|
||||
self.assertEqual(self.seen["mode"], "cloud")
|
||||
self.assertEqual(self.fc.posts, [])
|
||||
self.assertNotIn("mode", run.evidence)
|
||||
|
||||
def test_a_switch_that_fails_fails_the_test_before_it_runs(self):
|
||||
cloud(self.fc)
|
||||
self.fc.on_post = lambda path, form: (503, {"error": "supervisor busy"})
|
||||
run = self.run_test("m.grbl")
|
||||
self.assertEqual(run.finished["result"], "FAIL")
|
||||
self.assertIn("needs grbl mode", run.finished["message"])
|
||||
self.assertNotIn("mode", self.seen)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,269 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The operator channel: notices, `ready`, `act`, and the precheck.
|
||||
|
||||
A test asks for the operator's part in a way the page can show before
|
||||
it is needed and a bench actuator can later perform: `act` names a
|
||||
machine action and watches the machine for it (a standing notice for
|
||||
the human, no button to race), `ready` pre-announces a timed step, and
|
||||
a `precheck` refuses a start the machine cannot honor instead of
|
||||
recording a FAIL.
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
|
||||
import helpers
|
||||
from forgetest import catalog
|
||||
from forgetest.log import Log
|
||||
from forgetest.runner import Context, Failed, Run, Runner
|
||||
|
||||
|
||||
class Answerer:
|
||||
"""Answers every prompt with its first option and records it, and
|
||||
records every notice it sees."""
|
||||
|
||||
def __init__(self, run, on_prompt=None):
|
||||
self.run = run
|
||||
self.on_prompt = on_prompt
|
||||
self.asked = []
|
||||
self.notices = []
|
||||
self.stop = False
|
||||
threading.Thread(target=self._loop, daemon=True).start()
|
||||
|
||||
def _loop(self):
|
||||
seen = None
|
||||
seen_n = None
|
||||
while not self.stop:
|
||||
n = self.run.notice
|
||||
if n and n["id"] != seen_n:
|
||||
seen_n = n["id"]
|
||||
self.notices.append(n["text"])
|
||||
p = self.run.prompt
|
||||
if p and p["id"] != seen:
|
||||
seen = p["id"]
|
||||
self.asked.append((p["question"], list(p["options"])))
|
||||
if self.on_prompt:
|
||||
self.on_prompt(p)
|
||||
self.run.answer(p["id"], p["options"][0])
|
||||
time.sleep(0.01)
|
||||
|
||||
|
||||
class ActTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.fc = helpers.FakeForgectrl().start()
|
||||
self.run = Run("test", "t.x", "t.x")
|
||||
self.ctx = Context(self.run, None, helpers.make_test("t.x", [], kind="operator"))
|
||||
self.ans = Answerer(self.run)
|
||||
|
||||
def tearDown(self):
|
||||
self.ans.stop = True
|
||||
self.fc.stop()
|
||||
|
||||
def lid(self, closed):
|
||||
self.fc.state["status"]["switches"]["lid"] = bool(closed)
|
||||
|
||||
def interlock(self, ok):
|
||||
self.fc.state["status"]["switches"]["interlock_ok"] = bool(ok)
|
||||
|
||||
def test_lid_open_is_a_notice_until_the_switch_shows_it(self):
|
||||
threading.Timer(0.3, self.lid, args=(False,)).start()
|
||||
dt = self.ctx.act("lid", "open", timeout=5)
|
||||
self.assertGreater(dt, 0.2)
|
||||
self.assertEqual(self.ans.asked, []) # no button to click
|
||||
self.assertEqual(self.ans.notices, ["Open the lid."])
|
||||
self.assertIsNone(self.run.notice) # cleared when seen
|
||||
rec = self.run.evidence["actions"][0]
|
||||
self.assertEqual((rec["channel"], rec["state"], rec["by"]), ("lid", "open", "operator"))
|
||||
self.assertIsNotNone(rec["took_s"])
|
||||
self.assertTrue(any("ACT lid open: done" in ln for ln in self.run.lines))
|
||||
|
||||
def test_already_done_returns_at_once(self):
|
||||
self.lid(False)
|
||||
dt = self.ctx.act("lid", "open", timeout=2)
|
||||
self.assertLess(dt, 1.0)
|
||||
|
||||
def test_interlock_watches_interlock_ok(self):
|
||||
threading.Timer(0.2, self.interlock, args=(False,)).start()
|
||||
self.ctx.act("interlock", "open", timeout=5)
|
||||
threading.Timer(0.2, self.interlock, args=(True,)).start()
|
||||
self.ctx.act("interlock", "close", timeout=5)
|
||||
self.assertEqual([r["state"] for r in self.run.evidence["actions"]], ["open", "close"])
|
||||
|
||||
def test_a_timeout_fails_the_test_and_clears_the_notice(self):
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.ctx.act("lid", "open", timeout=0.6)
|
||||
self.assertIn("lid open was not seen", str(cm.exception))
|
||||
self.assertIsNone(self.run.notice)
|
||||
self.assertIsNone(self.run.evidence["actions"][0]["took_s"])
|
||||
|
||||
def test_a_timeout_can_be_handed_back_instead(self):
|
||||
self.assertIsNone(self.ctx.act("lid", "open", timeout=0.4, fail=False))
|
||||
|
||||
def test_a_button_press_needs_what_it_is_expected_to_do(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self.ctx.act("button", "press")
|
||||
held = []
|
||||
threading.Timer(0.2, held.append, args=(1,)).start()
|
||||
self.ctx.act("button", "press", until=lambda: bool(held), timeout=5)
|
||||
self.assertEqual(self.ans.notices, ["Press the button once."])
|
||||
|
||||
def test_unknown_actions_are_refused(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self.ctx.act("lid", "press")
|
||||
|
||||
def test_ready_is_a_single_button_prompt(self):
|
||||
self.ctx.ready("On Ready the head moves.")
|
||||
self.assertEqual(self.ans.asked, [("On Ready the head moves.", ["Ready", "Cannot"])])
|
||||
|
||||
def test_cannot_on_ready_fails(self):
|
||||
self.ans.stop = True
|
||||
ans = Answerer(self.run, on_prompt=lambda p: None)
|
||||
ans.stop = True
|
||||
time.sleep(0.05)
|
||||
|
||||
def say_cannot():
|
||||
while self.run.prompt is None:
|
||||
time.sleep(0.01)
|
||||
self.run.answer(self.run.prompt["id"], "Cannot")
|
||||
threading.Thread(target=say_cannot, daemon=True).start()
|
||||
with self.assertRaises(Failed):
|
||||
self.ctx.ready("x")
|
||||
|
||||
|
||||
class PrecheckTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.mkdtemp(prefix="forgetest-pre-")
|
||||
os.environ["FORGETEST_DATA"] = self.tmp
|
||||
os.environ["FORGETEST_MARKER"] = os.path.join(self.tmp, "marker")
|
||||
self.man = helpers.make_manifest()
|
||||
self.blocked = {"why": "HV reports good: open the lid"}
|
||||
self.reg = helpers.registry(
|
||||
helpers.make_test("p.gated", [("forgectrl", "src/main.c")],
|
||||
precheck=lambda: self.blocked["why"]),
|
||||
helpers.make_test("p.free", [("forgectrl", "src/ui.c")]),
|
||||
)
|
||||
self.log = Log(os.path.join(self.tmp, "r.jsonl"))
|
||||
self.runner = Runner(self.log, self.man, self.reg)
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||
|
||||
def test_a_precheck_reason_refuses_the_start_without_a_result(self):
|
||||
ok, msg = self.runner.start_test("p.gated")
|
||||
self.assertFalse(ok)
|
||||
self.assertEqual(msg, "cannot start: HV reports good: open the lid")
|
||||
self.assertEqual([r for r in self.log.read() if r["t"] == "result"], [])
|
||||
# the campaign is not even opened by a refused start
|
||||
self.assertEqual([r for r in self.log.read() if r["t"] == "campaign"], [])
|
||||
|
||||
def test_a_clear_precheck_starts(self):
|
||||
self.blocked["why"] = None
|
||||
ok, msg = self.runner.start_test("p.gated")
|
||||
self.assertTrue(ok, msg)
|
||||
while self.runner.busy():
|
||||
time.sleep(0.02)
|
||||
self.assertEqual(self.runner.state()[0]["tests"]["p.gated"]["status"], "pass")
|
||||
|
||||
def test_the_queue_skips_it_with_the_reason_and_carries_on(self):
|
||||
ok, msg, order = self.runner.start_batch("unattended")
|
||||
self.assertTrue(ok, msg)
|
||||
deadline = time.time() + 20
|
||||
while not self.runner.batch["finished"] and time.time() < deadline:
|
||||
time.sleep(0.02)
|
||||
b = self.runner.batch_snapshot()
|
||||
self.assertEqual([x["test"] for x in b["skipped"]], ["p.gated"])
|
||||
self.assertIn("cannot start: HV reports good", b["skipped"][0]["reason"])
|
||||
self.assertEqual([x["test"] for x in b["done"]], ["p.free"])
|
||||
|
||||
def test_an_erroring_precheck_refuses_rather_than_crashes(self):
|
||||
def boom():
|
||||
raise RuntimeError("sysfs gone")
|
||||
t = helpers.make_test("p.boom", [], precheck=boom)
|
||||
self.assertIn("precheck errored: RuntimeError: sysfs gone", t.cannot_start())
|
||||
|
||||
def test_the_catalog_describes_actions_and_the_precheck(self):
|
||||
t = helpers.make_test("p.x", [], kind="operator", actions=("lid", "button"),
|
||||
precheck=lambda: None)
|
||||
d = t.describe()
|
||||
self.assertEqual(d["actions"], ["lid", "button"])
|
||||
self.assertTrue(d["precheck"])
|
||||
# neither is part of the gate-visible definition
|
||||
self.assertNotIn("actions", t.definition())
|
||||
|
||||
def test_the_decorator_checks_actions(self):
|
||||
saved = dict(catalog.REGISTRY)
|
||||
try:
|
||||
with self.assertRaises(ValueError):
|
||||
catalog.test("x.a", title="t", subsystem="x", kind="operator", actions=("foot",))(lambda c: None)
|
||||
with self.assertRaises(ValueError):
|
||||
catalog.test("x.b", title="t", subsystem="x", kind="auto", actions=("lid",))(lambda c: None)
|
||||
with self.assertRaises(ValueError):
|
||||
catalog.test("x.c", title="t", subsystem="x", precheck="no")(lambda c: None)
|
||||
catalog.test("x.d", title="t", subsystem="x", kind="operator", actions=("lid",),
|
||||
precheck=lambda: None)(lambda c: None)
|
||||
self.assertIn("x.d", catalog.REGISTRY)
|
||||
finally:
|
||||
catalog.REGISTRY.clear()
|
||||
catalog.REGISTRY.update(saved)
|
||||
|
||||
|
||||
class AccelSamplerTests(unittest.TestCase):
|
||||
"""The head accelerometer sampler over a fake iio tree."""
|
||||
|
||||
def setUp(self):
|
||||
from forgetest import hw
|
||||
self.hw = hw
|
||||
self.tmp = tempfile.mkdtemp(prefix="forgetest-iio-")
|
||||
d = os.path.join(self.tmp, "iio_device1") # no colon: the host may be Windows
|
||||
os.makedirs(d)
|
||||
with open(os.path.join(d, "name"), "w") as f:
|
||||
f.write("lis2hh12 3-001e\n")
|
||||
self.dir = d
|
||||
self.write(100, 200)
|
||||
os.environ["GF_IIO_ROOT"] = self.tmp
|
||||
|
||||
def tearDown(self):
|
||||
os.environ.pop("GF_IIO_ROOT", None)
|
||||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||
|
||||
def write(self, x, y):
|
||||
for axis, v in (("x", x), ("y", y)):
|
||||
with open(os.path.join(self.dir, "in_accel_%s_raw" % axis), "w") as f:
|
||||
f.write("%d\n" % v)
|
||||
|
||||
def test_found_by_bus_address_and_sampled(self):
|
||||
self.assertEqual(self.hw.head_accel_dir(), self.dir)
|
||||
s = self.hw.AccelSampler(period=0.02)
|
||||
self.assertTrue(s.available)
|
||||
with s:
|
||||
t0 = time.time()
|
||||
time.sleep(0.1)
|
||||
self.write(1100, 200)
|
||||
time.sleep(0.1)
|
||||
self.write(100, 200)
|
||||
time.sleep(0.1)
|
||||
t1 = time.time()
|
||||
p2px, p2py, n = s.p2p(t0, t1)
|
||||
self.assertGreaterEqual(n, 5)
|
||||
self.assertEqual((p2px, p2py), (1000, 0))
|
||||
# a window with no samples reads as nothing, not as an error
|
||||
self.assertEqual(s.p2p(t1 + 10, t1 + 20), (0, 0, 0))
|
||||
|
||||
def test_absent_device_is_unavailable(self):
|
||||
os.environ["GF_IIO_ROOT"] = os.path.join(self.tmp, "nowhere")
|
||||
s = self.hw.AccelSampler()
|
||||
self.assertFalse(s.available)
|
||||
with s:
|
||||
pass
|
||||
self.assertEqual(s.p2p(0), (0, 0, 0))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user